Compare commits
25 Commits
master
...
web-recove
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f7d66ce54 | |||
| cfd2f1e8dc | |||
| d2d87701de | |||
| d77627e44b | |||
| 8f7e13bf2b | |||
| ab17204739 | |||
| c830a2131e | |||
| 21941e94fd | |||
| 6a3eb49ab6 | |||
| 7c4ba1e1a0 | |||
| 19c08a7618 | |||
| 0b5640a977 | |||
| 2bfe8ac8c8 | |||
| 73082225f6 | |||
| 64d1ad7896 | |||
| 6ec2d0b0e0 | |||
| 5fdf4c80e6 | |||
| b527256874 | |||
| 602694814f | |||
| 2275607bd2 | |||
| 394793fa28 | |||
| 2c1bbd565f | |||
| 6ec8891be5 | |||
| b55e600c9c | |||
| aa1346f6af |
5
.env.example
Normal file
5
.env.example
Normal 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
7
.gitignore
vendored
@ -56,3 +56,10 @@ pids
|
|||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
/src/generated/prisma
|
/src/generated/prisma
|
||||||
|
|
||||||
|
/tyt-admin/dist
|
||||||
|
/tyt-admin/node_modules
|
||||||
|
|
||||||
|
# Runtime upload assets
|
||||||
|
/storage/uploads
|
||||||
|
/storage/tmp-uploads
|
||||||
|
|||||||
48
AGENTS.md
Normal file
48
AGENTS.md
Normal 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
111
README.md
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
# 多租户医疗调压系统后端(NestJS + Prisma)
|
||||||
|
|
||||||
|
本项目是医疗调压系统后端 MVP,支持 B 端(医院内部)与 C 端(家属跨院视图)两套接口语义。
|
||||||
|
|
||||||
|
## 1. 技术栈
|
||||||
|
|
||||||
|
- NestJS(模块化后端框架)
|
||||||
|
- Prisma(ORM + 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
77
docs/auth.md
Normal 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
81
docs/devices.md
Normal 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
42
docs/dictionaries.md
Normal 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
78
docs/e2e-testing.md
Normal 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 端角色可见性
|
||||||
|
- 患者创建人返回与展示
|
||||||
|
- 跨院工程师隔离
|
||||||
|
- 组织域院管作用域限制与删除冲突
|
||||||
|
- 目录、设备、组织、用户的删除保护
|
||||||
50
docs/frontend-api-integration.md
Normal file
50
docs/frontend-api-integration.md
Normal 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
38
docs/patients.md
Normal 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
69
docs/tasks.md
Normal 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
72
docs/uploads.md
Normal 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
59
docs/users.md
Normal 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 中文说明
|
||||||
@ -3,7 +3,6 @@
|
|||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true,
|
"deleteOutDir": true
|
||||||
"plugins": ["@nestjs/swagger"]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
24
package.json
24
package.json
@ -12,35 +12,53 @@
|
|||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:debug": "nest start --debug --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": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.3",
|
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/event-emitter": "^3.0.1",
|
||||||
"@nestjs/mapped-types": "*",
|
"@nestjs/mapped-types": "*",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/swagger": "^11.2.6",
|
"@nestjs/swagger": "^11.2.6",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@prisma/adapter-pg": "^7.5.0",
|
"@prisma/adapter-pg": "^7.5.0",
|
||||||
"@prisma/client": "^7.5.0",
|
"@prisma/client": "^7.5.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.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",
|
"pg": "^8.20.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1",
|
||||||
|
"sharp": "^0.34.5",
|
||||||
|
"swagger-ui-express": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.0",
|
||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/express": "^5.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/node": "^22.10.7",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
|
"jest": "^30.3.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prisma": "^7.4.2",
|
"prisma": "^7.4.2",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.4.6",
|
||||||
"ts-loader": "^9.5.2",
|
"ts-loader": "^9.5.2",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
|||||||
2996
pnpm-lock.yaml
generated
2996
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
9
pnpm-workspace.yaml
Normal file
9
pnpm-workspace.yaml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- '@nestjs/core'
|
||||||
|
- '@prisma/engines'
|
||||||
|
- '@scarf/scarf'
|
||||||
|
- bcrypt
|
||||||
|
- ffmpeg-static
|
||||||
|
- prisma
|
||||||
|
- sharp
|
||||||
|
- unrs-resolver
|
||||||
@ -7,7 +7,6 @@ export default defineConfig({
|
|||||||
schema: "prisma/schema.prisma",
|
schema: "prisma/schema.prisma",
|
||||||
migrations: {
|
migrations: {
|
||||||
path: "prisma/migrations",
|
path: "prisma/migrations",
|
||||||
seed: "node --env-file=.env --loader ts-node/esm prisma/seed.ts",
|
|
||||||
},
|
},
|
||||||
datasource: {
|
datasource: {
|
||||||
url: process.env["DATABASE_URL"],
|
url: process.env["DATABASE_URL"],
|
||||||
|
|||||||
25
prisma/migrations/20260312073029_init/migration.sql
Normal file
25
prisma/migrations/20260312073029_init/migration.sql
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"name" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Post" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"content" TEXT,
|
||||||
|
"published" BOOLEAN DEFAULT false,
|
||||||
|
"authorId" INTEGER,
|
||||||
|
|
||||||
|
CONSTRAINT "Post_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Post" ADD CONSTRAINT "Post_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@ -1,151 +0,0 @@
|
|||||||
-- CreateEnum
|
|
||||||
CREATE TYPE "UserRole" AS ENUM ('SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR', 'TEAM_LEAD', 'DOCTOR', 'ENGINEER');
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "Hospital" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"code" TEXT NOT NULL,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "Hospital_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "Department" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"hospitalId" INTEGER NOT NULL,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "Department_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "MedicalGroup" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"departmentId" INTEGER NOT NULL,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "MedicalGroup_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "User" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"email" TEXT NOT NULL,
|
|
||||||
"name" TEXT,
|
|
||||||
"role" "UserRole" NOT NULL DEFAULT 'DOCTOR',
|
|
||||||
"hospitalId" INTEGER,
|
|
||||||
"departmentId" INTEGER,
|
|
||||||
"medicalGroupId" INTEGER,
|
|
||||||
"managerId" INTEGER,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "Patient" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"hospitalId" INTEGER NOT NULL,
|
|
||||||
"departmentId" INTEGER,
|
|
||||||
"medicalGroupId" INTEGER,
|
|
||||||
"doctorId" INTEGER NOT NULL,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "Patient_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "EngineerHospitalAssignment" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"hospitalId" INTEGER NOT NULL,
|
|
||||||
"engineerId" INTEGER NOT NULL,
|
|
||||||
"assignedById" INTEGER NOT NULL,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "EngineerHospitalAssignment_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Hospital_code_key" ON "Hospital"("code");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Department_hospitalId_name_key" ON "Department"("hospitalId", "name");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "MedicalGroup_departmentId_name_key" ON "MedicalGroup"("departmentId", "name");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "User_role_idx" ON "User"("role");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "User_hospitalId_idx" ON "User"("hospitalId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "User_managerId_idx" ON "User"("managerId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "Patient_doctorId_idx" ON "Patient"("doctorId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "Patient_hospitalId_idx" ON "Patient"("hospitalId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "EngineerHospitalAssignment_engineerId_idx" ON "EngineerHospitalAssignment"("engineerId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "EngineerHospitalAssignment_assignedById_idx" ON "EngineerHospitalAssignment"("assignedById");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "EngineerHospitalAssignment_hospitalId_engineerId_key" ON "EngineerHospitalAssignment"("hospitalId", "engineerId");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "Department" ADD CONSTRAINT "Department_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "MedicalGroup" ADD CONSTRAINT "MedicalGroup_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE CASCADE 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_medicalGroupId_fkey" FOREIGN KEY ("medicalGroupId") REFERENCES "MedicalGroup"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "User" ADD CONSTRAINT "User_managerId_fkey" FOREIGN KEY ("managerId") REFERENCES "User"("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_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "Patient" ADD CONSTRAINT "Patient_medicalGroupId_fkey" FOREIGN KEY ("medicalGroupId") REFERENCES "MedicalGroup"("id") ON DELETE SET NULL 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 "EngineerHospitalAssignment" ADD CONSTRAINT "EngineerHospitalAssignment_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "EngineerHospitalAssignment" ADD CONSTRAINT "EngineerHospitalAssignment_engineerId_fkey" FOREIGN KEY ("engineerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "EngineerHospitalAssignment" ADD CONSTRAINT "EngineerHospitalAssignment_assignedById_fkey" FOREIGN KEY ("assignedById") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- You are about to drop the column `email` on the `User` table. All the data in the column will be lost.
|
|
||||||
- A unique constraint covering the columns `[phone]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
|
||||||
- A unique constraint covering the columns `[wechatMiniOpenId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
|
||||||
- A unique constraint covering the columns `[wechatOfficialOpenId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
|
||||||
- Added the required column `passwordHash` to the `User` table without a default value. This is not possible if the table is not empty.
|
|
||||||
- Added the required column `phone` to the `User` table without a default value. This is not possible if the table is not empty.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- DropIndex
|
|
||||||
DROP INDEX "User_email_key";
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "User" DROP COLUMN "email",
|
|
||||||
ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true,
|
|
||||||
ADD COLUMN "lastLoginAt" TIMESTAMP(3),
|
|
||||||
ADD COLUMN "passwordHash" TEXT NOT NULL,
|
|
||||||
ADD COLUMN "phone" TEXT NOT NULL,
|
|
||||||
ADD COLUMN "wechatMiniOpenId" TEXT,
|
|
||||||
ADD COLUMN "wechatOfficialOpenId" TEXT;
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "User_wechatMiniOpenId_key" ON "User"("wechatMiniOpenId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "User_wechatOfficialOpenId_key" ON "User"("wechatOfficialOpenId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "User_phone_isActive_idx" ON "User"("phone", "isActive");
|
|
||||||
192
prisma/migrations/20260312174732_mvp_schema_sync/migration.sql
Normal file
192
prisma/migrations/20260312174732_mvp_schema_sync/migration.sql
Normal 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;
|
||||||
8
prisma/migrations/20260318100229/migration.sql
Normal file
8
prisma/migrations/20260318100229/migration.sql
Normal 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");
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "User"
|
||||||
|
ADD COLUMN "tokenValidAfter" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
ALTER TABLE "Patient"
|
||||||
|
RENAME COLUMN "idCardHash" TO "idCard";
|
||||||
|
|
||||||
|
ALTER INDEX "Patient_phone_idCardHash_idx"
|
||||||
|
RENAME TO "Patient_phone_idCard_idx";
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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");
|
||||||
@ -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";
|
||||||
@ -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;
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
-- DropIndex
|
||||||
|
DROP INDEX IF EXISTS "Device_snCode_key";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Device" DROP COLUMN "snCode";
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "FamilyMiniAppAccount" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"phone" TEXT NOT NULL,
|
||||||
|
"openId" TEXT,
|
||||||
|
"serviceUid" TEXT,
|
||||||
|
"lastLoginAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "FamilyMiniAppAccount_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "FamilyMiniAppAccount_phone_key" ON "FamilyMiniAppAccount"("phone");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "FamilyMiniAppAccount_openId_key" ON "FamilyMiniAppAccount"("openId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "FamilyMiniAppAccount_serviceUid_key" ON "FamilyMiniAppAccount"("serviceUid");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "FamilyMiniAppAccount_lastLoginAt_idx" ON "FamilyMiniAppAccount"("lastLoginAt");
|
||||||
@ -0,0 +1,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;
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "Task"
|
||||||
|
ADD COLUMN IF NOT EXISTS "completionMaterials" JSONB;
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
-- 允许同一个微信 openId 绑定多个院内账号,保留普通索引供查询复用。
|
||||||
|
DROP INDEX IF EXISTS "User_openId_key";
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "User_openId_idx" ON "User"("openId");
|
||||||
@ -1,219 +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 {
|
generator client {
|
||||||
provider = "prisma-client"
|
provider = "prisma-client"
|
||||||
output = "../src/generated/prisma"
|
output = "../src/generated/prisma"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 兼容 seed 脚本在 Node.js 直接运行时使用 @prisma/client runtime。
|
||||||
|
generator seed_client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 统一角色枚举:
|
// 角色枚举:用于鉴权与数据可见性控制。
|
||||||
/// - SYSTEM_ADMIN: 平台管理员
|
enum Role {
|
||||||
/// - HOSPITAL_ADMIN: 医院管理员
|
|
||||||
/// - DIRECTOR: 科室主任
|
|
||||||
/// - TEAM_LEAD: 小组组长
|
|
||||||
/// - DOCTOR: 医生
|
|
||||||
/// - ENGINEER: 工程师
|
|
||||||
enum UserRole {
|
|
||||||
SYSTEM_ADMIN
|
SYSTEM_ADMIN
|
||||||
HOSPITAL_ADMIN
|
HOSPITAL_ADMIN
|
||||||
DIRECTOR
|
DIRECTOR
|
||||||
TEAM_LEAD
|
LEADER
|
||||||
DOCTOR
|
DOCTOR
|
||||||
ENGINEER
|
ENGINEER
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 医院实体:多医院租户的顶层边界。
|
// 设备状态枚举:表示设备是否处于使用中。
|
||||||
|
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 {
|
model Hospital {
|
||||||
/// 主键 ID。
|
id Int @id @default(autoincrement())
|
||||||
id Int @id @default(autoincrement())
|
name String
|
||||||
/// 医院名称。
|
departments Department[]
|
||||||
name String
|
users User[]
|
||||||
/// 医院编码(唯一)。
|
patients Patient[]
|
||||||
code String @unique
|
tasks Task[]
|
||||||
/// 下属科室列表。
|
uploads UploadAsset[]
|
||||||
departments Department[]
|
|
||||||
/// 归属该医院的用户。
|
|
||||||
users User[]
|
|
||||||
/// 归属该医院的患者。
|
|
||||||
patients Patient[]
|
|
||||||
/// 该医院被分配的工程师任务关系。
|
|
||||||
engineerAssignments EngineerHospitalAssignment[]
|
|
||||||
/// 创建时间。
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
/// 更新时间。
|
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 科室实体:归属于某个医院。
|
// 科室表:归属于医院。
|
||||||
model Department {
|
model Department {
|
||||||
/// 主键 ID。
|
id Int @id @default(autoincrement())
|
||||||
id Int @id @default(autoincrement())
|
name String
|
||||||
/// 科室名称。
|
hospitalId Int
|
||||||
name String
|
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
||||||
/// 所属医院 ID。
|
groups Group[]
|
||||||
hospitalId Int
|
users User[]
|
||||||
/// 医院外键关系。
|
|
||||||
hospital Hospital @relation(fields: [hospitalId], references: [id], onDelete: Cascade)
|
|
||||||
/// 下属小组。
|
|
||||||
medicalGroups MedicalGroup[]
|
|
||||||
/// 科室下用户。
|
|
||||||
users User[]
|
|
||||||
/// 科室下患者。
|
|
||||||
patients Patient[]
|
|
||||||
/// 创建时间。
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
/// 更新时间。
|
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
|
||||||
|
|
||||||
/// 同一家医院下科室名称唯一。
|
@@index([hospitalId])
|
||||||
@@unique([hospitalId, name])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 医疗小组实体:归属于某个科室。
|
// 小组表:归属于科室。
|
||||||
model MedicalGroup {
|
model Group {
|
||||||
/// 主键 ID。
|
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
/// 小组名称。
|
|
||||||
name String
|
name String
|
||||||
/// 所属科室 ID。
|
|
||||||
departmentId Int
|
departmentId Int
|
||||||
/// 科室外键关系。
|
department Department @relation(fields: [departmentId], references: [id])
|
||||||
department Department @relation(fields: [departmentId], references: [id], onDelete: Cascade)
|
|
||||||
/// 小组用户。
|
|
||||||
users User[]
|
users User[]
|
||||||
/// 小组患者。
|
|
||||||
patients Patient[]
|
|
||||||
/// 创建时间。
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
/// 更新时间。
|
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
|
||||||
|
|
||||||
/// 同一科室下小组名称唯一。
|
@@index([departmentId])
|
||||||
@@unique([departmentId, name])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 用户实体:统一承载组织关系、登录凭证与上下级结构。
|
// 用户表:支持后台密码登录与小程序 openId。
|
||||||
|
// 同一个微信 openId 允许绑定多个院内账号,便于多角色/多院区切换。
|
||||||
model User {
|
model User {
|
||||||
/// 主键 ID。
|
id Int @id @default(autoincrement())
|
||||||
id Int @id @default(autoincrement())
|
name String
|
||||||
/// 手机号(唯一登录名)。
|
phone String
|
||||||
phone String @unique
|
// 后台登录密码哈希(bcrypt)。
|
||||||
/// 密码哈希(禁止存明文)。
|
passwordHash String?
|
||||||
passwordHash String
|
// 该时间点之前签发的 token 一律失效。
|
||||||
/// 用户姓名。
|
tokenValidAfter DateTime @default(now())
|
||||||
name String?
|
openId String?
|
||||||
/// 用户角色。
|
role Role
|
||||||
role UserRole @default(DOCTOR)
|
hospitalId Int?
|
||||||
/// 归属医院 ID(可空,支持平台角色)。
|
departmentId Int?
|
||||||
hospitalId Int?
|
groupId Int?
|
||||||
/// 归属科室 ID(可空)。
|
hospital Hospital? @relation(fields: [hospitalId], references: [id])
|
||||||
departmentId Int?
|
department Department? @relation(fields: [departmentId], references: [id])
|
||||||
/// 归属小组 ID(可空)。
|
// 小组删除必须先清理成员,避免静默把用户 groupId 置空。
|
||||||
medicalGroupId Int?
|
group Group? @relation(fields: [groupId], references: [id], onDelete: Restrict)
|
||||||
/// 上级用户 ID(自关联层级)。
|
doctorPatients Patient[] @relation("DoctorPatients")
|
||||||
managerId Int?
|
createdPatients Patient[] @relation("PatientCreator")
|
||||||
/// 小程序 openId(可空,唯一)。
|
createdTasks Task[] @relation("TaskCreator")
|
||||||
wechatMiniOpenId String? @unique
|
acceptedTasks Task[] @relation("TaskEngineer")
|
||||||
/// 服务号 openId(可空,唯一)。
|
surgeonSurgeries PatientSurgery[] @relation("SurgerySurgeon")
|
||||||
wechatOfficialOpenId String? @unique
|
createdUploads UploadAsset[] @relation("UploadCreator")
|
||||||
/// 账号是否启用。
|
|
||||||
isActive Boolean @default(true)
|
|
||||||
/// 最近登录时间。
|
|
||||||
lastLoginAt DateTime?
|
|
||||||
/// 医院关系。
|
|
||||||
hospital Hospital? @relation(fields: [hospitalId], references: [id], onDelete: SetNull)
|
|
||||||
/// 科室关系。
|
|
||||||
department Department? @relation(fields: [departmentId], references: [id], onDelete: SetNull)
|
|
||||||
/// 小组关系。
|
|
||||||
medicalGroup MedicalGroup? @relation(fields: [medicalGroupId], references: [id], onDelete: SetNull)
|
|
||||||
/// 上级关系。
|
|
||||||
manager User? @relation("UserHierarchy", fields: [managerId], references: [id], onDelete: SetNull)
|
|
||||||
/// 下级关系。
|
|
||||||
subordinates User[] @relation("UserHierarchy")
|
|
||||||
/// 医生持有患者关系。
|
|
||||||
patients Patient[] @relation("DoctorPatients")
|
|
||||||
/// 工程师被分配医院关系。
|
|
||||||
engineerAssignments EngineerHospitalAssignment[] @relation("EngineerAssignments")
|
|
||||||
/// 系统管理员分配记录关系。
|
|
||||||
assignedEngineerHospitals EngineerHospitalAssignment[] @relation("SystemAdminAssignments")
|
|
||||||
/// 创建时间。
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
/// 更新时间。
|
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
|
||||||
|
|
||||||
/// 角色索引,便于权限查询。
|
@@unique([phone, role, hospitalId])
|
||||||
@@index([role])
|
@@index([phone])
|
||||||
/// 医院索引,便于分院查询。
|
@@index([openId])
|
||||||
@@index([hospitalId])
|
@@index([hospitalId, role])
|
||||||
/// 上级索引,便于层级查询。
|
@@index([departmentId, role])
|
||||||
@@index([managerId])
|
@@index([groupId, role])
|
||||||
/// 手机号 + 启用状态联合索引,便于登录场景查询。
|
|
||||||
@@index([phone, isActive])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 患者实体:医生直接持有患者,上级通过层级可见性获取。
|
// 家属小程序账号:按手机号承载 C 端登录身份,并预留服务号绑定字段。
|
||||||
|
model FamilyMiniAppAccount {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
phone String @unique
|
||||||
|
openId String? @unique
|
||||||
|
serviceUid String? @unique
|
||||||
|
lastLoginAt DateTime @default(now())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
|
@@index([lastLoginAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 患者表:院内患者档案,按医院隔离。
|
||||||
model Patient {
|
model Patient {
|
||||||
/// 主键 ID。
|
id Int @id @default(autoincrement())
|
||||||
id Int @id @default(autoincrement())
|
name String
|
||||||
/// 患者姓名。
|
createdAt DateTime @default(now())
|
||||||
name String
|
// 住院号:用于院内患者检索与病案关联。
|
||||||
/// 所属医院 ID。
|
inpatientNo String?
|
||||||
hospitalId Int
|
// 项目名称:用于区分患者所属项目/课题。
|
||||||
/// 所属科室 ID(可空)。
|
projectName String?
|
||||||
departmentId Int?
|
phone String
|
||||||
/// 所属小组 ID(可空)。
|
// 患者身份证号,录入与查询都使用原始证件号。
|
||||||
medicalGroupId Int?
|
idCard String
|
||||||
/// 负责医生 ID。
|
hospitalId Int
|
||||||
doctorId Int
|
doctorId Int
|
||||||
/// 医院关系。
|
creatorId Int
|
||||||
hospital Hospital @relation(fields: [hospitalId], references: [id], onDelete: Restrict)
|
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
||||||
/// 科室关系。
|
doctor User @relation("DoctorPatients", fields: [doctorId], references: [id])
|
||||||
department Department? @relation(fields: [departmentId], references: [id], onDelete: SetNull)
|
creator User @relation("PatientCreator", fields: [creatorId], references: [id])
|
||||||
/// 小组关系。
|
surgeries PatientSurgery[]
|
||||||
medicalGroup MedicalGroup? @relation(fields: [medicalGroupId], references: [id], onDelete: SetNull)
|
devices Device[]
|
||||||
/// 负责医生关系。
|
|
||||||
doctor User @relation("DoctorPatients", fields: [doctorId], references: [id], onDelete: Restrict)
|
|
||||||
/// 创建时间。
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
/// 更新时间。
|
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
|
||||||
|
|
||||||
/// 医生索引,便于查“医生名下患者”。
|
@@index([phone, idCard])
|
||||||
@@index([doctorId])
|
@@index([hospitalId, doctorId])
|
||||||
/// 医院索引,便于查“全院患者”。
|
@@index([inpatientNo])
|
||||||
@@index([hospitalId])
|
@@index([creatorId])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 工程师任务分配关系:只有系统管理员可以分配工程师到医院。
|
// 患者手术表:保存每次分流/复手术档案。
|
||||||
model EngineerHospitalAssignment {
|
model PatientSurgery {
|
||||||
/// 主键 ID。
|
id Int @id @default(autoincrement())
|
||||||
id Int @id @default(autoincrement())
|
patientId Int
|
||||||
/// 医院 ID。
|
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
|
hospitalId Int
|
||||||
/// 工程师用户 ID。
|
creatorId Int
|
||||||
engineerId Int
|
type UploadAssetType
|
||||||
/// 分配人(系统管理员)用户 ID。
|
originalName String
|
||||||
assignedById Int
|
fileName String
|
||||||
/// 医院关系。
|
storagePath String @unique
|
||||||
hospital Hospital @relation(fields: [hospitalId], references: [id], onDelete: Cascade)
|
url String
|
||||||
/// 工程师关系。
|
mimeType String
|
||||||
engineer User @relation("EngineerAssignments", fields: [engineerId], references: [id], onDelete: Restrict)
|
fileSize Int
|
||||||
/// 分配人关系。
|
createdAt DateTime @default(now())
|
||||||
assignedBy User @relation("SystemAdminAssignments", fields: [assignedById], references: [id], onDelete: Restrict)
|
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
||||||
/// 创建时间。
|
creator User @relation("UploadCreator", fields: [creatorId], references: [id])
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
/// 同一医院与工程师不能重复分配。
|
@@index([hospitalId, type, createdAt])
|
||||||
@@unique([hospitalId, engineerId])
|
@@index([creatorId, createdAt])
|
||||||
/// 工程师索引。
|
}
|
||||||
@@index([engineerId])
|
|
||||||
/// 分配人索引。
|
// 设备表:每次手术植入的设备实例,保留当前压力与历史调压记录。
|
||||||
@@index([assignedById])
|
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
843
prisma/seed.mjs
Normal 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();
|
||||||
|
});
|
||||||
301
prisma/seed.ts
301
prisma/seed.ts
@ -1,301 +0,0 @@
|
|||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
|
||||||
import { randomBytes, scrypt } from 'node:crypto';
|
|
||||||
import { promisify } from 'node:util';
|
|
||||||
import { PrismaClient } from '../src/generated/prisma/client.js';
|
|
||||||
import { UserRole } from '../src/generated/prisma/enums.js';
|
|
||||||
|
|
||||||
const scryptAsync = promisify(scrypt);
|
|
||||||
|
|
||||||
const TEST_PASSWORD = 'Test123456';
|
|
||||||
const HOSPITAL_CODE = 'DEMO_HOSP_001';
|
|
||||||
const HOSPITAL_NAME = 'Demo Hospital';
|
|
||||||
const DEPARTMENT_NAME = 'Cardiology';
|
|
||||||
const MEDICAL_GROUP_NAME = 'Group A';
|
|
||||||
|
|
||||||
interface SeedUserInput {
|
|
||||||
phone: string;
|
|
||||||
name: string;
|
|
||||||
role: UserRole;
|
|
||||||
hospitalId: number | null;
|
|
||||||
departmentId: number | null;
|
|
||||||
medicalGroupId: number | null;
|
|
||||||
managerId: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PatientInput {
|
|
||||||
name: string;
|
|
||||||
hospitalId: number;
|
|
||||||
departmentId: number | null;
|
|
||||||
medicalGroupId: number | null;
|
|
||||||
doctorId: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requireDatabaseUrl(): string {
|
|
||||||
const url = process.env.DATABASE_URL;
|
|
||||||
if (!url) {
|
|
||||||
throw new Error('DATABASE_URL is required. Run with `node --env-file=.env ...`.');
|
|
||||||
}
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hashPassword(password: string): Promise<string> {
|
|
||||||
const salt = randomBytes(16).toString('hex');
|
|
||||||
const derivedKey = (await scryptAsync(password, salt, 64)) as Buffer;
|
|
||||||
return `${salt}:${derivedKey.toString('hex')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function upsertUser(
|
|
||||||
prisma: PrismaClient,
|
|
||||||
user: SeedUserInput,
|
|
||||||
passwordHash: string,
|
|
||||||
) {
|
|
||||||
return prisma.user.upsert({
|
|
||||||
where: { phone: user.phone },
|
|
||||||
create: {
|
|
||||||
phone: user.phone,
|
|
||||||
passwordHash,
|
|
||||||
name: user.name,
|
|
||||||
role: user.role,
|
|
||||||
hospitalId: user.hospitalId,
|
|
||||||
departmentId: user.departmentId,
|
|
||||||
medicalGroupId: user.medicalGroupId,
|
|
||||||
managerId: user.managerId,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
passwordHash,
|
|
||||||
name: user.name,
|
|
||||||
role: user.role,
|
|
||||||
hospitalId: user.hospitalId,
|
|
||||||
departmentId: user.departmentId,
|
|
||||||
medicalGroupId: user.medicalGroupId,
|
|
||||||
managerId: user.managerId,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
role: true,
|
|
||||||
phone: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function upsertPatientByNaturalKey(
|
|
||||||
prisma: PrismaClient,
|
|
||||||
patient: PatientInput,
|
|
||||||
) {
|
|
||||||
const existing = await prisma.patient.findFirst({
|
|
||||||
where: {
|
|
||||||
name: patient.name,
|
|
||||||
hospitalId: patient.hospitalId,
|
|
||||||
doctorId: patient.doctorId,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
return prisma.patient.update({
|
|
||||||
where: { id: existing.id },
|
|
||||||
data: {
|
|
||||||
departmentId: patient.departmentId,
|
|
||||||
medicalGroupId: patient.medicalGroupId,
|
|
||||||
},
|
|
||||||
select: { id: true, name: true },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return prisma.patient.create({
|
|
||||||
data: patient,
|
|
||||||
select: { id: true, name: true },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const adapter = new PrismaPg({ connectionString: requireDatabaseUrl() });
|
|
||||||
const prisma = new PrismaClient({ adapter });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const passwordHash = await hashPassword(TEST_PASSWORD);
|
|
||||||
|
|
||||||
const hospital = await prisma.hospital.upsert({
|
|
||||||
where: { code: HOSPITAL_CODE },
|
|
||||||
create: {
|
|
||||||
code: HOSPITAL_CODE,
|
|
||||||
name: HOSPITAL_NAME,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
name: HOSPITAL_NAME,
|
|
||||||
},
|
|
||||||
select: { id: true, name: true, code: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const department = await prisma.department.upsert({
|
|
||||||
where: {
|
|
||||||
hospitalId_name: {
|
|
||||||
hospitalId: hospital.id,
|
|
||||||
name: DEPARTMENT_NAME,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
name: DEPARTMENT_NAME,
|
|
||||||
hospitalId: hospital.id,
|
|
||||||
},
|
|
||||||
update: {},
|
|
||||||
select: { id: true, name: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const medicalGroup = await prisma.medicalGroup.upsert({
|
|
||||||
where: {
|
|
||||||
departmentId_name: {
|
|
||||||
departmentId: department.id,
|
|
||||||
name: MEDICAL_GROUP_NAME,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
name: MEDICAL_GROUP_NAME,
|
|
||||||
departmentId: department.id,
|
|
||||||
},
|
|
||||||
update: {},
|
|
||||||
select: { id: true, name: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const systemAdmin = await upsertUser(
|
|
||||||
prisma,
|
|
||||||
{
|
|
||||||
phone: '+8613800000001',
|
|
||||||
name: 'System Admin',
|
|
||||||
role: UserRole.SYSTEM_ADMIN,
|
|
||||||
hospitalId: null,
|
|
||||||
departmentId: null,
|
|
||||||
medicalGroupId: null,
|
|
||||||
managerId: null,
|
|
||||||
},
|
|
||||||
passwordHash,
|
|
||||||
);
|
|
||||||
|
|
||||||
const hospitalAdmin = await upsertUser(
|
|
||||||
prisma,
|
|
||||||
{
|
|
||||||
phone: '+8613800000002',
|
|
||||||
name: 'Hospital Admin',
|
|
||||||
role: UserRole.HOSPITAL_ADMIN,
|
|
||||||
hospitalId: hospital.id,
|
|
||||||
departmentId: null,
|
|
||||||
medicalGroupId: null,
|
|
||||||
managerId: null,
|
|
||||||
},
|
|
||||||
passwordHash,
|
|
||||||
);
|
|
||||||
|
|
||||||
const director = await upsertUser(
|
|
||||||
prisma,
|
|
||||||
{
|
|
||||||
phone: '+8613800000003',
|
|
||||||
name: 'Director',
|
|
||||||
role: UserRole.DIRECTOR,
|
|
||||||
hospitalId: hospital.id,
|
|
||||||
departmentId: department.id,
|
|
||||||
medicalGroupId: null,
|
|
||||||
managerId: hospitalAdmin.id,
|
|
||||||
},
|
|
||||||
passwordHash,
|
|
||||||
);
|
|
||||||
|
|
||||||
const teamLead = await upsertUser(
|
|
||||||
prisma,
|
|
||||||
{
|
|
||||||
phone: '+8613800000004',
|
|
||||||
name: 'Team Lead',
|
|
||||||
role: UserRole.TEAM_LEAD,
|
|
||||||
hospitalId: hospital.id,
|
|
||||||
departmentId: department.id,
|
|
||||||
medicalGroupId: medicalGroup.id,
|
|
||||||
managerId: director.id,
|
|
||||||
},
|
|
||||||
passwordHash,
|
|
||||||
);
|
|
||||||
|
|
||||||
const doctor = await upsertUser(
|
|
||||||
prisma,
|
|
||||||
{
|
|
||||||
phone: '+8613800000005',
|
|
||||||
name: 'Doctor',
|
|
||||||
role: UserRole.DOCTOR,
|
|
||||||
hospitalId: hospital.id,
|
|
||||||
departmentId: department.id,
|
|
||||||
medicalGroupId: medicalGroup.id,
|
|
||||||
managerId: teamLead.id,
|
|
||||||
},
|
|
||||||
passwordHash,
|
|
||||||
);
|
|
||||||
|
|
||||||
const engineer = await upsertUser(
|
|
||||||
prisma,
|
|
||||||
{
|
|
||||||
phone: '+8613800000006',
|
|
||||||
name: 'Engineer',
|
|
||||||
role: UserRole.ENGINEER,
|
|
||||||
hospitalId: hospital.id,
|
|
||||||
departmentId: null,
|
|
||||||
medicalGroupId: null,
|
|
||||||
managerId: hospitalAdmin.id,
|
|
||||||
},
|
|
||||||
passwordHash,
|
|
||||||
);
|
|
||||||
|
|
||||||
await upsertPatientByNaturalKey(prisma, {
|
|
||||||
name: 'Patient Alpha',
|
|
||||||
hospitalId: hospital.id,
|
|
||||||
departmentId: department.id,
|
|
||||||
medicalGroupId: medicalGroup.id,
|
|
||||||
doctorId: doctor.id,
|
|
||||||
});
|
|
||||||
await upsertPatientByNaturalKey(prisma, {
|
|
||||||
name: 'Patient Beta',
|
|
||||||
hospitalId: hospital.id,
|
|
||||||
departmentId: department.id,
|
|
||||||
medicalGroupId: medicalGroup.id,
|
|
||||||
doctorId: doctor.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.engineerHospitalAssignment.upsert({
|
|
||||||
where: {
|
|
||||||
hospitalId_engineerId: {
|
|
||||||
hospitalId: hospital.id,
|
|
||||||
engineerId: engineer.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
hospitalId: hospital.id,
|
|
||||||
engineerId: engineer.id,
|
|
||||||
assignedById: systemAdmin.id,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
assignedById: systemAdmin.id,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Seed completed for hospital ${hospital.code} (${hospital.name}).`);
|
|
||||||
console.log('Test password for all seeded users:', TEST_PASSWORD);
|
|
||||||
console.table(
|
|
||||||
[systemAdmin, hospitalAdmin, director, teamLead, doctor, engineer].map(
|
|
||||||
(user) => ({
|
|
||||||
role: user.role,
|
|
||||||
phone: user.phone,
|
|
||||||
name: user.name ?? '',
|
|
||||||
password: TEST_PASSWORD,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
console.log('No mini-program or official-account openId was pre-seeded.');
|
|
||||||
} finally {
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((error) => {
|
|
||||||
console.error('Seed failed:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,10 +1,47 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
import { AuthModule } from './auth/auth.module.js';
|
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 { 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({
|
@Module({
|
||||||
// ConfigModule 先加载,保证鉴权和数据库都可读取环境变量。
|
imports: [
|
||||||
imports: [ConfigModule.forRoot(), AuthModule, UsersModule],
|
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 {}
|
export class AppModule {}
|
||||||
|
|||||||
113
src/auth/access-token.guard.ts
Normal file
113
src/auth/access-token.guard.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,70 +1,80 @@
|
|||||||
import { Body, Controller, Get, Post } from '@nestjs/common';
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
import { CurrentUser } from './decorators/current-user.decorator.js';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { Public } from './decorators/public.decorator.js';
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { AuthService } from './auth.service.js';
|
import { AuthService } from './auth.service.js';
|
||||||
import { BindWechatDto } from './dto/bind-wechat.dto.js';
|
import { AccessTokenGuard } from './access-token.guard.js';
|
||||||
import { ChangePasswordDto } from './dto/change-password.dto.js';
|
import { CurrentActor } from './current-actor.decorator.js';
|
||||||
import { LoginMiniProgramDto } from './dto/login-mini-program.dto.js';
|
import type { ActorContext } from '../common/actor-context.js';
|
||||||
import { LoginOfficialAccountDto } from './dto/login-official-account.dto.js';
|
import { CreateSystemAdminDto } from './dto/create-system-admin.dto.js';
|
||||||
import { LoginPhoneDto } from './dto/login-phone.dto.js';
|
import { MiniappPhoneLoginDto } from './dto/miniapp-phone-login.dto.js';
|
||||||
import { RegisterDto } from './dto/register.dto.js';
|
import { MiniappPhoneLoginConfirmDto } from './dto/miniapp-phone-login-confirm.dto.js';
|
||||||
import type { AuthUser } from './types/auth-user.type.js';
|
import { PasswordLoginConfirmDto } from './dto/password-login-confirm.dto.js';
|
||||||
|
import { LoginDto } from '../users/dto/login.dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证控制器:提供系统管理员创建、登录、获取当前登录用户信息接口。
|
||||||
|
*/
|
||||||
|
@ApiTags('认证')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly authService: AuthService) {}
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
// 公开注册接口:手机号 + 密码。
|
/**
|
||||||
@Public()
|
* 创建系统管理员(需引导密钥)。
|
||||||
@Post('register')
|
*/
|
||||||
register(@Body() registerDto: RegisterDto) {
|
@Post('system-admin')
|
||||||
return this.authService.register(registerDto);
|
@Throttle({ default: { limit: 3, ttl: 60_000 } })
|
||||||
|
@ApiOperation({ summary: '创建系统管理员' })
|
||||||
|
createSystemAdmin(@Body() dto: CreateSystemAdminDto) {
|
||||||
|
return this.authService.createSystemAdmin(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 公开登录接口:手机号 + 密码。
|
/**
|
||||||
@Public()
|
* 院内账号密码登录:后台与小程序均可复用。
|
||||||
@Post('login/phone')
|
*/
|
||||||
loginWithPhone(@Body() loginPhoneDto: LoginPhoneDto) {
|
@Post('login')
|
||||||
return this.authService.loginWithPhone(loginPhoneDto);
|
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
||||||
|
@ApiOperation({ summary: '院内账号密码登录' })
|
||||||
|
login(@Body() dto: LoginDto) {
|
||||||
|
return this.authService.login(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 公开登录接口:小程序 openId。
|
@Post('login/confirm')
|
||||||
@Public()
|
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||||
@Post('login/mini-program')
|
@ApiOperation({ summary: '院内账号密码多账号确认登录' })
|
||||||
loginWithMiniProgram(@Body() loginMiniProgramDto: LoginMiniProgramDto) {
|
confirmLogin(@Body() dto: PasswordLoginConfirmDto) {
|
||||||
return this.authService.loginWithMiniProgram(loginMiniProgramDto);
|
return this.authService.confirmLogin(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 公开登录接口:服务号 openId。
|
@Post('miniapp/b/phone-login')
|
||||||
@Public()
|
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
||||||
@Post('login/official-account')
|
@ApiOperation({ summary: 'B 端小程序手机号登录' })
|
||||||
loginWithOfficialAccount(
|
miniAppBLogin(@Body() dto: MiniappPhoneLoginDto) {
|
||||||
@Body() loginOfficialAccountDto: LoginOfficialAccountDto,
|
return this.authService.miniAppBLogin(dto);
|
||||||
) {
|
|
||||||
return this.authService.loginWithOfficialAccount(loginOfficialAccountDto);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录后获取当前用户信息。
|
@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')
|
@Get('me')
|
||||||
me(@CurrentUser() currentUser: AuthUser) {
|
@UseGuards(AccessTokenGuard)
|
||||||
return this.authService.me(currentUser.id);
|
@ApiBearerAuth('bearer')
|
||||||
}
|
@ApiOperation({ summary: '获取当前用户信息' })
|
||||||
|
me(@CurrentActor() actor: ActorContext) {
|
||||||
// 登录后绑定小程序/服务号账号。
|
return this.authService.me(actor);
|
||||||
@Post('bind/wechat')
|
|
||||||
bindWechat(
|
|
||||||
@CurrentUser() currentUser: AuthUser,
|
|
||||||
@Body() bindWechatDto: BindWechatDto,
|
|
||||||
) {
|
|
||||||
return this.authService.bindWechat(currentUser.id, bindWechatDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 登录后修改密码。
|
|
||||||
@Post('change-password')
|
|
||||||
changePassword(
|
|
||||||
@CurrentUser() currentUser: AuthUser,
|
|
||||||
@Body() changePasswordDto: ChangePasswordDto,
|
|
||||||
) {
|
|
||||||
return this.authService.changePassword(currentUser.id, changePasswordDto);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,31 +1,18 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { APP_GUARD } from '@nestjs/core';
|
|
||||||
import { PrismaModule } from '../prisma.module.js';
|
|
||||||
import { AuthController } from './auth.controller.js';
|
|
||||||
import { AuthService } from './auth.service.js';
|
import { AuthService } from './auth.service.js';
|
||||||
import { PasswordService } from './password.service.js';
|
import { AuthController } from './auth.controller.js';
|
||||||
import { TokenService } from './token.service.js';
|
import { UsersModule } from '../users/users.module.js';
|
||||||
import { AuthGuard } from './guards/auth.guard.js';
|
import { AccessTokenGuard } from './access-token.guard.js';
|
||||||
import { RolesGuard } from './guards/roles.guard.js';
|
import { WechatMiniAppService } from './wechat-miniapp/wechat-miniapp.service.js';
|
||||||
|
import { MiniAppAuthService } from './miniapp-auth/miniapp-auth.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证模块:聚合认证控制器、服务与基础鉴权守卫。
|
||||||
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [UsersModule],
|
||||||
|
providers: [AuthService, AccessTokenGuard, WechatMiniAppService, MiniAppAuthService],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [
|
exports: [AuthService, AccessTokenGuard, WechatMiniAppService, MiniAppAuthService],
|
||||||
AuthService,
|
|
||||||
PasswordService,
|
|
||||||
TokenService,
|
|
||||||
// 全局鉴权:默认所有接口都需要登录,除非显式标记 @Public。
|
|
||||||
{
|
|
||||||
provide: APP_GUARD,
|
|
||||||
useClass: AuthGuard,
|
|
||||||
},
|
|
||||||
// 全局角色守卫:只有使用 @Roles 的接口才会进行角色判断。
|
|
||||||
{
|
|
||||||
provide: APP_GUARD,
|
|
||||||
useClass: RolesGuard,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
exports: [PasswordService],
|
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@ -1,290 +1,69 @@
|
|||||||
import {
|
import { Injectable } from '@nestjs/common';
|
||||||
ConflictException,
|
import type { ActorContext } from '../common/actor-context.js';
|
||||||
Injectable,
|
import { UsersService } from '../users/users.service.js';
|
||||||
NotFoundException,
|
import { LoginDto } from '../users/dto/login.dto.js';
|
||||||
UnauthorizedException,
|
import { CreateSystemAdminDto } from './dto/create-system-admin.dto.js';
|
||||||
} from '@nestjs/common';
|
import { MiniappPhoneLoginConfirmDto } from './dto/miniapp-phone-login-confirm.dto.js';
|
||||||
import { UserRole } from '../generated/prisma/enums.js';
|
import { MiniappPhoneLoginDto } from './dto/miniapp-phone-login.dto.js';
|
||||||
import { PrismaService } from '../prisma.service.js';
|
import { PasswordLoginConfirmDto } from './dto/password-login-confirm.dto.js';
|
||||||
import { BindWechatDto } from './dto/bind-wechat.dto.js';
|
import { MiniAppAuthService } from './miniapp-auth/miniapp-auth.service.js';
|
||||||
import { ChangePasswordDto } from './dto/change-password.dto.js';
|
|
||||||
import { LoginMiniProgramDto } from './dto/login-mini-program.dto.js';
|
|
||||||
import { LoginOfficialAccountDto } from './dto/login-official-account.dto.js';
|
|
||||||
import { LoginPhoneDto } from './dto/login-phone.dto.js';
|
|
||||||
import { RegisterDto } from './dto/register.dto.js';
|
|
||||||
import { PasswordService } from './password.service.js';
|
|
||||||
import { TokenService } from './token.service.js';
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证服务:将控制层输入转发到用户域能力,避免控制器直接操作用户仓储。
|
||||||
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
// 查询用户时统一选择字段,避免在多处重复定义。
|
|
||||||
private readonly userSelect = {
|
|
||||||
id: true,
|
|
||||||
phone: true,
|
|
||||||
name: true,
|
|
||||||
role: true,
|
|
||||||
hospitalId: true,
|
|
||||||
departmentId: true,
|
|
||||||
medicalGroupId: true,
|
|
||||||
managerId: true,
|
|
||||||
wechatMiniOpenId: true,
|
|
||||||
wechatOfficialOpenId: true,
|
|
||||||
isActive: true,
|
|
||||||
lastLoginAt: true,
|
|
||||||
createdAt: true,
|
|
||||||
updatedAt: true,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly usersService: UsersService,
|
||||||
private readonly passwordService: PasswordService,
|
private readonly miniAppAuthService: MiniAppAuthService,
|
||||||
private readonly tokenService: TokenService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async register(registerDto: RegisterDto) {
|
/**
|
||||||
// 自助注册只允许创建普通医生角色,防止越权注册管理员。
|
* 系统管理员创建能力委托给用户服务。
|
||||||
const existingUser = await this.prisma.user.findUnique({
|
*/
|
||||||
where: { phone: registerDto.phone },
|
createSystemAdmin(dto: CreateSystemAdminDto) {
|
||||||
select: { id: true },
|
return this.usersService.createSystemAdmin(dto);
|
||||||
});
|
|
||||||
if (existingUser) {
|
|
||||||
throw new ConflictException('手机号已注册');
|
|
||||||
}
|
|
||||||
|
|
||||||
const passwordHash = await this.passwordService.hashPassword(registerDto.password);
|
|
||||||
const user = await this.prisma.user.create({
|
|
||||||
data: {
|
|
||||||
phone: registerDto.phone,
|
|
||||||
passwordHash,
|
|
||||||
name: registerDto.name,
|
|
||||||
role: UserRole.DOCTOR,
|
|
||||||
hospitalId: registerDto.hospitalId,
|
|
||||||
departmentId: registerDto.departmentId,
|
|
||||||
medicalGroupId: registerDto.medicalGroupId,
|
|
||||||
managerId: registerDto.managerId,
|
|
||||||
wechatMiniOpenId: registerDto.wechatMiniOpenId,
|
|
||||||
wechatOfficialOpenId: registerDto.wechatOfficialOpenId,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
select: this.userSelect,
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.issueLoginResult(user);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loginWithPhone(loginPhoneDto: LoginPhoneDto) {
|
/**
|
||||||
// 手机号登录需要读出密码哈希并做安全校验。
|
* 院内账号密码登录。
|
||||||
const user = await this.prisma.user.findUnique({
|
*/
|
||||||
where: { phone: loginPhoneDto.phone },
|
login(dto: LoginDto) {
|
||||||
select: {
|
return this.usersService.login(dto);
|
||||||
id: true,
|
|
||||||
passwordHash: true,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!user || !user.isActive) {
|
|
||||||
throw new UnauthorizedException('手机号或密码错误');
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPasswordValid = await this.passwordService.verifyPassword(
|
|
||||||
loginPhoneDto.password,
|
|
||||||
user.passwordHash,
|
|
||||||
);
|
|
||||||
if (!isPasswordValid) {
|
|
||||||
throw new UnauthorizedException('手机号或密码错误');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.loginByUserId(user.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loginWithMiniProgram(loginMiniProgramDto: LoginMiniProgramDto) {
|
/**
|
||||||
// 小程序登录通过 miniOpenId 直连用户。
|
* 院内账号密码多账号确认登录。
|
||||||
const user = await this.prisma.user.findFirst({
|
*/
|
||||||
where: {
|
confirmLogin(dto: PasswordLoginConfirmDto) {
|
||||||
wechatMiniOpenId: loginMiniProgramDto.miniOpenId,
|
return this.usersService.confirmLogin(dto);
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (!user) {
|
|
||||||
throw new UnauthorizedException('小程序账号未绑定');
|
|
||||||
}
|
|
||||||
return this.loginByUserId(user.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async loginWithOfficialAccount(loginOfficialAccountDto: LoginOfficialAccountDto) {
|
/**
|
||||||
// 服务号登录通过 officialOpenId 直连用户。
|
* B 端小程序手机号登录。
|
||||||
const user = await this.prisma.user.findFirst({
|
*/
|
||||||
where: {
|
miniAppBLogin(dto: MiniappPhoneLoginDto) {
|
||||||
wechatOfficialOpenId: loginOfficialAccountDto.officialOpenId,
|
return this.miniAppAuthService.loginForB(dto);
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (!user) {
|
|
||||||
throw new UnauthorizedException('服务号账号未绑定');
|
|
||||||
}
|
|
||||||
return this.loginByUserId(user.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async bindWechat(userId: number, bindWechatDto: BindWechatDto) {
|
/**
|
||||||
// 绑定之前先做冲突检查,确保一个 openId 只归属一个用户。
|
* B 端小程序多账号确认登录。
|
||||||
if (bindWechatDto.miniOpenId) {
|
*/
|
||||||
const existingMini = await this.prisma.user.findFirst({
|
miniAppBConfirmLogin(dto: MiniappPhoneLoginConfirmDto) {
|
||||||
where: {
|
return this.miniAppAuthService.confirmLoginForB(dto);
|
||||||
wechatMiniOpenId: bindWechatDto.miniOpenId,
|
|
||||||
NOT: { id: userId },
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (existingMini) {
|
|
||||||
throw new ConflictException('小程序账号已被其他用户绑定');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bindWechatDto.officialOpenId) {
|
|
||||||
const existingOfficial = await this.prisma.user.findFirst({
|
|
||||||
where: {
|
|
||||||
wechatOfficialOpenId: bindWechatDto.officialOpenId,
|
|
||||||
NOT: { id: userId },
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (existingOfficial) {
|
|
||||||
throw new ConflictException('服务号账号已被其他用户绑定');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedUser = await this.prisma.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: {
|
|
||||||
wechatMiniOpenId: bindWechatDto.miniOpenId ?? undefined,
|
|
||||||
wechatOfficialOpenId: bindWechatDto.officialOpenId ?? undefined,
|
|
||||||
},
|
|
||||||
select: this.userSelect,
|
|
||||||
});
|
|
||||||
return this.toUserView(updatedUser);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async changePassword(userId: number, changePasswordDto: ChangePasswordDto) {
|
/**
|
||||||
// 改密必须验证旧密码,防止被盗登录态直接改密。
|
* C 端小程序手机号登录。
|
||||||
const user = await this.prisma.user.findUnique({
|
*/
|
||||||
where: { id: userId },
|
miniAppCLogin(dto: MiniappPhoneLoginDto) {
|
||||||
select: {
|
return this.miniAppAuthService.loginForC(dto);
|
||||||
id: true,
|
|
||||||
isActive: true,
|
|
||||||
passwordHash: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!user || !user.isActive) {
|
|
||||||
throw new NotFoundException('用户不存在');
|
|
||||||
}
|
|
||||||
|
|
||||||
const isOldPasswordValid = await this.passwordService.verifyPassword(
|
|
||||||
changePasswordDto.oldPassword,
|
|
||||||
user.passwordHash,
|
|
||||||
);
|
|
||||||
if (!isOldPasswordValid) {
|
|
||||||
throw new UnauthorizedException('旧密码不正确');
|
|
||||||
}
|
|
||||||
|
|
||||||
const newPasswordHash = await this.passwordService.hashPassword(
|
|
||||||
changePasswordDto.newPassword,
|
|
||||||
);
|
|
||||||
await this.prisma.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: { passwordHash: newPasswordHash },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
return { message: '密码修改成功' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async me(userId: number) {
|
/**
|
||||||
// 读取当前用户公开信息,不返回密码和 openId 明文。
|
* 读取当前登录用户详情。
|
||||||
const user = await this.prisma.user.findUnique({
|
*/
|
||||||
where: { id: userId },
|
me(actor: ActorContext) {
|
||||||
select: this.userSelect,
|
return this.usersService.me(actor);
|
||||||
});
|
|
||||||
if (!user) {
|
|
||||||
throw new NotFoundException('用户不存在');
|
|
||||||
}
|
|
||||||
return this.toUserView(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loginByUserId(userId: number) {
|
|
||||||
// 登录成功后更新最后登录时间,便于安全审计。
|
|
||||||
const user = await this.prisma.user.update({
|
|
||||||
where: { id: userId },
|
|
||||||
data: { lastLoginAt: new Date() },
|
|
||||||
select: this.userSelect,
|
|
||||||
});
|
|
||||||
return this.issueLoginResult(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
private issueLoginResult(user: {
|
|
||||||
id: number;
|
|
||||||
role: UserRole;
|
|
||||||
hospitalId: number | null;
|
|
||||||
wechatMiniOpenId: string | null;
|
|
||||||
wechatOfficialOpenId: string | null;
|
|
||||||
phone: string;
|
|
||||||
name: string | null;
|
|
||||||
departmentId: number | null;
|
|
||||||
medicalGroupId: number | null;
|
|
||||||
managerId: number | null;
|
|
||||||
isActive: boolean;
|
|
||||||
lastLoginAt: Date | null;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}) {
|
|
||||||
// token 中保留最小必要信息,其余数据走数据库校验。
|
|
||||||
const accessToken = this.tokenService.sign({
|
|
||||||
sub: user.id,
|
|
||||||
role: user.role,
|
|
||||||
hospitalId: user.hospitalId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
tokenType: 'Bearer',
|
|
||||||
accessToken,
|
|
||||||
expiresIn: this.tokenService.expiresInSeconds,
|
|
||||||
user: this.toUserView(user),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private toUserView(user: {
|
|
||||||
id: number;
|
|
||||||
phone: string;
|
|
||||||
name: string | null;
|
|
||||||
role: UserRole;
|
|
||||||
hospitalId: number | null;
|
|
||||||
departmentId: number | null;
|
|
||||||
medicalGroupId: number | null;
|
|
||||||
managerId: number | null;
|
|
||||||
wechatMiniOpenId: string | null;
|
|
||||||
wechatOfficialOpenId: string | null;
|
|
||||||
isActive: boolean;
|
|
||||||
lastLoginAt: Date | null;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}) {
|
|
||||||
// 输出层做脱敏:不回传 openId 原文,只回传是否已绑定。
|
|
||||||
return {
|
|
||||||
id: user.id,
|
|
||||||
phone: user.phone,
|
|
||||||
name: user.name,
|
|
||||||
role: user.role,
|
|
||||||
hospitalId: user.hospitalId,
|
|
||||||
departmentId: user.departmentId,
|
|
||||||
medicalGroupId: user.medicalGroupId,
|
|
||||||
managerId: user.managerId,
|
|
||||||
isActive: user.isActive,
|
|
||||||
lastLoginAt: user.lastLoginAt,
|
|
||||||
wechatMiniLinked: Boolean(user.wechatMiniOpenId),
|
|
||||||
wechatOfficialLinked: Boolean(user.wechatOfficialOpenId),
|
|
||||||
createdAt: user.createdAt,
|
|
||||||
updatedAt: user.updatedAt,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,2 +0,0 @@
|
|||||||
export const IS_PUBLIC_KEY = 'isPublic';
|
|
||||||
export const ROLES_KEY = 'roles';
|
|
||||||
14
src/auth/current-actor.decorator.ts
Normal file
14
src/auth/current-actor.decorator.ts
Normal 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;
|
||||||
|
},
|
||||||
|
);
|
||||||
14
src/auth/current-family-actor.decorator.ts
Normal file
14
src/auth/current-family-actor.decorator.ts
Normal 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;
|
||||||
|
},
|
||||||
|
);
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
|
||||||
import type { AuthUser } from '../types/auth-user.type.js';
|
|
||||||
|
|
||||||
export const CurrentUser = createParamDecorator(
|
|
||||||
(_data: unknown, ctx: ExecutionContext): AuthUser => {
|
|
||||||
const request = ctx
|
|
||||||
.switchToHttp()
|
|
||||||
.getRequest<{ user: AuthUser }>();
|
|
||||||
return request.user;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
import { SetMetadata } from '@nestjs/common';
|
|
||||||
import { IS_PUBLIC_KEY } from '../constants.js';
|
|
||||||
|
|
||||||
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
import { SetMetadata } from '@nestjs/common';
|
|
||||||
import type { UserRole } from '../../generated/prisma/enums.js';
|
|
||||||
import { ROLES_KEY } from '../constants.js';
|
|
||||||
|
|
||||||
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
import { IsOptional, IsString, MaxLength, MinLength, ValidateIf } from 'class-validator';
|
|
||||||
|
|
||||||
export class BindWechatDto {
|
|
||||||
// 两个字段至少传一个:如果未传 officialOpenId,则 miniOpenId 必填。
|
|
||||||
@ValidateIf((o: BindWechatDto) => !o.officialOpenId)
|
|
||||||
@IsString()
|
|
||||||
@MinLength(6)
|
|
||||||
@MaxLength(128)
|
|
||||||
@IsOptional()
|
|
||||||
miniOpenId?: string;
|
|
||||||
|
|
||||||
// 两个字段至少传一个:如果未传 miniOpenId,则 officialOpenId 必填。
|
|
||||||
@ValidateIf((o: BindWechatDto) => !o.miniOpenId)
|
|
||||||
@IsString()
|
|
||||||
@MinLength(6)
|
|
||||||
@MaxLength(128)
|
|
||||||
@IsOptional()
|
|
||||||
officialOpenId?: string;
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { Matches } from 'class-validator';
|
|
||||||
|
|
||||||
export class ChangePasswordDto {
|
|
||||||
// 老密码用于确认操作者身份。
|
|
||||||
@Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, {
|
|
||||||
message: '密码至少8位,且包含字母和数字',
|
|
||||||
})
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Current password of the account.',
|
|
||||||
example: 'Test123456',
|
|
||||||
})
|
|
||||||
oldPassword: string;
|
|
||||||
|
|
||||||
// 新密码使用与注册一致的安全策略。
|
|
||||||
@Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, {
|
|
||||||
message: '密码至少8位,且包含字母和数字',
|
|
||||||
})
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'New password, 8-64 chars with letters and numbers.',
|
|
||||||
example: 'NewTest123456',
|
|
||||||
})
|
|
||||||
newPassword: string;
|
|
||||||
}
|
|
||||||
32
src/auth/dto/create-system-admin.dto.ts
Normal file
32
src/auth/dto/create-system-admin.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
|
||||||
|
|
||||||
export class LoginMiniProgramDto {
|
|
||||||
// 小程序 openId 由前端/网关在登录态中传入。
|
|
||||||
@IsString()
|
|
||||||
@MinLength(6)
|
|
||||||
@MaxLength(128)
|
|
||||||
miniOpenId: string;
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import { IsString, MaxLength, MinLength } from 'class-validator';
|
|
||||||
|
|
||||||
export class LoginOfficialAccountDto {
|
|
||||||
// 服务号 openId 由前端/网关在登录态中传入。
|
|
||||||
@IsString()
|
|
||||||
@MinLength(6)
|
|
||||||
@MaxLength(128)
|
|
||||||
officialOpenId: string;
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { Matches } from 'class-validator';
|
|
||||||
|
|
||||||
export class LoginPhoneDto {
|
|
||||||
// 手机号登录入口字段。
|
|
||||||
@Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' })
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Login phone number in E.164 format.',
|
|
||||||
example: '+8613800138000',
|
|
||||||
})
|
|
||||||
phone: string;
|
|
||||||
|
|
||||||
// 登录密码,规则与注册保持一致。
|
|
||||||
@Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, {
|
|
||||||
message: '密码至少8位,且包含字母和数字',
|
|
||||||
})
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Password must be 8-64 chars and include letters and numbers.',
|
|
||||||
example: 'Test123456',
|
|
||||||
})
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
24
src/auth/dto/miniapp-phone-login-confirm.dto.ts
Normal file
24
src/auth/dto/miniapp-phone-login-confirm.dto.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsInt, IsString, Min } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B 端多账号确认登录 DTO。
|
||||||
|
*/
|
||||||
|
export class MiniappPhoneLoginConfirmDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'B 端候选账号选择票据',
|
||||||
|
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
})
|
||||||
|
@IsString({ message: 'loginTicket 必须是字符串' })
|
||||||
|
loginTicket!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '确认登录的用户 ID',
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'userId 必须是整数' })
|
||||||
|
@Min(1, { message: 'userId 必须大于 0' })
|
||||||
|
userId!: number;
|
||||||
|
}
|
||||||
21
src/auth/dto/miniapp-phone-login.dto.ts
Normal file
21
src/auth/dto/miniapp-phone-login.dto.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小程序手机号登录 DTO。
|
||||||
|
*/
|
||||||
|
export class MiniappPhoneLoginDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: '微信登录 code,用于换取 openId',
|
||||||
|
example: '08123456789abcdef',
|
||||||
|
})
|
||||||
|
@IsString({ message: 'loginCode 必须是字符串' })
|
||||||
|
loginCode!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '微信手机号授权 code,用于换取手机号',
|
||||||
|
example: '12ab34cd56ef78gh',
|
||||||
|
})
|
||||||
|
@IsString({ message: 'phoneCode 必须是字符串' })
|
||||||
|
phoneCode!: string;
|
||||||
|
}
|
||||||
24
src/auth/dto/password-login-confirm.dto.ts
Normal file
24
src/auth/dto/password-login-confirm.dto.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsInt, IsString, Min } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 院内账号密码登录确认 DTO。
|
||||||
|
*/
|
||||||
|
export class PasswordLoginConfirmDto {
|
||||||
|
@ApiProperty({
|
||||||
|
description: '密码登录候选账号票据',
|
||||||
|
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9',
|
||||||
|
})
|
||||||
|
@IsString({ message: 'loginTicket 必须是字符串' })
|
||||||
|
loginTicket!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: '最终确认登录的用户 ID',
|
||||||
|
example: 1,
|
||||||
|
})
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'userId 必须是整数' })
|
||||||
|
@Min(1, { message: 'userId 必须大于 0' })
|
||||||
|
userId!: number;
|
||||||
|
}
|
||||||
@ -1,99 +0,0 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
import {
|
|
||||||
IsInt,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
Matches,
|
|
||||||
MaxLength,
|
|
||||||
Min,
|
|
||||||
} from 'class-validator';
|
|
||||||
|
|
||||||
export class RegisterDto {
|
|
||||||
// 手机号是主登录标识。
|
|
||||||
@Matches(/^\+?[1-9]\d{7,14}$/, { message: '手机号格式不正确' })
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Phone number used as login account (E.164 format).',
|
|
||||||
example: '+8613800138000',
|
|
||||||
})
|
|
||||||
phone: string;
|
|
||||||
|
|
||||||
// 注册密码强度策略。
|
|
||||||
@Matches(/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, {
|
|
||||||
message: '密码至少8位,且包含字母和数字',
|
|
||||||
})
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Password must be 8-64 chars and include letters and numbers.',
|
|
||||||
example: 'Test123456',
|
|
||||||
})
|
|
||||||
password: string;
|
|
||||||
|
|
||||||
// 个人展示名称。
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
@MaxLength(64)
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Display name.',
|
|
||||||
example: 'Demo Doctor',
|
|
||||||
})
|
|
||||||
name?: string;
|
|
||||||
|
|
||||||
// 组织归属:医院。
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
@IsOptional()
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Hospital id to bind during registration.',
|
|
||||||
example: 1,
|
|
||||||
})
|
|
||||||
hospitalId?: number;
|
|
||||||
|
|
||||||
// 组织归属:科室。
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
@IsOptional()
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Department id to bind during registration.',
|
|
||||||
example: 1,
|
|
||||||
})
|
|
||||||
departmentId?: number;
|
|
||||||
|
|
||||||
// 组织归属:小组。
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
@IsOptional()
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Medical group id to bind during registration.',
|
|
||||||
example: 1,
|
|
||||||
})
|
|
||||||
medicalGroupId?: number;
|
|
||||||
|
|
||||||
// 直属上级用户 ID。
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
@IsOptional()
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Direct manager user id.',
|
|
||||||
example: 2,
|
|
||||||
})
|
|
||||||
managerId?: number;
|
|
||||||
|
|
||||||
// 可选:注册时直接绑定小程序账号。
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
@MaxLength(128)
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Optional mini-program openId.',
|
|
||||||
example: 'mini_open_id_xxx',
|
|
||||||
})
|
|
||||||
wechatMiniOpenId?: string;
|
|
||||||
|
|
||||||
// 可选:注册时直接绑定服务号账号。
|
|
||||||
@IsString()
|
|
||||||
@IsOptional()
|
|
||||||
@MaxLength(128)
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Optional official-account openId.',
|
|
||||||
example: 'official_open_id_xxx',
|
|
||||||
})
|
|
||||||
wechatOfficialOpenId?: string;
|
|
||||||
}
|
|
||||||
84
src/auth/family-access/family-access.guard.ts
Normal file
84
src/auth/family-access/family-access.guard.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,84 +0,0 @@
|
|||||||
import {
|
|
||||||
CanActivate,
|
|
||||||
ExecutionContext,
|
|
||||||
Injectable,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
|
||||||
import { Request } from 'express';
|
|
||||||
import { PrismaService } from '../../prisma.service.js';
|
|
||||||
import { IS_PUBLIC_KEY } from '../constants.js';
|
|
||||||
import { TokenService } from '../token.service.js';
|
|
||||||
import type { AuthUser } from '../types/auth-user.type.js';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class AuthGuard implements CanActivate {
|
|
||||||
constructor(
|
|
||||||
private readonly reflector: Reflector,
|
|
||||||
private readonly tokenService: TokenService,
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
// 被 @Public 标记的接口直接放行。
|
|
||||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
|
||||||
context.getHandler(),
|
|
||||||
context.getClass(),
|
|
||||||
]);
|
|
||||||
if (isPublic) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 读取 Authorization: Bearer <token>。
|
|
||||||
const request = context
|
|
||||||
.switchToHttp()
|
|
||||||
.getRequest<Request & { user?: AuthUser }>();
|
|
||||||
const token = this.extractBearerToken(request.headers.authorization);
|
|
||||||
if (!token) {
|
|
||||||
throw new UnauthorizedException('未登录');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验签成功后仍需到数据库核验账号状态。
|
|
||||||
const payload = this.tokenService.verify(token);
|
|
||||||
const user = await this.prisma.user.findUnique({
|
|
||||||
where: { id: payload.sub },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
role: true,
|
|
||||||
hospitalId: true,
|
|
||||||
departmentId: true,
|
|
||||||
medicalGroupId: true,
|
|
||||||
managerId: true,
|
|
||||||
isActive: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
|
||||||
throw new UnauthorizedException('账号不可用');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 把当前用户挂载到 request,供后续 decorator/业务层使用。
|
|
||||||
request.user = {
|
|
||||||
id: user.id,
|
|
||||||
role: user.role,
|
|
||||||
hospitalId: user.hospitalId,
|
|
||||||
departmentId: user.departmentId,
|
|
||||||
medicalGroupId: user.medicalGroupId,
|
|
||||||
managerId: user.managerId,
|
|
||||||
};
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private extractBearerToken(header?: string): string | null {
|
|
||||||
// Header 不存在直接视为未登录。
|
|
||||||
if (!header) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [type, token] = header.split(' ');
|
|
||||||
if (type !== 'Bearer' || !token) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
import {
|
|
||||||
CanActivate,
|
|
||||||
ExecutionContext,
|
|
||||||
ForbiddenException,
|
|
||||||
Injectable,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
|
||||||
import type { UserRole } from '../../generated/prisma/enums.js';
|
|
||||||
import { ROLES_KEY } from '../constants.js';
|
|
||||||
import type { AuthUser } from '../types/auth-user.type.js';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RolesGuard implements CanActivate {
|
|
||||||
constructor(private readonly reflector: Reflector) {}
|
|
||||||
|
|
||||||
canActivate(context: ExecutionContext): boolean {
|
|
||||||
// 未声明 @Roles 的接口不做角色限制。
|
|
||||||
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
|
|
||||||
ROLES_KEY,
|
|
||||||
[context.getHandler(), context.getClass()],
|
|
||||||
);
|
|
||||||
if (!requiredRoles || requiredRoles.length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 角色守卫依赖 AuthGuard 注入 request.user。
|
|
||||||
const request = context
|
|
||||||
.switchToHttp()
|
|
||||||
.getRequest<{ user?: AuthUser }>();
|
|
||||||
const user = request.user;
|
|
||||||
if (!user) {
|
|
||||||
throw new UnauthorizedException('未登录');
|
|
||||||
}
|
|
||||||
// 当前角色不在白名单则拒绝访问。
|
|
||||||
if (!requiredRoles.includes(user.role)) {
|
|
||||||
throw new ForbiddenException('权限不足');
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
277
src/auth/miniapp-auth/miniapp-auth.service.ts
Normal file
277
src/auth/miniapp-auth/miniapp-auth.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,33 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { randomBytes, scrypt, timingSafeEqual } from 'crypto';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PasswordService {
|
|
||||||
// 使用 Node.js 原生 scrypt,避免引入额外原生依赖。
|
|
||||||
private readonly scryptAsync = promisify(scrypt);
|
|
||||||
|
|
||||||
async hashPassword(password: string): Promise<string> {
|
|
||||||
// 每个密码生成独立盐值,抵抗彩虹表攻击。
|
|
||||||
const salt = randomBytes(16).toString('hex');
|
|
||||||
const derivedKey = (await this.scryptAsync(password, salt, 64)) as Buffer;
|
|
||||||
// 持久化格式:salt:hash。
|
|
||||||
return `${salt}:${derivedKey.toString('hex')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async verifyPassword(password: string, hashedPassword: string): Promise<boolean> {
|
|
||||||
// 从数据库格式中拆出盐值与哈希。
|
|
||||||
const [salt, keyHex] = hashedPassword.split(':');
|
|
||||||
if (!salt || !keyHex) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用同样参数重新推导哈希并做常量时间比较。
|
|
||||||
const derivedKey = (await this.scryptAsync(password, salt, 64)) as Buffer;
|
|
||||||
const storedKey = Buffer.from(keyHex, 'hex');
|
|
||||||
if (storedKey.length !== derivedKey.length) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return timingSafeEqual(storedKey, derivedKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
9
src/auth/roles.decorator.ts
Normal file
9
src/auth/roles.decorator.ts
Normal 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
42
src/auth/roles.guard.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,108 +0,0 @@
|
|||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
|
||||||
import { createHmac, timingSafeEqual } from 'crypto';
|
|
||||||
import type { UserRole } from '../generated/prisma/enums.js';
|
|
||||||
|
|
||||||
interface JwtHeader {
|
|
||||||
alg: 'HS256';
|
|
||||||
typ: 'JWT';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SignTokenInput {
|
|
||||||
sub: number;
|
|
||||||
role: UserRole;
|
|
||||||
hospitalId: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenPayload extends SignTokenInput {
|
|
||||||
iat: number;
|
|
||||||
exp: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class TokenService {
|
|
||||||
// 建议在生产环境通过环境变量覆盖,且长度不少于 32 位。
|
|
||||||
private readonly secret =
|
|
||||||
process.env.JWT_SECRET ??
|
|
||||||
'local-dev-insecure-secret-change-me-please-1234567890';
|
|
||||||
// 默认 24 小时过期,可通过环境变量调节。
|
|
||||||
readonly expiresInSeconds = Number.parseInt(
|
|
||||||
process.env.JWT_EXPIRES_IN_SECONDS ?? '86400',
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
// 启动时校验配置,避免线上运行才暴露错误。
|
|
||||||
if (this.secret.length < 32) {
|
|
||||||
throw new Error('JWT_SECRET 长度至少32位');
|
|
||||||
}
|
|
||||||
if (!Number.isFinite(this.expiresInSeconds) || this.expiresInSeconds <= 0) {
|
|
||||||
throw new Error('JWT_EXPIRES_IN_SECONDS 必须是正整数');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sign(input: SignTokenInput): string {
|
|
||||||
// 生成 iat/exp,形成完整 payload。
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const payload: TokenPayload = {
|
|
||||||
...input,
|
|
||||||
iat: now,
|
|
||||||
exp: now + this.expiresInSeconds,
|
|
||||||
};
|
|
||||||
const header: JwtHeader = {
|
|
||||||
alg: 'HS256',
|
|
||||||
typ: 'JWT',
|
|
||||||
};
|
|
||||||
|
|
||||||
// HMAC-SHA256 签名,输出标准三段式 token。
|
|
||||||
const encodedHeader = this.encodeObject(header);
|
|
||||||
const encodedPayload = this.encodeObject(payload);
|
|
||||||
const unsignedToken = `${encodedHeader}.${encodedPayload}`;
|
|
||||||
const signature = this.signRaw(unsignedToken);
|
|
||||||
return `${unsignedToken}.${signature}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
verify(token: string): TokenPayload {
|
|
||||||
// 必须是 header.payload.signature 三段。
|
|
||||||
const parts = token.split('.');
|
|
||||||
if (parts.length !== 3) {
|
|
||||||
throw new UnauthorizedException('无效 token');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 重新计算签名并做常量时间比较,防止签名篡改。
|
|
||||||
const [encodedHeader, encodedPayload, signature] = parts;
|
|
||||||
const unsignedToken = `${encodedHeader}.${encodedPayload}`;
|
|
||||||
const expectedSignature = this.signRaw(unsignedToken);
|
|
||||||
if (
|
|
||||||
signature.length !== expectedSignature.length ||
|
|
||||||
!timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))
|
|
||||||
) {
|
|
||||||
throw new UnauthorizedException('token 签名错误');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析 payload 并做过期检查。
|
|
||||||
const payload = this.decodeObject<TokenPayload>(encodedPayload);
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
if (!payload.sub || !payload.role || !payload.exp || payload.exp <= now) {
|
|
||||||
throw new UnauthorizedException('token 已过期');
|
|
||||||
}
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
private signRaw(content: string): string {
|
|
||||||
// 统一签名算法,便于后续切换实现。
|
|
||||||
return createHmac('sha256', this.secret).update(content).digest('base64url');
|
|
||||||
}
|
|
||||||
|
|
||||||
private encodeObject(value: object): string {
|
|
||||||
return Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
||||||
}
|
|
||||||
|
|
||||||
private decodeObject<T>(encoded: string): T {
|
|
||||||
try {
|
|
||||||
return JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8')) as T;
|
|
||||||
} catch {
|
|
||||||
// 解析异常统一转成鉴权异常,避免泄露内部细节。
|
|
||||||
throw new UnauthorizedException('token 解析失败');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import type { UserRole } from '../../generated/prisma/enums.js';
|
|
||||||
|
|
||||||
export interface AuthUser {
|
|
||||||
id: number;
|
|
||||||
role: UserRole;
|
|
||||||
hospitalId: number | null;
|
|
||||||
departmentId: number | null;
|
|
||||||
medicalGroupId: number | null;
|
|
||||||
managerId: number | null;
|
|
||||||
}
|
|
||||||
219
src/auth/wechat-miniapp/wechat-miniapp.service.ts
Normal file
219
src/auth/wechat-miniapp/wechat-miniapp.service.ts
Normal 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(', ')})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/common/actor-context.ts
Normal file
9
src/common/actor-context.ts
Normal 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;
|
||||||
|
};
|
||||||
6
src/common/family-actor-context.ts
Normal file
6
src/common/family-actor-context.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export type FamilyActorContext = {
|
||||||
|
id: number;
|
||||||
|
phone: string;
|
||||||
|
openId: string | null;
|
||||||
|
serviceUid: string | null;
|
||||||
|
};
|
||||||
117
src/common/http-exception.filter.ts
Normal file
117
src/common/http-exception.filter.ts
Normal 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
186
src/common/messages.ts
Normal 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;
|
||||||
56
src/common/pressure-level.util.ts
Normal file
56
src/common/pressure-level.util.ts
Normal 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);
|
||||||
|
}
|
||||||
54
src/common/response-envelope.interceptor.ts
Normal file
54
src/common/response-envelope.interceptor.ts
Normal 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')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/common/transforms/empty-string-to-undefined.transform.ts
Normal file
12
src/common/transforms/empty-string-to-undefined.transform.ts
Normal 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;
|
||||||
|
});
|
||||||
24
src/common/transforms/to-boolean.transform.ts
Normal file
24
src/common/transforms/to-boolean.transform.ts
Normal 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;
|
||||||
|
});
|
||||||
@ -1,22 +0,0 @@
|
|||||||
import {
|
|
||||||
ArgumentsHost,
|
|
||||||
Catch,
|
|
||||||
ExceptionFilter,
|
|
||||||
HttpStatus,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { PrismaClientKnownRequestError } from '../generated/prisma/internal/prismaNamespace.js';
|
|
||||||
import { Response } from 'express';
|
|
||||||
|
|
||||||
@Catch(PrismaClientKnownRequestError)
|
|
||||||
export class DbExceptionFilter<T> implements ExceptionFilter {
|
|
||||||
catch(exception: T, host: ArgumentsHost) {
|
|
||||||
const ctx = host.switchToHttp();
|
|
||||||
const response = ctx.getResponse<Response>();
|
|
||||||
if ((exception as PrismaClientKnownRequestError).code === 'P2002') {
|
|
||||||
response.status(HttpStatus.CONFLICT).json({
|
|
||||||
statusCode: HttpStatus.CONFLICT,
|
|
||||||
message: 'Unique constraint failed',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
120
src/departments/departments.controller.ts
Normal file
120
src/departments/departments.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/departments/departments.module.ts
Normal file
21
src/departments/departments.module.ts
Normal 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 {}
|
||||||
177
src/departments/departments.service.ts
Normal file
177
src/departments/departments.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/departments/dto/create-department.dto.ts
Normal file
18
src/departments/dto/create-department.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
18
src/departments/dto/update-department.dto.ts
Normal file
18
src/departments/dto/update-department.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
1
src/departments/entities/department.entity.ts
Normal file
1
src/departments/entities/department.entity.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export class Department {}
|
||||||
178
src/devices/b-devices/b-devices.controller.ts
Normal file
178
src/devices/b-devices/b-devices.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/devices/devices.module.ts
Normal file
12
src/devices/devices.module.ts
Normal 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 {}
|
||||||
747
src/devices/devices.service.ts
Normal file
747
src/devices/devices.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/devices/dto/create-device.dto.ts
Normal file
24
src/devices/dto/create-device.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
65
src/devices/dto/create-implant-catalog.dto.ts
Normal file
65
src/devices/dto/create-implant-catalog.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
69
src/devices/dto/device-query.dto.ts
Normal file
69
src/devices/dto/device-query.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
7
src/devices/dto/update-device.dto.ts
Normal file
7
src/devices/dto/update-device.dto.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateDeviceDto } from './create-device.dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新设备 DTO。
|
||||||
|
*/
|
||||||
|
export class UpdateDeviceDto extends PartialType(CreateDeviceDto) {}
|
||||||
9
src/devices/dto/update-implant-catalog.dto.ts
Normal file
9
src/devices/dto/update-implant-catalog.dto.ts
Normal 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,
|
||||||
|
) {}
|
||||||
112
src/dictionaries/b-dictionaries/b-dictionaries.controller.ts
Normal file
112
src/dictionaries/b-dictionaries/b-dictionaries.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/dictionaries/dictionaries.module.ts
Normal file
12
src/dictionaries/dictionaries.module.ts
Normal 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 {}
|
||||||
156
src/dictionaries/dictionaries.service.ts
Normal file
156
src/dictionaries/dictionaries.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/dictionaries/dto/create-dictionary-item.dto.ts
Normal file
55
src/dictionaries/dto/create-dictionary-item.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
30
src/dictionaries/dto/dictionary-query.dto.ts
Normal file
30
src/dictionaries/dto/dictionary-query.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
9
src/dictionaries/dto/update-dictionary-item.dto.ts
Normal file
9
src/dictionaries/dto/update-dictionary-item.dto.ts
Normal 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,
|
||||||
|
) {}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user