权限完善
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/event-emitter": "^3.0.1",
|
||||||
"@nestjs/mapped-types": "*",
|
"@nestjs/mapped-types": "*",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
|
"@nestjs/swagger": "^11.2.6",
|
||||||
"@prisma/adapter-pg": "^7.5.0",
|
"@prisma/adapter-pg": "^7.5.0",
|
||||||
"@prisma/client": "^7.5.0",
|
"@prisma/client": "^7.5.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.15.1",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1",
|
||||||
|
"swagger-ui-express": "^5.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^11.0.0",
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
|||||||
159
pnpm-lock.yaml
generated
159
pnpm-lock.yaml
generated
@ -10,19 +10,22 @@ importers:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common':
|
'@nestjs/common':
|
||||||
specifier: ^11.0.1
|
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':
|
'@nestjs/core':
|
||||||
specifier: ^11.0.1
|
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':
|
'@nestjs/event-emitter':
|
||||||
specifier: ^3.0.1
|
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':
|
'@nestjs/mapped-types':
|
||||||
specifier: '*'
|
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':
|
'@nestjs/platform-express':
|
||||||
specifier: ^11.0.1
|
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':
|
'@prisma/adapter-pg':
|
||||||
specifier: ^7.5.0
|
specifier: ^7.5.0
|
||||||
version: 7.5.0
|
version: 7.5.0
|
||||||
@ -32,6 +35,12 @@ importers:
|
|||||||
bcrypt:
|
bcrypt:
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 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:
|
dotenv:
|
||||||
specifier: ^17.3.1
|
specifier: ^17.3.1
|
||||||
version: 17.3.1
|
version: 17.3.1
|
||||||
@ -47,6 +56,9 @@ importers:
|
|||||||
rxjs:
|
rxjs:
|
||||||
specifier: ^7.8.1
|
specifier: ^7.8.1
|
||||||
version: 7.8.2
|
version: 7.8.2
|
||||||
|
swagger-ui-express:
|
||||||
|
specifier: ^5.0.1
|
||||||
|
version: 5.0.1(express@5.2.1)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@nestjs/cli':
|
'@nestjs/cli':
|
||||||
specifier: ^11.0.0
|
specifier: ^11.0.0
|
||||||
@ -56,7 +68,7 @@ importers:
|
|||||||
version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3)
|
version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3)
|
||||||
'@nestjs/testing':
|
'@nestjs/testing':
|
||||||
specifier: ^11.0.1
|
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':
|
'@types/bcrypt':
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.0.0
|
version: 6.0.0
|
||||||
@ -350,6 +362,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
|
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
'@microsoft/tsdoc@0.16.0':
|
||||||
|
resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==}
|
||||||
|
|
||||||
'@mrleebo/prisma-ast@0.13.1':
|
'@mrleebo/prisma-ast@0.13.1':
|
||||||
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
|
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
@ -428,6 +443,23 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '>=4.8.2'
|
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':
|
'@nestjs/testing@11.1.16':
|
||||||
resolution: {integrity: sha512-E7/aUCxzeMSJV80L5GWGIuiMyR/1ncS7uOIetAImfbS4ATE1/h2GBafk0qpk+vjFtPIbtoh9BWDGICzUEU5jDA==}
|
resolution: {integrity: sha512-E7/aUCxzeMSJV80L5GWGIuiMyR/1ncS7uOIetAImfbS4ATE1/h2GBafk0qpk+vjFtPIbtoh9BWDGICzUEU5jDA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -514,6 +546,9 @@ packages:
|
|||||||
react: ^18.0.0 || ^19.0.0
|
react: ^18.0.0 || ^19.0.0
|
||||||
react-dom: ^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':
|
'@standard-schema/spec@1.1.0':
|
||||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||||
|
|
||||||
@ -605,6 +640,9 @@ packages:
|
|||||||
'@types/supertest@6.0.3':
|
'@types/supertest@6.0.3':
|
||||||
resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==}
|
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':
|
'@webassemblyjs/ast@1.14.1':
|
||||||
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
|
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
|
||||||
|
|
||||||
@ -854,6 +892,12 @@ packages:
|
|||||||
citty@0.2.1:
|
citty@0.2.1:
|
||||||
resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==}
|
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:
|
cli-cursor@3.1.0:
|
||||||
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
|
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -1339,6 +1383,9 @@ packages:
|
|||||||
jws@4.0.1:
|
jws@4.0.1:
|
||||||
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
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:
|
lilconfig@2.1.0:
|
||||||
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
|
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@ -1919,6 +1966,18 @@ packages:
|
|||||||
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
|
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
|
||||||
engines: {node: '>=10'}
|
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:
|
symbol-observable@4.0.0:
|
||||||
resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==}
|
resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==}
|
||||||
engines: {node: '>=0.10'}
|
engines: {node: '>=0.10'}
|
||||||
@ -2054,6 +2113,10 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
validator@13.15.26:
|
||||||
|
resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==}
|
||||||
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
vary@1.1.2:
|
vary@1.1.2:
|
||||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@ -2382,6 +2445,8 @@ snapshots:
|
|||||||
|
|
||||||
'@lukeed/csprng@1.1.0': {}
|
'@lukeed/csprng@1.1.0': {}
|
||||||
|
|
||||||
|
'@microsoft/tsdoc@0.16.0': {}
|
||||||
|
|
||||||
'@mrleebo/prisma-ast@0.13.1':
|
'@mrleebo/prisma-ast@0.13.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
chevrotain: 10.5.0
|
chevrotain: 10.5.0
|
||||||
@ -2413,7 +2478,7 @@ snapshots:
|
|||||||
- uglify-js
|
- uglify-js
|
||||||
- webpack-cli
|
- 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:
|
dependencies:
|
||||||
file-type: 21.3.0
|
file-type: 21.3.0
|
||||||
iterare: 1.2.1
|
iterare: 1.2.1
|
||||||
@ -2422,12 +2487,15 @@ snapshots:
|
|||||||
rxjs: 7.8.2
|
rxjs: 7.8.2
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
uid: 2.0.2
|
uid: 2.0.2
|
||||||
|
optionalDependencies:
|
||||||
|
class-transformer: 0.5.1
|
||||||
|
class-validator: 0.15.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- 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:
|
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
|
'@nuxt/opencollective': 0.4.1
|
||||||
fast-safe-stringify: 2.1.1
|
fast-safe-stringify: 2.1.1
|
||||||
iterare: 1.2.1
|
iterare: 1.2.1
|
||||||
@ -2437,23 +2505,26 @@ snapshots:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
uid: 2.0.2
|
uid: 2.0.2
|
||||||
optionalDependencies:
|
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:
|
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)
|
||||||
'@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)
|
||||||
eventemitter2: 6.4.9
|
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:
|
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
|
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:
|
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)
|
||||||
'@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)
|
||||||
cors: 2.8.6
|
cors: 2.8.6
|
||||||
express: 5.2.1
|
express: 5.2.1
|
||||||
multer: 2.1.1
|
multer: 2.1.1
|
||||||
@ -2473,13 +2544,28 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- chokidar
|
- 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:
|
dependencies:
|
||||||
'@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@microsoft/tsdoc': 0.16.0
|
||||||
'@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)
|
||||||
|
'@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
|
tslib: 2.8.1
|
||||||
optionalDependencies:
|
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': {}
|
'@noble/hashes@1.8.0': {}
|
||||||
|
|
||||||
@ -2581,6 +2667,8 @@ snapshots:
|
|||||||
react: 19.2.4
|
react: 19.2.4
|
||||||
react-dom: 19.2.4(react@19.2.4)
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
|
||||||
|
'@scarf/scarf@1.4.0': {}
|
||||||
|
|
||||||
'@standard-schema/spec@1.1.0': {}
|
'@standard-schema/spec@1.1.0': {}
|
||||||
|
|
||||||
'@tokenizer/inflate@0.4.1':
|
'@tokenizer/inflate@0.4.1':
|
||||||
@ -2692,6 +2780,8 @@ snapshots:
|
|||||||
'@types/methods': 1.1.4
|
'@types/methods': 1.1.4
|
||||||
'@types/superagent': 8.1.9
|
'@types/superagent': 8.1.9
|
||||||
|
|
||||||
|
'@types/validator@13.15.10': {}
|
||||||
|
|
||||||
'@webassemblyjs/ast@1.14.1':
|
'@webassemblyjs/ast@1.14.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@webassemblyjs/helper-numbers': 1.13.2
|
'@webassemblyjs/helper-numbers': 1.13.2
|
||||||
@ -2975,6 +3065,14 @@ snapshots:
|
|||||||
|
|
||||||
citty@0.2.1: {}
|
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:
|
cli-cursor@3.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
restore-cursor: 3.1.0
|
restore-cursor: 3.1.0
|
||||||
@ -3454,6 +3552,8 @@ snapshots:
|
|||||||
jwa: 2.0.1
|
jwa: 2.0.1
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
|
libphonenumber-js@1.12.39: {}
|
||||||
|
|
||||||
lilconfig@2.1.0: {}
|
lilconfig@2.1.0: {}
|
||||||
|
|
||||||
lines-and-columns@1.2.4: {}
|
lines-and-columns@1.2.4: {}
|
||||||
@ -4002,6 +4102,19 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
has-flag: 4.0.0
|
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: {}
|
symbol-observable@4.0.0: {}
|
||||||
|
|
||||||
tapable@2.3.0: {}
|
tapable@2.3.0: {}
|
||||||
@ -4123,6 +4236,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
validator@13.15.26: {}
|
||||||
|
|
||||||
vary@1.1.2: {}
|
vary@1.1.2: {}
|
||||||
|
|
||||||
watchpack@2.5.1:
|
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"
|
provider = "postgresql"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 角色枚举:用于鉴权与数据可见性控制。
|
||||||
enum Role {
|
enum Role {
|
||||||
SYSTEM_ADMIN
|
SYSTEM_ADMIN
|
||||||
HOSPITAL_ADMIN
|
HOSPITAL_ADMIN
|
||||||
@ -16,11 +17,13 @@ enum Role {
|
|||||||
ENGINEER
|
ENGINEER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设备状态枚举:表示设备是否处于使用中。
|
||||||
enum DeviceStatus {
|
enum DeviceStatus {
|
||||||
ACTIVE
|
ACTIVE
|
||||||
INACTIVE
|
INACTIVE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 任务状态枚举:定义任务流转状态机。
|
||||||
enum TaskStatus {
|
enum TaskStatus {
|
||||||
PENDING
|
PENDING
|
||||||
ACCEPTED
|
ACCEPTED
|
||||||
@ -28,6 +31,7 @@ enum TaskStatus {
|
|||||||
CANCELLED
|
CANCELLED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 医院主表:多租户顶层实体。
|
||||||
model Hospital {
|
model Hospital {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
@ -37,6 +41,7 @@ model Hospital {
|
|||||||
tasks Task[]
|
tasks Task[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 科室表:归属于医院。
|
||||||
model Department {
|
model Department {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
@ -48,6 +53,7 @@ model Department {
|
|||||||
@@index([hospitalId])
|
@@index([hospitalId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 小组表:归属于科室。
|
||||||
model Group {
|
model Group {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
@ -58,11 +64,12 @@ model Group {
|
|||||||
@@index([departmentId])
|
@@index([departmentId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 用户表:支持后台密码登录与小程序 openId。
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
phone String
|
phone String
|
||||||
// Backend login password hash (bcrypt).
|
// 后台登录密码哈希(bcrypt)。
|
||||||
passwordHash String?
|
passwordHash String?
|
||||||
openId String? @unique
|
openId String? @unique
|
||||||
role Role
|
role Role
|
||||||
@ -82,6 +89,7 @@ model User {
|
|||||||
@@index([groupId, role])
|
@@index([groupId, role])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 患者表:院内患者档案,按医院隔离。
|
||||||
model Patient {
|
model Patient {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
@ -97,6 +105,7 @@ model Patient {
|
|||||||
@@index([hospitalId, doctorId])
|
@@index([hospitalId, doctorId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设备表:患者可绑定多个分流设备。
|
||||||
model Device {
|
model Device {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
snCode String @unique
|
snCode String @unique
|
||||||
@ -109,6 +118,7 @@ model Device {
|
|||||||
@@index([patientId, status])
|
@@index([patientId, status])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 主任务表:记录调压任务主单。
|
||||||
model Task {
|
model Task {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
status TaskStatus @default(PENDING)
|
status TaskStatus @default(PENDING)
|
||||||
@ -124,6 +134,7 @@ model Task {
|
|||||||
@@index([hospitalId, status, createdAt])
|
@@index([hospitalId, status, createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 任务明细表:一个任务可包含多个设备调压项。
|
||||||
model TaskItem {
|
model TaskItem {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
taskId Int
|
taskId Int
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import { UsersModule } from './users/users.module.js';
|
|||||||
import { TasksModule } from './tasks/tasks.module.js';
|
import { TasksModule } from './tasks/tasks.module.js';
|
||||||
import { PatientsModule } from './patients/patients.module.js';
|
import { PatientsModule } from './patients/patients.module.js';
|
||||||
import { AuthModule } from './auth/auth.module.js';
|
import { AuthModule } from './auth/auth.module.js';
|
||||||
|
import { OrganizationModule } from './organization/organization.module.js';
|
||||||
|
import { NotificationsModule } from './notifications/notifications.module.js';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -14,6 +16,8 @@ import { AuthModule } from './auth/auth.module.js';
|
|||||||
TasksModule,
|
TasksModule,
|
||||||
PatientsModule,
|
PatientsModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
OrganizationModule,
|
||||||
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@ -6,10 +6,17 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { Role } from '../generated/prisma/enums.js';
|
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()
|
@Injectable()
|
||||||
export class AccessTokenGuard implements CanActivate {
|
export class AccessTokenGuard implements CanActivate {
|
||||||
|
/**
|
||||||
|
* 守卫入口:认证通过返回 true,失败抛出 401。
|
||||||
|
*/
|
||||||
canActivate(context: ExecutionContext): boolean {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
const request = context.switchToHttp().getRequest<
|
const request = context.switchToHttp().getRequest<
|
||||||
{
|
{
|
||||||
@ -24,7 +31,7 @@ export class AccessTokenGuard implements CanActivate {
|
|||||||
: authorization;
|
: authorization;
|
||||||
|
|
||||||
if (!headerValue || !headerValue.startsWith('Bearer ')) {
|
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();
|
const token = headerValue.slice('Bearer '.length).trim();
|
||||||
@ -33,10 +40,13 @@ export class AccessTokenGuard implements CanActivate {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析并验证 token,同时提取最小化 actor 上下文。
|
||||||
|
*/
|
||||||
private verifyAndExtractActor(token: string): ActorContext {
|
private verifyAndExtractActor(token: string): ActorContext {
|
||||||
const secret = process.env.AUTH_TOKEN_SECRET;
|
const secret = process.env.AUTH_TOKEN_SECRET;
|
||||||
if (!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;
|
let payload: string | jwt.JwtPayload;
|
||||||
@ -46,16 +56,16 @@ export class AccessTokenGuard implements CanActivate {
|
|||||||
issuer: 'tyt-api-nest',
|
issuer: 'tyt-api-nest',
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new UnauthorizedException('Invalid or expired token');
|
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_INVALID);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof payload !== 'object') {
|
if (typeof payload !== 'object') {
|
||||||
throw new UnauthorizedException('Invalid token payload');
|
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_PAYLOAD_INVALID);
|
||||||
}
|
}
|
||||||
|
|
||||||
const role = payload.role;
|
const role = payload.role;
|
||||||
if (typeof role !== 'string' || !Object.values(Role).includes(role as 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 {
|
return {
|
||||||
@ -67,19 +77,25 @@ export class AccessTokenGuard implements CanActivate {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 严格校验 token 中必须为整数的字段。
|
||||||
|
*/
|
||||||
private asInt(value: unknown, field: string): number {
|
private asInt(value: unknown, field: string): number {
|
||||||
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
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;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 严格校验 token 中可空整数的字段。
|
||||||
|
*/
|
||||||
private asNullableInt(value: unknown, field: string): number | null {
|
private asNullableInt(value: unknown, field: string): number | null {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (typeof value !== 'number' || !Number.isInteger(value)) {
|
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;
|
return value;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { AuthService } from './auth.service.js';
|
import { AuthService } from './auth.service.js';
|
||||||
import { RegisterUserDto } from '../users/dto/register-user.dto.js';
|
import { RegisterUserDto } from '../users/dto/register-user.dto.js';
|
||||||
import { LoginDto } from '../users/dto/login.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 { CurrentActor } from './current-actor.decorator.js';
|
||||||
import type { ActorContext } from '../common/actor-context.js';
|
import type { ActorContext } from '../common/actor-context.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证控制器:提供注册、登录、获取当前登录用户信息接口。
|
||||||
|
*/
|
||||||
|
@ApiTags('认证')
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
constructor(private readonly authService: AuthService) {}
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册账号。
|
||||||
|
*/
|
||||||
@Post('register')
|
@Post('register')
|
||||||
|
@ApiOperation({ summary: '注册账号' })
|
||||||
register(@Body() dto: RegisterUserDto) {
|
register(@Body() dto: RegisterUserDto) {
|
||||||
return this.authService.register(dto);
|
return this.authService.register(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录并换取 JWT。
|
||||||
|
*/
|
||||||
@Post('login')
|
@Post('login')
|
||||||
|
@ApiOperation({ summary: '登录' })
|
||||||
login(@Body() dto: LoginDto) {
|
login(@Body() dto: LoginDto) {
|
||||||
return this.authService.login(dto);
|
return this.authService.login(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前登录用户信息。
|
||||||
|
*/
|
||||||
@Get('me')
|
@Get('me')
|
||||||
@UseGuards(AccessTokenGuard)
|
@UseGuards(AccessTokenGuard)
|
||||||
|
@ApiBearerAuth('bearer')
|
||||||
|
@ApiOperation({ summary: '获取当前用户信息' })
|
||||||
me(@CurrentActor() actor: ActorContext) {
|
me(@CurrentActor() actor: ActorContext) {
|
||||||
return this.authService.me(actor);
|
return this.authService.me(actor);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import { AuthController } from './auth.controller.js';
|
|||||||
import { UsersModule } from '../users/users.module.js';
|
import { UsersModule } from '../users/users.module.js';
|
||||||
import { AccessTokenGuard } from './access-token.guard.js';
|
import { AccessTokenGuard } from './access-token.guard.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证模块:聚合认证控制器、服务与基础鉴权守卫。
|
||||||
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [UsersModule],
|
imports: [UsersModule],
|
||||||
providers: [AuthService, AccessTokenGuard],
|
providers: [AuthService, AccessTokenGuard],
|
||||||
|
|||||||
@ -1,21 +1,33 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
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 { UsersService } from '../users/users.service.js';
|
||||||
import { RegisterUserDto } from '../users/dto/register-user.dto.js';
|
import { RegisterUserDto } from '../users/dto/register-user.dto.js';
|
||||||
import { LoginDto } from '../users/dto/login.dto.js';
|
import { LoginDto } from '../users/dto/login.dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证服务:将控制层输入转发到用户域能力,避免控制器直接操作用户仓储。
|
||||||
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
constructor(private readonly usersService: UsersService) {}
|
constructor(private readonly usersService: UsersService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册能力委托给用户服务。
|
||||||
|
*/
|
||||||
register(dto: RegisterUserDto) {
|
register(dto: RegisterUserDto) {
|
||||||
return this.usersService.register(dto);
|
return this.usersService.register(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录能力委托给用户服务。
|
||||||
|
*/
|
||||||
login(dto: LoginDto) {
|
login(dto: LoginDto) {
|
||||||
return this.usersService.login(dto);
|
return this.usersService.login(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取当前登录用户详情。
|
||||||
|
*/
|
||||||
me(actor: ActorContext) {
|
me(actor: ActorContext) {
|
||||||
return this.usersService.me(actor);
|
return this.usersService.me(actor);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
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(
|
export const CurrentActor = createParamDecorator(
|
||||||
(_data: unknown, context: ExecutionContext): ActorContext => {
|
(_data: unknown, context: ExecutionContext): ActorContext => {
|
||||||
const request = context.switchToHttp().getRequest<{ actor: ActorContext }>();
|
const request = context.switchToHttp().getRequest<{ actor: ActorContext }>();
|
||||||
|
|||||||
@ -2,4 +2,8 @@ import { SetMetadata } from '@nestjs/common';
|
|||||||
import { Role } from '../generated/prisma/enums.js';
|
import { Role } from '../generated/prisma/enums.js';
|
||||||
|
|
||||||
export const ROLES_KEY = 'roles';
|
export const ROLES_KEY = 'roles';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色装饰器:给路由声明允许访问的角色集合。
|
||||||
|
*/
|
||||||
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
|
||||||
|
|||||||
@ -7,11 +7,18 @@ import {
|
|||||||
import { Reflector } from '@nestjs/core';
|
import { Reflector } from '@nestjs/core';
|
||||||
import { Role } from '../generated/prisma/enums.js';
|
import { Role } from '../generated/prisma/enums.js';
|
||||||
import { ROLES_KEY } from './roles.decorator.js';
|
import { ROLES_KEY } from './roles.decorator.js';
|
||||||
|
import { MESSAGES } from '../common/messages.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色守卫:读取 @Roles 元数据并校验当前登录角色是否可访问。
|
||||||
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RolesGuard implements CanActivate {
|
export class RolesGuard implements CanActivate {
|
||||||
constructor(private readonly reflector: Reflector) {}
|
constructor(private readonly reflector: Reflector) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 守卫入口:认证后执行授权校验。
|
||||||
|
*/
|
||||||
canActivate(context: ExecutionContext): boolean {
|
canActivate(context: ExecutionContext): boolean {
|
||||||
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
|
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
|
||||||
context.getHandler(),
|
context.getHandler(),
|
||||||
@ -25,7 +32,7 @@ export class RolesGuard implements CanActivate {
|
|||||||
const request = context.switchToHttp().getRequest<{ actor?: { role?: Role } }>();
|
const request = context.switchToHttp().getRequest<{ actor?: { role?: Role } }>();
|
||||||
const actorRole = request.actor?.role;
|
const actorRole = request.actor?.role;
|
||||||
if (!actorRole || !requiredRoles.includes(actorRole)) {
|
if (!actorRole || !requiredRoles.includes(actorRole)) {
|
||||||
throw new ForbiddenException('Role is not allowed for this endpoint');
|
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
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 'dotenv/config';
|
||||||
|
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||||
import { AppModule } from './app.module.js';
|
import { AppModule } from './app.module.js';
|
||||||
|
import { HttpExceptionFilter } from './common/http-exception.filter.js';
|
||||||
|
import { MESSAGES } from './common/messages.js';
|
||||||
|
import { ResponseEnvelopeInterceptor } from './common/response-envelope.interceptor.js';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
|
// 创建应用实例并加载核心模块。
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
|
|
||||||
|
// 全局校验:清理未知字段、自动类型转换,并将校验错误统一为中文信息。
|
||||||
|
app.useGlobalPipes(
|
||||||
|
new ValidationPipe({
|
||||||
|
whitelist: true,
|
||||||
|
transform: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
exceptionFactory: (errors) => {
|
||||||
|
const messages = errors
|
||||||
|
.flatMap((error) => Object.values(error.constraints ?? {}))
|
||||||
|
.filter((item): item is string => Boolean(item));
|
||||||
|
return new BadRequestException(
|
||||||
|
messages.length > 0
|
||||||
|
? messages.join(';')
|
||||||
|
: MESSAGES.DEFAULT_BAD_REQUEST,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 全局异常与成功响应统一格式化。
|
||||||
|
app.useGlobalFilters(new HttpExceptionFilter());
|
||||||
|
app.useGlobalInterceptors(new ResponseEnvelopeInterceptor());
|
||||||
|
|
||||||
|
// Swagger 文档:提供在线调试与 OpenAPI JSON 导出。
|
||||||
|
const swaggerConfig = new DocumentBuilder()
|
||||||
|
.setTitle('TYT 多租户医疗调压系统 API')
|
||||||
|
.setDescription('后端接口文档(含认证、RBAC、任务流转与患者聚合)')
|
||||||
|
.setVersion('1.0.0')
|
||||||
|
.addServer('http://localhost:3000', 'localhost')
|
||||||
|
.addBearerAuth(
|
||||||
|
{
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
bearerFormat: 'JWT',
|
||||||
|
description: '在此输入登录接口返回的 accessToken',
|
||||||
|
},
|
||||||
|
'bearer',
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig);
|
||||||
|
SwaggerModule.setup('api/docs', app, swaggerDocument, {
|
||||||
|
jsonDocumentUrl: 'api/docs-json',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 启动 HTTP 服务。
|
||||||
await app.listen(process.env.PORT ?? 3000);
|
await app.listen(process.env.PORT ?? 3000);
|
||||||
}
|
}
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
12
src/notifications/notifications.module.ts
Normal file
12
src/notifications/notifications.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { WechatNotifyService } from './wechat-notify/wechat-notify.service.js';
|
||||||
|
import { TaskEventsListener } from './task-events.listener/task-events.listener.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知模块:承载事件监听与微信消息推送能力。
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
providers: [WechatNotifyService, TaskEventsListener],
|
||||||
|
exports: [WechatNotifyService],
|
||||||
|
})
|
||||||
|
export class NotificationsModule {}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
import { PrismaService } from '../../prisma.service.js';
|
||||||
|
import { WechatNotifyService } from '../wechat-notify/wechat-notify.service.js';
|
||||||
|
|
||||||
|
interface TaskEventPayload {
|
||||||
|
taskId: number;
|
||||||
|
hospitalId: number;
|
||||||
|
actorId: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务事件监听器:订阅任务状态变化并触发微信推送。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class TaskEventsListener {
|
||||||
|
private readonly logger = new Logger(TaskEventsListener.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly wechatNotifyService: WechatNotifyService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务发布事件:通知创建医生与指定工程师(如有)。
|
||||||
|
*/
|
||||||
|
@OnEvent('task.published', { async: true })
|
||||||
|
async onTaskPublished(payload: TaskEventPayload) {
|
||||||
|
await this.dispatchTaskEvent('task.published', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务接收事件。
|
||||||
|
*/
|
||||||
|
@OnEvent('task.accepted', { async: true })
|
||||||
|
async onTaskAccepted(payload: TaskEventPayload) {
|
||||||
|
await this.dispatchTaskEvent('task.accepted', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务完成事件。
|
||||||
|
*/
|
||||||
|
@OnEvent('task.completed', { async: true })
|
||||||
|
async onTaskCompleted(payload: TaskEventPayload) {
|
||||||
|
await this.dispatchTaskEvent('task.completed', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务取消事件。
|
||||||
|
*/
|
||||||
|
@OnEvent('task.cancelled', { async: true })
|
||||||
|
async onTaskCancelled(payload: TaskEventPayload) {
|
||||||
|
await this.dispatchTaskEvent('task.cancelled', payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一处理任务事件并派发通知目标。
|
||||||
|
*/
|
||||||
|
private async dispatchTaskEvent(event: string, payload: TaskEventPayload) {
|
||||||
|
const task = await this.prisma.task.findUnique({
|
||||||
|
where: { id: payload.taskId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
creator: { select: { id: true, openId: true } },
|
||||||
|
engineer: { select: { id: true, openId: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
this.logger.warn(`任务事件监听未找到任务:taskId=${payload.taskId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.wechatNotifyService.notifyTaskChange(
|
||||||
|
[task.creator.openId, task.engineer?.openId],
|
||||||
|
{
|
||||||
|
event,
|
||||||
|
taskId: payload.taskId,
|
||||||
|
hospitalId: payload.hospitalId,
|
||||||
|
actorId: payload.actorId,
|
||||||
|
status: payload.status,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/notifications/wechat-notify/wechat-notify.service.ts
Normal file
41
src/notifications/wechat-notify/wechat-notify.service.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
export interface TaskNotifyPayload {
|
||||||
|
event: string;
|
||||||
|
taskId: number;
|
||||||
|
hospitalId: number;
|
||||||
|
actorId: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WechatNotifyService {
|
||||||
|
private readonly logger = new Logger(WechatNotifyService.name);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务通知发送入口:后续可在此接入微信服务号/小程序订阅消息 API。
|
||||||
|
*/
|
||||||
|
async notifyTaskChange(openIds: Array<string | null | undefined>, payload: TaskNotifyPayload) {
|
||||||
|
const targets = Array.from(
|
||||||
|
new Set(
|
||||||
|
openIds
|
||||||
|
.map((item) => item?.trim())
|
||||||
|
.filter((item): item is string => Boolean(item)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (targets.length === 0) {
|
||||||
|
this.logger.warn(
|
||||||
|
`任务事件 ${payload.event} 无可用 openId,taskId=${payload.taskId}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const openId of targets) {
|
||||||
|
// TODO: 在此处调用微信服务号/小程序消息推送 API。
|
||||||
|
this.logger.log(
|
||||||
|
`模拟推送任务通知 event=${payload.event}, taskId=${payload.taskId}, openId=${openId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/organization-common/dto/organization-query.dto.ts
Normal file
51
src/organization-common/dto/organization-query.dto.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
|
||||||
|
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组织查询 DTO:用于医院/科室/小组列表筛选与分页。
|
||||||
|
*/
|
||||||
|
export class OrganizationQueryDto {
|
||||||
|
@ApiPropertyOptional({ description: '关键词(按名称模糊匹配)', example: '神经' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'keyword 必须是字符串' })
|
||||||
|
keyword?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '医院 ID', example: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@EmptyStringToUndefined()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'hospitalId 必须是整数' })
|
||||||
|
@Min(1, { message: 'hospitalId 必须大于 0' })
|
||||||
|
hospitalId?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '科室 ID', example: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@EmptyStringToUndefined()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'departmentId 必须是整数' })
|
||||||
|
@Min(1, { message: 'departmentId 必须大于 0' })
|
||||||
|
departmentId?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '页码(默认 1)', example: 1, default: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@EmptyStringToUndefined()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'page 必须是整数' })
|
||||||
|
@Min(1, { message: 'page 最小为 1' })
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '每页数量(默认 20,最大 100)',
|
||||||
|
example: 20,
|
||||||
|
default: 20,
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@EmptyStringToUndefined()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'pageSize 必须是整数' })
|
||||||
|
@Min(1, { message: 'pageSize 最小为 1' })
|
||||||
|
@Max(100, { message: 'pageSize 最大为 100' })
|
||||||
|
pageSize?: number = 20;
|
||||||
|
}
|
||||||
131
src/organization-common/organization-access.service.ts
Normal file
131
src/organization-common/organization-access.service.ts
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '../generated/prisma/client.js';
|
||||||
|
import { Role } from '../generated/prisma/enums.js';
|
||||||
|
import type { ActorContext } from '../common/actor-context.js';
|
||||||
|
import { MESSAGES } from '../common/messages.js';
|
||||||
|
import { PrismaService } from '../prisma.service.js';
|
||||||
|
import type { OrganizationQueryDto } from './dto/organization-query.dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组织域通用能力服务:
|
||||||
|
* 负责角色校验、作用域校验、分页标准化和基础存在性检查。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class OrganizationAccessService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验角色是否在允许范围内。
|
||||||
|
*/
|
||||||
|
assertRole(actor: ActorContext, roles: Role[]) {
|
||||||
|
if (!roles.includes(actor.role)) {
|
||||||
|
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验系统管理员权限。
|
||||||
|
*/
|
||||||
|
assertSystemAdmin(actor: ActorContext, message: string) {
|
||||||
|
if (actor.role !== Role.SYSTEM_ADMIN) {
|
||||||
|
throw new ForbiddenException(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验院管的数据作用域限制。
|
||||||
|
*/
|
||||||
|
assertHospitalScope(actor: ActorContext, targetHospitalId: number) {
|
||||||
|
if (actor.role !== Role.HOSPITAL_ADMIN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!actor.hospitalId || actor.hospitalId !== targetHospitalId) {
|
||||||
|
throw new ForbiddenException(MESSAGES.ORG.HOSPITAL_ADMIN_SCOPE_INVALID);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页参数标准化。
|
||||||
|
*/
|
||||||
|
resolvePaging(query: OrganizationQueryDto) {
|
||||||
|
const page = query.page && query.page > 0 ? query.page : 1;
|
||||||
|
const pageSize =
|
||||||
|
query.pageSize && query.pageSize > 0 && query.pageSize <= 100
|
||||||
|
? query.pageSize
|
||||||
|
: 20;
|
||||||
|
return {
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 名称字段标准化并确保非空。
|
||||||
|
*/
|
||||||
|
normalizeName(value: string, message: string) {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
throw new BadRequestException(message);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数字参数标准化。
|
||||||
|
*/
|
||||||
|
toInt(value: unknown, message: string) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed)) {
|
||||||
|
throw new BadRequestException(message);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认医院存在。
|
||||||
|
*/
|
||||||
|
async ensureHospitalExists(id: number) {
|
||||||
|
const hospital = await this.prisma.hospital.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!hospital) {
|
||||||
|
throw new NotFoundException(MESSAGES.ORG.HOSPITAL_NOT_FOUND);
|
||||||
|
}
|
||||||
|
return hospital;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认科室存在,并返回归属医院信息。
|
||||||
|
*/
|
||||||
|
async ensureDepartmentExists(id: number) {
|
||||||
|
const department = await this.prisma.department.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true, hospitalId: true },
|
||||||
|
});
|
||||||
|
if (!department) {
|
||||||
|
throw new NotFoundException(MESSAGES.ORG.DEPARTMENT_NOT_FOUND);
|
||||||
|
}
|
||||||
|
return department;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一处理删除冲突(存在外键引用)。
|
||||||
|
*/
|
||||||
|
handleDeleteConflict(error: unknown) {
|
||||||
|
if (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
(error.code === 'P2003' || error.code === 'P2014')
|
||||||
|
) {
|
||||||
|
throw new ConflictException(MESSAGES.ORG.DELETE_CONFLICT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/organization/organization.module.ts
Normal file
12
src/organization/organization.module.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { HospitalsModule } from '../hospitals/hospitals.module.js';
|
||||||
|
import { DepartmentsModule } from '../departments/departments.module.js';
|
||||||
|
import { GroupsModule } from '../groups/groups.module.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组织域聚合模块:统一挂载医院/科室/小组三个资源模块。
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [HospitalsModule, DepartmentsModule, GroupsModule],
|
||||||
|
})
|
||||||
|
export class OrganizationModule {}
|
||||||
@ -1,17 +1,26 @@
|
|||||||
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
|
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 type { ActorContext } from '../../common/actor-context.js';
|
||||||
import { CurrentActor } from '../../auth/current-actor.decorator.js';
|
import { CurrentActor } from '../../auth/current-actor.decorator.js';
|
||||||
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
|
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
|
||||||
import { RolesGuard } from '../../auth/roles.guard.js';
|
import { RolesGuard } from '../../auth/roles.guard.js';
|
||||||
import { Roles } from '../../auth/roles.decorator.js';
|
import { Roles } from '../../auth/roles.decorator.js';
|
||||||
import { Role } from '../../generated/prisma/enums.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')
|
@Controller('b/patients')
|
||||||
@UseGuards(AccessTokenGuard, RolesGuard)
|
@UseGuards(AccessTokenGuard, RolesGuard)
|
||||||
export class BPatientsController {
|
export class BPatientsController {
|
||||||
constructor(private readonly patientService: PatientService) {}
|
constructor(private readonly patientsService: BPatientsService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按角色返回可见患者列表。
|
||||||
|
*/
|
||||||
@Get()
|
@Get()
|
||||||
@Roles(
|
@Roles(
|
||||||
Role.SYSTEM_ADMIN,
|
Role.SYSTEM_ADMIN,
|
||||||
@ -20,6 +29,12 @@ export class BPatientsController {
|
|||||||
Role.LEADER,
|
Role.LEADER,
|
||||||
Role.DOCTOR,
|
Role.DOCTOR,
|
||||||
)
|
)
|
||||||
|
@ApiOperation({ summary: '按角色查询可见患者列表' })
|
||||||
|
@ApiQuery({
|
||||||
|
name: 'hospitalId',
|
||||||
|
required: false,
|
||||||
|
description: '系统管理员可显式指定医院',
|
||||||
|
})
|
||||||
findVisiblePatients(
|
findVisiblePatients(
|
||||||
@CurrentActor() actor: ActorContext,
|
@CurrentActor() actor: ActorContext,
|
||||||
@Query('hospitalId') hospitalId?: string,
|
@Query('hospitalId') hospitalId?: string,
|
||||||
@ -27,6 +42,6 @@ export class BPatientsController {
|
|||||||
const requestedHospitalId =
|
const requestedHospitalId =
|
||||||
hospitalId == null || hospitalId === '' ? undefined : Number(hospitalId);
|
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 { 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 { FamilyLifecycleQueryDto } from '../dto/family-lifecycle-query.dto.js';
|
||||||
|
import { CPatientsService } from './c-patients.service.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* C 端患者控制器:家属跨院聚合查询。
|
||||||
|
*/
|
||||||
|
@ApiTags('患者管理(C端)')
|
||||||
@Controller('c/patients')
|
@Controller('c/patients')
|
||||||
export class CPatientsController {
|
export class CPatientsController {
|
||||||
constructor(private readonly patientService: PatientService) {}
|
constructor(private readonly patientsService: CPatientsService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据手机号和身份证哈希查询跨院生命周期。
|
||||||
|
*/
|
||||||
@Get('lifecycle')
|
@Get('lifecycle')
|
||||||
|
@ApiOperation({ summary: '跨院患者生命周期查询' })
|
||||||
|
@ApiQuery({ name: 'phone', description: '手机号' })
|
||||||
|
@ApiQuery({ name: 'idCardHash', description: '身份证哈希' })
|
||||||
getLifecycle(@Query() query: FamilyLifecycleQueryDto) {
|
getLifecycle(@Query() query: FamilyLifecycleQueryDto) {
|
||||||
return this.patientService.getFamilyLifecycleByIdentity(
|
return this.patientsService.getFamilyLifecycleByIdentity(
|
||||||
query.phone,
|
query.phone,
|
||||||
query.idCardHash,
|
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 {
|
export class FamilyLifecycleQueryDto {
|
||||||
|
@ApiProperty({ description: '手机号', example: '13800000003' })
|
||||||
|
@IsString({ message: 'phone 必须是字符串' })
|
||||||
|
@Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' })
|
||||||
phone!: string;
|
phone!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '身份证哈希值', example: 'seed-id-card-hash' })
|
||||||
|
@IsString({ message: 'idCardHash 必须是字符串' })
|
||||||
idCardHash!: string;
|
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 { Module } from '@nestjs/common';
|
||||||
import { PatientService } from './patient/patient.service.js';
|
|
||||||
import { BPatientsController } from './b-patients/b-patients.controller.js';
|
import { BPatientsController } from './b-patients/b-patients.controller.js';
|
||||||
import { CPatientsController } from './c-patients/c-patients.controller.js';
|
import { CPatientsController } from './c-patients/c-patients.controller.js';
|
||||||
import { AccessTokenGuard } from '../auth/access-token.guard.js';
|
import { AccessTokenGuard } from '../auth/access-token.guard.js';
|
||||||
import { RolesGuard } from '../auth/roles.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({
|
@Module({
|
||||||
providers: [PatientService, AccessTokenGuard, RolesGuard],
|
providers: [BPatientsService, CPatientsService, AccessTokenGuard, RolesGuard],
|
||||||
controllers: [BPatientsController, CPatientsController],
|
controllers: [BPatientsController, CPatientsController],
|
||||||
exports: [PatientService],
|
exports: [BPatientsService, CPatientsService],
|
||||||
})
|
})
|
||||||
export class PatientsModule {}
|
export class PatientsModule {}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import type { ActorContext } from '../../common/actor-context.js';
|
import type { ActorContext } from '../../common/actor-context.js';
|
||||||
import { CurrentActor } from '../../auth/current-actor.decorator.js';
|
import { CurrentActor } from '../../auth/current-actor.decorator.js';
|
||||||
import { AccessTokenGuard } from '../../auth/access-token.guard.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 { CompleteTaskDto } from '../dto/complete-task.dto.js';
|
||||||
import { CancelTaskDto } from '../dto/cancel-task.dto.js';
|
import { CancelTaskDto } from '../dto/cancel-task.dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B 端任务控制器:封装调压任务状态流转接口。
|
||||||
|
*/
|
||||||
|
@ApiTags('调压任务(B端)')
|
||||||
|
@ApiBearerAuth('bearer')
|
||||||
@Controller('b/tasks')
|
@Controller('b/tasks')
|
||||||
@UseGuards(AccessTokenGuard, RolesGuard)
|
@UseGuards(AccessTokenGuard, RolesGuard)
|
||||||
export class BTasksController {
|
export class BTasksController {
|
||||||
constructor(private readonly taskService: TaskService) {}
|
constructor(private readonly taskService: TaskService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 医生发布调压任务。
|
||||||
|
*/
|
||||||
@Post('publish')
|
@Post('publish')
|
||||||
@Roles(Role.DOCTOR)
|
@Roles(Role.DOCTOR)
|
||||||
|
@ApiOperation({ summary: '发布任务(DOCTOR)' })
|
||||||
publish(@CurrentActor() actor: ActorContext, @Body() dto: PublishTaskDto) {
|
publish(@CurrentActor() actor: ActorContext, @Body() dto: PublishTaskDto) {
|
||||||
return this.taskService.publishTask(actor, dto);
|
return this.taskService.publishTask(actor, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工程师接收调压任务。
|
||||||
|
*/
|
||||||
@Post('accept')
|
@Post('accept')
|
||||||
@Roles(Role.ENGINEER)
|
@Roles(Role.ENGINEER)
|
||||||
|
@ApiOperation({ summary: '接收任务(ENGINEER)' })
|
||||||
accept(@CurrentActor() actor: ActorContext, @Body() dto: AcceptTaskDto) {
|
accept(@CurrentActor() actor: ActorContext, @Body() dto: AcceptTaskDto) {
|
||||||
return this.taskService.acceptTask(actor, dto);
|
return this.taskService.acceptTask(actor, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工程师完成调压任务。
|
||||||
|
*/
|
||||||
@Post('complete')
|
@Post('complete')
|
||||||
@Roles(Role.ENGINEER)
|
@Roles(Role.ENGINEER)
|
||||||
|
@ApiOperation({ summary: '完成任务(ENGINEER)' })
|
||||||
complete(@CurrentActor() actor: ActorContext, @Body() dto: CompleteTaskDto) {
|
complete(@CurrentActor() actor: ActorContext, @Body() dto: CompleteTaskDto) {
|
||||||
return this.taskService.completeTask(actor, dto);
|
return this.taskService.completeTask(actor, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 医生取消调压任务。
|
||||||
|
*/
|
||||||
@Post('cancel')
|
@Post('cancel')
|
||||||
@Roles(Role.DOCTOR)
|
@Roles(Role.DOCTOR)
|
||||||
|
@ApiOperation({ summary: '取消任务(DOCTOR)' })
|
||||||
cancel(@CurrentActor() actor: ActorContext, @Body() dto: CancelTaskDto) {
|
cancel(@CurrentActor() actor: ActorContext, @Body() dto: CancelTaskDto) {
|
||||||
return this.taskService.cancelTask(actor, dto);
|
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 {
|
export class AcceptTaskDto {
|
||||||
|
@ApiProperty({ description: '任务 ID', example: 1 })
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'taskId 必须是整数' })
|
||||||
|
@Min(1, { message: 'taskId 必须大于 0' })
|
||||||
taskId!: number;
|
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 {
|
export class CancelTaskDto {
|
||||||
|
@ApiProperty({ description: '任务 ID', example: 1 })
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'taskId 必须是整数' })
|
||||||
|
@Min(1, { message: 'taskId 必须大于 0' })
|
||||||
taskId!: number;
|
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 {
|
export class CompleteTaskDto {
|
||||||
|
@ApiProperty({ description: '任务 ID', example: 1 })
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'taskId 必须是整数' })
|
||||||
|
@Min(1, { message: 'taskId 必须大于 0' })
|
||||||
taskId!: number;
|
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 {
|
export class PublishTaskItemDto {
|
||||||
|
@ApiProperty({ description: '设备 ID', example: 1 })
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'deviceId 必须是整数' })
|
||||||
|
@Min(1, { message: 'deviceId 必须大于 0' })
|
||||||
deviceId!: number;
|
deviceId!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '目标压力值', example: 120 })
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'targetPressure 必须是整数' })
|
||||||
targetPressure!: number;
|
targetPressure!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布任务 DTO。
|
||||||
|
*/
|
||||||
export class PublishTaskDto {
|
export class PublishTaskDto {
|
||||||
|
@ApiPropertyOptional({ description: '指定工程师 ID(可选)', example: 2 })
|
||||||
|
@IsOptional()
|
||||||
|
@EmptyStringToUndefined()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'engineerId 必须是整数' })
|
||||||
|
@Min(1, { message: 'engineerId 必须大于 0' })
|
||||||
engineerId?: number;
|
engineerId?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [PublishTaskItemDto], description: '任务明细列表' })
|
||||||
|
@IsArray({ message: 'items 必须是数组' })
|
||||||
|
@ArrayMinSize(1, { message: 'items 至少包含一条明细' })
|
||||||
|
@ValidateNested({ each: true })
|
||||||
|
@Type(() => PublishTaskItemDto)
|
||||||
items!: PublishTaskItemDto[];
|
items!: PublishTaskItemDto[];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,12 +8,16 @@ import {
|
|||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { DeviceStatus, Role, TaskStatus } from '../generated/prisma/enums.js';
|
import { DeviceStatus, Role, TaskStatus } from '../generated/prisma/enums.js';
|
||||||
import { PrismaService } from '../prisma.service.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 { PublishTaskDto } from './dto/publish-task.dto.js';
|
||||||
import { AcceptTaskDto } from './dto/accept-task.dto.js';
|
import { AcceptTaskDto } from './dto/accept-task.dto.js';
|
||||||
import { CompleteTaskDto } from './dto/complete-task.dto.js';
|
import { CompleteTaskDto } from './dto/complete-task.dto.js';
|
||||||
import { CancelTaskDto } from './dto/cancel-task.dto.js';
|
import { CancelTaskDto } from './dto/cancel-task.dto.js';
|
||||||
|
import { MESSAGES } from '../common/messages.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务服务:封装调压任务状态机、院内隔离与事件发布逻辑。
|
||||||
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TaskService {
|
export class TaskService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -21,24 +25,25 @@ export class TaskService {
|
|||||||
private readonly eventEmitter: EventEmitter2,
|
private readonly eventEmitter: EventEmitter2,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发布任务:医生创建主任务与明细,状态初始化为 PENDING。
|
||||||
|
*/
|
||||||
async publishTask(actor: ActorContext, dto: PublishTaskDto) {
|
async publishTask(actor: ActorContext, dto: PublishTaskDto) {
|
||||||
this.assertRole(actor, [Role.DOCTOR]);
|
this.assertRole(actor, [Role.DOCTOR]);
|
||||||
const hospitalId = this.requireHospitalId(actor);
|
const hospitalId = this.requireHospitalId(actor);
|
||||||
|
|
||||||
if (!Array.isArray(dto.items) || dto.items.length === 0) {
|
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(
|
const deviceIds = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
dto.items.map((item) => {
|
dto.items.map((item) => {
|
||||||
if (!Number.isInteger(item.deviceId)) {
|
if (!Number.isInteger(item.deviceId)) {
|
||||||
throw new BadRequestException(`Invalid deviceId: ${item.deviceId}`);
|
throw new BadRequestException(`deviceId 非法: ${item.deviceId}`);
|
||||||
}
|
}
|
||||||
if (!Number.isInteger(item.targetPressure)) {
|
if (!Number.isInteger(item.targetPressure)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(`targetPressure 非法: ${item.targetPressure}`);
|
||||||
`Invalid targetPressure: ${item.targetPressure}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return item.deviceId;
|
return item.deviceId;
|
||||||
}),
|
}),
|
||||||
@ -55,7 +60,7 @@ export class TaskService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (devices.length !== deviceIds.length) {
|
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) {
|
if (dto.engineerId != null) {
|
||||||
@ -68,7 +73,7 @@ export class TaskService {
|
|||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (!engineer) {
|
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;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 接收任务:工程师将任务从 PENDING 流转到 ACCEPTED。
|
||||||
|
*/
|
||||||
async acceptTask(actor: ActorContext, dto: AcceptTaskDto) {
|
async acceptTask(actor: ActorContext, dto: AcceptTaskDto) {
|
||||||
this.assertRole(actor, [Role.ENGINEER]);
|
this.assertRole(actor, [Role.ENGINEER]);
|
||||||
const hospitalId = this.requireHospitalId(actor);
|
const hospitalId = this.requireHospitalId(actor);
|
||||||
@ -121,13 +129,13 @@ export class TaskService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!task) {
|
if (!task) {
|
||||||
throw new NotFoundException('Task not found in actor hospital');
|
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
|
||||||
}
|
}
|
||||||
if (task.status !== TaskStatus.PENDING) {
|
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) {
|
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({
|
const updatedTask = await this.prisma.task.update({
|
||||||
@ -149,6 +157,9 @@ export class TaskService {
|
|||||||
return updatedTask;
|
return updatedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成任务:工程师将任务置为 COMPLETED,并同步设备当前压力。
|
||||||
|
*/
|
||||||
async completeTask(actor: ActorContext, dto: CompleteTaskDto) {
|
async completeTask(actor: ActorContext, dto: CompleteTaskDto) {
|
||||||
this.assertRole(actor, [Role.ENGINEER]);
|
this.assertRole(actor, [Role.ENGINEER]);
|
||||||
const hospitalId = this.requireHospitalId(actor);
|
const hospitalId = this.requireHospitalId(actor);
|
||||||
@ -164,13 +175,13 @@ export class TaskService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!task) {
|
if (!task) {
|
||||||
throw new NotFoundException('Task not found in actor hospital');
|
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
|
||||||
}
|
}
|
||||||
if (task.status !== TaskStatus.ACCEPTED) {
|
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) {
|
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) => {
|
const completedTask = await this.prisma.$transaction(async (tx) => {
|
||||||
@ -202,6 +213,9 @@ export class TaskService {
|
|||||||
return completedTask;
|
return completedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消任务:创建医生可将 PENDING/ACCEPTED 任务取消。
|
||||||
|
*/
|
||||||
async cancelTask(actor: ActorContext, dto: CancelTaskDto) {
|
async cancelTask(actor: ActorContext, dto: CancelTaskDto) {
|
||||||
this.assertRole(actor, [Role.DOCTOR]);
|
this.assertRole(actor, [Role.DOCTOR]);
|
||||||
const hospitalId = this.requireHospitalId(actor);
|
const hospitalId = this.requireHospitalId(actor);
|
||||||
@ -220,16 +234,16 @@ export class TaskService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!task) {
|
if (!task) {
|
||||||
throw new NotFoundException('Task not found in actor hospital');
|
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
|
||||||
}
|
}
|
||||||
if (task.creatorId !== actor.id) {
|
if (task.creatorId !== actor.id) {
|
||||||
throw new ForbiddenException('Only task creator can cancel task');
|
throw new ForbiddenException(MESSAGES.TASK.CANCEL_ONLY_CREATOR);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
task.status !== TaskStatus.PENDING &&
|
task.status !== TaskStatus.PENDING &&
|
||||||
task.status !== TaskStatus.ACCEPTED
|
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({
|
const cancelledTask = await this.prisma.task.update({
|
||||||
@ -248,15 +262,21 @@ export class TaskService {
|
|||||||
return cancelledTask;
|
return cancelledTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验角色权限。
|
||||||
|
*/
|
||||||
private assertRole(actor: ActorContext, allowedRoles: Role[]) {
|
private assertRole(actor: ActorContext, allowedRoles: Role[]) {
|
||||||
if (!allowedRoles.includes(actor.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 {
|
private requireHospitalId(actor: ActorContext): number {
|
||||||
if (!actor.hospitalId) {
|
if (!actor.hospitalId) {
|
||||||
throw new BadRequestException('Actor hospitalId is required');
|
throw new BadRequestException(MESSAGES.TASK.ACTOR_HOSPITAL_REQUIRED);
|
||||||
}
|
}
|
||||||
return actor.hospitalId;
|
return actor.hospitalId;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Param, Patch, UseGuards } from '@nestjs/common';
|
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 type { ActorContext } from '../../common/actor-context.js';
|
||||||
import { CurrentActor } from '../../auth/current-actor.decorator.js';
|
import { CurrentActor } from '../../auth/current-actor.decorator.js';
|
||||||
import { AccessTokenGuard } from '../../auth/access-token.guard.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 { UsersService } from '../users.service.js';
|
||||||
import { AssignEngineerHospitalDto } from '../dto/assign-engineer-hospital.dto.js';
|
import { AssignEngineerHospitalDto } from '../dto/assign-engineer-hospital.dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B 端用户扩展控制器:承载非标准 CRUD 的组织授权接口。
|
||||||
|
*/
|
||||||
|
@ApiTags('用户管理(B端)')
|
||||||
|
@ApiBearerAuth('bearer')
|
||||||
@Controller('b/users')
|
@Controller('b/users')
|
||||||
@UseGuards(AccessTokenGuard, RolesGuard)
|
@UseGuards(AccessTokenGuard, RolesGuard)
|
||||||
export class BUsersController {
|
export class BUsersController {
|
||||||
constructor(private readonly usersService: UsersService) {}
|
constructor(private readonly usersService: UsersService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将工程师绑定到指定医院(仅系统管理员)。
|
||||||
|
*/
|
||||||
@Patch(':id/assign-engineer-hospital')
|
@Patch(':id/assign-engineer-hospital')
|
||||||
@Roles(Role.SYSTEM_ADMIN)
|
@Roles(Role.SYSTEM_ADMIN)
|
||||||
|
@ApiOperation({ summary: '绑定工程师到医院(SYSTEM_ADMIN)' })
|
||||||
|
@ApiParam({ name: 'id', description: '工程师用户 ID' })
|
||||||
assignEngineerHospital(
|
assignEngineerHospital(
|
||||||
@CurrentActor() actor: ActorContext,
|
@CurrentActor() actor: ActorContext,
|
||||||
@Param('id') id: string,
|
@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 {
|
export class AssignEngineerHospitalDto {
|
||||||
|
@ApiProperty({ description: '医院 ID', example: 1 })
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'hospitalId 必须是整数' })
|
||||||
|
@Min(1, { message: 'hospitalId 必须大于 0' })
|
||||||
hospitalId!: number;
|
hospitalId!: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,66 @@
|
|||||||
import { Role } from '../../generated/prisma/enums.js';
|
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 {
|
export class CreateUserDto {
|
||||||
|
@ApiProperty({ description: '用户姓名', example: '李医生' })
|
||||||
|
@IsString({ message: 'name 必须是字符串' })
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '手机号', example: '13800000002' })
|
||||||
|
@IsString({ message: 'phone 必须是字符串' })
|
||||||
|
@Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' })
|
||||||
phone!: string;
|
phone!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '密码(可选)', example: 'Abcd1234' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'password 必须是字符串' })
|
||||||
|
@MinLength(8, { message: 'password 长度至少 8 位' })
|
||||||
password?: string;
|
password?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '微信 openId', example: 'wx-open-id-demo' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'openId 必须是字符串' })
|
||||||
openId?: string;
|
openId?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '角色', enum: Role, example: Role.DOCTOR })
|
||||||
|
@IsEnum(Role, { message: 'role 枚举值不合法' })
|
||||||
role!: Role;
|
role!: Role;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '医院 ID', example: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@EmptyStringToUndefined()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'hospitalId 必须是整数' })
|
||||||
|
@Min(1, { message: 'hospitalId 必须大于 0' })
|
||||||
hospitalId?: number;
|
hospitalId?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '科室 ID', example: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@EmptyStringToUndefined()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'departmentId 必须是整数' })
|
||||||
|
@Min(1, { message: 'departmentId 必须大于 0' })
|
||||||
departmentId?: number;
|
departmentId?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '小组 ID', example: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@EmptyStringToUndefined()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'groupId 必须是整数' })
|
||||||
|
@Min(1, { message: 'groupId 必须大于 0' })
|
||||||
groupId?: number;
|
groupId?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,40 @@
|
|||||||
import { Role } from '../../generated/prisma/enums.js';
|
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 {
|
export class LoginDto {
|
||||||
|
@ApiProperty({ description: '手机号', example: '13800000002' })
|
||||||
|
@IsString({ message: 'phone 必须是字符串' })
|
||||||
|
@Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' })
|
||||||
phone!: string;
|
phone!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '密码', example: 'Abcd1234' })
|
||||||
|
@IsString({ message: 'password 必须是字符串' })
|
||||||
|
@MinLength(8, { message: 'password 长度至少 8 位' })
|
||||||
password!: string;
|
password!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '登录角色', enum: Role, example: Role.DOCTOR })
|
||||||
|
@IsEnum(Role, { message: 'role 枚举值不合法' })
|
||||||
role!: Role;
|
role!: Role;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '医院 ID(多账号场景建议传入)', example: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@EmptyStringToUndefined()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'hospitalId 必须是整数' })
|
||||||
|
@Min(1, { message: 'hospitalId 必须大于 0' })
|
||||||
hospitalId?: number;
|
hospitalId?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,76 @@
|
|||||||
import { Role } from '../../generated/prisma/enums.js';
|
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 {
|
export class RegisterUserDto {
|
||||||
|
@ApiProperty({ description: '用户姓名', example: '张三' })
|
||||||
|
@IsString({ message: 'name 必须是字符串' })
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '手机号(中国大陆)', example: '13800000001' })
|
||||||
|
@IsString({ message: 'phone 必须是字符串' })
|
||||||
|
@Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' })
|
||||||
phone!: string;
|
phone!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '登录密码(至少 8 位)', example: 'Abcd1234' })
|
||||||
|
@IsString({ message: 'password 必须是字符串' })
|
||||||
|
@MinLength(8, { message: 'password 长度至少 8 位' })
|
||||||
password!: string;
|
password!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: '系统角色', enum: Role, example: Role.DOCTOR })
|
||||||
|
@IsEnum(Role, { message: 'role 枚举值不合法' })
|
||||||
role!: Role;
|
role!: Role;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '微信 openId(可选)',
|
||||||
|
example: 'wx-open-id-demo',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'openId 必须是字符串' })
|
||||||
openId?: string;
|
openId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '所属医院 ID', example: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@EmptyStringToUndefined()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'hospitalId 必须是整数' })
|
||||||
|
@Min(1, { message: 'hospitalId 必须大于 0' })
|
||||||
hospitalId?: number;
|
hospitalId?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '所属科室 ID', example: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@EmptyStringToUndefined()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'departmentId 必须是整数' })
|
||||||
|
@Min(1, { message: 'departmentId 必须大于 0' })
|
||||||
departmentId?: number;
|
departmentId?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: '所属小组 ID', example: 1 })
|
||||||
|
@IsOptional()
|
||||||
|
@EmptyStringToUndefined()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: 'groupId 必须是整数' })
|
||||||
|
@Min(1, { message: 'groupId 必须大于 0' })
|
||||||
groupId?: number;
|
groupId?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: '系统管理员注册引导密钥(仅注册 SYSTEM_ADMIN 需要)',
|
||||||
|
example: 'admin-bootstrap-key',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: 'systemAdminBootstrapKey 必须是字符串' })
|
||||||
systemAdminBootstrapKey?: string;
|
systemAdminBootstrapKey?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
import { PartialType } from '@nestjs/mapped-types';
|
import { PartialType } from '@nestjs/swagger';
|
||||||
import { CreateUserDto } from './create-user.dto.js';
|
import { CreateUserDto } from './create-user.dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户 DTO。
|
||||||
|
*/
|
||||||
export class UpdateUserDto extends PartialType(CreateUserDto) {}
|
export class UpdateUserDto extends PartialType(CreateUserDto) {}
|
||||||
|
|||||||
@ -8,6 +8,12 @@ import {
|
|||||||
Param,
|
Param,
|
||||||
Delete,
|
Delete,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
import { AccessTokenGuard } from '../auth/access-token.guard.js';
|
import { AccessTokenGuard } from '../auth/access-token.guard.js';
|
||||||
import { RolesGuard } from '../auth/roles.guard.js';
|
import { RolesGuard } from '../auth/roles.guard.js';
|
||||||
import { Roles } from '../auth/roles.decorator.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 { CreateUserDto } from './dto/create-user.dto.js';
|
||||||
import { UpdateUserDto } from './dto/update-user.dto.js';
|
import { UpdateUserDto } from './dto/update-user.dto.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户管理控制器:面向 B 端后台的用户 CRUD。
|
||||||
|
*/
|
||||||
|
@ApiTags('用户管理(B端)')
|
||||||
|
@ApiBearerAuth('bearer')
|
||||||
@Controller('users')
|
@Controller('users')
|
||||||
@UseGuards(AccessTokenGuard, RolesGuard)
|
@UseGuards(AccessTokenGuard, RolesGuard)
|
||||||
export class UsersController {
|
export class UsersController {
|
||||||
constructor(private readonly usersService: UsersService) {}
|
constructor(private readonly usersService: UsersService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建用户。
|
||||||
|
*/
|
||||||
@Post()
|
@Post()
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||||
|
@ApiOperation({ summary: '创建用户' })
|
||||||
create(@Body() createUserDto: CreateUserDto) {
|
create(@Body() createUserDto: CreateUserDto) {
|
||||||
return this.usersService.create(createUserDto);
|
return this.usersService.create(createUserDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户列表。
|
||||||
|
*/
|
||||||
@Get()
|
@Get()
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||||
|
@ApiOperation({ summary: '查询用户列表' })
|
||||||
findAll() {
|
findAll() {
|
||||||
return this.usersService.findAll();
|
return this.usersService.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户详情。
|
||||||
|
*/
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||||
|
@ApiOperation({ summary: '查询用户详情' })
|
||||||
|
@ApiParam({ name: 'id', description: '用户 ID' })
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string) {
|
||||||
return this.usersService.findOne(+id);
|
return this.usersService.findOne(+id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户。
|
||||||
|
*/
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
|
||||||
|
@ApiOperation({ summary: '更新用户' })
|
||||||
|
@ApiParam({ name: 'id', description: '用户 ID' })
|
||||||
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
|
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
|
||||||
return this.usersService.update(+id, updateUserDto);
|
return this.usersService.update(+id, updateUserDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除用户。
|
||||||
|
*/
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@Roles(Role.SYSTEM_ADMIN)
|
@Roles(Role.SYSTEM_ADMIN)
|
||||||
|
@ApiOperation({ summary: '删除用户' })
|
||||||
|
@ApiParam({ name: 'id', description: '用户 ID' })
|
||||||
remove(@Param('id') id: string) {
|
remove(@Param('id') id: string) {
|
||||||
return this.usersService.remove(+id);
|
return this.usersService.remove(+id);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,10 +12,11 @@ import { CreateUserDto } from './dto/create-user.dto.js';
|
|||||||
import { UpdateUserDto } from './dto/update-user.dto.js';
|
import { UpdateUserDto } from './dto/update-user.dto.js';
|
||||||
import { Role } from '../generated/prisma/enums.js';
|
import { Role } from '../generated/prisma/enums.js';
|
||||||
import { PrismaService } from '../prisma.service.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 { AssignEngineerHospitalDto } from './dto/assign-engineer-hospital.dto.js';
|
||||||
import { RegisterUserDto } from './dto/register-user.dto.js';
|
import { RegisterUserDto } from './dto/register-user.dto.js';
|
||||||
import { LoginDto } from './dto/login.dto.js';
|
import { LoginDto } from './dto/login.dto.js';
|
||||||
|
import { MESSAGES } from '../common/messages.js';
|
||||||
|
|
||||||
const SAFE_USER_SELECT = {
|
const SAFE_USER_SELECT = {
|
||||||
id: true,
|
id: true,
|
||||||
@ -32,6 +33,9 @@ const SAFE_USER_SELECT = {
|
|||||||
export class UsersService {
|
export class UsersService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册账号:根据角色与组织范围进行约束,并写入 bcrypt 密码摘要。
|
||||||
|
*/
|
||||||
async register(dto: RegisterUserDto) {
|
async register(dto: RegisterUserDto) {
|
||||||
const role = this.normalizeRole(dto.role);
|
const role = this.normalizeRole(dto.role);
|
||||||
const name = this.normalizeRequiredString(dto.name, 'name');
|
const name = this.normalizeRequiredString(dto.name, 'name');
|
||||||
@ -67,6 +71,9 @@ export class UsersService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录:按手机号+角色(可选医院)定位账号并签发 JWT。
|
||||||
|
*/
|
||||||
async login(dto: LoginDto) {
|
async login(dto: LoginDto) {
|
||||||
const role = this.normalizeRole(dto.role);
|
const role = this.normalizeRole(dto.role);
|
||||||
const phone = this.normalizePhone(dto.phone);
|
const phone = this.normalizePhone(dto.phone);
|
||||||
@ -87,22 +94,22 @@ export class UsersService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (users.length === 0) {
|
if (users.length === 0) {
|
||||||
throw new UnauthorizedException('Invalid phone/role/password');
|
throw new UnauthorizedException(MESSAGES.AUTH.INVALID_CREDENTIALS);
|
||||||
}
|
}
|
||||||
if (users.length > 1 && hospitalId == null) {
|
if (users.length > 1 && hospitalId == null) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'Multiple accounts found. Please specify hospitalId',
|
MESSAGES.USER.MULTI_ACCOUNT_REQUIRE_HOSPITAL,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = users[0];
|
const user = users[0];
|
||||||
if (!user?.passwordHash) {
|
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);
|
const matched = await compare(password, user.passwordHash);
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
throw new UnauthorizedException('Invalid phone/role/password');
|
throw new UnauthorizedException(MESSAGES.AUTH.INVALID_CREDENTIALS);
|
||||||
}
|
}
|
||||||
|
|
||||||
const actor: ActorContext = {
|
const actor: ActorContext = {
|
||||||
@ -121,10 +128,16 @@ export class UsersService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前登录用户详情。
|
||||||
|
*/
|
||||||
async me(actor: ActorContext) {
|
async me(actor: ActorContext) {
|
||||||
return this.findOne(actor.id);
|
return this.findOne(actor.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B 端创建用户(通常由管理员使用)。
|
||||||
|
*/
|
||||||
async create(createUserDto: CreateUserDto) {
|
async create(createUserDto: CreateUserDto) {
|
||||||
const role = this.normalizeRole(createUserDto.role);
|
const role = this.normalizeRole(createUserDto.role);
|
||||||
const name = this.normalizeRequiredString(createUserDto.name, 'name');
|
const name = this.normalizeRequiredString(createUserDto.name, 'name');
|
||||||
@ -162,6 +175,9 @@ export class UsersService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户列表。
|
||||||
|
*/
|
||||||
async findAll() {
|
async findAll() {
|
||||||
return this.prisma.user.findMany({
|
return this.prisma.user.findMany({
|
||||||
select: SAFE_USER_SELECT,
|
select: SAFE_USER_SELECT,
|
||||||
@ -169,6 +185,9 @@ export class UsersService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询用户详情。
|
||||||
|
*/
|
||||||
async findOne(id: number) {
|
async findOne(id: number) {
|
||||||
const userId = this.normalizeRequiredInt(id, 'id');
|
const userId = this.normalizeRequiredInt(id, 'id');
|
||||||
|
|
||||||
@ -177,12 +196,15 @@ export class UsersService {
|
|||||||
select: SAFE_USER_SELECT,
|
select: SAFE_USER_SELECT,
|
||||||
});
|
});
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户信息(含可选密码重置)。
|
||||||
|
*/
|
||||||
async update(id: number, updateUserDto: UpdateUserDto) {
|
async update(id: number, updateUserDto: UpdateUserDto) {
|
||||||
const userId = this.normalizeRequiredInt(id, 'id');
|
const userId = this.normalizeRequiredInt(id, 'id');
|
||||||
const current = await this.prisma.user.findUnique({
|
const current = await this.prisma.user.findUnique({
|
||||||
@ -193,7 +215,7 @@ export class UsersService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!current) {
|
if (!current) {
|
||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextRole =
|
const nextRole =
|
||||||
@ -211,6 +233,13 @@ export class UsersService {
|
|||||||
? this.normalizeOptionalInt(updateUserDto.groupId, 'groupId')
|
? this.normalizeOptionalInt(updateUserDto.groupId, 'groupId')
|
||||||
: current.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(
|
await this.assertOrganizationScope(
|
||||||
nextRole,
|
nextRole,
|
||||||
nextHospitalId,
|
nextHospitalId,
|
||||||
@ -270,6 +299,9 @@ export class UsersService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除用户。
|
||||||
|
*/
|
||||||
async remove(id: number) {
|
async remove(id: number) {
|
||||||
const userId = this.normalizeRequiredInt(id, 'id');
|
const userId = this.normalizeRequiredInt(id, 'id');
|
||||||
await this.findOne(userId);
|
await this.findOne(userId);
|
||||||
@ -280,18 +312,19 @@ export class UsersService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统管理员将工程师绑定到指定医院。
|
||||||
|
*/
|
||||||
async assignEngineerHospital(
|
async assignEngineerHospital(
|
||||||
actor: ActorContext,
|
actor: ActorContext,
|
||||||
targetUserId: number,
|
targetUserId: number,
|
||||||
dto: AssignEngineerHospitalDto,
|
dto: AssignEngineerHospitalDto,
|
||||||
) {
|
) {
|
||||||
if (actor.role !== Role.SYSTEM_ADMIN) {
|
if (actor.role !== Role.SYSTEM_ADMIN) {
|
||||||
throw new ForbiddenException(
|
throw new ForbiddenException(MESSAGES.USER.ENGINEER_BIND_FORBIDDEN);
|
||||||
'Only SYSTEM_ADMIN can bind engineer to hospital',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (!Number.isInteger(dto.hospitalId)) {
|
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({
|
const hospital = await this.prisma.hospital.findUnique({
|
||||||
@ -299,7 +332,7 @@ export class UsersService {
|
|||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (!hospital) {
|
if (!hospital) {
|
||||||
throw new NotFoundException('Hospital not found');
|
throw new NotFoundException(MESSAGES.ORG.HOSPITAL_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
@ -307,10 +340,10 @@ export class UsersService {
|
|||||||
select: { id: true, role: true },
|
select: { id: true, role: true },
|
||||||
});
|
});
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
|
||||||
}
|
}
|
||||||
if (user.role !== Role.ENGINEER) {
|
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({
|
return this.prisma.user.update({
|
||||||
@ -324,11 +357,17 @@ export class UsersService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 去除密码摘要,避免泄露敏感信息。
|
||||||
|
*/
|
||||||
private toSafeUser(user: { passwordHash?: string | null } & Record<string, unknown>) {
|
private toSafeUser(user: { passwordHash?: string | null } & Record<string, unknown>) {
|
||||||
const { passwordHash, ...safe } = user;
|
const { passwordHash, ...safe } = user;
|
||||||
return safe;
|
return safe;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验系统管理员注册引导密钥。
|
||||||
|
*/
|
||||||
private assertSystemAdminBootstrapKey(
|
private assertSystemAdminBootstrapKey(
|
||||||
role: Role,
|
role: Role,
|
||||||
providedBootstrapKey?: string,
|
providedBootstrapKey?: string,
|
||||||
@ -339,13 +378,18 @@ export class UsersService {
|
|||||||
|
|
||||||
const expectedBootstrapKey = process.env.SYSTEM_ADMIN_BOOTSTRAP_KEY;
|
const expectedBootstrapKey = process.env.SYSTEM_ADMIN_BOOTSTRAP_KEY;
|
||||||
if (!expectedBootstrapKey) {
|
if (!expectedBootstrapKey) {
|
||||||
throw new ForbiddenException('SYSTEM_ADMIN registration is disabled');
|
throw new ForbiddenException(MESSAGES.USER.SYSTEM_ADMIN_REG_DISABLED);
|
||||||
}
|
}
|
||||||
if (providedBootstrapKey !== expectedBootstrapKey) {
|
if (providedBootstrapKey !== expectedBootstrapKey) {
|
||||||
throw new ForbiddenException('Invalid system admin bootstrap key');
|
throw new ForbiddenException(
|
||||||
|
MESSAGES.USER.SYSTEM_ADMIN_BOOTSTRAP_KEY_INVALID,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验“手机号 + 角色 + 医院”唯一性。
|
||||||
|
*/
|
||||||
private async assertPhoneRoleScopeUnique(
|
private async assertPhoneRoleScopeUnique(
|
||||||
phone: string,
|
phone: string,
|
||||||
role: Role,
|
role: Role,
|
||||||
@ -361,12 +405,13 @@ export class UsersService {
|
|||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (exists && exists.id !== selfId) {
|
if (exists && exists.id !== selfId) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(MESSAGES.USER.DUPLICATE_PHONE_ROLE_SCOPE);
|
||||||
'User with same phone/role/hospital already exists',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 openId 唯一性。
|
||||||
|
*/
|
||||||
private async assertOpenIdUnique(openId: string | null, selfId?: number) {
|
private async assertOpenIdUnique(openId: string | null, selfId?: number) {
|
||||||
if (!openId) {
|
if (!openId) {
|
||||||
return;
|
return;
|
||||||
@ -377,10 +422,13 @@ export class UsersService {
|
|||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (exists && exists.id !== selfId) {
|
if (exists && exists.id !== selfId) {
|
||||||
throw new ConflictException('openId already registered');
|
throw new ConflictException(MESSAGES.USER.DUPLICATE_OPEN_ID);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验角色与组织归属关系是否合法。
|
||||||
|
*/
|
||||||
private async assertOrganizationScope(
|
private async assertOrganizationScope(
|
||||||
role: Role,
|
role: Role,
|
||||||
hospitalId: number | null,
|
hospitalId: number | null,
|
||||||
@ -389,15 +437,13 @@ export class UsersService {
|
|||||||
) {
|
) {
|
||||||
if (role === Role.SYSTEM_ADMIN) {
|
if (role === Role.SYSTEM_ADMIN) {
|
||||||
if (hospitalId || departmentId || groupId) {
|
if (hospitalId || departmentId || groupId) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(MESSAGES.USER.SYSTEM_ADMIN_SCOPE_INVALID);
|
||||||
'SYSTEM_ADMIN must not bind hospital/department/group',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hospitalId) {
|
if (!hospitalId) {
|
||||||
throw new BadRequestException('hospitalId is required');
|
throw new BadRequestException(MESSAGES.USER.HOSPITAL_REQUIRED);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hospital = await this.prisma.hospital.findUnique({
|
const hospital = await this.prisma.hospital.findUnique({
|
||||||
@ -405,24 +451,22 @@ export class UsersService {
|
|||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (!hospital) {
|
if (!hospital) {
|
||||||
throw new BadRequestException('hospitalId does not exist');
|
throw new BadRequestException(MESSAGES.USER.HOSPITAL_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
const needsDepartment =
|
const needsDepartment =
|
||||||
role === Role.DIRECTOR || role === Role.LEADER || role === Role.DOCTOR;
|
role === Role.DIRECTOR || role === Role.LEADER || role === Role.DOCTOR;
|
||||||
if (needsDepartment && !departmentId) {
|
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;
|
const needsGroup = role === Role.LEADER || role === Role.DOCTOR;
|
||||||
if (needsGroup && !groupId) {
|
if (needsGroup && !groupId) {
|
||||||
throw new BadRequestException('groupId is required for role');
|
throw new BadRequestException(MESSAGES.USER.GROUP_REQUIRED);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role === Role.ENGINEER && (departmentId || groupId)) {
|
if (role === Role.ENGINEER && (departmentId || groupId)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(MESSAGES.USER.ENGINEER_SCOPE_INVALID);
|
||||||
'ENGINEER should not bind departmentId/groupId',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (departmentId) {
|
if (departmentId) {
|
||||||
@ -431,36 +475,38 @@ export class UsersService {
|
|||||||
select: { id: true, hospitalId: true },
|
select: { id: true, hospitalId: true },
|
||||||
});
|
});
|
||||||
if (!department || department.hospitalId !== hospitalId) {
|
if (!department || department.hospitalId !== hospitalId) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(MESSAGES.USER.DEPARTMENT_HOSPITAL_MISMATCH);
|
||||||
'departmentId does not belong to hospitalId',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (groupId) {
|
if (groupId) {
|
||||||
if (!departmentId) {
|
if (!departmentId) {
|
||||||
throw new BadRequestException('groupId requires departmentId');
|
throw new BadRequestException(MESSAGES.USER.GROUP_DEPARTMENT_REQUIRED);
|
||||||
}
|
}
|
||||||
const group = await this.prisma.group.findUnique({
|
const group = await this.prisma.group.findUnique({
|
||||||
where: { id: groupId },
|
where: { id: groupId },
|
||||||
select: { id: true, departmentId: true },
|
select: { id: true, departmentId: true },
|
||||||
});
|
});
|
||||||
if (!group || group.departmentId !== departmentId) {
|
if (!group || group.departmentId !== departmentId) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(MESSAGES.USER.GROUP_DEPARTMENT_MISMATCH);
|
||||||
'groupId does not belong to departmentId',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 必填整数标准化。
|
||||||
|
*/
|
||||||
private normalizeRequiredInt(value: unknown, fieldName: string): number {
|
private normalizeRequiredInt(value: unknown, fieldName: string): number {
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
if (!Number.isInteger(parsed)) {
|
if (!Number.isInteger(parsed)) {
|
||||||
throw new BadRequestException(`${fieldName} must be an integer`);
|
throw new BadRequestException(`${fieldName} 必须为整数`);
|
||||||
}
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可空整数标准化。
|
||||||
|
*/
|
||||||
private normalizeOptionalInt(
|
private normalizeOptionalInt(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
fieldName: string,
|
fieldName: string,
|
||||||
@ -471,61 +517,79 @@ export class UsersService {
|
|||||||
return this.normalizeRequiredInt(value, fieldName);
|
return this.normalizeRequiredInt(value, fieldName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 必填字符串标准化。
|
||||||
|
*/
|
||||||
private normalizeRequiredString(value: unknown, fieldName: string): string {
|
private normalizeRequiredString(value: unknown, fieldName: string): string {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
throw new BadRequestException(`${fieldName} must be a string`);
|
throw new BadRequestException(`${fieldName} 必须为字符串`);
|
||||||
}
|
}
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
throw new BadRequestException(`${fieldName} is required`);
|
throw new BadRequestException(`${fieldName} 不能为空`);
|
||||||
}
|
}
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可空字符串标准化。
|
||||||
|
*/
|
||||||
private normalizeOptionalString(value: unknown): string | null {
|
private normalizeOptionalString(value: unknown): string | null {
|
||||||
if (value === undefined || value === null) {
|
if (value === undefined || value === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
throw new BadRequestException('openId must be a string');
|
throw new BadRequestException(MESSAGES.USER.INVALID_OPEN_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
return trimmed ? trimmed : null;
|
return trimmed ? trimmed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手机号标准化与格式校验。
|
||||||
|
*/
|
||||||
private normalizePhone(phone: unknown): string {
|
private normalizePhone(phone: unknown): string {
|
||||||
const normalized = this.normalizeRequiredString(phone, 'phone');
|
const normalized = this.normalizeRequiredString(phone, 'phone');
|
||||||
if (!/^1\d{10}$/.test(normalized)) {
|
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;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密码标准化与长度校验。
|
||||||
|
*/
|
||||||
private normalizePassword(password: unknown): string {
|
private normalizePassword(password: unknown): string {
|
||||||
const normalized = this.normalizeRequiredString(password, 'password');
|
const normalized = this.normalizeRequiredString(password, 'password');
|
||||||
if (normalized.length < 8) {
|
if (normalized.length < 8) {
|
||||||
throw new BadRequestException('password must be at least 8 characters');
|
throw new BadRequestException(MESSAGES.USER.INVALID_PASSWORD);
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色枚举校验。
|
||||||
|
*/
|
||||||
private normalizeRole(role: unknown): Role {
|
private normalizeRole(role: unknown): Role {
|
||||||
if (typeof role !== 'string') {
|
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)) {
|
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;
|
return role as Role;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 签发访问令牌。
|
||||||
|
*/
|
||||||
private signAccessToken(actor: ActorContext): string {
|
private signAccessToken(actor: ActorContext): string {
|
||||||
const secret = process.env.AUTH_TOKEN_SECRET;
|
const secret = process.env.AUTH_TOKEN_SECRET;
|
||||||
if (!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, {
|
return jwt.sign(actor, secret, {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user