权限完善
This commit is contained in:
parent
aa1346f6af
commit
b55e600c9c
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 }`。
|
||||
36
docs/patients.md
Normal file
36
docs/patients.md
Normal file
@ -0,0 +1,36 @@
|
||||
# 患者模块说明(`src/patients`)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
- B 端:按组织与角色范围查询患者(强依赖 `hospitalId`)。
|
||||
- C 端:按 `phone + idCardHash` 做跨院聚合查询。
|
||||
|
||||
## 2. B 端可见性
|
||||
|
||||
- `DOCTOR`:仅可查自己名下患者
|
||||
- `LEADER`:可查本组医生名下患者(按医生当前 `groupId` 反查)
|
||||
- `DIRECTOR`:可查本科室医生名下患者(按医生当前 `departmentId` 反查)
|
||||
- `HOSPITAL_ADMIN`:可查本院全部患者
|
||||
- `SYSTEM_ADMIN`:需显式传入目标 `hospitalId`
|
||||
|
||||
说明:
|
||||
患者表只绑定 `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,14 +20,18 @@
|
||||
"@nestjs/event-emitter": "^3.0.1",
|
||||
"@nestjs/mapped-types": "*",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/swagger": "^11.2.6",
|
||||
"@prisma/adapter-pg": "^7.5.0",
|
||||
"@prisma/client": "^7.5.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"dotenv": "^17.3.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"pg": "^8.20.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
"rxjs": "^7.8.1",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
|
||||
159
pnpm-lock.yaml
generated
159
pnpm-lock.yaml
generated
@ -10,19 +10,22 @@ importers:
|
||||
dependencies:
|
||||
'@nestjs/common':
|
||||
specifier: ^11.0.1
|
||||
version: 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
version: 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core':
|
||||
specifier: ^11.0.1
|
||||
version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/event-emitter':
|
||||
specifier: ^3.0.1
|
||||
version: 3.0.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
|
||||
version: 3.0.1(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
|
||||
'@nestjs/mapped-types':
|
||||
specifier: '*'
|
||||
version: 2.1.0(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)
|
||||
version: 2.1.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
|
||||
'@nestjs/platform-express':
|
||||
specifier: ^11.0.1
|
||||
version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
|
||||
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
|
||||
'@nestjs/swagger':
|
||||
specifier: ^11.2.6
|
||||
version: 11.2.6(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
|
||||
'@prisma/adapter-pg':
|
||||
specifier: ^7.5.0
|
||||
version: 7.5.0
|
||||
@ -32,6 +35,12 @@ importers:
|
||||
bcrypt:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
class-transformer:
|
||||
specifier: ^0.5.1
|
||||
version: 0.5.1
|
||||
class-validator:
|
||||
specifier: ^0.15.1
|
||||
version: 0.15.1
|
||||
dotenv:
|
||||
specifier: ^17.3.1
|
||||
version: 17.3.1
|
||||
@ -47,6 +56,9 @@ importers:
|
||||
rxjs:
|
||||
specifier: ^7.8.1
|
||||
version: 7.8.2
|
||||
swagger-ui-express:
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1(express@5.2.1)
|
||||
devDependencies:
|
||||
'@nestjs/cli':
|
||||
specifier: ^11.0.0
|
||||
@ -56,7 +68,7 @@ importers:
|
||||
version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3)
|
||||
'@nestjs/testing':
|
||||
specifier: ^11.0.1
|
||||
version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)
|
||||
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)
|
||||
'@types/bcrypt':
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
@ -350,6 +362,9 @@ packages:
|
||||
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@microsoft/tsdoc@0.16.0':
|
||||
resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==}
|
||||
|
||||
'@mrleebo/prisma-ast@0.13.1':
|
||||
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
|
||||
engines: {node: '>=16'}
|
||||
@ -428,6 +443,23 @@ packages:
|
||||
peerDependencies:
|
||||
typescript: '>=4.8.2'
|
||||
|
||||
'@nestjs/swagger@11.2.6':
|
||||
resolution: {integrity: sha512-oiXOxMQqDFyv1AKAqFzSo6JPvMEs4uA36Eyz/s2aloZLxUjcLfUMELSLSNQunr61xCPTpwEOShfmO7NIufKXdA==}
|
||||
peerDependencies:
|
||||
'@fastify/static': ^8.0.0 || ^9.0.0
|
||||
'@nestjs/common': ^11.0.1
|
||||
'@nestjs/core': ^11.0.1
|
||||
class-transformer: '*'
|
||||
class-validator: '*'
|
||||
reflect-metadata: ^0.1.12 || ^0.2.0
|
||||
peerDependenciesMeta:
|
||||
'@fastify/static':
|
||||
optional: true
|
||||
class-transformer:
|
||||
optional: true
|
||||
class-validator:
|
||||
optional: true
|
||||
|
||||
'@nestjs/testing@11.1.16':
|
||||
resolution: {integrity: sha512-E7/aUCxzeMSJV80L5GWGIuiMyR/1ncS7uOIetAImfbS4ATE1/h2GBafk0qpk+vjFtPIbtoh9BWDGICzUEU5jDA==}
|
||||
peerDependencies:
|
||||
@ -514,6 +546,9 @@ packages:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
|
||||
'@scarf/scarf@1.4.0':
|
||||
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
@ -605,6 +640,9 @@ packages:
|
||||
'@types/supertest@6.0.3':
|
||||
resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==}
|
||||
|
||||
'@types/validator@13.15.10':
|
||||
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
|
||||
|
||||
'@webassemblyjs/ast@1.14.1':
|
||||
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
|
||||
|
||||
@ -854,6 +892,12 @@ packages:
|
||||
citty@0.2.1:
|
||||
resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==}
|
||||
|
||||
class-transformer@0.5.1:
|
||||
resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==}
|
||||
|
||||
class-validator@0.15.1:
|
||||
resolution: {integrity: sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==}
|
||||
|
||||
cli-cursor@3.1.0:
|
||||
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
|
||||
engines: {node: '>=8'}
|
||||
@ -1339,6 +1383,9 @@ packages:
|
||||
jws@4.0.1:
|
||||
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||
|
||||
libphonenumber-js@1.12.39:
|
||||
resolution: {integrity: sha512-MW79m7HuOqBk8mwytiXYTMELJiBbV3Zl9Y39dCCn1yC8K+WGNSq1QGvzywbylp5vGShEztMScCWHX/XFOS0rXg==}
|
||||
|
||||
lilconfig@2.1.0:
|
||||
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
|
||||
engines: {node: '>=10'}
|
||||
@ -1919,6 +1966,18 @@ packages:
|
||||
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
swagger-ui-dist@5.31.0:
|
||||
resolution: {integrity: sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==}
|
||||
|
||||
swagger-ui-dist@5.32.0:
|
||||
resolution: {integrity: sha512-nKZB0OuDvacB0s/lC2gbge+RigYvGRGpLLMWMFxaTUwfM+CfndVk9Th2IaTinqXiz6Mn26GK2zriCpv6/+5m3Q==}
|
||||
|
||||
swagger-ui-express@5.0.1:
|
||||
resolution: {integrity: sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==}
|
||||
engines: {node: '>= v0.10.32'}
|
||||
peerDependencies:
|
||||
express: '>=4.0.0 || >=5.0.0-beta'
|
||||
|
||||
symbol-observable@4.0.0:
|
||||
resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==}
|
||||
engines: {node: '>=0.10'}
|
||||
@ -2054,6 +2113,10 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
validator@13.15.26:
|
||||
resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
vary@1.1.2:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@ -2382,6 +2445,8 @@ snapshots:
|
||||
|
||||
'@lukeed/csprng@1.1.0': {}
|
||||
|
||||
'@microsoft/tsdoc@0.16.0': {}
|
||||
|
||||
'@mrleebo/prisma-ast@0.13.1':
|
||||
dependencies:
|
||||
chevrotain: 10.5.0
|
||||
@ -2413,7 +2478,7 @@ snapshots:
|
||||
- uglify-js
|
||||
- webpack-cli
|
||||
|
||||
'@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||
'@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||
dependencies:
|
||||
file-type: 21.3.0
|
||||
iterare: 1.2.1
|
||||
@ -2422,12 +2487,15 @@ snapshots:
|
||||
rxjs: 7.8.2
|
||||
tslib: 2.8.1
|
||||
uid: 2.0.2
|
||||
optionalDependencies:
|
||||
class-transformer: 0.5.1
|
||||
class-validator: 0.15.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@nestjs/core@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||
'@nestjs/core@11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nuxt/opencollective': 0.4.1
|
||||
fast-safe-stringify: 2.1.1
|
||||
iterare: 1.2.1
|
||||
@ -2437,23 +2505,26 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
uid: 2.0.2
|
||||
optionalDependencies:
|
||||
'@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
|
||||
'@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
|
||||
|
||||
'@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)':
|
||||
'@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
eventemitter2: 6.4.9
|
||||
|
||||
'@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)':
|
||||
'@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
reflect-metadata: 0.2.2
|
||||
optionalDependencies:
|
||||
class-transformer: 0.5.1
|
||||
class-validator: 0.15.1
|
||||
|
||||
'@nestjs/platform-express@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)':
|
||||
'@nestjs/platform-express@11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
cors: 2.8.6
|
||||
express: 5.2.1
|
||||
multer: 2.1.1
|
||||
@ -2473,13 +2544,28 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- chokidar
|
||||
|
||||
'@nestjs/testing@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)':
|
||||
'@nestjs/swagger@11.2.6(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@microsoft/tsdoc': 0.16.0
|
||||
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
|
||||
js-yaml: 4.1.1
|
||||
lodash: 4.17.23
|
||||
path-to-regexp: 8.3.0
|
||||
reflect-metadata: 0.2.2
|
||||
swagger-ui-dist: 5.31.0
|
||||
optionalDependencies:
|
||||
class-transformer: 0.5.1
|
||||
class-validator: 0.15.1
|
||||
|
||||
'@nestjs/testing@11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)':
|
||||
dependencies:
|
||||
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
'@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
|
||||
'@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
|
||||
|
||||
'@noble/hashes@1.8.0': {}
|
||||
|
||||
@ -2581,6 +2667,8 @@ snapshots:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
'@scarf/scarf@1.4.0': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@tokenizer/inflate@0.4.1':
|
||||
@ -2692,6 +2780,8 @@ snapshots:
|
||||
'@types/methods': 1.1.4
|
||||
'@types/superagent': 8.1.9
|
||||
|
||||
'@types/validator@13.15.10': {}
|
||||
|
||||
'@webassemblyjs/ast@1.14.1':
|
||||
dependencies:
|
||||
'@webassemblyjs/helper-numbers': 1.13.2
|
||||
@ -2975,6 +3065,14 @@ snapshots:
|
||||
|
||||
citty@0.2.1: {}
|
||||
|
||||
class-transformer@0.5.1: {}
|
||||
|
||||
class-validator@0.15.1:
|
||||
dependencies:
|
||||
'@types/validator': 13.15.10
|
||||
libphonenumber-js: 1.12.39
|
||||
validator: 13.15.26
|
||||
|
||||
cli-cursor@3.1.0:
|
||||
dependencies:
|
||||
restore-cursor: 3.1.0
|
||||
@ -3454,6 +3552,8 @@ snapshots:
|
||||
jwa: 2.0.1
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
libphonenumber-js@1.12.39: {}
|
||||
|
||||
lilconfig@2.1.0: {}
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
@ -4002,6 +4102,19 @@ snapshots:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
swagger-ui-dist@5.31.0:
|
||||
dependencies:
|
||||
'@scarf/scarf': 1.4.0
|
||||
|
||||
swagger-ui-dist@5.32.0:
|
||||
dependencies:
|
||||
'@scarf/scarf': 1.4.0
|
||||
|
||||
swagger-ui-express@5.0.1(express@5.2.1):
|
||||
dependencies:
|
||||
express: 5.2.1
|
||||
swagger-ui-dist: 5.32.0
|
||||
|
||||
symbol-observable@4.0.0: {}
|
||||
|
||||
tapable@2.3.0: {}
|
||||
@ -4123,6 +4236,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
validator@13.15.26: {}
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
watchpack@2.5.1:
|
||||
|
||||
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;
|
||||
@ -7,6 +7,7 @@ datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
// 角色枚举:用于鉴权与数据可见性控制。
|
||||
enum Role {
|
||||
SYSTEM_ADMIN
|
||||
HOSPITAL_ADMIN
|
||||
@ -16,11 +17,13 @@ enum Role {
|
||||
ENGINEER
|
||||
}
|
||||
|
||||
// 设备状态枚举:表示设备是否处于使用中。
|
||||
enum DeviceStatus {
|
||||
ACTIVE
|
||||
INACTIVE
|
||||
}
|
||||
|
||||
// 任务状态枚举:定义任务流转状态机。
|
||||
enum TaskStatus {
|
||||
PENDING
|
||||
ACCEPTED
|
||||
@ -28,6 +31,7 @@ enum TaskStatus {
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
// 医院主表:多租户顶层实体。
|
||||
model Hospital {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
@ -37,6 +41,7 @@ model Hospital {
|
||||
tasks Task[]
|
||||
}
|
||||
|
||||
// 科室表:归属于医院。
|
||||
model Department {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
@ -48,6 +53,7 @@ model Department {
|
||||
@@index([hospitalId])
|
||||
}
|
||||
|
||||
// 小组表:归属于科室。
|
||||
model Group {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
@ -58,11 +64,12 @@ model Group {
|
||||
@@index([departmentId])
|
||||
}
|
||||
|
||||
// 用户表:支持后台密码登录与小程序 openId。
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
phone String
|
||||
// Backend login password hash (bcrypt).
|
||||
// 后台登录密码哈希(bcrypt)。
|
||||
passwordHash String?
|
||||
openId String? @unique
|
||||
role Role
|
||||
@ -82,6 +89,7 @@ model User {
|
||||
@@index([groupId, role])
|
||||
}
|
||||
|
||||
// 患者表:院内患者档案,按医院隔离。
|
||||
model Patient {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
@ -97,6 +105,7 @@ model Patient {
|
||||
@@index([hospitalId, doctorId])
|
||||
}
|
||||
|
||||
// 设备表:患者可绑定多个分流设备。
|
||||
model Device {
|
||||
id Int @id @default(autoincrement())
|
||||
snCode String @unique
|
||||
@ -109,6 +118,7 @@ model Device {
|
||||
@@index([patientId, status])
|
||||
}
|
||||
|
||||
// 主任务表:记录调压任务主单。
|
||||
model Task {
|
||||
id Int @id @default(autoincrement())
|
||||
status TaskStatus @default(PENDING)
|
||||
@ -124,6 +134,7 @@ model Task {
|
||||
@@index([hospitalId, status, createdAt])
|
||||
}
|
||||
|
||||
// 任务明细表:一个任务可包含多个设备调压项。
|
||||
model TaskItem {
|
||||
id Int @id @default(autoincrement())
|
||||
taskId Int
|
||||
|
||||
@ -5,6 +5,8 @@ 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({
|
||||
imports: [
|
||||
@ -14,6 +16,8 @@ import { AuthModule } from './auth/auth.module.js';
|
||||
TasksModule,
|
||||
PatientsModule,
|
||||
AuthModule,
|
||||
OrganizationModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@ -6,10 +6,17 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { Role } from '../generated/prisma/enums.js';
|
||||
import { ActorContext } from '../common/actor-context.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<
|
||||
{
|
||||
@ -24,7 +31,7 @@ export class AccessTokenGuard implements CanActivate {
|
||||
: authorization;
|
||||
|
||||
if (!headerValue || !headerValue.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('Missing bearer token');
|
||||
throw new UnauthorizedException(MESSAGES.AUTH.MISSING_BEARER);
|
||||
}
|
||||
|
||||
const token = headerValue.slice('Bearer '.length).trim();
|
||||
@ -33,10 +40,13 @@ export class AccessTokenGuard implements CanActivate {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并验证 token,同时提取最小化 actor 上下文。
|
||||
*/
|
||||
private verifyAndExtractActor(token: string): ActorContext {
|
||||
const secret = process.env.AUTH_TOKEN_SECRET;
|
||||
if (!secret) {
|
||||
throw new UnauthorizedException('AUTH_TOKEN_SECRET is not configured');
|
||||
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_SECRET_MISSING);
|
||||
}
|
||||
|
||||
let payload: string | jwt.JwtPayload;
|
||||
@ -46,16 +56,16 @@ export class AccessTokenGuard implements CanActivate {
|
||||
issuer: 'tyt-api-nest',
|
||||
});
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid or expired token');
|
||||
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_INVALID);
|
||||
}
|
||||
|
||||
if (typeof payload !== 'object') {
|
||||
throw new UnauthorizedException('Invalid token payload');
|
||||
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('Invalid role in token');
|
||||
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_ROLE_INVALID);
|
||||
}
|
||||
|
||||
return {
|
||||
@ -67,19 +77,25 @@ export class AccessTokenGuard implements CanActivate {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格校验 token 中必须为整数的字段。
|
||||
*/
|
||||
private asInt(value: unknown, field: string): number {
|
||||
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
||||
throw new UnauthorizedException(`Invalid ${field} in token`);
|
||||
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(`Invalid ${field} in token`);
|
||||
throw new UnauthorizedException(`${MESSAGES.AUTH.TOKEN_FIELD_INVALID}: ${field}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
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';
|
||||
@ -6,22 +11,39 @@ 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);
|
||||
}
|
||||
|
||||
@ -4,6 +4,9 @@ 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],
|
||||
|
||||
@ -1,21 +1,33 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ActorContext } from '../common/actor-context.js';
|
||||
import type { ActorContext } from '../common/actor-context.js';
|
||||
import { UsersService } from '../users/users.service.js';
|
||||
import { RegisterUserDto } from '../users/dto/register-user.dto.js';
|
||||
import { LoginDto } from '../users/dto/login.dto.js';
|
||||
|
||||
/**
|
||||
* 认证服务:将控制层输入转发到用户域能力,避免控制器直接操作用户仓储。
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
/**
|
||||
* 注册能力委托给用户服务。
|
||||
*/
|
||||
register(dto: RegisterUserDto) {
|
||||
return this.usersService.register(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录能力委托给用户服务。
|
||||
*/
|
||||
login(dto: LoginDto) {
|
||||
return this.usersService.login(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取当前登录用户详情。
|
||||
*/
|
||||
me(actor: ActorContext) {
|
||||
return this.usersService.me(actor);
|
||||
}
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { ActorContext } from '../common/actor-context.js';
|
||||
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 }>();
|
||||
|
||||
@ -2,4 +2,8 @@ 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);
|
||||
|
||||
@ -7,11 +7,18 @@ import {
|
||||
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(),
|
||||
@ -25,7 +32,7 @@ export class RolesGuard implements CanActivate {
|
||||
const request = context.switchToHttp().getRequest<{ actor?: { role?: Role } }>();
|
||||
const actorRole = request.actor?.role;
|
||||
if (!actorRole || !requiredRoles.includes(actorRole)) {
|
||||
throw new ForbiddenException('Role is not allowed for this endpoint');
|
||||
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
102
src/common/messages.ts
Normal file
102
src/common/messages.ts
Normal file
@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 全局消息常量:统一维护接口中文提示,避免在业务代码中散落硬编码字符串。
|
||||
*/
|
||||
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: '仅医生允许调整科室/小组归属',
|
||||
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: {
|
||||
ROLE_FORBIDDEN: '当前角色无权限查询患者列表',
|
||||
GROUP_REQUIRED: '组长查询需携带 groupId',
|
||||
DEPARTMENT_REQUIRED: '主任查询需携带 departmentId',
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
53
src/main.ts
53
src/main.ts
@ -1,9 +1,62 @@
|
||||
import 'dotenv/config';
|
||||
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module.js';
|
||||
import { HttpExceptionFilter } from './common/http-exception.filter.js';
|
||||
import { MESSAGES } from './common/messages.js';
|
||||
import { ResponseEnvelopeInterceptor } from './common/response-envelope.interceptor.js';
|
||||
|
||||
async function bootstrap() {
|
||||
// 创建应用实例并加载核心模块。
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// 全局校验:清理未知字段、自动类型转换,并将校验错误统一为中文信息。
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
exceptionFactory: (errors) => {
|
||||
const messages = errors
|
||||
.flatMap((error) => Object.values(error.constraints ?? {}))
|
||||
.filter((item): item is string => Boolean(item));
|
||||
return new BadRequestException(
|
||||
messages.length > 0
|
||||
? messages.join(';')
|
||||
: MESSAGES.DEFAULT_BAD_REQUEST,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
// 全局异常与成功响应统一格式化。
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalInterceptors(new ResponseEnvelopeInterceptor());
|
||||
|
||||
// Swagger 文档:提供在线调试与 OpenAPI JSON 导出。
|
||||
const swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('TYT 多租户医疗调压系统 API')
|
||||
.setDescription('后端接口文档(含认证、RBAC、任务流转与患者聚合)')
|
||||
.setVersion('1.0.0')
|
||||
.addServer('http://localhost:3000', 'localhost')
|
||||
.addBearerAuth(
|
||||
{
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: '在此输入登录接口返回的 accessToken',
|
||||
},
|
||||
'bearer',
|
||||
)
|
||||
.build();
|
||||
|
||||
const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig);
|
||||
SwaggerModule.setup('api/docs', app, swaggerDocument, {
|
||||
jsonDocumentUrl: 'api/docs-json',
|
||||
});
|
||||
|
||||
// 启动 HTTP 服务。
|
||||
await app.listen(process.env.PORT ?? 3000);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
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 {}
|
||||
@ -1,17 +1,26 @@
|
||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, 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 { PatientService } from '../patient/patient.service.js';
|
||||
import { BPatientsService } from './b-patients.service.js';
|
||||
|
||||
/**
|
||||
* B 端患者控制器:院内可见性隔离查询。
|
||||
*/
|
||||
@ApiTags('患者管理(B端)')
|
||||
@ApiBearerAuth('bearer')
|
||||
@Controller('b/patients')
|
||||
@UseGuards(AccessTokenGuard, RolesGuard)
|
||||
export class BPatientsController {
|
||||
constructor(private readonly patientService: PatientService) {}
|
||||
constructor(private readonly patientsService: BPatientsService) {}
|
||||
|
||||
/**
|
||||
* 按角色返回可见患者列表。
|
||||
*/
|
||||
@Get()
|
||||
@Roles(
|
||||
Role.SYSTEM_ADMIN,
|
||||
@ -20,6 +29,12 @@ export class BPatientsController {
|
||||
Role.LEADER,
|
||||
Role.DOCTOR,
|
||||
)
|
||||
@ApiOperation({ summary: '按角色查询可见患者列表' })
|
||||
@ApiQuery({
|
||||
name: 'hospitalId',
|
||||
required: false,
|
||||
description: '系统管理员可显式指定医院',
|
||||
})
|
||||
findVisiblePatients(
|
||||
@CurrentActor() actor: ActorContext,
|
||||
@Query('hospitalId') hospitalId?: string,
|
||||
@ -27,6 +42,6 @@ export class BPatientsController {
|
||||
const requestedHospitalId =
|
||||
hospitalId == null || hospitalId === '' ? undefined : Number(hospitalId);
|
||||
|
||||
return this.patientService.findPatientsForB(actor, requestedHospitalId);
|
||||
return this.patientsService.findVisiblePatients(actor, requestedHospitalId);
|
||||
}
|
||||
}
|
||||
|
||||
90
src/patients/b-patients/b-patients.service.ts
Normal file
90
src/patients/b-patients/b-patients.service.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
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';
|
||||
|
||||
/**
|
||||
* B 端患者服务:承载院内可见性隔离查询。
|
||||
*/
|
||||
@Injectable()
|
||||
export class BPatientsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* B 端查询:根据角色自动限制可见患者范围。
|
||||
*/
|
||||
async findVisiblePatients(actor: ActorContext, requestedHospitalId?: number) {
|
||||
const hospitalId = this.resolveHospitalId(actor, requestedHospitalId);
|
||||
|
||||
// 患者仅绑定 doctorId/hospitalId,角色可见性通过关联 doctor 的当前组织归属反查。
|
||||
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 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' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 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;
|
||||
}
|
||||
}
|
||||
@ -1,14 +1,25 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { PatientService } from '../patient/patient.service.js';
|
||||
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 patientService: PatientService) {}
|
||||
constructor(private readonly patientsService: CPatientsService) {}
|
||||
|
||||
/**
|
||||
* 根据手机号和身份证哈希查询跨院生命周期。
|
||||
*/
|
||||
@Get('lifecycle')
|
||||
@ApiOperation({ summary: '跨院患者生命周期查询' })
|
||||
@ApiQuery({ name: 'phone', description: '手机号' })
|
||||
@ApiQuery({ name: 'idCardHash', description: '身份证哈希' })
|
||||
getLifecycle(@Query() query: FamilyLifecycleQueryDto) {
|
||||
return this.patientService.getFamilyLifecycleByIdentity(
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -1,4 +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;
|
||||
}
|
||||
|
||||
@ -1,154 +0,0 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Role } from '../../generated/prisma/enums.js';
|
||||
import { PrismaService } from '../../prisma.service.js';
|
||||
import { ActorContext } from '../../common/actor-context.js';
|
||||
|
||||
@Injectable()
|
||||
export class PatientService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findPatientsForB(actor: ActorContext, requestedHospitalId?: number) {
|
||||
const hospitalId = this.resolveHospitalId(actor, requestedHospitalId);
|
||||
|
||||
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('Actor groupId is required for LEADER');
|
||||
}
|
||||
where.doctor = {
|
||||
groupId: actor.groupId,
|
||||
role: Role.DOCTOR,
|
||||
};
|
||||
break;
|
||||
case Role.DIRECTOR:
|
||||
if (!actor.departmentId) {
|
||||
throw new BadRequestException(
|
||||
'Actor departmentId is required for DIRECTOR',
|
||||
);
|
||||
}
|
||||
where.doctor = {
|
||||
departmentId: actor.departmentId,
|
||||
role: Role.DOCTOR,
|
||||
};
|
||||
break;
|
||||
case Role.HOSPITAL_ADMIN:
|
||||
case Role.SYSTEM_ADMIN:
|
||||
break;
|
||||
default:
|
||||
throw new ForbiddenException('Role cannot query B-side patient list');
|
||||
}
|
||||
|
||||
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 getFamilyLifecycleByIdentity(phone: string, idCardHash: string) {
|
||||
if (!phone || !idCardHash) {
|
||||
throw new BadRequestException('phone and idCardHash are required');
|
||||
}
|
||||
|
||||
const patients = await this.prisma.patient.findMany({
|
||||
where: {
|
||||
phone,
|
||||
idCardHash,
|
||||
},
|
||||
include: {
|
||||
hospital: { select: { id: true, name: true } },
|
||||
devices: {
|
||||
include: {
|
||||
taskItems: {
|
||||
include: {
|
||||
task: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const lifecycle = patients
|
||||
.flatMap((patient) =>
|
||||
patient.devices.flatMap((device) =>
|
||||
device.taskItems.map((taskItem) => ({
|
||||
eventType: 'TASK_PRESSURE_ADJUSTMENT',
|
||||
occurredAt: taskItem.task.createdAt,
|
||||
hospital: patient.hospital,
|
||||
patient: {
|
||||
id: patient.id,
|
||||
name: patient.name,
|
||||
phone: patient.phone,
|
||||
},
|
||||
device: {
|
||||
id: device.id,
|
||||
snCode: device.snCode,
|
||||
status: device.status,
|
||||
currentPressure: device.currentPressure,
|
||||
},
|
||||
task: {
|
||||
id: taskItem.task.id,
|
||||
status: taskItem.task.status,
|
||||
creatorId: taskItem.task.creatorId,
|
||||
engineerId: taskItem.task.engineerId,
|
||||
hospitalId: taskItem.task.hospitalId,
|
||||
createdAt: taskItem.task.createdAt,
|
||||
},
|
||||
taskItem: {
|
||||
id: taskItem.id,
|
||||
oldPressure: taskItem.oldPressure,
|
||||
targetPressure: taskItem.targetPressure,
|
||||
},
|
||||
})),
|
||||
),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
|
||||
);
|
||||
|
||||
return {
|
||||
phone,
|
||||
idCardHash,
|
||||
patientCount: patients.length,
|
||||
lifecycle,
|
||||
};
|
||||
}
|
||||
|
||||
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(
|
||||
'SYSTEM_ADMIN must pass hospitalId query parameter',
|
||||
);
|
||||
}
|
||||
return normalizedHospitalId;
|
||||
}
|
||||
|
||||
if (!actor.hospitalId) {
|
||||
throw new BadRequestException('Actor hospitalId is required');
|
||||
}
|
||||
|
||||
return actor.hospitalId;
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PatientService } from './patient/patient.service.js';
|
||||
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: [PatientService, AccessTokenGuard, RolesGuard],
|
||||
providers: [BPatientsService, CPatientsService, AccessTokenGuard, RolesGuard],
|
||||
controllers: [BPatientsController, CPatientsController],
|
||||
exports: [PatientService],
|
||||
exports: [BPatientsService, CPatientsService],
|
||||
})
|
||||
export class PatientsModule {}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
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';
|
||||
@ -11,31 +12,52 @@ import { AcceptTaskDto } from '../dto/accept-task.dto.js';
|
||||
import { CompleteTaskDto } from '../dto/complete-task.dto.js';
|
||||
import { CancelTaskDto } from '../dto/cancel-task.dto.js';
|
||||
|
||||
/**
|
||||
* B 端任务控制器:封装调压任务状态流转接口。
|
||||
*/
|
||||
@ApiTags('调压任务(B端)')
|
||||
@ApiBearerAuth('bearer')
|
||||
@Controller('b/tasks')
|
||||
@UseGuards(AccessTokenGuard, RolesGuard)
|
||||
export class BTasksController {
|
||||
constructor(private readonly taskService: TaskService) {}
|
||||
|
||||
/**
|
||||
* 医生发布调压任务。
|
||||
*/
|
||||
@Post('publish')
|
||||
@Roles(Role.DOCTOR)
|
||||
@ApiOperation({ summary: '发布任务(DOCTOR)' })
|
||||
publish(@CurrentActor() actor: ActorContext, @Body() dto: PublishTaskDto) {
|
||||
return this.taskService.publishTask(actor, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工程师接收调压任务。
|
||||
*/
|
||||
@Post('accept')
|
||||
@Roles(Role.ENGINEER)
|
||||
@ApiOperation({ summary: '接收任务(ENGINEER)' })
|
||||
accept(@CurrentActor() actor: ActorContext, @Body() dto: AcceptTaskDto) {
|
||||
return this.taskService.acceptTask(actor, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工程师完成调压任务。
|
||||
*/
|
||||
@Post('complete')
|
||||
@Roles(Role.ENGINEER)
|
||||
@ApiOperation({ summary: '完成任务(ENGINEER)' })
|
||||
complete(@CurrentActor() actor: ActorContext, @Body() dto: CompleteTaskDto) {
|
||||
return this.taskService.completeTask(actor, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 医生取消调压任务。
|
||||
*/
|
||||
@Post('cancel')
|
||||
@Roles(Role.DOCTOR)
|
||||
@ApiOperation({ summary: '取消任务(DOCTOR)' })
|
||||
cancel(@CurrentActor() actor: ActorContext, @Body() dto: CancelTaskDto) {
|
||||
return this.taskService.cancelTask(actor, dto);
|
||||
}
|
||||
|
||||
@ -1,3 +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;
|
||||
}
|
||||
|
||||
@ -1,3 +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;
|
||||
}
|
||||
|
||||
@ -1,3 +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;
|
||||
}
|
||||
|
||||
@ -1,9 +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[];
|
||||
}
|
||||
|
||||
@ -8,12 +8,16 @@ import {
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { DeviceStatus, Role, TaskStatus } from '../generated/prisma/enums.js';
|
||||
import { PrismaService } from '../prisma.service.js';
|
||||
import { ActorContext } from '../common/actor-context.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(
|
||||
@ -21,24 +25,25 @@ export class TaskService {
|
||||
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('items is required');
|
||||
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(`Invalid deviceId: ${item.deviceId}`);
|
||||
throw new BadRequestException(`deviceId 非法: ${item.deviceId}`);
|
||||
}
|
||||
if (!Number.isInteger(item.targetPressure)) {
|
||||
throw new BadRequestException(
|
||||
`Invalid targetPressure: ${item.targetPressure}`,
|
||||
);
|
||||
throw new BadRequestException(`targetPressure 非法: ${item.targetPressure}`);
|
||||
}
|
||||
return item.deviceId;
|
||||
}),
|
||||
@ -55,7 +60,7 @@ export class TaskService {
|
||||
});
|
||||
|
||||
if (devices.length !== deviceIds.length) {
|
||||
throw new NotFoundException('Some devices are not found in actor hospital');
|
||||
throw new NotFoundException(MESSAGES.TASK.DEVICE_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (dto.engineerId != null) {
|
||||
@ -68,7 +73,7 @@ export class TaskService {
|
||||
select: { id: true },
|
||||
});
|
||||
if (!engineer) {
|
||||
throw new BadRequestException('engineerId must be a valid local engineer');
|
||||
throw new BadRequestException(MESSAGES.TASK.ENGINEER_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
@ -103,6 +108,9 @@ export class TaskService {
|
||||
return task;
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收任务:工程师将任务从 PENDING 流转到 ACCEPTED。
|
||||
*/
|
||||
async acceptTask(actor: ActorContext, dto: AcceptTaskDto) {
|
||||
this.assertRole(actor, [Role.ENGINEER]);
|
||||
const hospitalId = this.requireHospitalId(actor);
|
||||
@ -121,13 +129,13 @@ export class TaskService {
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException('Task not found in actor hospital');
|
||||
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
|
||||
}
|
||||
if (task.status !== TaskStatus.PENDING) {
|
||||
throw new ConflictException('Only pending task can be accepted');
|
||||
throw new ConflictException(MESSAGES.TASK.ACCEPT_ONLY_PENDING);
|
||||
}
|
||||
if (task.engineerId != null && task.engineerId !== actor.id) {
|
||||
throw new ForbiddenException('Task already assigned to another engineer');
|
||||
throw new ForbiddenException(MESSAGES.TASK.ENGINEER_ALREADY_ASSIGNED);
|
||||
}
|
||||
|
||||
const updatedTask = await this.prisma.task.update({
|
||||
@ -149,6 +157,9 @@ export class TaskService {
|
||||
return updatedTask;
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成任务:工程师将任务置为 COMPLETED,并同步设备当前压力。
|
||||
*/
|
||||
async completeTask(actor: ActorContext, dto: CompleteTaskDto) {
|
||||
this.assertRole(actor, [Role.ENGINEER]);
|
||||
const hospitalId = this.requireHospitalId(actor);
|
||||
@ -164,13 +175,13 @@ export class TaskService {
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException('Task not found in actor hospital');
|
||||
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
|
||||
}
|
||||
if (task.status !== TaskStatus.ACCEPTED) {
|
||||
throw new ConflictException('Only accepted task can be completed');
|
||||
throw new ConflictException(MESSAGES.TASK.COMPLETE_ONLY_ACCEPTED);
|
||||
}
|
||||
if (task.engineerId !== actor.id) {
|
||||
throw new ForbiddenException('Only the assigned engineer can complete task');
|
||||
throw new ForbiddenException(MESSAGES.TASK.ENGINEER_ONLY_ASSIGNEE);
|
||||
}
|
||||
|
||||
const completedTask = await this.prisma.$transaction(async (tx) => {
|
||||
@ -202,6 +213,9 @@ export class TaskService {
|
||||
return completedTask;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消任务:创建医生可将 PENDING/ACCEPTED 任务取消。
|
||||
*/
|
||||
async cancelTask(actor: ActorContext, dto: CancelTaskDto) {
|
||||
this.assertRole(actor, [Role.DOCTOR]);
|
||||
const hospitalId = this.requireHospitalId(actor);
|
||||
@ -220,16 +234,16 @@ export class TaskService {
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
throw new NotFoundException('Task not found in actor hospital');
|
||||
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
|
||||
}
|
||||
if (task.creatorId !== actor.id) {
|
||||
throw new ForbiddenException('Only task creator can cancel task');
|
||||
throw new ForbiddenException(MESSAGES.TASK.CANCEL_ONLY_CREATOR);
|
||||
}
|
||||
if (
|
||||
task.status !== TaskStatus.PENDING &&
|
||||
task.status !== TaskStatus.ACCEPTED
|
||||
) {
|
||||
throw new ConflictException('Only pending or accepted task can be cancelled');
|
||||
throw new ConflictException(MESSAGES.TASK.CANCEL_ONLY_PENDING_ACCEPTED);
|
||||
}
|
||||
|
||||
const cancelledTask = await this.prisma.task.update({
|
||||
@ -248,15 +262,21 @@ export class TaskService {
|
||||
return cancelledTask;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验角色权限。
|
||||
*/
|
||||
private assertRole(actor: ActorContext, allowedRoles: Role[]) {
|
||||
if (!allowedRoles.includes(actor.role)) {
|
||||
throw new ForbiddenException('Actor role is not allowed');
|
||||
throw new ForbiddenException(MESSAGES.TASK.ACTOR_ROLE_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并返回 hospitalId(B 端强依赖租户隔离)。
|
||||
*/
|
||||
private requireHospitalId(actor: ActorContext): number {
|
||||
if (!actor.hospitalId) {
|
||||
throw new BadRequestException('Actor hospitalId is required');
|
||||
throw new BadRequestException(MESSAGES.TASK.ACTOR_HOSPITAL_REQUIRED);
|
||||
}
|
||||
return actor.hospitalId;
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
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';
|
||||
@ -8,13 +9,23 @@ 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,
|
||||
|
||||
@ -1,3 +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,12 +1,66 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@ -1,8 +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;
|
||||
}
|
||||
|
||||
@ -1,13 +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.js';
|
||||
|
||||
/**
|
||||
* 更新用户 DTO。
|
||||
*/
|
||||
export class UpdateUserDto extends PartialType(CreateUserDto) {}
|
||||
|
||||
@ -8,6 +8,12 @@ import {
|
||||
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';
|
||||
@ -16,37 +22,65 @@ 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')
|
||||
@UseGuards(AccessTokenGuard, RolesGuard)
|
||||
export class UsersController {
|
||||
constructor(private readonly usersService: UsersService) {}
|
||||
|
||||
/**
|
||||
* 创建用户。
|
||||
*/
|
||||
@Post()
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@ApiOperation({ summary: '创建用户' })
|
||||
create(@Body() createUserDto: CreateUserDto) {
|
||||
return this.usersService.create(createUserDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户列表。
|
||||
*/
|
||||
@Get()
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@ApiOperation({ summary: '查询用户列表' })
|
||||
findAll() {
|
||||
return this.usersService.findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户详情。
|
||||
*/
|
||||
@Get(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@ApiOperation({ summary: '查询用户详情' })
|
||||
@ApiParam({ name: 'id', description: '用户 ID' })
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.usersService.findOne(+id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户。
|
||||
*/
|
||||
@Patch(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||
@ApiOperation({ summary: '更新用户' })
|
||||
@ApiParam({ name: 'id', description: '用户 ID' })
|
||||
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
|
||||
return this.usersService.update(+id, updateUserDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户。
|
||||
*/
|
||||
@Delete(':id')
|
||||
@Roles(Role.SYSTEM_ADMIN)
|
||||
@ApiOperation({ summary: '删除用户' })
|
||||
@ApiParam({ name: 'id', description: '用户 ID' })
|
||||
remove(@Param('id') id: string) {
|
||||
return this.usersService.remove(+id);
|
||||
}
|
||||
|
||||
@ -12,10 +12,11 @@ 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 { ActorContext } from '../common/actor-context.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,
|
||||
@ -32,6 +33,9 @@ const SAFE_USER_SELECT = {
|
||||
export class UsersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* 注册账号:根据角色与组织范围进行约束,并写入 bcrypt 密码摘要。
|
||||
*/
|
||||
async register(dto: RegisterUserDto) {
|
||||
const role = this.normalizeRole(dto.role);
|
||||
const name = this.normalizeRequiredString(dto.name, 'name');
|
||||
@ -67,6 +71,9 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录:按手机号+角色(可选医院)定位账号并签发 JWT。
|
||||
*/
|
||||
async login(dto: LoginDto) {
|
||||
const role = this.normalizeRole(dto.role);
|
||||
const phone = this.normalizePhone(dto.phone);
|
||||
@ -87,22 +94,22 @@ export class UsersService {
|
||||
});
|
||||
|
||||
if (users.length === 0) {
|
||||
throw new UnauthorizedException('Invalid phone/role/password');
|
||||
throw new UnauthorizedException(MESSAGES.AUTH.INVALID_CREDENTIALS);
|
||||
}
|
||||
if (users.length > 1 && hospitalId == null) {
|
||||
throw new BadRequestException(
|
||||
'Multiple accounts found. Please specify hospitalId',
|
||||
MESSAGES.USER.MULTI_ACCOUNT_REQUIRE_HOSPITAL,
|
||||
);
|
||||
}
|
||||
|
||||
const user = users[0];
|
||||
if (!user?.passwordHash) {
|
||||
throw new UnauthorizedException('Password login is not enabled');
|
||||
throw new UnauthorizedException(MESSAGES.AUTH.PASSWORD_NOT_ENABLED);
|
||||
}
|
||||
|
||||
const matched = await compare(password, user.passwordHash);
|
||||
if (!matched) {
|
||||
throw new UnauthorizedException('Invalid phone/role/password');
|
||||
throw new UnauthorizedException(MESSAGES.AUTH.INVALID_CREDENTIALS);
|
||||
}
|
||||
|
||||
const actor: ActorContext = {
|
||||
@ -121,10 +128,16 @@ export class UsersService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录用户详情。
|
||||
*/
|
||||
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');
|
||||
@ -162,6 +175,9 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户列表。
|
||||
*/
|
||||
async findAll() {
|
||||
return this.prisma.user.findMany({
|
||||
select: SAFE_USER_SELECT,
|
||||
@ -169,6 +185,9 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户详情。
|
||||
*/
|
||||
async findOne(id: number) {
|
||||
const userId = this.normalizeRequiredInt(id, 'id');
|
||||
|
||||
@ -177,12 +196,15 @@ export class UsersService {
|
||||
select: SAFE_USER_SELECT,
|
||||
});
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
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({
|
||||
@ -193,7 +215,7 @@ export class UsersService {
|
||||
},
|
||||
});
|
||||
if (!current) {
|
||||
throw new NotFoundException('User not found');
|
||||
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
|
||||
}
|
||||
|
||||
const nextRole =
|
||||
@ -211,6 +233,13 @@ export class UsersService {
|
||||
? this.normalizeOptionalInt(updateUserDto.groupId, 'groupId')
|
||||
: current.groupId;
|
||||
|
||||
const assigningDepartmentOrGroup =
|
||||
(updateUserDto.departmentId !== undefined && nextDepartmentId != null) ||
|
||||
(updateUserDto.groupId !== undefined && nextGroupId != null);
|
||||
if (assigningDepartmentOrGroup && nextRole !== Role.DOCTOR) {
|
||||
throw new BadRequestException(MESSAGES.USER.DOCTOR_ONLY_SCOPE_CHANGE);
|
||||
}
|
||||
|
||||
await this.assertOrganizationScope(
|
||||
nextRole,
|
||||
nextHospitalId,
|
||||
@ -270,6 +299,9 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除用户。
|
||||
*/
|
||||
async remove(id: number) {
|
||||
const userId = this.normalizeRequiredInt(id, 'id');
|
||||
await this.findOne(userId);
|
||||
@ -280,18 +312,19 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 系统管理员将工程师绑定到指定医院。
|
||||
*/
|
||||
async assignEngineerHospital(
|
||||
actor: ActorContext,
|
||||
targetUserId: number,
|
||||
dto: AssignEngineerHospitalDto,
|
||||
) {
|
||||
if (actor.role !== Role.SYSTEM_ADMIN) {
|
||||
throw new ForbiddenException(
|
||||
'Only SYSTEM_ADMIN can bind engineer to hospital',
|
||||
);
|
||||
throw new ForbiddenException(MESSAGES.USER.ENGINEER_BIND_FORBIDDEN);
|
||||
}
|
||||
if (!Number.isInteger(dto.hospitalId)) {
|
||||
throw new BadRequestException('hospitalId must be an integer');
|
||||
throw new BadRequestException(MESSAGES.USER.HOSPITAL_ID_INVALID);
|
||||
}
|
||||
|
||||
const hospital = await this.prisma.hospital.findUnique({
|
||||
@ -299,7 +332,7 @@ export class UsersService {
|
||||
select: { id: true },
|
||||
});
|
||||
if (!hospital) {
|
||||
throw new NotFoundException('Hospital not found');
|
||||
throw new NotFoundException(MESSAGES.ORG.HOSPITAL_NOT_FOUND);
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
@ -307,10 +340,10 @@ export class UsersService {
|
||||
select: { id: true, role: true },
|
||||
});
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
|
||||
}
|
||||
if (user.role !== Role.ENGINEER) {
|
||||
throw new BadRequestException('Target user is not ENGINEER');
|
||||
throw new BadRequestException(MESSAGES.USER.TARGET_NOT_ENGINEER);
|
||||
}
|
||||
|
||||
return this.prisma.user.update({
|
||||
@ -324,11 +357,17 @@ export class UsersService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 去除密码摘要,避免泄露敏感信息。
|
||||
*/
|
||||
private toSafeUser(user: { passwordHash?: string | null } & Record<string, unknown>) {
|
||||
const { passwordHash, ...safe } = user;
|
||||
return safe;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验系统管理员注册引导密钥。
|
||||
*/
|
||||
private assertSystemAdminBootstrapKey(
|
||||
role: Role,
|
||||
providedBootstrapKey?: string,
|
||||
@ -339,13 +378,18 @@ export class UsersService {
|
||||
|
||||
const expectedBootstrapKey = process.env.SYSTEM_ADMIN_BOOTSTRAP_KEY;
|
||||
if (!expectedBootstrapKey) {
|
||||
throw new ForbiddenException('SYSTEM_ADMIN registration is disabled');
|
||||
throw new ForbiddenException(MESSAGES.USER.SYSTEM_ADMIN_REG_DISABLED);
|
||||
}
|
||||
if (providedBootstrapKey !== expectedBootstrapKey) {
|
||||
throw new ForbiddenException('Invalid system admin bootstrap key');
|
||||
throw new ForbiddenException(
|
||||
MESSAGES.USER.SYSTEM_ADMIN_BOOTSTRAP_KEY_INVALID,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验“手机号 + 角色 + 医院”唯一性。
|
||||
*/
|
||||
private async assertPhoneRoleScopeUnique(
|
||||
phone: string,
|
||||
role: Role,
|
||||
@ -361,12 +405,13 @@ export class UsersService {
|
||||
select: { id: true },
|
||||
});
|
||||
if (exists && exists.id !== selfId) {
|
||||
throw new ConflictException(
|
||||
'User with same phone/role/hospital already exists',
|
||||
);
|
||||
throw new ConflictException(MESSAGES.USER.DUPLICATE_PHONE_ROLE_SCOPE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 openId 唯一性。
|
||||
*/
|
||||
private async assertOpenIdUnique(openId: string | null, selfId?: number) {
|
||||
if (!openId) {
|
||||
return;
|
||||
@ -377,10 +422,13 @@ export class UsersService {
|
||||
select: { id: true },
|
||||
});
|
||||
if (exists && exists.id !== selfId) {
|
||||
throw new ConflictException('openId already registered');
|
||||
throw new ConflictException(MESSAGES.USER.DUPLICATE_OPEN_ID);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验角色与组织归属关系是否合法。
|
||||
*/
|
||||
private async assertOrganizationScope(
|
||||
role: Role,
|
||||
hospitalId: number | null,
|
||||
@ -389,15 +437,13 @@ export class UsersService {
|
||||
) {
|
||||
if (role === Role.SYSTEM_ADMIN) {
|
||||
if (hospitalId || departmentId || groupId) {
|
||||
throw new BadRequestException(
|
||||
'SYSTEM_ADMIN must not bind hospital/department/group',
|
||||
);
|
||||
throw new BadRequestException(MESSAGES.USER.SYSTEM_ADMIN_SCOPE_INVALID);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hospitalId) {
|
||||
throw new BadRequestException('hospitalId is required');
|
||||
throw new BadRequestException(MESSAGES.USER.HOSPITAL_REQUIRED);
|
||||
}
|
||||
|
||||
const hospital = await this.prisma.hospital.findUnique({
|
||||
@ -405,24 +451,22 @@ export class UsersService {
|
||||
select: { id: true },
|
||||
});
|
||||
if (!hospital) {
|
||||
throw new BadRequestException('hospitalId does not exist');
|
||||
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('departmentId is required for role');
|
||||
throw new BadRequestException(MESSAGES.USER.DEPARTMENT_REQUIRED);
|
||||
}
|
||||
|
||||
const needsGroup = role === Role.LEADER || role === Role.DOCTOR;
|
||||
if (needsGroup && !groupId) {
|
||||
throw new BadRequestException('groupId is required for role');
|
||||
throw new BadRequestException(MESSAGES.USER.GROUP_REQUIRED);
|
||||
}
|
||||
|
||||
if (role === Role.ENGINEER && (departmentId || groupId)) {
|
||||
throw new BadRequestException(
|
||||
'ENGINEER should not bind departmentId/groupId',
|
||||
);
|
||||
throw new BadRequestException(MESSAGES.USER.ENGINEER_SCOPE_INVALID);
|
||||
}
|
||||
|
||||
if (departmentId) {
|
||||
@ -431,36 +475,38 @@ export class UsersService {
|
||||
select: { id: true, hospitalId: true },
|
||||
});
|
||||
if (!department || department.hospitalId !== hospitalId) {
|
||||
throw new BadRequestException(
|
||||
'departmentId does not belong to hospitalId',
|
||||
);
|
||||
throw new BadRequestException(MESSAGES.USER.DEPARTMENT_HOSPITAL_MISMATCH);
|
||||
}
|
||||
}
|
||||
|
||||
if (groupId) {
|
||||
if (!departmentId) {
|
||||
throw new BadRequestException('groupId requires 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(
|
||||
'groupId does not belong to 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} must be an integer`);
|
||||
throw new BadRequestException(`${fieldName} 必须为整数`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可空整数标准化。
|
||||
*/
|
||||
private normalizeOptionalInt(
|
||||
value: unknown,
|
||||
fieldName: string,
|
||||
@ -471,61 +517,79 @@ export class UsersService {
|
||||
return this.normalizeRequiredInt(value, fieldName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 必填字符串标准化。
|
||||
*/
|
||||
private normalizeRequiredString(value: unknown, fieldName: string): string {
|
||||
if (typeof value !== 'string') {
|
||||
throw new BadRequestException(`${fieldName} must be a string`);
|
||||
throw new BadRequestException(`${fieldName} 必须为字符串`);
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new BadRequestException(`${fieldName} is required`);
|
||||
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('openId must be a 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('phone must be a valid CN mobile number');
|
||||
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('password must be at least 8 characters');
|
||||
throw new BadRequestException(MESSAGES.USER.INVALID_PASSWORD);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色枚举校验。
|
||||
*/
|
||||
private normalizeRole(role: unknown): Role {
|
||||
if (typeof role !== 'string') {
|
||||
throw new BadRequestException('role must be a string enum');
|
||||
throw new BadRequestException(MESSAGES.USER.INVALID_ROLE);
|
||||
}
|
||||
|
||||
if (!Object.values(Role).includes(role as Role)) {
|
||||
throw new BadRequestException(`invalid role: ${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('AUTH_TOKEN_SECRET is not configured');
|
||||
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_SECRET_MISSING);
|
||||
}
|
||||
|
||||
return jwt.sign(actor, secret, {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user