权限完善

This commit is contained in:
EL 2026-03-13 02:40:21 +08:00
parent aa1346f6af
commit b55e600c9c
68 changed files with 2947 additions and 264 deletions

48
AGENTS.md Normal file
View 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
View File

@ -0,0 +1,105 @@
# 多租户医疗调压系统后端NestJS + Prisma
本项目是医疗调压系统后端 MVP支持 B 端(医院内部)与 C 端(家属跨院视图)两套接口语义。
## 1. 技术栈
- NestJS模块化后端框架
- PrismaORM + 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
View 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
View 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
View 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
View 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 中文说明

View File

@ -20,14 +20,18 @@
"@nestjs/event-emitter": "^3.0.1",
"@nestjs/mapped-types": "*",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.2.6",
"@prisma/adapter-pg": "^7.5.0",
"@prisma/client": "^7.5.0",
"bcrypt": "^6.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"dotenv": "^17.3.1",
"jsonwebtoken": "^9.0.3",
"pg": "^8.20.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"swagger-ui-express": "^5.0.1"
},
"devDependencies": {
"@nestjs/cli": "^11.0.0",

159
pnpm-lock.yaml generated
View File

@ -10,19 +10,22 @@ importers:
dependencies:
'@nestjs/common':
specifier: ^11.0.1
version: 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)
version: 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core':
specifier: ^11.0.1
version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/event-emitter':
specifier: ^3.0.1
version: 3.0.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
version: 3.0.1(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
'@nestjs/mapped-types':
specifier: '*'
version: 2.1.0(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)
version: 2.1.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
'@nestjs/platform-express':
specifier: ^11.0.1
version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
'@nestjs/swagger':
specifier: ^11.2.6
version: 11.2.6(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
'@prisma/adapter-pg':
specifier: ^7.5.0
version: 7.5.0
@ -32,6 +35,12 @@ importers:
bcrypt:
specifier: ^6.0.0
version: 6.0.0
class-transformer:
specifier: ^0.5.1
version: 0.5.1
class-validator:
specifier: ^0.15.1
version: 0.15.1
dotenv:
specifier: ^17.3.1
version: 17.3.1
@ -47,6 +56,9 @@ importers:
rxjs:
specifier: ^7.8.1
version: 7.8.2
swagger-ui-express:
specifier: ^5.0.1
version: 5.0.1(express@5.2.1)
devDependencies:
'@nestjs/cli':
specifier: ^11.0.0
@ -56,7 +68,7 @@ importers:
version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3)
'@nestjs/testing':
specifier: ^11.0.1
version: 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)
'@types/bcrypt':
specifier: ^6.0.0
version: 6.0.0
@ -350,6 +362,9 @@ packages:
resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==}
engines: {node: '>=8'}
'@microsoft/tsdoc@0.16.0':
resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==}
'@mrleebo/prisma-ast@0.13.1':
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
engines: {node: '>=16'}
@ -428,6 +443,23 @@ packages:
peerDependencies:
typescript: '>=4.8.2'
'@nestjs/swagger@11.2.6':
resolution: {integrity: sha512-oiXOxMQqDFyv1AKAqFzSo6JPvMEs4uA36Eyz/s2aloZLxUjcLfUMELSLSNQunr61xCPTpwEOShfmO7NIufKXdA==}
peerDependencies:
'@fastify/static': ^8.0.0 || ^9.0.0
'@nestjs/common': ^11.0.1
'@nestjs/core': ^11.0.1
class-transformer: '*'
class-validator: '*'
reflect-metadata: ^0.1.12 || ^0.2.0
peerDependenciesMeta:
'@fastify/static':
optional: true
class-transformer:
optional: true
class-validator:
optional: true
'@nestjs/testing@11.1.16':
resolution: {integrity: sha512-E7/aUCxzeMSJV80L5GWGIuiMyR/1ncS7uOIetAImfbS4ATE1/h2GBafk0qpk+vjFtPIbtoh9BWDGICzUEU5jDA==}
peerDependencies:
@ -514,6 +546,9 @@ packages:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
'@scarf/scarf@1.4.0':
resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
@ -605,6 +640,9 @@ packages:
'@types/supertest@6.0.3':
resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==}
'@types/validator@13.15.10':
resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==}
'@webassemblyjs/ast@1.14.1':
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
@ -854,6 +892,12 @@ packages:
citty@0.2.1:
resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==}
class-transformer@0.5.1:
resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==}
class-validator@0.15.1:
resolution: {integrity: sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==}
cli-cursor@3.1.0:
resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==}
engines: {node: '>=8'}
@ -1339,6 +1383,9 @@ packages:
jws@4.0.1:
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
libphonenumber-js@1.12.39:
resolution: {integrity: sha512-MW79m7HuOqBk8mwytiXYTMELJiBbV3Zl9Y39dCCn1yC8K+WGNSq1QGvzywbylp5vGShEztMScCWHX/XFOS0rXg==}
lilconfig@2.1.0:
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
engines: {node: '>=10'}
@ -1919,6 +1966,18 @@ packages:
resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==}
engines: {node: '>=10'}
swagger-ui-dist@5.31.0:
resolution: {integrity: sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==}
swagger-ui-dist@5.32.0:
resolution: {integrity: sha512-nKZB0OuDvacB0s/lC2gbge+RigYvGRGpLLMWMFxaTUwfM+CfndVk9Th2IaTinqXiz6Mn26GK2zriCpv6/+5m3Q==}
swagger-ui-express@5.0.1:
resolution: {integrity: sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==}
engines: {node: '>= v0.10.32'}
peerDependencies:
express: '>=4.0.0 || >=5.0.0-beta'
symbol-observable@4.0.0:
resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==}
engines: {node: '>=0.10'}
@ -2054,6 +2113,10 @@ packages:
typescript:
optional: true
validator@13.15.26:
resolution: {integrity: sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==}
engines: {node: '>= 0.10'}
vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
@ -2382,6 +2445,8 @@ snapshots:
'@lukeed/csprng@1.1.0': {}
'@microsoft/tsdoc@0.16.0': {}
'@mrleebo/prisma-ast@0.13.1':
dependencies:
chevrotain: 10.5.0
@ -2413,7 +2478,7 @@ snapshots:
- uglify-js
- webpack-cli
'@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)':
'@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
file-type: 21.3.0
iterare: 1.2.1
@ -2422,12 +2487,15 @@ snapshots:
rxjs: 7.8.2
tslib: 2.8.1
uid: 2.0.2
optionalDependencies:
class-transformer: 0.5.1
class-validator: 0.15.1
transitivePeerDependencies:
- supports-color
'@nestjs/core@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
'@nestjs/core@11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
'@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nuxt/opencollective': 0.4.1
fast-safe-stringify: 2.1.1
iterare: 1.2.1
@ -2437,23 +2505,26 @@ snapshots:
tslib: 2.8.1
uid: 2.0.2
optionalDependencies:
'@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
'@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
'@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)':
'@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)':
dependencies:
'@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
eventemitter2: 6.4.9
'@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)':
'@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)':
dependencies:
'@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
reflect-metadata: 0.2.2
optionalDependencies:
class-transformer: 0.5.1
class-validator: 0.15.1
'@nestjs/platform-express@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)':
'@nestjs/platform-express@11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)':
dependencies:
'@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
cors: 2.8.6
express: 5.2.1
multer: 2.1.1
@ -2473,13 +2544,28 @@ snapshots:
transitivePeerDependencies:
- chokidar
'@nestjs/testing@11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)':
'@nestjs/swagger@11.2.6(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)':
dependencies:
'@nestjs/common': 11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@microsoft/tsdoc': 0.16.0
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)
js-yaml: 4.1.1
lodash: 4.17.23
path-to-regexp: 8.3.0
reflect-metadata: 0.2.2
swagger-ui-dist: 5.31.0
optionalDependencies:
class-transformer: 0.5.1
class-validator: 0.15.1
'@nestjs/testing@11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)':
dependencies:
'@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2)
tslib: 2.8.1
optionalDependencies:
'@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
'@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)
'@noble/hashes@1.8.0': {}
@ -2581,6 +2667,8 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@scarf/scarf@1.4.0': {}
'@standard-schema/spec@1.1.0': {}
'@tokenizer/inflate@0.4.1':
@ -2692,6 +2780,8 @@ snapshots:
'@types/methods': 1.1.4
'@types/superagent': 8.1.9
'@types/validator@13.15.10': {}
'@webassemblyjs/ast@1.14.1':
dependencies:
'@webassemblyjs/helper-numbers': 1.13.2
@ -2975,6 +3065,14 @@ snapshots:
citty@0.2.1: {}
class-transformer@0.5.1: {}
class-validator@0.15.1:
dependencies:
'@types/validator': 13.15.10
libphonenumber-js: 1.12.39
validator: 13.15.26
cli-cursor@3.1.0:
dependencies:
restore-cursor: 3.1.0
@ -3454,6 +3552,8 @@ snapshots:
jwa: 2.0.1
safe-buffer: 5.2.1
libphonenumber-js@1.12.39: {}
lilconfig@2.1.0: {}
lines-and-columns@1.2.4: {}
@ -4002,6 +4102,19 @@ snapshots:
dependencies:
has-flag: 4.0.0
swagger-ui-dist@5.31.0:
dependencies:
'@scarf/scarf': 1.4.0
swagger-ui-dist@5.32.0:
dependencies:
'@scarf/scarf': 1.4.0
swagger-ui-express@5.0.1(express@5.2.1):
dependencies:
express: 5.2.1
swagger-ui-dist: 5.32.0
symbol-observable@4.0.0: {}
tapable@2.3.0: {}
@ -4123,6 +4236,8 @@ snapshots:
optionalDependencies:
typescript: 5.9.3
validator@13.15.26: {}
vary@1.1.2: {}
watchpack@2.5.1:

View 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;

View File

@ -7,6 +7,7 @@ datasource db {
provider = "postgresql"
}
// 角色枚举:用于鉴权与数据可见性控制。
enum Role {
SYSTEM_ADMIN
HOSPITAL_ADMIN
@ -16,11 +17,13 @@ enum Role {
ENGINEER
}
// 设备状态枚举:表示设备是否处于使用中。
enum DeviceStatus {
ACTIVE
INACTIVE
}
// 任务状态枚举:定义任务流转状态机。
enum TaskStatus {
PENDING
ACCEPTED
@ -28,6 +31,7 @@ enum TaskStatus {
CANCELLED
}
// 医院主表:多租户顶层实体。
model Hospital {
id Int @id @default(autoincrement())
name String
@ -37,6 +41,7 @@ model Hospital {
tasks Task[]
}
// 科室表:归属于医院。
model Department {
id Int @id @default(autoincrement())
name String
@ -48,6 +53,7 @@ model Department {
@@index([hospitalId])
}
// 小组表:归属于科室。
model Group {
id Int @id @default(autoincrement())
name String
@ -58,11 +64,12 @@ model Group {
@@index([departmentId])
}
// 用户表:支持后台密码登录与小程序 openId。
model User {
id Int @id @default(autoincrement())
name String
phone String
// Backend login password hash (bcrypt).
// 后台登录密码哈希bcrypt
passwordHash String?
openId String? @unique
role Role
@ -82,6 +89,7 @@ model User {
@@index([groupId, role])
}
// 患者表:院内患者档案,按医院隔离。
model Patient {
id Int @id @default(autoincrement())
name String
@ -97,6 +105,7 @@ model Patient {
@@index([hospitalId, doctorId])
}
// 设备表:患者可绑定多个分流设备。
model Device {
id Int @id @default(autoincrement())
snCode String @unique
@ -109,6 +118,7 @@ model Device {
@@index([patientId, status])
}
// 主任务表:记录调压任务主单。
model Task {
id Int @id @default(autoincrement())
status TaskStatus @default(PENDING)
@ -124,6 +134,7 @@ model Task {
@@index([hospitalId, status, createdAt])
}
// 任务明细表:一个任务可包含多个设备调压项。
model TaskItem {
id Int @id @default(autoincrement())
taskId Int

View File

@ -5,6 +5,8 @@ import { UsersModule } from './users/users.module.js';
import { TasksModule } from './tasks/tasks.module.js';
import { PatientsModule } from './patients/patients.module.js';
import { AuthModule } from './auth/auth.module.js';
import { OrganizationModule } from './organization/organization.module.js';
import { NotificationsModule } from './notifications/notifications.module.js';
@Module({
imports: [
@ -14,6 +16,8 @@ import { AuthModule } from './auth/auth.module.js';
TasksModule,
PatientsModule,
AuthModule,
OrganizationModule,
NotificationsModule,
],
})
export class AppModule {}

View File

@ -6,10 +6,17 @@ import {
} from '@nestjs/common';
import jwt from 'jsonwebtoken';
import { Role } from '../generated/prisma/enums.js';
import { ActorContext } from '../common/actor-context.js';
import type { ActorContext } from '../common/actor-context.js';
import { MESSAGES } from '../common/messages.js';
/**
* AccessToken Bearer JWT actor request
*/
@Injectable()
export class AccessTokenGuard implements CanActivate {
/**
* true 401
*/
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<
{
@ -24,7 +31,7 @@ export class AccessTokenGuard implements CanActivate {
: authorization;
if (!headerValue || !headerValue.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing bearer token');
throw new UnauthorizedException(MESSAGES.AUTH.MISSING_BEARER);
}
const token = headerValue.slice('Bearer '.length).trim();
@ -33,10 +40,13 @@ export class AccessTokenGuard implements CanActivate {
return true;
}
/**
* token actor
*/
private verifyAndExtractActor(token: string): ActorContext {
const secret = process.env.AUTH_TOKEN_SECRET;
if (!secret) {
throw new UnauthorizedException('AUTH_TOKEN_SECRET is not configured');
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_SECRET_MISSING);
}
let payload: string | jwt.JwtPayload;
@ -46,16 +56,16 @@ export class AccessTokenGuard implements CanActivate {
issuer: 'tyt-api-nest',
});
} catch {
throw new UnauthorizedException('Invalid or expired token');
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_INVALID);
}
if (typeof payload !== 'object') {
throw new UnauthorizedException('Invalid token payload');
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_PAYLOAD_INVALID);
}
const role = payload.role;
if (typeof role !== 'string' || !Object.values(Role).includes(role as Role)) {
throw new UnauthorizedException('Invalid role in token');
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_ROLE_INVALID);
}
return {
@ -67,19 +77,25 @@ export class AccessTokenGuard implements CanActivate {
};
}
/**
* token
*/
private asInt(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isInteger(value)) {
throw new UnauthorizedException(`Invalid ${field} in token`);
throw new UnauthorizedException(`${MESSAGES.AUTH.TOKEN_FIELD_INVALID}: ${field}`);
}
return value;
}
/**
* token
*/
private asNullableInt(value: unknown, field: string): number | null {
if (value === null || value === undefined) {
return null;
}
if (typeof value !== 'number' || !Number.isInteger(value)) {
throw new UnauthorizedException(`Invalid ${field} in token`);
throw new UnauthorizedException(`${MESSAGES.AUTH.TOKEN_FIELD_INVALID}: ${field}`);
}
return value;
}

View File

@ -1,4 +1,9 @@
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { AuthService } from './auth.service.js';
import { RegisterUserDto } from '../users/dto/register-user.dto.js';
import { LoginDto } from '../users/dto/login.dto.js';
@ -6,22 +11,39 @@ import { AccessTokenGuard } from './access-token.guard.js';
import { CurrentActor } from './current-actor.decorator.js';
import type { ActorContext } from '../common/actor-context.js';
/**
*
*/
@ApiTags('认证')
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
/**
*
*/
@Post('register')
@ApiOperation({ summary: '注册账号' })
register(@Body() dto: RegisterUserDto) {
return this.authService.register(dto);
}
/**
* JWT
*/
@Post('login')
@ApiOperation({ summary: '登录' })
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
/**
*
*/
@Get('me')
@UseGuards(AccessTokenGuard)
@ApiBearerAuth('bearer')
@ApiOperation({ summary: '获取当前用户信息' })
me(@CurrentActor() actor: ActorContext) {
return this.authService.me(actor);
}

View File

@ -4,6 +4,9 @@ import { AuthController } from './auth.controller.js';
import { UsersModule } from '../users/users.module.js';
import { AccessTokenGuard } from './access-token.guard.js';
/**
*
*/
@Module({
imports: [UsersModule],
providers: [AuthService, AccessTokenGuard],

View File

@ -1,21 +1,33 @@
import { Injectable } from '@nestjs/common';
import { ActorContext } from '../common/actor-context.js';
import type { ActorContext } from '../common/actor-context.js';
import { UsersService } from '../users/users.service.js';
import { RegisterUserDto } from '../users/dto/register-user.dto.js';
import { LoginDto } from '../users/dto/login.dto.js';
/**
*
*/
@Injectable()
export class AuthService {
constructor(private readonly usersService: UsersService) {}
/**
*
*/
register(dto: RegisterUserDto) {
return this.usersService.register(dto);
}
/**
*
*/
login(dto: LoginDto) {
return this.usersService.login(dto);
}
/**
*
*/
me(actor: ActorContext) {
return this.usersService.me(actor);
}

View File

@ -1,6 +1,9 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { ActorContext } from '../common/actor-context.js';
import type { ActorContext } from '../common/actor-context.js';
/**
* request AccessTokenGuard actor
*/
export const CurrentActor = createParamDecorator(
(_data: unknown, context: ExecutionContext): ActorContext => {
const request = context.switchToHttp().getRequest<{ actor: ActorContext }>();

View File

@ -2,4 +2,8 @@ import { SetMetadata } from '@nestjs/common';
import { Role } from '../generated/prisma/enums.js';
export const ROLES_KEY = 'roles';
/**
* 访
*/
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);

View File

@ -7,11 +7,18 @@ import {
import { Reflector } from '@nestjs/core';
import { Role } from '../generated/prisma/enums.js';
import { ROLES_KEY } from './roles.decorator.js';
import { MESSAGES } from '../common/messages.js';
/**
* @Roles 访
*/
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
/**
*
*/
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
@ -25,7 +32,7 @@ export class RolesGuard implements CanActivate {
const request = context.switchToHttp().getRequest<{ actor?: { role?: Role } }>();
const actorRole = request.actor?.role;
if (!actorRole || !requiredRoles.includes(actorRole)) {
throw new ForbiddenException('Role is not allowed for this endpoint');
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
return true;

View 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
View 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;

View 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')
);
}
}

View 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;
});

View 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);
}
}

View 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 {}

View 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;
}
}
}

View 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;
}

View 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;
}

View File

@ -0,0 +1 @@
export class Department {}

View 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;
}

View 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;
}

View File

@ -0,0 +1 @@
export class Group {}

View 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);
}
}

View 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 {}

View 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;
}
}
}

View 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;
}

View File

@ -0,0 +1,7 @@
import { PartialType } from '@nestjs/swagger';
import { CreateHospitalDto } from './create-hospital.dto.js';
/**
* DTO
*/
export class UpdateHospitalDto extends PartialType(CreateHospitalDto) {}

View File

@ -0,0 +1 @@
export class Hospital {}

View 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);
}
}

View 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 {}

View 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;
}
}
}

View File

@ -1,9 +1,62 @@
import 'dotenv/config';
import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { AppModule } from './app.module.js';
import { HttpExceptionFilter } from './common/http-exception.filter.js';
import { MESSAGES } from './common/messages.js';
import { ResponseEnvelopeInterceptor } from './common/response-envelope.interceptor.js';
async function bootstrap() {
// 创建应用实例并加载核心模块。
const app = await NestFactory.create(AppModule);
// 全局校验:清理未知字段、自动类型转换,并将校验错误统一为中文信息。
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
exceptionFactory: (errors) => {
const messages = errors
.flatMap((error) => Object.values(error.constraints ?? {}))
.filter((item): item is string => Boolean(item));
return new BadRequestException(
messages.length > 0
? messages.join('')
: MESSAGES.DEFAULT_BAD_REQUEST,
);
},
}),
);
// 全局异常与成功响应统一格式化。
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new ResponseEnvelopeInterceptor());
// Swagger 文档:提供在线调试与 OpenAPI JSON 导出。
const swaggerConfig = new DocumentBuilder()
.setTitle('TYT 多租户医疗调压系统 API')
.setDescription('后端接口文档含认证、RBAC、任务流转与患者聚合')
.setVersion('1.0.0')
.addServer('http://localhost:3000', 'localhost')
.addBearerAuth(
{
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
description: '在此输入登录接口返回的 accessToken',
},
'bearer',
)
.build();
const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig);
SwaggerModule.setup('api/docs', app, swaggerDocument, {
jsonDocumentUrl: 'api/docs-json',
});
// 启动 HTTP 服务。
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

View 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 {}

View File

@ -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,
},
);
}
}

View 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} 无可用 openIdtaskId=${payload.taskId}`,
);
return;
}
for (const openId of targets) {
// TODO: 在此处调用微信服务号/小程序消息推送 API。
this.logger.log(
`模拟推送任务通知 event=${payload.event}, taskId=${payload.taskId}, openId=${openId}`,
);
}
}
}

View 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;
}

View 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);
}
}
}

View 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 {}

View File

@ -1,17 +1,26 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import type { ActorContext } from '../../common/actor-context.js';
import { CurrentActor } from '../../auth/current-actor.decorator.js';
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
import { RolesGuard } from '../../auth/roles.guard.js';
import { Roles } from '../../auth/roles.decorator.js';
import { Role } from '../../generated/prisma/enums.js';
import { PatientService } from '../patient/patient.service.js';
import { BPatientsService } from './b-patients.service.js';
/**
* B
*/
@ApiTags('患者管理(B端)')
@ApiBearerAuth('bearer')
@Controller('b/patients')
@UseGuards(AccessTokenGuard, RolesGuard)
export class BPatientsController {
constructor(private readonly patientService: PatientService) {}
constructor(private readonly patientsService: BPatientsService) {}
/**
*
*/
@Get()
@Roles(
Role.SYSTEM_ADMIN,
@ -20,6 +29,12 @@ export class BPatientsController {
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '按角色查询可见患者列表' })
@ApiQuery({
name: 'hospitalId',
required: false,
description: '系统管理员可显式指定医院',
})
findVisiblePatients(
@CurrentActor() actor: ActorContext,
@Query('hospitalId') hospitalId?: string,
@ -27,6 +42,6 @@ export class BPatientsController {
const requestedHospitalId =
hospitalId == null || hospitalId === '' ? undefined : Number(hospitalId);
return this.patientService.findPatientsForB(actor, requestedHospitalId);
return this.patientsService.findVisiblePatients(actor, requestedHospitalId);
}
}

View 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;
}
}

View File

@ -1,14 +1,25 @@
import { Controller, Get, Query } from '@nestjs/common';
import { PatientService } from '../patient/patient.service.js';
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { FamilyLifecycleQueryDto } from '../dto/family-lifecycle-query.dto.js';
import { CPatientsService } from './c-patients.service.js';
/**
* C
*/
@ApiTags('患者管理(C端)')
@Controller('c/patients')
export class CPatientsController {
constructor(private readonly patientService: PatientService) {}
constructor(private readonly patientsService: CPatientsService) {}
/**
*
*/
@Get('lifecycle')
@ApiOperation({ summary: '跨院患者生命周期查询' })
@ApiQuery({ name: 'phone', description: '手机号' })
@ApiQuery({ name: 'idCardHash', description: '身份证哈希' })
getLifecycle(@Query() query: FamilyLifecycleQueryDto) {
return this.patientService.getFamilyLifecycleByIdentity(
return this.patientsService.getFamilyLifecycleByIdentity(
query.phone,
query.idCardHash,
);

View 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;
}
}

View File

@ -1,4 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, Matches } from 'class-validator';
/**
* DTO
*/
export class FamilyLifecycleQueryDto {
@ApiProperty({ description: '手机号', example: '13800000003' })
@IsString({ message: 'phone 必须是字符串' })
@Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' })
phone!: string;
@ApiProperty({ description: '身份证哈希值', example: 'seed-id-card-hash' })
@IsString({ message: 'idCardHash 必须是字符串' })
idCardHash!: string;
}

View File

@ -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;
}
}

View File

@ -1,13 +1,14 @@
import { Module } from '@nestjs/common';
import { PatientService } from './patient/patient.service.js';
import { BPatientsController } from './b-patients/b-patients.controller.js';
import { CPatientsController } from './c-patients/c-patients.controller.js';
import { AccessTokenGuard } from '../auth/access-token.guard.js';
import { RolesGuard } from '../auth/roles.guard.js';
import { BPatientsService } from './b-patients/b-patients.service.js';
import { CPatientsService } from './c-patients/c-patients.service.js';
@Module({
providers: [PatientService, AccessTokenGuard, RolesGuard],
providers: [BPatientsService, CPatientsService, AccessTokenGuard, RolesGuard],
controllers: [BPatientsController, CPatientsController],
exports: [PatientService],
exports: [BPatientsService, CPatientsService],
})
export class PatientsModule {}

View File

@ -1,4 +1,5 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { ActorContext } from '../../common/actor-context.js';
import { CurrentActor } from '../../auth/current-actor.decorator.js';
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
@ -11,31 +12,52 @@ import { AcceptTaskDto } from '../dto/accept-task.dto.js';
import { CompleteTaskDto } from '../dto/complete-task.dto.js';
import { CancelTaskDto } from '../dto/cancel-task.dto.js';
/**
* B
*/
@ApiTags('调压任务(B端)')
@ApiBearerAuth('bearer')
@Controller('b/tasks')
@UseGuards(AccessTokenGuard, RolesGuard)
export class BTasksController {
constructor(private readonly taskService: TaskService) {}
/**
*
*/
@Post('publish')
@Roles(Role.DOCTOR)
@ApiOperation({ summary: '发布任务DOCTOR' })
publish(@CurrentActor() actor: ActorContext, @Body() dto: PublishTaskDto) {
return this.taskService.publishTask(actor, dto);
}
/**
*
*/
@Post('accept')
@Roles(Role.ENGINEER)
@ApiOperation({ summary: '接收任务ENGINEER' })
accept(@CurrentActor() actor: ActorContext, @Body() dto: AcceptTaskDto) {
return this.taskService.acceptTask(actor, dto);
}
/**
*
*/
@Post('complete')
@Roles(Role.ENGINEER)
@ApiOperation({ summary: '完成任务ENGINEER' })
complete(@CurrentActor() actor: ActorContext, @Body() dto: CompleteTaskDto) {
return this.taskService.completeTask(actor, dto);
}
/**
*
*/
@Post('cancel')
@Roles(Role.DOCTOR)
@ApiOperation({ summary: '取消任务DOCTOR' })
cancel(@CurrentActor() actor: ActorContext, @Body() dto: CancelTaskDto) {
return this.taskService.cancelTask(actor, dto);
}

View File

@ -1,3 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, Min } from 'class-validator';
/**
* DTO
*/
export class AcceptTaskDto {
@ApiProperty({ description: '任务 ID', example: 1 })
@Type(() => Number)
@IsInt({ message: 'taskId 必须是整数' })
@Min(1, { message: 'taskId 必须大于 0' })
taskId!: number;
}

View File

@ -1,3 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, Min } from 'class-validator';
/**
* DTO
*/
export class CancelTaskDto {
@ApiProperty({ description: '任务 ID', example: 1 })
@Type(() => Number)
@IsInt({ message: 'taskId 必须是整数' })
@Min(1, { message: 'taskId 必须大于 0' })
taskId!: number;
}

View File

@ -1,3 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, Min } from 'class-validator';
/**
* DTO
*/
export class CompleteTaskDto {
@ApiProperty({ description: '任务 ID', example: 1 })
@Type(() => Number)
@IsInt({ message: 'taskId 必须是整数' })
@Min(1, { message: 'taskId 必须大于 0' })
taskId!: number;
}

View File

@ -1,9 +1,47 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
import {
ArrayMinSize,
IsArray,
IsInt,
IsOptional,
Min,
ValidateNested,
} from 'class-validator';
/**
* DTO
*/
export class PublishTaskItemDto {
@ApiProperty({ description: '设备 ID', example: 1 })
@Type(() => Number)
@IsInt({ message: 'deviceId 必须是整数' })
@Min(1, { message: 'deviceId 必须大于 0' })
deviceId!: number;
@ApiProperty({ description: '目标压力值', example: 120 })
@Type(() => Number)
@IsInt({ message: 'targetPressure 必须是整数' })
targetPressure!: number;
}
/**
* DTO
*/
export class PublishTaskDto {
@ApiPropertyOptional({ description: '指定工程师 ID可选', example: 2 })
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'engineerId 必须是整数' })
@Min(1, { message: 'engineerId 必须大于 0' })
engineerId?: number;
@ApiProperty({ type: [PublishTaskItemDto], description: '任务明细列表' })
@IsArray({ message: 'items 必须是数组' })
@ArrayMinSize(1, { message: 'items 至少包含一条明细' })
@ValidateNested({ each: true })
@Type(() => PublishTaskItemDto)
items!: PublishTaskItemDto[];
}

View File

@ -8,12 +8,16 @@ import {
import { EventEmitter2 } from '@nestjs/event-emitter';
import { DeviceStatus, Role, TaskStatus } from '../generated/prisma/enums.js';
import { PrismaService } from '../prisma.service.js';
import { ActorContext } from '../common/actor-context.js';
import type { ActorContext } from '../common/actor-context.js';
import { PublishTaskDto } from './dto/publish-task.dto.js';
import { AcceptTaskDto } from './dto/accept-task.dto.js';
import { CompleteTaskDto } from './dto/complete-task.dto.js';
import { CancelTaskDto } from './dto/cancel-task.dto.js';
import { MESSAGES } from '../common/messages.js';
/**
*
*/
@Injectable()
export class TaskService {
constructor(
@ -21,24 +25,25 @@ export class TaskService {
private readonly eventEmitter: EventEmitter2,
) {}
/**
* PENDING
*/
async publishTask(actor: ActorContext, dto: PublishTaskDto) {
this.assertRole(actor, [Role.DOCTOR]);
const hospitalId = this.requireHospitalId(actor);
if (!Array.isArray(dto.items) || dto.items.length === 0) {
throw new BadRequestException('items is required');
throw new BadRequestException(MESSAGES.TASK.ITEMS_REQUIRED);
}
const deviceIds = Array.from(
new Set(
dto.items.map((item) => {
if (!Number.isInteger(item.deviceId)) {
throw new BadRequestException(`Invalid deviceId: ${item.deviceId}`);
throw new BadRequestException(`deviceId 非法: ${item.deviceId}`);
}
if (!Number.isInteger(item.targetPressure)) {
throw new BadRequestException(
`Invalid targetPressure: ${item.targetPressure}`,
);
throw new BadRequestException(`targetPressure 非法: ${item.targetPressure}`);
}
return item.deviceId;
}),
@ -55,7 +60,7 @@ export class TaskService {
});
if (devices.length !== deviceIds.length) {
throw new NotFoundException('Some devices are not found in actor hospital');
throw new NotFoundException(MESSAGES.TASK.DEVICE_NOT_FOUND);
}
if (dto.engineerId != null) {
@ -68,7 +73,7 @@ export class TaskService {
select: { id: true },
});
if (!engineer) {
throw new BadRequestException('engineerId must be a valid local engineer');
throw new BadRequestException(MESSAGES.TASK.ENGINEER_INVALID);
}
}
@ -103,6 +108,9 @@ export class TaskService {
return task;
}
/**
* PENDING ACCEPTED
*/
async acceptTask(actor: ActorContext, dto: AcceptTaskDto) {
this.assertRole(actor, [Role.ENGINEER]);
const hospitalId = this.requireHospitalId(actor);
@ -121,13 +129,13 @@ export class TaskService {
});
if (!task) {
throw new NotFoundException('Task not found in actor hospital');
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
}
if (task.status !== TaskStatus.PENDING) {
throw new ConflictException('Only pending task can be accepted');
throw new ConflictException(MESSAGES.TASK.ACCEPT_ONLY_PENDING);
}
if (task.engineerId != null && task.engineerId !== actor.id) {
throw new ForbiddenException('Task already assigned to another engineer');
throw new ForbiddenException(MESSAGES.TASK.ENGINEER_ALREADY_ASSIGNED);
}
const updatedTask = await this.prisma.task.update({
@ -149,6 +157,9 @@ export class TaskService {
return updatedTask;
}
/**
* COMPLETED
*/
async completeTask(actor: ActorContext, dto: CompleteTaskDto) {
this.assertRole(actor, [Role.ENGINEER]);
const hospitalId = this.requireHospitalId(actor);
@ -164,13 +175,13 @@ export class TaskService {
});
if (!task) {
throw new NotFoundException('Task not found in actor hospital');
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
}
if (task.status !== TaskStatus.ACCEPTED) {
throw new ConflictException('Only accepted task can be completed');
throw new ConflictException(MESSAGES.TASK.COMPLETE_ONLY_ACCEPTED);
}
if (task.engineerId !== actor.id) {
throw new ForbiddenException('Only the assigned engineer can complete task');
throw new ForbiddenException(MESSAGES.TASK.ENGINEER_ONLY_ASSIGNEE);
}
const completedTask = await this.prisma.$transaction(async (tx) => {
@ -202,6 +213,9 @@ export class TaskService {
return completedTask;
}
/**
* PENDING/ACCEPTED
*/
async cancelTask(actor: ActorContext, dto: CancelTaskDto) {
this.assertRole(actor, [Role.DOCTOR]);
const hospitalId = this.requireHospitalId(actor);
@ -220,16 +234,16 @@ export class TaskService {
});
if (!task) {
throw new NotFoundException('Task not found in actor hospital');
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
}
if (task.creatorId !== actor.id) {
throw new ForbiddenException('Only task creator can cancel task');
throw new ForbiddenException(MESSAGES.TASK.CANCEL_ONLY_CREATOR);
}
if (
task.status !== TaskStatus.PENDING &&
task.status !== TaskStatus.ACCEPTED
) {
throw new ConflictException('Only pending or accepted task can be cancelled');
throw new ConflictException(MESSAGES.TASK.CANCEL_ONLY_PENDING_ACCEPTED);
}
const cancelledTask = await this.prisma.task.update({
@ -248,15 +262,21 @@ export class TaskService {
return cancelledTask;
}
/**
*
*/
private assertRole(actor: ActorContext, allowedRoles: Role[]) {
if (!allowedRoles.includes(actor.role)) {
throw new ForbiddenException('Actor role is not allowed');
throw new ForbiddenException(MESSAGES.TASK.ACTOR_ROLE_FORBIDDEN);
}
}
/**
* hospitalIdB
*/
private requireHospitalId(actor: ActorContext): number {
if (!actor.hospitalId) {
throw new BadRequestException('Actor hospitalId is required');
throw new BadRequestException(MESSAGES.TASK.ACTOR_HOSPITAL_REQUIRED);
}
return actor.hospitalId;
}

View File

@ -1,4 +1,5 @@
import { Body, Controller, Param, Patch, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
import type { ActorContext } from '../../common/actor-context.js';
import { CurrentActor } from '../../auth/current-actor.decorator.js';
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
@ -8,13 +9,23 @@ import { Role } from '../../generated/prisma/enums.js';
import { UsersService } from '../users.service.js';
import { AssignEngineerHospitalDto } from '../dto/assign-engineer-hospital.dto.js';
/**
* B CRUD
*/
@ApiTags('用户管理(B端)')
@ApiBearerAuth('bearer')
@Controller('b/users')
@UseGuards(AccessTokenGuard, RolesGuard)
export class BUsersController {
constructor(private readonly usersService: UsersService) {}
/**
*
*/
@Patch(':id/assign-engineer-hospital')
@Roles(Role.SYSTEM_ADMIN)
@ApiOperation({ summary: '绑定工程师到医院SYSTEM_ADMIN' })
@ApiParam({ name: 'id', description: '工程师用户 ID' })
assignEngineerHospital(
@CurrentActor() actor: ActorContext,
@Param('id') id: string,

View File

@ -1,3 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, Min } from 'class-validator';
/**
* DTO
*/
export class AssignEngineerHospitalDto {
@ApiProperty({ description: '医院 ID', example: 1 })
@Type(() => Number)
@IsInt({ message: 'hospitalId 必须是整数' })
@Min(1, { message: 'hospitalId 必须大于 0' })
hospitalId!: number;
}

View File

@ -1,12 +1,66 @@
import { Role } from '../../generated/prisma/enums.js';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
import {
IsEnum,
IsInt,
IsOptional,
IsString,
Matches,
Min,
MinLength,
} from 'class-validator';
/**
* B DTO
*/
export class CreateUserDto {
@ApiProperty({ description: '用户姓名', example: '李医生' })
@IsString({ message: 'name 必须是字符串' })
name!: string;
@ApiProperty({ description: '手机号', example: '13800000002' })
@IsString({ message: 'phone 必须是字符串' })
@Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' })
phone!: string;
@ApiPropertyOptional({ description: '密码(可选)', example: 'Abcd1234' })
@IsOptional()
@IsString({ message: 'password 必须是字符串' })
@MinLength(8, { message: 'password 长度至少 8 位' })
password?: string;
@ApiPropertyOptional({ description: '微信 openId', example: 'wx-open-id-demo' })
@IsOptional()
@IsString({ message: 'openId 必须是字符串' })
openId?: string;
@ApiProperty({ description: '角色', enum: Role, example: Role.DOCTOR })
@IsEnum(Role, { message: 'role 枚举值不合法' })
role!: Role;
@ApiPropertyOptional({ description: '医院 ID', example: 1 })
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'hospitalId 必须是整数' })
@Min(1, { message: 'hospitalId 必须大于 0' })
hospitalId?: number;
@ApiPropertyOptional({ description: '科室 ID', example: 1 })
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'departmentId 必须是整数' })
@Min(1, { message: 'departmentId 必须大于 0' })
departmentId?: number;
@ApiPropertyOptional({ description: '小组 ID', example: 1 })
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'groupId 必须是整数' })
@Min(1, { message: 'groupId 必须大于 0' })
groupId?: number;
}

View File

@ -1,8 +1,40 @@
import { Role } from '../../generated/prisma/enums.js';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
import {
IsEnum,
IsInt,
IsOptional,
IsString,
Matches,
Min,
MinLength,
} from 'class-validator';
/**
* DTO
*/
export class LoginDto {
@ApiProperty({ description: '手机号', example: '13800000002' })
@IsString({ message: 'phone 必须是字符串' })
@Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' })
phone!: string;
@ApiProperty({ description: '密码', example: 'Abcd1234' })
@IsString({ message: 'password 必须是字符串' })
@MinLength(8, { message: 'password 长度至少 8 位' })
password!: string;
@ApiProperty({ description: '登录角色', enum: Role, example: Role.DOCTOR })
@IsEnum(Role, { message: 'role 枚举值不合法' })
role!: Role;
@ApiPropertyOptional({ description: '医院 ID多账号场景建议传入', example: 1 })
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'hospitalId 必须是整数' })
@Min(1, { message: 'hospitalId 必须大于 0' })
hospitalId?: number;
}

View File

@ -1,13 +1,76 @@
import { Role } from '../../generated/prisma/enums.js';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
import {
IsEnum,
IsInt,
IsOptional,
IsString,
Matches,
Min,
MinLength,
} from 'class-validator';
/**
* DTO
*/
export class RegisterUserDto {
@ApiProperty({ description: '用户姓名', example: '张三' })
@IsString({ message: 'name 必须是字符串' })
name!: string;
@ApiProperty({ description: '手机号(中国大陆)', example: '13800000001' })
@IsString({ message: 'phone 必须是字符串' })
@Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' })
phone!: string;
@ApiProperty({ description: '登录密码(至少 8 位)', example: 'Abcd1234' })
@IsString({ message: 'password 必须是字符串' })
@MinLength(8, { message: 'password 长度至少 8 位' })
password!: string;
@ApiProperty({ description: '系统角色', enum: Role, example: Role.DOCTOR })
@IsEnum(Role, { message: 'role 枚举值不合法' })
role!: Role;
@ApiPropertyOptional({
description: '微信 openId可选',
example: 'wx-open-id-demo',
})
@IsOptional()
@IsString({ message: 'openId 必须是字符串' })
openId?: string;
@ApiPropertyOptional({ description: '所属医院 ID', example: 1 })
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'hospitalId 必须是整数' })
@Min(1, { message: 'hospitalId 必须大于 0' })
hospitalId?: number;
@ApiPropertyOptional({ description: '所属科室 ID', example: 1 })
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'departmentId 必须是整数' })
@Min(1, { message: 'departmentId 必须大于 0' })
departmentId?: number;
@ApiPropertyOptional({ description: '所属小组 ID', example: 1 })
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'groupId 必须是整数' })
@Min(1, { message: 'groupId 必须大于 0' })
groupId?: number;
@ApiPropertyOptional({
description: '系统管理员注册引导密钥(仅注册 SYSTEM_ADMIN 需要)',
example: 'admin-bootstrap-key',
})
@IsOptional()
@IsString({ message: 'systemAdminBootstrapKey 必须是字符串' })
systemAdminBootstrapKey?: string;
}

View File

@ -1,4 +1,7 @@
import { PartialType } from '@nestjs/mapped-types';
import { PartialType } from '@nestjs/swagger';
import { CreateUserDto } from './create-user.dto.js';
/**
* DTO
*/
export class UpdateUserDto extends PartialType(CreateUserDto) {}

View File

@ -8,6 +8,12 @@ import {
Param,
Delete,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import { AccessTokenGuard } from '../auth/access-token.guard.js';
import { RolesGuard } from '../auth/roles.guard.js';
import { Roles } from '../auth/roles.decorator.js';
@ -16,37 +22,65 @@ import { UsersService } from './users.service.js';
import { CreateUserDto } from './dto/create-user.dto.js';
import { UpdateUserDto } from './dto/update-user.dto.js';
/**
* B CRUD
*/
@ApiTags('用户管理(B端)')
@ApiBearerAuth('bearer')
@Controller('users')
@UseGuards(AccessTokenGuard, RolesGuard)
export class UsersController {
constructor(private readonly usersService: UsersService) {}
/**
*
*/
@Post()
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '创建用户' })
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
/**
*
*/
@Get()
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '查询用户列表' })
findAll() {
return this.usersService.findAll();
}
/**
*
*/
@Get(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '查询用户详情' })
@ApiParam({ name: 'id', description: '用户 ID' })
findOne(@Param('id') id: string) {
return this.usersService.findOne(+id);
}
/**
*
*/
@Patch(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '更新用户' })
@ApiParam({ name: 'id', description: '用户 ID' })
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
return this.usersService.update(+id, updateUserDto);
}
/**
*
*/
@Delete(':id')
@Roles(Role.SYSTEM_ADMIN)
@ApiOperation({ summary: '删除用户' })
@ApiParam({ name: 'id', description: '用户 ID' })
remove(@Param('id') id: string) {
return this.usersService.remove(+id);
}

View File

@ -12,10 +12,11 @@ import { CreateUserDto } from './dto/create-user.dto.js';
import { UpdateUserDto } from './dto/update-user.dto.js';
import { Role } from '../generated/prisma/enums.js';
import { PrismaService } from '../prisma.service.js';
import { ActorContext } from '../common/actor-context.js';
import type { ActorContext } from '../common/actor-context.js';
import { AssignEngineerHospitalDto } from './dto/assign-engineer-hospital.dto.js';
import { RegisterUserDto } from './dto/register-user.dto.js';
import { LoginDto } from './dto/login.dto.js';
import { MESSAGES } from '../common/messages.js';
const SAFE_USER_SELECT = {
id: true,
@ -32,6 +33,9 @@ const SAFE_USER_SELECT = {
export class UsersService {
constructor(private readonly prisma: PrismaService) {}
/**
* bcrypt
*/
async register(dto: RegisterUserDto) {
const role = this.normalizeRole(dto.role);
const name = this.normalizeRequiredString(dto.name, 'name');
@ -67,6 +71,9 @@ export class UsersService {
});
}
/**
* + JWT
*/
async login(dto: LoginDto) {
const role = this.normalizeRole(dto.role);
const phone = this.normalizePhone(dto.phone);
@ -87,22 +94,22 @@ export class UsersService {
});
if (users.length === 0) {
throw new UnauthorizedException('Invalid phone/role/password');
throw new UnauthorizedException(MESSAGES.AUTH.INVALID_CREDENTIALS);
}
if (users.length > 1 && hospitalId == null) {
throw new BadRequestException(
'Multiple accounts found. Please specify hospitalId',
MESSAGES.USER.MULTI_ACCOUNT_REQUIRE_HOSPITAL,
);
}
const user = users[0];
if (!user?.passwordHash) {
throw new UnauthorizedException('Password login is not enabled');
throw new UnauthorizedException(MESSAGES.AUTH.PASSWORD_NOT_ENABLED);
}
const matched = await compare(password, user.passwordHash);
if (!matched) {
throw new UnauthorizedException('Invalid phone/role/password');
throw new UnauthorizedException(MESSAGES.AUTH.INVALID_CREDENTIALS);
}
const actor: ActorContext = {
@ -121,10 +128,16 @@ export class UsersService {
};
}
/**
*
*/
async me(actor: ActorContext) {
return this.findOne(actor.id);
}
/**
* B 使
*/
async create(createUserDto: CreateUserDto) {
const role = this.normalizeRole(createUserDto.role);
const name = this.normalizeRequiredString(createUserDto.name, 'name');
@ -162,6 +175,9 @@ export class UsersService {
});
}
/**
*
*/
async findAll() {
return this.prisma.user.findMany({
select: SAFE_USER_SELECT,
@ -169,6 +185,9 @@ export class UsersService {
});
}
/**
*
*/
async findOne(id: number) {
const userId = this.normalizeRequiredInt(id, 'id');
@ -177,12 +196,15 @@ export class UsersService {
select: SAFE_USER_SELECT,
});
if (!user) {
throw new NotFoundException('User not found');
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
}
return user;
}
/**
*
*/
async update(id: number, updateUserDto: UpdateUserDto) {
const userId = this.normalizeRequiredInt(id, 'id');
const current = await this.prisma.user.findUnique({
@ -193,7 +215,7 @@ export class UsersService {
},
});
if (!current) {
throw new NotFoundException('User not found');
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
}
const nextRole =
@ -211,6 +233,13 @@ export class UsersService {
? this.normalizeOptionalInt(updateUserDto.groupId, 'groupId')
: current.groupId;
const assigningDepartmentOrGroup =
(updateUserDto.departmentId !== undefined && nextDepartmentId != null) ||
(updateUserDto.groupId !== undefined && nextGroupId != null);
if (assigningDepartmentOrGroup && nextRole !== Role.DOCTOR) {
throw new BadRequestException(MESSAGES.USER.DOCTOR_ONLY_SCOPE_CHANGE);
}
await this.assertOrganizationScope(
nextRole,
nextHospitalId,
@ -270,6 +299,9 @@ export class UsersService {
});
}
/**
*
*/
async remove(id: number) {
const userId = this.normalizeRequiredInt(id, 'id');
await this.findOne(userId);
@ -280,18 +312,19 @@ export class UsersService {
});
}
/**
*
*/
async assignEngineerHospital(
actor: ActorContext,
targetUserId: number,
dto: AssignEngineerHospitalDto,
) {
if (actor.role !== Role.SYSTEM_ADMIN) {
throw new ForbiddenException(
'Only SYSTEM_ADMIN can bind engineer to hospital',
);
throw new ForbiddenException(MESSAGES.USER.ENGINEER_BIND_FORBIDDEN);
}
if (!Number.isInteger(dto.hospitalId)) {
throw new BadRequestException('hospitalId must be an integer');
throw new BadRequestException(MESSAGES.USER.HOSPITAL_ID_INVALID);
}
const hospital = await this.prisma.hospital.findUnique({
@ -299,7 +332,7 @@ export class UsersService {
select: { id: true },
});
if (!hospital) {
throw new NotFoundException('Hospital not found');
throw new NotFoundException(MESSAGES.ORG.HOSPITAL_NOT_FOUND);
}
const user = await this.prisma.user.findUnique({
@ -307,10 +340,10 @@ export class UsersService {
select: { id: true, role: true },
});
if (!user) {
throw new NotFoundException('User not found');
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
}
if (user.role !== Role.ENGINEER) {
throw new BadRequestException('Target user is not ENGINEER');
throw new BadRequestException(MESSAGES.USER.TARGET_NOT_ENGINEER);
}
return this.prisma.user.update({
@ -324,11 +357,17 @@ export class UsersService {
});
}
/**
*
*/
private toSafeUser(user: { passwordHash?: string | null } & Record<string, unknown>) {
const { passwordHash, ...safe } = user;
return safe;
}
/**
*
*/
private assertSystemAdminBootstrapKey(
role: Role,
providedBootstrapKey?: string,
@ -339,13 +378,18 @@ export class UsersService {
const expectedBootstrapKey = process.env.SYSTEM_ADMIN_BOOTSTRAP_KEY;
if (!expectedBootstrapKey) {
throw new ForbiddenException('SYSTEM_ADMIN registration is disabled');
throw new ForbiddenException(MESSAGES.USER.SYSTEM_ADMIN_REG_DISABLED);
}
if (providedBootstrapKey !== expectedBootstrapKey) {
throw new ForbiddenException('Invalid system admin bootstrap key');
throw new ForbiddenException(
MESSAGES.USER.SYSTEM_ADMIN_BOOTSTRAP_KEY_INVALID,
);
}
}
/**
* + +
*/
private async assertPhoneRoleScopeUnique(
phone: string,
role: Role,
@ -361,12 +405,13 @@ export class UsersService {
select: { id: true },
});
if (exists && exists.id !== selfId) {
throw new ConflictException(
'User with same phone/role/hospital already exists',
);
throw new ConflictException(MESSAGES.USER.DUPLICATE_PHONE_ROLE_SCOPE);
}
}
/**
* openId
*/
private async assertOpenIdUnique(openId: string | null, selfId?: number) {
if (!openId) {
return;
@ -377,10 +422,13 @@ export class UsersService {
select: { id: true },
});
if (exists && exists.id !== selfId) {
throw new ConflictException('openId already registered');
throw new ConflictException(MESSAGES.USER.DUPLICATE_OPEN_ID);
}
}
/**
*
*/
private async assertOrganizationScope(
role: Role,
hospitalId: number | null,
@ -389,15 +437,13 @@ export class UsersService {
) {
if (role === Role.SYSTEM_ADMIN) {
if (hospitalId || departmentId || groupId) {
throw new BadRequestException(
'SYSTEM_ADMIN must not bind hospital/department/group',
);
throw new BadRequestException(MESSAGES.USER.SYSTEM_ADMIN_SCOPE_INVALID);
}
return;
}
if (!hospitalId) {
throw new BadRequestException('hospitalId is required');
throw new BadRequestException(MESSAGES.USER.HOSPITAL_REQUIRED);
}
const hospital = await this.prisma.hospital.findUnique({
@ -405,24 +451,22 @@ export class UsersService {
select: { id: true },
});
if (!hospital) {
throw new BadRequestException('hospitalId does not exist');
throw new BadRequestException(MESSAGES.USER.HOSPITAL_NOT_FOUND);
}
const needsDepartment =
role === Role.DIRECTOR || role === Role.LEADER || role === Role.DOCTOR;
if (needsDepartment && !departmentId) {
throw new BadRequestException('departmentId is required for role');
throw new BadRequestException(MESSAGES.USER.DEPARTMENT_REQUIRED);
}
const needsGroup = role === Role.LEADER || role === Role.DOCTOR;
if (needsGroup && !groupId) {
throw new BadRequestException('groupId is required for role');
throw new BadRequestException(MESSAGES.USER.GROUP_REQUIRED);
}
if (role === Role.ENGINEER && (departmentId || groupId)) {
throw new BadRequestException(
'ENGINEER should not bind departmentId/groupId',
);
throw new BadRequestException(MESSAGES.USER.ENGINEER_SCOPE_INVALID);
}
if (departmentId) {
@ -431,36 +475,38 @@ export class UsersService {
select: { id: true, hospitalId: true },
});
if (!department || department.hospitalId !== hospitalId) {
throw new BadRequestException(
'departmentId does not belong to hospitalId',
);
throw new BadRequestException(MESSAGES.USER.DEPARTMENT_HOSPITAL_MISMATCH);
}
}
if (groupId) {
if (!departmentId) {
throw new BadRequestException('groupId requires departmentId');
throw new BadRequestException(MESSAGES.USER.GROUP_DEPARTMENT_REQUIRED);
}
const group = await this.prisma.group.findUnique({
where: { id: groupId },
select: { id: true, departmentId: true },
});
if (!group || group.departmentId !== departmentId) {
throw new BadRequestException(
'groupId does not belong to departmentId',
);
throw new BadRequestException(MESSAGES.USER.GROUP_DEPARTMENT_MISMATCH);
}
}
}
/**
*
*/
private normalizeRequiredInt(value: unknown, fieldName: string): number {
const parsed = Number(value);
if (!Number.isInteger(parsed)) {
throw new BadRequestException(`${fieldName} must be an integer`);
throw new BadRequestException(`${fieldName} 必须为整数`);
}
return parsed;
}
/**
*
*/
private normalizeOptionalInt(
value: unknown,
fieldName: string,
@ -471,61 +517,79 @@ export class UsersService {
return this.normalizeRequiredInt(value, fieldName);
}
/**
*
*/
private normalizeRequiredString(value: unknown, fieldName: string): string {
if (typeof value !== 'string') {
throw new BadRequestException(`${fieldName} must be a string`);
throw new BadRequestException(`${fieldName} 必须为字符串`);
}
const trimmed = value.trim();
if (!trimmed) {
throw new BadRequestException(`${fieldName} is required`);
throw new BadRequestException(`${fieldName} 不能为空`);
}
return trimmed;
}
/**
*
*/
private normalizeOptionalString(value: unknown): string | null {
if (value === undefined || value === null) {
return null;
}
if (typeof value !== 'string') {
throw new BadRequestException('openId must be a string');
throw new BadRequestException(MESSAGES.USER.INVALID_OPEN_ID);
}
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
/**
*
*/
private normalizePhone(phone: unknown): string {
const normalized = this.normalizeRequiredString(phone, 'phone');
if (!/^1\d{10}$/.test(normalized)) {
throw new BadRequestException('phone must be a valid CN mobile number');
throw new BadRequestException(MESSAGES.USER.INVALID_PHONE);
}
return normalized;
}
/**
*
*/
private normalizePassword(password: unknown): string {
const normalized = this.normalizeRequiredString(password, 'password');
if (normalized.length < 8) {
throw new BadRequestException('password must be at least 8 characters');
throw new BadRequestException(MESSAGES.USER.INVALID_PASSWORD);
}
return normalized;
}
/**
*
*/
private normalizeRole(role: unknown): Role {
if (typeof role !== 'string') {
throw new BadRequestException('role must be a string enum');
throw new BadRequestException(MESSAGES.USER.INVALID_ROLE);
}
if (!Object.values(Role).includes(role as Role)) {
throw new BadRequestException(`invalid role: ${role}`);
throw new BadRequestException(MESSAGES.USER.INVALID_ROLE);
}
return role as Role;
}
/**
* 访
*/
private signAccessToken(actor: ActorContext): string {
const secret = process.env.AUTH_TOKEN_SECRET;
if (!secret) {
throw new UnauthorizedException('AUTH_TOKEN_SECRET is not configured');
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_SECRET_MISSING);
}
return jwt.sign(actor, secret, {