From 6ec2d0b0e05ce0846220989c5bbc1ac7e794366e Mon Sep 17 00:00:00 2001 From: EL <1175065040@qq.com> Date: Wed, 18 Mar 2026 20:23:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20B=20=E7=AB=AF=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E6=A8=A1=E5=9D=97=EF=BC=88=E5=90=8E=E7=AB=AF=20CRUD?= =?UTF-8?q?=E3=80=81=E5=88=86=E9=A1=B5=E7=AD=9B=E9=80=89=E3=80=81=E6=9D=83?= =?UTF-8?q?=E9=99=90=E9=9A=94=E7=A6=BB=EF=BC=89=E5=B9=B6=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E8=AE=BE=E5=A4=87=E7=AE=A1=E7=90=86=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E4=B8=8E=E8=B7=AF=E7=94=B1=E8=8F=9C=E5=8D=95=20?= =?UTF-8?q?=E9=89=B4=E6=9D=83=E6=94=B9=E4=B8=BA=E7=99=BB=E5=BD=95=E6=80=81?= =?UTF-8?q?=E5=9B=9E=E5=BA=93=E6=A0=A1=E9=AA=8C=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=20tokenValidAfter=20=E5=A4=B1=E6=95=88=E6=97=B6=E9=97=B4?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E5=AF=86=E7=A0=81=E5=8F=98=E6=9B=B4?= =?UTF-8?q?=E4=B8=8E=20seed=20=E9=87=8D=E7=BD=AE=E5=90=8E=E6=97=A7=20token?= =?UTF-8?q?=20=E7=AB=8B=E5=8D=B3=E5=A4=B1=E6=95=88=20=E6=82=A3=E8=80=85?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E7=94=B1=20idCardHash=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E4=B8=BA=20idCard=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E8=AF=81=E6=A0=87=E5=87=86=E5=8C=96=E9=80=BB?= =?UTF-8?q?=E8=BE=91=E5=B9=B6=E5=90=8C=E6=AD=A5=20C=20=E7=AB=AF=E7=94=9F?= =?UTF-8?q?=E5=91=BD=E5=91=A8=E6=9C=9F=E6=9F=A5=E8=AF=A2=E5=8F=82=E6=95=B0?= =?UTF-8?q?=20=E7=BB=84=E7=BB=87=E6=A8=A1=E5=9D=97=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=B0=8F=E7=BB=84=E5=88=A0=E9=99=A4=E9=99=90=E5=88=B6=EF=BC=88?= =?UTF-8?q?=E6=9C=89=E6=88=90=E5=91=98=E6=97=B6=E8=BF=94=E5=9B=9E=20409?= =?UTF-8?q?=EF=BC=89=E5=B9=B6=E8=A1=A5=E5=85=85=E4=B8=AD=E6=96=87=E9=94=99?= =?UTF-8?q?=E8=AF=AF=E6=B6=88=E6=81=AF=20=E4=BB=BB=E5=8A=A1=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E6=8E=A5=E5=8F=A3=E6=94=AF=E6=8C=81=E5=8F=AF=E9=80=89?= =?UTF-8?q?=20reason=20=E5=AD=97=E6=AE=B5=EF=BC=88=E5=85=88=E9=80=8F?= =?UTF-8?q?=E4=BC=A0=E4=BA=8B=E4=BB=B6=E5=B1=82=EF=BC=89=20=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=20Prisma=20=E8=BF=81=E7=A7=BB=E3=80=81=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E8=AF=B4=E6=98=8E=E5=92=8C=20E2E=20=E7=94=A8=E4=BE=8B?= =?UTF-8?q?=EF=BC=88=E5=90=AB=E8=AE=BE=E5=A4=87=E6=A8=A1=E5=9D=97=E4=B8=8E?= =?UTF-8?q?=20token=20=E5=A4=B1=E6=95=88=E5=9C=BA=E6=99=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/auth.md | 16 +- docs/devices.md | 27 + docs/e2e-testing.md | 1 + docs/frontend-api-integration.md | 14 +- docs/patients.md | 8 +- docs/tasks.md | 5 + .../migrations/20260318100229/migration.sql | 8 + .../migration.sql | 2 + .../migration.sql | 5 + .../migration.sql | 8 + prisma/schema.prisma | 38 +- prisma/seed.mjs | 26 +- src/app.module.ts | 2 + src/auth/access-token.guard.ts | 77 +-- src/common/messages.ts | 20 +- src/devices/b-devices/b-devices.controller.ts | 102 ++++ src/devices/devices.module.ts | 12 + src/devices/devices.service.ts | 403 +++++++++++++ src/devices/dto/create-device.dto.ts | 34 ++ src/devices/dto/device-query.dto.ts | 68 +++ src/devices/dto/update-device.dto.ts | 7 + src/groups/groups.service.ts | 36 +- src/patients/b-patients/b-patients.service.ts | 26 +- .../c-patients/c-patients.controller.ts | 6 +- src/patients/c-patients/c-patients.service.ts | 15 +- src/patients/dto/create-patient.dto.ts | 15 +- .../dto/family-lifecycle-query.dto.ts | 9 +- src/patients/patient-id-card.util.ts | 8 + src/tasks/dto/cancel-task.dto.ts | 12 +- src/tasks/task.service.ts | 2 + src/users/users.service.ts | 2 + test/e2e/helpers/e2e-fixtures.helper.ts | 16 +- .../specs/auth-token-revocation.e2e-spec.ts | 59 ++ test/e2e/specs/auth.e2e-spec.ts | 28 +- test/e2e/specs/devices.e2e-spec.ts | 161 ++++++ test/e2e/specs/organization.e2e-spec.ts | 64 ++- test/e2e/specs/patients.e2e-spec.ts | 19 +- test/e2e/specs/tasks.e2e-spec.ts | 12 +- test/e2e/specs/users.e2e-spec.ts | 12 +- tyt-admin/src/api/devices.js | 24 + tyt-admin/src/api/request.js | 29 +- tyt-admin/src/constants/role-permissions.js | 3 + tyt-admin/src/layouts/AdminLayout.vue | 38 +- tyt-admin/src/router/index.js | 12 +- tyt-admin/src/views/devices/Devices.vue | 531 ++++++++++++++++++ tyt-admin/src/views/patients/Patients.vue | 36 +- 46 files changed, 1838 insertions(+), 220 deletions(-) create mode 100644 docs/devices.md create mode 100644 prisma/migrations/20260318100229/migration.sql create mode 100644 prisma/migrations/20260318103000_add_user_token_valid_after/migration.sql create mode 100644 prisma/migrations/20260318162000_rename_patient_id_card/migration.sql create mode 100644 prisma/migrations/20260318171000_restrict_group_delete_with_users/migration.sql create mode 100644 src/devices/b-devices/b-devices.controller.ts create mode 100644 src/devices/devices.module.ts create mode 100644 src/devices/devices.service.ts create mode 100644 src/devices/dto/create-device.dto.ts create mode 100644 src/devices/dto/device-query.dto.ts create mode 100644 src/devices/dto/update-device.dto.ts create mode 100644 src/patients/patient-id-card.util.ts create mode 100644 test/e2e/specs/auth-token-revocation.e2e-spec.ts create mode 100644 test/e2e/specs/devices.e2e-spec.ts create mode 100644 tyt-admin/src/api/devices.js create mode 100644 tyt-admin/src/views/devices/Devices.vue diff --git a/docs/auth.md b/docs/auth.md index 6ed0b01..819cf3f 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -14,14 +14,17 @@ ## 3. 鉴权流程 1. `AccessTokenGuard` 从 `Authorization` 读取 Bearer Token。 -2. 校验 JWT 签名与载荷字段。 -3. 载荷映射为 `ActorContext` 注入 `request.actor`。 -4. `RolesGuard` 根据 `@Roles(...)` 判断角色是否允许访问。 +2. 校验 JWT 签名、`id`、`iat` 等关键载荷字段。 +3. 根据 `id` 回库读取用户当前角色与组织归属,不再直接信任 token 里的角色和范围。 +4. 校验 `iat >= user.tokenValidAfter`,若用户被重置密码、seed 重刷或账号被清理,则旧 token 立即失效。 +5. 当前数据库用户映射为 `ActorContext` 注入 `request.actor`。 +6. `RolesGuard` 根据 `@Roles(...)` 判断角色是否允许访问。 ## 4. Token 约定 - Header:`Authorization: Bearer ` -- 载荷关键字段:`id`、`role`、`hospitalId`、`departmentId`、`groupId` +- 载荷关键字段:`id`、`iat` +- 角色和组织范围以数据库当前用户记录为准,不以 token 历史载荷为准 ## 5. 错误码与中文消息 @@ -30,3 +33,8 @@ - 参数非法:`400` + 中文 `msg` 统一由全局异常过滤器输出:`{ code, msg, data: null }`。 + +## 6. 失效策略 + +- 用户密码被修改后,会刷新 `user.tokenValidAfter`,旧 token 全部失效。 +- 执行 E2E 重置并重新 seed 后,seed 账号的 `tokenValidAfter` 也会刷新,历史 token 不可继续复用。 diff --git a/docs/devices.md b/docs/devices.md new file mode 100644 index 0000000..0715fe4 --- /dev/null +++ b/docs/devices.md @@ -0,0 +1,27 @@ +# 设备模块说明(`src/devices`) + +## 1. 目标 + +- 提供 B 端设备 CRUD。 +- 管理设备与患者的归属关系。 +- 支持管理员按医院、患者、状态和关键词分页查询设备。 + +## 2. 权限 + +- `SYSTEM_ADMIN`:可跨院查询和维护设备。 +- `HOSPITAL_ADMIN`:仅可操作本院患者名下设备。 +- 其他角色:默认拒绝。 + +## 3. 接口 + +- `GET /b/devices`:分页查询设备列表 +- `GET /b/devices/:id`:查询设备详情 +- `POST /b/devices`:创建设备 +- `PATCH /b/devices/:id`:更新设备 +- `DELETE /b/devices/:id`:删除设备 + +## 4. 约束 + +- 设备必须绑定到一个患者。 +- 设备 SN 在全库唯一,服务端会统一转成大写后再校验。 +- 删除已被任务明细引用的设备会返回 `409`。 diff --git a/docs/e2e-testing.md b/docs/e2e-testing.md index c374473..bb17bd2 100644 --- a/docs/e2e-testing.md +++ b/docs/e2e-testing.md @@ -14,6 +14,7 @@ 2. `node prisma/seed.mjs` 这会清空 `.env` 中 `DATABASE_URL` 指向数据库的全部数据,请仅在测试库执行。 +另外,seed 账号会刷新 `tokenValidAfter`,所以重置前签发的旧 token 会全部失效,需要重新登录获取新 token。 ## 3. 运行命令 diff --git a/docs/frontend-api-integration.md b/docs/frontend-api-integration.md index 0626cc5..f114e1d 100644 --- a/docs/frontend-api-integration.md +++ b/docs/frontend-api-integration.md @@ -4,16 +4,21 @@ - 登录页:`/auth/login`,支持可选 `hospitalId`。 - 首页看板:按角色拉取组织与患者统计。 +- 设备页:新增管理员专用设备 CRUD,复用真实设备接口。 - 任务页:接入 `publish/accept/complete/cancel` 四个真实任务接口。 - 用户页:修复用户列表响应结构、组织字段联动、工程师分配医院参数。 -- 患者页:接入真实患者字段与生命周期查询参数(`phone + idCardHash`)。 +- 患者页:接入真实患者字段与生命周期查询参数(`phone + idCard`), + 后端直接保存身份证号原文,不再做哈希转换。 ## 2. 接口契约对齐点 - `GET /users` 当前返回数组,前端已在 `api/users.js` 做本地分页与筛选适配。 - `PATCH /b/users/:id/assign-engineer-hospital` 参数为单个 `hospitalId`,非数组。 - `GET /b/patients` 返回数组,前端已改为本地分页与筛选。 -- `GET /c/patients/lifecycle` 必须同时传 `phone` 和 `idCardHash`。 +- `GET /b/devices` 已支持服务端分页与筛选,前端直接透传 `page/pageSize`。 +- `GET /c/patients/lifecycle` 必须同时传 `phone` 和 `idCard`。 +- 患者表单中的 `idCard` 字段直接传身份证号; + 服务端只会做去空格与 `x/X` 标准化,不会转哈希。 - 任务模块暂无任务列表接口,前端改为“表单操作 + 最近结果”模式。 ## 3. 角色权限提示 @@ -37,13 +42,14 @@ - `organization/tree`、`organization/departments`、`organization/groups`、`users` - `organization/tree`、`organization/departments`、`organization/groups`: `SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER` 可访问 - - `users`:仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可访问 +- `users`:仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可访问 +- `devices`:仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN` 可访问 - `organization/hospitals` - 仅 `SYSTEM_ADMIN` 可访问 - `tasks` - 仅 `DOCTOR`、`DIRECTOR`、`LEADER`、`ENGINEER` 可访问 - `patients` - - 仅 `SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER` 可访问 + - `SYSTEM_ADMIN`、`HOSPITAL_ADMIN`、`DIRECTOR`、`LEADER`、`DOCTOR` 可访问 前端已在路由守卫和侧边栏菜单同时做权限控制,无权限角色会被拦截并跳转到首页,避免进入页面后触发接口 `403`。 diff --git a/docs/patients.md b/docs/patients.md index bfdc3a9..0c6001c 100644 --- a/docs/patients.md +++ b/docs/patients.md @@ -3,7 +3,9 @@ ## 1. 目标 - B 端:按组织与角色范围查询患者(强依赖 `hospitalId`)。 -- C 端:按 `phone + idCardHash` 做跨院聚合查询。 +- C 端:按 `phone + idCard` 做跨院聚合查询。 +- 患者档案直接保存身份证号原文,不再做哈希转换。 +- 服务端只做轻量格式整理:去空格、统一末尾 `x/X` 为大写。 ## 2. B 端可见性 @@ -28,12 +30,12 @@ ## 3. C 端生命周期聚合 -接口:`GET /c/patients/lifecycle?phone=...&idCardHash=...` +接口:`GET /c/patients/lifecycle?phone=...&idCard=...` 查询策略: 1. 不做医院隔离(跨租户) -2. 双字段精确匹配 `phone + idCardHash` +2. 先将 `idCard` 做轻量标准化,再做双字段精确匹配 3. 关联查询 `Patient -> Device -> TaskItem -> Task` 4. 返回扁平生命周期列表(按 `Task.createdAt DESC`) diff --git a/docs/tasks.md b/docs/tasks.md index 3079186..4f51bb9 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -18,6 +18,11 @@ - 工程师:接收任务、完成自己接收的任务 - 其他角色:默认拒绝 +补充: + +- `POST /b/tasks/cancel` 现支持可选 `reason` 字段,便于前端保留取消原因输入。 +- 当前取消原因仅透传到事件层,数据库暂未持久化该字段。 + ## 4. 事件触发 状态变化后会发出事件: diff --git a/prisma/migrations/20260318100229/migration.sql b/prisma/migrations/20260318100229/migration.sql new file mode 100644 index 0000000..7db45d3 --- /dev/null +++ b/prisma/migrations/20260318100229/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[phone,role,hospitalId]` on the table `User` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "User_phone_role_hospitalId_key" ON "User"("phone", "role", "hospitalId"); diff --git a/prisma/migrations/20260318103000_add_user_token_valid_after/migration.sql b/prisma/migrations/20260318103000_add_user_token_valid_after/migration.sql new file mode 100644 index 0000000..aef5f52 --- /dev/null +++ b/prisma/migrations/20260318103000_add_user_token_valid_after/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "User" +ADD COLUMN "tokenValidAfter" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/prisma/migrations/20260318162000_rename_patient_id_card/migration.sql b/prisma/migrations/20260318162000_rename_patient_id_card/migration.sql new file mode 100644 index 0000000..75cb796 --- /dev/null +++ b/prisma/migrations/20260318162000_rename_patient_id_card/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE "Patient" +RENAME COLUMN "idCardHash" TO "idCard"; + +ALTER INDEX "Patient_phone_idCardHash_idx" +RENAME TO "Patient_phone_idCard_idx"; diff --git a/prisma/migrations/20260318171000_restrict_group_delete_with_users/migration.sql b/prisma/migrations/20260318171000_restrict_group_delete_with_users/migration.sql new file mode 100644 index 0000000..4ab78a7 --- /dev/null +++ b/prisma/migrations/20260318171000_restrict_group_delete_with_users/migration.sql @@ -0,0 +1,8 @@ +ALTER TABLE "User" +DROP CONSTRAINT "User_groupId_fkey"; + +ALTER TABLE "User" +ADD CONSTRAINT "User_groupId_fkey" +FOREIGN KEY ("groupId") REFERENCES "Group"("id") +ON DELETE RESTRICT +ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ab6c429..4f0a996 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -71,22 +71,25 @@ model Group { // 用户表:支持后台密码登录与小程序 openId。 model User { - id Int @id @default(autoincrement()) - name String - phone String + id Int @id @default(autoincrement()) + name String + phone String // 后台登录密码哈希(bcrypt)。 - passwordHash String? - openId String? @unique - role Role - hospitalId Int? - departmentId Int? - groupId Int? - hospital Hospital? @relation(fields: [hospitalId], references: [id]) - department Department? @relation(fields: [departmentId], references: [id]) - group Group? @relation(fields: [groupId], references: [id]) - doctorPatients Patient[] @relation("DoctorPatients") - createdTasks Task[] @relation("TaskCreator") - acceptedTasks Task[] @relation("TaskEngineer") + passwordHash String? + // 该时间点之前签发的 token 一律失效。 + tokenValidAfter DateTime @default(now()) + openId String? @unique + role Role + hospitalId Int? + departmentId Int? + groupId Int? + hospital Hospital? @relation(fields: [hospitalId], references: [id]) + department Department? @relation(fields: [departmentId], references: [id]) + // 小组删除必须先清理成员,避免静默把用户 groupId 置空。 + group Group? @relation(fields: [groupId], references: [id], onDelete: Restrict) + doctorPatients Patient[] @relation("DoctorPatients") + createdTasks Task[] @relation("TaskCreator") + acceptedTasks Task[] @relation("TaskEngineer") @@unique([phone, role, hospitalId]) @@index([phone]) @@ -100,14 +103,15 @@ model Patient { id Int @id @default(autoincrement()) name String phone String - idCardHash String + // 患者身份证号,录入与查询都使用原始证件号。 + idCard String hospitalId Int doctorId Int hospital Hospital @relation(fields: [hospitalId], references: [id]) doctor User @relation("DoctorPatients", fields: [doctorId], references: [id]) devices Device[] - @@index([phone, idCardHash]) + @@index([phone, idCard]) @@index([hospitalId, doctorId]) } diff --git a/prisma/seed.mjs b/prisma/seed.mjs index d7c9047..bde8b50 100644 --- a/prisma/seed.mjs +++ b/prisma/seed.mjs @@ -48,7 +48,11 @@ async function ensureGroup(departmentId, name) { async function upsertUserByOpenId(openId, data) { return prisma.user.upsert({ where: { openId }, - update: data, + // 每次重置/补种子时推进失效时间,确保历史 token 无法继续访问。 + update: { + ...data, + tokenValidAfter: new Date(), + }, create: { ...data, openId, @@ -56,18 +60,12 @@ async function upsertUserByOpenId(openId, data) { }); } -async function ensurePatient({ - hospitalId, - doctorId, - name, - phone, - idCardHash, -}) { +async function ensurePatient({ hospitalId, doctorId, name, phone, idCard }) { const existing = await prisma.patient.findFirst({ where: { hospitalId, phone, - idCardHash, + idCard, }, }); @@ -87,7 +85,7 @@ async function ensurePatient({ doctorId, name, phone, - idCardHash, + idCard, }, }); } @@ -224,7 +222,7 @@ async function main() { doctorId: doctorA.id, name: 'Seed Patient A1', phone: '13800002001', - idCardHash: 'seed-id-card-cross-hospital', + idCard: '110101199001010011', }); const patientA2 = await ensurePatient({ @@ -232,7 +230,7 @@ async function main() { doctorId: doctorA2.id, name: 'Seed Patient A2', phone: '13800002002', - idCardHash: 'seed-id-card-a2', + idCard: '110101199002020022', }); const patientA3 = await ensurePatient({ @@ -240,7 +238,7 @@ async function main() { doctorId: doctorA3.id, name: 'Seed Patient A3', phone: '13800002003', - idCardHash: 'seed-id-card-a3', + idCard: '110101199003030033', }); const patientB1 = await ensurePatient({ @@ -248,7 +246,7 @@ async function main() { doctorId: doctorB.id, name: 'Seed Patient B1', phone: '13800002001', - idCardHash: 'seed-id-card-cross-hospital', + idCard: '110101199001010011', }); const deviceA1 = await prisma.device.upsert({ diff --git a/src/app.module.ts b/src/app.module.ts index 26f76ce..c356ecc 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -7,6 +7,7 @@ import { PatientsModule } from './patients/patients.module.js'; import { AuthModule } from './auth/auth.module.js'; import { OrganizationModule } from './organization/organization.module.js'; import { NotificationsModule } from './notifications/notifications.module.js'; +import { DevicesModule } from './devices/devices.module.js'; @Module({ imports: [ @@ -18,6 +19,7 @@ import { NotificationsModule } from './notifications/notifications.module.js'; AuthModule, OrganizationModule, NotificationsModule, + DevicesModule, ], }) export class AppModule {} diff --git a/src/auth/access-token.guard.ts b/src/auth/access-token.guard.ts index d5fec90..18406fa 100644 --- a/src/auth/access-token.guard.ts +++ b/src/auth/access-token.guard.ts @@ -5,25 +5,25 @@ import { UnauthorizedException, } from '@nestjs/common'; import jwt from 'jsonwebtoken'; -import { Role } from '../generated/prisma/enums.js'; import type { ActorContext } from '../common/actor-context.js'; import { MESSAGES } from '../common/messages.js'; +import { PrismaService } from '../prisma.service.js'; /** * AccessToken 守卫:校验 Bearer JWT 并把 actor 注入到 request 上下文。 */ @Injectable() export class AccessTokenGuard implements CanActivate { + constructor(private readonly prisma: PrismaService) {} + /** * 守卫入口:认证通过返回 true,失败抛出 401。 */ - canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest< - { - headers: Record; - actor?: unknown; - } - >(); + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest<{ + headers: Record; + actor?: unknown; + }>(); const authorization = request.headers.authorization; const headerValue = Array.isArray(authorization) @@ -35,15 +35,15 @@ export class AccessTokenGuard implements CanActivate { } const token = headerValue.slice('Bearer '.length).trim(); - request.actor = this.verifyAndExtractActor(token); + request.actor = await this.verifyAndExtractActor(token); return true; } /** - * 解析并验证 token,同时提取最小化 actor 上下文。 + * 解析并验证 token,同时回库确认当前用户仍然有效。 */ - private verifyAndExtractActor(token: string): ActorContext { + private async verifyAndExtractActor(token: string): Promise { const secret = process.env.AUTH_TOKEN_SECRET; if (!secret) { throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_SECRET_MISSING); @@ -63,17 +63,39 @@ export class AccessTokenGuard implements CanActivate { throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_PAYLOAD_INVALID); } - const role = payload.role; - if (typeof role !== 'string' || !Object.values(Role).includes(role as Role)) { - throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_ROLE_INVALID); + const userId = this.asInt(payload.id, 'id'); + const issuedAt = this.asInt(payload.iat, 'iat'); + const user = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { + id: true, + role: true, + hospitalId: true, + departmentId: true, + groupId: true, + tokenValidAfter: true, + }, + }); + + // 数据库里已经没有该用户时,旧 token 必须立即失效。 + if (!user) { + throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_USER_NOT_FOUND); + } + + // JWT 的 iat 精度是秒,这里按秒比较,避免同秒登录被误伤。 + const tokenValidAfterUnix = Math.floor( + user.tokenValidAfter.getTime() / 1000, + ); + if (issuedAt < tokenValidAfterUnix) { + throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_REVOKED); } return { - id: this.asInt(payload.id, 'id'), - role: role as Role, - hospitalId: this.asNullableInt(payload.hospitalId, 'hospitalId'), - departmentId: this.asNullableInt(payload.departmentId, 'departmentId'), - groupId: this.asNullableInt(payload.groupId, 'groupId'), + id: user.id, + role: user.role, + hospitalId: user.hospitalId, + departmentId: user.departmentId, + groupId: user.groupId, }; } @@ -82,20 +104,9 @@ export class AccessTokenGuard implements CanActivate { */ private asInt(value: unknown, field: string): number { if (typeof value !== 'number' || !Number.isInteger(value)) { - throw new UnauthorizedException(`${MESSAGES.AUTH.TOKEN_FIELD_INVALID}: ${field}`); - } - return value; - } - - /** - * 严格校验 token 中可空整数的字段。 - */ - private asNullableInt(value: unknown, field: string): number | null { - if (value === null || value === undefined) { - return null; - } - if (typeof value !== 'number' || !Number.isInteger(value)) { - throw new UnauthorizedException(`${MESSAGES.AUTH.TOKEN_FIELD_INVALID}: ${field}`); + throw new UnauthorizedException( + `${MESSAGES.AUTH.TOKEN_FIELD_INVALID}: ${field}`, + ); } return value; } diff --git a/src/common/messages.ts b/src/common/messages.ts index 71e6bd1..45108a3 100644 --- a/src/common/messages.ts +++ b/src/common/messages.ts @@ -21,6 +21,8 @@ export const MESSAGES = { TOKEN_SECRET_MISSING: '服务端未配置认证密钥', TOKEN_INVALID: 'Token 无效或已过期', TOKEN_PAYLOAD_INVALID: 'Token 载荷不合法', + TOKEN_USER_NOT_FOUND: 'Token 对应用户不存在,请重新登录', + TOKEN_REVOKED: 'Token 已失效,请重新登录', TOKEN_ROLE_INVALID: 'Token 中角色信息不合法', TOKEN_FIELD_INVALID: 'Token 中字段不合法', INVALID_CREDENTIALS: '手机号、角色或密码错误', @@ -82,12 +84,25 @@ export const MESSAGES = { DOCTOR_ROLE_REQUIRED: '归属用户必须为医生/主任/组长角色', DOCTOR_SCOPE_FORBIDDEN: '仅可选择当前权限范围内医生/主任/组长', DELETE_CONFLICT: '患者存在关联设备,无法删除', - PHONE_IDCARD_REQUIRED: 'phone 与 idCardHash 均为必填', - LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证哈希', + PHONE_IDCARD_REQUIRED: 'phone 与 idCard 均为必填', + LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证号', SYSTEM_ADMIN_HOSPITAL_REQUIRED: '系统管理员查询必须显式传入 hospitalId', ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息', }, + DEVICE: { + NOT_FOUND: '设备不存在或无权限访问', + SN_CODE_REQUIRED: 'snCode 不能为空', + SN_CODE_DUPLICATE: '设备 SN 已存在', + CURRENT_PRESSURE_INVALID: 'currentPressure 必须为大于等于 0 的整数', + STATUS_INVALID: '设备状态不合法', + PATIENT_REQUIRED: 'patientId 必填且必须为整数', + PATIENT_NOT_FOUND: '归属患者不存在', + PATIENT_SCOPE_FORBIDDEN: '仅可绑定当前权限范围内患者', + DELETE_CONFLICT: '设备存在关联任务记录,无法删除', + ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息', + }, + ORG: { HOSPITAL_NOT_FOUND: '医院不存在', DEPARTMENT_NOT_FOUND: '科室不存在', @@ -108,6 +123,7 @@ export const MESSAGES = { GROUP_DEPARTMENT_MISMATCH: '小组不属于指定科室', DEPARTMENT_REPARENT_FORBIDDEN: '科室不允许更换所属医院', GROUP_REPARENT_FORBIDDEN: '小组不允许更换所属科室', + GROUP_DELETE_HAS_USERS: '小组下仍有成员,无法删除,请先调整用户归属', DELETE_CONFLICT: '存在关联数据,无法删除,请先清理用户、患者、任务或下级组织后重试', }, diff --git a/src/devices/b-devices/b-devices.controller.ts b/src/devices/b-devices/b-devices.controller.ts new file mode 100644 index 0000000..da04017 --- /dev/null +++ b/src/devices/b-devices/b-devices.controller.ts @@ -0,0 +1,102 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseIntPipe, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; +import { AccessTokenGuard } from '../../auth/access-token.guard.js'; +import { CurrentActor } from '../../auth/current-actor.decorator.js'; +import { Roles } from '../../auth/roles.decorator.js'; +import { RolesGuard } from '../../auth/roles.guard.js'; +import type { ActorContext } from '../../common/actor-context.js'; +import { Role } from '../../generated/prisma/enums.js'; +import { CreateDeviceDto } from '../dto/create-device.dto.js'; +import { DeviceQueryDto } from '../dto/device-query.dto.js'; +import { UpdateDeviceDto } from '../dto/update-device.dto.js'; +import { DevicesService } from '../devices.service.js'; + +/** + * B 端设备控制器:仅管理员可访问设备 CRUD。 + */ +@ApiTags('设备管理(B端)') +@ApiBearerAuth('bearer') +@Controller('b/devices') +@UseGuards(AccessTokenGuard, RolesGuard) +export class BDevicesController { + constructor(private readonly devicesService: DevicesService) {} + + /** + * 查询设备列表。 + */ + @Get() + @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN) + @ApiOperation({ summary: '查询设备列表' }) + findAll(@CurrentActor() actor: ActorContext, @Query() query: DeviceQueryDto) { + return this.devicesService.findAll(actor, query); + } + + /** + * 查询设备详情。 + */ + @Get(':id') + @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN) + @ApiOperation({ summary: '查询设备详情' }) + @ApiParam({ name: 'id', description: '设备 ID' }) + findOne( + @CurrentActor() actor: ActorContext, + @Param('id', ParseIntPipe) id: number, + ) { + return this.devicesService.findOne(actor, id); + } + + /** + * 创建设备。 + */ + @Post() + @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN) + @ApiOperation({ summary: '创建设备' }) + create(@CurrentActor() actor: ActorContext, @Body() dto: CreateDeviceDto) { + return this.devicesService.create(actor, dto); + } + + /** + * 更新设备。 + */ + @Patch(':id') + @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN) + @ApiOperation({ summary: '更新设备' }) + @ApiParam({ name: 'id', description: '设备 ID' }) + update( + @CurrentActor() actor: ActorContext, + @Param('id', ParseIntPipe) id: number, + @Body() dto: UpdateDeviceDto, + ) { + return this.devicesService.update(actor, id, dto); + } + + /** + * 删除设备。 + */ + @Delete(':id') + @Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN) + @ApiOperation({ summary: '删除设备' }) + @ApiParam({ name: 'id', description: '设备 ID' }) + remove( + @CurrentActor() actor: ActorContext, + @Param('id', ParseIntPipe) id: number, + ) { + return this.devicesService.remove(actor, id); + } +} diff --git a/src/devices/devices.module.ts b/src/devices/devices.module.ts new file mode 100644 index 0000000..52172a3 --- /dev/null +++ b/src/devices/devices.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AccessTokenGuard } from '../auth/access-token.guard.js'; +import { RolesGuard } from '../auth/roles.guard.js'; +import { BDevicesController } from './b-devices/b-devices.controller.js'; +import { DevicesService } from './devices.service.js'; + +@Module({ + controllers: [BDevicesController], + providers: [DevicesService, AccessTokenGuard, RolesGuard], + exports: [DevicesService], +}) +export class DevicesModule {} diff --git a/src/devices/devices.service.ts b/src/devices/devices.service.ts new file mode 100644 index 0000000..4ccf598 --- /dev/null +++ b/src/devices/devices.service.ts @@ -0,0 +1,403 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '../generated/prisma/client.js'; +import { DeviceStatus, Role } from '../generated/prisma/enums.js'; +import type { ActorContext } from '../common/actor-context.js'; +import { MESSAGES } from '../common/messages.js'; +import { PrismaService } from '../prisma.service.js'; +import { CreateDeviceDto } from './dto/create-device.dto.js'; +import { DeviceQueryDto } from './dto/device-query.dto.js'; +import { UpdateDeviceDto } from './dto/update-device.dto.js'; + +const DEVICE_DETAIL_INCLUDE = { + patient: { + select: { + id: true, + name: true, + phone: true, + hospitalId: true, + hospital: { + select: { + id: true, + name: true, + }, + }, + doctor: { + select: { + id: true, + name: true, + role: true, + }, + }, + }, + }, + _count: { + select: { + taskItems: true, + }, + }, +} as const; + +/** + * 设备服务:承载管理员设备 CRUD、租户隔离与分页筛选。 + */ +@Injectable() +export class DevicesService { + constructor(private readonly prisma: PrismaService) {} + + /** + * 查询设备列表:系统管理员可跨院查询,院管仅限本院。 + */ + async findAll(actor: ActorContext, query: DeviceQueryDto) { + this.assertAdmin(actor); + + const paging = this.resolvePaging(query); + const scopedHospitalId = this.resolveScopedHospitalId( + actor, + query.hospitalId, + ); + const where = this.buildListWhere(query, scopedHospitalId); + + const [total, list] = await this.prisma.$transaction([ + this.prisma.device.count({ where }), + this.prisma.device.findMany({ + where, + include: DEVICE_DETAIL_INCLUDE, + skip: paging.skip, + take: paging.take, + orderBy: { id: 'desc' }, + }), + ]); + + return { + total, + ...paging, + list, + }; + } + + /** + * 查询设备详情。 + */ + async findOne(actor: ActorContext, id: number) { + this.assertAdmin(actor); + + const deviceId = this.toInt(id, 'id'); + const device = await this.prisma.device.findUnique({ + where: { id: deviceId }, + include: DEVICE_DETAIL_INCLUDE, + }); + + if (!device) { + throw new NotFoundException(MESSAGES.DEVICE.NOT_FOUND); + } + + this.assertDeviceReadable(actor, device.patient.hospitalId); + return device; + } + + /** + * 创建设备:归属患者必须在当前管理员可写范围内。 + */ + async create(actor: ActorContext, dto: CreateDeviceDto) { + this.assertAdmin(actor); + + const snCode = this.normalizeSnCode(dto.snCode); + const patient = await this.resolveWritablePatient(actor, dto.patientId); + await this.assertSnCodeUnique(snCode); + + return this.prisma.device.create({ + data: { + snCode, + currentPressure: this.normalizePressure(dto.currentPressure), + status: dto.status ?? DeviceStatus.ACTIVE, + patientId: patient.id, + }, + include: DEVICE_DETAIL_INCLUDE, + }); + } + + /** + * 更新设备:允许修改 SN、当前压力、状态和归属患者。 + */ + async update(actor: ActorContext, id: number, dto: UpdateDeviceDto) { + const current = await this.findOne(actor, id); + + const data: Prisma.DeviceUpdateInput = {}; + if (dto.snCode !== undefined) { + const snCode = this.normalizeSnCode(dto.snCode); + await this.assertSnCodeUnique(snCode, current.id); + data.snCode = snCode; + } + if (dto.currentPressure !== undefined) { + data.currentPressure = this.normalizePressure(dto.currentPressure); + } + if (dto.status !== undefined) { + data.status = this.normalizeStatus(dto.status); + } + if (dto.patientId !== undefined) { + const patient = await this.resolveWritablePatient(actor, dto.patientId); + data.patient = { connect: { id: patient.id } }; + } + + return this.prisma.device.update({ + where: { id: current.id }, + data, + include: DEVICE_DETAIL_INCLUDE, + }); + } + + /** + * 删除设备:若设备已被任务明细引用,则返回 409。 + */ + async remove(actor: ActorContext, id: number) { + const current = await this.findOne(actor, id); + + try { + return await this.prisma.device.delete({ + where: { id: current.id }, + include: DEVICE_DETAIL_INCLUDE, + }); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + (error.code === 'P2003' || error.code === 'P2014') + ) { + throw new ConflictException(MESSAGES.DEVICE.DELETE_CONFLICT); + } + throw error; + } + } + + /** + * 构造列表筛选:支持按医院、患者、状态和关键词组合查询。 + */ + private buildListWhere(query: DeviceQueryDto, scopedHospitalId?: number) { + const andConditions: Prisma.DeviceWhereInput[] = []; + const keyword = query.keyword?.trim(); + + if (scopedHospitalId != null) { + andConditions.push({ + patient: { + is: { + hospitalId: scopedHospitalId, + }, + }, + }); + } + + if (query.patientId != null) { + andConditions.push({ + patientId: query.patientId, + }); + } + + if (query.status != null) { + andConditions.push({ + status: query.status, + }); + } + + if (keyword) { + andConditions.push({ + OR: [ + { + snCode: { + contains: keyword, + mode: 'insensitive', + }, + }, + { + patient: { + is: { + name: { + contains: keyword, + mode: 'insensitive', + }, + }, + }, + }, + { + patient: { + is: { + phone: { + contains: keyword, + }, + }, + }, + }, + ], + }); + } + + return andConditions.length > 0 ? { AND: andConditions } : {}; + } + + /** + * 解析列表分页。 + */ + private resolvePaging(query: DeviceQueryDto) { + const page = query.page && query.page > 0 ? query.page : 1; + const pageSize = + query.pageSize && query.pageSize > 0 && query.pageSize <= 100 + ? query.pageSize + : 20; + + return { + page, + pageSize, + skip: (page - 1) * pageSize, + take: pageSize, + }; + } + + /** + * 解析当前查询实际生效的医院作用域。 + */ + private resolveScopedHospitalId( + actor: ActorContext, + hospitalId?: number, + ): number | undefined { + if (actor.role === Role.SYSTEM_ADMIN) { + return hospitalId; + } + + return this.requireActorHospitalId(actor); + } + + /** + * 读取并校验当前管理员可写的患者。 + */ + private async resolveWritablePatient(actor: ActorContext, patientId: number) { + const normalizedPatientId = this.toInt( + patientId, + MESSAGES.DEVICE.PATIENT_REQUIRED, + ); + + const patient = await this.prisma.patient.findUnique({ + where: { id: normalizedPatientId }, + select: { + id: true, + hospitalId: true, + }, + }); + + if (!patient) { + throw new NotFoundException(MESSAGES.DEVICE.PATIENT_NOT_FOUND); + } + + if ( + actor.role === Role.HOSPITAL_ADMIN && + patient.hospitalId !== this.requireActorHospitalId(actor) + ) { + throw new ForbiddenException(MESSAGES.DEVICE.PATIENT_SCOPE_FORBIDDEN); + } + + return patient; + } + + /** + * 校验当前用户是否可读/写该设备。 + */ + private assertDeviceReadable(actor: ActorContext, hospitalId: number) { + if (actor.role === Role.SYSTEM_ADMIN) { + return; + } + + if (hospitalId !== this.requireActorHospitalId(actor)) { + throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN); + } + } + + /** + * 管理员角色校验:仅系统管理员与院管可操作设备。 + */ + private assertAdmin(actor: ActorContext) { + if ( + actor.role !== Role.SYSTEM_ADMIN && + actor.role !== Role.HOSPITAL_ADMIN + ) { + throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN); + } + } + + /** + * 设备 SN 标准化:统一去空白并转大写,避免大小写重复。 + */ + private normalizeSnCode(value: unknown) { + if (typeof value !== 'string') { + throw new BadRequestException(MESSAGES.DEVICE.SN_CODE_REQUIRED); + } + + const normalized = value.trim().toUpperCase(); + if (!normalized) { + throw new BadRequestException(MESSAGES.DEVICE.SN_CODE_REQUIRED); + } + return normalized; + } + + /** + * 压力值必须是非负整数。 + */ + private normalizePressure(value: unknown) { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new BadRequestException(MESSAGES.DEVICE.CURRENT_PRESSURE_INVALID); + } + return parsed; + } + + /** + * 设备状态枚举校验。 + */ + private normalizeStatus(value: unknown): DeviceStatus { + if (!Object.values(DeviceStatus).includes(value as DeviceStatus)) { + throw new BadRequestException(MESSAGES.DEVICE.STATUS_INVALID); + } + return value as DeviceStatus; + } + + /** + * 统一整数参数校验。 + */ + private toInt(value: unknown, message: string) { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new BadRequestException(message); + } + return parsed; + } + + /** + * 当前登录上下文中的医院 ID 对院管是必填项。 + */ + private requireActorHospitalId(actor: ActorContext) { + if ( + typeof actor.hospitalId !== 'number' || + !Number.isInteger(actor.hospitalId) || + actor.hospitalId <= 0 + ) { + throw new BadRequestException(MESSAGES.DEVICE.ACTOR_HOSPITAL_REQUIRED); + } + return actor.hospitalId; + } + + /** + * 确保设备 SN 唯一;更新时允许命中自身。 + */ + private async assertSnCodeUnique(snCode: string, selfId?: number) { + const existing = await this.prisma.device.findUnique({ + where: { snCode }, + select: { id: true }, + }); + + if (existing && existing.id !== selfId) { + throw new ConflictException(MESSAGES.DEVICE.SN_CODE_DUPLICATE); + } + } +} diff --git a/src/devices/dto/create-device.dto.ts b/src/devices/dto/create-device.dto.ts new file mode 100644 index 0000000..c711931 --- /dev/null +++ b/src/devices/dto/create-device.dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { DeviceStatus } from '../../generated/prisma/enums.js'; +import { Type } from 'class-transformer'; +import { IsEnum, IsInt, IsOptional, IsString, Min } from 'class-validator'; + +/** + * 创建设备 DTO。 + */ +export class CreateDeviceDto { + @ApiProperty({ description: '设备 SN', example: 'TYT-SN-10001' }) + @IsString({ message: 'snCode 必须是字符串' }) + snCode!: string; + + @ApiProperty({ description: '当前压力值', example: 120 }) + @Type(() => Number) + @IsInt({ message: 'currentPressure 必须是整数' }) + @Min(0, { message: 'currentPressure 必须大于等于 0' }) + currentPressure!: number; + + @ApiPropertyOptional({ + description: '设备状态,默认 ACTIVE', + enum: DeviceStatus, + example: DeviceStatus.ACTIVE, + }) + @IsOptional() + @IsEnum(DeviceStatus, { message: 'status 枚举值不合法' }) + status?: DeviceStatus; + + @ApiProperty({ description: '归属患者 ID', example: 1 }) + @Type(() => Number) + @IsInt({ message: 'patientId 必须是整数' }) + @Min(1, { message: 'patientId 必须大于 0' }) + patientId!: number; +} diff --git a/src/devices/dto/device-query.dto.ts b/src/devices/dto/device-query.dto.ts new file mode 100644 index 0000000..b1f7550 --- /dev/null +++ b/src/devices/dto/device-query.dto.ts @@ -0,0 +1,68 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { DeviceStatus } from '../../generated/prisma/enums.js'; +import { Type } from 'class-transformer'; +import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js'; +import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +/** + * 设备列表查询 DTO:支持管理员后台按设备、患者和医院筛选。 + */ +export class DeviceQueryDto { + @ApiPropertyOptional({ + description: '关键词(支持设备 SN / 患者姓名 / 患者手机号)', + example: 'SN-A', + }) + @IsOptional() + @IsString({ message: 'keyword 必须是字符串' }) + keyword?: string; + + @ApiPropertyOptional({ + description: '设备状态', + enum: DeviceStatus, + example: DeviceStatus.ACTIVE, + }) + @IsOptional() + @IsEnum(DeviceStatus, { message: 'status 枚举值不合法' }) + status?: DeviceStatus; + + @ApiPropertyOptional({ description: '医院 ID', example: 1 }) + @IsOptional() + @EmptyStringToUndefined() + @Type(() => Number) + @IsInt({ message: 'hospitalId 必须是整数' }) + @Min(1, { message: 'hospitalId 必须大于 0' }) + hospitalId?: number; + + @ApiPropertyOptional({ description: '患者 ID', example: 1 }) + @IsOptional() + @EmptyStringToUndefined() + @Type(() => Number) + @IsInt({ message: 'patientId 必须是整数' }) + @Min(1, { message: 'patientId 必须大于 0' }) + patientId?: number; + + @ApiPropertyOptional({ + description: '页码(默认 1)', + example: 1, + default: 1, + }) + @IsOptional() + @EmptyStringToUndefined() + @Type(() => Number) + @IsInt({ message: 'page 必须是整数' }) + @Min(1, { message: 'page 最小为 1' }) + page?: number = 1; + + @ApiPropertyOptional({ + description: '每页数量(默认 20,最大 100)', + example: 20, + default: 20, + }) + @IsOptional() + @EmptyStringToUndefined() + @Type(() => Number) + @IsInt({ message: 'pageSize 必须是整数' }) + @Min(1, { message: 'pageSize 最小为 1' }) + @Max(100, { message: 'pageSize 最大为 100' }) + pageSize?: number = 20; +} diff --git a/src/devices/dto/update-device.dto.ts b/src/devices/dto/update-device.dto.ts new file mode 100644 index 0000000..550009c --- /dev/null +++ b/src/devices/dto/update-device.dto.ts @@ -0,0 +1,7 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateDeviceDto } from './create-device.dto.js'; + +/** + * 更新设备 DTO。 + */ +export class UpdateDeviceDto extends PartialType(CreateDeviceDto) {} diff --git a/src/groups/groups.service.ts b/src/groups/groups.service.ts index 314e3f1..b6f307a 100644 --- a/src/groups/groups.service.ts +++ b/src/groups/groups.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, ForbiddenException, Injectable, NotFoundException, @@ -33,7 +34,10 @@ export class GroupsService { Role.HOSPITAL_ADMIN, Role.DIRECTOR, ]); - const departmentId = this.access.toInt(dto.departmentId, MESSAGES.ORG.DEPARTMENT_ID_REQUIRED); + const departmentId = this.access.toInt( + dto.departmentId, + MESSAGES.ORG.DEPARTMENT_ID_REQUIRED, + ); const department = await this.access.ensureDepartmentExists(departmentId); if (actor.role === Role.HOSPITAL_ADMIN) { this.access.assertHospitalScope(actor, department.hospitalId); @@ -47,7 +51,10 @@ export class GroupsService { return this.prisma.group.create({ data: { - name: this.access.normalizeName(dto.name, MESSAGES.ORG.GROUP_NAME_REQUIRED), + name: this.access.normalizeName( + dto.name, + MESSAGES.ORG.GROUP_NAME_REQUIRED, + ), departmentId, }, }); @@ -70,18 +77,26 @@ export class GroupsService { where.name = { contains: query.keyword.trim(), mode: 'insensitive' }; } if (query.departmentId != null) { - where.departmentId = this.access.toInt(query.departmentId, MESSAGES.ORG.DEPARTMENT_ID_REQUIRED); + where.departmentId = this.access.toInt( + query.departmentId, + MESSAGES.ORG.DEPARTMENT_ID_REQUIRED, + ); } if (actor.role === Role.HOSPITAL_ADMIN) { - where.department = { hospitalId: this.access.requireActorHospitalId(actor) }; + where.department = { + hospitalId: this.access.requireActorHospitalId(actor), + }; } else if (actor.role === Role.DIRECTOR) { where.departmentId = this.access.requireActorDepartmentId(actor); } else if (actor.role === Role.LEADER) { where.id = this.access.requireActorGroupId(actor); } else if (query.hospitalId != null) { where.department = { - hospitalId: this.access.toInt(query.hospitalId, MESSAGES.ORG.HOSPITAL_ID_REQUIRED), + hospitalId: this.access.toInt( + query.hospitalId, + MESSAGES.ORG.HOSPITAL_ID_REQUIRED, + ), }; } @@ -153,7 +168,10 @@ export class GroupsService { } if (dto.name !== undefined) { - data.name = this.access.normalizeName(dto.name, MESSAGES.ORG.GROUP_NAME_REQUIRED); + data.name = this.access.normalizeName( + dto.name, + MESSAGES.ORG.GROUP_NAME_REQUIRED, + ); } return this.prisma.group.update({ @@ -172,6 +190,12 @@ export class GroupsService { Role.DIRECTOR, ]); const current = await this.findOne(actor, id); + + // 业务层先拦截,给前端稳定中文提示;数据库层仍保留 RESTRICT 兜底。 + if (current._count.users > 0) { + throw new ConflictException(MESSAGES.ORG.GROUP_DELETE_HAS_USERS); + } + try { return await this.prisma.group.delete({ where: { id: current.id } }); } catch (error) { diff --git a/src/patients/b-patients/b-patients.service.ts b/src/patients/b-patients/b-patients.service.ts index 26ae325..b9b97cb 100644 --- a/src/patients/b-patients/b-patients.service.ts +++ b/src/patients/b-patients/b-patients.service.ts @@ -12,6 +12,7 @@ import type { ActorContext } from '../../common/actor-context.js'; import { MESSAGES } from '../../common/messages.js'; import { CreatePatientDto } from '../dto/create-patient.dto.js'; import { UpdatePatientDto } from '../dto/update-patient.dto.js'; +import { normalizePatientIdCard } from '../patient-id-card.util.js'; const PATIENT_OWNER_ROLES: Role[] = [Role.DOCTOR, Role.DIRECTOR, Role.LEADER]; @@ -98,7 +99,8 @@ export class BPatientsService { data: { name: this.normalizeRequiredString(dto.name, 'name'), phone: this.normalizePhone(dto.phone), - idCardHash: this.normalizeRequiredString(dto.idCardHash, 'idCardHash'), + // 身份证统一做轻量标准化后落库,数据库中保存原始证件号而不是哈希。 + idCard: this.normalizeIdCard(dto.idCard), hospitalId: doctor.hospitalId!, doctorId: doctor.id, }, @@ -133,8 +135,9 @@ export class BPatientsService { if (dto.phone !== undefined) { data.phone = this.normalizePhone(dto.phone); } - if (dto.idCardHash !== undefined) { - data.idCardHash = this.normalizeRequiredString(dto.idCardHash, 'idCardHash'); + if (dto.idCard !== undefined) { + // 更新时沿用同一标准化逻辑,保证查询条件与落库格式一致。 + data.idCard = this.normalizeIdCard(dto.idCard); } if (dto.doctorId !== undefined) { const doctor = await this.resolveWritableDoctor(actor, dto.doctorId); @@ -234,7 +237,10 @@ export class BPatientsService { } return; case Role.DIRECTOR: - if (!actor.departmentId || patient.doctor.departmentId !== actor.departmentId) { + if ( + !actor.departmentId || + patient.doctor.departmentId !== actor.departmentId + ) { throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN); } return; @@ -360,7 +366,9 @@ export class BPatientsService { normalizedHospitalId == null || !Number.isInteger(normalizedHospitalId) ) { - throw new BadRequestException(MESSAGES.PATIENT.SYSTEM_ADMIN_HOSPITAL_REQUIRED); + throw new BadRequestException( + MESSAGES.PATIENT.SYSTEM_ADMIN_HOSPITAL_REQUIRED, + ); } return normalizedHospitalId; } @@ -390,4 +398,12 @@ export class BPatientsService { } return normalized; } + + /** + * 统一整理身份证号,避免空格和末尾 x 大小写带来重复数据。 + */ + private normalizeIdCard(value: unknown) { + const normalized = this.normalizeRequiredString(value, 'idCard'); + return normalizePatientIdCard(normalized); + } } diff --git a/src/patients/c-patients/c-patients.controller.ts b/src/patients/c-patients/c-patients.controller.ts index 1f19fa3..aad7c73 100644 --- a/src/patients/c-patients/c-patients.controller.ts +++ b/src/patients/c-patients/c-patients.controller.ts @@ -20,16 +20,16 @@ export class CPatientsController { constructor(private readonly patientsService: CPatientsService) {} /** - * 根据手机号和身份证哈希查询跨院生命周期。 + * 根据手机号和身份证号查询跨院生命周期。 */ @Get('lifecycle') @ApiOperation({ summary: '跨院患者生命周期查询' }) @ApiQuery({ name: 'phone', description: '手机号' }) - @ApiQuery({ name: 'idCardHash', description: '身份证哈希' }) + @ApiQuery({ name: 'idCard', description: '身份证号' }) getLifecycle(@Query() query: FamilyLifecycleQueryDto) { return this.patientsService.getFamilyLifecycleByIdentity( query.phone, - query.idCardHash, + query.idCard, ); } } diff --git a/src/patients/c-patients/c-patients.service.ts b/src/patients/c-patients/c-patients.service.ts index 79f64c7..f41d637 100644 --- a/src/patients/c-patients/c-patients.service.ts +++ b/src/patients/c-patients/c-patients.service.ts @@ -5,6 +5,7 @@ import { } from '@nestjs/common'; import { PrismaService } from '../../prisma.service.js'; import { MESSAGES } from '../../common/messages.js'; +import { normalizePatientIdCard } from '../patient-id-card.util.js'; /** * C 端患者服务:承载家属跨院生命周期聚合查询。 @@ -14,17 +15,20 @@ export class CPatientsService { constructor(private readonly prisma: PrismaService) {} /** - * C 端查询:按 phone + idCardHash 跨院聚合患者生命周期记录。 + * C 端查询:按 phone + idCard 跨院聚合患者生命周期记录。 */ - async getFamilyLifecycleByIdentity(phone: string, idCardHash: string) { - if (!phone || !idCardHash) { + 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, - idCardHash, + idCard: normalizedIdCard, }, include: { hospital: { select: { id: true, name: true } }, @@ -89,6 +93,9 @@ export class CPatientsService { ); return { + // 前端详情弹窗和现有 E2E 都依赖这两个回显字段。 + phone, + idCard: normalizedIdCard, patientCount: patients.length, lifecycle, }; diff --git a/src/patients/dto/create-patient.dto.ts b/src/patients/dto/create-patient.dto.ts index 70a26ef..ca4c2be 100644 --- a/src/patients/dto/create-patient.dto.ts +++ b/src/patients/dto/create-patient.dto.ts @@ -1,11 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { - IsInt, - IsString, - Matches, - Min, -} from 'class-validator'; +import { IsInt, IsString, Matches, Min } from 'class-validator'; /** * 患者创建 DTO:B 端新增患者使用。 @@ -21,11 +16,11 @@ export class CreatePatientDto { phone!: string; @ApiProperty({ - description: '身份证哈希(前端传加密后值)', - example: 'id-card-hash-demo', + description: '身份证号原文', + example: '110101199001010011', }) - @IsString({ message: 'idCardHash 必须是字符串' }) - idCardHash!: string; + @IsString({ message: 'idCard 必须是字符串' }) + idCard!: string; @ApiProperty({ description: '归属人员 ID(医生/主任/组长)', example: 10001 }) @Type(() => Number) diff --git a/src/patients/dto/family-lifecycle-query.dto.ts b/src/patients/dto/family-lifecycle-query.dto.ts index f287179..72fb286 100644 --- a/src/patients/dto/family-lifecycle-query.dto.ts +++ b/src/patients/dto/family-lifecycle-query.dto.ts @@ -10,7 +10,10 @@ export class FamilyLifecycleQueryDto { @Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' }) phone!: string; - @ApiProperty({ description: '身份证哈希值', example: 'seed-id-card-hash' }) - @IsString({ message: 'idCardHash 必须是字符串' }) - idCardHash!: string; + @ApiProperty({ + description: '身份证号原文', + example: '110101199001010011', + }) + @IsString({ message: 'idCard 必须是字符串' }) + idCard!: string; } diff --git a/src/patients/patient-id-card.util.ts b/src/patients/patient-id-card.util.ts new file mode 100644 index 0000000..d17d3ad --- /dev/null +++ b/src/patients/patient-id-card.util.ts @@ -0,0 +1,8 @@ +/** + * 统一整理身份证号: + * 1. 去掉前后空白与中间空格 + * 2. 将末尾可能出现的小写 x 规范成大写 X + */ +export function normalizePatientIdCard(value: string): string { + return value.trim().replace(/\s+/g, '').toUpperCase(); +} diff --git a/src/tasks/dto/cancel-task.dto.ts b/src/tasks/dto/cancel-task.dto.ts index a3c9430..7e1f37a 100644 --- a/src/tasks/dto/cancel-task.dto.ts +++ b/src/tasks/dto/cancel-task.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsInt, Min } from 'class-validator'; +import { IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; /** * 取消任务 DTO。 @@ -11,4 +11,14 @@ export class CancelTaskDto { @IsInt({ message: 'taskId 必须是整数' }) @Min(1, { message: 'taskId 必须大于 0' }) taskId!: number; + + @ApiProperty({ + description: '取消原因(可选,当前仅用于接口兼容与后续通知扩展)', + example: '后台手动取消', + required: false, + }) + @IsOptional() + @IsString({ message: 'reason 必须是字符串' }) + @MaxLength(100, { message: 'reason 长度不能超过 100 个字符' }) + reason?: string; } diff --git a/src/tasks/task.service.ts b/src/tasks/task.service.ts index 8027030..b59241b 100644 --- a/src/tasks/task.service.ts +++ b/src/tasks/task.service.ts @@ -275,6 +275,8 @@ export class TaskService { hospitalId: cancelledTask.hospitalId, actorId: actor.id, status: cancelledTask.status, + // 当前库表未持久化取消原因,但先透传到事件层,方便通知链路后续接入。 + reason: dto.reason?.trim() || null, }); return cancelledTask; diff --git a/src/users/users.service.ts b/src/users/users.service.ts index bea2dce..8f21294 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -337,10 +337,12 @@ export class UsersService { data.openId = nextOpenId; } if (updateUserDto.password) { + // 密码变更后立即吊销旧 token,避免旧会话继续使用。 data.passwordHash = await hash( this.normalizePassword(updateUserDto.password), 12, ); + data.tokenValidAfter = new Date(); } return this.prisma.user.update({ diff --git a/test/e2e/helpers/e2e-fixtures.helper.ts b/test/e2e/helpers/e2e-fixtures.helper.ts index 95c3413..af7cf6d 100644 --- a/test/e2e/helpers/e2e-fixtures.helper.ts +++ b/test/e2e/helpers/e2e-fixtures.helper.ts @@ -81,16 +81,14 @@ async function requirePatientId( prisma: PrismaService, hospitalId: number, phone: string, - idCardHash: string, + idCard: string, ): Promise { const patient = await prisma.patient.findFirst({ - where: { hospitalId, phone, idCardHash }, + where: { hospitalId, phone, idCard }, select: { id: true }, }); if (!patient) { - throw new NotFoundException( - `Seed patient not found: ${phone}/${idCardHash}`, - ); + throw new NotFoundException(`Seed patient not found: ${phone}/${idCard}`); } return patient.id; } @@ -163,25 +161,25 @@ export async function loadSeedFixtures( prisma, hospitalAId, '13800002001', - 'seed-id-card-cross-hospital', + '110101199001010011', ), patientA2Id: await requirePatientId( prisma, hospitalAId, '13800002002', - 'seed-id-card-a2', + '110101199002020022', ), patientA3Id: await requirePatientId( prisma, hospitalAId, '13800002003', - 'seed-id-card-a3', + '110101199003030033', ), patientB1Id: await requirePatientId( prisma, hospitalBId, '13800002001', - 'seed-id-card-cross-hospital', + '110101199001010011', ), }, devices: { diff --git a/test/e2e/specs/auth-token-revocation.e2e-spec.ts b/test/e2e/specs/auth-token-revocation.e2e-spec.ts new file mode 100644 index 0000000..4f62c00 --- /dev/null +++ b/test/e2e/specs/auth-token-revocation.e2e-spec.ts @@ -0,0 +1,59 @@ +import request from 'supertest'; +import { Role } from '../../../src/generated/prisma/enums.js'; +import { + closeE2EContext, + createE2EContext, + type E2EContext, +} from '../helpers/e2e-context.helper.js'; +import { + expectErrorEnvelope, + expectSuccessEnvelope, +} from '../helpers/e2e-http.helper.js'; + +describe('Auth token revocation (e2e)', () => { + let ctx: E2EContext; + + beforeAll(async () => { + ctx = await createE2EContext(); + }); + + afterAll(async () => { + await closeE2EContext(ctx); + }); + + it('旧 token 在 tokenValidAfter 推进后失效', async () => { + const token = ctx.tokens[Role.DOCTOR]; + const originalUser = await ctx.prisma.user.findUnique({ + where: { id: ctx.fixtures.users.doctorAId }, + select: { tokenValidAfter: true }, + }); + + const beforeResponse = await request(ctx.app.getHttpServer()) + .get('/auth/me') + .set('Authorization', `Bearer ${token}`); + + expectSuccessEnvelope(beforeResponse, 200); + + try { + await ctx.prisma.user.update({ + where: { id: ctx.fixtures.users.doctorAId }, + // 往未来推进一分钟,确保当前 token 的 iat 一定早于失效时间。 + data: { tokenValidAfter: new Date(Date.now() + 60_000) }, + }); + + const afterResponse = await request(ctx.app.getHttpServer()) + .get('/auth/me') + .set('Authorization', `Bearer ${token}`); + + expectErrorEnvelope(afterResponse, 401, 'Token 已失效,请重新登录'); + } finally { + if (originalUser) { + // 恢复种子用户状态,避免串行 E2E 后续用例继续拿到失效 token。 + await ctx.prisma.user.update({ + where: { id: ctx.fixtures.users.doctorAId }, + data: { tokenValidAfter: originalUser.tokenValidAfter }, + }); + } + } + }); +}); diff --git a/test/e2e/specs/auth.e2e-spec.ts b/test/e2e/specs/auth.e2e-spec.ts index 82fccb0..b5912e3 100644 --- a/test/e2e/specs/auth.e2e-spec.ts +++ b/test/e2e/specs/auth.e2e-spec.ts @@ -24,39 +24,33 @@ describe('AuthController (e2e)', () => { await closeE2EContext(ctx); }); - describe('POST /auth/register', () => { - it('成功:注册医生账号', async () => { + describe('POST /auth/system-admin', () => { + it('成功:创建系统管理员账号', async () => { const response = await request(ctx.app.getHttpServer()) - .post('/auth/register') + .post('/auth/system-admin') .send({ - name: uniqueSeedValue('Auth 注册医生'), + name: uniqueSeedValue('Auth 系统管理员'), phone: uniquePhone(), password: 'Seed@1234', - role: Role.DOCTOR, - hospitalId: ctx.fixtures.hospitalAId, - departmentId: ctx.fixtures.departmentA1Id, - groupId: ctx.fixtures.groupA1Id, - openId: uniqueSeedValue('auth-register-openid'), + openId: uniqueSeedValue('auth-system-admin-openid'), + systemAdminBootstrapKey: process.env.SYSTEM_ADMIN_BOOTSTRAP_KEY, }); expectSuccessEnvelope(response, 201); - expect(response.body.data.role).toBe(Role.DOCTOR); + expect(response.body.data.role).toBe(Role.SYSTEM_ADMIN); }); it('失败:参数不合法返回 400', async () => { const response = await request(ctx.app.getHttpServer()) - .post('/auth/register') + .post('/auth/system-admin') .send({ - name: 'bad-register', + name: 'bad-system-admin', phone: '13800009999', password: '123', - role: Role.DOCTOR, - hospitalId: ctx.fixtures.hospitalAId, - departmentId: ctx.fixtures.departmentA1Id, - groupId: ctx.fixtures.groupA1Id, + systemAdminBootstrapKey: process.env.SYSTEM_ADMIN_BOOTSTRAP_KEY, }); - expectErrorEnvelope(response, 400, 'password 长度至少 8 位'); + expectErrorEnvelope(response, 400, '密码长度至少 8 位'); }); }); diff --git a/test/e2e/specs/devices.e2e-spec.ts b/test/e2e/specs/devices.e2e-spec.ts new file mode 100644 index 0000000..baa7ace --- /dev/null +++ b/test/e2e/specs/devices.e2e-spec.ts @@ -0,0 +1,161 @@ +import request from 'supertest'; +import { DeviceStatus, Role } from '../../../src/generated/prisma/enums.js'; +import { + closeE2EContext, + createE2EContext, + type E2EContext, +} from '../helpers/e2e-context.helper.js'; +import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js'; +import { + expectErrorEnvelope, + expectSuccessEnvelope, + uniqueSeedValue, +} from '../helpers/e2e-http.helper.js'; + +describe('BDevicesController (e2e)', () => { + let ctx: E2EContext; + + beforeAll(async () => { + ctx = await createE2EContext(); + }); + + afterAll(async () => { + await closeE2EContext(ctx); + }); + + async function createDevice(token: string, patientId: number) { + const response = await request(ctx.app.getHttpServer()) + .post('/b/devices') + .set('Authorization', `Bearer ${token}`) + .send({ + snCode: uniqueSeedValue('device-sn'), + currentPressure: 118, + status: DeviceStatus.ACTIVE, + patientId, + }); + + expectSuccessEnvelope(response, 201); + return response.body.data as { + id: number; + snCode: string; + status: DeviceStatus; + patient: { id: number }; + }; + } + + describe('GET /b/devices', () => { + it('成功:SYSTEM_ADMIN 可分页查询设备列表', async () => { + const response = await request(ctx.app.getHttpServer()) + .get('/b/devices') + .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`); + + expectSuccessEnvelope(response, 200); + expect(Array.isArray(response.body.data.list)).toBe(true); + expect(response.body.data.total).toBeGreaterThan(0); + }); + + it('成功:HOSPITAL_ADMIN 仅能看到本院设备', async () => { + const response = await request(ctx.app.getHttpServer()) + .get('/b/devices') + .set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`); + + expectSuccessEnvelope(response, 200); + const hospitalIds = ( + response.body.data.list as Array<{ + patient?: { hospital?: { id: number } }; + }> + ) + .map((item) => item.patient?.hospital?.id) + .filter(Boolean); + + expect(hospitalIds.every((id) => id === ctx.fixtures.hospitalAId)).toBe( + true, + ); + }); + + it('角色矩阵:仅 SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问列表,其他角色 403,未登录 401', async () => { + await assertRoleMatrix({ + name: 'GET /b/devices role matrix', + tokens: ctx.tokens, + expectedStatusByRole: { + [Role.SYSTEM_ADMIN]: 200, + [Role.HOSPITAL_ADMIN]: 200, + [Role.DIRECTOR]: 403, + [Role.LEADER]: 403, + [Role.DOCTOR]: 403, + [Role.ENGINEER]: 403, + }, + sendAsRole: async (_role, token) => + request(ctx.app.getHttpServer()) + .get('/b/devices') + .set('Authorization', `Bearer ${token}`), + sendWithoutToken: async () => + request(ctx.app.getHttpServer()).get('/b/devices'), + }); + }); + }); + + describe('设备 CRUD 流程', () => { + it('成功:HOSPITAL_ADMIN 可创建设备', async () => { + const created = await createDevice( + ctx.tokens[Role.HOSPITAL_ADMIN], + ctx.fixtures.patients.patientA1Id, + ); + + expect(created.status).toBe(DeviceStatus.ACTIVE); + expect(created.patient.id).toBe(ctx.fixtures.patients.patientA1Id); + expect(created.snCode).toMatch(/^DEVICE-SN-/); + }); + + it('失败:HOSPITAL_ADMIN 绑定跨院患者返回 403', async () => { + const response = await request(ctx.app.getHttpServer()) + .post('/b/devices') + .set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`) + .send({ + snCode: uniqueSeedValue('cross-hospital-device'), + currentPressure: 120, + status: DeviceStatus.ACTIVE, + patientId: ctx.fixtures.patients.patientB1Id, + }); + + expectErrorEnvelope(response, 403, '仅可绑定当前权限范围内患者'); + }); + + it('成功:SYSTEM_ADMIN 可更新设备状态与归属患者', async () => { + const created = await createDevice( + ctx.tokens[Role.SYSTEM_ADMIN], + ctx.fixtures.patients.patientA1Id, + ); + + const response = await request(ctx.app.getHttpServer()) + .patch(`/b/devices/${created.id}`) + .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`) + .send({ + status: DeviceStatus.INACTIVE, + patientId: ctx.fixtures.patients.patientA2Id, + currentPressure: 99, + }); + + expectSuccessEnvelope(response, 200); + expect(response.body.data.status).toBe(DeviceStatus.INACTIVE); + expect(response.body.data.patient.id).toBe( + ctx.fixtures.patients.patientA2Id, + ); + expect(response.body.data.currentPressure).toBe(99); + }); + + it('成功:SYSTEM_ADMIN 可删除未被任务引用的设备', async () => { + const created = await createDevice( + ctx.tokens[Role.SYSTEM_ADMIN], + ctx.fixtures.patients.patientA1Id, + ); + + const response = await request(ctx.app.getHttpServer()) + .delete(`/b/devices/${created.id}`) + .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`); + + expectSuccessEnvelope(response, 200); + expect(response.body.data.id).toBe(created.id); + }); + }); +}); diff --git a/test/e2e/specs/organization.e2e-spec.ts b/test/e2e/specs/organization.e2e-spec.ts index cb7e7ef..70c1fdb 100644 --- a/test/e2e/specs/organization.e2e-spec.ts +++ b/test/e2e/specs/organization.e2e-spec.ts @@ -86,15 +86,15 @@ describe('Organization Controllers (e2e)', () => { expectErrorEnvelope(response, 401, '缺少 Bearer Token'); }); - it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => { + it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'GET /b/organization/hospitals role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 200, [Role.HOSPITAL_ADMIN]: 200, - [Role.DIRECTOR]: 403, - [Role.LEADER]: 403, + [Role.DIRECTOR]: 200, + [Role.LEADER]: 200, [Role.DOCTOR]: 403, [Role.ENGINEER]: 403, }, @@ -126,15 +126,15 @@ describe('Organization Controllers (e2e)', () => { expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据'); }); - it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => { + it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'GET /b/organization/hospitals/:id role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 200, [Role.HOSPITAL_ADMIN]: 200, - [Role.DIRECTOR]: 403, - [Role.LEADER]: 403, + [Role.DIRECTOR]: 200, + [Role.LEADER]: 200, [Role.DOCTOR]: 403, [Role.ENGINEER]: 403, }, @@ -321,15 +321,15 @@ describe('Organization Controllers (e2e)', () => { expectErrorEnvelope(response, 401, '缺少 Bearer Token'); }); - it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => { + it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'GET /b/organization/departments role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 200, [Role.HOSPITAL_ADMIN]: 200, - [Role.DIRECTOR]: 403, - [Role.LEADER]: 403, + [Role.DIRECTOR]: 200, + [Role.LEADER]: 200, [Role.DOCTOR]: 403, [Role.ENGINEER]: 403, }, @@ -361,15 +361,15 @@ describe('Organization Controllers (e2e)', () => { expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据'); }); - it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => { + it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'GET /b/organization/departments/:id role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 200, [Role.HOSPITAL_ADMIN]: 200, - [Role.DIRECTOR]: 403, - [Role.LEADER]: 403, + [Role.DIRECTOR]: 200, + [Role.LEADER]: 200, [Role.DOCTOR]: 403, [Role.ENGINEER]: 403, }, @@ -413,15 +413,15 @@ describe('Organization Controllers (e2e)', () => { expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据'); }); - it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => { + it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'PATCH /b/organization/departments/:id role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 404, [Role.HOSPITAL_ADMIN]: 404, - [Role.DIRECTOR]: 403, - [Role.LEADER]: 403, + [Role.DIRECTOR]: 404, + [Role.LEADER]: 404, [Role.DOCTOR]: 403, [Role.ENGINEER]: 403, }, @@ -516,14 +516,14 @@ describe('Organization Controllers (e2e)', () => { expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据'); }); - it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => { + it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可进入业务,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'POST /b/organization/groups role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 400, [Role.HOSPITAL_ADMIN]: 400, - [Role.DIRECTOR]: 403, + [Role.DIRECTOR]: 400, [Role.LEADER]: 403, [Role.DOCTOR]: 403, [Role.ENGINEER]: 403, @@ -558,15 +558,15 @@ describe('Organization Controllers (e2e)', () => { expectErrorEnvelope(response, 401, '缺少 Bearer Token'); }); - it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => { + it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'GET /b/organization/groups role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 200, [Role.HOSPITAL_ADMIN]: 200, - [Role.DIRECTOR]: 403, - [Role.LEADER]: 403, + [Role.DIRECTOR]: 200, + [Role.LEADER]: 200, [Role.DOCTOR]: 403, [Role.ENGINEER]: 403, }, @@ -598,15 +598,15 @@ describe('Organization Controllers (e2e)', () => { expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据'); }); - it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => { + it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'GET /b/organization/groups/:id role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 200, [Role.HOSPITAL_ADMIN]: 200, - [Role.DIRECTOR]: 403, - [Role.LEADER]: 403, + [Role.DIRECTOR]: 200, + [Role.LEADER]: 200, [Role.DOCTOR]: 403, [Role.ENGINEER]: 403, }, @@ -650,15 +650,15 @@ describe('Organization Controllers (e2e)', () => { expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据'); }); - it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => { + it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'PATCH /b/organization/groups/:id role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 404, [Role.HOSPITAL_ADMIN]: 404, - [Role.DIRECTOR]: 403, - [Role.LEADER]: 403, + [Role.DIRECTOR]: 404, + [Role.LEADER]: 404, [Role.DOCTOR]: 403, [Role.ENGINEER]: 403, }, @@ -702,14 +702,22 @@ describe('Organization Controllers (e2e)', () => { expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据'); }); - it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => { + it('失败:删除有成员的小组返回 409', async () => { + const response = await request(ctx.app.getHttpServer()) + .delete(`/b/organization/groups/${ctx.fixtures.groupA1Id}`) + .set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`); + + expectErrorEnvelope(response, 409, '小组下仍有成员,无法删除'); + }); + + it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可进入业务,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'DELETE /b/organization/groups/:id role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 404, [Role.HOSPITAL_ADMIN]: 404, - [Role.DIRECTOR]: 403, + [Role.DIRECTOR]: 404, [Role.LEADER]: 403, [Role.DOCTOR]: 403, [Role.ENGINEER]: 403, diff --git a/test/e2e/specs/patients.e2e-spec.ts b/test/e2e/specs/patients.e2e-spec.ts index 878928b..cd8eb8d 100644 --- a/test/e2e/specs/patients.e2e-spec.ts +++ b/test/e2e/specs/patients.e2e-spec.ts @@ -146,17 +146,18 @@ describe('Patients Controllers (e2e)', () => { }); describe('GET /c/patients/lifecycle', () => { - it('成功:可按 phone + idCardHash 查询跨院生命周期', async () => { + it('成功:已登录用户可按 phone + idCard 查询跨院生命周期', async () => { const response = await request(ctx.app.getHttpServer()) .get('/c/patients/lifecycle') .query({ phone: '13800002001', - idCardHash: 'seed-id-card-cross-hospital', - }); + idCard: '110101199001010011', + }) + .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`); expectSuccessEnvelope(response, 200); expect(response.body.data.phone).toBe('13800002001'); - expect(response.body.data.idCardHash).toBe('seed-id-card-cross-hospital'); + expect(response.body.data.idCard).toBe('110101199001010011'); expect(response.body.data.patientCount).toBeGreaterThanOrEqual(2); expect(Array.isArray(response.body.data.lifecycle)).toBe(true); }); @@ -166,9 +167,10 @@ describe('Patients Controllers (e2e)', () => { .get('/c/patients/lifecycle') .query({ phone: '13800002001', - }); + }) + .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`); - expectErrorEnvelope(response, 400, 'idCardHash 必须是字符串'); + expectErrorEnvelope(response, 400, 'idCard 必须是字符串'); }); it('失败:不存在患者返回 404', async () => { @@ -176,8 +178,9 @@ describe('Patients Controllers (e2e)', () => { .get('/c/patients/lifecycle') .query({ phone: '13800009999', - idCardHash: 'not-exists-idcard-hash', - }); + idCard: '110101199009090099', + }) + .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`); expectErrorEnvelope(response, 404, '未找到匹配的患者档案'); }); diff --git a/test/e2e/specs/tasks.e2e-spec.ts b/test/e2e/specs/tasks.e2e-spec.ts index cfff46c..9941314 100644 --- a/test/e2e/specs/tasks.e2e-spec.ts +++ b/test/e2e/specs/tasks.e2e-spec.ts @@ -74,15 +74,15 @@ describe('BTasksController (e2e)', () => { expectErrorEnvelope(response, 404, '存在设备不在当前医院或设备不存在'); }); - it('角色矩阵:仅 DOCTOR 可进入业务,其他角色 403,未登录 401', async () => { + it('角色矩阵:DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'POST /b/tasks/publish role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 403, [Role.HOSPITAL_ADMIN]: 403, - [Role.DIRECTOR]: 403, - [Role.LEADER]: 403, + [Role.DIRECTOR]: 400, + [Role.LEADER]: 400, [Role.DOCTOR]: 400, [Role.ENGINEER]: 403, }, @@ -298,15 +298,15 @@ describe('BTasksController (e2e)', () => { expectErrorEnvelope(cancelResponse, 409, '仅待接收/已接收任务可取消'); }); - it('角色矩阵:仅 DOCTOR 可进入业务,其他角色 403,未登录 401', async () => { + it('角色矩阵:DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'POST /b/tasks/cancel role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 403, [Role.HOSPITAL_ADMIN]: 403, - [Role.DIRECTOR]: 403, - [Role.LEADER]: 403, + [Role.DIRECTOR]: 404, + [Role.LEADER]: 404, [Role.DOCTOR]: 404, [Role.ENGINEER]: 403, }, diff --git a/test/e2e/specs/users.e2e-spec.ts b/test/e2e/specs/users.e2e-spec.ts index 9bfe25d..18dabd2 100644 --- a/test/e2e/specs/users.e2e-spec.ts +++ b/test/e2e/specs/users.e2e-spec.ts @@ -120,15 +120,15 @@ describe('UsersController + BUsersController (e2e)', () => { expectErrorEnvelope(response, 401, '缺少 Bearer Token'); }); - it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => { + it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403,未登录 401', async () => { await assertRoleMatrix({ name: 'GET /users role matrix', tokens: ctx.tokens, expectedStatusByRole: { [Role.SYSTEM_ADMIN]: 200, [Role.HOSPITAL_ADMIN]: 200, - [Role.DIRECTOR]: 403, - [Role.LEADER]: 403, + [Role.DIRECTOR]: 200, + [Role.LEADER]: 200, [Role.DOCTOR]: 403, [Role.ENGINEER]: 403, }, @@ -207,7 +207,11 @@ describe('UsersController + BUsersController (e2e)', () => { groupId: ctx.fixtures.groupA1Id, }); - expectErrorEnvelope(response, 400, '仅医生/主任/组长允许调整科室/小组归属'); + expectErrorEnvelope( + response, + 400, + '仅医生/主任/组长允许调整科室/小组归属', + ); }); it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => { diff --git a/tyt-admin/src/api/devices.js b/tyt-admin/src/api/devices.js new file mode 100644 index 0000000..a763c50 --- /dev/null +++ b/tyt-admin/src/api/devices.js @@ -0,0 +1,24 @@ +import request from './request'; + +/** + * 设备列表:后端已支持服务端分页与筛选。 + */ +export const getDevices = (params) => { + return request.get('/b/devices', { params }); +}; + +export const getDeviceById = (id) => { + return request.get(`/b/devices/${id}`); +}; + +export const createDevice = (data) => { + return request.post('/b/devices', data); +}; + +export const updateDevice = (id, data) => { + return request.patch(`/b/devices/${id}`, data); +}; + +export const deleteDevice = (id) => { + return request.delete(`/b/devices/${id}`); +}; diff --git a/tyt-admin/src/api/request.js b/tyt-admin/src/api/request.js index a75b8ec..f67490e 100644 --- a/tyt-admin/src/api/request.js +++ b/tyt-admin/src/api/request.js @@ -4,11 +4,12 @@ import { useUserStore } from '../store/user'; import router from '../router'; const service = axios.create({ - baseURL: import.meta.env.VITE_API_BASE_URL || '/api', // Use /api as default proxy prefix + // 开发环境默认走 Vite /api 代理,生产环境可用环境变量覆盖。 + baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 10000, }); -// Request Interceptor +// 请求拦截:统一挂载 Bearer Token,避免各页面重复拼接鉴权头。 service.interceptors.request.use( (config) => { const userStore = useUserStore(); @@ -19,19 +20,18 @@ service.interceptors.request.use( }, (error) => { return Promise.reject(error); - } + }, ); -// Response Interceptor +// 响应拦截:对齐后端统一响应包裹 { code, msg, data }。 service.interceptors.response.use( (response) => { const res = response.data; - // Backend standard format: { code: number, msg: string, data: any } - // Accept code 0 or 2xx as success + + // 后端成功响应统一为 code=0;这里兼容少量 code=2xx 的历史结构。 if (res.code === 0 || (res.code >= 200 && res.code < 300)) { return res.data; } else { - // If backend returns code !== 0/2xx but HTTP status is 200 ElMessage.error(res.msg || '请求失败'); return Promise.reject(new Error(res.msg || 'Error')); } @@ -39,28 +39,29 @@ service.interceptors.response.use( (error) => { const userStore = useUserStore(); let message = error.message; - + if (error.response) { const { status, data } = error.response; - // Backend error response format: { code: number, msg: string, data: null } message = data?.msg || message; - + if (status === 401) { - // Token expired or invalid + // 401 统一视为登录态失效,先清理本地态再跳登录页。 userStore.logout(); - router.push(`/login?redirect=${encodeURIComponent(router.currentRoute.value.fullPath)}`); + router.push( + `/login?redirect=${encodeURIComponent(router.currentRoute.value.fullPath)}`, + ); ElMessage.error(message || '登录状态已过期,请重新登录'); return Promise.reject(new Error('Unauthorized')); } else if (status === 403) { ElMessage.error(message || '没有权限执行该操作'); } else { - ElMessage.error(message || '请求失败'); + ElMessage.error(message || '请求失败'); } } else { ElMessage.error(message || '网络连接异常'); } return Promise.reject(error); - } + }, ); export default service; diff --git a/tyt-admin/src/constants/role-permissions.js b/tyt-admin/src/constants/role-permissions.js index d2a6cae..650febb 100644 --- a/tyt-admin/src/constants/role-permissions.js +++ b/tyt-admin/src/constants/role-permissions.js @@ -14,6 +14,8 @@ const PATIENT_ROLES = Object.freeze([ 'HOSPITAL_ADMIN', 'DIRECTOR', 'LEADER', + // 后端患者接口允许医生访问,页面侧也应放开,避免前端先把医生拦掉。 + 'DOCTOR', ]); export const ROLE_PERMISSIONS = Object.freeze({ @@ -22,6 +24,7 @@ export const ROLE_PERMISSIONS = Object.freeze({ ORG_DEPARTMENTS: ORG_MANAGER_ROLES, ORG_GROUPS: ORG_MANAGER_ROLES, USERS: ADMIN_ROLES, + DEVICES: ADMIN_ROLES, TASKS: TASK_ROLES, PATIENTS: PATIENT_ROLES, }); diff --git a/tyt-admin/src/layouts/AdminLayout.vue b/tyt-admin/src/layouts/AdminLayout.vue index 920748d..c3a3e00 100644 --- a/tyt-admin/src/layouts/AdminLayout.vue +++ b/tyt-admin/src/layouts/AdminLayout.vue @@ -14,7 +14,7 @@ 首页 - + @@ -47,6 +51,11 @@ 用户管理 + + + 设备管理 + + 任务管理 @@ -58,11 +67,11 @@ - +
- +
@@ -80,7 +89,7 @@
- + @@ -100,7 +109,17 @@ import { ROLE_PERMISSIONS, hasRolePermission, } from '../constants/role-permissions'; -import { DataLine, OfficeBuilding, User, List, Avatar, ArrowDown, Connection, Share } from '@element-plus/icons-vue'; +import { + DataLine, + OfficeBuilding, + User, + List, + Avatar, + ArrowDown, + Connection, + Share, + Monitor, +} from '@element-plus/icons-vue'; const route = useRoute(); const router = useRouter(); @@ -113,6 +132,9 @@ const activeMenu = computed(() => { const canAccessUsers = computed(() => hasRolePermission(userStore.role, ROLE_PERMISSIONS.USERS), ); +const canAccessDevices = computed(() => + hasRolePermission(userStore.role, ROLE_PERMISSIONS.DEVICES), +); const canAccessOrgTree = computed(() => hasRolePermission(userStore.role, ROLE_PERMISSIONS.ORG_TREE), ); @@ -174,7 +196,7 @@ const handleCommand = (command) => { /* fade-transform transition */ .fade-transform-leave-active, .fade-transform-enter-active { - transition: all .3s; + transition: all 0.3s; } .fade-transform-enter-from { opacity: 0; diff --git a/tyt-admin/src/router/index.js b/tyt-admin/src/router/index.js index e61f38b..e2af074 100644 --- a/tyt-admin/src/router/index.js +++ b/tyt-admin/src/router/index.js @@ -77,6 +77,16 @@ const routes = [ allowedRoles: ROLE_PERMISSIONS.USERS, }, }, + { + path: 'devices', + name: 'Devices', + component: () => import('../views/devices/Devices.vue'), + meta: { + title: '设备管理', + requiresAuth: true, + allowedRoles: ROLE_PERMISSIONS.DEVICES, + }, + }, { path: 'tasks', name: 'Tasks', @@ -96,7 +106,7 @@ const routes = [ requiresAuth: true, allowedRoles: ROLE_PERMISSIONS.PATIENTS, }, - } + }, ], }, { diff --git a/tyt-admin/src/views/devices/Devices.vue b/tyt-admin/src/views/devices/Devices.vue new file mode 100644 index 0000000..570c348 --- /dev/null +++ b/tyt-admin/src/views/devices/Devices.vue @@ -0,0 +1,531 @@ + + + + + diff --git a/tyt-admin/src/views/patients/Patients.vue b/tyt-admin/src/views/patients/Patients.vue index 5ff4e51..8a0a93a 100644 --- a/tyt-admin/src/views/patients/Patients.vue +++ b/tyt-admin/src/views/patients/Patients.vue @@ -59,7 +59,7 @@ - +