Files
nj/crates/erp-diary/src/handler/topic_handler.rs
iven b81a972245
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
fix(diary): 为所有 DTO 添加 Validate derive + handler 调用 validate()
DTO 验证规则:
- CreateJournalReq: title 1-200, tags ≤20
- UpdateJournalReq: title 1-200, tags ≤20
- CreateClassReq: name 1-50, school_name ≤100
- JoinClassReq: class_code = 6位
- UpdateClassReq: name 1-50, school_name ≤100
- SyncReq: changes ≤100 条
- CreateTopicReq: title 1-200, description ≤2000
- UpdateTopicReq: title 1-200, description ≤2000
- CreateCommentReq: content 1-1000
- CreateStickerPackReq: name 1-50, description ≤500
- UpdateStickerPackReq: name 1-50, description ≤500
- CreateStickerReq: name 1-30, image_url 1-500
- BindChildReq/DeleteChildDataReq: Validate derive (Uuid 已由 serde 验证)

Handler 调用: validate() 放在 require_permission() 之前(先验证输入再检查权限)

审计 ID: 5a-C01, 5a-C02, 5a-C03
2026-06-03 01:14:23 +08:00

181 lines
5.5 KiB
Rust

// 主题布置 API 处理器 — 老师布置/查询主题
use axum::extract::{Extension, FromRef, Path, State};
use axum::response::Json;
use uuid::Uuid;
use validator::Validate;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, TenantContext};
use crate::dto::{CreateTopicReq, TopicResp, UpdateTopicReq};
use crate::service::topic_service::TopicService;
use crate::state::DiaryState;
#[utoipa::path(
post,
path = "/api/v1/diary/classes/{class_id}/topics",
params(("class_id" = Uuid, Path, description = "班级ID")),
request_body = CreateTopicReq,
responses(
(status = 200, description = "布置成功", body = ApiResponse<TopicResp>),
(status = 400, description = "验证失败"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "班级不存在"),
),
security(("bearer_auth" = [])),
tag = "主题布置"
)]
/// POST /api/v1/diary/classes/:class_id/topics
///
/// 布置日记主题。需要 `diary.topic.assign` 权限(老师角色)。
pub async fn assign_topic<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Path(class_id): Path<Uuid>,
Json(req): Json<CreateTopicReq>,
) -> Result<Json<ApiResponse<TopicResp>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
require_permission(&ctx, "diary.topic.assign")?;
if req.title.trim().is_empty() {
return Err(AppError::Validation("主题标题不能为空".to_string()));
}
let resp = TopicService::assign_topic(
ctx.tenant_id,
ctx.user_id,
class_id,
&req,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(resp)))
}
#[utoipa::path(
get,
path = "/api/v1/diary/classes/{class_id}/topics",
params(("class_id" = Uuid, Path, description = "班级ID")),
responses(
(status = 200, description = "成功", body = ApiResponse<Vec<TopicResp>>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "主题布置"
)]
/// GET /api/v1/diary/classes/:class_id/topics
///
/// 获取班级主题列表。需要 `diary.journal.read` 权限。
pub async fn list_topics<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Path(class_id): Path<Uuid>,
) -> Result<Json<ApiResponse<Vec<TopicResp>>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.journal.read")?;
let resp = TopicService::list_topics(ctx.tenant_id, class_id, &state.db).await?;
Ok(Json(ApiResponse::ok(resp)))
}
#[utoipa::path(
put,
path = "/api/v1/diary/topics/{topic_id}",
params(("topic_id" = Uuid, Path, description = "主题ID")),
request_body = UpdateTopicReq,
responses(
(status = 200, description = "更新成功", body = ApiResponse<TopicResp>),
(status = 400, description = "验证失败"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "主题不存在"),
(status = 409, description = "版本冲突"),
),
security(("bearer_auth" = [])),
tag = "主题布置"
)]
/// PUT /api/v1/diary/topics/:topic_id
///
/// 更新主题信息。需要 `diary.topic.assign` 权限(仅布置者可编辑)。
pub async fn update_topic<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Path(topic_id): Path<Uuid>,
Json(req): Json<UpdateTopicReq>,
) -> Result<Json<ApiResponse<TopicResp>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
require_permission(&ctx, "diary.topic.assign")?;
if let Some(ref title) = req.title {
if title.trim().is_empty() {
return Err(AppError::Validation("主题标题不能为空".to_string()));
}
}
let resp = TopicService::update_topic(
ctx.tenant_id,
ctx.user_id,
topic_id,
&req,
&state.db,
)
.await?;
Ok(Json(ApiResponse::ok(resp)))
}
#[utoipa::path(
patch,
path = "/api/v1/diary/topics/{topic_id}/deactivate",
params(("topic_id" = Uuid, Path, description = "主题ID")),
responses(
(status = 200, description = "停用成功", body = ApiResponse<TopicResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "主题不存在"),
),
security(("bearer_auth" = [])),
tag = "主题布置"
)]
/// PATCH /api/v1/diary/topics/:topic_id/deactivate
///
/// 停用主题。需要 `diary.topic.assign` 权限(仅布置者可停用)。
pub async fn deactivate_topic<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Path(topic_id): Path<Uuid>,
) -> Result<Json<ApiResponse<TopicResp>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.topic.assign")?;
let resp = TopicService::deactivate_topic(
ctx.tenant_id,
ctx.user_id,
topic_id,
&state.db,
)
.await?;
Ok(Json(ApiResponse::ok(resp)))
}