diff --git a/crates/erp-diary/src/handler/parent_handler.rs b/crates/erp-diary/src/handler/parent_handler.rs index 951ae38..d07ddf2 100644 --- a/crates/erp-diary/src/handler/parent_handler.rs +++ b/crates/erp-diary/src/handler/parent_handler.rs @@ -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>), + (status = 401, description = "未授权"), + ), + security(("bearer_auth" = [])), + tag = "家长中心" +)] +/// GET /api/v1/diary/parent/pending +/// +/// 孩子查看自己的待确认绑定请求列表。 +pub async fn list_pending_bindings( + State(state): State, + Extension(ctx): Extension, +) -> Result>>, AppError> +where + DiaryState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let bindings = + ParentService::list_pending_for_child(ctx.tenant_id, ctx.user_id, &state.db).await?; + + let resp: Vec = 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), + (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( + State(state): State, + Extension(ctx): Extension, + Path(binding_id): Path, +) -> Result>, AppError> +where + DiaryState: FromRef, + 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( + State(state): State, + Extension(ctx): Extension, + Path(binding_id): Path, +) -> Result>, AppError> +where + DiaryState: FromRef, + 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 逻辑一致, diff --git a/crates/erp-diary/src/lib.rs b/crates/erp-diary/src/lib.rs index e56e034..9f9a776 100644 --- a/crates/erp-diary/src/lib.rs +++ b/crates/erp-diary/src/lib.rs @@ -255,5 +255,18 @@ impl DiaryModule { "/diary/parent/unbind", 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), + ) } } diff --git a/crates/erp-diary/src/service/parent_service.rs b/crates/erp-diary/src/service/parent_service.rs index 8b37941..5cd1a34 100644 --- a/crates/erp-diary/src/service/parent_service.rs +++ b/crates/erp-diary/src/service/parent_service.rs @@ -16,10 +16,10 @@ use erp_core::events::{DomainEvent, EventBus}; pub struct ParentService; impl ParentService { - /// 绑定孩子 — 家长通过孩子用户 ID 建立绑定关系 + /// 绑定孩子 — 家长发起绑定请求(需要孩子确认) /// - /// 检查是否已存在有效绑定,避免重复绑定。 - /// 插入后发布 `diary.parent.child_bound` 事件。 + /// 创建 pending 状态的绑定记录,孩子需调用 confirm_binding 确认后 + /// 才能获得 verified 状态。防止未授权绑定(审计 S-10)。 pub async fn bind_child( tenant_id: Uuid, parent_id: Uuid, @@ -27,30 +27,38 @@ impl ParentService { db: &DatabaseConnection, event_bus: &EventBus, ) -> DiaryResult { - // 检查是否已绑定 + // 验证孩子用户存在且角色为 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() .filter(parent_child_binding::Column::ParentId.eq(parent_id)) .filter(parent_child_binding::Column::ChildId.eq(child_id)) .filter(parent_child_binding::Column::TenantId.eq(tenant_id)) .filter(parent_child_binding::Column::Status.ne("revoked")) + .filter(parent_child_binding::Column::DeletedAt.is_null()) .one(db) .await?; if existing.is_some() { - return Err(DiaryError::BadRequest("已绑定该孩子".to_string())); + return Err(DiaryError::BadRequest("已存在绑定关系(待确认或已生效)".to_string())); } let now = Utc::now(); let id = Uuid::now_v7(); + // 创建 pending 状态 — 需要孩子确认后才变为 verified let model = parent_child_binding::ActiveModel { id: Set(id), tenant_id: Set(tenant_id), parent_id: Set(parent_id), child_id: Set(child_id), - verification_method: Set("manual".to_string()), - verified_at: Set(Some(now)), - status: Set("verified".to_string()), + verification_method: Set("child_confirm".to_string()), + verified_at: Set(None), // 确认后填入 + status: Set("pending".to_string()), created_at: Set(now), updated_at: Set(now), created_by: Set(parent_id), @@ -64,11 +72,12 @@ impl ParentService { event_bus .publish( DomainEvent::new( - "diary.parent.child_bound", + "diary.parent.binding_requested", tenant_id, serde_json::json!({ "parent_id": parent_id, "child_id": child_id, + "binding_id": id, }), ), db, @@ -78,6 +87,114 @@ impl ParentService { 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 { + 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> { + 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 且未软删除的绑定。 @@ -267,6 +384,28 @@ impl ParentService { } } +/// 验证目标用户存在且角色为 student +async fn validate_child_user( + tenant_id: Uuid, + child_id: Uuid, + db: &DatabaseConnection, +) -> DiaryResult { + 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)] mod tests { use super::*;