fix(diary): 家长绑定改为两步验证 — 孩子确认后才生效

- bind_child: 创建 pending 状态绑定(不再自动 verified)
- validate_child_user: 验证目标用户存在且有 student 角色
- confirm_binding: 孩子确认后状态变为 verified,家长获得访问权限
- reject_binding: 孩子拒绝绑定请求
- list_pending_for_child: 孩子查看待确认绑定列表
- 新增 3 个 API 端点: /parent/pending, /bindings/{id}/confirm, /bindings/{id}/reject
- 防止未授权绑定(任何人不验证即可绑定孩子的漏洞)

审计 ID: S-10
This commit is contained in:
iven
2026-06-03 10:03:50 +08:00
parent cca2d77ea2
commit c4b2de8294
3 changed files with 280 additions and 10 deletions

View File

@@ -1,6 +1,6 @@
// 家长中心 API 处理器 — PIPL 合规: 绑定/查阅/导出/删除
use axum::extract::{Extension, FromRef, Query, State};
use axum::extract::{Extension, FromRef, Path, Query, State};
use axum::response::Json;
use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema};
@@ -309,6 +309,124 @@ where
}))
}
/// 确认绑定请求的路径参数
#[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 = 200, 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<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(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 逻辑一致,