新增上传资产模型与迁移,支持 IMAGE、VIDEO、FILE 三类资产管理

新增 B 端上传接口与列表接口,统一文件上传和分页查询能力
上传能力支持医院级数据隔离:系统管理员需显式指定医院,院内角色按登录医院自动隔离
图片上传自动压缩并转为 webp,视频上传自动转码并压缩为 mp4,普通文件按原始类型存储
增加上传目录与公开访问能力,统一输出可直接预览的访问地址
前端新增影像库页面,支持按类型筛选、关键字检索、分页浏览、在线预览与原文件访问
前端新增通用上传组件,支持在页面内复用并返回上传结果
管理后台新增影像库菜单与路由,并补充页面级角色权限控制
患者手术相关表单接入上传复用能力,支持术前资料与设备标签上传回填
新增上传模块 e2e 用例,覆盖成功路径、权限矩阵与关键失败场景
补充上传模块文档与安装依赖说明,完善工程内使用说明
This commit is contained in:
EL 2026-03-20 04:35:43 +08:00
parent 73082225f6
commit 2bfe8ac8c8
117 changed files with 5252 additions and 1574 deletions

View File

@ -13,20 +13,20 @@
核心字段:
- `snCode`:设备唯一标识
- `patientId`:归属患者
- `surgeryId`:归属手术,可为空
- `implantCatalogId`:型号字典 ID可为空
- `implantModel` / `implantManufacturer` / `implantName`:历史快照
- `isPressureAdjustable`:是否可调压
- `isAbandoned`:是否弃用
- `currentPressure`:当前压力
- `currentPressure`:当前压力挡位标签
- `status`:设备状态
补充:
- `currentPressure` 不允许在创建/编辑设备实例时手工指定。
- 新植入设备默认以 `initialPressure`(或系统默认值)作为当前压力起点,后续只允许在调压任务完成时更新。
- 新植入设备默认以 `initialPressure`(或系统默认值 `0`)作为当前压力起点,后续只允许在调压任务完成时更新。
- 发布调压任务时不会立刻修改 `currentPressure`,只有任务完成后才会把目标挡位回写到设备。
## 3. 植入物目录
@ -35,7 +35,7 @@
- `modelCode`:型号编码,唯一
- `manufacturer`:厂商
- `name`:名称
- `pressureLevels`:可调压器械的挡位列表
- `pressureLevels`:可调压器械的挡位字符串标签列表
- `isPressureAdjustable`:是否可调压
- `notes`:目录备注
@ -45,6 +45,11 @@
- 仅 `SYSTEM_ADMIN` 可做目录 CRUD
- 目录是全局共享的,不按医院隔离
说明:
- 挡位列表按字符串标签保存,例如 `["0.5", "1", "1.5"]``["10", "20", "30"]`
- 保存前会自动标准化并去重排序,例如 `["01.0", "1.50", "1"]` 最终会整理为 `["1", "1.5"]`
## 4. 接口
设备实例:
@ -65,7 +70,6 @@
## 5. 约束
- 设备必须绑定到一个患者。
- 设备 SN 在全库唯一,服务端会统一转成大写后再校验。
- 删除已被任务明细引用的设备会返回 `409`
- 删除已被患者手术引用的植入物目录会返回 `409`
- 可调压植入物若配置了 `pressureLevels`,患者手术录入和任务调压时的压力值必须命中该挡位列表。

View File

@ -4,17 +4,17 @@
- 覆盖 `src/**/*controller.ts` 当前全部 30 个业务接口。
- 采用 `supertest + @nestjs/testing` 进行真实 HTTP E2E 测试。
- 测试前固定执行数据库重置与 seed,确保结果可重复。
- 测试前固定执行数据库重置,并通过真实接口全流程建数,确保结果可重复。
## 2. 风险提示
`pnpm test:e2e` 会执行:
1. `prisma migrate reset --force`
2. `node prisma/seed.mjs`
2. 启动 Jest 后,由测试用例通过真实 HTTP 接口完成基础夹具创建
这会清空 `.env``DATABASE_URL` 指向数据库的全部数据,请仅在测试库执行。
另外,seed 账号会刷新 `tokenValidAfter`,所以重置前签发的旧 token 会全部失效,需要重新登录获取新 token。
另外,接口引导创建的测试账号会刷新 `tokenValidAfter`,所以重置前签发的旧 token 会全部失效,需要重新登录获取新 token。
## 3. 运行命令
@ -22,7 +22,7 @@
pnpm test:e2e
```
仅重置数据库并注入 seed
仅重置数据库并重新生成 Prisma Client
```bash
pnpm test:e2e:prepare
@ -34,7 +34,7 @@ pnpm test:e2e:prepare
pnpm test:e2e:watch
```
## 4. 种子账号(默认密码:`Seed@1234`
## 4. 接口引导夹具(默认密码:`Seed@1234`
- 系统管理员:`13800001000`
- 院管(医院 A`13800001001`
@ -42,20 +42,37 @@ pnpm test:e2e:watch
- 组长(医院 A`13800001003`
- 医生(医院 A`13800001004`
- 工程师(医院 A`13800001005`
- 院管(医院 B`13800001011`
- 工程师(医院 B`13800001015`
说明:
- 这些账号不再由 `prisma/seed.mjs` 直写生成。
- 每次执行 E2E 时,会先创建系统管理员,再通过后台接口依次创建医院、院管、医生、工程师、目录、患者、手术和调压任务。
- 因为夹具是通过真实业务接口生成的,所以权限、作用域、删除保护和调压链路都能在同一套测试里被覆盖。
## 5. 用例结构
- `test/e2e/specs/auth.e2e-spec.ts`
- `test/e2e/specs/users.e2e-spec.ts`
- `test/e2e/specs/organization.e2e-spec.ts`
- `test/e2e/specs/dictionaries.e2e-spec.ts`
- `test/e2e/specs/devices.e2e-spec.ts`
- `test/e2e/specs/tasks.e2e-spec.ts`
- `test/e2e/specs/patients.e2e-spec.ts`
- `test/e2e/specs/auth-token-revocation.e2e-spec.ts`
## 6. 覆盖策略
- 受保护接口27 个):每个接口覆盖 6 角色访问结果 + 未登录 401。
- 非受保护接口3 个):每个接口至少 1 个成功 + 1 个失败。
- 关键行为额外覆盖:
- 从创建系统管理员开始的完整接口建数链路
- 任务状态机冲突409
- 调压任务发布后不改当前压力,完成任务后才回写设备当前压力
- 主刀医生自动跟随患者归属医生,且历史手术保留快照
- 患者 B 端角色可见性
- 患者创建人返回与展示
- 跨院工程师隔离
- 组织域院管作用域限制与删除冲突
- 目录、设备、组织、用户的删除保护

View File

@ -5,10 +5,11 @@
- 登录页:`/auth/login`,支持可选 `hospitalId`
- 首页看板:按角色拉取组织与患者统计。
- 设备页:新增管理员专用设备 CRUD复用真实设备接口。
- 任务页:接入 `publish/accept/complete/cancel` 四个真实任务接口。
- 任务页:改为只读调压记录页,接入真实任务列表接口。
- 用户页:修复用户列表响应结构、组织字段联动、工程师分配医院参数。
- 患者页:接入真实患者字段与生命周期查询参数(`phone + idCard`
后端直接保存身份证号原文,不再做哈希转换。
后端直接保存身份证号原文,不再做哈希转换;调压任务入口迁到患者页。
- 新增影像库页:接入真实上传接口,支持图片/视频/文件上传与分页查看。
## 2. 接口契约对齐点
@ -16,23 +17,27 @@
- `PATCH /b/users/:id/assign-engineer-hospital` 参数为单个 `hospitalId`,非数组。
- `GET /b/patients` 返回数组,前端已改为本地分页与筛选。
- `GET /b/devices` 已支持服务端分页与筛选,前端直接透传 `page/pageSize`
- `GET /b/tasks` 返回 `{ list, total, page, pageSize }`,供任务页只读展示调压记录。
- `POST /b/uploads` 使用 `multipart/form-data` 上传文件,返回上传资产元数据。
- `GET /b/uploads` 返回 `{ list, total, page, pageSize }`,仅供系统管理员/医院管理员影像库分页展示。
- `GET /b/tasks/engineers` 返回可选接收工程师列表,患者页发布调压任务前需要先拉取。
- `GET /c/patients/lifecycle` 必须同时传 `phone``idCard`
- 患者表单中的 `idCard` 字段直接传身份证号;
服务端只会做去空格与 `x/X` 标准化,不会转哈希。
- 任务模块暂无任务列表接口,前端改为“表单操作 + 最近结果”模式。
- 患者手术、调压任务、设备目录中的压力值全部按字符串挡位标签传输,例如 `0.5``1``1.5``10`
## 3. 角色权限提示
- 任务接口权限:
- `DOCTOR/DIRECTOR/LEADER`:发布、取消(仅可取消自己创建的任务)
- `ENGINEER`接收、完成
- `SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER`:发布时必须指定接收工程师;可取消自己创建的任务
- `ENGINEER`仅可完成分配给自己的任务
- 患者列表权限:
- `SYSTEM_ADMIN` 查询时必须传 `hospitalId`
- 用户管理接口:
- `SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR` 可访问列表与创建
- `DIRECTOR` 页面语义调整为“医生管理”,仅管理本科室医生
- `SYSTEM_ADMIN/HOSPITAL_ADMIN` 可创建、编辑、删除
- `DIRECTOR` 可只读查看本科室下级医生/组长
- 工程师绑定医院仅 `SYSTEM_ADMIN`
- 删除:`SYSTEM_ADMIN` 可删除任意无关联用户;`DIRECTOR` 可删除本科室无关联医生
- 删除:`SYSTEM_ADMIN` 可删除任意无关联用户;`HOSPITAL_ADMIN` 可删除本院无关联、且非管理员用户
## 3.1 结构图页面交互调整
@ -46,27 +51,29 @@
`SYSTEM_ADMIN``HOSPITAL_ADMIN``DIRECTOR``LEADER` 可访问
- `organization/departments`
`SYSTEM_ADMIN``HOSPITAL_ADMIN` 可访问
- `users``SYSTEM_ADMIN``HOSPITAL_ADMIN``DIRECTOR` 可访问
- `users``SYSTEM_ADMIN``HOSPITAL_ADMIN` 可管理;`DIRECTOR` 可只读查看
- `devices`:仅 `SYSTEM_ADMIN``HOSPITAL_ADMIN` 可访问
- `uploads`:仅 `SYSTEM_ADMIN``HOSPITAL_ADMIN` 可访问
- `organization/hospitals`
- 仅 `SYSTEM_ADMIN` 可访问
- `tasks`
- `DOCTOR``DIRECTOR``LEADER``ENGINEER` 可访问
- `SYSTEM_ADMIN``HOSPITAL_ADMIN``DOCTOR``DIRECTOR``LEADER``ENGINEER` 可访问
- `patients`
- `SYSTEM_ADMIN``HOSPITAL_ADMIN``DIRECTOR``LEADER``DOCTOR` 可访问
患者页负责发起调压任务并指定接收人,任务页仅查看调压记录,不再提供发布或接收入口。
患者手术表单中的主刀医生不再单独选择,直接跟随患者归属医生展示和保存。
前端已在路由守卫和侧边栏菜单同时做权限控制,无权限角色会被拦截并跳转到首页,避免进入页面后触发接口 `403`
## 3.3 主任/组长组织管理范围
- `DIRECTOR`
- 可查看组织架构、小组列表(限定本科室范围)
- 可创建/编辑/删除本科室下小组
- 可进入“医生管理”页,创建/维护本科室医生
- 仅保留业务使用和患者管理能力,不再承担科室/小组/医生管理
- `LEADER`
- 可查看组织架构、小组列表(限定本科室/本小组范围)
- 可编辑本小组名称
- 主任/组长不再显示独立“科室管理”页面。
- 仅保留业务使用和患者管理能力,不再承担科室/小组/医生管理
- 主任/组长不再显示“科室管理”“小组管理”“用户管理”页面。
- 负责人设置(设主任/设组长)入口仍仅 `SYSTEM_ADMIN``HOSPITAL_ADMIN` 显示。
## 4. 本地运行

View File

@ -29,7 +29,8 @@
- `surgeryDate`:手术日期
- `surgeryName`:手术名称
- `surgeonName`:主刀医生
- `surgeonId`:主刀医生账号 ID自动等于患者归属医生
- `surgeonName`:主刀医生姓名快照
- `preOpPressure`:术前测压,可为空
- `primaryDisease`:原发病
- `hydrocephalusTypes`:脑积水类型,多选
@ -43,6 +44,12 @@
- `activeDeviceCount`:本次手术仍在用设备数
- `abandonedDeviceCount`:本次手术已弃用设备数
说明:
- 新增/修改手术时,前端不再单独提交主刀医生。
- 后端会直接使用患者当前的 `doctorId` 作为 `surgeonId`,并保存当时的 `surgeonName` 快照。
- 如果后续患者归属医生发生变化,只会影响后续新建手术;历史手术仍保留创建当时的主刀医生快照。
## 4. 植入设备
设备表仍沿用 `Device`,但语义改为“患者手术下植入的设备实例”。
@ -57,7 +64,7 @@
- `proximalPunctureAreas`:近端穿刺区域,最多 2 个
- `valvePlacementSites`:阀门植入部位,最多 2 个
- `distalShuntDirection`:远端分流方向
- `initialPressure`:初始压力,可为空
- `initialPressure`:初始压力挡位标签,可为空
- `implantNotes`:植入物备注,可为空
- `labelImageUrl`:植入物标签图片地址,可为空
@ -68,8 +75,10 @@
- 旧设备弃用后,`TaskItem` 历史不会删除。
- 任务发布只允许选择 `ACTIVE + isPressureAdjustable=true + isAbandoned=false` 的设备。
- 同一患者一次手术可录入多个设备,因此支持“两个可调压仪器同时佩戴”。
- 如果植入物目录配置了挡位,`initialPressure` 必须取挡位中的值。
- 手术创建时不允许手工录入 `currentPressure`,设备当前压力默认继承 `initialPressure`,后续仅能由调压任务完成后更新。
- 如果植入物目录配置了挡位,`initialPressure` 必须取挡位中的字符串标签值,例如 `0.5 / 1 / 1.5 / 10 / 20 / 30`
- 挡位标签保存前会自动标准化,例如 `01.0 -> 1``1.50 -> 1.5`
- 手术创建时不允许手工录入 `currentPressure`,设备当前压力默认继承 `initialPressure`;发布调压任务时不会立刻改当前压力,只有工程师完成任务后才会更新。
- 术前资料与植入物标签现在支持直接上传;上传成功后,患者详情会按图片/视频做预览,不再只是裸链接。
## 5. B 端可见性
@ -93,6 +102,7 @@
- `PATCH /b/patients/:id` 不直接修改手术,手术必须走新增手术接口。
- 新增二次手术时可传 `abandonedDeviceIds`,后端会将这些旧设备标记为 `INACTIVE + isAbandoned=true`
- 患者建档时会自动记录 `creator`,列表和详情都会返回创建人信息。
## 7. C 端生命周期聚合
@ -111,6 +121,10 @@
- `SURGERY`
- `TASK_PRESSURE_ADJUSTMENT`
说明:
- 生命周期中的 `initialPressure / currentPressure / oldPressure / targetPressure` 均返回字符串挡位标签。
## 8. 响应结构
全部接口统一返回:

View File

@ -7,34 +7,47 @@
## 2. 状态机
- `PENDING -> ACCEPTED -> COMPLETED`
- `PENDING/ACCEPTED -> CANCELLED`
- 当前发布流程:`ACCEPTED -> COMPLETED`
- 当前取消流程:`ACCEPTED -> CANCELLED`
- 历史兼容:保留 `PENDING` 状态枚举,便于兼容旧数据与旧任务记录
非法流转会返回 `409` 冲突错误(中文消息)。
## 3. 角色权限
- 医生/主任/组长:发布任务、取消自己创建的任务
- 工程师:接收任务、完成自己接收的任务
- 系统管理员/医院管理员/医生/主任/组长:发布任务时必须直接指定接收工程师,并且只能取消自己创建的任务
- 工程师:不能再执行“接收”动作,只能完成指派给自己的任务
- 其他角色:默认拒绝
补充:
- `GET /b/tasks/engineers`:返回当前角色可选的接收工程师列表,系统管理员可按医院筛选。
- `GET /b/tasks`:返回当前角色可见的调压记录列表,系统管理员可按医院筛选。
- `POST /b/tasks/cancel` 现支持可选 `reason` 字段,便于前端保留取消原因输入。
- 当前取消原因仅透传到事件层,数据库暂未持久化该字段。
## 4. 事件触发
## 4. 记录列表
- 后台任务页不再承担手工发布入口,只展示调压记录。
- 记录维度按 `TaskItem` 展开,每条记录会携带:
- 任务状态
- 患者信息
- 手术名称
- 设备信息
- 旧压力 / 目标压力 / 当前压力(均为字符串挡位标签)
- 创建人 / 接收人 / 发布时间
## 5. 事件触发
状态变化后会发出事件:
- `task.published`
- `task.accepted`
- `task.completed`
- `task.cancelled`
用于后续接入微信通知或消息中心。
## 5. 完成任务时的设备同步
## 6. 完成任务时的设备同步
`completeTask` 在单事务中执行:
@ -43,3 +56,8 @@
3. 批量更新关联 `Device.currentPressure`
确保任务状态与设备压力一致性。
补充:
- `publishTask` 只负责生成任务和目标挡位,不会立刻修改设备当前压力。
- 只有工程师完成任务后,目标挡位才会回写到设备实例。

71
docs/uploads.md Normal file
View File

@ -0,0 +1,71 @@
# 上传资产模块说明(`src/uploads`
## 1. 目标
- 提供图片、视频、文件的统一上传入口。
- 为 B 端“影像库/视频库/文件库”页面提供分页查询。
- 为患者手术表单中的术前资料、植入物标签上传提供复用能力。
## 2. 数据模型
新增 `UploadAsset` 表,保存上传文件元数据:
- `hospitalId`:医院归属
- `creatorId`:上传人
- `type``IMAGE / VIDEO / FILE`
- `originalName`:原始文件名
- `fileName`:服务端生成文件名
- `storagePath`:相对存储路径
- `url`:公开访问地址,前端直接用于预览
- `mimeType`:文件 MIME 类型
- `fileSize`:文件大小(字节)
文件本体默认落盘到:
- 公开目录:`storage/uploads`
- 临时目录:`storage/tmp-uploads`
- 最终目录规则:`storage/uploads/YYYY/MM/DD`
- 最终文件名规则:`YYYYMMDDHHmmss-原文件名`
- 图片压缩后扩展名统一为 `.webp`
- 视频压缩后扩展名统一为 `.mp4`
- 如同一秒内出现同名文件,会自动追加 `-1``-2` 防止覆盖
## 3. 接口
- `POST /b/uploads`
- 角色:`SYSTEM_ADMIN / HOSPITAL_ADMIN / DIRECTOR / LEADER / DOCTOR`
- 表单字段:
- `file`:二进制文件
- `hospitalId`:仅 `SYSTEM_ADMIN` 上传时必填
- `GET /b/uploads`
- 角色:`SYSTEM_ADMIN / HOSPITAL_ADMIN`
- 查询参数:
- `keyword`
- `type`
- `hospitalId`:仅 `SYSTEM_ADMIN` 可选
- `page`
- `pageSize`
## 4. 使用说明
- 患者手术表单中的“术前 CT 影像/资料”支持直接上传,上传成功后自动回填 `type/name/url`
- 设备表单中的“植入物标签”支持直接上传图片,上传成功后自动回填 `labelImageUrl`
- 患者详情页会直接预览术前图片、视频和设备标签。
- 单独新增“影像库”页面,按图片/视频/文件分页查看所有上传资产。
- 页面访问权限仅 `SYSTEM_ADMIN / HOSPITAL_ADMIN`
## 5. 压缩策略
- 图片上传后会自动压缩并统一转成 `webp`
- 自动纠正旋转方向
- 最大边限制为 `2560`
- 返回的 `mimeType``image/webp`
- 视频上传后会自动压缩并统一转成 `mp4`
- 最大边限制为 `1280`
- 视频编码为 `H.264`
- 音频编码为 `AAC`
- 返回的 `mimeType``video/mp4`
- 普通文件类型不做转码,按原文件保存。
- 如果本地 `pnpm install` 屏蔽了依赖安装脚本,`ffmpeg-static` 二进制不会自动落盘,视频压缩会失败。
- 这种情况下手动执行:
- `node node_modules/.pnpm/ffmpeg-static@5.3.0/node_modules/ffmpeg-static/install.js`

View File

@ -18,8 +18,10 @@
- 医院内数据按 `hospitalId` 强隔离。
- 仅 `SYSTEM_ADMIN` 可执行工程师绑定医院。
- `DIRECTOR/LEADER` 可读取用户列表,但仅返回当前科室可见用户。
- `DIRECTOR` 可创建、查看、编辑、删除本科室医生,但不能跨科室操作,也不能把医生改成其他角色。
- `SYSTEM_ADMIN/HOSPITAL_ADMIN` 可执行用户创建、编辑、删除。
- `DIRECTOR` 仅可只读查看本科室下级医生/组长。
- `LEADER` 仅可只读查看本小组医生列表。
- `HOSPITAL_ADMIN` 仅可操作本院非管理员账号。
- 用户组织字段校验:
- 院管/医生/工程师等需有医院归属;
- 主任/组长需有科室/小组等必要归属;
@ -32,12 +34,22 @@
- `GET /users``GET /users/:id``PATCH /users/:id``DELETE /users/:id`
- `POST /b/users/:id/assign-engineer-hospital`
其中院管侧的常用链路为:
- `POST /users`:创建本院用户
- `GET /users/:id`:查看本院用户详情
- `PATCH /users/:id`:修改本院用户信息
- `DELETE /users/:id`:删除本院无关联、且非管理员用户
其中主任侧的常用链路为:
- `POST /users`:创建本科室医生
- `GET /users/:id`:查看本科室医生详情
- `PATCH /users/:id`:修改本科室医生信息
- `DELETE /users/:id`:删除无关联数据的本科室医生
- `GET /users`:查看本科室下级医生/组长
- `GET /users/:id`:查看本科室下级详情
其中组长侧的常用链路为:
- `GET /users`:查看本小组医生列表
- `GET /users/:id`:查看本小组医生详情
## 5. 开发改造建议

View File

@ -13,7 +13,7 @@
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"test:e2e:prepare": "pnpm prisma migrate reset --force && pnpm prisma generate && node prisma/seed.mjs",
"test:e2e:prepare": "pnpm prisma migrate reset --force && pnpm prisma generate",
"test:e2e": "pnpm test:e2e:prepare && NODE_OPTIONS=--experimental-vm-modules pnpm exec jest --config ./test/jest-e2e.config.cjs --runInBand",
"test:e2e:watch": "NODE_OPTIONS=--experimental-vm-modules pnpm exec jest --config ./test/jest-e2e.config.cjs --watch"
},
@ -30,10 +30,13 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"dotenv": "^17.3.1",
"ffmpeg-static": "^5.3.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.1.1",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sharp": "^0.34.5",
"swagger-ui-express": "^5.0.1"
},
"devDependencies": {
@ -44,6 +47,7 @@
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.1.0",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",
"globals": "^16.0.0",

388
pnpm-lock.yaml generated
View File

@ -44,9 +44,15 @@ importers:
dotenv:
specifier: ^17.3.1
version: 17.3.1
ffmpeg-static:
specifier: ^5.3.0
version: 5.3.0
jsonwebtoken:
specifier: ^9.0.3
version: 9.0.3
multer:
specifier: ^2.1.1
version: 2.1.1
pg:
specifier: ^8.20.0
version: 8.20.0
@ -56,6 +62,9 @@ importers:
rxjs:
specifier: ^7.8.1
version: 7.8.2
sharp:
specifier: ^0.34.5
version: 0.34.5
swagger-ui-express:
specifier: ^5.0.1
version: 5.0.1(express@5.2.1)
@ -81,6 +90,9 @@ importers:
'@types/jsonwebtoken':
specifier: ^9.0.10
version: 9.0.10
'@types/multer':
specifier: ^2.1.0
version: 2.1.0
'@types/node':
specifier: ^22.10.7
version: 22.19.15
@ -342,6 +354,10 @@ packages:
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@derhuerst/http-basic@8.2.4':
resolution: {integrity: sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==}
engines: {node: '>=6.0.0'}
'@electric-sql/pglite-socket@0.0.20':
resolution: {integrity: sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg==}
hasBin: true
@ -371,6 +387,159 @@ packages:
peerDependencies:
hono: ^4
'@img/colour@1.1.0':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
'@inquirer/ansi@1.0.2':
resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==}
engines: {node: '>=18'}
@ -932,6 +1101,12 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/multer@2.1.0':
resolution: {integrity: sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==}
'@types/node@10.17.60':
resolution: {integrity: sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==}
'@types/node@22.19.15':
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
@ -1147,6 +1322,10 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
agent-base@6.0.2:
resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==}
engines: {node: '>= 6.0.0'}
ajv-formats@2.1.1:
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
peerDependencies:
@ -1368,6 +1547,9 @@ packages:
caniuse-lite@1.0.30001778:
resolution: {integrity: sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==}
caseless@0.12.0:
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@ -1573,6 +1755,10 @@ packages:
destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
detect-newline@3.1.0:
resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==}
engines: {node: '>=8'}
@ -1633,6 +1819,10 @@ packages:
resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==}
engines: {node: '>=10.13.0'}
env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
error-ex@1.3.4:
resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==}
@ -1736,6 +1926,10 @@ packages:
fb-watchman@2.0.2:
resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==}
ffmpeg-static@5.3.0:
resolution: {integrity: sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==}
engines: {node: '>=16'}
file-type@21.3.0:
resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==}
engines: {node: '>=20'}
@ -1896,9 +2090,16 @@ packages:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
http-response-object@3.0.2:
resolution: {integrity: sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==}
http-status-codes@2.3.0:
resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==}
https-proxy-agent@5.0.1:
resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
engines: {node: '>= 6'}
human-signals@2.1.0:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
engines: {node: '>=10.17.0'}
@ -2465,6 +2666,9 @@ packages:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
parse-cache-control@1.0.1:
resolution: {integrity: sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==}
parse-json@5.2.0:
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
engines: {node: '>=8'}
@ -2639,6 +2843,10 @@ packages:
typescript:
optional: true
progress@2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}
proper-lockfile@4.1.2:
resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
@ -2778,6 +2986,10 @@ packages:
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@ -3491,6 +3703,13 @@ snapshots:
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@derhuerst/http-basic@8.2.4':
dependencies:
caseless: 0.12.0
concat-stream: 2.0.0
http-response-object: 3.0.2
parse-cache-control: 1.0.1
'@electric-sql/pglite-socket@0.0.20(@electric-sql/pglite@0.3.15)':
dependencies:
'@electric-sql/pglite': 0.3.15
@ -3521,6 +3740,102 @@ snapshots:
dependencies:
hono: 4.11.4
'@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.9.0
optional: true
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@inquirer/ansi@1.0.2': {}
'@inquirer/checkbox@4.3.2(@types/node@22.19.15)':
@ -4247,6 +4562,12 @@ snapshots:
'@types/ms@2.1.0': {}
'@types/multer@2.1.0':
dependencies:
'@types/express': 5.0.6
'@types/node@10.17.60': {}
'@types/node@22.19.15':
dependencies:
undici-types: 6.21.0
@ -4452,6 +4773,12 @@ snapshots:
acorn@8.16.0: {}
agent-base@6.0.2:
dependencies:
debug: 4.4.3
transitivePeerDependencies:
- supports-color
ajv-formats@2.1.1(ajv@8.18.0):
optionalDependencies:
ajv: 8.18.0
@ -4699,6 +5026,8 @@ snapshots:
caniuse-lite@1.0.30001778: {}
caseless@0.12.0: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@ -4864,6 +5193,8 @@ snapshots:
destr@2.0.5: {}
detect-libc@2.1.2: {}
detect-newline@3.1.0: {}
dezalgo@1.0.4:
@ -4913,6 +5244,8 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.0
env-paths@2.2.1: {}
error-ex@1.3.4:
dependencies:
is-arrayish: 0.2.1
@ -5035,6 +5368,15 @@ snapshots:
dependencies:
bser: 2.1.1
ffmpeg-static@5.3.0:
dependencies:
'@derhuerst/http-basic': 8.2.4
env-paths: 2.2.1
https-proxy-agent: 5.0.1
progress: 2.0.3
transitivePeerDependencies:
- supports-color
file-type@21.3.0:
dependencies:
'@tokenizer/inflate': 0.4.1
@ -5229,8 +5571,19 @@ snapshots:
statuses: 2.0.2
toidentifier: 1.0.1
http-response-object@3.0.2:
dependencies:
'@types/node': 10.17.60
http-status-codes@2.3.0: {}
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.4.3
transitivePeerDependencies:
- supports-color
human-signals@2.1.0: {}
iconv-lite@0.7.2:
@ -5926,6 +6279,8 @@ snapshots:
dependencies:
callsites: 3.1.0
parse-cache-control@1.0.1: {}
parse-json@5.2.0:
dependencies:
'@babel/code-frame': 7.29.0
@ -6076,6 +6431,8 @@ snapshots:
- react
- react-dom
progress@2.0.3: {}
proper-lockfile@4.1.2:
dependencies:
graceful-fs: 4.2.11
@ -6223,6 +6580,37 @@ snapshots:
setprototypeof@1.2.0: {}
sharp@0.34.5:
dependencies:
'@img/colour': 1.1.0
detect-libc: 2.1.2
semver: 7.7.4
optionalDependencies:
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0

3
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,3 @@
onlyBuiltDependencies:
- ffmpeg-static
- sharp

View File

@ -0,0 +1,19 @@
-- AlterTable
ALTER TABLE "Patient"
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "creatorId" INTEGER;
-- Backfill
UPDATE "Patient"
SET "creatorId" = "doctorId"
WHERE "creatorId" IS NULL;
-- AlterTable
ALTER TABLE "Patient"
ALTER COLUMN "creatorId" SET NOT NULL;
-- CreateIndex
CREATE INDEX "Patient_creatorId_idx" ON "Patient"("creatorId");
-- AddForeignKey
ALTER TABLE "Patient" ADD CONSTRAINT "Patient_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,5 @@
-- DropIndex
DROP INDEX IF EXISTS "Device_snCode_key";
-- AlterTable
ALTER TABLE "Device" DROP COLUMN "snCode";

View File

@ -0,0 +1,19 @@
-- AlterTable
ALTER TABLE "Device" ALTER COLUMN "currentPressure" SET DATA TYPE TEXT,
ALTER COLUMN "initialPressure" SET DATA TYPE TEXT;
-- AlterTable
ALTER TABLE "ImplantCatalog" ALTER COLUMN "pressureLevels" SET DATA TYPE TEXT[];
-- AlterTable
ALTER TABLE "PatientSurgery" ADD COLUMN "surgeonId" INTEGER;
-- AlterTable
ALTER TABLE "TaskItem" ALTER COLUMN "oldPressure" SET DATA TYPE TEXT,
ALTER COLUMN "targetPressure" SET DATA TYPE TEXT;
-- CreateIndex
CREATE INDEX "PatientSurgery_surgeonId_idx" ON "PatientSurgery"("surgeonId");
-- AddForeignKey
ALTER TABLE "PatientSurgery" ADD CONSTRAINT "PatientSurgery_surgeonId_fkey" FOREIGN KEY ("surgeonId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,34 @@
-- CreateEnum
CREATE TYPE "UploadAssetType" AS ENUM ('IMAGE', 'VIDEO', 'FILE');
-- CreateTable
CREATE TABLE "UploadAsset" (
"id" SERIAL NOT NULL,
"hospitalId" INTEGER NOT NULL,
"creatorId" INTEGER NOT NULL,
"type" "UploadAssetType" NOT NULL,
"originalName" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"storagePath" TEXT NOT NULL,
"url" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"fileSize" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UploadAsset_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "UploadAsset_storagePath_key" ON "UploadAsset"("storagePath");
-- CreateIndex
CREATE INDEX "UploadAsset_hospitalId_type_createdAt_idx" ON "UploadAsset"("hospitalId", "type", "createdAt");
-- CreateIndex
CREATE INDEX "UploadAsset_creatorId_createdAt_idx" ON "UploadAsset"("creatorId", "createdAt");
-- AddForeignKey
ALTER TABLE "UploadAsset" ADD CONSTRAINT "UploadAsset_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UploadAsset" ADD CONSTRAINT "UploadAsset_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -46,6 +46,13 @@ enum DictionaryType {
DISTAL_SHUNT_DIRECTION
}
// 上传资产类型:用于图库/视频库分类。
enum UploadAssetType {
IMAGE
VIDEO
FILE
}
// 医院主表:多租户顶层实体。
model Hospital {
id Int @id @default(autoincrement())
@ -54,6 +61,7 @@ model Hospital {
users User[]
patients Patient[]
tasks Task[]
uploads UploadAsset[]
}
// 科室表:归属于医院。
@ -98,8 +106,11 @@ model User {
// 小组删除必须先清理成员,避免静默把用户 groupId 置空。
group Group? @relation(fields: [groupId], references: [id], onDelete: Restrict)
doctorPatients Patient[] @relation("DoctorPatients")
createdPatients Patient[] @relation("PatientCreator")
createdTasks Task[] @relation("TaskCreator")
acceptedTasks Task[] @relation("TaskEngineer")
surgeonSurgeries PatientSurgery[] @relation("SurgerySurgeon")
createdUploads UploadAsset[] @relation("UploadCreator")
@@unique([phone, role, hospitalId])
@@index([phone])
@ -112,6 +123,7 @@ model User {
model Patient {
id Int @id @default(autoincrement())
name String
createdAt DateTime @default(now())
// 住院号:用于院内患者检索与病案关联。
inpatientNo String?
// 项目名称:用于区分患者所属项目/课题。
@ -121,14 +133,17 @@ model Patient {
idCard String
hospitalId Int
doctorId Int
creatorId Int
hospital Hospital @relation(fields: [hospitalId], references: [id])
doctor User @relation("DoctorPatients", fields: [doctorId], references: [id])
creator User @relation("PatientCreator", fields: [creatorId], references: [id])
surgeries PatientSurgery[]
devices Device[]
@@index([phone, idCard])
@@index([hospitalId, doctorId])
@@index([inpatientNo])
@@index([creatorId])
}
// 患者手术表:保存每次分流/复手术档案。
@ -137,6 +152,7 @@ model PatientSurgery {
patientId Int
surgeryDate DateTime
surgeryName String
surgeonId Int?
surgeonName String
// 术前测压:部分患者可为空。
preOpPressure Int?
@ -151,9 +167,11 @@ model PatientSurgery {
notes String?
createdAt DateTime @default(now())
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
surgeon User? @relation("SurgerySurgeon", fields: [surgeonId], references: [id], onDelete: SetNull)
devices Device[]
@@index([patientId, surgeryDate])
@@index([surgeonId])
}
// 植入物型号字典:供前端单选型号后自动回填厂家与名称。
@ -163,7 +181,7 @@ model ImplantCatalog {
manufacturer String
name String
// 可调压器械的可选挡位,由系统管理员维护。
pressureLevels Int[] @default([])
pressureLevels String[] @default([])
isPressureAdjustable Boolean @default(true)
notes String?
createdAt DateTime @default(now())
@ -185,11 +203,30 @@ model DictionaryItem {
@@index([type, enabled, sortOrder])
}
// 上传资产表:保存图片/视频/文件元数据,供图库与患者表单复用。
model UploadAsset {
id Int @id @default(autoincrement())
hospitalId Int
creatorId Int
type UploadAssetType
originalName String
fileName String
storagePath String @unique
url String
mimeType String
fileSize Int
createdAt DateTime @default(now())
hospital Hospital @relation(fields: [hospitalId], references: [id])
creator User @relation("UploadCreator", fields: [creatorId], references: [id])
@@index([hospitalId, type, createdAt])
@@index([creatorId, createdAt])
}
// 设备表:每次手术植入的设备实例,保留当前压力与历史调压记录。
model Device {
id Int @id @default(autoincrement())
snCode String @unique
currentPressure Int
currentPressure String
status DeviceStatus @default(ACTIVE)
patientId Int
surgeryId Int?
@ -205,7 +242,7 @@ model Device {
proximalPunctureAreas String[] @default([])
valvePlacementSites String[] @default([])
distalShuntDirection String?
initialPressure Int?
initialPressure String?
implantNotes String?
labelImageUrl String?
patient Patient @relation(fields: [patientId], references: [id])
@ -240,8 +277,8 @@ model TaskItem {
id Int @id @default(autoincrement())
taskId Int
deviceId Int
oldPressure Int
targetPressure Int
oldPressure String
targetPressure String
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
device Device @relation(fields: [deviceId], references: [id])

View File

@ -64,6 +64,7 @@ async function upsertUserByOpenId(openId, data) {
async function ensurePatient({
hospitalId,
doctorId,
creatorId,
name,
inpatientNo = null,
projectName = null,
@ -81,13 +82,14 @@ async function ensurePatient({
if (existing) {
if (
existing.doctorId !== doctorId ||
existing.creatorId !== creatorId ||
existing.name !== name ||
existing.inpatientNo !== inpatientNo ||
existing.projectName !== projectName
) {
return prisma.patient.update({
where: { id: existing.id },
data: { doctorId, name, inpatientNo, projectName },
data: { doctorId, creatorId, name, inpatientNo, projectName },
});
}
return existing;
@ -97,6 +99,7 @@ async function ensurePatient({
data: {
hospitalId,
doctorId,
creatorId,
name,
inpatientNo,
projectName,
@ -216,6 +219,63 @@ async function ensurePatientSurgery({
});
}
async function ensureDevice({
patientId,
surgeryId,
implantCatalogId,
currentPressure,
status,
implantModel,
implantManufacturer,
implantName,
isPressureAdjustable,
isAbandoned,
shuntMode,
proximalPunctureAreas,
valvePlacementSites,
distalShuntDirection,
initialPressure,
implantNotes,
labelImageUrl,
}) {
const existing = await prisma.device.findFirst({
where: {
patientId,
surgeryId,
implantNotes,
},
});
const data = {
patientId,
surgeryId,
implantCatalogId,
currentPressure,
status,
implantModel,
implantManufacturer,
implantName,
isPressureAdjustable,
isAbandoned,
shuntMode,
proximalPunctureAreas,
valvePlacementSites,
distalShuntDirection,
initialPressure,
implantNotes,
labelImageUrl,
};
if (existing) {
return prisma.device.update({
where: { id: existing.id },
data,
});
}
return prisma.device.create({ data });
}
async function main() {
const seedPasswordHash = await hash(SEED_PASSWORD_PLAIN, 12);
@ -395,6 +455,7 @@ async function main() {
const patientA1 = await ensurePatient({
hospitalId: hospitalA.id,
doctorId: doctorA.id,
creatorId: doctorA.id,
name: 'Seed Patient A1',
inpatientNo: 'ZYH-A-0001',
projectName: '脑积水随访项目-A',
@ -405,6 +466,7 @@ async function main() {
const patientA2 = await ensurePatient({
hospitalId: hospitalA.id,
doctorId: doctorA2.id,
creatorId: doctorA2.id,
name: 'Seed Patient A2',
inpatientNo: 'ZYH-A-0002',
projectName: '脑积水随访项目-A',
@ -415,6 +477,7 @@ async function main() {
const patientA3 = await ensurePatient({
hospitalId: hospitalA.id,
doctorId: doctorA3.id,
creatorId: doctorA3.id,
name: 'Seed Patient A3',
inpatientNo: 'ZYH-A-0003',
projectName: '脑积水随访项目-A',
@ -425,6 +488,7 @@ async function main() {
const patientB1 = await ensurePatient({
hospitalId: hospitalB.id,
doctorId: doctorB.id,
creatorId: doctorB.id,
name: 'Seed Patient B1',
inpatientNo: 'ZYH-B-0001',
projectName: '脑积水随访项目-B',
@ -510,9 +574,7 @@ async function main() {
hydrocephalusTypes: ['高压性'],
});
const deviceA1 = await prisma.device.upsert({
where: { snCode: 'SEED-SN-A-001' },
update: {
const deviceA1 = await ensureDevice({
patientId: patientA1.id,
surgeryId: surgeryA1New.id,
implantCatalogId: adjustableCatalog.id,
@ -530,32 +592,9 @@ async function main() {
initialPressure: 118,
implantNotes: 'Seed A1 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/a1-001.jpg',
},
create: {
snCode: 'SEED-SN-A-001',
patientId: patientA1.id,
surgeryId: surgeryA1New.id,
implantCatalogId: adjustableCatalog.id,
currentPressure: 118,
status: DeviceStatus.ACTIVE,
implantModel: adjustableCatalog.modelCode,
implantManufacturer: adjustableCatalog.manufacturer,
implantName: adjustableCatalog.name,
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
isAbandoned: false,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: 118,
implantNotes: 'Seed A1 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/a1-001.jpg',
},
});
const deviceA2 = await prisma.device.upsert({
where: { snCode: 'SEED-SN-A-002' },
update: {
const deviceA2 = await ensureDevice({
patientId: patientA2.id,
surgeryId: surgeryA2.id,
implantCatalogId: adjustableCatalog.id,
@ -573,32 +612,9 @@ async function main() {
initialPressure: 112,
implantNotes: 'Seed A2 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg',
},
create: {
snCode: 'SEED-SN-A-002',
patientId: patientA2.id,
surgeryId: surgeryA2.id,
implantCatalogId: adjustableCatalog.id,
currentPressure: 112,
status: DeviceStatus.ACTIVE,
implantModel: adjustableCatalog.modelCode,
implantManufacturer: adjustableCatalog.manufacturer,
implantName: adjustableCatalog.name,
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
isAbandoned: false,
shuntMode: 'VPS',
proximalPunctureAreas: ['枕角'],
valvePlacementSites: ['胸前'],
distalShuntDirection: '腹腔',
initialPressure: 112,
implantNotes: 'Seed A2 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg',
},
});
await prisma.device.upsert({
where: { snCode: 'SEED-SN-A-003' },
update: {
await ensureDevice({
patientId: patientA3.id,
surgeryId: surgeryA3.id,
implantCatalogId: adjustableCatalog.id,
@ -616,32 +632,9 @@ async function main() {
initialPressure: 109,
implantNotes: 'Seed A3 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/a3-003.jpg',
},
create: {
snCode: 'SEED-SN-A-003',
patientId: patientA3.id,
surgeryId: surgeryA3.id,
implantCatalogId: adjustableCatalog.id,
currentPressure: 109,
status: DeviceStatus.ACTIVE,
implantModel: adjustableCatalog.modelCode,
implantManufacturer: adjustableCatalog.manufacturer,
implantName: adjustableCatalog.name,
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
isAbandoned: false,
shuntMode: 'LPS',
proximalPunctureAreas: ['腰穿'],
valvePlacementSites: ['腰背部'],
distalShuntDirection: '腹腔',
initialPressure: 109,
implantNotes: 'Seed A3 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/a3-003.jpg',
},
});
const deviceB1 = await prisma.device.upsert({
where: { snCode: 'SEED-SN-B-001' },
update: {
const deviceB1 = await ensureDevice({
patientId: patientB1.id,
surgeryId: surgeryB1.id,
implantCatalogId: adjustableCatalog.id,
@ -659,32 +652,9 @@ async function main() {
initialPressure: 121,
implantNotes: 'Seed B1 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/b1-001.jpg',
},
create: {
snCode: 'SEED-SN-B-001',
patientId: patientB1.id,
surgeryId: surgeryB1.id,
implantCatalogId: adjustableCatalog.id,
currentPressure: 121,
status: DeviceStatus.ACTIVE,
implantModel: adjustableCatalog.modelCode,
implantManufacturer: adjustableCatalog.manufacturer,
implantName: adjustableCatalog.name,
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
isAbandoned: false,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: 121,
implantNotes: 'Seed B1 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/b1-001.jpg',
},
});
await prisma.device.upsert({
where: { snCode: 'SEED-SN-A-004' },
update: {
await ensureDevice({
patientId: patientA1.id,
surgeryId: surgeryA1Old.id,
implantCatalogId: adjustableCatalog.id,
@ -702,27 +672,6 @@ async function main() {
initialPressure: 130,
implantNotes: 'Seed A1 弃用历史设备',
labelImageUrl: 'https://seed.example.com/labels/a1-004.jpg',
},
create: {
snCode: 'SEED-SN-A-004',
patientId: patientA1.id,
surgeryId: surgeryA1Old.id,
implantCatalogId: adjustableCatalog.id,
currentPressure: 130,
status: DeviceStatus.INACTIVE,
implantModel: adjustableCatalog.modelCode,
implantManufacturer: adjustableCatalog.manufacturer,
implantName: adjustableCatalog.name,
isPressureAdjustable: adjustableCatalog.isPressureAdjustable,
isAbandoned: true,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: 130,
implantNotes: 'Seed A1 弃用历史设备',
labelImageUrl: 'https://seed.example.com/labels/a1-004.jpg',
},
});
// 清理与种子设备关联的历史任务,保证 seed 可重复执行且生命周期夹具稳定。

View File

@ -9,6 +9,7 @@ import { OrganizationModule } from './organization/organization.module.js';
import { NotificationsModule } from './notifications/notifications.module.js';
import { DevicesModule } from './devices/devices.module.js';
import { DictionariesModule } from './dictionaries/dictionaries.module.js';
import { UploadsModule } from './uploads/uploads.module.js';
@Module({
imports: [
@ -22,6 +23,7 @@ import { DictionariesModule } from './dictionaries/dictionaries.module.js';
NotificationsModule,
DevicesModule,
DictionariesModule,
UploadsModule,
],
})
export class AppModule {}

View File

@ -58,19 +58,22 @@ export const MESSAGES = {
'检测到多个同手机号账号,请传 hospitalId 指定登录医院',
CREATE_FORBIDDEN: '当前角色无权限创建该用户',
HOSPITAL_ADMIN_SCOPE_FORBIDDEN: '医院管理员仅可操作本院非管理员账号',
DIRECTOR_SCOPE_FORBIDDEN: '科室主任仅可操作本科室医生账号',
DIRECTOR_SCOPE_FORBIDDEN: '科室主任仅可操作本科室医生或组长账号',
},
TASK: {
ITEMS_REQUIRED: '任务明细 items 不能为空',
DEVICE_NOT_FOUND: '存在设备不在当前医院或设备不存在',
DEVICE_MULTI_HOSPITAL: '同一批调压任务中的设备必须属于同一家医院',
ENGINEER_REQUIRED: '接收工程师必选',
ENGINEER_INVALID: '工程师必须为当前医院有效工程师',
TASK_NOT_FOUND: '任务不存在或不属于当前医院',
ACCEPT_ONLY_PENDING: '仅待接收任务可执行接收',
COMPLETE_ONLY_ACCEPTED: '仅已接收任务可执行完成',
CANCEL_ONLY_PENDING_ACCEPTED: '仅待接收/已接收任务可取消',
ENGINEER_ALREADY_ASSIGNED: '任务已被其他工程师接收',
ENGINEER_ONLY_ASSIGNEE: '仅任务接收工程师可完成任务',
ACCEPT_DISABLED: '当前流程不支持工程师接收,请由创建人直接指定接收工程师',
ACCEPT_ONLY_PENDING: '仅待指派任务可执行接收',
COMPLETE_ONLY_ACCEPTED: '仅已指派任务可执行完成',
CANCEL_ONLY_PENDING_ACCEPTED: '仅待指派/已指派任务可取消',
ENGINEER_ALREADY_ASSIGNED: '任务已指派给其他工程师',
ENGINEER_ONLY_ASSIGNEE: '仅任务接收人可完成任务',
CANCEL_ONLY_CREATOR: '仅任务创建者可取消任务',
ACTOR_ROLE_FORBIDDEN: '当前角色无权限执行该任务操作',
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
@ -99,9 +102,7 @@ export const MESSAGES = {
DEVICE: {
NOT_FOUND: '设备不存在或无权限访问',
SN_CODE_REQUIRED: 'snCode 不能为空',
SN_CODE_DUPLICATE: '设备 SN 已存在',
CURRENT_PRESSURE_INVALID: 'currentPressure 必须为大于等于 0 的整数',
CURRENT_PRESSURE_INVALID: 'currentPressure 必须是合法挡位标签',
STATUS_INVALID: '设备状态不合法',
PATIENT_REQUIRED: 'patientId 必填且必须为整数',
PATIENT_NOT_FOUND: '归属患者不存在',
@ -123,6 +124,17 @@ export const MESSAGES = {
SYSTEM_ADMIN_ONLY_MAINTAIN: '仅系统管理员可维护字典',
},
UPLOAD: {
FILE_REQUIRED: '请先选择要上传的文件',
UNSUPPORTED_FILE_TYPE: '仅支持图片、视频、PDF/Office 文档上传',
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息,无法上传文件',
SYSTEM_ADMIN_HOSPITAL_REQUIRED:
'系统管理员上传文件时必须显式指定 hospitalId',
INVALID_IMAGE_FILE: '上传的图片无法解析或压缩失败',
INVALID_VIDEO_FILE: '上传的视频无法解析或压缩失败',
FFMPEG_NOT_AVAILABLE: '服务端缺少视频压缩能力',
},
ORG: {
HOSPITAL_NOT_FOUND: '医院不存在',
DEPARTMENT_NOT_FOUND: '科室不存在',

View File

@ -0,0 +1,56 @@
import { BadRequestException } from '@nestjs/common';
/**
*
*/
export function normalizePressureLabel(value: unknown, fieldName: string) {
const raw =
typeof value === 'string' || typeof value === 'number'
? String(value).trim()
: '';
if (!raw) {
throw new BadRequestException(`${fieldName} 不能为空`);
}
if (!/^\d+(\.\d+)?$/.test(raw)) {
throw new BadRequestException(`${fieldName} 必须是合法挡位标签`);
}
const [integerPart, fractionPart = ''] = raw.split('.');
const normalizedInteger = integerPart.replace(/^0+(?=\d)/, '') || '0';
const normalizedFraction = fractionPart.replace(/0+$/, '');
return normalizedFraction
? `${normalizedInteger}.${normalizedFraction}`
: normalizedInteger;
}
/**
*
*/
export function comparePressureLabel(left: string, right: string) {
const leftNumber = Number(left);
const rightNumber = Number(right);
if (leftNumber !== rightNumber) {
return leftNumber - rightNumber;
}
return left.localeCompare(right, 'en');
}
/**
*
*/
export function normalizePressureLabelList(
values: unknown[] | undefined,
fieldName: string,
) {
if (!Array.isArray(values) || values.length === 0) {
return [];
}
return Array.from(
new Set(values.map((value) => normalizePressureLabel(value, fieldName))),
).sort(comparePressureLabel);
}

View File

@ -55,7 +55,13 @@ export class DepartmentsController {
*
*/
@Get()
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '查询科室列表' })
@ApiQuery({ name: 'hospitalId', required: false, description: '医院 ID' })
findAll(
@ -69,7 +75,13 @@ export class DepartmentsController {
*
*/
@Get(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '查询科室详情' })
@ApiParam({ name: 'id', description: '科室 ID' })
findOne(
@ -83,7 +95,7 @@ export class DepartmentsController {
*
*/
@Patch(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '更新科室' })
update(
@CurrentActor() actor: ActorContext,

View File

@ -48,7 +48,7 @@ export class DepartmentsService {
}
/**
* /
*
*/
async findAll(actor: ActorContext, query: OrganizationQueryDto) {
this.access.assertRole(actor, [
@ -56,6 +56,7 @@ export class DepartmentsService {
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
]);
const paging = this.access.resolvePaging(query);
const where: Prisma.DepartmentWhereInput = {};
@ -66,7 +67,11 @@ export class DepartmentsService {
if (actor.role === Role.HOSPITAL_ADMIN) {
where.hospitalId = this.access.requireActorHospitalId(actor);
} else if (actor.role === Role.DIRECTOR || actor.role === Role.LEADER) {
} else if (
actor.role === Role.DIRECTOR ||
actor.role === Role.LEADER ||
actor.role === Role.DOCTOR
) {
where.id = this.access.requireActorDepartmentId(actor);
} else if (query.hospitalId != null) {
where.hospitalId = this.access.toInt(
@ -93,7 +98,7 @@ export class DepartmentsService {
}
/**
* /
*
*/
async findOne(actor: ActorContext, id: number) {
this.access.assertRole(actor, [
@ -101,6 +106,7 @@ export class DepartmentsService {
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
]);
const departmentId = this.access.toInt(
id,
@ -118,18 +124,21 @@ export class DepartmentsService {
}
if (actor.role === Role.HOSPITAL_ADMIN) {
this.access.assertHospitalScope(actor, department.hospitalId);
}
if (actor.role === Role.DIRECTOR || actor.role === Role.LEADER) {
} else if (
actor.role === Role.DIRECTOR ||
actor.role === Role.LEADER ||
actor.role === Role.DOCTOR
) {
const actorDepartmentId = this.access.requireActorDepartmentId(actor);
if (department.id !== actorDepartmentId) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
throw new ForbiddenException(MESSAGES.ORG.HOSPITAL_ADMIN_SCOPE_INVALID);
}
}
return department;
}
/**
* /
*
*/
async update(actor: ActorContext, id: number, dto: UpdateDepartmentDto) {
const current = await this.findOne(actor, id);

View File

@ -9,6 +9,10 @@ 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 {
normalizePressureLabelList,
normalizePressureLabel,
} from '../common/pressure-level.util.js';
import { PrismaService } from '../prisma.service.js';
import { CreateImplantCatalogDto } from './dto/create-implant-catalog.dto.js';
import { CreateDeviceDto } from './dto/create-device.dto.js';
@ -133,15 +137,12 @@ export class DevicesService {
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,
// 当前压力只允许由调压任务流转维护,手工创建设备时先置 0。
currentPressure: 0,
currentPressure: '0',
status: dto.status ?? DeviceStatus.ACTIVE,
patientId: patient.id,
},
@ -150,17 +151,12 @@ export class DevicesService {
}
/**
* 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.status !== undefined) {
data.status = this.normalizeStatus(dto.status);
}
@ -366,12 +362,6 @@ export class DevicesService {
if (keyword) {
andConditions.push({
OR: [
{
snCode: {
contains: keyword,
mode: 'insensitive',
},
},
{
implantModel: {
contains: keyword,
@ -590,13 +580,6 @@ export class DevicesService {
return this.normalizeRequiredString(value, 'modelCode').toUpperCase();
}
/**
* SN
*/
private normalizeSnCode(value: unknown) {
return this.normalizeRequiredString(value, 'snCode').toUpperCase();
}
private normalizeRequiredString(value: unknown, fieldName: string) {
if (typeof value !== 'string') {
throw new BadRequestException(`${fieldName} 必须是字符串`);
@ -622,41 +605,25 @@ export class DevicesService {
*
*/
private normalizePressureLevels(
pressureLevels: number[] | undefined,
pressureLevels: unknown[] | undefined,
isPressureAdjustable: boolean,
) {
if (!isPressureAdjustable) {
return [];
}
if (!Array.isArray(pressureLevels) || pressureLevels.length === 0) {
return [];
}
return Array.from(
new Set(
pressureLevels.map((level) => {
const normalized = Number(level);
if (!Number.isInteger(normalized) || normalized < 0) {
throw new BadRequestException(
'pressureLevels 必须为大于等于 0 的整数数组',
);
}
return normalized;
}),
),
).sort((left, right) => left - right);
return normalizePressureLabelList(pressureLevels, 'pressureLevels');
}
/**
*
*
*/
private normalizePressure(value: unknown) {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed < 0) {
try {
return normalizePressureLabel(value, 'currentPressure');
} catch {
throw new BadRequestException(MESSAGES.DEVICE.CURRENT_PRESSURE_INVALID);
}
return parsed;
}
/**
@ -693,18 +660,4 @@ export class DevicesService {
}
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);
}
}
}

View File

@ -1,16 +1,12 @@
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';
import { IsEnum, IsInt, IsOptional, Min } from 'class-validator';
/**
* DTO
*/
export class CreateDeviceDto {
@ApiProperty({ description: '设备 SN', example: 'TYT-SN-10001' })
@IsString({ message: 'snCode 必须是字符串' })
snCode!: string;
@ApiPropertyOptional({
description: '设备状态,默认 ACTIVE',
enum: DeviceStatus,

View File

@ -1,14 +1,11 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsInt,
IsOptional,
IsString,
MaxLength,
Min,
} from 'class-validator';
import { ToBoolean } from '../../common/transforms/to-boolean.transform.js';
@ -38,17 +35,15 @@ export class CreateImplantCatalogDto {
name!: string;
@ApiPropertyOptional({
description: '可调压器械的挡位列表,按整数录入',
type: [Number],
example: [80, 100, 120, 140],
description: '可调压器械的挡位列表,按字符串挡位标签录入',
type: [String],
example: ['0.5', '1', '1.5'],
})
@IsOptional()
@IsArray({ message: 'pressureLevels 必须是数组' })
@ArrayMaxSize(30, { message: 'pressureLevels 最多 30 项' })
@Type(() => Number)
@IsInt({ each: true, message: 'pressureLevels 必须为整数数组' })
@Min(0, { each: true, message: 'pressureLevels 必须大于等于 0' })
pressureLevels?: number[];
@IsString({ each: true, message: 'pressureLevels 必须为字符串数组' })
pressureLevels?: string[];
@ApiPropertyOptional({
description: '是否支持调压,默认 true',

View File

@ -9,8 +9,9 @@ import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
*/
export class DeviceQueryDto {
@ApiPropertyOptional({
description: '关键词(支持设备 SN / 患者姓名 / 患者手机号)',
example: 'SN-A',
description:
'关键词(支持植入物型号 / 植入物名称 / 患者姓名 / 患者手机号)',
example: '脑室',
})
@IsOptional()
@IsString({ message: 'keyword 必须是字符串' })

View File

@ -41,7 +41,7 @@ export class GroupsController {
*
*/
@Post()
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '创建小组' })
create(@CurrentActor() actor: ActorContext, @Body() dto: CreateGroupDto) {
return this.groupsService.create(actor, dto);
@ -51,7 +51,13 @@ export class GroupsController {
*
*/
@Get()
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '查询小组列表' })
findAll(
@CurrentActor() actor: ActorContext,
@ -64,7 +70,13 @@ export class GroupsController {
*
*/
@Get(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '查询小组详情' })
@ApiParam({ name: 'id', description: '小组 ID' })
findOne(
@ -78,7 +90,7 @@ export class GroupsController {
*
*/
@Patch(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '更新小组' })
update(
@CurrentActor() actor: ActorContext,
@ -92,7 +104,7 @@ export class GroupsController {
*
*/
@Delete(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '删除小组' })
remove(
@CurrentActor() actor: ActorContext,

View File

@ -26,14 +26,10 @@ export class GroupsService {
) {}
/**
*
*
*/
async create(actor: ActorContext, dto: CreateGroupDto) {
this.access.assertRole(actor, [
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
]);
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
const departmentId = this.access.toInt(
dto.departmentId,
MESSAGES.ORG.DEPARTMENT_ID_REQUIRED,
@ -42,12 +38,6 @@ export class GroupsService {
if (actor.role === Role.HOSPITAL_ADMIN) {
this.access.assertHospitalScope(actor, department.hospitalId);
}
if (actor.role === Role.DIRECTOR) {
const actorDepartmentId = this.access.requireActorDepartmentId(actor);
if (actorDepartmentId !== department.id) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
}
return this.prisma.group.create({
data: {
@ -61,7 +51,7 @@ export class GroupsService {
}
/**
*
*
*/
async findAll(actor: ActorContext, query: OrganizationQueryDto) {
this.access.assertRole(actor, [
@ -69,6 +59,7 @@ export class GroupsService {
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
]);
const paging = this.access.resolvePaging(query);
const where: Prisma.GroupWhereInput = {};
@ -87,10 +78,12 @@ export class GroupsService {
where.department = {
hospitalId: this.access.requireActorHospitalId(actor),
};
} else if (actor.role === Role.DIRECTOR) {
} else if (
actor.role === Role.DIRECTOR ||
actor.role === Role.LEADER ||
actor.role === Role.DOCTOR
) {
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(
@ -118,7 +111,7 @@ export class GroupsService {
}
/**
*
*
*/
async findOne(actor: ActorContext, id: number) {
this.access.assertRole(actor, [
@ -126,6 +119,7 @@ export class GroupsService {
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
]);
const groupId = this.access.toInt(id, MESSAGES.ORG.GROUP_ID_REQUIRED);
const group = await this.prisma.group.findUnique({
@ -140,24 +134,21 @@ export class GroupsService {
}
if (actor.role === Role.HOSPITAL_ADMIN) {
this.access.assertHospitalScope(actor, group.department.hospital.id);
}
if (actor.role === Role.DIRECTOR) {
} else if (
actor.role === Role.DIRECTOR ||
actor.role === Role.LEADER ||
actor.role === Role.DOCTOR
) {
const actorDepartmentId = this.access.requireActorDepartmentId(actor);
if (group.department.id !== actorDepartmentId) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
}
if (actor.role === Role.LEADER) {
const actorGroupId = this.access.requireActorGroupId(actor);
if (group.id !== actorGroupId) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
throw new ForbiddenException(MESSAGES.ORG.HOSPITAL_ADMIN_SCOPE_INVALID);
}
}
return group;
}
/**
*
*
*/
async update(actor: ActorContext, id: number, dto: UpdateGroupDto) {
const current = await this.findOne(actor, id);
@ -181,14 +172,10 @@ export class GroupsService {
}
/**
*
*
*/
async remove(actor: ActorContext, id: number) {
this.access.assertRole(actor, [
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
]);
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
const current = await this.findOne(actor, id);
// 业务层先拦截,给前端稳定中文提示;数据库层仍保留 RESTRICT 兜底。

View File

@ -1,16 +1,23 @@
import 'dotenv/config';
import { mkdirSync } from 'node:fs';
import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module.js';
import { HttpExceptionFilter } from './common/http-exception.filter.js';
import { MESSAGES } from './common/messages.js';
import { ResponseEnvelopeInterceptor } from './common/response-envelope.interceptor.js';
import { resolveUploadRootDir } from './uploads/upload-path.util.js';
async function bootstrap() {
// 创建应用实例并加载核心模块。
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.enableCors();
mkdirSync(resolveUploadRootDir(), { recursive: true });
app.useStaticAssets(resolveUploadRootDir(), {
prefix: '/uploads/',
});
// 全局校验:清理未知字段、自动类型转换,并将校验错误统一为中文信息。
app.useGlobalPipes(
new ValidationPipe({

View File

@ -1,4 +1,3 @@
import { randomUUID } from 'node:crypto';
import {
BadRequestException,
ConflictException,
@ -11,6 +10,7 @@ import { DeviceStatus, Role } from '../../generated/prisma/enums.js';
import { PrismaService } from '../../prisma.service.js';
import type { ActorContext } from '../../common/actor-context.js';
import { MESSAGES } from '../../common/messages.js';
import { normalizePressureLabel } from '../../common/pressure-level.util.js';
import { CreatePatientDto } from '../dto/create-patient.dto.js';
import { CreatePatientSurgeryDto } from '../dto/create-patient-surgery.dto.js';
import { UpdatePatientDto } from '../dto/update-patient.dto.js';
@ -33,10 +33,10 @@ const IMPLANT_CATALOG_SELECT = {
const PATIENT_LIST_INCLUDE = {
hospital: { select: { id: true, name: true } },
doctor: { select: { id: true, name: true, role: true } },
creator: { select: { id: true, name: true, role: true } },
devices: {
select: {
id: true,
snCode: true,
status: true,
currentPressure: true,
isAbandoned: true,
@ -52,6 +52,7 @@ const PATIENT_LIST_INCLUDE = {
id: true,
surgeryDate: true,
surgeryName: true,
surgeonId: true,
surgeonName: true,
},
orderBy: { surgeryDate: 'desc' },
@ -76,6 +77,13 @@ const PATIENT_DETAIL_INCLUDE = {
groupId: true,
},
},
creator: {
select: {
id: true,
name: true,
role: true,
},
},
devices: {
include: {
implantCatalog: {
@ -101,6 +109,13 @@ const PATIENT_DETAIL_INCLUDE = {
},
orderBy: { id: 'desc' },
},
surgeon: {
select: {
id: true,
name: true,
role: true,
},
},
},
orderBy: { surgeryDate: 'desc' },
},
@ -212,6 +227,7 @@ export class BPatientsService {
const patient = await tx.patient.create({
data: {
name: this.normalizeRequiredString(dto.name, 'name'),
creatorId: actor.id,
inpatientNo:
dto.inpatientNo === undefined
? undefined
@ -231,7 +247,9 @@ export class BPatientsService {
if (dto.initialSurgery) {
await this.createPatientSurgeryRecord(
tx,
actor,
patient.id,
patient.doctorId,
dto.initialSurgery,
);
}
@ -255,7 +273,9 @@ export class BPatientsService {
return this.prisma.$transaction(async (tx) => {
const createdSurgery = await this.createPatientSurgeryRecord(
tx,
actor,
patient.id,
patient.doctorId,
dto,
);
@ -440,6 +460,7 @@ export class BPatientsService {
where: { id: normalizedDoctorId },
select: {
id: true,
name: true,
role: true,
hospitalId: true,
departmentId: true,
@ -553,7 +574,9 @@ export class BPatientsService {
*/
private async createPatientSurgeryRecord(
prisma: PrismaExecutor,
actor: ActorContext,
patientId: number,
patientDoctorId: number,
dto: CreatePatientSurgeryDto,
) {
if (!Array.isArray(dto.devices) || dto.devices.length === 0) {
@ -571,13 +594,14 @@ export class BPatientsService {
new Set(dto.abandonedDeviceIds ?? []),
);
const [catalogMap, latestSurgery] = await Promise.all([
const [catalogMap, latestSurgery, surgeon] = await Promise.all([
this.resolveImplantCatalogMap(prisma, catalogIds),
prisma.patientSurgery.findFirst({
where: { patientId },
orderBy: { surgeryDate: 'desc' },
select: { surgeryDate: true },
}),
this.resolveWritableDoctor(actor, patientDoctorId),
]);
if (abandonedDeviceIds.length > 0) {
@ -596,7 +620,7 @@ export class BPatientsService {
}
}
const deviceDrafts = dto.devices.map((device, index) => {
const deviceDrafts = dto.devices.map((device) => {
const catalog = catalogMap.get(device.implantCatalogId);
if (!catalog) {
throw new NotFoundException(MESSAGES.PATIENT.IMPLANT_CATALOG_NOT_FOUND);
@ -607,7 +631,7 @@ export class BPatientsService {
? null
: this.assertPressureLevelAllowed(
catalog,
this.normalizeNonNegativeInteger(
this.normalizePressureLevel(
device.initialPressure,
'initialPressure',
),
@ -615,18 +639,17 @@ export class BPatientsService {
const fallbackPressureLevel =
catalog.isPressureAdjustable && catalog.pressureLevels.length > 0
? catalog.pressureLevels[0]
: 0;
: '0';
const currentPressure = catalog.isPressureAdjustable
? this.assertPressureLevelAllowed(
catalog,
initialPressure ?? fallbackPressureLevel,
)
: 0;
: '0';
return {
patient: { connect: { id: patientId } },
implantCatalog: { connect: { id: catalog.id } },
snCode: this.resolveDeviceSnCode(device.snCode, patientId, index),
currentPressure,
status: DeviceStatus.ACTIVE,
implantModel: catalog.modelCode,
@ -662,11 +685,6 @@ export class BPatientsService {
};
});
await this.assertSnCodesUnique(
prisma,
deviceDrafts.map((device) => device.snCode),
);
const surgery = await prisma.patientSurgery.create({
data: {
patientId,
@ -675,10 +693,8 @@ export class BPatientsService {
dto.surgeryName,
'surgeryName',
),
surgeonName: this.normalizeRequiredString(
dto.surgeonName,
'surgeonName',
),
surgeonId: surgeon.id,
surgeonName: surgeon.name,
preOpPressure:
dto.preOpPressure == null
? null
@ -765,9 +781,9 @@ export class BPatientsService {
private assertPressureLevelAllowed(
catalog: {
isPressureAdjustable: boolean;
pressureLevels: number[];
pressureLevels: string[];
},
pressure: number,
pressure: string,
) {
if (
catalog.isPressureAdjustable &&
@ -898,6 +914,10 @@ export class BPatientsService {
return parsed;
}
private normalizePressureLevel(value: unknown, fieldName: string) {
return normalizePressureLabel(value, fieldName);
}
private normalizeStringArray(value: unknown, fieldName: string) {
if (!Array.isArray(value) || value.length === 0) {
throw new BadRequestException(`${fieldName} 必须为非空数组`);
@ -927,39 +947,6 @@ export class BPatientsService {
})) as Prisma.InputJsonArray;
}
private resolveDeviceSnCode(
snCode: string | undefined,
patientId: number,
index: number,
) {
if (snCode) {
return this.normalizeRequiredString(snCode, 'snCode').toUpperCase();
}
return `SURG-${patientId}-${Date.now()}-${index + 1}-${randomUUID()
.slice(0, 8)
.toUpperCase()}`;
}
private async assertSnCodesUnique(prisma: PrismaExecutor, snCodes: string[]) {
const uniqueSnCodes = Array.from(new Set(snCodes));
if (uniqueSnCodes.length !== snCodes.length) {
throw new ConflictException(MESSAGES.DEVICE.SN_CODE_DUPLICATE);
}
const existing = await prisma.device.findMany({
where: {
snCode: { in: uniqueSnCodes },
},
select: { id: true },
take: 1,
});
if (existing.length > 0) {
throw new ConflictException(MESSAGES.DEVICE.SN_CODE_DUPLICATE);
}
}
private toInt(value: unknown, fieldName: string) {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {

View File

@ -110,11 +110,10 @@ export class CPatientsService {
},
devices: surgery.devices.map((device) => ({
id: this.toJsonNumber(device.id),
snCode: device.snCode,
status: device.status,
isAbandoned: device.isAbandoned,
currentPressure: this.toJsonNumber(device.currentPressure),
initialPressure: this.toJsonNumber(device.initialPressure),
currentPressure: device.currentPressure,
initialPressure: device.initialPressure,
implantModel: device.implantModel,
implantManufacturer: device.implantManufacturer,
implantName: device.implantName,
@ -149,10 +148,9 @@ export class CPatientsService {
},
device: {
id: this.toJsonNumber(device.id),
snCode: device.snCode,
status: device.status,
isAbandoned: device.isAbandoned,
currentPressure: this.toJsonNumber(device.currentPressure),
currentPressure: device.currentPressure,
implantModel: device.implantModel,
implantManufacturer: device.implantManufacturer,
implantName: device.implantName,
@ -172,8 +170,8 @@ export class CPatientsService {
},
taskItem: {
id: this.toJsonNumber(taskItem.id),
oldPressure: this.toJsonNumber(taskItem.oldPressure),
targetPressure: this.toJsonNumber(taskItem.targetPressure),
oldPressure: taskItem.oldPressure,
targetPressure: taskItem.targetPressure,
},
},
];

View File

@ -32,13 +32,6 @@ export class CreatePatientSurgeryDto {
@IsString({ message: 'surgeryName 必须是字符串' })
surgeryName!: string;
@ApiProperty({
description: '主刀医生',
example: '张主任',
})
@IsString({ message: 'surgeonName 必须是字符串' })
surgeonName!: string;
@ApiPropertyOptional({
description: '术前测压,可为空',
example: 22,

View File

@ -23,14 +23,6 @@ export class CreateSurgeryDeviceDto {
@Min(1, { message: 'implantCatalogId 必须大于 0' })
implantCatalogId!: number;
@ApiPropertyOptional({
description: '设备 SN可不传不传时系统自动生成',
example: 'TYT-SHUNT-001',
})
@IsOptional()
@IsString({ message: 'snCode 必须是字符串' })
snCode?: string;
@ApiProperty({
description: '分流方式',
example: 'VPS',
@ -68,14 +60,12 @@ export class CreateSurgeryDeviceDto {
distalShuntDirection!: string;
@ApiPropertyOptional({
description: '初始压力,可为空',
example: 120,
description: '初始压力挡位,可为空',
example: '1.5',
})
@IsOptional()
@Type(() => Number)
@IsInt({ message: 'initialPressure 必须是整数' })
@Min(0, { message: 'initialPressure 必须大于等于 0' })
initialPressure?: number;
@IsString({ message: 'initialPressure 必须是字符串' })
initialPressure?: string;
@ApiPropertyOptional({
description: '植入物备注',

View File

@ -1,5 +1,10 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import type { ActorContext } from '../../common/actor-context.js';
import { CurrentActor } from '../../auth/current-actor.decorator.js';
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
@ -11,6 +16,8 @@ import { PublishTaskDto } from '../dto/publish-task.dto.js';
import { AcceptTaskDto } from '../dto/accept-task.dto.js';
import { CompleteTaskDto } from '../dto/complete-task.dto.js';
import { CancelTaskDto } from '../dto/cancel-task.dto.js';
import { TaskRecordQueryDto } from '../dto/task-record-query.dto.js';
import { AssignableEngineerQueryDto } from '../dto/assignable-engineer-query.dto.js';
/**
* B
@ -23,21 +30,78 @@ export class BTasksController {
constructor(private readonly taskService: TaskService) {}
/**
* //
*
*/
@Get('engineers')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DOCTOR,
Role.DIRECTOR,
Role.LEADER,
)
@ApiOperation({ summary: '查询可选接收工程师列表' })
@ApiQuery({
name: 'hospitalId',
required: false,
description: '系统管理员可按医院筛选',
})
findAssignableEngineers(
@CurrentActor() actor: ActorContext,
@Query() query: AssignableEngineerQueryDto,
) {
return this.taskService.findAssignableEngineers(actor, query.hospitalId);
}
/**
*
*/
@Get()
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DOCTOR,
Role.DIRECTOR,
Role.LEADER,
Role.ENGINEER,
)
@ApiOperation({ summary: '查询调压记录列表' })
@ApiQuery({
name: 'hospitalId',
required: false,
description: '系统管理员可按医院筛选',
})
findRecords(
@CurrentActor() actor: ActorContext,
@Query() query: TaskRecordQueryDto,
) {
return this.taskService.findTaskRecords(actor, query);
}
/**
* ////
*/
@Post('publish')
@Roles(Role.DOCTOR, Role.DIRECTOR, Role.LEADER)
@ApiOperation({ summary: '发布任务DOCTOR/DIRECTOR/LEADER' })
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DOCTOR,
Role.DIRECTOR,
Role.LEADER,
)
@ApiOperation({
summary: '发布任务SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER',
})
publish(@CurrentActor() actor: ActorContext, @Body() dto: PublishTaskDto) {
return this.taskService.publishTask(actor, dto);
}
/**
*
*
*/
@Post('accept')
@Roles(Role.ENGINEER)
@ApiOperation({ summary: '接收任务ENGINEER' })
@ApiOperation({ summary: '接收任务(已停用' })
accept(@CurrentActor() actor: ActorContext, @Body() dto: AcceptTaskDto) {
return this.taskService.acceptTask(actor, dto);
}
@ -53,11 +117,19 @@ export class BTasksController {
}
/**
* //
* ////
*/
@Post('cancel')
@Roles(Role.DOCTOR, Role.DIRECTOR, Role.LEADER)
@ApiOperation({ summary: '取消任务DOCTOR/DIRECTOR/LEADER' })
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DOCTOR,
Role.DIRECTOR,
Role.LEADER,
)
@ApiOperation({
summary: '取消任务SYSTEM_ADMIN/HOSPITAL_ADMIN/DOCTOR/DIRECTOR/LEADER',
})
cancel(@CurrentActor() actor: ActorContext, @Body() dto: CancelTaskDto) {
return this.taskService.cancelTask(actor, dto);
}

View File

@ -0,0 +1,20 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Min } from 'class-validator';
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
/**
* DTO
*/
export class AssignableEngineerQueryDto {
@ApiPropertyOptional({
description: '医院 ID仅系统管理员可选传',
example: 1,
})
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'hospitalId 必须是整数' })
@Min(1, { message: 'hospitalId 必须大于 0' })
hospitalId?: number;
}

View File

@ -1,11 +1,10 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
import {
ArrayMinSize,
IsArray,
IsInt,
IsOptional,
IsString,
Min,
ValidateNested,
} from 'class-validator';
@ -20,23 +19,20 @@ export class PublishTaskItemDto {
@Min(1, { message: 'deviceId 必须大于 0' })
deviceId!: number;
@ApiProperty({ description: '目标压力值', example: 120 })
@Type(() => Number)
@IsInt({ message: 'targetPressure 必须是整数' })
targetPressure!: number;
@ApiProperty({ description: '目标挡位标签', example: '1.5' })
@IsString({ message: 'targetPressure 必须是字符串' })
targetPressure!: string;
}
/**
* DTO
*/
export class PublishTaskDto {
@ApiPropertyOptional({ description: '指定工程师 ID可选', example: 2 })
@IsOptional()
@EmptyStringToUndefined()
@ApiProperty({ description: '接收工程师 ID', example: 2 })
@Type(() => Number)
@IsInt({ message: 'engineerId 必须是整数' })
@Min(1, { message: 'engineerId 必须大于 0' })
engineerId?: number;
engineerId!: number;
@ApiProperty({ type: [PublishTaskItemDto], description: '任务明细列表' })
@IsArray({ message: 'items 必须是数组' })

View File

@ -0,0 +1,63 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
import { TaskStatus } from '../../generated/prisma/enums.js';
/**
* DTO
*/
export class TaskRecordQueryDto {
@ApiPropertyOptional({
description: '关键词(支持患者姓名/住院号/手机号/植入物名称/植入物型号)',
example: '张三',
})
@IsOptional()
@IsString({ message: 'keyword 必须是字符串' })
keyword?: string;
@ApiPropertyOptional({
description: '任务状态',
enum: TaskStatus,
example: TaskStatus.PENDING,
})
@IsOptional()
@IsEnum(TaskStatus, { message: 'status 枚举值不合法' })
status?: TaskStatus;
@ApiPropertyOptional({
description: '医院 ID仅系统管理员可选传',
example: 1,
})
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'hospitalId 必须是整数' })
@Min(1, { message: 'hospitalId 必须大于 0' })
hospitalId?: 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;
}

View File

@ -6,6 +6,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Prisma } from '../generated/prisma/client.js';
import { DeviceStatus, Role, TaskStatus } from '../generated/prisma/enums.js';
import { PrismaService } from '../prisma.service.js';
import type { ActorContext } from '../common/actor-context.js';
@ -13,7 +14,9 @@ import { PublishTaskDto } from './dto/publish-task.dto.js';
import { AcceptTaskDto } from './dto/accept-task.dto.js';
import { CompleteTaskDto } from './dto/complete-task.dto.js';
import { CancelTaskDto } from './dto/cancel-task.dto.js';
import { TaskRecordQueryDto } from './dto/task-record-query.dto.js';
import { MESSAGES } from '../common/messages.js';
import { normalizePressureLabel } from '../common/pressure-level.util.js';
/**
*
@ -26,11 +29,162 @@ export class TaskService {
) {}
/**
* // PENDING
*
*/
async findAssignableEngineers(
actor: ActorContext,
requestedHospitalId?: number,
) {
this.assertRole(actor, [
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DOCTOR,
Role.DIRECTOR,
Role.LEADER,
]);
const hospitalId = this.resolveAssignableHospitalId(
actor,
requestedHospitalId,
);
return this.prisma.user.findMany({
where: {
role: Role.ENGINEER,
hospitalId,
},
select: {
id: true,
name: true,
phone: true,
hospitalId: true,
},
orderBy: { id: 'desc' },
});
}
/**
*
*/
async findTaskRecords(actor: ActorContext, query: TaskRecordQueryDto) {
this.assertRole(actor, [
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DOCTOR,
Role.DIRECTOR,
Role.LEADER,
Role.ENGINEER,
]);
const hospitalId = this.resolveListHospitalId(actor, query.hospitalId);
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const skip = (page - 1) * pageSize;
const where = this.buildTaskRecordWhere(query, hospitalId);
const [total, items] = await Promise.all([
this.prisma.taskItem.count({ where }),
this.prisma.taskItem.findMany({
where,
skip,
take: pageSize,
orderBy: { id: 'desc' },
select: {
id: true,
oldPressure: true,
targetPressure: true,
task: {
select: {
id: true,
status: true,
createdAt: true,
hospital: {
select: {
id: true,
name: true,
},
},
creator: {
select: {
id: true,
name: true,
role: true,
},
},
engineer: {
select: {
id: true,
name: true,
role: true,
},
},
},
},
device: {
select: {
id: true,
currentPressure: true,
implantModel: true,
implantManufacturer: true,
implantName: true,
patient: {
select: {
id: true,
name: true,
inpatientNo: true,
phone: true,
},
},
surgery: {
select: {
id: true,
surgeryName: true,
surgeryDate: true,
},
},
},
},
},
}),
]);
return {
list: items.map((item) => ({
id: item.id,
oldPressure: item.oldPressure,
targetPressure: item.targetPressure,
currentPressure: item.device.currentPressure,
taskId: item.task.id,
status: item.task.status,
createdAt: item.task.createdAt,
hospital: item.task.hospital,
creator: item.task.creator,
engineer: item.task.engineer,
patient: item.device.patient,
surgery: item.device.surgery,
device: {
id: item.device.id,
implantModel: item.device.implantModel,
implantManufacturer: item.device.implantManufacturer,
implantName: item.device.implantName,
},
})),
total,
page,
pageSize,
};
}
/**
*
*/
async publishTask(actor: ActorContext, dto: PublishTaskDto) {
this.assertRole(actor, [Role.DOCTOR, Role.DIRECTOR, Role.LEADER]);
const hospitalId = this.requireHospitalId(actor);
this.assertRole(actor, [
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DOCTOR,
Role.DIRECTOR,
Role.LEADER,
]);
if (!Array.isArray(dto.items) || dto.items.length === 0) {
throw new BadRequestException(MESSAGES.TASK.ITEMS_REQUIRED);
@ -42,27 +196,32 @@ export class TaskService {
if (!Number.isInteger(item.deviceId)) {
throw new BadRequestException(`deviceId 非法: ${item.deviceId}`);
}
if (!Number.isInteger(item.targetPressure)) {
throw new BadRequestException(
`targetPressure 非法: ${item.targetPressure}`,
);
}
return item.deviceId;
}),
),
);
const scopedHospitalId = this.resolveScopedHospitalId(actor);
const devices = await this.prisma.device.findMany({
where: {
id: { in: deviceIds },
status: DeviceStatus.ACTIVE,
isAbandoned: false,
isPressureAdjustable: true,
patient: { hospitalId },
patient: scopedHospitalId
? {
hospitalId: scopedHospitalId,
}
: undefined,
},
select: {
id: true,
currentPressure: true,
patient: {
select: {
hospitalId: true,
},
},
implantCatalog: {
select: {
pressureLevels: true,
@ -75,7 +234,11 @@ export class TaskService {
throw new NotFoundException(MESSAGES.TASK.DEVICE_NOT_FOUND);
}
if (dto.engineerId != null) {
const hospitalId = this.resolveTaskHospitalId(
actor,
devices.map((device) => device.patient.hospitalId),
);
const engineer = await this.prisma.user.findFirst({
where: {
id: dto.engineerId,
@ -87,7 +250,6 @@ export class TaskService {
if (!engineer) {
throw new BadRequestException(MESSAGES.TASK.ENGINEER_INVALID);
}
}
const pressureByDeviceId = new Map(
devices.map((device) => [device.id, device.currentPressure] as const),
@ -102,25 +264,30 @@ export class TaskService {
);
dto.items.forEach((item) => {
const normalizedTargetPressure = this.normalizeTargetPressure(
item.targetPressure,
);
const pressureLevels = pressureLevelsByDeviceId.get(item.deviceId) ?? [];
if (
pressureLevels.length > 0 &&
!pressureLevels.includes(item.targetPressure)
!pressureLevels.includes(normalizedTargetPressure)
) {
throw new BadRequestException(MESSAGES.DEVICE.PRESSURE_LEVEL_INVALID);
}
item.targetPressure = normalizedTargetPressure;
});
const task = await this.prisma.task.create({
data: {
status: TaskStatus.PENDING,
status: TaskStatus.ACCEPTED,
creatorId: actor.id,
engineerId: dto.engineerId ?? null,
engineerId: engineer.id,
hospitalId,
items: {
create: dto.items.map((item) => ({
deviceId: item.deviceId,
oldPressure: pressureByDeviceId.get(item.deviceId) ?? 0,
oldPressure: pressureByDeviceId.get(item.deviceId) ?? '0',
targetPressure: item.targetPressure,
})),
},
@ -139,68 +306,10 @@ export class TaskService {
}
/**
* PENDING ACCEPTED
*
*/
async acceptTask(actor: ActorContext, dto: AcceptTaskDto) {
this.assertRole(actor, [Role.ENGINEER]);
const hospitalId = this.requireHospitalId(actor);
const task = await this.prisma.task.findFirst({
where: {
id: dto.taskId,
hospitalId,
},
select: {
id: true,
status: true,
hospitalId: true,
engineerId: true,
},
});
if (!task) {
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
}
if (task.status !== TaskStatus.PENDING) {
throw new ConflictException(MESSAGES.TASK.ACCEPT_ONLY_PENDING);
}
if (task.engineerId != null && task.engineerId !== actor.id) {
throw new ForbiddenException(MESSAGES.TASK.ENGINEER_ALREADY_ASSIGNED);
}
const accepted = await this.prisma.task.updateMany({
where: {
id: task.id,
hospitalId,
status: TaskStatus.PENDING,
OR: [{ engineerId: null }, { engineerId: actor.id }],
},
data: {
status: TaskStatus.ACCEPTED,
engineerId: actor.id,
},
});
if (accepted.count !== 1) {
throw new ConflictException(MESSAGES.TASK.ACCEPT_ONLY_PENDING);
}
const updatedTask = await this.prisma.task.findUnique({
where: { id: task.id },
include: { items: true },
});
if (!updatedTask) {
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
}
await this.eventEmitter.emitAsync('task.accepted', {
taskId: updatedTask.id,
hospitalId: updatedTask.hospitalId,
actorId: actor.id,
status: updatedTask.status,
});
return updatedTask;
async acceptTask(_actor: ActorContext, _dto: AcceptTaskDto) {
throw new ForbiddenException(MESSAGES.TASK.ACCEPT_DISABLED);
}
/**
@ -263,13 +372,19 @@ export class TaskService {
* PENDING/ACCEPTED
*/
async cancelTask(actor: ActorContext, dto: CancelTaskDto) {
this.assertRole(actor, [Role.DOCTOR, Role.DIRECTOR, Role.LEADER]);
const hospitalId = this.requireHospitalId(actor);
this.assertRole(actor, [
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DOCTOR,
Role.DIRECTOR,
Role.LEADER,
]);
const scopedHospitalId = this.resolveScopedHospitalId(actor);
const task = await this.prisma.task.findFirst({
where: {
id: dto.taskId,
hospitalId,
hospitalId: scopedHospitalId ?? undefined,
},
select: {
id: true,
@ -320,7 +435,154 @@ export class TaskService {
}
/**
* hospitalIdB
*
*/
private resolveScopedHospitalId(actor: ActorContext): number | null {
if (actor.role === Role.SYSTEM_ADMIN) {
return actor.hospitalId ?? null;
}
if (!actor.hospitalId) {
throw new BadRequestException(MESSAGES.TASK.ACTOR_HOSPITAL_REQUIRED);
}
return actor.hospitalId;
}
/**
*
*/
private resolveTaskHospitalId(
actor: ActorContext,
hospitalIds: number[],
): number {
const uniqueHospitalIds = Array.from(
new Set(hospitalIds.filter((hospitalId) => Number.isInteger(hospitalId))),
);
if (uniqueHospitalIds.length !== 1) {
throw new BadRequestException(MESSAGES.TASK.DEVICE_MULTI_HOSPITAL);
}
const [hospitalId] = uniqueHospitalIds;
if (actor.hospitalId && actor.hospitalId !== hospitalId) {
throw new ForbiddenException(MESSAGES.TASK.DEVICE_NOT_FOUND);
}
return hospitalId;
}
/**
*
*/
private resolveAssignableHospitalId(
actor: ActorContext,
requestedHospitalId?: number,
) {
if (actor.role === Role.SYSTEM_ADMIN) {
if (requestedHospitalId !== undefined) {
return requestedHospitalId;
}
return this.requireHospitalId(actor);
}
return this.requireHospitalId(actor);
}
/**
*
*/
private resolveListHospitalId(
actor: ActorContext,
requestedHospitalId?: number,
) {
if (actor.role === Role.SYSTEM_ADMIN) {
return requestedHospitalId ?? actor.hospitalId ?? undefined;
}
return this.requireHospitalId(actor);
}
/**
*
*/
private buildTaskRecordWhere(
query: TaskRecordQueryDto,
hospitalId?: number,
): Prisma.TaskItemWhereInput {
const keyword = query.keyword?.trim();
const where: Prisma.TaskItemWhereInput = {
task: {
hospitalId,
status: query.status,
},
};
if (!keyword) {
return where;
}
where.OR = [
{
device: {
patient: {
name: {
contains: keyword,
mode: 'insensitive',
},
},
},
},
{
device: {
patient: {
inpatientNo: {
contains: keyword,
mode: 'insensitive',
},
},
},
},
{
device: {
patient: {
phone: {
contains: keyword,
mode: 'insensitive',
},
},
},
},
{
device: {
implantName: {
contains: keyword,
mode: 'insensitive',
},
},
},
{
device: {
implantModel: {
contains: keyword,
mode: 'insensitive',
},
},
},
];
return where;
}
/**
*
*/
private normalizeTargetPressure(value: unknown) {
return normalizePressureLabel(value, 'targetPressure');
}
/**
*
*/
private requireHospitalId(actor: ActorContext): number {
if (!actor.hospitalId) {

View File

@ -0,0 +1,133 @@
import {
BadRequestException,
Body,
Controller,
Get,
ParseIntPipe,
Post,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiBody,
ApiConsumes,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { FileInterceptor } from '@nestjs/platform-express';
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 { MESSAGES } from '../../common/messages.js';
import { UploadAssetQueryDto } from '../dto/upload-asset-query.dto.js';
import { ensureUploadDirectories, resolveUploadTempDir } from '../upload-path.util.js';
import { UploadsService } from '../uploads.service.js';
import { diskStorage } from 'multer';
import { extname } from 'node:path';
import { randomUUID } from 'node:crypto';
const MAX_UPLOAD_SIZE = 1024 * 1024 * 200;
function isAllowedMimeType(mimeType: string) {
return (
mimeType.startsWith('image/') ||
mimeType.startsWith('video/') ||
[
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
].includes(mimeType)
);
}
@ApiTags('上传资产(B端)')
@ApiBearerAuth('bearer')
@Controller('b/uploads')
@UseGuards(AccessTokenGuard, RolesGuard)
export class BUploadsController {
constructor(private readonly uploadsService: UploadsService) {}
/**
* //
*/
@Post()
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
destination: (_req, _file, cb) => {
ensureUploadDirectories();
cb(null, resolveUploadTempDir());
},
filename: (_req, file, cb) => {
cb(null, `${randomUUID()}${extname(file.originalname)}`);
},
}),
limits: { fileSize: MAX_UPLOAD_SIZE },
fileFilter: (_req, file, cb) => {
if (!isAllowedMimeType(file.mimetype)) {
cb(
new BadRequestException(MESSAGES.UPLOAD.UNSUPPORTED_FILE_TYPE),
false,
);
return;
}
cb(null, true);
},
}),
)
@ApiOperation({ summary: '上传图片/视频/文件' })
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
file: {
type: 'string',
format: 'binary',
},
hospitalId: {
type: 'integer',
},
},
required: ['file'],
},
})
create(
@CurrentActor() actor: ActorContext,
@UploadedFile() file?: Express.Multer.File,
@Body('hospitalId') hospitalId?: string,
) {
const requestedHospitalId =
hospitalId == null || hospitalId === '' ? undefined : Number(hospitalId);
return this.uploadsService.createUpload(actor, file, requestedHospitalId);
}
/**
* //
*/
@Get()
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '查询上传资产列表' })
findAll(
@CurrentActor() actor: ActorContext,
@Query() query: UploadAssetQueryDto,
) {
return this.uploadsService.findAll(actor, query);
}
}

View File

@ -0,0 +1,63 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
import { UploadAssetType } from '../../generated/prisma/enums.js';
import {
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
/**
* DTO/
*/
export class UploadAssetQueryDto {
@ApiPropertyOptional({
description: '关键词(按原始文件名模糊匹配)',
example: 'ct',
})
@IsOptional()
@IsString({ message: 'keyword 必须是字符串' })
keyword?: string;
@ApiPropertyOptional({
description: '资产类型',
enum: UploadAssetType,
example: UploadAssetType.IMAGE,
})
@IsOptional()
@IsEnum(UploadAssetType, { message: 'type 枚举值不合法' })
type?: UploadAssetType;
@ApiPropertyOptional({ description: '医院 IDSYSTEM_ADMIN 可选)', example: 1 })
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'hospitalId 必须是整数' })
@Min(1, { message: 'hospitalId 必须大于 0' })
hospitalId?: number;
@ApiPropertyOptional({ description: '页码', example: 1, default: 1 })
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'page 必须是整数' })
@Min(1, { message: 'page 最小为 1' })
page?: number = 1;
@ApiPropertyOptional({
description: '每页数量',
example: 20,
default: 20,
})
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'pageSize 必须是整数' })
@Min(1, { message: 'pageSize 最小为 1' })
@Max(100, { message: 'pageSize 最大为 100' })
pageSize?: number = 20;
}

View File

@ -0,0 +1,18 @@
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
/**
*
*/
export function resolveUploadRootDir() {
return join(process.cwd(), 'storage', 'uploads');
}
export function resolveUploadTempDir() {
return join(process.cwd(), 'storage', 'tmp-uploads');
}
export function ensureUploadDirectories() {
mkdirSync(resolveUploadRootDir(), { recursive: true });
mkdirSync(resolveUploadTempDir(), { recursive: true });
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma.module.js';
import { BUploadsController } from './b-uploads/b-uploads.controller.js';
import { UploadsService } from './uploads.service.js';
@Module({
imports: [PrismaModule],
controllers: [BUploadsController],
providers: [UploadsService],
exports: [UploadsService],
})
export class UploadsModule {}

View File

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { UploadsService } from './uploads.service';
describe('UploadsService', () => {
let service: UploadsService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [UploadsService],
}).compile();
service = module.get<UploadsService>(UploadsService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View File

@ -0,0 +1,383 @@
import {
BadRequestException,
Injectable,
InternalServerErrorException,
NotFoundException,
} from '@nestjs/common';
import { spawn } from 'node:child_process';
import { access, mkdir, rename, stat, unlink } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { randomUUID } from 'node:crypto';
import ffmpegPath from 'ffmpeg-static';
import sharp from 'sharp';
import { Prisma } from '../generated/prisma/client.js';
import { Role, UploadAssetType } 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 { UploadAssetQueryDto } from './dto/upload-asset-query.dto.js';
import {
ensureUploadDirectories,
resolveUploadRootDir,
resolveUploadTempDir,
} from './upload-path.util.js';
@Injectable()
export class UploadsService {
constructor(private readonly prisma: PrismaService) {}
/**
*
*/
async createUpload(
actor: ActorContext,
file: Express.Multer.File | undefined,
requestedHospitalId?: number,
) {
if (!file) {
throw new BadRequestException(MESSAGES.UPLOAD.FILE_REQUIRED);
}
const hospitalId = await this.resolveHospitalScope(actor, requestedHospitalId);
const type = this.detectAssetType(file.mimetype);
ensureUploadDirectories();
const prepared = await this.prepareStoredFile(file, type);
const date = new Date();
const relativeDir = join(
`${date.getFullYear()}`,
`${date.getMonth() + 1}`.padStart(2, '0'),
`${date.getDate()}`.padStart(2, '0'),
);
const absoluteDir = join(resolveUploadRootDir(), relativeDir);
await mkdir(absoluteDir, { recursive: true });
const finalFileName = await this.buildFinalFileName(
absoluteDir,
file.originalname,
prepared.outputExtension,
date,
);
const relativePath = join(relativeDir, finalFileName);
const absolutePath = join(resolveUploadRootDir(), relativePath);
try {
await rename(prepared.tempPath, absolutePath);
if (prepared.tempPath !== file.path) {
await this.safeUnlink(file.path);
}
} catch (error) {
await this.safeUnlink(file.path);
await this.safeUnlink(prepared.tempPath);
throw error;
}
const url = `/${relativePath.replace(/\\/g, '/')}`.replace(/^\/+/, '/uploads/');
try {
return await this.prisma.uploadAsset.create({
data: {
hospitalId,
creatorId: actor.id,
type,
originalName: file.originalname,
fileName: finalFileName,
storagePath: relativePath.replace(/\\/g, '/'),
url,
mimeType: prepared.mimeType,
fileSize: prepared.fileSize,
},
include: {
hospital: { select: { id: true, name: true } },
creator: { select: { id: true, name: true, role: true } },
},
});
} catch (error) {
await this.safeUnlink(absolutePath);
throw error;
}
}
/**
*
*/
async findAll(actor: ActorContext, query: UploadAssetQueryDto) {
const page = query.page && query.page > 0 ? query.page : 1;
const pageSize =
query.pageSize && query.pageSize > 0 && query.pageSize <= 100
? query.pageSize
: 20;
const where: Prisma.UploadAssetWhereInput = {};
const scopedHospitalId = this.resolveReadableHospitalScope(actor, query.hospitalId);
if (scopedHospitalId != null) {
where.hospitalId = scopedHospitalId;
}
if (query.type) {
where.type = query.type;
}
if (query.keyword?.trim()) {
where.originalName = {
contains: query.keyword.trim(),
mode: 'insensitive',
};
}
const [total, list] = await this.prisma.$transaction([
this.prisma.uploadAsset.count({ where }),
this.prisma.uploadAsset.findMany({
where,
include: {
hospital: { select: { id: true, name: true } },
creator: { select: { id: true, name: true, role: true } },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
return { total, page, pageSize, list };
}
private detectAssetType(mimeType: string) {
if (mimeType.startsWith('image/')) {
return UploadAssetType.IMAGE;
}
if (mimeType.startsWith('video/')) {
return UploadAssetType.VIDEO;
}
return UploadAssetType.FILE;
}
private async resolveHospitalScope(
actor: ActorContext,
requestedHospitalId?: number,
) {
if (actor.role === Role.SYSTEM_ADMIN) {
if (
requestedHospitalId == null ||
!Number.isInteger(requestedHospitalId) ||
requestedHospitalId <= 0
) {
throw new BadRequestException(
MESSAGES.UPLOAD.SYSTEM_ADMIN_HOSPITAL_REQUIRED,
);
}
const hospital = await this.prisma.hospital.findUnique({
where: { id: requestedHospitalId },
select: { id: true },
});
if (!hospital) {
throw new NotFoundException(MESSAGES.ORG.HOSPITAL_NOT_FOUND);
}
return hospital.id;
}
if (!actor.hospitalId) {
throw new BadRequestException(MESSAGES.UPLOAD.ACTOR_HOSPITAL_REQUIRED);
}
return actor.hospitalId;
}
private async prepareStoredFile(
file: Express.Multer.File,
type: UploadAssetType,
) {
const baseName = randomUUID();
if (type === UploadAssetType.IMAGE) {
const tempPath = join(resolveUploadTempDir(), `${baseName}.webp`);
try {
await sharp(file.path)
.rotate()
.resize({
width: 2560,
height: 2560,
fit: 'inside',
withoutEnlargement: true,
})
.webp({
quality: 82,
effort: 4,
})
.toFile(tempPath);
} catch {
await this.safeUnlink(tempPath);
throw new BadRequestException(MESSAGES.UPLOAD.INVALID_IMAGE_FILE);
}
const fileInfo = await stat(tempPath);
return {
tempPath,
outputExtension: '.webp',
mimeType: 'image/webp',
fileSize: fileInfo.size,
};
}
if (type === UploadAssetType.VIDEO) {
const tempPath = join(resolveUploadTempDir(), `${baseName}.mp4`);
try {
await this.compressVideo(file.path, tempPath);
} catch (error) {
await this.safeUnlink(tempPath);
if (error instanceof BadRequestException) {
throw error;
}
throw new BadRequestException(MESSAGES.UPLOAD.INVALID_VIDEO_FILE);
}
const fileInfo = await stat(tempPath);
return {
tempPath,
outputExtension: '.mp4',
mimeType: 'video/mp4',
fileSize: fileInfo.size,
};
}
return {
tempPath: file.path,
outputExtension: extname(file.originalname),
mimeType: file.mimetype,
fileSize: file.size,
};
}
private async buildFinalFileName(
absoluteDir: string,
originalName: string,
outputExtension: string,
date: Date,
) {
const timestamp = this.formatDateToSecond(date);
const originalBaseName =
this.normalizeOriginalBaseName(originalName) || 'upload-file';
const extension = outputExtension || extname(originalName) || '';
const baseFileName = `${timestamp}-${originalBaseName}`;
let candidate = `${baseFileName}${extension}`;
let duplicateIndex = 1;
while (await this.fileExists(join(absoluteDir, candidate))) {
candidate = `${baseFileName}-${duplicateIndex}${extension}`;
duplicateIndex += 1;
}
return candidate;
}
private normalizeOriginalBaseName(originalName: string) {
const rawBaseName = originalName.replace(/\.[^.]+$/, '');
const sanitized = rawBaseName
.normalize('NFKC')
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '-')
.replace(/\s+/g, ' ')
.trim()
.replace(/-+/g, '-');
return sanitized || 'upload-file';
}
private formatDateToSecond(date: Date) {
const year = String(date.getFullYear());
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}${month}${day}${hours}${minutes}${seconds}`;
}
private async fileExists(path: string) {
try {
await access(path);
return true;
} catch {
return false;
}
}
private async compressVideo(inputPath: string, outputPath: string) {
const command = ffmpegPath as unknown as string | null;
if (!command) {
throw new InternalServerErrorException(MESSAGES.UPLOAD.FFMPEG_NOT_AVAILABLE);
}
const args = [
'-y',
'-i',
inputPath,
'-map',
'0:v:0',
'-map',
'0:a?',
'-vf',
"scale='if(gt(iw,ih),min(1280,iw),-2)':'if(gt(iw,ih),-2,min(1280,ih))'",
'-c:v',
'libx264',
'-preset',
'veryfast',
'-crf',
'29',
'-pix_fmt',
'yuv420p',
'-movflags',
'+faststart',
'-c:a',
'aac',
'-b:a',
'128k',
outputPath,
];
await new Promise<void>((resolve, reject) => {
const child = spawn(command, args);
let stderr = '';
child.stderr?.on('data', (chunk) => {
stderr += String(chunk);
});
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) {
resolve();
return;
}
reject(
new BadRequestException(
stderr.trim() || MESSAGES.UPLOAD.INVALID_VIDEO_FILE,
),
);
});
});
}
private resolveReadableHospitalScope(
actor: ActorContext,
requestedHospitalId?: number,
) {
if (actor.role === Role.SYSTEM_ADMIN) {
return requestedHospitalId && requestedHospitalId > 0
? requestedHospitalId
: null;
}
if (!actor.hospitalId) {
throw new BadRequestException(MESSAGES.UPLOAD.ACTOR_HOSPITAL_REQUIRED);
}
return actor.hospitalId;
}
private async safeUnlink(path: string | undefined) {
if (!path) {
return;
}
try {
await unlink(path);
} catch {
// 临时文件清理失败不影响主流程,避免吞掉原始错误。
}
}
}

View File

@ -38,7 +38,7 @@ export class UsersController {
*
*/
@Post()
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '创建用户' })
create(
@CurrentActor() actor: ActorContext,
@ -61,7 +61,7 @@ export class UsersController {
*
*/
@Get(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
@ApiOperation({ summary: '查询用户详情' })
@ApiParam({ name: 'id', description: '用户 ID' })
findOne(@CurrentActor() actor: ActorContext, @Param('id') id: string) {
@ -72,7 +72,7 @@ export class UsersController {
*
*/
@Patch(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR)
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '更新用户' })
@ApiParam({ name: 'id', description: '用户 ID' })
update(
@ -87,7 +87,7 @@ export class UsersController {
*
*/
@Delete(':id')
@Roles(Role.SYSTEM_ADMIN, Role.DIRECTOR)
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '删除用户' })
@ApiParam({ name: 'id', description: '用户 ID' })
remove(@CurrentActor() actor: ActorContext, @Param('id') id: string) {

View File

@ -30,6 +30,9 @@ const SAFE_USER_SELECT = {
groupId: true,
} as const;
const DIRECTOR_VISIBLE_ROLES = [Role.LEADER, Role.DOCTOR] as const;
const LEADER_VISIBLE_ROLES = [Role.DOCTOR] as const;
@Injectable()
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
@ -202,15 +205,18 @@ export class UsersService {
actor.hospitalId,
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
);
} else if (actor.role === Role.DIRECTOR || actor.role === Role.LEADER) {
where.hospitalId = this.requireActorScopeInt(
actor.hospitalId,
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
);
} else if (actor.role === Role.DIRECTOR) {
where.departmentId = this.requireActorScopeInt(
actor.departmentId,
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
);
where.role = { in: [...DIRECTOR_VISIBLE_ROLES] };
} else if (actor.role === Role.LEADER) {
where.groupId = this.requireActorScopeInt(
actor.groupId,
MESSAGES.ORG.ACTOR_GROUP_REQUIRED,
);
where.role = { in: [...LEADER_VISIBLE_ROLES] };
} else if (actor.role !== Role.SYSTEM_ADMIN) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
@ -682,38 +688,9 @@ export class UsersService {
}
if (actor.role !== Role.HOSPITAL_ADMIN) {
if (actor.role !== Role.DIRECTOR) {
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
}
// 科室主任仅允许创建本科室医生。
if (targetRole !== Role.DOCTOR) {
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
}
const actorHospitalId = this.requireActorScopeInt(
actor.hospitalId,
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
);
const actorDepartmentId = this.requireActorScopeInt(
actor.departmentId,
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
);
if (hospitalId != null && hospitalId !== actorHospitalId) {
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
}
if (departmentId != null && departmentId !== actorDepartmentId) {
throw new ForbiddenException(MESSAGES.USER.CREATE_FORBIDDEN);
}
return {
hospitalId: actorHospitalId,
departmentId: actorDepartmentId,
groupId,
};
}
if (
targetRole === Role.SYSTEM_ADMIN ||
targetRole === Role.HOSPITAL_ADMIN
@ -740,7 +717,7 @@ export class UsersService {
}
/**
*
*
*/
private assertUserReadable(
actor: ActorContext,
@ -749,6 +726,7 @@ export class UsersService {
role: Role;
hospitalId: number | null;
departmentId: number | null;
groupId: number | null;
},
) {
if (actor.role === Role.SYSTEM_ADMIN) {
@ -769,30 +747,36 @@ export class UsersService {
}
if (actor.role === Role.DIRECTOR) {
const actorHospitalId = this.requireActorScopeInt(
actor.hospitalId,
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
);
const actorDepartmentId = this.requireActorScopeInt(
actor.departmentId,
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
);
if (
target.role === Role.DOCTOR &&
target.hospitalId === actorHospitalId &&
target.departmentId === actorDepartmentId
target.departmentId === actorDepartmentId &&
(DIRECTOR_VISIBLE_ROLES as readonly Role[]).includes(target.role)
) {
return;
}
}
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
if (actor.role === Role.LEADER) {
const actorGroupId = this.requireActorScopeInt(
actor.groupId,
MESSAGES.ORG.ACTOR_GROUP_REQUIRED,
);
if (
target.groupId === actorGroupId &&
(LEADER_VISIBLE_ROLES as readonly Role[]).includes(target.role)
) {
return;
}
}
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
/**
*
*
*/
private assertUserWritable(
actor: ActorContext,
@ -807,25 +791,6 @@ export class UsersService {
return;
}
if (actor.role === Role.DIRECTOR) {
const actorHospitalId = this.requireActorScopeInt(
actor.hospitalId,
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
);
const actorDepartmentId = this.requireActorScopeInt(
actor.departmentId,
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
);
if (
target.role !== Role.DOCTOR ||
target.hospitalId !== actorHospitalId ||
target.departmentId !== actorDepartmentId
) {
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
}
return;
}
if (actor.role !== Role.HOSPITAL_ADMIN) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
@ -857,10 +822,6 @@ export class UsersService {
return;
}
if (actor.role === Role.DIRECTOR && nextRole !== Role.DOCTOR) {
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
}
if (
actor.role === Role.HOSPITAL_ADMIN &&
(nextRole === Role.SYSTEM_ADMIN || nextRole === Role.HOSPITAL_ADMIN)
@ -878,17 +839,6 @@ export class UsersService {
actor: ActorContext,
hospitalId: number | null,
) {
if (actor.role === Role.DIRECTOR) {
const actorHospitalId = this.requireActorScopeInt(
actor.hospitalId,
MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED,
);
if (hospitalId !== actorHospitalId) {
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
}
return;
}
if (actor.role !== Role.HOSPITAL_ADMIN) {
return;
}
@ -911,17 +861,9 @@ export class UsersService {
actor: ActorContext,
departmentId: number | null,
) {
if (actor.role !== Role.DIRECTOR) {
if (actor.role !== Role.HOSPITAL_ADMIN) {
return;
}
const actorDepartmentId = this.requireActorScopeInt(
actor.departmentId,
MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED,
);
if (departmentId !== actorDepartmentId) {
throw new ForbiddenException(MESSAGES.USER.DIRECTOR_SCOPE_FORBIDDEN);
}
}
/**

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 B

View File

@ -0,0 +1 @@
upload-SYSTEM_ADMIN

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 B

View File

@ -0,0 +1 @@
upload-SYSTEM_ADMIN

View File

@ -0,0 +1 @@
fake-image-content

View File

@ -0,0 +1 @@
fake-image-content

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

View File

@ -0,0 +1 @@
upload-HOSPITAL_ADMIN

View File

@ -0,0 +1 @@
upload-DIRECTOR

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

View File

@ -0,0 +1 @@
upload-LEADER

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

View File

@ -0,0 +1 @@
fake-video-content

View File

@ -0,0 +1 @@
upload-HOSPITAL_ADMIN

View File

@ -0,0 +1 @@
upload-LEADER

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

View File

@ -0,0 +1 @@
fake-image-content

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

View File

@ -0,0 +1 @@
upload-DOCTOR

View File

@ -0,0 +1 @@
upload-DIRECTOR

View File

@ -0,0 +1 @@
fake-image-content

View File

@ -0,0 +1 @@
fake-video-content

View File

@ -0,0 +1 @@
upload-DOCTOR

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 B

View File

@ -5,13 +5,23 @@ import {
type E2ERole,
E2E_SEED_CREDENTIALS,
} from '../fixtures/e2e-roles.js';
import type { E2ESeedFixtures } from './e2e-fixtures.helper.js';
import { expectSuccessEnvelope } from './e2e-http.helper.js';
export type E2EAccessTokenMap = Record<E2ERole, string>;
function resolveRoleHospitalId(role: E2ERole, fixtures: E2ESeedFixtures) {
if (role === 'SYSTEM_ADMIN') {
return undefined;
}
return fixtures.hospitalAId;
}
export async function loginAsRole(
app: INestApplication,
role: E2ERole,
fixtures: E2ESeedFixtures,
): Promise<string> {
const credential = E2E_SEED_CREDENTIALS[role];
const payload: Record<string, unknown> = {
@ -19,9 +29,9 @@ export async function loginAsRole(
password: credential.password,
role: credential.role,
};
if (credential.hospitalId != null) {
payload.hospitalId = credential.hospitalId;
const hospitalId = resolveRoleHospitalId(role, fixtures);
if (hospitalId != null) {
payload.hospitalId = hospitalId;
}
const response = await request(app.getHttpServer())
@ -36,10 +46,11 @@ export async function loginAsRole(
export async function loginAllRoles(
app: INestApplication,
fixtures: E2ESeedFixtures,
): Promise<E2EAccessTokenMap> {
const tokenEntries = await Promise.all(
E2E_ROLE_LIST.map(
async (role) => [role, await loginAsRole(app, role)] as const,
async (role) => [role, await loginAsRole(app, role, fixtures)] as const,
),
);

View File

@ -3,7 +3,7 @@ import { PrismaService } from '../../../src/prisma.service.js';
import { loginAllRoles, type E2EAccessTokenMap } from './e2e-auth.helper.js';
import { createE2eApp } from './e2e-app.helper.js';
import {
loadSeedFixtures,
ensureE2EFixtures,
type E2ESeedFixtures,
} from './e2e-fixtures.helper.js';
@ -17,8 +17,8 @@ export interface E2EContext {
export async function createE2EContext(): Promise<E2EContext> {
const app = await createE2eApp();
const prisma = app.get(PrismaService);
const fixtures = await loadSeedFixtures(prisma);
const tokens = await loginAllRoles(app);
const fixtures = await ensureE2EFixtures(app, prisma);
const tokens = await loginAllRoles(app, fixtures);
return {
app,

View File

@ -1,5 +1,13 @@
import type { INestApplication } from '@nestjs/common';
import { NotFoundException } from '@nestjs/common';
import request from 'supertest';
import { Role } from '../../../src/generated/prisma/enums.js';
import { PrismaService } from '../../../src/prisma.service.js';
import {
E2E_SEED_CREDENTIALS,
E2E_SEED_PASSWORD,
} from '../fixtures/e2e-roles.js';
import { expectSuccessEnvelope } from './e2e-http.helper.js';
export interface E2ESeedFixtures {
hospitalAId: number;
@ -10,6 +18,10 @@ export interface E2ESeedFixtures {
groupA1Id: number;
groupA2Id: number;
groupB1Id: number;
catalogs: {
adjustableValveId: number;
highPressureValveId: number;
};
users: {
systemAdminId: number;
hospitalAdminAId: number;
@ -44,6 +56,610 @@ interface SeedUserScope {
groupId: number | null;
}
const OPEN_IDS = {
systemAdmin: 'seed-system-admin-openid',
hospitalAdminA: 'seed-hospital-admin-a-openid',
hospitalAdminB: 'seed-hospital-admin-b-openid',
directorA: 'seed-director-a-openid',
leaderA: 'seed-leader-a-openid',
doctorA: 'seed-doctor-a-openid',
doctorA2: 'seed-doctor-a2-openid',
doctorA3: 'seed-doctor-a3-openid',
doctorB: 'seed-doctor-b-openid',
engineerA: 'seed-engineer-a-openid',
engineerB: 'seed-engineer-b-openid',
} as const;
const FIXTURE_NAMES = {
hospitalA: 'Seed Hospital A',
hospitalB: 'Seed Hospital B',
departmentA1: 'Neurosurgery-A1',
departmentA2: 'Cardiology-A2',
departmentB1: 'Neurosurgery-B1',
groupA1: 'Shift-A1',
groupA2: 'Shift-A2',
groupB1: 'Shift-B1',
adjustableCatalog: 'SEED-ADJUSTABLE-VALVE',
highPressureCatalog: 'SEED-HIGH-PRESSURE-VALVE',
} as const;
const EXTRA_PHONES = {
hospitalAdminB: '13800001101',
doctorA2: '13800001204',
doctorA3: '13800001304',
doctorB: '13800001104',
engineerB: '13800001105',
} as const;
const SHARED_PATIENT_IDENTITY = {
phone: '13800002001',
idCard: '110101199001010011',
} as const;
export async function ensureE2EFixtures(
app: INestApplication,
prisma: PrismaService,
): Promise<E2ESeedFixtures> {
const existingSystemAdmin = await prisma.user.findFirst({
where: {
phone: E2E_SEED_CREDENTIALS[Role.SYSTEM_ADMIN].phone,
role: Role.SYSTEM_ADMIN,
},
select: { id: true },
});
if (!existingSystemAdmin) {
await bootstrapFixturesViaApi(app);
}
return loadSeedFixtures(prisma);
}
async function bootstrapFixturesViaApi(app: INestApplication) {
const server = app.getHttpServer();
await createSystemAdmin(server);
const systemAdminToken = await loginByCredential(server, {
phone: E2E_SEED_CREDENTIALS[Role.SYSTEM_ADMIN].phone,
password: E2E_SEED_PASSWORD,
role: Role.SYSTEM_ADMIN,
});
const hospitalA = await createWithToken(
server,
systemAdminToken,
'/b/organization/hospitals',
{ name: FIXTURE_NAMES.hospitalA },
);
const hospitalB = await createWithToken(
server,
systemAdminToken,
'/b/organization/hospitals',
{ name: FIXTURE_NAMES.hospitalB },
);
const hospitalAdminA = await createWithToken(server, systemAdminToken, '/users', {
name: 'Seed Hospital Admin A',
phone: E2E_SEED_CREDENTIALS[Role.HOSPITAL_ADMIN].phone,
password: E2E_SEED_PASSWORD,
role: Role.HOSPITAL_ADMIN,
hospitalId: hospitalA.id,
openId: OPEN_IDS.hospitalAdminA,
});
await createWithToken(server, systemAdminToken, '/users', {
name: 'Seed Hospital Admin B',
phone: EXTRA_PHONES.hospitalAdminB,
password: E2E_SEED_PASSWORD,
role: Role.HOSPITAL_ADMIN,
hospitalId: hospitalB.id,
openId: OPEN_IDS.hospitalAdminB,
});
const engineerA = await createWithToken(server, systemAdminToken, '/users', {
name: 'Seed Engineer A',
phone: E2E_SEED_CREDENTIALS[Role.ENGINEER].phone,
password: E2E_SEED_PASSWORD,
role: Role.ENGINEER,
hospitalId: hospitalA.id,
openId: OPEN_IDS.engineerA,
});
const engineerB = await createWithToken(server, systemAdminToken, '/users', {
name: 'Seed Engineer B',
phone: EXTRA_PHONES.engineerB,
password: E2E_SEED_PASSWORD,
role: Role.ENGINEER,
hospitalId: hospitalB.id,
openId: OPEN_IDS.engineerB,
});
await patchWithToken(
server,
systemAdminToken,
`/b/users/${engineerA.id}/assign-engineer-hospital`,
{ hospitalId: hospitalA.id },
);
await patchWithToken(
server,
systemAdminToken,
`/b/users/${engineerB.id}/assign-engineer-hospital`,
{ hospitalId: hospitalB.id },
);
const hospitalAdminAToken = await loginByCredential(server, {
phone: hospitalAdminA.phone,
password: E2E_SEED_PASSWORD,
role: Role.HOSPITAL_ADMIN,
hospitalId: hospitalA.id,
});
const hospitalAdminBToken = await loginByCredential(server, {
phone: EXTRA_PHONES.hospitalAdminB,
password: E2E_SEED_PASSWORD,
role: Role.HOSPITAL_ADMIN,
hospitalId: hospitalB.id,
});
const departmentA1 = await createWithToken(
server,
hospitalAdminAToken,
'/b/organization/departments',
{
name: FIXTURE_NAMES.departmentA1,
hospitalId: hospitalA.id,
},
);
const departmentA2 = await createWithToken(
server,
hospitalAdminAToken,
'/b/organization/departments',
{
name: FIXTURE_NAMES.departmentA2,
hospitalId: hospitalA.id,
},
);
const departmentB1 = await createWithToken(
server,
hospitalAdminBToken,
'/b/organization/departments',
{
name: FIXTURE_NAMES.departmentB1,
hospitalId: hospitalB.id,
},
);
const groupA1 = await createWithToken(
server,
hospitalAdminAToken,
'/b/organization/groups',
{
name: FIXTURE_NAMES.groupA1,
departmentId: departmentA1.id,
},
);
const groupA2 = await createWithToken(
server,
hospitalAdminAToken,
'/b/organization/groups',
{
name: FIXTURE_NAMES.groupA2,
departmentId: departmentA2.id,
},
);
const groupB1 = await createWithToken(
server,
hospitalAdminBToken,
'/b/organization/groups',
{
name: FIXTURE_NAMES.groupB1,
departmentId: departmentB1.id,
},
);
const directorA = await createWithToken(server, hospitalAdminAToken, '/users', {
name: 'Seed Director A',
phone: E2E_SEED_CREDENTIALS[Role.DIRECTOR].phone,
password: E2E_SEED_PASSWORD,
role: Role.DIRECTOR,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
openId: OPEN_IDS.directorA,
});
await createWithToken(server, hospitalAdminAToken, '/users', {
name: 'Seed Leader A',
phone: E2E_SEED_CREDENTIALS[Role.LEADER].phone,
password: E2E_SEED_PASSWORD,
role: Role.LEADER,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: groupA1.id,
openId: OPEN_IDS.leaderA,
});
const doctorA = await createWithToken(server, hospitalAdminAToken, '/users', {
name: 'Seed Doctor A',
phone: E2E_SEED_CREDENTIALS[Role.DOCTOR].phone,
password: E2E_SEED_PASSWORD,
role: Role.DOCTOR,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: groupA1.id,
openId: OPEN_IDS.doctorA,
});
const doctorA2 = await createWithToken(server, hospitalAdminAToken, '/users', {
name: 'Seed Doctor A2',
phone: EXTRA_PHONES.doctorA2,
password: E2E_SEED_PASSWORD,
role: Role.DOCTOR,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: groupA1.id,
openId: OPEN_IDS.doctorA2,
});
const doctorA3 = await createWithToken(server, hospitalAdminAToken, '/users', {
name: 'Seed Doctor A3',
phone: EXTRA_PHONES.doctorA3,
password: E2E_SEED_PASSWORD,
role: Role.DOCTOR,
hospitalId: hospitalA.id,
departmentId: departmentA2.id,
groupId: groupA2.id,
openId: OPEN_IDS.doctorA3,
});
const doctorB = await createWithToken(server, hospitalAdminBToken, '/users', {
name: 'Seed Doctor B',
phone: EXTRA_PHONES.doctorB,
password: E2E_SEED_PASSWORD,
role: Role.DOCTOR,
hospitalId: hospitalB.id,
departmentId: departmentB1.id,
groupId: groupB1.id,
openId: OPEN_IDS.doctorB,
});
await bootstrapDictionaries(server, systemAdminToken);
const adjustableCatalog = await createWithToken(
server,
systemAdminToken,
'/b/devices/catalogs',
{
modelCode: FIXTURE_NAMES.adjustableCatalog,
manufacturer: 'Seed MedTech',
name: 'Seed 可调压分流阀',
pressureLevels: ['0.5', '1', '1.5'],
isPressureAdjustable: true,
notes: 'Seed 全局可调压目录样例',
},
);
await createWithToken(server, systemAdminToken, '/b/devices/catalogs', {
modelCode: FIXTURE_NAMES.highPressureCatalog,
manufacturer: 'Seed MedTech',
name: 'Seed 高压挡位阀',
pressureLevels: ['10', '20', '30'],
isPressureAdjustable: true,
notes: 'Seed 高压挡位目录样例',
});
const doctorAToken = await loginByCredential(server, {
phone: doctorA.phone,
password: E2E_SEED_PASSWORD,
role: Role.DOCTOR,
hospitalId: hospitalA.id,
});
const doctorA2Token = await loginByCredential(server, {
phone: EXTRA_PHONES.doctorA2,
password: E2E_SEED_PASSWORD,
role: Role.DOCTOR,
hospitalId: hospitalA.id,
});
const doctorA3Token = await loginByCredential(server, {
phone: EXTRA_PHONES.doctorA3,
password: E2E_SEED_PASSWORD,
role: Role.DOCTOR,
hospitalId: hospitalA.id,
});
const doctorBToken = await loginByCredential(server, {
phone: EXTRA_PHONES.doctorB,
password: E2E_SEED_PASSWORD,
role: Role.DOCTOR,
hospitalId: hospitalB.id,
});
const engineerAToken = await loginByCredential(server, {
phone: E2E_SEED_CREDENTIALS[Role.ENGINEER].phone,
password: E2E_SEED_PASSWORD,
role: Role.ENGINEER,
hospitalId: hospitalA.id,
});
const patientA1 = await createWithToken(server, doctorAToken, '/b/patients', {
name: 'Seed Patient A1',
inpatientNo: 'ZYH-A-0001',
projectName: '脑积水随访项目-A',
phone: SHARED_PATIENT_IDENTITY.phone,
idCard: SHARED_PATIENT_IDENTITY.idCard,
doctorId: doctorA.id,
initialSurgery: {
surgeryDate: '2024-06-01T08:00:00.000Z',
surgeryName: '首次脑室腹腔分流术',
preOpPressure: 24,
primaryDisease: '先天性脑积水',
hydrocephalusTypes: ['交通性'],
notes: '首台手术',
devices: [
{
implantCatalogId: adjustableCatalog.id,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: '1',
implantNotes: 'Seed A1 弃用历史设备',
labelImageUrl: 'https://seed.example.com/labels/a1-004.jpg',
},
],
},
});
const oldDeviceId = patientA1.devices?.[0]?.id;
if (!oldDeviceId) {
throw new Error('failed to create seed old device');
}
const revisionA1 = await createWithToken(
server,
doctorAToken,
`/b/patients/${patientA1.id}/surgeries`,
{
surgeryDate: '2025-09-10T08:00:00.000Z',
surgeryName: '分流系统翻修术',
preOpPressure: 18,
primaryDisease: '分流功能障碍',
hydrocephalusTypes: ['交通性', '高压性'],
previousShuntSurgeryDate: '2024-06-01T08:00:00.000Z',
preOpMaterials: [
{
type: 'IMAGE',
url: 'https://seed.example.com/a1-ct-preop.png',
name: 'Seed A1 术前 CT',
},
],
notes: '二次手术,保留原设备历史',
abandonedDeviceIds: [oldDeviceId],
devices: [
{
implantCatalogId: adjustableCatalog.id,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: '1',
implantNotes: 'Seed A1 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/a1-001.jpg',
},
],
},
);
const patientA2 = await createWithToken(server, doctorA2Token, '/b/patients', {
name: 'Seed Patient A2',
inpatientNo: 'ZYH-A-0002',
projectName: '脑积水随访项目-A',
phone: '13800002002',
idCard: '110101199002020022',
doctorId: doctorA2.id,
initialSurgery: {
surgeryDate: '2025-12-15T08:00:00.000Z',
surgeryName: '脑室腹腔分流术',
preOpPressure: 20,
primaryDisease: '肿瘤相关脑积水',
hydrocephalusTypes: ['梗阻性'],
devices: [
{
implantCatalogId: adjustableCatalog.id,
shuntMode: 'VPS',
proximalPunctureAreas: ['枕角'],
valvePlacementSites: ['胸前'],
distalShuntDirection: '腹腔',
initialPressure: '1',
implantNotes: 'Seed A2 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/a2-002.jpg',
},
],
},
});
await createWithToken(server, doctorA3Token, '/b/patients', {
name: 'Seed Patient A3',
inpatientNo: 'ZYH-A-0003',
projectName: '脑积水随访项目-A',
phone: '13800002003',
idCard: '110101199003030033',
doctorId: doctorA3.id,
initialSurgery: {
surgeryDate: '2025-11-20T08:00:00.000Z',
surgeryName: '脑室腹腔分流术',
preOpPressure: 21,
primaryDisease: '外伤后脑积水',
hydrocephalusTypes: ['交通性'],
devices: [
{
implantCatalogId: adjustableCatalog.id,
shuntMode: 'LPS',
proximalPunctureAreas: ['腰穿'],
valvePlacementSites: ['腰背部'],
distalShuntDirection: '腹腔',
initialPressure: '1',
implantNotes: 'Seed A3 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/a3-003.jpg',
},
],
},
});
const patientB1 = await createWithToken(server, doctorBToken, '/b/patients', {
name: 'Seed Patient B1',
inpatientNo: 'ZYH-B-0001',
projectName: '脑积水随访项目-B',
phone: SHARED_PATIENT_IDENTITY.phone,
idCard: SHARED_PATIENT_IDENTITY.idCard,
doctorId: doctorB.id,
initialSurgery: {
surgeryDate: '2025-10-05T08:00:00.000Z',
surgeryName: '脑室腹腔分流术',
preOpPressure: 23,
primaryDisease: '出血后脑积水',
hydrocephalusTypes: ['高压性'],
devices: [
{
implantCatalogId: adjustableCatalog.id,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: '1',
implantNotes: 'Seed B1 当前在用设备',
labelImageUrl: 'https://seed.example.com/labels/b1-001.jpg',
},
],
},
});
const deviceA1Id = revisionA1.devices?.[0]?.id;
const deviceB1Id = patientB1.devices?.[0]?.id;
if (!deviceA1Id || !deviceB1Id) {
throw new Error('failed to create seed active devices');
}
const publishedA = await createWithToken(
server,
doctorAToken,
'/b/tasks/publish',
{
engineerId: engineerA.id,
items: [
{
deviceId: deviceA1Id,
targetPressure: '1.5',
},
],
},
);
await createWithToken(server, engineerAToken, '/b/tasks/complete', {
taskId: publishedA.id,
});
await createWithToken(server, doctorBToken, '/b/tasks/publish', {
engineerId: engineerB.id,
items: [
{
deviceId: deviceB1Id,
targetPressure: '1.5',
},
],
});
// 防止未使用变量被误删,保留显式访问。
void patientA2;
}
async function bootstrapDictionaries(
server: ReturnType<INestApplication['getHttpServer']>,
systemAdminToken: string,
) {
const dictionarySeeds = {
PRIMARY_DISEASE: [
'先天性脑积水',
'梗阻性脑积水',
'交通性脑积水',
'出血后脑积水',
'肿瘤相关脑积水',
'外伤后脑积水',
'感染后脑积水',
'分流功能障碍',
],
HYDROCEPHALUS_TYPE: [
'交通性',
'梗阻性',
'高压性',
'正常压力',
'先天性',
'继发性',
],
SHUNT_MODE: ['VPS', 'VPLS', 'LPS', '脑室心房分流'],
PROXIMAL_PUNCTURE_AREA: ['额角', '枕角', '三角区', '腰穿', '后角'],
VALVE_PLACEMENT_SITE: ['耳后', '胸前', '锁骨下', '腹壁', '腰背部'],
DISTAL_SHUNT_DIRECTION: ['腹腔', '胸腔', '心房', '腰大池'],
} as const;
for (const [type, labels] of Object.entries(dictionarySeeds)) {
for (const [index, label] of labels.entries()) {
await createWithToken(server, systemAdminToken, '/b/dictionaries', {
type,
label,
sortOrder: index * 10,
enabled: true,
});
}
}
}
async function createSystemAdmin(
server: ReturnType<INestApplication['getHttpServer']>,
) {
const response = await request(server).post('/auth/system-admin').send({
name: 'Seed System Admin',
phone: E2E_SEED_CREDENTIALS[Role.SYSTEM_ADMIN].phone,
password: E2E_SEED_PASSWORD,
openId: OPEN_IDS.systemAdmin,
systemAdminBootstrapKey: process.env.SYSTEM_ADMIN_BOOTSTRAP_KEY,
});
expectSuccessEnvelope(response, 201);
}
async function loginByCredential(
server: ReturnType<INestApplication['getHttpServer']>,
payload: {
phone: string;
password: string;
role: Role;
hospitalId?: number;
},
) {
const response = await request(server).post('/auth/login').send(payload);
expectSuccessEnvelope(response, 201);
return response.body.data.accessToken as string;
}
async function createWithToken(
server: ReturnType<INestApplication['getHttpServer']>,
token: string,
path: string,
payload: Record<string, unknown>,
) {
const response = await request(server)
.post(path)
.set('Authorization', `Bearer ${token}`)
.send(payload);
expectSuccessEnvelope(response, 201);
return response.body.data;
}
async function patchWithToken(
server: ReturnType<INestApplication['getHttpServer']>,
token: string,
path: string,
payload: Record<string, unknown>,
) {
const response = await request(server)
.patch(path)
.set('Authorization', `Bearer ${token}`)
.send(payload);
expectSuccessEnvelope(response, 200);
return response.body.data;
}
async function requireUserScope(
prisma: PrismaService,
openId: string,
@ -63,16 +679,30 @@ async function requireUserScope(
return user;
}
async function requireCatalogId(
prisma: PrismaService,
modelCode: string,
): Promise<number> {
const catalog = await prisma.implantCatalog.findFirst({
where: { modelCode },
select: { id: true },
});
if (!catalog) {
throw new NotFoundException(`Seed catalog not found: ${modelCode}`);
}
return catalog.id;
}
async function requireDeviceId(
prisma: PrismaService,
snCode: string,
implantNotes: string,
): Promise<number> {
const device = await prisma.device.findUnique({
where: { snCode },
const device = await prisma.device.findFirst({
where: { implantNotes },
select: { id: true },
});
if (!device) {
throw new NotFoundException(`Seed device not found: ${snCode}`);
throw new NotFoundException(`Seed device not found: ${implantNotes}`);
}
return device.id;
}
@ -96,22 +726,16 @@ async function requirePatientId(
export async function loadSeedFixtures(
prisma: PrismaService,
): Promise<E2ESeedFixtures> {
const systemAdmin = await requireUserScope(
prisma,
'seed-system-admin-openid',
);
const hospitalAdminA = await requireUserScope(
prisma,
'seed-hospital-admin-a-openid',
);
const directorA = await requireUserScope(prisma, 'seed-director-a-openid');
const leaderA = await requireUserScope(prisma, 'seed-leader-a-openid');
const doctorA = await requireUserScope(prisma, 'seed-doctor-a-openid');
const doctorA2 = await requireUserScope(prisma, 'seed-doctor-a2-openid');
const doctorA3 = await requireUserScope(prisma, 'seed-doctor-a3-openid');
const doctorB = await requireUserScope(prisma, 'seed-doctor-b-openid');
const engineerA = await requireUserScope(prisma, 'seed-engineer-a-openid');
const engineerB = await requireUserScope(prisma, 'seed-engineer-b-openid');
const systemAdmin = await requireUserScope(prisma, OPEN_IDS.systemAdmin);
const hospitalAdminA = await requireUserScope(prisma, OPEN_IDS.hospitalAdminA);
const directorA = await requireUserScope(prisma, OPEN_IDS.directorA);
const leaderA = await requireUserScope(prisma, OPEN_IDS.leaderA);
const doctorA = await requireUserScope(prisma, OPEN_IDS.doctorA);
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);
const engineerB = await requireUserScope(prisma, OPEN_IDS.engineerB);
const hospitalAId = hospitalAdminA.hospitalId;
const hospitalBId = doctorB.hospitalId;
@ -144,6 +768,16 @@ export async function loadSeedFixtures(
groupA1Id,
groupA2Id,
groupB1Id,
catalogs: {
adjustableValveId: await requireCatalogId(
prisma,
FIXTURE_NAMES.adjustableCatalog,
),
highPressureValveId: await requireCatalogId(
prisma,
FIXTURE_NAMES.highPressureCatalog,
),
},
users: {
systemAdminId: systemAdmin.id,
hospitalAdminAId: hospitalAdminA.id,
@ -160,8 +794,8 @@ export async function loadSeedFixtures(
patientA1Id: await requirePatientId(
prisma,
hospitalAId,
'13800002001',
'110101199001010011',
SHARED_PATIENT_IDENTITY.phone,
SHARED_PATIENT_IDENTITY.idCard,
),
patientA2Id: await requirePatientId(
prisma,
@ -178,16 +812,16 @@ export async function loadSeedFixtures(
patientB1Id: await requirePatientId(
prisma,
hospitalBId,
'13800002001',
'110101199001010011',
SHARED_PATIENT_IDENTITY.phone,
SHARED_PATIENT_IDENTITY.idCard,
),
},
devices: {
deviceA1Id: await requireDeviceId(prisma, 'SEED-SN-A-001'),
deviceA2Id: await requireDeviceId(prisma, 'SEED-SN-A-002'),
deviceA3Id: await requireDeviceId(prisma, 'SEED-SN-A-003'),
deviceA4InactiveId: await requireDeviceId(prisma, 'SEED-SN-A-004'),
deviceB1Id: await requireDeviceId(prisma, 'SEED-SN-B-001'),
deviceA1Id: await requireDeviceId(prisma, 'Seed A1 当前在用设备'),
deviceA2Id: await requireDeviceId(prisma, 'Seed A2 当前在用设备'),
deviceA3Id: await requireDeviceId(prisma, 'Seed A3 当前在用设备'),
deviceA4InactiveId: await requireDeviceId(prisma, 'Seed A1 弃用历史设备'),
deviceB1Id: await requireDeviceId(prisma, 'Seed B1 当前在用设备'),
},
};
}

View File

@ -28,7 +28,6 @@ describe('BDevicesController (e2e)', () => {
.post('/b/devices')
.set('Authorization', `Bearer ${token}`)
.send({
snCode: uniqueSeedValue('device-sn'),
status: DeviceStatus.ACTIVE,
patientId,
});
@ -36,8 +35,7 @@ describe('BDevicesController (e2e)', () => {
expectSuccessEnvelope(response, 201);
return response.body.data as {
id: number;
snCode: string;
currentPressure: number;
currentPressure: string;
status: DeviceStatus;
patient: { id: number };
};
@ -118,24 +116,28 @@ describe('BDevicesController (e2e)', () => {
manufacturer: 'Global Vendor',
name: '全局可调压阀',
isPressureAdjustable: true,
pressureLevels: [70, 90, 110],
pressureLevels: ['10.0', '20', '30.0'],
notes: '测试全局目录',
});
expectSuccessEnvelope(createResponse, 201);
expect(createResponse.body.data.pressureLevels).toEqual([70, 90, 110]);
expect(createResponse.body.data.pressureLevels).toEqual(['10', '20', '30']);
const updateResponse = await request(ctx.app.getHttpServer())
.patch(`/b/devices/catalogs/${createResponse.body.data.id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({
name: '全局可调压阀-更新版',
pressureLevels: [80, 100, 120],
pressureLevels: ['0.5', '1.0', '1.50'],
});
expectSuccessEnvelope(updateResponse, 200);
expect(updateResponse.body.data.name).toBe('全局可调压阀-更新版');
expect(updateResponse.body.data.pressureLevels).toEqual([80, 100, 120]);
expect(updateResponse.body.data.pressureLevels).toEqual([
'0.5',
'1',
'1.5',
]);
const deleteResponse = await request(ctx.app.getHttpServer())
.delete(`/b/devices/catalogs/${createResponse.body.data.id}`)
@ -166,7 +168,7 @@ describe('BDevicesController (e2e)', () => {
manufacturer: 'Role Matrix Vendor',
name: '角色矩阵目录',
isPressureAdjustable: true,
pressureLevels: [50, 80],
pressureLevels: ['10', '20'],
}),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
@ -188,9 +190,9 @@ describe('BDevicesController (e2e)', () => {
);
expect(created.status).toBe(DeviceStatus.ACTIVE);
expect(created.currentPressure).toBe(0);
expect(created.currentPressure).toBe('0');
expect(created.patient.id).toBe(ctx.fixtures.patients.patientA1Id);
expect(created.snCode).toMatch(/^DEVICE-SN-/);
expect(created).not.toHaveProperty('snCode');
});
it('失败HOSPITAL_ADMIN 绑定跨院患者返回 403', async () => {
@ -198,7 +200,6 @@ describe('BDevicesController (e2e)', () => {
.post('/b/devices')
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({
snCode: uniqueSeedValue('cross-hospital-device'),
status: DeviceStatus.ACTIVE,
patientId: ctx.fixtures.patients.patientB1Id,
});
@ -225,7 +226,7 @@ describe('BDevicesController (e2e)', () => {
expect(response.body.data.patient.id).toBe(
ctx.fixtures.patients.patientA2Id,
);
expect(response.body.data.currentPressure).toBe(0);
expect(response.body.data.currentPressure).toBe('0');
});
it('失败:设备实例接口不允许手工更新 currentPressure', async () => {
@ -238,7 +239,7 @@ describe('BDevicesController (e2e)', () => {
.patch(`/b/devices/${created.id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({
currentPressure: 99,
currentPressure: '1.5',
});
expectErrorEnvelope(response, 400, 'currentPressure should not exist');

View File

@ -321,7 +321,7 @@ describe('Organization Controllers (e2e)', () => {
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403未登录 401', async () => {
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER/DOCTOR 可访问,其余角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /b/organization/departments role matrix',
tokens: ctx.tokens,
@ -330,7 +330,7 @@ describe('Organization Controllers (e2e)', () => {
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 200,
[Role.LEADER]: 200,
[Role.DOCTOR]: 403,
[Role.DOCTOR]: 200,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
@ -361,7 +361,7 @@ describe('Organization Controllers (e2e)', () => {
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403未登录 401', async () => {
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER/DOCTOR 可访问,其余角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /b/organization/departments/:id role matrix',
tokens: ctx.tokens,
@ -370,7 +370,7 @@ describe('Organization Controllers (e2e)', () => {
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 200,
[Role.LEADER]: 200,
[Role.DOCTOR]: 403,
[Role.DOCTOR]: 200,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
@ -413,15 +413,15 @@ describe('Organization Controllers (e2e)', () => {
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可进入业务,其余角色 403未登录 401', async () => {
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其余角色 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]: 404,
[Role.LEADER]: 404,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
@ -516,14 +516,14 @@ describe('Organization Controllers (e2e)', () => {
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可进入业务,其余角色 403未登录 401', async () => {
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其余角色 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]: 400,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
@ -558,7 +558,7 @@ describe('Organization Controllers (e2e)', () => {
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403未登录 401', async () => {
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER/DOCTOR 可访问,其余角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /b/organization/groups role matrix',
tokens: ctx.tokens,
@ -567,7 +567,7 @@ describe('Organization Controllers (e2e)', () => {
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 200,
[Role.LEADER]: 200,
[Role.DOCTOR]: 403,
[Role.DOCTOR]: 200,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
@ -598,7 +598,7 @@ describe('Organization Controllers (e2e)', () => {
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其余角色 403未登录 401', async () => {
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER/DOCTOR 可访问,其余角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /b/organization/groups/:id role matrix',
tokens: ctx.tokens,
@ -607,7 +607,7 @@ describe('Organization Controllers (e2e)', () => {
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 200,
[Role.LEADER]: 200,
[Role.DOCTOR]: 403,
[Role.DOCTOR]: 200,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
@ -650,15 +650,15 @@ describe('Organization Controllers (e2e)', () => {
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可进入业务,其余角色 403未登录 401', async () => {
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其余角色 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]: 404,
[Role.LEADER]: 404,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
@ -710,14 +710,14 @@ describe('Organization Controllers (e2e)', () => {
expectErrorEnvelope(response, 409, '小组下仍有成员,无法删除');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可进入业务,其余角色 403未登录 401', async () => {
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其余角色 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]: 404,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,

View File

@ -1,9 +1,5 @@
import request from 'supertest';
import {
DeviceStatus,
Role,
TaskStatus,
} from '../../../src/generated/prisma/enums.js';
import { DeviceStatus, Role } from '../../../src/generated/prisma/enums.js';
import {
closeE2EContext,
createE2EContext,
@ -201,12 +197,6 @@ describe('Patients Controllers (e2e)', () => {
describe('患者手术录入', () => {
it('成功DOCTOR 可创建患者并附带首台手术和植入设备', async () => {
const adjustableCatalog = await ctx.prisma.implantCatalog.findFirst({
where: { modelCode: 'SEED-ADJUSTABLE-VALVE' },
select: { id: true },
});
expect(adjustableCatalog).toBeTruthy();
const response = await request(ctx.app.getHttpServer())
.post('/b/patients')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
@ -220,18 +210,17 @@ describe('Patients Controllers (e2e)', () => {
initialSurgery: {
surgeryDate: '2026-03-19T08:00:00.000Z',
surgeryName: '脑室腹腔分流术',
surgeonName: 'Seed Doctor A',
preOpPressure: 20,
primaryDisease: '梗阻性脑积水',
hydrocephalusTypes: ['交通性'],
devices: [
{
implantCatalogId: adjustableCatalog!.id,
implantCatalogId: ctx.fixtures.catalogs.adjustableValveId,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: 120,
initialPressure: '1',
implantNotes: '首术植入',
labelImageUrl:
'https://seed.example.com/tests/first-surgery.jpg',
@ -241,8 +230,18 @@ describe('Patients Controllers (e2e)', () => {
});
expectSuccessEnvelope(response, 201);
expect(response.body.data.creatorId).toBe(ctx.fixtures.users.doctorAId);
expect(response.body.data.creator).toEqual(
expect.objectContaining({
id: ctx.fixtures.users.doctorAId,
}),
);
expect(response.body.data.createdAt).toBeTruthy();
expect(response.body.data.shuntSurgeryCount).toBe(1);
expect(response.body.data.surgeries).toHaveLength(1);
expect(response.body.data.surgeries[0].surgeonId).toBe(
ctx.fixtures.users.doctorAId,
);
expect(response.body.data.surgeries[0].devices).toHaveLength(1);
expect(response.body.data.surgeries[0].devices[0].implantModel).toBe(
'SEED-ADJUSTABLE-VALVE',
@ -250,12 +249,6 @@ describe('Patients Controllers (e2e)', () => {
});
it('成功:新增二次手术时可弃用旧设备且保留旧设备调压历史', async () => {
const adjustableCatalog = await ctx.prisma.implantCatalog.findFirst({
where: { modelCode: 'SEED-ADJUSTABLE-VALVE' },
select: { id: true },
});
expect(adjustableCatalog).toBeTruthy();
const createPatientResponse = await request(ctx.app.getHttpServer())
.post('/b/patients')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
@ -269,18 +262,17 @@ describe('Patients Controllers (e2e)', () => {
initialSurgery: {
surgeryDate: '2026-03-01T08:00:00.000Z',
surgeryName: '首次分流术',
surgeonName: 'Seed Doctor A',
preOpPressure: 18,
primaryDisease: '出血后脑积水',
hydrocephalusTypes: ['高压性'],
devices: [
{
implantCatalogId: adjustableCatalog!.id,
implantCatalogId: ctx.fixtures.catalogs.adjustableValveId,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: 100,
initialPressure: '1',
implantNotes: '首术设备',
labelImageUrl:
'https://seed.example.com/tests/initial-device.jpg',
@ -296,23 +288,25 @@ describe('Patients Controllers (e2e)', () => {
};
const oldDeviceId = patient.devices[0].id;
await ctx.prisma.task.create({
data: {
status: TaskStatus.COMPLETED,
creatorId: ctx.fixtures.users.doctorAId,
const publishResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
engineerId: ctx.fixtures.users.engineerAId,
hospitalId: ctx.fixtures.hospitalAId,
items: {
create: [
items: [
{
deviceId: oldDeviceId,
oldPressure: 100,
targetPressure: 120,
targetPressure: '1.5',
},
],
},
},
});
expectSuccessEnvelope(publishResponse, 201);
const completeResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: publishResponse.body.data.id });
expectSuccessEnvelope(completeResponse, 201);
const surgeryResponse = await request(ctx.app.getHttpServer())
.post(`/b/patients/${patient.id}/surgeries`)
@ -320,29 +314,28 @@ describe('Patients Controllers (e2e)', () => {
.send({
surgeryDate: '2026-03-18T08:00:00.000Z',
surgeryName: '二次翻修术',
surgeonName: 'Seed Doctor A',
preOpPressure: 16,
primaryDisease: '分流功能障碍',
hydrocephalusTypes: ['交通性', '高压性'],
abandonedDeviceIds: [oldDeviceId],
devices: [
{
implantCatalogId: adjustableCatalog!.id,
implantCatalogId: ctx.fixtures.catalogs.adjustableValveId,
shuntMode: 'VPS',
proximalPunctureAreas: ['枕角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: 120,
initialPressure: '1',
implantNotes: '二次手术新设备-1',
labelImageUrl: 'https://seed.example.com/tests/revision-1.jpg',
},
{
implantCatalogId: adjustableCatalog!.id,
implantCatalogId: ctx.fixtures.catalogs.adjustableValveId,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['胸前'],
distalShuntDirection: '胸腔',
initialPressure: 140,
initialPressure: '1.5',
implantNotes: '二次手术新设备-2',
labelImageUrl: 'https://seed.example.com/tests/revision-2.jpg',
},
@ -364,12 +357,6 @@ describe('Patients Controllers (e2e)', () => {
});
it('失败:手术录入设备不允许手工传 currentPressure', async () => {
const adjustableCatalog = await ctx.prisma.implantCatalog.findFirst({
where: { modelCode: 'SEED-ADJUSTABLE-VALVE' },
select: { id: true },
});
expect(adjustableCatalog).toBeTruthy();
const response = await request(ctx.app.getHttpServer())
.post('/b/patients')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
@ -383,18 +370,17 @@ describe('Patients Controllers (e2e)', () => {
initialSurgery: {
surgeryDate: '2026-03-19T08:00:00.000Z',
surgeryName: '脑室腹腔分流术',
surgeonName: 'Seed Doctor A',
primaryDisease: '梗阻性脑积水',
hydrocephalusTypes: ['交通性'],
devices: [
{
implantCatalogId: adjustableCatalog!.id,
implantCatalogId: ctx.fixtures.catalogs.adjustableValveId,
shuntMode: 'VPS',
proximalPunctureAreas: ['额角'],
valvePlacementSites: ['耳后'],
distalShuntDirection: '腹腔',
initialPressure: 120,
currentPressure: 120,
initialPressure: '1',
currentPressure: '1',
},
],
},

View File

@ -22,11 +22,17 @@ describe('BTasksController (e2e)', () => {
await closeE2EContext(ctx);
});
async function publishPendingTask(deviceId: number, targetPressure: number) {
async function publishAssignedTask(
deviceId: number,
targetPressure: string,
actorToken = ctx.tokens[Role.DOCTOR],
engineerId = ctx.fixtures.users.engineerAId,
) {
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.set('Authorization', `Bearer ${actorToken}`)
.send({
engineerId,
items: [
{
deviceId,
@ -36,11 +42,123 @@ describe('BTasksController (e2e)', () => {
});
expectSuccessEnvelope(response, 201);
return response.body.data as { id: number; status: TaskStatus };
return response.body.data as {
id: number;
status: TaskStatus;
engineerId: number;
hospitalId: number;
};
}
describe('GET /b/tasks/engineers', () => {
it('成功DOCTOR 可查看本院可选工程师', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/b/tasks/engineers')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
expectSuccessEnvelope(response, 200);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: ctx.fixtures.users.engineerAId,
}),
]),
);
});
it('成功SYSTEM_ADMIN 可按医院筛选工程师', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/b/tasks/engineers')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.query({ hospitalId: ctx.fixtures.hospitalBId });
expectSuccessEnvelope(response, 200);
expect(response.body.data).toEqual([
expect.objectContaining({
id: ctx.fixtures.users.engineerBId,
hospitalId: ctx.fixtures.hospitalBId,
}),
]);
});
it('失败hospitalId 非法返回 400', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/b/tasks/engineers')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.query({ hospitalId: 0 });
expectErrorEnvelope(response, 400, 'hospitalId 必须大于 0');
});
});
describe('GET /b/tasks', () => {
it('成功SYSTEM_ADMIN 可查看跨医院调压记录', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/b/tasks')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.query({ page: 1, pageSize: 20 });
expectSuccessEnvelope(response, 200);
expect(Array.isArray(response.body.data.list)).toBe(true);
expect(response.body.data.total).toBeGreaterThan(0);
expect(
response.body.data.list.every(
(item: {
creator?: { id?: number; name?: string };
engineer?: { id?: number; name?: string };
}) =>
Number.isInteger(item.creator?.id) &&
Boolean(item.creator?.name) &&
Number.isInteger(item.engineer?.id) &&
Boolean(item.engineer?.name),
),
).toBe(true);
const hospitalIds = Array.from(
new Set(
response.body.data.list
.map((item: { hospital?: { id?: number } }) => item.hospital?.id)
.filter(Boolean),
),
);
expect(hospitalIds).toEqual(
expect.arrayContaining([
ctx.fixtures.hospitalAId,
ctx.fixtures.hospitalBId,
]),
);
});
it('成功DOCTOR 仅可查看本院调压记录', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/b/tasks')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.query({ page: 1, pageSize: 20 });
expectSuccessEnvelope(response, 200);
expect(Array.isArray(response.body.data.list)).toBe(true);
expect(response.body.data.total).toBeGreaterThan(0);
expect(
response.body.data.list.every(
(item: { hospital?: { id?: number } }) =>
item.hospital?.id === ctx.fixtures.hospitalAId,
),
).toBe(true);
});
it('失败hospitalId 非法返回 400', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/b/tasks')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.query({ hospitalId: 0 });
expectErrorEnvelope(response, 400, 'hospitalId 必须大于 0');
});
});
describe('POST /b/tasks/publish', () => {
it('成功DOCTOR 可发布任务', async () => {
it('成功DOCTOR 发布任务时必须直接指定接收工程师', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
@ -49,16 +167,30 @@ describe('BTasksController (e2e)', () => {
items: [
{
deviceId: ctx.fixtures.devices.deviceA2Id,
targetPressure: 120,
targetPressure: '1.5',
},
],
});
expectSuccessEnvelope(response, 201);
expect(response.body.data.status).toBe(TaskStatus.PENDING);
expect(response.body.data.status).toBe(TaskStatus.ACCEPTED);
expect(response.body.data.engineerId).toBe(
ctx.fixtures.users.engineerAId,
);
});
it('失败:可调压设备使用非法挡位返回 400', async () => {
it('成功SYSTEM_ADMIN 可按设备自动归院发布任务', async () => {
const task = await publishAssignedTask(
ctx.fixtures.devices.deviceA1Id,
'1.5',
ctx.tokens[Role.SYSTEM_ADMIN],
);
expect(task.status).toBe(TaskStatus.ACCEPTED);
expect(task.hospitalId).toBe(ctx.fixtures.hospitalAId);
});
it('失败:未指定接收工程师返回 400', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
@ -66,7 +198,24 @@ describe('BTasksController (e2e)', () => {
items: [
{
deviceId: ctx.fixtures.devices.deviceA2Id,
targetPressure: 126,
targetPressure: '1.5',
},
],
});
expectErrorEnvelope(response, 400, 'engineerId 必须是整数');
});
it('失败:可调压设备使用非法挡位返回 400', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
engineerId: ctx.fixtures.users.engineerAId,
items: [
{
deviceId: ctx.fixtures.devices.deviceA2Id,
targetPressure: '2',
},
],
});
@ -79,10 +228,11 @@ describe('BTasksController (e2e)', () => {
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
engineerId: ctx.fixtures.users.engineerAId,
items: [
{
deviceId: ctx.fixtures.devices.deviceB1Id,
targetPressure: 120,
targetPressure: '1.5',
},
],
});
@ -90,13 +240,13 @@ describe('BTasksController (e2e)', () => {
expectErrorEnvelope(response, 404, '存在设备不在当前医院或设备不存在');
});
it('角色矩阵:DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 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.SYSTEM_ADMIN]: 400,
[Role.HOSPITAL_ADMIN]: 400,
[Role.DIRECTOR]: 400,
[Role.LEADER]: 400,
[Role.DOCTOR]: 400,
@ -114,10 +264,10 @@ describe('BTasksController (e2e)', () => {
});
describe('POST /b/tasks/accept', () => {
it('成功ENGINEER 可接收待处理任务', async () => {
const task = await publishPendingTask(
it('失败ENGINEER 接收接口已停用,返回 403', async () => {
const task = await publishAssignedTask(
ctx.fixtures.devices.deviceA2Id,
140,
'1.5',
);
const response = await request(ctx.app.getHttpServer())
@ -125,43 +275,14 @@ describe('BTasksController (e2e)', () => {
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectSuccessEnvelope(response, 201);
expect(response.body.data.status).toBe(TaskStatus.ACCEPTED);
expect(response.body.data.engineerId).toBe(
ctx.fixtures.users.engineerAId,
expectErrorEnvelope(
response,
403,
'当前流程不支持工程师接收,请由创建人直接指定接收工程师',
);
});
it('失败:接收不存在任务返回 404', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: 99999999 });
expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院');
});
it('状态机失败:重复接收返回 409', async () => {
const task = await publishPendingTask(
ctx.fixtures.devices.deviceA3Id,
120,
);
const firstAccept = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectSuccessEnvelope(firstAccept, 201);
const secondAccept = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectErrorEnvelope(secondAccept, 409, '仅待接收任务可执行接收');
});
it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403未登录 401', async () => {
it('角色矩阵:接收接口对所有角色都不可用,未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /b/tasks/accept role matrix',
tokens: ctx.tokens,
@ -171,7 +292,7 @@ describe('BTasksController (e2e)', () => {
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 404,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
@ -187,19 +308,13 @@ describe('BTasksController (e2e)', () => {
});
describe('POST /b/tasks/complete', () => {
it('成功ENGINEER 完成已接收任务并同步设备压力', async () => {
const targetPressure = 140;
const task = await publishPendingTask(
it('成功ENGINEER 可直接完成已指派任务并同步设备压力', async () => {
const targetPressure = '1.5';
const task = await publishAssignedTask(
ctx.fixtures.devices.deviceA1Id,
targetPressure,
);
const acceptResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectSuccessEnvelope(acceptResponse, 201);
const completeResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
@ -224,18 +339,24 @@ describe('BTasksController (e2e)', () => {
expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院');
});
it('状态机失败:未接收任务直接完成返回 409', async () => {
const task = await publishPendingTask(
it('状态机失败:重复完成返回 409', async () => {
const task = await publishAssignedTask(
ctx.fixtures.devices.deviceA2Id,
100,
'1',
);
const response = await request(ctx.app.getHttpServer())
const firstComplete = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectSuccessEnvelope(firstComplete, 201);
const secondComplete = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectErrorEnvelope(response, 409, '仅已接收任务可执行完成');
expectErrorEnvelope(secondComplete, 409, '仅已指派任务可执行完成');
});
it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403未登录 401', async () => {
@ -264,10 +385,10 @@ describe('BTasksController (e2e)', () => {
});
describe('POST /b/tasks/cancel', () => {
it('成功DOCTOR 可取消自己创建的任务', async () => {
const task = await publishPendingTask(
it('成功DOCTOR 可取消自己创建的已指派任务', async () => {
const task = await publishAssignedTask(
ctx.fixtures.devices.deviceA3Id,
120,
'1.5',
);
const response = await request(ctx.app.getHttpServer())
@ -289,17 +410,11 @@ describe('BTasksController (e2e)', () => {
});
it('状态机失败:已完成任务不可取消返回 409', async () => {
const task = await publishPendingTask(
const task = await publishAssignedTask(
ctx.fixtures.devices.deviceA2Id,
160,
'1.5',
);
const acceptResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectSuccessEnvelope(acceptResponse, 201);
const completeResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
@ -311,16 +426,16 @@ describe('BTasksController (e2e)', () => {
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({ taskId: task.id });
expectErrorEnvelope(cancelResponse, 409, '仅待接收/已接收任务可取消');
expectErrorEnvelope(cancelResponse, 409, '仅待指派/已指派任务可取消');
});
it('角色矩阵:DOCTOR/DIRECTOR/LEADER 可进入业务,其余角色 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.SYSTEM_ADMIN]: 404,
[Role.HOSPITAL_ADMIN]: 404,
[Role.DIRECTOR]: 404,
[Role.LEADER]: 404,
[Role.DOCTOR]: 404,

View File

@ -0,0 +1,225 @@
import { spawn } from 'node:child_process';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import ffmpegPath from 'ffmpeg-static';
import sharp from 'sharp';
import request from 'supertest';
import { 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,
} from '../helpers/e2e-http.helper.js';
describe('BUploadsController (e2e)', () => {
let ctx: E2EContext;
let tempDir = '';
let sampleVideoPath = '';
let samplePngBuffer: Buffer;
beforeAll(async () => {
ctx = await createE2EContext();
tempDir = await mkdtemp(join(tmpdir(), 'upload-assets-e2e-'));
sampleVideoPath = join(tempDir, 'ct-video.mov');
await createSampleVideo(sampleVideoPath);
samplePngBuffer = await sharp({
create: {
width: 32,
height: 32,
channels: 3,
background: { r: 24, g: 46, b: 82 },
},
})
.png()
.toBuffer();
});
afterAll(async () => {
await closeE2EContext(ctx);
await rm(tempDir, { recursive: true, force: true });
});
it('成功HOSPITAL_ADMIN 可上传图片', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/uploads')
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.attach('file', samplePngBuffer, {
filename: 'ct-image.png',
contentType: 'image/png',
});
expectSuccessEnvelope(response, 201);
expect(response.body.data.type).toBe('IMAGE');
expect(response.body.data.mimeType).toBe('image/webp');
expect(response.body.data.fileName).toMatch(/^\d{14}-ct-image\.webp$/);
expect(response.body.data.storagePath).toMatch(
/^\d{4}\/\d{2}\/\d{2}\/\d{14}-ct-image\.webp$/,
);
expect(response.body.data.url).toMatch(
/^\/uploads\/\d{4}\/\d{2}\/\d{2}\/\d{14}-ct-image\.webp$/,
);
expect(response.body.data.hospital.id).toBe(ctx.fixtures.hospitalAId);
});
it('成功HOSPITAL_ADMIN 可上传视频并转为 mp4', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/uploads')
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.attach('file', sampleVideoPath, {
filename: 'ct-video.mov',
contentType: 'video/quicktime',
});
expectSuccessEnvelope(response, 201);
expect(response.body.data.type).toBe('VIDEO');
expect(response.body.data.mimeType).toBe('video/mp4');
expect(response.body.data.fileName).toMatch(/^\d{14}-ct-video\.mp4$/);
expect(response.body.data.storagePath).toMatch(
/^\d{4}\/\d{2}\/\d{2}\/\d{14}-ct-video\.mp4$/,
);
expect(response.body.data.url).toMatch(
/^\/uploads\/\d{4}\/\d{2}\/\d{2}\/\d{14}-ct-video\.mp4$/,
);
});
it('失败SYSTEM_ADMIN 上传文件时未指定医院返回 400', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/uploads')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.attach('file', samplePngBuffer, {
filename: 'ct-image.png',
contentType: 'image/png',
});
expectErrorEnvelope(response, 400, '系统管理员上传文件时必须显式指定 hospitalId');
});
it('失败DIRECTOR 查询影像库返回 403', async () => {
await request(ctx.app.getHttpServer())
.post('/b/uploads')
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.attach('file', samplePngBuffer, {
filename: 'seed-image.png',
contentType: 'image/png',
});
const response = await request(ctx.app.getHttpServer())
.get('/b/uploads')
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
expectErrorEnvelope(response, 403, '无权限执行当前操作');
});
it('角色矩阵:上传允许系统/医院/主任/组长/医生,工程师 403未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /b/uploads role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 400,
[Role.HOSPITAL_ADMIN]: 201,
[Role.DIRECTOR]: 201,
[Role.LEADER]: 201,
[Role.DOCTOR]: 201,
[Role.ENGINEER]: 403,
},
sendAsRole: async (role, token) => {
const req = request(ctx.app.getHttpServer())
.post('/b/uploads')
.set('Authorization', `Bearer ${token}`)
.attach('file', samplePngBuffer, {
filename: `${role.toLowerCase()}.png`,
contentType: 'image/png',
});
if (role === Role.SYSTEM_ADMIN) {
return req;
}
return req;
},
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.post('/b/uploads')
.attach('file', samplePngBuffer, {
filename: 'anonymous.png',
contentType: 'image/png',
}),
});
});
it('角色矩阵:上传列表仅系统/医院管理员可访问,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /b/uploads 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) => {
const req = request(ctx.app.getHttpServer())
.get('/b/uploads')
.set('Authorization', `Bearer ${token}`);
if (role === Role.SYSTEM_ADMIN) {
req.query({ hospitalId: ctx.fixtures.hospitalAId });
}
return req;
},
sendWithoutToken: async () => request(ctx.app.getHttpServer()).get('/b/uploads'),
});
});
});
async function createSampleVideo(outputPath: string) {
const command = ffmpegPath as unknown as string | null;
if (!command) {
throw new Error('ffmpeg-static not available');
}
const args = [
'-y',
'-f',
'lavfi',
'-i',
'color=c=black:s=320x240:d=1',
'-f',
'lavfi',
'-i',
'anullsrc=r=44100:cl=stereo',
'-shortest',
'-c:v',
'libx264',
'-pix_fmt',
'yuv420p',
'-c:a',
'aac',
'-movflags',
'+faststart',
outputPath,
];
await new Promise<void>((resolve, reject) => {
const child = spawn(command, args);
let stderr = '';
child.stderr?.on('data', (chunk) => {
stderr += String(chunk);
});
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(stderr.trim() || 'failed to create sample video'));
});
});
}

View File

@ -43,6 +43,25 @@ describe('UsersController + BUsersController (e2e)', () => {
return response.body.data as { id: number; name: string };
}
async function createLeaderUser(token: string) {
const response = await request(ctx.app.getHttpServer())
.post('/users')
.set('Authorization', `Bearer ${token}`)
.send({
name: uniqueSeedValue('用户-组长'),
phone: uniquePhone(),
password: 'Seed@1234',
role: Role.LEADER,
hospitalId: ctx.fixtures.hospitalAId,
departmentId: ctx.fixtures.departmentA1Id,
groupId: ctx.fixtures.groupA1Id,
openId: uniqueSeedValue('users-leader-openid'),
});
expectSuccessEnvelope(response, 201);
return response.body.data as { id: number; name: string };
}
async function createEngineerUser(token: string) {
const response = await request(ctx.app.getHttpServer())
.post('/users')
@ -65,8 +84,12 @@ describe('UsersController + BUsersController (e2e)', () => {
await createDoctorUser(ctx.tokens[Role.SYSTEM_ADMIN]);
});
it('成功DIRECTOR 可创建本科室医生', async () => {
await createDoctorUser(ctx.tokens[Role.DIRECTOR]);
it('成功HOSPITAL_ADMIN 可创建本院医生', async () => {
await createDoctorUser(ctx.tokens[Role.HOSPITAL_ADMIN]);
});
it('成功HOSPITAL_ADMIN 可创建本院组长', async () => {
await createLeaderUser(ctx.tokens[Role.HOSPITAL_ADMIN]);
});
it('失败:参数校验失败返回 400', async () => {
@ -86,31 +109,31 @@ describe('UsersController + BUsersController (e2e)', () => {
expectErrorEnvelope(response, 400, 'phone 必须是合法手机号');
});
it('失败DIRECTOR 创建非医生角色返回 403', async () => {
it('失败DIRECTOR 创建用户返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/users')
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`)
.send({
name: uniqueSeedValue('主任创建组长'),
name: uniqueSeedValue('主任创建用户'),
phone: uniquePhone(),
password: 'Seed@1234',
role: Role.LEADER,
role: Role.DOCTOR,
hospitalId: ctx.fixtures.hospitalAId,
departmentId: ctx.fixtures.departmentA1Id,
groupId: ctx.fixtures.groupA1Id,
});
expectErrorEnvelope(response, 403, '当前角色无权限创建该用户');
expectErrorEnvelope(response, 403, '无权限执行当前操作');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可进入业务,其他角色 403未登录 401', async () => {
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /users role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 400,
[Role.HOSPITAL_ADMIN]: 400,
[Role.DIRECTOR]: 400,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
@ -136,6 +159,34 @@ describe('UsersController + BUsersController (e2e)', () => {
expect(Array.isArray(response.body.data)).toBe(true);
});
it('成功DIRECTOR 可查看本科室下级列表', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/users')
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
expectSuccessEnvelope(response, 200);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBeGreaterThan(0);
response.body.data.forEach((item: { role: Role; departmentId: number }) => {
expect([Role.DOCTOR, Role.LEADER]).toContain(item.role);
expect(item.departmentId).toBe(ctx.fixtures.departmentA1Id);
});
});
it('成功LEADER 可查看本组医生列表', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/users')
.set('Authorization', `Bearer ${ctx.tokens[Role.LEADER]}`);
expectSuccessEnvelope(response, 200);
expect(Array.isArray(response.body.data)).toBe(true);
expect(response.body.data.length).toBeGreaterThan(0);
response.body.data.forEach((item: { role: Role; groupId: number }) => {
expect(item.role).toBe(Role.DOCTOR);
expect(item.groupId).toBe(ctx.fixtures.groupA1Id);
});
});
it('失败:未登录返回 401', async () => {
const response = await request(ctx.app.getHttpServer()).get('/users');
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
@ -173,15 +224,6 @@ describe('UsersController + BUsersController (e2e)', () => {
expect(response.body.data.id).toBe(ctx.fixtures.users.doctorAId);
});
it('成功DIRECTOR 可查询本科室医生详情', async () => {
const response = await request(ctx.app.getHttpServer())
.get(`/users/${ctx.fixtures.users.doctorAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data.id).toBe(ctx.fixtures.users.doctorAId);
});
it('失败:查询不存在用户返回 404', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/users/99999999')
@ -190,15 +232,43 @@ describe('UsersController + BUsersController (e2e)', () => {
expectErrorEnvelope(response, 404, '用户不存在');
});
it('失败DIRECTOR 查询非本科室医生返回 403', async () => {
it('成功DIRECTOR 可查询本科室医生详情', async () => {
const response = await request(ctx.app.getHttpServer())
.get(`/users/${ctx.fixtures.users.doctorAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data.id).toBe(ctx.fixtures.users.doctorAId);
expect(response.body.data.departmentId).toBe(ctx.fixtures.departmentA1Id);
});
it('成功LEADER 可查询本组医生详情', async () => {
const response = await request(ctx.app.getHttpServer())
.get(`/users/${ctx.fixtures.users.doctorAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.LEADER]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data.id).toBe(ctx.fixtures.users.doctorAId);
expect(response.body.data.groupId).toBe(ctx.fixtures.groupA1Id);
});
it('失败DIRECTOR 查询跨科室医生返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.get(`/users/${ctx.fixtures.users.doctorA3Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
expectErrorEnvelope(response, 403, '科室主任仅可操作本科室医生账号');
expectErrorEnvelope(response, 403, '无权限执行当前操作');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可访问,其他角色 403未登录 401', async () => {
it('失败LEADER 查询其他小组医生返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.get(`/users/${ctx.fixtures.users.doctorA3Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.LEADER]}`);
expectErrorEnvelope(response, 403, '无权限执行当前操作');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER 可访问,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /users/:id role matrix',
tokens: ctx.tokens,
@ -206,7 +276,7 @@ describe('UsersController + BUsersController (e2e)', () => {
[Role.SYSTEM_ADMIN]: 200,
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 200,
[Role.LEADER]: 403,
[Role.LEADER]: 200,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
@ -236,19 +306,31 @@ describe('UsersController + BUsersController (e2e)', () => {
expect(response.body.data.name).toBe(nextName);
});
it('成功:DIRECTOR 可更新本科室医生姓名', async () => {
const created = await createDoctorUser(ctx.tokens[Role.DIRECTOR]);
it('成功:HOSPITAL_ADMIN 可更新本院医生姓名', async () => {
const created = await createDoctorUser(ctx.tokens[Role.HOSPITAL_ADMIN]);
const nextName = uniqueSeedValue('主任更新医生名');
const response = await request(ctx.app.getHttpServer())
.patch(`/users/${created.id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({ name: nextName });
expectSuccessEnvelope(response, 200);
expect(response.body.data.name).toBe(nextName);
});
it('成功HOSPITAL_ADMIN 可将本院医生调整为组长', async () => {
const created = await createDoctorUser(ctx.tokens[Role.HOSPITAL_ADMIN]);
const response = await request(ctx.app.getHttpServer())
.patch(`/users/${created.id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({ role: Role.LEADER, groupId: ctx.fixtures.groupA1Id });
expectSuccessEnvelope(response, 200);
expect(response.body.data.role).toBe(Role.LEADER);
});
it('失败:非医生调整科室/小组返回 400', async () => {
const response = await request(ctx.app.getHttpServer())
.patch(`/users/${ctx.fixtures.users.engineerAId}`)
@ -265,32 +347,32 @@ describe('UsersController + BUsersController (e2e)', () => {
);
});
it('失败DIRECTOR 不能把医生改成其他角色', async () => {
it('失败DIRECTOR 更新用户返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.patch(`/users/${ctx.fixtures.users.doctorAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`)
.send({ role: Role.LEADER });
.send({ name: uniqueSeedValue('主任越权更新') });
expectErrorEnvelope(response, 403, '科室主任仅可操作本科室医生账号');
expectErrorEnvelope(response, 403, '无权限执行当前操作');
});
it('失败:DIRECTOR 不能把医生调整到其他科室', async () => {
it('失败:HOSPITAL_ADMIN 不能把用户改成医院管理员', async () => {
const response = await request(ctx.app.getHttpServer())
.patch(`/users/${ctx.fixtures.users.doctorAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`)
.send({ departmentId: ctx.fixtures.departmentA2Id });
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({ role: Role.HOSPITAL_ADMIN });
expectErrorEnvelope(response, 403, '科室主任仅可操作本科室医生账号');
expectErrorEnvelope(response, 403, '医院管理员仅可操作本院非管理员账号');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR 可进入业务,其他角色 403未登录 401', async () => {
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'PATCH /users/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 404,
[Role.HOSPITAL_ADMIN]: 404,
[Role.DIRECTOR]: 404,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
@ -319,11 +401,21 @@ describe('UsersController + BUsersController (e2e)', () => {
expect(response.body.data.id).toBe(created.id);
});
it('成功:DIRECTOR 可删除本科室医生', async () => {
const created = await createDoctorUser(ctx.tokens[Role.DIRECTOR]);
it('成功:HOSPITAL_ADMIN 可删除本院医生', async () => {
const created = await createDoctorUser(ctx.tokens[Role.HOSPITAL_ADMIN]);
const response = await request(ctx.app.getHttpServer())
.delete(`/users/${created.id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data.id).toBe(created.id);
});
it('成功HOSPITAL_ADMIN 可删除本院组长', async () => {
const created = await createLeaderUser(ctx.tokens[Role.HOSPITAL_ADMIN]);
const response = await request(ctx.app.getHttpServer())
.delete(`/users/${created.id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data.id).toBe(created.id);
@ -337,30 +429,30 @@ describe('UsersController + BUsersController (e2e)', () => {
expectErrorEnvelope(response, 409, '用户存在关联患者或任务,无法删除');
});
it('失败DIRECTOR 删除非本科室医生返回 403', async () => {
it('失败DIRECTOR 删除用户返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.delete(`/users/${ctx.fixtures.users.doctorA3Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
expectErrorEnvelope(response, 403, '科室主任仅可操作本科室医生账号');
});
it('失败HOSPITAL_ADMIN 无法删除返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.delete(`/users/${ctx.fixtures.users.doctorAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
expectErrorEnvelope(response, 403, '无权限执行当前操作');
});
it('角色矩阵SYSTEM_ADMIN/DIRECTOR 可进入业务,其他角色 403未登录 401', async () => {
it('失败HOSPITAL_ADMIN 无法删除本院管理员', async () => {
const response = await request(ctx.app.getHttpServer())
.delete(`/users/${ctx.fixtures.users.hospitalAdminAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
expectErrorEnvelope(response, 403, '医院管理员仅可操作本院非管理员账号');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'DELETE /users/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 404,
[Role.HOSPITAL_ADMIN]: 403,
[Role.DIRECTOR]: 404,
[Role.HOSPITAL_ADMIN]: 404,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,

View File

@ -17,4 +17,5 @@ module.exports = {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
maxWorkers: 1,
testTimeout: 30000,
};

Some files were not shown because too many files have changed in this diff Show More