// 班级服务 — 创建班级、加入班级、班级查询 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, db: &DatabaseConnection, event_bus: &EventBus, ) -> DiaryResult { 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, db: &DatabaseConnection, redis: Option<&redis::Client>, event_bus: &EventBus, ) -> DiaryResult { 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 { 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> { 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> { // 先查用户所在的班级 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 = 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> { 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, school_name: Option, version: i32, db: &DatabaseConnection, ) -> DiaryResult { 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 { 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 { 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 { 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 = (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")); } }