Compare commits
6 Commits
ff6739ab68
...
2275607bd2
| Author | SHA1 | Date | |
|---|---|---|---|
| 2275607bd2 | |||
| 394793fa28 | |||
| 2c1bbd565f | |||
| 6ec8891be5 | |||
| b55e600c9c | |||
| aa1346f6af |
3
.env.example
Normal file
3
.env.example
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
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,3 +56,6 @@ pids
|
|||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
/src/generated/prisma
|
/src/generated/prisma
|
||||||
|
|
||||||
|
/tyt-admin/dist
|
||||||
|
/tyt-admin/node_modules
|
||||||
48
AGENTS.md
Normal file
48
AGENTS.md
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
|
||||||
|
Core application code lives in `src/`. Domain modules are split by business area: `auth/`, `users/`, `tasks/`, `patients/`, and `organization/`. Keep controllers, services, and DTOs inside their module directories (for example, `src/tasks/dto/`).
|
||||||
|
|
||||||
|
Shared infrastructure is in `src/common/` (global response/exception handling, constants) plus `src/prisma.module.ts` and `src/prisma.service.ts`. Database schema and migrations are under `prisma/`, and generated Prisma artifacts are in `src/generated/prisma/`. API behavior notes are documented in `docs/*.md`.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
|
||||||
|
Use `pnpm` for all local workflows:
|
||||||
|
|
||||||
|
- `pnpm install`: install dependencies.
|
||||||
|
- `pnpm start:dev`: run NestJS in watch mode.
|
||||||
|
- `pnpm build`: compile TypeScript to `dist/`.
|
||||||
|
- `pnpm start:prod`: run compiled output from `dist/main`.
|
||||||
|
- `pnpm format`: apply Prettier to `src/**/*.ts` (and `test/**/*.ts` when present).
|
||||||
|
- `pnpm prisma generate`: regenerate Prisma client after schema changes.
|
||||||
|
- `pnpm prisma migrate dev`: create/apply local migrations.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
|
||||||
|
This repo uses TypeScript + NestJS with ES module imports (use `.js` suffix in local imports). Formatting is Prettier-driven (`singleQuote: true`, `trailingComma: all`); keep 2-space indentation and avoid manual style drift.
|
||||||
|
|
||||||
|
Use `PascalCase` for classes (`TaskService`), `camelCase` for methods/variables, and `kebab-case` for filenames (`publish-task.dto.ts`). Place DTOs under `dto/` and keep validation decorators/messages close to fields.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
|
||||||
|
There are currently no committed `test` scripts or spec files. For new features, add automated tests using `@nestjs/testing` and `supertest` (already in dev dependencies), with names like `*.spec.ts`.
|
||||||
|
|
||||||
|
Minimum expectation for new endpoints: one success path and one authorization/validation failure path. Include test run instructions in the PR when introducing test tooling.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
|
||||||
|
Recent history uses short, single-line subjects (for example: `配置数据库生成用户模块`, `测试`, `init`). Keep commits focused and descriptive, one logical change per commit.
|
||||||
|
|
||||||
|
For PRs, include:
|
||||||
|
|
||||||
|
- What changed and why.
|
||||||
|
- Related issue/task link.
|
||||||
|
- API or schema impact (`prisma/schema.prisma`, migrations, env vars).
|
||||||
|
- Verification steps (for example, `pnpm build`, key endpoint checks in `/api/docs`).
|
||||||
|
|
||||||
|
## Security & Configuration Tips
|
||||||
|
|
||||||
|
Start from `.env.example`; never commit real secrets. Rotate `AUTH_TOKEN_SECRET` and bootstrap keys per environment, and treat `DATABASE_URL` as sensitive.
|
||||||
|
|
||||||
|
使用nest cli,不要直接改配置文件,最后发给我安装命令,让我执行,中文注释和文档
|
||||||
105
README.md
Normal file
105
README.md
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
# 多租户医疗调压系统后端(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
Normal file
32
docs/auth.md
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# 认证模块说明(`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 }`。
|
||||||
60
docs/e2e-testing.md
Normal file
60
docs/e2e-testing.md
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# 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 端角色可见性
|
||||||
|
- 组织域院管作用域限制与删除冲突
|
||||||
37
docs/frontend-api-integration.md
Normal file
37
docs/frontend-api-integration.md
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
# 前端接口接入说明(`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
|
||||||
|
```
|
||||||
45
docs/patients.md
Normal file
45
docs/patients.md
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# 患者模块说明(`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 }`
|
||||||
40
docs/tasks.md
Normal file
40
docs/tasks.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# 调压任务模块说明(`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`
|
||||||
|
|
||||||
|
确保任务状态与设备压力一致性。
|
||||||
38
docs/users.md
Normal file
38
docs/users.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# 用户与权限模块说明(`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,31 +12,47 @@
|
|||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"start:dev": "nest start --watch",
|
"start:dev": "nest start --watch",
|
||||||
"start:debug": "nest start --debug --watch",
|
"start:debug": "nest start --debug --watch",
|
||||||
"start:prod": "node dist/main"
|
"start:prod": "node dist/main",
|
||||||
|
"test:e2e:prepare": "pnpm prisma migrate reset --force && pnpm prisma generate && 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"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/event-emitter": "^3.0.1",
|
||||||
"@nestjs/mapped-types": "*",
|
"@nestjs/mapped-types": "*",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@nestjs/swagger": "^11.2.6",
|
||||||
"@prisma/adapter-pg": "^7.5.0",
|
"@prisma/adapter-pg": "^7.5.0",
|
||||||
"@prisma/client": "^7.5.0",
|
"@prisma/client": "^7.5.0",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.15.1",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"jsonwebtoken": "^9.0.3",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1",
|
||||||
|
"swagger-ui-express": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.0",
|
||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.0.1",
|
"@nestjs/testing": "^11.0.1",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jest": "^30.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"globals": "^16.0.0",
|
"globals": "^16.0.0",
|
||||||
|
"jest": "^30.3.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prisma": "^7.4.2",
|
"prisma": "^7.4.2",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
|
"ts-jest": "^29.4.6",
|
||||||
"ts-loader": "^9.5.2",
|
"ts-loader": "^9.5.2",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
|||||||
2688
pnpm-lock.yaml
generated
2688
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
192
prisma/migrations/20260312174732_mvp_schema_sync/migration.sql
Normal file
192
prisma/migrations/20260312174732_mvp_schema_sync/migration.sql
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `email` on the `User` table. All the data in the column will be lost.
|
||||||
|
- You are about to drop the `Post` table. If the table is not empty, all the data it contains will be lost.
|
||||||
|
- A unique constraint covering the columns `[openId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- Added the required column `phone` to the `User` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `role` to the `User` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Made the column `name` on table `User` required. This step will fail if there are existing NULL values in that column.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Role" AS ENUM ('SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR', 'LEADER', 'DOCTOR', 'ENGINEER');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "DeviceStatus" AS ENUM ('ACTIVE', 'INACTIVE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "TaskStatus" AS ENUM ('PENDING', 'ACCEPTED', 'COMPLETED', 'CANCELLED');
|
||||||
|
|
||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "Post" DROP CONSTRAINT "Post_authorId_fkey";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "User_email_key";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" DROP COLUMN "email",
|
||||||
|
ADD COLUMN "departmentId" INTEGER,
|
||||||
|
ADD COLUMN "groupId" INTEGER,
|
||||||
|
ADD COLUMN "hospitalId" INTEGER,
|
||||||
|
ADD COLUMN "openId" TEXT,
|
||||||
|
ADD COLUMN "passwordHash" TEXT,
|
||||||
|
ADD COLUMN "phone" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "role" "Role" NOT NULL,
|
||||||
|
ALTER COLUMN "name" SET NOT NULL;
|
||||||
|
|
||||||
|
-- DropTable
|
||||||
|
DROP TABLE "Post";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Hospital" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Hospital_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Department" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"hospitalId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Department_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Group" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"departmentId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Group_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Patient" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"phone" TEXT NOT NULL,
|
||||||
|
"idCardHash" TEXT NOT NULL,
|
||||||
|
"hospitalId" INTEGER NOT NULL,
|
||||||
|
"doctorId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Patient_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Device" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"snCode" TEXT NOT NULL,
|
||||||
|
"currentPressure" INTEGER NOT NULL,
|
||||||
|
"status" "DeviceStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
"patientId" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Device_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Task" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"status" "TaskStatus" NOT NULL DEFAULT 'PENDING',
|
||||||
|
"creatorId" INTEGER NOT NULL,
|
||||||
|
"engineerId" INTEGER,
|
||||||
|
"hospitalId" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TaskItem" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"taskId" INTEGER NOT NULL,
|
||||||
|
"deviceId" INTEGER NOT NULL,
|
||||||
|
"oldPressure" INTEGER NOT NULL,
|
||||||
|
"targetPressure" INTEGER NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "TaskItem_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Department_hospitalId_idx" ON "Department"("hospitalId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Group_departmentId_idx" ON "Group"("departmentId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Patient_phone_idCardHash_idx" ON "Patient"("phone", "idCardHash");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Patient_hospitalId_doctorId_idx" ON "Patient"("hospitalId", "doctorId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Device_snCode_key" ON "Device"("snCode");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Device_patientId_status_idx" ON "Device"("patientId", "status");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Task_hospitalId_status_createdAt_idx" ON "Task"("hospitalId", "status", "createdAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TaskItem_taskId_idx" ON "TaskItem"("taskId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TaskItem_deviceId_idx" ON "TaskItem"("deviceId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_openId_key" ON "User"("openId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "User_phone_idx" ON "User"("phone");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "User_hospitalId_role_idx" ON "User"("hospitalId", "role");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "User_departmentId_role_idx" ON "User"("departmentId", "role");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "User_groupId_role_idx" ON "User"("groupId", "role");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Department" ADD CONSTRAINT "Department_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Group" ADD CONSTRAINT "Group_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "User" ADD CONSTRAINT "User_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "User" ADD CONSTRAINT "User_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "User" ADD CONSTRAINT "User_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Patient" ADD CONSTRAINT "Patient_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Patient" ADD CONSTRAINT "Patient_doctorId_fkey" FOREIGN KEY ("doctorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Device" ADD CONSTRAINT "Device_patientId_fkey" FOREIGN KEY ("patientId") REFERENCES "Patient"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Task" ADD CONSTRAINT "Task_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Task" ADD CONSTRAINT "Task_engineerId_fkey" FOREIGN KEY ("engineerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Task" ADD CONSTRAINT "Task_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TaskItem" ADD CONSTRAINT "TaskItem_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "TaskItem" ADD CONSTRAINT "TaskItem_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@ -1,30 +1,154 @@
|
|||||||
// This is your Prisma schema file,
|
|
||||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
|
||||||
|
|
||||||
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
|
|
||||||
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
|
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client"
|
provider = "prisma-client"
|
||||||
output = "../src/generated/prisma"
|
output = "../src/generated/prisma"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 兼容 seed 脚本在 Node.js 直接运行时使用 @prisma/client runtime。
|
||||||
|
generator seed_client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
// 角色枚举:用于鉴权与数据可见性控制。
|
||||||
id Int @id @default(autoincrement())
|
enum Role {
|
||||||
email String @unique
|
SYSTEM_ADMIN
|
||||||
name String?
|
HOSPITAL_ADMIN
|
||||||
posts Post[]
|
DIRECTOR
|
||||||
|
LEADER
|
||||||
|
DOCTOR
|
||||||
|
ENGINEER
|
||||||
}
|
}
|
||||||
|
|
||||||
model Post {
|
// 设备状态枚举:表示设备是否处于使用中。
|
||||||
id Int @id @default(autoincrement())
|
enum DeviceStatus {
|
||||||
title String
|
ACTIVE
|
||||||
content String?
|
INACTIVE
|
||||||
published Boolean? @default(false)
|
}
|
||||||
author User? @relation(fields: [authorId], references: [id])
|
|
||||||
authorId Int?
|
// 任务状态枚举:定义任务流转状态机。
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
|
||||||
|
// 患者表:院内患者档案,按医院隔离。
|
||||||
|
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])
|
||||||
}
|
}
|
||||||
|
|||||||
449
prisma/seed.mjs
Normal file
449
prisma/seed.mjs
Normal file
@ -0,0 +1,449 @@
|
|||||||
|
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,7 +1,23 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { UsersModule } from './users/users.module';
|
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';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UsersModule],
|
imports: [
|
||||||
|
PrismaModule,
|
||||||
|
EventEmitterModule.forRoot(),
|
||||||
|
UsersModule,
|
||||||
|
TasksModule,
|
||||||
|
PatientsModule,
|
||||||
|
AuthModule,
|
||||||
|
OrganizationModule,
|
||||||
|
NotificationsModule,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
102
src/auth/access-token.guard.ts
Normal file
102
src/auth/access-token.guard.ts
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/auth/auth.controller.ts
Normal file
50
src/auth/auth.controller.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/auth/auth.module.ts
Normal file
16
src/auth/auth.module.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
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 {}
|
||||||
34
src/auth/auth.service.ts
Normal file
34
src/auth/auth.service.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/auth/current-actor.decorator.ts
Normal file
12
src/auth/current-actor.decorator.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
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;
|
||||||
|
},
|
||||||
|
);
|
||||||
9
src/auth/roles.decorator.ts
Normal file
9
src/auth/roles.decorator.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { SetMetadata } from '@nestjs/common';
|
||||||
|
import { Role } from '../generated/prisma/enums.js';
|
||||||
|
|
||||||
|
export const ROLES_KEY = 'roles';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色装饰器:给路由声明允许访问的角色集合。
|
||||||
|
*/
|
||||||
|
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
40
src/auth/roles.guard.ts
Normal file
40
src/auth/roles.guard.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/common/actor-context.ts
Normal file
9
src/common/actor-context.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Role } from '../generated/prisma/enums.js';
|
||||||
|
|
||||||
|
export type ActorContext = {
|
||||||
|
id: number;
|
||||||
|
role: Role;
|
||||||
|
hospitalId: number | null;
|
||||||
|
departmentId: number | null;
|
||||||
|
groupId: number | null;
|
||||||
|
};
|
||||||
120
src/common/http-exception.filter.ts
Normal file
120
src/common/http-exception.filter.ts
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/common/messages.ts
Normal file
108
src/common/messages.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* 全局消息常量:统一维护接口中文提示,避免在业务代码中散落硬编码字符串。
|
||||||
|
*/
|
||||||
|
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;
|
||||||
54
src/common/response-envelope.interceptor.ts
Normal file
54
src/common/response-envelope.interceptor.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
CallHandler,
|
||||||
|
ExecutionContext,
|
||||||
|
Injectable,
|
||||||
|
NestInterceptor,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { map, Observable } from 'rxjs';
|
||||||
|
import { MESSAGES } from './messages.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局响应拦截器:将所有成功响应统一包装为 { code, msg, data }。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ResponseEnvelopeInterceptor implements NestInterceptor {
|
||||||
|
intercept(
|
||||||
|
_context: ExecutionContext,
|
||||||
|
next: CallHandler,
|
||||||
|
): Observable<{ code: number; msg: string; data: unknown }> {
|
||||||
|
return next.handle().pipe(
|
||||||
|
map((data: unknown) => {
|
||||||
|
// 若业务已返回统一结构,直接透传,避免二次包裹。
|
||||||
|
if (this.isEnveloped(data)) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: 0,
|
||||||
|
msg: MESSAGES.SUCCESS,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断当前对象是否已经是统一响应结构。
|
||||||
|
*/
|
||||||
|
private isEnveloped(data: unknown): data is {
|
||||||
|
code: number;
|
||||||
|
msg: string;
|
||||||
|
data: unknown;
|
||||||
|
} {
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = data as Record<string, unknown>;
|
||||||
|
return (
|
||||||
|
typeof target.code === 'number' &&
|
||||||
|
typeof target.msg === 'string' &&
|
||||||
|
Object.prototype.hasOwnProperty.call(target, 'data')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/common/transforms/empty-string-to-undefined.transform.ts
Normal file
12
src/common/transforms/empty-string-to-undefined.transform.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Transform } from 'class-transformer';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将空字符串统一转为 undefined,便于可选字段走 IsOptional 分支。
|
||||||
|
*/
|
||||||
|
export const EmptyStringToUndefined = () =>
|
||||||
|
Transform(({ value }) => {
|
||||||
|
if (typeof value === 'string' && value.trim() === '') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
});
|
||||||
108
src/departments/departments.controller.ts
Normal file
108
src/departments/departments.controller.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/departments/departments.module.ts
Normal file
21
src/departments/departments.module.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DepartmentsService } from './departments.service.js';
|
||||||
|
import { DepartmentsController } from './departments.controller.js';
|
||||||
|
import { AccessTokenGuard } from '../auth/access-token.guard.js';
|
||||||
|
import { RolesGuard } from '../auth/roles.guard.js';
|
||||||
|
import { OrganizationAccessService } from '../organization-common/organization-access.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 科室资源模块:聚合科室控制器与服务。
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
controllers: [DepartmentsController],
|
||||||
|
providers: [
|
||||||
|
DepartmentsService,
|
||||||
|
OrganizationAccessService,
|
||||||
|
AccessTokenGuard,
|
||||||
|
RolesGuard,
|
||||||
|
],
|
||||||
|
exports: [DepartmentsService],
|
||||||
|
})
|
||||||
|
export class DepartmentsModule {}
|
||||||
127
src/departments/departments.service.ts
Normal file
127
src/departments/departments.service.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/departments/dto/create-department.dto.ts
Normal file
18
src/departments/dto/create-department.dto.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsInt, IsString, Min } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建科室 DTO。
|
||||||
|
*/
|
||||||
|
export class CreateDepartmentDto {
|
||||||
|
@ApiProperty({ description: '科室名称', example: '神经外科' })
|
||||||
|
@IsString({ message: 'name 必须是字符串' })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '医院 ID', example: 1 })
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'hospitalId 必须是整数' })
|
||||||
|
@Min(1, { message: 'hospitalId 必须大于 0' })
|
||||||
|
hospitalId!: number;
|
||||||
|
}
|
||||||
18
src/departments/dto/update-department.dto.ts
Normal file
18
src/departments/dto/update-department.dto.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { ApiHideProperty, OmitType, PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateDepartmentDto } from './create-department.dto.js';
|
||||||
|
import { IsEmpty, IsOptional } from 'class-validator';
|
||||||
|
import { MESSAGES } from '../../common/messages.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新科室 DTO。
|
||||||
|
*/
|
||||||
|
class UpdateDepartmentNameDto extends PartialType(
|
||||||
|
OmitType(CreateDepartmentDto, ['hospitalId'] as const),
|
||||||
|
) {}
|
||||||
|
|
||||||
|
export class UpdateDepartmentDto extends UpdateDepartmentNameDto {
|
||||||
|
@ApiHideProperty()
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmpty({ message: MESSAGES.ORG.DEPARTMENT_REPARENT_FORBIDDEN })
|
||||||
|
hospitalId?: unknown;
|
||||||
|
}
|
||||||
1
src/departments/entities/department.entity.ts
Normal file
1
src/departments/entities/department.entity.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export class Department {}
|
||||||
18
src/groups/dto/create-group.dto.ts
Normal file
18
src/groups/dto/create-group.dto.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { IsInt, IsString, Min } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建小组 DTO。
|
||||||
|
*/
|
||||||
|
export class 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;
|
||||||
|
}
|
||||||
18
src/groups/dto/update-group.dto.ts
Normal file
18
src/groups/dto/update-group.dto.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { ApiHideProperty, OmitType, PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateGroupDto } from './create-group.dto.js';
|
||||||
|
import { IsEmpty, IsOptional } from 'class-validator';
|
||||||
|
import { MESSAGES } from '../../common/messages.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新小组 DTO。
|
||||||
|
*/
|
||||||
|
class UpdateGroupNameDto extends PartialType(
|
||||||
|
OmitType(CreateGroupDto, ['departmentId'] as const),
|
||||||
|
) {}
|
||||||
|
|
||||||
|
export class UpdateGroupDto extends UpdateGroupNameDto {
|
||||||
|
@ApiHideProperty()
|
||||||
|
@IsOptional()
|
||||||
|
@IsEmpty({ message: MESSAGES.ORG.GROUP_REPARENT_FORBIDDEN })
|
||||||
|
departmentId?: unknown;
|
||||||
|
}
|
||||||
1
src/groups/entities/group.entity.ts
Normal file
1
src/groups/entities/group.entity.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export class Group {}
|
||||||
106
src/groups/groups.controller.ts
Normal file
106
src/groups/groups.controller.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/groups/groups.module.ts
Normal file
16
src/groups/groups.module.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
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 {}
|
||||||
135
src/groups/groups.service.ts
Normal file
135
src/groups/groups.service.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/hospitals/dto/create-hospital.dto.ts
Normal file
11
src/hospitals/dto/create-hospital.dto.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建医院 DTO。
|
||||||
|
*/
|
||||||
|
export class CreateHospitalDto {
|
||||||
|
@ApiProperty({ description: '医院名称', example: '示例人民医院' })
|
||||||
|
@IsString({ message: 'name 必须是字符串' })
|
||||||
|
name!: string;
|
||||||
|
}
|
||||||
7
src/hospitals/dto/update-hospital.dto.ts
Normal file
7
src/hospitals/dto/update-hospital.dto.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateHospitalDto } from './create-hospital.dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新医院 DTO。
|
||||||
|
*/
|
||||||
|
export class UpdateHospitalDto extends PartialType(CreateHospitalDto) {}
|
||||||
1
src/hospitals/entities/hospital.entity.ts
Normal file
1
src/hospitals/entities/hospital.entity.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export class Hospital {}
|
||||||
106
src/hospitals/hospitals.controller.ts
Normal file
106
src/hospitals/hospitals.controller.ts
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/hospitals/hospitals.module.ts
Normal file
21
src/hospitals/hospitals.module.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HospitalsService } from './hospitals.service.js';
|
||||||
|
import { HospitalsController } from './hospitals.controller.js';
|
||||||
|
import { AccessTokenGuard } from '../auth/access-token.guard.js';
|
||||||
|
import { RolesGuard } from '../auth/roles.guard.js';
|
||||||
|
import { OrganizationAccessService } from '../organization-common/organization-access.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 医院资源模块:聚合医院控制器与服务。
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
controllers: [HospitalsController],
|
||||||
|
providers: [
|
||||||
|
HospitalsService,
|
||||||
|
OrganizationAccessService,
|
||||||
|
AccessTokenGuard,
|
||||||
|
RolesGuard,
|
||||||
|
],
|
||||||
|
exports: [HospitalsService],
|
||||||
|
})
|
||||||
|
export class HospitalsModule {}
|
||||||
116
src/hospitals/hospitals.service.ts
Normal file
116
src/hospitals/hospitals.service.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
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,8 +1,62 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import { AppModule } from './app.module.js';
|
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() {
|
async function bootstrap() {
|
||||||
|
// 创建应用实例并加载核心模块。
|
||||||
const app = await NestFactory.create(AppModule);
|
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);
|
await app.listen(process.env.PORT ?? 3000);
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
12
src/notifications/notifications.module.ts
Normal file
12
src/notifications/notifications.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { WechatNotifyService } from './wechat-notify/wechat-notify.service.js';
|
||||||
|
import { TaskEventsListener } from './task-events.listener/task-events.listener.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知模块:承载事件监听与微信消息推送能力。
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
providers: [WechatNotifyService, TaskEventsListener],
|
||||||
|
exports: [WechatNotifyService],
|
||||||
|
})
|
||||||
|
export class NotificationsModule {}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/notifications/wechat-notify/wechat-notify.service.ts
Normal file
41
src/notifications/wechat-notify/wechat-notify.service.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/organization-common/dto/organization-query.dto.ts
Normal file
51
src/organization-common/dto/organization-query.dto.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
131
src/organization-common/organization-access.service.ts
Normal file
131
src/organization-common/organization-access.service.ts
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/organization/organization.module.ts
Normal file
12
src/organization/organization.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HospitalsModule } from '../hospitals/hospitals.module.js';
|
||||||
|
import { DepartmentsModule } from '../departments/departments.module.js';
|
||||||
|
import { GroupsModule } from '../groups/groups.module.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组织域聚合模块:统一挂载医院/科室/小组三个资源模块。
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [HospitalsModule, DepartmentsModule, GroupsModule],
|
||||||
|
})
|
||||||
|
export class OrganizationModule {}
|
||||||
169
src/patients/b-patients/b-patients.controller.ts
Normal file
169
src/patients/b-patients/b-patients.controller.ts
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
391
src/patients/b-patients/b-patients.service.ts
Normal file
391
src/patients/b-patients/b-patients.service.ts
Normal file
@ -0,0 +1,391 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/patients/c-patients/c-patients.controller.ts
Normal file
27
src/patients/c-patients/c-patients.controller.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/patients/c-patients/c-patients.service.ts
Normal file
108
src/patients/c-patients/c-patients.service.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/patients/dto/create-patient.dto.ts
Normal file
35
src/patients/dto/create-patient.dto.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
16
src/patients/dto/family-lifecycle-query.dto.ts
Normal file
16
src/patients/dto/family-lifecycle-query.dto.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
7
src/patients/dto/update-patient.dto.ts
Normal file
7
src/patients/dto/update-patient.dto.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreatePatientDto } from './create-patient.dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 患者更新 DTO。
|
||||||
|
*/
|
||||||
|
export class UpdatePatientDto extends PartialType(CreatePatientDto) {}
|
||||||
14
src/patients/patients.module.ts
Normal file
14
src/patients/patients.module.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
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 {}
|
||||||
9
src/prisma.module.ts
Normal file
9
src/prisma.module.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
import { PrismaService } from './prisma.service.js';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
providers: [PrismaService],
|
||||||
|
exports: [PrismaService],
|
||||||
|
})
|
||||||
|
export class PrismaModule {}
|
||||||
64
src/tasks/b-tasks/b-tasks.controller.ts
Normal file
64
src/tasks/b-tasks/b-tasks.controller.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/tasks/dto/accept-task.dto.ts
Normal file
14
src/tasks/dto/accept-task.dto.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
14
src/tasks/dto/cancel-task.dto.ts
Normal file
14
src/tasks/dto/cancel-task.dto.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
14
src/tasks/dto/complete-task.dto.ts
Normal file
14
src/tasks/dto/complete-task.dto.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
47
src/tasks/dto/publish-task.dto.ts
Normal file
47
src/tasks/dto/publish-task.dto.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
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[];
|
||||||
|
}
|
||||||
283
src/tasks/task.service.ts
Normal file
283
src/tasks/task.service.ts
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/tasks/tasks.module.ts
Normal file
12
src/tasks/tasks.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
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 {}
|
||||||
36
src/users/b-users/b-users.controller.ts
Normal file
36
src/users/b-users/b-users.controller.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/users/dto/assign-engineer-hospital.dto.ts
Normal file
14
src/users/dto/assign-engineer-hospital.dto.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
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 +1,66 @@
|
|||||||
export class CreateUserDto {}
|
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;
|
||||||
|
}
|
||||||
|
|||||||
40
src/users/dto/login.dto.ts
Normal file
40
src/users/dto/login.dto.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
76
src/users/dto/register-user.dto.ts
Normal file
76
src/users/dto/register-user.dto.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
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,4 +1,7 @@
|
|||||||
import { PartialType } from '@nestjs/mapped-types';
|
import { PartialType } from '@nestjs/swagger';
|
||||||
import { CreateUserDto } from './create-user.dto';
|
import { CreateUserDto } from './create-user.dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户 DTO。
|
||||||
|
*/
|
||||||
export class UpdateUserDto extends PartialType(CreateUserDto) {}
|
export class UpdateUserDto extends PartialType(CreateUserDto) {}
|
||||||
|
|||||||
@ -1,33 +1,86 @@
|
|||||||
import { Controller, Get, Post, Body, Patch, Param, Delete } from '@nestjs/common';
|
import {
|
||||||
import { UsersService } from './users.service';
|
UseGuards,
|
||||||
import { CreateUserDto } from './dto/create-user.dto';
|
Controller,
|
||||||
import { UpdateUserDto } from './dto/update-user.dto';
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户管理控制器:面向 B 端后台的用户 CRUD。
|
||||||
|
*/
|
||||||
|
@ApiTags('用户管理(B端)')
|
||||||
|
@ApiBearerAuth('bearer')
|
||||||
@Controller('users')
|
@Controller('users')
|
||||||
|
@UseGuards(AccessTokenGuard, RolesGuard)
|
||||||
export class UsersController {
|
export class UsersController {
|
||||||
constructor(private readonly usersService: UsersService) {}
|
constructor(private readonly usersService: UsersService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建用户。
|
||||||
|
*/
|
||||||
@Post()
|
@Post()
|
||||||
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||||
|
@ApiOperation({ summary: '创建用户' })
|
||||||
create(@Body() createUserDto: CreateUserDto) {
|
create(@Body() createUserDto: CreateUserDto) {
|
||||||
return this.usersService.create(createUserDto);
|
return this.usersService.create(createUserDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户列表。
|
||||||
|
*/
|
||||||
@Get()
|
@Get()
|
||||||
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||||
|
@ApiOperation({ summary: '查询用户列表' })
|
||||||
findAll() {
|
findAll() {
|
||||||
return this.usersService.findAll();
|
return this.usersService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户详情。
|
||||||
|
*/
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||||
|
@ApiOperation({ summary: '查询用户详情' })
|
||||||
|
@ApiParam({ name: 'id', description: '用户 ID' })
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.usersService.findOne(+id);
|
return this.usersService.findOne(+id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户。
|
||||||
|
*/
|
||||||
@Patch(':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) {
|
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
|
||||||
return this.usersService.update(+id, updateUserDto);
|
return this.usersService.update(+id, updateUserDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除用户。
|
||||||
|
*/
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@Roles(Role.SYSTEM_ADMIN)
|
||||||
|
@ApiOperation({ summary: '删除用户' })
|
||||||
|
@ApiParam({ name: 'id', description: '用户 ID' })
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.usersService.remove(+id);
|
return this.usersService.remove(+id);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { UsersService } from './users.service';
|
import { UsersService } from './users.service.js';
|
||||||
import { UsersController } from './users.controller';
|
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';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [UsersController],
|
controllers: [UsersController, BUsersController],
|
||||||
providers: [UsersService],
|
providers: [UsersService, AccessTokenGuard, RolesGuard],
|
||||||
|
exports: [UsersService],
|
||||||
})
|
})
|
||||||
export class UsersModule {}
|
export class UsersModule {}
|
||||||
|
|||||||
@ -1,26 +1,617 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import {
|
||||||
import { CreateUserDto } from './dto/create-user.dto';
|
BadRequestException,
|
||||||
import { UpdateUserDto } from './dto/update-user.dto';
|
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;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UsersService {
|
export class UsersService {
|
||||||
create(createUserDto: CreateUserDto) {
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
return 'This action adds a new user';
|
|
||||||
|
/**
|
||||||
|
* 注册账号:根据角色与组织范围进行约束,并写入 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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
findAll() {
|
/**
|
||||||
return `This action returns all users`;
|
* 登录:按手机号+角色(可选医院)定位账号并签发 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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
findOne(id: number) {
|
const user = users[0];
|
||||||
return `This action returns a #${id} user`;
|
if (!user?.passwordHash) {
|
||||||
|
throw new UnauthorizedException(MESSAGES.AUTH.PASSWORD_NOT_ENABLED);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(id: number, updateUserDto: UpdateUserDto) {
|
const matched = await compare(password, user.passwordHash);
|
||||||
return `This action updates a #${id} user`;
|
if (!matched) {
|
||||||
|
throw new UnauthorizedException(MESSAGES.AUTH.INVALID_CREDENTIALS);
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(id: number) {
|
const actor: ActorContext = {
|
||||||
return `This action removes a #${id} user`;
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前登录用户详情。
|
||||||
|
*/
|
||||||
|
async me(actor: ActorContext) {
|
||||||
|
return this.findOne(actor.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户列表。
|
||||||
|
*/
|
||||||
|
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',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
test/e2e/fixtures/e2e-roles.ts
Normal file
59
test/e2e/fixtures/e2e-roles.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
41
test/e2e/helpers/e2e-app.helper.ts
Normal file
41
test/e2e/helpers/e2e-app.helper.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
47
test/e2e/helpers/e2e-auth.helper.ts
Normal file
47
test/e2e/helpers/e2e-auth.helper.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
38
test/e2e/helpers/e2e-context.helper.ts
Normal file
38
test/e2e/helpers/e2e-context.helper.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
195
test/e2e/helpers/e2e-fixtures.helper.ts
Normal file
195
test/e2e/helpers/e2e-fixtures.helper.ts
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
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'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
37
test/e2e/helpers/e2e-http.helper.ts
Normal file
37
test/e2e/helpers/e2e-http.helper.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
32
test/e2e/helpers/e2e-matrix.helper.ts
Normal file
32
test/e2e/helpers/e2e-matrix.helper.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
129
test/e2e/specs/auth.e2e-spec.ts
Normal file
129
test/e2e/specs/auth.e2e-spec.ts
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
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'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
729
test/e2e/specs/organization.e2e-spec.ts
Normal file
729
test/e2e/specs/organization.e2e-spec.ts
Normal file
@ -0,0 +1,729 @@
|
|||||||
|
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',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
185
test/e2e/specs/patients.e2e-spec.ts
Normal file
185
test/e2e/specs/patients.e2e-spec.ts
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
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, '未找到匹配的患者档案');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
325
test/e2e/specs/tasks.e2e-spec.ts
Normal file
325
test/e2e/specs/tasks.e2e-spec.ts
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
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 }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
340
test/e2e/specs/users.e2e-spec.ts
Normal file
340
test/e2e/specs/users.e2e-spec.ts
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
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({}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
20
test/jest-e2e.config.cjs
Normal file
20
test/jest-e2e.config.cjs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
10
test/tsconfig.e2e.json
Normal file
10
test/tsconfig.e2e.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"types": ["node", "jest"],
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["./e2e/**/*.ts", "../src/**/*.ts"]
|
||||||
|
}
|
||||||
24
tyt-admin/.gitignore
vendored
Normal file
24
tyt-admin/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# 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
Normal file
3
tyt-admin/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["Vue.volar"]
|
||||||
|
}
|
||||||
10
tyt-admin/auto-imports.d.ts
vendored
Normal file
10
tyt-admin/auto-imports.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/* 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
Normal file
54
tyt-admin/components.d.ts
vendored
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
/* 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']
|
||||||
|
}
|
||||||
|
}
|
||||||
13
tyt-admin/index.html
Normal file
13
tyt-admin/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!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>
|
||||||
27
tyt-admin/package.json
Normal file
27
tyt-admin/package.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"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
Normal file
1758
tyt-admin/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
tyt-admin/src/App.vue
Normal file
23
tyt-admin/src/App.vue
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<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