diff --git a/crates/erp-diary/src/dto.rs b/crates/erp-diary/src/dto.rs index 0066b48..e0fb45d 100644 --- a/crates/erp-diary/src/dto.rs +++ b/crates/erp-diary/src/dto.rs @@ -81,6 +81,26 @@ pub struct JoinClassReq { pub class_code: String, } +/// 更新班级请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateClassReq { + /// 班级名称 + pub name: Option, + /// 学校名称 + pub school_name: Option, + /// 乐观锁版本号 + pub version: i32, +} + +/// 重置班级码响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct ResetClassCodeResp { + /// 班级 ID + pub class_id: uuid::Uuid, + /// 新的 6 位班级码 + pub new_class_code: String, +} + /// 班级响应 #[derive(Debug, Serialize, ToSchema)] pub struct ClassResp { diff --git a/crates/erp-diary/src/handler/class_handler.rs b/crates/erp-diary/src/handler/class_handler.rs index f4f66d8..17c5b50 100644 --- a/crates/erp-diary/src/handler/class_handler.rs +++ b/crates/erp-diary/src/handler/class_handler.rs @@ -8,7 +8,7 @@ use erp_core::error::AppError; use erp_core::rbac::require_permission; use erp_core::types::{ApiResponse, TenantContext}; -use crate::dto::{ClassMemberResp, ClassResp, CreateClassReq, JoinClassReq}; +use crate::dto::{ClassMemberResp, ClassResp, CreateClassReq, JoinClassReq, ResetClassCodeResp, UpdateClassReq}; use crate::service::class_service::ClassService; use crate::state::DiaryState; @@ -190,3 +190,152 @@ where let resp = ClassService::my_classes(ctx.tenant_id, ctx.user_id, &state.db).await?; Ok(Json(ApiResponse::ok(resp))) } + +#[utoipa::path( + get, + path = "/api/v1/diary/classes/all", + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "班级管理" +)] +/// GET /api/v1/diary/classes/all +/// +/// 获取租户下所有班级(管理端用)。需要 `diary.class.manage` 权限。 +pub async fn list_all_classes( + State(state): State, + Extension(ctx): Extension, +) -> Result>>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.class.manage")?; + + let resp = ClassService::list_all_classes(ctx.tenant_id, &state.db).await?; + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + put, + path = "/api/v1/diary/classes/{id}", + params(("id" = Uuid, Path, description = "班级ID")), + request_body = UpdateClassReq, + responses( + (status = 200, description = "更新成功", body = ApiResponse), + (status = 400, description = "验证失败"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "班级不存在"), + (status = 409, description = "版本冲突"), + ), + security(("bearer_auth" = [])), + tag = "班级管理" +)] +/// PUT /api/v1/diary/classes/:id +/// +/// 更新班级信息。需要 `diary.class.manage` 权限(仅班级创建者可编辑)。 +pub async fn update_class( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.class.manage")?; + + if let Some(ref name) = req.name { + if name.trim().is_empty() { + return Err(AppError::Validation("班级名称不能为空".to_string())); + } + } + + let resp = ClassService::update_class( + ctx.tenant_id, + ctx.user_id, + id, + req.name, + req.school_name, + req.version, + &state.db, + ) + .await?; + + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + patch, + path = "/api/v1/diary/classes/{id}/deactivate", + params(("id" = Uuid, Path, description = "班级ID")), + responses( + (status = 200, description = "停用成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "班级不存在"), + ), + security(("bearer_auth" = [])), + tag = "班级管理" +)] +/// PATCH /api/v1/diary/classes/:id/deactivate +/// +/// 停用班级。需要 `diary.class.manage` 权限(仅班级创建者可停用)。 +pub async fn deactivate_class( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.class.manage")?; + + let resp = ClassService::deactivate_class( + ctx.tenant_id, + ctx.user_id, + id, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + post, + path = "/api/v1/diary/classes/{id}/reset-code", + params(("id" = Uuid, Path, description = "班级ID")), + responses( + (status = 200, description = "重置成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "班级不存在"), + ), + security(("bearer_auth" = [])), + tag = "班级管理" +)] +/// POST /api/v1/diary/classes/:id/reset-code +/// +/// 重置班级码。需要 `diary.class.manage` 权限(仅班级创建者可重置)。 +pub async fn reset_class_code( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "diary.class.manage")?; + + let resp = ClassService::reset_class_code(ctx.tenant_id, ctx.user_id, id, &state.db).await?; + Ok(Json(ApiResponse::ok(resp))) +} diff --git a/crates/erp-diary/src/lib.rs b/crates/erp-diary/src/lib.rs index 22cec3d..b6b0f0c 100644 --- a/crates/erp-diary/src/lib.rs +++ b/crates/erp-diary/src/lib.rs @@ -142,18 +142,31 @@ impl DiaryModule { axum::routing::post(class_handler::create_class) .get(class_handler::my_classes), ) + .route( + "/diary/classes/all", + axum::routing::get(class_handler::list_all_classes), + ) .route( "/diary/classes/join", axum::routing::post(class_handler::join_class), ) .route( "/diary/classes/{id}", - axum::routing::get(class_handler::get_class), + axum::routing::get(class_handler::get_class) + .put(class_handler::update_class), ) .route( "/diary/classes/{id}/members", axum::routing::get(class_handler::list_members), ) + .route( + "/diary/classes/{id}/deactivate", + axum::routing::patch(class_handler::deactivate_class), + ) + .route( + "/diary/classes/{id}/reset-code", + axum::routing::post(class_handler::reset_class_code), + ) // 主题布置 .route( "/diary/classes/{class_id}/topics", diff --git a/crates/erp-diary/src/service/class_service.rs b/crates/erp-diary/src/service/class_service.rs index 05600e0..aedd09f 100644 --- a/crates/erp-diary/src/service/class_service.rs +++ b/crates/erp-diary/src/service/class_service.rs @@ -7,7 +7,7 @@ use sea_orm::{ }; use uuid::Uuid; -use crate::dto::{ClassMemberResp, ClassResp}; +use crate::dto::{ClassMemberResp, ClassResp, ResetClassCodeResp}; use crate::entity::{class_member, school_class}; use crate::error::{DiaryError, DiaryResult}; use erp_core::events::{DomainEvent, EventBus}; @@ -321,6 +321,172 @@ impl ClassService { 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 { diff --git a/crates/erp-diary/src/service/comment_service.rs b/crates/erp-diary/src/service/comment_service.rs index dd655d1..f5e8a55 100644 --- a/crates/erp-diary/src/service/comment_service.rs +++ b/crates/erp-diary/src/service/comment_service.rs @@ -200,41 +200,20 @@ fn comment_model_to_resp(model: comment::Model) -> CommentResp { } } -/// 基础敏感词检查 -/// -/// Phase 1 使用简单字符串匹配,B6 阶段替换为 ContentSafetyService。 -fn contains_sensitive_words(content: &str) -> bool { - const SENSITIVE_WORDS: &[&str] = &[ - // 占位 — Phase 1 仅检查是否为空或过短 - // 完整词库将在 B6 ContentSafetyService 中添加 - ]; - - if content.trim().is_empty() { - return true; - } - - for word in SENSITIVE_WORDS { - if content.contains(word) { - return true; - } - } - - false -} - #[cfg(test)] mod tests { use super::*; #[test] - fn empty_content_is_sensitive() { - assert!(contains_sensitive_words("")); - assert!(contains_sensitive_words(" ")); + fn content_safety_phase1_empty_is_safe() { + // Phase 1 词库为空,所有内容(包括空串)返回 Safe + // 空内容检查由 handler 层的 Validation 守卫处理 + assert!(ContentSafetyService::is_safe("")); } #[test] - fn normal_content_is_not_sensitive() { - assert!(!contains_sensitive_words("今天天气真好!")); - assert!(!contains_sensitive_words("老师点评:写得不错")); + fn normal_content_is_safe() { + assert!(ContentSafetyService::is_safe("今天天气真好!")); + assert!(ContentSafetyService::is_safe("老师点评:写得不错")); } } diff --git a/crates/erp-server/migration/src/m20260601_000300_diary_role_seed.rs b/crates/erp-server/migration/src/m20260601_000300_diary_role_seed.rs index 2e764d3..36bb3b3 100644 --- a/crates/erp-server/migration/src/m20260601_000300_diary_role_seed.rs +++ b/crates/erp-server/migration/src/m20260601_000300_diary_role_seed.rs @@ -33,17 +33,23 @@ impl MigrationTrait for Migration { .map_err(|e| DbErr::Custom(e.to_string()))?; } - // student 权限: diary.journal.create, diary.journal.read - // teacher 权限: diary.journal.create, diary.journal.read, diary.class.manage, diary.topic.assign, diary.comment.write + // student 权限: diary.journal.create, diary.journal.read, diary.journal.update, diary.journal.delete + // teacher 权限: diary.journal.create, diary.journal.read, diary.journal.update, diary.journal.delete, + // diary.class.manage, diary.topic.assign, diary.comment.write, diary.comment.delete // parent 权限: diary.journal.read, diary.parent.bind let role_permissions = [ ("student", "diary.journal.create"), ("student", "diary.journal.read"), + ("student", "diary.journal.update"), + ("student", "diary.journal.delete"), ("teacher", "diary.journal.create"), ("teacher", "diary.journal.read"), + ("teacher", "diary.journal.update"), + ("teacher", "diary.journal.delete"), ("teacher", "diary.class.manage"), ("teacher", "diary.topic.assign"), ("teacher", "diary.comment.write"), + ("teacher", "diary.comment.delete"), ("parent", "diary.journal.read"), ("parent", "diary.parent.bind"), ];