Files
nj/crates/erp-diary/src/handler/parent_handler.rs
iven 8300822232
Some checks failed
Main Merge / backend (push) Has been cancelled
Main Merge / frontend (push) Has been cancelled
fix(diary): JournalResp 补充 assigned_topic_id 字段
- dto.rs: JournalResp 添加 assigned_topic_id: Option<Uuid>
- journal_service model_to_resp: 映射 model.assigned_topic_id
- parent_handler journal_model_to_resp: 同步映射

Flutter 端 JournalEntry 已有 assignedTopicId,无需修改
测试: 84/84 通过
2026-06-03 17:46:50 +08:00

462 lines
13 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")?;
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")?;
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")?;
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,
}
}