diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..25db389 --- /dev/null +++ b/AGENTS.md @@ -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,不要直接改配置文件,最后发给我安装命令,让我执行,中文注释和文档 diff --git a/README.md b/README.md new file mode 100644 index 0000000..5a23149 --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# 多租户医疗调压系统后端(NestJS + Prisma) + +本项目是医疗调压系统后端 MVP,支持 B 端(医院内部)与 C 端(家属跨院视图)两套接口语义。 + +## 1. 技术栈 + +- NestJS(模块化后端框架) +- Prisma(ORM + Schema 管理) +- PostgreSQL/MySQL(按 `.env` 的 `DATABASE_URL` 决定) +- JWT(认证) +- Swagger(接口文档) + +## 2. 目录结构 + +```text +src/ + auth/ 认证与鉴权(JWT、Guard、RBAC) + users/ 用户与角色管理 + tasks/ 调压任务流转(发布/接收/完成/取消) + patients/ 患者查询(B 端范围 + C 端聚合) + hospitals/ 医院管理模块 + departments/ 科室管理模块 + groups/ 小组管理模块 + organization-common/ 组织域共享 DTO/权限校验能力 + organization/ 组织域聚合模块(仅负责引入子模块) + common/ 全局响应、异常、消息常量 + generated/prisma/ Prisma 生成代码 +prisma/ + schema.prisma 数据模型定义 +docs/ + auth.md + users.md + tasks.md + patients.md +``` + +## 3. 环境变量 + +请在项目根目录创建 `.env`: + +```env +DATABASE_URL="postgresql://user:password@127.0.0.1:5432/tyt?schema=public" +JWT_SECRET="请替换为强随机密钥" +JWT_EXPIRES_IN="7d" +SYSTEM_ADMIN_BOOTSTRAP_KEY="初始化系统管理员用密钥" +``` + +## 4. 启动流程 + +```bash +pnpm install +pnpm prisma generate +pnpm prisma migrate dev +pnpm start:dev +``` + +## 5. 统一响应规范 + +- 成功:`{ code: 0, msg: "成功", data: ... }` +- 失败:`{ code: 4xx/5xx, msg: "中文错误信息", data: null }` + +已通过全局拦截器与全局异常过滤器统一输出。 + +## 6. API 文档 + +- Swagger UI: `/api/docs` +- OpenAPI JSON: `/api/docs-json` +- 鉴权头:`Authorization: Bearer ` + +## 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 的权限断言。 diff --git a/docs/auth.md b/docs/auth.md new file mode 100644 index 0000000..f7274f5 --- /dev/null +++ b/docs/auth.md @@ -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 ` +- 载荷关键字段:`sub`、`role`、`hospitalId`、`departmentId`、`groupId` + +## 5. 错误码与中文消息 + +- 未登录/Token 失效:`401` + 中文 `msg` +- 角色无权限:`403` + 中文 `msg` +- 参数非法:`400` + 中文 `msg` + +统一由全局异常过滤器输出:`{ code, msg, data: null }`。 diff --git a/docs/patients.md b/docs/patients.md new file mode 100644 index 0000000..461eda7 --- /dev/null +++ b/docs/patients.md @@ -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 }` diff --git a/docs/tasks.md b/docs/tasks.md new file mode 100644 index 0000000..7139888 --- /dev/null +++ b/docs/tasks.md @@ -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` + +确保任务状态与设备压力一致性。 diff --git a/docs/users.md b/docs/users.md new file mode 100644 index 0000000..09b343e --- /dev/null +++ b/docs/users.md @@ -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 中文说明 diff --git a/package.json b/package.json index 906eae9..7e05ba2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1fff09d..388444a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/prisma/migrations/20260312174732_mvp_schema_sync/migration.sql b/prisma/migrations/20260312174732_mvp_schema_sync/migration.sql new file mode 100644 index 0000000..38b798b --- /dev/null +++ b/prisma/migrations/20260312174732_mvp_schema_sync/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0e85b5c..aae5e93 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app.module.ts b/src/app.module.ts index e8810f8..26f76ce 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 {} diff --git a/src/auth/access-token.guard.ts b/src/auth/access-token.guard.ts index 991e29f..d5fec90 100644 --- a/src/auth/access-token.guard.ts +++ b/src/auth/access-token.guard.ts @@ -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; } diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index da40fad..1a76ae1 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -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); } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 98ccd58..21e3008 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -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], diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index bb866f1..508e229 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -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); } diff --git a/src/auth/current-actor.decorator.ts b/src/auth/current-actor.decorator.ts index b6cec52..ee36d85 100644 --- a/src/auth/current-actor.decorator.ts +++ b/src/auth/current-actor.decorator.ts @@ -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 }>(); diff --git a/src/auth/roles.decorator.ts b/src/auth/roles.decorator.ts index 7daf938..8da2975 100644 --- a/src/auth/roles.decorator.ts +++ b/src/auth/roles.decorator.ts @@ -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); diff --git a/src/auth/roles.guard.ts b/src/auth/roles.guard.ts index 1226d21..f558d70 100644 --- a/src/auth/roles.guard.ts +++ b/src/auth/roles.guard.ts @@ -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(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; diff --git a/src/common/http-exception.filter.ts b/src/common/http-exception.filter.ts new file mode 100644 index 0000000..16cc8c3 --- /dev/null +++ b/src/common/http-exception.filter.ts @@ -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(); + + // 非 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; + 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; + } + } +} diff --git a/src/common/messages.ts b/src/common/messages.ts new file mode 100644 index 0000000..d518d72 --- /dev/null +++ b/src/common/messages.ts @@ -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; diff --git a/src/common/response-envelope.interceptor.ts b/src/common/response-envelope.interceptor.ts new file mode 100644 index 0000000..0bbd6f1 --- /dev/null +++ b/src/common/response-envelope.interceptor.ts @@ -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; + return ( + typeof target.code === 'number' && + typeof target.msg === 'string' && + Object.prototype.hasOwnProperty.call(target, 'data') + ); + } +} diff --git a/src/common/transforms/empty-string-to-undefined.transform.ts b/src/common/transforms/empty-string-to-undefined.transform.ts new file mode 100644 index 0000000..bb91c7f --- /dev/null +++ b/src/common/transforms/empty-string-to-undefined.transform.ts @@ -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; + }); diff --git a/src/departments/departments.controller.ts b/src/departments/departments.controller.ts new file mode 100644 index 0000000..cbde73a --- /dev/null +++ b/src/departments/departments.controller.ts @@ -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); + } +} diff --git a/src/departments/departments.module.ts b/src/departments/departments.module.ts new file mode 100644 index 0000000..c796b8e --- /dev/null +++ b/src/departments/departments.module.ts @@ -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 {} diff --git a/src/departments/departments.service.ts b/src/departments/departments.service.ts new file mode 100644 index 0000000..10dcb05 --- /dev/null +++ b/src/departments/departments.service.ts @@ -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; + } + } +} diff --git a/src/departments/dto/create-department.dto.ts b/src/departments/dto/create-department.dto.ts new file mode 100644 index 0000000..8bcd7c2 --- /dev/null +++ b/src/departments/dto/create-department.dto.ts @@ -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; +} diff --git a/src/departments/dto/update-department.dto.ts b/src/departments/dto/update-department.dto.ts new file mode 100644 index 0000000..6255145 --- /dev/null +++ b/src/departments/dto/update-department.dto.ts @@ -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; +} diff --git a/src/departments/entities/department.entity.ts b/src/departments/entities/department.entity.ts new file mode 100644 index 0000000..64d5c20 --- /dev/null +++ b/src/departments/entities/department.entity.ts @@ -0,0 +1 @@ +export class Department {} diff --git a/src/groups/dto/create-group.dto.ts b/src/groups/dto/create-group.dto.ts new file mode 100644 index 0000000..28e5a2a --- /dev/null +++ b/src/groups/dto/create-group.dto.ts @@ -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; +} diff --git a/src/groups/dto/update-group.dto.ts b/src/groups/dto/update-group.dto.ts new file mode 100644 index 0000000..e270061 --- /dev/null +++ b/src/groups/dto/update-group.dto.ts @@ -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; +} diff --git a/src/groups/entities/group.entity.ts b/src/groups/entities/group.entity.ts new file mode 100644 index 0000000..f087764 --- /dev/null +++ b/src/groups/entities/group.entity.ts @@ -0,0 +1 @@ +export class Group {} diff --git a/src/groups/groups.controller.ts b/src/groups/groups.controller.ts new file mode 100644 index 0000000..70b6f72 --- /dev/null +++ b/src/groups/groups.controller.ts @@ -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); + } +} diff --git a/src/groups/groups.module.ts b/src/groups/groups.module.ts new file mode 100644 index 0000000..c35889a --- /dev/null +++ b/src/groups/groups.module.ts @@ -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 {} diff --git a/src/groups/groups.service.ts b/src/groups/groups.service.ts new file mode 100644 index 0000000..7175600 --- /dev/null +++ b/src/groups/groups.service.ts @@ -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; + } + } +} diff --git a/src/hospitals/dto/create-hospital.dto.ts b/src/hospitals/dto/create-hospital.dto.ts new file mode 100644 index 0000000..723640b --- /dev/null +++ b/src/hospitals/dto/create-hospital.dto.ts @@ -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; +} diff --git a/src/hospitals/dto/update-hospital.dto.ts b/src/hospitals/dto/update-hospital.dto.ts new file mode 100644 index 0000000..8c859c8 --- /dev/null +++ b/src/hospitals/dto/update-hospital.dto.ts @@ -0,0 +1,7 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateHospitalDto } from './create-hospital.dto.js'; + +/** + * 更新医院 DTO。 + */ +export class UpdateHospitalDto extends PartialType(CreateHospitalDto) {} diff --git a/src/hospitals/entities/hospital.entity.ts b/src/hospitals/entities/hospital.entity.ts new file mode 100644 index 0000000..de0bc48 --- /dev/null +++ b/src/hospitals/entities/hospital.entity.ts @@ -0,0 +1 @@ +export class Hospital {} diff --git a/src/hospitals/hospitals.controller.ts b/src/hospitals/hospitals.controller.ts new file mode 100644 index 0000000..892190c --- /dev/null +++ b/src/hospitals/hospitals.controller.ts @@ -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); + } +} diff --git a/src/hospitals/hospitals.module.ts b/src/hospitals/hospitals.module.ts new file mode 100644 index 0000000..e491dc6 --- /dev/null +++ b/src/hospitals/hospitals.module.ts @@ -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 {} diff --git a/src/hospitals/hospitals.service.ts b/src/hospitals/hospitals.service.ts new file mode 100644 index 0000000..a499db3 --- /dev/null +++ b/src/hospitals/hospitals.service.ts @@ -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; + } + } +} diff --git a/src/main.ts b/src/main.ts index 79e74b1..4a57d29 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,9 +1,62 @@ import 'dotenv/config'; +import { BadRequestException, ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { AppModule } from './app.module.js'; +import { HttpExceptionFilter } from './common/http-exception.filter.js'; +import { MESSAGES } from './common/messages.js'; +import { ResponseEnvelopeInterceptor } from './common/response-envelope.interceptor.js'; async function bootstrap() { + // 创建应用实例并加载核心模块。 const app = await NestFactory.create(AppModule); + + // 全局校验:清理未知字段、自动类型转换,并将校验错误统一为中文信息。 + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + exceptionFactory: (errors) => { + const messages = errors + .flatMap((error) => Object.values(error.constraints ?? {})) + .filter((item): item is string => Boolean(item)); + return new BadRequestException( + messages.length > 0 + ? messages.join(';') + : MESSAGES.DEFAULT_BAD_REQUEST, + ); + }, + }), + ); + + // 全局异常与成功响应统一格式化。 + app.useGlobalFilters(new HttpExceptionFilter()); + app.useGlobalInterceptors(new ResponseEnvelopeInterceptor()); + + // Swagger 文档:提供在线调试与 OpenAPI JSON 导出。 + const swaggerConfig = new DocumentBuilder() + .setTitle('TYT 多租户医疗调压系统 API') + .setDescription('后端接口文档(含认证、RBAC、任务流转与患者聚合)') + .setVersion('1.0.0') + .addServer('http://localhost:3000', 'localhost') + .addBearerAuth( + { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: '在此输入登录接口返回的 accessToken', + }, + 'bearer', + ) + .build(); + + const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig); + SwaggerModule.setup('api/docs', app, swaggerDocument, { + jsonDocumentUrl: 'api/docs-json', + }); + + // 启动 HTTP 服务。 await app.listen(process.env.PORT ?? 3000); } bootstrap(); diff --git a/src/notifications/notifications.module.ts b/src/notifications/notifications.module.ts new file mode 100644 index 0000000..cda756c --- /dev/null +++ b/src/notifications/notifications.module.ts @@ -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 {} diff --git a/src/notifications/task-events.listener/task-events.listener.ts b/src/notifications/task-events.listener/task-events.listener.ts new file mode 100644 index 0000000..0934fb9 --- /dev/null +++ b/src/notifications/task-events.listener/task-events.listener.ts @@ -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, + }, + ); + } +} diff --git a/src/notifications/wechat-notify/wechat-notify.service.ts b/src/notifications/wechat-notify/wechat-notify.service.ts new file mode 100644 index 0000000..fabe3bf --- /dev/null +++ b/src/notifications/wechat-notify/wechat-notify.service.ts @@ -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, payload: TaskNotifyPayload) { + const targets = Array.from( + new Set( + openIds + .map((item) => item?.trim()) + .filter((item): item is string => Boolean(item)), + ), + ); + + if (targets.length === 0) { + this.logger.warn( + `任务事件 ${payload.event} 无可用 openId,taskId=${payload.taskId}`, + ); + return; + } + + for (const openId of targets) { + // TODO: 在此处调用微信服务号/小程序消息推送 API。 + this.logger.log( + `模拟推送任务通知 event=${payload.event}, taskId=${payload.taskId}, openId=${openId}`, + ); + } + } +} diff --git a/src/organization-common/dto/organization-query.dto.ts b/src/organization-common/dto/organization-query.dto.ts new file mode 100644 index 0000000..7dc3001 --- /dev/null +++ b/src/organization-common/dto/organization-query.dto.ts @@ -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; +} diff --git a/src/organization-common/organization-access.service.ts b/src/organization-common/organization-access.service.ts new file mode 100644 index 0000000..fb584f9 --- /dev/null +++ b/src/organization-common/organization-access.service.ts @@ -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); + } + } +} diff --git a/src/organization/organization.module.ts b/src/organization/organization.module.ts new file mode 100644 index 0000000..3190efd --- /dev/null +++ b/src/organization/organization.module.ts @@ -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 {} diff --git a/src/patients/b-patients/b-patients.controller.ts b/src/patients/b-patients/b-patients.controller.ts index 95c9c37..2f57801 100644 --- a/src/patients/b-patients/b-patients.controller.ts +++ b/src/patients/b-patients/b-patients.controller.ts @@ -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); } } diff --git a/src/patients/b-patients/b-patients.service.ts b/src/patients/b-patients/b-patients.service.ts new file mode 100644 index 0000000..9614d85 --- /dev/null +++ b/src/patients/b-patients/b-patients.service.ts @@ -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 = { 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; + } +} diff --git a/src/patients/c-patients/c-patients.controller.ts b/src/patients/c-patients/c-patients.controller.ts index a1e1ffc..c307128 100644 --- a/src/patients/c-patients/c-patients.controller.ts +++ b/src/patients/c-patients/c-patients.controller.ts @@ -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, ); diff --git a/src/patients/c-patients/c-patients.service.ts b/src/patients/c-patients/c-patients.service.ts new file mode 100644 index 0000000..a99db5d --- /dev/null +++ b/src/patients/c-patients/c-patients.service.ts @@ -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; + } +} diff --git a/src/patients/dto/family-lifecycle-query.dto.ts b/src/patients/dto/family-lifecycle-query.dto.ts index 6b47cab..f287179 100644 --- a/src/patients/dto/family-lifecycle-query.dto.ts +++ b/src/patients/dto/family-lifecycle-query.dto.ts @@ -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; } diff --git a/src/patients/patient/patient.service.ts b/src/patients/patient/patient.service.ts deleted file mode 100644 index b57b350..0000000 --- a/src/patients/patient/patient.service.ts +++ /dev/null @@ -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 = { 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; - } -} diff --git a/src/patients/patients.module.ts b/src/patients/patients.module.ts index b6131f6..8011383 100644 --- a/src/patients/patients.module.ts +++ b/src/patients/patients.module.ts @@ -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 {} diff --git a/src/tasks/b-tasks/b-tasks.controller.ts b/src/tasks/b-tasks/b-tasks.controller.ts index b1b47ba..82f61ab 100644 --- a/src/tasks/b-tasks/b-tasks.controller.ts +++ b/src/tasks/b-tasks/b-tasks.controller.ts @@ -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); } diff --git a/src/tasks/dto/accept-task.dto.ts b/src/tasks/dto/accept-task.dto.ts index c07fc8a..7a2b5dc 100644 --- a/src/tasks/dto/accept-task.dto.ts +++ b/src/tasks/dto/accept-task.dto.ts @@ -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; } diff --git a/src/tasks/dto/cancel-task.dto.ts b/src/tasks/dto/cancel-task.dto.ts index 5c157ef..a3c9430 100644 --- a/src/tasks/dto/cancel-task.dto.ts +++ b/src/tasks/dto/cancel-task.dto.ts @@ -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; } diff --git a/src/tasks/dto/complete-task.dto.ts b/src/tasks/dto/complete-task.dto.ts index 3447620..305e192 100644 --- a/src/tasks/dto/complete-task.dto.ts +++ b/src/tasks/dto/complete-task.dto.ts @@ -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; } diff --git a/src/tasks/dto/publish-task.dto.ts b/src/tasks/dto/publish-task.dto.ts index 874d516..28a1cd6 100644 --- a/src/tasks/dto/publish-task.dto.ts +++ b/src/tasks/dto/publish-task.dto.ts @@ -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[]; } diff --git a/src/tasks/task.service.ts b/src/tasks/task.service.ts index efac764..6e8d551 100644 --- a/src/tasks/task.service.ts +++ b/src/tasks/task.service.ts @@ -8,12 +8,16 @@ import { import { EventEmitter2 } from '@nestjs/event-emitter'; import { DeviceStatus, Role, TaskStatus } from '../generated/prisma/enums.js'; import { PrismaService } from '../prisma.service.js'; -import { ActorContext } from '../common/actor-context.js'; +import type { ActorContext } from '../common/actor-context.js'; import { PublishTaskDto } from './dto/publish-task.dto.js'; import { AcceptTaskDto } from './dto/accept-task.dto.js'; import { CompleteTaskDto } from './dto/complete-task.dto.js'; import { CancelTaskDto } from './dto/cancel-task.dto.js'; +import { MESSAGES } from '../common/messages.js'; +/** + * 任务服务:封装调压任务状态机、院内隔离与事件发布逻辑。 + */ @Injectable() export class TaskService { constructor( @@ -21,24 +25,25 @@ export class TaskService { private readonly eventEmitter: EventEmitter2, ) {} + /** + * 发布任务:医生创建主任务与明细,状态初始化为 PENDING。 + */ async publishTask(actor: ActorContext, dto: PublishTaskDto) { this.assertRole(actor, [Role.DOCTOR]); const hospitalId = this.requireHospitalId(actor); if (!Array.isArray(dto.items) || dto.items.length === 0) { - throw new BadRequestException('items is required'); + throw new BadRequestException(MESSAGES.TASK.ITEMS_REQUIRED); } const deviceIds = Array.from( new Set( dto.items.map((item) => { if (!Number.isInteger(item.deviceId)) { - throw new BadRequestException(`Invalid deviceId: ${item.deviceId}`); + throw new BadRequestException(`deviceId 非法: ${item.deviceId}`); } if (!Number.isInteger(item.targetPressure)) { - throw new BadRequestException( - `Invalid targetPressure: ${item.targetPressure}`, - ); + throw new BadRequestException(`targetPressure 非法: ${item.targetPressure}`); } return item.deviceId; }), @@ -55,7 +60,7 @@ export class TaskService { }); if (devices.length !== deviceIds.length) { - throw new NotFoundException('Some devices are not found in actor hospital'); + throw new NotFoundException(MESSAGES.TASK.DEVICE_NOT_FOUND); } if (dto.engineerId != null) { @@ -68,7 +73,7 @@ export class TaskService { select: { id: true }, }); if (!engineer) { - throw new BadRequestException('engineerId must be a valid local engineer'); + throw new BadRequestException(MESSAGES.TASK.ENGINEER_INVALID); } } @@ -103,6 +108,9 @@ export class TaskService { return task; } + /** + * 接收任务:工程师将任务从 PENDING 流转到 ACCEPTED。 + */ async acceptTask(actor: ActorContext, dto: AcceptTaskDto) { this.assertRole(actor, [Role.ENGINEER]); const hospitalId = this.requireHospitalId(actor); @@ -121,13 +129,13 @@ export class TaskService { }); if (!task) { - throw new NotFoundException('Task not found in actor hospital'); + throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND); } if (task.status !== TaskStatus.PENDING) { - throw new ConflictException('Only pending task can be accepted'); + throw new ConflictException(MESSAGES.TASK.ACCEPT_ONLY_PENDING); } if (task.engineerId != null && task.engineerId !== actor.id) { - throw new ForbiddenException('Task already assigned to another engineer'); + throw new ForbiddenException(MESSAGES.TASK.ENGINEER_ALREADY_ASSIGNED); } const updatedTask = await this.prisma.task.update({ @@ -149,6 +157,9 @@ export class TaskService { return updatedTask; } + /** + * 完成任务:工程师将任务置为 COMPLETED,并同步设备当前压力。 + */ async completeTask(actor: ActorContext, dto: CompleteTaskDto) { this.assertRole(actor, [Role.ENGINEER]); const hospitalId = this.requireHospitalId(actor); @@ -164,13 +175,13 @@ export class TaskService { }); if (!task) { - throw new NotFoundException('Task not found in actor hospital'); + throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND); } if (task.status !== TaskStatus.ACCEPTED) { - throw new ConflictException('Only accepted task can be completed'); + throw new ConflictException(MESSAGES.TASK.COMPLETE_ONLY_ACCEPTED); } if (task.engineerId !== actor.id) { - throw new ForbiddenException('Only the assigned engineer can complete task'); + throw new ForbiddenException(MESSAGES.TASK.ENGINEER_ONLY_ASSIGNEE); } const completedTask = await this.prisma.$transaction(async (tx) => { @@ -202,6 +213,9 @@ export class TaskService { return completedTask; } + /** + * 取消任务:创建医生可将 PENDING/ACCEPTED 任务取消。 + */ async cancelTask(actor: ActorContext, dto: CancelTaskDto) { this.assertRole(actor, [Role.DOCTOR]); const hospitalId = this.requireHospitalId(actor); @@ -220,16 +234,16 @@ export class TaskService { }); if (!task) { - throw new NotFoundException('Task not found in actor hospital'); + throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND); } if (task.creatorId !== actor.id) { - throw new ForbiddenException('Only task creator can cancel task'); + throw new ForbiddenException(MESSAGES.TASK.CANCEL_ONLY_CREATOR); } if ( task.status !== TaskStatus.PENDING && task.status !== TaskStatus.ACCEPTED ) { - throw new ConflictException('Only pending or accepted task can be cancelled'); + throw new ConflictException(MESSAGES.TASK.CANCEL_ONLY_PENDING_ACCEPTED); } const cancelledTask = await this.prisma.task.update({ @@ -248,15 +262,21 @@ export class TaskService { return cancelledTask; } + /** + * 校验角色权限。 + */ private assertRole(actor: ActorContext, allowedRoles: Role[]) { if (!allowedRoles.includes(actor.role)) { - throw new ForbiddenException('Actor role is not allowed'); + throw new ForbiddenException(MESSAGES.TASK.ACTOR_ROLE_FORBIDDEN); } } + /** + * 校验并返回 hospitalId(B 端强依赖租户隔离)。 + */ private requireHospitalId(actor: ActorContext): number { if (!actor.hospitalId) { - throw new BadRequestException('Actor hospitalId is required'); + throw new BadRequestException(MESSAGES.TASK.ACTOR_HOSPITAL_REQUIRED); } return actor.hospitalId; } diff --git a/src/users/b-users/b-users.controller.ts b/src/users/b-users/b-users.controller.ts index fa95b56..f1b8187 100644 --- a/src/users/b-users/b-users.controller.ts +++ b/src/users/b-users/b-users.controller.ts @@ -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, diff --git a/src/users/dto/assign-engineer-hospital.dto.ts b/src/users/dto/assign-engineer-hospital.dto.ts index cb0cb31..6d8cb58 100644 --- a/src/users/dto/assign-engineer-hospital.dto.ts +++ b/src/users/dto/assign-engineer-hospital.dto.ts @@ -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; } diff --git a/src/users/dto/create-user.dto.ts b/src/users/dto/create-user.dto.ts index 632ff31..2b764a8 100644 --- a/src/users/dto/create-user.dto.ts +++ b/src/users/dto/create-user.dto.ts @@ -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; } diff --git a/src/users/dto/login.dto.ts b/src/users/dto/login.dto.ts index 344d049..093355e 100644 --- a/src/users/dto/login.dto.ts +++ b/src/users/dto/login.dto.ts @@ -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; } diff --git a/src/users/dto/register-user.dto.ts b/src/users/dto/register-user.dto.ts index acdc588..62c5c1b 100644 --- a/src/users/dto/register-user.dto.ts +++ b/src/users/dto/register-user.dto.ts @@ -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; } diff --git a/src/users/dto/update-user.dto.ts b/src/users/dto/update-user.dto.ts index 912cdc5..5c1106c 100644 --- a/src/users/dto/update-user.dto.ts +++ b/src/users/dto/update-user.dto.ts @@ -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) {} diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index c4c9c50..d80a13f 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -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); } diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 089113b..f9320cd 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -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) { 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, {