feat(auth,mp): 患者登录流程优化 — 智能合并 + 角色冻结 + 页面冻结

- 智能合并:微信注册时用手机号盲索引匹配已有患者档案,避免重复建
  档(AuthState 添加 PiiCrypto + ensure_patient_record 增加盲索引查询)
- 角色冻结:小程序仅允许患者角色登录,医护角色被拦截
  (auth_service.rs 添加反向拦截 + 登录页移除 credential login 表单)
- 页面冻结:10 个非核心页面替换为 FrozenPage 占位组件(用药/知情同意
  /透析/家属/诊断/事件),移除 profile 导航入口,移除医生端预加载
- 医生端代码保留,仅隐藏入口,后续可零成本恢复

讨论记录:docs/discussions/2026-05-23-account-registration-login-flow.md
This commit is contained in:
iven
2026-05-23 12:27:14 +08:00
parent f7d98a59f0
commit f11dd59382
21 changed files with 328 additions and 1510 deletions

View File

@@ -1,3 +1,4 @@
use erp_core::crypto::PiiCrypto;
use erp_core::events::EventBus;
use sea_orm::DatabaseConnection;
use uuid::Uuid;
@@ -25,6 +26,7 @@ pub struct AuthState {
pub wechat_secret: String,
pub wechat_dev_mode: bool,
pub redis: Option<redis::Client>,
pub crypto: PiiCrypto,
}
/// Parse a human-readable TTL string (e.g. "15m", "7d", "1h", "900s") into seconds.

View File

@@ -127,6 +127,12 @@ impl AuthService {
return Err(AuthError::Forbidden("患者账号请使用小程序登录".to_string()));
}
// 小程序端仅允许患者角色登录,医护角色请使用管理端
let has_patient_role = roles.iter().any(|r| r == "patient");
if is_miniprogram && !has_patient_role {
return Err(AuthError::Forbidden("医护账号请使用管理端登录".to_string()));
}
let permissions = TokenService::get_user_permissions(user_model.id, tenant_id, db).await?;
// 6. Sign tokens

View File

@@ -151,7 +151,8 @@ impl WechatService {
return Err(AuthError::Validation("该微信已绑定账号".to_string()));
}
let user_id = Self::find_or_create_user_by_phone(&state.db, tenant_id, &phone).await?;
let user_id =
Self::find_or_create_user_by_phone(&state.db, tenant_id, &phone, &state.crypto).await?;
let now = Utc::now();
let wu = wechat_user::ActiveModel {
@@ -189,6 +190,7 @@ impl WechatService {
db: &sea_orm::DatabaseConnection,
tenant_id: Uuid,
phone: &str,
crypto: &erp_core::crypto::PiiCrypto,
) -> AuthResult<Uuid> {
use crate::entity::user;
@@ -234,7 +236,7 @@ impl WechatService {
Self::assign_patient_role(db, tenant_id, user_id).await?;
// 自动创建或关联 patient 记录
Self::ensure_patient_record(db, tenant_id, user_id, phone).await?;
Self::ensure_patient_record(db, tenant_id, user_id, phone, crypto).await?;
Ok(user_id)
}
@@ -282,12 +284,14 @@ impl WechatService {
/// 自动创建或关联 patient 记录。
///
/// 1. 如果已有 user_id 关联的 patient → 跳过
/// 2. 否则 → 创建新的 patient 记录
/// 2. 如果手机号盲索引匹配到未绑定的已有患者 → 合并(关联 user_id
/// 3. 否则 → 创建新的 patient 记录
async fn ensure_patient_record(
db: &sea_orm::DatabaseConnection,
tenant_id: Uuid,
user_id: Uuid,
phone: &str,
crypto: &erp_core::crypto::PiiCrypto,
) -> AuthResult<()> {
use sea_orm::{ConnectionTrait, Statement};
@@ -306,6 +310,40 @@ impl WechatService {
return Ok(());
}
// 智能合并:用手机号盲索引查找未绑定的已有患者(管理员/护士建档)
let phone_hash = erp_core::crypto::hmac_hash(crypto.hmac_key(), phone);
let blind_match: Option<sea_orm::QueryResult> = db
.query_one(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
r#"SELECT bi.entity_id AS patient_id
FROM blind_index bi
JOIN patient p ON p.id = bi.entity_id AND p.tenant_id = $2 AND p.deleted_at IS NULL
WHERE bi.entity_type = 'patient'
AND bi.field_name = 'emergency_contact_phone'
AND bi.blind_hash = $1
AND bi.tenant_id = $2
AND p.user_id IS NULL
LIMIT 1"#,
[phone_hash.as_str().into(), tenant_id.into()],
))
.await
.map_err(|e| AuthError::DbError(e.to_string()))?;
if let Some(row) = blind_match {
let patient_id: Uuid = row
.try_get("", "patient_id")
.map_err(|e| AuthError::DbError(format!("blind_index parse: {}", e)))?;
db.execute(Statement::from_sql_and_values(
sea_orm::DatabaseBackend::Postgres,
"UPDATE patient SET user_id = $1, updated_at = NOW(), updated_by = $1 WHERE id = $2 AND user_id IS NULL",
[user_id.into(), patient_id.into()],
))
.await
.map_err(|e| AuthError::DbError(e.to_string()))?;
tracing::info!(%user_id, %patient_id, "手机号盲索引合并 patient");
return Ok(());
}
let suffix = &phone[phone.len().saturating_sub(4)..];
let patient_id = Uuid::now_v7();
let now = Utc::now();

View File

@@ -59,6 +59,7 @@ impl FromRef<AppState> for erp_auth::AuthState {
wechat_secret: state.config.wechat.secret.clone(),
wechat_dev_mode: state.config.wechat.dev_mode,
redis: Some(state.redis.clone()),
crypto: state.pii_crypto.clone(),
}
}
}