Compare commits
No commits in common. "2275607bd23fbea5fa32705be8a2ae64edf71c93" and "ff6739ab68a2741c670f265e52b1a7a23d883490" have entirely different histories.
2275607bd2
...
ff6739ab68
@ -1,3 +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"
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@ -56,6 +56,3 @@ pids
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
/src/generated/prisma
|
||||
|
||||
/tyt-admin/dist
|
||||
/tyt-admin/node_modules
|
||||
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,不要直接改配置文件,最后发给我安装命令,让我执行,中文注释和文档
|
||||
105
README.md
105
README.md
@ -1,105 +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"
|
||||
JWT_SECRET="请替换为强随机密钥"
|
||||
JWT_EXPIRES_IN="7d"
|
||||
SYSTEM_ADMIN_BOOTSTRAP_KEY="初始化系统管理员用密钥"
|
||||
```
|
||||
|
||||
## 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 的权限断言。
|
||||
32
docs/auth.md
32
docs/auth.md
@ -1,32 +0,0 @@
|
||||
# 认证模块说明(`src/auth`)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
- 提供注册、登录、`/me` 身份查询。
|
||||
- 使用 JWT 做认证,Guard 做鉴权,RolesGuard 做 RBAC。
|
||||
|
||||
## 2. 核心接口
|
||||
|
||||
- `POST /auth/register`:注册账号(支持医生/工程师/院管等角色约束)
|
||||
- `POST /auth/login`:手机号 + 角色 + 密码登录(支持同手机号多院场景)
|
||||
- `GET /auth/me`:返回当前登录用户上下文
|
||||
|
||||
## 3. 鉴权流程
|
||||
|
||||
1. `AccessTokenGuard` 从 `Authorization` 读取 Bearer Token。
|
||||
2. 校验 JWT 签名与载荷字段。
|
||||
3. 载荷映射为 `ActorContext` 注入 `request.user`。
|
||||
4. `RolesGuard` 根据 `@Roles(...)` 判断角色是否允许访问。
|
||||
|
||||
## 4. Token 约定
|
||||
|
||||
- Header:`Authorization: Bearer <token>`
|
||||
- 载荷关键字段:`sub`、`role`、`hospitalId`、`departmentId`、`groupId`
|
||||
|
||||
## 5. 错误码与中文消息
|
||||
|
||||
- 未登录/Token 失效:`401` + 中文 `msg`
|
||||
- 角色无权限:`403` + 中文 `msg`
|
||||
- 参数非法:`400` + 中文 `msg`
|
||||
|
||||
统一由全局异常过滤器输出:`{ code, msg, data: null }`。
|
||||
@ -1,60 +0,0 @@
|
||||
# E2E 接口测试说明
|
||||
|
||||
## 1. 目标
|
||||
|
||||
- 覆盖 `src/**/*controller.ts` 当前全部 30 个业务接口。
|
||||
- 采用 `supertest + @nestjs/testing` 进行真实 HTTP E2E 测试。
|
||||
- 测试前固定执行数据库重置与 seed,确保结果可重复。
|
||||
|
||||
## 2. 风险提示
|
||||
|
||||
`pnpm test:e2e` 会执行:
|
||||
|
||||
1. `prisma migrate reset --force`
|
||||
2. `node prisma/seed.mjs`
|
||||
|
||||
这会清空 `.env` 中 `DATABASE_URL` 指向数据库的全部数据,请仅在测试库执行。
|
||||
|
||||
## 3. 运行命令
|
||||
|
||||
```bash
|
||||
pnpm test:e2e
|
||||
```
|
||||
|
||||
仅重置数据库并注入 seed:
|
||||
|
||||
```bash
|
||||
pnpm test:e2e:prepare
|
||||
```
|
||||
|
||||
监听模式:
|
||||
|
||||
```bash
|
||||
pnpm test:e2e:watch
|
||||
```
|
||||
|
||||
## 4. 种子账号(默认密码:`Seed@1234`)
|
||||
|
||||
- 系统管理员:`13800001000`
|
||||
- 院管(医院 A):`13800001001`
|
||||
- 主任(医院 A):`13800001002`
|
||||
- 组长(医院 A):`13800001003`
|
||||
- 医生(医院 A):`13800001004`
|
||||
- 工程师(医院 A):`13800001005`
|
||||
|
||||
## 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/tasks.e2e-spec.ts`
|
||||
- `test/e2e/specs/patients.e2e-spec.ts`
|
||||
|
||||
## 6. 覆盖策略
|
||||
|
||||
- 受保护接口(27 个):每个接口覆盖 6 角色访问结果 + 未登录 401。
|
||||
- 非受保护接口(3 个):每个接口至少 1 个成功 + 1 个失败。
|
||||
- 关键行为额外覆盖:
|
||||
- 任务状态机冲突(409)
|
||||
- 患者 B 端角色可见性
|
||||
- 组织域院管作用域限制与删除冲突
|
||||
@ -1,37 +0,0 @@
|
||||
# 前端接口接入说明(`tyt-admin`)
|
||||
|
||||
## 1. 本次接入范围
|
||||
|
||||
- 登录页:`/auth/login`,支持可选 `hospitalId`。
|
||||
- 首页看板:按角色拉取组织与患者统计。
|
||||
- 任务页:接入 `publish/accept/complete/cancel` 四个真实任务接口。
|
||||
- 用户页:修复用户列表响应结构、组织字段联动、工程师分配医院参数。
|
||||
- 患者页:接入真实患者字段与生命周期查询参数(`phone + idCardHash`)。
|
||||
|
||||
## 2. 接口契约对齐点
|
||||
|
||||
- `GET /users` 当前返回数组,前端已在 `api/users.js` 做本地分页与筛选适配。
|
||||
- `PATCH /b/users/:id/assign-engineer-hospital` 参数为单个 `hospitalId`,非数组。
|
||||
- `GET /b/patients` 返回数组,前端已改为本地分页与筛选。
|
||||
- `GET /c/patients/lifecycle` 必须同时传 `phone` 和 `idCardHash`。
|
||||
- 任务模块暂无任务列表接口,前端改为“表单操作 + 最近结果”模式。
|
||||
|
||||
## 3. 角色权限提示
|
||||
|
||||
- 任务接口权限:
|
||||
- `DOCTOR`:发布、取消
|
||||
- `ENGINEER`:接收、完成
|
||||
- 患者列表权限:
|
||||
- `SYSTEM_ADMIN` 查询时必须传 `hospitalId`
|
||||
- 用户管理接口:
|
||||
- `SYSTEM_ADMIN/HOSPITAL_ADMIN` 可访问列表与创建
|
||||
- 删除和工程师绑定医院仅 `SYSTEM_ADMIN`
|
||||
|
||||
## 4. 本地运行
|
||||
|
||||
在 `tyt-admin` 目录执行:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
@ -1,45 +0,0 @@
|
||||
# 患者模块说明(`src/patients`)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
- B 端:按组织与角色范围查询患者(强依赖 `hospitalId`)。
|
||||
- C 端:按 `phone + idCardHash` 做跨院聚合查询。
|
||||
|
||||
## 2. B 端可见性
|
||||
|
||||
- `DOCTOR`:仅可查自己名下患者
|
||||
- `LEADER`:可查本组医生名下患者(按医生当前 `groupId` 反查)
|
||||
- `DIRECTOR`:可查本科室医生名下患者(按医生当前 `departmentId` 反查)
|
||||
- `HOSPITAL_ADMIN`:可查本院全部患者
|
||||
- `SYSTEM_ADMIN`:需显式传入目标 `hospitalId`
|
||||
|
||||
## 2.1 B 端 CRUD
|
||||
|
||||
- `GET /b/patients`:按角色查询可见患者
|
||||
- `GET /b/patients/doctors`:查询当前角色可见的医生候选(用于患者表单)
|
||||
- `POST /b/patients`:创建患者
|
||||
- `GET /b/patients/:id`:查询患者详情
|
||||
- `PATCH /b/patients/:id`:更新患者
|
||||
- `DELETE /b/patients/:id`:删除患者(若存在关联设备返回 409)
|
||||
|
||||
说明:
|
||||
患者表只绑定 `doctorId + hospitalId`,不直接绑定小组/科室。医生调组或调科后,
|
||||
可见范围会按医生当前组织归属自动变化,无需迁移患者数据。
|
||||
|
||||
## 3. C 端生命周期聚合
|
||||
|
||||
接口:`GET /c/patients/lifecycle?phone=...&idCardHash=...`
|
||||
|
||||
查询策略:
|
||||
|
||||
1. 不做医院隔离(跨租户)
|
||||
2. 双字段精确匹配 `phone + idCardHash`
|
||||
3. 关联查询 `Patient -> Device -> TaskItem -> Task`
|
||||
4. 返回扁平生命周期列表(按 `Task.createdAt DESC`)
|
||||
|
||||
## 4. 响应结构
|
||||
|
||||
全部接口统一返回:
|
||||
|
||||
- 成功:`{ code: 0, msg: "成功", data: ... }`
|
||||
- 失败:`{ code: x, msg: "中文错误", data: null }`
|
||||
@ -1,40 +0,0 @@
|
||||
# 调压任务模块说明(`src/tasks`)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
- 管理调压主任务 `Task` 与明细 `TaskItem`。
|
||||
- 支持状态机流转与事件触发,保证设备压力同步更新。
|
||||
|
||||
## 2. 状态机
|
||||
|
||||
- `PENDING -> ACCEPTED -> COMPLETED`
|
||||
- `PENDING/ACCEPTED -> CANCELLED`
|
||||
|
||||
非法流转会返回 `409` 冲突错误(中文消息)。
|
||||
|
||||
## 3. 角色权限
|
||||
|
||||
- 医生:发布任务、取消自己创建的任务
|
||||
- 工程师:接收任务、完成自己接收的任务
|
||||
- 其他角色:默认拒绝
|
||||
|
||||
## 4. 事件触发
|
||||
|
||||
状态变化后会发出事件:
|
||||
|
||||
- `task.published`
|
||||
- `task.accepted`
|
||||
- `task.completed`
|
||||
- `task.cancelled`
|
||||
|
||||
用于后续接入微信通知或消息中心。
|
||||
|
||||
## 5. 完成任务时的设备同步
|
||||
|
||||
`completeTask` 在单事务中执行:
|
||||
|
||||
1. 更新任务状态为 `COMPLETED`
|
||||
2. 读取 `TaskItem.targetPressure`
|
||||
3. 批量更新关联 `Device.currentPressure`
|
||||
|
||||
确保任务状态与设备压力一致性。
|
||||
@ -1,38 +0,0 @@
|
||||
# 用户与权限模块说明(`src/users`)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
- 管理用户基础信息(姓名、手机号、角色、组织归属)。
|
||||
- 维护 B 端角色权限边界和工程师绑定医院逻辑。
|
||||
|
||||
## 2. 角色枚举
|
||||
|
||||
- `SYSTEM_ADMIN`:系统管理员
|
||||
- `HOSPITAL_ADMIN`:院管
|
||||
- `DIRECTOR`:主任
|
||||
- `LEADER`:组长
|
||||
- `DOCTOR`:医生
|
||||
- `ENGINEER`:工程师
|
||||
|
||||
## 3. 关键规则
|
||||
|
||||
- 医院内数据按 `hospitalId` 强隔离。
|
||||
- 仅 `SYSTEM_ADMIN` 可执行工程师绑定医院。
|
||||
- 用户组织字段校验:
|
||||
- 院管/医生/工程师等需有医院归属;
|
||||
- 主任/组长需有科室/小组等必要归属;
|
||||
- 系统管理员不能绑定院内组织字段。
|
||||
- 更新用户时,仅允许医生调整 `departmentId/groupId`(后端强约束)。
|
||||
- 科室/小组父级关系冻结:不允许通过更新接口迁移科室所属医院或小组所属科室。
|
||||
|
||||
## 4. 典型接口
|
||||
|
||||
- `GET /users`、`GET /users/:id`、`PATCH /users/:id`、`DELETE /users/:id`
|
||||
- `POST /b/users/:id/assign-engineer-hospital`
|
||||
|
||||
## 5. 开发改造建议
|
||||
|
||||
- 若增加角色,请同步修改:
|
||||
- Prisma `Role` 枚举
|
||||
- `roles.guard.ts` 与各 Service 权限判断
|
||||
- Swagger DTO 中文说明
|
||||
20
package.json
20
package.json
@ -12,47 +12,31 @@
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"test:e2e:prepare": "pnpm prisma migrate reset --force && pnpm prisma generate && node prisma/seed.mjs",
|
||||
"test:e2e": "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"
|
||||
"start:prod": "node dist/main"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/event-emitter": "^3.0.1",
|
||||
"@nestjs/mapped-types": "*",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/swagger": "^11.2.6",
|
||||
"@prisma/adapter-pg": "^7.5.0",
|
||||
"@prisma/client": "^7.5.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"pg": "^8.20.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.1",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^22.10.7",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"globals": "^16.0.0",
|
||||
"jest": "^30.3.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prisma": "^7.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.4.6",
|
||||
"ts-loader": "^9.5.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
|
||||
2688
pnpm-lock.yaml
generated
2688
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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,154 +1,30 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
|
||||
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
}
|
||||
|
||||
// 兼容 seed 脚本在 Node.js 直接运行时使用 @prisma/client runtime。
|
||||
generator seed_client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
// 角色枚举:用于鉴权与数据可见性控制。
|
||||
enum Role {
|
||||
SYSTEM_ADMIN
|
||||
HOSPITAL_ADMIN
|
||||
DIRECTOR
|
||||
LEADER
|
||||
DOCTOR
|
||||
ENGINEER
|
||||
}
|
||||
|
||||
// 设备状态枚举:表示设备是否处于使用中。
|
||||
enum DeviceStatus {
|
||||
ACTIVE
|
||||
INACTIVE
|
||||
}
|
||||
|
||||
// 任务状态枚举:定义任务流转状态机。
|
||||
enum TaskStatus {
|
||||
PENDING
|
||||
ACCEPTED
|
||||
COMPLETED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
// 医院主表:多租户顶层实体。
|
||||
model Hospital {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
departments Department[]
|
||||
users User[]
|
||||
patients Patient[]
|
||||
tasks Task[]
|
||||
}
|
||||
|
||||
// 科室表:归属于医院。
|
||||
model Department {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
hospitalId Int
|
||||
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
||||
groups Group[]
|
||||
users User[]
|
||||
|
||||
@@index([hospitalId])
|
||||
}
|
||||
|
||||
// 小组表:归属于科室。
|
||||
model Group {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
departmentId Int
|
||||
department Department @relation(fields: [departmentId], references: [id])
|
||||
users User[]
|
||||
|
||||
@@index([departmentId])
|
||||
}
|
||||
|
||||
// 用户表:支持后台密码登录与小程序 openId。
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
phone String
|
||||
// 后台登录密码哈希(bcrypt)。
|
||||
passwordHash String?
|
||||
openId String? @unique
|
||||
role Role
|
||||
hospitalId Int?
|
||||
departmentId Int?
|
||||
groupId Int?
|
||||
hospital Hospital? @relation(fields: [hospitalId], references: [id])
|
||||
department Department? @relation(fields: [departmentId], references: [id])
|
||||
group Group? @relation(fields: [groupId], references: [id])
|
||||
doctorPatients Patient[] @relation("DoctorPatients")
|
||||
createdTasks Task[] @relation("TaskCreator")
|
||||
acceptedTasks Task[] @relation("TaskEngineer")
|
||||
|
||||
@@index([phone])
|
||||
@@index([hospitalId, role])
|
||||
@@index([departmentId, role])
|
||||
@@index([groupId, role])
|
||||
id Int @id @default(autoincrement())
|
||||
email String @unique
|
||||
name String?
|
||||
posts Post[]
|
||||
}
|
||||
|
||||
// 患者表:院内患者档案,按医院隔离。
|
||||
model Patient {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
phone String
|
||||
idCardHash String
|
||||
hospitalId Int
|
||||
doctorId Int
|
||||
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
||||
doctor User @relation("DoctorPatients", fields: [doctorId], references: [id])
|
||||
devices Device[]
|
||||
|
||||
@@index([phone, idCardHash])
|
||||
@@index([hospitalId, doctorId])
|
||||
}
|
||||
|
||||
// 设备表:患者可绑定多个分流设备。
|
||||
model Device {
|
||||
id Int @id @default(autoincrement())
|
||||
snCode String @unique
|
||||
currentPressure Int
|
||||
status DeviceStatus @default(ACTIVE)
|
||||
patientId Int
|
||||
patient Patient @relation(fields: [patientId], references: [id])
|
||||
taskItems TaskItem[]
|
||||
|
||||
@@index([patientId, status])
|
||||
}
|
||||
|
||||
// 主任务表:记录调压任务主单。
|
||||
model Task {
|
||||
id Int @id @default(autoincrement())
|
||||
status TaskStatus @default(PENDING)
|
||||
creatorId Int
|
||||
engineerId Int?
|
||||
hospitalId Int
|
||||
createdAt DateTime @default(now())
|
||||
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 Int
|
||||
targetPressure Int
|
||||
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
|
||||
device Device @relation(fields: [deviceId], references: [id])
|
||||
|
||||
@@index([taskId])
|
||||
@@index([deviceId])
|
||||
model Post {
|
||||
id Int @id @default(autoincrement())
|
||||
title String
|
||||
content String?
|
||||
published Boolean? @default(false)
|
||||
author User? @relation(fields: [authorId], references: [id])
|
||||
authorId Int?
|
||||
}
|
||||
|
||||
449
prisma/seed.mjs
449
prisma/seed.mjs
@ -1,449 +0,0 @@
|
||||
import 'dotenv/config';
|
||||
import { PrismaPg } from '@prisma/adapter-pg';
|
||||
import { hash } from 'bcrypt';
|
||||
import prismaClientPackage from '@prisma/client';
|
||||
|
||||
const { 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 upsertUserByOpenId(openId, data) {
|
||||
return prisma.user.upsert({
|
||||
where: { openId },
|
||||
update: data,
|
||||
create: {
|
||||
...data,
|
||||
openId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function ensurePatient({
|
||||
hospitalId,
|
||||
doctorId,
|
||||
name,
|
||||
phone,
|
||||
idCardHash,
|
||||
}) {
|
||||
const existing = await prisma.patient.findFirst({
|
||||
where: {
|
||||
hospitalId,
|
||||
phone,
|
||||
idCardHash,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (existing.doctorId !== doctorId || existing.name !== name) {
|
||||
return prisma.patient.update({
|
||||
where: { id: existing.id },
|
||||
data: { doctorId, name },
|
||||
});
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
return prisma.patient.create({
|
||||
data: {
|
||||
hospitalId,
|
||||
doctorId,
|
||||
name,
|
||||
phone,
|
||||
idCardHash,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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 upsertUserByOpenId('seed-system-admin-openid', {
|
||||
name: 'Seed System Admin',
|
||||
phone: '13800001000',
|
||||
passwordHash: seedPasswordHash,
|
||||
role: Role.SYSTEM_ADMIN,
|
||||
hospitalId: null,
|
||||
departmentId: null,
|
||||
groupId: null,
|
||||
});
|
||||
|
||||
const hospitalAdminA = await upsertUserByOpenId(
|
||||
'seed-hospital-admin-a-openid',
|
||||
{
|
||||
name: 'Seed Hospital Admin A',
|
||||
phone: '13800001001',
|
||||
passwordHash: seedPasswordHash,
|
||||
role: Role.HOSPITAL_ADMIN,
|
||||
hospitalId: hospitalA.id,
|
||||
departmentId: null,
|
||||
groupId: null,
|
||||
},
|
||||
);
|
||||
|
||||
await upsertUserByOpenId('seed-hospital-admin-b-openid', {
|
||||
name: 'Seed Hospital Admin B',
|
||||
phone: '13800001101',
|
||||
passwordHash: seedPasswordHash,
|
||||
role: Role.HOSPITAL_ADMIN,
|
||||
hospitalId: hospitalB.id,
|
||||
departmentId: null,
|
||||
groupId: null,
|
||||
});
|
||||
|
||||
const directorA = await upsertUserByOpenId('seed-director-a-openid', {
|
||||
name: 'Seed Director A',
|
||||
phone: '13800001002',
|
||||
passwordHash: seedPasswordHash,
|
||||
role: Role.DIRECTOR,
|
||||
hospitalId: hospitalA.id,
|
||||
departmentId: departmentA1.id,
|
||||
groupId: null,
|
||||
});
|
||||
|
||||
const leaderA = await upsertUserByOpenId('seed-leader-a-openid', {
|
||||
name: 'Seed Leader A',
|
||||
phone: '13800001003',
|
||||
passwordHash: seedPasswordHash,
|
||||
role: Role.LEADER,
|
||||
hospitalId: hospitalA.id,
|
||||
departmentId: departmentA1.id,
|
||||
groupId: groupA1.id,
|
||||
});
|
||||
|
||||
const doctorA = await upsertUserByOpenId('seed-doctor-a-openid', {
|
||||
name: 'Seed Doctor A',
|
||||
phone: '13800001004',
|
||||
passwordHash: seedPasswordHash,
|
||||
role: Role.DOCTOR,
|
||||
hospitalId: hospitalA.id,
|
||||
departmentId: departmentA1.id,
|
||||
groupId: groupA1.id,
|
||||
});
|
||||
|
||||
const doctorA2 = await upsertUserByOpenId('seed-doctor-a2-openid', {
|
||||
name: 'Seed Doctor A2',
|
||||
phone: '13800001204',
|
||||
passwordHash: seedPasswordHash,
|
||||
role: Role.DOCTOR,
|
||||
hospitalId: hospitalA.id,
|
||||
departmentId: departmentA1.id,
|
||||
groupId: groupA1.id,
|
||||
});
|
||||
|
||||
const doctorA3 = await upsertUserByOpenId('seed-doctor-a3-openid', {
|
||||
name: 'Seed Doctor A3',
|
||||
phone: '13800001304',
|
||||
passwordHash: seedPasswordHash,
|
||||
role: Role.DOCTOR,
|
||||
hospitalId: hospitalA.id,
|
||||
departmentId: departmentA2.id,
|
||||
groupId: groupA2.id,
|
||||
});
|
||||
|
||||
const doctorB = await upsertUserByOpenId('seed-doctor-b-openid', {
|
||||
name: 'Seed Doctor B',
|
||||
phone: '13800001104',
|
||||
passwordHash: seedPasswordHash,
|
||||
role: Role.DOCTOR,
|
||||
hospitalId: hospitalB.id,
|
||||
departmentId: departmentB1.id,
|
||||
groupId: groupB1.id,
|
||||
});
|
||||
|
||||
const engineerA = await upsertUserByOpenId('seed-engineer-a-openid', {
|
||||
name: 'Seed Engineer A',
|
||||
phone: '13800001005',
|
||||
passwordHash: seedPasswordHash,
|
||||
role: Role.ENGINEER,
|
||||
hospitalId: hospitalA.id,
|
||||
departmentId: null,
|
||||
groupId: null,
|
||||
});
|
||||
|
||||
const engineerB = await upsertUserByOpenId('seed-engineer-b-openid', {
|
||||
name: 'Seed Engineer B',
|
||||
phone: '13800001105',
|
||||
passwordHash: seedPasswordHash,
|
||||
role: Role.ENGINEER,
|
||||
hospitalId: hospitalB.id,
|
||||
departmentId: null,
|
||||
groupId: null,
|
||||
});
|
||||
|
||||
const patientA1 = await ensurePatient({
|
||||
hospitalId: hospitalA.id,
|
||||
doctorId: doctorA.id,
|
||||
name: 'Seed Patient A1',
|
||||
phone: '13800002001',
|
||||
idCardHash: 'seed-id-card-cross-hospital',
|
||||
});
|
||||
|
||||
const patientA2 = await ensurePatient({
|
||||
hospitalId: hospitalA.id,
|
||||
doctorId: doctorA2.id,
|
||||
name: 'Seed Patient A2',
|
||||
phone: '13800002002',
|
||||
idCardHash: 'seed-id-card-a2',
|
||||
});
|
||||
|
||||
const patientA3 = await ensurePatient({
|
||||
hospitalId: hospitalA.id,
|
||||
doctorId: doctorA3.id,
|
||||
name: 'Seed Patient A3',
|
||||
phone: '13800002003',
|
||||
idCardHash: 'seed-id-card-a3',
|
||||
});
|
||||
|
||||
const patientB1 = await ensurePatient({
|
||||
hospitalId: hospitalB.id,
|
||||
doctorId: doctorB.id,
|
||||
name: 'Seed Patient B1',
|
||||
phone: '13800002001',
|
||||
idCardHash: 'seed-id-card-cross-hospital',
|
||||
});
|
||||
|
||||
const deviceA1 = await prisma.device.upsert({
|
||||
where: { snCode: 'SEED-SN-A-001' },
|
||||
update: {
|
||||
patientId: patientA1.id,
|
||||
currentPressure: 118,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
},
|
||||
create: {
|
||||
snCode: 'SEED-SN-A-001',
|
||||
patientId: patientA1.id,
|
||||
currentPressure: 118,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
const deviceA2 = await prisma.device.upsert({
|
||||
where: { snCode: 'SEED-SN-A-002' },
|
||||
update: {
|
||||
patientId: patientA2.id,
|
||||
currentPressure: 112,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
},
|
||||
create: {
|
||||
snCode: 'SEED-SN-A-002',
|
||||
patientId: patientA2.id,
|
||||
currentPressure: 112,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.device.upsert({
|
||||
where: { snCode: 'SEED-SN-A-003' },
|
||||
update: {
|
||||
patientId: patientA3.id,
|
||||
currentPressure: 109,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
},
|
||||
create: {
|
||||
snCode: 'SEED-SN-A-003',
|
||||
patientId: patientA3.id,
|
||||
currentPressure: 109,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
const deviceB1 = await prisma.device.upsert({
|
||||
where: { snCode: 'SEED-SN-B-001' },
|
||||
update: {
|
||||
patientId: patientB1.id,
|
||||
currentPressure: 121,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
},
|
||||
create: {
|
||||
snCode: 'SEED-SN-B-001',
|
||||
patientId: patientB1.id,
|
||||
currentPressure: 121,
|
||||
status: DeviceStatus.ACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.device.upsert({
|
||||
where: { snCode: 'SEED-SN-A-004' },
|
||||
update: {
|
||||
patientId: patientA1.id,
|
||||
currentPressure: 130,
|
||||
status: DeviceStatus.INACTIVE,
|
||||
},
|
||||
create: {
|
||||
snCode: 'SEED-SN-A-004',
|
||||
patientId: patientA1.id,
|
||||
currentPressure: 130,
|
||||
status: DeviceStatus.INACTIVE,
|
||||
},
|
||||
});
|
||||
|
||||
// 清理与种子设备关联的历史任务,保证 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();
|
||||
});
|
||||
@ -1,23 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { PrismaModule } from './prisma.module.js';
|
||||
import { UsersModule } from './users/users.module.js';
|
||||
import { TasksModule } from './tasks/tasks.module.js';
|
||||
import { PatientsModule } from './patients/patients.module.js';
|
||||
import { AuthModule } from './auth/auth.module.js';
|
||||
import { OrganizationModule } from './organization/organization.module.js';
|
||||
import { NotificationsModule } from './notifications/notifications.module.js';
|
||||
import { UsersModule } from './users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PrismaModule,
|
||||
EventEmitterModule.forRoot(),
|
||||
UsersModule,
|
||||
TasksModule,
|
||||
PatientsModule,
|
||||
AuthModule,
|
||||
OrganizationModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
imports: [UsersModule],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@ -1,102 +0,0 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { Role } from '../generated/prisma/enums.js';
|
||||
import type { ActorContext } from '../common/actor-context.js';
|
||||
import { MESSAGES } from '../common/messages.js';
|
||||
|
||||
/**
|
||||
* AccessToken 守卫:校验 Bearer JWT 并把 actor 注入到 request 上下文。
|
||||
*/
|
||||
@Injectable()
|
||||
export class AccessTokenGuard implements CanActivate {
|
||||
/**
|
||||
* 守卫入口:认证通过返回 true,失败抛出 401。
|
||||
*/
|
||||
canActivate(context: ExecutionContext): 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 = this.verifyAndExtractActor(token);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并验证 token,同时提取最小化 actor 上下文。
|
||||
*/
|
||||
private verifyAndExtractActor(token: string): 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 role = payload.role;
|
||||
if (typeof role !== 'string' || !Object.values(Role).includes(role as Role)) {
|
||||
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_ROLE_INVALID);
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.asInt(payload.id, 'id'),
|
||||
role: role as Role,
|
||||
hospitalId: this.asNullableInt(payload.hospitalId, 'hospitalId'),
|
||||
departmentId: this.asNullableInt(payload.departmentId, 'departmentId'),
|
||||
groupId: this.asNullableInt(payload.groupId, 'groupId'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格校验 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格校验 token 中可空整数的字段。
|
||||
*/
|
||||
private asNullableInt(value: unknown, field: string): number | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
||||
throw new UnauthorizedException(`${MESSAGES.AUTH.TOKEN_FIELD_INVALID}: ${field}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { RegisterUserDto } from '../users/dto/register-user.dto.js';
|
||||
import { LoginDto } from '../users/dto/login.dto.js';
|
||||
import { AccessTokenGuard } from './access-token.guard.js';
|
||||
import { CurrentActor } from './current-actor.decorator.js';
|
||||
import type { ActorContext } from '../common/actor-context.js';
|
||||
|
||||
/**
|
||||
* 认证控制器:提供注册、登录、获取当前登录用户信息接口。
|
||||
*/
|
||||
@ApiTags('认证')
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
/**
|
||||
* 注册账号。
|
||||
*/
|
||||
@Post('register')
|
||||
@ApiOperation({ summary: '注册账号' })
|
||||
register(@Body() dto: RegisterUserDto) {
|
||||
return this.authService.register(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录并换取 JWT。
|
||||
*/
|
||||
@Post('login')
|
||||
@ApiOperation({ summary: '登录' })
|
||||
login(@Body() dto: LoginDto) {
|
||||
return this.authService.login(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录用户信息。
|
||||
*/
|
||||
@Get('me')
|
||||
@UseGuards(AccessTokenGuard)
|
||||
@ApiBearerAuth('bearer')
|
||||
@ApiOperation({ summary: '获取当前用户信息' })
|
||||
me(@CurrentActor() actor: ActorContext) {
|
||||
return this.authService.me(actor);
|
||||
}
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service.js';
|
||||
import { AuthController } from './auth.controller.js';
|
||||
import { UsersModule } from '../users/users.module.js';
|
||||
import { AccessTokenGuard } from './access-token.guard.js';
|
||||
|
||||
/**
|
||||
* 认证模块:聚合认证控制器、服务与基础鉴权守卫。
|
||||
*/
|
||||
@Module({
|
||||
imports: [UsersModule],
|
||||
providers: [AuthService, AccessTokenGuard],
|
||||
controllers: [AuthController],
|
||||
exports: [AuthService, AccessTokenGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@ -1,34 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { ActorContext } from '../common/actor-context.js';
|
||||
import { UsersService } from '../users/users.service.js';
|
||||
import { RegisterUserDto } from '../users/dto/register-user.dto.js';
|
||||
import { LoginDto } from '../users/dto/login.dto.js';
|
||||
|
||||
/**
|
||||
* 认证服务:将控制层输入转发到用户域能力,避免控制器直接操作用户仓储。
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
/**
|
||||
* 注册能力委托给用户服务。
|
||||
*/
|
||||
register(dto: RegisterUserDto) {
|
||||
return this.usersService.register(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录能力委托给用户服务。
|
||||
*/
|
||||
login(dto: LoginDto) {
|
||||
return this.usersService.login(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取当前登录用户详情。
|
||||
*/
|
||||
me(actor: ActorContext) {
|
||||
return this.usersService.me(actor);
|
||||
}
|
||||
}
|
||||
@ -1,12 +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,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,40 +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;
|
||||
}
|
||||
}
|
||||
@ -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,120 +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,108 +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_ROLE_INVALID: 'Token 中角色信息不合法',
|
||||
TOKEN_FIELD_INVALID: 'Token 中字段不合法',
|
||||
INVALID_CREDENTIALS: '手机号、角色或密码错误',
|
||||
PASSWORD_NOT_ENABLED: '该账号未启用密码登录',
|
||||
},
|
||||
|
||||
USER: {
|
||||
NOT_FOUND: '用户不存在',
|
||||
DUPLICATE_OPEN_ID: 'openId 已被注册',
|
||||
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: '用户存在关联患者或任务,无法删除',
|
||||
MULTI_ACCOUNT_REQUIRE_HOSPITAL:
|
||||
'检测到多个同手机号账号,请传 hospitalId 指定登录医院',
|
||||
},
|
||||
|
||||
TASK: {
|
||||
ITEMS_REQUIRED: '任务明细 items 不能为空',
|
||||
DEVICE_NOT_FOUND: '存在设备不在当前医院或设备不存在',
|
||||
ENGINEER_INVALID: '工程师必须为当前医院有效工程师',
|
||||
TASK_NOT_FOUND: '任务不存在或不属于当前医院',
|
||||
ACCEPT_ONLY_PENDING: '仅待接收任务可执行接收',
|
||||
COMPLETE_ONLY_ACCEPTED: '仅已接收任务可执行完成',
|
||||
CANCEL_ONLY_PENDING_ACCEPTED: '仅待接收/已接收任务可取消',
|
||||
ENGINEER_ALREADY_ASSIGNED: '任务已被其他工程师接收',
|
||||
ENGINEER_ONLY_ASSIGNEE: '仅任务接收工程师可完成任务',
|
||||
CANCEL_ONLY_CREATOR: '仅任务创建医生可取消任务',
|
||||
ACTOR_ROLE_FORBIDDEN: '当前角色无权限执行该任务操作',
|
||||
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
|
||||
},
|
||||
|
||||
PATIENT: {
|
||||
NOT_FOUND: '患者不存在或无权限访问',
|
||||
ROLE_FORBIDDEN: '当前角色无权限查询患者列表',
|
||||
GROUP_REQUIRED: '组长查询需携带 groupId',
|
||||
DEPARTMENT_REQUIRED: '主任查询需携带 departmentId',
|
||||
DOCTOR_NOT_FOUND: '归属医生不存在',
|
||||
DOCTOR_ROLE_REQUIRED: '归属用户必须为医生角色',
|
||||
DOCTOR_SCOPE_FORBIDDEN: '仅可选择当前权限范围内医生',
|
||||
DELETE_CONFLICT: '患者存在关联设备,无法删除',
|
||||
PHONE_IDCARD_REQUIRED: 'phone 与 idCardHash 均为必填',
|
||||
LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证哈希',
|
||||
SYSTEM_ADMIN_HOSPITAL_REQUIRED: '系统管理员查询必须显式传入 hospitalId',
|
||||
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
|
||||
},
|
||||
|
||||
ORG: {
|
||||
HOSPITAL_NOT_FOUND: '医院不存在',
|
||||
DEPARTMENT_NOT_FOUND: '科室不存在',
|
||||
GROUP_NOT_FOUND: '小组不存在',
|
||||
HOSPITAL_ADMIN_SCOPE_INVALID: '院管仅可操作本院组织数据',
|
||||
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: '小组不允许更换所属科室',
|
||||
DELETE_CONFLICT:
|
||||
'存在关联数据,无法删除,请先清理用户、患者、任务或下级组织后重试',
|
||||
},
|
||||
} as const;
|
||||
@ -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,108 +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)
|
||||
@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)
|
||||
@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,127 +0,0 @@
|
||||
import { BadRequestException, 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]);
|
||||
const paging = this.access.resolvePaging(query);
|
||||
const where: Prisma.DepartmentWhereInput = {};
|
||||
|
||||
if (query.keyword) {
|
||||
where.name = { contains: query.keyword.trim(), mode: 'insensitive' };
|
||||
}
|
||||
|
||||
const targetHospitalId =
|
||||
actor.role === Role.HOSPITAL_ADMIN ? actor.hospitalId : query.hospitalId;
|
||||
if (targetHospitalId != null) {
|
||||
where.hospitalId = this.access.toInt(targetHospitalId, MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
|
||||
}
|
||||
if (actor.role === Role.HOSPITAL_ADMIN && where.hospitalId == null) {
|
||||
throw new BadRequestException(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]);
|
||||
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);
|
||||
}
|
||||
this.access.assertHospitalScope(actor, department.hospitalId);
|
||||
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) {
|
||||
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,18 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, IsString, Min } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 创建小组 DTO。
|
||||
*/
|
||||
export class CreateGroupDto {
|
||||
@ApiProperty({ description: '小组名称', example: 'A组' })
|
||||
@IsString({ message: 'name 必须是字符串' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: '科室 ID', example: 1 })
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'departmentId 必须是整数' })
|
||||
@Min(1, { message: 'departmentId 必须大于 0' })
|
||||
departmentId!: number;
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
import { ApiHideProperty, OmitType, PartialType } from '@nestjs/swagger';
|
||||
import { CreateGroupDto } from './create-group.dto.js';
|
||||
import { IsEmpty, IsOptional } from 'class-validator';
|
||||
import { MESSAGES } from '../../common/messages.js';
|
||||
|
||||
/**
|
||||
* 更新小组 DTO。
|
||||
*/
|
||||
class UpdateGroupNameDto extends PartialType(
|
||||
OmitType(CreateGroupDto, ['departmentId'] as const),
|
||||
) {}
|
||||
|
||||
export class UpdateGroupDto extends UpdateGroupNameDto {
|
||||
@ApiHideProperty()
|
||||
@IsOptional()
|
||||
@IsEmpty({ message: MESSAGES.ORG.GROUP_REPARENT_FORBIDDEN })
|
||||
departmentId?: unknown;
|
||||
}
|
||||
@ -1 +0,0 @@
|
||||
export class Group {}
|
||||
@ -1,106 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type { ActorContext } from '../common/actor-context.js';
|
||||
import { AccessTokenGuard } from '../auth/access-token.guard.js';
|
||||
import { CurrentActor } from '../auth/current-actor.decorator.js';
|
||||
import { Roles } from '../auth/roles.decorator.js';
|
||||
import { RolesGuard } from '../auth/roles.guard.js';
|
||||
import { Role } from '../generated/prisma/enums.js';
|
||||
import { GroupsService } from './groups.service.js';
|
||||
import { CreateGroupDto } from './dto/create-group.dto.js';
|
||||
import { UpdateGroupDto } from './dto/update-group.dto.js';
|
||||
import { OrganizationQueryDto } from '../organization-common/dto/organization-query.dto.js';
|
||||
|
||||
/**
|
||||
* 小组管理控制器:拆分自组织大控制器,专注小组资源。
|
||||
*/
|
||||
@ApiTags('小组管理(B端)')
|
||||
@ApiBearerAuth('bearer')
|
||||
@Controller('b/organization/groups')
|
||||
@UseGuards(AccessTokenGuard, RolesGuard)
|
||||
export class GroupsController {
|
||||
constructor(private readonly groupsService: GroupsService) {}
|
||||
|
||||
/**
|
||||
* 创建小组。
|
||||
*/
|
||||
@Post()
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@ApiOperation({ summary: '创建小组' })
|
||||
create(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Body() dto: CreateGroupDto,
|
||||
) {
|
||||
return this.groupsService.create(actor, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询小组列表。
|
||||
*/
|
||||
@Get()
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@ApiOperation({ summary: '查询小组列表' })
|
||||
findAll(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Query() query: OrganizationQueryDto,
|
||||
) {
|
||||
return this.groupsService.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.groupsService.findOne(actor, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新小组。
|
||||
*/
|
||||
@Patch(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@ApiOperation({ summary: '更新小组' })
|
||||
update(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateGroupDto,
|
||||
) {
|
||||
return this.groupsService.update(actor, id, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除小组。
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@ApiOperation({ summary: '删除小组' })
|
||||
remove(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
) {
|
||||
return this.groupsService.remove(actor, id);
|
||||
}
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { GroupsService } from './groups.service.js';
|
||||
import { GroupsController } from './groups.controller.js';
|
||||
import { AccessTokenGuard } from '../auth/access-token.guard.js';
|
||||
import { RolesGuard } from '../auth/roles.guard.js';
|
||||
import { OrganizationAccessService } from '../organization-common/organization-access.service.js';
|
||||
|
||||
/**
|
||||
* 小组资源模块:聚合小组控制器与服务。
|
||||
*/
|
||||
@Module({
|
||||
controllers: [GroupsController],
|
||||
providers: [GroupsService, OrganizationAccessService, AccessTokenGuard, RolesGuard],
|
||||
exports: [GroupsService],
|
||||
})
|
||||
export class GroupsModule {}
|
||||
@ -1,135 +0,0 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '../generated/prisma/client.js';
|
||||
import { Role } from '../generated/prisma/enums.js';
|
||||
import type { ActorContext } from '../common/actor-context.js';
|
||||
import { MESSAGES } from '../common/messages.js';
|
||||
import { PrismaService } from '../prisma.service.js';
|
||||
import { OrganizationAccessService } from '../organization-common/organization-access.service.js';
|
||||
import { CreateGroupDto } from './dto/create-group.dto.js';
|
||||
import { UpdateGroupDto } from './dto/update-group.dto.js';
|
||||
import { OrganizationQueryDto } from '../organization-common/dto/organization-query.dto.js';
|
||||
|
||||
/**
|
||||
* 小组资源服务:聚焦小组实体 CRUD 与院级作用域约束。
|
||||
*/
|
||||
@Injectable()
|
||||
export class GroupsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly access: OrganizationAccessService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建小组:系统管理员可跨院;院管仅可在本院科室下创建。
|
||||
*/
|
||||
async create(actor: ActorContext, dto: CreateGroupDto) {
|
||||
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
|
||||
const departmentId = this.access.toInt(dto.departmentId, MESSAGES.ORG.DEPARTMENT_ID_REQUIRED);
|
||||
const department = await this.access.ensureDepartmentExists(departmentId);
|
||||
this.access.assertHospitalScope(actor, department.hospitalId);
|
||||
|
||||
return this.prisma.group.create({
|
||||
data: {
|
||||
name: this.access.normalizeName(dto.name, MESSAGES.ORG.GROUP_NAME_REQUIRED),
|
||||
departmentId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询小组列表:院管默认仅返回本院数据。
|
||||
*/
|
||||
async findAll(actor: ActorContext, query: OrganizationQueryDto) {
|
||||
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
|
||||
const paging = this.access.resolvePaging(query);
|
||||
const where: Prisma.GroupWhereInput = {};
|
||||
|
||||
if (query.keyword) {
|
||||
where.name = { contains: query.keyword.trim(), mode: 'insensitive' };
|
||||
}
|
||||
if (query.departmentId != null) {
|
||||
where.departmentId = this.access.toInt(query.departmentId, MESSAGES.ORG.DEPARTMENT_ID_REQUIRED);
|
||||
}
|
||||
|
||||
if (actor.role === Role.HOSPITAL_ADMIN) {
|
||||
if (!actor.hospitalId) {
|
||||
throw new BadRequestException(MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
|
||||
}
|
||||
where.department = { hospitalId: actor.hospitalId };
|
||||
} else if (query.hospitalId != null) {
|
||||
where.department = {
|
||||
hospitalId: this.access.toInt(query.hospitalId, MESSAGES.ORG.HOSPITAL_ID_REQUIRED),
|
||||
};
|
||||
}
|
||||
|
||||
const [total, list] = await this.prisma.$transaction([
|
||||
this.prisma.group.count({ where }),
|
||||
this.prisma.group.findMany({
|
||||
where,
|
||||
include: {
|
||||
department: { include: { hospital: true } },
|
||||
_count: { select: { users: true } },
|
||||
},
|
||||
skip: paging.skip,
|
||||
take: paging.take,
|
||||
orderBy: { id: 'desc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { total, ...paging, list };
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询小组详情:院管仅可查看本院小组。
|
||||
*/
|
||||
async findOne(actor: ActorContext, id: number) {
|
||||
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
|
||||
const groupId = this.access.toInt(id, MESSAGES.ORG.GROUP_ID_REQUIRED);
|
||||
const group = await this.prisma.group.findUnique({
|
||||
where: { id: groupId },
|
||||
include: {
|
||||
department: { include: { hospital: true } },
|
||||
_count: { select: { users: true } },
|
||||
},
|
||||
});
|
||||
if (!group) {
|
||||
throw new NotFoundException(MESSAGES.ORG.GROUP_NOT_FOUND);
|
||||
}
|
||||
this.access.assertHospitalScope(actor, group.department.hospital.id);
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新小组:院管仅可修改本院小组。
|
||||
*/
|
||||
async update(actor: ActorContext, id: number, dto: UpdateGroupDto) {
|
||||
const current = await this.findOne(actor, id);
|
||||
const data: Prisma.GroupUpdateInput = {};
|
||||
|
||||
if (dto.departmentId !== undefined) {
|
||||
throw new BadRequestException(MESSAGES.ORG.GROUP_REPARENT_FORBIDDEN);
|
||||
}
|
||||
|
||||
if (dto.name !== undefined) {
|
||||
data.name = this.access.normalizeName(dto.name, MESSAGES.ORG.GROUP_NAME_REQUIRED);
|
||||
}
|
||||
|
||||
return this.prisma.group.update({
|
||||
where: { id: current.id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除小组:院管仅可删除本院小组。
|
||||
*/
|
||||
async remove(actor: ActorContext, id: number) {
|
||||
const current = await this.findOne(actor, id);
|
||||
try {
|
||||
return await this.prisma.group.delete({ where: { id: current.id } });
|
||||
} catch (error) {
|
||||
this.access.handleDeleteConflict(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 创建医院 DTO。
|
||||
*/
|
||||
export class CreateHospitalDto {
|
||||
@ApiProperty({ description: '医院名称', example: '示例人民医院' })
|
||||
@IsString({ message: 'name 必须是字符串' })
|
||||
name!: string;
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateHospitalDto } from './create-hospital.dto.js';
|
||||
|
||||
/**
|
||||
* 更新医院 DTO。
|
||||
*/
|
||||
export class UpdateHospitalDto extends PartialType(CreateHospitalDto) {}
|
||||
@ -1 +0,0 @@
|
||||
export class Hospital {}
|
||||
@ -1,106 +0,0 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import type { ActorContext } from '../common/actor-context.js';
|
||||
import { AccessTokenGuard } from '../auth/access-token.guard.js';
|
||||
import { CurrentActor } from '../auth/current-actor.decorator.js';
|
||||
import { Roles } from '../auth/roles.decorator.js';
|
||||
import { RolesGuard } from '../auth/roles.guard.js';
|
||||
import { Role } from '../generated/prisma/enums.js';
|
||||
import { HospitalsService } from './hospitals.service.js';
|
||||
import { CreateHospitalDto } from './dto/create-hospital.dto.js';
|
||||
import { UpdateHospitalDto } from './dto/update-hospital.dto.js';
|
||||
import { OrganizationQueryDto } from '../organization-common/dto/organization-query.dto.js';
|
||||
|
||||
/**
|
||||
* 医院管理控制器:拆分自组织大控制器,专注医院资源。
|
||||
*/
|
||||
@ApiTags('医院管理(B端)')
|
||||
@ApiBearerAuth('bearer')
|
||||
@Controller('b/organization/hospitals')
|
||||
@UseGuards(AccessTokenGuard, RolesGuard)
|
||||
export class HospitalsController {
|
||||
constructor(private readonly hospitalsService: HospitalsService) {}
|
||||
|
||||
/**
|
||||
* 创建医院(仅系统管理员)。
|
||||
*/
|
||||
@Post()
|
||||
@Roles(Role.SYSTEM_ADMIN)
|
||||
@ApiOperation({ summary: '创建医院(SYSTEM_ADMIN)' })
|
||||
create(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Body() dto: CreateHospitalDto,
|
||||
) {
|
||||
return this.hospitalsService.create(actor, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询医院列表(系统管理员全量,院管仅本院)。
|
||||
*/
|
||||
@Get()
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@ApiOperation({ summary: '查询医院列表' })
|
||||
findAll(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Query() query: OrganizationQueryDto,
|
||||
) {
|
||||
return this.hospitalsService.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.hospitalsService.findOne(actor, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新医院信息。
|
||||
*/
|
||||
@Patch(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@ApiOperation({ summary: '更新医院信息' })
|
||||
update(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateHospitalDto,
|
||||
) {
|
||||
return this.hospitalsService.update(actor, id, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除医院(仅系统管理员)。
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN)
|
||||
@ApiOperation({ summary: '删除医院(SYSTEM_ADMIN)' })
|
||||
remove(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
) {
|
||||
return this.hospitalsService.remove(actor, id);
|
||||
}
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HospitalsService } from './hospitals.service.js';
|
||||
import { HospitalsController } from './hospitals.controller.js';
|
||||
import { AccessTokenGuard } from '../auth/access-token.guard.js';
|
||||
import { RolesGuard } from '../auth/roles.guard.js';
|
||||
import { OrganizationAccessService } from '../organization-common/organization-access.service.js';
|
||||
|
||||
/**
|
||||
* 医院资源模块:聚合医院控制器与服务。
|
||||
*/
|
||||
@Module({
|
||||
controllers: [HospitalsController],
|
||||
providers: [
|
||||
HospitalsService,
|
||||
OrganizationAccessService,
|
||||
AccessTokenGuard,
|
||||
RolesGuard,
|
||||
],
|
||||
exports: [HospitalsService],
|
||||
})
|
||||
export class HospitalsModule {}
|
||||
@ -1,116 +0,0 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '../generated/prisma/client.js';
|
||||
import { Role } from '../generated/prisma/enums.js';
|
||||
import type { ActorContext } from '../common/actor-context.js';
|
||||
import { MESSAGES } from '../common/messages.js';
|
||||
import { PrismaService } from '../prisma.service.js';
|
||||
import { OrganizationAccessService } from '../organization-common/organization-access.service.js';
|
||||
import { CreateHospitalDto } from './dto/create-hospital.dto.js';
|
||||
import { UpdateHospitalDto } from './dto/update-hospital.dto.js';
|
||||
import { OrganizationQueryDto } from '../organization-common/dto/organization-query.dto.js';
|
||||
|
||||
/**
|
||||
* 医院资源服务:聚焦医院实体的 CRUD 与院级权限约束。
|
||||
*/
|
||||
@Injectable()
|
||||
export class HospitalsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly access: OrganizationAccessService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 创建医院:仅系统管理员可调用。
|
||||
*/
|
||||
async create(actor: ActorContext, dto: CreateHospitalDto) {
|
||||
this.access.assertSystemAdmin(actor, MESSAGES.ORG.SYSTEM_ADMIN_ONLY_CREATE_HOSPITAL);
|
||||
return this.prisma.hospital.create({
|
||||
data: {
|
||||
name: this.access.normalizeName(dto.name, MESSAGES.ORG.HOSPITAL_NAME_REQUIRED),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询医院列表:系统管理员可查全量;院管仅可查看本院。
|
||||
*/
|
||||
async findAll(actor: ActorContext, query: OrganizationQueryDto) {
|
||||
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
|
||||
const paging = this.access.resolvePaging(query);
|
||||
|
||||
const where: Prisma.HospitalWhereInput = {};
|
||||
if (query.keyword) {
|
||||
where.name = { contains: query.keyword.trim(), mode: 'insensitive' };
|
||||
}
|
||||
if (actor.role === Role.HOSPITAL_ADMIN) {
|
||||
if (!actor.hospitalId) {
|
||||
throw new BadRequestException(MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
|
||||
}
|
||||
where.id = actor.hospitalId ?? undefined;
|
||||
}
|
||||
|
||||
const [total, list] = await this.prisma.$transaction([
|
||||
this.prisma.hospital.count({ where }),
|
||||
this.prisma.hospital.findMany({
|
||||
where,
|
||||
skip: paging.skip,
|
||||
take: paging.take,
|
||||
orderBy: { id: 'desc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
return { total, ...paging, list };
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询医院详情:院管仅能查看本院。
|
||||
*/
|
||||
async findOne(actor: ActorContext, id: number) {
|
||||
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
|
||||
const hospitalId = this.access.toInt(id, MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
|
||||
const hospital = await this.prisma.hospital.findUnique({
|
||||
where: { id: hospitalId },
|
||||
include: {
|
||||
_count: {
|
||||
select: { departments: true, users: true, patients: true, tasks: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!hospital) {
|
||||
throw new NotFoundException(MESSAGES.ORG.HOSPITAL_NOT_FOUND);
|
||||
}
|
||||
this.access.assertHospitalScope(actor, hospital.id);
|
||||
return hospital;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新医院:院管仅能更新本院。
|
||||
*/
|
||||
async update(actor: ActorContext, id: number, dto: UpdateHospitalDto) {
|
||||
const current = await this.findOne(actor, id);
|
||||
const data: Prisma.HospitalUpdateInput = {};
|
||||
if (dto.name !== undefined) {
|
||||
data.name = this.access.normalizeName(dto.name, MESSAGES.ORG.HOSPITAL_NAME_REQUIRED);
|
||||
}
|
||||
return this.prisma.hospital.update({
|
||||
where: { id: current.id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除医院:仅系统管理员允许。
|
||||
*/
|
||||
async remove(actor: ActorContext, id: number) {
|
||||
this.access.assertSystemAdmin(actor, MESSAGES.ORG.SYSTEM_ADMIN_ONLY_DELETE_HOSPITAL);
|
||||
const hospitalId = this.access.toInt(id, MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
|
||||
await this.access.ensureHospitalExists(hospitalId);
|
||||
|
||||
try {
|
||||
return await this.prisma.hospital.delete({ where: { id: hospitalId } });
|
||||
} catch (error) {
|
||||
this.access.handleDeleteConflict(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
54
src/main.ts
54
src/main.ts
@ -1,62 +1,8 @@
|
||||
import 'dotenv/config';
|
||||
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module.js';
|
||||
import { HttpExceptionFilter } from './common/http-exception.filter.js';
|
||||
import { MESSAGES } from './common/messages.js';
|
||||
import { ResponseEnvelopeInterceptor } from './common/response-envelope.interceptor.js';
|
||||
|
||||
async function bootstrap() {
|
||||
// 创建应用实例并加载核心模块。
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// 全局校验:清理未知字段、自动类型转换,并将校验错误统一为中文信息。
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
exceptionFactory: (errors) => {
|
||||
const messages = errors
|
||||
.flatMap((error) => Object.values(error.constraints ?? {}))
|
||||
.filter((item): item is string => Boolean(item));
|
||||
return new BadRequestException(
|
||||
messages.length > 0
|
||||
? messages.join(';')
|
||||
: MESSAGES.DEFAULT_BAD_REQUEST,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// 全局异常与成功响应统一格式化。
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new ResponseEnvelopeInterceptor());
|
||||
|
||||
// Swagger 文档:提供在线调试与 OpenAPI JSON 导出。
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('TYT 多租户医疗调压系统 API')
|
||||
.setDescription('后端接口文档(含认证、RBAC、任务流转与患者聚合)')
|
||||
.setVersion('1.0.0')
|
||||
.addServer('http://localhost:3000', 'localhost')
|
||||
.addBearerAuth(
|
||||
{
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: '在此输入登录接口返回的 accessToken',
|
||||
},
|
||||
'bearer',
|
||||
)
|
||||
.build();
|
||||
|
||||
const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig);
|
||||
SwaggerModule.setup('api/docs', app, swaggerDocument, {
|
||||
jsonDocumentUrl: 'api/docs-json',
|
||||
});
|
||||
|
||||
// 启动 HTTP 服务。
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WechatNotifyService } from './wechat-notify/wechat-notify.service.js';
|
||||
import { TaskEventsListener } from './task-events.listener/task-events.listener.js';
|
||||
|
||||
/**
|
||||
* 通知模块:承载事件监听与微信消息推送能力。
|
||||
*/
|
||||
@Module({
|
||||
providers: [WechatNotifyService, TaskEventsListener],
|
||||
exports: [WechatNotifyService],
|
||||
})
|
||||
export class NotificationsModule {}
|
||||
@ -1,86 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { PrismaService } from '../../prisma.service.js';
|
||||
import { WechatNotifyService } from '../wechat-notify/wechat-notify.service.js';
|
||||
|
||||
interface TaskEventPayload {
|
||||
taskId: number;
|
||||
hospitalId: number;
|
||||
actorId: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务事件监听器:订阅任务状态变化并触发微信推送。
|
||||
*/
|
||||
@Injectable()
|
||||
export class TaskEventsListener {
|
||||
private readonly logger = new Logger(TaskEventsListener.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly wechatNotifyService: WechatNotifyService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 任务发布事件:通知创建医生与指定工程师(如有)。
|
||||
*/
|
||||
@OnEvent('task.published', { async: true })
|
||||
async onTaskPublished(payload: TaskEventPayload) {
|
||||
await this.dispatchTaskEvent('task.published', payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务接收事件。
|
||||
*/
|
||||
@OnEvent('task.accepted', { async: true })
|
||||
async onTaskAccepted(payload: TaskEventPayload) {
|
||||
await this.dispatchTaskEvent('task.accepted', payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务完成事件。
|
||||
*/
|
||||
@OnEvent('task.completed', { async: true })
|
||||
async onTaskCompleted(payload: TaskEventPayload) {
|
||||
await this.dispatchTaskEvent('task.completed', payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 任务取消事件。
|
||||
*/
|
||||
@OnEvent('task.cancelled', { async: true })
|
||||
async onTaskCancelled(payload: TaskEventPayload) {
|
||||
await this.dispatchTaskEvent('task.cancelled', payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一处理任务事件并派发通知目标。
|
||||
*/
|
||||
private async dispatchTaskEvent(event: string, payload: TaskEventPayload) {
|
||||
const task = await this.prisma.task.findUnique({
|
||||
where: { id: payload.taskId },
|
||||
select: {
|
||||
id: true,
|
||||
creator: { select: { id: true, openId: true } },
|
||||
engineer: { select: { id: true, openId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
this.logger.warn(`任务事件监听未找到任务:taskId=${payload.taskId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.wechatNotifyService.notifyTaskChange(
|
||||
[task.creator.openId, task.engineer?.openId],
|
||||
{
|
||||
event,
|
||||
taskId: payload.taskId,
|
||||
hospitalId: payload.hospitalId,
|
||||
actorId: payload.actorId,
|
||||
status: payload.status,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,41 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface TaskNotifyPayload {
|
||||
event: string;
|
||||
taskId: number;
|
||||
hospitalId: number;
|
||||
actorId: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WechatNotifyService {
|
||||
private readonly logger = new Logger(WechatNotifyService.name);
|
||||
|
||||
/**
|
||||
* 任务通知发送入口:后续可在此接入微信服务号/小程序订阅消息 API。
|
||||
*/
|
||||
async notifyTaskChange(openIds: Array<string | null | undefined>, payload: TaskNotifyPayload) {
|
||||
const targets = Array.from(
|
||||
new Set(
|
||||
openIds
|
||||
.map((item) => item?.trim())
|
||||
.filter((item): item is string => Boolean(item)),
|
||||
),
|
||||
);
|
||||
|
||||
if (targets.length === 0) {
|
||||
this.logger.warn(
|
||||
`任务事件 ${payload.event} 无可用 openId,taskId=${payload.taskId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const openId of targets) {
|
||||
// TODO: 在此处调用微信服务号/小程序消息推送 API。
|
||||
this.logger.log(
|
||||
`模拟推送任务通知 event=${payload.event}, taskId=${payload.taskId}, openId=${openId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
|
||||
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 组织查询 DTO:用于医院/科室/小组列表筛选与分页。
|
||||
*/
|
||||
export class OrganizationQueryDto {
|
||||
@ApiPropertyOptional({ description: '关键词(按名称模糊匹配)', example: '神经' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'keyword 必须是字符串' })
|
||||
keyword?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '医院 ID', example: 1 })
|
||||
@IsOptional()
|
||||
@EmptyStringToUndefined()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'hospitalId 必须是整数' })
|
||||
@Min(1, { message: 'hospitalId 必须大于 0' })
|
||||
hospitalId?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '科室 ID', example: 1 })
|
||||
@IsOptional()
|
||||
@EmptyStringToUndefined()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'departmentId 必须是整数' })
|
||||
@Min(1, { message: 'departmentId 必须大于 0' })
|
||||
departmentId?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '页码(默认 1)', example: 1, default: 1 })
|
||||
@IsOptional()
|
||||
@EmptyStringToUndefined()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'page 必须是整数' })
|
||||
@Min(1, { message: 'page 最小为 1' })
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '每页数量(默认 20,最大 100)',
|
||||
example: 20,
|
||||
default: 20,
|
||||
})
|
||||
@IsOptional()
|
||||
@EmptyStringToUndefined()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'pageSize 必须是整数' })
|
||||
@Min(1, { message: 'pageSize 最小为 1' })
|
||||
@Max(100, { message: 'pageSize 最大为 100' })
|
||||
pageSize?: number = 20;
|
||||
}
|
||||
@ -1,131 +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 type { OrganizationQueryDto } from './dto/organization-query.dto.js';
|
||||
|
||||
/**
|
||||
* 组织域通用能力服务:
|
||||
* 负责角色校验、作用域校验、分页标准化和基础存在性检查。
|
||||
*/
|
||||
@Injectable()
|
||||
export class OrganizationAccessService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* 校验角色是否在允许范围内。
|
||||
*/
|
||||
assertRole(actor: ActorContext, roles: Role[]) {
|
||||
if (!roles.includes(actor.role)) {
|
||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验系统管理员权限。
|
||||
*/
|
||||
assertSystemAdmin(actor: ActorContext, message: string) {
|
||||
if (actor.role !== Role.SYSTEM_ADMIN) {
|
||||
throw new ForbiddenException(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验院管的数据作用域限制。
|
||||
*/
|
||||
assertHospitalScope(actor: ActorContext, targetHospitalId: number) {
|
||||
if (actor.role !== Role.HOSPITAL_ADMIN) {
|
||||
return;
|
||||
}
|
||||
if (!actor.hospitalId || actor.hospitalId !== targetHospitalId) {
|
||||
throw new ForbiddenException(MESSAGES.ORG.HOSPITAL_ADMIN_SCOPE_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页参数标准化。
|
||||
*/
|
||||
resolvePaging(query: OrganizationQueryDto) {
|
||||
const page = query.page && query.page > 0 ? query.page : 1;
|
||||
const pageSize =
|
||||
query.pageSize && query.pageSize > 0 && query.pageSize <= 100
|
||||
? query.pageSize
|
||||
: 20;
|
||||
return {
|
||||
page,
|
||||
pageSize,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 名称字段标准化并确保非空。
|
||||
*/
|
||||
normalizeName(value: string, message: string) {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数字参数标准化。
|
||||
*/
|
||||
toInt(value: unknown, message: string) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed)) {
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认医院存在。
|
||||
*/
|
||||
async ensureHospitalExists(id: number) {
|
||||
const hospital = await this.prisma.hospital.findUnique({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!hospital) {
|
||||
throw new NotFoundException(MESSAGES.ORG.HOSPITAL_NOT_FOUND);
|
||||
}
|
||||
return hospital;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确认科室存在,并返回归属医院信息。
|
||||
*/
|
||||
async ensureDepartmentExists(id: number) {
|
||||
const department = await this.prisma.department.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, hospitalId: true },
|
||||
});
|
||||
if (!department) {
|
||||
throw new NotFoundException(MESSAGES.ORG.DEPARTMENT_NOT_FOUND);
|
||||
}
|
||||
return department;
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一处理删除冲突(存在外键引用)。
|
||||
*/
|
||||
handleDeleteConflict(error: unknown) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
(error.code === 'P2003' || error.code === 'P2014')
|
||||
) {
|
||||
throw new ConflictException(MESSAGES.ORG.DELETE_CONFLICT);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HospitalsModule } from '../hospitals/hospitals.module.js';
|
||||
import { DepartmentsModule } from '../departments/departments.module.js';
|
||||
import { GroupsModule } from '../groups/groups.module.js';
|
||||
|
||||
/**
|
||||
* 组织域聚合模块:统一挂载医院/科室/小组三个资源模块。
|
||||
*/
|
||||
@Module({
|
||||
imports: [HospitalsModule, DepartmentsModule, GroupsModule],
|
||||
})
|
||||
export class OrganizationModule {}
|
||||
@ -1,169 +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 { CurrentActor } from '../../auth/current-actor.decorator.js';
|
||||
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
|
||||
import { RolesGuard } from '../../auth/roles.guard.js';
|
||||
import { Roles } from '../../auth/roles.decorator.js';
|
||||
import { Role } from '../../generated/prisma/enums.js';
|
||||
import { BPatientsService } from './b-patients.service.js';
|
||||
import { CreatePatientDto } from '../dto/create-patient.dto.js';
|
||||
import { UpdatePatientDto } from '../dto/update-patient.dto.js';
|
||||
|
||||
/**
|
||||
* B 端患者控制器:院内可见性隔离查询。
|
||||
*/
|
||||
@ApiTags('患者管理(B端)')
|
||||
@ApiBearerAuth('bearer')
|
||||
@Controller('b/patients')
|
||||
@UseGuards(AccessTokenGuard, RolesGuard)
|
||||
export class BPatientsController {
|
||||
constructor(private readonly patientsService: BPatientsService) {}
|
||||
|
||||
/**
|
||||
* 查询当前角色可选择的医生列表(用于创建/编辑患者)。
|
||||
*/
|
||||
@Get('doctors')
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
)
|
||||
@ApiOperation({ summary: '查询当前角色可见医生列表' })
|
||||
@ApiQuery({
|
||||
name: 'hospitalId',
|
||||
required: false,
|
||||
description: '系统管理员可显式指定医院',
|
||||
})
|
||||
findVisibleDoctors(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Query('hospitalId') hospitalId?: string,
|
||||
) {
|
||||
const requestedHospitalId =
|
||||
hospitalId == null || hospitalId === '' ? undefined : Number(hospitalId);
|
||||
return this.patientsService.findVisibleDoctors(actor, requestedHospitalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按角色返回可见患者列表。
|
||||
*/
|
||||
@Get()
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
)
|
||||
@ApiOperation({ summary: '按角色查询可见患者列表' })
|
||||
@ApiQuery({
|
||||
name: 'hospitalId',
|
||||
required: false,
|
||||
description: '系统管理员可显式指定医院',
|
||||
})
|
||||
findVisiblePatients(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Query('hospitalId') hospitalId?: string,
|
||||
) {
|
||||
const requestedHospitalId =
|
||||
hospitalId == null || hospitalId === '' ? undefined : Number(hospitalId);
|
||||
|
||||
return this.patientsService.findVisiblePatients(actor, requestedHospitalId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建患者。
|
||||
*/
|
||||
@Post()
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
)
|
||||
@ApiOperation({ summary: '创建患者' })
|
||||
createPatient(@CurrentActor() actor: ActorContext, @Body() dto: CreatePatientDto) {
|
||||
return this.patientsService.createPatient(actor, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询患者详情。
|
||||
*/
|
||||
@Get(':id')
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
)
|
||||
@ApiOperation({ summary: '查询患者详情' })
|
||||
@ApiParam({ name: 'id', description: '患者 ID' })
|
||||
findPatientById(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
) {
|
||||
return this.patientsService.findPatientById(actor, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新患者信息。
|
||||
*/
|
||||
@Patch(':id')
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
)
|
||||
@ApiOperation({ summary: '更新患者信息' })
|
||||
@ApiParam({ name: 'id', description: '患者 ID' })
|
||||
updatePatient(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdatePatientDto,
|
||||
) {
|
||||
return this.patientsService.updatePatient(actor, id, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除患者。
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
)
|
||||
@ApiOperation({ summary: '删除患者' })
|
||||
@ApiParam({ name: 'id', description: '患者 ID' })
|
||||
removePatient(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
) {
|
||||
return this.patientsService.removePatient(actor, id);
|
||||
}
|
||||
}
|
||||
@ -1,391 +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 { PrismaService } from '../../prisma.service.js';
|
||||
import type { ActorContext } from '../../common/actor-context.js';
|
||||
import { MESSAGES } from '../../common/messages.js';
|
||||
import { CreatePatientDto } from '../dto/create-patient.dto.js';
|
||||
import { UpdatePatientDto } from '../dto/update-patient.dto.js';
|
||||
|
||||
/**
|
||||
* B 端患者服务:承载院内可见性隔离与患者 CRUD。
|
||||
*/
|
||||
@Injectable()
|
||||
export class BPatientsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* 查询当前角色可见患者列表。
|
||||
*/
|
||||
async findVisiblePatients(actor: ActorContext, requestedHospitalId?: number) {
|
||||
const hospitalId = this.resolveHospitalId(actor, requestedHospitalId);
|
||||
const where = this.buildVisiblePatientWhere(actor, hospitalId);
|
||||
|
||||
return this.prisma.patient.findMany({
|
||||
where,
|
||||
include: {
|
||||
hospital: { select: { id: true, name: true } },
|
||||
doctor: { select: { id: true, name: true, role: true } },
|
||||
devices: true,
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前角色可见医生列表,用于患者表单选择。
|
||||
*/
|
||||
async findVisibleDoctors(actor: ActorContext, requestedHospitalId?: number) {
|
||||
const hospitalId = this.resolveHospitalId(actor, requestedHospitalId);
|
||||
const where: Prisma.UserWhereInput = {
|
||||
role: Role.DOCTOR,
|
||||
hospitalId,
|
||||
};
|
||||
|
||||
switch (actor.role) {
|
||||
case Role.DOCTOR:
|
||||
where.id = actor.id;
|
||||
break;
|
||||
case Role.LEADER:
|
||||
if (!actor.groupId) {
|
||||
throw new BadRequestException(MESSAGES.PATIENT.GROUP_REQUIRED);
|
||||
}
|
||||
where.groupId = actor.groupId;
|
||||
break;
|
||||
case Role.DIRECTOR:
|
||||
if (!actor.departmentId) {
|
||||
throw new BadRequestException(MESSAGES.PATIENT.DEPARTMENT_REQUIRED);
|
||||
}
|
||||
where.departmentId = actor.departmentId;
|
||||
break;
|
||||
case Role.HOSPITAL_ADMIN:
|
||||
case Role.SYSTEM_ADMIN:
|
||||
break;
|
||||
default:
|
||||
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
|
||||
}
|
||||
|
||||
return this.prisma.user.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
hospitalId: true,
|
||||
departmentId: true,
|
||||
groupId: true,
|
||||
role: true,
|
||||
},
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建患者。
|
||||
*/
|
||||
async createPatient(actor: ActorContext, dto: CreatePatientDto) {
|
||||
const doctor = await this.resolveWritableDoctor(actor, dto.doctorId);
|
||||
|
||||
return this.prisma.patient.create({
|
||||
data: {
|
||||
name: this.normalizeRequiredString(dto.name, 'name'),
|
||||
phone: this.normalizePhone(dto.phone),
|
||||
idCardHash: this.normalizeRequiredString(dto.idCardHash, 'idCardHash'),
|
||||
hospitalId: doctor.hospitalId!,
|
||||
doctorId: doctor.id,
|
||||
},
|
||||
include: {
|
||||
hospital: { select: { id: true, name: true } },
|
||||
doctor: { select: { id: true, name: true, role: true } },
|
||||
devices: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询患者详情。
|
||||
*/
|
||||
async findPatientById(actor: ActorContext, id: number) {
|
||||
const patient = await this.findPatientWithScope(id);
|
||||
this.assertPatientScope(actor, patient);
|
||||
return patient;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新患者信息。
|
||||
*/
|
||||
async updatePatient(actor: ActorContext, id: number, dto: UpdatePatientDto) {
|
||||
const patient = await this.findPatientWithScope(id);
|
||||
this.assertPatientScope(actor, patient);
|
||||
|
||||
const data: Prisma.PatientUpdateInput = {};
|
||||
if (dto.name !== undefined) {
|
||||
data.name = this.normalizeRequiredString(dto.name, 'name');
|
||||
}
|
||||
if (dto.phone !== undefined) {
|
||||
data.phone = this.normalizePhone(dto.phone);
|
||||
}
|
||||
if (dto.idCardHash !== undefined) {
|
||||
data.idCardHash = this.normalizeRequiredString(dto.idCardHash, 'idCardHash');
|
||||
}
|
||||
if (dto.doctorId !== undefined) {
|
||||
const doctor = await this.resolveWritableDoctor(actor, dto.doctorId);
|
||||
data.doctor = { connect: { id: doctor.id } };
|
||||
data.hospital = { connect: { id: doctor.hospitalId! } };
|
||||
}
|
||||
|
||||
return this.prisma.patient.update({
|
||||
where: { id: patient.id },
|
||||
data,
|
||||
include: {
|
||||
hospital: { select: { id: true, name: true } },
|
||||
doctor: { select: { id: true, name: true, role: true } },
|
||||
devices: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除患者。
|
||||
*/
|
||||
async removePatient(actor: ActorContext, id: number) {
|
||||
const patient = await this.findPatientWithScope(id);
|
||||
this.assertPatientScope(actor, patient);
|
||||
|
||||
try {
|
||||
return await this.prisma.patient.delete({
|
||||
where: { id: patient.id },
|
||||
include: {
|
||||
hospital: { select: { id: true, name: true } },
|
||||
doctor: { select: { id: true, name: true, role: true } },
|
||||
devices: true,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2003'
|
||||
) {
|
||||
throw new ConflictException(MESSAGES.PATIENT.DELETE_CONFLICT);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询患者并附带医生组织信息,用于权限判定。
|
||||
*/
|
||||
private async findPatientWithScope(id: number) {
|
||||
const patientId = Number(id);
|
||||
if (!Number.isInteger(patientId)) {
|
||||
throw new BadRequestException('id 必须为整数');
|
||||
}
|
||||
|
||||
const patient = await this.prisma.patient.findUnique({
|
||||
where: { id: patientId },
|
||||
include: {
|
||||
hospital: { select: { id: true, name: true } },
|
||||
doctor: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
role: true,
|
||||
hospitalId: true,
|
||||
departmentId: true,
|
||||
groupId: true,
|
||||
},
|
||||
},
|
||||
devices: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!patient) {
|
||||
throw new NotFoundException(MESSAGES.PATIENT.NOT_FOUND);
|
||||
}
|
||||
|
||||
return patient;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验当前角色是否可操作该患者。
|
||||
*/
|
||||
private assertPatientScope(
|
||||
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 (!actor.hospitalId || actor.hospitalId !== patient.hospitalId) {
|
||||
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
|
||||
}
|
||||
return;
|
||||
case Role.DIRECTOR:
|
||||
if (!actor.departmentId || patient.doctor.departmentId !== actor.departmentId) {
|
||||
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
|
||||
}
|
||||
return;
|
||||
case Role.LEADER:
|
||||
if (!actor.groupId || patient.doctor.groupId !== actor.groupId) {
|
||||
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
|
||||
}
|
||||
return;
|
||||
case Role.DOCTOR:
|
||||
if (patient.doctorId !== actor.id) {
|
||||
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
|
||||
}
|
||||
return;
|
||||
default:
|
||||
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并返回当前角色可写的医生。
|
||||
*/
|
||||
private async resolveWritableDoctor(actor: ActorContext, doctorId: number) {
|
||||
const normalizedDoctorId = Number(doctorId);
|
||||
if (!Number.isInteger(normalizedDoctorId)) {
|
||||
throw new BadRequestException('doctorId 必须为整数');
|
||||
}
|
||||
|
||||
const doctor = await this.prisma.user.findUnique({
|
||||
where: { id: normalizedDoctorId },
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
hospitalId: true,
|
||||
departmentId: true,
|
||||
groupId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!doctor) {
|
||||
throw new NotFoundException(MESSAGES.PATIENT.DOCTOR_NOT_FOUND);
|
||||
}
|
||||
if (doctor.role !== Role.DOCTOR) {
|
||||
throw new BadRequestException(MESSAGES.PATIENT.DOCTOR_ROLE_REQUIRED);
|
||||
}
|
||||
if (!doctor.hospitalId) {
|
||||
throw new BadRequestException(MESSAGES.PATIENT.DOCTOR_NOT_FOUND);
|
||||
}
|
||||
|
||||
switch (actor.role) {
|
||||
case Role.SYSTEM_ADMIN:
|
||||
return doctor;
|
||||
case Role.HOSPITAL_ADMIN:
|
||||
if (!actor.hospitalId || doctor.hospitalId !== actor.hospitalId) {
|
||||
throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN);
|
||||
}
|
||||
return doctor;
|
||||
case Role.DIRECTOR:
|
||||
if (!actor.departmentId || doctor.departmentId !== actor.departmentId) {
|
||||
throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN);
|
||||
}
|
||||
return doctor;
|
||||
case Role.LEADER:
|
||||
if (!actor.groupId || doctor.groupId !== actor.groupId) {
|
||||
throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN);
|
||||
}
|
||||
return doctor;
|
||||
case Role.DOCTOR:
|
||||
if (doctor.id !== actor.id) {
|
||||
throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN);
|
||||
}
|
||||
return doctor;
|
||||
default:
|
||||
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按角色构造患者可见性查询条件。
|
||||
*/
|
||||
private buildVisiblePatientWhere(actor: ActorContext, hospitalId: number) {
|
||||
const where: Record<string, unknown> = { hospitalId };
|
||||
switch (actor.role) {
|
||||
case Role.DOCTOR:
|
||||
where.doctorId = actor.id;
|
||||
break;
|
||||
case Role.LEADER:
|
||||
if (!actor.groupId) {
|
||||
throw new BadRequestException(MESSAGES.PATIENT.GROUP_REQUIRED);
|
||||
}
|
||||
where.doctor = {
|
||||
groupId: actor.groupId,
|
||||
role: Role.DOCTOR,
|
||||
};
|
||||
break;
|
||||
case Role.DIRECTOR:
|
||||
if (!actor.departmentId) {
|
||||
throw new BadRequestException(MESSAGES.PATIENT.DEPARTMENT_REQUIRED);
|
||||
}
|
||||
where.doctor = {
|
||||
departmentId: actor.departmentId,
|
||||
role: Role.DOCTOR,
|
||||
};
|
||||
break;
|
||||
case Role.HOSPITAL_ADMIN:
|
||||
case Role.SYSTEM_ADMIN:
|
||||
break;
|
||||
default:
|
||||
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 B 端查询 hospitalId:系统管理员必须显式指定医院。
|
||||
*/
|
||||
private resolveHospitalId(
|
||||
actor: ActorContext,
|
||||
requestedHospitalId?: number,
|
||||
): number {
|
||||
if (actor.role === Role.SYSTEM_ADMIN) {
|
||||
const normalizedHospitalId = requestedHospitalId;
|
||||
if (
|
||||
normalizedHospitalId == null ||
|
||||
!Number.isInteger(normalizedHospitalId)
|
||||
) {
|
||||
throw new BadRequestException(MESSAGES.PATIENT.SYSTEM_ADMIN_HOSPITAL_REQUIRED);
|
||||
}
|
||||
return normalizedHospitalId;
|
||||
}
|
||||
|
||||
if (!actor.hospitalId) {
|
||||
throw new BadRequestException(MESSAGES.PATIENT.ACTOR_HOSPITAL_REQUIRED);
|
||||
}
|
||||
|
||||
return actor.hospitalId;
|
||||
}
|
||||
|
||||
private normalizeRequiredString(value: unknown, fieldName: string) {
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException(`${fieldName} 必须是字符串`);
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new BadRequestException(`${fieldName} 不能为空`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private normalizePhone(phone: unknown) {
|
||||
const normalized = this.normalizeRequiredString(phone, 'phone');
|
||||
if (!/^1\d{10}$/.test(normalized)) {
|
||||
throw new BadRequestException('phone 必须是合法手机号');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@ -1,27 +0,0 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
import { FamilyLifecycleQueryDto } from '../dto/family-lifecycle-query.dto.js';
|
||||
import { CPatientsService } from './c-patients.service.js';
|
||||
|
||||
/**
|
||||
* C 端患者控制器:家属跨院聚合查询。
|
||||
*/
|
||||
@ApiTags('患者管理(C端)')
|
||||
@Controller('c/patients')
|
||||
export class CPatientsController {
|
||||
constructor(private readonly patientsService: CPatientsService) {}
|
||||
|
||||
/**
|
||||
* 根据手机号和身份证哈希查询跨院生命周期。
|
||||
*/
|
||||
@Get('lifecycle')
|
||||
@ApiOperation({ summary: '跨院患者生命周期查询' })
|
||||
@ApiQuery({ name: 'phone', description: '手机号' })
|
||||
@ApiQuery({ name: 'idCardHash', description: '身份证哈希' })
|
||||
getLifecycle(@Query() query: FamilyLifecycleQueryDto) {
|
||||
return this.patientsService.getFamilyLifecycleByIdentity(
|
||||
query.phone,
|
||||
query.idCardHash,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,108 +0,0 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../prisma.service.js';
|
||||
import { MESSAGES } from '../../common/messages.js';
|
||||
|
||||
/**
|
||||
* C 端患者服务:承载家属跨院生命周期聚合查询。
|
||||
*/
|
||||
@Injectable()
|
||||
export class CPatientsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* C 端查询:按 phone + idCardHash 跨院聚合患者生命周期记录。
|
||||
*/
|
||||
async getFamilyLifecycleByIdentity(phone: string, idCardHash: string) {
|
||||
if (!phone || !idCardHash) {
|
||||
throw new BadRequestException(MESSAGES.PATIENT.PHONE_IDCARD_REQUIRED);
|
||||
}
|
||||
|
||||
const patients = await this.prisma.patient.findMany({
|
||||
where: {
|
||||
phone,
|
||||
idCardHash,
|
||||
},
|
||||
include: {
|
||||
hospital: { select: { id: true, name: true } },
|
||||
devices: {
|
||||
include: {
|
||||
taskItems: {
|
||||
include: {
|
||||
task: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (patients.length === 0) {
|
||||
throw new NotFoundException(MESSAGES.PATIENT.LIFE_CYCLE_NOT_FOUND);
|
||||
}
|
||||
|
||||
const lifecycle = patients
|
||||
.flatMap((patient) =>
|
||||
patient.devices.flatMap((device) =>
|
||||
device.taskItems.flatMap((taskItem) => {
|
||||
// 容错:若存在脏数据导致 task 为空,直接跳过该条明细,避免接口 500。
|
||||
if (!taskItem.task) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const task = taskItem.task;
|
||||
return [
|
||||
{
|
||||
eventType: 'TASK_PRESSURE_ADJUSTMENT',
|
||||
occurredAt: task.createdAt,
|
||||
hospital: patient.hospital,
|
||||
patient: {
|
||||
id: this.toJsonNumber(patient.id),
|
||||
name: patient.name,
|
||||
phone: patient.phone,
|
||||
},
|
||||
device: {
|
||||
id: this.toJsonNumber(device.id),
|
||||
snCode: device.snCode,
|
||||
status: device.status,
|
||||
currentPressure: this.toJsonNumber(device.currentPressure),
|
||||
},
|
||||
task: {
|
||||
id: this.toJsonNumber(task.id),
|
||||
status: task.status,
|
||||
creatorId: this.toJsonNumber(task.creatorId),
|
||||
engineerId: this.toJsonNumber(task.engineerId),
|
||||
hospitalId: this.toJsonNumber(task.hospitalId),
|
||||
createdAt: task.createdAt,
|
||||
},
|
||||
taskItem: {
|
||||
id: this.toJsonNumber(taskItem.id),
|
||||
oldPressure: this.toJsonNumber(taskItem.oldPressure),
|
||||
targetPressure: this.toJsonNumber(taskItem.targetPressure),
|
||||
},
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
|
||||
);
|
||||
|
||||
return {
|
||||
phone,
|
||||
idCardHash,
|
||||
patientCount: patients.length,
|
||||
lifecycle,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一将 number/bigint 转为可 JSON 序列化的 number,避免 BigInt 序列化异常。
|
||||
*/
|
||||
private toJsonNumber(value: number | bigint | null | undefined) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return typeof value === 'bigint' ? Number(value) : value;
|
||||
}
|
||||
}
|
||||
@ -1,35 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsString,
|
||||
Matches,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* 患者创建 DTO:B 端新增患者使用。
|
||||
*/
|
||||
export class CreatePatientDto {
|
||||
@ApiProperty({ description: '患者姓名', example: '张三' })
|
||||
@IsString({ message: 'name 必须是字符串' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: '手机号', example: '13800002001' })
|
||||
@IsString({ message: 'phone 必须是字符串' })
|
||||
@Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' })
|
||||
phone!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '身份证哈希(前端传加密后值)',
|
||||
example: 'id-card-hash-demo',
|
||||
})
|
||||
@IsString({ message: 'idCardHash 必须是字符串' })
|
||||
idCardHash!: string;
|
||||
|
||||
@ApiProperty({ description: '归属医生 ID', example: 10001 })
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'doctorId 必须是整数' })
|
||||
@Min(1, { message: 'doctorId 必须大于 0' })
|
||||
doctorId!: number;
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, Matches } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 家属端生命周期查询 DTO。
|
||||
*/
|
||||
export class FamilyLifecycleQueryDto {
|
||||
@ApiProperty({ description: '手机号', example: '13800000003' })
|
||||
@IsString({ message: 'phone 必须是字符串' })
|
||||
@Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' })
|
||||
phone!: string;
|
||||
|
||||
@ApiProperty({ description: '身份证哈希值', example: 'seed-id-card-hash' })
|
||||
@IsString({ message: 'idCardHash 必须是字符串' })
|
||||
idCardHash!: string;
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreatePatientDto } from './create-patient.dto.js';
|
||||
|
||||
/**
|
||||
* 患者更新 DTO。
|
||||
*/
|
||||
export class UpdatePatientDto extends PartialType(CreatePatientDto) {}
|
||||
@ -1,14 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BPatientsController } from './b-patients/b-patients.controller.js';
|
||||
import { CPatientsController } from './c-patients/c-patients.controller.js';
|
||||
import { AccessTokenGuard } from '../auth/access-token.guard.js';
|
||||
import { RolesGuard } from '../auth/roles.guard.js';
|
||||
import { BPatientsService } from './b-patients/b-patients.service.js';
|
||||
import { CPatientsService } from './c-patients/c-patients.service.js';
|
||||
|
||||
@Module({
|
||||
providers: [BPatientsService, CPatientsService, AccessTokenGuard, RolesGuard],
|
||||
controllers: [BPatientsController, CPatientsController],
|
||||
exports: [BPatientsService, CPatientsService],
|
||||
})
|
||||
export class PatientsModule {}
|
||||
@ -1,9 +0,0 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service.js';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@ -1,64 +0,0 @@
|
||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { ActorContext } from '../../common/actor-context.js';
|
||||
import { CurrentActor } from '../../auth/current-actor.decorator.js';
|
||||
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
|
||||
import { RolesGuard } from '../../auth/roles.guard.js';
|
||||
import { Roles } from '../../auth/roles.decorator.js';
|
||||
import { Role } from '../../generated/prisma/enums.js';
|
||||
import { TaskService } from '../task.service.js';
|
||||
import { PublishTaskDto } from '../dto/publish-task.dto.js';
|
||||
import { AcceptTaskDto } from '../dto/accept-task.dto.js';
|
||||
import { CompleteTaskDto } from '../dto/complete-task.dto.js';
|
||||
import { CancelTaskDto } from '../dto/cancel-task.dto.js';
|
||||
|
||||
/**
|
||||
* B 端任务控制器:封装调压任务状态流转接口。
|
||||
*/
|
||||
@ApiTags('调压任务(B端)')
|
||||
@ApiBearerAuth('bearer')
|
||||
@Controller('b/tasks')
|
||||
@UseGuards(AccessTokenGuard, RolesGuard)
|
||||
export class BTasksController {
|
||||
constructor(private readonly taskService: TaskService) {}
|
||||
|
||||
/**
|
||||
* 医生发布调压任务。
|
||||
*/
|
||||
@Post('publish')
|
||||
@Roles(Role.DOCTOR)
|
||||
@ApiOperation({ summary: '发布任务(DOCTOR)' })
|
||||
publish(@CurrentActor() actor: ActorContext, @Body() dto: PublishTaskDto) {
|
||||
return this.taskService.publishTask(actor, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工程师接收调压任务。
|
||||
*/
|
||||
@Post('accept')
|
||||
@Roles(Role.ENGINEER)
|
||||
@ApiOperation({ summary: '接收任务(ENGINEER)' })
|
||||
accept(@CurrentActor() actor: ActorContext, @Body() dto: AcceptTaskDto) {
|
||||
return this.taskService.acceptTask(actor, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工程师完成调压任务。
|
||||
*/
|
||||
@Post('complete')
|
||||
@Roles(Role.ENGINEER)
|
||||
@ApiOperation({ summary: '完成任务(ENGINEER)' })
|
||||
complete(@CurrentActor() actor: ActorContext, @Body() dto: CompleteTaskDto) {
|
||||
return this.taskService.completeTask(actor, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 医生取消调压任务。
|
||||
*/
|
||||
@Post('cancel')
|
||||
@Roles(Role.DOCTOR)
|
||||
@ApiOperation({ summary: '取消任务(DOCTOR)' })
|
||||
cancel(@CurrentActor() actor: ActorContext, @Body() dto: CancelTaskDto) {
|
||||
return this.taskService.cancelTask(actor, dto);
|
||||
}
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, Min } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 接收任务 DTO。
|
||||
*/
|
||||
export class AcceptTaskDto {
|
||||
@ApiProperty({ description: '任务 ID', example: 1 })
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'taskId 必须是整数' })
|
||||
@Min(1, { message: 'taskId 必须大于 0' })
|
||||
taskId!: number;
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, Min } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 取消任务 DTO。
|
||||
*/
|
||||
export class CancelTaskDto {
|
||||
@ApiProperty({ description: '任务 ID', example: 1 })
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'taskId 必须是整数' })
|
||||
@Min(1, { message: 'taskId 必须大于 0' })
|
||||
taskId!: number;
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, Min } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 完成任务 DTO。
|
||||
*/
|
||||
export class CompleteTaskDto {
|
||||
@ApiProperty({ description: '任务 ID', example: 1 })
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'taskId 必须是整数' })
|
||||
@Min(1, { message: 'taskId 必须大于 0' })
|
||||
taskId!: number;
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* 发布任务明细 DTO。
|
||||
*/
|
||||
export class PublishTaskItemDto {
|
||||
@ApiProperty({ description: '设备 ID', example: 1 })
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'deviceId 必须是整数' })
|
||||
@Min(1, { message: 'deviceId 必须大于 0' })
|
||||
deviceId!: number;
|
||||
|
||||
@ApiProperty({ description: '目标压力值', example: 120 })
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'targetPressure 必须是整数' })
|
||||
targetPressure!: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布任务 DTO。
|
||||
*/
|
||||
export class PublishTaskDto {
|
||||
@ApiPropertyOptional({ description: '指定工程师 ID(可选)', example: 2 })
|
||||
@IsOptional()
|
||||
@EmptyStringToUndefined()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'engineerId 必须是整数' })
|
||||
@Min(1, { message: 'engineerId 必须大于 0' })
|
||||
engineerId?: number;
|
||||
|
||||
@ApiProperty({ type: [PublishTaskItemDto], description: '任务明细列表' })
|
||||
@IsArray({ message: 'items 必须是数组' })
|
||||
@ArrayMinSize(1, { message: 'items 至少包含一条明细' })
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PublishTaskItemDto)
|
||||
items!: PublishTaskItemDto[];
|
||||
}
|
||||
@ -1,283 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { DeviceStatus, Role, TaskStatus } from '../generated/prisma/enums.js';
|
||||
import { PrismaService } from '../prisma.service.js';
|
||||
import type { ActorContext } from '../common/actor-context.js';
|
||||
import { PublishTaskDto } from './dto/publish-task.dto.js';
|
||||
import { AcceptTaskDto } from './dto/accept-task.dto.js';
|
||||
import { CompleteTaskDto } from './dto/complete-task.dto.js';
|
||||
import { CancelTaskDto } from './dto/cancel-task.dto.js';
|
||||
import { MESSAGES } from '../common/messages.js';
|
||||
|
||||
/**
|
||||
* 任务服务:封装调压任务状态机、院内隔离与事件发布逻辑。
|
||||
*/
|
||||
@Injectable()
|
||||
export class TaskService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 发布任务:医生创建主任务与明细,状态初始化为 PENDING。
|
||||
*/
|
||||
async publishTask(actor: ActorContext, dto: PublishTaskDto) {
|
||||
this.assertRole(actor, [Role.DOCTOR]);
|
||||
const hospitalId = this.requireHospitalId(actor);
|
||||
|
||||
if (!Array.isArray(dto.items) || dto.items.length === 0) {
|
||||
throw new BadRequestException(MESSAGES.TASK.ITEMS_REQUIRED);
|
||||
}
|
||||
|
||||
const deviceIds = Array.from(
|
||||
new Set(
|
||||
dto.items.map((item) => {
|
||||
if (!Number.isInteger(item.deviceId)) {
|
||||
throw new BadRequestException(`deviceId 非法: ${item.deviceId}`);
|
||||
}
|
||||
if (!Number.isInteger(item.targetPressure)) {
|
||||
throw new BadRequestException(`targetPressure 非法: ${item.targetPressure}`);
|
||||
}
|
||||
return item.deviceId;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const devices = await this.prisma.device.findMany({
|
||||
where: {
|
||||
id: { in: deviceIds },
|
||||
status: DeviceStatus.ACTIVE,
|
||||
patient: { hospitalId },
|
||||
},
|
||||
select: { id: true, currentPressure: true },
|
||||
});
|
||||
|
||||
if (devices.length !== deviceIds.length) {
|
||||
throw new NotFoundException(MESSAGES.TASK.DEVICE_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (dto.engineerId != null) {
|
||||
const engineer = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
id: dto.engineerId,
|
||||
role: Role.ENGINEER,
|
||||
hospitalId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!engineer) {
|
||||
throw new BadRequestException(MESSAGES.TASK.ENGINEER_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
const pressureByDeviceId = new Map(
|
||||
devices.map((device) => [device.id, device.currentPressure] as const),
|
||||
);
|
||||
|
||||
const task = await this.prisma.task.create({
|
||||
data: {
|
||||
status: TaskStatus.PENDING,
|
||||
creatorId: actor.id,
|
||||
engineerId: dto.engineerId ?? null,
|
||||
hospitalId,
|
||||
items: {
|
||||
create: dto.items.map((item) => ({
|
||||
deviceId: item.deviceId,
|
||||
oldPressure: pressureByDeviceId.get(item.deviceId) ?? 0,
|
||||
targetPressure: item.targetPressure,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
await this.eventEmitter.emitAsync('task.published', {
|
||||
taskId: task.id,
|
||||
hospitalId: task.hospitalId,
|
||||
actorId: actor.id,
|
||||
status: task.status,
|
||||
});
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收任务:工程师将任务从 PENDING 流转到 ACCEPTED。
|
||||
*/
|
||||
async acceptTask(actor: ActorContext, dto: AcceptTaskDto) {
|
||||
this.assertRole(actor, [Role.ENGINEER]);
|
||||
const hospitalId = this.requireHospitalId(actor);
|
||||
|
||||
const task = await this.prisma.task.findFirst({
|
||||
where: {
|
||||
id: dto.taskId,
|
||||
hospitalId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
hospitalId: true,
|
||||
engineerId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
|
||||
}
|
||||
if (task.status !== TaskStatus.PENDING) {
|
||||
throw new ConflictException(MESSAGES.TASK.ACCEPT_ONLY_PENDING);
|
||||
}
|
||||
if (task.engineerId != null && task.engineerId !== actor.id) {
|
||||
throw new ForbiddenException(MESSAGES.TASK.ENGINEER_ALREADY_ASSIGNED);
|
||||
}
|
||||
|
||||
const updatedTask = await this.prisma.task.update({
|
||||
where: { id: task.id },
|
||||
data: {
|
||||
status: TaskStatus.ACCEPTED,
|
||||
engineerId: actor.id,
|
||||
},
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
await this.eventEmitter.emitAsync('task.accepted', {
|
||||
taskId: updatedTask.id,
|
||||
hospitalId: updatedTask.hospitalId,
|
||||
actorId: actor.id,
|
||||
status: updatedTask.status,
|
||||
});
|
||||
|
||||
return updatedTask;
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成任务:工程师将任务置为 COMPLETED,并同步设备当前压力。
|
||||
*/
|
||||
async completeTask(actor: ActorContext, dto: CompleteTaskDto) {
|
||||
this.assertRole(actor, [Role.ENGINEER]);
|
||||
const hospitalId = this.requireHospitalId(actor);
|
||||
|
||||
const task = await this.prisma.task.findFirst({
|
||||
where: {
|
||||
id: dto.taskId,
|
||||
hospitalId,
|
||||
},
|
||||
include: {
|
||||
items: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
|
||||
}
|
||||
if (task.status !== TaskStatus.ACCEPTED) {
|
||||
throw new ConflictException(MESSAGES.TASK.COMPLETE_ONLY_ACCEPTED);
|
||||
}
|
||||
if (task.engineerId !== actor.id) {
|
||||
throw new ForbiddenException(MESSAGES.TASK.ENGINEER_ONLY_ASSIGNEE);
|
||||
}
|
||||
|
||||
const completedTask = await this.prisma.$transaction(async (tx) => {
|
||||
const nextTask = await tx.task.update({
|
||||
where: { id: task.id },
|
||||
data: { status: TaskStatus.COMPLETED },
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
task.items.map((item) =>
|
||||
tx.device.update({
|
||||
where: { id: item.deviceId },
|
||||
data: { currentPressure: item.targetPressure },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return nextTask;
|
||||
});
|
||||
|
||||
await this.eventEmitter.emitAsync('task.completed', {
|
||||
taskId: completedTask.id,
|
||||
hospitalId: completedTask.hospitalId,
|
||||
actorId: actor.id,
|
||||
status: completedTask.status,
|
||||
});
|
||||
|
||||
return completedTask;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消任务:创建医生可将 PENDING/ACCEPTED 任务取消。
|
||||
*/
|
||||
async cancelTask(actor: ActorContext, dto: CancelTaskDto) {
|
||||
this.assertRole(actor, [Role.DOCTOR]);
|
||||
const hospitalId = this.requireHospitalId(actor);
|
||||
|
||||
const task = await this.prisma.task.findFirst({
|
||||
where: {
|
||||
id: dto.taskId,
|
||||
hospitalId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
creatorId: true,
|
||||
hospitalId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
|
||||
}
|
||||
if (task.creatorId !== actor.id) {
|
||||
throw new ForbiddenException(MESSAGES.TASK.CANCEL_ONLY_CREATOR);
|
||||
}
|
||||
if (
|
||||
task.status !== TaskStatus.PENDING &&
|
||||
task.status !== TaskStatus.ACCEPTED
|
||||
) {
|
||||
throw new ConflictException(MESSAGES.TASK.CANCEL_ONLY_PENDING_ACCEPTED);
|
||||
}
|
||||
|
||||
const cancelledTask = await this.prisma.task.update({
|
||||
where: { id: task.id },
|
||||
data: { status: TaskStatus.CANCELLED },
|
||||
include: { items: true },
|
||||
});
|
||||
|
||||
await this.eventEmitter.emitAsync('task.cancelled', {
|
||||
taskId: cancelledTask.id,
|
||||
hospitalId: cancelledTask.hospitalId,
|
||||
actorId: actor.id,
|
||||
status: cancelledTask.status,
|
||||
});
|
||||
|
||||
return cancelledTask;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验角色权限。
|
||||
*/
|
||||
private assertRole(actor: ActorContext, allowedRoles: Role[]) {
|
||||
if (!allowedRoles.includes(actor.role)) {
|
||||
throw new ForbiddenException(MESSAGES.TASK.ACTOR_ROLE_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并返回 hospitalId(B 端强依赖租户隔离)。
|
||||
*/
|
||||
private requireHospitalId(actor: ActorContext): number {
|
||||
if (!actor.hospitalId) {
|
||||
throw new BadRequestException(MESSAGES.TASK.ACTOR_HOSPITAL_REQUIRED);
|
||||
}
|
||||
return actor.hospitalId;
|
||||
}
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { BTasksController } from './b-tasks/b-tasks.controller.js';
|
||||
import { TaskService } from './task.service.js';
|
||||
import { AccessTokenGuard } from '../auth/access-token.guard.js';
|
||||
import { RolesGuard } from '../auth/roles.guard.js';
|
||||
|
||||
@Module({
|
||||
controllers: [BTasksController],
|
||||
providers: [TaskService, AccessTokenGuard, RolesGuard],
|
||||
exports: [TaskService],
|
||||
})
|
||||
export class TasksModule {}
|
||||
@ -1,36 +0,0 @@
|
||||
import { Body, Controller, Param, Patch, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
||||
import type { ActorContext } from '../../common/actor-context.js';
|
||||
import { CurrentActor } from '../../auth/current-actor.decorator.js';
|
||||
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
|
||||
import { RolesGuard } from '../../auth/roles.guard.js';
|
||||
import { Roles } from '../../auth/roles.decorator.js';
|
||||
import { Role } from '../../generated/prisma/enums.js';
|
||||
import { UsersService } from '../users.service.js';
|
||||
import { AssignEngineerHospitalDto } from '../dto/assign-engineer-hospital.dto.js';
|
||||
|
||||
/**
|
||||
* B 端用户扩展控制器:承载非标准 CRUD 的组织授权接口。
|
||||
*/
|
||||
@ApiTags('用户管理(B端)')
|
||||
@ApiBearerAuth('bearer')
|
||||
@Controller('b/users')
|
||||
@UseGuards(AccessTokenGuard, RolesGuard)
|
||||
export class BUsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
/**
|
||||
* 将工程师绑定到指定医院(仅系统管理员)。
|
||||
*/
|
||||
@Patch(':id/assign-engineer-hospital')
|
||||
@Roles(Role.SYSTEM_ADMIN)
|
||||
@ApiOperation({ summary: '绑定工程师到医院(SYSTEM_ADMIN)' })
|
||||
@ApiParam({ name: 'id', description: '工程师用户 ID' })
|
||||
assignEngineerHospital(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: AssignEngineerHospitalDto,
|
||||
) {
|
||||
return this.usersService.assignEngineerHospital(actor, Number(id), dto);
|
||||
}
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsInt, Min } from 'class-validator';
|
||||
|
||||
/**
|
||||
* 绑定工程师医院 DTO。
|
||||
*/
|
||||
export class AssignEngineerHospitalDto {
|
||||
@ApiProperty({ description: '医院 ID', example: 1 })
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'hospitalId 必须是整数' })
|
||||
@Min(1, { message: 'hospitalId 必须大于 0' })
|
||||
hospitalId!: number;
|
||||
}
|
||||
@ -1,66 +1 @@
|
||||
import { Role } from '../../generated/prisma/enums.js';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
|
||||
import {
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* B 端创建用户 DTO。
|
||||
*/
|
||||
export class CreateUserDto {
|
||||
@ApiProperty({ description: '用户姓名', example: '李医生' })
|
||||
@IsString({ message: 'name 必须是字符串' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: '手机号', example: '13800000002' })
|
||||
@IsString({ message: 'phone 必须是字符串' })
|
||||
@Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' })
|
||||
phone!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '密码(可选)', example: 'Abcd1234' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'password 必须是字符串' })
|
||||
@MinLength(8, { message: 'password 长度至少 8 位' })
|
||||
password?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '微信 openId', example: 'wx-open-id-demo' })
|
||||
@IsOptional()
|
||||
@IsString({ message: 'openId 必须是字符串' })
|
||||
openId?: string;
|
||||
|
||||
@ApiProperty({ description: '角色', enum: Role, example: Role.DOCTOR })
|
||||
@IsEnum(Role, { message: 'role 枚举值不合法' })
|
||||
role!: Role;
|
||||
|
||||
@ApiPropertyOptional({ description: '医院 ID', example: 1 })
|
||||
@IsOptional()
|
||||
@EmptyStringToUndefined()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'hospitalId 必须是整数' })
|
||||
@Min(1, { message: 'hospitalId 必须大于 0' })
|
||||
hospitalId?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '科室 ID', example: 1 })
|
||||
@IsOptional()
|
||||
@EmptyStringToUndefined()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'departmentId 必须是整数' })
|
||||
@Min(1, { message: 'departmentId 必须大于 0' })
|
||||
departmentId?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '小组 ID', example: 1 })
|
||||
@IsOptional()
|
||||
@EmptyStringToUndefined()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'groupId 必须是整数' })
|
||||
@Min(1, { message: 'groupId 必须大于 0' })
|
||||
groupId?: number;
|
||||
}
|
||||
export class CreateUserDto {}
|
||||
|
||||
@ -1,40 +0,0 @@
|
||||
import { Role } from '../../generated/prisma/enums.js';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
|
||||
import {
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* 登录 DTO:后台与小程序均可复用。
|
||||
*/
|
||||
export class LoginDto {
|
||||
@ApiProperty({ description: '手机号', example: '13800000002' })
|
||||
@IsString({ message: 'phone 必须是字符串' })
|
||||
@Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' })
|
||||
phone!: string;
|
||||
|
||||
@ApiProperty({ description: '密码', example: 'Abcd1234' })
|
||||
@IsString({ message: 'password 必须是字符串' })
|
||||
@MinLength(8, { message: 'password 长度至少 8 位' })
|
||||
password!: string;
|
||||
|
||||
@ApiProperty({ description: '登录角色', enum: Role, example: Role.DOCTOR })
|
||||
@IsEnum(Role, { message: 'role 枚举值不合法' })
|
||||
role!: Role;
|
||||
|
||||
@ApiPropertyOptional({ description: '医院 ID(多账号场景建议传入)', example: 1 })
|
||||
@IsOptional()
|
||||
@EmptyStringToUndefined()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'hospitalId 必须是整数' })
|
||||
@Min(1, { message: 'hospitalId 必须大于 0' })
|
||||
hospitalId?: number;
|
||||
}
|
||||
@ -1,76 +0,0 @@
|
||||
import { Role } from '../../generated/prisma/enums.js';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
|
||||
import {
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
/**
|
||||
* 注册 DTO:同时服务小程序与后台账号创建。
|
||||
*/
|
||||
export class RegisterUserDto {
|
||||
@ApiProperty({ description: '用户姓名', example: '张三' })
|
||||
@IsString({ message: 'name 必须是字符串' })
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: '手机号(中国大陆)', example: '13800000001' })
|
||||
@IsString({ message: 'phone 必须是字符串' })
|
||||
@Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' })
|
||||
phone!: string;
|
||||
|
||||
@ApiProperty({ description: '登录密码(至少 8 位)', example: 'Abcd1234' })
|
||||
@IsString({ message: 'password 必须是字符串' })
|
||||
@MinLength(8, { message: 'password 长度至少 8 位' })
|
||||
password!: string;
|
||||
|
||||
@ApiProperty({ description: '系统角色', enum: Role, example: Role.DOCTOR })
|
||||
@IsEnum(Role, { message: 'role 枚举值不合法' })
|
||||
role!: Role;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '微信 openId(可选)',
|
||||
example: 'wx-open-id-demo',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: 'openId 必须是字符串' })
|
||||
openId?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '所属医院 ID', example: 1 })
|
||||
@IsOptional()
|
||||
@EmptyStringToUndefined()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'hospitalId 必须是整数' })
|
||||
@Min(1, { message: 'hospitalId 必须大于 0' })
|
||||
hospitalId?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '所属科室 ID', example: 1 })
|
||||
@IsOptional()
|
||||
@EmptyStringToUndefined()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'departmentId 必须是整数' })
|
||||
@Min(1, { message: 'departmentId 必须大于 0' })
|
||||
departmentId?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '所属小组 ID', example: 1 })
|
||||
@IsOptional()
|
||||
@EmptyStringToUndefined()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: 'groupId 必须是整数' })
|
||||
@Min(1, { message: 'groupId 必须大于 0' })
|
||||
groupId?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '系统管理员注册引导密钥(仅注册 SYSTEM_ADMIN 需要)',
|
||||
example: 'admin-bootstrap-key',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString({ message: 'systemAdminBootstrapKey 必须是字符串' })
|
||||
systemAdminBootstrapKey?: string;
|
||||
}
|
||||
@ -1,7 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateUserDto } from './create-user.dto.js';
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateUserDto } from './create-user.dto';
|
||||
|
||||
/**
|
||||
* 更新用户 DTO。
|
||||
*/
|
||||
export class UpdateUserDto extends PartialType(CreateUserDto) {}
|
||||
|
||||
@ -1,86 +1,33 @@
|
||||
import {
|
||||
UseGuards,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Patch,
|
||||
Param,
|
||||
Delete,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { AccessTokenGuard } from '../auth/access-token.guard.js';
|
||||
import { RolesGuard } from '../auth/roles.guard.js';
|
||||
import { Roles } from '../auth/roles.decorator.js';
|
||||
import { Role } from '../generated/prisma/enums.js';
|
||||
import { UsersService } from './users.service.js';
|
||||
import { CreateUserDto } from './dto/create-user.dto.js';
|
||||
import { UpdateUserDto } from './dto/update-user.dto.js';
|
||||
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
|
||||
import { UsersService } from './users.service';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
|
||||
/**
|
||||
* 用户管理控制器:面向 B 端后台的用户 CRUD。
|
||||
*/
|
||||
@ApiTags('用户管理(B端)')
|
||||
@ApiBearerAuth('bearer')
|
||||
@Controller('users')
|
||||
@UseGuards(AccessTokenGuard, RolesGuard)
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
/**
|
||||
* 创建用户。
|
||||
*/
|
||||
@Post()
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@ApiOperation({ summary: '创建用户' })
|
||||
create(@Body() createUserDto: CreateUserDto) {
|
||||
return this.usersService.create(createUserDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户列表。
|
||||
*/
|
||||
@Get()
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@ApiOperation({ summary: '查询用户列表' })
|
||||
findAll() {
|
||||
return this.usersService.findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户详情。
|
||||
*/
|
||||
@Get(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@ApiOperation({ summary: '查询用户详情' })
|
||||
@ApiParam({ name: 'id', description: '用户 ID' })
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.usersService.findOne(+id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户。
|
||||
*/
|
||||
@Patch(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@ApiOperation({ summary: '更新用户' })
|
||||
@ApiParam({ name: 'id', description: '用户 ID' })
|
||||
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
|
||||
return this.usersService.update(+id, updateUserDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户。
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN)
|
||||
@ApiOperation({ summary: '删除用户' })
|
||||
@ApiParam({ name: 'id', description: '用户 ID' })
|
||||
remove(@Param('id') id: string) {
|
||||
return this.usersService.remove(+id);
|
||||
}
|
||||
|
||||
@ -1,13 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersService } from './users.service.js';
|
||||
import { UsersController } from './users.controller.js';
|
||||
import { BUsersController } from './b-users/b-users.controller.js';
|
||||
import { AccessTokenGuard } from '../auth/access-token.guard.js';
|
||||
import { RolesGuard } from '../auth/roles.guard.js';
|
||||
import { UsersService } from './users.service';
|
||||
import { UsersController } from './users.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [UsersController, BUsersController],
|
||||
providers: [UsersService, AccessTokenGuard, RolesGuard],
|
||||
exports: [UsersService],
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
@ -1,617 +1,26 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { compare, hash } from 'bcrypt';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { Prisma } from '../generated/prisma/client.js';
|
||||
import { CreateUserDto } from './dto/create-user.dto.js';
|
||||
import { UpdateUserDto } from './dto/update-user.dto.js';
|
||||
import { Role } from '../generated/prisma/enums.js';
|
||||
import { PrismaService } from '../prisma.service.js';
|
||||
import type { ActorContext } from '../common/actor-context.js';
|
||||
import { AssignEngineerHospitalDto } from './dto/assign-engineer-hospital.dto.js';
|
||||
import { RegisterUserDto } from './dto/register-user.dto.js';
|
||||
import { LoginDto } from './dto/login.dto.js';
|
||||
import { MESSAGES } from '../common/messages.js';
|
||||
|
||||
const SAFE_USER_SELECT = {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
openId: true,
|
||||
role: true,
|
||||
hospitalId: true,
|
||||
departmentId: true,
|
||||
groupId: true,
|
||||
} as const;
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CreateUserDto } from './dto/create-user.dto';
|
||||
import { UpdateUserDto } from './dto/update-user.dto';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* 注册账号:根据角色与组织范围进行约束,并写入 bcrypt 密码摘要。
|
||||
*/
|
||||
async register(dto: RegisterUserDto) {
|
||||
const role = this.normalizeRole(dto.role);
|
||||
const name = this.normalizeRequiredString(dto.name, 'name');
|
||||
const phone = this.normalizePhone(dto.phone);
|
||||
const password = this.normalizePassword(dto.password);
|
||||
const openId = this.normalizeOptionalString(dto.openId);
|
||||
const hospitalId = this.normalizeOptionalInt(dto.hospitalId, 'hospitalId');
|
||||
const departmentId = this.normalizeOptionalInt(
|
||||
dto.departmentId,
|
||||
'departmentId',
|
||||
);
|
||||
const groupId = this.normalizeOptionalInt(dto.groupId, 'groupId');
|
||||
|
||||
this.assertSystemAdminBootstrapKey(role, dto.systemAdminBootstrapKey);
|
||||
await this.assertOrganizationScope(role, hospitalId, departmentId, groupId);
|
||||
await this.assertOpenIdUnique(openId);
|
||||
await this.assertPhoneRoleScopeUnique(phone, role, hospitalId);
|
||||
|
||||
const passwordHash = await hash(password, 12);
|
||||
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
phone,
|
||||
passwordHash,
|
||||
openId,
|
||||
role,
|
||||
hospitalId,
|
||||
departmentId,
|
||||
groupId,
|
||||
},
|
||||
select: SAFE_USER_SELECT,
|
||||
});
|
||||
create(createUserDto: CreateUserDto) {
|
||||
return 'This action adds a new user';
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录:按手机号+角色(可选医院)定位账号并签发 JWT。
|
||||
*/
|
||||
async login(dto: LoginDto) {
|
||||
const role = this.normalizeRole(dto.role);
|
||||
const phone = this.normalizePhone(dto.phone);
|
||||
const password = this.normalizePassword(dto.password);
|
||||
const hospitalId = this.normalizeOptionalInt(dto.hospitalId, 'hospitalId');
|
||||
|
||||
const users = await this.prisma.user.findMany({
|
||||
where: {
|
||||
phone,
|
||||
role,
|
||||
...(hospitalId != null ? { hospitalId } : {}),
|
||||
},
|
||||
select: {
|
||||
...SAFE_USER_SELECT,
|
||||
passwordHash: true,
|
||||
},
|
||||
take: 5,
|
||||
});
|
||||
|
||||
if (users.length === 0) {
|
||||
throw new UnauthorizedException(MESSAGES.AUTH.INVALID_CREDENTIALS);
|
||||
}
|
||||
if (users.length > 1 && hospitalId == null) {
|
||||
throw new BadRequestException(
|
||||
MESSAGES.USER.MULTI_ACCOUNT_REQUIRE_HOSPITAL,
|
||||
);
|
||||
}
|
||||
|
||||
const user = users[0];
|
||||
if (!user?.passwordHash) {
|
||||
throw new UnauthorizedException(MESSAGES.AUTH.PASSWORD_NOT_ENABLED);
|
||||
}
|
||||
|
||||
const matched = await compare(password, user.passwordHash);
|
||||
if (!matched) {
|
||||
throw new UnauthorizedException(MESSAGES.AUTH.INVALID_CREDENTIALS);
|
||||
}
|
||||
|
||||
const actor: ActorContext = {
|
||||
id: user.id,
|
||||
role: user.role,
|
||||
hospitalId: user.hospitalId,
|
||||
departmentId: user.departmentId,
|
||||
groupId: user.groupId,
|
||||
};
|
||||
|
||||
return {
|
||||
tokenType: 'Bearer',
|
||||
accessToken: this.signAccessToken(actor),
|
||||
actor,
|
||||
user: this.toSafeUser(user),
|
||||
};
|
||||
findAll() {
|
||||
return `This action returns all users`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录用户详情。
|
||||
*/
|
||||
async me(actor: ActorContext) {
|
||||
return this.findOne(actor.id);
|
||||
findOne(id: number) {
|
||||
return `This action returns a #${id} user`;
|
||||
}
|
||||
|
||||
/**
|
||||
* B 端创建用户(通常由管理员使用)。
|
||||
*/
|
||||
async create(createUserDto: CreateUserDto) {
|
||||
const role = this.normalizeRole(createUserDto.role);
|
||||
const name = this.normalizeRequiredString(createUserDto.name, 'name');
|
||||
const phone = this.normalizePhone(createUserDto.phone);
|
||||
const password = createUserDto.password
|
||||
? this.normalizePassword(createUserDto.password)
|
||||
: null;
|
||||
const openId = this.normalizeOptionalString(createUserDto.openId);
|
||||
const hospitalId = this.normalizeOptionalInt(
|
||||
createUserDto.hospitalId,
|
||||
'hospitalId',
|
||||
);
|
||||
const departmentId = this.normalizeOptionalInt(
|
||||
createUserDto.departmentId,
|
||||
'departmentId',
|
||||
);
|
||||
const groupId = this.normalizeOptionalInt(createUserDto.groupId, 'groupId');
|
||||
|
||||
await this.assertOrganizationScope(role, hospitalId, departmentId, groupId);
|
||||
await this.assertOpenIdUnique(openId);
|
||||
await this.assertPhoneRoleScopeUnique(phone, role, hospitalId);
|
||||
|
||||
return this.prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
phone,
|
||||
passwordHash: password ? await hash(password, 12) : null,
|
||||
openId,
|
||||
role,
|
||||
hospitalId,
|
||||
departmentId,
|
||||
groupId,
|
||||
},
|
||||
select: SAFE_USER_SELECT,
|
||||
});
|
||||
update(id: number, updateUserDto: UpdateUserDto) {
|
||||
return `This action updates a #${id} user`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户列表。
|
||||
*/
|
||||
async findAll() {
|
||||
return this.prisma.user.findMany({
|
||||
select: SAFE_USER_SELECT,
|
||||
orderBy: { id: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户详情。
|
||||
*/
|
||||
async findOne(id: number) {
|
||||
const userId = this.normalizeRequiredInt(id, 'id');
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: SAFE_USER_SELECT,
|
||||
});
|
||||
if (!user) {
|
||||
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户信息(含可选密码重置)。
|
||||
*/
|
||||
async update(id: number, updateUserDto: UpdateUserDto) {
|
||||
const userId = this.normalizeRequiredInt(id, 'id');
|
||||
const current = await this.prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
...SAFE_USER_SELECT,
|
||||
passwordHash: true,
|
||||
},
|
||||
});
|
||||
if (!current) {
|
||||
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
|
||||
}
|
||||
|
||||
const nextRole =
|
||||
updateUserDto.role != null ? this.normalizeRole(updateUserDto.role) : current.role;
|
||||
const nextHospitalId =
|
||||
updateUserDto.hospitalId !== undefined
|
||||
? this.normalizeOptionalInt(updateUserDto.hospitalId, 'hospitalId')
|
||||
: current.hospitalId;
|
||||
const nextDepartmentId =
|
||||
updateUserDto.departmentId !== undefined
|
||||
? this.normalizeOptionalInt(updateUserDto.departmentId, 'departmentId')
|
||||
: current.departmentId;
|
||||
const nextGroupId =
|
||||
updateUserDto.groupId !== undefined
|
||||
? this.normalizeOptionalInt(updateUserDto.groupId, 'groupId')
|
||||
: current.groupId;
|
||||
|
||||
const assigningDepartmentOrGroup =
|
||||
(updateUserDto.departmentId !== undefined && nextDepartmentId != null) ||
|
||||
(updateUserDto.groupId !== undefined && nextGroupId != null);
|
||||
if (
|
||||
assigningDepartmentOrGroup &&
|
||||
nextRole !== Role.DOCTOR &&
|
||||
nextRole !== Role.DIRECTOR &&
|
||||
nextRole !== Role.LEADER
|
||||
) {
|
||||
throw new BadRequestException(MESSAGES.USER.DOCTOR_ONLY_SCOPE_CHANGE);
|
||||
}
|
||||
|
||||
await this.assertOrganizationScope(
|
||||
nextRole,
|
||||
nextHospitalId,
|
||||
nextDepartmentId,
|
||||
nextGroupId,
|
||||
);
|
||||
|
||||
const nextOpenId =
|
||||
updateUserDto.openId !== undefined
|
||||
? this.normalizeOptionalString(updateUserDto.openId)
|
||||
: current.openId;
|
||||
await this.assertOpenIdUnique(nextOpenId, userId);
|
||||
const nextPhone =
|
||||
updateUserDto.phone !== undefined
|
||||
? this.normalizePhone(updateUserDto.phone)
|
||||
: current.phone;
|
||||
await this.assertPhoneRoleScopeUnique(
|
||||
nextPhone,
|
||||
nextRole,
|
||||
nextHospitalId,
|
||||
userId,
|
||||
);
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (updateUserDto.name !== undefined) {
|
||||
data.name = this.normalizeRequiredString(updateUserDto.name, 'name');
|
||||
}
|
||||
if (updateUserDto.phone !== undefined) {
|
||||
data.phone = nextPhone;
|
||||
}
|
||||
if (updateUserDto.role !== undefined) {
|
||||
data.role = nextRole;
|
||||
}
|
||||
if (updateUserDto.hospitalId !== undefined) {
|
||||
data.hospitalId = nextHospitalId;
|
||||
}
|
||||
if (updateUserDto.departmentId !== undefined) {
|
||||
data.departmentId = nextDepartmentId;
|
||||
}
|
||||
if (updateUserDto.groupId !== undefined) {
|
||||
data.groupId = nextGroupId;
|
||||
}
|
||||
if (updateUserDto.openId !== undefined) {
|
||||
data.openId = nextOpenId;
|
||||
}
|
||||
if (updateUserDto.password) {
|
||||
data.passwordHash = await hash(
|
||||
this.normalizePassword(updateUserDto.password),
|
||||
12,
|
||||
);
|
||||
}
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data,
|
||||
select: SAFE_USER_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户。
|
||||
*/
|
||||
async remove(id: number) {
|
||||
const userId = this.normalizeRequiredInt(id, 'id');
|
||||
await this.findOne(userId);
|
||||
|
||||
try {
|
||||
return await this.prisma.user.delete({
|
||||
where: { id: userId },
|
||||
select: SAFE_USER_SELECT,
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2003'
|
||||
) {
|
||||
throw new ConflictException(MESSAGES.USER.DELETE_CONFLICT);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统管理员将工程师绑定到指定医院。
|
||||
*/
|
||||
async assignEngineerHospital(
|
||||
actor: ActorContext,
|
||||
targetUserId: number,
|
||||
dto: AssignEngineerHospitalDto,
|
||||
) {
|
||||
if (actor.role !== Role.SYSTEM_ADMIN) {
|
||||
throw new ForbiddenException(MESSAGES.USER.ENGINEER_BIND_FORBIDDEN);
|
||||
}
|
||||
if (!Number.isInteger(dto.hospitalId)) {
|
||||
throw new BadRequestException(MESSAGES.USER.HOSPITAL_ID_INVALID);
|
||||
}
|
||||
|
||||
const hospital = await this.prisma.hospital.findUnique({
|
||||
where: { id: dto.hospitalId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!hospital) {
|
||||
throw new NotFoundException(MESSAGES.ORG.HOSPITAL_NOT_FOUND);
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: targetUserId },
|
||||
select: { id: true, role: true },
|
||||
});
|
||||
if (!user) {
|
||||
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
|
||||
}
|
||||
if (user.role !== Role.ENGINEER) {
|
||||
throw new BadRequestException(MESSAGES.USER.TARGET_NOT_ENGINEER);
|
||||
}
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: { id: targetUserId },
|
||||
data: {
|
||||
hospitalId: dto.hospitalId,
|
||||
departmentId: null,
|
||||
groupId: null,
|
||||
},
|
||||
select: SAFE_USER_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 去除密码摘要,避免泄露敏感信息。
|
||||
*/
|
||||
private toSafeUser(user: { passwordHash?: string | null } & Record<string, unknown>) {
|
||||
const { passwordHash, ...safe } = user;
|
||||
return safe;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验系统管理员注册引导密钥。
|
||||
*/
|
||||
private assertSystemAdminBootstrapKey(
|
||||
role: Role,
|
||||
providedBootstrapKey?: string,
|
||||
) {
|
||||
if (role !== Role.SYSTEM_ADMIN) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expectedBootstrapKey = process.env.SYSTEM_ADMIN_BOOTSTRAP_KEY;
|
||||
if (!expectedBootstrapKey) {
|
||||
throw new ForbiddenException(MESSAGES.USER.SYSTEM_ADMIN_REG_DISABLED);
|
||||
}
|
||||
if (providedBootstrapKey !== expectedBootstrapKey) {
|
||||
throw new ForbiddenException(
|
||||
MESSAGES.USER.SYSTEM_ADMIN_BOOTSTRAP_KEY_INVALID,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验“手机号 + 角色 + 医院”唯一性。
|
||||
*/
|
||||
private async assertPhoneRoleScopeUnique(
|
||||
phone: string,
|
||||
role: Role,
|
||||
hospitalId: number | null,
|
||||
selfId?: number,
|
||||
) {
|
||||
const exists = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
phone,
|
||||
role,
|
||||
hospitalId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (exists && exists.id !== selfId) {
|
||||
throw new ConflictException(MESSAGES.USER.DUPLICATE_PHONE_ROLE_SCOPE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 openId 唯一性。
|
||||
*/
|
||||
private async assertOpenIdUnique(openId: string | null, selfId?: number) {
|
||||
if (!openId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const exists = await this.prisma.user.findUnique({
|
||||
where: { openId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (exists && exists.id !== selfId) {
|
||||
throw new ConflictException(MESSAGES.USER.DUPLICATE_OPEN_ID);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验角色与组织归属关系是否合法。
|
||||
*/
|
||||
private async assertOrganizationScope(
|
||||
role: Role,
|
||||
hospitalId: number | null,
|
||||
departmentId: number | null,
|
||||
groupId: number | null,
|
||||
) {
|
||||
if (role === Role.SYSTEM_ADMIN) {
|
||||
if (hospitalId || departmentId || groupId) {
|
||||
throw new BadRequestException(MESSAGES.USER.SYSTEM_ADMIN_SCOPE_INVALID);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hospitalId) {
|
||||
throw new BadRequestException(MESSAGES.USER.HOSPITAL_REQUIRED);
|
||||
}
|
||||
|
||||
const hospital = await this.prisma.hospital.findUnique({
|
||||
where: { id: hospitalId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!hospital) {
|
||||
throw new BadRequestException(MESSAGES.USER.HOSPITAL_NOT_FOUND);
|
||||
}
|
||||
|
||||
const needsDepartment =
|
||||
role === Role.DIRECTOR || role === Role.LEADER || role === Role.DOCTOR;
|
||||
if (needsDepartment && !departmentId) {
|
||||
throw new BadRequestException(MESSAGES.USER.DEPARTMENT_REQUIRED);
|
||||
}
|
||||
|
||||
const needsGroup = role === Role.LEADER || role === Role.DOCTOR;
|
||||
if (needsGroup && !groupId) {
|
||||
throw new BadRequestException(MESSAGES.USER.GROUP_REQUIRED);
|
||||
}
|
||||
|
||||
if (role === Role.ENGINEER && (departmentId || groupId)) {
|
||||
throw new BadRequestException(MESSAGES.USER.ENGINEER_SCOPE_INVALID);
|
||||
}
|
||||
|
||||
if (departmentId) {
|
||||
const department = await this.prisma.department.findUnique({
|
||||
where: { id: departmentId },
|
||||
select: { id: true, hospitalId: true },
|
||||
});
|
||||
if (!department || department.hospitalId !== hospitalId) {
|
||||
throw new BadRequestException(MESSAGES.USER.DEPARTMENT_HOSPITAL_MISMATCH);
|
||||
}
|
||||
}
|
||||
|
||||
if (groupId) {
|
||||
if (!departmentId) {
|
||||
throw new BadRequestException(MESSAGES.USER.GROUP_DEPARTMENT_REQUIRED);
|
||||
}
|
||||
const group = await this.prisma.group.findUnique({
|
||||
where: { id: groupId },
|
||||
select: { id: true, departmentId: true },
|
||||
});
|
||||
if (!group || group.departmentId !== departmentId) {
|
||||
throw new BadRequestException(MESSAGES.USER.GROUP_DEPARTMENT_MISMATCH);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 必填整数标准化。
|
||||
*/
|
||||
private normalizeRequiredInt(value: unknown, fieldName: string): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed)) {
|
||||
throw new BadRequestException(`${fieldName} 必须为整数`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可空整数标准化。
|
||||
*/
|
||||
private normalizeOptionalInt(
|
||||
value: unknown,
|
||||
fieldName: string,
|
||||
): number | null {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null;
|
||||
}
|
||||
return this.normalizeRequiredInt(value, fieldName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 必填字符串标准化。
|
||||
*/
|
||||
private normalizeRequiredString(value: unknown, fieldName: string): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException(`${fieldName} 必须为字符串`);
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new BadRequestException(`${fieldName} 不能为空`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可空字符串标准化。
|
||||
*/
|
||||
private normalizeOptionalString(value: unknown): string | null {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException(MESSAGES.USER.INVALID_OPEN_ID);
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号标准化与格式校验。
|
||||
*/
|
||||
private normalizePhone(phone: unknown): string {
|
||||
const normalized = this.normalizeRequiredString(phone, 'phone');
|
||||
if (!/^1\d{10}$/.test(normalized)) {
|
||||
throw new BadRequestException(MESSAGES.USER.INVALID_PHONE);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 密码标准化与长度校验。
|
||||
*/
|
||||
private normalizePassword(password: unknown): string {
|
||||
const normalized = this.normalizeRequiredString(password, 'password');
|
||||
if (normalized.length < 8) {
|
||||
throw new BadRequestException(MESSAGES.USER.INVALID_PASSWORD);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色枚举校验。
|
||||
*/
|
||||
private normalizeRole(role: unknown): Role {
|
||||
if (typeof role !== 'string') {
|
||||
throw new BadRequestException(MESSAGES.USER.INVALID_ROLE);
|
||||
}
|
||||
|
||||
if (!Object.values(Role).includes(role as Role)) {
|
||||
throw new BadRequestException(MESSAGES.USER.INVALID_ROLE);
|
||||
}
|
||||
|
||||
return role as Role;
|
||||
}
|
||||
|
||||
/**
|
||||
* 签发访问令牌。
|
||||
*/
|
||||
private signAccessToken(actor: ActorContext): string {
|
||||
const secret = process.env.AUTH_TOKEN_SECRET;
|
||||
if (!secret) {
|
||||
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_SECRET_MISSING);
|
||||
}
|
||||
|
||||
return jwt.sign(actor, secret, {
|
||||
algorithm: 'HS256',
|
||||
expiresIn: '7d',
|
||||
issuer: 'tyt-api-nest',
|
||||
});
|
||||
remove(id: number) {
|
||||
return `This action removes a #${id} user`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,59 +0,0 @@
|
||||
import { Role } from '../../../src/generated/prisma/enums.js';
|
||||
|
||||
export const E2E_SEED_PASSWORD = 'Seed@1234';
|
||||
|
||||
export const E2E_ROLE_LIST = [
|
||||
Role.SYSTEM_ADMIN,
|
||||
Role.HOSPITAL_ADMIN,
|
||||
Role.DIRECTOR,
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
Role.ENGINEER,
|
||||
] as const;
|
||||
|
||||
export type E2ERole = (typeof E2E_ROLE_LIST)[number];
|
||||
|
||||
export interface E2ESeedCredential {
|
||||
role: E2ERole;
|
||||
phone: string;
|
||||
password: string;
|
||||
hospitalId?: number;
|
||||
}
|
||||
|
||||
export const E2E_SEED_CREDENTIALS: Record<E2ERole, E2ESeedCredential> = {
|
||||
[Role.SYSTEM_ADMIN]: {
|
||||
role: Role.SYSTEM_ADMIN,
|
||||
phone: '13800001000',
|
||||
password: E2E_SEED_PASSWORD,
|
||||
},
|
||||
[Role.HOSPITAL_ADMIN]: {
|
||||
role: Role.HOSPITAL_ADMIN,
|
||||
phone: '13800001001',
|
||||
password: E2E_SEED_PASSWORD,
|
||||
hospitalId: 1,
|
||||
},
|
||||
[Role.DIRECTOR]: {
|
||||
role: Role.DIRECTOR,
|
||||
phone: '13800001002',
|
||||
password: E2E_SEED_PASSWORD,
|
||||
hospitalId: 1,
|
||||
},
|
||||
[Role.LEADER]: {
|
||||
role: Role.LEADER,
|
||||
phone: '13800001003',
|
||||
password: E2E_SEED_PASSWORD,
|
||||
hospitalId: 1,
|
||||
},
|
||||
[Role.DOCTOR]: {
|
||||
role: Role.DOCTOR,
|
||||
phone: '13800001004',
|
||||
password: E2E_SEED_PASSWORD,
|
||||
hospitalId: 1,
|
||||
},
|
||||
[Role.ENGINEER]: {
|
||||
role: Role.ENGINEER,
|
||||
phone: '13800001005',
|
||||
password: E2E_SEED_PASSWORD,
|
||||
hospitalId: 1,
|
||||
},
|
||||
};
|
||||
@ -1,41 +0,0 @@
|
||||
import 'dotenv/config';
|
||||
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { AppModule } from '../../../src/app.module.js';
|
||||
import { HttpExceptionFilter } from '../../../src/common/http-exception.filter.js';
|
||||
import { MESSAGES } from '../../../src/common/messages.js';
|
||||
import { ResponseEnvelopeInterceptor } from '../../../src/common/response-envelope.interceptor.js';
|
||||
|
||||
export async function createE2eApp(): Promise<INestApplication> {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
|
||||
const app = moduleRef.createNestApplication();
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
exceptionFactory: (errors) => {
|
||||
const messages = errors
|
||||
.flatMap((error) => Object.values(error.constraints ?? {}))
|
||||
.filter((item): item is string => Boolean(item));
|
||||
|
||||
return new BadRequestException(
|
||||
messages.length > 0
|
||||
? messages.join(';')
|
||||
: MESSAGES.DEFAULT_BAD_REQUEST,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new ResponseEnvelopeInterceptor());
|
||||
|
||||
await app.init();
|
||||
return app;
|
||||
}
|
||||
@ -1,47 +0,0 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import request from 'supertest';
|
||||
import {
|
||||
E2E_ROLE_LIST,
|
||||
type E2ERole,
|
||||
E2E_SEED_CREDENTIALS,
|
||||
} from '../fixtures/e2e-roles.js';
|
||||
import { expectSuccessEnvelope } from './e2e-http.helper.js';
|
||||
|
||||
export type E2EAccessTokenMap = Record<E2ERole, string>;
|
||||
|
||||
export async function loginAsRole(
|
||||
app: INestApplication,
|
||||
role: E2ERole,
|
||||
): Promise<string> {
|
||||
const credential = E2E_SEED_CREDENTIALS[role];
|
||||
const payload: Record<string, unknown> = {
|
||||
phone: credential.phone,
|
||||
password: credential.password,
|
||||
role: credential.role,
|
||||
};
|
||||
|
||||
if (credential.hospitalId != null) {
|
||||
payload.hospitalId = credential.hospitalId;
|
||||
}
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send(payload);
|
||||
|
||||
expectSuccessEnvelope(response, 201);
|
||||
expect(response.body.data?.accessToken).toEqual(expect.any(String));
|
||||
|
||||
return response.body.data.accessToken as string;
|
||||
}
|
||||
|
||||
export async function loginAllRoles(
|
||||
app: INestApplication,
|
||||
): Promise<E2EAccessTokenMap> {
|
||||
const tokenEntries = await Promise.all(
|
||||
E2E_ROLE_LIST.map(
|
||||
async (role) => [role, await loginAsRole(app, role)] as const,
|
||||
),
|
||||
);
|
||||
|
||||
return Object.fromEntries(tokenEntries) as E2EAccessTokenMap;
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../src/prisma.service.js';
|
||||
import { loginAllRoles, type E2EAccessTokenMap } from './e2e-auth.helper.js';
|
||||
import { createE2eApp } from './e2e-app.helper.js';
|
||||
import {
|
||||
loadSeedFixtures,
|
||||
type E2ESeedFixtures,
|
||||
} from './e2e-fixtures.helper.js';
|
||||
|
||||
export interface E2EContext {
|
||||
app: INestApplication;
|
||||
prisma: PrismaService;
|
||||
tokens: E2EAccessTokenMap;
|
||||
fixtures: E2ESeedFixtures;
|
||||
}
|
||||
|
||||
export async function createE2EContext(): Promise<E2EContext> {
|
||||
const app = await createE2eApp();
|
||||
const prisma = app.get(PrismaService);
|
||||
const fixtures = await loadSeedFixtures(prisma);
|
||||
const tokens = await loginAllRoles(app);
|
||||
|
||||
return {
|
||||
app,
|
||||
prisma,
|
||||
fixtures,
|
||||
tokens,
|
||||
};
|
||||
}
|
||||
|
||||
export async function closeE2EContext(ctx?: E2EContext) {
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.prisma.$disconnect();
|
||||
await ctx.app.close();
|
||||
}
|
||||
@ -1,195 +0,0 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../../src/prisma.service.js';
|
||||
|
||||
export interface E2ESeedFixtures {
|
||||
hospitalAId: number;
|
||||
hospitalBId: number;
|
||||
departmentA1Id: number;
|
||||
departmentA2Id: number;
|
||||
departmentB1Id: number;
|
||||
groupA1Id: number;
|
||||
groupA2Id: number;
|
||||
groupB1Id: number;
|
||||
users: {
|
||||
systemAdminId: number;
|
||||
hospitalAdminAId: number;
|
||||
directorAId: number;
|
||||
leaderAId: number;
|
||||
doctorAId: number;
|
||||
doctorA2Id: number;
|
||||
doctorA3Id: number;
|
||||
doctorBId: number;
|
||||
engineerAId: number;
|
||||
engineerBId: number;
|
||||
};
|
||||
patients: {
|
||||
patientA1Id: number;
|
||||
patientA2Id: number;
|
||||
patientA3Id: number;
|
||||
patientB1Id: number;
|
||||
};
|
||||
devices: {
|
||||
deviceA1Id: number;
|
||||
deviceA2Id: number;
|
||||
deviceA3Id: number;
|
||||
deviceA4InactiveId: number;
|
||||
deviceB1Id: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface SeedUserScope {
|
||||
id: number;
|
||||
hospitalId: number | null;
|
||||
departmentId: number | null;
|
||||
groupId: number | null;
|
||||
}
|
||||
|
||||
async function requireUserScope(
|
||||
prisma: PrismaService,
|
||||
openId: string,
|
||||
): Promise<SeedUserScope> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { openId },
|
||||
select: {
|
||||
id: true,
|
||||
hospitalId: true,
|
||||
departmentId: true,
|
||||
groupId: true,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
throw new NotFoundException(`Seed user not found: ${openId}`);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
async function requireDeviceId(
|
||||
prisma: PrismaService,
|
||||
snCode: string,
|
||||
): Promise<number> {
|
||||
const device = await prisma.device.findUnique({
|
||||
where: { snCode },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!device) {
|
||||
throw new NotFoundException(`Seed device not found: ${snCode}`);
|
||||
}
|
||||
return device.id;
|
||||
}
|
||||
|
||||
async function requirePatientId(
|
||||
prisma: PrismaService,
|
||||
hospitalId: number,
|
||||
phone: string,
|
||||
idCardHash: string,
|
||||
): Promise<number> {
|
||||
const patient = await prisma.patient.findFirst({
|
||||
where: { hospitalId, phone, idCardHash },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!patient) {
|
||||
throw new NotFoundException(
|
||||
`Seed patient not found: ${phone}/${idCardHash}`,
|
||||
);
|
||||
}
|
||||
return patient.id;
|
||||
}
|
||||
|
||||
export async function loadSeedFixtures(
|
||||
prisma: PrismaService,
|
||||
): Promise<E2ESeedFixtures> {
|
||||
const systemAdmin = await requireUserScope(
|
||||
prisma,
|
||||
'seed-system-admin-openid',
|
||||
);
|
||||
const hospitalAdminA = await requireUserScope(
|
||||
prisma,
|
||||
'seed-hospital-admin-a-openid',
|
||||
);
|
||||
const directorA = await requireUserScope(prisma, 'seed-director-a-openid');
|
||||
const leaderA = await requireUserScope(prisma, 'seed-leader-a-openid');
|
||||
const doctorA = await requireUserScope(prisma, 'seed-doctor-a-openid');
|
||||
const doctorA2 = await requireUserScope(prisma, 'seed-doctor-a2-openid');
|
||||
const doctorA3 = await requireUserScope(prisma, 'seed-doctor-a3-openid');
|
||||
const doctorB = await requireUserScope(prisma, 'seed-doctor-b-openid');
|
||||
const engineerA = await requireUserScope(prisma, 'seed-engineer-a-openid');
|
||||
const engineerB = await requireUserScope(prisma, 'seed-engineer-b-openid');
|
||||
|
||||
const hospitalAId = hospitalAdminA.hospitalId;
|
||||
const hospitalBId = doctorB.hospitalId;
|
||||
const departmentA1Id = doctorA.departmentId;
|
||||
const departmentA2Id = doctorA3.departmentId;
|
||||
const departmentB1Id = doctorB.departmentId;
|
||||
const groupA1Id = doctorA.groupId;
|
||||
const groupA2Id = doctorA3.groupId;
|
||||
const groupB1Id = doctorB.groupId;
|
||||
|
||||
if (
|
||||
hospitalAId == null ||
|
||||
hospitalBId == null ||
|
||||
departmentA1Id == null ||
|
||||
departmentA2Id == null ||
|
||||
departmentB1Id == null ||
|
||||
groupA1Id == null ||
|
||||
groupA2Id == null ||
|
||||
groupB1Id == null
|
||||
) {
|
||||
throw new NotFoundException('Seed user scope is incomplete');
|
||||
}
|
||||
|
||||
return {
|
||||
hospitalAId,
|
||||
hospitalBId,
|
||||
departmentA1Id,
|
||||
departmentA2Id,
|
||||
departmentB1Id,
|
||||
groupA1Id,
|
||||
groupA2Id,
|
||||
groupB1Id,
|
||||
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: await requirePatientId(
|
||||
prisma,
|
||||
hospitalAId,
|
||||
'13800002001',
|
||||
'seed-id-card-cross-hospital',
|
||||
),
|
||||
patientA2Id: await requirePatientId(
|
||||
prisma,
|
||||
hospitalAId,
|
||||
'13800002002',
|
||||
'seed-id-card-a2',
|
||||
),
|
||||
patientA3Id: await requirePatientId(
|
||||
prisma,
|
||||
hospitalAId,
|
||||
'13800002003',
|
||||
'seed-id-card-a3',
|
||||
),
|
||||
patientB1Id: await requirePatientId(
|
||||
prisma,
|
||||
hospitalBId,
|
||||
'13800002001',
|
||||
'seed-id-card-cross-hospital',
|
||||
),
|
||||
},
|
||||
devices: {
|
||||
deviceA1Id: await requireDeviceId(prisma, 'SEED-SN-A-001'),
|
||||
deviceA2Id: await requireDeviceId(prisma, 'SEED-SN-A-002'),
|
||||
deviceA3Id: await requireDeviceId(prisma, 'SEED-SN-A-003'),
|
||||
deviceA4InactiveId: await requireDeviceId(prisma, 'SEED-SN-A-004'),
|
||||
deviceB1Id: await requireDeviceId(prisma, 'SEED-SN-B-001'),
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -1,37 +0,0 @@
|
||||
import type { Response } from 'supertest';
|
||||
|
||||
export function expectSuccessEnvelope(response: Response, status: number) {
|
||||
expect(response.status).toBe(status);
|
||||
expect(response.body).toEqual(
|
||||
expect.objectContaining({
|
||||
code: 0,
|
||||
msg: '成功',
|
||||
}),
|
||||
);
|
||||
expect(response.body).toHaveProperty('data');
|
||||
}
|
||||
|
||||
export function expectErrorEnvelope(
|
||||
response: Response,
|
||||
status: number,
|
||||
messageIncludes?: string,
|
||||
) {
|
||||
expect(response.status).toBe(status);
|
||||
expect(response.body.code).toBe(status);
|
||||
expect(response.body.data).toBeNull();
|
||||
|
||||
if (messageIncludes) {
|
||||
expect(String(response.body.msg)).toContain(messageIncludes);
|
||||
}
|
||||
}
|
||||
|
||||
export function uniqueSeedValue(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
export function uniquePhone(): string {
|
||||
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`
|
||||
.replace(/\D/g, '')
|
||||
.slice(-10);
|
||||
return `1${suffix.padStart(10, '0')}`.slice(0, 11);
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
import type { Response } from 'supertest';
|
||||
import { E2E_ROLE_LIST, type E2ERole } from '../fixtures/e2e-roles.js';
|
||||
import type { E2EAccessTokenMap } from './e2e-auth.helper.js';
|
||||
|
||||
interface RoleMatrixCase {
|
||||
name: string;
|
||||
tokens: E2EAccessTokenMap;
|
||||
expectedStatusByRole: Record<E2ERole, number>;
|
||||
sendAsRole: (role: E2ERole, token: string) => Promise<Response>;
|
||||
sendWithoutToken: () => Promise<Response>;
|
||||
expectedStatusWithoutToken?: number;
|
||||
}
|
||||
|
||||
export async function assertRoleMatrix(matrixCase: RoleMatrixCase) {
|
||||
for (const role of E2E_ROLE_LIST) {
|
||||
const response = await matrixCase.sendAsRole(role, matrixCase.tokens[role]);
|
||||
const expectedStatus = matrixCase.expectedStatusByRole[role];
|
||||
const isSuccess = expectedStatus >= 200 && expectedStatus < 300;
|
||||
|
||||
expect(response.status).toBe(expectedStatus);
|
||||
expect(response.body.code).toBe(isSuccess ? 0 : expectedStatus);
|
||||
}
|
||||
|
||||
const unauthorizedResponse = await matrixCase.sendWithoutToken();
|
||||
const unauthorizedStatus = matrixCase.expectedStatusWithoutToken ?? 401;
|
||||
expect(unauthorizedResponse.status).toBe(unauthorizedStatus);
|
||||
expect(unauthorizedResponse.body.code).toBe(
|
||||
unauthorizedStatus >= 200 && unauthorizedStatus < 300
|
||||
? 0
|
||||
: unauthorizedStatus,
|
||||
);
|
||||
}
|
||||
@ -1,129 +0,0 @@
|
||||
import request from 'supertest';
|
||||
import { Role } from '../../../src/generated/prisma/enums.js';
|
||||
import {
|
||||
closeE2EContext,
|
||||
createE2EContext,
|
||||
type E2EContext,
|
||||
} from '../helpers/e2e-context.helper.js';
|
||||
import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js';
|
||||
import {
|
||||
expectErrorEnvelope,
|
||||
expectSuccessEnvelope,
|
||||
uniquePhone,
|
||||
uniqueSeedValue,
|
||||
} from '../helpers/e2e-http.helper.js';
|
||||
|
||||
describe('AuthController (e2e)', () => {
|
||||
let ctx: E2EContext;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createE2EContext();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeE2EContext(ctx);
|
||||
});
|
||||
|
||||
describe('POST /auth/register', () => {
|
||||
it('成功:注册医生账号', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
name: uniqueSeedValue('Auth 注册医生'),
|
||||
phone: uniquePhone(),
|
||||
password: 'Seed@1234',
|
||||
role: Role.DOCTOR,
|
||||
hospitalId: ctx.fixtures.hospitalAId,
|
||||
departmentId: ctx.fixtures.departmentA1Id,
|
||||
groupId: ctx.fixtures.groupA1Id,
|
||||
openId: uniqueSeedValue('auth-register-openid'),
|
||||
});
|
||||
|
||||
expectSuccessEnvelope(response, 201);
|
||||
expect(response.body.data.role).toBe(Role.DOCTOR);
|
||||
});
|
||||
|
||||
it('失败:参数不合法返回 400', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/auth/register')
|
||||
.send({
|
||||
name: 'bad-register',
|
||||
phone: '13800009999',
|
||||
password: '123',
|
||||
role: Role.DOCTOR,
|
||||
hospitalId: ctx.fixtures.hospitalAId,
|
||||
departmentId: ctx.fixtures.departmentA1Id,
|
||||
groupId: ctx.fixtures.groupA1Id,
|
||||
});
|
||||
|
||||
expectErrorEnvelope(response, 400, 'password 长度至少 8 位');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /auth/login', () => {
|
||||
it('成功:seed 账号登录并拿到 token', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
phone: '13800001004',
|
||||
password: 'Seed@1234',
|
||||
role: Role.DOCTOR,
|
||||
hospitalId: ctx.fixtures.hospitalAId,
|
||||
});
|
||||
|
||||
expectSuccessEnvelope(response, 201);
|
||||
expect(response.body.data.accessToken).toEqual(expect.any(String));
|
||||
expect(response.body.data.actor.role).toBe(Role.DOCTOR);
|
||||
});
|
||||
|
||||
it('失败:密码错误返回 401', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/auth/login')
|
||||
.send({
|
||||
phone: '13800001004',
|
||||
password: 'Seed@12345',
|
||||
role: Role.DOCTOR,
|
||||
hospitalId: ctx.fixtures.hospitalAId,
|
||||
});
|
||||
|
||||
expectErrorEnvelope(response, 401, '手机号、角色或密码错误');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /auth/me', () => {
|
||||
it('成功:已登录用户可读取当前信息', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.get('/auth/me')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
|
||||
|
||||
expectSuccessEnvelope(response, 200);
|
||||
expect(response.body.data.role).toBe(Role.DOCTOR);
|
||||
});
|
||||
|
||||
it('失败:未登录返回 401', async () => {
|
||||
const response = await request(ctx.app.getHttpServer()).get('/auth/me');
|
||||
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
|
||||
});
|
||||
|
||||
it('角色矩阵:6 角色都可访问,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'GET /auth/me role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 200,
|
||||
[Role.HOSPITAL_ADMIN]: 200,
|
||||
[Role.DIRECTOR]: 200,
|
||||
[Role.LEADER]: 200,
|
||||
[Role.DOCTOR]: 200,
|
||||
[Role.ENGINEER]: 200,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.get('/auth/me')
|
||||
.set('Authorization', `Bearer ${token}`),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer()).get('/auth/me'),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,729 +0,0 @@
|
||||
import request from 'supertest';
|
||||
import { Role } from '../../../src/generated/prisma/enums.js';
|
||||
import {
|
||||
closeE2EContext,
|
||||
createE2EContext,
|
||||
type E2EContext,
|
||||
} from '../helpers/e2e-context.helper.js';
|
||||
import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js';
|
||||
import {
|
||||
expectErrorEnvelope,
|
||||
expectSuccessEnvelope,
|
||||
uniqueSeedValue,
|
||||
} from '../helpers/e2e-http.helper.js';
|
||||
|
||||
describe('Organization Controllers (e2e)', () => {
|
||||
let ctx: E2EContext;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createE2EContext();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeE2EContext(ctx);
|
||||
});
|
||||
|
||||
describe('HospitalsController', () => {
|
||||
describe('POST /b/organization/hospitals', () => {
|
||||
it('成功:SYSTEM_ADMIN 可创建医院', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/b/organization/hospitals')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||||
.send({ name: uniqueSeedValue('组织-医院') });
|
||||
|
||||
expectSuccessEnvelope(response, 201);
|
||||
expect(response.body.data.name).toContain('组织-医院');
|
||||
});
|
||||
|
||||
it('失败:非系统管理员创建返回 403', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/b/organization/hospitals')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||
.send({ name: uniqueSeedValue('组织-医院-失败') });
|
||||
|
||||
expectErrorEnvelope(response, 403, '无权限执行当前操作');
|
||||
});
|
||||
|
||||
it('角色矩阵:仅 SYSTEM_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'POST /b/organization/hospitals role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 400,
|
||||
[Role.HOSPITAL_ADMIN]: 403,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.post('/b/organization/hospitals')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({}),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.post('/b/organization/hospitals')
|
||||
.send({}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /b/organization/hospitals', () => {
|
||||
it('成功:SYSTEM_ADMIN 可查询医院列表', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.get('/b/organization/hospitals')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
|
||||
|
||||
expectSuccessEnvelope(response, 200);
|
||||
expect(response.body.data).toHaveProperty('list');
|
||||
});
|
||||
|
||||
it('失败:未登录返回 401', async () => {
|
||||
const response = await request(ctx.app.getHttpServer()).get(
|
||||
'/b/organization/hospitals',
|
||||
);
|
||||
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'GET /b/organization/hospitals role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 200,
|
||||
[Role.HOSPITAL_ADMIN]: 200,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.get('/b/organization/hospitals')
|
||||
.set('Authorization', `Bearer ${token}`),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer()).get('/b/organization/hospitals'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /b/organization/hospitals/:id', () => {
|
||||
it('成功:HOSPITAL_ADMIN 可查询本院详情', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.get(`/b/organization/hospitals/${ctx.fixtures.hospitalAId}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
|
||||
|
||||
expectSuccessEnvelope(response, 200);
|
||||
expect(response.body.data.id).toBe(ctx.fixtures.hospitalAId);
|
||||
});
|
||||
|
||||
it('失败:HOSPITAL_ADMIN 查询他院返回 403', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.get(`/b/organization/hospitals/${ctx.fixtures.hospitalBId}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
|
||||
|
||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'GET /b/organization/hospitals/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 200,
|
||||
[Role.HOSPITAL_ADMIN]: 200,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.get(`/b/organization/hospitals/${ctx.fixtures.hospitalAId}`)
|
||||
.set('Authorization', `Bearer ${token}`),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer()).get(
|
||||
`/b/organization/hospitals/${ctx.fixtures.hospitalAId}`,
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /b/organization/hospitals/:id', () => {
|
||||
it('成功:HOSPITAL_ADMIN 可更新本院名称', async () => {
|
||||
const originalName = 'Seed Hospital A';
|
||||
const nextName = uniqueSeedValue('医院更新');
|
||||
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.patch(`/b/organization/hospitals/${ctx.fixtures.hospitalAId}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||
.send({ name: nextName });
|
||||
|
||||
expectSuccessEnvelope(response, 200);
|
||||
|
||||
const rollbackResponse = await request(ctx.app.getHttpServer())
|
||||
.patch(`/b/organization/hospitals/${ctx.fixtures.hospitalAId}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||
.send({ name: originalName });
|
||||
expectSuccessEnvelope(rollbackResponse, 200);
|
||||
});
|
||||
|
||||
it('失败:HOSPITAL_ADMIN 更新他院返回 403', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.patch(`/b/organization/hospitals/${ctx.fixtures.hospitalBId}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||
.send({ name: uniqueSeedValue('跨院更新失败') });
|
||||
|
||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'PATCH /b/organization/hospitals/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 404,
|
||||
[Role.HOSPITAL_ADMIN]: 404,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.patch('/b/organization/hospitals/99999999')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ name: 'matrix-hospital-patch' }),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.patch('/b/organization/hospitals/99999999')
|
||||
.send({ name: 'matrix-hospital-patch' }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /b/organization/hospitals/:id', () => {
|
||||
it('成功:SYSTEM_ADMIN 可删除空医院', async () => {
|
||||
const createResponse = await request(ctx.app.getHttpServer())
|
||||
.post('/b/organization/hospitals')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||||
.send({ name: uniqueSeedValue('医院待删') });
|
||||
expectSuccessEnvelope(createResponse, 201);
|
||||
|
||||
const targetId = createResponse.body.data.id as number;
|
||||
const deleteResponse = await request(ctx.app.getHttpServer())
|
||||
.delete(`/b/organization/hospitals/${targetId}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
|
||||
|
||||
expectSuccessEnvelope(deleteResponse, 200);
|
||||
expect(deleteResponse.body.data.id).toBe(targetId);
|
||||
});
|
||||
|
||||
it('失败:HOSPITAL_ADMIN 删除医院返回 403', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.delete(`/b/organization/hospitals/${ctx.fixtures.hospitalAId}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
|
||||
|
||||
expectErrorEnvelope(response, 403, '无权限执行当前操作');
|
||||
});
|
||||
|
||||
it('角色矩阵:仅 SYSTEM_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'DELETE /b/organization/hospitals/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 404,
|
||||
[Role.HOSPITAL_ADMIN]: 403,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.delete('/b/organization/hospitals/99999999')
|
||||
.set('Authorization', `Bearer ${token}`),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer()).delete(
|
||||
'/b/organization/hospitals/99999999',
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DepartmentsController', () => {
|
||||
describe('POST /b/organization/departments', () => {
|
||||
it('成功:HOSPITAL_ADMIN 可在本院创建科室', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/b/organization/departments')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||
.send({
|
||||
name: uniqueSeedValue('组织-科室'),
|
||||
hospitalId: ctx.fixtures.hospitalAId,
|
||||
});
|
||||
|
||||
expectSuccessEnvelope(response, 201);
|
||||
});
|
||||
|
||||
it('失败:HOSPITAL_ADMIN 跨院创建返回 403', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/b/organization/departments')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||
.send({
|
||||
name: uniqueSeedValue('组织-跨院科室失败'),
|
||||
hospitalId: ctx.fixtures.hospitalBId,
|
||||
});
|
||||
|
||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'POST /b/organization/departments role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 400,
|
||||
[Role.HOSPITAL_ADMIN]: 400,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.post('/b/organization/departments')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({}),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.post('/b/organization/departments')
|
||||
.send({}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /b/organization/departments', () => {
|
||||
it('成功:HOSPITAL_ADMIN 可查询本院科室列表', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.get('/b/organization/departments')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
|
||||
|
||||
expectSuccessEnvelope(response, 200);
|
||||
expect(response.body.data).toHaveProperty('list');
|
||||
});
|
||||
|
||||
it('失败:未登录返回 401', async () => {
|
||||
const response = await request(ctx.app.getHttpServer()).get(
|
||||
'/b/organization/departments',
|
||||
);
|
||||
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'GET /b/organization/departments role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 200,
|
||||
[Role.HOSPITAL_ADMIN]: 200,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.get('/b/organization/departments')
|
||||
.set('Authorization', `Bearer ${token}`),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer()).get('/b/organization/departments'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /b/organization/departments/:id', () => {
|
||||
it('成功:SYSTEM_ADMIN 可查询科室详情', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.get(`/b/organization/departments/${ctx.fixtures.departmentA1Id}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
|
||||
|
||||
expectSuccessEnvelope(response, 200);
|
||||
expect(response.body.data.id).toBe(ctx.fixtures.departmentA1Id);
|
||||
});
|
||||
|
||||
it('失败:HOSPITAL_ADMIN 查询他院科室返回 403', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.get(`/b/organization/departments/${ctx.fixtures.departmentB1Id}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
|
||||
|
||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'GET /b/organization/departments/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 200,
|
||||
[Role.HOSPITAL_ADMIN]: 200,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.get(`/b/organization/departments/${ctx.fixtures.departmentA1Id}`)
|
||||
.set('Authorization', `Bearer ${token}`),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer()).get(
|
||||
`/b/organization/departments/${ctx.fixtures.departmentA1Id}`,
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /b/organization/departments/:id', () => {
|
||||
it('成功:HOSPITAL_ADMIN 可更新本院科室', async () => {
|
||||
const originalName = 'Cardiology-A2';
|
||||
const nextName = uniqueSeedValue('科室更新');
|
||||
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.patch(`/b/organization/departments/${ctx.fixtures.departmentA2Id}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||
.send({ name: nextName });
|
||||
|
||||
expectSuccessEnvelope(response, 200);
|
||||
|
||||
const rollbackResponse = await request(ctx.app.getHttpServer())
|
||||
.patch(`/b/organization/departments/${ctx.fixtures.departmentA2Id}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||
.send({ name: originalName });
|
||||
expectSuccessEnvelope(rollbackResponse, 200);
|
||||
});
|
||||
|
||||
it('失败:HOSPITAL_ADMIN 更新他院科室返回 403', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.patch(`/b/organization/departments/${ctx.fixtures.departmentB1Id}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||
.send({ name: uniqueSeedValue('跨院科室更新失败') });
|
||||
|
||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'PATCH /b/organization/departments/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 404,
|
||||
[Role.HOSPITAL_ADMIN]: 404,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.patch('/b/organization/departments/99999999')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ name: 'matrix-department-patch' }),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.patch('/b/organization/departments/99999999')
|
||||
.send({ name: 'matrix-department-patch' }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /b/organization/departments/:id', () => {
|
||||
it('成功:SYSTEM_ADMIN 可删除无关联科室', async () => {
|
||||
const createResponse = await request(ctx.app.getHttpServer())
|
||||
.post('/b/organization/departments')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||||
.send({
|
||||
name: uniqueSeedValue('科室待删'),
|
||||
hospitalId: ctx.fixtures.hospitalAId,
|
||||
});
|
||||
expectSuccessEnvelope(createResponse, 201);
|
||||
|
||||
const targetId = createResponse.body.data.id as number;
|
||||
const deleteResponse = await request(ctx.app.getHttpServer())
|
||||
.delete(`/b/organization/departments/${targetId}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
|
||||
|
||||
expectSuccessEnvelope(deleteResponse, 200);
|
||||
});
|
||||
|
||||
it('失败:存在关联数据删除返回 409', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.delete(`/b/organization/departments/${ctx.fixtures.departmentA1Id}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
|
||||
|
||||
expectErrorEnvelope(response, 409, '存在关联数据,无法删除');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'DELETE /b/organization/departments/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 404,
|
||||
[Role.HOSPITAL_ADMIN]: 404,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.delete('/b/organization/departments/99999999')
|
||||
.set('Authorization', `Bearer ${token}`),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer()).delete(
|
||||
'/b/organization/departments/99999999',
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GroupsController', () => {
|
||||
describe('POST /b/organization/groups', () => {
|
||||
it('成功:HOSPITAL_ADMIN 可创建小组', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/b/organization/groups')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||
.send({
|
||||
name: uniqueSeedValue('组织-小组'),
|
||||
departmentId: ctx.fixtures.departmentA1Id,
|
||||
});
|
||||
|
||||
expectSuccessEnvelope(response, 201);
|
||||
});
|
||||
|
||||
it('失败:HOSPITAL_ADMIN 跨院创建小组返回 403', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/b/organization/groups')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||
.send({
|
||||
name: uniqueSeedValue('组织-跨院小组失败'),
|
||||
departmentId: ctx.fixtures.departmentB1Id,
|
||||
});
|
||||
|
||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'POST /b/organization/groups role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 400,
|
||||
[Role.HOSPITAL_ADMIN]: 400,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.post('/b/organization/groups')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({}),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.post('/b/organization/groups')
|
||||
.send({}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /b/organization/groups', () => {
|
||||
it('成功:SYSTEM_ADMIN 可查询小组列表', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.get('/b/organization/groups')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
|
||||
|
||||
expectSuccessEnvelope(response, 200);
|
||||
expect(response.body.data).toHaveProperty('list');
|
||||
});
|
||||
|
||||
it('失败:未登录返回 401', async () => {
|
||||
const response = await request(ctx.app.getHttpServer()).get(
|
||||
'/b/organization/groups',
|
||||
);
|
||||
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'GET /b/organization/groups role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 200,
|
||||
[Role.HOSPITAL_ADMIN]: 200,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.get('/b/organization/groups')
|
||||
.set('Authorization', `Bearer ${token}`),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer()).get('/b/organization/groups'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /b/organization/groups/:id', () => {
|
||||
it('成功:HOSPITAL_ADMIN 可查询本院小组详情', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.get(`/b/organization/groups/${ctx.fixtures.groupA1Id}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
|
||||
|
||||
expectSuccessEnvelope(response, 200);
|
||||
expect(response.body.data.id).toBe(ctx.fixtures.groupA1Id);
|
||||
});
|
||||
|
||||
it('失败:HOSPITAL_ADMIN 查询他院小组返回 403', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.get(`/b/organization/groups/${ctx.fixtures.groupB1Id}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
|
||||
|
||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'GET /b/organization/groups/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 200,
|
||||
[Role.HOSPITAL_ADMIN]: 200,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.get(`/b/organization/groups/${ctx.fixtures.groupA1Id}`)
|
||||
.set('Authorization', `Bearer ${token}`),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer()).get(
|
||||
`/b/organization/groups/${ctx.fixtures.groupA1Id}`,
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /b/organization/groups/:id', () => {
|
||||
it('成功:HOSPITAL_ADMIN 可更新本院小组', async () => {
|
||||
const originalName = 'Shift-A2';
|
||||
const nextName = uniqueSeedValue('小组更新');
|
||||
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.patch(`/b/organization/groups/${ctx.fixtures.groupA2Id}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||
.send({ name: nextName });
|
||||
|
||||
expectSuccessEnvelope(response, 200);
|
||||
|
||||
const rollbackResponse = await request(ctx.app.getHttpServer())
|
||||
.patch(`/b/organization/groups/${ctx.fixtures.groupA2Id}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||
.send({ name: originalName });
|
||||
expectSuccessEnvelope(rollbackResponse, 200);
|
||||
});
|
||||
|
||||
it('失败:HOSPITAL_ADMIN 更新他院小组返回 403', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.patch(`/b/organization/groups/${ctx.fixtures.groupB1Id}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
|
||||
.send({ name: uniqueSeedValue('跨院小组更新失败') });
|
||||
|
||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'PATCH /b/organization/groups/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 404,
|
||||
[Role.HOSPITAL_ADMIN]: 404,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.patch('/b/organization/groups/99999999')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ name: 'matrix-group-patch' }),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.patch('/b/organization/groups/99999999')
|
||||
.send({ name: 'matrix-group-patch' }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /b/organization/groups/:id', () => {
|
||||
it('成功:SYSTEM_ADMIN 可删除无关联小组', async () => {
|
||||
const createResponse = await request(ctx.app.getHttpServer())
|
||||
.post('/b/organization/groups')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||||
.send({
|
||||
name: uniqueSeedValue('小组待删'),
|
||||
departmentId: ctx.fixtures.departmentA1Id,
|
||||
});
|
||||
expectSuccessEnvelope(createResponse, 201);
|
||||
|
||||
const targetId = createResponse.body.data.id as number;
|
||||
const deleteResponse = await request(ctx.app.getHttpServer())
|
||||
.delete(`/b/organization/groups/${targetId}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
|
||||
|
||||
expectSuccessEnvelope(deleteResponse, 200);
|
||||
});
|
||||
|
||||
it('失败:HOSPITAL_ADMIN 删除他院小组返回 403', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.delete(`/b/organization/groups/${ctx.fixtures.groupB1Id}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
|
||||
|
||||
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'DELETE /b/organization/groups/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 404,
|
||||
[Role.HOSPITAL_ADMIN]: 404,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.delete('/b/organization/groups/99999999')
|
||||
.set('Authorization', `Bearer ${token}`),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer()).delete(
|
||||
'/b/organization/groups/99999999',
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,185 +0,0 @@
|
||||
import request from 'supertest';
|
||||
import { Role } from '../../../src/generated/prisma/enums.js';
|
||||
import {
|
||||
closeE2EContext,
|
||||
createE2EContext,
|
||||
type E2EContext,
|
||||
} from '../helpers/e2e-context.helper.js';
|
||||
import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js';
|
||||
import {
|
||||
expectErrorEnvelope,
|
||||
expectSuccessEnvelope,
|
||||
} from '../helpers/e2e-http.helper.js';
|
||||
|
||||
describe('Patients Controllers (e2e)', () => {
|
||||
let ctx: E2EContext;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createE2EContext();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeE2EContext(ctx);
|
||||
});
|
||||
|
||||
describe('GET /b/patients', () => {
|
||||
it('成功:按角色返回正确可见性范围', async () => {
|
||||
const systemAdminResponse = await request(ctx.app.getHttpServer())
|
||||
.get('/b/patients')
|
||||
.query({ hospitalId: ctx.fixtures.hospitalAId })
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
|
||||
expectSuccessEnvelope(systemAdminResponse, 200);
|
||||
const systemPatientIds = (
|
||||
systemAdminResponse.body.data as Array<{ id: number }>
|
||||
).map((item) => item.id);
|
||||
expect(systemPatientIds).toEqual(
|
||||
expect.arrayContaining([
|
||||
ctx.fixtures.patients.patientA1Id,
|
||||
ctx.fixtures.patients.patientA2Id,
|
||||
ctx.fixtures.patients.patientA3Id,
|
||||
]),
|
||||
);
|
||||
|
||||
const hospitalAdminResponse = await request(ctx.app.getHttpServer())
|
||||
.get('/b/patients')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
|
||||
expectSuccessEnvelope(hospitalAdminResponse, 200);
|
||||
const hospitalPatientIds = (
|
||||
hospitalAdminResponse.body.data as Array<{ id: number }>
|
||||
).map((item) => item.id);
|
||||
expect(hospitalPatientIds).toEqual(
|
||||
expect.arrayContaining([
|
||||
ctx.fixtures.patients.patientA1Id,
|
||||
ctx.fixtures.patients.patientA2Id,
|
||||
ctx.fixtures.patients.patientA3Id,
|
||||
]),
|
||||
);
|
||||
|
||||
const directorResponse = await request(ctx.app.getHttpServer())
|
||||
.get('/b/patients')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
|
||||
expectSuccessEnvelope(directorResponse, 200);
|
||||
const directorPatientIds = (
|
||||
directorResponse.body.data as Array<{ id: number }>
|
||||
).map((item) => item.id);
|
||||
expect(directorPatientIds).toEqual(
|
||||
expect.arrayContaining([
|
||||
ctx.fixtures.patients.patientA1Id,
|
||||
ctx.fixtures.patients.patientA2Id,
|
||||
]),
|
||||
);
|
||||
expect(directorPatientIds).not.toContain(
|
||||
ctx.fixtures.patients.patientA3Id,
|
||||
);
|
||||
|
||||
const leaderResponse = await request(ctx.app.getHttpServer())
|
||||
.get('/b/patients')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.LEADER]}`);
|
||||
expectSuccessEnvelope(leaderResponse, 200);
|
||||
const leaderPatientIds = (
|
||||
leaderResponse.body.data as Array<{ id: number }>
|
||||
).map((item) => item.id);
|
||||
expect(leaderPatientIds).toEqual(
|
||||
expect.arrayContaining([
|
||||
ctx.fixtures.patients.patientA1Id,
|
||||
ctx.fixtures.patients.patientA2Id,
|
||||
]),
|
||||
);
|
||||
expect(leaderPatientIds).not.toContain(ctx.fixtures.patients.patientA3Id);
|
||||
|
||||
const doctorResponse = await request(ctx.app.getHttpServer())
|
||||
.get('/b/patients')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
|
||||
expectSuccessEnvelope(doctorResponse, 200);
|
||||
const doctorPatientIds = (
|
||||
doctorResponse.body.data as Array<{ id: number }>
|
||||
).map((item) => item.id);
|
||||
expect(doctorPatientIds).toContain(ctx.fixtures.patients.patientA1Id);
|
||||
expect(doctorPatientIds).not.toContain(ctx.fixtures.patients.patientA2Id);
|
||||
expect(doctorPatientIds).not.toContain(ctx.fixtures.patients.patientA3Id);
|
||||
|
||||
const engineerResponse = await request(ctx.app.getHttpServer())
|
||||
.get('/b/patients')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`);
|
||||
expectErrorEnvelope(engineerResponse, 403, '无权限执行当前操作');
|
||||
});
|
||||
|
||||
it('失败:SYSTEM_ADMIN 不传 hospitalId 返回 400', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.get('/b/patients')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
|
||||
|
||||
expectErrorEnvelope(
|
||||
response,
|
||||
400,
|
||||
'系统管理员查询必须显式传入 hospitalId',
|
||||
);
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER/DOCTOR 可访问,ENGINEER 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'GET /b/patients role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 200,
|
||||
[Role.HOSPITAL_ADMIN]: 200,
|
||||
[Role.DIRECTOR]: 200,
|
||||
[Role.LEADER]: 200,
|
||||
[Role.DOCTOR]: 200,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (role, token) => {
|
||||
const req = request(ctx.app.getHttpServer())
|
||||
.get('/b/patients')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
if (role === Role.SYSTEM_ADMIN) {
|
||||
req.query({ hospitalId: ctx.fixtures.hospitalAId });
|
||||
}
|
||||
|
||||
return req;
|
||||
},
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer()).get('/b/patients'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /c/patients/lifecycle', () => {
|
||||
it('成功:可按 phone + idCardHash 查询跨院生命周期', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.get('/c/patients/lifecycle')
|
||||
.query({
|
||||
phone: '13800002001',
|
||||
idCardHash: 'seed-id-card-cross-hospital',
|
||||
});
|
||||
|
||||
expectSuccessEnvelope(response, 200);
|
||||
expect(response.body.data.phone).toBe('13800002001');
|
||||
expect(response.body.data.idCardHash).toBe('seed-id-card-cross-hospital');
|
||||
expect(response.body.data.patientCount).toBeGreaterThanOrEqual(2);
|
||||
expect(Array.isArray(response.body.data.lifecycle)).toBe(true);
|
||||
});
|
||||
|
||||
it('失败:参数缺失返回 400', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.get('/c/patients/lifecycle')
|
||||
.query({
|
||||
phone: '13800002001',
|
||||
});
|
||||
|
||||
expectErrorEnvelope(response, 400, 'idCardHash 必须是字符串');
|
||||
});
|
||||
|
||||
it('失败:不存在患者返回 404', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.get('/c/patients/lifecycle')
|
||||
.query({
|
||||
phone: '13800009999',
|
||||
idCardHash: 'not-exists-idcard-hash',
|
||||
});
|
||||
|
||||
expectErrorEnvelope(response, 404, '未找到匹配的患者档案');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,325 +0,0 @@
|
||||
import request from 'supertest';
|
||||
import { Role, TaskStatus } from '../../../src/generated/prisma/enums.js';
|
||||
import {
|
||||
closeE2EContext,
|
||||
createE2EContext,
|
||||
type E2EContext,
|
||||
} from '../helpers/e2e-context.helper.js';
|
||||
import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js';
|
||||
import {
|
||||
expectErrorEnvelope,
|
||||
expectSuccessEnvelope,
|
||||
} from '../helpers/e2e-http.helper.js';
|
||||
|
||||
describe('BTasksController (e2e)', () => {
|
||||
let ctx: E2EContext;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createE2EContext();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeE2EContext(ctx);
|
||||
});
|
||||
|
||||
async function publishPendingTask(deviceId: number, targetPressure: number) {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/publish')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||
.send({
|
||||
items: [
|
||||
{
|
||||
deviceId,
|
||||
targetPressure,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expectSuccessEnvelope(response, 201);
|
||||
return response.body.data as { id: number; status: TaskStatus };
|
||||
}
|
||||
|
||||
describe('POST /b/tasks/publish', () => {
|
||||
it('成功:DOCTOR 可发布任务', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/publish')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||
.send({
|
||||
engineerId: ctx.fixtures.users.engineerAId,
|
||||
items: [
|
||||
{
|
||||
deviceId: ctx.fixtures.devices.deviceA2Id,
|
||||
targetPressure: 126,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expectSuccessEnvelope(response, 201);
|
||||
expect(response.body.data.status).toBe(TaskStatus.PENDING);
|
||||
});
|
||||
|
||||
it('失败:发布跨院设备任务返回 404', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/publish')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||
.send({
|
||||
items: [
|
||||
{
|
||||
deviceId: ctx.fixtures.devices.deviceB1Id,
|
||||
targetPressure: 120,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expectErrorEnvelope(response, 404, '存在设备不在当前医院或设备不存在');
|
||||
});
|
||||
|
||||
it('角色矩阵:仅 DOCTOR 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'POST /b/tasks/publish role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 403,
|
||||
[Role.HOSPITAL_ADMIN]: 403,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 400,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/publish')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({}),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer()).post('/b/tasks/publish').send({}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /b/tasks/accept', () => {
|
||||
it('成功:ENGINEER 可接收待处理任务', async () => {
|
||||
const task = await publishPendingTask(
|
||||
ctx.fixtures.devices.deviceA2Id,
|
||||
127,
|
||||
);
|
||||
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/accept')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||
.send({ taskId: task.id });
|
||||
|
||||
expectSuccessEnvelope(response, 201);
|
||||
expect(response.body.data.status).toBe(TaskStatus.ACCEPTED);
|
||||
expect(response.body.data.engineerId).toBe(
|
||||
ctx.fixtures.users.engineerAId,
|
||||
);
|
||||
});
|
||||
|
||||
it('失败:接收不存在任务返回 404', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/accept')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||
.send({ taskId: 99999999 });
|
||||
|
||||
expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院');
|
||||
});
|
||||
|
||||
it('状态机失败:重复接收返回 409', async () => {
|
||||
const task = await publishPendingTask(
|
||||
ctx.fixtures.devices.deviceA3Id,
|
||||
122,
|
||||
);
|
||||
|
||||
const firstAccept = await request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/accept')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||
.send({ taskId: task.id });
|
||||
expectSuccessEnvelope(firstAccept, 201);
|
||||
|
||||
const secondAccept = await request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/accept')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||
.send({ taskId: task.id });
|
||||
|
||||
expectErrorEnvelope(secondAccept, 409, '仅待接收任务可执行接收');
|
||||
});
|
||||
|
||||
it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'POST /b/tasks/accept role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 403,
|
||||
[Role.HOSPITAL_ADMIN]: 403,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 404,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/accept')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ taskId: 99999999 }),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/accept')
|
||||
.send({ taskId: 99999999 }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /b/tasks/complete', () => {
|
||||
it('成功:ENGINEER 完成已接收任务并同步设备压力', async () => {
|
||||
const targetPressure = 135;
|
||||
const task = await publishPendingTask(
|
||||
ctx.fixtures.devices.deviceA1Id,
|
||||
targetPressure,
|
||||
);
|
||||
|
||||
const acceptResponse = await request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/accept')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||
.send({ taskId: task.id });
|
||||
expectSuccessEnvelope(acceptResponse, 201);
|
||||
|
||||
const completeResponse = await request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/complete')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||
.send({ taskId: task.id });
|
||||
|
||||
expectSuccessEnvelope(completeResponse, 201);
|
||||
expect(completeResponse.body.data.status).toBe(TaskStatus.COMPLETED);
|
||||
|
||||
const device = await ctx.prisma.device.findUnique({
|
||||
where: { id: ctx.fixtures.devices.deviceA1Id },
|
||||
select: { currentPressure: true },
|
||||
});
|
||||
expect(device?.currentPressure).toBe(targetPressure);
|
||||
});
|
||||
|
||||
it('失败:完成不存在任务返回 404', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/complete')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||
.send({ taskId: 99999999 });
|
||||
|
||||
expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院');
|
||||
});
|
||||
|
||||
it('状态机失败:未接收任务直接完成返回 409', async () => {
|
||||
const task = await publishPendingTask(
|
||||
ctx.fixtures.devices.deviceA2Id,
|
||||
124,
|
||||
);
|
||||
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/complete')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||
.send({ taskId: task.id });
|
||||
|
||||
expectErrorEnvelope(response, 409, '仅已接收任务可执行完成');
|
||||
});
|
||||
|
||||
it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'POST /b/tasks/complete role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 403,
|
||||
[Role.HOSPITAL_ADMIN]: 403,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 404,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/complete')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ taskId: 99999999 }),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/complete')
|
||||
.send({ taskId: 99999999 }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /b/tasks/cancel', () => {
|
||||
it('成功:DOCTOR 可取消自己创建的任务', async () => {
|
||||
const task = await publishPendingTask(
|
||||
ctx.fixtures.devices.deviceA3Id,
|
||||
120,
|
||||
);
|
||||
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/cancel')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||
.send({ taskId: task.id });
|
||||
|
||||
expectSuccessEnvelope(response, 201);
|
||||
expect(response.body.data.status).toBe(TaskStatus.CANCELLED);
|
||||
});
|
||||
|
||||
it('失败:取消不存在任务返回 404', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/cancel')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||
.send({ taskId: 99999999 });
|
||||
|
||||
expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院');
|
||||
});
|
||||
|
||||
it('状态机失败:已完成任务不可取消返回 409', async () => {
|
||||
const task = await publishPendingTask(
|
||||
ctx.fixtures.devices.deviceA2Id,
|
||||
123,
|
||||
);
|
||||
|
||||
const acceptResponse = await request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/accept')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||
.send({ taskId: task.id });
|
||||
expectSuccessEnvelope(acceptResponse, 201);
|
||||
|
||||
const completeResponse = await request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/complete')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
|
||||
.send({ taskId: task.id });
|
||||
expectSuccessEnvelope(completeResponse, 201);
|
||||
|
||||
const cancelResponse = await request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/cancel')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
|
||||
.send({ taskId: task.id });
|
||||
|
||||
expectErrorEnvelope(cancelResponse, 409, '仅待接收/已接收任务可取消');
|
||||
});
|
||||
|
||||
it('角色矩阵:仅 DOCTOR 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'POST /b/tasks/cancel role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 403,
|
||||
[Role.HOSPITAL_ADMIN]: 403,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 404,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/cancel')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ taskId: 99999999 }),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.post('/b/tasks/cancel')
|
||||
.send({ taskId: 99999999 }),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,340 +0,0 @@
|
||||
import request from 'supertest';
|
||||
import { Role } from '../../../src/generated/prisma/enums.js';
|
||||
import {
|
||||
closeE2EContext,
|
||||
createE2EContext,
|
||||
type E2EContext,
|
||||
} from '../helpers/e2e-context.helper.js';
|
||||
import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js';
|
||||
import {
|
||||
expectErrorEnvelope,
|
||||
expectSuccessEnvelope,
|
||||
uniquePhone,
|
||||
uniqueSeedValue,
|
||||
} from '../helpers/e2e-http.helper.js';
|
||||
|
||||
describe('UsersController + BUsersController (e2e)', () => {
|
||||
let ctx: E2EContext;
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createE2EContext();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await closeE2EContext(ctx);
|
||||
});
|
||||
|
||||
async function createDoctorUser(token: string) {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/users')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({
|
||||
name: uniqueSeedValue('用户-医生'),
|
||||
phone: uniquePhone(),
|
||||
password: 'Seed@1234',
|
||||
role: Role.DOCTOR,
|
||||
hospitalId: ctx.fixtures.hospitalAId,
|
||||
departmentId: ctx.fixtures.departmentA1Id,
|
||||
groupId: ctx.fixtures.groupA1Id,
|
||||
openId: uniqueSeedValue('users-doctor-openid'),
|
||||
});
|
||||
|
||||
expectSuccessEnvelope(response, 201);
|
||||
return response.body.data as { id: number; name: string };
|
||||
}
|
||||
|
||||
async function createEngineerUser(token: string) {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/users')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({
|
||||
name: uniqueSeedValue('用户-工程师'),
|
||||
phone: uniquePhone(),
|
||||
password: 'Seed@1234',
|
||||
role: Role.ENGINEER,
|
||||
hospitalId: ctx.fixtures.hospitalAId,
|
||||
openId: uniqueSeedValue('users-engineer-openid'),
|
||||
});
|
||||
|
||||
expectSuccessEnvelope(response, 201);
|
||||
return response.body.data as { id: number; name: string };
|
||||
}
|
||||
|
||||
describe('POST /users', () => {
|
||||
it('成功:SYSTEM_ADMIN 可创建用户', async () => {
|
||||
await createDoctorUser(ctx.tokens[Role.SYSTEM_ADMIN]);
|
||||
});
|
||||
|
||||
it('失败:参数校验失败返回 400', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/users')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||||
.send({
|
||||
name: 'bad-user',
|
||||
phone: '123',
|
||||
password: 'short',
|
||||
role: Role.DOCTOR,
|
||||
hospitalId: ctx.fixtures.hospitalAId,
|
||||
departmentId: ctx.fixtures.departmentA1Id,
|
||||
groupId: ctx.fixtures.groupA1Id,
|
||||
});
|
||||
|
||||
expectErrorEnvelope(response, 400, 'phone 必须是合法手机号');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'POST /users role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 400,
|
||||
[Role.HOSPITAL_ADMIN]: 400,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.post('/users')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({}),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer()).post('/users').send({}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /users', () => {
|
||||
it('成功:SYSTEM_ADMIN 可查询用户列表', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.get('/users')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
|
||||
|
||||
expectSuccessEnvelope(response, 200);
|
||||
expect(Array.isArray(response.body.data)).toBe(true);
|
||||
});
|
||||
|
||||
it('失败:未登录返回 401', async () => {
|
||||
const response = await request(ctx.app.getHttpServer()).get('/users');
|
||||
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'GET /users role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 200,
|
||||
[Role.HOSPITAL_ADMIN]: 200,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.get('/users')
|
||||
.set('Authorization', `Bearer ${token}`),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer()).get('/users'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /users/:id', () => {
|
||||
it('成功:SYSTEM_ADMIN 可查询用户详情', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.get(`/users/${ctx.fixtures.users.doctorAId}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
|
||||
|
||||
expectSuccessEnvelope(response, 200);
|
||||
expect(response.body.data.id).toBe(ctx.fixtures.users.doctorAId);
|
||||
});
|
||||
|
||||
it('失败:查询不存在用户返回 404', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.get('/users/99999999')
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
|
||||
|
||||
expectErrorEnvelope(response, 404, '用户不存在');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'GET /users/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 200,
|
||||
[Role.HOSPITAL_ADMIN]: 200,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.get(`/users/${ctx.fixtures.users.doctorAId}`)
|
||||
.set('Authorization', `Bearer ${token}`),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer()).get(
|
||||
`/users/${ctx.fixtures.users.doctorAId}`,
|
||||
),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /users/:id', () => {
|
||||
it('成功:SYSTEM_ADMIN 可更新用户姓名', async () => {
|
||||
const created = await createDoctorUser(ctx.tokens[Role.SYSTEM_ADMIN]);
|
||||
const nextName = uniqueSeedValue('更新后医生名');
|
||||
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.patch(`/users/${created.id}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||||
.send({ name: nextName });
|
||||
|
||||
expectSuccessEnvelope(response, 200);
|
||||
expect(response.body.data.name).toBe(nextName);
|
||||
});
|
||||
|
||||
it('失败:非医生调整科室/小组返回 400', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.patch(`/users/${ctx.fixtures.users.engineerAId}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||||
.send({
|
||||
departmentId: ctx.fixtures.departmentA1Id,
|
||||
groupId: ctx.fixtures.groupA1Id,
|
||||
});
|
||||
|
||||
expectErrorEnvelope(response, 400, '仅医生/主任/组长允许调整科室/小组归属');
|
||||
});
|
||||
|
||||
it('角色矩阵:SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'PATCH /users/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 404,
|
||||
[Role.HOSPITAL_ADMIN]: 404,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.patch('/users/99999999')
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({ name: 'matrix-patch' }),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.patch('/users/99999999')
|
||||
.send({ name: 'matrix-patch' }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /users/:id', () => {
|
||||
it('成功:SYSTEM_ADMIN 可删除用户', async () => {
|
||||
const created = await createEngineerUser(ctx.tokens[Role.SYSTEM_ADMIN]);
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.delete(`/users/${created.id}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
|
||||
|
||||
expectSuccessEnvelope(response, 200);
|
||||
expect(response.body.data.id).toBe(created.id);
|
||||
});
|
||||
|
||||
it('失败:存在关联患者/任务时返回 409', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.delete(`/users/${ctx.fixtures.users.doctorAId}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
|
||||
|
||||
expectErrorEnvelope(response, 409, '用户存在关联患者或任务,无法删除');
|
||||
});
|
||||
|
||||
it('失败:HOSPITAL_ADMIN 无法删除返回 403', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.delete(`/users/${ctx.fixtures.users.doctorAId}`)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
|
||||
|
||||
expectErrorEnvelope(response, 403, '无权限执行当前操作');
|
||||
});
|
||||
|
||||
it('角色矩阵:仅 SYSTEM_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'DELETE /users/:id role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 404,
|
||||
[Role.HOSPITAL_ADMIN]: 403,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.delete('/users/99999999')
|
||||
.set('Authorization', `Bearer ${token}`),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer()).delete('/users/99999999'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PATCH /b/users/:id/assign-engineer-hospital', () => {
|
||||
it('成功:SYSTEM_ADMIN 可绑定工程师医院', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.patch(
|
||||
`/b/users/${ctx.fixtures.users.engineerAId}/assign-engineer-hospital`,
|
||||
)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||||
.send({ hospitalId: ctx.fixtures.hospitalAId });
|
||||
|
||||
expectSuccessEnvelope(response, 200);
|
||||
expect(response.body.data.hospitalId).toBe(ctx.fixtures.hospitalAId);
|
||||
expect(response.body.data.role).toBe(Role.ENGINEER);
|
||||
});
|
||||
|
||||
it('失败:目标用户不是工程师返回 400', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.patch(
|
||||
`/b/users/${ctx.fixtures.users.doctorAId}/assign-engineer-hospital`,
|
||||
)
|
||||
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
|
||||
.send({ hospitalId: ctx.fixtures.hospitalAId });
|
||||
|
||||
expectErrorEnvelope(response, 400, '目标用户不是工程师');
|
||||
});
|
||||
|
||||
it('角色矩阵:仅 SYSTEM_ADMIN 可进入业务,其他角色 403,未登录 401', async () => {
|
||||
await assertRoleMatrix({
|
||||
name: 'PATCH /b/users/:id/assign-engineer-hospital role matrix',
|
||||
tokens: ctx.tokens,
|
||||
expectedStatusByRole: {
|
||||
[Role.SYSTEM_ADMIN]: 400,
|
||||
[Role.HOSPITAL_ADMIN]: 403,
|
||||
[Role.DIRECTOR]: 403,
|
||||
[Role.LEADER]: 403,
|
||||
[Role.DOCTOR]: 403,
|
||||
[Role.ENGINEER]: 403,
|
||||
},
|
||||
sendAsRole: async (_role, token) =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.patch(
|
||||
`/b/users/${ctx.fixtures.users.engineerAId}/assign-engineer-hospital`,
|
||||
)
|
||||
.set('Authorization', `Bearer ${token}`)
|
||||
.send({}),
|
||||
sendWithoutToken: async () =>
|
||||
request(ctx.app.getHttpServer())
|
||||
.patch(
|
||||
`/b/users/${ctx.fixtures.users.engineerAId}/assign-engineer-hospital`,
|
||||
)
|
||||
.send({}),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,20 +0,0 @@
|
||||
module.exports = {
|
||||
rootDir: '../',
|
||||
testEnvironment: 'node',
|
||||
moduleFileExtensions: ['js', 'json', 'ts'],
|
||||
testRegex: 'test/e2e/specs/.*\\.e2e-spec\\.ts$',
|
||||
transform: {
|
||||
'^.+\\.(t|j)s$': [
|
||||
'ts-jest',
|
||||
{
|
||||
useESM: true,
|
||||
tsconfig: '<rootDir>/test/tsconfig.e2e.json',
|
||||
},
|
||||
],
|
||||
},
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
moduleNameMapper: {
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
maxWorkers: 1,
|
||||
};
|
||||
@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"types": ["node", "jest"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["./e2e/**/*.ts", "../src/**/*.ts"]
|
||||
}
|
||||
24
tyt-admin/.gitignore
vendored
24
tyt-admin/.gitignore
vendored
@ -1,24 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
tyt-admin/.vscode/extensions.json
vendored
3
tyt-admin/.vscode/extensions.json
vendored
@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
10
tyt-admin/auto-imports.d.ts
vendored
10
tyt-admin/auto-imports.d.ts
vendored
@ -1,10 +0,0 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
declare global {
|
||||
|
||||
}
|
||||
54
tyt-admin/components.d.ts
vendored
54
tyt-admin/components.d.ts
vendored
@ -1,54 +0,0 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint: disable
|
||||
// oxlint-disable
|
||||
// ------
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElAside: typeof import('element-plus/es')['ElAside']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCol: typeof import('element-plus/es')['ElCol']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElContainer: typeof import('element-plus/es')['ElContainer']
|
||||
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
|
||||
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
|
||||
ElDialog: typeof import('element-plus/es')['ElDialog']
|
||||
ElDropdown: typeof import('element-plus/es')['ElDropdown']
|
||||
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
|
||||
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
|
||||
ElEmpty: typeof import('element-plus/es')['ElEmpty']
|
||||
ElForm: typeof import('element-plus/es')['ElForm']
|
||||
ElFormItem: typeof import('element-plus/es')['ElFormItem']
|
||||
ElHeader: typeof import('element-plus/es')['ElHeader']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
|
||||
ElMain: typeof import('element-plus/es')['ElMain']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElOption: typeof import('element-plus/es')['ElOption']
|
||||
ElPagination: typeof import('element-plus/es')['ElPagination']
|
||||
ElRow: typeof import('element-plus/es')['ElRow']
|
||||
ElSelect: typeof import('element-plus/es')['ElSelect']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElTable: typeof import('element-plus/es')['ElTable']
|
||||
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
ElTimeline: typeof import('element-plus/es')['ElTimeline']
|
||||
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
|
||||
ElTree: typeof import('element-plus/es')['ElTree']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
export interface GlobalDirectives {
|
||||
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
|
||||
}
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>tyt-admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "tyt-admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.2",
|
||||
"axios": "^1.13.6",
|
||||
"element-plus": "^2.13.5",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.30",
|
||||
"vue-router": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"sass": "^1.98.0",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^31.0.0",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
1758
tyt-admin/pnpm-lock.yaml
generated
1758
tyt-admin/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,23 +0,0 @@
|
||||
<template>
|
||||
<el-config-provider :locale="locale">
|
||||
<router-view />
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn';
|
||||
|
||||
const locale = ref(zhCn);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: #f0f2f5;
|
||||
}
|
||||
#app {
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
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