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:
78
crates/erp-diary/src/handler/achievement_handler.rs
Normal file
78
crates/erp-diary/src/handler/achievement_handler.rs
Normal 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)))
|
||||
}
|
||||
@@ -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(())))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
66
crates/erp-diary/src/handler/stats_handler.rs
Normal file
66
crates/erp-diary/src/handler/stats_handler.rs
Normal 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)))
|
||||
}
|
||||
157
crates/erp-diary/src/handler/sticker_handler.rs
Normal file
157
crates/erp-diary/src/handler/sticker_handler.rs
Normal 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)))
|
||||
}
|
||||
Reference in New Issue
Block a user