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:
@@ -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<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()
|
||||
.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<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 且未软删除的绑定。
|
||||
@@ -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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Reference in New Issue
Block a user