Compare commits
6 Commits
web-recove
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 569d827b78 | |||
| 0024562863 | |||
| 3cd7a044ca | |||
| f22469d400 | |||
| 48a6cb99db | |||
| 2812832fa5 |
@ -1,5 +0,0 @@
|
|||||||
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,10 +56,3 @@ 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
48
AGENTS.md
@ -1,48 +0,0 @@
|
|||||||
# 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
111
README.md
@ -1,111 +0,0 @@
|
|||||||
# 多租户医疗调压系统后端(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
77
docs/auth.md
@ -1,77 +0,0 @@
|
|||||||
# 认证模块说明(`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` 仅预留字段,本次不提供绑定接口。
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
# 设备模块说明(`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` 只允许由调压任务完成时更新,患者手术录入和设备实例编辑都不开放手工写入。
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
# 系统字典说明(`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` 仅系统管理员生效。
|
|
||||||
- 患者手术表单现在从该接口动态读取选项,不再使用前端硬编码数组。
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
# 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 端角色可见性
|
|
||||||
- 患者创建人返回与展示
|
|
||||||
- 跨院工程师隔离
|
|
||||||
- 组织域院管作用域限制与删除冲突
|
|
||||||
- 目录、设备、组织、用户的删除保护
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
# 前后端联调说明
|
|
||||||
|
|
||||||
## 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` 仍可读取当前院内账号信息
|
|
||||||
- 同手机号多账号时,前端必须先让用户选定账号,再提交确认登录
|
|
||||||
- 同一个微信号可绑定多个院内账号,切换账号时继续走“小程序登录 -> 候选账号选择”即可
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
# 患者模块说明(`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`
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
# 调压任务模块说明(`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`。
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
# 上传资产模块说明(`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`
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
# 用户与权限模块说明(`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,6 +3,7 @@
|
|||||||
"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,53 +12,35 @@
|
|||||||
"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
@ -1,9 +0,0 @@
|
|||||||
onlyBuiltDependencies:
|
|
||||||
- '@nestjs/core'
|
|
||||||
- '@prisma/engines'
|
|
||||||
- '@scarf/scarf'
|
|
||||||
- bcrypt
|
|
||||||
- ffmpeg-static
|
|
||||||
- prisma
|
|
||||||
- sharp
|
|
||||||
- unrs-resolver
|
|
||||||
@ -7,6 +7,7 @@ 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"],
|
||||||
|
|||||||
@ -1,25 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
151
prisma/migrations/20260312092840/migration.sql
Normal file
151
prisma/migrations/20260312092840/migration.sql
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
-- 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;
|
||||||
34
prisma/migrations/20260312095251/migration.sql
Normal file
34
prisma/migrations/20260312095251/migration.sql
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
/*
|
||||||
|
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");
|
||||||
@ -1,192 +0,0 @@
|
|||||||
/*
|
|
||||||
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;
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
/*
|
|
||||||
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");
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE "User"
|
|
||||||
ADD COLUMN "tokenValidAfter" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
ALTER TABLE "Patient"
|
|
||||||
RENAME COLUMN "idCardHash" TO "idCard";
|
|
||||||
|
|
||||||
ALTER INDEX "Patient_phone_idCardHash_idx"
|
|
||||||
RENAME TO "Patient_phone_idCard_idx";
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
-- 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");
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
-- 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";
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
-- DropIndex
|
|
||||||
DROP INDEX IF EXISTS "Device_snCode_key";
|
|
||||||
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Device" DROP COLUMN "snCode";
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
-- CreateTable
|
|
||||||
CREATE TABLE "FamilyMiniAppAccount" (
|
|
||||||
"id" SERIAL NOT NULL,
|
|
||||||
"phone" TEXT NOT NULL,
|
|
||||||
"openId" TEXT,
|
|
||||||
"serviceUid" TEXT,
|
|
||||||
"lastLoginAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
|
|
||||||
CONSTRAINT "FamilyMiniAppAccount_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "FamilyMiniAppAccount_phone_key" ON "FamilyMiniAppAccount"("phone");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "FamilyMiniAppAccount_openId_key" ON "FamilyMiniAppAccount"("openId");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "FamilyMiniAppAccount_serviceUid_key" ON "FamilyMiniAppAccount"("serviceUid");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE INDEX "FamilyMiniAppAccount_lastLoginAt_idx" ON "FamilyMiniAppAccount"("lastLoginAt");
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
ALTER TABLE "Task"
|
|
||||||
ADD COLUMN IF NOT EXISTS "completionMaterials" JSONB;
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
-- 允许同一个微信 openId 绑定多个院内账号,保留普通索引供查询复用。
|
|
||||||
DROP INDEX IF EXISTS "User_openId_key";
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS "User_openId_idx" ON "User"("openId");
|
|
||||||
@ -1,307 +1,219 @@
|
|||||||
|
// 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"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 角色枚举:用于鉴权与数据可见性控制。
|
/// 统一角色枚举:
|
||||||
enum Role {
|
/// - SYSTEM_ADMIN: 平台管理员
|
||||||
|
/// - HOSPITAL_ADMIN: 医院管理员
|
||||||
|
/// - DIRECTOR: 科室主任
|
||||||
|
/// - TEAM_LEAD: 小组组长
|
||||||
|
/// - DOCTOR: 医生
|
||||||
|
/// - ENGINEER: 工程师
|
||||||
|
enum UserRole {
|
||||||
SYSTEM_ADMIN
|
SYSTEM_ADMIN
|
||||||
HOSPITAL_ADMIN
|
HOSPITAL_ADMIN
|
||||||
DIRECTOR
|
DIRECTOR
|
||||||
LEADER
|
TEAM_LEAD
|
||||||
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 Int @id @default(autoincrement())
|
/// 主键 ID。
|
||||||
name String
|
id Int @id @default(autoincrement())
|
||||||
departments Department[]
|
/// 医院名称。
|
||||||
users User[]
|
name String
|
||||||
patients Patient[]
|
/// 医院编码(唯一)。
|
||||||
tasks Task[]
|
code String @unique
|
||||||
uploads UploadAsset[]
|
/// 下属科室列表。
|
||||||
|
departments Department[]
|
||||||
|
/// 归属该医院的用户。
|
||||||
|
users User[]
|
||||||
|
/// 归属该医院的患者。
|
||||||
|
patients Patient[]
|
||||||
|
/// 该医院被分配的工程师任务关系。
|
||||||
|
engineerAssignments EngineerHospitalAssignment[]
|
||||||
|
/// 创建时间。
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
/// 更新时间。
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
// 科室表:归属于医院。
|
/// 科室实体:归属于某个医院。
|
||||||
model Department {
|
model Department {
|
||||||
id Int @id @default(autoincrement())
|
/// 主键 ID。
|
||||||
name String
|
id Int @id @default(autoincrement())
|
||||||
hospitalId Int
|
/// 科室名称。
|
||||||
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
name String
|
||||||
groups Group[]
|
/// 所属医院 ID。
|
||||||
users User[]
|
hospitalId Int
|
||||||
|
/// 医院外键关系。
|
||||||
|
hospital Hospital @relation(fields: [hospitalId], references: [id], onDelete: Cascade)
|
||||||
|
/// 下属小组。
|
||||||
|
medicalGroups MedicalGroup[]
|
||||||
|
/// 科室下用户。
|
||||||
|
users User[]
|
||||||
|
/// 科室下患者。
|
||||||
|
patients Patient[]
|
||||||
|
/// 创建时间。
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
/// 更新时间。
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
|
/// 同一家医院下科室名称唯一。
|
||||||
|
@@unique([hospitalId, name])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 医疗小组实体:归属于某个科室。
|
||||||
|
model MedicalGroup {
|
||||||
|
/// 主键 ID。
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
/// 小组名称。
|
||||||
|
name String
|
||||||
|
/// 所属科室 ID。
|
||||||
|
departmentId Int
|
||||||
|
/// 科室外键关系。
|
||||||
|
department Department @relation(fields: [departmentId], references: [id], onDelete: Cascade)
|
||||||
|
/// 小组用户。
|
||||||
|
users User[]
|
||||||
|
/// 小组患者。
|
||||||
|
patients Patient[]
|
||||||
|
/// 创建时间。
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
/// 更新时间。
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
|
/// 同一科室下小组名称唯一。
|
||||||
|
@@unique([departmentId, name])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户实体:统一承载组织关系、登录凭证与上下级结构。
|
||||||
|
model User {
|
||||||
|
/// 主键 ID。
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
/// 手机号(唯一登录名)。
|
||||||
|
phone String @unique
|
||||||
|
/// 密码哈希(禁止存明文)。
|
||||||
|
passwordHash String
|
||||||
|
/// 用户姓名。
|
||||||
|
name String?
|
||||||
|
/// 用户角色。
|
||||||
|
role UserRole @default(DOCTOR)
|
||||||
|
/// 归属医院 ID(可空,支持平台角色)。
|
||||||
|
hospitalId Int?
|
||||||
|
/// 归属科室 ID(可空)。
|
||||||
|
departmentId Int?
|
||||||
|
/// 归属小组 ID(可空)。
|
||||||
|
medicalGroupId Int?
|
||||||
|
/// 上级用户 ID(自关联层级)。
|
||||||
|
managerId Int?
|
||||||
|
/// 小程序 openId(可空,唯一)。
|
||||||
|
wechatMiniOpenId String? @unique
|
||||||
|
/// 服务号 openId(可空,唯一)。
|
||||||
|
wechatOfficialOpenId String? @unique
|
||||||
|
/// 账号是否启用。
|
||||||
|
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
|
||||||
|
|
||||||
|
/// 角色索引,便于权限查询。
|
||||||
|
@@index([role])
|
||||||
|
/// 医院索引,便于分院查询。
|
||||||
|
@@index([hospitalId])
|
||||||
|
/// 上级索引,便于层级查询。
|
||||||
|
@@index([managerId])
|
||||||
|
/// 手机号 + 启用状态联合索引,便于登录场景查询。
|
||||||
|
@@index([phone, isActive])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 患者实体:医生直接持有患者,上级通过层级可见性获取。
|
||||||
|
model Patient {
|
||||||
|
/// 主键 ID。
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
/// 患者姓名。
|
||||||
|
name String
|
||||||
|
/// 所属医院 ID。
|
||||||
|
hospitalId Int
|
||||||
|
/// 所属科室 ID(可空)。
|
||||||
|
departmentId Int?
|
||||||
|
/// 所属小组 ID(可空)。
|
||||||
|
medicalGroupId Int?
|
||||||
|
/// 负责医生 ID。
|
||||||
|
doctorId Int
|
||||||
|
/// 医院关系。
|
||||||
|
hospital Hospital @relation(fields: [hospitalId], references: [id], onDelete: Restrict)
|
||||||
|
/// 科室关系。
|
||||||
|
department Department? @relation(fields: [departmentId], references: [id], onDelete: SetNull)
|
||||||
|
/// 小组关系。
|
||||||
|
medicalGroup MedicalGroup? @relation(fields: [medicalGroupId], references: [id], onDelete: SetNull)
|
||||||
|
/// 负责医生关系。
|
||||||
|
doctor User @relation("DoctorPatients", fields: [doctorId], references: [id], onDelete: Restrict)
|
||||||
|
/// 创建时间。
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
/// 更新时间。
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
|
/// 医生索引,便于查“医生名下患者”。
|
||||||
|
@@index([doctorId])
|
||||||
|
/// 医院索引,便于查“全院患者”。
|
||||||
@@index([hospitalId])
|
@@index([hospitalId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// 小组表:归属于科室。
|
/// 工程师任务分配关系:只有系统管理员可以分配工程师到医院。
|
||||||
model Group {
|
model EngineerHospitalAssignment {
|
||||||
id Int @id @default(autoincrement())
|
/// 主键 ID。
|
||||||
name String
|
id Int @id @default(autoincrement())
|
||||||
departmentId Int
|
/// 医院 ID。
|
||||||
department Department @relation(fields: [departmentId], references: [id])
|
|
||||||
users User[]
|
|
||||||
|
|
||||||
@@index([departmentId])
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户表:支持后台密码登录与小程序 openId。
|
|
||||||
// 同一个微信 openId 允许绑定多个院内账号,便于多角色/多院区切换。
|
|
||||||
model User {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
name String
|
|
||||||
phone String
|
|
||||||
// 后台登录密码哈希(bcrypt)。
|
|
||||||
passwordHash String?
|
|
||||||
// 该时间点之前签发的 token 一律失效。
|
|
||||||
tokenValidAfter DateTime @default(now())
|
|
||||||
openId String?
|
|
||||||
role Role
|
|
||||||
hospitalId Int?
|
|
||||||
departmentId Int?
|
|
||||||
groupId Int?
|
|
||||||
hospital Hospital? @relation(fields: [hospitalId], references: [id])
|
|
||||||
department Department? @relation(fields: [departmentId], references: [id])
|
|
||||||
// 小组删除必须先清理成员,避免静默把用户 groupId 置空。
|
|
||||||
group Group? @relation(fields: [groupId], references: [id], onDelete: Restrict)
|
|
||||||
doctorPatients Patient[] @relation("DoctorPatients")
|
|
||||||
createdPatients Patient[] @relation("PatientCreator")
|
|
||||||
createdTasks Task[] @relation("TaskCreator")
|
|
||||||
acceptedTasks Task[] @relation("TaskEngineer")
|
|
||||||
surgeonSurgeries PatientSurgery[] @relation("SurgerySurgeon")
|
|
||||||
createdUploads UploadAsset[] @relation("UploadCreator")
|
|
||||||
|
|
||||||
@@unique([phone, role, hospitalId])
|
|
||||||
@@index([phone])
|
|
||||||
@@index([openId])
|
|
||||||
@@index([hospitalId, role])
|
|
||||||
@@index([departmentId, role])
|
|
||||||
@@index([groupId, role])
|
|
||||||
}
|
|
||||||
|
|
||||||
// 家属小程序账号:按手机号承载 C 端登录身份,并预留服务号绑定字段。
|
|
||||||
model FamilyMiniAppAccount {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
phone String @unique
|
|
||||||
openId String? @unique
|
|
||||||
serviceUid String? @unique
|
|
||||||
lastLoginAt DateTime @default(now())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
|
||||||
|
|
||||||
@@index([lastLoginAt])
|
|
||||||
}
|
|
||||||
|
|
||||||
// 患者表:院内患者档案,按医院隔离。
|
|
||||||
model Patient {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
name String
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
// 住院号:用于院内患者检索与病案关联。
|
|
||||||
inpatientNo String?
|
|
||||||
// 项目名称:用于区分患者所属项目/课题。
|
|
||||||
projectName String?
|
|
||||||
phone String
|
|
||||||
// 患者身份证号,录入与查询都使用原始证件号。
|
|
||||||
idCard String
|
|
||||||
hospitalId Int
|
|
||||||
doctorId Int
|
|
||||||
creatorId Int
|
|
||||||
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
|
||||||
doctor User @relation("DoctorPatients", fields: [doctorId], references: [id])
|
|
||||||
creator User @relation("PatientCreator", fields: [creatorId], references: [id])
|
|
||||||
surgeries PatientSurgery[]
|
|
||||||
devices Device[]
|
|
||||||
|
|
||||||
@@index([phone, idCard])
|
|
||||||
@@index([hospitalId, doctorId])
|
|
||||||
@@index([inpatientNo])
|
|
||||||
@@index([creatorId])
|
|
||||||
}
|
|
||||||
|
|
||||||
// 患者手术表:保存每次分流/复手术档案。
|
|
||||||
model PatientSurgery {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
patientId Int
|
|
||||||
surgeryDate DateTime
|
|
||||||
surgeryName String
|
|
||||||
surgeonId Int?
|
|
||||||
surgeonName String
|
|
||||||
// 术前测压:部分患者可为空。
|
|
||||||
preOpPressure Int?
|
|
||||||
// 原发病:前端单选,后端先按字符串存储,方便后续补字典。
|
|
||||||
primaryDisease String
|
|
||||||
// 脑积水类型:前端多选。
|
|
||||||
hydrocephalusTypes String[] @default([])
|
|
||||||
// 上次分流手术时间:无既往分流史时为空。
|
|
||||||
previousShuntSurgeryDate DateTime?
|
|
||||||
// 术前影像/资料:支持图片、视频等附件元数据。
|
|
||||||
preOpMaterials Json?
|
|
||||||
notes String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
|
|
||||||
surgeon User? @relation("SurgerySurgeon", fields: [surgeonId], references: [id], onDelete: SetNull)
|
|
||||||
devices Device[]
|
|
||||||
|
|
||||||
@@index([patientId, surgeryDate])
|
|
||||||
@@index([surgeonId])
|
|
||||||
}
|
|
||||||
|
|
||||||
// 植入物型号字典:供前端单选型号后自动回填厂家与名称。
|
|
||||||
model ImplantCatalog {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
modelCode String @unique
|
|
||||||
manufacturer String
|
|
||||||
name String
|
|
||||||
// 是否为阀门;关闭时表示管子/附件,不提供压力挡位。
|
|
||||||
isValve Boolean @default(true)
|
|
||||||
// 可调压器械的可选挡位,由系统管理员维护。
|
|
||||||
pressureLevels String[] @default([])
|
|
||||||
isPressureAdjustable Boolean @default(true)
|
|
||||||
notes String?
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
|
||||||
devices Device[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 系统级字典项:由系统管理员维护,供患者手术表单选择使用。
|
|
||||||
model DictionaryItem {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
type DictionaryType
|
|
||||||
label String
|
|
||||||
sortOrder Int @default(0)
|
|
||||||
enabled Boolean @default(true)
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
updatedAt DateTime @updatedAt
|
|
||||||
|
|
||||||
@@unique([type, label])
|
|
||||||
@@index([type, enabled, sortOrder])
|
|
||||||
}
|
|
||||||
|
|
||||||
// 上传资产表:保存图片/视频/文件元数据,供图库与患者表单复用。
|
|
||||||
model UploadAsset {
|
|
||||||
id Int @id @default(autoincrement())
|
|
||||||
hospitalId Int
|
hospitalId Int
|
||||||
creatorId Int
|
/// 工程师用户 ID。
|
||||||
type UploadAssetType
|
engineerId Int
|
||||||
originalName String
|
/// 分配人(系统管理员)用户 ID。
|
||||||
fileName String
|
assignedById Int
|
||||||
storagePath String @unique
|
/// 医院关系。
|
||||||
url String
|
hospital Hospital @relation(fields: [hospitalId], references: [id], onDelete: Cascade)
|
||||||
mimeType String
|
/// 工程师关系。
|
||||||
fileSize Int
|
engineer User @relation("EngineerAssignments", fields: [engineerId], references: [id], onDelete: Restrict)
|
||||||
createdAt DateTime @default(now())
|
/// 分配人关系。
|
||||||
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
assignedBy User @relation("SystemAdminAssignments", fields: [assignedById], references: [id], onDelete: Restrict)
|
||||||
creator User @relation("UploadCreator", fields: [creatorId], references: [id])
|
/// 创建时间。
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([hospitalId, type, createdAt])
|
/// 同一医院与工程师不能重复分配。
|
||||||
@@index([creatorId, createdAt])
|
@@unique([hospitalId, engineerId])
|
||||||
}
|
/// 工程师索引。
|
||||||
|
@@index([engineerId])
|
||||||
// 设备表:每次手术植入的设备实例,保留当前压力与历史调压记录。
|
/// 分配人索引。
|
||||||
model Device {
|
@@index([assignedById])
|
||||||
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
843
prisma/seed.mjs
@ -1,843 +0,0 @@
|
|||||||
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
Normal file
301
prisma/seed.ts
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
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,47 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { APP_GUARD } from '@nestjs/core';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
|
||||||
import { PrismaModule } from './prisma.module.js';
|
|
||||||
import { UsersModule } from './users/users.module.js';
|
|
||||||
import { TasksModule } from './tasks/tasks.module.js';
|
|
||||||
import { PatientsModule } from './patients/patients.module.js';
|
|
||||||
import { AuthModule } from './auth/auth.module.js';
|
import { AuthModule } from './auth/auth.module.js';
|
||||||
import { OrganizationModule } from './organization/organization.module.js';
|
import { UsersModule } from './users/users.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({
|
||||||
imports: [
|
// ConfigModule 先加载,保证鉴权和数据库都可读取环境变量。
|
||||||
PrismaModule,
|
imports: [ConfigModule.forRoot(), AuthModule, UsersModule],
|
||||||
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 {}
|
||||||
|
|||||||
@ -1,113 +0,0 @@
|
|||||||
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,80 +1,70 @@
|
|||||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Post } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { CurrentUser } from './decorators/current-user.decorator.js';
|
||||||
import { Throttle } from '@nestjs/throttler';
|
import { Public } from './decorators/public.decorator.js';
|
||||||
import { AuthService } from './auth.service.js';
|
import { AuthService } from './auth.service.js';
|
||||||
import { AccessTokenGuard } from './access-token.guard.js';
|
import { BindWechatDto } from './dto/bind-wechat.dto.js';
|
||||||
import { CurrentActor } from './current-actor.decorator.js';
|
import { ChangePasswordDto } from './dto/change-password.dto.js';
|
||||||
import type { ActorContext } from '../common/actor-context.js';
|
import { LoginMiniProgramDto } from './dto/login-mini-program.dto.js';
|
||||||
import { CreateSystemAdminDto } from './dto/create-system-admin.dto.js';
|
import { LoginOfficialAccountDto } from './dto/login-official-account.dto.js';
|
||||||
import { MiniappPhoneLoginDto } from './dto/miniapp-phone-login.dto.js';
|
import { LoginPhoneDto } from './dto/login-phone.dto.js';
|
||||||
import { MiniappPhoneLoginConfirmDto } from './dto/miniapp-phone-login-confirm.dto.js';
|
import { RegisterDto } from './dto/register.dto.js';
|
||||||
import { PasswordLoginConfirmDto } from './dto/password-login-confirm.dto.js';
|
import type { AuthUser } from './types/auth-user.type.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')
|
||||||
@Post('system-admin')
|
register(@Body() registerDto: RegisterDto) {
|
||||||
@Throttle({ default: { limit: 3, ttl: 60_000 } })
|
return this.authService.register(registerDto);
|
||||||
@ApiOperation({ summary: '创建系统管理员' })
|
|
||||||
createSystemAdmin(@Body() dto: CreateSystemAdminDto) {
|
|
||||||
return this.authService.createSystemAdmin(dto);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// 公开登录接口:手机号 + 密码。
|
||||||
* 院内账号密码登录:后台与小程序均可复用。
|
@Public()
|
||||||
*/
|
@Post('login/phone')
|
||||||
@Post('login')
|
loginWithPhone(@Body() loginPhoneDto: LoginPhoneDto) {
|
||||||
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
return this.authService.loginWithPhone(loginPhoneDto);
|
||||||
@ApiOperation({ summary: '院内账号密码登录' })
|
|
||||||
login(@Body() dto: LoginDto) {
|
|
||||||
return this.authService.login(dto);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('login/confirm')
|
// 公开登录接口:小程序 openId。
|
||||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
@Public()
|
||||||
@ApiOperation({ summary: '院内账号密码多账号确认登录' })
|
@Post('login/mini-program')
|
||||||
confirmLogin(@Body() dto: PasswordLoginConfirmDto) {
|
loginWithMiniProgram(@Body() loginMiniProgramDto: LoginMiniProgramDto) {
|
||||||
return this.authService.confirmLogin(dto);
|
return this.authService.loginWithMiniProgram(loginMiniProgramDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('miniapp/b/phone-login')
|
// 公开登录接口:服务号 openId。
|
||||||
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
@Public()
|
||||||
@ApiOperation({ summary: 'B 端小程序手机号登录' })
|
@Post('login/official-account')
|
||||||
miniAppBLogin(@Body() dto: MiniappPhoneLoginDto) {
|
loginWithOfficialAccount(
|
||||||
return this.authService.miniAppBLogin(dto);
|
@Body() loginOfficialAccountDto: LoginOfficialAccountDto,
|
||||||
|
) {
|
||||||
|
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')
|
||||||
@UseGuards(AccessTokenGuard)
|
me(@CurrentUser() currentUser: AuthUser) {
|
||||||
@ApiBearerAuth('bearer')
|
return this.authService.me(currentUser.id);
|
||||||
@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,18 +1,31 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthService } from './auth.service.js';
|
import { APP_GUARD } from '@nestjs/core';
|
||||||
|
import { PrismaModule } from '../prisma.module.js';
|
||||||
import { AuthController } from './auth.controller.js';
|
import { AuthController } from './auth.controller.js';
|
||||||
import { UsersModule } from '../users/users.module.js';
|
import { AuthService } from './auth.service.js';
|
||||||
import { AccessTokenGuard } from './access-token.guard.js';
|
import { PasswordService } from './password.service.js';
|
||||||
import { WechatMiniAppService } from './wechat-miniapp/wechat-miniapp.service.js';
|
import { TokenService } from './token.service.js';
|
||||||
import { MiniAppAuthService } from './miniapp-auth/miniapp-auth.service.js';
|
import { AuthGuard } from './guards/auth.guard.js';
|
||||||
|
import { RolesGuard } from './guards/roles.guard.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* 认证模块:聚合认证控制器、服务与基础鉴权守卫。
|
|
||||||
*/
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UsersModule],
|
imports: [PrismaModule],
|
||||||
providers: [AuthService, AccessTokenGuard, WechatMiniAppService, MiniAppAuthService],
|
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
exports: [AuthService, AccessTokenGuard, WechatMiniAppService, MiniAppAuthService],
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
PasswordService,
|
||||||
|
TokenService,
|
||||||
|
// 全局鉴权:默认所有接口都需要登录,除非显式标记 @Public。
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: AuthGuard,
|
||||||
|
},
|
||||||
|
// 全局角色守卫:只有使用 @Roles 的接口才会进行角色判断。
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: RolesGuard,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [PasswordService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@ -1,69 +1,290 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import {
|
||||||
import type { ActorContext } from '../common/actor-context.js';
|
ConflictException,
|
||||||
import { UsersService } from '../users/users.service.js';
|
Injectable,
|
||||||
import { LoginDto } from '../users/dto/login.dto.js';
|
NotFoundException,
|
||||||
import { CreateSystemAdminDto } from './dto/create-system-admin.dto.js';
|
UnauthorizedException,
|
||||||
import { MiniappPhoneLoginConfirmDto } from './dto/miniapp-phone-login-confirm.dto.js';
|
} from '@nestjs/common';
|
||||||
import { MiniappPhoneLoginDto } from './dto/miniapp-phone-login.dto.js';
|
import { UserRole } from '../generated/prisma/enums.js';
|
||||||
import { PasswordLoginConfirmDto } from './dto/password-login-confirm.dto.js';
|
import { PrismaService } from '../prisma.service.js';
|
||||||
import { MiniAppAuthService } from './miniapp-auth/miniapp-auth.service.js';
|
import { BindWechatDto } from './dto/bind-wechat.dto.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 usersService: UsersService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly miniAppAuthService: MiniAppAuthService,
|
private readonly passwordService: PasswordService,
|
||||||
|
private readonly tokenService: TokenService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
async register(registerDto: RegisterDto) {
|
||||||
* 系统管理员创建能力委托给用户服务。
|
// 自助注册只允许创建普通医生角色,防止越权注册管理员。
|
||||||
*/
|
const existingUser = await this.prisma.user.findUnique({
|
||||||
createSystemAdmin(dto: CreateSystemAdminDto) {
|
where: { phone: registerDto.phone },
|
||||||
return this.usersService.createSystemAdmin(dto);
|
select: { id: true },
|
||||||
|
});
|
||||||
|
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({
|
||||||
login(dto: LoginDto) {
|
where: { phone: loginPhoneDto.phone },
|
||||||
return this.usersService.login(dto);
|
select: {
|
||||||
|
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({
|
||||||
confirmLogin(dto: PasswordLoginConfirmDto) {
|
where: {
|
||||||
return this.usersService.confirmLogin(dto);
|
wechatMiniOpenId: loginMiniProgramDto.miniOpenId,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('小程序账号未绑定');
|
||||||
|
}
|
||||||
|
return this.loginByUserId(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async loginWithOfficialAccount(loginOfficialAccountDto: LoginOfficialAccountDto) {
|
||||||
* B 端小程序手机号登录。
|
// 服务号登录通过 officialOpenId 直连用户。
|
||||||
*/
|
const user = await this.prisma.user.findFirst({
|
||||||
miniAppBLogin(dto: MiniappPhoneLoginDto) {
|
where: {
|
||||||
return this.miniAppAuthService.loginForB(dto);
|
wechatOfficialOpenId: loginOfficialAccountDto.officialOpenId,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
throw new UnauthorizedException('服务号账号未绑定');
|
||||||
|
}
|
||||||
|
return this.loginByUserId(user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async bindWechat(userId: number, bindWechatDto: BindWechatDto) {
|
||||||
* B 端小程序多账号确认登录。
|
// 绑定之前先做冲突检查,确保一个 openId 只归属一个用户。
|
||||||
*/
|
if (bindWechatDto.miniOpenId) {
|
||||||
miniAppBConfirmLogin(dto: MiniappPhoneLoginConfirmDto) {
|
const existingMini = await this.prisma.user.findFirst({
|
||||||
return this.miniAppAuthService.confirmLoginForB(dto);
|
where: {
|
||||||
|
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({
|
||||||
miniAppCLogin(dto: MiniappPhoneLoginDto) {
|
where: { id: userId },
|
||||||
return this.miniAppAuthService.loginForC(dto);
|
select: {
|
||||||
|
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({
|
||||||
me(actor: ActorContext) {
|
where: { id: userId },
|
||||||
return this.usersService.me(actor);
|
select: this.userSelect,
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2
src/auth/constants.ts
Normal file
2
src/auth/constants.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export const IS_PUBLIC_KEY = 'isPublic';
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
@ -1,14 +0,0 @@
|
|||||||
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;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
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;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
11
src/auth/decorators/current-user.decorator.ts
Normal file
11
src/auth/decorators/current-user.decorator.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
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;
|
||||||
|
},
|
||||||
|
);
|
||||||
4
src/auth/decorators/public.decorator.ts
Normal file
4
src/auth/decorators/public.decorator.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { IS_PUBLIC_KEY } from '../constants.js';
|
||||||
|
|
||||||
|
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
|
||||||
5
src/auth/decorators/roles.decorator.ts
Normal file
5
src/auth/decorators/roles.decorator.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
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);
|
||||||
19
src/auth/dto/bind-wechat.dto.ts
Normal file
19
src/auth/dto/bind-wechat.dto.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
24
src/auth/dto/change-password.dto.ts
Normal file
24
src/auth/dto/change-password.dto.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@ -1,32 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
9
src/auth/dto/login-mini-program.dto.ts
Normal file
9
src/auth/dto/login-mini-program.dto.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class LoginMiniProgramDto {
|
||||||
|
// 小程序 openId 由前端/网关在登录态中传入。
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6)
|
||||||
|
@MaxLength(128)
|
||||||
|
miniOpenId: string;
|
||||||
|
}
|
||||||
9
src/auth/dto/login-official-account.dto.ts
Normal file
9
src/auth/dto/login-official-account.dto.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class LoginOfficialAccountDto {
|
||||||
|
// 服务号 openId 由前端/网关在登录态中传入。
|
||||||
|
@IsString()
|
||||||
|
@MinLength(6)
|
||||||
|
@MaxLength(128)
|
||||||
|
officialOpenId: string;
|
||||||
|
}
|
||||||
22
src/auth/dto/login-phone.dto.ts
Normal file
22
src/auth/dto/login-phone.dto.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@ -1,24 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { Type } from 'class-transformer';
|
|
||||||
import { IsInt, IsString, Min } from 'class-validator';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* B 端多账号确认登录 DTO。
|
|
||||||
*/
|
|
||||||
export class MiniappPhoneLoginConfirmDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'B 端候选账号选择票据',
|
|
||||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
|
||||||
})
|
|
||||||
@IsString({ message: 'loginTicket 必须是字符串' })
|
|
||||||
loginTicket!: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: '确认登录的用户 ID',
|
|
||||||
example: 1,
|
|
||||||
})
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsInt({ message: 'userId 必须是整数' })
|
|
||||||
@Min(1, { message: 'userId 必须大于 0' })
|
|
||||||
userId!: number;
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { IsString } from 'class-validator';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 小程序手机号登录 DTO。
|
|
||||||
*/
|
|
||||||
export class MiniappPhoneLoginDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: '微信登录 code,用于换取 openId',
|
|
||||||
example: '08123456789abcdef',
|
|
||||||
})
|
|
||||||
@IsString({ message: 'loginCode 必须是字符串' })
|
|
||||||
loginCode!: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: '微信手机号授权 code,用于换取手机号',
|
|
||||||
example: '12ab34cd56ef78gh',
|
|
||||||
})
|
|
||||||
@IsString({ message: 'phoneCode 必须是字符串' })
|
|
||||||
phoneCode!: string;
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { Type } from 'class-transformer';
|
|
||||||
import { IsInt, IsString, Min } from 'class-validator';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 院内账号密码登录确认 DTO。
|
|
||||||
*/
|
|
||||||
export class PasswordLoginConfirmDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: '密码登录候选账号票据',
|
|
||||||
example: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9',
|
|
||||||
})
|
|
||||||
@IsString({ message: 'loginTicket 必须是字符串' })
|
|
||||||
loginTicket!: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: '最终确认登录的用户 ID',
|
|
||||||
example: 1,
|
|
||||||
})
|
|
||||||
@Type(() => Number)
|
|
||||||
@IsInt({ message: 'userId 必须是整数' })
|
|
||||||
@Min(1, { message: 'userId 必须大于 0' })
|
|
||||||
userId!: number;
|
|
||||||
}
|
|
||||||
99
src/auth/dto/register.dto.ts
Normal file
99
src/auth/dto/register.dto.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@ -1,84 +0,0 @@
|
|||||||
import {
|
|
||||||
CanActivate,
|
|
||||||
ExecutionContext,
|
|
||||||
Injectable,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import jwt from 'jsonwebtoken';
|
|
||||||
import type { FamilyActorContext } from '../../common/family-actor-context.js';
|
|
||||||
import { MESSAGES } from '../../common/messages.js';
|
|
||||||
import { PrismaService } from '../../prisma.service.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* C 端小程序登录守卫。
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class FamilyAccessTokenGuard implements CanActivate {
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
|
||||||
|
|
||||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
||||||
const request = context.switchToHttp().getRequest<{
|
|
||||||
headers: Record<string, string | string[] | undefined>;
|
|
||||||
familyActor?: FamilyActorContext;
|
|
||||||
}>();
|
|
||||||
const authorization = request.headers.authorization;
|
|
||||||
const headerValue = Array.isArray(authorization)
|
|
||||||
? authorization[0]
|
|
||||||
: authorization;
|
|
||||||
|
|
||||||
if (!headerValue || !headerValue.startsWith('Bearer ')) {
|
|
||||||
throw new UnauthorizedException(MESSAGES.AUTH.MISSING_BEARER);
|
|
||||||
}
|
|
||||||
|
|
||||||
request.familyActor = await this.verifyAndExtractActor(
|
|
||||||
headerValue.slice('Bearer '.length).trim(),
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 校验 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
84
src/auth/guards/auth.guard.ts
Normal file
84
src/auth/guards/auth.guard.ts
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/auth/guards/roles.guard.ts
Normal file
41
src/auth/guards/roles.guard.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,277 +0,0 @@
|
|||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
ConflictException,
|
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import jwt from 'jsonwebtoken';
|
|
||||||
import { Role } from '../../generated/prisma/enums.js';
|
|
||||||
import { MESSAGES } from '../../common/messages.js';
|
|
||||||
import { PrismaService } from '../../prisma.service.js';
|
|
||||||
import { UsersService } from '../../users/users.service.js';
|
|
||||||
import { MiniappPhoneLoginConfirmDto } from '../dto/miniapp-phone-login-confirm.dto.js';
|
|
||||||
import { MiniappPhoneLoginDto } from '../dto/miniapp-phone-login.dto.js';
|
|
||||||
import { WechatMiniAppService } from '../wechat-miniapp/wechat-miniapp.service.js';
|
|
||||||
|
|
||||||
type LoginTicketPayload = {
|
|
||||||
purpose: 'MINIAPP_B_LOGIN_TICKET';
|
|
||||||
phone: string;
|
|
||||||
openId: string;
|
|
||||||
userIds: number[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 小程序登录服务:承载 B/C 端手机号快捷登录链路。
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class MiniAppAuthService {
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly usersService: UsersService,
|
|
||||||
private readonly wechatMiniAppService: WechatMiniAppService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* B 端手机号登录:单账号直接签发,多账号返回候选列表。
|
|
||||||
*/
|
|
||||||
async loginForB(dto: MiniappPhoneLoginDto) {
|
|
||||||
const identity = await this.wechatMiniAppService.resolvePhoneIdentity(
|
|
||||||
dto.loginCode,
|
|
||||||
dto.phoneCode,
|
|
||||||
);
|
|
||||||
const accounts = await this.prisma.user.findMany({
|
|
||||||
where: { phone: identity.phone },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
phone: true,
|
|
||||||
openId: true,
|
|
||||||
role: true,
|
|
||||||
hospitalId: true,
|
|
||||||
departmentId: true,
|
|
||||||
groupId: true,
|
|
||||||
hospital: {
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
name: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: [{ hospitalId: 'asc' }, { id: 'asc' }],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (accounts.length === 0) {
|
|
||||||
throw new NotFoundException(MESSAGES.AUTH.MINIAPP_NO_MATCHED_USER);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (accounts.length === 1) {
|
|
||||||
const [user] = accounts;
|
|
||||||
await this.usersService.bindOpenIdForMiniAppLogin(
|
|
||||||
user.id,
|
|
||||||
identity.openId,
|
|
||||||
);
|
|
||||||
return this.usersService.loginByUserId(user.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
needSelect: true,
|
|
||||||
loginTicket: this.signLoginTicket({
|
|
||||||
purpose: 'MINIAPP_B_LOGIN_TICKET',
|
|
||||||
phone: identity.phone,
|
|
||||||
openId: identity.openId,
|
|
||||||
userIds: accounts.map((account) => account.id),
|
|
||||||
}),
|
|
||||||
accounts: accounts.map((account) => ({
|
|
||||||
id: account.id,
|
|
||||||
name: account.name,
|
|
||||||
role: account.role,
|
|
||||||
hospitalId: account.hospitalId,
|
|
||||||
hospitalName: account.hospital?.name ?? null,
|
|
||||||
departmentId: account.departmentId,
|
|
||||||
groupId: account.groupId,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* B 端多账号确认登录。
|
|
||||||
*/
|
|
||||||
async confirmLoginForB(dto: MiniappPhoneLoginConfirmDto) {
|
|
||||||
const payload = this.verifyLoginTicket(dto.loginTicket);
|
|
||||||
if (!payload.userIds.includes(dto.userId)) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
MESSAGES.AUTH.MINIAPP_ACCOUNT_SELECTION_INVALID,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.usersService.bindOpenIdForMiniAppLogin(
|
|
||||||
dto.userId,
|
|
||||||
payload.openId,
|
|
||||||
);
|
|
||||||
return this.usersService.loginByUserId(dto.userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* C 端手机号登录:要求手机号唯一命中患者档案。
|
|
||||||
*/
|
|
||||||
async loginForC(dto: MiniappPhoneLoginDto) {
|
|
||||||
const identity = await this.wechatMiniAppService.resolvePhoneIdentity(
|
|
||||||
dto.loginCode,
|
|
||||||
dto.phoneCode,
|
|
||||||
);
|
|
||||||
const 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
33
src/auth/password.service.ts
Normal file
33
src/auth/password.service.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,9 +0,0 @@
|
|||||||
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);
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
108
src/auth/token.service.ts
Normal file
108
src/auth/token.service.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
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 解析失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/auth/types/auth-user.type.ts
Normal file
10
src/auth/types/auth-user.type.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@ -1,219 +0,0 @@
|
|||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
Injectable,
|
|
||||||
InternalServerErrorException,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { MESSAGES } from '../../common/messages.js';
|
|
||||||
|
|
||||||
type WechatAccessTokenResponse = {
|
|
||||||
access_token?: string;
|
|
||||||
expires_in?: number;
|
|
||||||
errcode?: number;
|
|
||||||
errmsg?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WechatCode2SessionResponse = {
|
|
||||||
openid?: string;
|
|
||||||
errcode?: number;
|
|
||||||
errmsg?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type WechatPhoneResponse = {
|
|
||||||
phone_info?: {
|
|
||||||
phoneNumber?: string;
|
|
||||||
};
|
|
||||||
errcode?: number;
|
|
||||||
errmsg?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 微信小程序认证服务:负责和微信开放接口交换 openId 与手机号。
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class WechatMiniAppService {
|
|
||||||
private accessTokenCache:
|
|
||||||
| {
|
|
||||||
token: string;
|
|
||||||
expiresAt: number;
|
|
||||||
}
|
|
||||||
| null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 一次性解析微信登录 code 与手机号 code。
|
|
||||||
*/
|
|
||||||
async resolvePhoneIdentity(loginCode: string, phoneCode: string) {
|
|
||||||
const [openId, phone] = await Promise.all([
|
|
||||||
this.exchangeLoginCode(loginCode),
|
|
||||||
this.exchangePhoneCode(phoneCode),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
openId,
|
|
||||||
phone,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通过 wx.login 返回的 code 换取 openId。
|
|
||||||
*/
|
|
||||||
async exchangeLoginCode(loginCode: string): Promise<string> {
|
|
||||||
const config = this.requireConfig();
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
appid: config.appId,
|
|
||||||
secret: config.secret,
|
|
||||||
js_code: this.normalizeCode(loginCode, 'loginCode'),
|
|
||||||
grant_type: 'authorization_code',
|
|
||||||
});
|
|
||||||
const response = await fetch(
|
|
||||||
`https://api.weixin.qq.com/sns/jscode2session?${params.toString()}`,
|
|
||||||
);
|
|
||||||
const payload =
|
|
||||||
(await response.json().catch(() => null)) as WechatCode2SessionResponse | null;
|
|
||||||
|
|
||||||
if (!response.ok || !payload?.openid || payload.errcode) {
|
|
||||||
throw new UnauthorizedException(
|
|
||||||
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(', ')})`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import { Role } from '../generated/prisma/enums.js';
|
|
||||||
|
|
||||||
export type ActorContext = {
|
|
||||||
id: number;
|
|
||||||
role: Role;
|
|
||||||
hospitalId: number | null;
|
|
||||||
departmentId: number | null;
|
|
||||||
groupId: number | null;
|
|
||||||
};
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
export type FamilyActorContext = {
|
|
||||||
id: number;
|
|
||||||
phone: string;
|
|
||||||
openId: string | null;
|
|
||||||
serviceUid: string | null;
|
|
||||||
};
|
|
||||||
@ -1,117 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,186 +0,0 @@
|
|||||||
/**
|
|
||||||
* 全局消息常量:统一维护接口中文提示,避免在业务代码中散落硬编码字符串。
|
|
||||||
*/
|
|
||||||
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;
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
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')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
import { Transform } from 'class-transformer';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将空字符串统一转为 undefined,便于可选字段走 IsOptional 分支。
|
|
||||||
*/
|
|
||||||
export const EmptyStringToUndefined = () =>
|
|
||||||
Transform(({ value }) => {
|
|
||||||
if (typeof value === 'string' && value.trim() === '') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
});
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
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;
|
|
||||||
});
|
|
||||||
22
src/db-exception/db-exception.filter.ts
Normal file
22
src/db-exception/db-exception.filter.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,120 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@ -1,177 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
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 +0,0 @@
|
|||||||
export class Department {}
|
|
||||||
@ -1,178 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@ -1,747 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
import { PartialType } from '@nestjs/swagger';
|
|
||||||
import { CreateDeviceDto } from './create-device.dto.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新设备 DTO。
|
|
||||||
*/
|
|
||||||
export class UpdateDeviceDto extends PartialType(CreateDeviceDto) {}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import { PartialType } from '@nestjs/swagger';
|
|
||||||
import { CreateImplantCatalogDto } from './create-implant-catalog.dto.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 植入物型号更新 DTO。
|
|
||||||
*/
|
|
||||||
export class UpdateImplantCatalogDto extends PartialType(
|
|
||||||
CreateImplantCatalogDto,
|
|
||||||
) {}
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@ -1,156 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
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