Files
nj/crates/erp-diary/src/service/class_service.rs
iven 1766cefde9
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
refactor(diary): Service 层改用 DiaryEvent 枚举替代字符串事件
- journal_service: 3 处 (JournalCreated/Updated/Deleted)
- class_service: 2 处 (ClassCreated/StudentJoinedClass)
- comment_service: 1 处 (CommentCreated)
- topic_service: 1 处 (TopicAssigned)
- parent_service: 1 处 confirm_binding → ParentBound

保留 DomainEvent::new 的场景:
- class_service deactivate_class (diary.class.deactivated)
- parent_service bind_child (diary.parent.binding_requested)
- parent_service delete_child_data (diary.parent.data_deleted)
以上事件不在 DiaryEvent 枚举中(非核心创建事件)

测试: 509/509 全部通过
2026-06-03 17:15:00 +08:00

611 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 班级服务 — 创建班级、加入班级、班级查询
use chrono::{Months, Utc};
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter,
Set, TransactionTrait,
};
use uuid::Uuid;
use crate::dto::{ClassMemberResp, ClassResp, ResetClassCodeResp};
use crate::entity::{class_member, school_class};
use crate::error::{DiaryError, DiaryResult};
use erp_core::events::{DomainEvent, EventBus};
use crate::event::DiaryEvent;
/// 班级服务 — 6 位码生成、过期控制、成员管理
pub struct ClassService;
impl ClassService {
/// 创建班级(老师)
///
/// 生成 6 位随机班级码设置过期时间6 个月后),
/// 自动将老师加入 class_members。
pub async fn create_class(
tenant_id: Uuid,
teacher_id: Uuid,
name: String,
school_name: Option<String>,
db: &DatabaseConnection,
event_bus: &EventBus,
) -> DiaryResult<ClassResp> {
let now = Utc::now();
let id = Uuid::now_v7();
// 生成唯一班级码(最多重试 10 次)
let class_code = Self::generate_unique_code(db).await?;
// 过期时间6 个月后
let expires_at = now.checked_add_months(Months::new(6));
// 事务:创建班级 + 老师自动加入(原子操作,保证一致性)
let inserted_class = db
.transaction::<_, school_class::Model, DiaryError>(|txn| {
Box::pin(async move {
let class_model = school_class::ActiveModel {
id: Set(id),
tenant_id: Set(tenant_id),
name: Set(name),
school_name: Set(school_name),
teacher_id: Set(teacher_id),
class_code: Set(class_code),
member_count: Set(1), // 老师自动计入
is_active: Set(true),
expires_at: Set(expires_at),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(teacher_id),
updated_by: Set(teacher_id),
deleted_at: Set(None),
version: Set(1),
};
let inserted = class_model.insert(txn).await?;
let member_model = class_member::ActiveModel {
class_id: Set(id),
user_id: Set(teacher_id),
tenant_id: Set(tenant_id),
role: Set("teacher".to_string()),
nickname: Set(None),
joined_at: Set(now),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(teacher_id),
updated_by: Set(teacher_id),
deleted_at: Set(None),
version: Set(1),
};
member_model.insert(txn).await?;
Ok(inserted)
})
})
.await?;
// 发布 ClassCreated 事件
event_bus
.publish(
DiaryEvent::ClassCreated {
class_id: id,
teacher_id,
}
.to_domain_event(tenant_id),
db,
)
.await;
Ok(class_model_to_resp(inserted_class))
}
/// 加入班级(学生通过班级码)
///
/// 验证班级码有效性和过期状态,检查是否已是成员,
/// 创建 class_member 记录并更新 member_count。
/// 包含 Redis 错误锁定:连续 5 次错误后锁定 30 分钟。
pub async fn join_class(
tenant_id: Uuid,
user_id: Uuid,
class_code: String,
nickname: Option<String>,
db: &DatabaseConnection,
redis: Option<&redis::Client>,
event_bus: &EventBus,
) -> DiaryResult<ClassResp> {
let now = Utc::now();
let lock_key = format!("class_code_attempts:{user_id}");
// 0. 检查错误锁定
if let Some(redis_client) = redis {
if let Ok(mut conn) = redis_client.get_multiplexed_async_connection().await {
let attempts: i64 = redis::cmd("GET")
.arg(&lock_key)
.query_async(&mut conn)
.await
.unwrap_or(0);
if attempts >= 5 {
let ttl: i64 = redis::cmd("TTL")
.arg(&lock_key)
.query_async(&mut conn)
.await
.unwrap_or(1800);
let lockout_minutes = (ttl.max(0) as u32 / 60).max(1);
return Err(DiaryError::ClassCodeLocked { lockout_minutes });
}
}
}
// 1. 查找班级码对应的班级
let class_model = school_class::Entity::find()
.filter(school_class::Column::ClassCode.eq(&class_code))
.filter(school_class::Column::TenantId.eq(tenant_id))
.filter(school_class::Column::DeletedAt.is_null())
.one(db)
.await?;
// 班级码无效 → 增加错误计数
let class_model = match class_model {
Some(m) => m,
None => {
Self::increment_fail_count(redis, &lock_key).await;
return Err(DiaryError::InvalidClassCode);
}
};
// 2. 检查班级是否激活
if !class_model.is_active {
Self::increment_fail_count(redis, &lock_key).await;
return Err(DiaryError::BadRequest("班级已停用".to_string()));
}
// 3. 检查是否过期
if let Some(expires) = class_model.expires_at {
if now > expires {
Self::increment_fail_count(redis, &lock_key).await;
return Err(DiaryError::ClassCodeExpired);
}
}
let class_id = class_model.id;
// 4. 检查是否已是成员
let existing = class_member::Entity::find()
.filter(class_member::Column::ClassId.eq(class_id))
.filter(class_member::Column::UserId.eq(user_id))
.filter(class_member::Column::DeletedAt.is_null())
.one(db)
.await?;
if existing.is_some() {
return Err(DiaryError::BadRequest("已是班级成员".to_string()));
}
// 5. 事务:创建成员记录 + 更新 member_count原子操作
let current_count = class_model.member_count;
let current_version = class_model.version;
let updated_class = db
.transaction::<_, school_class::Model, DiaryError>(|txn| {
Box::pin(async move {
let member_model = class_member::ActiveModel {
class_id: Set(class_id),
user_id: Set(user_id),
tenant_id: Set(tenant_id),
role: Set("student".to_string()),
nickname: Set(nickname),
joined_at: Set(now),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(user_id),
updated_by: Set(user_id),
deleted_at: Set(None),
version: Set(1),
};
member_model.insert(txn).await?;
let mut active_class: school_class::ActiveModel = class_model.into();
active_class.member_count = Set(current_count + 1);
active_class.updated_at = Set(now);
active_class.version = Set(current_version + 1);
let updated = active_class.update(txn).await?;
Ok(updated)
})
})
.await?;
// 7. 成功加入 → 清除错误计数
if let Some(redis_client) = redis {
if let Ok(mut conn) = redis_client.get_multiplexed_async_connection().await {
let _: () = redis::cmd("DEL")
.arg(&lock_key)
.query_async(&mut conn)
.await
.unwrap_or(());
}
}
// 8. 发布 StudentJoinedClass 事件
event_bus
.publish(
DiaryEvent::StudentJoinedClass {
class_id,
student_id: user_id,
}
.to_domain_event(tenant_id),
db,
)
.await;
Ok(class_model_to_resp(updated_class))
}
/// 增加班级码验证失败计数Redis INCR + 30分钟过期
async fn increment_fail_count(redis: Option<&redis::Client>, lock_key: &str) {
if let Some(redis_client) = redis {
if let Ok(mut conn) = redis_client.get_multiplexed_async_connection().await {
let _: i64 = redis::cmd("INCR")
.arg(lock_key)
.query_async(&mut conn)
.await
.unwrap_or(0);
// 设置 30 分钟过期(仅在第一次失败时设置,后续 INCR 不重置 TTL
let ttl: i64 = redis::cmd("TTL")
.arg(lock_key)
.query_async(&mut conn)
.await
.unwrap_or(-1);
if ttl == -1 {
// key 存在但没有过期时间 → 设置 30 分钟
let _: () = redis::cmd("EXPIRE")
.arg(lock_key)
.arg(1800i64)
.query_async(&mut conn)
.await
.unwrap_or(());
}
}
}
}
/// 获取班级详情
pub async fn get_class(
tenant_id: Uuid,
class_id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<ClassResp> {
let model = school_class::Entity::find()
.filter(school_class::Column::Id.eq(class_id))
.filter(school_class::Column::TenantId.eq(tenant_id))
.filter(school_class::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| DiaryError::NotFound(format!("班级 {} 不存在", class_id)))?;
Ok(class_model_to_resp(model))
}
/// 获取班级成员列表
pub async fn list_members(
tenant_id: Uuid,
class_id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<Vec<ClassMemberResp>> {
let members = class_member::Entity::find()
.filter(class_member::Column::ClassId.eq(class_id))
.filter(class_member::Column::TenantId.eq(tenant_id))
.filter(class_member::Column::DeletedAt.is_null())
.all(db)
.await?;
Ok(members.into_iter().map(member_model_to_resp).collect())
}
/// 获取我加入的班级列表
pub async fn my_classes(
tenant_id: Uuid,
user_id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<Vec<ClassResp>> {
// 先查用户所在的班级 ID
let memberships = class_member::Entity::find()
.filter(class_member::Column::UserId.eq(user_id))
.filter(class_member::Column::TenantId.eq(tenant_id))
.filter(class_member::Column::DeletedAt.is_null())
.all(db)
.await?;
let class_ids: Vec<Uuid> = memberships.iter().map(|m| m.class_id).collect();
if class_ids.is_empty() {
return Ok(vec![]);
}
let classes = school_class::Entity::find()
.filter(school_class::Column::Id.is_in(class_ids))
.filter(school_class::Column::TenantId.eq(tenant_id))
.filter(school_class::Column::DeletedAt.is_null())
.filter(school_class::Column::IsActive.eq(true))
.all(db)
.await?;
Ok(classes.into_iter().map(class_model_to_resp).collect())
}
/// 获取租户下所有班级(管理端用)
///
/// 仅限管理员/老师角色调用,返回租户内所有未删除的班级。
pub async fn list_all_classes(
tenant_id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<Vec<ClassResp>> {
let classes = school_class::Entity::find()
.filter(school_class::Column::TenantId.eq(tenant_id))
.filter(school_class::Column::DeletedAt.is_null())
.all(db)
.await?;
Ok(classes.into_iter().map(class_model_to_resp).collect())
}
/// 更新班级信息(老师)
///
/// 仅班级创建者teacher_id可修改班级名称和学校名称。
/// 使用乐观锁防止并发冲突。
pub async fn update_class(
tenant_id: Uuid,
user_id: Uuid,
class_id: Uuid,
name: Option<String>,
school_name: Option<String>,
version: i32,
db: &DatabaseConnection,
) -> DiaryResult<ClassResp> {
let model = school_class::Entity::find()
.filter(school_class::Column::Id.eq(class_id))
.filter(school_class::Column::TenantId.eq(tenant_id))
.filter(school_class::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| DiaryError::NotFound(format!("班级 {} 不存在", class_id)))?;
// 仅班级创建者可编辑
if model.teacher_id != user_id {
return Err(DiaryError::Forbidden);
}
// 乐观锁校验
if model.version != version {
return Err(DiaryError::VersionConflict {
local: version,
server: model.version,
});
}
let now = Utc::now();
let mut active: school_class::ActiveModel = model.into();
if let Some(n) = name {
if n.trim().is_empty() {
return Err(DiaryError::Validation("班级名称不能为空".to_string()));
}
active.name = Set(n);
}
if let Some(s) = school_name {
active.school_name = Set(Some(s));
}
active.updated_at = Set(now);
active.updated_by = Set(user_id);
active.version = Set(version + 1);
let updated = active.update(db).await?;
Ok(class_model_to_resp(updated))
}
/// 停用班级(老师)
///
/// 将班级设为停用状态,学生将无法通过班级码加入。
/// 已在班内的学生仍可查看班级内容。
pub async fn deactivate_class(
tenant_id: Uuid,
user_id: Uuid,
class_id: Uuid,
db: &DatabaseConnection,
event_bus: &EventBus,
) -> DiaryResult<ClassResp> {
let model = school_class::Entity::find()
.filter(school_class::Column::Id.eq(class_id))
.filter(school_class::Column::TenantId.eq(tenant_id))
.filter(school_class::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| DiaryError::NotFound(format!("班级 {} 不存在", class_id)))?;
// 仅班级创建者可停用
if model.teacher_id != user_id {
return Err(DiaryError::Forbidden);
}
if !model.is_active {
return Err(DiaryError::BadRequest("班级已处于停用状态".to_string()));
}
let now = Utc::now();
let current_version = model.version;
let mut active: school_class::ActiveModel = model.into();
active.is_active = Set(false);
active.updated_at = Set(now);
active.updated_by = Set(user_id);
active.version = Set(current_version + 1);
let updated = active.update(db).await?;
// 发布 ClassDeactivated 事件
event_bus
.publish(
DomainEvent::new(
"diary.class.deactivated",
tenant_id,
serde_json::json!({
"class_id": class_id,
"teacher_id": user_id,
}),
),
db,
)
.await;
Ok(class_model_to_resp(updated))
}
/// 重置班级码(老师)
///
/// 生成新的 6 位班级码,旧码立即失效。
/// CLAUDE.md 要求:"老师可随时重置"。
pub async fn reset_class_code(
tenant_id: Uuid,
user_id: Uuid,
class_id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<ResetClassCodeResp> {
let model = school_class::Entity::find()
.filter(school_class::Column::Id.eq(class_id))
.filter(school_class::Column::TenantId.eq(tenant_id))
.filter(school_class::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| DiaryError::NotFound(format!("班级 {} 不存在", class_id)))?;
// 仅班级创建者可重置班级码
if model.teacher_id != user_id {
return Err(DiaryError::Forbidden);
}
let new_code = Self::generate_unique_code(db).await?;
let now = Utc::now();
let current_version = model.version;
let mut active: school_class::ActiveModel = model.into();
active.class_code = Set(new_code.clone());
// 重置过期时间为 6 个月
active.expires_at = Set(now.checked_add_months(Months::new(6)));
active.updated_at = Set(now);
active.updated_by = Set(user_id);
active.version = Set(current_version + 1);
active.update(db).await?;
Ok(ResetClassCodeResp {
class_id,
new_class_code: new_code,
})
}
/// 生成唯一班级码(重试最多 10 次)
async fn generate_unique_code(db: &DatabaseConnection) -> DiaryResult<String> {
for _ in 0..10 {
let code = generate_class_code();
let exists = school_class::Entity::find()
.filter(school_class::Column::ClassCode.eq(&code))
.one(db)
.await?
.is_some();
if !exists {
return Ok(code);
}
}
Err(DiaryError::Internal("无法生成唯一班级码".to_string()))
}
}
/// 生成 6 位字母数字混合班级码62^6 ≈ 568 亿种组合)
///
/// CLAUDE.md 要求"6 位字母数字混合62^6 ≈ 568 亿种组合)"。
/// 使用 UUID v7 后 8 字节(随机部分)作为熵源,映射到 [0-9A-Za-z] 字符集。
/// 前 8 字节含时间戳,同毫秒内重复概率高,因此只用后 8 字节。
fn generate_class_code() -> String {
const CHARSET: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
let bytes = *Uuid::now_v7().as_bytes();
let mut code = String::with_capacity(6);
// UUID v7: bytes[0..8] = 时间戳, bytes[8..16] = 随机部分
// 从随机部分取 6 个字节,避免同毫秒碰撞
for i in 0..6 {
code.push(CHARSET[(bytes[8 + i] as usize) % CHARSET.len()] as char);
}
code
}
/// school_class::Model -> ClassResp
fn class_model_to_resp(model: school_class::Model) -> ClassResp {
ClassResp {
id: model.id,
name: model.name,
school_name: model.school_name,
teacher_id: model.teacher_id,
class_code: model.class_code,
member_count: model.member_count,
is_active: model.is_active,
}
}
/// class_member::Model -> ClassMemberResp
fn member_model_to_resp(model: class_member::Model) -> ClassMemberResp {
ClassMemberResp {
user_id: model.user_id,
role: model.role,
nickname: model.nickname,
joined_at: model.joined_at,
}
}
#[cfg(test)]
mod tests {
use super::*;
// ===== 班级码生成测试 =====
#[test]
fn generate_class_code_is_6_chars() {
let code = generate_class_code();
assert_eq!(code.len(), 6, "班级码必须是 6 位");
}
#[test]
fn generate_class_code_is_alphanumeric() {
let code = generate_class_code();
assert!(
code.chars().all(|c| c.is_ascii_alphanumeric()),
"班级码必须全部是字母数字"
);
}
#[test]
fn generate_class_code_is_unique() {
let codes: std::collections::HashSet<String> = (0..100)
.map(|_| generate_class_code())
.collect();
// 100 个码应该全部不同(概率上几乎确定)
assert!(codes.len() > 90, "生成的班级码应该高度唯一");
}
// ===== 错误映射测试 =====
#[test]
fn invalid_class_code_error() {
let err = DiaryError::InvalidClassCode;
assert!(err.to_string().contains("无效"));
}
#[test]
fn class_code_expired_error() {
let err = DiaryError::ClassCodeExpired;
assert!(err.to_string().contains("过期"));
}
#[test]
fn class_code_locked_error() {
let err = DiaryError::ClassCodeLocked {
lockout_minutes: 30,
};
assert!(err.to_string().contains("30"));
}
}