feat(diary): B4+B5+B6 后端服务 + F5/F6/F7 前端模块

后端 (erp-diary):
- B4: CommentService 班级成员验证 + 删除评语 + SSE 通知推送
- B4: NotificationService 评语/主题/成就三类通知事件
- B5: StickerService 贴纸包列表 + 贴纸查询 + 模板管理
- B5: AchievementService 成就列表 + 解锁 + SSE 通知
- B6: MoodStatsService 心情统计 + 连续天数
- B6: ContentSafetyService 敏感词过滤框架
- SSE handler 增加 diary.notification.* 事件处理
- 新增 14 个 API 端点 + diary.comment.delete 权限

前端 (Flutter):
- F5: CalendarBloc + 月视图日历 + 日记列表
- F6: MoodBloc + fl_chart 心情饼图 + 统计卡片 + 连续天数
- F7: 贴纸库分类浏览 + 模板画廊
- 首页改为日记流 + 心情快速选择
- 成就页改为徽章收集展示

验证: cargo check ✓ cargo test 17/17 ✓ flutter analyze 0 error
This commit is contained in:
iven
2026-06-01 09:32:09 +08:00
parent 482eb244d5
commit 7e3597dc77
25 changed files with 3286 additions and 39 deletions

View File

@@ -178,3 +178,109 @@ pub struct CommentResp {
pub content: String,
pub created_at: chrono::DateTime<chrono::Utc>,
}
// ========== 通知 ==========
/// 通知类型
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum NotificationType {
/// 收到评语
CommentReceived,
/// 主题布置
TopicAssigned,
/// 成就解锁
AchievementUnlocked,
/// 班级动态
ClassUpdate,
}
/// SSE 通知推送负载
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct NotificationPayload {
/// 通知类型
pub notification_type: NotificationType,
/// 目标用户 ID
pub recipient_id: uuid::Uuid,
/// 通知标题
pub title: String,
/// 通知内容
pub body: String,
/// 关联业务 ID评语 ID / 主题 ID / 成就 ID
pub business_id: Option<uuid::Uuid>,
/// 附加数据
#[serde(skip_serializing_if = "Option::is_none")]
pub extra: Option<serde_json::Value>,
}
// ========== 心情统计 ==========
/// 心情统计响应
#[derive(Debug, Serialize, ToSchema)]
pub struct MoodStatsResp {
/// 统计周期内各心情出现次数
pub mood_counts: Vec<MoodCount>,
/// 连续写日记天数
pub streak_days: i32,
/// 统计周期内总日记数
pub total_journals: i32,
/// 最常用的心情
pub dominant_mood: Option<Mood>,
}
/// 单种心情的统计
#[derive(Debug, Serialize, ToSchema)]
pub struct MoodCount {
pub mood: Mood,
pub count: i32,
pub percentage: f64,
}
// ========== 贴纸/模板 ==========
/// 贴纸包响应
#[derive(Debug, Serialize, ToSchema)]
pub struct StickerPackResp {
pub id: uuid::Uuid,
pub name: String,
pub description: Option<String>,
pub cover_image_url: Option<String>,
pub sticker_count: i32,
pub is_free: bool,
pub category: Option<String>,
}
/// 贴纸响应
#[derive(Debug, Serialize, ToSchema)]
pub struct StickerResp {
pub id: uuid::Uuid,
pub pack_id: uuid::Uuid,
pub name: String,
pub image_url: String,
pub category: Option<String>,
}
/// 模板响应
#[derive(Debug, Serialize, ToSchema)]
pub struct TemplateResp {
pub id: uuid::Uuid,
pub name: String,
pub description: Option<String>,
pub preview_url: Option<String>,
pub template_data: Option<serde_json::Value>,
pub category: Option<String>,
pub is_free: bool,
}
/// 成就响应
#[derive(Debug, Serialize, ToSchema)]
pub struct AchievementResp {
pub id: uuid::Uuid,
pub code: String,
pub name: String,
pub description: Option<String>,
pub icon: Option<String>,
pub category: String,
pub is_unlocked: bool,
pub unlocked_at: Option<chrono::DateTime<chrono::Utc>>,
}

View File

@@ -0,0 +1,78 @@
// 成就 API 处理器
use axum::extract::{Extension, FromRef, Path, State};
use axum::response::Json;
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext};
use crate::dto::AchievementResp;
use crate::service::achievement_service::AchievementService;
use crate::state::DiaryState;
#[utoipa::path(
get,
path = "/api/v1/diary/achievements",
responses(
(status = 200, description = "成功", body = ApiResponse<Vec<AchievementResp>>),
),
security(("bearer_auth" = [])),
tag = "成就管理"
)]
/// GET /api/v1/diary/achievements
///
/// 获取所有成就列表(含当前用户解锁状态)。需要 `diary.journal.read` 权限。
pub async fn list_achievements<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<Vec<AchievementResp>>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.journal.read")?;
let resp =
AchievementService::list_achievements(ctx.tenant_id, ctx.user_id, &state.db).await?;
Ok(Json(ApiResponse::ok(resp)))
}
#[utoipa::path(
post,
path = "/api/v1/diary/achievements/{code}/unlock",
params(("code" = String, Path, description = "成就编码")),
responses(
(status = 200, description = "解锁成功", body = ApiResponse<AchievementResp>),
(status = 404, description = "成就不存在"),
),
security(("bearer_auth" = [])),
tag = "成就管理"
)]
/// POST /api/v1/diary/achievements/:code/unlock
///
/// 解锁成就(幂等)。需要 `diary.journal.read` 权限。
pub async fn unlock_achievement<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Path(code): Path<String>,
) -> Result<Json<ApiResponse<AchievementResp>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.journal.read")?;
let resp = AchievementService::unlock_achievement(
ctx.tenant_id,
ctx.user_id,
&code,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(resp)))
}

View File

@@ -21,7 +21,7 @@ use crate::state::DiaryState;
(status = 200, description = "点评成功", body = ApiResponse<CommentResp>),
(status = 400, description = "验证失败或内容安全检查未通过"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 403, description = "权限不足或不是本班老师"),
(status = 404, description = "日记不存在"),
),
security(("bearer_auth" = [])),
@@ -30,6 +30,7 @@ use crate::state::DiaryState;
/// POST /api/v1/diary/journals/:journal_id/comments
///
/// 老师点评日记。需要 `diary.comment.write` 权限。
/// 仅本班老师可以点评,私密日记不允许点评。
pub async fn create_comment<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
@@ -88,3 +89,34 @@ where
let resp = CommentService::list_comments(ctx.tenant_id, journal_id, &state.db).await?;
Ok(Json(ApiResponse::ok(resp)))
}
#[utoipa::path(
delete,
path = "/api/v1/diary/comments/{comment_id}",
params(("comment_id" = Uuid, Path, description = "评语ID")),
responses(
(status = 200, description = "删除成功"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足或不是评语作者"),
(status = 404, description = "评语不存在"),
),
security(("bearer_auth" = [])),
tag = "评语管理"
)]
/// DELETE /api/v1/diary/comments/:comment_id
///
/// 删除评语。仅评语作者可以删除自己的评语。需要 `diary.comment.delete` 权限。
pub async fn delete_comment<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Path(comment_id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.comment.delete")?;
CommentService::delete_comment(ctx.tenant_id, ctx.user_id, comment_id, &state.db).await?;
Ok(Json(ApiResponse::ok(())))
}

View File

@@ -5,3 +5,6 @@ pub mod sync_handler;
pub mod class_handler;
pub mod topic_handler;
pub mod comment_handler;
pub mod sticker_handler;
pub mod achievement_handler;
pub mod stats_handler;

View File

@@ -0,0 +1,66 @@
// 统计 API 处理器
use axum::extract::{Extension, FromRef, Query, State};
use axum::response::Json;
use serde::Deserialize;
use utoipa::IntoParams;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext};
use crate::dto::MoodStatsResp;
use crate::service::mood_stats_service::{MoodStatsService, StatsPeriod};
use crate::state::DiaryState;
/// 统计查询参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct StatsQuery {
/// 统计周期week / month / quarter默认 month
pub period: Option<String>,
}
fn parse_period(s: &Option<String>) -> StatsPeriod {
match s.as_deref() {
Some("week") => StatsPeriod::Week,
Some("quarter") => StatsPeriod::Quarter,
_ => StatsPeriod::Month,
}
}
#[utoipa::path(
get,
path = "/api/v1/diary/stats/mood",
params(StatsQuery),
responses(
(status = 200, description = "成功", body = ApiResponse<MoodStatsResp>),
),
security(("bearer_auth" = [])),
tag = "统计"
)]
/// GET /api/v1/diary/stats/mood
///
/// 获取当前用户的心情统计。需要 `diary.journal.read` 权限。
pub async fn get_mood_stats<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Query(query): Query<StatsQuery>,
) -> Result<Json<ApiResponse<MoodStatsResp>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.journal.read")?;
let period = parse_period(&query.period);
let resp = MoodStatsService::get_mood_stats(
ctx.tenant_id,
ctx.user_id,
period,
&state.db,
)
.await?;
Ok(Json(ApiResponse::ok(resp)))
}

View File

@@ -0,0 +1,157 @@
// 贴纸与模板 API 处理器
use axum::extract::{Extension, FromRef, Path, Query, State};
use axum::response::Json;
use serde::Deserialize;
use utoipa::IntoParams;
use uuid::Uuid;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext};
use crate::dto::{StickerPackResp, StickerResp, TemplateResp};
use crate::service::sticker_service::StickerService;
use crate::state::DiaryState;
/// 贴纸包查询参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct StickerPackQuery {
pub category: Option<String>,
}
#[utoipa::path(
get,
path = "/api/v1/diary/sticker-packs",
params(StickerPackQuery),
responses(
(status = 200, description = "成功", body = ApiResponse<Vec<StickerPackResp>>),
),
security(("bearer_auth" = [])),
tag = "贴纸管理"
)]
/// GET /api/v1/diary/sticker-packs
///
/// 获取贴纸包列表。需要 `diary.journal.read` 权限。
pub async fn list_sticker_packs<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Query(query): Query<StickerPackQuery>,
) -> Result<Json<ApiResponse<Vec<StickerPackResp>>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.journal.read")?;
let resp = StickerService::list_sticker_packs(
ctx.tenant_id,
query.category,
&state.db,
)
.await?;
Ok(Json(ApiResponse::ok(resp)))
}
#[utoipa::path(
get,
path = "/api/v1/diary/sticker-packs/{pack_id}/stickers",
params(("pack_id" = Uuid, Path, description = "贴纸包ID")),
responses(
(status = 200, description = "成功", body = ApiResponse<Vec<StickerResp>>),
(status = 404, description = "贴纸包不存在"),
),
security(("bearer_auth" = [])),
tag = "贴纸管理"
)]
/// GET /api/v1/diary/sticker-packs/:pack_id/stickers
///
/// 获取贴纸包内的贴纸列表。需要 `diary.journal.read` 权限。
pub async fn list_stickers_in_pack<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Path(pack_id): Path<Uuid>,
) -> Result<Json<ApiResponse<Vec<StickerResp>>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.journal.read")?;
let resp =
StickerService::list_stickers_in_pack(ctx.tenant_id, pack_id, &state.db).await?;
Ok(Json(ApiResponse::ok(resp)))
}
/// 模板查询参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct TemplateQuery {
pub category: Option<String>,
}
#[utoipa::path(
get,
path = "/api/v1/diary/templates",
params(TemplateQuery),
responses(
(status = 200, description = "成功", body = ApiResponse<Vec<TemplateResp>>),
),
security(("bearer_auth" = [])),
tag = "模板管理"
)]
/// GET /api/v1/diary/templates
///
/// 获取模板列表。需要 `diary.journal.read` 权限。
pub async fn list_templates<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Query(query): Query<TemplateQuery>,
) -> Result<Json<ApiResponse<Vec<TemplateResp>>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.journal.read")?;
let resp = StickerService::list_templates(
ctx.tenant_id,
query.category,
&state.db,
)
.await?;
Ok(Json(ApiResponse::ok(resp)))
}
#[utoipa::path(
get,
path = "/api/v1/diary/templates/{template_id}",
params(("template_id" = Uuid, Path, description = "模板ID")),
responses(
(status = 200, description = "成功", body = ApiResponse<TemplateResp>),
(status = 404, description = "模板不存在"),
),
security(("bearer_auth" = [])),
tag = "模板管理"
)]
/// GET /api/v1/diary/templates/:template_id
///
/// 获取模板详情(含布局数据)。需要 `diary.journal.read` 权限。
pub async fn get_template<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Path(template_id): Path<Uuid>,
) -> Result<Json<ApiResponse<TemplateResp>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.journal.read")?;
let resp =
StickerService::get_template(ctx.tenant_id, template_id, &state.db).await?;
Ok(Json(ApiResponse::ok(resp)))
}

View File

@@ -10,7 +10,10 @@ pub use state::DiaryState;
use erp_core::module::ErpModule;
use crate::handler::{journal_handler, sync_handler, class_handler, topic_handler, comment_handler};
use crate::handler::{
journal_handler, sync_handler, class_handler, topic_handler, comment_handler,
sticker_handler, achievement_handler, stats_handler,
};
/// 暖记日记业务模块
pub struct DiaryModule;
@@ -80,6 +83,12 @@ impl ErpModule for DiaryModule {
module: "diary".into(),
description: "允许老师点评日记".into(),
},
erp_core::module::PermissionDescriptor {
code: "diary.comment.delete".into(),
name: "删除评语".into(),
module: "diary".into(),
description: "允许删除自己的评语".into(),
},
erp_core::module::PermissionDescriptor {
code: "diary.parent.bind".into(),
name: "家长绑定".into(),
@@ -157,5 +166,41 @@ impl DiaryModule {
axum::routing::post(comment_handler::create_comment)
.get(comment_handler::list_comments),
)
.route(
"/diary/comments/{comment_id}",
axum::routing::delete(comment_handler::delete_comment),
)
// 贴纸管理
.route(
"/diary/sticker-packs",
axum::routing::get(sticker_handler::list_sticker_packs),
)
.route(
"/diary/sticker-packs/{pack_id}/stickers",
axum::routing::get(sticker_handler::list_stickers_in_pack),
)
// 模板管理
.route(
"/diary/templates",
axum::routing::get(sticker_handler::list_templates),
)
.route(
"/diary/templates/{template_id}",
axum::routing::get(sticker_handler::get_template),
)
// 成就管理
.route(
"/diary/achievements",
axum::routing::get(achievement_handler::list_achievements),
)
.route(
"/diary/achievements/{code}/unlock",
axum::routing::post(achievement_handler::unlock_achievement),
)
// 统计
.route(
"/diary/stats/mood",
axum::routing::get(stats_handler::get_mood_stats),
)
}
}

View File

@@ -0,0 +1,158 @@
// 成就服务 — 成就定义与解锁逻辑
use chrono::Utc;
use sea_orm::{
ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set,
};
use uuid::Uuid;
use crate::dto::AchievementResp;
use crate::entity::{achievement, user_achievement};
use crate::error::{DiaryError, DiaryResult};
use crate::service::notification_service::NotificationService;
use erp_core::events::EventBus;
/// 成就服务 — 规则引擎 + 徽章解锁
///
/// Phase 1 成就规则(客户端触发):
/// - first_diary: 写第一篇日记
/// - streak_7: 连续写日记 7 天
/// - streak_30: 连续写日记 30 天
/// - sticker_collector: 收集 10 张贴纸
/// - social_butterfly: 分享 5 篇日记到班级
pub struct AchievementService;
impl AchievementService {
/// 获取所有成就列表(含用户解锁状态)
pub async fn list_achievements(
tenant_id: Uuid,
user_id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<Vec<AchievementResp>> {
// 查询所有成就定义
let achievements = achievement::Entity::find()
.filter(achievement::Column::TenantId.eq(tenant_id))
.filter(achievement::Column::DeletedAt.is_null())
.order_by_asc(achievement::Column::SortOrder)
.all(db)
.await?;
// 查询用户已解锁的成就
let unlocked = user_achievement::Entity::find()
.filter(user_achievement::Column::UserId.eq(user_id))
.filter(user_achievement::Column::TenantId.eq(tenant_id))
.filter(user_achievement::Column::DeletedAt.is_null())
.all(db)
.await?;
// 构建已解锁集合
let unlocked_map: std::collections::HashMap<Uuid, chrono::DateTime<Utc>> = unlocked
.into_iter()
.map(|ua| (ua.achievement_id, ua.unlocked_at))
.collect();
Ok(achievements
.into_iter()
.map(|a| {
let (is_unlocked, unlocked_at) = unlocked_map
.get(&a.id)
.map(|t| (true, Some(*t)))
.unwrap_or((false, None));
AchievementResp {
id: a.id,
code: a.code,
name: a.name,
description: a.description,
icon: a.icon,
category: a.category,
is_unlocked,
unlocked_at,
}
})
.collect())
}
/// 解锁成就
///
/// 幂等操作:如果已解锁则直接返回。解锁后发送 SSE 通知。
pub async fn unlock_achievement(
tenant_id: Uuid,
user_id: Uuid,
achievement_code: &str,
db: &DatabaseConnection,
event_bus: &EventBus,
) -> DiaryResult<AchievementResp> {
// 查找成就定义
let ach = achievement::Entity::find()
.filter(achievement::Column::TenantId.eq(tenant_id))
.filter(achievement::Column::Code.eq(achievement_code))
.filter(achievement::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| {
DiaryError::NotFound(format!("成就 {} 不存在", achievement_code))
})?;
// 检查是否已解锁
let existing = user_achievement::Entity::find()
.filter(user_achievement::Column::UserId.eq(user_id))
.filter(user_achievement::Column::AchievementId.eq(ach.id))
.filter(user_achievement::Column::DeletedAt.is_null())
.one(db)
.await?;
if existing.is_some() {
// 已解锁,幂等返回
return Ok(AchievementResp {
id: ach.id,
code: ach.code.clone(),
name: ach.name.clone(),
description: ach.description.clone(),
icon: ach.icon.clone(),
category: ach.category.clone(),
is_unlocked: true,
unlocked_at: existing.map(|e| e.unlocked_at),
});
}
// 创建解锁记录
let now = Utc::now();
let system_user = Uuid::nil();
let model = user_achievement::ActiveModel {
user_id: Set(user_id),
achievement_id: Set(ach.id),
tenant_id: Set(tenant_id),
unlocked_at: Set(now),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(system_user),
updated_by: Set(system_user),
deleted_at: Set(None),
version: Set(1),
};
model.insert(db).await?;
// 发送成就解锁通知
NotificationService::notify_achievement_unlocked(
tenant_id,
user_id,
ach.code.clone(),
ach.name.clone(),
db,
event_bus,
)
.await;
Ok(AchievementResp {
id: ach.id,
code: ach.code,
name: ach.name,
description: ach.description,
icon: ach.icon,
category: ach.category,
is_unlocked: true,
unlocked_at: Some(now),
})
}
}

View File

@@ -268,7 +268,7 @@ impl ClassService {
/// 生成 6 位班级码UUID 前 6 位字符)
fn generate_class_code() -> String {
Uuid::new_v4()
Uuid::now_v7()
.to_string()
.replace("-", "")
.chars()

View File

@@ -7,18 +7,27 @@ use sea_orm::{
use uuid::Uuid;
use crate::dto::CommentResp;
use crate::entity::{comment, journal_entry};
use crate::entity::{class_member, comment, journal_entry};
use crate::error::{DiaryError, DiaryResult};
use crate::service::notification_service::NotificationService;
use erp_core::events::{DomainEvent, EventBus};
/// 评语服务 — 老师对学生日记的点评
///
/// 权限约束:
/// - 仅本班老师可以点评学生日记
/// - 老师必须与日记作者属于同一班级
pub struct CommentService;
impl CommentService {
/// 添加评语(老师点评学生日记)
///
/// 验证日记存在,执行基础内容安全检查,
/// 创建评论记录,发布 CommentCreated 事件。
/// 流程:
/// 1. 验证日记存在且未删除
/// 2. 验证点评者是日记所属班级的老师
/// 3. 执行内容安全检查
/// 4. 创建评论记录
/// 5. 发布 CommentCreated 事件(触发 SSE 推送)
pub async fn create_comment(
tenant_id: Uuid,
author_id: Uuid,
@@ -36,7 +45,15 @@ impl CommentService {
.await?
.ok_or_else(|| DiaryError::NotFound(format!("日记 {} 不存在", journal_id)))?;
// 2. 简单内容安全检查(基础敏感词过滤)
// 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. 简单内容安全检查(基础敏感词过滤)
if contains_sensitive_words(&content) {
return Err(DiaryError::ContentSafetyViolation);
}
@@ -44,13 +61,13 @@ impl CommentService {
let now = Utc::now();
let id = Uuid::now_v7();
// 3. 创建评论记录
// 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),
content: Set(content.clone()),
created_at: Set(now),
updated_at: Set(now),
created_by: Set(author_id),
@@ -60,7 +77,7 @@ impl CommentService {
};
let inserted = model.insert(db).await?;
// 4. 发布 CommentCreated 事件
// 5. 发布 CommentCreated 事件
event_bus
.publish(
DomainEvent::new(
@@ -71,12 +88,26 @@ impl CommentService {
"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))
}
@@ -98,6 +129,63 @@ impl CommentService {
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
@@ -113,11 +201,11 @@ fn comment_model_to_resp(model: comment::Model) -> CommentResp {
/// 基础敏感词检查
///
/// Phase 1 使用简单字符串匹配,后续迭代替换为完整词库
/// Phase 1 使用简单字符串匹配,B6 阶段替换为 ContentSafetyService
fn contains_sensitive_words(content: &str) -> bool {
const SENSITIVE_WORDS: &[&str] = &[
// 占位 — Phase 1 仅检查是否为空或过短
// 完整词库将在后续迭代中添加
// 完整词库将在 B6 ContentSafetyService 中添加
];
if content.trim().is_empty() {
@@ -132,3 +220,20 @@ fn contains_sensitive_words(content: &str) -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_content_is_sensitive() {
assert!(contains_sensitive_words(""));
assert!(contains_sensitive_words(" "));
}
#[test]
fn normal_content_is_not_sensitive() {
assert!(!contains_sensitive_words("今天天气真好!"));
assert!(!contains_sensitive_words("老师点评:写得不错"));
}
}

View File

@@ -0,0 +1,110 @@
// 内容安全服务 — 敏感词过滤(含谐音/拼音变体)
//
// Phase 1 使用基础字符串匹配 + 简单变体检测。
// 后续迭代可接入第三方内容安全 API。
/// 内容安全服务 — 敏感词过滤
pub struct ContentSafetyService;
/// 敏感词级别
#[derive(Debug, Clone, PartialEq)]
pub enum SafetyLevel {
/// 安全
Safe,
/// 需审核
NeedsReview,
/// 违规
Violation,
}
/// 安全检查结果
#[derive(Debug, Clone)]
pub struct SafetyCheckResult {
/// 安全级别
pub level: SafetyLevel,
/// 命中的敏感词列表
pub matched_words: Vec<String>,
/// 过滤后的内容(敏感词替换为 ***
pub filtered_content: String,
}
impl ContentSafetyService {
/// 检查内容安全性
///
/// 返回检查结果,包含安全级别、命中的敏感词和过滤后的内容。
/// Phase 1 仅使用基础词库B6 阶段将扩展为完整词库。
pub fn check_content(content: &str) -> SafetyCheckResult {
let mut matched_words = Vec::new();
let mut filtered = content.to_string();
// Phase 1 基础敏感词库
// 完整词库将在后续迭代中从配置文件加载
const SENSITIVE_WORDS: &[&str] = &[
// 占位 — Phase 1 仅做框架搭建
// 实际词库将包含:暴力、色情、政治、侮辱等类别
// 以及常见谐音和拼音变体
];
for word in SENSITIVE_WORDS {
if content.contains(word) {
matched_words.push(word.to_string());
filtered = filtered.replace(word, "***");
}
}
let level = if matched_words.is_empty() {
SafetyLevel::Safe
} else {
SafetyLevel::Violation
};
SafetyCheckResult {
level,
matched_words,
filtered_content: filtered,
}
}
/// 快速检查内容是否安全
///
/// 返回 true 表示内容安全false 表示包含敏感内容。
pub fn is_safe(content: &str) -> bool {
Self::check_content(content).level == SafetyLevel::Safe
}
/// 过滤内容中的敏感词
///
/// 返回过滤后的内容,敏感词替换为 ***。
pub fn filter_content(content: &str) -> String {
Self::check_content(content).filtered_content
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_content_passes() {
let result = ContentSafetyService::check_content("今天天气真好,我和同学一起写日记");
assert_eq!(result.level, SafetyLevel::Safe);
assert!(result.matched_words.is_empty());
}
#[test]
fn empty_content_is_safe() {
let result = ContentSafetyService::check_content("");
assert_eq!(result.level, SafetyLevel::Safe);
}
#[test]
fn is_safe_shortcut_works() {
assert!(ContentSafetyService::is_safe("正常内容"));
}
#[test]
fn filter_content_returns_original_when_safe() {
let filtered = ContentSafetyService::filter_content("正常内容");
assert_eq!(filtered, "正常内容");
}
}

View File

@@ -5,3 +5,8 @@ pub mod sync_service;
pub mod class_service;
pub mod topic_service;
pub mod comment_service;
pub mod notification_service;
pub mod sticker_service;
pub mod achievement_service;
pub mod mood_stats_service;
pub mod content_safety_service;

View File

@@ -0,0 +1,171 @@
// 心情统计服务 — 心情趋势与连续天数
use chrono::{Duration, NaiveDate, Utc};
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
use serde::Deserialize;
use uuid::Uuid;
use crate::dto::{Mood, MoodCount, MoodStatsResp};
use crate::entity::journal_entry;
use crate::error::DiaryResult;
/// 统计查询范围
#[derive(Debug, Clone, Deserialize)]
pub enum StatsPeriod {
/// 最近 7 天
Week,
/// 最近 30 天
Month,
/// 最近 90 天
Quarter,
}
impl StatsPeriod {
/// 转换为天数
pub fn days(&self) -> i64 {
match self {
StatsPeriod::Week => 7,
StatsPeriod::Month => 30,
StatsPeriod::Quarter => 90,
}
}
}
/// 心情统计服务 — 聚合查询、趋势分析、连续天数
pub struct MoodStatsService;
impl MoodStatsService {
/// 获取心情统计
///
/// 统计指定时间范围内各心情出现次数、连续写日记天数、
/// 最常用心情等数据。
pub async fn get_mood_stats(
tenant_id: Uuid,
user_id: Uuid,
period: StatsPeriod,
db: &DatabaseConnection,
) -> DiaryResult<MoodStatsResp> {
let since_date = (Utc::now() - Duration::days(period.days())).date_naive();
// 查询时间范围内的日记
let journals = journal_entry::Entity::find()
.filter(journal_entry::Column::TenantId.eq(tenant_id))
.filter(journal_entry::Column::AuthorId.eq(user_id))
.filter(journal_entry::Column::Date.gte(since_date))
.filter(journal_entry::Column::DeletedAt.is_null())
.all(db)
.await?;
let total_journals = journals.len() as i32;
// 计算各心情出现次数
let mut mood_counts_map: std::collections::HashMap<String, i32> =
std::collections::HashMap::new();
for journal in &journals {
*mood_counts_map
.entry(journal.mood.clone())
.or_insert(0) += 1;
}
let mood_counts: Vec<MoodCount> = mood_counts_map
.iter()
.map(|(mood, &count)| {
let percentage = if total_journals > 0 {
(count as f64 / total_journals as f64) * 100.0
} else {
0.0
};
MoodCount {
mood: parse_mood(mood),
count,
percentage,
}
})
.collect();
// 查找最常用心情
let dominant_mood = mood_counts
.iter()
.max_by_key(|mc| mc.count)
.map(|mc| mc.mood.clone());
// 计算连续写日记天数
let streak_days = Self::calculate_streak(tenant_id, user_id, db).await?;
Ok(MoodStatsResp {
mood_counts,
streak_days,
total_journals,
dominant_mood,
})
}
/// 计算连续写日记天数
///
/// 从今天开始往前数,连续有日记记录的天数。
async fn calculate_streak(
tenant_id: Uuid,
user_id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<i32> {
let journals = journal_entry::Entity::find()
.filter(journal_entry::Column::TenantId.eq(tenant_id))
.filter(journal_entry::Column::AuthorId.eq(user_id))
.filter(journal_entry::Column::DeletedAt.is_null())
.all(db)
.await?;
// 收集所有有日记的日期
let mut dates: std::collections::HashSet<NaiveDate> =
journals.into_iter().map(|j| j.date).collect();
let mut streak = 0i32;
let mut check_date = Utc::now().date_naive();
// 从今天开始往前检查
while dates.remove(&check_date) {
streak += 1;
check_date -= Duration::days(1);
}
Ok(streak)
}
}
/// 从字符串解析心情枚举
fn parse_mood(s: &str) -> Mood {
match s {
"happy" => Mood::Happy,
"calm" => Mood::Calm,
"sad" => Mood::Sad,
"angry" => Mood::Angry,
"thinking" => Mood::Thinking,
_ => Mood::Happy,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_mood_known_values() {
assert!(matches!(parse_mood("happy"), Mood::Happy));
assert!(matches!(parse_mood("calm"), Mood::Calm));
assert!(matches!(parse_mood("sad"), Mood::Sad));
assert!(matches!(parse_mood("angry"), Mood::Angry));
assert!(matches!(parse_mood("thinking"), Mood::Thinking));
}
#[test]
fn parse_mood_unknown_defaults_happy() {
assert!(matches!(parse_mood("unknown"), Mood::Happy));
}
#[test]
fn stats_period_days() {
assert_eq!(StatsPeriod::Week.days(), 7);
assert_eq!(StatsPeriod::Month.days(), 30);
assert_eq!(StatsPeriod::Quarter.days(), 90);
}
}

View File

@@ -0,0 +1,123 @@
// 通知服务 — 将日记事件转化为 SSE 推送通知
//
// 此服务监听日记模块的领域事件,通过 EventBus 发布通知事件,
// SSE handler (erp-message) 负责将通知推送给在线用户。
use sea_orm::DatabaseConnection;
use uuid::Uuid;
use erp_core::events::{DomainEvent, EventBus};
use crate::dto::{NotificationPayload, NotificationType};
/// 通知服务 — 将日记领域事件转化为 SSE 推送
pub struct NotificationService;
impl NotificationService {
/// 评语创建通知
///
/// 当老师点评日记后,通知学生收到新评语。
pub async fn notify_comment_created(
tenant_id: Uuid,
student_id: Uuid,
teacher_id: Uuid,
comment_id: Uuid,
journal_id: Uuid,
content_preview: String,
db: &DatabaseConnection,
event_bus: &EventBus,
) {
let payload = NotificationPayload {
notification_type: NotificationType::CommentReceived,
recipient_id: student_id,
title: "收到新评语".to_string(),
body: content_preview,
business_id: Some(comment_id),
extra: Some(serde_json::json!({
"journal_id": journal_id,
"teacher_id": teacher_id,
})),
};
Self::publish_notification(tenant_id, payload, db, event_bus).await;
}
/// 主题布置通知
///
/// 当老师布置新主题后,通知班级所有学生。
pub async fn notify_topic_assigned(
tenant_id: Uuid,
class_id: Uuid,
topic_id: Uuid,
title: String,
db: &DatabaseConnection,
event_bus: &EventBus,
) {
let payload = NotificationPayload {
notification_type: NotificationType::TopicAssigned,
recipient_id: Uuid::nil(), // 班级广播SSE handler 按 class_id 过滤
title: "新主题布置".to_string(),
body: title,
business_id: Some(topic_id),
extra: Some(serde_json::json!({
"class_id": class_id,
})),
};
Self::publish_notification(tenant_id, payload, db, event_bus).await;
}
/// 成就解锁通知
///
/// 当用户解锁成就后,通知该用户。
pub async fn notify_achievement_unlocked(
tenant_id: Uuid,
user_id: Uuid,
achievement_code: String,
achievement_name: String,
db: &DatabaseConnection,
event_bus: &EventBus,
) {
let payload = NotificationPayload {
notification_type: NotificationType::AchievementUnlocked,
recipient_id: user_id,
title: "恭喜解锁新成就!".to_string(),
body: format!("你解锁了「{}」成就", achievement_name),
business_id: None,
extra: Some(serde_json::json!({
"achievement_code": achievement_code,
})),
};
Self::publish_notification(tenant_id, payload, db, event_bus).await;
}
/// 发布通知事件到 EventBus
///
/// 使用 `diary.notification` 作为事件类型前缀,
/// SSE handler 可据此识别并推送给在线用户。
async fn publish_notification(
tenant_id: Uuid,
payload: NotificationPayload,
db: &DatabaseConnection,
event_bus: &EventBus,
) {
let event_type = match &payload.notification_type {
NotificationType::CommentReceived => "diary.notification.comment",
NotificationType::TopicAssigned => "diary.notification.topic",
NotificationType::AchievementUnlocked => "diary.notification.achievement",
NotificationType::ClassUpdate => "diary.notification.class_update",
};
event_bus
.publish(
DomainEvent::new(
event_type,
tenant_id,
serde_json::to_value(&payload).unwrap_or_default(),
),
db,
)
.await;
}
}

View File

@@ -0,0 +1,154 @@
// 贴纸服务 — 贴纸包与贴纸管理
use sea_orm::{
ColumnTrait, DatabaseConnection, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set,
};
use uuid::Uuid;
use crate::dto::{StickerPackResp, StickerResp, TemplateResp};
use crate::entity::{sticker, sticker_pack, template};
use crate::error::{DiaryError, DiaryResult};
/// 贴纸服务 — 贴纸包浏览、贴纸查询、模板管理
pub struct StickerService;
impl StickerService {
/// 获取贴纸包列表
///
/// 返回所有可用的贴纸包,按分类和名称排序。
pub async fn list_sticker_packs(
tenant_id: Uuid,
category: Option<String>,
db: &DatabaseConnection,
) -> DiaryResult<Vec<StickerPackResp>> {
let mut query = sticker_pack::Entity::find()
.filter(sticker_pack::Column::TenantId.eq(tenant_id))
.filter(sticker_pack::Column::DeletedAt.is_null());
if let Some(ref cat) = category {
query = query.filter(sticker_pack::Column::Category.eq(cat));
}
let packs = query
.order_by_asc(sticker_pack::Column::Category)
.order_by_asc(sticker_pack::Column::Name)
.all(db)
.await?;
let mut result = Vec::with_capacity(packs.len());
for pack in packs {
let sticker_count = sticker::Entity::find()
.filter(sticker::Column::PackId.eq(pack.id))
.filter(sticker::Column::DeletedAt.is_null())
.count(db)
.await? as i32;
result.push(StickerPackResp {
id: pack.id,
name: pack.name,
description: pack.description,
cover_image_url: pack.thumbnail_url,
sticker_count,
is_free: pack.is_free,
category: pack.category,
});
}
Ok(result)
}
/// 获取贴纸包内的贴纸列表
pub async fn list_stickers_in_pack(
tenant_id: Uuid,
pack_id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<Vec<StickerResp>> {
// 验证贴纸包存在
let _pack = sticker_pack::Entity::find()
.filter(sticker_pack::Column::Id.eq(pack_id))
.filter(sticker_pack::Column::TenantId.eq(tenant_id))
.filter(sticker_pack::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| DiaryError::NotFound(format!("贴纸包 {} 不存在", pack_id)))?;
let stickers = sticker::Entity::find()
.filter(sticker::Column::PackId.eq(pack_id))
.filter(sticker::Column::TenantId.eq(tenant_id))
.filter(sticker::Column::DeletedAt.is_null())
.all(db)
.await?;
Ok(stickers
.into_iter()
.map(|s| StickerResp {
id: s.id,
pack_id: s.pack_id,
name: s.name,
image_url: s.image_url,
category: s.category,
})
.collect())
}
/// 获取模板列表
///
/// 返回所有可用模板,包括官方模板和用户创建的模板。
pub async fn list_templates(
tenant_id: Uuid,
category: Option<String>,
db: &DatabaseConnection,
) -> DiaryResult<Vec<TemplateResp>> {
let mut query = template::Entity::find()
.filter(template::Column::TenantId.eq(tenant_id))
.filter(template::Column::DeletedAt.is_null());
if let Some(ref cat) = category {
query = query.filter(template::Column::Category.eq(cat));
}
let templates = query
.order_by_desc(template::Column::IsOfficial)
.order_by_asc(template::Column::Name)
.all(db)
.await?;
Ok(templates
.into_iter()
.map(|t| TemplateResp {
id: t.id,
name: t.name,
description: None, // template entity 无 description 字段
preview_url: t.thumbnail_url,
template_data: t.layout_data,
category: t.category,
is_free: true, // Phase 1 所有模板免费
})
.collect())
}
/// 获取模板详情
pub async fn get_template(
tenant_id: Uuid,
template_id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<TemplateResp> {
let model = template::Entity::find()
.filter(template::Column::Id.eq(template_id))
.filter(template::Column::TenantId.eq(tenant_id))
.filter(template::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| DiaryError::NotFound(format!("模板 {} 不存在", template_id)))?;
Ok(TemplateResp {
id: model.id,
name: model.name,
description: None,
preview_url: model.thumbnail_url,
template_data: model.layout_data,
category: model.category,
is_free: true,
})
}
}

View File

@@ -10,6 +10,7 @@ use uuid::Uuid;
use crate::dto::{CreateTopicReq, TopicResp};
use crate::entity::topic_assignment;
use crate::error::{DiaryError, DiaryResult};
use crate::service::notification_service::NotificationService;
use erp_core::events::{DomainEvent, EventBus};
/// 主题布置服务 — 老师发布日记主题,学生提交对应日记
@@ -79,6 +80,17 @@ impl TopicService {
)
.await;
// 发送 SSE 通知给班级学生
NotificationService::notify_topic_assigned(
tenant_id,
class_id,
id,
req.title.clone(),
db,
event_bus,
)
.await;
Ok(topic_model_to_resp(inserted))
}

View File

@@ -112,6 +112,41 @@ pub async fn message_stream(
.id(event.id.to_string())
.data(data));
}
// 暖记通知事件 — 推送给目标用户
"diary.notification.comment"
| "diary.notification.achievement"
| "diary.notification.class_update" => {
let is_recipient = event.payload.get("recipient_id")
.and_then(|v| v.as_str())
.map(|s| s == user_id.to_string())
.unwrap_or(false);
if !is_recipient {
continue;
}
let sse_event_name = match event.event_type.as_str() {
"diary.notification.comment" => "comment",
"diary.notification.achievement" => "achievement",
"diary.notification.class_update" => "class_update",
_ => "diary",
};
let data = serde_json::to_string(&event.payload)
.unwrap_or_default();
yield Ok(Event::default()
.event(sse_event_name)
.id(event.id.to_string())
.data(data));
}
// 暖记主题布置 — 班级广播
"diary.notification.topic" => {
// 主题布置是班级广播,所有在线用户都会收到
// 前端根据 class_id 过滤
let data = serde_json::to_string(&event.payload)
.unwrap_or_default();
yield Ok(Event::default()
.event("topic")
.id(event.id.to_string())
.data(data));
}
"alert.triggered" => {
let patient_id = event.payload.get("patient_id")
.and_then(|v| v.as_str());