Files
nj/crates/erp-diary/src/handler/parent_handler.rs
iven dbb74b6545 fix(diary): 系统性修复 DTO 输入验证 — 42 项审计发现中输入验证类全部修复
DTO 字段级验证:
- version 字段全部添加 range(min=0) 防止负数
- 标签内容验证: 单个标签最长 30 字符,不允许空白
- 班级码正则: 仅允许字母数字,拒绝特殊字符
- 贴纸包 price 添加 range(min=0) 防止负价格
- thumbnail_url/image_url 添加 length(max=500) 限制
- 同步请求 data payload 限制 1MB/条

Handler validate() 调用补齐:
- delete_journal: DeleteJournalReq 添加 Validate derive + handler 调用
- bind_child / unbind_child / delete_child_data: 补齐 req.validate() 调用
- join_class: 添加 validate_code() 字母数字检查
- sync_journals: 添加 validate_changes_data() payload 大小检查

审计覆盖: 5a-C01/02/03 + 5a-H02/03/04 + B-03 + 7b-C02
2026-06-07 12:55:50 +08:00

468 lines
14 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.
// 家长中心 API 处理器 — PIPL 合规: 绑定/查阅/导出/删除
use axum::extract::{Extension, FromRef, Path, Query, State};
use axum::http::StatusCode;
use axum::response::Json;
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
use uuid::Uuid;
use validator::Validate;
use erp_core::error::AppError;
use erp_core::rbac::require_permission;
use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext};
use crate::dto::JournalResp;
use crate::service::parent_service::ParentService;
use crate::state::DiaryState;
// ---- 请求/响应 DTO ----
/// 绑定孩子请求
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct BindChildReq {
/// 孩子的用户 ID
pub child_id: Uuid,
}
/// 查看孩子日记查询参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct ChildJournalsQuery {
/// 孩子的用户 ID
pub child_id: Uuid,
/// 页码(默认 1
pub page: Option<u64>,
/// 每页条数(默认 20最大 100
pub page_size: Option<u64>,
}
/// 导出数据查询参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct ExportQuery {
/// 孩子的用户 ID
pub child_id: Uuid,
}
/// 删除孩子数据请求
#[derive(Debug, Deserialize, Validate, ToSchema)]
pub struct DeleteChildDataReq {
/// 孩子的用户 ID
pub child_id: Uuid,
}
/// 绑定信息响应
#[derive(Debug, Serialize, ToSchema)]
pub struct BindingResp {
pub binding_id: Uuid,
pub child_id: Uuid,
pub verified_at: Option<chrono::DateTime<chrono::Utc>>,
}
/// 删除结果响应
#[derive(Debug, Serialize, ToSchema)]
pub struct DeleteResultResp {
pub deleted_count: usize,
pub message: String,
}
// ---- Handler 函数 ----
#[utoipa::path(
post,
path = "/api/v1/diary/parent/bind",
request_body = BindChildReq,
responses(
(status = 201, description = "绑定成功", body = ApiResponse<BindingResp>),
(status = 400, description = "已绑定该孩子"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "家长中心"
)]
/// POST /api/v1/diary/parent/bind
///
/// 家长绑定孩子账号。需要 `diary.parent.bind` 权限。
pub async fn bind_child<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<BindChildReq>,
) -> Result<(StatusCode, Json<ApiResponse<BindingResp>>), AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.parent.bind")?;
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
let binding = ParentService::bind_child(
ctx.tenant_id,
ctx.user_id,
req.child_id,
&state.db,
&state.event_bus,
)
.await?;
Ok((StatusCode::CREATED, Json(ApiResponse::ok(BindingResp {
binding_id: binding.id,
child_id: binding.child_id,
verified_at: binding.verified_at,
}))))
}
#[utoipa::path(
get,
path = "/api/v1/diary/parent/children",
responses(
(status = 200, description = "孩子列表", body = ApiResponse<Vec<BindingResp>>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
),
security(("bearer_auth" = [])),
tag = "家长中心"
)]
/// GET /api/v1/diary/parent/children
///
/// 获取家长绑定的孩子列表。需要 `diary.parent.bind` 权限。
pub async fn list_children<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<Vec<BindingResp>>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.parent.bind")?;
let bindings = ParentService::list_children(ctx.tenant_id, ctx.user_id, &state.db).await?;
let resp: Vec<BindingResp> = bindings
.into_iter()
.map(|b| BindingResp {
binding_id: b.id,
child_id: b.child_id,
verified_at: b.verified_at,
})
.collect();
Ok(Json(ApiResponse::ok(resp)))
}
#[utoipa::path(
get,
path = "/api/v1/diary/parent/journals",
params(ChildJournalsQuery),
responses(
(status = 200, description = "孩子日记列表", body = ApiResponse<PaginatedResponse<JournalResp>>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足或未绑定该孩子"),
),
security(("bearer_auth" = [])),
tag = "家长中心"
)]
/// GET /api/v1/diary/parent/journals
///
/// 查看已绑定孩子的日记列表(只读)。需要 `diary.journal.read` 权限。
pub async fn get_child_journals<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<ChildJournalsQuery>,
) -> Result<Json<ApiResponse<PaginatedResponse<JournalResp>>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.journal.read")?;
let page = params.page.unwrap_or(1);
let page_size = params.page_size.unwrap_or(20).min(100);
let (models, total) = ParentService::get_child_journals(
ctx.tenant_id,
ctx.user_id,
params.child_id,
page,
page_size,
&state.db,
)
.await?;
let items: Vec<JournalResp> = models.into_iter().map(journal_model_to_resp).collect();
let total_pages = total.div_ceil(page_size);
Ok(Json(ApiResponse::ok(PaginatedResponse {
data: items,
total,
page,
page_size,
total_pages,
})))
}
#[utoipa::path(
get,
path = "/api/v1/diary/parent/export",
params(ExportQuery),
responses(
(status = 200, description = "导出数据"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足或未绑定该孩子"),
),
security(("bearer_auth" = [])),
tag = "家长中心"
)]
/// GET /api/v1/diary/parent/export
///
/// 导出孩子所有日记数据PIPL 数据可携带权)。需要 `diary.journal.read` 权限。
pub async fn export_child_data<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Query(params): Query<ExportQuery>,
) -> Result<Json<serde_json::Value>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.journal.read")?;
let data =
ParentService::export_child_data(ctx.tenant_id, ctx.user_id, params.child_id, &state.db)
.await?;
Ok(Json(data))
}
#[utoipa::path(
delete,
path = "/api/v1/diary/parent/data",
request_body = DeleteChildDataReq,
responses(
(status = 200, description = "删除成功", body = ApiResponse<DeleteResultResp>),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足或未绑定该孩子"),
),
security(("bearer_auth" = [])),
tag = "家长中心"
)]
/// DELETE /api/v1/diary/parent/data
///
/// 软删除孩子所有日记数据PIPL 删除权)。需要 `diary.parent.bind` 权限。
/// 数据将在 30 天内完成清理。
pub async fn delete_child_data<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<DeleteChildDataReq>,
) -> Result<Json<ApiResponse<DeleteResultResp>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.parent.bind")?;
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
let count = ParentService::delete_child_data(
ctx.tenant_id,
ctx.user_id,
req.child_id,
&state.db,
&state.event_bus,
)
.await?;
Ok(Json(ApiResponse::ok(DeleteResultResp {
deleted_count: count,
message: "数据删除请求已提交,将在 30 天内完成删除".to_string(),
})))
}
#[utoipa::path(
delete,
path = "/api/v1/diary/parent/unbind",
request_body = BindChildReq,
responses(
(status = 200, description = "解绑成功"),
(status = 401, description = "未授权"),
(status = 403, description = "权限不足"),
(status = 404, description = "绑定关系不存在"),
),
security(("bearer_auth" = [])),
tag = "家长中心"
)]
/// DELETE /api/v1/diary/parent/unbind
///
/// 解绑孩子。需要 `diary.parent.bind` 权限。
pub async fn unbind_child<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Json(req): Json<BindChildReq>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
require_permission(&ctx, "diary.parent.bind")?;
req.validate().map_err(|e| AppError::Validation(e.to_string()))?;
ParentService::unbind_child(ctx.tenant_id, ctx.user_id, req.child_id, &state.db).await?;
Ok(Json(ApiResponse {
success: true,
data: None,
message: Some("已解绑".to_string()),
}))
}
/// 确认绑定请求的路径参数
#[derive(Debug, Deserialize, IntoParams)]
pub struct BindingIdPath {
/// 绑定请求 ID
pub binding_id: Uuid,
}
#[utoipa::path(
get,
path = "/api/v1/diary/parent/pending",
responses(
(status = 200, description = "待确认绑定列表", body = ApiResponse<Vec<BindingResp>>),
(status = 401, description = "未授权"),
),
security(("bearer_auth" = [])),
tag = "家长中心"
)]
/// GET /api/v1/diary/parent/pending
///
/// 孩子查看自己的待确认绑定请求列表。
pub async fn list_pending_bindings<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
) -> Result<Json<ApiResponse<Vec<BindingResp>>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
let bindings =
ParentService::list_pending_for_child(ctx.tenant_id, ctx.user_id, &state.db).await?;
let resp: Vec<BindingResp> = bindings
.into_iter()
.map(|b| BindingResp {
binding_id: b.id,
child_id: b.parent_id, // 对于孩子端,显示家长 ID
verified_at: b.verified_at,
})
.collect();
Ok(Json(ApiResponse::ok(resp)))
}
#[utoipa::path(
post,
path = "/api/v1/diary/parent/bindings/{binding_id}/confirm",
params(("binding_id" = Uuid, Path, description = "绑定请求ID")),
responses(
(status = 201, description = "确认成功", body = ApiResponse<BindingResp>),
(status = 401, description = "未授权"),
(status = 403, description = "无权确认此绑定"),
(status = 404, description = "绑定请求不存在"),
),
security(("bearer_auth" = [])),
tag = "家长中心"
)]
/// POST /api/v1/diary/parent/bindings/:binding_id/confirm
///
/// 孩子确认家长绑定请求。确认后家长获得查看日记等权限。
pub async fn confirm_binding<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Path(binding_id): Path<Uuid>,
) -> Result<(StatusCode, Json<ApiResponse<BindingResp>>), AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
let binding = ParentService::confirm_binding(
ctx.tenant_id,
ctx.user_id,
binding_id,
&state.db,
&state.event_bus,
)
.await?;
Ok((StatusCode::CREATED, Json(ApiResponse::ok(BindingResp {
binding_id: binding.id,
child_id: binding.parent_id,
verified_at: binding.verified_at,
}))))
}
#[utoipa::path(
post,
path = "/api/v1/diary/parent/bindings/{binding_id}/reject",
params(("binding_id" = Uuid, Path, description = "绑定请求ID")),
responses(
(status = 200, description = "拒绝成功"),
(status = 401, description = "未授权"),
(status = 403, description = "无权拒绝此绑定"),
(status = 404, description = "绑定请求不存在"),
),
security(("bearer_auth" = [])),
tag = "家长中心"
)]
/// POST /api/v1/diary/parent/bindings/:binding_id/reject
///
/// 孩子拒绝家长绑定请求。
pub async fn reject_binding<S>(
State(state): State<DiaryState>,
Extension(ctx): Extension<TenantContext>,
Path(binding_id): Path<Uuid>,
) -> Result<Json<ApiResponse<()>>, AppError>
where
DiaryState: FromRef<S>,
S: Clone + Send + Sync + 'static,
{
ParentService::reject_binding(ctx.tenant_id, ctx.user_id, binding_id, &state.db).await?;
Ok(Json(ApiResponse {
success: true,
data: None,
message: Some("已拒绝绑定请求".to_string()),
}))
}
/// journal_entry::Model -> JournalResp DTO 转换
///
/// 与 journal_service 中的 model_to_resp 逻辑一致,
/// 但这里直接从 Model 转换避免循环依赖。
fn journal_model_to_resp(model: crate::entity::journal_entry::Model) -> JournalResp {
use crate::dto::{Mood, Weather};
let mood: Mood = serde_json::from_str(&model.mood).unwrap_or(Mood::Happy);
let weather: Weather = serde_json::from_str(&model.weather).unwrap_or(Weather::Sunny);
let tags: Vec<String> = model
.tags
.and_then(|v| serde_json::from_value(v).ok())
.unwrap_or_default();
JournalResp {
id: model.id,
author_id: model.author_id,
class_id: model.class_id,
title: model.title,
date: model.date,
mood,
weather,
tags,
is_private: model.is_private,
shared_to_class: model.shared_to_class,
assigned_topic_id: model.assigned_topic_id,
version: model.version,
created_at: model.created_at,
updated_at: model.updated_at,
}
}