Files
nj/crates/erp-diary/src/service/comment_service.rs
iven a83909dd24
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
fix(server): Phase 1.2 核心功能修复 — C1/C2/H4/H6
- feat(diary): 新增 list_all_classes 管理端 API (GET /diary/classes/all)
- feat(diary): 新增班级更新 API (PUT /diary/classes/{id}) — 名称/学校名编辑
- feat(diary): 新增班级停用 API (PATCH /diary/classes/{id}/deactivate)
- feat(diary): 新增班级码重置 API (POST /diary/classes/{id}/reset-code)
- fix(db): 补充权限 seed — student 获得 update/delete, teacher 获得 comment.delete
- refactor(diary): 删除 comment_service 中废弃的 contains_sensitive_words 死代码
- test(diary): 77 测试全部通过
2026-06-02 21:33:47 +08:00

220 lines
7.1 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::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<CommentResp> {
// 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::<String>(),
}),
),
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<Vec<CommentResp>> {
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("老师点评:写得不错"));
}
}