Compare commits
2 Commits
0b5640a977
...
7c4ba1e1a0
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c4ba1e1a0 | |||
| 19c08a7618 |
@ -1,3 +1,5 @@
|
|||||||
DATABASE_URL="postgresql://postgres:lyh1234@192.168.0.180:5432/tyt-api-nest"
|
DATABASE_URL="postgresql://postgres:lyh1234@192.168.0.180:5432/tyt-api-nest"
|
||||||
AUTH_TOKEN_SECRET="replace-with-a-strong-random-secret"
|
AUTH_TOKEN_SECRET="replace-with-a-strong-random-secret"
|
||||||
SYSTEM_ADMIN_BOOTSTRAP_KEY="replace-with-admin-bootstrap-key"
|
SYSTEM_ADMIN_BOOTSTRAP_KEY="replace-with-admin-bootstrap-key"
|
||||||
|
WECHAT_MINIAPP_APPID="replace-with-miniapp-appid"
|
||||||
|
WECHAT_MINIAPP_SECRET="replace-with-miniapp-secret"
|
||||||
|
|||||||
79
docs/auth.md
79
docs/auth.md
@ -2,39 +2,74 @@
|
|||||||
|
|
||||||
## 1. 目标
|
## 1. 目标
|
||||||
|
|
||||||
- 提供系统管理员创建、登录、`/me` 身份查询。
|
- 提供系统管理员创建、院内账号密码登录、B 端小程序手机号登录、C 端小程序手机号登录、`/me` 身份查询。
|
||||||
- 使用 JWT 做认证,Guard 做鉴权,RolesGuard 做 RBAC。
|
- 使用 JWT 做认证,院内账号与家属小程序账号走两套守卫。
|
||||||
|
|
||||||
## 2. 核心接口
|
## 2. 核心接口
|
||||||
|
|
||||||
- `POST /auth/system-admin`:创建系统管理员(需引导密钥)
|
- `POST /auth/system-admin`:创建系统管理员(需引导密钥)
|
||||||
- `POST /auth/login`:手机号 + 角色 + 密码登录(支持同手机号多院场景)
|
- `POST /auth/login`:院内账号密码登录,后台与小程序均可复用
|
||||||
- `GET /auth/me`:返回当前登录用户上下文
|
- `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。
|
1. `AccessTokenGuard` 从 `Authorization` 读取 Bearer Token。
|
||||||
2. 校验 JWT 签名、`id`、`iat` 等关键载荷字段。
|
2. 校验 JWT 签名、`id`、`iat` 等关键载荷字段。
|
||||||
3. 根据 `id` 回库读取用户当前角色与组织归属,不再直接信任 token 里的角色和范围。
|
3. 根据 `id` 回库读取 `User` 当前角色与组织归属。
|
||||||
4. 校验 `iat >= user.tokenValidAfter`,若用户被重置密码、seed 重刷或账号被清理,则旧 token 立即失效。
|
4. 校验 `iat >= user.tokenValidAfter`,保证旧 token 失效。
|
||||||
5. 当前数据库用户映射为 `ActorContext` 注入 `request.actor`。
|
|
||||||
6. `RolesGuard` 根据 `@Roles(...)` 判断角色是否允许访问。
|
|
||||||
|
|
||||||
## 4. Token 约定
|
### 家属小程序账号
|
||||||
|
|
||||||
- Header:`Authorization: Bearer <token>`
|
1. `FamilyAccessTokenGuard` 从 `Authorization` 读取 Bearer Token。
|
||||||
- 载荷关键字段:`id`、`iat`
|
2. 校验 C 端 token 的 `id + type=FAMILY_MINIAPP`。
|
||||||
- 角色和组织范围以数据库当前用户记录为准,不以 token 历史载荷为准
|
3. 根据 `id` 回库读取 `FamilyMiniAppAccount`。
|
||||||
|
4. 将家属账号上下文注入 `request.familyActor`。
|
||||||
|
|
||||||
## 5. 错误码与中文消息
|
## 6. 环境变量
|
||||||
|
|
||||||
- 未登录/Token 失效:`401` + 中文 `msg`
|
- `AUTH_TOKEN_SECRET`
|
||||||
- 角色无权限:`403` + 中文 `msg`
|
- `SYSTEM_ADMIN_BOOTSTRAP_KEY`
|
||||||
- 参数非法:`400` + 中文 `msg`
|
- `WECHAT_MINIAPP_APPID`
|
||||||
|
- `WECHAT_MINIAPP_SECRET`
|
||||||
|
|
||||||
统一由全局异常过滤器输出:`{ code, msg, data: null }`。
|
## 7. 关键规则
|
||||||
|
|
||||||
## 6. 失效策略
|
- 后台 Web 与 B 端小程序都可以复用 `POST /auth/login` 做账号密码登录。
|
||||||
|
- 密码登录命中多个院内账号时,不再强制前端手填 `hospitalId`,改为后端返回候选账号供选择。
|
||||||
- 用户密码被修改后,会刷新 `user.tokenValidAfter`,旧 token 全部失效。
|
- B 端小程序登录复用 `User` 表,继续使用 `openId`。
|
||||||
- 执行 E2E 重置并重新 seed 后,seed 账号的 `tokenValidAfter` 也会刷新,历史 token 不可继续复用。
|
- B 端账号未绑定 `openId` 时,首次小程序登录自动绑定。
|
||||||
|
- 同一个 `openId` 可绑定多个院内账号,支持同一微信号切换多角色/多院区账号。
|
||||||
|
- 若目标院内账号已绑定其他微信号,仍需先在用户管理中清空该账号的 `openId` 再重新绑定。
|
||||||
|
- C 端家属账号独立存放在 `FamilyMiniAppAccount`。
|
||||||
|
- C 端手机号必须先存在于患者档案,否则拒绝登录。
|
||||||
|
- `serviceUid` 仅预留字段,本次不提供绑定接口。
|
||||||
|
|||||||
@ -1,86 +1,46 @@
|
|||||||
# 前端接口接入说明(`tyt-admin`)
|
# 前后端联调说明
|
||||||
|
|
||||||
## 1. 本次接入范围
|
## 1. 登录
|
||||||
|
|
||||||
- 登录页:`/auth/login`,支持可选 `hospitalId`。
|
### B 端账号密码登录
|
||||||
- 首页看板:按角色拉取组织与患者统计。
|
|
||||||
- 设备页:新增管理员专用设备 CRUD,复用真实设备接口。
|
|
||||||
- 任务页:接入真实任务列表、工程师接收与完成接口。
|
|
||||||
- 用户页:修复用户列表响应结构、组织字段联动、工程师分配医院参数。
|
|
||||||
- 患者页:接入真实患者字段与生命周期查询参数(`phone + idCard`),
|
|
||||||
后端直接保存身份证号原文,不再做哈希转换;调压任务入口迁到患者页。
|
|
||||||
- 新增影像库页:接入真实上传接口,支持图片/视频/文件上传与分页查看。
|
|
||||||
|
|
||||||
## 2. 接口契约对齐点
|
- `POST /auth/login`
|
||||||
|
- 入参:
|
||||||
|
- `phone`
|
||||||
|
- `password`
|
||||||
|
- `role`(可选)
|
||||||
|
- `hospitalId`(可选)
|
||||||
|
- 若返回 `needSelect: true`,继续调用:
|
||||||
|
- `POST /auth/login/confirm`
|
||||||
|
- 入参:`loginTicket + userId`
|
||||||
|
|
||||||
- `GET /users` 当前返回数组,前端已在 `api/users.js` 做本地分页与筛选适配。
|
### B 端小程序
|
||||||
- `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`。
|
|
||||||
|
|
||||||
## 3. 角色权限提示
|
- 第一步:`POST /auth/miniapp/b/phone-login`
|
||||||
|
- 入参:
|
||||||
|
- `loginCode`
|
||||||
|
- `phoneCode`
|
||||||
|
- 若返回 `needSelect: true`,继续调用:
|
||||||
|
- `POST /auth/miniapp/b/phone-login/confirm`
|
||||||
|
- 入参:`loginTicket + userId`
|
||||||
|
|
||||||
- 任务接口权限:
|
### C 端小程序
|
||||||
- `SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER`:发布时不再指定接收工程师;可取消自己创建的任务
|
|
||||||
- `ENGINEER`:可接收本院待接收任务;仅可完成自己已接收的任务
|
|
||||||
- 患者列表权限:
|
|
||||||
- `SYSTEM_ADMIN` 查询时必须传 `hospitalId`
|
|
||||||
- 用户管理接口:
|
|
||||||
- `SYSTEM_ADMIN/HOSPITAL_ADMIN` 可创建、编辑、删除
|
|
||||||
- `DIRECTOR` 可只读查看本科室下级医生/组长
|
|
||||||
- 工程师绑定医院仅 `SYSTEM_ADMIN`
|
|
||||||
- 删除:`SYSTEM_ADMIN` 可删除任意无关联用户;`HOSPITAL_ADMIN` 可删除本院无关联、且非管理员用户
|
|
||||||
|
|
||||||
## 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`
|
## 3. B 端说明
|
||||||
- `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` 可访问
|
|
||||||
|
|
||||||
患者页负责发起调压任务,任务页负责查看、接收与完成调压任务。
|
- B 端业务接口仍使用 Bearer Token
|
||||||
|
- 后台管理端与小程序都可以复用 `POST /auth/login` 做账号密码登录
|
||||||
患者手术表单中的主刀医生不再单独选择,直接跟随患者归属医生展示和保存。
|
- `GET /auth/me` 仍可读取当前院内账号信息
|
||||||
|
- 同手机号多账号时,前端必须先让用户选定账号,再提交确认登录
|
||||||
前端已在路由守卫和侧边栏菜单同时做权限控制,无权限角色会被拦截并跳转到首页,避免进入页面后触发接口 `403`。
|
- 同一个微信号可绑定多个院内账号,切换账号时继续走“小程序登录 -> 候选账号选择”即可
|
||||||
|
|
||||||
## 3.3 主任/组长组织管理范围
|
|
||||||
|
|
||||||
- `DIRECTOR`
|
|
||||||
- 仅保留业务使用和患者管理能力,不再承担科室/小组/医生管理
|
|
||||||
- `LEADER`
|
|
||||||
- 仅保留业务使用和患者管理能力,不再承担科室/小组/医生管理
|
|
||||||
- 主任/组长不再显示“科室管理”“小组管理”“用户管理”页面。
|
|
||||||
- 负责人设置(设主任/设组长)入口仍仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 显示。
|
|
||||||
|
|
||||||
## 4. 本地运行
|
|
||||||
|
|
||||||
在 `tyt-admin` 目录执行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm install
|
|
||||||
pnpm dev
|
|
||||||
```
|
|
||||||
|
|||||||
145
docs/patients.md
145
docs/patients.md
@ -2,134 +2,33 @@
|
|||||||
|
|
||||||
## 1. 目标
|
## 1. 目标
|
||||||
|
|
||||||
- B 端:按组织与角色范围查询患者,并维护患者基础档案。
|
- B 端:维护患者、手术、植入设备及生命周期数据。
|
||||||
- B 端:支持患者首术录入、二次手术追加、旧设备弃用标记。
|
- C 端:家属小程序登录后,按已绑定手机号聚合查询跨院患者生命周期。
|
||||||
- 数据关系升级为 `Patient -> PatientSurgery -> Device -> TaskItem -> Task`。
|
|
||||||
- C 端:按 `phone + idCard` 做跨院生命周期聚合,返回手术事件与调压事件。
|
|
||||||
|
|
||||||
## 2. 患者基础档案
|
## 2. B 端能力
|
||||||
|
|
||||||
患者表新增以下字段:
|
- 患者列表、详情、创建、更新、删除
|
||||||
|
- 手术记录新增
|
||||||
|
- 植入设备录入与历史保留
|
||||||
|
|
||||||
- `inpatientNo`:住院号
|
## 3. C 端能力
|
||||||
- `projectName`:项目名称
|
|
||||||
- `phone`:联系电话
|
|
||||||
- `idCard`:身份证号原文
|
|
||||||
- `doctorId`:患者归属人员(医生/主任/组长)
|
|
||||||
|
|
||||||
说明:
|
- 家属账号通过小程序手机号登录
|
||||||
|
- `GET /c/patients/my-lifecycle`
|
||||||
|
- 查询口径:按 `FamilyMiniAppAccount.phone` 聚合 `Patient.phone`
|
||||||
|
- 返回内容:手术事件 + 调压事件的时间线
|
||||||
|
|
||||||
- `name` 仍然保留为患者姓名必填字段。
|
## 4. 当前规则
|
||||||
- `doctorId + hospitalId` 仍然是患者的组织归属来源,不直接绑定小组/科室。
|
|
||||||
- 手术表单中的 `原发病 / 脑积水类型 / 分流方式 / 近端穿刺区域 / 阀门植入部位 / 远端分流方向` 改为读取系统字典,不再前端硬编码。
|
|
||||||
|
|
||||||
## 3. 手术档案
|
- 同一个手机号可关联多个患者,C 端会统一聚合返回。
|
||||||
|
- C 端手机号来源于患者手术/档案中维护的联系电话。
|
||||||
|
- 仅已登录的家属小程序账号可访问 `my-lifecycle`。
|
||||||
|
- 家属账号不存在或 token 无效时返回 `401`。
|
||||||
|
- 手机号下无患者档案时,登录阶段直接拦截,不进入生命周期查询。
|
||||||
|
|
||||||
新增 `PatientSurgery` 表,每次手术保存:
|
## 5. 典型接口
|
||||||
|
|
||||||
- `surgeryDate`:手术日期
|
- `GET /b/patients`
|
||||||
- `surgeryName`:手术名称
|
- `POST /b/patients`
|
||||||
- `surgeonId`:主刀医生账号 ID(自动等于患者归属医生)
|
- `POST /b/patients/:id/surgeries`
|
||||||
- `surgeonName`:主刀医生姓名快照
|
- `GET /c/patients/my-lifecycle`
|
||||||
- `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 }`
|
|
||||||
|
|||||||
@ -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");
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
-- 允许同一个微信 openId 绑定多个院内账号,保留普通索引供查询复用。
|
||||||
|
DROP INDEX IF EXISTS "User_openId_key";
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "User_openId_idx" ON "User"("openId");
|
||||||
@ -55,7 +55,7 @@ enum UploadAssetType {
|
|||||||
|
|
||||||
// 医院主表:多租户顶层实体。
|
// 医院主表:多租户顶层实体。
|
||||||
model Hospital {
|
model Hospital {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
departments Department[]
|
departments Department[]
|
||||||
users User[]
|
users User[]
|
||||||
@ -88,6 +88,7 @@ model Group {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 用户表:支持后台密码登录与小程序 openId。
|
// 用户表:支持后台密码登录与小程序 openId。
|
||||||
|
// 同一个微信 openId 允许绑定多个院内账号,便于多角色/多院区切换。
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
@ -96,7 +97,7 @@ model User {
|
|||||||
passwordHash String?
|
passwordHash String?
|
||||||
// 该时间点之前签发的 token 一律失效。
|
// 该时间点之前签发的 token 一律失效。
|
||||||
tokenValidAfter DateTime @default(now())
|
tokenValidAfter DateTime @default(now())
|
||||||
openId String? @unique
|
openId String?
|
||||||
role Role
|
role Role
|
||||||
hospitalId Int?
|
hospitalId Int?
|
||||||
departmentId Int?
|
departmentId Int?
|
||||||
@ -114,11 +115,25 @@ model User {
|
|||||||
|
|
||||||
@@unique([phone, role, hospitalId])
|
@@unique([phone, role, hospitalId])
|
||||||
@@index([phone])
|
@@index([phone])
|
||||||
|
@@index([openId])
|
||||||
@@index([hospitalId, role])
|
@@index([hospitalId, role])
|
||||||
@@index([departmentId, role])
|
@@index([departmentId, role])
|
||||||
@@index([groupId, role])
|
@@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 {
|
model Patient {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
@ -261,18 +276,18 @@ model Device {
|
|||||||
|
|
||||||
// 主任务表:记录调压任务主单。
|
// 主任务表:记录调压任务主单。
|
||||||
model Task {
|
model Task {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
status TaskStatus @default(PENDING)
|
status TaskStatus @default(PENDING)
|
||||||
creatorId Int
|
creatorId Int
|
||||||
engineerId Int?
|
engineerId Int?
|
||||||
hospitalId Int
|
hospitalId Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
// 工程师完成任务时上传的图片/视频凭证。
|
// 工程师完成任务时上传的图片/视频凭证。
|
||||||
completionMaterials Json?
|
completionMaterials Json?
|
||||||
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
|
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
|
||||||
engineer User? @relation("TaskEngineer", fields: [engineerId], references: [id])
|
engineer User? @relation("TaskEngineer", fields: [engineerId], references: [id])
|
||||||
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
||||||
items TaskItem[]
|
items TaskItem[]
|
||||||
|
|
||||||
@@index([hospitalId, status, createdAt])
|
@@index([hospitalId, status, createdAt])
|
||||||
}
|
}
|
||||||
|
|||||||
102
prisma/seed.mjs
102
prisma/seed.mjs
@ -46,18 +46,21 @@ async function ensureGroup(departmentId, name) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upsertUserByOpenId(openId, data) {
|
async function upsertUserByScope(data) {
|
||||||
return prisma.user.upsert({
|
return prisma.user.upsert({
|
||||||
where: { openId },
|
where: {
|
||||||
|
phone_role_hospitalId: {
|
||||||
|
phone: data.phone,
|
||||||
|
role: data.role,
|
||||||
|
hospitalId: data.hospitalId,
|
||||||
|
},
|
||||||
|
},
|
||||||
// 每次重置/补种子时推进失效时间,确保历史 token 无法继续访问。
|
// 每次重置/补种子时推进失效时间,确保历史 token 无法继续访问。
|
||||||
update: {
|
update: {
|
||||||
...data,
|
...data,
|
||||||
tokenValidAfter: new Date(),
|
tokenValidAfter: new Date(),
|
||||||
},
|
},
|
||||||
create: {
|
create: data,
|
||||||
...data,
|
|
||||||
openId,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,6 +112,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({
|
async function ensureImplantCatalog({
|
||||||
modelCode,
|
modelCode,
|
||||||
manufacturer,
|
manufacturer,
|
||||||
@ -290,113 +323,121 @@ async function main() {
|
|||||||
const groupA2 = await ensureGroup(departmentA2.id, 'Shift-A2');
|
const groupA2 = await ensureGroup(departmentA2.id, 'Shift-A2');
|
||||||
const groupB1 = await ensureGroup(departmentB1.id, 'Shift-B1');
|
const groupB1 = await ensureGroup(departmentB1.id, 'Shift-B1');
|
||||||
|
|
||||||
const systemAdmin = await upsertUserByOpenId('seed-system-admin-openid', {
|
const systemAdmin = await upsertUserByScope({
|
||||||
name: 'Seed System Admin',
|
name: 'Seed System Admin',
|
||||||
phone: '13800001000',
|
phone: '13800001000',
|
||||||
passwordHash: seedPasswordHash,
|
passwordHash: seedPasswordHash,
|
||||||
|
openId: 'seed-system-admin-openid',
|
||||||
role: Role.SYSTEM_ADMIN,
|
role: Role.SYSTEM_ADMIN,
|
||||||
hospitalId: null,
|
hospitalId: null,
|
||||||
departmentId: null,
|
departmentId: null,
|
||||||
groupId: null,
|
groupId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const hospitalAdminA = await upsertUserByOpenId(
|
const hospitalAdminA = await upsertUserByScope({
|
||||||
'seed-hospital-admin-a-openid',
|
name: 'Seed Hospital Admin A',
|
||||||
{
|
phone: '13800001001',
|
||||||
name: 'Seed Hospital Admin A',
|
passwordHash: seedPasswordHash,
|
||||||
phone: '13800001001',
|
openId: 'seed-hospital-admin-a-openid',
|
||||||
passwordHash: seedPasswordHash,
|
role: Role.HOSPITAL_ADMIN,
|
||||||
role: Role.HOSPITAL_ADMIN,
|
hospitalId: hospitalA.id,
|
||||||
hospitalId: hospitalA.id,
|
departmentId: null,
|
||||||
departmentId: null,
|
groupId: null,
|
||||||
groupId: null,
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await upsertUserByOpenId('seed-hospital-admin-b-openid', {
|
await upsertUserByScope({
|
||||||
name: 'Seed Hospital Admin B',
|
name: 'Seed Hospital Admin B',
|
||||||
phone: '13800001101',
|
phone: '13800001101',
|
||||||
passwordHash: seedPasswordHash,
|
passwordHash: seedPasswordHash,
|
||||||
|
openId: 'seed-hospital-admin-b-openid',
|
||||||
role: Role.HOSPITAL_ADMIN,
|
role: Role.HOSPITAL_ADMIN,
|
||||||
hospitalId: hospitalB.id,
|
hospitalId: hospitalB.id,
|
||||||
departmentId: null,
|
departmentId: null,
|
||||||
groupId: null,
|
groupId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const directorA = await upsertUserByOpenId('seed-director-a-openid', {
|
const directorA = await upsertUserByScope({
|
||||||
name: 'Seed Director A',
|
name: 'Seed Director A',
|
||||||
phone: '13800001002',
|
phone: '13800001002',
|
||||||
passwordHash: seedPasswordHash,
|
passwordHash: seedPasswordHash,
|
||||||
|
openId: 'seed-director-a-openid',
|
||||||
role: Role.DIRECTOR,
|
role: Role.DIRECTOR,
|
||||||
hospitalId: hospitalA.id,
|
hospitalId: hospitalA.id,
|
||||||
departmentId: departmentA1.id,
|
departmentId: departmentA1.id,
|
||||||
groupId: null,
|
groupId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const leaderA = await upsertUserByOpenId('seed-leader-a-openid', {
|
const leaderA = await upsertUserByScope({
|
||||||
name: 'Seed Leader A',
|
name: 'Seed Leader A',
|
||||||
phone: '13800001003',
|
phone: '13800001003',
|
||||||
passwordHash: seedPasswordHash,
|
passwordHash: seedPasswordHash,
|
||||||
|
openId: 'seed-leader-a-openid',
|
||||||
role: Role.LEADER,
|
role: Role.LEADER,
|
||||||
hospitalId: hospitalA.id,
|
hospitalId: hospitalA.id,
|
||||||
departmentId: departmentA1.id,
|
departmentId: departmentA1.id,
|
||||||
groupId: groupA1.id,
|
groupId: groupA1.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const doctorA = await upsertUserByOpenId('seed-doctor-a-openid', {
|
const doctorA = await upsertUserByScope({
|
||||||
name: 'Seed Doctor A',
|
name: 'Seed Doctor A',
|
||||||
phone: '13800001004',
|
phone: '13800001004',
|
||||||
passwordHash: seedPasswordHash,
|
passwordHash: seedPasswordHash,
|
||||||
|
openId: 'seed-doctor-a-openid',
|
||||||
role: Role.DOCTOR,
|
role: Role.DOCTOR,
|
||||||
hospitalId: hospitalA.id,
|
hospitalId: hospitalA.id,
|
||||||
departmentId: departmentA1.id,
|
departmentId: departmentA1.id,
|
||||||
groupId: groupA1.id,
|
groupId: groupA1.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const doctorA2 = await upsertUserByOpenId('seed-doctor-a2-openid', {
|
const doctorA2 = await upsertUserByScope({
|
||||||
name: 'Seed Doctor A2',
|
name: 'Seed Doctor A2',
|
||||||
phone: '13800001204',
|
phone: '13800001204',
|
||||||
passwordHash: seedPasswordHash,
|
passwordHash: seedPasswordHash,
|
||||||
|
openId: 'seed-doctor-a2-openid',
|
||||||
role: Role.DOCTOR,
|
role: Role.DOCTOR,
|
||||||
hospitalId: hospitalA.id,
|
hospitalId: hospitalA.id,
|
||||||
departmentId: departmentA1.id,
|
departmentId: departmentA1.id,
|
||||||
groupId: groupA1.id,
|
groupId: groupA1.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const doctorA3 = await upsertUserByOpenId('seed-doctor-a3-openid', {
|
const doctorA3 = await upsertUserByScope({
|
||||||
name: 'Seed Doctor A3',
|
name: 'Seed Doctor A3',
|
||||||
phone: '13800001304',
|
phone: '13800001304',
|
||||||
passwordHash: seedPasswordHash,
|
passwordHash: seedPasswordHash,
|
||||||
|
openId: 'seed-doctor-a3-openid',
|
||||||
role: Role.DOCTOR,
|
role: Role.DOCTOR,
|
||||||
hospitalId: hospitalA.id,
|
hospitalId: hospitalA.id,
|
||||||
departmentId: departmentA2.id,
|
departmentId: departmentA2.id,
|
||||||
groupId: groupA2.id,
|
groupId: groupA2.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const doctorB = await upsertUserByOpenId('seed-doctor-b-openid', {
|
const doctorB = await upsertUserByScope({
|
||||||
name: 'Seed Doctor B',
|
name: 'Seed Doctor B',
|
||||||
phone: '13800001104',
|
phone: '13800001104',
|
||||||
passwordHash: seedPasswordHash,
|
passwordHash: seedPasswordHash,
|
||||||
|
openId: 'seed-doctor-b-openid',
|
||||||
role: Role.DOCTOR,
|
role: Role.DOCTOR,
|
||||||
hospitalId: hospitalB.id,
|
hospitalId: hospitalB.id,
|
||||||
departmentId: departmentB1.id,
|
departmentId: departmentB1.id,
|
||||||
groupId: groupB1.id,
|
groupId: groupB1.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const engineerA = await upsertUserByOpenId('seed-engineer-a-openid', {
|
const engineerA = await upsertUserByScope({
|
||||||
name: 'Seed Engineer A',
|
name: 'Seed Engineer A',
|
||||||
phone: '13800001005',
|
phone: '13800001005',
|
||||||
passwordHash: seedPasswordHash,
|
passwordHash: seedPasswordHash,
|
||||||
|
openId: 'seed-engineer-a-openid',
|
||||||
role: Role.ENGINEER,
|
role: Role.ENGINEER,
|
||||||
hospitalId: hospitalA.id,
|
hospitalId: hospitalA.id,
|
||||||
departmentId: null,
|
departmentId: null,
|
||||||
groupId: null,
|
groupId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const engineerB = await upsertUserByOpenId('seed-engineer-b-openid', {
|
const engineerB = await upsertUserByScope({
|
||||||
name: 'Seed Engineer B',
|
name: 'Seed Engineer B',
|
||||||
phone: '13800001105',
|
phone: '13800001105',
|
||||||
passwordHash: seedPasswordHash,
|
passwordHash: seedPasswordHash,
|
||||||
|
openId: 'seed-engineer-b-openid',
|
||||||
role: Role.ENGINEER,
|
role: Role.ENGINEER,
|
||||||
hospitalId: hospitalB.id,
|
hospitalId: hospitalB.id,
|
||||||
departmentId: null,
|
departmentId: null,
|
||||||
@ -496,6 +537,11 @@ async function main() {
|
|||||||
idCard: '110101199001010011',
|
idCard: '110101199001010011',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await ensureFamilyMiniAppAccount({
|
||||||
|
phone: patientA2.phone,
|
||||||
|
openId: 'seed-family-a2-openid',
|
||||||
|
});
|
||||||
|
|
||||||
const adjustableCatalog = await ensureImplantCatalog({
|
const adjustableCatalog = await ensureImplantCatalog({
|
||||||
modelCode: 'SEED-ADJUSTABLE-VALVE',
|
modelCode: 'SEED-ADJUSTABLE-VALVE',
|
||||||
manufacturer: 'Seed MedTech',
|
manufacturer: 'Seed MedTech',
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { AuthService } from './auth.service.js';
|
import { AuthService } from './auth.service.js';
|
||||||
import { LoginDto } from '../users/dto/login.dto.js';
|
|
||||||
import { AccessTokenGuard } from './access-token.guard.js';
|
import { AccessTokenGuard } from './access-token.guard.js';
|
||||||
import { CurrentActor } from './current-actor.decorator.js';
|
import { CurrentActor } from './current-actor.decorator.js';
|
||||||
import type { ActorContext } from '../common/actor-context.js';
|
import type { ActorContext } from '../common/actor-context.js';
|
||||||
import { CreateSystemAdminDto } from './dto/create-system-admin.dto.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')
|
@Post('login')
|
||||||
@ApiOperation({ summary: '登录' })
|
@ApiOperation({ summary: '院内账号密码登录' })
|
||||||
login(@Body() dto: LoginDto) {
|
login(@Body() dto: LoginDto) {
|
||||||
return this.authService.login(dto);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前登录用户信息。
|
* 获取当前登录用户信息。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -3,14 +3,16 @@ import { AuthService } from './auth.service.js';
|
|||||||
import { AuthController } from './auth.controller.js';
|
import { AuthController } from './auth.controller.js';
|
||||||
import { UsersModule } from '../users/users.module.js';
|
import { UsersModule } from '../users/users.module.js';
|
||||||
import { AccessTokenGuard } from './access-token.guard.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({
|
@Module({
|
||||||
imports: [UsersModule],
|
imports: [UsersModule],
|
||||||
providers: [AuthService, AccessTokenGuard],
|
providers: [AuthService, AccessTokenGuard, WechatMiniAppService, MiniAppAuthService],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
exports: [AuthService, AccessTokenGuard],
|
exports: [AuthService, AccessTokenGuard, WechatMiniAppService, MiniAppAuthService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@ -3,13 +3,20 @@ import type { ActorContext } from '../common/actor-context.js';
|
|||||||
import { UsersService } from '../users/users.service.js';
|
import { UsersService } from '../users/users.service.js';
|
||||||
import { LoginDto } from '../users/dto/login.dto.js';
|
import { LoginDto } from '../users/dto/login.dto.js';
|
||||||
import { CreateSystemAdminDto } from './dto/create-system-admin.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()
|
@Injectable()
|
||||||
export class AuthService {
|
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) {
|
login(dto: LoginDto) {
|
||||||
return this.usersService.login(dto);
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 读取当前登录用户详情。
|
* 读取当前登录用户详情。
|
||||||
*/
|
*/
|
||||||
|
|||||||
12
src/auth/current-family-actor.decorator.ts
Normal file
12
src/auth/current-family-actor.decorator.ts
Normal file
@ -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;
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -15,7 +15,7 @@ export class CreateSystemAdminDto {
|
|||||||
password!: string;
|
password!: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: '可选微信 openId',
|
description: '可选微信 openId(院内账号间可复用)',
|
||||||
example: 'o123abcxyz',
|
example: 'o123abcxyz',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
24
src/auth/dto/miniapp-phone-login-confirm.dto.ts
Normal file
24
src/auth/dto/miniapp-phone-login-confirm.dto.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
21
src/auth/dto/miniapp-phone-login.dto.ts
Normal file
21
src/auth/dto/miniapp-phone-login.dto.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
24
src/auth/dto/password-login-confirm.dto.ts
Normal file
24
src/auth/dto/password-login-confirm.dto.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
82
src/auth/family-access/family-access.guard.ts
Normal file
82
src/auth/family-access/family-access.guard.ts
Normal file
@ -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<boolean> {
|
||||||
|
const request = context.switchToHttp().getRequest<{
|
||||||
|
headers: Record<string, string | string[] | undefined>;
|
||||||
|
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<FamilyActorContext> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
251
src/auth/miniapp-auth/miniapp-auth.service.ts
Normal file
251
src/auth/miniapp-auth/miniapp-auth.service.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
176
src/auth/wechat-miniapp/wechat-miniapp.service.ts
Normal file
176
src/auth/wechat-miniapp/wechat-miniapp.service.ts
Normal file
@ -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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/common/family-actor-context.ts
Normal file
6
src/common/family-actor-context.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export type FamilyActorContext = {
|
||||||
|
id: number;
|
||||||
|
phone: string;
|
||||||
|
openId: string | null;
|
||||||
|
serviceUid: string | null;
|
||||||
|
};
|
||||||
@ -25,14 +25,25 @@ export const MESSAGES = {
|
|||||||
TOKEN_REVOKED: 'Token 已失效,请重新登录',
|
TOKEN_REVOKED: 'Token 已失效,请重新登录',
|
||||||
TOKEN_ROLE_INVALID: 'Token 中角色信息不合法',
|
TOKEN_ROLE_INVALID: 'Token 中角色信息不合法',
|
||||||
TOKEN_FIELD_INVALID: 'Token 中字段不合法',
|
TOKEN_FIELD_INVALID: 'Token 中字段不合法',
|
||||||
INVALID_CREDENTIALS: '手机号、角色或密码错误',
|
INVALID_CREDENTIALS: '手机号、密码或角色不匹配',
|
||||||
PASSWORD_NOT_ENABLED: '该账号未启用密码登录',
|
PASSWORD_NOT_ENABLED: '该账号未启用密码登录',
|
||||||
|
PASSWORD_LOGIN_TICKET_INVALID: '账号选择票据无效或已过期',
|
||||||
|
PASSWORD_ACCOUNT_SELECTION_INVALID: '请选择有效的候选账号',
|
||||||
REGISTER_DISABLED: '注册接口已关闭,请联系管理员创建账号',
|
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: {
|
USER: {
|
||||||
NOT_FOUND: '用户不存在',
|
NOT_FOUND: '用户不存在',
|
||||||
DUPLICATE_OPEN_ID: 'openId 已被注册',
|
|
||||||
DUPLICATE_PHONE_ROLE_SCOPE: '同医院下该角色手机号已存在',
|
DUPLICATE_PHONE_ROLE_SCOPE: '同医院下该角色手机号已存在',
|
||||||
INVALID_ROLE: '角色不合法',
|
INVALID_ROLE: '角色不合法',
|
||||||
INVALID_PHONE: '手机号格式不合法',
|
INVALID_PHONE: '手机号格式不合法',
|
||||||
@ -54,8 +65,6 @@ export const MESSAGES = {
|
|||||||
GROUP_DEPARTMENT_MISMATCH: '小组不属于指定科室',
|
GROUP_DEPARTMENT_MISMATCH: '小组不属于指定科室',
|
||||||
DOCTOR_ONLY_SCOPE_CHANGE: '仅医生/主任/组长允许调整科室/小组归属',
|
DOCTOR_ONLY_SCOPE_CHANGE: '仅医生/主任/组长允许调整科室/小组归属',
|
||||||
DELETE_CONFLICT: '用户存在关联患者或任务,无法删除',
|
DELETE_CONFLICT: '用户存在关联患者或任务,无法删除',
|
||||||
MULTI_ACCOUNT_REQUIRE_HOSPITAL:
|
|
||||||
'检测到多个同手机号账号,请传 hospitalId 指定登录医院',
|
|
||||||
CREATE_FORBIDDEN: '当前角色无权限创建该用户',
|
CREATE_FORBIDDEN: '当前角色无权限创建该用户',
|
||||||
HOSPITAL_ADMIN_SCOPE_FORBIDDEN: '医院管理员仅可操作本院非管理员账号',
|
HOSPITAL_ADMIN_SCOPE_FORBIDDEN: '医院管理员仅可操作本院非管理员账号',
|
||||||
DIRECTOR_SCOPE_FORBIDDEN: '科室主任仅可操作本科室医生或组长账号',
|
DIRECTOR_SCOPE_FORBIDDEN: '科室主任仅可操作本科室医生或组长账号',
|
||||||
@ -91,7 +100,6 @@ export const MESSAGES = {
|
|||||||
DOCTOR_ROLE_REQUIRED: '归属用户必须为医生/主任/组长角色',
|
DOCTOR_ROLE_REQUIRED: '归属用户必须为医生/主任/组长角色',
|
||||||
DOCTOR_SCOPE_FORBIDDEN: '仅可选择当前权限范围内医生/主任/组长',
|
DOCTOR_SCOPE_FORBIDDEN: '仅可选择当前权限范围内医生/主任/组长',
|
||||||
DELETE_CONFLICT: '患者存在关联设备,无法删除',
|
DELETE_CONFLICT: '患者存在关联设备,无法删除',
|
||||||
PHONE_IDCARD_REQUIRED: 'phone 与 idCard 均为必填',
|
|
||||||
LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证号',
|
LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证号',
|
||||||
SYSTEM_ADMIN_HOSPITAL_REQUIRED: '系统管理员查询必须显式传入 hospitalId',
|
SYSTEM_ADMIN_HOSPITAL_REQUIRED: '系统管理员查询必须显式传入 hospitalId',
|
||||||
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
|
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
|
||||||
|
|||||||
@ -40,6 +40,7 @@ const PATIENT_LIST_INCLUDE = {
|
|||||||
id: true,
|
id: true,
|
||||||
status: true,
|
status: true,
|
||||||
currentPressure: true,
|
currentPressure: true,
|
||||||
|
initialPressure: true,
|
||||||
isAbandoned: true,
|
isAbandoned: true,
|
||||||
implantModel: true,
|
implantModel: true,
|
||||||
implantManufacturer: true,
|
implantManufacturer: true,
|
||||||
@ -54,6 +55,8 @@ const PATIENT_LIST_INCLUDE = {
|
|||||||
id: true,
|
id: true,
|
||||||
surgeryDate: true,
|
surgeryDate: true,
|
||||||
surgeryName: true,
|
surgeryName: true,
|
||||||
|
primaryDisease: true,
|
||||||
|
hydrocephalusTypes: true,
|
||||||
surgeonId: true,
|
surgeonId: true,
|
||||||
surgeonName: true,
|
surgeonName: true,
|
||||||
},
|
},
|
||||||
@ -156,10 +159,25 @@ export class BPatientsService {
|
|||||||
|
|
||||||
return patients.map((patient) => {
|
return patients.map((patient) => {
|
||||||
const { _count, surgeries, ...rest } = patient;
|
const { _count, surgeries, ...rest } = patient;
|
||||||
|
const latestSurgery = surgeries[0] ?? null;
|
||||||
|
const currentDevice =
|
||||||
|
patient.devices.find(
|
||||||
|
(device) =>
|
||||||
|
device.status === DeviceStatus.ACTIVE && !device.isAbandoned,
|
||||||
|
) ??
|
||||||
|
patient.devices.find((device) => !device.isAbandoned) ??
|
||||||
|
patient.devices[0] ??
|
||||||
|
null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
|
primaryDisease: latestSurgery?.primaryDisease ?? null,
|
||||||
|
hydrocephalusTypes: latestSurgery?.hydrocephalusTypes ?? [],
|
||||||
|
surgeryDate: latestSurgery?.surgeryDate ?? null,
|
||||||
|
currentPressure: currentDevice?.currentPressure ?? null,
|
||||||
|
initialPressure: currentDevice?.initialPressure ?? null,
|
||||||
shuntSurgeryCount: _count.surgeries,
|
shuntSurgeryCount: _count.surgeries,
|
||||||
latestSurgery: surgeries[0] ?? null,
|
latestSurgery,
|
||||||
activeDeviceCount: patient.devices.filter(
|
activeDeviceCount: patient.devices.filter(
|
||||||
(device) =>
|
(device) =>
|
||||||
device.status === DeviceStatus.ACTIVE && !device.isAbandoned,
|
device.status === DeviceStatus.ACTIVE && !device.isAbandoned,
|
||||||
|
|||||||
@ -1,12 +1,8 @@
|
|||||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||||
import {
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
ApiBearerAuth,
|
import { CurrentFamilyActor } from '../../auth/current-family-actor.decorator.js';
|
||||||
ApiOperation,
|
import { FamilyAccessTokenGuard } from '../../auth/family-access/family-access.guard.js';
|
||||||
ApiQuery,
|
import type { FamilyActorContext } from '../../common/family-actor-context.js';
|
||||||
ApiTags,
|
|
||||||
} from '@nestjs/swagger';
|
|
||||||
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
|
|
||||||
import { FamilyLifecycleQueryDto } from '../dto/family-lifecycle-query.dto.js';
|
|
||||||
import { CPatientsService } from './c-patients.service.js';
|
import { CPatientsService } from './c-patients.service.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -15,21 +11,16 @@ import { CPatientsService } from './c-patients.service.js';
|
|||||||
@ApiTags('患者管理(C端)')
|
@ApiTags('患者管理(C端)')
|
||||||
@ApiBearerAuth('bearer')
|
@ApiBearerAuth('bearer')
|
||||||
@Controller('c/patients')
|
@Controller('c/patients')
|
||||||
@UseGuards(AccessTokenGuard)
|
@UseGuards(FamilyAccessTokenGuard)
|
||||||
export class CPatientsController {
|
export class CPatientsController {
|
||||||
constructor(private readonly patientsService: CPatientsService) {}
|
constructor(private readonly patientsService: CPatientsService) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据手机号和身份证号查询跨院生命周期。
|
* 根据当前登录手机号查询跨院生命周期。
|
||||||
*/
|
*/
|
||||||
@Get('lifecycle')
|
@Get('my-lifecycle')
|
||||||
@ApiOperation({ summary: '跨院患者生命周期查询' })
|
@ApiOperation({ summary: '按当前登录手机号查询患者生命周期' })
|
||||||
@ApiQuery({ name: 'phone', description: '手机号' })
|
getMyLifecycle(@CurrentFamilyActor() actor: FamilyActorContext) {
|
||||||
@ApiQuery({ name: 'idCard', description: '身份证号' })
|
return this.patientsService.getFamilyLifecycleByAccount(actor.id);
|
||||||
getLifecycle(@Query() query: FamilyLifecycleQueryDto) {
|
|
||||||
return this.patientsService.getFamilyLifecycleByIdentity(
|
|
||||||
query.phone,
|
|
||||||
query.idCard,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../../prisma.service.js';
|
import { PrismaService } from '../../prisma.service.js';
|
||||||
import { MESSAGES } from '../../common/messages.js';
|
import { MESSAGES } from '../../common/messages.js';
|
||||||
import { normalizePatientIdCard } from '../patient-id-card.util.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* C 端患者服务:承载家属跨院生命周期聚合查询。
|
* C 端患者服务:承载家属跨院生命周期聚合查询。
|
||||||
@ -15,20 +13,23 @@ export class CPatientsService {
|
|||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* C 端查询:按 phone + idCard 跨院聚合患者生命周期记录。
|
* C 端查询:按当前家属账号绑定手机号跨院聚合患者生命周期记录。
|
||||||
*/
|
*/
|
||||||
async getFamilyLifecycleByIdentity(phone: string, idCard: string) {
|
async getFamilyLifecycleByAccount(accountId: number) {
|
||||||
if (!phone || !idCard) {
|
const account = await this.prisma.familyMiniAppAccount.findUnique({
|
||||||
throw new BadRequestException(MESSAGES.PATIENT.PHONE_IDCARD_REQUIRED);
|
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({
|
const patients = await this.prisma.patient.findMany({
|
||||||
where: {
|
where: {
|
||||||
phone,
|
phone: account.phone,
|
||||||
idCard: normalizedIdCard,
|
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
hospital: { select: { id: true, name: true } },
|
hospital: { select: { id: true, name: true } },
|
||||||
@ -191,8 +192,7 @@ export class CPatientsService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
// 前端详情弹窗和现有 E2E 都依赖这两个回显字段。
|
// 前端详情弹窗和现有 E2E 都依赖这两个回显字段。
|
||||||
phone,
|
phone: account.phone,
|
||||||
idCard: normalizedIdCard,
|
|
||||||
patientCount: patients.length,
|
patientCount: patients.length,
|
||||||
lifecycle,
|
lifecycle,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -2,12 +2,19 @@ import { Module } from '@nestjs/common';
|
|||||||
import { BPatientsController } from './b-patients/b-patients.controller.js';
|
import { BPatientsController } from './b-patients/b-patients.controller.js';
|
||||||
import { CPatientsController } from './c-patients/c-patients.controller.js';
|
import { CPatientsController } from './c-patients/c-patients.controller.js';
|
||||||
import { AccessTokenGuard } from '../auth/access-token.guard.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 { RolesGuard } from '../auth/roles.guard.js';
|
||||||
import { BPatientsService } from './b-patients/b-patients.service.js';
|
import { BPatientsService } from './b-patients/b-patients.service.js';
|
||||||
import { CPatientsService } from './c-patients/c-patients.service.js';
|
import { CPatientsService } from './c-patients/c-patients.service.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [BPatientsService, CPatientsService, AccessTokenGuard, RolesGuard],
|
providers: [
|
||||||
|
BPatientsService,
|
||||||
|
CPatientsService,
|
||||||
|
AccessTokenGuard,
|
||||||
|
FamilyAccessTokenGuard,
|
||||||
|
RolesGuard,
|
||||||
|
],
|
||||||
controllers: [BPatientsController, CPatientsController],
|
controllers: [BPatientsController, CPatientsController],
|
||||||
exports: [BPatientsService, CPatientsService],
|
exports: [BPatientsService, CPatientsService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -32,7 +32,7 @@ export class CreateUserDto {
|
|||||||
password?: string;
|
password?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: '微信 openId',
|
description: '微信 openId(院内账号间可复用)',
|
||||||
example: 'wx-open-id-demo',
|
example: 'wx-open-id-demo',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import {
|
|||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录 DTO:后台与小程序均可复用。
|
* 院内账号密码登录 DTO。
|
||||||
*/
|
*/
|
||||||
export class LoginDto {
|
export class LoginDto {
|
||||||
@ApiProperty({ description: '手机号', example: '13800000002' })
|
@ApiProperty({ description: '手机号', example: '13800000002' })
|
||||||
@ -26,12 +26,17 @@ export class LoginDto {
|
|||||||
@MinLength(8, { message: 'password 长度至少 8 位' })
|
@MinLength(8, { message: 'password 长度至少 8 位' })
|
||||||
password!: string;
|
password!: string;
|
||||||
|
|
||||||
@ApiProperty({ description: '登录角色', enum: Role, example: Role.DOCTOR })
|
@ApiPropertyOptional({
|
||||||
|
description: '登录角色;不传时按手机号+密码匹配全部院内账号',
|
||||||
|
enum: Role,
|
||||||
|
example: Role.DOCTOR,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
@IsEnum(Role, { message: 'role 枚举值不合法' })
|
@IsEnum(Role, { message: 'role 枚举值不合法' })
|
||||||
role!: Role;
|
role?: Role;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: '医院 ID(多账号场景建议传入)',
|
description: '医院 ID;传入后仅在该医院范围内匹配候选账号',
|
||||||
example: 1,
|
example: 1,
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@ -35,7 +35,7 @@ export class RegisterUserDto {
|
|||||||
role!: Role;
|
role!: Role;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: '微信 openId(可选)',
|
description: '微信 openId(可选,院内账号间可复用)',
|
||||||
example: 'wx-open-id-demo',
|
example: 'wx-open-id-demo',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
66
src/users/users.service.spec.ts
Normal file
66
src/users/users.service.spec.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { jest } from '@jest/globals';
|
||||||
|
import { UsersService } from './users.service.js';
|
||||||
|
import type { PrismaService } from '../prisma.service.js';
|
||||||
|
|
||||||
|
describe('UsersService.bindOpenIdForMiniAppLogin', () => {
|
||||||
|
function createService() {
|
||||||
|
const prisma = {
|
||||||
|
user: {
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
},
|
||||||
|
} as unknown as PrismaService;
|
||||||
|
|
||||||
|
return {
|
||||||
|
prisma,
|
||||||
|
service: new UsersService(prisma),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('允许同一个 openId 绑定多个院内账号', async () => {
|
||||||
|
const { prisma, service } = createService();
|
||||||
|
const sharedOpenId = 'shared-openid';
|
||||||
|
|
||||||
|
(prisma.user.findUnique as jest.Mock)
|
||||||
|
.mockResolvedValueOnce({ id: 1, openId: null })
|
||||||
|
.mockResolvedValueOnce({ id: 2, openId: null });
|
||||||
|
(prisma.user.update as jest.Mock).mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await service.bindOpenIdForMiniAppLogin(1, sharedOpenId);
|
||||||
|
await service.bindOpenIdForMiniAppLogin(2, sharedOpenId);
|
||||||
|
|
||||||
|
expect(prisma.user.update).toHaveBeenNthCalledWith(1, {
|
||||||
|
where: { id: 1 },
|
||||||
|
data: { openId: sharedOpenId },
|
||||||
|
});
|
||||||
|
expect(prisma.user.update).toHaveBeenNthCalledWith(2, {
|
||||||
|
where: { id: 2 },
|
||||||
|
data: { openId: sharedOpenId },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('拒绝用新的微信账号覆盖已绑定院内账号', async () => {
|
||||||
|
const { prisma, service } = createService();
|
||||||
|
|
||||||
|
(prisma.user.findUnique as jest.Mock).mockResolvedValue({
|
||||||
|
id: 1,
|
||||||
|
openId: 'bound-openid',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.bindOpenIdForMiniAppLogin(1, 'other-openid'),
|
||||||
|
).rejects.toThrow(new ConflictException('当前院内账号已绑定其他微信账号'));
|
||||||
|
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('目标院内账号不存在时返回 404', async () => {
|
||||||
|
const { prisma, service } = createService();
|
||||||
|
|
||||||
|
(prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.bindOpenIdForMiniAppLogin(999, 'shared-openid'),
|
||||||
|
).rejects.toThrow(new NotFoundException('用户不存在'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -18,6 +18,7 @@ import { AssignEngineerHospitalDto } from './dto/assign-engineer-hospital.dto.js
|
|||||||
import { LoginDto } from './dto/login.dto.js';
|
import { LoginDto } from './dto/login.dto.js';
|
||||||
import { MESSAGES } from '../common/messages.js';
|
import { MESSAGES } from '../common/messages.js';
|
||||||
import { CreateSystemAdminDto } from '../auth/dto/create-system-admin.dto.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 = {
|
const SAFE_USER_SELECT = {
|
||||||
id: true,
|
id: true,
|
||||||
@ -28,11 +29,21 @@ const SAFE_USER_SELECT = {
|
|||||||
hospitalId: true,
|
hospitalId: true,
|
||||||
departmentId: true,
|
departmentId: true,
|
||||||
groupId: true,
|
groupId: true,
|
||||||
|
hospital: {
|
||||||
|
select:{
|
||||||
|
name:true
|
||||||
|
}
|
||||||
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const DIRECTOR_VISIBLE_ROLES = [Role.LEADER, Role.DOCTOR] as const;
|
const DIRECTOR_VISIBLE_ROLES = [Role.LEADER, Role.DOCTOR] as const;
|
||||||
const LEADER_VISIBLE_ROLES = [Role.DOCTOR] as const;
|
const LEADER_VISIBLE_ROLES = [Role.DOCTOR] as const;
|
||||||
|
|
||||||
|
type PasswordLoginTicketPayload = {
|
||||||
|
purpose: 'PASSWORD_LOGIN_TICKET';
|
||||||
|
userIds: number[];
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
@ -57,7 +68,6 @@ export class UsersService {
|
|||||||
Role.SYSTEM_ADMIN,
|
Role.SYSTEM_ADMIN,
|
||||||
dto.systemAdminBootstrapKey,
|
dto.systemAdminBootstrapKey,
|
||||||
);
|
);
|
||||||
await this.assertOpenIdUnique(openId);
|
|
||||||
await this.assertPhoneRoleScopeUnique(phone, Role.SYSTEM_ADMIN, null);
|
await this.assertPhoneRoleScopeUnique(phone, Role.SYSTEM_ADMIN, null);
|
||||||
|
|
||||||
const passwordHash = await hash(password, 12);
|
const passwordHash = await hash(password, 12);
|
||||||
@ -78,10 +88,10 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录:按手机号+角色(可选医院)定位账号并签发 JWT。
|
* 院内账号密码登录:支持后台和小程序复用。
|
||||||
*/
|
*/
|
||||||
async login(dto: LoginDto) {
|
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 phone = this.normalizePhone(dto.phone);
|
||||||
const password = this.normalizePassword(dto.password);
|
const password = this.normalizePassword(dto.password);
|
||||||
const hospitalId = this.normalizeOptionalInt(dto.hospitalId, 'hospitalId');
|
const hospitalId = this.normalizeOptionalInt(dto.hospitalId, 'hospitalId');
|
||||||
@ -89,49 +99,122 @@ export class UsersService {
|
|||||||
const users = await this.prisma.user.findMany({
|
const users = await this.prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
phone,
|
phone,
|
||||||
role,
|
...(role != null ? { role } : {}),
|
||||||
...(hospitalId != null ? { hospitalId } : {}),
|
...(hospitalId != null ? { hospitalId } : {}),
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
...SAFE_USER_SELECT,
|
...SAFE_USER_SELECT,
|
||||||
passwordHash: true,
|
passwordHash: true,
|
||||||
|
hospital: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
take: 5,
|
orderBy: [{ hospitalId: 'asc' }, { id: 'asc' }],
|
||||||
|
take: 10,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (users.length === 0) {
|
if (users.length === 0) {
|
||||||
throw new UnauthorizedException(MESSAGES.AUTH.INVALID_CREDENTIALS);
|
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(
|
throw new BadRequestException(
|
||||||
MESSAGES.USER.MULTI_ACCOUNT_REQUIRE_HOSPITAL,
|
MESSAGES.AUTH.PASSWORD_ACCOUNT_SELECTION_INVALID,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = users[0];
|
return this.loginByUserId(dto.userId);
|
||||||
if (!user?.passwordHash) {
|
}
|
||||||
throw new UnauthorizedException(MESSAGES.AUTH.PASSWORD_NOT_ENABLED);
|
|
||||||
|
/**
|
||||||
|
* 按用户 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);
|
return this.buildUserLoginResponse(user);
|
||||||
if (!matched) {
|
}
|
||||||
throw new UnauthorizedException(MESSAGES.AUTH.INVALID_CREDENTIALS);
|
|
||||||
|
/**
|
||||||
|
* 小程序登录时绑定 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 = {
|
if (!current.openId) {
|
||||||
id: user.id,
|
await this.prisma.user.update({
|
||||||
role: user.role,
|
where: { id: current.id },
|
||||||
hospitalId: user.hospitalId,
|
data: { openId: normalizedOpenId },
|
||||||
departmentId: user.departmentId,
|
});
|
||||||
groupId: user.groupId,
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
tokenType: 'Bearer',
|
|
||||||
accessToken: this.signAccessToken(actor),
|
|
||||||
actor,
|
|
||||||
user: this.toSafeUser(user),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -176,7 +259,6 @@ export class UsersService {
|
|||||||
scoped.departmentId,
|
scoped.departmentId,
|
||||||
scoped.groupId,
|
scoped.groupId,
|
||||||
);
|
);
|
||||||
await this.assertOpenIdUnique(openId);
|
|
||||||
await this.assertPhoneRoleScopeUnique(phone, role, scoped.hospitalId);
|
await this.assertPhoneRoleScopeUnique(phone, role, scoped.hospitalId);
|
||||||
|
|
||||||
return this.prisma.user.create({
|
return this.prisma.user.create({
|
||||||
@ -309,7 +391,6 @@ export class UsersService {
|
|||||||
updateUserDto.openId !== undefined
|
updateUserDto.openId !== undefined
|
||||||
? this.normalizeOptionalString(updateUserDto.openId)
|
? this.normalizeOptionalString(updateUserDto.openId)
|
||||||
: current.openId;
|
: current.openId;
|
||||||
await this.assertOpenIdUnique(nextOpenId, userId);
|
|
||||||
const nextPhone =
|
const nextPhone =
|
||||||
updateUserDto.phone !== undefined
|
updateUserDto.phone !== undefined
|
||||||
? this.normalizePhone(updateUserDto.phone)
|
? this.normalizePhone(updateUserDto.phone)
|
||||||
@ -438,6 +519,36 @@ export class UsersService {
|
|||||||
return safe;
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验系统管理员注册引导密钥。
|
* 校验系统管理员注册引导密钥。
|
||||||
*/
|
*/
|
||||||
@ -482,23 +593,6 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 校验 openId 唯一性。
|
|
||||||
*/
|
|
||||||
private async assertOpenIdUnique(openId: string | null, selfId?: number) {
|
|
||||||
if (!openId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const exists = await this.prisma.user.findUnique({
|
|
||||||
where: { openId },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (exists && exists.id !== selfId) {
|
|
||||||
throw new ConflictException(MESSAGES.USER.DUPLICATE_OPEN_ID);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验角色与组织归属关系是否合法。
|
* 校验角色与组织归属关系是否合法。
|
||||||
*/
|
*/
|
||||||
@ -870,10 +964,7 @@ export class UsersService {
|
|||||||
* 签发访问令牌。
|
* 签发访问令牌。
|
||||||
*/
|
*/
|
||||||
private signAccessToken(actor: ActorContext): string {
|
private signAccessToken(actor: ActorContext): string {
|
||||||
const secret = process.env.AUTH_TOKEN_SECRET;
|
const secret = this.requireAuthSecret();
|
||||||
if (!secret) {
|
|
||||||
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_SECRET_MISSING);
|
|
||||||
}
|
|
||||||
|
|
||||||
return jwt.sign(actor, secret, {
|
return jwt.sign(actor, secret, {
|
||||||
algorithm: 'HS256',
|
algorithm: 'HS256',
|
||||||
@ -881,4 +972,59 @@ export class UsersService {
|
|||||||
issuer: 'tyt-api-nest',
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export interface E2ESeedCredential {
|
|||||||
role: E2ERole;
|
role: E2ERole;
|
||||||
phone: string;
|
phone: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
openId: string;
|
||||||
hospitalId?: number;
|
hospitalId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -25,35 +26,41 @@ export const E2E_SEED_CREDENTIALS: Record<E2ERole, E2ESeedCredential> = {
|
|||||||
role: Role.SYSTEM_ADMIN,
|
role: Role.SYSTEM_ADMIN,
|
||||||
phone: '13800001000',
|
phone: '13800001000',
|
||||||
password: E2E_SEED_PASSWORD,
|
password: E2E_SEED_PASSWORD,
|
||||||
|
openId: 'seed-system-admin-openid',
|
||||||
},
|
},
|
||||||
[Role.HOSPITAL_ADMIN]: {
|
[Role.HOSPITAL_ADMIN]: {
|
||||||
role: Role.HOSPITAL_ADMIN,
|
role: Role.HOSPITAL_ADMIN,
|
||||||
phone: '13800001001',
|
phone: '13800001001',
|
||||||
password: E2E_SEED_PASSWORD,
|
password: E2E_SEED_PASSWORD,
|
||||||
|
openId: 'seed-hospital-admin-a-openid',
|
||||||
hospitalId: 1,
|
hospitalId: 1,
|
||||||
},
|
},
|
||||||
[Role.DIRECTOR]: {
|
[Role.DIRECTOR]: {
|
||||||
role: Role.DIRECTOR,
|
role: Role.DIRECTOR,
|
||||||
phone: '13800001002',
|
phone: '13800001002',
|
||||||
password: E2E_SEED_PASSWORD,
|
password: E2E_SEED_PASSWORD,
|
||||||
|
openId: 'seed-director-a-openid',
|
||||||
hospitalId: 1,
|
hospitalId: 1,
|
||||||
},
|
},
|
||||||
[Role.LEADER]: {
|
[Role.LEADER]: {
|
||||||
role: Role.LEADER,
|
role: Role.LEADER,
|
||||||
phone: '13800001003',
|
phone: '13800001003',
|
||||||
password: E2E_SEED_PASSWORD,
|
password: E2E_SEED_PASSWORD,
|
||||||
|
openId: 'seed-leader-a-openid',
|
||||||
hospitalId: 1,
|
hospitalId: 1,
|
||||||
},
|
},
|
||||||
[Role.DOCTOR]: {
|
[Role.DOCTOR]: {
|
||||||
role: Role.DOCTOR,
|
role: Role.DOCTOR,
|
||||||
phone: '13800001004',
|
phone: '13800001004',
|
||||||
password: E2E_SEED_PASSWORD,
|
password: E2E_SEED_PASSWORD,
|
||||||
|
openId: 'seed-doctor-a-openid',
|
||||||
hospitalId: 1,
|
hospitalId: 1,
|
||||||
},
|
},
|
||||||
[Role.ENGINEER]: {
|
[Role.ENGINEER]: {
|
||||||
role: Role.ENGINEER,
|
role: Role.ENGINEER,
|
||||||
phone: '13800001005',
|
phone: '13800001005',
|
||||||
password: E2E_SEED_PASSWORD,
|
password: E2E_SEED_PASSWORD,
|
||||||
|
openId: 'seed-engineer-a-openid',
|
||||||
hospitalId: 1,
|
hospitalId: 1,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,14 +3,43 @@ import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
|||||||
import type { INestApplication } from '@nestjs/common';
|
import type { INestApplication } from '@nestjs/common';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { AppModule } from '../../../src/app.module.js';
|
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 { HttpExceptionFilter } from '../../../src/common/http-exception.filter.js';
|
||||||
import { MESSAGES } from '../../../src/common/messages.js';
|
import { MESSAGES } from '../../../src/common/messages.js';
|
||||||
import { ResponseEnvelopeInterceptor } from '../../../src/common/response-envelope.interceptor.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<INestApplication> {
|
export async function createE2eApp(): Promise<INestApplication> {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
imports: [AppModule],
|
imports: [AppModule],
|
||||||
}).compile();
|
})
|
||||||
|
.overrideProvider(WechatMiniAppService)
|
||||||
|
.useValue(new FakeWechatMiniAppService())
|
||||||
|
.compile();
|
||||||
|
|
||||||
const app = moduleRef.createNestApplication();
|
const app = moduleRef.createNestApplication();
|
||||||
|
|
||||||
|
|||||||
@ -24,23 +24,16 @@ export async function loginAsRole(
|
|||||||
fixtures: E2ESeedFixtures,
|
fixtures: E2ESeedFixtures,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const credential = E2E_SEED_CREDENTIALS[role];
|
const credential = E2E_SEED_CREDENTIALS[role];
|
||||||
const payload: Record<string, unknown> = {
|
|
||||||
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())
|
const response = await request(app.getHttpServer())
|
||||||
.post('/auth/login')
|
.post('/auth/login')
|
||||||
.send(payload);
|
.send({
|
||||||
|
phone: credential.phone,
|
||||||
|
password: credential.password,
|
||||||
|
role: credential.role,
|
||||||
|
hospitalId: resolveRoleHospitalId(role, fixtures),
|
||||||
|
});
|
||||||
|
|
||||||
expectSuccessEnvelope(response, 201);
|
expectSuccessEnvelope(response, 201);
|
||||||
expect(response.body.data?.accessToken).toEqual(expect.any(String));
|
|
||||||
|
|
||||||
return response.body.data.accessToken as string;
|
return response.body.data.accessToken as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import type { INestApplication } from '@nestjs/common';
|
import type { INestApplication } from '@nestjs/common';
|
||||||
import { NotFoundException } from '@nestjs/common';
|
import { NotFoundException } from '@nestjs/common';
|
||||||
import request from 'supertest';
|
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 { PrismaService } from '../../../src/prisma.service.js';
|
||||||
import {
|
import {
|
||||||
E2E_SEED_CREDENTIALS,
|
E2E_SEED_CREDENTIALS,
|
||||||
@ -109,7 +109,7 @@ export async function ensureE2EFixtures(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!existingSystemAdmin) {
|
if (!existingSystemAdmin) {
|
||||||
await bootstrapFixturesViaApi(app);
|
await bootstrapFixturesViaApi(app, prisma);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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();
|
const server = app.getHttpServer();
|
||||||
|
|
||||||
await createSystemAdmin(server);
|
await createSystemAdmin(server);
|
||||||
@ -576,9 +579,18 @@ async function bootstrapFixturesViaApi(app: INestApplication) {
|
|||||||
taskId: publishedA.id,
|
taskId: publishedA.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
await createWithToken(server, engineerAToken, '/b/tasks/complete', {
|
const engineerProofAsset = await ensureTaskCompletionAsset(
|
||||||
taskId: publishedA.id,
|
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', {
|
await createWithToken(server, doctorBToken, '/b/tasks/publish', {
|
||||||
items: [
|
items: [
|
||||||
@ -612,15 +624,18 @@ async function repairFixturesViaApi(
|
|||||||
const doctorA2 = await requireUserScope(prisma, OPEN_IDS.doctorA2);
|
const doctorA2 = await requireUserScope(prisma, OPEN_IDS.doctorA2);
|
||||||
const doctorA3 = await requireUserScope(prisma, OPEN_IDS.doctorA3);
|
const doctorA3 = await requireUserScope(prisma, OPEN_IDS.doctorA3);
|
||||||
const doctorB = await requireUserScope(prisma, OPEN_IDS.doctorB);
|
const doctorB = await requireUserScope(prisma, OPEN_IDS.doctorB);
|
||||||
|
const engineerA = await requireUserScope(prisma, OPEN_IDS.engineerA);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
hospitalAdminA.hospitalId == null ||
|
hospitalAdminA.hospitalId == null ||
|
||||||
doctorA.hospitalId == null ||
|
doctorA.hospitalId == null ||
|
||||||
doctorB.hospitalId == null ||
|
doctorB.hospitalId == null ||
|
||||||
|
engineerA.hospitalId == null ||
|
||||||
doctorA.id == null ||
|
doctorA.id == null ||
|
||||||
doctorA2.id == null ||
|
doctorA2.id == null ||
|
||||||
doctorA3.id == null ||
|
doctorA3.id == null ||
|
||||||
doctorB.id == null
|
doctorB.id == null ||
|
||||||
|
engineerA.id == null
|
||||||
) {
|
) {
|
||||||
throw new NotFoundException('Seed user scope is incomplete');
|
throw new NotFoundException('Seed user scope is incomplete');
|
||||||
}
|
}
|
||||||
@ -893,9 +908,18 @@ async function repairFixturesViaApi(
|
|||||||
await createWithToken(server, engineerAToken, '/b/tasks/accept', {
|
await createWithToken(server, engineerAToken, '/b/tasks/accept', {
|
||||||
taskId: publishedA.id,
|
taskId: publishedA.id,
|
||||||
});
|
});
|
||||||
await createWithToken(server, engineerAToken, '/b/tasks/complete', {
|
const engineerProofAsset = await ensureTaskCompletionAsset(
|
||||||
taskId: publishedA.id,
|
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))) {
|
if (!(await hasTaskItemForDevice(prisma, deviceB1Id))) {
|
||||||
@ -1011,11 +1035,67 @@ async function patchWithToken(
|
|||||||
return response.body.data;
|
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(
|
async function requireUserScope(
|
||||||
prisma: PrismaService,
|
prisma: PrismaService,
|
||||||
openId: string,
|
openId: string,
|
||||||
): Promise<SeedUserScope> {
|
): Promise<SeedUserScope> {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findFirst({
|
||||||
where: { openId },
|
where: { openId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|||||||
77
test/e2e/helpers/e2e-miniapp-auth.helper.ts
Normal file
77
test/e2e/helpers/e2e-miniapp-auth.helper.ts
Normal file
@ -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<request.Test>,
|
||||||
|
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;
|
||||||
|
}
|
||||||
@ -1,5 +1,9 @@
|
|||||||
import request from 'supertest';
|
import request from 'supertest';
|
||||||
import { Role } from '../../../src/generated/prisma/enums.js';
|
import { Role } from '../../../src/generated/prisma/enums.js';
|
||||||
|
import {
|
||||||
|
E2E_SEED_CREDENTIALS,
|
||||||
|
E2E_SEED_PASSWORD,
|
||||||
|
} from '../fixtures/e2e-roles.js';
|
||||||
import {
|
import {
|
||||||
closeE2EContext,
|
closeE2EContext,
|
||||||
createE2EContext,
|
createE2EContext,
|
||||||
@ -12,6 +16,7 @@ import {
|
|||||||
uniquePhone,
|
uniquePhone,
|
||||||
uniqueSeedValue,
|
uniqueSeedValue,
|
||||||
} from '../helpers/e2e-http.helper.js';
|
} from '../helpers/e2e-http.helper.js';
|
||||||
|
import { buildMiniAppMockPayload } from '../helpers/e2e-miniapp-auth.helper.js';
|
||||||
|
|
||||||
describe('AuthController (e2e)', () => {
|
describe('AuthController (e2e)', () => {
|
||||||
let ctx: E2EContext;
|
let ctx: E2EContext;
|
||||||
@ -55,12 +60,12 @@ describe('AuthController (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /auth/login', () => {
|
describe('POST /auth/login', () => {
|
||||||
it('成功:seed 账号登录并拿到 token', async () => {
|
it('成功:院内账号可使用手机号密码登录', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.post('/auth/login')
|
.post('/auth/login')
|
||||||
.send({
|
.send({
|
||||||
phone: '13800001004',
|
phone: E2E_SEED_CREDENTIALS[Role.DOCTOR].phone,
|
||||||
password: 'Seed@1234',
|
password: E2E_SEED_PASSWORD,
|
||||||
role: Role.DOCTOR,
|
role: Role.DOCTOR,
|
||||||
hospitalId: ctx.fixtures.hospitalAId,
|
hospitalId: ctx.fixtures.hospitalAId,
|
||||||
});
|
});
|
||||||
@ -70,17 +75,236 @@ describe('AuthController (e2e)', () => {
|
|||||||
expect(response.body.data.actor.role).toBe(Role.DOCTOR);
|
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 () => {
|
it('失败:密码错误返回 401', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.post('/auth/login')
|
.post('/auth/login')
|
||||||
.send({
|
.send({
|
||||||
phone: '13800001004',
|
phone: E2E_SEED_CREDENTIALS[Role.DOCTOR].phone,
|
||||||
password: 'Seed@12345',
|
password: 'Wrong@1234',
|
||||||
role: Role.DOCTOR,
|
role: Role.DOCTOR,
|
||||||
hospitalId: ctx.fixtures.hospitalAId,
|
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('成功:同一微信 openId 可切换绑定多个院内账号', async () => {
|
||||||
|
const sharedPhone = uniquePhone();
|
||||||
|
const sharedOpenId = uniqueSeedValue('shared-openid');
|
||||||
|
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 firstStageForDoctor = await request(ctx.app.getHttpServer())
|
||||||
|
.post('/auth/miniapp/b/phone-login')
|
||||||
|
.send(buildMiniAppMockPayload(sharedPhone, sharedOpenId));
|
||||||
|
expectSuccessEnvelope(firstStageForDoctor, 201);
|
||||||
|
|
||||||
|
const doctorConfirm = await request(ctx.app.getHttpServer())
|
||||||
|
.post('/auth/miniapp/b/phone-login/confirm')
|
||||||
|
.send({
|
||||||
|
loginTicket: firstStageForDoctor.body.data.loginTicket,
|
||||||
|
userId: firstUser.body.data.id,
|
||||||
|
});
|
||||||
|
expectSuccessEnvelope(doctorConfirm, 201);
|
||||||
|
|
||||||
|
const firstStageForEngineer = await request(ctx.app.getHttpServer())
|
||||||
|
.post('/auth/miniapp/b/phone-login')
|
||||||
|
.send(buildMiniAppMockPayload(sharedPhone, sharedOpenId));
|
||||||
|
expectSuccessEnvelope(firstStageForEngineer, 201);
|
||||||
|
|
||||||
|
const engineerConfirm = await request(ctx.app.getHttpServer())
|
||||||
|
.post('/auth/miniapp/b/phone-login/confirm')
|
||||||
|
.send({
|
||||||
|
loginTicket: firstStageForEngineer.body.data.loginTicket,
|
||||||
|
userId: secondUser.body.data.id,
|
||||||
|
});
|
||||||
|
expectSuccessEnvelope(engineerConfirm, 201);
|
||||||
|
|
||||||
|
const users = await ctx.prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
id: {
|
||||||
|
in: [firstUser.body.data.id, secondUser.body.data.id],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
openId: true,
|
||||||
|
},
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(users).toEqual([
|
||||||
|
{ id: firstUser.body.data.id, openId: sharedOpenId },
|
||||||
|
{ id: secondUser.body.data.id, openId: sharedOpenId },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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, '当前手机号未关联患者档案');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
uniquePhone,
|
uniquePhone,
|
||||||
uniqueSeedValue,
|
uniqueSeedValue,
|
||||||
} from '../helpers/e2e-http.helper.js';
|
} from '../helpers/e2e-http.helper.js';
|
||||||
|
import { buildMiniAppMockPayload } from '../helpers/e2e-miniapp-auth.helper.js';
|
||||||
|
|
||||||
function uniqueIdCard() {
|
function uniqueIdCard() {
|
||||||
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`
|
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', () => {
|
describe('GET /b/patients', () => {
|
||||||
it('成功:按角色返回正确可见性范围', async () => {
|
it('成功:按角色返回正确可见性范围', async () => {
|
||||||
const systemAdminResponse = await request(ctx.app.getHttpServer())
|
const systemAdminResponse = await request(ctx.app.getHttpServer())
|
||||||
@ -80,6 +90,23 @@ describe('Patients Controllers (e2e)', () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const patientA1 = (
|
||||||
|
systemAdminResponse.body.data as Array<{
|
||||||
|
id: number;
|
||||||
|
primaryDisease: string | null;
|
||||||
|
hydrocephalusTypes: string[];
|
||||||
|
surgeryDate: string | null;
|
||||||
|
currentPressure: string | null;
|
||||||
|
initialPressure: string | null;
|
||||||
|
}>
|
||||||
|
).find((item) => item.id === ctx.fixtures.patients.patientA1Id);
|
||||||
|
expect(patientA1).toBeDefined();
|
||||||
|
expect(patientA1?.primaryDisease).toBeTruthy();
|
||||||
|
expect(Array.isArray(patientA1?.hydrocephalusTypes)).toBe(true);
|
||||||
|
expect(patientA1?.surgeryDate).toBeTruthy();
|
||||||
|
expect(patientA1?.currentPressure).toBeTruthy();
|
||||||
|
expect(patientA1?.initialPressure).toBeTruthy();
|
||||||
|
|
||||||
const hospitalAdminResponse = await request(ctx.app.getHttpServer())
|
const hospitalAdminResponse = await request(ctx.app.getHttpServer())
|
||||||
.get('/b/patients')
|
.get('/b/patients')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
|
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
|
||||||
@ -185,44 +212,37 @@ describe('Patients Controllers (e2e)', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /c/patients/lifecycle', () => {
|
describe('GET /c/patients/my-lifecycle', () => {
|
||||||
it('成功:已登录用户可按 phone + idCard 查询跨院生命周期', async () => {
|
it('成功:家属小程序登录后可按绑定手机号查询跨院生命周期', async () => {
|
||||||
|
const familyToken = await loginFamilyByPhone(
|
||||||
|
'13800002001',
|
||||||
|
'seed-family-a1-openid',
|
||||||
|
);
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.get('/c/patients/lifecycle')
|
.get('/c/patients/my-lifecycle')
|
||||||
.query({
|
.set('Authorization', `Bearer ${familyToken}`);
|
||||||
phone: '13800002001',
|
|
||||||
idCard: '110101199001010011',
|
|
||||||
})
|
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
|
|
||||||
|
|
||||||
expectSuccessEnvelope(response, 200);
|
expectSuccessEnvelope(response, 200);
|
||||||
expect(response.body.data.phone).toBe('13800002001');
|
expect(response.body.data.phone).toBe('13800002001');
|
||||||
expect(response.body.data.idCard).toBe('110101199001010011');
|
|
||||||
expect(response.body.data.patientCount).toBeGreaterThanOrEqual(2);
|
expect(response.body.data.patientCount).toBeGreaterThanOrEqual(2);
|
||||||
expect(Array.isArray(response.body.data.lifecycle)).toBe(true);
|
expect(Array.isArray(response.body.data.lifecycle)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('失败:参数缺失返回 400', async () => {
|
it('失败:未登录返回 401', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.get('/c/patients/lifecycle')
|
.get('/c/patients/my-lifecycle');
|
||||||
.query({
|
|
||||||
phone: '13800002001',
|
|
||||||
})
|
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
|
|
||||||
|
|
||||||
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())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.get('/c/patients/lifecycle')
|
.get('/c/patients/my-lifecycle')
|
||||||
.query({
|
.set('Authorization', `Bearer ${familyToken}`);
|
||||||
phone: '13800009999',
|
|
||||||
idCard: '110101199009090099',
|
|
||||||
})
|
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
|
|
||||||
|
|
||||||
expectErrorEnvelope(response, 404, '未找到匹配的患者档案');
|
expectSuccessEnvelope(response, 200);
|
||||||
|
expect(response.body.data.phone).toBe('13800002002');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
uniquePhone,
|
uniquePhone,
|
||||||
uniqueSeedValue,
|
uniqueSeedValue,
|
||||||
} from '../helpers/e2e-http.helper.js';
|
} from '../helpers/e2e-http.helper.js';
|
||||||
|
import { loginByMiniApp } from '../helpers/e2e-miniapp-auth.helper.js';
|
||||||
|
|
||||||
function uniqueIdCard() {
|
function uniqueIdCard() {
|
||||||
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`
|
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) {
|
async function loginByUser(userId: number, role: Role, hospitalId: number) {
|
||||||
const user = await ctx.prisma.user.findUnique({
|
const user = await ctx.prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
select: { phone: true },
|
select: { phone: true, openId: true },
|
||||||
});
|
});
|
||||||
expect(user?.phone).toBeTruthy();
|
expect(user?.phone).toBeTruthy();
|
||||||
|
expect(user?.openId).toBeTruthy();
|
||||||
|
|
||||||
const response = await request(ctx.app.getHttpServer())
|
return loginByMiniApp(ctx.app.getHttpServer(), {
|
||||||
.post('/auth/login')
|
phone: user!.phone,
|
||||||
.send({
|
openId: user!.openId!,
|
||||||
phone: user?.phone,
|
role,
|
||||||
password: 'Seed@1234',
|
hospitalId,
|
||||||
role,
|
userId,
|
||||||
hospitalId,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
expectSuccessEnvelope(response, 201);
|
|
||||||
return response.body.data.accessToken as string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createAdjustableDevices(options?: {
|
async function createAdjustableDevices(options?: {
|
||||||
@ -466,12 +464,10 @@ describe('BTasksController (e2e)', () => {
|
|||||||
expect(engineerB?.phone).toBeTruthy();
|
expect(engineerB?.phone).toBeTruthy();
|
||||||
|
|
||||||
const loginResponse = await request(ctx.app.getHttpServer())
|
const loginResponse = await request(ctx.app.getHttpServer())
|
||||||
.post('/auth/login')
|
.post('/auth/miniapp/b/phone-login')
|
||||||
.send({
|
.send({
|
||||||
phone: engineerB?.phone,
|
loginCode: `mock-login:${encodeURIComponent('seed-engineer-b-openid')}`,
|
||||||
password: 'Seed@1234',
|
phoneCode: `mock-phone:${engineerB?.phone}`,
|
||||||
role: Role.ENGINEER,
|
|
||||||
hospitalId: ctx.fixtures.hospitalBId,
|
|
||||||
});
|
});
|
||||||
expectSuccessEnvelope(loginResponse, 201);
|
expectSuccessEnvelope(loginResponse, 201);
|
||||||
|
|
||||||
|
|||||||
2
tyt-admin/components.d.ts
vendored
2
tyt-admin/components.d.ts
vendored
@ -42,6 +42,8 @@ declare module 'vue' {
|
|||||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
ElOption: typeof import('element-plus/es')['ElOption']
|
ElOption: typeof import('element-plus/es')['ElOption']
|
||||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
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']
|
ElRow: typeof import('element-plus/es')['ElRow']
|
||||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||||
|
|||||||
@ -19,24 +19,50 @@ export const useUserStore = defineStore('user', {
|
|||||||
this.userInfo = info;
|
this.userInfo = info;
|
||||||
localStorage.setItem('tyt_user_info', JSON.stringify(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) {
|
async login(loginForm) {
|
||||||
const payload = { ...loginForm };
|
const payload = { ...loginForm };
|
||||||
|
if (!payload.role) {
|
||||||
|
delete payload.role;
|
||||||
|
}
|
||||||
if (!payload.hospitalId) {
|
if (!payload.hospitalId) {
|
||||||
delete payload.hospitalId;
|
delete payload.hospitalId;
|
||||||
}
|
}
|
||||||
const data = await request.post('/auth/login', payload);
|
const data = await request.post('/auth/login', payload);
|
||||||
// The backend should return the token in data.accessToken
|
if (data?.needSelect) {
|
||||||
if (data && data.accessToken) {
|
return {
|
||||||
this.setToken(data.accessToken);
|
needSelect: true,
|
||||||
// Backend also returns actor and user info directly in login response
|
loginTicket: data.loginTicket,
|
||||||
if (data.user) {
|
accounts: data.accounts || [],
|
||||||
this.setUserInfo(data.user);
|
};
|
||||||
} else {
|
|
||||||
await this.fetchUserInfo();
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
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() {
|
async fetchUserInfo() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -26,33 +26,10 @@
|
|||||||
:prefix-icon="Lock"
|
:prefix-icon="Lock"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item prop="role">
|
|
||||||
<el-select
|
|
||||||
v-model="loginForm.role"
|
|
||||||
placeholder="请选择登录角色"
|
|
||||||
style="width: 100%"
|
|
||||||
>
|
|
||||||
<el-option label="系统管理员" value="SYSTEM_ADMIN" />
|
|
||||||
<el-option label="医院管理员" value="HOSPITAL_ADMIN" />
|
|
||||||
<el-option label="科室主任" value="DIRECTOR" />
|
|
||||||
<el-option label="小组组长" value="LEADER" />
|
|
||||||
<el-option label="医生" value="DOCTOR" />
|
|
||||||
<el-option label="工程师" value="ENGINEER" />
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item>
|
|
||||||
<el-input-number
|
|
||||||
v-model="loginForm.hospitalId"
|
|
||||||
:min="1"
|
|
||||||
:controls="false"
|
|
||||||
placeholder="医院 ID(多账号场景建议填写)"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-alert
|
<el-alert
|
||||||
type="info"
|
type="info"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
title="若同一手机号在多个医院有同角色账号,请填写医院 ID。"
|
title="登录页只需手机号和密码。若匹配到多个院内账号,系统会在下一步让你选择。"
|
||||||
style="margin-bottom: 16px"
|
style="margin-bottom: 16px"
|
||||||
/>
|
/>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
@ -66,6 +43,40 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="accountSelectVisible"
|
||||||
|
title="选择登录账号"
|
||||||
|
width="420px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:close-on-press-escape="false"
|
||||||
|
:show-close="false"
|
||||||
|
>
|
||||||
|
<el-radio-group v-model="selectedAccountId" class="account-list">
|
||||||
|
<el-radio
|
||||||
|
v-for="account in candidateAccounts"
|
||||||
|
:key="account.id"
|
||||||
|
:value="account.id"
|
||||||
|
class="account-item"
|
||||||
|
>
|
||||||
|
<div class="account-name">{{ account.name }}</div>
|
||||||
|
<div class="account-meta">
|
||||||
|
{{ formatAccountMeta(account) }}
|
||||||
|
</div>
|
||||||
|
</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="resetAccountSelection">取消</el-button>
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:loading="confirmLoading"
|
||||||
|
@click="handleConfirmLogin"
|
||||||
|
>
|
||||||
|
确认进入
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -82,12 +93,15 @@ const userStore = useUserStore();
|
|||||||
|
|
||||||
const loginFormRef = ref(null);
|
const loginFormRef = ref(null);
|
||||||
const loading = ref(false);
|
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({
|
const loginForm = reactive({
|
||||||
phone: '',
|
phone: '',
|
||||||
password: '',
|
password: '',
|
||||||
role: 'SYSTEM_ADMIN', // Default role
|
|
||||||
hospitalId: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const rules = {
|
const rules = {
|
||||||
@ -99,7 +113,27 @@ const rules = {
|
|||||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||||
{ min: 8, message: '密码长度至少为 8 位', 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 () => {
|
const handleLogin = async () => {
|
||||||
@ -108,11 +142,17 @@ const handleLogin = async () => {
|
|||||||
if (valid) {
|
if (valid) {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const success = await userStore.login(loginForm);
|
const result = await userStore.login(loginForm);
|
||||||
if (success) {
|
if (result?.needSelect) {
|
||||||
ElMessage.success('登录成功');
|
pendingLoginTicket.value = result.loginTicket;
|
||||||
const redirect = route.query.redirect || '/';
|
candidateAccounts.value = result.accounts || [];
|
||||||
router.push(redirect);
|
selectedAccountId.value = result.accounts?.[0]?.id ?? null;
|
||||||
|
accountSelectVisible.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result?.success) {
|
||||||
|
redirectToTarget();
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error('登录失败,未获取到登录信息');
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -145,4 +211,30 @@ const handleLogin = async () => {
|
|||||||
.login-btn {
|
.login-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.account-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-item {
|
||||||
|
margin-right: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #dcdfe6;
|
||||||
|
border-radius: 10px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #303133;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.account-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user