- 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 全部通过
611 lines
21 KiB
Rust
611 lines
21 KiB
Rust
// 班级服务 — 创建班级、加入班级、班级查询
|
||
|
||
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"));
|
||
}
|
||
}
|