From 19c08a76182c2827616dbcdcd39eb9c2de5d7fa1 Mon Sep 17 00:00:00 2001 From: EL <1175065040@qq.com> Date: Fri, 20 Mar 2026 14:05:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=A4=9A=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E9=80=89=E6=8B=A9=E7=99=BB=E5=BD=95=E4=B8=8E=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=20B/C=20=E7=AB=AF=E6=89=8B=E6=9C=BA?= =?UTF-8?q?=E5=8F=B7=E8=AE=A4=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + docs/auth.md | 77 ++++-- docs/frontend-api-integration.md | 109 +++----- docs/patients.md | 145 ++-------- .../migration.sql | 24 ++ prisma/schema.prisma | 13 + prisma/seed.mjs | 35 +++ src/auth/auth.controller.ts | 33 ++- src/auth/auth.module.ts | 6 +- src/auth/auth.service.ts | 39 ++- src/auth/current-family-actor.decorator.ts | 12 + .../dto/miniapp-phone-login-confirm.dto.ts | 24 ++ src/auth/dto/miniapp-phone-login.dto.ts | 21 ++ src/auth/dto/password-login-confirm.dto.ts | 24 ++ src/auth/family-access/family-access.guard.ts | 82 ++++++ src/auth/miniapp-auth/miniapp-auth.service.ts | 251 ++++++++++++++++++ .../wechat-miniapp/wechat-miniapp.service.ts | 176 ++++++++++++ src/common/family-actor-context.ts | 6 + src/common/messages.ts | 17 +- .../c-patients/c-patients.controller.ts | 31 +-- src/patients/c-patients/c-patients.service.ts | 26 +- .../dto/family-lifecycle-query.dto.ts | 19 -- src/patients/patients.module.ts | 9 +- src/users/dto/login.dto.ts | 13 +- src/users/users.service.ts | 224 ++++++++++++++-- test/e2e/fixtures/e2e-roles.ts | 7 + test/e2e/helpers/e2e-app.helper.ts | 31 ++- test/e2e/helpers/e2e-auth.helper.ts | 19 +- test/e2e/helpers/e2e-fixtures.helper.ts | 100 ++++++- test/e2e/helpers/e2e-miniapp-auth.helper.ts | 77 ++++++ test/e2e/specs/auth.e2e-spec.ts | 152 ++++++++++- test/e2e/specs/patients.e2e-spec.ts | 51 ++-- test/e2e/specs/tasks.e2e-spec.ts | 30 +-- tyt-admin/components.d.ts | 2 + tyt-admin/src/store/user.js | 48 +++- tyt-admin/src/views/Login.vue | 156 ++++++++--- 36 files changed, 1660 insertions(+), 431 deletions(-) create mode 100644 prisma/migrations/20260320051750_miniapp_phone_auth/migration.sql create mode 100644 src/auth/current-family-actor.decorator.ts create mode 100644 src/auth/dto/miniapp-phone-login-confirm.dto.ts create mode 100644 src/auth/dto/miniapp-phone-login.dto.ts create mode 100644 src/auth/dto/password-login-confirm.dto.ts create mode 100644 src/auth/family-access/family-access.guard.ts create mode 100644 src/auth/miniapp-auth/miniapp-auth.service.ts create mode 100644 src/auth/wechat-miniapp/wechat-miniapp.service.ts create mode 100644 src/common/family-actor-context.ts delete mode 100644 src/patients/dto/family-lifecycle-query.dto.ts create mode 100644 test/e2e/helpers/e2e-miniapp-auth.helper.ts diff --git a/.env.example b/.env.example index c5bea09..957fb9c 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,5 @@ DATABASE_URL="postgresql://postgres:lyh1234@192.168.0.180:5432/tyt-api-nest" AUTH_TOKEN_SECRET="replace-with-a-strong-random-secret" SYSTEM_ADMIN_BOOTSTRAP_KEY="replace-with-admin-bootstrap-key" +WECHAT_MINIAPP_APPID="replace-with-miniapp-appid" +WECHAT_MINIAPP_SECRET="replace-with-miniapp-secret" diff --git a/docs/auth.md b/docs/auth.md index 819cf3f..21099a7 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -2,39 +2,72 @@ ## 1. 目标 -- 提供系统管理员创建、登录、`/me` 身份查询。 -- 使用 JWT 做认证,Guard 做鉴权,RolesGuard 做 RBAC。 +- 提供系统管理员创建、院内账号密码登录、B 端小程序手机号登录、C 端小程序手机号登录、`/me` 身份查询。 +- 使用 JWT 做认证,院内账号与家属小程序账号走两套守卫。 ## 2. 核心接口 - `POST /auth/system-admin`:创建系统管理员(需引导密钥) -- `POST /auth/login`:手机号 + 角色 + 密码登录(支持同手机号多院场景) -- `GET /auth/me`:返回当前登录用户上下文 +- `POST /auth/login`:院内账号密码登录,后台与小程序均可复用 +- `POST /auth/login/confirm`:院内账号密码多账号确认登录 +- `POST /auth/miniapp/b/phone-login`:B 端小程序手机号登录 +- `POST /auth/miniapp/b/phone-login/confirm`:B 端同手机号多账号确认登录 +- `POST /auth/miniapp/c/phone-login`:C 端小程序手机号登录 +- `GET /auth/me`:返回当前院内登录用户上下文 -## 3. 鉴权流程 +## 3. 院内账号密码登录流程 + +1. 前端提交 `phone + password`,`role` 与 `hospitalId` 都可以选传。 +2. 若仅匹配到 1 个院内账号,后端直接返回 JWT。 +3. 若匹配到多个院内账号,后端返回 `loginTicket + accounts` 候选列表。 +4. 前端带 `loginTicket + userId` 调用确认接口获取最终 JWT。 +## 4. 微信小程序登录流程 + +### B 端 + +1. 前端调用 `wx.login` 获取 `loginCode`。 +2. 前端调用手机号授权获取 `phoneCode`。 +3. 调用 `POST /auth/miniapp/b/phone-login`。 +4. 若手机号仅命中 1 个院内账号,后端直接返回 JWT。 +5. 若命中多个院内账号,后端返回 `loginTicket + accounts`。 +6. 前端带 `loginTicket + userId` 调用确认接口拿最终 JWT。 + +### C 端 + +1. 前端同样传 `loginCode + phoneCode`。 +2. 后端先校验该手机号是否已存在于 `Patient.phone`。 +3. 校验通过后创建或绑定 `FamilyMiniAppAccount`,并返回 C 端 JWT。 + +## 5. 鉴权流程 + +### 院内账号 1. `AccessTokenGuard` 从 `Authorization` 读取 Bearer Token。 2. 校验 JWT 签名、`id`、`iat` 等关键载荷字段。 -3. 根据 `id` 回库读取用户当前角色与组织归属,不再直接信任 token 里的角色和范围。 -4. 校验 `iat >= user.tokenValidAfter`,若用户被重置密码、seed 重刷或账号被清理,则旧 token 立即失效。 -5. 当前数据库用户映射为 `ActorContext` 注入 `request.actor`。 -6. `RolesGuard` 根据 `@Roles(...)` 判断角色是否允许访问。 +3. 根据 `id` 回库读取 `User` 当前角色与组织归属。 +4. 校验 `iat >= user.tokenValidAfter`,保证旧 token 失效。 -## 4. Token 约定 +### 家属小程序账号 -- Header:`Authorization: Bearer ` -- 载荷关键字段:`id`、`iat` -- 角色和组织范围以数据库当前用户记录为准,不以 token 历史载荷为准 +1. `FamilyAccessTokenGuard` 从 `Authorization` 读取 Bearer Token。 +2. 校验 C 端 token 的 `id + type=FAMILY_MINIAPP`。 +3. 根据 `id` 回库读取 `FamilyMiniAppAccount`。 +4. 将家属账号上下文注入 `request.familyActor`。 -## 5. 错误码与中文消息 +## 6. 环境变量 -- 未登录/Token 失效:`401` + 中文 `msg` -- 角色无权限:`403` + 中文 `msg` -- 参数非法:`400` + 中文 `msg` +- `AUTH_TOKEN_SECRET` +- `SYSTEM_ADMIN_BOOTSTRAP_KEY` +- `WECHAT_MINIAPP_APPID` +- `WECHAT_MINIAPP_SECRET` -统一由全局异常过滤器输出:`{ code, msg, data: null }`。 +## 7. 关键规则 -## 6. 失效策略 - -- 用户密码被修改后,会刷新 `user.tokenValidAfter`,旧 token 全部失效。 -- 执行 E2E 重置并重新 seed 后,seed 账号的 `tokenValidAfter` 也会刷新,历史 token 不可继续复用。 +- 后台 Web 与 B 端小程序都可以复用 `POST /auth/login` 做账号密码登录。 +- 密码登录命中多个院内账号时,不再强制前端手填 `hospitalId`,改为后端返回候选账号供选择。 +- B 端小程序登录复用 `User` 表,继续使用 `openId`。 +- B 端账号未绑定 `openId` 时,首次小程序登录自动绑定。 +- 同一个 `openId` 不能绑定多个院内账号。 +- C 端家属账号独立存放在 `FamilyMiniAppAccount`。 +- C 端手机号必须先存在于患者档案,否则拒绝登录。 +- `serviceUid` 仅预留字段,本次不提供绑定接口。 diff --git a/docs/frontend-api-integration.md b/docs/frontend-api-integration.md index 1db710c..761357d 100644 --- a/docs/frontend-api-integration.md +++ b/docs/frontend-api-integration.md @@ -1,86 +1,45 @@ -# 前端接口接入说明(`tyt-admin`) +# 前后端联调说明 -## 1. 本次接入范围 +## 1. 登录 -- 登录页:`/auth/login`,支持可选 `hospitalId`。 -- 首页看板:按角色拉取组织与患者统计。 -- 设备页:新增管理员专用设备 CRUD,复用真实设备接口。 -- 任务页:接入真实任务列表、工程师接收与完成接口。 -- 用户页:修复用户列表响应结构、组织字段联动、工程师分配医院参数。 -- 患者页:接入真实患者字段与生命周期查询参数(`phone + idCard`), - 后端直接保存身份证号原文,不再做哈希转换;调压任务入口迁到患者页。 -- 新增影像库页:接入真实上传接口,支持图片/视频/文件上传与分页查看。 +### B 端账号密码登录 -## 2. 接口契约对齐点 +- `POST /auth/login` +- 入参: + - `phone` + - `password` + - `role`(可选) + - `hospitalId`(可选) +- 若返回 `needSelect: true`,继续调用: + - `POST /auth/login/confirm` + - 入参:`loginTicket + userId` -- `GET /users` 当前返回数组,前端已在 `api/users.js` 做本地分页与筛选适配。 -- `PATCH /b/users/:id/assign-engineer-hospital` 参数为单个 `hospitalId`,非数组。 -- `GET /b/patients` 返回数组,前端已改为本地分页与筛选。 -- `GET /b/devices` 已支持服务端分页与筛选,前端直接透传 `page/pageSize`。 -- `GET /b/tasks` 返回 `{ list, total, page, pageSize }`,供任务页只读展示调压记录。 -- `POST /b/uploads` 使用 `multipart/form-data` 上传文件,返回上传资产元数据。 -- `GET /b/uploads` 返回 `{ list, total, page, pageSize }`,仅供系统管理员/医院管理员影像库分页展示。 -- `GET /b/tasks/engineers` 返回当前角色可见的医院工程师列表。 -- `GET /c/patients/lifecycle` 必须同时传 `phone` 和 `idCard`。 -- 患者表单中的 `idCard` 字段直接传身份证号; - 服务端只会做去空格与 `x/X` 标准化,不会转哈希。 -- 患者手术、调压任务、设备目录中的压力值全部按字符串挡位标签传输,例如 `0.5`、`1`、`1.5`、`10`。 +### B 端小程序 -## 3. 角色权限提示 +- 第一步:`POST /auth/miniapp/b/phone-login` +- 入参: + - `loginCode` + - `phoneCode` +- 若返回 `needSelect: true`,继续调用: + - `POST /auth/miniapp/b/phone-login/confirm` + - 入参:`loginTicket + userId` -- 任务接口权限: - - `SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER`:发布时不再指定接收工程师;可取消自己创建的任务 - - `ENGINEER`:可接收本院待接收任务;仅可完成自己已接收的任务 -- 患者列表权限: - - `SYSTEM_ADMIN` 查询时必须传 `hospitalId` -- 用户管理接口: - - `SYSTEM_ADMIN/HOSPITAL_ADMIN` 可创建、编辑、删除 - - `DIRECTOR` 可只读查看本科室下级医生/组长 - - 工程师绑定医院仅 `SYSTEM_ADMIN` - - 删除:`SYSTEM_ADMIN` 可删除任意无关联用户;`HOSPITAL_ADMIN` 可删除本院无关联、且非管理员用户 +### C 端小程序 -## 3.1 结构图页面交互调整 +- `POST /auth/miniapp/c/phone-login` +- 入参: + - `loginCode` + - `phoneCode` -- 医院管理员视角下,右侧下级列表会优先显示“人员”节点,再显示组织节点。 -- 选中人员节点时,右侧展示人员详情(角色、手机号、所属医院/科室/小组),不再显示空白占位。 +## 2. C 端生命周期 -## 3.2 后台页面路由权限(与后端 RBAC 对齐) +- 登录成功后调用:`GET /c/patients/my-lifecycle` +- 不再需要传 `phone` 或 `idCard` +- Bearer Token 使用 C 端家属登录返回的 `accessToken` -- `organization/tree`、`organization/departments`、`organization/groups`、`users` - - `organization/tree`、`organization/groups`: - `SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER` 可访问 - - `organization/departments`: - 仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可访问 -- `users`:`SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可管理;`DIRECTOR` 可只读查看 -- `devices`:仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可访问 -- `uploads`:仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可访问 -- `organization/hospitals` - - 仅 `SYSTEM_ADMIN` 可访问 -- `tasks` - - `SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DOCTOR`、`DIRECTOR`、`LEADER`、`ENGINEER` 可访问 -- `patients` - - `SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER`、`DOCTOR` 可访问 +## 3. B 端说明 -患者页负责发起调压任务,任务页负责查看、接收与完成调压任务。 - -患者手术表单中的主刀医生不再单独选择,直接跟随患者归属医生展示和保存。 - -前端已在路由守卫和侧边栏菜单同时做权限控制,无权限角色会被拦截并跳转到首页,避免进入页面后触发接口 `403`。 - -## 3.3 主任/组长组织管理范围 - -- `DIRECTOR` - - 仅保留业务使用和患者管理能力,不再承担科室/小组/医生管理 -- `LEADER` - - 仅保留业务使用和患者管理能力,不再承担科室/小组/医生管理 -- 主任/组长不再显示“科室管理”“小组管理”“用户管理”页面。 -- 负责人设置(设主任/设组长)入口仍仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 显示。 - -## 4. 本地运行 - -在 `tyt-admin` 目录执行: - -```bash -pnpm install -pnpm dev -``` +- B 端业务接口仍使用 Bearer Token +- 后台管理端与小程序都可以复用 `POST /auth/login` 做账号密码登录 +- `GET /auth/me` 仍可读取当前院内账号信息 +- 同手机号多账号时,前端必须先让用户选定账号,再提交确认登录 diff --git a/docs/patients.md b/docs/patients.md index 17e8f38..d637a18 100644 --- a/docs/patients.md +++ b/docs/patients.md @@ -2,134 +2,33 @@ ## 1. 目标 -- B 端:按组织与角色范围查询患者,并维护患者基础档案。 -- B 端:支持患者首术录入、二次手术追加、旧设备弃用标记。 -- 数据关系升级为 `Patient -> PatientSurgery -> Device -> TaskItem -> Task`。 -- C 端:按 `phone + idCard` 做跨院生命周期聚合,返回手术事件与调压事件。 +- B 端:维护患者、手术、植入设备及生命周期数据。 +- C 端:家属小程序登录后,按已绑定手机号聚合查询跨院患者生命周期。 -## 2. 患者基础档案 +## 2. B 端能力 -患者表新增以下字段: +- 患者列表、详情、创建、更新、删除 +- 手术记录新增 +- 植入设备录入与历史保留 -- `inpatientNo`:住院号 -- `projectName`:项目名称 -- `phone`:联系电话 -- `idCard`:身份证号原文 -- `doctorId`:患者归属人员(医生/主任/组长) +## 3. C 端能力 -说明: +- 家属账号通过小程序手机号登录 +- `GET /c/patients/my-lifecycle` +- 查询口径:按 `FamilyMiniAppAccount.phone` 聚合 `Patient.phone` +- 返回内容:手术事件 + 调压事件的时间线 -- `name` 仍然保留为患者姓名必填字段。 -- `doctorId + hospitalId` 仍然是患者的组织归属来源,不直接绑定小组/科室。 -- 手术表单中的 `原发病 / 脑积水类型 / 分流方式 / 近端穿刺区域 / 阀门植入部位 / 远端分流方向` 改为读取系统字典,不再前端硬编码。 +## 4. 当前规则 -## 3. 手术档案 +- 同一个手机号可关联多个患者,C 端会统一聚合返回。 +- C 端手机号来源于患者手术/档案中维护的联系电话。 +- 仅已登录的家属小程序账号可访问 `my-lifecycle`。 +- 家属账号不存在或 token 无效时返回 `401`。 +- 手机号下无患者档案时,登录阶段直接拦截,不进入生命周期查询。 -新增 `PatientSurgery` 表,每次手术保存: +## 5. 典型接口 -- `surgeryDate`:手术日期 -- `surgeryName`:手术名称 -- `surgeonId`:主刀医生账号 ID(自动等于患者归属医生) -- `surgeonName`:主刀医生姓名快照 -- `preOpPressure`:术前测压,可为空 -- `primaryDisease`:原发病 -- `hydrocephalusTypes`:脑积水类型,多选 -- `previousShuntSurgeryDate`:上次分流手术时间,可为空 -- `preOpMaterials`:术前 CT 影像/资料,保存为附件元数据数组 -- `notes`:手术备注,可为空 - -返回时会自动补充: - -- `shuntSurgeryCount`:当前这台手术是该患者第几次分流手术 -- `activeDeviceCount`:本次手术仍在用设备数 -- `abandonedDeviceCount`:本次手术已弃用设备数 - -说明: - -- 新增/修改手术时,前端不再单独提交主刀医生。 -- 后端会直接使用患者当前的 `doctorId` 作为 `surgeonId`,并保存当时的 `surgeonName` 快照。 -- 如果后续患者归属医生发生变化,只会影响后续新建手术;历史手术仍保留创建当时的主刀医生快照。 - -## 4. 植入设备 - -设备表仍沿用 `Device`,但语义改为“患者手术下植入的设备实例”。 - -新增/使用字段: - -- `implantCatalogId`:植入物型号字典 ID -- `implantModel` / `implantManufacturer` / `implantName`:型号快照 -- `isValve`:是否为阀门 -- `isPressureAdjustable`:是否可调压 -- `isAbandoned`:是否已弃用 -- `shuntMode`:分流方式 -- `proximalPunctureAreas`:近端穿刺区域,最多 2 个 -- `valvePlacementSites`:阀门植入部位,最多 2 个 -- `distalShuntDirection`:远端分流方向 -- `initialPressure`:初始压力挡位标签,可为空 -- `implantNotes`:植入物备注,可为空 -- `labelImageUrl`:植入物标签图片地址,可为空 - -说明: - -- 患者手术里选择的是“全局植入物目录”,不是按医院单独维护的设备库。 -- 同一个植入物目录可被多个患者手术重复绑定,患者侧保存的是目录快照。 -- 旧设备弃用后,`TaskItem` 历史不会删除。 -- 任务发布只允许选择 `ACTIVE + isPressureAdjustable=true + isAbandoned=false` 的设备。 -- 同一患者一次手术可录入多个设备,因此支持“两个可调压仪器同时佩戴”。 -- 管子/附件类型不会显示“阀门植入部位”和“初始压力”录入项。 -- 如果植入物目录配置了挡位,`initialPressure` 必须取挡位中的字符串标签值,例如 `0.5 / 1 / 1.5 / 10 / 20 / 30`。 -- 挡位标签保存前会自动标准化,例如 `01.0 -> 1`、`1.50 -> 1.5`。 -- 手术创建时不允许手工录入 `currentPressure`,设备当前压力默认继承 `initialPressure`;发布调压任务时不会立刻改当前压力,只有工程师完成任务后才会更新。 -- 术前资料与植入物标签现在支持直接上传;上传成功后,患者详情会按图片/视频做预览,不再只是裸链接。 - -## 5. B 端可见性 - -- `DOCTOR`:仅可查自己名下患者 -- `LEADER`:可查本组医生名下患者 -- `DIRECTOR`:可查本科室医生名下患者 -- `HOSPITAL_ADMIN`:可查本院全部患者 -- `SYSTEM_ADMIN`:需显式传入目标 `hospitalId` - -## 6. B 端接口 - -- `GET /b/patients`:按角色查询可见患者列表 -- `GET /b/patients/doctors`:查询当前角色可见的归属人员候选 -- `POST /b/patients`:创建患者,可选带 `initialSurgery` -- `POST /b/patients/:id/surgeries`:为患者新增手术 -- `GET /b/patients/:id`:查询患者详情 -- `PATCH /b/patients/:id`:更新患者基础信息 -- `DELETE /b/patients/:id`:删除患者 - -约束: - -- `PATCH /b/patients/:id` 不直接修改手术,手术必须走新增手术接口。 -- 新增二次手术时可传 `abandonedDeviceIds`,后端会将这些旧设备标记为 `INACTIVE + isAbandoned=true`。 -- 患者建档时会自动记录 `creator`,列表和详情都会返回创建人信息。 - -## 7. C 端生命周期聚合 - -接口:`GET /c/patients/lifecycle?phone=...&idCard=...` - -查询策略: - -1. 不做医院隔离(跨租户) -2. 先将 `idCard` 做轻量标准化,再做双字段精确匹配 -3. 聚合 `Patient -> PatientSurgery -> Device` 的手术事件 -4. 聚合 `Patient -> Device -> TaskItem -> Task` 的调压事件 -5. 全部事件按 `occurredAt DESC` 返回 - -事件类型: - -- `SURGERY` -- `TASK_PRESSURE_ADJUSTMENT` - -说明: - -- 生命周期中的 `initialPressure / currentPressure / oldPressure / targetPressure` 均返回字符串挡位标签。 - -## 8. 响应结构 - -全部接口统一返回: - -- 成功:`{ code: 0, msg: "成功", data: ... }` -- 失败:`{ code: x, msg: "中文错误", data: null }` +- `GET /b/patients` +- `POST /b/patients` +- `POST /b/patients/:id/surgeries` +- `GET /c/patients/my-lifecycle` diff --git a/prisma/migrations/20260320051750_miniapp_phone_auth/migration.sql b/prisma/migrations/20260320051750_miniapp_phone_auth/migration.sql new file mode 100644 index 0000000..1ac5ace --- /dev/null +++ b/prisma/migrations/20260320051750_miniapp_phone_auth/migration.sql @@ -0,0 +1,24 @@ +-- CreateTable +CREATE TABLE "FamilyMiniAppAccount" ( + "id" SERIAL NOT NULL, + "phone" TEXT NOT NULL, + "openId" TEXT, + "serviceUid" TEXT, + "lastLoginAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FamilyMiniAppAccount_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "FamilyMiniAppAccount_phone_key" ON "FamilyMiniAppAccount"("phone"); + +-- CreateIndex +CREATE UNIQUE INDEX "FamilyMiniAppAccount_openId_key" ON "FamilyMiniAppAccount"("openId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FamilyMiniAppAccount_serviceUid_key" ON "FamilyMiniAppAccount"("serviceUid"); + +-- CreateIndex +CREATE INDEX "FamilyMiniAppAccount_lastLoginAt_idx" ON "FamilyMiniAppAccount"("lastLoginAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 36d92e8..e47c01d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -119,6 +119,19 @@ model User { @@index([groupId, role]) } +// 家属小程序账号:按手机号承载 C 端登录身份,并预留服务号绑定字段。 +model FamilyMiniAppAccount { + id Int @id @default(autoincrement()) + phone String @unique + openId String? @unique + serviceUid String? @unique + lastLoginAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@index([lastLoginAt]) +} + // 患者表:院内患者档案,按医院隔离。 model Patient { id Int @id @default(autoincrement()) diff --git a/prisma/seed.mjs b/prisma/seed.mjs index fc5eafe..f280600 100644 --- a/prisma/seed.mjs +++ b/prisma/seed.mjs @@ -109,6 +109,36 @@ async function ensurePatient({ }); } +async function ensureFamilyMiniAppAccount({ + phone, + openId = null, + serviceUid = null, +}) { + const existing = await prisma.familyMiniAppAccount.findUnique({ + where: { phone }, + }); + + if (existing) { + return prisma.familyMiniAppAccount.update({ + where: { id: existing.id }, + data: { + openId, + serviceUid, + lastLoginAt: new Date(), + }, + }); + } + + return prisma.familyMiniAppAccount.create({ + data: { + phone, + openId, + serviceUid, + lastLoginAt: new Date(), + }, + }); +} + async function ensureImplantCatalog({ modelCode, manufacturer, @@ -496,6 +526,11 @@ async function main() { idCard: '110101199001010011', }); + await ensureFamilyMiniAppAccount({ + phone: patientA2.phone, + openId: 'seed-family-a2-openid', + }); + const adjustableCatalog = await ensureImplantCatalog({ modelCode: 'SEED-ADJUSTABLE-VALVE', manufacturer: 'Seed MedTech', diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 0bc18e6..74a4c8e 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,11 +1,14 @@ import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { AuthService } from './auth.service.js'; -import { LoginDto } from '../users/dto/login.dto.js'; import { AccessTokenGuard } from './access-token.guard.js'; import { CurrentActor } from './current-actor.decorator.js'; import type { ActorContext } from '../common/actor-context.js'; import { CreateSystemAdminDto } from './dto/create-system-admin.dto.js'; +import { MiniappPhoneLoginDto } from './dto/miniapp-phone-login.dto.js'; +import { MiniappPhoneLoginConfirmDto } from './dto/miniapp-phone-login-confirm.dto.js'; +import { PasswordLoginConfirmDto } from './dto/password-login-confirm.dto.js'; +import { LoginDto } from '../users/dto/login.dto.js'; /** * 认证控制器:提供系统管理员创建、登录、获取当前登录用户信息接口。 @@ -25,14 +28,38 @@ export class AuthController { } /** - * 登录并换取 JWT。 + * 院内账号密码登录:后台与小程序均可复用。 */ @Post('login') - @ApiOperation({ summary: '登录' }) + @ApiOperation({ summary: '院内账号密码登录' }) login(@Body() dto: LoginDto) { return this.authService.login(dto); } + @Post('login/confirm') + @ApiOperation({ summary: '院内账号密码多账号确认登录' }) + confirmLogin(@Body() dto: PasswordLoginConfirmDto) { + return this.authService.confirmLogin(dto); + } + + @Post('miniapp/b/phone-login') + @ApiOperation({ summary: 'B 端小程序手机号登录' }) + miniAppBLogin(@Body() dto: MiniappPhoneLoginDto) { + return this.authService.miniAppBLogin(dto); + } + + @Post('miniapp/b/phone-login/confirm') + @ApiOperation({ summary: 'B 端小程序多账号确认登录' }) + miniAppBConfirmLogin(@Body() dto: MiniappPhoneLoginConfirmDto) { + return this.authService.miniAppBConfirmLogin(dto); + } + + @Post('miniapp/c/phone-login') + @ApiOperation({ summary: 'C 端小程序手机号登录' }) + miniAppCLogin(@Body() dto: MiniappPhoneLoginDto) { + return this.authService.miniAppCLogin(dto); + } + /** * 获取当前登录用户信息。 */ diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 21e3008..988f210 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -3,14 +3,16 @@ import { AuthService } from './auth.service.js'; import { AuthController } from './auth.controller.js'; import { UsersModule } from '../users/users.module.js'; import { AccessTokenGuard } from './access-token.guard.js'; +import { WechatMiniAppService } from './wechat-miniapp/wechat-miniapp.service.js'; +import { MiniAppAuthService } from './miniapp-auth/miniapp-auth.service.js'; /** * 认证模块:聚合认证控制器、服务与基础鉴权守卫。 */ @Module({ imports: [UsersModule], - providers: [AuthService, AccessTokenGuard], + providers: [AuthService, AccessTokenGuard, WechatMiniAppService, MiniAppAuthService], controllers: [AuthController], - exports: [AuthService, AccessTokenGuard], + exports: [AuthService, AccessTokenGuard, WechatMiniAppService, MiniAppAuthService], }) export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index ffa1690..b6d7f51 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -3,13 +3,20 @@ import type { ActorContext } from '../common/actor-context.js'; import { UsersService } from '../users/users.service.js'; import { LoginDto } from '../users/dto/login.dto.js'; import { CreateSystemAdminDto } from './dto/create-system-admin.dto.js'; +import { MiniappPhoneLoginConfirmDto } from './dto/miniapp-phone-login-confirm.dto.js'; +import { MiniappPhoneLoginDto } from './dto/miniapp-phone-login.dto.js'; +import { PasswordLoginConfirmDto } from './dto/password-login-confirm.dto.js'; +import { MiniAppAuthService } from './miniapp-auth/miniapp-auth.service.js'; /** * 认证服务:将控制层输入转发到用户域能力,避免控制器直接操作用户仓储。 */ @Injectable() export class AuthService { - constructor(private readonly usersService: UsersService) {} + constructor( + private readonly usersService: UsersService, + private readonly miniAppAuthService: MiniAppAuthService, + ) {} /** * 系统管理员创建能力委托给用户服务。 @@ -19,12 +26,40 @@ export class AuthService { } /** - * 登录能力委托给用户服务。 + * 院内账号密码登录。 */ login(dto: LoginDto) { return this.usersService.login(dto); } + /** + * 院内账号密码多账号确认登录。 + */ + confirmLogin(dto: PasswordLoginConfirmDto) { + return this.usersService.confirmLogin(dto); + } + + /** + * B 端小程序手机号登录。 + */ + miniAppBLogin(dto: MiniappPhoneLoginDto) { + return this.miniAppAuthService.loginForB(dto); + } + + /** + * B 端小程序多账号确认登录。 + */ + miniAppBConfirmLogin(dto: MiniappPhoneLoginConfirmDto) { + return this.miniAppAuthService.confirmLoginForB(dto); + } + + /** + * C 端小程序手机号登录。 + */ + miniAppCLogin(dto: MiniappPhoneLoginDto) { + return this.miniAppAuthService.loginForC(dto); + } + /** * 读取当前登录用户详情。 */ diff --git a/src/auth/current-family-actor.decorator.ts b/src/auth/current-family-actor.decorator.ts new file mode 100644 index 0000000..822c824 --- /dev/null +++ b/src/auth/current-family-actor.decorator.ts @@ -0,0 +1,12 @@ +import { createParamDecorator, type ExecutionContext } from '@nestjs/common'; +import type { FamilyActorContext } from '../common/family-actor-context.js'; + +/** + * 读取当前已认证的家属小程序账号上下文。 + */ +export const CurrentFamilyActor = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): FamilyActorContext | undefined => { + const request = ctx.switchToHttp().getRequest<{ familyActor?: FamilyActorContext }>(); + return request.familyActor; + }, +); diff --git a/src/auth/dto/miniapp-phone-login-confirm.dto.ts b/src/auth/dto/miniapp-phone-login-confirm.dto.ts new file mode 100644 index 0000000..eebf14b --- /dev/null +++ b/src/auth/dto/miniapp-phone-login-confirm.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsString, Min } from 'class-validator'; + +/** + * B 端多账号确认登录 DTO。 + */ +export class MiniappPhoneLoginConfirmDto { + @ApiProperty({ + description: 'B 端候选账号选择票据', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', + }) + @IsString({ message: 'loginTicket 必须是字符串' }) + loginTicket!: string; + + @ApiProperty({ + description: '确认登录的用户 ID', + example: 1, + }) + @Type(() => Number) + @IsInt({ message: 'userId 必须是整数' }) + @Min(1, { message: 'userId 必须大于 0' }) + userId!: number; +} diff --git a/src/auth/dto/miniapp-phone-login.dto.ts b/src/auth/dto/miniapp-phone-login.dto.ts new file mode 100644 index 0000000..65e4640 --- /dev/null +++ b/src/auth/dto/miniapp-phone-login.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; + +/** + * 小程序手机号登录 DTO。 + */ +export class MiniappPhoneLoginDto { + @ApiProperty({ + description: '微信登录 code,用于换取 openId', + example: '08123456789abcdef', + }) + @IsString({ message: 'loginCode 必须是字符串' }) + loginCode!: string; + + @ApiProperty({ + description: '微信手机号授权 code,用于换取手机号', + example: '12ab34cd56ef78gh', + }) + @IsString({ message: 'phoneCode 必须是字符串' }) + phoneCode!: string; +} diff --git a/src/auth/dto/password-login-confirm.dto.ts b/src/auth/dto/password-login-confirm.dto.ts new file mode 100644 index 0000000..c815e2f --- /dev/null +++ b/src/auth/dto/password-login-confirm.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsString, Min } from 'class-validator'; + +/** + * 院内账号密码登录确认 DTO。 + */ +export class PasswordLoginConfirmDto { + @ApiProperty({ + description: '密码登录候选账号票据', + example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9', + }) + @IsString({ message: 'loginTicket 必须是字符串' }) + loginTicket!: string; + + @ApiProperty({ + description: '最终确认登录的用户 ID', + example: 1, + }) + @Type(() => Number) + @IsInt({ message: 'userId 必须是整数' }) + @Min(1, { message: 'userId 必须大于 0' }) + userId!: number; +} diff --git a/src/auth/family-access/family-access.guard.ts b/src/auth/family-access/family-access.guard.ts new file mode 100644 index 0000000..0048a58 --- /dev/null +++ b/src/auth/family-access/family-access.guard.ts @@ -0,0 +1,82 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import jwt from 'jsonwebtoken'; +import type { FamilyActorContext } from '../../common/family-actor-context.js'; +import { MESSAGES } from '../../common/messages.js'; +import { PrismaService } from '../../prisma.service.js'; + +/** + * C 端家属小程序登录守卫。 + */ +@Injectable() +export class FamilyAccessTokenGuard implements CanActivate { + constructor(private readonly prisma: PrismaService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest<{ + headers: Record; + familyActor?: FamilyActorContext; + }>(); + const authorization = request.headers.authorization; + const headerValue = Array.isArray(authorization) + ? authorization[0] + : authorization; + + if (!headerValue || !headerValue.startsWith('Bearer ')) { + throw new UnauthorizedException(MESSAGES.AUTH.MISSING_BEARER); + } + + request.familyActor = await this.verifyAndExtractActor( + headerValue.slice('Bearer '.length).trim(), + ); + return true; + } + + /** + * 校验家属 token 并回库确认账号仍存在。 + */ + private async verifyAndExtractActor(token: string): Promise { + const secret = process.env.AUTH_TOKEN_SECRET; + if (!secret) { + throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_SECRET_MISSING); + } + + let payload: string | jwt.JwtPayload; + try { + payload = jwt.verify(token, secret, { + algorithms: ['HS256'], + issuer: 'tyt-api-nest-family', + }); + } catch { + throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_INVALID); + } + + if ( + typeof payload !== 'object' || + payload.type !== 'FAMILY_MINIAPP' || + typeof payload.id !== 'number' || + !Number.isInteger(payload.id) + ) { + throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_PAYLOAD_INVALID); + } + + const account = await this.prisma.familyMiniAppAccount.findUnique({ + where: { id: payload.id }, + select: { + id: true, + phone: true, + openId: true, + serviceUid: true, + }, + }); + if (!account) { + throw new UnauthorizedException(MESSAGES.AUTH.FAMILY_ACCOUNT_NOT_FOUND); + } + + return account; + } +} diff --git a/src/auth/miniapp-auth/miniapp-auth.service.ts b/src/auth/miniapp-auth/miniapp-auth.service.ts new file mode 100644 index 0000000..9108653 --- /dev/null +++ b/src/auth/miniapp-auth/miniapp-auth.service.ts @@ -0,0 +1,251 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import jwt from 'jsonwebtoken'; +import { Role } from '../../generated/prisma/enums.js'; +import { MESSAGES } from '../../common/messages.js'; +import { PrismaService } from '../../prisma.service.js'; +import { UsersService } from '../../users/users.service.js'; +import { MiniappPhoneLoginConfirmDto } from '../dto/miniapp-phone-login-confirm.dto.js'; +import { MiniappPhoneLoginDto } from '../dto/miniapp-phone-login.dto.js'; +import { WechatMiniAppService } from '../wechat-miniapp/wechat-miniapp.service.js'; + +type LoginTicketPayload = { + purpose: 'MINIAPP_B_LOGIN_TICKET'; + phone: string; + openId: string; + userIds: number[]; +}; + +/** + * 小程序登录服务:承载 B/C 端手机号快捷登录链路。 + */ +@Injectable() +export class MiniAppAuthService { + constructor( + private readonly prisma: PrismaService, + private readonly usersService: UsersService, + private readonly wechatMiniAppService: WechatMiniAppService, + ) {} + + /** + * B 端手机号登录:单账号直接签发,多账号返回候选列表。 + */ + async loginForB(dto: MiniappPhoneLoginDto) { + const identity = await this.wechatMiniAppService.resolvePhoneIdentity( + dto.loginCode, + dto.phoneCode, + ); + const accounts = await this.prisma.user.findMany({ + where: { phone: identity.phone }, + select: { + id: true, + name: true, + phone: true, + openId: true, + role: true, + hospitalId: true, + departmentId: true, + groupId: true, + hospital: { + select: { + id: true, + name: true, + }, + }, + }, + orderBy: [{ hospitalId: 'asc' }, { id: 'asc' }], + }); + + if (accounts.length === 0) { + throw new NotFoundException(MESSAGES.AUTH.MINIAPP_NO_MATCHED_USER); + } + + if (accounts.length === 1) { + const [user] = accounts; + await this.usersService.bindOpenIdForMiniAppLogin(user.id, identity.openId); + return this.usersService.loginByUserId(user.id); + } + + return { + needSelect: true, + loginTicket: this.signLoginTicket({ + purpose: 'MINIAPP_B_LOGIN_TICKET', + phone: identity.phone, + openId: identity.openId, + userIds: accounts.map((account) => account.id), + }), + accounts: accounts.map((account) => ({ + id: account.id, + name: account.name, + role: account.role, + hospitalId: account.hospitalId, + hospitalName: account.hospital?.name ?? null, + departmentId: account.departmentId, + groupId: account.groupId, + })), + }; + } + + /** + * B 端多账号确认登录。 + */ + async confirmLoginForB(dto: MiniappPhoneLoginConfirmDto) { + const payload = this.verifyLoginTicket(dto.loginTicket); + if (!payload.userIds.includes(dto.userId)) { + throw new BadRequestException(MESSAGES.AUTH.MINIAPP_ACCOUNT_SELECTION_INVALID); + } + + await this.usersService.bindOpenIdForMiniAppLogin(dto.userId, payload.openId); + return this.usersService.loginByUserId(dto.userId); + } + + /** + * C 端手机号登录:要求手机号已存在于患者档案。 + */ + async loginForC(dto: MiniappPhoneLoginDto) { + const identity = await this.wechatMiniAppService.resolvePhoneIdentity( + dto.loginCode, + dto.phoneCode, + ); + const patientExists = await this.prisma.patient.findFirst({ + where: { phone: identity.phone }, + select: { id: true }, + }); + if (!patientExists) { + throw new NotFoundException(MESSAGES.AUTH.FAMILY_PHONE_NOT_LINKED_PATIENT); + } + + const existingByOpenId = await this.prisma.familyMiniAppAccount.findUnique({ + where: { openId: identity.openId }, + select: { id: true, phone: true }, + }); + if (existingByOpenId && existingByOpenId.phone !== identity.phone) { + throw new ConflictException(MESSAGES.AUTH.MINIAPP_OPEN_ID_BOUND_OTHER_FAMILY); + } + + const current = await this.prisma.familyMiniAppAccount.findUnique({ + where: { phone: identity.phone }, + select: { + id: true, + phone: true, + openId: true, + serviceUid: true, + }, + }); + + if (current?.openId && current.openId !== identity.openId) { + throw new ConflictException(MESSAGES.AUTH.MINIAPP_OPEN_ID_BOUND_OTHER_FAMILY); + } + + const familyAccount = current + ? await this.prisma.familyMiniAppAccount.update({ + where: { id: current.id }, + data: { + openId: current.openId ?? identity.openId, + lastLoginAt: new Date(), + }, + select: { + id: true, + phone: true, + openId: true, + serviceUid: true, + }, + }) + : await this.prisma.familyMiniAppAccount.create({ + data: { + phone: identity.phone, + openId: identity.openId, + lastLoginAt: new Date(), + }, + select: { + id: true, + phone: true, + openId: true, + serviceUid: true, + }, + }); + + return { + tokenType: 'Bearer', + accessToken: this.signFamilyAccessToken(familyAccount.id), + familyAccount, + }; + } + + /** + * 短期登录票据签发。 + */ + private signLoginTicket(payload: LoginTicketPayload) { + const secret = this.requireAuthSecret(); + return jwt.sign(payload, secret, { + algorithm: 'HS256', + expiresIn: '5m', + issuer: 'tyt-api-nest', + }); + } + + /** + * 校验短期登录票据。 + */ + private verifyLoginTicket(token: string): LoginTicketPayload { + const secret = this.requireAuthSecret(); + let payload: string | jwt.JwtPayload; + + try { + payload = jwt.verify(token, secret, { + algorithms: ['HS256'], + issuer: 'tyt-api-nest', + }); + } catch { + throw new UnauthorizedException(MESSAGES.AUTH.MINIAPP_LOGIN_TICKET_INVALID); + } + + if ( + typeof payload !== 'object' || + payload.purpose !== 'MINIAPP_B_LOGIN_TICKET' || + typeof payload.phone !== 'string' || + typeof payload.openId !== 'string' || + !Array.isArray(payload.userIds) || + payload.userIds.some((item) => typeof item !== 'number' || !Number.isInteger(item)) + ) { + throw new UnauthorizedException(MESSAGES.AUTH.MINIAPP_LOGIN_TICKET_INVALID); + } + + return payload as unknown as LoginTicketPayload; + } + + /** + * 签发 C 端家属 token。 + */ + private signFamilyAccessToken(accountId: number) { + const secret = this.requireAuthSecret(); + return jwt.sign( + { + id: accountId, + type: 'FAMILY_MINIAPP', + }, + secret, + { + algorithm: 'HS256', + expiresIn: '7d', + issuer: 'tyt-api-nest-family', + }, + ); + } + + /** + * 读取认证密钥。 + */ + private requireAuthSecret() { + const secret = process.env.AUTH_TOKEN_SECRET; + if (!secret) { + throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_SECRET_MISSING); + } + return secret; + } +} diff --git a/src/auth/wechat-miniapp/wechat-miniapp.service.ts b/src/auth/wechat-miniapp/wechat-miniapp.service.ts new file mode 100644 index 0000000..16b7bd9 --- /dev/null +++ b/src/auth/wechat-miniapp/wechat-miniapp.service.ts @@ -0,0 +1,176 @@ +import { + BadRequestException, + Injectable, + InternalServerErrorException, + UnauthorizedException, +} from '@nestjs/common'; +import { MESSAGES } from '../../common/messages.js'; + +type WechatAccessTokenResponse = { + access_token?: string; + expires_in?: number; + errcode?: number; + errmsg?: string; +}; + +type WechatCode2SessionResponse = { + openid?: string; + errcode?: number; + errmsg?: string; +}; + +type WechatPhoneResponse = { + phone_info?: { + phoneNumber?: string; + }; + errcode?: number; + errmsg?: string; +}; + +/** + * 微信小程序认证服务:负责和微信开放接口交换 openId 与手机号。 + */ +@Injectable() +export class WechatMiniAppService { + private accessTokenCache: + | { + token: string; + expiresAt: number; + } + | null = null; + + /** + * 一次性解析微信登录 code 与手机号 code。 + */ + async resolvePhoneIdentity(loginCode: string, phoneCode: string) { + const [openId, phone] = await Promise.all([ + this.exchangeLoginCode(loginCode), + this.exchangePhoneCode(phoneCode), + ]); + + return { + openId, + phone, + }; + } + + /** + * 通过 wx.login 返回的 code 换取 openId。 + */ + async exchangeLoginCode(loginCode: string): Promise { + const config = this.requireConfig(); + const params = new URLSearchParams({ + appid: config.appId, + secret: config.secret, + js_code: this.normalizeCode(loginCode, 'loginCode'), + grant_type: 'authorization_code', + }); + const response = await fetch( + `https://api.weixin.qq.com/sns/jscode2session?${params.toString()}`, + ); + const payload = + (await response.json().catch(() => null)) as WechatCode2SessionResponse | null; + + if (!response.ok || !payload?.openid || payload.errcode) { + throw new UnauthorizedException(MESSAGES.AUTH.WECHAT_MINIAPP_LOGIN_FAILED); + } + + return payload.openid; + } + + /** + * 通过手机号授权 code 换取手机号。 + */ + async exchangePhoneCode(phoneCode: string): Promise { + const accessToken = await this.getAccessToken(); + const response = await fetch( + `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${encodeURIComponent(accessToken)}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + code: this.normalizeCode(phoneCode, 'phoneCode'), + }), + }, + ); + const payload = + (await response.json().catch(() => null)) as WechatPhoneResponse | null; + const phone = payload?.phone_info?.phoneNumber; + + if (!response.ok || !phone || payload?.errcode) { + throw new UnauthorizedException(MESSAGES.AUTH.WECHAT_MINIAPP_PHONE_FAILED); + } + + return phone; + } + + /** + * 获取微信小程序 access token,带简单进程内缓存。 + */ + private async getAccessToken(): Promise { + const cached = this.accessTokenCache; + if (cached && cached.expiresAt > Date.now()) { + return cached.token; + } + + const config = this.requireConfig(); + const params = new URLSearchParams({ + grant_type: 'client_credential', + appid: config.appId, + secret: config.secret, + }); + const response = await fetch( + `https://api.weixin.qq.com/cgi-bin/token?${params.toString()}`, + ); + const payload = + (await response.json().catch(() => null)) as WechatAccessTokenResponse | null; + + if (!response.ok || !payload?.access_token || payload.errcode) { + throw new UnauthorizedException(MESSAGES.AUTH.WECHAT_MINIAPP_PHONE_FAILED); + } + + this.accessTokenCache = { + token: payload.access_token, + expiresAt: Date.now() + Math.max((payload.expires_in ?? 7200) - 120, 60) * 1000, + }; + + return payload.access_token; + } + + /** + * 校验微信配置存在。 + */ + private requireConfig() { + const appId = process.env.WECHAT_MINIAPP_APPID?.trim(); + const secret = process.env.WECHAT_MINIAPP_SECRET?.trim(); + + if (!appId || !secret) { + throw new InternalServerErrorException( + MESSAGES.AUTH.WECHAT_MINIAPP_CONFIG_MISSING, + ); + } + + return { + appId, + secret, + }; + } + + /** + * 登录 code 基础校验。 + */ + private normalizeCode(code: unknown, fieldName: string) { + if (typeof code !== 'string') { + throw new BadRequestException(`${fieldName} 必须是字符串`); + } + + const trimmed = code.trim(); + if (!trimmed) { + throw new BadRequestException(`${fieldName} 不能为空`); + } + + return trimmed; + } +} diff --git a/src/common/family-actor-context.ts b/src/common/family-actor-context.ts new file mode 100644 index 0000000..3869ec2 --- /dev/null +++ b/src/common/family-actor-context.ts @@ -0,0 +1,6 @@ +export type FamilyActorContext = { + id: number; + phone: string; + openId: string | null; + serviceUid: string | null; +}; diff --git a/src/common/messages.ts b/src/common/messages.ts index 6670ed9..793d649 100644 --- a/src/common/messages.ts +++ b/src/common/messages.ts @@ -25,9 +25,21 @@ export const MESSAGES = { TOKEN_REVOKED: 'Token 已失效,请重新登录', TOKEN_ROLE_INVALID: 'Token 中角色信息不合法', TOKEN_FIELD_INVALID: 'Token 中字段不合法', - INVALID_CREDENTIALS: '手机号、角色或密码错误', + INVALID_CREDENTIALS: '手机号、密码或角色不匹配', PASSWORD_NOT_ENABLED: '该账号未启用密码登录', + PASSWORD_LOGIN_TICKET_INVALID: '账号选择票据无效或已过期', + PASSWORD_ACCOUNT_SELECTION_INVALID: '请选择有效的候选账号', REGISTER_DISABLED: '注册接口已关闭,请联系管理员创建账号', + WECHAT_MINIAPP_CONFIG_MISSING: '服务端未配置微信小程序认证参数', + WECHAT_MINIAPP_LOGIN_FAILED: '微信登录授权失败,请重新获取登录凭证', + WECHAT_MINIAPP_PHONE_FAILED: '微信手机号授权失败,请重新获取手机号凭证', + MINIAPP_NO_MATCHED_USER: '手机号未匹配到院内账号', + MINIAPP_LOGIN_TICKET_INVALID: '账号选择票据无效或已过期', + MINIAPP_ACCOUNT_SELECTION_INVALID: '请选择有效的候选账号', + MINIAPP_OPEN_ID_BOUND_OTHER_USER: '当前微信账号已绑定其他院内账号', + MINIAPP_OPEN_ID_BOUND_OTHER_FAMILY: '当前微信账号已绑定其他家属账号', + FAMILY_PHONE_NOT_LINKED_PATIENT: '当前手机号未关联患者档案', + FAMILY_ACCOUNT_NOT_FOUND: '家属登录账号不存在,请重新登录', }, USER: { @@ -54,8 +66,6 @@ export const MESSAGES = { GROUP_DEPARTMENT_MISMATCH: '小组不属于指定科室', DOCTOR_ONLY_SCOPE_CHANGE: '仅医生/主任/组长允许调整科室/小组归属', DELETE_CONFLICT: '用户存在关联患者或任务,无法删除', - MULTI_ACCOUNT_REQUIRE_HOSPITAL: - '检测到多个同手机号账号,请传 hospitalId 指定登录医院', CREATE_FORBIDDEN: '当前角色无权限创建该用户', HOSPITAL_ADMIN_SCOPE_FORBIDDEN: '医院管理员仅可操作本院非管理员账号', DIRECTOR_SCOPE_FORBIDDEN: '科室主任仅可操作本科室医生或组长账号', @@ -91,7 +101,6 @@ export const MESSAGES = { DOCTOR_ROLE_REQUIRED: '归属用户必须为医生/主任/组长角色', DOCTOR_SCOPE_FORBIDDEN: '仅可选择当前权限范围内医生/主任/组长', DELETE_CONFLICT: '患者存在关联设备,无法删除', - PHONE_IDCARD_REQUIRED: 'phone 与 idCard 均为必填', LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证号', SYSTEM_ADMIN_HOSPITAL_REQUIRED: '系统管理员查询必须显式传入 hospitalId', ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息', diff --git a/src/patients/c-patients/c-patients.controller.ts b/src/patients/c-patients/c-patients.controller.ts index aad7c73..6d4ebfe 100644 --- a/src/patients/c-patients/c-patients.controller.ts +++ b/src/patients/c-patients/c-patients.controller.ts @@ -1,12 +1,8 @@ -import { Controller, Get, Query, UseGuards } from '@nestjs/common'; -import { - ApiBearerAuth, - ApiOperation, - ApiQuery, - ApiTags, -} from '@nestjs/swagger'; -import { AccessTokenGuard } from '../../auth/access-token.guard.js'; -import { FamilyLifecycleQueryDto } from '../dto/family-lifecycle-query.dto.js'; +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentFamilyActor } from '../../auth/current-family-actor.decorator.js'; +import { FamilyAccessTokenGuard } from '../../auth/family-access/family-access.guard.js'; +import type { FamilyActorContext } from '../../common/family-actor-context.js'; import { CPatientsService } from './c-patients.service.js'; /** @@ -15,21 +11,16 @@ import { CPatientsService } from './c-patients.service.js'; @ApiTags('患者管理(C端)') @ApiBearerAuth('bearer') @Controller('c/patients') -@UseGuards(AccessTokenGuard) +@UseGuards(FamilyAccessTokenGuard) export class CPatientsController { constructor(private readonly patientsService: CPatientsService) {} /** - * 根据手机号和身份证号查询跨院生命周期。 + * 根据当前登录手机号查询跨院生命周期。 */ - @Get('lifecycle') - @ApiOperation({ summary: '跨院患者生命周期查询' }) - @ApiQuery({ name: 'phone', description: '手机号' }) - @ApiQuery({ name: 'idCard', description: '身份证号' }) - getLifecycle(@Query() query: FamilyLifecycleQueryDto) { - return this.patientsService.getFamilyLifecycleByIdentity( - query.phone, - query.idCard, - ); + @Get('my-lifecycle') + @ApiOperation({ summary: '按当前登录手机号查询患者生命周期' }) + getMyLifecycle(@CurrentFamilyActor() actor: FamilyActorContext) { + return this.patientsService.getFamilyLifecycleByAccount(actor.id); } } diff --git a/src/patients/c-patients/c-patients.service.ts b/src/patients/c-patients/c-patients.service.ts index 3c3b2d3..bb58454 100644 --- a/src/patients/c-patients/c-patients.service.ts +++ b/src/patients/c-patients/c-patients.service.ts @@ -1,11 +1,9 @@ import { - BadRequestException, Injectable, NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../../prisma.service.js'; import { MESSAGES } from '../../common/messages.js'; -import { normalizePatientIdCard } from '../patient-id-card.util.js'; /** * C 端患者服务:承载家属跨院生命周期聚合查询。 @@ -15,20 +13,23 @@ export class CPatientsService { constructor(private readonly prisma: PrismaService) {} /** - * C 端查询:按 phone + idCard 跨院聚合患者生命周期记录。 + * C 端查询:按当前家属账号绑定手机号跨院聚合患者生命周期记录。 */ - async getFamilyLifecycleByIdentity(phone: string, idCard: string) { - if (!phone || !idCard) { - throw new BadRequestException(MESSAGES.PATIENT.PHONE_IDCARD_REQUIRED); + async getFamilyLifecycleByAccount(accountId: number) { + const account = await this.prisma.familyMiniAppAccount.findUnique({ + where: { id: accountId }, + select: { + id: true, + phone: true, + }, + }); + if (!account) { + throw new NotFoundException(MESSAGES.AUTH.FAMILY_ACCOUNT_NOT_FOUND); } - // 查询侧统一整理身份证格式,避免空格或末尾 x 大小写导致查不到。 - const normalizedIdCard = normalizePatientIdCard(idCard); - const patients = await this.prisma.patient.findMany({ where: { - phone, - idCard: normalizedIdCard, + phone: account.phone, }, include: { hospital: { select: { id: true, name: true } }, @@ -191,8 +192,7 @@ export class CPatientsService { return { // 前端详情弹窗和现有 E2E 都依赖这两个回显字段。 - phone, - idCard: normalizedIdCard, + phone: account.phone, patientCount: patients.length, lifecycle, }; diff --git a/src/patients/dto/family-lifecycle-query.dto.ts b/src/patients/dto/family-lifecycle-query.dto.ts deleted file mode 100644 index 72fb286..0000000 --- a/src/patients/dto/family-lifecycle-query.dto.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { IsString, Matches } from 'class-validator'; - -/** - * 家属端生命周期查询 DTO。 - */ -export class FamilyLifecycleQueryDto { - @ApiProperty({ description: '手机号', example: '13800000003' }) - @IsString({ message: 'phone 必须是字符串' }) - @Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' }) - phone!: string; - - @ApiProperty({ - description: '身份证号原文', - example: '110101199001010011', - }) - @IsString({ message: 'idCard 必须是字符串' }) - idCard!: string; -} diff --git a/src/patients/patients.module.ts b/src/patients/patients.module.ts index 8011383..1631c64 100644 --- a/src/patients/patients.module.ts +++ b/src/patients/patients.module.ts @@ -2,12 +2,19 @@ import { Module } from '@nestjs/common'; import { BPatientsController } from './b-patients/b-patients.controller.js'; import { CPatientsController } from './c-patients/c-patients.controller.js'; import { AccessTokenGuard } from '../auth/access-token.guard.js'; +import { FamilyAccessTokenGuard } from '../auth/family-access/family-access.guard.js'; import { RolesGuard } from '../auth/roles.guard.js'; import { BPatientsService } from './b-patients/b-patients.service.js'; import { CPatientsService } from './c-patients/c-patients.service.js'; @Module({ - providers: [BPatientsService, CPatientsService, AccessTokenGuard, RolesGuard], + providers: [ + BPatientsService, + CPatientsService, + AccessTokenGuard, + FamilyAccessTokenGuard, + RolesGuard, + ], controllers: [BPatientsController, CPatientsController], exports: [BPatientsService, CPatientsService], }) diff --git a/src/users/dto/login.dto.ts b/src/users/dto/login.dto.ts index 9e0135c..52bd7ea 100644 --- a/src/users/dto/login.dto.ts +++ b/src/users/dto/login.dto.ts @@ -13,7 +13,7 @@ import { } from 'class-validator'; /** - * 登录 DTO:后台与小程序均可复用。 + * 院内账号密码登录 DTO。 */ export class LoginDto { @ApiProperty({ description: '手机号', example: '13800000002' }) @@ -26,12 +26,17 @@ export class LoginDto { @MinLength(8, { message: 'password 长度至少 8 位' }) password!: string; - @ApiProperty({ description: '登录角色', enum: Role, example: Role.DOCTOR }) + @ApiPropertyOptional({ + description: '登录角色;不传时按手机号+密码匹配全部院内账号', + enum: Role, + example: Role.DOCTOR, + }) + @IsOptional() @IsEnum(Role, { message: 'role 枚举值不合法' }) - role!: Role; + role?: Role; @ApiPropertyOptional({ - description: '医院 ID(多账号场景建议传入)', + description: '医院 ID;传入后仅在该医院范围内匹配候选账号', example: 1, }) @IsOptional() diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 94c0a28..04f99c0 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -18,6 +18,7 @@ import { AssignEngineerHospitalDto } from './dto/assign-engineer-hospital.dto.js import { LoginDto } from './dto/login.dto.js'; import { MESSAGES } from '../common/messages.js'; import { CreateSystemAdminDto } from '../auth/dto/create-system-admin.dto.js'; +import { PasswordLoginConfirmDto } from '../auth/dto/password-login-confirm.dto.js'; const SAFE_USER_SELECT = { id: true, @@ -33,6 +34,11 @@ const SAFE_USER_SELECT = { const DIRECTOR_VISIBLE_ROLES = [Role.LEADER, Role.DOCTOR] as const; const LEADER_VISIBLE_ROLES = [Role.DOCTOR] as const; +type PasswordLoginTicketPayload = { + purpose: 'PASSWORD_LOGIN_TICKET'; + userIds: number[]; +}; + @Injectable() export class UsersService { constructor(private readonly prisma: PrismaService) {} @@ -78,10 +84,10 @@ export class UsersService { } /** - * 登录:按手机号+角色(可选医院)定位账号并签发 JWT。 + * 院内账号密码登录:支持后台和小程序复用。 */ async login(dto: LoginDto) { - const role = this.normalizeRole(dto.role); + const role = dto.role != null ? this.normalizeRole(dto.role) : undefined; const phone = this.normalizePhone(dto.phone); const password = this.normalizePassword(dto.password); const hospitalId = this.normalizeOptionalInt(dto.hospitalId, 'hospitalId'); @@ -89,49 +95,127 @@ export class UsersService { const users = await this.prisma.user.findMany({ where: { phone, - role, + ...(role != null ? { role } : {}), ...(hospitalId != null ? { hospitalId } : {}), }, select: { ...SAFE_USER_SELECT, passwordHash: true, + hospital: { + select: { + id: true, + name: true, + }, + }, }, - take: 5, + orderBy: [{ hospitalId: 'asc' }, { id: 'asc' }], + take: 10, }); if (users.length === 0) { throw new UnauthorizedException(MESSAGES.AUTH.INVALID_CREDENTIALS); } - if (users.length > 1 && hospitalId == null) { + + const matchedUsers = ( + await Promise.all( + users.map(async (user) => { + if (!user.passwordHash) { + return null; + } + + const matched = await compare(password, user.passwordHash); + return matched ? user : null; + }), + ) + ).filter((item) => item != null); + + if (matchedUsers.length === 0) { + throw new UnauthorizedException(MESSAGES.AUTH.INVALID_CREDENTIALS); + } + if (matchedUsers.length === 1) { + return this.buildUserLoginResponse(matchedUsers[0]); + } + + return { + needSelect: true, + loginTicket: this.signPasswordLoginTicket({ + purpose: 'PASSWORD_LOGIN_TICKET', + userIds: matchedUsers.map((user) => user.id), + }), + accounts: matchedUsers.map((user) => ({ + id: user.id, + name: user.name, + role: user.role, + hospitalId: user.hospitalId, + hospitalName: user.hospital?.name ?? null, + departmentId: user.departmentId, + groupId: user.groupId, + })), + }; + } + + /** + * 院内账号密码多账号确认登录。 + */ + async confirmLogin(dto: PasswordLoginConfirmDto) { + const payload = this.verifyPasswordLoginTicket(dto.loginTicket); + if (!payload.userIds.includes(dto.userId)) { throw new BadRequestException( - MESSAGES.USER.MULTI_ACCOUNT_REQUIRE_HOSPITAL, + MESSAGES.AUTH.PASSWORD_ACCOUNT_SELECTION_INVALID, ); } - const user = users[0]; - if (!user?.passwordHash) { - throw new UnauthorizedException(MESSAGES.AUTH.PASSWORD_NOT_ENABLED); + return this.loginByUserId(dto.userId); + } + + /** + * 按用户 ID 直接签发院内登录态,供小程序快捷登录复用。 + */ + async loginByUserId(userId: number) { + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: SAFE_USER_SELECT, + }); + if (!user) { + throw new NotFoundException(MESSAGES.USER.NOT_FOUND); } - const matched = await compare(password, user.passwordHash); - if (!matched) { - throw new UnauthorizedException(MESSAGES.AUTH.INVALID_CREDENTIALS); + return this.buildUserLoginResponse(user); + } + + /** + * 小程序登录时绑定 openId。 + */ + async bindOpenIdForMiniAppLogin(userId: number, openId: string) { + const normalizedOpenId = this.normalizeRequiredString(openId, 'openId'); + const current = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + openId: true, + }, + }); + if (!current) { + throw new NotFoundException(MESSAGES.USER.NOT_FOUND); + } + if (current.openId && current.openId !== normalizedOpenId) { + throw new ConflictException(MESSAGES.AUTH.MINIAPP_OPEN_ID_BOUND_OTHER_USER); } - const actor: ActorContext = { - id: user.id, - role: user.role, - hospitalId: user.hospitalId, - departmentId: user.departmentId, - groupId: user.groupId, - }; + const existing = await this.prisma.user.findUnique({ + where: { openId: normalizedOpenId }, + select: { id: true }, + }); + if (existing && existing.id !== current.id) { + throw new ConflictException(MESSAGES.AUTH.MINIAPP_OPEN_ID_BOUND_OTHER_USER); + } - return { - tokenType: 'Bearer', - accessToken: this.signAccessToken(actor), - actor, - user: this.toSafeUser(user), - }; + if (!current.openId) { + await this.prisma.user.update({ + where: { id: current.id }, + data: { openId: normalizedOpenId }, + }); + } } /** @@ -438,6 +522,38 @@ export class UsersService { return safe; } + /** + * 统一构造院内账号登录响应。 + */ + private buildUserLoginResponse( + user: { + id: number; + name: string; + phone: string; + openId: string | null; + role: Role; + hospitalId: number | null; + departmentId: number | null; + groupId: number | null; + passwordHash?: string | null; + }, + ) { + const actor: ActorContext = { + id: user.id, + role: user.role, + hospitalId: user.hospitalId, + departmentId: user.departmentId, + groupId: user.groupId, + }; + + return { + tokenType: 'Bearer', + accessToken: this.signAccessToken(actor), + actor, + user: this.toSafeUser(user), + }; + } + /** * 校验系统管理员注册引导密钥。 */ @@ -870,10 +986,7 @@ export class UsersService { * 签发访问令牌。 */ private signAccessToken(actor: ActorContext): string { - const secret = process.env.AUTH_TOKEN_SECRET; - if (!secret) { - throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_SECRET_MISSING); - } + const secret = this.requireAuthSecret(); return jwt.sign(actor, secret, { algorithm: 'HS256', @@ -881,4 +994,57 @@ export class UsersService { issuer: 'tyt-api-nest', }); } + + /** + * 密码登录候选账号票据签发。 + */ + private signPasswordLoginTicket(payload: PasswordLoginTicketPayload) { + return jwt.sign(payload, this.requireAuthSecret(), { + algorithm: 'HS256', + expiresIn: '5m', + issuer: 'tyt-api-nest', + }); + } + + /** + * 校验密码登录候选账号票据。 + */ + private verifyPasswordLoginTicket(token: string): PasswordLoginTicketPayload { + let payload: string | jwt.JwtPayload; + + try { + payload = jwt.verify(token, this.requireAuthSecret(), { + algorithms: ['HS256'], + issuer: 'tyt-api-nest', + }); + } catch { + throw new UnauthorizedException( + MESSAGES.AUTH.PASSWORD_LOGIN_TICKET_INVALID, + ); + } + + if ( + typeof payload !== 'object' || + payload.purpose !== 'PASSWORD_LOGIN_TICKET' || + !Array.isArray(payload.userIds) || + payload.userIds.some((item) => typeof item !== 'number' || !Number.isInteger(item)) + ) { + throw new UnauthorizedException( + MESSAGES.AUTH.PASSWORD_LOGIN_TICKET_INVALID, + ); + } + + return payload as PasswordLoginTicketPayload; + } + + /** + * 统一读取认证密钥。 + */ + private requireAuthSecret() { + const secret = process.env.AUTH_TOKEN_SECRET; + if (!secret) { + throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_SECRET_MISSING); + } + return secret; + } } diff --git a/test/e2e/fixtures/e2e-roles.ts b/test/e2e/fixtures/e2e-roles.ts index f28f9e5..833c8dc 100644 --- a/test/e2e/fixtures/e2e-roles.ts +++ b/test/e2e/fixtures/e2e-roles.ts @@ -17,6 +17,7 @@ export interface E2ESeedCredential { role: E2ERole; phone: string; password: string; + openId: string; hospitalId?: number; } @@ -25,35 +26,41 @@ export const E2E_SEED_CREDENTIALS: Record = { role: Role.SYSTEM_ADMIN, phone: '13800001000', password: E2E_SEED_PASSWORD, + openId: 'seed-system-admin-openid', }, [Role.HOSPITAL_ADMIN]: { role: Role.HOSPITAL_ADMIN, phone: '13800001001', password: E2E_SEED_PASSWORD, + openId: 'seed-hospital-admin-a-openid', hospitalId: 1, }, [Role.DIRECTOR]: { role: Role.DIRECTOR, phone: '13800001002', password: E2E_SEED_PASSWORD, + openId: 'seed-director-a-openid', hospitalId: 1, }, [Role.LEADER]: { role: Role.LEADER, phone: '13800001003', password: E2E_SEED_PASSWORD, + openId: 'seed-leader-a-openid', hospitalId: 1, }, [Role.DOCTOR]: { role: Role.DOCTOR, phone: '13800001004', password: E2E_SEED_PASSWORD, + openId: 'seed-doctor-a-openid', hospitalId: 1, }, [Role.ENGINEER]: { role: Role.ENGINEER, phone: '13800001005', password: E2E_SEED_PASSWORD, + openId: 'seed-engineer-a-openid', hospitalId: 1, }, }; diff --git a/test/e2e/helpers/e2e-app.helper.ts b/test/e2e/helpers/e2e-app.helper.ts index 0b1b41b..fbad6a8 100644 --- a/test/e2e/helpers/e2e-app.helper.ts +++ b/test/e2e/helpers/e2e-app.helper.ts @@ -3,14 +3,43 @@ import { BadRequestException, ValidationPipe } from '@nestjs/common'; import type { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import { AppModule } from '../../../src/app.module.js'; +import { WechatMiniAppService } from '../../../src/auth/wechat-miniapp/wechat-miniapp.service.js'; import { HttpExceptionFilter } from '../../../src/common/http-exception.filter.js'; import { MESSAGES } from '../../../src/common/messages.js'; import { ResponseEnvelopeInterceptor } from '../../../src/common/response-envelope.interceptor.js'; +class FakeWechatMiniAppService { + async resolvePhoneIdentity(loginCode: string, phoneCode: string) { + return { + openId: this.exchangeLoginCode(loginCode), + phone: this.exchangePhoneCode(phoneCode), + }; + } + + exchangeLoginCode(loginCode: string) { + return this.parseCode(loginCode, 'mock-login:'); + } + + exchangePhoneCode(phoneCode: string) { + return this.parseCode(phoneCode, 'mock-phone:'); + } + + private parseCode(value: string, prefix: string) { + if (typeof value !== 'string' || !value.startsWith(prefix)) { + throw new Error(`invalid mock miniapp code: ${value}`); + } + + return decodeURIComponent(value.slice(prefix.length)); + } +} + export async function createE2eApp(): Promise { const moduleRef = await Test.createTestingModule({ imports: [AppModule], - }).compile(); + }) + .overrideProvider(WechatMiniAppService) + .useValue(new FakeWechatMiniAppService()) + .compile(); const app = moduleRef.createNestApplication(); diff --git a/test/e2e/helpers/e2e-auth.helper.ts b/test/e2e/helpers/e2e-auth.helper.ts index 7dc4b71..8f04a99 100644 --- a/test/e2e/helpers/e2e-auth.helper.ts +++ b/test/e2e/helpers/e2e-auth.helper.ts @@ -24,23 +24,16 @@ export async function loginAsRole( fixtures: E2ESeedFixtures, ): Promise { const credential = E2E_SEED_CREDENTIALS[role]; - const payload: Record = { - phone: credential.phone, - password: credential.password, - role: credential.role, - }; - const hospitalId = resolveRoleHospitalId(role, fixtures); - if (hospitalId != null) { - payload.hospitalId = hospitalId; - } - const response = await request(app.getHttpServer()) .post('/auth/login') - .send(payload); + .send({ + phone: credential.phone, + password: credential.password, + role: credential.role, + hospitalId: resolveRoleHospitalId(role, fixtures), + }); expectSuccessEnvelope(response, 201); - expect(response.body.data?.accessToken).toEqual(expect.any(String)); - return response.body.data.accessToken as string; } diff --git a/test/e2e/helpers/e2e-fixtures.helper.ts b/test/e2e/helpers/e2e-fixtures.helper.ts index 9358b31..d7f582b 100644 --- a/test/e2e/helpers/e2e-fixtures.helper.ts +++ b/test/e2e/helpers/e2e-fixtures.helper.ts @@ -1,7 +1,7 @@ import type { INestApplication } from '@nestjs/common'; import { NotFoundException } from '@nestjs/common'; import request from 'supertest'; -import { Role } from '../../../src/generated/prisma/enums.js'; +import { Role, UploadAssetType } from '../../../src/generated/prisma/enums.js'; import { PrismaService } from '../../../src/prisma.service.js'; import { E2E_SEED_CREDENTIALS, @@ -109,7 +109,7 @@ export async function ensureE2EFixtures( }); if (!existingSystemAdmin) { - await bootstrapFixturesViaApi(app); + await bootstrapFixturesViaApi(app, prisma); } try { @@ -120,7 +120,10 @@ export async function ensureE2EFixtures( } } -async function bootstrapFixturesViaApi(app: INestApplication) { +async function bootstrapFixturesViaApi( + app: INestApplication, + prisma: PrismaService, +) { const server = app.getHttpServer(); await createSystemAdmin(server); @@ -576,9 +579,18 @@ async function bootstrapFixturesViaApi(app: INestApplication) { taskId: publishedA.id, }); - await createWithToken(server, engineerAToken, '/b/tasks/complete', { - taskId: publishedA.id, - }); + const engineerProofAsset = await ensureTaskCompletionAsset( + prisma, + hospitalA.id, + engineerA.id, + 'seed-bootstrap-engineer-proof-a', + ); + await createWithToken( + server, + engineerAToken, + '/b/tasks/complete', + buildTaskCompletionPayload(publishedA.id, engineerProofAsset), + ); await createWithToken(server, doctorBToken, '/b/tasks/publish', { items: [ @@ -612,15 +624,18 @@ async function repairFixturesViaApi( const doctorA2 = await requireUserScope(prisma, OPEN_IDS.doctorA2); const doctorA3 = await requireUserScope(prisma, OPEN_IDS.doctorA3); const doctorB = await requireUserScope(prisma, OPEN_IDS.doctorB); + const engineerA = await requireUserScope(prisma, OPEN_IDS.engineerA); if ( hospitalAdminA.hospitalId == null || doctorA.hospitalId == null || doctorB.hospitalId == null || + engineerA.hospitalId == null || doctorA.id == null || doctorA2.id == null || doctorA3.id == null || - doctorB.id == null + doctorB.id == null || + engineerA.id == null ) { throw new NotFoundException('Seed user scope is incomplete'); } @@ -893,9 +908,18 @@ async function repairFixturesViaApi( await createWithToken(server, engineerAToken, '/b/tasks/accept', { taskId: publishedA.id, }); - await createWithToken(server, engineerAToken, '/b/tasks/complete', { - taskId: publishedA.id, - }); + const engineerProofAsset = await ensureTaskCompletionAsset( + prisma, + engineerA.hospitalId, + engineerA.id, + 'seed-repair-engineer-proof-a', + ); + await createWithToken( + server, + engineerAToken, + '/b/tasks/complete', + buildTaskCompletionPayload(publishedA.id, engineerProofAsset), + ); } if (!(await hasTaskItemForDevice(prisma, deviceB1Id))) { @@ -1011,6 +1035,62 @@ async function patchWithToken( return response.body.data; } +async function ensureTaskCompletionAsset( + prisma: PrismaService, + hospitalId: number, + creatorId: number, + key: string, +) { + const fileName = `${key}.webp`; + const storagePath = `e2e/${fileName}`; + + return prisma.uploadAsset.upsert({ + where: { storagePath }, + update: {}, + create: { + hospitalId, + creatorId, + type: UploadAssetType.IMAGE, + originalName: fileName, + fileName, + storagePath, + url: `/uploads/e2e/${fileName}`, + mimeType: 'image/webp', + fileSize: 1024, + }, + select: { + id: true, + type: true, + url: true, + originalName: true, + fileName: true, + }, + }); +} + +function buildTaskCompletionPayload( + taskId: number, + asset: { + id: number; + type: UploadAssetType; + url: string; + originalName: string; + fileName: string; + }, +) { + return { + taskId, + completionMaterials: [ + { + assetId: asset.id, + type: asset.type, + url: asset.url, + name: asset.originalName || asset.fileName, + }, + ], + }; +} + async function requireUserScope( prisma: PrismaService, openId: string, diff --git a/test/e2e/helpers/e2e-miniapp-auth.helper.ts b/test/e2e/helpers/e2e-miniapp-auth.helper.ts new file mode 100644 index 0000000..cb834e0 --- /dev/null +++ b/test/e2e/helpers/e2e-miniapp-auth.helper.ts @@ -0,0 +1,77 @@ +import request from 'supertest'; +import { Role } from '../../../src/generated/prisma/enums.js'; +import { expectSuccessEnvelope } from './e2e-http.helper.js'; + +/** + * 生成 E2E 用的小程序模拟授权参数。 + */ +export function buildMiniAppMockPayload(phone: string, openId: string) { + return { + loginCode: `mock-login:${encodeURIComponent(openId)}`, + phoneCode: `mock-phone:${phone}`, + }; +} + +/** + * 通过 B 端小程序登录接口获取院内账号 token。 + */ +export async function loginByMiniApp( + server: request.SuperTest, + options: { + phone: string; + openId: string; + role?: Role; + hospitalId?: number; + userId?: number; + }, +) { + const response = await request(server) + .post('/auth/miniapp/b/phone-login') + .send(buildMiniAppMockPayload(options.phone, options.openId)); + + expectSuccessEnvelope(response, 201); + const firstStage = response.body.data as + | { + accessToken: string; + } + | { + needSelect: true; + loginTicket: string; + accounts: Array<{ + id: number; + role: Role; + hospitalId: number | null; + }>; + }; + + if ('accessToken' in firstStage) { + return firstStage.accessToken; + } + + const selectedAccount = firstStage.accounts.find((account) => { + if (options.userId != null) { + return account.id === options.userId; + } + + if (options.role == null) { + return false; + } + + return ( + account.role === options.role && + (options.hospitalId == null || account.hospitalId === options.hospitalId) + ); + }); + + expect(selectedAccount).toBeTruthy(); + + const confirmResponse = await request(server) + .post('/auth/miniapp/b/phone-login/confirm') + .send({ + loginTicket: firstStage.loginTicket, + userId: selectedAccount?.id, + }); + + expectSuccessEnvelope(confirmResponse, 201); + return confirmResponse.body.data.accessToken as string; +} diff --git a/test/e2e/specs/auth.e2e-spec.ts b/test/e2e/specs/auth.e2e-spec.ts index b5912e3..2cad441 100644 --- a/test/e2e/specs/auth.e2e-spec.ts +++ b/test/e2e/specs/auth.e2e-spec.ts @@ -1,5 +1,9 @@ import request from 'supertest'; import { Role } from '../../../src/generated/prisma/enums.js'; +import { + E2E_SEED_CREDENTIALS, + E2E_SEED_PASSWORD, +} from '../fixtures/e2e-roles.js'; import { closeE2EContext, createE2EContext, @@ -12,6 +16,7 @@ import { uniquePhone, uniqueSeedValue, } from '../helpers/e2e-http.helper.js'; +import { buildMiniAppMockPayload } from '../helpers/e2e-miniapp-auth.helper.js'; describe('AuthController (e2e)', () => { let ctx: E2EContext; @@ -55,12 +60,12 @@ describe('AuthController (e2e)', () => { }); describe('POST /auth/login', () => { - it('成功:seed 账号登录并拿到 token', async () => { + it('成功:院内账号可使用手机号密码登录', async () => { const response = await request(ctx.app.getHttpServer()) .post('/auth/login') .send({ - phone: '13800001004', - password: 'Seed@1234', + phone: E2E_SEED_CREDENTIALS[Role.DOCTOR].phone, + password: E2E_SEED_PASSWORD, role: Role.DOCTOR, hospitalId: ctx.fixtures.hospitalAId, }); @@ -70,17 +75,152 @@ describe('AuthController (e2e)', () => { expect(response.body.data.actor.role).toBe(Role.DOCTOR); }); + it('成功:同手机号命中多个账号时先返回候选,再确认登录', async () => { + const sharedPhone = uniquePhone(); + const firstUser = await request(ctx.app.getHttpServer()) + .post('/users') + .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`) + .send({ + name: uniqueSeedValue('Password 多账号医生'), + phone: sharedPhone, + password: 'Seed@1234', + role: Role.DOCTOR, + hospitalId: ctx.fixtures.hospitalAId, + departmentId: ctx.fixtures.departmentA1Id, + groupId: ctx.fixtures.groupA1Id, + }); + expectSuccessEnvelope(firstUser, 201); + + const secondUser = await request(ctx.app.getHttpServer()) + .post('/users') + .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`) + .send({ + name: uniqueSeedValue('Password 多账号工程师'), + phone: sharedPhone, + password: 'Seed@1234', + role: Role.ENGINEER, + hospitalId: ctx.fixtures.hospitalAId, + }); + expectSuccessEnvelope(secondUser, 201); + + const firstStage = await request(ctx.app.getHttpServer()) + .post('/auth/login') + .send({ + phone: sharedPhone, + password: 'Seed@1234', + }); + + expectSuccessEnvelope(firstStage, 201); + expect(firstStage.body.data.needSelect).toBe(true); + expect(firstStage.body.data.accounts).toHaveLength(2); + + const confirmResponse = await request(ctx.app.getHttpServer()) + .post('/auth/login/confirm') + .send({ + loginTicket: firstStage.body.data.loginTicket, + userId: firstUser.body.data.id, + }); + + expectSuccessEnvelope(confirmResponse, 201); + expect(confirmResponse.body.data.user.id).toBe(firstUser.body.data.id); + }); + it('失败:密码错误返回 401', async () => { const response = await request(ctx.app.getHttpServer()) .post('/auth/login') .send({ - phone: '13800001004', - password: 'Seed@12345', + phone: E2E_SEED_CREDENTIALS[Role.DOCTOR].phone, + password: 'Wrong@1234', role: Role.DOCTOR, hospitalId: ctx.fixtures.hospitalAId, }); - expectErrorEnvelope(response, 401, '手机号、角色或密码错误'); + expectErrorEnvelope(response, 401, '手机号、密码或角色不匹配'); + }); + }); + + describe('POST /auth/miniapp/b/phone-login', () => { + it('成功:单院内账号手机号可直接登录', async () => { + const response = await request(ctx.app.getHttpServer()) + .post('/auth/miniapp/b/phone-login') + .send(buildMiniAppMockPayload('13800001004', 'seed-doctor-a-openid')); + + expectSuccessEnvelope(response, 201); + expect(response.body.data.accessToken).toEqual(expect.any(String)); + expect(response.body.data.actor.role).toBe(Role.DOCTOR); + }); + + it('成功:同手机号多账号时先返回候选,再确认登录', async () => { + const sharedPhone = uniquePhone(); + const firstUser = await request(ctx.app.getHttpServer()) + .post('/users') + .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`) + .send({ + name: uniqueSeedValue('MiniApp 多账号医生'), + phone: sharedPhone, + role: Role.DOCTOR, + hospitalId: ctx.fixtures.hospitalAId, + departmentId: ctx.fixtures.departmentA1Id, + groupId: ctx.fixtures.groupA1Id, + }); + expectSuccessEnvelope(firstUser, 201); + + const secondUser = await request(ctx.app.getHttpServer()) + .post('/users') + .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`) + .send({ + name: uniqueSeedValue('MiniApp 多账号工程师'), + phone: sharedPhone, + role: Role.ENGINEER, + hospitalId: ctx.fixtures.hospitalAId, + }); + expectSuccessEnvelope(secondUser, 201); + + const firstStage = await request(ctx.app.getHttpServer()) + .post('/auth/miniapp/b/phone-login') + .send(buildMiniAppMockPayload(sharedPhone, uniqueSeedValue('multi-openid'))); + + expectSuccessEnvelope(firstStage, 201); + expect(firstStage.body.data.needSelect).toBe(true); + expect(firstStage.body.data.accounts).toHaveLength(2); + + const confirmResponse = await request(ctx.app.getHttpServer()) + .post('/auth/miniapp/b/phone-login/confirm') + .send({ + loginTicket: firstStage.body.data.loginTicket, + userId: firstUser.body.data.id, + }); + + expectSuccessEnvelope(confirmResponse, 201); + expect(confirmResponse.body.data.user.id).toBe(firstUser.body.data.id); + }); + + it('失败:手机号未匹配院内账号返回 404', async () => { + const response = await request(ctx.app.getHttpServer()) + .post('/auth/miniapp/b/phone-login') + .send(buildMiniAppMockPayload(uniquePhone(), uniqueSeedValue('no-user-openid'))); + + expectErrorEnvelope(response, 404, '手机号未匹配到院内账号'); + }); + }); + + describe('POST /auth/miniapp/c/phone-login', () => { + it('成功:手机号关联患者时可创建家属账号并返回 token', async () => { + const response = await request(ctx.app.getHttpServer()) + .post('/auth/miniapp/c/phone-login') + .send(buildMiniAppMockPayload('13800002001', 'seed-family-a1-openid')); + + expectSuccessEnvelope(response, 201); + expect(response.body.data.accessToken).toEqual(expect.any(String)); + expect(response.body.data.familyAccount.phone).toBe('13800002001'); + }); + + it('失败:手机号未关联患者档案返回 404', async () => { + const response = await request(ctx.app.getHttpServer()) + .post('/auth/miniapp/c/phone-login') + .send(buildMiniAppMockPayload(uniquePhone(), uniqueSeedValue('family-openid'))); + + expectErrorEnvelope(response, 404, '当前手机号未关联患者档案'); }); }); diff --git a/test/e2e/specs/patients.e2e-spec.ts b/test/e2e/specs/patients.e2e-spec.ts index 3870642..b48aa77 100644 --- a/test/e2e/specs/patients.e2e-spec.ts +++ b/test/e2e/specs/patients.e2e-spec.ts @@ -13,6 +13,7 @@ import { uniquePhone, uniqueSeedValue, } from '../helpers/e2e-http.helper.js'; +import { buildMiniAppMockPayload } from '../helpers/e2e-miniapp-auth.helper.js'; function uniqueIdCard() { const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}` @@ -62,6 +63,15 @@ describe('Patients Controllers (e2e)', () => { }; } + async function loginFamilyByPhone(phone: string, openId?: string) { + const response = await request(ctx.app.getHttpServer()) + .post('/auth/miniapp/c/phone-login') + .send(buildMiniAppMockPayload(phone, openId ?? uniqueSeedValue('family-openid'))); + + expectSuccessEnvelope(response, 201); + return response.body.data.accessToken as string; + } + describe('GET /b/patients', () => { it('成功:按角色返回正确可见性范围', async () => { const systemAdminResponse = await request(ctx.app.getHttpServer()) @@ -185,44 +195,37 @@ describe('Patients Controllers (e2e)', () => { }); }); - describe('GET /c/patients/lifecycle', () => { - it('成功:已登录用户可按 phone + idCard 查询跨院生命周期', async () => { + describe('GET /c/patients/my-lifecycle', () => { + it('成功:家属小程序登录后可按绑定手机号查询跨院生命周期', async () => { + const familyToken = await loginFamilyByPhone( + '13800002001', + 'seed-family-a1-openid', + ); const response = await request(ctx.app.getHttpServer()) - .get('/c/patients/lifecycle') - .query({ - phone: '13800002001', - idCard: '110101199001010011', - }) - .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`); + .get('/c/patients/my-lifecycle') + .set('Authorization', `Bearer ${familyToken}`); expectSuccessEnvelope(response, 200); expect(response.body.data.phone).toBe('13800002001'); - expect(response.body.data.idCard).toBe('110101199001010011'); expect(response.body.data.patientCount).toBeGreaterThanOrEqual(2); expect(Array.isArray(response.body.data.lifecycle)).toBe(true); }); - it('失败:参数缺失返回 400', async () => { + it('失败:未登录返回 401', async () => { const response = await request(ctx.app.getHttpServer()) - .get('/c/patients/lifecycle') - .query({ - phone: '13800002001', - }) - .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`); + .get('/c/patients/my-lifecycle'); - expectErrorEnvelope(response, 400, 'idCard 必须是字符串'); + expectErrorEnvelope(response, 401, '缺少 Bearer Token'); }); - it('失败:不存在患者返回 404', async () => { + it('成功:已存在家属账号再次登录后仍可查询', async () => { + const familyToken = await loginFamilyByPhone('13800002002', 'seed-family-a2-openid'); const response = await request(ctx.app.getHttpServer()) - .get('/c/patients/lifecycle') - .query({ - phone: '13800009999', - idCard: '110101199009090099', - }) - .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`); + .get('/c/patients/my-lifecycle') + .set('Authorization', `Bearer ${familyToken}`); - expectErrorEnvelope(response, 404, '未找到匹配的患者档案'); + expectSuccessEnvelope(response, 200); + expect(response.body.data.phone).toBe('13800002002'); }); }); diff --git a/test/e2e/specs/tasks.e2e-spec.ts b/test/e2e/specs/tasks.e2e-spec.ts index 393f752..bb1e958 100644 --- a/test/e2e/specs/tasks.e2e-spec.ts +++ b/test/e2e/specs/tasks.e2e-spec.ts @@ -13,6 +13,7 @@ import { uniquePhone, uniqueSeedValue, } from '../helpers/e2e-http.helper.js'; +import { loginByMiniApp } from '../helpers/e2e-miniapp-auth.helper.js'; function uniqueIdCard() { const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}` @@ -95,21 +96,18 @@ describe('BTasksController (e2e)', () => { async function loginByUser(userId: number, role: Role, hospitalId: number) { const user = await ctx.prisma.user.findUnique({ where: { id: userId }, - select: { phone: true }, + select: { phone: true, openId: true }, }); expect(user?.phone).toBeTruthy(); + expect(user?.openId).toBeTruthy(); - const response = await request(ctx.app.getHttpServer()) - .post('/auth/login') - .send({ - phone: user?.phone, - password: 'Seed@1234', - role, - hospitalId, - }); - - expectSuccessEnvelope(response, 201); - return response.body.data.accessToken as string; + return loginByMiniApp(ctx.app.getHttpServer(), { + phone: user!.phone, + openId: user!.openId!, + role, + hospitalId, + userId, + }); } async function createAdjustableDevices(options?: { @@ -466,12 +464,10 @@ describe('BTasksController (e2e)', () => { expect(engineerB?.phone).toBeTruthy(); const loginResponse = await request(ctx.app.getHttpServer()) - .post('/auth/login') + .post('/auth/miniapp/b/phone-login') .send({ - phone: engineerB?.phone, - password: 'Seed@1234', - role: Role.ENGINEER, - hospitalId: ctx.fixtures.hospitalBId, + loginCode: `mock-login:${encodeURIComponent('seed-engineer-b-openid')}`, + phoneCode: `mock-phone:${engineerB?.phone}`, }); expectSuccessEnvelope(loginResponse, 201); diff --git a/tyt-admin/components.d.ts b/tyt-admin/components.d.ts index 4dc3202..45beefd 100644 --- a/tyt-admin/components.d.ts +++ b/tyt-admin/components.d.ts @@ -42,6 +42,8 @@ declare module 'vue' { ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] ElOption: typeof import('element-plus/es')['ElOption'] ElPagination: typeof import('element-plus/es')['ElPagination'] + ElRadio: typeof import('element-plus/es')['ElRadio'] + ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup'] ElRow: typeof import('element-plus/es')['ElRow'] ElSelect: typeof import('element-plus/es')['ElSelect'] ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] diff --git a/tyt-admin/src/store/user.js b/tyt-admin/src/store/user.js index 7db76b3..bbd27bc 100644 --- a/tyt-admin/src/store/user.js +++ b/tyt-admin/src/store/user.js @@ -19,24 +19,50 @@ export const useUserStore = defineStore('user', { this.userInfo = info; localStorage.setItem('tyt_user_info', JSON.stringify(info)); }, + applyLoginResponse(data) { + if (!data?.accessToken) { + return false; + } + + this.setToken(data.accessToken); + if (data.user) { + this.setUserInfo(data.user); + } + return true; + }, async login(loginForm) { const payload = { ...loginForm }; + if (!payload.role) { + delete payload.role; + } if (!payload.hospitalId) { delete payload.hospitalId; } const data = await request.post('/auth/login', payload); - // The backend should return the token in data.accessToken - if (data && data.accessToken) { - this.setToken(data.accessToken); - // Backend also returns actor and user info directly in login response - if (data.user) { - this.setUserInfo(data.user); - } else { - await this.fetchUserInfo(); - } - return true; + if (data?.needSelect) { + return { + needSelect: true, + loginTicket: data.loginTicket, + accounts: data.accounts || [], + }; } - return false; + + const success = this.applyLoginResponse(data); + return { + needSelect: false, + success, + }; + }, + async confirmLogin(loginTicket, userId) { + const data = await request.post('/auth/login/confirm', { + loginTicket, + userId, + }); + + const success = this.applyLoginResponse(data); + return { + success, + }; }, async fetchUserInfo() { try { diff --git a/tyt-admin/src/views/Login.vue b/tyt-admin/src/views/Login.vue index 9179f04..fa19b11 100644 --- a/tyt-admin/src/views/Login.vue +++ b/tyt-admin/src/views/Login.vue @@ -26,33 +26,10 @@ :prefix-icon="Lock" /> - - - - - - - - - - - - - @@ -66,6 +43,40 @@ + + + + + + @@ -82,12 +93,15 @@ const userStore = useUserStore(); const loginFormRef = ref(null); const loading = ref(false); +const confirmLoading = ref(false); +const accountSelectVisible = ref(false); +const selectedAccountId = ref(null); +const pendingLoginTicket = ref(''); +const candidateAccounts = ref([]); const loginForm = reactive({ phone: '', password: '', - role: 'SYSTEM_ADMIN', // Default role - hospitalId: null, }); const rules = { @@ -99,7 +113,27 @@ const rules = { { required: true, message: '请输入密码', trigger: 'blur' }, { min: 8, message: '密码长度至少为 8 位', trigger: 'blur' }, ], - role: [{ required: true, message: '请选择角色', trigger: 'change' }], +}; + +const formatAccountMeta = (account) => { + const parts = [account.role]; + if (account.hospitalName) { + parts.push(account.hospitalName); + } + return parts.join(' / '); +}; + +const redirectToTarget = () => { + ElMessage.success('登录成功'); + const redirect = route.query.redirect || '/'; + router.push(redirect); +}; + +const resetAccountSelection = () => { + accountSelectVisible.value = false; + selectedAccountId.value = null; + pendingLoginTicket.value = ''; + candidateAccounts.value = []; }; const handleLogin = async () => { @@ -108,11 +142,17 @@ const handleLogin = async () => { if (valid) { loading.value = true; try { - const success = await userStore.login(loginForm); - if (success) { - ElMessage.success('登录成功'); - const redirect = route.query.redirect || '/'; - router.push(redirect); + const result = await userStore.login(loginForm); + if (result?.needSelect) { + pendingLoginTicket.value = result.loginTicket; + candidateAccounts.value = result.accounts || []; + selectedAccountId.value = result.accounts?.[0]?.id ?? null; + accountSelectVisible.value = true; + return; + } + + if (result?.success) { + redirectToTarget(); } else { ElMessage.error('登录失败,未获取到登录信息'); } @@ -124,6 +164,32 @@ const handleLogin = async () => { } }); }; + +const handleConfirmLogin = async () => { + if (!pendingLoginTicket.value || !selectedAccountId.value) { + ElMessage.warning('请先选择一个账号'); + return; + } + + confirmLoading.value = true; + try { + const result = await userStore.confirmLogin( + pendingLoginTicket.value, + selectedAccountId.value, + ); + if (result?.success) { + resetAccountSelection(); + redirectToTarget(); + return; + } + + ElMessage.error('登录失败,未获取到登录信息'); + } catch (error) { + console.error('Confirm login failed', error); + } finally { + confirmLoading.value = false; + } +};