Compare commits

..

No commits in common. "7c4ba1e1a042cfe066e849aa951b17f7854a2108" and "0b5640a97722aa013463206ec4f9c0074040cbd1" have entirely different histories.

42 changed files with 499 additions and 1912 deletions

View File

@ -1,5 +1,3 @@
DATABASE_URL="postgresql://postgres:lyh1234@192.168.0.180:5432/tyt-api-nest"
AUTH_TOKEN_SECRET="replace-with-a-strong-random-secret"
SYSTEM_ADMIN_BOOTSTRAP_KEY="replace-with-admin-bootstrap-key"
WECHAT_MINIAPP_APPID="replace-with-miniapp-appid"
WECHAT_MINIAPP_SECRET="replace-with-miniapp-secret"

View File

@ -2,74 +2,39 @@
## 1. 目标
- 提供系统管理员创建、院内账号密码登录、B 端小程序手机号登录、C 端小程序手机号登录、`/me` 身份查询。
- 使用 JWT 做认证,院内账号与家属小程序账号走两套守卫
- 提供系统管理员创建、登录、`/me` 身份查询。
- 使用 JWT 做认证,Guard 做鉴权RolesGuard 做 RBAC
## 2. 核心接口
- `POST /auth/system-admin`:创建系统管理员(需引导密钥)
- `POST /auth/login`:院内账号密码登录,后台与小程序均可复用
- `POST /auth/login/confirm`:院内账号密码多账号确认登录
- `POST /auth/miniapp/b/phone-login`B 端小程序手机号登录
- `POST /auth/miniapp/b/phone-login/confirm`B 端同手机号多账号确认登录
- `POST /auth/miniapp/c/phone-login`C 端小程序手机号登录
- `GET /auth/me`:返回当前院内登录用户上下文
- `POST /auth/login`:手机号 + 角色 + 密码登录(支持同手机号多院场景)
- `GET /auth/me`:返回当前登录用户上下文
## 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. 鉴权流程
### 院内账号
## 3. 鉴权流程
1. `AccessTokenGuard``Authorization` 读取 Bearer Token。
2. 校验 JWT 签名、`id``iat` 等关键载荷字段。
3. 根据 `id` 回库读取 `User` 当前角色与组织归属。
4. 校验 `iat >= user.tokenValidAfter`,保证旧 token 失效。
3. 根据 `id` 回库读取用户当前角色与组织归属,不再直接信任 token 里的角色和范围。
4. 校验 `iat >= user.tokenValidAfter`若用户被重置密码、seed 重刷或账号被清理,则旧 token 立即失效。
5. 当前数据库用户映射为 `ActorContext` 注入 `request.actor`
6. `RolesGuard` 根据 `@Roles(...)` 判断角色是否允许访问。
### 家属小程序账号
## 4. Token 约定
1. `FamilyAccessTokenGuard``Authorization` 读取 Bearer Token。
2. 校验 C 端 token 的 `id + type=FAMILY_MINIAPP`
3. 根据 `id` 回库读取 `FamilyMiniAppAccount`
4. 将家属账号上下文注入 `request.familyActor`
- Header`Authorization: Bearer <token>`
- 载荷关键字段:`id``iat`
- 角色和组织范围以数据库当前用户记录为准,不以 token 历史载荷为准
## 6. 环境变量
## 5. 错误码与中文消息
- `AUTH_TOKEN_SECRET`
- `SYSTEM_ADMIN_BOOTSTRAP_KEY`
- `WECHAT_MINIAPP_APPID`
- `WECHAT_MINIAPP_SECRET`
- 未登录/Token 失效:`401` + 中文 `msg`
- 角色无权限:`403` + 中文 `msg`
- 参数非法:`400` + 中文 `msg`
## 7. 关键规则
统一由全局异常过滤器输出:`{ code, msg, data: null }`
- 后台 Web 与 B 端小程序都可以复用 `POST /auth/login` 做账号密码登录。
- 密码登录命中多个院内账号时,不再强制前端手填 `hospitalId`,改为后端返回候选账号供选择。
- B 端小程序登录复用 `User` 表,继续使用 `openId`
- B 端账号未绑定 `openId` 时,首次小程序登录自动绑定。
- 同一个 `openId` 可绑定多个院内账号,支持同一微信号切换多角色/多院区账号。
- 若目标院内账号已绑定其他微信号,仍需先在用户管理中清空该账号的 `openId` 再重新绑定。
- C 端家属账号独立存放在 `FamilyMiniAppAccount`
- C 端手机号必须先存在于患者档案,否则拒绝登录。
- `serviceUid` 仅预留字段,本次不提供绑定接口。
## 6. 失效策略
- 用户密码被修改后,会刷新 `user.tokenValidAfter`,旧 token 全部失效。
- 执行 E2E 重置并重新 seed 后seed 账号的 `tokenValidAfter` 也会刷新,历史 token 不可继续复用。

View File

@ -1,46 +1,86 @@
# 前后端联调说明
# 前端接口接入说明(`tyt-admin`
## 1. 登录
## 1. 本次接入范围
### B 端账号密码登录
- 登录页:`/auth/login`,支持可选 `hospitalId`
- 首页看板:按角色拉取组织与患者统计。
- 设备页:新增管理员专用设备 CRUD复用真实设备接口。
- 任务页:接入真实任务列表、工程师接收与完成接口。
- 用户页:修复用户列表响应结构、组织字段联动、工程师分配医院参数。
- 患者页:接入真实患者字段与生命周期查询参数(`phone + idCard`
后端直接保存身份证号原文,不再做哈希转换;调压任务入口迁到患者页。
- 新增影像库页:接入真实上传接口,支持图片/视频/文件上传与分页查看。
- `POST /auth/login`
- 入参:
- `phone`
- `password`
- `role`(可选)
- `hospitalId`(可选)
- 若返回 `needSelect: true`,继续调用:
- `POST /auth/login/confirm`
- 入参:`loginTicket + userId`
## 2. 接口契约对齐点
### B 端小程序
- `GET /users` 当前返回数组,前端已在 `api/users.js` 做本地分页与筛选适配。
- `PATCH /b/users/:id/assign-engineer-hospital` 参数为单个 `hospitalId`,非数组。
- `GET /b/patients` 返回数组,前端已改为本地分页与筛选。
- `GET /b/devices` 已支持服务端分页与筛选,前端直接透传 `page/pageSize`
- `GET /b/tasks` 返回 `{ list, total, page, pageSize }`,供任务页只读展示调压记录。
- `POST /b/uploads` 使用 `multipart/form-data` 上传文件,返回上传资产元数据。
- `GET /b/uploads` 返回 `{ list, total, page, pageSize }`,仅供系统管理员/医院管理员影像库分页展示。
- `GET /b/tasks/engineers` 返回当前角色可见的医院工程师列表。
- `GET /c/patients/lifecycle` 必须同时传 `phone``idCard`
- 患者表单中的 `idCard` 字段直接传身份证号;
服务端只会做去空格与 `x/X` 标准化,不会转哈希。
- 患者手术、调压任务、设备目录中的压力值全部按字符串挡位标签传输,例如 `0.5``1``1.5``10`
- 第一步:`POST /auth/miniapp/b/phone-login`
- 入参:
- `loginCode`
- `phoneCode`
- 若返回 `needSelect: true`,继续调用:
- `POST /auth/miniapp/b/phone-login/confirm`
- 入参:`loginTicket + userId`
## 3. 角色权限提示
### C 端小程序
- 任务接口权限:
- `SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER`:发布时不再指定接收工程师;可取消自己创建的任务
- `ENGINEER`:可接收本院待接收任务;仅可完成自己已接收的任务
- 患者列表权限:
- `SYSTEM_ADMIN` 查询时必须传 `hospitalId`
- 用户管理接口:
- `SYSTEM_ADMIN/HOSPITAL_ADMIN` 可创建、编辑、删除
- `DIRECTOR` 可只读查看本科室下级医生/组长
- 工程师绑定医院仅 `SYSTEM_ADMIN`
- 删除:`SYSTEM_ADMIN` 可删除任意无关联用户;`HOSPITAL_ADMIN` 可删除本院无关联、且非管理员用户
- `POST /auth/miniapp/c/phone-login`
- 入参:
- `loginCode`
- `phoneCode`
## 3.1 结构图页面交互调整
## 2. C 端生命周期
- 医院管理员视角下,右侧下级列表会优先显示“人员”节点,再显示组织节点。
- 选中人员节点时,右侧展示人员详情(角色、手机号、所属医院/科室/小组),不再显示空白占位。
- 登录成功后调用:`GET /c/patients/my-lifecycle`
- 不再需要传 `phone``idCard`
- Bearer Token 使用 C 端家属登录返回的 `accessToken`
## 3.2 后台页面路由权限(与后端 RBAC 对齐)
## 3. B 端说明
- `organization/tree``organization/departments``organization/groups``users`
- `organization/tree``organization/groups`
`SYSTEM_ADMIN``HOSPITAL_ADMIN``DIRECTOR``LEADER` 可访问
- `organization/departments`
`SYSTEM_ADMIN``HOSPITAL_ADMIN` 可访问
- `users``SYSTEM_ADMIN``HOSPITAL_ADMIN` 可管理;`DIRECTOR` 可只读查看
- `devices`:仅 `SYSTEM_ADMIN``HOSPITAL_ADMIN` 可访问
- `uploads`:仅 `SYSTEM_ADMIN``HOSPITAL_ADMIN` 可访问
- `organization/hospitals`
- 仅 `SYSTEM_ADMIN` 可访问
- `tasks`
- `SYSTEM_ADMIN``HOSPITAL_ADMIN``DOCTOR``DIRECTOR``LEADER``ENGINEER` 可访问
- `patients`
- `SYSTEM_ADMIN``HOSPITAL_ADMIN``DIRECTOR``LEADER``DOCTOR` 可访问
- 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
```

View File

@ -2,33 +2,134 @@
## 1. 目标
- B 端:维护患者、手术、植入设备及生命周期数据。
- C 端:家属小程序登录后,按已绑定手机号聚合查询跨院患者生命周期。
- B 端:按组织与角色范围查询患者,并维护患者基础档案。
- B 端:支持患者首术录入、二次手术追加、旧设备弃用标记。
- 数据关系升级为 `Patient -> PatientSurgery -> Device -> TaskItem -> Task`
- C 端:按 `phone + idCard` 做跨院生命周期聚合,返回手术事件与调压事件。
## 2. B 端能力
## 2. 患者基础档案
- 患者列表、详情、创建、更新、删除
- 手术记录新增
- 植入设备录入与历史保留
患者表新增以下字段:
## 3. C 端能力
- `inpatientNo`:住院号
- `projectName`:项目名称
- `phone`:联系电话
- `idCard`:身份证号原文
- `doctorId`:患者归属人员(医生/主任/组长)
- 家属账号通过小程序手机号登录
- `GET /c/patients/my-lifecycle`
- 查询口径:按 `FamilyMiniAppAccount.phone` 聚合 `Patient.phone`
- 返回内容:手术事件 + 调压事件的时间线
说明:
## 4. 当前规则
- `name` 仍然保留为患者姓名必填字段。
- `doctorId + hospitalId` 仍然是患者的组织归属来源,不直接绑定小组/科室。
- 手术表单中的 `原发病 / 脑积水类型 / 分流方式 / 近端穿刺区域 / 阀门植入部位 / 远端分流方向` 改为读取系统字典,不再前端硬编码。
- 同一个手机号可关联多个患者C 端会统一聚合返回。
- C 端手机号来源于患者手术/档案中维护的联系电话。
- 仅已登录的家属小程序账号可访问 `my-lifecycle`
- 家属账号不存在或 token 无效时返回 `401`
- 手机号下无患者档案时,登录阶段直接拦截,不进入生命周期查询。
## 3. 手术档案
## 5. 典型接口
新增 `PatientSurgery` 表,每次手术保存:
- `GET /b/patients`
- `POST /b/patients`
- `POST /b/patients/:id/surgeries`
- `GET /c/patients/my-lifecycle`
- `surgeryDate`:手术日期
- `surgeryName`:手术名称
- `surgeonId`:主刀医生账号 ID自动等于患者归属医生
- `surgeonName`:主刀医生姓名快照
- `preOpPressure`:术前测压,可为空
- `primaryDisease`:原发病
- `hydrocephalusTypes`:脑积水类型,多选
- `previousShuntSurgeryDate`:上次分流手术时间,可为空
- `preOpMaterials`:术前 CT 影像/资料,保存为附件元数据数组
- `notes`:手术备注,可为空
返回时会自动补充:
- `shuntSurgeryCount`:当前这台手术是该患者第几次分流手术
- `activeDeviceCount`:本次手术仍在用设备数
- `abandonedDeviceCount`:本次手术已弃用设备数
说明:
- 新增/修改手术时,前端不再单独提交主刀医生。
- 后端会直接使用患者当前的 `doctorId` 作为 `surgeonId`,并保存当时的 `surgeonName` 快照。
- 如果后续患者归属医生发生变化,只会影响后续新建手术;历史手术仍保留创建当时的主刀医生快照。
## 4. 植入设备
设备表仍沿用 `Device`,但语义改为“患者手术下植入的设备实例”。
新增/使用字段:
- `implantCatalogId`:植入物型号字典 ID
- `implantModel` / `implantManufacturer` / `implantName`:型号快照
- `isValve`:是否为阀门
- `isPressureAdjustable`:是否可调压
- `isAbandoned`:是否已弃用
- `shuntMode`:分流方式
- `proximalPunctureAreas`:近端穿刺区域,最多 2 个
- `valvePlacementSites`:阀门植入部位,最多 2 个
- `distalShuntDirection`:远端分流方向
- `initialPressure`:初始压力挡位标签,可为空
- `implantNotes`:植入物备注,可为空
- `labelImageUrl`:植入物标签图片地址,可为空
说明:
- 患者手术里选择的是“全局植入物目录”,不是按医院单独维护的设备库。
- 同一个植入物目录可被多个患者手术重复绑定,患者侧保存的是目录快照。
- 旧设备弃用后,`TaskItem` 历史不会删除。
- 任务发布只允许选择 `ACTIVE + isPressureAdjustable=true + isAbandoned=false` 的设备。
- 同一患者一次手术可录入多个设备,因此支持“两个可调压仪器同时佩戴”。
- 管子/附件类型不会显示“阀门植入部位”和“初始压力”录入项。
- 如果植入物目录配置了挡位,`initialPressure` 必须取挡位中的字符串标签值,例如 `0.5 / 1 / 1.5 / 10 / 20 / 30`
- 挡位标签保存前会自动标准化,例如 `01.0 -> 1``1.50 -> 1.5`
- 手术创建时不允许手工录入 `currentPressure`,设备当前压力默认继承 `initialPressure`;发布调压任务时不会立刻改当前压力,只有工程师完成任务后才会更新。
- 术前资料与植入物标签现在支持直接上传;上传成功后,患者详情会按图片/视频做预览,不再只是裸链接。
## 5. B 端可见性
- `DOCTOR`:仅可查自己名下患者
- `LEADER`:可查本组医生名下患者
- `DIRECTOR`:可查本科室医生名下患者
- `HOSPITAL_ADMIN`:可查本院全部患者
- `SYSTEM_ADMIN`:需显式传入目标 `hospitalId`
## 6. B 端接口
- `GET /b/patients`:按角色查询可见患者列表
- `GET /b/patients/doctors`:查询当前角色可见的归属人员候选
- `POST /b/patients`:创建患者,可选带 `initialSurgery`
- `POST /b/patients/:id/surgeries`:为患者新增手术
- `GET /b/patients/:id`:查询患者详情
- `PATCH /b/patients/:id`:更新患者基础信息
- `DELETE /b/patients/:id`:删除患者
约束:
- `PATCH /b/patients/:id` 不直接修改手术,手术必须走新增手术接口。
- 新增二次手术时可传 `abandonedDeviceIds`,后端会将这些旧设备标记为 `INACTIVE + isAbandoned=true`
- 患者建档时会自动记录 `creator`,列表和详情都会返回创建人信息。
## 7. C 端生命周期聚合
接口:`GET /c/patients/lifecycle?phone=...&idCard=...`
查询策略:
1. 不做医院隔离(跨租户)
2. 先将 `idCard` 做轻量标准化,再做双字段精确匹配
3. 聚合 `Patient -> PatientSurgery -> Device` 的手术事件
4. 聚合 `Patient -> Device -> TaskItem -> Task` 的调压事件
5. 全部事件按 `occurredAt DESC` 返回
事件类型:
- `SURGERY`
- `TASK_PRESSURE_ADJUSTMENT`
说明:
- 生命周期中的 `initialPressure / currentPressure / oldPressure / targetPressure` 均返回字符串挡位标签。
## 8. 响应结构
全部接口统一返回:
- 成功:`{ code: 0, msg: "成功", data: ... }`
- 失败:`{ code: x, msg: "中文错误", data: null }`

View File

@ -1,24 +0,0 @@
-- 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");

View File

@ -1,4 +0,0 @@
-- 允许同一个微信 openId 绑定多个院内账号,保留普通索引供查询复用。
DROP INDEX IF EXISTS "User_openId_key";
CREATE INDEX IF NOT EXISTS "User_openId_idx" ON "User"("openId");

View File

@ -55,7 +55,7 @@ enum UploadAssetType {
// 医院主表:多租户顶层实体。
model Hospital {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
name String
departments Department[]
users User[]
@ -88,7 +88,6 @@ model Group {
}
// 用户表:支持后台密码登录与小程序 openId。
// 同一个微信 openId 允许绑定多个院内账号,便于多角色/多院区切换。
model User {
id Int @id @default(autoincrement())
name String
@ -97,7 +96,7 @@ model User {
passwordHash String?
// 该时间点之前签发的 token 一律失效。
tokenValidAfter DateTime @default(now())
openId String?
openId String? @unique
role Role
hospitalId Int?
departmentId Int?
@ -115,25 +114,11 @@ model User {
@@unique([phone, role, hospitalId])
@@index([phone])
@@index([openId])
@@index([hospitalId, role])
@@index([departmentId, 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 {
id Int @id @default(autoincrement())
@ -276,18 +261,18 @@ model Device {
// 主任务表:记录调压任务主单。
model Task {
id Int @id @default(autoincrement())
status TaskStatus @default(PENDING)
creatorId Int
engineerId Int?
hospitalId Int
createdAt DateTime @default(now())
id Int @id @default(autoincrement())
status TaskStatus @default(PENDING)
creatorId Int
engineerId Int?
hospitalId Int
createdAt DateTime @default(now())
// 工程师完成任务时上传的图片/视频凭证。
completionMaterials Json?
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
engineer User? @relation("TaskEngineer", fields: [engineerId], references: [id])
hospital Hospital @relation(fields: [hospitalId], references: [id])
items TaskItem[]
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
engineer User? @relation("TaskEngineer", fields: [engineerId], references: [id])
hospital Hospital @relation(fields: [hospitalId], references: [id])
items TaskItem[]
@@index([hospitalId, status, createdAt])
}

View File

@ -46,21 +46,18 @@ async function ensureGroup(departmentId, name) {
);
}
async function upsertUserByScope(data) {
async function upsertUserByOpenId(openId, data) {
return prisma.user.upsert({
where: {
phone_role_hospitalId: {
phone: data.phone,
role: data.role,
hospitalId: data.hospitalId,
},
},
where: { openId },
// 每次重置/补种子时推进失效时间,确保历史 token 无法继续访问。
update: {
...data,
tokenValidAfter: new Date(),
},
create: data,
create: {
...data,
openId,
},
});
}
@ -112,36 +109,6 @@ async function ensurePatient({
});
}
async function ensureFamilyMiniAppAccount({
phone,
openId = null,
serviceUid = null,
}) {
const existing = await prisma.familyMiniAppAccount.findUnique({
where: { phone },
});
if (existing) {
return prisma.familyMiniAppAccount.update({
where: { id: existing.id },
data: {
openId,
serviceUid,
lastLoginAt: new Date(),
},
});
}
return prisma.familyMiniAppAccount.create({
data: {
phone,
openId,
serviceUid,
lastLoginAt: new Date(),
},
});
}
async function ensureImplantCatalog({
modelCode,
manufacturer,
@ -323,121 +290,113 @@ async function main() {
const groupA2 = await ensureGroup(departmentA2.id, 'Shift-A2');
const groupB1 = await ensureGroup(departmentB1.id, 'Shift-B1');
const systemAdmin = await upsertUserByScope({
const systemAdmin = await upsertUserByOpenId('seed-system-admin-openid', {
name: 'Seed System Admin',
phone: '13800001000',
passwordHash: seedPasswordHash,
openId: 'seed-system-admin-openid',
role: Role.SYSTEM_ADMIN,
hospitalId: null,
departmentId: null,
groupId: null,
});
const hospitalAdminA = await upsertUserByScope({
name: 'Seed Hospital Admin A',
phone: '13800001001',
passwordHash: seedPasswordHash,
openId: 'seed-hospital-admin-a-openid',
role: Role.HOSPITAL_ADMIN,
hospitalId: hospitalA.id,
departmentId: null,
groupId: null,
});
const hospitalAdminA = await upsertUserByOpenId(
'seed-hospital-admin-a-openid',
{
name: 'Seed Hospital Admin A',
phone: '13800001001',
passwordHash: seedPasswordHash,
role: Role.HOSPITAL_ADMIN,
hospitalId: hospitalA.id,
departmentId: null,
groupId: null,
},
);
await upsertUserByScope({
await upsertUserByOpenId('seed-hospital-admin-b-openid', {
name: 'Seed Hospital Admin B',
phone: '13800001101',
passwordHash: seedPasswordHash,
openId: 'seed-hospital-admin-b-openid',
role: Role.HOSPITAL_ADMIN,
hospitalId: hospitalB.id,
departmentId: null,
groupId: null,
});
const directorA = await upsertUserByScope({
const directorA = await upsertUserByOpenId('seed-director-a-openid', {
name: 'Seed Director A',
phone: '13800001002',
passwordHash: seedPasswordHash,
openId: 'seed-director-a-openid',
role: Role.DIRECTOR,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: null,
});
const leaderA = await upsertUserByScope({
const leaderA = await upsertUserByOpenId('seed-leader-a-openid', {
name: 'Seed Leader A',
phone: '13800001003',
passwordHash: seedPasswordHash,
openId: 'seed-leader-a-openid',
role: Role.LEADER,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: groupA1.id,
});
const doctorA = await upsertUserByScope({
const doctorA = await upsertUserByOpenId('seed-doctor-a-openid', {
name: 'Seed Doctor A',
phone: '13800001004',
passwordHash: seedPasswordHash,
openId: 'seed-doctor-a-openid',
role: Role.DOCTOR,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: groupA1.id,
});
const doctorA2 = await upsertUserByScope({
const doctorA2 = await upsertUserByOpenId('seed-doctor-a2-openid', {
name: 'Seed Doctor A2',
phone: '13800001204',
passwordHash: seedPasswordHash,
openId: 'seed-doctor-a2-openid',
role: Role.DOCTOR,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: groupA1.id,
});
const doctorA3 = await upsertUserByScope({
const doctorA3 = await upsertUserByOpenId('seed-doctor-a3-openid', {
name: 'Seed Doctor A3',
phone: '13800001304',
passwordHash: seedPasswordHash,
openId: 'seed-doctor-a3-openid',
role: Role.DOCTOR,
hospitalId: hospitalA.id,
departmentId: departmentA2.id,
groupId: groupA2.id,
});
const doctorB = await upsertUserByScope({
const doctorB = await upsertUserByOpenId('seed-doctor-b-openid', {
name: 'Seed Doctor B',
phone: '13800001104',
passwordHash: seedPasswordHash,
openId: 'seed-doctor-b-openid',
role: Role.DOCTOR,
hospitalId: hospitalB.id,
departmentId: departmentB1.id,
groupId: groupB1.id,
});
const engineerA = await upsertUserByScope({
const engineerA = await upsertUserByOpenId('seed-engineer-a-openid', {
name: 'Seed Engineer A',
phone: '13800001005',
passwordHash: seedPasswordHash,
openId: 'seed-engineer-a-openid',
role: Role.ENGINEER,
hospitalId: hospitalA.id,
departmentId: null,
groupId: null,
});
const engineerB = await upsertUserByScope({
const engineerB = await upsertUserByOpenId('seed-engineer-b-openid', {
name: 'Seed Engineer B',
phone: '13800001105',
passwordHash: seedPasswordHash,
openId: 'seed-engineer-b-openid',
role: Role.ENGINEER,
hospitalId: hospitalB.id,
departmentId: null,
@ -537,11 +496,6 @@ async function main() {
idCard: '110101199001010011',
});
await ensureFamilyMiniAppAccount({
phone: patientA2.phone,
openId: 'seed-family-a2-openid',
});
const adjustableCatalog = await ensureImplantCatalog({
modelCode: 'SEED-ADJUSTABLE-VALVE',
manufacturer: 'Seed MedTech',

View File

@ -1,14 +1,11 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AuthService } from './auth.service.js';
import { LoginDto } from '../users/dto/login.dto.js';
import { AccessTokenGuard } from './access-token.guard.js';
import { CurrentActor } from './current-actor.decorator.js';
import type { ActorContext } from '../common/actor-context.js';
import { CreateSystemAdminDto } from './dto/create-system-admin.dto.js';
import { MiniappPhoneLoginDto } from './dto/miniapp-phone-login.dto.js';
import { MiniappPhoneLoginConfirmDto } from './dto/miniapp-phone-login-confirm.dto.js';
import { PasswordLoginConfirmDto } from './dto/password-login-confirm.dto.js';
import { LoginDto } from '../users/dto/login.dto.js';
/**
*
@ -28,38 +25,14 @@ export class AuthController {
}
/**
*
* JWT
*/
@Post('login')
@ApiOperation({ summary: '院内账号密码登录' })
@ApiOperation({ summary: '登录' })
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Post('login/confirm')
@ApiOperation({ summary: '院内账号密码多账号确认登录' })
confirmLogin(@Body() dto: PasswordLoginConfirmDto) {
return this.authService.confirmLogin(dto);
}
@Post('miniapp/b/phone-login')
@ApiOperation({ summary: 'B 端小程序手机号登录' })
miniAppBLogin(@Body() dto: MiniappPhoneLoginDto) {
return this.authService.miniAppBLogin(dto);
}
@Post('miniapp/b/phone-login/confirm')
@ApiOperation({ summary: 'B 端小程序多账号确认登录' })
miniAppBConfirmLogin(@Body() dto: MiniappPhoneLoginConfirmDto) {
return this.authService.miniAppBConfirmLogin(dto);
}
@Post('miniapp/c/phone-login')
@ApiOperation({ summary: 'C 端小程序手机号登录' })
miniAppCLogin(@Body() dto: MiniappPhoneLoginDto) {
return this.authService.miniAppCLogin(dto);
}
/**
*
*/

View File

@ -3,16 +3,14 @@ import { AuthService } from './auth.service.js';
import { AuthController } from './auth.controller.js';
import { UsersModule } from '../users/users.module.js';
import { AccessTokenGuard } from './access-token.guard.js';
import { WechatMiniAppService } from './wechat-miniapp/wechat-miniapp.service.js';
import { MiniAppAuthService } from './miniapp-auth/miniapp-auth.service.js';
/**
*
*/
@Module({
imports: [UsersModule],
providers: [AuthService, AccessTokenGuard, WechatMiniAppService, MiniAppAuthService],
providers: [AuthService, AccessTokenGuard],
controllers: [AuthController],
exports: [AuthService, AccessTokenGuard, WechatMiniAppService, MiniAppAuthService],
exports: [AuthService, AccessTokenGuard],
})
export class AuthModule {}

View File

@ -3,20 +3,13 @@ import type { ActorContext } from '../common/actor-context.js';
import { UsersService } from '../users/users.service.js';
import { LoginDto } from '../users/dto/login.dto.js';
import { CreateSystemAdminDto } from './dto/create-system-admin.dto.js';
import { MiniappPhoneLoginConfirmDto } from './dto/miniapp-phone-login-confirm.dto.js';
import { MiniappPhoneLoginDto } from './dto/miniapp-phone-login.dto.js';
import { PasswordLoginConfirmDto } from './dto/password-login-confirm.dto.js';
import { MiniAppAuthService } from './miniapp-auth/miniapp-auth.service.js';
/**
*
*/
@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly miniAppAuthService: MiniAppAuthService,
) {}
constructor(private readonly usersService: UsersService) {}
/**
*
@ -26,40 +19,12 @@ export class AuthService {
}
/**
*
*
*/
login(dto: LoginDto) {
return this.usersService.login(dto);
}
/**
*
*/
confirmLogin(dto: PasswordLoginConfirmDto) {
return this.usersService.confirmLogin(dto);
}
/**
* B
*/
miniAppBLogin(dto: MiniappPhoneLoginDto) {
return this.miniAppAuthService.loginForB(dto);
}
/**
* B
*/
miniAppBConfirmLogin(dto: MiniappPhoneLoginConfirmDto) {
return this.miniAppAuthService.confirmLoginForB(dto);
}
/**
* C
*/
miniAppCLogin(dto: MiniappPhoneLoginDto) {
return this.miniAppAuthService.loginForC(dto);
}
/**
*
*/

View File

@ -1,12 +0,0 @@
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;
},
);

View File

@ -15,7 +15,7 @@ export class CreateSystemAdminDto {
password!: string;
@ApiPropertyOptional({
description: '可选微信 openId(院内账号间可复用)',
description: '可选微信 openId',
example: 'o123abcxyz',
})
@IsOptional()

View File

@ -1,24 +0,0 @@
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;
}

View File

@ -1,21 +0,0 @@
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;
}

View File

@ -1,24 +0,0 @@
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;
}

View File

@ -1,82 +0,0 @@
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;
}
}

View File

@ -1,251 +0,0 @@
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;
}
}

View File

@ -1,176 +0,0 @@
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;
}
}

View File

@ -1,6 +0,0 @@
export type FamilyActorContext = {
id: number;
phone: string;
openId: string | null;
serviceUid: string | null;
};

View File

@ -25,25 +25,14 @@ export const MESSAGES = {
TOKEN_REVOKED: 'Token 已失效,请重新登录',
TOKEN_ROLE_INVALID: 'Token 中角色信息不合法',
TOKEN_FIELD_INVALID: 'Token 中字段不合法',
INVALID_CREDENTIALS: '手机号、密码或角色不匹配',
INVALID_CREDENTIALS: '手机号、角色或密码错误',
PASSWORD_NOT_ENABLED: '该账号未启用密码登录',
PASSWORD_LOGIN_TICKET_INVALID: '账号选择票据无效或已过期',
PASSWORD_ACCOUNT_SELECTION_INVALID: '请选择有效的候选账号',
REGISTER_DISABLED: '注册接口已关闭,请联系管理员创建账号',
WECHAT_MINIAPP_CONFIG_MISSING: '服务端未配置微信小程序认证参数',
WECHAT_MINIAPP_LOGIN_FAILED: '微信登录授权失败,请重新获取登录凭证',
WECHAT_MINIAPP_PHONE_FAILED: '微信手机号授权失败,请重新获取手机号凭证',
MINIAPP_NO_MATCHED_USER: '手机号未匹配到院内账号',
MINIAPP_LOGIN_TICKET_INVALID: '账号选择票据无效或已过期',
MINIAPP_ACCOUNT_SELECTION_INVALID: '请选择有效的候选账号',
MINIAPP_OPEN_ID_BOUND_OTHER_USER: '当前院内账号已绑定其他微信账号',
MINIAPP_OPEN_ID_BOUND_OTHER_FAMILY: '当前微信账号已绑定其他家属账号',
FAMILY_PHONE_NOT_LINKED_PATIENT: '当前手机号未关联患者档案',
FAMILY_ACCOUNT_NOT_FOUND: '家属登录账号不存在,请重新登录',
},
USER: {
NOT_FOUND: '用户不存在',
DUPLICATE_OPEN_ID: 'openId 已被注册',
DUPLICATE_PHONE_ROLE_SCOPE: '同医院下该角色手机号已存在',
INVALID_ROLE: '角色不合法',
INVALID_PHONE: '手机号格式不合法',
@ -65,6 +54,8 @@ export const MESSAGES = {
GROUP_DEPARTMENT_MISMATCH: '小组不属于指定科室',
DOCTOR_ONLY_SCOPE_CHANGE: '仅医生/主任/组长允许调整科室/小组归属',
DELETE_CONFLICT: '用户存在关联患者或任务,无法删除',
MULTI_ACCOUNT_REQUIRE_HOSPITAL:
'检测到多个同手机号账号,请传 hospitalId 指定登录医院',
CREATE_FORBIDDEN: '当前角色无权限创建该用户',
HOSPITAL_ADMIN_SCOPE_FORBIDDEN: '医院管理员仅可操作本院非管理员账号',
DIRECTOR_SCOPE_FORBIDDEN: '科室主任仅可操作本科室医生或组长账号',
@ -100,6 +91,7 @@ export const MESSAGES = {
DOCTOR_ROLE_REQUIRED: '归属用户必须为医生/主任/组长角色',
DOCTOR_SCOPE_FORBIDDEN: '仅可选择当前权限范围内医生/主任/组长',
DELETE_CONFLICT: '患者存在关联设备,无法删除',
PHONE_IDCARD_REQUIRED: 'phone 与 idCard 均为必填',
LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证号',
SYSTEM_ADMIN_HOSPITAL_REQUIRED: '系统管理员查询必须显式传入 hospitalId',
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',

View File

@ -40,7 +40,6 @@ const PATIENT_LIST_INCLUDE = {
id: true,
status: true,
currentPressure: true,
initialPressure: true,
isAbandoned: true,
implantModel: true,
implantManufacturer: true,
@ -55,8 +54,6 @@ const PATIENT_LIST_INCLUDE = {
id: true,
surgeryDate: true,
surgeryName: true,
primaryDisease: true,
hydrocephalusTypes: true,
surgeonId: true,
surgeonName: true,
},
@ -159,25 +156,10 @@ export class BPatientsService {
return patients.map((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 {
...rest,
primaryDisease: latestSurgery?.primaryDisease ?? null,
hydrocephalusTypes: latestSurgery?.hydrocephalusTypes ?? [],
surgeryDate: latestSurgery?.surgeryDate ?? null,
currentPressure: currentDevice?.currentPressure ?? null,
initialPressure: currentDevice?.initialPressure ?? null,
shuntSurgeryCount: _count.surgeries,
latestSurgery,
latestSurgery: surgeries[0] ?? null,
activeDeviceCount: patient.devices.filter(
(device) =>
device.status === DeviceStatus.ACTIVE && !device.isAbandoned,

View File

@ -1,8 +1,12 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentFamilyActor } from '../../auth/current-family-actor.decorator.js';
import { FamilyAccessTokenGuard } from '../../auth/family-access/family-access.guard.js';
import type { FamilyActorContext } from '../../common/family-actor-context.js';
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
import { FamilyLifecycleQueryDto } from '../dto/family-lifecycle-query.dto.js';
import { CPatientsService } from './c-patients.service.js';
/**
@ -11,16 +15,21 @@ import { CPatientsService } from './c-patients.service.js';
@ApiTags('患者管理(C端)')
@ApiBearerAuth('bearer')
@Controller('c/patients')
@UseGuards(FamilyAccessTokenGuard)
@UseGuards(AccessTokenGuard)
export class CPatientsController {
constructor(private readonly patientsService: CPatientsService) {}
/**
*
*
*/
@Get('my-lifecycle')
@ApiOperation({ summary: '按当前登录手机号查询患者生命周期' })
getMyLifecycle(@CurrentFamilyActor() actor: FamilyActorContext) {
return this.patientsService.getFamilyLifecycleByAccount(actor.id);
@Get('lifecycle')
@ApiOperation({ summary: '跨院患者生命周期查询' })
@ApiQuery({ name: 'phone', description: '手机号' })
@ApiQuery({ name: 'idCard', description: '身份证号' })
getLifecycle(@Query() query: FamilyLifecycleQueryDto) {
return this.patientsService.getFamilyLifecycleByIdentity(
query.phone,
query.idCard,
);
}
}

View File

@ -1,9 +1,11 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../prisma.service.js';
import { MESSAGES } from '../../common/messages.js';
import { normalizePatientIdCard } from '../patient-id-card.util.js';
/**
* C
@ -13,23 +15,20 @@ export class CPatientsService {
constructor(private readonly prisma: PrismaService) {}
/**
* C
* C phone + idCard
*/
async getFamilyLifecycleByAccount(accountId: number) {
const account = await this.prisma.familyMiniAppAccount.findUnique({
where: { id: accountId },
select: {
id: true,
phone: true,
},
});
if (!account) {
throw new NotFoundException(MESSAGES.AUTH.FAMILY_ACCOUNT_NOT_FOUND);
async getFamilyLifecycleByIdentity(phone: string, idCard: string) {
if (!phone || !idCard) {
throw new BadRequestException(MESSAGES.PATIENT.PHONE_IDCARD_REQUIRED);
}
// 查询侧统一整理身份证格式,避免空格或末尾 x 大小写导致查不到。
const normalizedIdCard = normalizePatientIdCard(idCard);
const patients = await this.prisma.patient.findMany({
where: {
phone: account.phone,
phone,
idCard: normalizedIdCard,
},
include: {
hospital: { select: { id: true, name: true } },
@ -192,7 +191,8 @@ export class CPatientsService {
return {
// 前端详情弹窗和现有 E2E 都依赖这两个回显字段。
phone: account.phone,
phone,
idCard: normalizedIdCard,
patientCount: patients.length,
lifecycle,
};

View File

@ -0,0 +1,19 @@
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;
}

View File

@ -2,19 +2,12 @@ import { Module } from '@nestjs/common';
import { BPatientsController } from './b-patients/b-patients.controller.js';
import { CPatientsController } from './c-patients/c-patients.controller.js';
import { AccessTokenGuard } from '../auth/access-token.guard.js';
import { FamilyAccessTokenGuard } from '../auth/family-access/family-access.guard.js';
import { RolesGuard } from '../auth/roles.guard.js';
import { BPatientsService } from './b-patients/b-patients.service.js';
import { CPatientsService } from './c-patients/c-patients.service.js';
@Module({
providers: [
BPatientsService,
CPatientsService,
AccessTokenGuard,
FamilyAccessTokenGuard,
RolesGuard,
],
providers: [BPatientsService, CPatientsService, AccessTokenGuard, RolesGuard],
controllers: [BPatientsController, CPatientsController],
exports: [BPatientsService, CPatientsService],
})

View File

@ -32,7 +32,7 @@ export class CreateUserDto {
password?: string;
@ApiPropertyOptional({
description: '微信 openId(院内账号间可复用)',
description: '微信 openId',
example: 'wx-open-id-demo',
})
@IsOptional()

View File

@ -13,7 +13,7 @@ import {
} from 'class-validator';
/**
* DTO
* DTO
*/
export class LoginDto {
@ApiProperty({ description: '手机号', example: '13800000002' })
@ -26,17 +26,12 @@ export class LoginDto {
@MinLength(8, { message: 'password 长度至少 8 位' })
password!: string;
@ApiPropertyOptional({
description: '登录角色;不传时按手机号+密码匹配全部院内账号',
enum: Role,
example: Role.DOCTOR,
})
@IsOptional()
@ApiProperty({ description: '登录角色', enum: Role, example: Role.DOCTOR })
@IsEnum(Role, { message: 'role 枚举值不合法' })
role?: Role;
role!: Role;
@ApiPropertyOptional({
description: '医院 ID;传入后仅在该医院范围内匹配候选账号',
description: '医院 ID多账号场景建议传入',
example: 1,
})
@IsOptional()

View File

@ -35,7 +35,7 @@ export class RegisterUserDto {
role!: Role;
@ApiPropertyOptional({
description: '微信 openId可选,院内账号间可复用',
description: '微信 openId可选',
example: 'wx-open-id-demo',
})
@IsOptional()

View File

@ -1,66 +0,0 @@
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('用户不存在'));
});
});

View File

@ -18,7 +18,6 @@ import { AssignEngineerHospitalDto } from './dto/assign-engineer-hospital.dto.js
import { LoginDto } from './dto/login.dto.js';
import { MESSAGES } from '../common/messages.js';
import { CreateSystemAdminDto } from '../auth/dto/create-system-admin.dto.js';
import { PasswordLoginConfirmDto } from '../auth/dto/password-login-confirm.dto.js';
const SAFE_USER_SELECT = {
id: true,
@ -29,21 +28,11 @@ const SAFE_USER_SELECT = {
hospitalId: true,
departmentId: true,
groupId: true,
hospital: {
select:{
name:true
}
}
} as const;
const DIRECTOR_VISIBLE_ROLES = [Role.LEADER, Role.DOCTOR] as const;
const LEADER_VISIBLE_ROLES = [Role.DOCTOR] as const;
type PasswordLoginTicketPayload = {
purpose: 'PASSWORD_LOGIN_TICKET';
userIds: number[];
};
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
@ -68,6 +57,7 @@ export class UsersService {
Role.SYSTEM_ADMIN,
dto.systemAdminBootstrapKey,
);
await this.assertOpenIdUnique(openId);
await this.assertPhoneRoleScopeUnique(phone, Role.SYSTEM_ADMIN, null);
const passwordHash = await hash(password, 12);
@ -88,10 +78,10 @@ export class UsersService {
}
/**
*
* + JWT
*/
async login(dto: LoginDto) {
const role = dto.role != null ? this.normalizeRole(dto.role) : undefined;
const role = this.normalizeRole(dto.role);
const phone = this.normalizePhone(dto.phone);
const password = this.normalizePassword(dto.password);
const hospitalId = this.normalizeOptionalInt(dto.hospitalId, 'hospitalId');
@ -99,124 +89,51 @@ export class UsersService {
const users = await this.prisma.user.findMany({
where: {
phone,
...(role != null ? { role } : {}),
role,
...(hospitalId != null ? { hospitalId } : {}),
},
select: {
...SAFE_USER_SELECT,
passwordHash: true,
hospital: {
select: {
id: true,
name: true,
},
},
},
orderBy: [{ hospitalId: 'asc' }, { id: 'asc' }],
take: 10,
take: 5,
});
if (users.length === 0) {
throw new UnauthorizedException(MESSAGES.AUTH.INVALID_CREDENTIALS);
}
if (users.length > 1 && hospitalId == null) {
throw new BadRequestException(
MESSAGES.USER.MULTI_ACCOUNT_REQUIRE_HOSPITAL,
);
}
const matchedUsers = (
await Promise.all(
users.map(async (user) => {
if (!user.passwordHash) {
return null;
}
const user = users[0];
if (!user?.passwordHash) {
throw new UnauthorizedException(MESSAGES.AUTH.PASSWORD_NOT_ENABLED);
}
const matched = await compare(password, user.passwordHash);
return matched ? user : null;
}),
)
).filter((item) => item != null);
if (matchedUsers.length === 0) {
const matched = await compare(password, user.passwordHash);
if (!matched) {
throw new UnauthorizedException(MESSAGES.AUTH.INVALID_CREDENTIALS);
}
if (matchedUsers.length === 1) {
return this.buildUserLoginResponse(matchedUsers[0]);
}
const actor: ActorContext = {
id: user.id,
role: user.role,
hospitalId: user.hospitalId,
departmentId: user.departmentId,
groupId: user.groupId,
};
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,
})),
tokenType: 'Bearer',
accessToken: this.signAccessToken(actor),
actor,
user: this.toSafeUser(user),
};
}
/**
*
*/
async confirmLogin(dto: PasswordLoginConfirmDto) {
const payload = this.verifyPasswordLoginTicket(dto.loginTicket);
if (!payload.userIds.includes(dto.userId)) {
throw new BadRequestException(
MESSAGES.AUTH.PASSWORD_ACCOUNT_SELECTION_INVALID,
);
}
return this.loginByUserId(dto.userId);
}
/**
* ID
*/
async loginByUserId(userId: number) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: SAFE_USER_SELECT,
});
if (!user) {
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
}
return this.buildUserLoginResponse(user);
}
/**
* openId
*
*/
async bindOpenIdForMiniAppLogin(userId: number, openId: string) {
const normalizedOpenId = this.normalizeRequiredString(openId, 'openId');
const current = await this.prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
openId: true,
},
});
if (!current) {
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
}
if (current.openId && current.openId !== normalizedOpenId) {
throw new ConflictException(
MESSAGES.AUTH.MINIAPP_OPEN_ID_BOUND_OTHER_USER,
);
}
if (!current.openId) {
await this.prisma.user.update({
where: { id: current.id },
data: { openId: normalizedOpenId },
});
}
}
/**
*
*/
@ -259,6 +176,7 @@ export class UsersService {
scoped.departmentId,
scoped.groupId,
);
await this.assertOpenIdUnique(openId);
await this.assertPhoneRoleScopeUnique(phone, role, scoped.hospitalId);
return this.prisma.user.create({
@ -391,6 +309,7 @@ export class UsersService {
updateUserDto.openId !== undefined
? this.normalizeOptionalString(updateUserDto.openId)
: current.openId;
await this.assertOpenIdUnique(nextOpenId, userId);
const nextPhone =
updateUserDto.phone !== undefined
? this.normalizePhone(updateUserDto.phone)
@ -519,36 +438,6 @@ export class UsersService {
return safe;
}
/**
*
*/
private buildUserLoginResponse(user: {
id: number;
name: string;
phone: string;
openId: string | null;
role: Role;
hospitalId: number | null;
departmentId: number | null;
groupId: number | null;
passwordHash?: string | null;
}) {
const actor: ActorContext = {
id: user.id,
role: user.role,
hospitalId: user.hospitalId,
departmentId: user.departmentId,
groupId: user.groupId,
};
return {
tokenType: 'Bearer',
accessToken: this.signAccessToken(actor),
actor,
user: this.toSafeUser(user),
};
}
/**
*
*/
@ -593,6 +482,23 @@ 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);
}
}
/**
*
*/
@ -964,7 +870,10 @@ export class UsersService {
* 访
*/
private signAccessToken(actor: ActorContext): string {
const secret = this.requireAuthSecret();
const secret = process.env.AUTH_TOKEN_SECRET;
if (!secret) {
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_SECRET_MISSING);
}
return jwt.sign(actor, secret, {
algorithm: 'HS256',
@ -972,59 +881,4 @@ export class UsersService {
issuer: 'tyt-api-nest',
});
}
/**
*
*/
private signPasswordLoginTicket(payload: PasswordLoginTicketPayload) {
return jwt.sign(payload, this.requireAuthSecret(), {
algorithm: 'HS256',
expiresIn: '5m',
issuer: 'tyt-api-nest',
});
}
/**
*
*/
private verifyPasswordLoginTicket(token: string): PasswordLoginTicketPayload {
let payload: string | jwt.JwtPayload;
try {
payload = jwt.verify(token, this.requireAuthSecret(), {
algorithms: ['HS256'],
issuer: 'tyt-api-nest',
});
} catch {
throw new UnauthorizedException(
MESSAGES.AUTH.PASSWORD_LOGIN_TICKET_INVALID,
);
}
if (
typeof payload !== 'object' ||
payload.purpose !== 'PASSWORD_LOGIN_TICKET' ||
!Array.isArray(payload.userIds) ||
payload.userIds.some(
(item) => typeof item !== 'number' || !Number.isInteger(item),
)
) {
throw new UnauthorizedException(
MESSAGES.AUTH.PASSWORD_LOGIN_TICKET_INVALID,
);
}
return payload as PasswordLoginTicketPayload;
}
/**
*
*/
private requireAuthSecret() {
const secret = process.env.AUTH_TOKEN_SECRET;
if (!secret) {
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_SECRET_MISSING);
}
return secret;
}
}

View File

@ -17,7 +17,6 @@ export interface E2ESeedCredential {
role: E2ERole;
phone: string;
password: string;
openId: string;
hospitalId?: number;
}
@ -26,41 +25,35 @@ export const E2E_SEED_CREDENTIALS: Record<E2ERole, E2ESeedCredential> = {
role: Role.SYSTEM_ADMIN,
phone: '13800001000',
password: E2E_SEED_PASSWORD,
openId: 'seed-system-admin-openid',
},
[Role.HOSPITAL_ADMIN]: {
role: Role.HOSPITAL_ADMIN,
phone: '13800001001',
password: E2E_SEED_PASSWORD,
openId: 'seed-hospital-admin-a-openid',
hospitalId: 1,
},
[Role.DIRECTOR]: {
role: Role.DIRECTOR,
phone: '13800001002',
password: E2E_SEED_PASSWORD,
openId: 'seed-director-a-openid',
hospitalId: 1,
},
[Role.LEADER]: {
role: Role.LEADER,
phone: '13800001003',
password: E2E_SEED_PASSWORD,
openId: 'seed-leader-a-openid',
hospitalId: 1,
},
[Role.DOCTOR]: {
role: Role.DOCTOR,
phone: '13800001004',
password: E2E_SEED_PASSWORD,
openId: 'seed-doctor-a-openid',
hospitalId: 1,
},
[Role.ENGINEER]: {
role: Role.ENGINEER,
phone: '13800001005',
password: E2E_SEED_PASSWORD,
openId: 'seed-engineer-a-openid',
hospitalId: 1,
},
};

View File

@ -3,43 +3,14 @@ import { BadRequestException, ValidationPipe } from '@nestjs/common';
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { AppModule } from '../../../src/app.module.js';
import { WechatMiniAppService } from '../../../src/auth/wechat-miniapp/wechat-miniapp.service.js';
import { HttpExceptionFilter } from '../../../src/common/http-exception.filter.js';
import { MESSAGES } from '../../../src/common/messages.js';
import { ResponseEnvelopeInterceptor } from '../../../src/common/response-envelope.interceptor.js';
class FakeWechatMiniAppService {
async resolvePhoneIdentity(loginCode: string, phoneCode: string) {
return {
openId: this.exchangeLoginCode(loginCode),
phone: this.exchangePhoneCode(phoneCode),
};
}
exchangeLoginCode(loginCode: string) {
return this.parseCode(loginCode, 'mock-login:');
}
exchangePhoneCode(phoneCode: string) {
return this.parseCode(phoneCode, 'mock-phone:');
}
private parseCode(value: string, prefix: string) {
if (typeof value !== 'string' || !value.startsWith(prefix)) {
throw new Error(`invalid mock miniapp code: ${value}`);
}
return decodeURIComponent(value.slice(prefix.length));
}
}
export async function createE2eApp(): Promise<INestApplication> {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(WechatMiniAppService)
.useValue(new FakeWechatMiniAppService())
.compile();
}).compile();
const app = moduleRef.createNestApplication();

View File

@ -24,16 +24,23 @@ export async function loginAsRole(
fixtures: E2ESeedFixtures,
): Promise<string> {
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())
.post('/auth/login')
.send({
phone: credential.phone,
password: credential.password,
role: credential.role,
hospitalId: resolveRoleHospitalId(role, fixtures),
});
.send(payload);
expectSuccessEnvelope(response, 201);
expect(response.body.data?.accessToken).toEqual(expect.any(String));
return response.body.data.accessToken as string;
}

View File

@ -1,7 +1,7 @@
import type { INestApplication } from '@nestjs/common';
import { NotFoundException } from '@nestjs/common';
import request from 'supertest';
import { Role, UploadAssetType } from '../../../src/generated/prisma/enums.js';
import { Role } from '../../../src/generated/prisma/enums.js';
import { PrismaService } from '../../../src/prisma.service.js';
import {
E2E_SEED_CREDENTIALS,
@ -109,7 +109,7 @@ export async function ensureE2EFixtures(
});
if (!existingSystemAdmin) {
await bootstrapFixturesViaApi(app, prisma);
await bootstrapFixturesViaApi(app);
}
try {
@ -120,10 +120,7 @@ export async function ensureE2EFixtures(
}
}
async function bootstrapFixturesViaApi(
app: INestApplication,
prisma: PrismaService,
) {
async function bootstrapFixturesViaApi(app: INestApplication) {
const server = app.getHttpServer();
await createSystemAdmin(server);
@ -579,18 +576,9 @@ async function bootstrapFixturesViaApi(
taskId: publishedA.id,
});
const engineerProofAsset = await ensureTaskCompletionAsset(
prisma,
hospitalA.id,
engineerA.id,
'seed-bootstrap-engineer-proof-a',
);
await createWithToken(
server,
engineerAToken,
'/b/tasks/complete',
buildTaskCompletionPayload(publishedA.id, engineerProofAsset),
);
await createWithToken(server, engineerAToken, '/b/tasks/complete', {
taskId: publishedA.id,
});
await createWithToken(server, doctorBToken, '/b/tasks/publish', {
items: [
@ -624,18 +612,15 @@ async function repairFixturesViaApi(
const doctorA2 = await requireUserScope(prisma, OPEN_IDS.doctorA2);
const doctorA3 = await requireUserScope(prisma, OPEN_IDS.doctorA3);
const doctorB = await requireUserScope(prisma, OPEN_IDS.doctorB);
const engineerA = await requireUserScope(prisma, OPEN_IDS.engineerA);
if (
hospitalAdminA.hospitalId == null ||
doctorA.hospitalId == null ||
doctorB.hospitalId == null ||
engineerA.hospitalId == null ||
doctorA.id == null ||
doctorA2.id == null ||
doctorA3.id == null ||
doctorB.id == null ||
engineerA.id == null
doctorB.id == null
) {
throw new NotFoundException('Seed user scope is incomplete');
}
@ -908,18 +893,9 @@ async function repairFixturesViaApi(
await createWithToken(server, engineerAToken, '/b/tasks/accept', {
taskId: publishedA.id,
});
const engineerProofAsset = await ensureTaskCompletionAsset(
prisma,
engineerA.hospitalId,
engineerA.id,
'seed-repair-engineer-proof-a',
);
await createWithToken(
server,
engineerAToken,
'/b/tasks/complete',
buildTaskCompletionPayload(publishedA.id, engineerProofAsset),
);
await createWithToken(server, engineerAToken, '/b/tasks/complete', {
taskId: publishedA.id,
});
}
if (!(await hasTaskItemForDevice(prisma, deviceB1Id))) {
@ -1035,67 +1011,11 @@ async function patchWithToken(
return response.body.data;
}
async function ensureTaskCompletionAsset(
prisma: PrismaService,
hospitalId: number,
creatorId: number,
key: string,
) {
const fileName = `${key}.webp`;
const storagePath = `e2e/${fileName}`;
return prisma.uploadAsset.upsert({
where: { storagePath },
update: {},
create: {
hospitalId,
creatorId,
type: UploadAssetType.IMAGE,
originalName: fileName,
fileName,
storagePath,
url: `/uploads/e2e/${fileName}`,
mimeType: 'image/webp',
fileSize: 1024,
},
select: {
id: true,
type: true,
url: true,
originalName: true,
fileName: true,
},
});
}
function buildTaskCompletionPayload(
taskId: number,
asset: {
id: number;
type: UploadAssetType;
url: string;
originalName: string;
fileName: string;
},
) {
return {
taskId,
completionMaterials: [
{
assetId: asset.id,
type: asset.type,
url: asset.url,
name: asset.originalName || asset.fileName,
},
],
};
}
async function requireUserScope(
prisma: PrismaService,
openId: string,
): Promise<SeedUserScope> {
const user = await prisma.user.findFirst({
const user = await prisma.user.findUnique({
where: { openId },
select: {
id: true,

View File

@ -1,77 +0,0 @@
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;
}

View File

@ -1,9 +1,5 @@
import request from 'supertest';
import { Role } from '../../../src/generated/prisma/enums.js';
import {
E2E_SEED_CREDENTIALS,
E2E_SEED_PASSWORD,
} from '../fixtures/e2e-roles.js';
import {
closeE2EContext,
createE2EContext,
@ -16,7 +12,6 @@ import {
uniquePhone,
uniqueSeedValue,
} from '../helpers/e2e-http.helper.js';
import { buildMiniAppMockPayload } from '../helpers/e2e-miniapp-auth.helper.js';
describe('AuthController (e2e)', () => {
let ctx: E2EContext;
@ -60,12 +55,12 @@ describe('AuthController (e2e)', () => {
});
describe('POST /auth/login', () => {
it('成功:院内账号可使用手机号密码登录', async () => {
it('成功:seed 账号登录并拿到 token', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/auth/login')
.send({
phone: E2E_SEED_CREDENTIALS[Role.DOCTOR].phone,
password: E2E_SEED_PASSWORD,
phone: '13800001004',
password: 'Seed@1234',
role: Role.DOCTOR,
hospitalId: ctx.fixtures.hospitalAId,
});
@ -75,236 +70,17 @@ describe('AuthController (e2e)', () => {
expect(response.body.data.actor.role).toBe(Role.DOCTOR);
});
it('成功:同手机号命中多个账号时先返回候选,再确认登录', async () => {
const sharedPhone = uniquePhone();
const firstUser = await request(ctx.app.getHttpServer())
.post('/users')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({
name: uniqueSeedValue('Password 多账号医生'),
phone: sharedPhone,
password: 'Seed@1234',
role: Role.DOCTOR,
hospitalId: ctx.fixtures.hospitalAId,
departmentId: ctx.fixtures.departmentA1Id,
groupId: ctx.fixtures.groupA1Id,
});
expectSuccessEnvelope(firstUser, 201);
const secondUser = await request(ctx.app.getHttpServer())
.post('/users')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({
name: uniqueSeedValue('Password 多账号工程师'),
phone: sharedPhone,
password: 'Seed@1234',
role: Role.ENGINEER,
hospitalId: ctx.fixtures.hospitalAId,
});
expectSuccessEnvelope(secondUser, 201);
const firstStage = await request(ctx.app.getHttpServer())
.post('/auth/login')
.send({
phone: sharedPhone,
password: 'Seed@1234',
});
expectSuccessEnvelope(firstStage, 201);
expect(firstStage.body.data.needSelect).toBe(true);
expect(firstStage.body.data.accounts).toHaveLength(2);
const confirmResponse = await request(ctx.app.getHttpServer())
.post('/auth/login/confirm')
.send({
loginTicket: firstStage.body.data.loginTicket,
userId: firstUser.body.data.id,
});
expectSuccessEnvelope(confirmResponse, 201);
expect(confirmResponse.body.data.user.id).toBe(firstUser.body.data.id);
});
it('失败:密码错误返回 401', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/auth/login')
.send({
phone: E2E_SEED_CREDENTIALS[Role.DOCTOR].phone,
password: 'Wrong@1234',
phone: '13800001004',
password: 'Seed@12345',
role: Role.DOCTOR,
hospitalId: ctx.fixtures.hospitalAId,
});
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, '当前手机号未关联患者档案');
expectErrorEnvelope(response, 401, '手机号、角色或密码错误');
});
});

View File

@ -13,7 +13,6 @@ import {
uniquePhone,
uniqueSeedValue,
} from '../helpers/e2e-http.helper.js';
import { buildMiniAppMockPayload } from '../helpers/e2e-miniapp-auth.helper.js';
function uniqueIdCard() {
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`
@ -63,15 +62,6 @@ describe('Patients Controllers (e2e)', () => {
};
}
async function loginFamilyByPhone(phone: string, openId?: string) {
const response = await request(ctx.app.getHttpServer())
.post('/auth/miniapp/c/phone-login')
.send(buildMiniAppMockPayload(phone, openId ?? uniqueSeedValue('family-openid')));
expectSuccessEnvelope(response, 201);
return response.body.data.accessToken as string;
}
describe('GET /b/patients', () => {
it('成功:按角色返回正确可见性范围', async () => {
const systemAdminResponse = await request(ctx.app.getHttpServer())
@ -90,23 +80,6 @@ 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())
.get('/b/patients')
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
@ -212,37 +185,44 @@ describe('Patients Controllers (e2e)', () => {
});
});
describe('GET /c/patients/my-lifecycle', () => {
it('成功:家属小程序登录后可按绑定手机号查询跨院生命周期', async () => {
const familyToken = await loginFamilyByPhone(
'13800002001',
'seed-family-a1-openid',
);
describe('GET /c/patients/lifecycle', () => {
it('成功:已登录用户可按 phone + idCard 查询跨院生命周期', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/c/patients/my-lifecycle')
.set('Authorization', `Bearer ${familyToken}`);
.get('/c/patients/lifecycle')
.query({
phone: '13800002001',
idCard: '110101199001010011',
})
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data.phone).toBe('13800002001');
expect(response.body.data.idCard).toBe('110101199001010011');
expect(response.body.data.patientCount).toBeGreaterThanOrEqual(2);
expect(Array.isArray(response.body.data.lifecycle)).toBe(true);
});
it('失败:未登录返回 401', async () => {
it('失败:参数缺失返回 400', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/c/patients/my-lifecycle');
.get('/c/patients/lifecycle')
.query({
phone: '13800002001',
})
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
expectErrorEnvelope(response, 400, 'idCard 必须是字符串');
});
it('成功:已存在家属账号再次登录后仍可查询', async () => {
const familyToken = await loginFamilyByPhone('13800002002', 'seed-family-a2-openid');
it('失败:不存在患者返回 404', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/c/patients/my-lifecycle')
.set('Authorization', `Bearer ${familyToken}`);
.get('/c/patients/lifecycle')
.query({
phone: '13800009999',
idCard: '110101199009090099',
})
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data.phone).toBe('13800002002');
expectErrorEnvelope(response, 404, '未找到匹配的患者档案');
});
});

View File

@ -13,7 +13,6 @@ import {
uniquePhone,
uniqueSeedValue,
} from '../helpers/e2e-http.helper.js';
import { loginByMiniApp } from '../helpers/e2e-miniapp-auth.helper.js';
function uniqueIdCard() {
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`
@ -96,18 +95,21 @@ describe('BTasksController (e2e)', () => {
async function loginByUser(userId: number, role: Role, hospitalId: number) {
const user = await ctx.prisma.user.findUnique({
where: { id: userId },
select: { phone: true, openId: true },
select: { phone: true },
});
expect(user?.phone).toBeTruthy();
expect(user?.openId).toBeTruthy();
return loginByMiniApp(ctx.app.getHttpServer(), {
phone: user!.phone,
openId: user!.openId!,
role,
hospitalId,
userId,
});
const response = await request(ctx.app.getHttpServer())
.post('/auth/login')
.send({
phone: user?.phone,
password: 'Seed@1234',
role,
hospitalId,
});
expectSuccessEnvelope(response, 201);
return response.body.data.accessToken as string;
}
async function createAdjustableDevices(options?: {
@ -464,10 +466,12 @@ describe('BTasksController (e2e)', () => {
expect(engineerB?.phone).toBeTruthy();
const loginResponse = await request(ctx.app.getHttpServer())
.post('/auth/miniapp/b/phone-login')
.post('/auth/login')
.send({
loginCode: `mock-login:${encodeURIComponent('seed-engineer-b-openid')}`,
phoneCode: `mock-phone:${engineerB?.phone}`,
phone: engineerB?.phone,
password: 'Seed@1234',
role: Role.ENGINEER,
hospitalId: ctx.fixtures.hospitalBId,
});
expectSuccessEnvelope(loginResponse, 201);

View File

@ -42,8 +42,6 @@ declare module 'vue' {
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']

View File

@ -19,50 +19,24 @@ export const useUserStore = defineStore('user', {
this.userInfo = info;
localStorage.setItem('tyt_user_info', JSON.stringify(info));
},
applyLoginResponse(data) {
if (!data?.accessToken) {
return false;
}
this.setToken(data.accessToken);
if (data.user) {
this.setUserInfo(data.user);
}
return true;
},
async login(loginForm) {
const payload = { ...loginForm };
if (!payload.role) {
delete payload.role;
}
if (!payload.hospitalId) {
delete payload.hospitalId;
}
const data = await request.post('/auth/login', payload);
if (data?.needSelect) {
return {
needSelect: true,
loginTicket: data.loginTicket,
accounts: data.accounts || [],
};
// The backend should return the token in data.accessToken
if (data && data.accessToken) {
this.setToken(data.accessToken);
// Backend also returns actor and user info directly in login response
if (data.user) {
this.setUserInfo(data.user);
} else {
await this.fetchUserInfo();
}
return true;
}
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,
};
return false;
},
async fetchUserInfo() {
try {

View File

@ -26,10 +26,33 @@
:prefix-icon="Lock"
/>
</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
type="info"
:closable="false"
title="登录页只需手机号和密码。若匹配到多个院内账号,系统会在下一步让你选择。"
title="若同一手机号在多个医院有同角色账号,请填写医院 ID。"
style="margin-bottom: 16px"
/>
<el-form-item>
@ -43,40 +66,6 @@
</el-form-item>
</el-form>
</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>
</template>
@ -93,15 +82,12 @@ const userStore = useUserStore();
const loginFormRef = ref(null);
const loading = ref(false);
const confirmLoading = ref(false);
const accountSelectVisible = ref(false);
const selectedAccountId = ref(null);
const pendingLoginTicket = ref('');
const candidateAccounts = ref([]);
const loginForm = reactive({
phone: '',
password: '',
role: 'SYSTEM_ADMIN', // Default role
hospitalId: null,
});
const rules = {
@ -113,27 +99,7 @@ const rules = {
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, message: '密码长度至少为 8 位', trigger: 'blur' },
],
};
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 = [];
role: [{ required: true, message: '请选择角色', trigger: 'change' }],
};
const handleLogin = async () => {
@ -142,17 +108,11 @@ const handleLogin = async () => {
if (valid) {
loading.value = true;
try {
const result = await userStore.login(loginForm);
if (result?.needSelect) {
pendingLoginTicket.value = result.loginTicket;
candidateAccounts.value = result.accounts || [];
selectedAccountId.value = result.accounts?.[0]?.id ?? null;
accountSelectVisible.value = true;
return;
}
if (result?.success) {
redirectToTarget();
const success = await userStore.login(loginForm);
if (success) {
ElMessage.success('登录成功');
const redirect = route.query.redirect || '/';
router.push(redirect);
} else {
ElMessage.error('登录失败,未获取到登录信息');
}
@ -164,32 +124,6 @@ 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>
<style scoped>
@ -211,30 +145,4 @@ const handleConfirmLogin = async () => {
.login-btn {
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>