// 评语服务 — 老师点评学生日记 use chrono::Utc; use sea_orm::{ ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set, }; use uuid::Uuid; use crate::dto::CommentResp; use crate::entity::{class_member, comment, journal_entry}; use crate::error::{DiaryError, DiaryResult}; use crate::service::content_safety_service::ContentSafetyService; use crate::service::notification_service::NotificationService; use erp_core::events::{DomainEvent, EventBus}; /// 评语服务 — 老师对学生日记的点评 /// /// 权限约束: /// - 仅本班老师可以点评学生日记 /// - 老师必须与日记作者属于同一班级 pub struct CommentService; impl CommentService { /// 添加评语(老师点评学生日记) /// /// 流程: /// 1. 验证日记存在且未删除 /// 2. 验证点评者是日记所属班级的老师 /// 3. 执行内容安全检查 /// 4. 创建评论记录 /// 5. 发布 CommentCreated 事件(触发 SSE 推送) pub async fn create_comment( tenant_id: Uuid, author_id: Uuid, journal_id: Uuid, content: String, db: &DatabaseConnection, event_bus: &EventBus, ) -> DiaryResult { // 1. 验证日记存在 let journal = journal_entry::Entity::find() .filter(journal_entry::Column::Id.eq(journal_id)) .filter(journal_entry::Column::TenantId.eq(tenant_id)) .filter(journal_entry::Column::DeletedAt.is_null()) .one(db) .await? .ok_or_else(|| DiaryError::NotFound(format!("日记 {} 不存在", journal_id)))?; // 2. 班级成员验证:点评者必须是日记所属班级的老师 if let Some(class_id) = journal.class_id { Self::verify_teacher_in_class(tenant_id, author_id, class_id, db).await?; } else { // 私密日记(无班级)不允许点评 return Err(DiaryError::Forbidden); } // 3. 内容安全检查(使用 ContentSafetyService) if !ContentSafetyService::is_safe(&content) { return Err(DiaryError::ContentSafetyViolation); } let now = Utc::now(); let id = Uuid::now_v7(); // 4. 创建评论记录 let model = comment::ActiveModel { id: Set(id), tenant_id: Set(tenant_id), journal_id: Set(journal_id), author_id: Set(author_id), content: Set(content.clone()), created_at: Set(now), updated_at: Set(now), created_by: Set(author_id), updated_by: Set(author_id), deleted_at: Set(None), version: Set(1), }; let inserted = model.insert(db).await?; // 5. 发布 CommentCreated 事件 event_bus .publish( DomainEvent::new( "diary.comment.created", tenant_id, serde_json::json!({ "comment_id": id, "journal_id": journal_id, "teacher_id": author_id, "student_id": journal.author_id, "content_preview": content.chars().take(50).collect::(), }), ), db, ) .await; // 6. 发送 SSE 通知给学生 NotificationService::notify_comment_created( tenant_id, journal.author_id, author_id, id, journal_id, content.chars().take(50).collect(), db, event_bus, ) .await; Ok(comment_model_to_resp(inserted)) } /// 获取日记的评语列表 /// /// 按创建时间正序返回日记下所有未删除的评语。 pub async fn list_comments( tenant_id: Uuid, journal_id: Uuid, db: &DatabaseConnection, ) -> DiaryResult> { let comments = comment::Entity::find() .filter(comment::Column::JournalId.eq(journal_id)) .filter(comment::Column::TenantId.eq(tenant_id)) .filter(comment::Column::DeletedAt.is_null()) .order_by_asc(comment::Column::CreatedAt) .all(db) .await?; Ok(comments.into_iter().map(comment_model_to_resp).collect()) } /// 删除评语(仅作者可删除自己的评语) pub async fn delete_comment( tenant_id: Uuid, user_id: Uuid, comment_id: Uuid, db: &DatabaseConnection, ) -> DiaryResult<()> { let model = comment::Entity::find() .filter(comment::Column::Id.eq(comment_id)) .filter(comment::Column::TenantId.eq(tenant_id)) .filter(comment::Column::DeletedAt.is_null()) .one(db) .await? .ok_or_else(|| DiaryError::NotFound(format!("评语 {} 不存在", comment_id)))?; // 仅评语作者可以删除 if model.author_id != user_id { return Err(DiaryError::Forbidden); } let now = Utc::now(); let current_version = model.version; let mut active: comment::ActiveModel = model.into(); active.deleted_at = Set(Some(now)); active.updated_at = Set(now); active.updated_by = Set(user_id); active.version = Set(current_version + 1); active.update(db).await?; Ok(()) } /// 验证用户是指定班级的老师 /// /// 检查 class_members 表中是否存在 (class_id, user_id, role=teacher) 记录。 async fn verify_teacher_in_class( tenant_id: Uuid, user_id: Uuid, class_id: Uuid, db: &DatabaseConnection, ) -> DiaryResult<()> { let membership = class_member::Entity::find() .filter(class_member::Column::ClassId.eq(class_id)) .filter(class_member::Column::UserId.eq(user_id)) .filter(class_member::Column::TenantId.eq(tenant_id)) .filter(class_member::Column::Role.eq("teacher")) .filter(class_member::Column::DeletedAt.is_null()) .one(db) .await?; if membership.is_none() { return Err(DiaryError::Forbidden); } Ok(()) } } /// comment::Model -> CommentResp fn comment_model_to_resp(model: comment::Model) -> CommentResp { CommentResp { id: model.id, journal_id: model.journal_id, author_id: model.author_id, content: model.content, created_at: model.created_at, } } #[cfg(test)] mod tests { use super::*; #[test] fn content_safety_phase1_empty_is_safe() { // Phase 1 词库为空,所有内容(包括空串)返回 Safe // 空内容检查由 handler 层的 Validation 守卫处理 assert!(ContentSafetyService::is_safe("")); } #[test] fn normal_content_is_safe() { assert!(ContentSafetyService::is_safe("今天天气真好!")); assert!(ContentSafetyService::is_safe("老师点评:写得不错")); } }