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 合规: 绑定/查阅/导出/删除 // 家长中心 API 处理器 — PIPL 合规: 绑定/查阅/导出/删除
use axum::extract::{Extension, FromRef, Query, State}; use axum::extract::{Extension, FromRef, Path, Query, State};
use axum::response::Json; use axum::response::Json;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::{IntoParams, ToSchema}; 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_entry::Model -> JournalResp DTO 转换
/// ///
/// 与 journal_service 中的 model_to_resp 逻辑一致, /// 与 journal_service 中的 model_to_resp 逻辑一致,

View File

@@ -255,5 +255,18 @@ impl DiaryModule {
"/diary/parent/unbind", "/diary/parent/unbind",
axum::routing::delete(parent_handler::unbind_child), axum::routing::delete(parent_handler::unbind_child),
) )
// 孩子确认/拒绝绑定
.route(
"/diary/parent/pending",
axum::routing::get(parent_handler::list_pending_bindings),
)
.route(
"/diary/parent/bindings/{binding_id}/confirm",
axum::routing::post(parent_handler::confirm_binding),
)
.route(
"/diary/parent/bindings/{binding_id}/reject",
axum::routing::post(parent_handler::reject_binding),
)
} }
} }

View File

@@ -16,10 +16,10 @@ use erp_core::events::{DomainEvent, EventBus};
pub struct ParentService; pub struct ParentService;
impl ParentService { impl ParentService {
/// 绑定孩子 — 家长通过孩子用户 ID 建立绑定关系 /// 绑定孩子 — 家长发起绑定请求(需要孩子确认)
/// ///
/// 检查是否已存在有效绑定,避免重复绑定。 /// 创建 pending 状态的绑定记录,孩子需调用 confirm_binding 确认后
/// 插入后发布 `diary.parent.child_bound` 事件 /// 才能获得 verified 状态。防止未授权绑定(审计 S-10
pub async fn bind_child( pub async fn bind_child(
tenant_id: Uuid, tenant_id: Uuid,
parent_id: Uuid, parent_id: Uuid,
@@ -27,30 +27,38 @@ impl ParentService {
db: &DatabaseConnection, db: &DatabaseConnection,
event_bus: &EventBus, event_bus: &EventBus,
) -> DiaryResult<parent_child_binding::Model> { ) -> DiaryResult<parent_child_binding::Model> {
// 检查是否已绑定 // 验证孩子用户存在且角色为 student
let child_exists = validate_child_user(tenant_id, child_id, db).await?;
if !child_exists {
return Err(DiaryError::BadRequest("目标用户不存在或不是学生角色".to_string()));
}
// 检查是否已存在有效绑定pending 或 verified 均视为已绑定)
let existing = parent_child_binding::Entity::find() let existing = parent_child_binding::Entity::find()
.filter(parent_child_binding::Column::ParentId.eq(parent_id)) .filter(parent_child_binding::Column::ParentId.eq(parent_id))
.filter(parent_child_binding::Column::ChildId.eq(child_id)) .filter(parent_child_binding::Column::ChildId.eq(child_id))
.filter(parent_child_binding::Column::TenantId.eq(tenant_id)) .filter(parent_child_binding::Column::TenantId.eq(tenant_id))
.filter(parent_child_binding::Column::Status.ne("revoked")) .filter(parent_child_binding::Column::Status.ne("revoked"))
.filter(parent_child_binding::Column::DeletedAt.is_null())
.one(db) .one(db)
.await?; .await?;
if existing.is_some() { if existing.is_some() {
return Err(DiaryError::BadRequest("绑定该孩子".to_string())); return Err(DiaryError::BadRequest("存在绑定关系(待确认或已生效)".to_string()));
} }
let now = Utc::now(); let now = Utc::now();
let id = Uuid::now_v7(); let id = Uuid::now_v7();
// 创建 pending 状态 — 需要孩子确认后才变为 verified
let model = parent_child_binding::ActiveModel { let model = parent_child_binding::ActiveModel {
id: Set(id), id: Set(id),
tenant_id: Set(tenant_id), tenant_id: Set(tenant_id),
parent_id: Set(parent_id), parent_id: Set(parent_id),
child_id: Set(child_id), child_id: Set(child_id),
verification_method: Set("manual".to_string()), verification_method: Set("child_confirm".to_string()),
verified_at: Set(Some(now)), verified_at: Set(None), // 确认后填入
status: Set("verified".to_string()), status: Set("pending".to_string()),
created_at: Set(now), created_at: Set(now),
updated_at: Set(now), updated_at: Set(now),
created_by: Set(parent_id), created_by: Set(parent_id),
@@ -64,11 +72,12 @@ impl ParentService {
event_bus event_bus
.publish( .publish(
DomainEvent::new( DomainEvent::new(
"diary.parent.child_bound", "diary.parent.binding_requested",
tenant_id, tenant_id,
serde_json::json!({ serde_json::json!({
"parent_id": parent_id, "parent_id": parent_id,
"child_id": child_id, "child_id": child_id,
"binding_id": id,
}), }),
), ),
db, db,
@@ -78,6 +87,114 @@ impl ParentService {
Ok(inserted) Ok(inserted)
} }
/// 孩子确认绑定 — 将 pending 状态变为 verified
///
/// 只有绑定中的 child_id 本人才能确认。确认后家长获得
/// 查看孩子日记/导出数据/删除数据的权限。
pub async fn confirm_binding(
tenant_id: Uuid,
child_id: Uuid,
binding_id: Uuid,
db: &DatabaseConnection,
event_bus: &EventBus,
) -> DiaryResult<parent_child_binding::Model> {
let binding = parent_child_binding::Entity::find()
.filter(parent_child_binding::Column::Id.eq(binding_id))
.filter(parent_child_binding::Column::TenantId.eq(tenant_id))
.filter(parent_child_binding::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| DiaryError::NotFound("绑定请求不存在".to_string()))?;
// 只有绑定目标孩子能确认
if binding.child_id != child_id {
return Err(DiaryError::Forbidden);
}
if binding.status != "pending" {
return Err(DiaryError::BadRequest("绑定请求不在待确认状态".to_string()));
}
let now = Utc::now();
let current_version = binding.version;
let mut active: parent_child_binding::ActiveModel = binding.into();
active.status = Set("verified".to_string());
active.verified_at = Set(Some(now));
active.updated_at = Set(now);
active.updated_by = Set(child_id);
active.version = Set(current_version + 1);
let updated = active.update(db).await?;
event_bus
.publish(
DomainEvent::new(
"diary.parent.binding_confirmed",
tenant_id,
serde_json::json!({
"parent_id": updated.parent_id,
"child_id": child_id,
"binding_id": binding_id,
}),
),
db,
)
.await;
Ok(updated)
}
/// 孩子拒绝绑定 — 将 pending 状态变为 revoked
pub async fn reject_binding(
tenant_id: Uuid,
child_id: Uuid,
binding_id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<()> {
let binding = parent_child_binding::Entity::find()
.filter(parent_child_binding::Column::Id.eq(binding_id))
.filter(parent_child_binding::Column::TenantId.eq(tenant_id))
.filter(parent_child_binding::Column::DeletedAt.is_null())
.one(db)
.await?
.ok_or_else(|| DiaryError::NotFound("绑定请求不存在".to_string()))?;
if binding.child_id != child_id {
return Err(DiaryError::Forbidden);
}
if binding.status != "pending" {
return Err(DiaryError::BadRequest("绑定请求不在待确认状态".to_string()));
}
let now = Utc::now();
let current_version = binding.version;
let mut active: parent_child_binding::ActiveModel = binding.into();
active.status = Set("revoked".to_string());
active.updated_at = Set(now);
active.updated_by = Set(child_id);
active.version = Set(current_version + 1);
active.update(db).await?;
Ok(())
}
/// 获取孩子的待确认绑定列表
pub async fn list_pending_for_child(
tenant_id: Uuid,
child_id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<Vec<parent_child_binding::Model>> {
let bindings = parent_child_binding::Entity::find()
.filter(parent_child_binding::Column::TenantId.eq(tenant_id))
.filter(parent_child_binding::Column::ChildId.eq(child_id))
.filter(parent_child_binding::Column::Status.eq("pending"))
.filter(parent_child_binding::Column::DeletedAt.is_null())
.all(db)
.await?;
Ok(bindings)
}
/// 获取家长绑定的孩子列表 /// 获取家长绑定的孩子列表
/// ///
/// 只返回 status=verified 且未软删除的绑定。 /// 只返回 status=verified 且未软删除的绑定。
@@ -267,6 +384,28 @@ impl ParentService {
} }
} }
/// 验证目标用户存在且角色为 student
async fn validate_child_user(
tenant_id: Uuid,
child_id: Uuid,
db: &DatabaseConnection,
) -> DiaryResult<bool> {
use erp_auth::entity::{role, user_role};
// 检查用户存在且有 student 角色
let count = user_role::Entity::find()
.filter(user_role::Column::UserId.eq(child_id))
.filter(user_role::Column::TenantId.eq(tenant_id))
.filter(user_role::Column::DeletedAt.is_null())
.inner_join(role::Entity)
.filter(role::Column::Code.eq("student"))
.filter(role::Column::DeletedAt.is_null())
.count(db)
.await?;
Ok(count > 0)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;