From 11101ac204c25701bd4de69cd697887988291c5f Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 10 May 2026 09:57:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E5=BE=AE=E4=BF=A1=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E8=87=AA=E5=8A=A8=E5=88=86=E9=85=8D=20patient=20?= =?UTF-8?q?=E8=A7=92=E8=89=B2=20+=20=E5=88=9B=E5=BB=BA=E6=82=A3=E8=80=85?= =?UTF-8?q?=E6=A1=A3=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增迁移 m20260510_000133:为所有租户创建 patient 角色并分配 19 个权限 - wechat_service: bind_phone 自动 assign_patient_role + ensure_patient_record - find_or_create_user_by_phone 新用户自动获得 patient 角色和患者档案 - 小程序 auth store: bindPhone 抛出异常而非静默返回 false - 小程序登录页: 捕获绑定错误并显示可操作的对话框 --- apps/miniprogram/src/pages/login/index.tsx | 23 +++- apps/miniprogram/src/stores/auth.ts | 7 +- crates/erp-auth/src/service/wechat_service.rs | 113 +++++++++++++++++- crates/erp-server/migration/src/lib.rs | 2 + .../m20260510_000133_create_patient_role.rs | 105 ++++++++++++++++ 5 files changed, 238 insertions(+), 12 deletions(-) create mode 100644 crates/erp-server/migration/src/m20260510_000133_create_patient_role.rs diff --git a/apps/miniprogram/src/pages/login/index.tsx b/apps/miniprogram/src/pages/login/index.tsx index 09f0984..ddce300 100644 --- a/apps/miniprogram/src/pages/login/index.tsx +++ b/apps/miniprogram/src/pages/login/index.tsx @@ -52,11 +52,24 @@ export default function Login() { return; } const { encryptedData, iv } = e.detail; - const success = await bindPhone(encryptedData, iv); - if (success) { - navigateAfterLogin(); - } else { - Taro.showToast({ title: '绑定失败,请重试', icon: 'none' }); + try { + const success = await bindPhone(encryptedData, iv); + if (success) { + navigateAfterLogin(); + } + } catch (err: any) { + const msg = err?.message || '绑定失败'; + Taro.showModal({ + title: '绑定手机号失败', + content: msg, + confirmText: '重新登录', + cancelText: '取消', + success: (res) => { + if (res.confirm) { + setNeedBind(false); + } + }, + }); } }; diff --git a/apps/miniprogram/src/stores/auth.ts b/apps/miniprogram/src/stores/auth.ts index 156bc66..7c7ce43 100644 --- a/apps/miniprogram/src/stores/auth.ts +++ b/apps/miniprogram/src/stores/auth.ts @@ -114,7 +114,7 @@ export const useAuthStore = create((set, get) => ({ const openid = secureGet('wechat_openid') || ''; if (!openid) { set({ loading: false }); - return false; + throw new Error('登录态丢失,请返回重试'); } const resp = await authApi.wechatBindPhone(openid, encryptedData, iv) as Record; const tokenData = resp as { access_token: string; refresh_token: string; user: AuthState['user'] }; @@ -129,9 +129,10 @@ export const useAuthStore = create((set, get) => ({ secureRemove('wechat_openid'); set({ user: tokenData.user, roles, loading: false }); return true; - } catch { + } catch (err: any) { + secureRemove('wechat_openid'); set({ loading: false }); - return false; + throw err; } }, diff --git a/crates/erp-auth/src/service/wechat_service.rs b/crates/erp-auth/src/service/wechat_service.rs index 18b123b..6453764 100644 --- a/crates/erp-auth/src/service/wechat_service.rs +++ b/crates/erp-auth/src/service/wechat_service.rs @@ -126,10 +126,18 @@ impl WechatService { encrypted_data: &str, iv: &str, ) -> AuthResult { - // 从 Redis 或内存获取 session_key - let session_key = Self::get_session_key(&state.redis, openid).await?; - - let phone = decrypt_phone_number(&session_key, encrypted_data, iv)?; + // Dev 模式:mock session_key 无法解密真实微信加密数据,直接使用 mock 手机号 + let phone = if state.wechat_dev_mode { + let hash = openid + .bytes() + .fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u32)); + let suffix = hash % 10000; + tracing::warn!(%openid, mock_phone = format!("1380000{suffix:04}"), "开发模式:跳过手机号解密,使用 mock 手机号"); + format!("1380000{suffix:04}") + } else { + let session_key = Self::get_session_key(&state.redis, openid).await?; + decrypt_phone_number(&session_key, encrypted_data, iv)? + }; let existing = wechat_user::Entity::find() .filter(wechat_user::Column::Openid.eq(openid)) @@ -222,9 +230,106 @@ impl WechatService { .await .map_err(|e| AuthError::DbError(e.to_string()))?; + // 自动分配 patient 角色 + Self::assign_patient_role(db, tenant_id, user_id).await?; + + // 自动创建或关联 patient 记录 + Self::ensure_patient_record(db, tenant_id, user_id, phone).await?; + Ok(user_id) } + async fn assign_patient_role( + db: &sea_orm::DatabaseConnection, + tenant_id: Uuid, + user_id: Uuid, + ) -> AuthResult<()> { + use crate::entity::role; + use crate::entity::user_role; + + let patient_role = role::Entity::find() + .filter(role::Column::Code.eq("patient")) + .filter(role::Column::TenantId.eq(tenant_id)) + .filter(role::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::DbError(e.to_string()))?; + + if let Some(r) = patient_role { + let now = Utc::now(); + let ur = user_role::ActiveModel { + user_id: Set(user_id), + role_id: Set(r.id), + tenant_id: Set(tenant_id), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(user_id), + updated_by: Set(user_id), + deleted_at: Set(None), + version: Set(1), + }; + ur.insert(db) + .await + .map_err(|e| AuthError::DbError(e.to_string()))?; + tracing::info!(%user_id, role_id = %r.id, "已为新用户分配 patient 角色"); + } else { + tracing::warn!(%tenant_id, "patient 角色不存在,跳过角色分配"); + } + + Ok(()) + } + + /// 自动创建或关联 patient 记录。 + /// + /// 1. 如果已有 user_id 关联的 patient → 跳过 + /// 2. 否则 → 创建新的 patient 记录 + async fn ensure_patient_record( + db: &sea_orm::DatabaseConnection, + tenant_id: Uuid, + user_id: Uuid, + phone: &str, + ) -> AuthResult<()> { + use sea_orm::{ConnectionTrait, Statement}; + + // 使用 raw SQL 避免跨 crate 依赖 erp-health 的 entity + let result: Option = db + .query_one(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + "SELECT id FROM patient WHERE user_id = $1 AND tenant_id = $2 AND deleted_at IS NULL LIMIT 1", + [user_id.into(), tenant_id.into()], + )) + .await + .map_err(|e| AuthError::DbError(e.to_string()))?; + + if result.is_some() { + tracing::debug!(%user_id, "patient 记录已存在,跳过创建"); + return Ok(()); + } + + let suffix = &phone[phone.len().saturating_sub(4)..]; + let patient_id = Uuid::now_v7(); + let now = Utc::now(); + + db.execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + r#"INSERT INTO patient (id, tenant_id, user_id, name, gender, status, verification_status, source, created_at, updated_at, created_by, updated_by, deleted_at, version) + VALUES ($1, $2, $3, $4, NULL, 'active', 'pending', 'wechat_miniprogram', $5, $5, $3, $3, NULL, 1) + ON CONFLICT DO NOTHING"#, + [ + patient_id.into(), + tenant_id.into(), + user_id.into(), + sanitize_string(&format!("微信用户{}", suffix)).into(), + now.into(), + ], + )) + .await + .map_err(|e| AuthError::DbError(e.to_string()))?; + + tracing::info!(%user_id, %patient_id, "已自动创建 patient 记录"); + Ok(()) + } + async fn store_session_key_redis( redis: &Option, openid: &str, diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs index 49423c6..4988c0c 100644 --- a/crates/erp-server/migration/src/lib.rs +++ b/crates/erp-server/migration/src/lib.rs @@ -134,6 +134,7 @@ mod m20260507_000129_fix_nurse_operator_points_permissions; mod m20260508_000130_fix_operator_permissions_and_nurse_devices; mod m20260508_000131_fix_all_role_permissions; mod m20260508_000132_fix_doctor_permissions_restore; +mod m20260510_000133_create_patient_role; pub struct Migrator; @@ -275,6 +276,7 @@ impl MigratorTrait for Migrator { Box::new(m20260508_000130_fix_operator_permissions_and_nurse_devices::Migration), Box::new(m20260508_000131_fix_all_role_permissions::Migration), Box::new(m20260508_000132_fix_doctor_permissions_restore::Migration), + Box::new(m20260510_000133_create_patient_role::Migration), ] } } diff --git a/crates/erp-server/migration/src/m20260510_000133_create_patient_role.rs b/crates/erp-server/migration/src/m20260510_000133_create_patient_role.rs new file mode 100644 index 0000000..1e361a5 --- /dev/null +++ b/crates/erp-server/migration/src/m20260510_000133_create_patient_role.rs @@ -0,0 +1,105 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + let sys = "00000000-0000-0000-0000-000000000000"; + + // Part 1: 为所有租户创建 patient 角色(幂等) + db.execute_unprepared(&format!( + "INSERT INTO roles (id, tenant_id, name, code, description, is_system, created_at, updated_at, created_by, updated_by, deleted_at, version) \ + SELECT gen_random_uuid(), t.id, '患者', 'patient', '小程序患者用户,可查看自身健康数据、积分、咨询等', false, NOW(), NOW(), '{sys}', '{sys}', NULL, 1 \ + FROM tenant t \ + WHERE NOT EXISTS (SELECT 1 FROM roles r WHERE r.tenant_id = t.id AND r.code = 'patient' AND r.deleted_at IS NULL)" + )).await?; + + // Part 2: 为 patient 角色分配小程序所需权限 + let patient_perms: &[&str] = &[ + // 健康数据 + "health.health-data.list", + // 患者自身 + "health.patient.list", + // 预约 + "health.appointment.list", + // 随访 + "health.follow-up.list", + // 咨询 + "health.consultation.list", + // 积分 + "health.points.list", + // 文章 + "health.articles.list", + // 告警(自身) + "health.alerts.list", + // 日常监测 + "health.daily-monitoring.list", + // 设备数据 + "health.device-readings.list", + // 设备绑定 + "health.devices.list", + // 知情同意 + "health.consent.list", + // 用药记录 + "health.medication-records.list", + // 药物提醒 + "health.medication-reminders.list", + // 护理计划 + "health.care-plan.list", + // AI 建议 + "ai.suggestion.list", + // 消息 + "message.list", + // 透析 + "health.dialysis.list", + ]; + + assign_perms_by_codes(db, "patient", patient_perms).await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // 删除 patient 角色的权限关联 + db.execute_unprepared( + "DELETE FROM role_permissions WHERE role_id IN (SELECT id FROM roles WHERE code = 'patient')" + ).await?; + + // 软删除 patient 角色 + db.execute_unprepared( + "UPDATE roles SET deleted_at = NOW() WHERE code = 'patient' AND deleted_at IS NULL", + ) + .await?; + + Ok(()) + } +} + +async fn assign_perms_by_codes( + db: &sea_orm_migration::prelude::SchemaManagerConnection<'_>, + role_code: &str, + perm_codes: &[&str], +) -> Result<(), DbErr> { + let codes_csv: String = perm_codes + .iter() + .map(|c| format!("'{}'", c)) + .collect::>() + .join(","); + + db.execute_unprepared(&format!( + "INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) \ + SELECT r.id, p.id, r.tenant_id, 'self', NOW(), NOW(), r.id, r.id, NULL, 1 \ + FROM roles r \ + JOIN permissions p ON p.tenant_id = r.tenant_id AND p.code IN ({codes_csv}) AND p.deleted_at IS NULL \ + WHERE r.code = '{role_code}' AND r.deleted_at IS NULL \ + ON CONFLICT (role_id, permission_id) WHERE deleted_at IS NULL \ + DO UPDATE SET deleted_at = NULL, version = role_permissions.version + 1, updated_at = NOW()" + )).await?; + + Ok(()) +}