Compare commits

...

25 Commits

Author SHA1 Message Date
EL
5f7d66ce54 新增调压记录详情接口并完善设备删除能力与前台操作 2026-04-03 13:30:37 +08:00
EL
cfd2f1e8dc 修复提示 2026-04-03 10:01:21 +08:00
EL
d2d87701de 支持主任/组长/医生删除设备并新增删除范围权限校验 2026-04-03 09:41:31 +08:00
EL
d77627e44b 统一医生可见范围并优化患者归属医生选择 2026-04-02 05:29:00 +08:00
EL
8f7e13bf2b 新增C端患者me接口并补充文档说明 2026-04-02 05:19:52 +08:00
EL
ab17204739 C端 miniapp 登录增加手机号唯一命中患者规则,命中多份档案返回 409
C端 my-lifecycle 由跨院多患者聚合改为单患者返回,结构统一为 patient + lifecycle
生命周期事件移除事件内重复 patient 字段,减少冗余
B端患者生命周期接口同步采用 patient + lifecycle 结构
新增并接入生命周期 Swagger 响应模型,补齐接口文档
更新 auth/patients/frontend 集成文档说明
增加 e2e:多患者冲突、C端/B端新返回结构、权限失败场景
2026-04-02 04:07:40 +08:00
EL
c830a2131e 患者列表改为服务端分页/筛选:支持 page、pageSize、keyword、doctorId 参数
患者详情页支持“编辑手术”流程:单手术直接编辑,多手术先进入手术列表选择
手术弹窗新增编辑模式:禁用设备结构增删,仅允许修改既有手术及设备字段
新增更新手术 API:PATCH /b/patients/:patientId/surgeries/:surgeryId
生命周期查询改为 B 端接口:GET /b/patients/:id/lifecycle
手术提交后支持回到详情并保持手术标签页,提升连续操作效率
2026-03-26 03:47:52 +08:00
EL
21941e94fd 调压任务收束 2026-03-24 20:09:20 +08:00
EL
6a3eb49ab6 工程师端可查看患者手机号 2026-03-24 17:00:38 +08:00
EL
7c4ba1e1a0 feat(auth): 支持同一微信 openId 绑定多个院内账号
feat(patients): 增强 B 端患者列表返回原发病/压力/手术日期字段
2026-03-24 16:51:37 +08:00
EL
19c08a7618 支持多账号选择登录与微信小程序 B/C 端手机号认证 2026-03-20 14:05:41 +08:00
EL
0b5640a977 调压任务流程从“发布即指派”改为“发布待接收(PENDING) -> 工程师接收(ACCEPTED) -> 完成(COMPLETED)”。
新增工程师“取消接收”能力,任务可从 ACCEPTED 回退到 PENDING。
发布任务不再要求 engineerId,并增加同设备存在未结束任务时的重复发布拦截。
完成任务新增 completionMaterials 必填校验,仅允许图片/视频凭证,并在完成时落库。
植入物目录新增 isValve,区分阀门与管子;非阀门不维护压力挡位,阀门至少 1 个挡位。
患者设备与任务查询返回新增字段,前端任务页支持接收/取消接收/上传凭证后完成。
增补 Prisma 迁移、接口文档、E2E 用例与夹具修复逻辑。
2026-03-20 06:03:09 +08:00
EL
2bfe8ac8c8 新增上传资产模型与迁移,支持 IMAGE、VIDEO、FILE 三类资产管理
新增 B 端上传接口与列表接口,统一文件上传和分页查询能力
上传能力支持医院级数据隔离:系统管理员需显式指定医院,院内角色按登录医院自动隔离
图片上传自动压缩并转为 webp,视频上传自动转码并压缩为 mp4,普通文件按原始类型存储
增加上传目录与公开访问能力,统一输出可直接预览的访问地址
前端新增影像库页面,支持按类型筛选、关键字检索、分页浏览、在线预览与原文件访问
前端新增通用上传组件,支持在页面内复用并返回上传结果
管理后台新增影像库菜单与路由,并补充页面级角色权限控制
患者手术相关表单接入上传复用能力,支持术前资料与设备标签上传回填
新增上传模块 e2e 用例,覆盖成功路径、权限矩阵与关键失败场景
补充上传模块文档与安装依赖说明,完善工程内使用说明
2026-03-20 04:35:43 +08:00
EL
73082225f6 "1. 新增系统字典与全局植入目录相关表结构及迁移
2. 扩展患者手术与材料模型,更新种子数据
3. 新增字典模块,增强设备植入目录管理能力
4. 重构患者后台服务与表单链路,统一权限与参数校验
5. 管理台新增字典页面并改造患者/设备页面与路由权限
6. 补充字典及相关领域 e2e 测试并更新文档"
2026-03-19 20:42:17 +08:00
EL
64d1ad7896 新增主任范围校验:仅可操作同医院同科室的 DOCTOR 账号
限制主任变更:禁止将医生改为其他角色,禁止跨科室调整归属
新增 DIRECTOR_SCOPE_FORBIDDEN 统一错误文案
前端权限同步:主任可进入用户页,页面文案调整为“医生管理”
前端交互同步:主任创建/编辑时角色固定为医生,医院与科室范围锁定
仪表盘统计按角色收敛,主任视角展示本科室医生相关统计
补充 e2e 场景覆盖与接口文档说明"
2026-03-19 11:08:36 +08:00
EL
6ec2d0b0e0 新增 B 端设备模块(后端 CRUD、分页筛选、权限隔离)并接入前端设备管理页面与路由菜单
鉴权改为登录态回库校验,新增 tokenValidAfter 失效时间,支持密码变更与 seed 重置后旧 token 立即失效
患者字段由 idCardHash 统一迁移为 idCard,新增身份证标准化逻辑并同步 C 端生命周期查询参数
组织模块增加小组删除限制(有成员时返回 409)并补充中文错误消息
任务取消接口支持可选 reason 字段(先透传事件层)
补齐 Prisma 迁移、文档说明和 E2E 用例(含设备模块与 token 失效场景)
2026-03-18 20:23:55 +08:00
EL
5fdf4c80e6 医院管理页新增医院管理员列并支持任命医院管理员
组织架构树展示医院管理员信息
科室与小组弹窗支持设置主任/组长并限制候选角色
患者页优化归属医生选择与字段文案
统一“小组组长”角色文案
2026-03-18 17:07:37 +08:00
EL
b527256874 feat(auth-org): 强化用户权限边界并完善组织负责人配置展示
feat(admin-ui): 医院管理显示医院管理员并限制候选角色
feat(security): 关闭注册入口,新增 system-admin 创建链路与数据脱敏
2026-03-18 17:05:36 +08:00
EL
602694814f 更新权限 2026-03-13 13:23:59 +08:00
EL
2275607bd2 设置 2026-03-13 11:14:16 +08:00
EL
394793fa28 web 2026-03-13 06:10:32 +08:00
EL
2c1bbd565f web test 2026-03-13 03:50:34 +08:00
EL
6ec8891be5 修复 E2E 准备脚本:
package.json
test:e2e:prepare 现在是 migrate reset --force && prisma generate && seed
为 seed 运行时补充 JS Prisma client 生成器:
schema.prisma
修复 seed 在 ESM/CJS 下的 Prisma 导入兼容:
seed.mjs
修复 Jest 环境未加载 .env 导致连到 127.0.0.1 的问题:
e2e-app.helper.ts
修复夹具依赖“名称”导致被组织测试改名后失效的问题(改为按 seed openId 反查):
e2e-fixtures.helper.ts
修复组织测试的状态污染与清理逻辑,并收敛 afterAll 资源释放:
organization.e2e-spec.ts
e2e-context.helper.ts
2026-03-13 03:29:16 +08:00
EL
b55e600c9c 权限完善 2026-03-13 02:40:21 +08:00
EL
aa1346f6af 测试 2026-03-13 00:19:34 +08:00
247 changed files with 34067 additions and 75 deletions

5
.env.example Normal file
View File

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

7
.gitignore vendored
View File

@ -56,3 +56,10 @@ pids
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
/src/generated/prisma
/tyt-admin/dist
/tyt-admin/node_modules
# Runtime upload assets
/storage/uploads
/storage/tmp-uploads

48
AGENTS.md Normal file
View File

@ -0,0 +1,48 @@
# Repository Guidelines
## Project Structure & Module Organization
Core application code lives in `src/`. Domain modules are split by business area: `auth/`, `users/`, `tasks/`, `patients/`, and `organization/`. Keep controllers, services, and DTOs inside their module directories (for example, `src/tasks/dto/`).
Shared infrastructure is in `src/common/` (global response/exception handling, constants) plus `src/prisma.module.ts` and `src/prisma.service.ts`. Database schema and migrations are under `prisma/`, and generated Prisma artifacts are in `src/generated/prisma/`. API behavior notes are documented in `docs/*.md`.
## Build, Test, and Development Commands
Use `pnpm` for all local workflows:
- `pnpm install`: install dependencies.
- `pnpm start:dev`: run NestJS in watch mode.
- `pnpm build`: compile TypeScript to `dist/`.
- `pnpm start:prod`: run compiled output from `dist/main`.
- `pnpm format`: apply Prettier to `src/**/*.ts` (and `test/**/*.ts` when present).
- `pnpm prisma generate`: regenerate Prisma client after schema changes.
- `pnpm prisma migrate dev`: create/apply local migrations.
## Coding Style & Naming Conventions
This repo uses TypeScript + NestJS with ES module imports (use `.js` suffix in local imports). Formatting is Prettier-driven (`singleQuote: true`, `trailingComma: all`); keep 2-space indentation and avoid manual style drift.
Use `PascalCase` for classes (`TaskService`), `camelCase` for methods/variables, and `kebab-case` for filenames (`publish-task.dto.ts`). Place DTOs under `dto/` and keep validation decorators/messages close to fields.
## Testing Guidelines
There are currently no committed `test` scripts or spec files. For new features, add automated tests using `@nestjs/testing` and `supertest` (already in dev dependencies), with names like `*.spec.ts`.
Minimum expectation for new endpoints: one success path and one authorization/validation failure path. Include test run instructions in the PR when introducing test tooling.
## Commit & Pull Request Guidelines
Recent history uses short, single-line subjects (for example: `配置数据库生成用户模块`, `测试`, `init`). Keep commits focused and descriptive, one logical change per commit.
For PRs, include:
- What changed and why.
- Related issue/task link.
- API or schema impact (`prisma/schema.prisma`, migrations, env vars).
- Verification steps (for example, `pnpm build`, key endpoint checks in `/api/docs`).
## Security & Configuration Tips
Start from `.env.example`; never commit real secrets. Rotate `AUTH_TOKEN_SECRET` and bootstrap keys per environment, and treat `DATABASE_URL` as sensitive.
使用nest cli不要直接改配置文件最后发给我安装命令让我执行中文注释和文档

111
README.md Normal file
View File

@ -0,0 +1,111 @@
# 多租户医疗调压系统后端NestJS + Prisma
本项目是医疗调压系统后端 MVP支持 B 端(医院内部)与 C 端(家属跨院视图)两套接口语义。
## 1. 技术栈
- NestJS模块化后端框架
- PrismaORM + Schema 管理)
- PostgreSQL/MySQL`.env``DATABASE_URL` 决定)
- JWT认证
- Swagger接口文档
## 2. 目录结构
```text
src/
auth/ 认证与鉴权JWT、Guard、RBAC
users/ 用户与角色管理
tasks/ 调压任务流转(发布/接收/完成/取消)
patients/ 患者查询B 端范围 + C 端聚合)
hospitals/ 医院管理模块
departments/ 科室管理模块
groups/ 小组管理模块
organization-common/ 组织域共享 DTO/权限校验能力
organization/ 组织域聚合模块(仅负责引入子模块)
common/ 全局响应、异常、消息常量
generated/prisma/ Prisma 生成代码
prisma/
schema.prisma 数据模型定义
docs/
auth.md
users.md
tasks.md
patients.md
```
## 3. 环境变量
请在项目根目录创建 `.env`
```env
DATABASE_URL="postgresql://user:password@127.0.0.1:5432/tyt?schema=public"
AUTH_TOKEN_SECRET="请替换为强随机密钥"
JWT_EXPIRES_IN="7d"
SYSTEM_ADMIN_BOOTSTRAP_KEY="初始化系统管理员用密钥"
```
管理员创建链路:
- 可通过 `POST /auth/system-admin` 创建系统管理员(需引导密钥)。
- 系统管理员负责创建医院、系统管理员与医院管理员。
- 医院管理员负责创建本院下级角色(主任/组长/医生/工程师)。
## 4. 启动流程
```bash
pnpm install
pnpm prisma generate
pnpm prisma migrate dev
pnpm start:dev
```
## 5. 统一响应规范
- 成功:`{ code: 0, msg: "成功", data: ... }`
- 失败:`{ code: 4xx/5xx, msg: "中文错误信息", data: null }`
已通过全局拦截器与全局异常过滤器统一输出。
## 6. API 文档
- Swagger UI: `/api/docs`
- OpenAPI JSON: `/api/docs-json`
- 鉴权头:`Authorization: Bearer <token>`
## 7. 组织管理(医院/科室/小组 CRUD
统一前缀:`/b/organization`
- 医院:
- `POST /b/organization/hospitals`
- `GET /b/organization/hospitals`
- `GET /b/organization/hospitals/:id`
- `PATCH /b/organization/hospitals/:id`
- `DELETE /b/organization/hospitals/:id`
- 科室:
- `POST /b/organization/departments`
- `GET /b/organization/departments`
- `GET /b/organization/departments/:id`
- `PATCH /b/organization/departments/:id`
- `DELETE /b/organization/departments/:id`
- 小组:
- `POST /b/organization/groups`
- `GET /b/organization/groups`
- `GET /b/organization/groups/:id`
- `PATCH /b/organization/groups/:id`
- `DELETE /b/organization/groups/:id`
## 8. 模块文档
- 认证与登录:`docs/auth.md`
- 用户与权限:`docs/users.md`
- 任务流转:`docs/tasks.md`
- 患者查询:`docs/patients.md`
## 9. 常见改造入口
- 新增字段/关系:修改 `prisma/schema.prisma` 后执行 `prisma migrate`
- 调整中文提示:修改 `src/common/messages.ts`
- 调整全局响应壳:修改 `src/common/response-envelope.interceptor.ts``src/common/http-exception.filter.ts`
- 扩展 RBAC修改 `src/auth/roles.guard.ts` 与对应 Service 的权限断言。

77
docs/auth.md Normal file
View File

@ -0,0 +1,77 @@
# 认证模块说明(`src/auth`
## 1. 目标
- 提供系统管理员创建、院内账号密码登录、B 端小程序手机号登录、C 端小程序手机号登录、身份查询。
- 使用 JWT 做认证,院内账号与 C 端小程序账号走两套守卫。
## 2. 核心接口
- `POST /auth/system-admin`:创建系统管理员(需引导密钥)
- `POST /auth/login`:院内账号密码登录,后台与小程序均可复用
- `POST /auth/login/confirm`:院内账号密码多账号确认登录
- `POST /auth/miniapp/b/phone-login`B 端小程序手机号登录
- `POST /auth/miniapp/b/phone-login/confirm`B 端同手机号多账号确认登录
- `POST /auth/miniapp/c/phone-login`C 端小程序手机号登录
- `GET /auth/me`:返回当前院内登录用户上下文
- `GET /c/patients/me`:返回当前 C 端登录账号绑定的患者基础信息
## 3. 院内账号密码登录流程
1. 前端提交 `phone + password``role``hospitalId` 都可以选传。
2. 若仅匹配到 1 个院内账号,后端直接返回 JWT。
3. 若匹配到多个院内账号,后端返回 `loginTicket + accounts` 候选列表。
4. 前端带 `loginTicket + userId` 调用确认接口获取最终 JWT。
## 4. 微信小程序登录流程
### B 端
1. 前端调用 `wx.login` 获取 `loginCode`
2. 前端调用手机号授权获取 `phoneCode`
3. 调用 `POST /auth/miniapp/b/phone-login`
4. 若手机号仅命中 1 个院内账号,后端直接返回 JWT。
5. 若命中多个院内账号,后端返回 `loginTicket + accounts`
6. 前端带 `loginTicket + userId` 调用确认接口拿最终 JWT。
### C 端
1. 前端同样传 `loginCode + phoneCode`
2. 后端先校验该手机号是否唯一命中 `Patient.phone`
3. 校验通过后创建或绑定 `FamilyMiniAppAccount`,并返回 C 端 JWT。
## 5. 鉴权流程
### 院内账号
1. `AccessTokenGuard``Authorization` 读取 Bearer Token。
2. 校验 JWT 签名、`id``iat` 等关键载荷字段。
3. 根据 `id` 回库读取 `User` 当前角色与组织归属。
4. 校验 `iat >= user.tokenValidAfter`,保证旧 token 失效。
### C 端小程序账号
1. `FamilyAccessTokenGuard``Authorization` 读取 Bearer Token。
2. 校验 C 端 token 的 `id + type=FAMILY_MINIAPP`
3. 根据 `id` 回库读取 `FamilyMiniAppAccount`
4. 将 C 端账号上下文注入 `request.familyActor`
## 6. 环境变量
- `AUTH_TOKEN_SECRET`
- `SYSTEM_ADMIN_BOOTSTRAP_KEY`
- `WECHAT_MINIAPP_APPID`
- `WECHAT_MINIAPP_SECRET`
## 7. 关键规则
- 后台 Web 与 B 端小程序都可以复用 `POST /auth/login` 做账号密码登录。
- 密码登录命中多个院内账号时,不再强制前端手填 `hospitalId`,改为后端返回候选账号供选择。
- B 端小程序登录复用 `User` 表,继续使用 `openId`
- B 端账号未绑定 `openId` 时,首次小程序登录自动绑定。
- 同一个 `openId` 可绑定多个院内账号,支持同一微信号切换多角色/多院区账号。
- 若目标院内账号已绑定其他微信号,仍需先在用户管理中清空该账号的 `openId` 再重新绑定。
- C 端账号独立存放在 `FamilyMiniAppAccount`
- C 端手机号必须唯一命中患者档案,否则拒绝登录。
- C 端登录后读取“我的信息”请调用 `GET /c/patients/me`,不要复用 `GET /auth/me`
- `serviceUid` 仅预留字段,本次不提供绑定接口。

81
docs/devices.md Normal file
View File

@ -0,0 +1,81 @@
# 设备模块说明(`src/devices`
## 1. 目标
- 提供“全局植入物目录”管理,供患者手术表单选择。
- 维护患者手术下的植入实例记录。
- 支持区分“阀门 / 管子”,并仅为阀门配置挡位列表。
- 支持管理员按医院、患者、状态和关键词分页查询患者植入实例。
## 2. 设备实例
`Device` 现在表示“患者某次手术下的植入设备实例”,不是独立库存主数据。
核心字段:
- `patientId`:归属患者
- `surgeryId`:归属手术,可为空
- `implantCatalogId`:型号字典 ID可为空
- `implantModel` / `implantManufacturer` / `implantName`:历史快照
- `isValve`:是否为阀门
- `isPressureAdjustable`:是否可调压
- `isAbandoned`:是否弃用
- `currentPressure`:当前压力挡位标签
- `status`:设备状态
补充:
- `currentPressure` 不允许在创建/编辑设备实例时手工指定。
- 新植入设备默认以 `initialPressure`(或系统默认值 `0`)作为当前压力起点,后续只允许在调压任务完成时更新。
- 发布调压任务时不会立刻修改 `currentPressure`,只有任务完成后才会把目标挡位回写到设备。
## 3. 植入物目录
新增 `ImplantCatalog`
- `modelCode`:型号编码,唯一
- `manufacturer`:厂商
- `name`:名称
- `isValve`:是否为阀门;关闭时表示管子或附件
- `pressureLevels`:可调压器械的挡位字符串标签列表
- `isPressureAdjustable`:后端按 `isValve` 自动派生
- `notes`:目录备注
可见性:
- 全部已登录 B 端角色都可读取,用于患者手术录入
- 仅 `SYSTEM_ADMIN` 可做目录 CRUD
- 目录是全局共享的,不按医院隔离
说明:
- 非阀门目录项不会保存压力挡位,前端也不会显示压力录入区域。
- 阀门目录项至少需要配置一个挡位。
- 挡位列表按字符串标签保存,例如 `["0.5", "1", "1.5"]``["10", "20", "30"]`
- 保存前会自动标准化并去重排序,例如 `["01.0", "1.50", "1"]` 最终会整理为 `["1", "1.5"]`
## 4. 接口
设备实例:
- `GET /b/devices`:分页查询设备列表
- `GET /b/devices/:id`:查询设备详情
- `POST /b/devices`:创建设备
- `PATCH /b/devices/:id`:更新设备
- `DELETE /b/devices/:id`:删除设备
型号字典:
- `GET /b/devices/catalogs`:查询植入物型号字典
- `POST /b/devices/catalogs`:新增植入物目录
- `PATCH /b/devices/catalogs/:id`:更新植入物目录
- `DELETE /b/devices/catalogs/:id`:删除植入物目录
## 5. 约束
- 设备必须绑定到一个患者。
- 删除已被任务明细引用的设备会返回 `409`
- 删除已被患者手术引用的植入物目录会返回 `409`
- 可调压植入物若配置了 `pressureLevels`,患者手术录入和任务调压时的压力值必须命中该挡位列表。
- 调压任务仅允许针对 `ACTIVE + isPressureAdjustable=true + isAbandoned=false` 的设备发布。
- `Device.currentPressure` 只允许由调压任务完成时更新,患者手术录入和设备实例编辑都不开放手工写入。

42
docs/dictionaries.md Normal file
View File

@ -0,0 +1,42 @@
# 系统字典说明(`src/dictionaries`
## 1. 目标
- 将患者手术表单中的固定选项沉淀为系统级字典。
- 仅允许 `SYSTEM_ADMIN` 做 CRUD。
- 业务角色仅可读取启用中的字典项,用于患者录入表单。
## 2. 当前字典类型
- `PRIMARY_DISEASE`:原发病
- `HYDROCEPHALUS_TYPE`:脑积水类型
- `SHUNT_MODE`:分流方式
- `PROXIMAL_PUNCTURE_AREA`:近端穿刺区域
- `VALVE_PLACEMENT_SITE`:阀门植入部位
- `DISTAL_SHUNT_DIRECTION`:远端分流方向
## 3. 数据结构
新增 `DictionaryItem`
- `type`:字典类型枚举
- `label`:字典项显示值
- `sortOrder`:排序值,越小越靠前
- `enabled`:是否启用
约束:
- 同一 `type``label` 唯一。
- 非系统管理员读取时只返回 `enabled=true` 的字典项。
## 4. 接口
- `GET /b/dictionaries`:查询字典项
- `POST /b/dictionaries`:创建字典项(仅系统管理员)
- `PATCH /b/dictionaries/:id`:更新字典项(仅系统管理员)
- `DELETE /b/dictionaries/:id`:删除字典项(仅系统管理员)
说明:
- `GET /b/dictionaries?includeDisabled=true` 仅系统管理员生效。
- 患者手术表单现在从该接口动态读取选项,不再使用前端硬编码数组。

78
docs/e2e-testing.md Normal file
View File

@ -0,0 +1,78 @@
# E2E 接口测试说明
## 1. 目标
- 覆盖 `src/**/*controller.ts` 当前全部 30 个业务接口。
- 采用 `supertest + @nestjs/testing` 进行真实 HTTP E2E 测试。
- 测试前固定执行数据库重置,并通过真实接口全流程建数,确保结果可重复。
## 2. 风险提示
`pnpm test:e2e` 会执行:
1. `prisma migrate reset --force`
2. 启动 Jest 后,由测试用例通过真实 HTTP 接口完成基础夹具创建
这会清空 `.env``DATABASE_URL` 指向数据库的全部数据,请仅在测试库执行。
另外,接口引导创建的测试账号会刷新 `tokenValidAfter`,所以重置前签发的旧 token 会全部失效,需要重新登录获取新 token。
## 3. 运行命令
```bash
pnpm test:e2e
```
仅重置数据库并重新生成 Prisma Client
```bash
pnpm test:e2e:prepare
```
监听模式:
```bash
pnpm test:e2e:watch
```
## 4. 接口引导夹具(默认密码:`Seed@1234`
- 系统管理员:`13800001000`
- 院管(医院 A`13800001001`
- 主任(医院 A`13800001002`
- 组长(医院 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

@ -0,0 +1,50 @@
# 前后端联调说明
## 1. 登录
### B 端账号密码登录
- `POST /auth/login`
- 入参:
- `phone`
- `password`
- `role`(可选)
- `hospitalId`(可选)
- 若返回 `needSelect: true`,继续调用:
- `POST /auth/login/confirm`
- 入参:`loginTicket + userId`
### B 端小程序
- 第一步:`POST /auth/miniapp/b/phone-login`
- 入参:
- `loginCode`
- `phoneCode`
- 若返回 `needSelect: true`,继续调用:
- `POST /auth/miniapp/b/phone-login/confirm`
- 入参:`loginTicket + userId`
### C 端小程序
- `POST /auth/miniapp/c/phone-login`
- 入参:
- `loginCode`
- `phoneCode`
- 要求当前手机号唯一关联 1 份患者档案,否则返回冲突错误
## 2. C 端生命周期
- 登录成功后可先调用:`GET /c/patients/me`
- 返回当前 C 端账号信息与当前手机号唯一命中的患者基础档案
- 登录成功后调用:`GET /c/patients/my-lifecycle`
- 不再需要传 `phone``idCard`
- Bearer Token 使用 C 端患者登录返回的 `accessToken`
- 返回结构改为顶层 `patient + lifecycle`,事件项内不再重复返回 `patient`
## 3. B 端说明
- B 端业务接口仍使用 Bearer Token
- 后台管理端与小程序都可以复用 `POST /auth/login` 做账号密码登录
- `GET /auth/me` 仍可读取当前院内账号信息
- 同手机号多账号时,前端必须先让用户选定账号,再提交确认登录
- 同一个微信号可绑定多个院内账号,切换账号时继续走“小程序登录 -> 候选账号选择”即可

38
docs/patients.md Normal file
View File

@ -0,0 +1,38 @@
# 患者模块说明(`src/patients`
## 1. 目标
- B 端:维护患者、手术、植入设备及生命周期数据。
- C 端:患者本人小程序登录后,按当前手机号查询自己的生命周期。
## 2. B 端能力
- 患者列表、详情、创建、更新、删除
- 手术记录新增
- 植入设备录入与历史保留
## 3. C 端能力
- 患者本人通过小程序手机号登录
- `GET /c/patients/me`
- `GET /c/patients/my-lifecycle`
- 查询口径:按 `FamilyMiniAppAccount.phone` 唯一命中 `Patient.phone`
- `me` 返回内容:当前 C 端账号信息 + 当前手机号命中的患者基础档案
- `my-lifecycle` 返回内容:顶层患者信息 + 手术事件/调压事件时间线
## 4. 当前规则
- 同一个手机号在 C 端只允许命中 1 份患者档案。
- 若同一个手机号命中多份患者档案,登录阶段直接返回冲突错误。
- C 端手机号来源于患者手术/档案中维护的联系电话。
- 仅已登录的 C 端小程序账号可访问 `me``my-lifecycle`
- C 端登录账号不存在或 token 无效时返回 `401`
- 手机号下无患者档案时,登录阶段直接拦截,不进入生命周期查询。
## 5. 典型接口
- `GET /b/patients`
- `POST /b/patients`
- `POST /b/patients/:id/surgeries`
- `GET /c/patients/me`
- `GET /c/patients/my-lifecycle`

69
docs/tasks.md Normal file
View File

@ -0,0 +1,69 @@
# 调压任务模块说明(`src/tasks`
## 1. 目标
- 管理调压主任务 `Task` 与明细 `TaskItem`
- 支持状态机流转与事件触发,保证设备压力同步更新。
## 2. 状态机
- 当前发布流程:`PENDING -> ACCEPTED -> COMPLETED`
- 当前工程师撤回流程:`ACCEPTED -> PENDING`
- 当前取消流程:`PENDING/ACCEPTED -> CANCELLED`
- `PENDING` 表示任务已发布,等待本院工程师接收
非法流转会返回 `409` 冲突错误(中文消息)。
## 3. 角色权限
- 系统管理员/医院管理员/医生/主任/组长:发布任务时不再指定工程师,只能取消自己创建的任务
- 工程师:可接收本院 `PENDING` 任务;接收后只能由接收工程师自己完成,或取消接收并退回 `PENDING`
- 其他角色:默认拒绝
补充:
- `GET /b/tasks/engineers`:返回当前角色可见的医院工程师列表,系统管理员可按医院筛选。
- `GET /b/tasks`:返回当前角色可见的调压记录列表,系统管理员可按医院筛选。
- `POST /b/tasks/cancel` 现支持可选 `reason` 字段,便于前端保留取消原因输入。
- 当前取消原因仅透传到事件层,数据库暂未持久化该字段。
- 如果当前设备已经存在 `PENDING / ACCEPTED` 调压任务,则禁止再次发布;同一患者的其他设备不受影响。
## 4. 记录列表
- 后台任务页不再承担手工发布入口,只展示调压记录。
- 记录维度按 `TaskItem` 展开,每条记录会携带:
- 任务状态
- 患者信息
- 手术名称
- 设备信息
- 旧压力 / 目标压力 / 当前压力(均为字符串挡位标签)
- 完成凭证(图片/视频)
- 创建人 / 接收人 / 发布时间
## 5. 事件触发
状态变化后会发出事件:
- `task.published`
- `task.accepted`
- `task.completed`
- `task.cancelled`
用于后续接入微信通知或消息中心。
## 6. 完成任务时的设备同步
`completeTask` 在单事务中执行:
1. 更新任务状态为 `COMPLETED`
2. 校验至少上传 1 条图片或视频凭证
3. 读取 `TaskItem.targetPressure`
4. 批量更新关联 `Device.currentPressure`
确保任务状态与设备压力一致性。
补充:
- `publishTask` 只负责生成任务和目标挡位,不会立刻修改设备当前压力。
- 只有工程师完成任务后,目标挡位才会回写到设备实例。
- 完成任务时必须上传至少一张图片或一个视频,凭证会保存到 `Task.completionMaterials`

72
docs/uploads.md Normal file
View File

@ -0,0 +1,72 @@
# 上传资产模块说明(`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 / ENGINEER`
- 表单字段:
- `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`

59
docs/users.md Normal file
View File

@ -0,0 +1,59 @@
# 用户与权限模块说明(`src/users`
## 1. 目标
- 管理用户基础信息(姓名、手机号、角色、组织归属)。
- 维护 B 端角色权限边界和工程师绑定医院逻辑。
## 2. 角色枚举
- `SYSTEM_ADMIN`:系统管理员
- `HOSPITAL_ADMIN`:院管
- `DIRECTOR`:主任
- `LEADER`:组长
- `DOCTOR`:医生
- `ENGINEER`:工程师
## 3. 关键规则
- 医院内数据按 `hospitalId` 强隔离。
- 仅 `SYSTEM_ADMIN` 可执行工程师绑定医院。
- `SYSTEM_ADMIN/HOSPITAL_ADMIN` 可执行用户创建、编辑、删除。
- `DIRECTOR` 仅可只读查看本科室下级医生/组长。
- `LEADER` 仅可只读查看本小组医生列表。
- `HOSPITAL_ADMIN` 仅可操作本院非管理员账号。
- 用户组织字段校验:
- 院管/医生/工程师等需有医院归属;
- 主任/组长需有科室/小组等必要归属;
- 系统管理员不能绑定院内组织字段。
- 更新用户时,仅允许医生调整 `departmentId/groupId`(后端强约束)。
- 科室/小组父级关系冻结:不允许通过更新接口迁移科室所属医院或小组所属科室。
## 4. 典型接口
- `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`:删除本院无关联、且非管理员用户
其中主任侧的常用链路为:
- `GET /users`:查看本科室下级医生/组长
- `GET /users/:id`:查看本科室下级详情
其中组长侧的常用链路为:
- `GET /users`:查看本小组医生列表
- `GET /users/:id`:查看本小组医生详情
## 5. 开发改造建议
- 若增加角色,请同步修改:
- Prisma `Role` 枚举
- `roles.guard.ts` 与各 Service 权限判断
- Swagger DTO 中文说明

View File

@ -12,31 +12,53 @@
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main"
"start:prod": "node dist/main",
"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"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/mapped-types": "*",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.2.6",
"@nestjs/throttler": "^6.5.0",
"@prisma/adapter-pg": "^7.5.0",
"@prisma/client": "^7.5.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"dotenv": "^17.3.1",
"ffmpeg-static": "^5.3.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"multer": "^2.1.1",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"sharp": "^0.34.5",
"swagger-ui-express": "^5.0.1"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@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",
"jest": "^30.3.0",
"prettier": "^3.4.2",
"prisma": "^7.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.4.6",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",

3101
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

9
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,9 @@
onlyBuiltDependencies:
- '@nestjs/core'
- '@prisma/engines'
- '@scarf/scarf'
- bcrypt
- ffmpeg-static
- prisma
- sharp
- unrs-resolver

View File

@ -0,0 +1,192 @@
/*
Warnings:
- You are about to drop the column `email` on the `User` table. All the data in the column will be lost.
- You are about to drop the `Post` table. If the table is not empty, all the data it contains will be lost.
- A unique constraint covering the columns `[openId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
- Added the required column `phone` to the `User` table without a default value. This is not possible if the table is not empty.
- Added the required column `role` to the `User` table without a default value. This is not possible if the table is not empty.
- Made the column `name` on table `User` required. This step will fail if there are existing NULL values in that column.
*/
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR', 'LEADER', 'DOCTOR', 'ENGINEER');
-- CreateEnum
CREATE TYPE "DeviceStatus" AS ENUM ('ACTIVE', 'INACTIVE');
-- CreateEnum
CREATE TYPE "TaskStatus" AS ENUM ('PENDING', 'ACCEPTED', 'COMPLETED', 'CANCELLED');
-- DropForeignKey
ALTER TABLE "Post" DROP CONSTRAINT "Post_authorId_fkey";
-- DropIndex
DROP INDEX "User_email_key";
-- AlterTable
ALTER TABLE "User" DROP COLUMN "email",
ADD COLUMN "departmentId" INTEGER,
ADD COLUMN "groupId" INTEGER,
ADD COLUMN "hospitalId" INTEGER,
ADD COLUMN "openId" TEXT,
ADD COLUMN "passwordHash" TEXT,
ADD COLUMN "phone" TEXT NOT NULL,
ADD COLUMN "role" "Role" NOT NULL,
ALTER COLUMN "name" SET NOT NULL;
-- DropTable
DROP TABLE "Post";
-- CreateTable
CREATE TABLE "Hospital" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "Hospital_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Department" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"hospitalId" INTEGER NOT NULL,
CONSTRAINT "Department_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Group" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"departmentId" INTEGER NOT NULL,
CONSTRAINT "Group_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Patient" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"idCardHash" TEXT NOT NULL,
"hospitalId" INTEGER NOT NULL,
"doctorId" INTEGER NOT NULL,
CONSTRAINT "Patient_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Device" (
"id" SERIAL NOT NULL,
"snCode" TEXT NOT NULL,
"currentPressure" INTEGER NOT NULL,
"status" "DeviceStatus" NOT NULL DEFAULT 'ACTIVE',
"patientId" INTEGER NOT NULL,
CONSTRAINT "Device_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Task" (
"id" SERIAL NOT NULL,
"status" "TaskStatus" NOT NULL DEFAULT 'PENDING',
"creatorId" INTEGER NOT NULL,
"engineerId" INTEGER,
"hospitalId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TaskItem" (
"id" SERIAL NOT NULL,
"taskId" INTEGER NOT NULL,
"deviceId" INTEGER NOT NULL,
"oldPressure" INTEGER NOT NULL,
"targetPressure" INTEGER NOT NULL,
CONSTRAINT "TaskItem_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Department_hospitalId_idx" ON "Department"("hospitalId");
-- CreateIndex
CREATE INDEX "Group_departmentId_idx" ON "Group"("departmentId");
-- CreateIndex
CREATE INDEX "Patient_phone_idCardHash_idx" ON "Patient"("phone", "idCardHash");
-- CreateIndex
CREATE INDEX "Patient_hospitalId_doctorId_idx" ON "Patient"("hospitalId", "doctorId");
-- CreateIndex
CREATE UNIQUE INDEX "Device_snCode_key" ON "Device"("snCode");
-- CreateIndex
CREATE INDEX "Device_patientId_status_idx" ON "Device"("patientId", "status");
-- CreateIndex
CREATE INDEX "Task_hospitalId_status_createdAt_idx" ON "Task"("hospitalId", "status", "createdAt");
-- CreateIndex
CREATE INDEX "TaskItem_taskId_idx" ON "TaskItem"("taskId");
-- CreateIndex
CREATE INDEX "TaskItem_deviceId_idx" ON "TaskItem"("deviceId");
-- CreateIndex
CREATE UNIQUE INDEX "User_openId_key" ON "User"("openId");
-- CreateIndex
CREATE INDEX "User_phone_idx" ON "User"("phone");
-- CreateIndex
CREATE INDEX "User_hospitalId_role_idx" ON "User"("hospitalId", "role");
-- CreateIndex
CREATE INDEX "User_departmentId_role_idx" ON "User"("departmentId", "role");
-- CreateIndex
CREATE INDEX "User_groupId_role_idx" ON "User"("groupId", "role");
-- AddForeignKey
ALTER TABLE "Department" ADD CONSTRAINT "Department_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Group" ADD CONSTRAINT "Group_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Patient" ADD CONSTRAINT "Patient_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Patient" ADD CONSTRAINT "Patient_doctorId_fkey" FOREIGN KEY ("doctorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_patientId_fkey" FOREIGN KEY ("patientId") REFERENCES "Patient"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_engineerId_fkey" FOREIGN KEY ("engineerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TaskItem" ADD CONSTRAINT "TaskItem_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TaskItem" ADD CONSTRAINT "TaskItem_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[phone,role,hospitalId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "User_phone_role_hospitalId_key" ON "User"("phone", "role", "hospitalId");

View File

@ -0,0 +1,2 @@
ALTER TABLE "User"
ADD COLUMN "tokenValidAfter" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

View File

@ -0,0 +1,5 @@
ALTER TABLE "Patient"
RENAME COLUMN "idCardHash" TO "idCard";
ALTER INDEX "Patient_phone_idCardHash_idx"
RENAME TO "Patient_phone_idCard_idx";

View File

@ -0,0 +1,8 @@
ALTER TABLE "User"
DROP CONSTRAINT "User_groupId_fkey";
ALTER TABLE "User"
ADD CONSTRAINT "User_groupId_fkey"
FOREIGN KEY ("groupId") REFERENCES "Group"("id")
ON DELETE RESTRICT
ON UPDATE CASCADE;

View File

@ -0,0 +1,82 @@
-- AlterTable
ALTER TABLE "Device" ADD COLUMN "distalShuntDirection" TEXT,
ADD COLUMN "implantCatalogId" INTEGER,
ADD COLUMN "implantManufacturer" TEXT,
ADD COLUMN "implantModel" TEXT,
ADD COLUMN "implantName" TEXT,
ADD COLUMN "implantNotes" TEXT,
ADD COLUMN "initialPressure" INTEGER,
ADD COLUMN "isAbandoned" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "isPressureAdjustable" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "labelImageUrl" TEXT,
ADD COLUMN "proximalPunctureAreas" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "shuntMode" TEXT,
ADD COLUMN "surgeryId" INTEGER,
ADD COLUMN "valvePlacementSites" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- AlterTable
ALTER TABLE "Patient" ADD COLUMN "inpatientNo" TEXT,
ADD COLUMN "projectName" TEXT;
-- CreateTable
CREATE TABLE "PatientSurgery" (
"id" SERIAL NOT NULL,
"patientId" INTEGER NOT NULL,
"surgeryDate" TIMESTAMP(3) NOT NULL,
"surgeryName" TEXT NOT NULL,
"surgeonName" TEXT NOT NULL,
"preOpPressure" INTEGER,
"primaryDisease" TEXT NOT NULL,
"hydrocephalusTypes" TEXT[] DEFAULT ARRAY[]::TEXT[],
"previousShuntSurgeryDate" TIMESTAMP(3),
"preOpMaterials" JSONB,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PatientSurgery_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ImplantCatalog" (
"id" SERIAL NOT NULL,
"modelCode" TEXT NOT NULL,
"manufacturer" TEXT NOT NULL,
"name" TEXT NOT NULL,
"hospitalId" INTEGER,
"isPressureAdjustable" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "ImplantCatalog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PatientSurgery_patientId_surgeryDate_idx" ON "PatientSurgery"("patientId", "surgeryDate");
-- CreateIndex
CREATE UNIQUE INDEX "ImplantCatalog_modelCode_key" ON "ImplantCatalog"("modelCode");
-- CreateIndex
CREATE INDEX "ImplantCatalog_hospitalId_idx" ON "ImplantCatalog"("hospitalId");
-- CreateIndex
CREATE INDEX "Device_surgeryId_idx" ON "Device"("surgeryId");
-- CreateIndex
CREATE INDEX "Device_implantCatalogId_idx" ON "Device"("implantCatalogId");
-- CreateIndex
CREATE INDEX "Device_patientId_isAbandoned_idx" ON "Device"("patientId", "isAbandoned");
-- CreateIndex
CREATE INDEX "Patient_inpatientNo_idx" ON "Patient"("inpatientNo");
-- AddForeignKey
ALTER TABLE "PatientSurgery" ADD CONSTRAINT "PatientSurgery_patientId_fkey" FOREIGN KEY ("patientId") REFERENCES "Patient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ImplantCatalog" ADD CONSTRAINT "ImplantCatalog_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_surgeryId_fkey" FOREIGN KEY ("surgeryId") REFERENCES "PatientSurgery"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_implantCatalogId_fkey" FOREIGN KEY ("implantCatalogId") REFERENCES "ImplantCatalog"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,21 @@
-- CreateEnum
CREATE TYPE "DictionaryType" AS ENUM ('PRIMARY_DISEASE', 'HYDROCEPHALUS_TYPE', 'SHUNT_MODE', 'PROXIMAL_PUNCTURE_AREA', 'VALVE_PLACEMENT_SITE', 'DISTAL_SHUNT_DIRECTION');
-- CreateTable
CREATE TABLE "DictionaryItem" (
"id" SERIAL NOT NULL,
"type" "DictionaryType" NOT NULL,
"label" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "DictionaryItem_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "DictionaryItem_type_enabled_sortOrder_idx" ON "DictionaryItem"("type", "enabled", "sortOrder");
-- CreateIndex
CREATE UNIQUE INDEX "DictionaryItem_type_label_key" ON "DictionaryItem"("type", "label");

View File

@ -0,0 +1,15 @@
-- AlterTable
ALTER TABLE "ImplantCatalog"
ADD COLUMN "pressureLevels" INTEGER[] NOT NULL DEFAULT ARRAY[]::INTEGER[],
ADD COLUMN "notes" TEXT,
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
-- DropForeignKey
ALTER TABLE "ImplantCatalog" DROP CONSTRAINT IF EXISTS "ImplantCatalog_hospitalId_fkey";
-- DropIndex
DROP INDEX IF EXISTS "ImplantCatalog_hospitalId_idx";
-- DropColumn
ALTER TABLE "ImplantCatalog" DROP COLUMN IF EXISTS "hospitalId";

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

@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "FamilyMiniAppAccount" (
"id" SERIAL NOT NULL,
"phone" TEXT NOT NULL,
"openId" TEXT,
"serviceUid" TEXT,
"lastLoginAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FamilyMiniAppAccount_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "FamilyMiniAppAccount_phone_key" ON "FamilyMiniAppAccount"("phone");
-- CreateIndex
CREATE UNIQUE INDEX "FamilyMiniAppAccount_openId_key" ON "FamilyMiniAppAccount"("openId");
-- CreateIndex
CREATE UNIQUE INDEX "FamilyMiniAppAccount_serviceUid_key" ON "FamilyMiniAppAccount"("serviceUid");
-- CreateIndex
CREATE INDEX "FamilyMiniAppAccount_lastLoginAt_idx" ON "FamilyMiniAppAccount"("lastLoginAt");

View File

@ -0,0 +1,25 @@
ALTER TABLE "ImplantCatalog"
ADD COLUMN IF NOT EXISTS "isValve" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "Device"
ADD COLUMN IF NOT EXISTS "isValve" BOOLEAN NOT NULL DEFAULT true;
UPDATE "ImplantCatalog"
SET "isPressureAdjustable" = CASE
WHEN "isValve" THEN true
ELSE false
END
WHERE "isPressureAdjustable" IS DISTINCT FROM CASE
WHEN "isValve" THEN true
ELSE false
END;
UPDATE "Device"
SET "isPressureAdjustable" = CASE
WHEN "isValve" THEN true
ELSE false
END
WHERE "isPressureAdjustable" IS DISTINCT FROM CASE
WHEN "isValve" THEN true
ELSE false
END;

View File

@ -0,0 +1,2 @@
ALTER TABLE "Task"
ADD COLUMN IF NOT EXISTS "completionMaterials" JSONB;

View File

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

View File

@ -1,30 +1,307 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
// 兼容 seed 脚本在 Node.js 直接运行时使用 @prisma/client runtime。
generator seed_client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
// 角色枚举:用于鉴权与数据可见性控制。
enum Role {
SYSTEM_ADMIN
HOSPITAL_ADMIN
DIRECTOR
LEADER
DOCTOR
ENGINEER
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean? @default(false)
author User? @relation(fields: [authorId], references: [id])
authorId Int?
// 设备状态枚举:表示设备是否处于使用中。
enum DeviceStatus {
ACTIVE
INACTIVE
}
// 任务状态枚举:定义任务流转状态机。
enum TaskStatus {
PENDING
ACCEPTED
COMPLETED
CANCELLED
}
// 医学字典类型:驱动患者手术表单中的单选/多选项。
enum DictionaryType {
PRIMARY_DISEASE
HYDROCEPHALUS_TYPE
SHUNT_MODE
PROXIMAL_PUNCTURE_AREA
VALVE_PLACEMENT_SITE
DISTAL_SHUNT_DIRECTION
}
// 上传资产类型:用于图库/视频库分类。
enum UploadAssetType {
IMAGE
VIDEO
FILE
}
// 医院主表:多租户顶层实体。
model Hospital {
id Int @id @default(autoincrement())
name String
departments Department[]
users User[]
patients Patient[]
tasks Task[]
uploads UploadAsset[]
}
// 科室表:归属于医院。
model Department {
id Int @id @default(autoincrement())
name String
hospitalId Int
hospital Hospital @relation(fields: [hospitalId], references: [id])
groups Group[]
users User[]
@@index([hospitalId])
}
// 小组表:归属于科室。
model Group {
id Int @id @default(autoincrement())
name String
departmentId Int
department Department @relation(fields: [departmentId], references: [id])
users User[]
@@index([departmentId])
}
// 用户表:支持后台密码登录与小程序 openId。
// 同一个微信 openId 允许绑定多个院内账号,便于多角色/多院区切换。
model User {
id Int @id @default(autoincrement())
name String
phone String
// 后台登录密码哈希bcrypt
passwordHash String?
// 该时间点之前签发的 token 一律失效。
tokenValidAfter DateTime @default(now())
openId String?
role Role
hospitalId Int?
departmentId Int?
groupId Int?
hospital Hospital? @relation(fields: [hospitalId], references: [id])
department Department? @relation(fields: [departmentId], references: [id])
// 小组删除必须先清理成员,避免静默把用户 groupId 置空。
group Group? @relation(fields: [groupId], references: [id], onDelete: Restrict)
doctorPatients Patient[] @relation("DoctorPatients")
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])
@@index([openId])
@@index([hospitalId, role])
@@index([departmentId, role])
@@index([groupId, role])
}
// 家属小程序账号:按手机号承载 C 端登录身份,并预留服务号绑定字段。
model FamilyMiniAppAccount {
id Int @id @default(autoincrement())
phone String @unique
openId String? @unique
serviceUid String? @unique
lastLoginAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@index([lastLoginAt])
}
// 患者表:院内患者档案,按医院隔离。
model Patient {
id Int @id @default(autoincrement())
name String
createdAt DateTime @default(now())
// 住院号:用于院内患者检索与病案关联。
inpatientNo String?
// 项目名称:用于区分患者所属项目/课题。
projectName String?
phone String
// 患者身份证号,录入与查询都使用原始证件号。
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])
}
// 患者手术表:保存每次分流/复手术档案。
model PatientSurgery {
id Int @id @default(autoincrement())
patientId Int
surgeryDate DateTime
surgeryName String
surgeonId Int?
surgeonName String
// 术前测压:部分患者可为空。
preOpPressure Int?
// 原发病:前端单选,后端先按字符串存储,方便后续补字典。
primaryDisease String
// 脑积水类型:前端多选。
hydrocephalusTypes String[] @default([])
// 上次分流手术时间:无既往分流史时为空。
previousShuntSurgeryDate DateTime?
// 术前影像/资料:支持图片、视频等附件元数据。
preOpMaterials Json?
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])
}
// 植入物型号字典:供前端单选型号后自动回填厂家与名称。
model ImplantCatalog {
id Int @id @default(autoincrement())
modelCode String @unique
manufacturer String
name String
// 是否为阀门;关闭时表示管子/附件,不提供压力挡位。
isValve Boolean @default(true)
// 可调压器械的可选挡位,由系统管理员维护。
pressureLevels String[] @default([])
isPressureAdjustable Boolean @default(true)
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
devices Device[]
}
// 系统级字典项:由系统管理员维护,供患者手术表单选择使用。
model DictionaryItem {
id Int @id @default(autoincrement())
type DictionaryType
label String
sortOrder Int @default(0)
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([type, label])
@@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())
currentPressure String
status DeviceStatus @default(ACTIVE)
patientId Int
surgeryId Int?
implantCatalogId Int?
// 植入物快照:避免型号字典修改后影响历史病历。
implantModel String?
implantManufacturer String?
implantName String?
isValve Boolean @default(true)
isPressureAdjustable Boolean @default(true)
// 二次手术后旧设备可标记弃用,但历史调压任务仍需保留。
isAbandoned Boolean @default(false)
shuntMode String?
proximalPunctureAreas String[] @default([])
valvePlacementSites String[] @default([])
distalShuntDirection String?
initialPressure String?
implantNotes String?
labelImageUrl String?
patient Patient @relation(fields: [patientId], references: [id])
surgery PatientSurgery? @relation(fields: [surgeryId], references: [id], onDelete: SetNull)
implantCatalog ImplantCatalog? @relation(fields: [implantCatalogId], references: [id], onDelete: SetNull)
taskItems TaskItem[]
@@index([patientId, status])
@@index([surgeryId])
@@index([implantCatalogId])
@@index([patientId, isAbandoned])
}
// 主任务表:记录调压任务主单。
model Task {
id Int @id @default(autoincrement())
status TaskStatus @default(PENDING)
creatorId Int
engineerId Int?
hospitalId Int
createdAt DateTime @default(now())
// 工程师完成任务时上传的图片/视频凭证。
completionMaterials Json?
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
engineer User? @relation("TaskEngineer", fields: [engineerId], references: [id])
hospital Hospital @relation(fields: [hospitalId], references: [id])
items TaskItem[]
@@index([hospitalId, status, createdAt])
}
// 任务明细表:一个任务可包含多个设备调压项。
model TaskItem {
id Int @id @default(autoincrement())
taskId Int
deviceId Int
oldPressure String
targetPressure String
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
device Device @relation(fields: [deviceId], references: [id])
@@index([taskId])
@@index([deviceId])
}

843
prisma/seed.mjs Normal file
View File

@ -0,0 +1,843 @@
import 'dotenv/config';
import { PrismaPg } from '@prisma/adapter-pg';
import { hash } from 'bcrypt';
import prismaClientPackage from '@prisma/client';
const { DictionaryType, DeviceStatus, PrismaClient, Role, TaskStatus } =
prismaClientPackage;
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL is required to run seed');
}
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString }),
});
const SEED_PASSWORD_PLAIN = 'Seed@1234';
async function ensureHospital(name) {
return (
(await prisma.hospital.findFirst({ where: { name } })) ??
prisma.hospital.create({ data: { name } })
);
}
async function ensureDepartment(hospitalId, name) {
return (
(await prisma.department.findFirst({
where: { hospitalId, name },
})) ??
prisma.department.create({
data: { hospitalId, name },
})
);
}
async function ensureGroup(departmentId, name) {
return (
(await prisma.group.findFirst({
where: { departmentId, name },
})) ??
prisma.group.create({
data: { departmentId, name },
})
);
}
async function upsertUserByScope(data) {
return prisma.user.upsert({
where: {
phone_role_hospitalId: {
phone: data.phone,
role: data.role,
hospitalId: data.hospitalId,
},
},
// 每次重置/补种子时推进失效时间,确保历史 token 无法继续访问。
update: {
...data,
tokenValidAfter: new Date(),
},
create: data,
});
}
async function ensurePatient({
hospitalId,
doctorId,
creatorId,
name,
inpatientNo = null,
projectName = null,
phone,
idCard,
}) {
const existing = await prisma.patient.findFirst({
where: {
hospitalId,
phone,
idCard,
},
});
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, creatorId, name, inpatientNo, projectName },
});
}
return existing;
}
return prisma.patient.create({
data: {
hospitalId,
doctorId,
creatorId,
name,
inpatientNo,
projectName,
phone,
idCard,
},
});
}
async function ensureFamilyMiniAppAccount({
phone,
openId = null,
serviceUid = null,
}) {
const existing = await prisma.familyMiniAppAccount.findUnique({
where: { phone },
});
if (existing) {
return prisma.familyMiniAppAccount.update({
where: { id: existing.id },
data: {
openId,
serviceUid,
lastLoginAt: new Date(),
},
});
}
return prisma.familyMiniAppAccount.create({
data: {
phone,
openId,
serviceUid,
lastLoginAt: new Date(),
},
});
}
async function ensureImplantCatalog({
modelCode,
manufacturer,
name,
pressureLevels = [],
isPressureAdjustable = true,
notes = null,
}) {
return prisma.implantCatalog.upsert({
where: { modelCode },
update: {
manufacturer,
name,
pressureLevels,
isPressureAdjustable,
notes,
},
create: {
modelCode,
manufacturer,
name,
pressureLevels,
isPressureAdjustable,
notes,
},
});
}
async function ensureDictionaryItem({
type,
label,
sortOrder = 0,
enabled = true,
}) {
return prisma.dictionaryItem.upsert({
where: {
type_label: {
type,
label,
},
},
update: {
sortOrder,
enabled,
},
create: {
type,
label,
sortOrder,
enabled,
},
});
}
async function ensurePatientSurgery({
patientId,
surgeryDate,
surgeryName,
surgeonName,
preOpPressure = null,
primaryDisease,
hydrocephalusTypes,
previousShuntSurgeryDate = null,
preOpMaterials = null,
notes = null,
}) {
const normalizedSurgeryDate = new Date(surgeryDate);
const normalizedPreviousDate = previousShuntSurgeryDate
? new Date(previousShuntSurgeryDate)
: null;
const existing = await prisma.patientSurgery.findFirst({
where: {
patientId,
surgeryDate: normalizedSurgeryDate,
surgeryName,
},
});
if (existing) {
return prisma.patientSurgery.update({
where: { id: existing.id },
data: {
surgeonName,
preOpPressure,
primaryDisease,
hydrocephalusTypes,
previousShuntSurgeryDate: normalizedPreviousDate,
preOpMaterials,
notes,
},
});
}
return prisma.patientSurgery.create({
data: {
patientId,
surgeryDate: normalizedSurgeryDate,
surgeryName,
surgeonName,
preOpPressure,
primaryDisease,
hydrocephalusTypes,
previousShuntSurgeryDate: normalizedPreviousDate,
preOpMaterials,
notes,
},
});
}
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);
const hospitalA = await ensureHospital('Seed Hospital A');
const hospitalB = await ensureHospital('Seed Hospital B');
const departmentA1 = await ensureDepartment(hospitalA.id, 'Neurosurgery-A1');
const departmentA2 = await ensureDepartment(hospitalA.id, 'Cardiology-A2');
const departmentB1 = await ensureDepartment(hospitalB.id, 'Neurosurgery-B1');
const groupA1 = await ensureGroup(departmentA1.id, 'Shift-A1');
const groupA2 = await ensureGroup(departmentA2.id, 'Shift-A2');
const groupB1 = await ensureGroup(departmentB1.id, 'Shift-B1');
const systemAdmin = await upsertUserByScope({
name: 'Seed System Admin',
phone: '13800001000',
passwordHash: seedPasswordHash,
openId: 'seed-system-admin-openid',
role: Role.SYSTEM_ADMIN,
hospitalId: null,
departmentId: null,
groupId: null,
});
const hospitalAdminA = await upsertUserByScope({
name: 'Seed Hospital Admin A',
phone: '13800001001',
passwordHash: seedPasswordHash,
openId: 'seed-hospital-admin-a-openid',
role: Role.HOSPITAL_ADMIN,
hospitalId: hospitalA.id,
departmentId: null,
groupId: null,
});
await upsertUserByScope({
name: 'Seed Hospital Admin B',
phone: '13800001101',
passwordHash: seedPasswordHash,
openId: 'seed-hospital-admin-b-openid',
role: Role.HOSPITAL_ADMIN,
hospitalId: hospitalB.id,
departmentId: null,
groupId: null,
});
const directorA = await upsertUserByScope({
name: 'Seed Director A',
phone: '13800001002',
passwordHash: seedPasswordHash,
openId: 'seed-director-a-openid',
role: Role.DIRECTOR,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: null,
});
const leaderA = await upsertUserByScope({
name: 'Seed Leader A',
phone: '13800001003',
passwordHash: seedPasswordHash,
openId: 'seed-leader-a-openid',
role: Role.LEADER,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: groupA1.id,
});
const doctorA = await upsertUserByScope({
name: 'Seed Doctor A',
phone: '13800001004',
passwordHash: seedPasswordHash,
openId: 'seed-doctor-a-openid',
role: Role.DOCTOR,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: groupA1.id,
});
const doctorA2 = await upsertUserByScope({
name: 'Seed Doctor A2',
phone: '13800001204',
passwordHash: seedPasswordHash,
openId: 'seed-doctor-a2-openid',
role: Role.DOCTOR,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: groupA1.id,
});
const doctorA3 = await upsertUserByScope({
name: 'Seed Doctor A3',
phone: '13800001304',
passwordHash: seedPasswordHash,
openId: 'seed-doctor-a3-openid',
role: Role.DOCTOR,
hospitalId: hospitalA.id,
departmentId: departmentA2.id,
groupId: groupA2.id,
});
const doctorB = await upsertUserByScope({
name: 'Seed Doctor B',
phone: '13800001104',
passwordHash: seedPasswordHash,
openId: 'seed-doctor-b-openid',
role: Role.DOCTOR,
hospitalId: hospitalB.id,
departmentId: departmentB1.id,
groupId: groupB1.id,
});
const engineerA = await upsertUserByScope({
name: 'Seed Engineer A',
phone: '13800001005',
passwordHash: seedPasswordHash,
openId: 'seed-engineer-a-openid',
role: Role.ENGINEER,
hospitalId: hospitalA.id,
departmentId: null,
groupId: null,
});
const engineerB = await upsertUserByScope({
name: 'Seed Engineer B',
phone: '13800001105',
passwordHash: seedPasswordHash,
openId: 'seed-engineer-b-openid',
role: Role.ENGINEER,
hospitalId: hospitalB.id,
departmentId: null,
groupId: null,
});
const dictionarySeeds = {
[DictionaryType.PRIMARY_DISEASE]: [
'先天性脑积水',
'梗阻性脑积水',
'交通性脑积水',
'出血后脑积水',
'肿瘤相关脑积水',
'外伤后脑积水',
'感染后脑积水',
'分流功能障碍',
],
[DictionaryType.HYDROCEPHALUS_TYPE]: [
'交通性',
'梗阻性',
'高压性',
'正常压力',
'先天性',
'继发性',
],
[DictionaryType.SHUNT_MODE]: ['VPS', 'VPLS', 'LPS', '脑室心房分流'],
[DictionaryType.PROXIMAL_PUNCTURE_AREA]: [
'额角',
'枕角',
'三角区',
'腰穿',
'后角',
],
[DictionaryType.VALVE_PLACEMENT_SITE]: [
'耳后',
'胸前',
'锁骨下',
'腹壁',
'腰背部',
],
[DictionaryType.DISTAL_SHUNT_DIRECTION]: ['腹腔', '胸腔', '心房', '腰大池'],
};
await Promise.all(
Object.entries(dictionarySeeds).flatMap(([type, labels]) =>
labels.map((label, index) =>
ensureDictionaryItem({
type,
label,
sortOrder: index * 10,
}),
),
),
);
const patientA1 = await ensurePatient({
hospitalId: hospitalA.id,
doctorId: doctorA.id,
creatorId: doctorA.id,
name: 'Seed Patient A1',
inpatientNo: 'ZYH-A-0001',
projectName: '脑积水随访项目-A',
phone: '13800002001',
idCard: '110101199001010011',
});
const patientA2 = await ensurePatient({
hospitalId: hospitalA.id,
doctorId: doctorA2.id,
creatorId: doctorA2.id,
name: 'Seed Patient A2',
inpatientNo: 'ZYH-A-0002',
projectName: '脑积水随访项目-A',
phone: '13800002002',
idCard: '110101199002020022',
});
const patientA3 = await ensurePatient({
hospitalId: hospitalA.id,
doctorId: doctorA3.id,
creatorId: doctorA3.id,
name: 'Seed Patient A3',
inpatientNo: 'ZYH-A-0003',
projectName: '脑积水随访项目-A',
phone: '13800002003',
idCard: '110101199003030033',
});
const patientB1 = await ensurePatient({
hospitalId: hospitalB.id,
doctorId: doctorB.id,
creatorId: doctorB.id,
name: 'Seed Patient B1',
inpatientNo: 'ZYH-B-0001',
projectName: '脑积水随访项目-B',
phone: '13800002001',
idCard: '110101199001010011',
});
await ensureFamilyMiniAppAccount({
phone: patientA2.phone,
openId: 'seed-family-a2-openid',
});
const adjustableCatalog = await ensureImplantCatalog({
modelCode: 'SEED-ADJUSTABLE-VALVE',
manufacturer: 'Seed MedTech',
name: 'Seed 可调压分流阀',
pressureLevels: [80, 100, 120, 140, 160],
isPressureAdjustable: true,
notes: 'Seed 全局可调压目录样例',
});
const fixedCatalog = await ensureImplantCatalog({
modelCode: 'SEED-FIXED-VALVE',
manufacturer: 'Seed MedTech',
name: 'Seed 固定压分流阀',
pressureLevels: [],
isPressureAdjustable: false,
notes: 'Seed 固定压目录样例',
});
const surgeryA1Old = await ensurePatientSurgery({
patientId: patientA1.id,
surgeryDate: '2024-06-01T08:00:00.000Z',
surgeryName: '首次脑室腹腔分流术',
surgeonName: 'Seed Director A',
preOpPressure: 24,
primaryDisease: '先天性脑积水',
hydrocephalusTypes: ['交通性'],
notes: '首台手术',
});
const surgeryA1New = await ensurePatientSurgery({
patientId: patientA1.id,
surgeryDate: '2025-09-10T08:00:00.000Z',
surgeryName: '分流系统翻修术',
surgeonName: 'Seed Director A',
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: '二次手术,保留原设备历史',
});
const surgeryA2 = await ensurePatientSurgery({
patientId: patientA2.id,
surgeryDate: '2025-12-15T08:00:00.000Z',
surgeryName: '脑室腹腔分流术',
surgeonName: 'Seed Doctor A2',
preOpPressure: 20,
primaryDisease: '肿瘤相关脑积水',
hydrocephalusTypes: ['梗阻性'],
});
const surgeryA3 = await ensurePatientSurgery({
patientId: patientA3.id,
surgeryDate: '2025-11-20T08:00:00.000Z',
surgeryName: '脑室腹腔分流术',
surgeonName: 'Seed Doctor A3',
preOpPressure: 21,
primaryDisease: '外伤后脑积水',
hydrocephalusTypes: ['交通性'],
});
const surgeryB1 = await ensurePatientSurgery({
patientId: patientB1.id,
surgeryDate: '2025-10-05T08:00:00.000Z',
surgeryName: '脑室腹腔分流术',
surgeonName: 'Seed Doctor B',
preOpPressure: 23,
primaryDisease: '出血后脑积水',
hydrocephalusTypes: ['高压性'],
});
const deviceA1 = await ensureDevice({
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 ensureDevice({
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 ensureDevice({
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 ensureDevice({
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 ensureDevice({
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 可重复执行且生命周期夹具稳定。
const seedTaskItems = await prisma.taskItem.findMany({
where: {
deviceId: {
in: [deviceA1.id, deviceB1.id],
},
},
select: { taskId: true },
});
const seedTaskIds = Array.from(
new Set(seedTaskItems.map((item) => item.taskId)),
);
if (seedTaskIds.length > 0) {
await prisma.task.deleteMany({
where: {
id: {
in: seedTaskIds,
},
},
});
}
const lifecycleTaskA = await prisma.task.create({
data: {
status: TaskStatus.COMPLETED,
creatorId: doctorA.id,
engineerId: engineerA.id,
hospitalId: hospitalA.id,
items: {
create: [
{
deviceId: deviceA1.id,
oldPressure: 118,
targetPressure: 120,
},
],
},
},
include: { items: true },
});
const lifecycleTaskB = await prisma.task.create({
data: {
status: TaskStatus.PENDING,
creatorId: doctorB.id,
engineerId: engineerB.id,
hospitalId: hospitalB.id,
items: {
create: [
{
deviceId: deviceB1.id,
oldPressure: 121,
targetPressure: 119,
},
],
},
},
include: { items: true },
});
console.log(
JSON.stringify(
{
ok: true,
seedPasswordPlain: SEED_PASSWORD_PLAIN,
hospitals: {
hospitalAId: hospitalA.id,
hospitalBId: hospitalB.id,
},
departments: {
departmentA1Id: departmentA1.id,
departmentA2Id: departmentA2.id,
departmentB1Id: departmentB1.id,
},
groups: {
groupA1Id: groupA1.id,
groupA2Id: groupA2.id,
groupB1Id: groupB1.id,
},
users: {
systemAdminId: systemAdmin.id,
hospitalAdminAId: hospitalAdminA.id,
directorAId: directorA.id,
leaderAId: leaderA.id,
doctorAId: doctorA.id,
doctorA2Id: doctorA2.id,
doctorA3Id: doctorA3.id,
doctorBId: doctorB.id,
engineerAId: engineerA.id,
engineerBId: engineerB.id,
},
patients: {
patientA1Id: patientA1.id,
patientA2Id: patientA2.id,
patientA3Id: patientA3.id,
patientB1Id: patientB1.id,
},
devices: {
deviceA1Id: deviceA1.id,
deviceA2Id: deviceA2.id,
deviceB1Id: deviceB1.id,
},
tasks: {
lifecycleTaskAId: lifecycleTaskA.id,
lifecycleTaskBId: lifecycleTaskB.id,
},
},
null,
2,
),
);
}
main()
.catch((error) => {
console.error('Seed failed:', error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -1,7 +1,47 @@
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
import { APP_GUARD } from '@nestjs/core';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { PrismaModule } from './prisma.module.js';
import { UsersModule } from './users/users.module.js';
import { TasksModule } from './tasks/tasks.module.js';
import { PatientsModule } from './patients/patients.module.js';
import { AuthModule } from './auth/auth.module.js';
import { OrganizationModule } from './organization/organization.module.js';
import { NotificationsModule } from './notifications/notifications.module.js';
import { DevicesModule } from './devices/devices.module.js';
import { DictionariesModule } from './dictionaries/dictionaries.module.js';
import { UploadsModule } from './uploads/uploads.module.js';
@Module({
imports: [UsersModule],
imports: [
PrismaModule,
EventEmitterModule.forRoot(),
ThrottlerModule.forRoot({
errorMessage: '操作过于频繁,请稍后再试',
throttlers: [
{
name: 'default',
ttl: 60_000,
limit: 120,
},
],
}),
UsersModule,
TasksModule,
PatientsModule,
AuthModule,
OrganizationModule,
NotificationsModule,
DevicesModule,
DictionariesModule,
UploadsModule,
],
providers: [
{
provide: APP_GUARD,
useClass: ThrottlerGuard,
},
],
})
export class AppModule {}

View File

@ -0,0 +1,113 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import jwt from 'jsonwebtoken';
import type { ActorContext } from '../common/actor-context.js';
import { MESSAGES } from '../common/messages.js';
import { PrismaService } from '../prisma.service.js';
/**
* AccessToken Bearer JWT actor request
*/
@Injectable()
export class AccessTokenGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {}
/**
* true 401
*/
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<{
headers: Record<string, string | string[] | undefined>;
actor?: unknown;
}>();
const authorization = request.headers.authorization;
const headerValue = Array.isArray(authorization)
? authorization[0]
: authorization;
if (!headerValue || !headerValue.startsWith('Bearer ')) {
throw new UnauthorizedException(MESSAGES.AUTH.MISSING_BEARER);
}
const token = headerValue.slice('Bearer '.length).trim();
request.actor = await this.verifyAndExtractActor(token);
return true;
}
/**
* token
*/
private async verifyAndExtractActor(token: string): Promise<ActorContext> {
const secret = process.env.AUTH_TOKEN_SECRET;
if (!secret) {
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_SECRET_MISSING);
}
let payload: string | jwt.JwtPayload;
try {
payload = jwt.verify(token, secret, {
algorithms: ['HS256'],
issuer: 'tyt-api-nest',
});
} catch {
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_INVALID);
}
if (typeof payload !== 'object') {
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_PAYLOAD_INVALID);
}
const userId = this.asInt(payload.id, 'id');
const issuedAt = this.asInt(payload.iat, 'iat');
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
role: true,
hospitalId: true,
departmentId: true,
groupId: true,
tokenValidAfter: true,
},
});
// 数据库里已经没有该用户时,旧 token 必须立即失效。
if (!user) {
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_USER_NOT_FOUND);
}
// JWT 的 iat 精度是秒,这里按秒比较,避免同秒登录被误伤。
const tokenValidAfterUnix = Math.floor(
user.tokenValidAfter.getTime() / 1000,
);
if (issuedAt < tokenValidAfterUnix) {
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_REVOKED);
}
return {
id: user.id,
role: user.role,
hospitalId: user.hospitalId,
departmentId: user.departmentId,
groupId: user.groupId,
};
}
/**
* token
*/
private asInt(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isInteger(value)) {
throw new UnauthorizedException(
`${MESSAGES.AUTH.TOKEN_FIELD_INVALID}: ${field}`,
);
}
return value;
}
}

View File

@ -0,0 +1,80 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service.js';
import { AccessTokenGuard } from './access-token.guard.js';
import { CurrentActor } from './current-actor.decorator.js';
import type { ActorContext } from '../common/actor-context.js';
import { CreateSystemAdminDto } from './dto/create-system-admin.dto.js';
import { MiniappPhoneLoginDto } from './dto/miniapp-phone-login.dto.js';
import { MiniappPhoneLoginConfirmDto } from './dto/miniapp-phone-login-confirm.dto.js';
import { PasswordLoginConfirmDto } from './dto/password-login-confirm.dto.js';
import { LoginDto } from '../users/dto/login.dto.js';
/**
*
*/
@ApiTags('认证')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
/**
*
*/
@Post('system-admin')
@Throttle({ default: { limit: 3, ttl: 60_000 } })
@ApiOperation({ summary: '创建系统管理员' })
createSystemAdmin(@Body() dto: CreateSystemAdminDto) {
return this.authService.createSystemAdmin(dto);
}
/**
*
*/
@Post('login')
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@ApiOperation({ summary: '院内账号密码登录' })
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Post('login/confirm')
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@ApiOperation({ summary: '院内账号密码多账号确认登录' })
confirmLogin(@Body() dto: PasswordLoginConfirmDto) {
return this.authService.confirmLogin(dto);
}
@Post('miniapp/b/phone-login')
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@ApiOperation({ summary: 'B 端小程序手机号登录' })
miniAppBLogin(@Body() dto: MiniappPhoneLoginDto) {
return this.authService.miniAppBLogin(dto);
}
@Post('miniapp/b/phone-login/confirm')
@Throttle({ default: { limit: 10, ttl: 60_000 } })
@ApiOperation({ summary: 'B 端小程序多账号确认登录' })
miniAppBConfirmLogin(@Body() dto: MiniappPhoneLoginConfirmDto) {
return this.authService.miniAppBConfirmLogin(dto);
}
@Post('miniapp/c/phone-login')
@Throttle({ default: { limit: 5, ttl: 60_000 } })
@ApiOperation({ summary: 'C 端小程序手机号登录' })
miniAppCLogin(@Body() dto: MiniappPhoneLoginDto) {
return this.authService.miniAppCLogin(dto);
}
/**
*
*/
@Get('me')
@UseGuards(AccessTokenGuard)
@ApiBearerAuth('bearer')
@ApiOperation({ summary: '获取当前用户信息' })
me(@CurrentActor() actor: ActorContext) {
return this.authService.me(actor);
}
}

18
src/auth/auth.module.ts Normal file
View File

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

69
src/auth/auth.service.ts Normal file
View File

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

View File

@ -0,0 +1,14 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import type { ActorContext } from '../common/actor-context.js';
/**
* request AccessTokenGuard actor
*/
export const CurrentActor = createParamDecorator(
(_data: unknown, context: ExecutionContext): ActorContext => {
const request = context
.switchToHttp()
.getRequest<{ actor: ActorContext }>();
return request.actor;
},
);

View File

@ -0,0 +1,14 @@
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
import type { FamilyActorContext } from '../common/family-actor-context.js';
/**
* C
*/
export const CurrentFamilyActor = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): FamilyActorContext | undefined => {
const request = ctx
.switchToHttp()
.getRequest<{ familyActor?: FamilyActorContext }>();
return request.familyActor;
},
);

View File

@ -0,0 +1,32 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, IsString } from 'class-validator';
export class CreateSystemAdminDto {
@ApiProperty({ description: '姓名', example: '系统管理员' })
@IsString({ message: 'name 必须是字符串' })
name!: string;
@ApiProperty({ description: '手机号', example: '13800000000' })
@IsString({ message: 'phone 必须是字符串' })
phone!: string;
@ApiProperty({ description: '密码(至少 8 位)', example: 'Admin@12345' })
@IsString({ message: 'password 必须是字符串' })
password!: string;
@ApiPropertyOptional({
description: '可选微信 openId院内账号间可复用',
example: 'o123abcxyz',
})
@IsOptional()
@IsString({ message: 'openId 必须是字符串' })
openId?: string;
@ApiProperty({
description:
'系统管理员创建引导密钥(来自环境变量 SYSTEM_ADMIN_BOOTSTRAP_KEY',
example: 'init-admin-secret',
})
@IsString({ message: 'systemAdminBootstrapKey 必须是字符串' })
systemAdminBootstrapKey!: string;
}

View File

@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsString, Min } from 'class-validator';
/**
* B DTO
*/
export class MiniappPhoneLoginConfirmDto {
@ApiProperty({
description: 'B 端候选账号选择票据',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
})
@IsString({ message: 'loginTicket 必须是字符串' })
loginTicket!: string;
@ApiProperty({
description: '确认登录的用户 ID',
example: 1,
})
@Type(() => Number)
@IsInt({ message: 'userId 必须是整数' })
@Min(1, { message: 'userId 必须大于 0' })
userId!: number;
}

View File

@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
/**
* DTO
*/
export class MiniappPhoneLoginDto {
@ApiProperty({
description: '微信登录 code用于换取 openId',
example: '08123456789abcdef',
})
@IsString({ message: 'loginCode 必须是字符串' })
loginCode!: string;
@ApiProperty({
description: '微信手机号授权 code用于换取手机号',
example: '12ab34cd56ef78gh',
})
@IsString({ message: 'phoneCode 必须是字符串' })
phoneCode!: string;
}

View File

@ -0,0 +1,24 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsString, Min } from 'class-validator';
/**
* DTO
*/
export class PasswordLoginConfirmDto {
@ApiProperty({
description: '密码登录候选账号票据',
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9',
})
@IsString({ message: 'loginTicket 必须是字符串' })
loginTicket!: string;
@ApiProperty({
description: '最终确认登录的用户 ID',
example: 1,
})
@Type(() => Number)
@IsInt({ message: 'userId 必须是整数' })
@Min(1, { message: 'userId 必须大于 0' })
userId!: number;
}

View File

@ -0,0 +1,84 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import jwt from 'jsonwebtoken';
import type { FamilyActorContext } from '../../common/family-actor-context.js';
import { MESSAGES } from '../../common/messages.js';
import { PrismaService } from '../../prisma.service.js';
/**
* C
*/
@Injectable()
export class FamilyAccessTokenGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<{
headers: Record<string, string | string[] | undefined>;
familyActor?: FamilyActorContext;
}>();
const authorization = request.headers.authorization;
const headerValue = Array.isArray(authorization)
? authorization[0]
: authorization;
if (!headerValue || !headerValue.startsWith('Bearer ')) {
throw new UnauthorizedException(MESSAGES.AUTH.MISSING_BEARER);
}
request.familyActor = await this.verifyAndExtractActor(
headerValue.slice('Bearer '.length).trim(),
);
return true;
}
/**
* C token
*/
private async verifyAndExtractActor(
token: string,
): Promise<FamilyActorContext> {
const secret = process.env.AUTH_TOKEN_SECRET;
if (!secret) {
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_SECRET_MISSING);
}
let payload: string | jwt.JwtPayload;
try {
payload = jwt.verify(token, secret, {
algorithms: ['HS256'],
issuer: 'tyt-api-nest-family',
});
} catch {
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_INVALID);
}
if (
typeof payload !== 'object' ||
payload.type !== 'FAMILY_MINIAPP' ||
typeof payload.id !== 'number' ||
!Number.isInteger(payload.id)
) {
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_PAYLOAD_INVALID);
}
const account = await this.prisma.familyMiniAppAccount.findUnique({
where: { id: payload.id },
select: {
id: true,
phone: true,
openId: true,
serviceUid: true,
},
});
if (!account) {
throw new UnauthorizedException(MESSAGES.AUTH.FAMILY_ACCOUNT_NOT_FOUND);
}
return account;
}
}

View File

@ -0,0 +1,277 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import jwt from 'jsonwebtoken';
import { Role } from '../../generated/prisma/enums.js';
import { MESSAGES } from '../../common/messages.js';
import { PrismaService } from '../../prisma.service.js';
import { UsersService } from '../../users/users.service.js';
import { MiniappPhoneLoginConfirmDto } from '../dto/miniapp-phone-login-confirm.dto.js';
import { MiniappPhoneLoginDto } from '../dto/miniapp-phone-login.dto.js';
import { WechatMiniAppService } from '../wechat-miniapp/wechat-miniapp.service.js';
type LoginTicketPayload = {
purpose: 'MINIAPP_B_LOGIN_TICKET';
phone: string;
openId: string;
userIds: number[];
};
/**
* B/C
*/
@Injectable()
export class MiniAppAuthService {
constructor(
private readonly prisma: PrismaService,
private readonly usersService: UsersService,
private readonly wechatMiniAppService: WechatMiniAppService,
) {}
/**
* B
*/
async loginForB(dto: MiniappPhoneLoginDto) {
const identity = await this.wechatMiniAppService.resolvePhoneIdentity(
dto.loginCode,
dto.phoneCode,
);
const accounts = await this.prisma.user.findMany({
where: { phone: identity.phone },
select: {
id: true,
name: true,
phone: true,
openId: true,
role: true,
hospitalId: true,
departmentId: true,
groupId: true,
hospital: {
select: {
id: true,
name: true,
},
},
},
orderBy: [{ hospitalId: 'asc' }, { id: 'asc' }],
});
if (accounts.length === 0) {
throw new NotFoundException(MESSAGES.AUTH.MINIAPP_NO_MATCHED_USER);
}
if (accounts.length === 1) {
const [user] = accounts;
await this.usersService.bindOpenIdForMiniAppLogin(
user.id,
identity.openId,
);
return this.usersService.loginByUserId(user.id);
}
return {
needSelect: true,
loginTicket: this.signLoginTicket({
purpose: 'MINIAPP_B_LOGIN_TICKET',
phone: identity.phone,
openId: identity.openId,
userIds: accounts.map((account) => account.id),
}),
accounts: accounts.map((account) => ({
id: account.id,
name: account.name,
role: account.role,
hospitalId: account.hospitalId,
hospitalName: account.hospital?.name ?? null,
departmentId: account.departmentId,
groupId: account.groupId,
})),
};
}
/**
* B
*/
async confirmLoginForB(dto: MiniappPhoneLoginConfirmDto) {
const payload = this.verifyLoginTicket(dto.loginTicket);
if (!payload.userIds.includes(dto.userId)) {
throw new BadRequestException(
MESSAGES.AUTH.MINIAPP_ACCOUNT_SELECTION_INVALID,
);
}
await this.usersService.bindOpenIdForMiniAppLogin(
dto.userId,
payload.openId,
);
return this.usersService.loginByUserId(dto.userId);
}
/**
* C
*/
async loginForC(dto: MiniappPhoneLoginDto) {
const identity = await this.wechatMiniAppService.resolvePhoneIdentity(
dto.loginCode,
dto.phoneCode,
);
const matchedPatients = await this.prisma.patient.findMany({
where: { phone: identity.phone },
select: { id: true },
take: 2,
});
if (matchedPatients.length === 0) {
throw new NotFoundException(
MESSAGES.AUTH.FAMILY_PHONE_NOT_LINKED_PATIENT,
);
}
if (matchedPatients.length > 1) {
throw new ConflictException(
MESSAGES.AUTH.FAMILY_PHONE_LINKED_MULTI_PATIENTS,
);
}
const existingByOpenId = await this.prisma.familyMiniAppAccount.findUnique({
where: { openId: identity.openId },
select: { id: true, phone: true },
});
if (existingByOpenId && existingByOpenId.phone !== identity.phone) {
throw new ConflictException(
MESSAGES.AUTH.MINIAPP_OPEN_ID_BOUND_OTHER_FAMILY,
);
}
const current = await this.prisma.familyMiniAppAccount.findUnique({
where: { phone: identity.phone },
select: {
id: true,
phone: true,
openId: true,
serviceUid: true,
},
});
if (current?.openId && current.openId !== identity.openId) {
throw new ConflictException(
MESSAGES.AUTH.MINIAPP_OPEN_ID_BOUND_OTHER_FAMILY,
);
}
const familyAccount = current
? await this.prisma.familyMiniAppAccount.update({
where: { id: current.id },
data: {
openId: current.openId ?? identity.openId,
lastLoginAt: new Date(),
},
select: {
id: true,
phone: true,
openId: true,
serviceUid: true,
},
})
: await this.prisma.familyMiniAppAccount.create({
data: {
phone: identity.phone,
openId: identity.openId,
lastLoginAt: new Date(),
},
select: {
id: true,
phone: true,
openId: true,
serviceUid: true,
},
});
return {
tokenType: 'Bearer',
accessToken: this.signFamilyAccessToken(familyAccount.id),
familyAccount,
};
}
/**
*
*/
private signLoginTicket(payload: LoginTicketPayload) {
const secret = this.requireAuthSecret();
return jwt.sign(payload, secret, {
algorithm: 'HS256',
expiresIn: '5m',
issuer: 'tyt-api-nest',
});
}
/**
*
*/
private verifyLoginTicket(token: string): LoginTicketPayload {
const secret = this.requireAuthSecret();
let payload: string | jwt.JwtPayload;
try {
payload = jwt.verify(token, secret, {
algorithms: ['HS256'],
issuer: 'tyt-api-nest',
});
} catch {
throw new UnauthorizedException(
MESSAGES.AUTH.MINIAPP_LOGIN_TICKET_INVALID,
);
}
if (
typeof payload !== 'object' ||
payload.purpose !== 'MINIAPP_B_LOGIN_TICKET' ||
typeof payload.phone !== 'string' ||
typeof payload.openId !== 'string' ||
!Array.isArray(payload.userIds) ||
payload.userIds.some(
(item) => typeof item !== 'number' || !Number.isInteger(item),
)
) {
throw new UnauthorizedException(
MESSAGES.AUTH.MINIAPP_LOGIN_TICKET_INVALID,
);
}
return payload as unknown as LoginTicketPayload;
}
/**
* C 访 token
*/
private signFamilyAccessToken(accountId: number) {
const secret = this.requireAuthSecret();
return jwt.sign(
{
id: accountId,
type: 'FAMILY_MINIAPP',
},
secret,
{
algorithm: 'HS256',
expiresIn: '7d',
issuer: 'tyt-api-nest-family',
},
);
}
/**
*
*/
private requireAuthSecret() {
const secret = process.env.AUTH_TOKEN_SECRET;
if (!secret) {
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_SECRET_MISSING);
}
return secret;
}
}

View File

@ -0,0 +1,9 @@
import { SetMetadata } from '@nestjs/common';
import { Role } from '../generated/prisma/enums.js';
export const ROLES_KEY = 'roles';
/**
* 访
*/
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

42
src/auth/roles.guard.ts Normal file
View File

@ -0,0 +1,42 @@
import {
CanActivate,
ExecutionContext,
ForbiddenException,
Injectable,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Role } from '../generated/prisma/enums.js';
import { ROLES_KEY } from './roles.decorator.js';
import { MESSAGES } from '../common/messages.js';
/**
* @Roles 访
*/
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
/**
*
*/
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles || requiredRoles.length === 0) {
return true;
}
const request = context
.switchToHttp()
.getRequest<{ actor?: { role?: Role } }>();
const actorRole = request.actor?.role;
if (!actorRole || !requiredRoles.includes(actorRole)) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
return true;
}
}

View File

@ -0,0 +1,219 @@
import {
BadRequestException,
Injectable,
InternalServerErrorException,
UnauthorizedException,
} from '@nestjs/common';
import { MESSAGES } from '../../common/messages.js';
type WechatAccessTokenResponse = {
access_token?: string;
expires_in?: number;
errcode?: number;
errmsg?: string;
};
type WechatCode2SessionResponse = {
openid?: string;
errcode?: number;
errmsg?: string;
};
type WechatPhoneResponse = {
phone_info?: {
phoneNumber?: string;
};
errcode?: number;
errmsg?: string;
};
/**
* openId
*/
@Injectable()
export class WechatMiniAppService {
private accessTokenCache:
| {
token: string;
expiresAt: number;
}
| null = null;
/**
* code code
*/
async resolvePhoneIdentity(loginCode: string, phoneCode: string) {
const [openId, phone] = await Promise.all([
this.exchangeLoginCode(loginCode),
this.exchangePhoneCode(phoneCode),
]);
return {
openId,
phone,
};
}
/**
* wx.login code openId
*/
async exchangeLoginCode(loginCode: string): Promise<string> {
const config = this.requireConfig();
const params = new URLSearchParams({
appid: config.appId,
secret: config.secret,
js_code: this.normalizeCode(loginCode, 'loginCode'),
grant_type: 'authorization_code',
});
const response = await fetch(
`https://api.weixin.qq.com/sns/jscode2session?${params.toString()}`,
);
const payload =
(await response.json().catch(() => null)) as WechatCode2SessionResponse | null;
if (!response.ok || !payload?.openid || payload.errcode) {
throw new UnauthorizedException(
this.buildWechatAuthErrorMessage(
MESSAGES.AUTH.WECHAT_MINIAPP_LOGIN_FAILED,
payload,
),
);
}
return payload.openid;
}
/**
* code
*/
async exchangePhoneCode(phoneCode: string): Promise<string> {
const accessToken = await this.getAccessToken();
const response = await fetch(
`https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${encodeURIComponent(accessToken)}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
code: this.normalizeCode(phoneCode, 'phoneCode'),
}),
},
);
const payload =
(await response.json().catch(() => null)) as WechatPhoneResponse | null;
const phone = payload?.phone_info?.phoneNumber;
if (!response.ok || !phone || payload?.errcode) {
throw new UnauthorizedException(
this.buildWechatAuthErrorMessage(
MESSAGES.AUTH.WECHAT_MINIAPP_PHONE_FAILED,
payload,
),
);
}
return phone;
}
/**
* access token
*/
private async getAccessToken(): Promise<string> {
const cached = this.accessTokenCache;
if (cached && cached.expiresAt > Date.now()) {
return cached.token;
}
const config = this.requireConfig();
const params = new URLSearchParams({
grant_type: 'client_credential',
appid: config.appId,
secret: config.secret,
});
const response = await fetch(
`https://api.weixin.qq.com/cgi-bin/token?${params.toString()}`,
);
const payload =
(await response.json().catch(() => null)) as WechatAccessTokenResponse | null;
if (!response.ok || !payload?.access_token || payload.errcode) {
throw new UnauthorizedException(
this.buildWechatAuthErrorMessage(
MESSAGES.AUTH.WECHAT_MINIAPP_PHONE_FAILED,
payload,
),
);
}
this.accessTokenCache = {
token: payload.access_token,
expiresAt: Date.now() + Math.max((payload.expires_in ?? 7200) - 120, 60) * 1000,
};
return payload.access_token;
}
/**
*
*/
private requireConfig() {
const appId = process.env.WECHAT_MINIAPP_APPID?.trim();
const secret = process.env.WECHAT_MINIAPP_SECRET?.trim();
if (!appId || !secret) {
throw new InternalServerErrorException(
MESSAGES.AUTH.WECHAT_MINIAPP_CONFIG_MISSING,
);
}
return {
appId,
secret,
};
}
/**
* code
*/
private normalizeCode(code: unknown, fieldName: string) {
if (typeof code !== 'string') {
throw new BadRequestException(`${fieldName} 必须是字符串`);
}
const trimmed = code.trim();
if (!trimmed) {
throw new BadRequestException(`${fieldName} 不能为空`);
}
return trimmed;
}
/**
* 便 appid/code/
*/
private buildWechatAuthErrorMessage(
fallbackMessage: string,
payload:
| WechatAccessTokenResponse
| WechatCode2SessionResponse
| WechatPhoneResponse
| null,
) {
const errcode =
payload && typeof payload.errcode === 'number' ? payload.errcode : null;
const errmsg =
payload && typeof payload.errmsg === 'string' ? payload.errmsg.trim() : '';
if (errcode == null && !errmsg) {
return fallbackMessage;
}
const details = [`errcode=${errcode ?? 'unknown'}`];
if (errmsg) {
details.push(`errmsg=${errmsg}`);
}
return `${fallbackMessage}${details.join(', ')}`;
}
}

View File

@ -0,0 +1,9 @@
import { Role } from '../generated/prisma/enums.js';
export type ActorContext = {
id: number;
role: Role;
hospitalId: number | null;
departmentId: number | null;
groupId: number | null;
};

View File

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

View File

@ -0,0 +1,117 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Response } from 'express';
import { Prisma } from '../generated/prisma/client.js';
import { MESSAGES } from './messages.js';
/**
* msg
*/
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
// 非 HttpException 统一记录堆栈,便于定位 500 根因。
if (!(exception instanceof HttpException)) {
const error = exception as { message?: string; stack?: string };
this.logger.error(error?.message ?? 'Unhandled exception', error?.stack);
}
const status = this.resolveStatus(exception);
const msg = this.resolveMessage(exception, status);
response.status(status).json({
code: status,
msg,
data: null,
});
}
/**
* HTTP HttpException 500
*/
private resolveStatus(exception: unknown): number {
if (exception instanceof Prisma.PrismaClientInitializationError) {
return HttpStatus.SERVICE_UNAVAILABLE;
}
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
switch (exception.code) {
case 'P2002':
return HttpStatus.CONFLICT;
case 'P2025':
return HttpStatus.NOT_FOUND;
default:
return HttpStatus.INTERNAL_SERVER_ERROR;
}
}
if (exception instanceof HttpException) {
return exception.getStatus();
}
return HttpStatus.INTERNAL_SERVER_ERROR;
}
/**
* 使 message
*/
private resolveMessage(exception: unknown, status: number): string {
if (exception instanceof Prisma.PrismaClientInitializationError) {
return MESSAGES.DB.CONNECTION_FAILED;
}
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
switch (exception.code) {
case 'P2021':
return MESSAGES.DB.TABLE_MISSING;
case 'P2022':
return MESSAGES.DB.COLUMN_MISSING;
case 'P2002':
return MESSAGES.DEFAULT_CONFLICT;
case 'P2025':
return MESSAGES.DEFAULT_NOT_FOUND;
default:
return MESSAGES.DEFAULT_INTERNAL_ERROR;
}
}
if (exception instanceof HttpException) {
const payload = exception.getResponse();
if (typeof payload === 'string') {
return payload;
}
if (payload && typeof payload === 'object') {
const body = payload as Record<string, unknown>;
const message = body.message;
if (Array.isArray(message)) {
return message.join('');
}
if (typeof message === 'string' && message.trim()) {
return message;
}
}
}
switch (status) {
case HttpStatus.BAD_REQUEST:
return MESSAGES.DEFAULT_BAD_REQUEST;
case HttpStatus.UNAUTHORIZED:
return MESSAGES.DEFAULT_UNAUTHORIZED;
case HttpStatus.FORBIDDEN:
return MESSAGES.DEFAULT_FORBIDDEN;
case HttpStatus.NOT_FOUND:
return MESSAGES.DEFAULT_NOT_FOUND;
case HttpStatus.CONFLICT:
return MESSAGES.DEFAULT_CONFLICT;
default:
return MESSAGES.DEFAULT_INTERNAL_ERROR;
}
}
}

186
src/common/messages.ts Normal file
View File

@ -0,0 +1,186 @@
/**
*
*/
export const MESSAGES = {
SUCCESS: '成功',
DEFAULT_BAD_REQUEST: '请求参数不合法',
DEFAULT_UNAUTHORIZED: '未登录或登录已过期',
DEFAULT_FORBIDDEN: '无权限执行当前操作',
DEFAULT_NOT_FOUND: '请求资源不存在',
DEFAULT_CONFLICT: '请求冲突,请检查后重试',
DEFAULT_INTERNAL_ERROR: '服务器内部错误,请稍后重试',
DB: {
TABLE_MISSING: '数据库表不存在,请先执行数据库迁移',
COLUMN_MISSING: '数据库字段不存在,请先同步数据库结构',
CONNECTION_FAILED: '数据库连接失败,请检查 DATABASE_URL 与数据库服务状态',
},
AUTH: {
MISSING_BEARER: '缺少 Bearer Token',
TOKEN_SECRET_MISSING: '服务端未配置认证密钥',
TOKEN_INVALID: 'Token 无效或已过期',
TOKEN_PAYLOAD_INVALID: 'Token 载荷不合法',
TOKEN_USER_NOT_FOUND: 'Token 对应用户不存在,请重新登录',
TOKEN_REVOKED: 'Token 已失效,请重新登录',
TOKEN_ROLE_INVALID: 'Token 中角色信息不合法',
TOKEN_FIELD_INVALID: 'Token 中字段不合法',
INVALID_CREDENTIALS: '手机号、密码或角色不匹配',
PASSWORD_NOT_ENABLED: '该账号未启用密码登录',
PASSWORD_LOGIN_TICKET_INVALID: '账号选择票据无效或已过期',
PASSWORD_ACCOUNT_SELECTION_INVALID: '请选择有效的候选账号',
REGISTER_DISABLED: '注册接口已关闭,请联系管理员创建账号',
WECHAT_MINIAPP_CONFIG_MISSING: '服务端未配置微信小程序认证参数',
WECHAT_MINIAPP_LOGIN_FAILED: '微信登录授权失败,请重新获取登录凭证',
WECHAT_MINIAPP_PHONE_FAILED: '微信手机号授权失败,请重新获取手机号凭证',
MINIAPP_NO_MATCHED_USER: '手机号未匹配到院内账号',
MINIAPP_LOGIN_TICKET_INVALID: '账号选择票据无效或已过期',
MINIAPP_ACCOUNT_SELECTION_INVALID: '请选择有效的候选账号',
MINIAPP_OPEN_ID_BOUND_OTHER_USER: '当前院内账号已绑定其他微信账号',
MINIAPP_OPEN_ID_BOUND_OTHER_FAMILY: '当前微信账号已绑定其他 C 端账号',
FAMILY_PHONE_NOT_LINKED_PATIENT: '当前手机号未关联患者档案',
FAMILY_PHONE_LINKED_MULTI_PATIENTS:
'当前手机号关联了多份患者档案,请联系管理员处理',
FAMILY_ACCOUNT_NOT_FOUND: 'C端登录账号不存在请重新登录',
THROTTLED: '操作过于频繁,请稍后再试',
},
USER: {
NOT_FOUND: '用户不存在',
DUPLICATE_PHONE_ROLE_SCOPE: '同医院下该角色手机号已存在',
INVALID_ROLE: '角色不合法',
INVALID_PHONE: '手机号格式不合法',
INVALID_PASSWORD: '密码长度至少 8 位',
INVALID_OPEN_ID: 'openId 格式不合法',
HOSPITAL_REQUIRED: 'hospitalId 必填',
HOSPITAL_NOT_FOUND: 'hospitalId 对应医院不存在',
HOSPITAL_ID_INVALID: 'hospitalId 必须为整数',
TARGET_NOT_ENGINEER: '目标用户不是工程师',
ENGINEER_BIND_FORBIDDEN: '仅系统管理员可绑定工程师医院',
SYSTEM_ADMIN_REG_DISABLED: '系统管理员注册已关闭',
SYSTEM_ADMIN_BOOTSTRAP_KEY_INVALID: '系统管理员引导密钥错误',
SYSTEM_ADMIN_SCOPE_INVALID: '系统管理员不可绑定医院/科室/小组',
DEPARTMENT_REQUIRED: '当前角色必须绑定科室',
GROUP_REQUIRED: '当前角色必须绑定小组',
ENGINEER_SCOPE_INVALID: '工程师不可绑定科室/小组',
DEPARTMENT_HOSPITAL_MISMATCH: '科室不属于指定医院',
GROUP_DEPARTMENT_REQUIRED: '绑定小组时必须同时传入科室',
GROUP_DEPARTMENT_MISMATCH: '小组不属于指定科室',
DOCTOR_ONLY_SCOPE_CHANGE: '仅医生/主任/组长允许调整科室/小组归属',
DELETE_CONFLICT: '用户存在关联患者或任务,无法删除',
CREATE_FORBIDDEN: '当前角色无权限创建该用户',
HOSPITAL_ADMIN_SCOPE_FORBIDDEN: '医院管理员仅可操作本院非管理员账号',
DIRECTOR_SCOPE_FORBIDDEN: '科室主任仅可操作本科室医生或组长账号',
},
TASK: {
RECORD_NOT_FOUND: '调压记录不存在或无权限访问',
UPDATE_ONLY_PENDING: '仅待处理调压记录可编辑',
DELETE_ONLY_PENDING_CANCELLED: '仅待处理/已取消调压记录可删除',
ITEMS_REQUIRED: '任务明细 items 不能为空',
DEVICE_NOT_FOUND: '存在设备不在当前医院或设备不存在',
DEVICE_MULTI_HOSPITAL: '同一批调压任务中的设备必须属于同一家医院',
DUPLICATE_DEVICE_OPEN_TASK: '该设备已有待处理调压任务,请勿重复发布',
ENGINEER_REQUIRED: '接收工程师必选',
ENGINEER_INVALID: '工程师必须为当前医院有效工程师',
TASK_NOT_FOUND: '任务不存在或不属于当前医院',
ACCEPT_ONLY_PENDING: '仅待接收任务可执行接收',
COMPLETE_ONLY_ACCEPTED: '仅已接收任务可执行完成',
COMPLETE_MATERIALS_REQUIRED: '完成任务至少上传一张图片或一个视频',
COMPLETE_MATERIAL_TYPE_INVALID: '完成任务仅支持图片或视频凭证',
CANCEL_ONLY_PENDING_ACCEPTED: '仅待接收/已接收任务可取消',
ENGINEER_ALREADY_ASSIGNED: '任务已被其他工程师接收',
ENGINEER_ONLY_ASSIGNEE: '仅任务接收人可完成任务',
CANCEL_ONLY_ASSIGNEE: '仅任务接收人可取消接收',
CANCEL_ONLY_CREATOR: '仅任务创建者可取消任务',
ACTOR_ROLE_FORBIDDEN: '当前角色无权限执行该任务操作',
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
},
PATIENT: {
NOT_FOUND: '患者不存在或无权限访问',
ROLE_FORBIDDEN: '当前角色无权限查询患者列表',
GROUP_REQUIRED: '组长查询需携带 groupId',
DEPARTMENT_REQUIRED: '主任查询需携带 departmentId',
DOCTOR_NOT_FOUND: '归属人员不存在',
DOCTOR_ROLE_REQUIRED: '归属用户必须为医生/主任/组长角色',
DOCTOR_SCOPE_FORBIDDEN: '仅可选择当前权限范围内医生/主任/组长',
DELETE_CONFLICT: '患者存在关联设备,无法删除',
LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证号',
SYSTEM_ADMIN_HOSPITAL_REQUIRED: '系统管理员查询必须显式传入 hospitalId',
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
SURGERY_ITEMS_REQUIRED: '手术下至少需要录入一个植入设备',
SURGERY_NOT_FOUND: '手术记录不存在或无权限访问',
IMPLANT_CATALOG_NOT_FOUND: '植入物型号不存在或不在当前医院可见范围内',
SURGERY_UPDATE_NOT_SUPPORTED:
'患者更新接口不支持直接修改手术,请使用新增手术接口',
SURGERY_DEVICE_SET_UPDATE_NOT_SUPPORTED:
'编辑手术暂不支持新增或删除植入设备,请逐项修改现有设备信息',
SURGERY_ABANDON_UPDATE_NOT_SUPPORTED:
'编辑手术暂不支持修改弃用旧设备,请通过追加手术处理',
SURGERY_DEVICE_TASK_CONFLICT: '存在调压任务记录的植入设备不支持修改型号',
ABANDON_DEVICE_SCOPE_FORBIDDEN: '仅可弃用当前患者名下设备',
},
DEVICE: {
NOT_FOUND: '设备不存在或无权限访问',
CURRENT_PRESSURE_INVALID: 'currentPressure 必须是合法挡位标签',
STATUS_INVALID: '设备状态不合法',
PATIENT_REQUIRED: 'patientId 必填且必须为整数',
PATIENT_NOT_FOUND: '归属患者不存在',
PATIENT_SCOPE_FORBIDDEN: '仅可绑定当前权限范围内患者',
DELETE_CONFLICT: '设备存在关联任务记录,无法删除',
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
CATALOG_NOT_FOUND: '植入物型号不存在',
CATALOG_MODEL_DUPLICATE: '植入物型号编码已存在',
CATALOG_SCOPE_FORBIDDEN: '当前角色无权限维护该植入物型号',
CATALOG_DELETE_CONFLICT: '植入物型号已被患者手术引用,无法删除',
VALVE_PRESSURE_REQUIRED: '阀门类型至少需要配置一个压力挡位',
PRESSURE_LEVEL_INVALID: '压力值不在该植入物配置的挡位范围内',
DEVICE_NOT_ADJUSTABLE: '仅可调压设备允许创建调压任务',
},
DICTIONARY: {
NOT_FOUND: '字典项不存在',
LABEL_REQUIRED: '字典项名称不能为空',
DUPLICATE: '同类型下字典项名称已存在',
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: '上传的视频无法解析或压缩失败',
INVALID_FILE_SIGNATURE: '上传文件内容与声明类型不匹配',
FFMPEG_NOT_AVAILABLE: '服务端缺少视频压缩能力',
},
ORG: {
HOSPITAL_NOT_FOUND: '医院不存在',
DEPARTMENT_NOT_FOUND: '科室不存在',
GROUP_NOT_FOUND: '小组不存在',
HOSPITAL_ADMIN_SCOPE_INVALID: '院管仅可操作本院组织数据',
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
ACTOR_DEPARTMENT_REQUIRED: '当前登录上下文缺少科室信息',
ACTOR_GROUP_REQUIRED: '当前登录上下文缺少小组信息',
SYSTEM_ADMIN_ONLY_CREATE_HOSPITAL: '仅系统管理员可创建医院',
SYSTEM_ADMIN_ONLY_DELETE_HOSPITAL: '仅系统管理员可删除医院',
HOSPITAL_NAME_REQUIRED: '医院名称不能为空',
DEPARTMENT_NAME_REQUIRED: '科室名称不能为空',
GROUP_NAME_REQUIRED: '小组名称不能为空',
HOSPITAL_ID_REQUIRED: 'hospitalId 必填且必须为整数',
DEPARTMENT_ID_REQUIRED: 'departmentId 必填且必须为整数',
GROUP_ID_REQUIRED: 'groupId 必填且必须为整数',
DEPARTMENT_HOSPITAL_MISMATCH: '科室不属于指定医院',
GROUP_DEPARTMENT_MISMATCH: '小组不属于指定科室',
DEPARTMENT_REPARENT_FORBIDDEN: '科室不允许更换所属医院',
GROUP_REPARENT_FORBIDDEN: '小组不允许更换所属科室',
GROUP_DELETE_HAS_USERS: '小组下仍有成员,无法删除,请先调整用户归属',
DELETE_CONFLICT:
'存在关联数据,无法删除,请先清理用户、患者、任务或下级组织后重试',
},
} as const;

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

@ -0,0 +1,54 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { map, Observable } from 'rxjs';
import { MESSAGES } from './messages.js';
/**
* { code, msg, data }
*/
@Injectable()
export class ResponseEnvelopeInterceptor implements NestInterceptor {
intercept(
_context: ExecutionContext,
next: CallHandler,
): Observable<{ code: number; msg: string; data: unknown }> {
return next.handle().pipe(
map((data: unknown) => {
// 若业务已返回统一结构,直接透传,避免二次包裹。
if (this.isEnveloped(data)) {
return data;
}
return {
code: 0,
msg: MESSAGES.SUCCESS,
data,
};
}),
);
}
/**
*
*/
private isEnveloped(data: unknown): data is {
code: number;
msg: string;
data: unknown;
} {
if (!data || typeof data !== 'object') {
return false;
}
const target = data as Record<string, unknown>;
return (
typeof target.code === 'number' &&
typeof target.msg === 'string' &&
Object.prototype.hasOwnProperty.call(target, 'data')
);
}
}

View File

@ -0,0 +1,12 @@
import { Transform } from 'class-transformer';
/**
* undefined便 IsOptional
*/
export const EmptyStringToUndefined = () =>
Transform(({ value }) => {
if (typeof value === 'string' && value.trim() === '') {
return undefined;
}
return value;
});

View File

@ -0,0 +1,24 @@
import { Transform } from 'class-transformer';
/**
* boolean
*/
export const ToBoolean = () =>
Transform(({ value }) => {
if (value === undefined || value === null || value === '') {
return value;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === 'true' || normalized === '1') {
return true;
}
if (normalized === 'false' || normalized === '0') {
return false;
}
}
return value;
});

View File

@ -0,0 +1,120 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import type { ActorContext } from '../common/actor-context.js';
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 { Role } from '../generated/prisma/enums.js';
import { DepartmentsService } from './departments.service.js';
import { CreateDepartmentDto } from './dto/create-department.dto.js';
import { UpdateDepartmentDto } from './dto/update-department.dto.js';
import { OrganizationQueryDto } from '../organization-common/dto/organization-query.dto.js';
/**
*
*/
@ApiTags('科室管理(B端)')
@ApiBearerAuth('bearer')
@Controller('b/organization/departments')
@UseGuards(AccessTokenGuard, RolesGuard)
export class DepartmentsController {
constructor(private readonly departmentsService: DepartmentsService) {}
/**
*
*/
@Post()
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '创建科室' })
create(
@CurrentActor() actor: ActorContext,
@Body() dto: CreateDepartmentDto,
) {
return this.departmentsService.create(actor, dto);
}
/**
*
*/
@Get()
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '查询科室列表' })
@ApiQuery({ name: 'hospitalId', required: false, description: '医院 ID' })
findAll(
@CurrentActor() actor: ActorContext,
@Query() query: OrganizationQueryDto,
) {
return this.departmentsService.findAll(actor, query);
}
/**
*
*/
@Get(':id')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '查询科室详情' })
@ApiParam({ name: 'id', description: '科室 ID' })
findOne(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.departmentsService.findOne(actor, id);
}
/**
*
*/
@Patch(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '更新科室' })
update(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateDepartmentDto,
) {
return this.departmentsService.update(actor, id, dto);
}
/**
*
*/
@Delete(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '删除科室' })
remove(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.departmentsService.remove(actor, id);
}
}

View File

@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { DepartmentsService } from './departments.service.js';
import { DepartmentsController } from './departments.controller.js';
import { AccessTokenGuard } from '../auth/access-token.guard.js';
import { RolesGuard } from '../auth/roles.guard.js';
import { OrganizationAccessService } from '../organization-common/organization-access.service.js';
/**
*
*/
@Module({
controllers: [DepartmentsController],
providers: [
DepartmentsService,
OrganizationAccessService,
AccessTokenGuard,
RolesGuard,
],
exports: [DepartmentsService],
})
export class DepartmentsModule {}

View File

@ -0,0 +1,177 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '../generated/prisma/client.js';
import { Role } from '../generated/prisma/enums.js';
import type { ActorContext } from '../common/actor-context.js';
import { MESSAGES } from '../common/messages.js';
import { PrismaService } from '../prisma.service.js';
import { OrganizationAccessService } from '../organization-common/organization-access.service.js';
import { CreateDepartmentDto } from './dto/create-department.dto.js';
import { UpdateDepartmentDto } from './dto/update-department.dto.js';
import { OrganizationQueryDto } from '../organization-common/dto/organization-query.dto.js';
/**
* CRUD
*/
@Injectable()
export class DepartmentsService {
constructor(
private readonly prisma: PrismaService,
private readonly access: OrganizationAccessService,
) {}
/**
*
*/
async create(actor: ActorContext, dto: CreateDepartmentDto) {
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
const hospitalId = this.access.toInt(
dto.hospitalId,
MESSAGES.ORG.HOSPITAL_ID_REQUIRED,
);
await this.access.ensureHospitalExists(hospitalId);
this.access.assertHospitalScope(actor, hospitalId);
return this.prisma.department.create({
data: {
name: this.access.normalizeName(
dto.name,
MESSAGES.ORG.DEPARTMENT_NAME_REQUIRED,
),
hospitalId,
},
});
}
/**
*
*/
async findAll(actor: ActorContext, query: OrganizationQueryDto) {
this.access.assertRole(actor, [
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
]);
const paging = this.access.resolvePaging(query);
const where: Prisma.DepartmentWhereInput = {};
if (query.keyword) {
where.name = { contains: query.keyword.trim(), mode: 'insensitive' };
}
if (actor.role === Role.HOSPITAL_ADMIN) {
where.hospitalId = this.access.requireActorHospitalId(actor);
} 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(
query.hospitalId,
MESSAGES.ORG.HOSPITAL_ID_REQUIRED,
);
}
const [total, list] = await this.prisma.$transaction([
this.prisma.department.count({ where }),
this.prisma.department.findMany({
where,
include: {
hospital: true,
_count: { select: { users: true, groups: true } },
},
skip: paging.skip,
take: paging.take,
orderBy: { id: 'desc' },
}),
]);
return { total, ...paging, list };
}
/**
*
*/
async findOne(actor: ActorContext, id: number) {
this.access.assertRole(actor, [
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
]);
const departmentId = this.access.toInt(
id,
MESSAGES.ORG.DEPARTMENT_ID_REQUIRED,
);
const department = await this.prisma.department.findUnique({
where: { id: departmentId },
include: {
hospital: true,
_count: { select: { users: true, groups: true } },
},
});
if (!department) {
throw new NotFoundException(MESSAGES.ORG.DEPARTMENT_NOT_FOUND);
}
if (actor.role === Role.HOSPITAL_ADMIN) {
this.access.assertHospitalScope(actor, department.hospitalId);
} 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.ORG.HOSPITAL_ADMIN_SCOPE_INVALID);
}
}
return department;
}
/**
*
*/
async update(actor: ActorContext, id: number, dto: UpdateDepartmentDto) {
const current = await this.findOne(actor, id);
const data: Prisma.DepartmentUpdateInput = {};
if (dto.hospitalId !== undefined) {
throw new BadRequestException(MESSAGES.ORG.DEPARTMENT_REPARENT_FORBIDDEN);
}
if (dto.name !== undefined) {
data.name = this.access.normalizeName(
dto.name,
MESSAGES.ORG.DEPARTMENT_NAME_REQUIRED,
);
}
return this.prisma.department.update({
where: { id: current.id },
data,
});
}
/**
*
*/
async remove(actor: ActorContext, id: number) {
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
const current = await this.findOne(actor, id);
try {
return await this.prisma.department.delete({ where: { id: current.id } });
} catch (error) {
this.access.handleDeleteConflict(error);
throw error;
}
}
}

View File

@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsString, Min } from 'class-validator';
/**
* DTO
*/
export class CreateDepartmentDto {
@ApiProperty({ description: '科室名称', example: '神经外科' })
@IsString({ message: 'name 必须是字符串' })
name!: string;
@ApiProperty({ description: '医院 ID', example: 1 })
@Type(() => Number)
@IsInt({ message: 'hospitalId 必须是整数' })
@Min(1, { message: 'hospitalId 必须大于 0' })
hospitalId!: number;
}

View File

@ -0,0 +1,18 @@
import { ApiHideProperty, OmitType, PartialType } from '@nestjs/swagger';
import { CreateDepartmentDto } from './create-department.dto.js';
import { IsEmpty, IsOptional } from 'class-validator';
import { MESSAGES } from '../../common/messages.js';
/**
* DTO
*/
class UpdateDepartmentNameDto extends PartialType(
OmitType(CreateDepartmentDto, ['hospitalId'] as const),
) {}
export class UpdateDepartmentDto extends UpdateDepartmentNameDto {
@ApiHideProperty()
@IsOptional()
@IsEmpty({ message: MESSAGES.ORG.DEPARTMENT_REPARENT_FORBIDDEN })
hospitalId?: unknown;
}

View File

@ -0,0 +1 @@
export class Department {}

View File

@ -0,0 +1,178 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
import { CurrentActor } from '../../auth/current-actor.decorator.js';
import { Roles } from '../../auth/roles.decorator.js';
import { RolesGuard } from '../../auth/roles.guard.js';
import type { ActorContext } from '../../common/actor-context.js';
import { Role } from '../../generated/prisma/enums.js';
import { CreateImplantCatalogDto } from '../dto/create-implant-catalog.dto.js';
import { CreateDeviceDto } from '../dto/create-device.dto.js';
import { DeviceQueryDto } from '../dto/device-query.dto.js';
import { UpdateImplantCatalogDto } from '../dto/update-implant-catalog.dto.js';
import { UpdateDeviceDto } from '../dto/update-device.dto.js';
import { DevicesService } from '../devices.service.js';
/**
* B 访 CRUD
*/
@ApiTags('设备管理(B端)')
@ApiBearerAuth('bearer')
@Controller('b/devices')
@UseGuards(AccessTokenGuard, RolesGuard)
export class BDevicesController {
constructor(private readonly devicesService: DevicesService) {}
/**
*
*/
@Get('catalogs')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
Role.ENGINEER,
)
@ApiOperation({ summary: '查询植入物型号字典' })
@ApiQuery({
name: 'keyword',
required: false,
description: '支持按型号、厂家、名称模糊查询',
})
findCatalogs(
@CurrentActor() actor: ActorContext,
@Query('keyword') keyword?: string,
) {
return this.devicesService.findCatalogs(actor, keyword);
}
/**
*
*/
@Post('catalogs')
@Roles(Role.SYSTEM_ADMIN)
@ApiOperation({ summary: '新增植入物目录SYSTEM_ADMIN' })
createCatalog(
@CurrentActor() actor: ActorContext,
@Body() dto: CreateImplantCatalogDto,
) {
return this.devicesService.createCatalog(actor, dto);
}
/**
*
*/
@Patch('catalogs/:id')
@Roles(Role.SYSTEM_ADMIN)
@ApiOperation({ summary: '更新植入物目录SYSTEM_ADMIN' })
@ApiParam({ name: 'id', description: '型号字典 ID' })
updateCatalog(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateImplantCatalogDto,
) {
return this.devicesService.updateCatalog(actor, id, dto);
}
/**
*
*/
@Delete('catalogs/:id')
@Roles(Role.SYSTEM_ADMIN)
@ApiOperation({ summary: '删除植入物目录SYSTEM_ADMIN' })
@ApiParam({ name: 'id', description: '型号字典 ID' })
removeCatalog(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.devicesService.removeCatalog(actor, id);
}
/**
*
*/
@Get()
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '查询设备列表' })
findAll(@CurrentActor() actor: ActorContext, @Query() query: DeviceQueryDto) {
return this.devicesService.findAll(actor, query);
}
/**
*
*/
@Get(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '查询设备详情' })
@ApiParam({ name: 'id', description: '设备 ID' })
findOne(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.devicesService.findOne(actor, id);
}
/**
*
*/
@Post()
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '创建设备' })
create(@CurrentActor() actor: ActorContext, @Body() dto: CreateDeviceDto) {
return this.devicesService.create(actor, dto);
}
/**
*
*/
@Patch(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '更新设备' })
@ApiParam({ name: 'id', description: '设备 ID' })
update(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateDeviceDto,
) {
return this.devicesService.update(actor, id, dto);
}
/**
*
*/
@Delete(':id')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '删除设备' })
@ApiParam({ name: 'id', description: '设备 ID' })
remove(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.devicesService.remove(actor, id);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AccessTokenGuard } from '../auth/access-token.guard.js';
import { RolesGuard } from '../auth/roles.guard.js';
import { BDevicesController } from './b-devices/b-devices.controller.js';
import { DevicesService } from './devices.service.js';
@Module({
controllers: [BDevicesController],
providers: [DevicesService, AccessTokenGuard, RolesGuard],
exports: [DevicesService],
})
export class DevicesModule {}

View File

@ -0,0 +1,747 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '../generated/prisma/client.js';
import { DeviceStatus, Role } from '../generated/prisma/enums.js';
import type { ActorContext } from '../common/actor-context.js';
import { MESSAGES } from '../common/messages.js';
import {
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';
import { DeviceQueryDto } from './dto/device-query.dto.js';
import { UpdateImplantCatalogDto } from './dto/update-implant-catalog.dto.js';
import { UpdateDeviceDto } from './dto/update-device.dto.js';
const CATALOG_SELECT = {
id: true,
modelCode: true,
manufacturer: true,
name: true,
isValve: true,
pressureLevels: true,
isPressureAdjustable: true,
notes: true,
createdAt: true,
updatedAt: true,
} as const;
const DEVICE_DETAIL_INCLUDE = {
patient: {
select: {
id: true,
name: true,
inpatientNo: true,
phone: true,
hospitalId: true,
hospital: {
select: {
id: true,
name: true,
},
},
doctor: {
select: {
id: true,
name: true,
role: true,
},
},
},
},
surgery: {
select: {
id: true,
surgeryDate: true,
surgeryName: true,
surgeonName: true,
},
},
implantCatalog: {
select: CATALOG_SELECT,
},
_count: {
select: {
taskItems: true,
},
},
} as const;
/**
* CRUD
*/
@Injectable()
export class DevicesService {
constructor(private readonly prisma: PrismaService) {}
/**
*
*/
async findAll(actor: ActorContext, query: DeviceQueryDto) {
this.assertAdmin(actor);
const paging = this.resolvePaging(query);
const scopedHospitalId = this.resolveScopedHospitalId(
actor,
query.hospitalId,
);
const where = this.buildListWhere(query, scopedHospitalId);
const [total, list] = await this.prisma.$transaction([
this.prisma.device.count({ where }),
this.prisma.device.findMany({
where,
include: DEVICE_DETAIL_INCLUDE,
skip: paging.skip,
take: paging.take,
orderBy: { id: 'desc' },
}),
]);
return {
total,
...paging,
list,
};
}
/**
*
*/
async findOne(actor: ActorContext, id: number) {
this.assertAdmin(actor);
const deviceId = this.toInt(id, 'id');
const device = await this.prisma.device.findUnique({
where: { id: deviceId },
include: DEVICE_DETAIL_INCLUDE,
});
if (!device) {
throw new NotFoundException(MESSAGES.DEVICE.NOT_FOUND);
}
this.assertDeviceReadable(actor, device.patient.hospitalId);
return device;
}
/**
*
*/
async create(actor: ActorContext, dto: CreateDeviceDto) {
this.assertAdmin(actor);
const patient = await this.resolveWritablePatient(actor, dto.patientId);
return this.prisma.device.create({
data: {
// 当前压力只允许由调压任务流转维护,手工创建设备时先置 0。
currentPressure: '0',
status: dto.status ?? DeviceStatus.ACTIVE,
patientId: patient.id,
},
include: DEVICE_DETAIL_INCLUDE,
});
}
/**
*
*/
async update(actor: ActorContext, id: number, dto: UpdateDeviceDto) {
const current = await this.findOne(actor, id);
const data: Prisma.DeviceUpdateInput = {};
if (dto.status !== undefined) {
data.status = this.normalizeStatus(dto.status);
}
if (dto.patientId !== undefined) {
const patient = await this.resolveWritablePatient(actor, dto.patientId);
data.patient = { connect: { id: patient.id } };
}
return this.prisma.device.update({
where: { id: current.id },
data,
include: DEVICE_DETAIL_INCLUDE,
});
}
/**
* 409
*/
async remove(actor: ActorContext, id: number) {
const current = await this.findRemovableDevice(actor, id);
const relatedTaskCount = await this.prisma.taskItem.count({
where: { deviceId: current.id },
});
if (relatedTaskCount > 0) {
throw new ConflictException(MESSAGES.DEVICE.DELETE_CONFLICT);
}
try {
return await this.prisma.device.delete({
where: { id: current.id },
include: DEVICE_DETAIL_INCLUDE,
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
(error.code === 'P2003' || error.code === 'P2014')
) {
throw new ConflictException(MESSAGES.DEVICE.DELETE_CONFLICT);
}
throw error;
}
}
/**
*
*/
async findCatalogs(actor: ActorContext, keyword?: string) {
this.assertCatalogReadable(actor);
const where = this.buildCatalogWhere(keyword);
return this.prisma.implantCatalog.findMany({
where,
select: CATALOG_SELECT,
orderBy: [{ manufacturer: 'asc' }, { name: 'asc' }, { modelCode: 'asc' }],
});
}
/**
*
*/
async createCatalog(actor: ActorContext, dto: CreateImplantCatalogDto) {
this.assertSystemAdmin(actor);
const isValve = dto.isValve ?? true;
const isPressureAdjustable = isValve;
try {
return await this.prisma.implantCatalog.create({
data: {
modelCode: this.normalizeModelCode(dto.modelCode),
manufacturer: this.normalizeRequiredString(
dto.manufacturer,
'manufacturer',
),
name: this.normalizeRequiredString(dto.name, 'name'),
isValve,
pressureLevels: this.normalizePressureLevels(
dto.pressureLevels,
isValve,
),
isPressureAdjustable,
notes:
dto.notes === undefined
? undefined
: this.normalizeNullableString(dto.notes, 'notes'),
},
select: CATALOG_SELECT,
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new ConflictException(MESSAGES.DEVICE.CATALOG_MODEL_DUPLICATE);
}
throw error;
}
}
/**
*
*/
async updateCatalog(
actor: ActorContext,
id: number,
dto: UpdateImplantCatalogDto,
) {
this.assertSystemAdmin(actor);
const current = await this.findWritableCatalog(id);
const nextIsValve = dto.isValve ?? current.isValve;
const nextIsPressureAdjustable = nextIsValve;
const data: Prisma.ImplantCatalogUpdateInput = {};
if (dto.modelCode !== undefined) {
data.modelCode = this.normalizeModelCode(dto.modelCode);
}
if (dto.manufacturer !== undefined) {
data.manufacturer = this.normalizeRequiredString(
dto.manufacturer,
'manufacturer',
);
}
if (dto.name !== undefined) {
data.name = this.normalizeRequiredString(dto.name, 'name');
}
if (dto.isValve !== undefined) {
data.isValve = dto.isValve;
data.isPressureAdjustable = nextIsPressureAdjustable;
}
if (dto.pressureLevels !== undefined || dto.isValve !== undefined) {
data.pressureLevels = this.normalizePressureLevels(
dto.pressureLevels ?? current.pressureLevels,
nextIsValve,
);
}
if (dto.notes !== undefined) {
data.notes = this.normalizeNullableString(dto.notes, 'notes');
}
try {
return await this.prisma.implantCatalog.update({
where: { id: current.id },
data,
select: CATALOG_SELECT,
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new ConflictException(MESSAGES.DEVICE.CATALOG_MODEL_DUPLICATE);
}
throw error;
}
}
/**
* 409
*/
async removeCatalog(actor: ActorContext, id: number) {
this.assertSystemAdmin(actor);
const current = await this.findWritableCatalog(id);
try {
return await this.prisma.implantCatalog.delete({
where: { id: current.id },
select: CATALOG_SELECT,
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
(error.code === 'P2003' || error.code === 'P2014')
) {
throw new ConflictException(MESSAGES.DEVICE.CATALOG_DELETE_CONFLICT);
}
throw error;
}
}
/**
*
*/
private buildListWhere(query: DeviceQueryDto, scopedHospitalId?: number) {
const andConditions: Prisma.DeviceWhereInput[] = [];
const keyword = query.keyword?.trim();
if (scopedHospitalId != null) {
andConditions.push({
patient: {
is: {
hospitalId: scopedHospitalId,
},
},
});
}
if (query.patientId != null) {
andConditions.push({
patientId: query.patientId,
});
}
if (query.status != null) {
andConditions.push({
status: query.status,
});
}
if (keyword) {
andConditions.push({
OR: [
{
implantModel: {
contains: keyword,
mode: 'insensitive',
},
},
{
implantName: {
contains: keyword,
mode: 'insensitive',
},
},
{
patient: {
is: {
name: {
contains: keyword,
mode: 'insensitive',
},
},
},
},
{
patient: {
is: {
phone: {
contains: keyword,
},
},
},
},
],
});
}
return andConditions.length > 0 ? { AND: andConditions } : {};
}
/**
*
*/
private buildCatalogWhere(keyword?: string): Prisma.ImplantCatalogWhereInput {
const andConditions: Prisma.ImplantCatalogWhereInput[] = [];
const normalizedKeyword = keyword?.trim();
if (normalizedKeyword) {
andConditions.push({
OR: [
{
modelCode: {
contains: normalizedKeyword,
mode: 'insensitive',
},
},
{
manufacturer: {
contains: normalizedKeyword,
mode: 'insensitive',
},
},
{
name: {
contains: normalizedKeyword,
mode: 'insensitive',
},
},
{
notes: {
contains: normalizedKeyword,
mode: 'insensitive',
},
},
],
});
}
return andConditions.length > 0 ? { AND: andConditions } : {};
}
/**
*
*/
private resolvePaging(query: DeviceQueryDto) {
const page = query.page && query.page > 0 ? query.page : 1;
const pageSize =
query.pageSize && query.pageSize > 0 && query.pageSize <= 100
? query.pageSize
: 20;
return {
page,
pageSize,
skip: (page - 1) * pageSize,
take: pageSize,
};
}
/**
*
*/
private resolveScopedHospitalId(
actor: ActorContext,
hospitalId?: number,
): number | undefined {
if (actor.role === Role.SYSTEM_ADMIN) {
return hospitalId;
}
return this.requireActorHospitalId(actor);
}
/**
*
*/
private async resolveWritablePatient(actor: ActorContext, patientId: number) {
const normalizedPatientId = this.toInt(
patientId,
MESSAGES.DEVICE.PATIENT_REQUIRED,
);
const patient = await this.prisma.patient.findUnique({
where: { id: normalizedPatientId },
select: {
id: true,
hospitalId: true,
},
});
if (!patient) {
throw new NotFoundException(MESSAGES.DEVICE.PATIENT_NOT_FOUND);
}
if (
actor.role === Role.HOSPITAL_ADMIN &&
patient.hospitalId !== this.requireActorHospitalId(actor)
) {
throw new ForbiddenException(MESSAGES.DEVICE.PATIENT_SCOPE_FORBIDDEN);
}
return patient;
}
/**
*
*/
private async findWritableCatalog(id: number) {
const catalogId = this.toInt(id, 'id');
const catalog = await this.prisma.implantCatalog.findUnique({
where: { id: catalogId },
select: CATALOG_SELECT,
});
if (!catalog) {
throw new NotFoundException(MESSAGES.DEVICE.CATALOG_NOT_FOUND);
}
return catalog;
}
/**
* /
*/
private assertDeviceReadable(actor: ActorContext, hospitalId: number) {
if (actor.role === Role.SYSTEM_ADMIN) {
return;
}
if (hospitalId !== this.requireActorHospitalId(actor)) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
}
/**
*
*/
private async findRemovableDevice(actor: ActorContext, id: number) {
const deviceId = this.toInt(id, 'id');
const device = await this.prisma.device.findUnique({
where: { id: deviceId },
select: {
id: true,
patient: {
select: {
hospitalId: true,
doctorId: true,
doctor: {
select: {
departmentId: true,
groupId: true,
},
},
},
},
},
});
if (!device) {
throw new NotFoundException(MESSAGES.DEVICE.NOT_FOUND);
}
this.assertDeviceRemovableScope(actor, device.patient);
return device;
}
private assertDeviceRemovableScope(
actor: ActorContext,
patient: {
hospitalId: number;
doctorId: number;
doctor: { departmentId: number | null; groupId: number | null };
},
) {
switch (actor.role) {
case Role.SYSTEM_ADMIN:
return;
case Role.HOSPITAL_ADMIN:
if (patient.hospitalId !== this.requireActorHospitalId(actor)) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
return;
case Role.DIRECTOR:
if (
!actor.departmentId ||
patient.doctor.departmentId !== actor.departmentId
) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
return;
case Role.LEADER:
if (!actor.groupId || patient.doctor.groupId !== actor.groupId) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
return;
case Role.DOCTOR:
if (patient.doctorId !== actor.id) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
return;
default:
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
}
private assertAdmin(actor: ActorContext) {
if (
actor.role !== Role.SYSTEM_ADMIN &&
actor.role !== Role.HOSPITAL_ADMIN
) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
}
/**
* B 访
*/
private assertCatalogReadable(actor: ActorContext) {
if (
actor.role === Role.SYSTEM_ADMIN ||
actor.role === Role.HOSPITAL_ADMIN ||
actor.role === Role.DIRECTOR ||
actor.role === Role.LEADER ||
actor.role === Role.DOCTOR ||
actor.role === Role.ENGINEER
) {
return;
}
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
/**
*
*/
private assertSystemAdmin(actor: ActorContext) {
if (actor.role !== Role.SYSTEM_ADMIN) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
}
/**
*
*/
private normalizeModelCode(value: unknown) {
return this.normalizeRequiredString(value, 'modelCode').toUpperCase();
}
private normalizeRequiredString(value: unknown, fieldName: string) {
if (typeof value !== 'string') {
throw new BadRequestException(`${fieldName} 必须是字符串`);
}
const normalized = value.trim();
if (!normalized) {
throw new BadRequestException(`${fieldName} 不能为空`);
}
return normalized;
}
private normalizeNullableString(value: unknown, fieldName: string) {
if (typeof value !== 'string') {
throw new BadRequestException(`${fieldName} 必须是字符串`);
}
const normalized = value.trim();
return normalized || null;
}
/**
*
*/
private normalizePressureLevels(
pressureLevels: unknown[] | undefined,
isValve: boolean,
) {
if (!isValve) {
return [];
}
const normalized = normalizePressureLabelList(
pressureLevels,
'pressureLevels',
);
if (normalized.length === 0) {
throw new BadRequestException(MESSAGES.DEVICE.VALVE_PRESSURE_REQUIRED);
}
return normalized;
}
/**
*
*/
private normalizePressure(value: unknown) {
try {
return normalizePressureLabel(value, 'currentPressure');
} catch {
throw new BadRequestException(MESSAGES.DEVICE.CURRENT_PRESSURE_INVALID);
}
}
/**
*
*/
private normalizeStatus(value: unknown): DeviceStatus {
if (!Object.values(DeviceStatus).includes(value as DeviceStatus)) {
throw new BadRequestException(MESSAGES.DEVICE.STATUS_INVALID);
}
return value as DeviceStatus;
}
/**
*
*/
private toInt(value: unknown, message: string) {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new BadRequestException(message);
}
return parsed;
}
/**
* ID
*/
private requireActorHospitalId(actor: ActorContext) {
if (
typeof actor.hospitalId !== 'number' ||
!Number.isInteger(actor.hospitalId) ||
actor.hospitalId <= 0
) {
throw new BadRequestException(MESSAGES.DEVICE.ACTOR_HOSPITAL_REQUIRED);
}
return actor.hospitalId;
}
}

View File

@ -0,0 +1,24 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { DeviceStatus } from '../../generated/prisma/enums.js';
import { Type } from 'class-transformer';
import { IsEnum, IsInt, IsOptional, Min } from 'class-validator';
/**
* DTO
*/
export class CreateDeviceDto {
@ApiPropertyOptional({
description: '设备状态,默认 ACTIVE',
enum: DeviceStatus,
example: DeviceStatus.ACTIVE,
})
@IsOptional()
@IsEnum(DeviceStatus, { message: 'status 枚举值不合法' })
status?: DeviceStatus;
@ApiProperty({ description: '归属患者 ID', example: 1 })
@Type(() => Number)
@IsInt({ message: 'patientId 必须是整数' })
@Min(1, { message: 'patientId 必须大于 0' })
patientId!: number;
}

View File

@ -0,0 +1,65 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsOptional,
IsString,
MaxLength,
} from 'class-validator';
import { ToBoolean } from '../../common/transforms/to-boolean.transform.js';
/**
* DTO
*/
export class CreateImplantCatalogDto {
@ApiProperty({
description: '型号编码',
example: 'CODMAN-HAKIM-120',
})
@IsString({ message: 'modelCode 必须是字符串' })
modelCode!: string;
@ApiProperty({
description: '厂家',
example: 'Codman',
})
@IsString({ message: 'manufacturer 必须是字符串' })
manufacturer!: string;
@ApiProperty({
description: '名称',
example: 'Hakim 可调压阀',
})
@IsString({ message: 'name 必须是字符串' })
name!: string;
@ApiPropertyOptional({
description: '是否为阀门,关闭时表示管子或附件',
example: true,
})
@IsOptional()
@ToBoolean()
@IsBoolean({ message: 'isValve 必须是布尔值' })
isValve?: boolean;
@ApiPropertyOptional({
description: '可调压器械的挡位列表,按字符串挡位标签录入',
type: [String],
example: ['0.5', '1', '1.5'],
})
@IsOptional()
@IsArray({ message: 'pressureLevels 必须是数组' })
@ArrayMaxSize(30, { message: 'pressureLevels 最多 30 项' })
@IsString({ each: true, message: 'pressureLevels 必须为字符串数组' })
pressureLevels?: string[];
@ApiPropertyOptional({
description: '植入物备注',
example: '适用于儿童脑积水病例',
})
@IsOptional()
@IsString({ message: 'notes 必须是字符串' })
@MaxLength(200, { message: 'notes 最长 200 个字符' })
notes?: string;
}

View File

@ -0,0 +1,69 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { DeviceStatus } from '../../generated/prisma/enums.js';
import { Type } from 'class-transformer';
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
/**
* DTO
*/
export class DeviceQueryDto {
@ApiPropertyOptional({
description:
'关键词(支持植入物型号 / 植入物名称 / 患者姓名 / 患者手机号)',
example: '脑室',
})
@IsOptional()
@IsString({ message: 'keyword 必须是字符串' })
keyword?: string;
@ApiPropertyOptional({
description: '设备状态',
enum: DeviceStatus,
example: DeviceStatus.ACTIVE,
})
@IsOptional()
@IsEnum(DeviceStatus, { message: 'status 枚举值不合法' })
status?: DeviceStatus;
@ApiPropertyOptional({ description: '医院 ID', example: 1 })
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'hospitalId 必须是整数' })
@Min(1, { message: 'hospitalId 必须大于 0' })
hospitalId?: number;
@ApiPropertyOptional({ description: '患者 ID', example: 1 })
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'patientId 必须是整数' })
@Min(1, { message: 'patientId 必须大于 0' })
patientId?: number;
@ApiPropertyOptional({
description: '页码(默认 1',
example: 1,
default: 1,
})
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'page 必须是整数' })
@Min(1, { message: 'page 最小为 1' })
page?: number = 1;
@ApiPropertyOptional({
description: '每页数量(默认 20最大 100',
example: 20,
default: 20,
})
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'pageSize 必须是整数' })
@Min(1, { message: 'pageSize 最小为 1' })
@Max(100, { message: 'pageSize 最大为 100' })
pageSize?: number = 20;
}

View File

@ -0,0 +1,7 @@
import { PartialType } from '@nestjs/swagger';
import { CreateDeviceDto } from './create-device.dto.js';
/**
* DTO
*/
export class UpdateDeviceDto extends PartialType(CreateDeviceDto) {}

View File

@ -0,0 +1,9 @@
import { PartialType } from '@nestjs/swagger';
import { CreateImplantCatalogDto } from './create-implant-catalog.dto.js';
/**
* DTO
*/
export class UpdateImplantCatalogDto extends PartialType(
CreateImplantCatalogDto,
) {}

View File

@ -0,0 +1,112 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
import { CurrentActor } from '../../auth/current-actor.decorator.js';
import { Roles } from '../../auth/roles.decorator.js';
import { RolesGuard } from '../../auth/roles.guard.js';
import type { ActorContext } from '../../common/actor-context.js';
import { Role } from '../../generated/prisma/enums.js';
import { CreateDictionaryItemDto } from '../dto/create-dictionary-item.dto.js';
import { DictionaryQueryDto } from '../dto/dictionary-query.dto.js';
import { UpdateDictionaryItemDto } from '../dto/update-dictionary-item.dto.js';
import { DictionariesService } from '../dictionaries.service.js';
/**
* B
*/
@ApiTags('字典管理(B端)')
@ApiBearerAuth('bearer')
@Controller('b/dictionaries')
@UseGuards(AccessTokenGuard, RolesGuard)
export class BDictionariesController {
constructor(private readonly dictionariesService: DictionariesService) {}
/**
*
*/
@Get()
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
Role.ENGINEER,
)
@ApiOperation({ summary: '查询系统字典' })
@ApiQuery({
name: 'type',
required: false,
description: '字典类型,不传返回全部类型',
})
@ApiQuery({
name: 'includeDisabled',
required: false,
description: '是否包含停用项,仅系统管理员生效',
})
findAll(
@CurrentActor() actor: ActorContext,
@Query() query: DictionaryQueryDto,
) {
return this.dictionariesService.findAll(actor, query);
}
/**
*
*/
@Post()
@Roles(Role.SYSTEM_ADMIN)
@ApiOperation({ summary: '创建系统字典项SYSTEM_ADMIN' })
create(
@CurrentActor() actor: ActorContext,
@Body() dto: CreateDictionaryItemDto,
) {
return this.dictionariesService.create(actor, dto);
}
/**
*
*/
@Patch(':id')
@Roles(Role.SYSTEM_ADMIN)
@ApiOperation({ summary: '更新系统字典项SYSTEM_ADMIN' })
@ApiParam({ name: 'id', description: '字典项 ID' })
update(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateDictionaryItemDto,
) {
return this.dictionariesService.update(actor, id, dto);
}
/**
*
*/
@Delete(':id')
@Roles(Role.SYSTEM_ADMIN)
@ApiOperation({ summary: '删除系统字典项SYSTEM_ADMIN' })
@ApiParam({ name: 'id', description: '字典项 ID' })
remove(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.dictionariesService.remove(actor, id);
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AccessTokenGuard } from '../auth/access-token.guard.js';
import { RolesGuard } from '../auth/roles.guard.js';
import { BDictionariesController } from './b-dictionaries/b-dictionaries.controller.js';
import { DictionariesService } from './dictionaries.service.js';
@Module({
controllers: [BDictionariesController],
providers: [DictionariesService, AccessTokenGuard, RolesGuard],
exports: [DictionariesService],
})
export class DictionariesModule {}

View File

@ -0,0 +1,156 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '../generated/prisma/client.js';
import { Role } from '../generated/prisma/enums.js';
import type { ActorContext } from '../common/actor-context.js';
import { MESSAGES } from '../common/messages.js';
import { PrismaService } from '../prisma.service.js';
import { CreateDictionaryItemDto } from './dto/create-dictionary-item.dto.js';
import { DictionaryQueryDto } from './dto/dictionary-query.dto.js';
import { UpdateDictionaryItemDto } from './dto/update-dictionary-item.dto.js';
@Injectable()
export class DictionariesService {
constructor(private readonly prisma: PrismaService) {}
/**
*
*/
async findAll(actor: ActorContext, query: DictionaryQueryDto) {
const where: Prisma.DictionaryItemWhereInput = {};
if (query.type) {
where.type = query.type;
}
// 非系统管理员一律只看启用项,避免业务页面误拿到停用值。
if (actor.role !== Role.SYSTEM_ADMIN || !query.includeDisabled) {
where.enabled = true;
}
return this.prisma.dictionaryItem.findMany({
where,
orderBy: [{ type: 'asc' }, { sortOrder: 'asc' }, { id: 'asc' }],
});
}
/**
*
*/
async create(actor: ActorContext, dto: CreateDictionaryItemDto) {
this.assertSystemAdmin(actor);
try {
return await this.prisma.dictionaryItem.create({
data: {
type: dto.type,
label: this.normalizeLabel(dto.label),
sortOrder: dto.sortOrder ?? 0,
enabled: dto.enabled ?? true,
},
});
} catch (error) {
this.handleDuplicate(error);
throw error;
}
}
/**
*
*/
async update(actor: ActorContext, id: number, dto: UpdateDictionaryItemDto) {
this.assertSystemAdmin(actor);
await this.ensureExists(id);
const data: Prisma.DictionaryItemUpdateInput = {};
if (dto.type !== undefined) {
data.type = dto.type;
}
if (dto.label !== undefined) {
data.label = this.normalizeLabel(dto.label);
}
if (dto.sortOrder !== undefined) {
data.sortOrder = dto.sortOrder;
}
if (dto.enabled !== undefined) {
data.enabled = dto.enabled;
}
try {
return await this.prisma.dictionaryItem.update({
where: { id },
data,
});
} catch (error) {
this.handleDuplicate(error);
throw error;
}
}
/**
*
*/
async remove(actor: ActorContext, id: number) {
this.assertSystemAdmin(actor);
await this.ensureExists(id);
return this.prisma.dictionaryItem.delete({
where: { id },
});
}
/**
*
*/
private normalizeLabel(value: string) {
const label = value?.trim();
if (!label) {
throw new BadRequestException(MESSAGES.DICTIONARY.LABEL_REQUIRED);
}
return label;
}
/**
*
*/
private assertSystemAdmin(actor: ActorContext) {
if (actor.role !== Role.SYSTEM_ADMIN) {
throw new ForbiddenException(
MESSAGES.DICTIONARY.SYSTEM_ADMIN_ONLY_MAINTAIN,
);
}
}
/**
*
*/
private async ensureExists(id: number) {
const current = await this.prisma.dictionaryItem.findUnique({
where: { id },
select: { id: true },
});
if (!current) {
throw new NotFoundException(MESSAGES.DICTIONARY.NOT_FOUND);
}
return current;
}
/**
*
*/
private handleDuplicate(error: unknown) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new ConflictException(MESSAGES.DICTIONARY.DUPLICATE);
}
}
}

View File

@ -0,0 +1,55 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
import { ToBoolean } from '../../common/transforms/to-boolean.transform.js';
import { DictionaryType } from '../../generated/prisma/enums.js';
/**
* DTO
*/
export class CreateDictionaryItemDto {
@ApiProperty({
description: '字典类型',
enum: DictionaryType,
example: DictionaryType.PRIMARY_DISEASE,
})
@IsEnum(DictionaryType, { message: 'type 枚举值不合法' })
type!: DictionaryType;
@ApiProperty({
description: '字典项名称',
example: '先天性脑积水',
})
@IsString({ message: 'label 必须是字符串' })
label!: string;
@ApiPropertyOptional({
description: '排序值,越小越靠前,默认 0',
example: 10,
default: 0,
})
@IsOptional()
@Type(() => Number)
@IsInt({ message: 'sortOrder 必须是整数' })
@Min(-9999, { message: 'sortOrder 不能小于 -9999' })
@Max(9999, { message: 'sortOrder 不能大于 9999' })
sortOrder?: number;
@ApiPropertyOptional({
description: '是否启用,默认 true',
example: true,
default: true,
})
@IsOptional()
@ToBoolean()
@IsBoolean({ message: 'enabled 必须是布尔值' })
enabled?: boolean;
}

View File

@ -0,0 +1,30 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsEnum, IsOptional } from 'class-validator';
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
import { ToBoolean } from '../../common/transforms/to-boolean.transform.js';
import { DictionaryType } from '../../generated/prisma/enums.js';
/**
* DTO
*/
export class DictionaryQueryDto {
@ApiPropertyOptional({
description: '字典类型,不传返回全部类型',
enum: DictionaryType,
example: DictionaryType.PRIMARY_DISEASE,
})
@IsOptional()
@EmptyStringToUndefined()
@IsEnum(DictionaryType, { message: 'type 枚举值不合法' })
type?: DictionaryType;
@ApiPropertyOptional({
description: '是否包含停用项,仅系统管理员生效',
example: true,
})
@IsOptional()
@EmptyStringToUndefined()
@ToBoolean()
@IsBoolean({ message: 'includeDisabled 必须是布尔值' })
includeDisabled?: boolean;
}

View File

@ -0,0 +1,9 @@
import { PartialType } from '@nestjs/swagger';
import { CreateDictionaryItemDto } from './create-dictionary-item.dto.js';
/**
* DTO
*/
export class UpdateDictionaryItemDto extends PartialType(
CreateDictionaryItemDto,
) {}

View File

@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsString, Min } from 'class-validator';
/**
* DTO
*/
export class CreateGroupDto {
@ApiProperty({ description: '小组名称', example: 'A组' })
@IsString({ message: 'name 必须是字符串' })
name!: string;
@ApiProperty({ description: '科室 ID', example: 1 })
@Type(() => Number)
@IsInt({ message: 'departmentId 必须是整数' })
@Min(1, { message: 'departmentId 必须大于 0' })
departmentId!: number;
}

View File

@ -0,0 +1,18 @@
import { ApiHideProperty, OmitType, PartialType } from '@nestjs/swagger';
import { CreateGroupDto } from './create-group.dto.js';
import { IsEmpty, IsOptional } from 'class-validator';
import { MESSAGES } from '../../common/messages.js';
/**
* DTO
*/
class UpdateGroupNameDto extends PartialType(
OmitType(CreateGroupDto, ['departmentId'] as const),
) {}
export class UpdateGroupDto extends UpdateGroupNameDto {
@ApiHideProperty()
@IsOptional()
@IsEmpty({ message: MESSAGES.ORG.GROUP_REPARENT_FORBIDDEN })
departmentId?: unknown;
}

View File

@ -0,0 +1 @@
export class Group {}

View File

@ -0,0 +1,115 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import type { ActorContext } from '../common/actor-context.js';
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 { Role } from '../generated/prisma/enums.js';
import { GroupsService } from './groups.service.js';
import { CreateGroupDto } from './dto/create-group.dto.js';
import { UpdateGroupDto } from './dto/update-group.dto.js';
import { OrganizationQueryDto } from '../organization-common/dto/organization-query.dto.js';
/**
*
*/
@ApiTags('小组管理(B端)')
@ApiBearerAuth('bearer')
@Controller('b/organization/groups')
@UseGuards(AccessTokenGuard, RolesGuard)
export class GroupsController {
constructor(private readonly groupsService: GroupsService) {}
/**
*
*/
@Post()
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '创建小组' })
create(@CurrentActor() actor: ActorContext, @Body() dto: CreateGroupDto) {
return this.groupsService.create(actor, dto);
}
/**
*
*/
@Get()
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '查询小组列表' })
findAll(
@CurrentActor() actor: ActorContext,
@Query() query: OrganizationQueryDto,
) {
return this.groupsService.findAll(actor, query);
}
/**
*
*/
@Get(':id')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '查询小组详情' })
@ApiParam({ name: 'id', description: '小组 ID' })
findOne(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.groupsService.findOne(actor, id);
}
/**
*
*/
@Patch(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '更新小组' })
update(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateGroupDto,
) {
return this.groupsService.update(actor, id, dto);
}
/**
*
*/
@Delete(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '删除小组' })
remove(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.groupsService.remove(actor, id);
}
}

View File

@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { GroupsService } from './groups.service.js';
import { GroupsController } from './groups.controller.js';
import { AccessTokenGuard } from '../auth/access-token.guard.js';
import { RolesGuard } from '../auth/roles.guard.js';
import { OrganizationAccessService } from '../organization-common/organization-access.service.js';
/**
*
*/
@Module({
controllers: [GroupsController],
providers: [
GroupsService,
OrganizationAccessService,
AccessTokenGuard,
RolesGuard,
],
exports: [GroupsService],
})
export class GroupsModule {}

View File

@ -0,0 +1,193 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '../generated/prisma/client.js';
import { Role } from '../generated/prisma/enums.js';
import type { ActorContext } from '../common/actor-context.js';
import { MESSAGES } from '../common/messages.js';
import { PrismaService } from '../prisma.service.js';
import { OrganizationAccessService } from '../organization-common/organization-access.service.js';
import { CreateGroupDto } from './dto/create-group.dto.js';
import { UpdateGroupDto } from './dto/update-group.dto.js';
import { OrganizationQueryDto } from '../organization-common/dto/organization-query.dto.js';
/**
* CRUD
*/
@Injectable()
export class GroupsService {
constructor(
private readonly prisma: PrismaService,
private readonly access: OrganizationAccessService,
) {}
/**
*
*/
async create(actor: ActorContext, dto: CreateGroupDto) {
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
const departmentId = this.access.toInt(
dto.departmentId,
MESSAGES.ORG.DEPARTMENT_ID_REQUIRED,
);
const department = await this.access.ensureDepartmentExists(departmentId);
if (actor.role === Role.HOSPITAL_ADMIN) {
this.access.assertHospitalScope(actor, department.hospitalId);
}
return this.prisma.group.create({
data: {
name: this.access.normalizeName(
dto.name,
MESSAGES.ORG.GROUP_NAME_REQUIRED,
),
departmentId,
},
});
}
/**
*
*/
async findAll(actor: ActorContext, query: OrganizationQueryDto) {
this.access.assertRole(actor, [
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
]);
const paging = this.access.resolvePaging(query);
const where: Prisma.GroupWhereInput = {};
if (query.keyword) {
where.name = { contains: query.keyword.trim(), mode: 'insensitive' };
}
if (query.departmentId != null) {
where.departmentId = this.access.toInt(
query.departmentId,
MESSAGES.ORG.DEPARTMENT_ID_REQUIRED,
);
}
if (actor.role === Role.HOSPITAL_ADMIN) {
where.department = {
hospitalId: this.access.requireActorHospitalId(actor),
};
} else if (
actor.role === Role.DIRECTOR ||
actor.role === Role.LEADER ||
actor.role === Role.DOCTOR
) {
where.departmentId = this.access.requireActorDepartmentId(actor);
} else if (query.hospitalId != null) {
where.department = {
hospitalId: this.access.toInt(
query.hospitalId,
MESSAGES.ORG.HOSPITAL_ID_REQUIRED,
),
};
}
const [total, list] = await this.prisma.$transaction([
this.prisma.group.count({ where }),
this.prisma.group.findMany({
where,
include: {
department: { include: { hospital: true } },
_count: { select: { users: true } },
},
skip: paging.skip,
take: paging.take,
orderBy: { id: 'desc' },
}),
]);
return { total, ...paging, list };
}
/**
*
*/
async findOne(actor: ActorContext, id: number) {
this.access.assertRole(actor, [
Role.SYSTEM_ADMIN,
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({
where: { id: groupId },
include: {
department: { include: { hospital: true } },
_count: { select: { users: true } },
},
});
if (!group) {
throw new NotFoundException(MESSAGES.ORG.GROUP_NOT_FOUND);
}
if (actor.role === Role.HOSPITAL_ADMIN) {
this.access.assertHospitalScope(actor, group.department.hospital.id);
} 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.ORG.HOSPITAL_ADMIN_SCOPE_INVALID);
}
}
return group;
}
/**
*
*/
async update(actor: ActorContext, id: number, dto: UpdateGroupDto) {
const current = await this.findOne(actor, id);
const data: Prisma.GroupUpdateInput = {};
if (dto.departmentId !== undefined) {
throw new BadRequestException(MESSAGES.ORG.GROUP_REPARENT_FORBIDDEN);
}
if (dto.name !== undefined) {
data.name = this.access.normalizeName(
dto.name,
MESSAGES.ORG.GROUP_NAME_REQUIRED,
);
}
return this.prisma.group.update({
where: { id: current.id },
data,
});
}
/**
*
*/
async remove(actor: ActorContext, id: number) {
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
const current = await this.findOne(actor, id);
// 业务层先拦截,给前端稳定中文提示;数据库层仍保留 RESTRICT 兜底。
if (current._count.users > 0) {
throw new ConflictException(MESSAGES.ORG.GROUP_DELETE_HAS_USERS);
}
try {
return await this.prisma.group.delete({ where: { id: current.id } });
} catch (error) {
this.access.handleDeleteConflict(error);
throw error;
}
}
}

View File

@ -0,0 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
/**
* DTO
*/
export class CreateHospitalDto {
@ApiProperty({ description: '医院名称', example: '示例人民医院' })
@IsString({ message: 'name 必须是字符串' })
name!: string;
}

View File

@ -0,0 +1,7 @@
import { PartialType } from '@nestjs/swagger';
import { CreateHospitalDto } from './create-hospital.dto.js';
/**
* DTO
*/
export class UpdateHospitalDto extends PartialType(CreateHospitalDto) {}

View File

@ -0,0 +1 @@
export class Hospital {}

View File

@ -0,0 +1,103 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import type { ActorContext } from '../common/actor-context.js';
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 { Role } from '../generated/prisma/enums.js';
import { HospitalsService } from './hospitals.service.js';
import { CreateHospitalDto } from './dto/create-hospital.dto.js';
import { UpdateHospitalDto } from './dto/update-hospital.dto.js';
import { OrganizationQueryDto } from '../organization-common/dto/organization-query.dto.js';
/**
*
*/
@ApiTags('医院管理(B端)')
@ApiBearerAuth('bearer')
@Controller('b/organization/hospitals')
@UseGuards(AccessTokenGuard, RolesGuard)
export class HospitalsController {
constructor(private readonly hospitalsService: HospitalsService) {}
/**
*
*/
@Post()
@Roles(Role.SYSTEM_ADMIN)
@ApiOperation({ summary: '创建医院SYSTEM_ADMIN' })
create(@CurrentActor() actor: ActorContext, @Body() dto: CreateHospitalDto) {
return this.hospitalsService.create(actor, dto);
}
/**
*
*/
@Get()
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
@ApiOperation({ summary: '查询医院列表' })
findAll(
@CurrentActor() actor: ActorContext,
@Query() query: OrganizationQueryDto,
) {
return this.hospitalsService.findAll(actor, query);
}
/**
*
*/
@Get(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN, Role.DIRECTOR, Role.LEADER)
@ApiOperation({ summary: '查询医院详情' })
@ApiParam({ name: 'id', description: '医院 ID' })
findOne(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.hospitalsService.findOne(actor, id);
}
/**
*
*/
@Patch(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '更新医院信息' })
update(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateHospitalDto,
) {
return this.hospitalsService.update(actor, id, dto);
}
/**
*
*/
@Delete(':id')
@Roles(Role.SYSTEM_ADMIN)
@ApiOperation({ summary: '删除医院SYSTEM_ADMIN' })
remove(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.hospitalsService.remove(actor, id);
}
}

View File

@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { HospitalsService } from './hospitals.service.js';
import { HospitalsController } from './hospitals.controller.js';
import { AccessTokenGuard } from '../auth/access-token.guard.js';
import { RolesGuard } from '../auth/roles.guard.js';
import { OrganizationAccessService } from '../organization-common/organization-access.service.js';
/**
*
*/
@Module({
controllers: [HospitalsController],
providers: [
HospitalsService,
OrganizationAccessService,
AccessTokenGuard,
RolesGuard,
],
exports: [HospitalsService],
})
export class HospitalsModule {}

View File

@ -0,0 +1,140 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '../generated/prisma/client.js';
import { Role } from '../generated/prisma/enums.js';
import type { ActorContext } from '../common/actor-context.js';
import { MESSAGES } from '../common/messages.js';
import { PrismaService } from '../prisma.service.js';
import { OrganizationAccessService } from '../organization-common/organization-access.service.js';
import { CreateHospitalDto } from './dto/create-hospital.dto.js';
import { UpdateHospitalDto } from './dto/update-hospital.dto.js';
import { OrganizationQueryDto } from '../organization-common/dto/organization-query.dto.js';
/**
* CRUD
*/
@Injectable()
export class HospitalsService {
constructor(
private readonly prisma: PrismaService,
private readonly access: OrganizationAccessService,
) {}
/**
*
*/
async create(actor: ActorContext, dto: CreateHospitalDto) {
this.access.assertSystemAdmin(
actor,
MESSAGES.ORG.SYSTEM_ADMIN_ONLY_CREATE_HOSPITAL,
);
return this.prisma.hospital.create({
data: {
name: this.access.normalizeName(
dto.name,
MESSAGES.ORG.HOSPITAL_NAME_REQUIRED,
),
},
});
}
/**
* //
*/
async findAll(actor: ActorContext, query: OrganizationQueryDto) {
this.access.assertRole(actor, [
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
]);
const paging = this.access.resolvePaging(query);
const where: Prisma.HospitalWhereInput = {};
if (query.keyword) {
where.name = { contains: query.keyword.trim(), mode: 'insensitive' };
}
if (actor.role !== Role.SYSTEM_ADMIN) {
where.id = this.access.requireActorHospitalId(actor);
}
const [total, list] = await this.prisma.$transaction([
this.prisma.hospital.count({ where }),
this.prisma.hospital.findMany({
where,
skip: paging.skip,
take: paging.take,
orderBy: { id: 'desc' },
}),
]);
return { total, ...paging, list };
}
/**
* //
*/
async findOne(actor: ActorContext, id: number) {
this.access.assertRole(actor, [
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
]);
const hospitalId = this.access.toInt(id, MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
const hospital = await this.prisma.hospital.findUnique({
where: { id: hospitalId },
include: {
_count: {
select: {
departments: true,
users: true,
patients: true,
tasks: true,
},
},
},
});
if (!hospital) {
throw new NotFoundException(MESSAGES.ORG.HOSPITAL_NOT_FOUND);
}
this.access.assertHospitalScope(actor, hospital.id);
return hospital;
}
/**
*
*/
async update(actor: ActorContext, id: number, dto: UpdateHospitalDto) {
const current = await this.findOne(actor, id);
const data: Prisma.HospitalUpdateInput = {};
if (dto.name !== undefined) {
data.name = this.access.normalizeName(
dto.name,
MESSAGES.ORG.HOSPITAL_NAME_REQUIRED,
);
}
return this.prisma.hospital.update({
where: { id: current.id },
data,
});
}
/**
*
*/
async remove(actor: ActorContext, id: number) {
this.access.assertSystemAdmin(
actor,
MESSAGES.ORG.SYSTEM_ADMIN_ONLY_DELETE_HOSPITAL,
);
const hospitalId = this.access.toInt(id, MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
await this.access.ensureHospitalExists(hospitalId);
try {
return await this.prisma.hospital.delete({ where: { id: hospitalId } });
} catch (error) {
this.access.handleDeleteConflict(error);
throw error;
}
}
}

View File

@ -1,8 +1,163 @@
import 'dotenv/config';
import { mkdirSync } from 'node:fs';
import { basename, extname } from 'node:path';
import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import helmet from 'helmet';
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';
const INLINE_UPLOAD_EXTENSIONS = new Set([
'.png',
'.jpg',
'.jpeg',
'.gif',
'.bmp',
'.webp',
'.mp4',
'.mov',
'.avi',
'.mkv',
'.webm',
]);
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.use(
helmet({
contentSecurityPolicy: false,
crossOriginResourcePolicy: false,
}),
);
app.enableCors(buildCorsOptions());
mkdirSync(resolveUploadRootDir(), { recursive: true });
app.useStaticAssets(resolveUploadRootDir(), {
prefix: '/uploads/',
setHeaders: (res, filePath) => {
const extension = extname(filePath).toLowerCase();
res.setHeader('X-Content-Type-Options', 'nosniff');
if (!INLINE_UPLOAD_EXTENSIONS.has(extension)) {
const fileName = basename(filePath);
res.setHeader(
'Content-Disposition',
`attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`,
);
res.setHeader('Content-Type', 'application/octet-stream');
}
},
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
exceptionFactory: (errors) => {
const messages = errors
.flatMap((error) => Object.values(error.constraints ?? {}))
.filter((item): item is string => Boolean(item));
return new BadRequestException(
messages.length > 0
? messages.join('; ')
: MESSAGES.DEFAULT_BAD_REQUEST,
);
},
}),
);
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new ResponseEnvelopeInterceptor());
if (shouldEnableSwagger()) {
const swaggerBuilder = new DocumentBuilder()
.setTitle('调压通医疗平台接口文档')
.setDescription('包含认证、权限、患者、手术、任务和上传等后端接口。')
.setVersion('1.0.0')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: '请填写登录接口返回的访问令牌。',
},
'bearer',
);
const publicBaseUrl = normalizeOrigin(process.env.PUBLIC_BASE_URL);
if (publicBaseUrl) {
swaggerBuilder.addServer(publicBaseUrl, '公开地址');
}
const swaggerDocument = SwaggerModule.createDocument(
app,
swaggerBuilder.build(),
);
SwaggerModule.setup('api/docs', app, swaggerDocument, {
jsonDocumentUrl: 'api/docs-json',
});
}
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
function buildCorsOptions() {
const allowedOrigins = parseAllowedOrigins(process.env.CORS_ALLOWED_ORIGINS);
if (allowedOrigins.length === 0) {
return {
origin: process.env.NODE_ENV === 'production' ? false : true,
credentials: true,
};
}
return {
credentials: true,
origin: (
origin: string | undefined,
callback: (error: Error | null, allow?: boolean) => void,
) => {
if (!origin) {
callback(null, true);
return;
}
callback(null, allowedOrigins.includes(normalizeOrigin(origin)));
},
};
}
function parseAllowedOrigins(value: string | undefined) {
if (!value) {
return [];
}
return Array.from(
new Set(
value
.split(',')
.map((origin) => normalizeOrigin(origin))
.filter(Boolean),
),
);
}
function normalizeOrigin(origin: string | undefined) {
return String(origin ?? '').trim().replace(/\/+$/, '');
}
function shouldEnableSwagger() {
return (
process.env.ENABLE_SWAGGER === 'true' ||
process.env.NODE_ENV !== 'production'
);
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { WechatNotifyService } from './wechat-notify/wechat-notify.service.js';
import { TaskEventsListener } from './task-events.listener/task-events.listener.js';
/**
*
*/
@Module({
providers: [WechatNotifyService, TaskEventsListener],
exports: [WechatNotifyService],
})
export class NotificationsModule {}

View File

@ -0,0 +1,94 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PrismaService } from '../../prisma.service.js';
import { WechatNotifyService } from '../wechat-notify/wechat-notify.service.js';
interface TaskEventPayload {
taskId: number;
hospitalId: number;
actorId: number;
status: string;
}
/**
*
*/
@Injectable()
export class TaskEventsListener {
private readonly logger = new Logger(TaskEventsListener.name);
constructor(
private readonly prisma: PrismaService,
private readonly wechatNotifyService: WechatNotifyService,
) {}
/**
* openId
*/
@OnEvent('task.published', { async: true })
async onTaskPublished(payload: TaskEventPayload) {
await this.dispatchTaskEvent('task.published', payload);
}
/**
*
*/
@OnEvent('task.accepted', { async: true })
async onTaskAccepted(payload: TaskEventPayload) {
await this.dispatchTaskEvent('task.accepted', payload);
}
/**
*
*/
@OnEvent('task.completed', { async: true })
async onTaskCompleted(payload: TaskEventPayload) {
await this.dispatchTaskEvent('task.completed', payload);
}
/**
*
*/
@OnEvent('task.cancelled', { async: true })
async onTaskCancelled(payload: TaskEventPayload) {
await this.dispatchTaskEvent('task.cancelled', payload);
}
/**
*
*/
@OnEvent('task.released', { async: true })
async onTaskReleased(payload: TaskEventPayload) {
await this.dispatchTaskEvent('task.released', payload);
}
/**
*
*/
private async dispatchTaskEvent(event: string, payload: TaskEventPayload) {
const task = await this.prisma.task.findUnique({
where: { id: payload.taskId },
select: {
id: true,
creator: { select: { id: true, openId: true } },
engineer: { select: { id: true, openId: true } },
},
});
if (!task) {
this.logger.warn(`任务事件监听未找到任务taskId=${payload.taskId}`);
return;
}
await this.wechatNotifyService.notifyTaskChange(
[task.creator.openId, task.engineer?.openId],
{
event,
taskId: payload.taskId,
hospitalId: payload.hospitalId,
actorId: payload.actorId,
status: payload.status,
},
);
}
}

View File

@ -0,0 +1,56 @@
import { Injectable, Logger } from '@nestjs/common';
export interface TaskNotifyPayload {
event: string;
taskId: number;
hospitalId: number;
actorId: number;
status: string;
}
@Injectable()
export class WechatNotifyService {
private readonly logger = new Logger(WechatNotifyService.name);
/**
* / API
*/
async notifyTaskChange(
openIds: Array<string | null | undefined>,
payload: TaskNotifyPayload,
) {
const targets = Array.from(
new Set(
openIds
.map((item) => item?.trim())
.filter((item): item is string => Boolean(item)),
),
);
if (targets.length === 0) {
this.logger.warn(
`任务事件 ${payload.event} 无可用 openIdtaskId=${payload.taskId}`,
);
return;
}
for (const openId of targets) {
const maskedOpenId = this.maskOpenId(openId);
// TODO: 在此处调用微信服务号/小程序消息推送 API。
this.logger.log(
`模拟推送任务通知 event=${payload.event}, taskId=${payload.taskId}, openId=${maskedOpenId}`,
);
}
}
/**
* openId
*/
private maskOpenId(openId: string) {
if (openId.length <= 6) {
return '***';
}
return `${openId.slice(0, 3)}***${openId.slice(-3)}`;
}
}

View File

@ -0,0 +1,58 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
/**
* DTO//
*/
export class OrganizationQueryDto {
@ApiPropertyOptional({
description: '关键词(按名称模糊匹配)',
example: '神经',
})
@IsOptional()
@IsString({ message: 'keyword 必须是字符串' })
keyword?: string;
@ApiPropertyOptional({ description: '医院 ID', example: 1 })
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'hospitalId 必须是整数' })
@Min(1, { message: 'hospitalId 必须大于 0' })
hospitalId?: number;
@ApiPropertyOptional({ description: '科室 ID', example: 1 })
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'departmentId 必须是整数' })
@Min(1, { message: 'departmentId 必须大于 0' })
departmentId?: 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

@ -0,0 +1,184 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '../generated/prisma/client.js';
import { Role } from '../generated/prisma/enums.js';
import type { ActorContext } from '../common/actor-context.js';
import { MESSAGES } from '../common/messages.js';
import { PrismaService } from '../prisma.service.js';
import type { OrganizationQueryDto } from './dto/organization-query.dto.js';
/**
*
*
*/
@Injectable()
export class OrganizationAccessService {
constructor(private readonly prisma: PrismaService) {}
/**
*
*/
assertRole(actor: ActorContext, roles: Role[]) {
if (!roles.includes(actor.role)) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
}
/**
*
*/
assertSystemAdmin(actor: ActorContext, message: string) {
if (actor.role !== Role.SYSTEM_ADMIN) {
throw new ForbiddenException(message);
}
}
/**
*
*/
assertHospitalScope(actor: ActorContext, targetHospitalId: number) {
if (actor.role === Role.SYSTEM_ADMIN) {
return;
}
const hospitalScopedRoles: Role[] = [
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
];
if (!hospitalScopedRoles.includes(actor.role)) {
return;
}
const actorHospitalId = this.requireActorHospitalId(actor);
if (actorHospitalId !== targetHospitalId) {
throw new ForbiddenException(MESSAGES.ORG.HOSPITAL_ADMIN_SCOPE_INVALID);
}
}
/**
* ID
*/
requireActorHospitalId(actor: ActorContext): number {
if (
typeof actor.hospitalId !== 'number' ||
!Number.isInteger(actor.hospitalId) ||
actor.hospitalId <= 0
) {
throw new BadRequestException(MESSAGES.ORG.ACTOR_HOSPITAL_REQUIRED);
}
return actor.hospitalId;
}
/**
* ID
*/
requireActorDepartmentId(actor: ActorContext): number {
if (
typeof actor.departmentId !== 'number' ||
!Number.isInteger(actor.departmentId) ||
actor.departmentId <= 0
) {
throw new BadRequestException(MESSAGES.ORG.ACTOR_DEPARTMENT_REQUIRED);
}
return actor.departmentId;
}
/**
* ID
*/
requireActorGroupId(actor: ActorContext): number {
if (
typeof actor.groupId !== 'number' ||
!Number.isInteger(actor.groupId) ||
actor.groupId <= 0
) {
throw new BadRequestException(MESSAGES.ORG.ACTOR_GROUP_REQUIRED);
}
return actor.groupId;
}
/**
*
*/
resolvePaging(query: OrganizationQueryDto) {
const page = query.page && query.page > 0 ? query.page : 1;
const pageSize =
query.pageSize && query.pageSize > 0 && query.pageSize <= 100
? query.pageSize
: 20;
return {
page,
pageSize,
skip: (page - 1) * pageSize,
take: pageSize,
};
}
/**
*
*/
normalizeName(value: string, message: string) {
const trimmed = value?.trim();
if (!trimmed) {
throw new BadRequestException(message);
}
return trimmed;
}
/**
*
*/
toInt(value: unknown, message: string) {
const parsed = Number(value);
if (!Number.isInteger(parsed)) {
throw new BadRequestException(message);
}
return parsed;
}
/**
*
*/
async ensureHospitalExists(id: number) {
const hospital = await this.prisma.hospital.findUnique({
where: { id },
select: { id: true },
});
if (!hospital) {
throw new NotFoundException(MESSAGES.ORG.HOSPITAL_NOT_FOUND);
}
return hospital;
}
/**
*
*/
async ensureDepartmentExists(id: number) {
const department = await this.prisma.department.findUnique({
where: { id },
select: { id: true, hospitalId: true },
});
if (!department) {
throw new NotFoundException(MESSAGES.ORG.DEPARTMENT_NOT_FOUND);
}
return department;
}
/**
*
*/
handleDeleteConflict(error: unknown) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
(error.code === 'P2003' || error.code === 'P2014')
) {
throw new ConflictException(MESSAGES.ORG.DELETE_CONFLICT);
}
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { HospitalsModule } from '../hospitals/hospitals.module.js';
import { DepartmentsModule } from '../departments/departments.module.js';
import { GroupsModule } from '../groups/groups.module.js';
/**
* //
*/
@Module({
imports: [HospitalsModule, DepartmentsModule, GroupsModule],
})
export class OrganizationModule {}

View File

@ -0,0 +1,212 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiExtraModels,
ApiOkResponse,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
import { CurrentActor } from '../../auth/current-actor.decorator.js';
import { Roles } from '../../auth/roles.decorator.js';
import { RolesGuard } from '../../auth/roles.guard.js';
import type { ActorContext } from '../../common/actor-context.js';
import { Role } from '../../generated/prisma/enums.js';
import { CreatePatientDto } from '../dto/create-patient.dto.js';
import { CreatePatientSurgeryDto } from '../dto/create-patient-surgery.dto.js';
import { PatientQueryDto } from '../dto/patient-query.dto.js';
import {
BPatientLifecycleResponseDto,
PATIENT_LIFECYCLE_SWAGGER_MODELS,
} from '../dto/patient-lifecycle-response.dto.js';
import { UpdatePatientSurgeryDto } from '../dto/update-patient-surgery.dto.js';
import { UpdatePatientDto } from '../dto/update-patient.dto.js';
import { BPatientsService } from './b-patients.service.js';
@ApiTags('患者管理(B端)')
@ApiBearerAuth('bearer')
@Controller('b/patients')
@UseGuards(AccessTokenGuard, RolesGuard)
export class BPatientsController {
constructor(private readonly patientsService: BPatientsService) {}
@Get('doctors')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '查询当前登录人可见的医生列表' })
@ApiQuery({
name: 'hospitalId',
required: false,
description: '医院编号,仅系统管理员可选传',
})
findVisibleDoctors(
@CurrentActor() actor: ActorContext,
@Query('hospitalId') hospitalId?: string,
) {
const requestedHospitalId =
hospitalId == null || hospitalId === '' ? undefined : Number(hospitalId);
return this.patientsService.findVisibleDoctors(actor, requestedHospitalId);
}
@Get()
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '按服务端分页查询当前登录人可见的患者列表' })
findVisiblePatients(
@CurrentActor() actor: ActorContext,
@Query() query: PatientQueryDto,
) {
return this.patientsService.findVisiblePatients(actor, query);
}
@Post()
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '创建患者档案' })
createPatient(
@CurrentActor() actor: ActorContext,
@Body() dto: CreatePatientDto,
) {
return this.patientsService.createPatient(actor, dto);
}
@Post(':id/surgeries')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '为患者新增手术记录' })
@ApiParam({ name: 'id', description: '患者编号' })
createPatientSurgery(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
@Body() dto: CreatePatientSurgeryDto,
) {
return this.patientsService.createPatientSurgery(actor, id, dto);
}
@Patch(':id/surgeries/:surgeryId')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '更新患者手术记录' })
@ApiParam({ name: 'id', description: '患者编号' })
@ApiParam({ name: 'surgeryId', description: '手术编号' })
updatePatientSurgery(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
@Param('surgeryId', ParseIntPipe) surgeryId: number,
@Body() dto: UpdatePatientSurgeryDto,
) {
return this.patientsService.updatePatientSurgery(actor, id, surgeryId, dto);
}
@Get(':id/lifecycle')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '查询患者生命周期事件B端详情页' })
@ApiParam({ name: 'id', description: '患者编号' })
@ApiExtraModels(...PATIENT_LIFECYCLE_SWAGGER_MODELS)
@ApiOkResponse({
description: '返回指定患者的生命周期数据',
type: BPatientLifecycleResponseDto,
})
findPatientLifecycle(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.patientsService.findPatientLifecycle(actor, id);
}
@Get(':id')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '查询患者详情' })
@ApiParam({ name: 'id', description: '患者编号' })
findPatientById(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.patientsService.findPatientById(actor, id);
}
@Patch(':id')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '更新患者档案' })
@ApiParam({ name: 'id', description: '患者编号' })
updatePatient(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdatePatientDto,
) {
return this.patientsService.updatePatient(actor, id, dto);
}
@Delete(':id')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '删除患者档案' })
@ApiParam({ name: 'id', description: '患者编号' })
removePatient(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.patientsService.removePatient(actor, id);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,55 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import {
ApiBearerAuth,
ApiExtraModels,
ApiOkResponse,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { CurrentFamilyActor } from '../../auth/current-family-actor.decorator.js';
import { FamilyAccessTokenGuard } from '../../auth/family-access/family-access.guard.js';
import type { FamilyActorContext } from '../../common/family-actor-context.js';
import { CPatientMeResponseDto } from '../dto/c-patient-me-response.dto.js';
import {
CPatientLifecycleResponseDto,
PATIENT_LIFECYCLE_SWAGGER_MODELS,
} from '../dto/patient-lifecycle-response.dto.js';
import { CPatientsService } from './c-patients.service.js';
/**
* C
*/
@ApiTags('患者管理(C端)')
@ApiBearerAuth('bearer')
@Controller('c/patients')
@UseGuards(FamilyAccessTokenGuard)
export class CPatientsController {
constructor(private readonly patientsService: CPatientsService) {}
/**
*
*/
@Get('me')
@ApiOperation({ summary: '获取当前登录患者信息' })
@ApiOkResponse({
description: '返回当前 C 端登录账号绑定的患者档案与账号信息',
type: CPatientMeResponseDto,
})
me(@CurrentFamilyActor() actor: FamilyActorContext) {
return this.patientsService.getMeByAccount(actor.id);
}
/**
*
*/
@Get('my-lifecycle')
@ApiOperation({ summary: '按当前登录手机号查询患者生命周期' })
@ApiExtraModels(...PATIENT_LIFECYCLE_SWAGGER_MODELS)
@ApiOkResponse({
description: '按当前登录手机号返回唯一患者的生命周期数据',
type: CPatientLifecycleResponseDto,
})
getMyLifecycle(@CurrentFamilyActor() actor: FamilyActorContext) {
return this.patientsService.getFamilyLifecycleByAccount(actor.id);
}
}

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