Compare commits

...

6 Commits

Author SHA1 Message Date
EL
2275607bd2 设置 2026-03-13 11:14:16 +08:00
EL
394793fa28 web 2026-03-13 06:10:32 +08:00
EL
2c1bbd565f web test 2026-03-13 03:50:34 +08:00
EL
6ec8891be5 修复 E2E 准备脚本:
package.json
test:e2e:prepare 现在是 migrate reset --force && prisma generate && seed
为 seed 运行时补充 JS Prisma client 生成器:
schema.prisma
修复 seed 在 ESM/CJS 下的 Prisma 导入兼容:
seed.mjs
修复 Jest 环境未加载 .env 导致连到 127.0.0.1 的问题:
e2e-app.helper.ts
修复夹具依赖“名称”导致被组织测试改名后失效的问题(改为按 seed openId 反查):
e2e-fixtures.helper.ts
修复组织测试的状态污染与清理逻辑,并收敛 afterAll 资源释放:
organization.e2e-spec.ts
e2e-context.helper.ts
2026-03-13 03:29:16 +08:00
EL
b55e600c9c 权限完善 2026-03-13 02:40:21 +08:00
EL
aa1346f6af 测试 2026-03-13 00:19:34 +08:00
122 changed files with 16103 additions and 64 deletions

3
.env.example Normal file
View File

@ -0,0 +1,3 @@
DATABASE_URL="postgresql://postgres:lyh1234@192.168.0.180:5432/tyt-api-nest"
AUTH_TOKEN_SECRET="replace-with-a-strong-random-secret"
SYSTEM_ADMIN_BOOTSTRAP_KEY="replace-with-admin-bootstrap-key"

3
.gitignore vendored
View File

@ -56,3 +56,6 @@ pids
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
/src/generated/prisma
/tyt-admin/dist
/tyt-admin/node_modules

48
AGENTS.md Normal file
View File

@ -0,0 +1,48 @@
# Repository Guidelines
## Project Structure & Module Organization
Core application code lives in `src/`. Domain modules are split by business area: `auth/`, `users/`, `tasks/`, `patients/`, and `organization/`. Keep controllers, services, and DTOs inside their module directories (for example, `src/tasks/dto/`).
Shared infrastructure is in `src/common/` (global response/exception handling, constants) plus `src/prisma.module.ts` and `src/prisma.service.ts`. Database schema and migrations are under `prisma/`, and generated Prisma artifacts are in `src/generated/prisma/`. API behavior notes are documented in `docs/*.md`.
## Build, Test, and Development Commands
Use `pnpm` for all local workflows:
- `pnpm install`: install dependencies.
- `pnpm start:dev`: run NestJS in watch mode.
- `pnpm build`: compile TypeScript to `dist/`.
- `pnpm start:prod`: run compiled output from `dist/main`.
- `pnpm format`: apply Prettier to `src/**/*.ts` (and `test/**/*.ts` when present).
- `pnpm prisma generate`: regenerate Prisma client after schema changes.
- `pnpm prisma migrate dev`: create/apply local migrations.
## Coding Style & Naming Conventions
This repo uses TypeScript + NestJS with ES module imports (use `.js` suffix in local imports). Formatting is Prettier-driven (`singleQuote: true`, `trailingComma: all`); keep 2-space indentation and avoid manual style drift.
Use `PascalCase` for classes (`TaskService`), `camelCase` for methods/variables, and `kebab-case` for filenames (`publish-task.dto.ts`). Place DTOs under `dto/` and keep validation decorators/messages close to fields.
## Testing Guidelines
There are currently no committed `test` scripts or spec files. For new features, add automated tests using `@nestjs/testing` and `supertest` (already in dev dependencies), with names like `*.spec.ts`.
Minimum expectation for new endpoints: one success path and one authorization/validation failure path. Include test run instructions in the PR when introducing test tooling.
## Commit & Pull Request Guidelines
Recent history uses short, single-line subjects (for example: `配置数据库生成用户模块`, `测试`, `init`). Keep commits focused and descriptive, one logical change per commit.
For PRs, include:
- What changed and why.
- Related issue/task link.
- API or schema impact (`prisma/schema.prisma`, migrations, env vars).
- Verification steps (for example, `pnpm build`, key endpoint checks in `/api/docs`).
## Security & Configuration Tips
Start from `.env.example`; never commit real secrets. Rotate `AUTH_TOKEN_SECRET` and bootstrap keys per environment, and treat `DATABASE_URL` as sensitive.
使用nest cli不要直接改配置文件最后发给我安装命令让我执行中文注释和文档

105
README.md Normal file
View File

@ -0,0 +1,105 @@
# 多租户医疗调压系统后端NestJS + Prisma
本项目是医疗调压系统后端 MVP支持 B 端(医院内部)与 C 端(家属跨院视图)两套接口语义。
## 1. 技术栈
- NestJS模块化后端框架
- PrismaORM + Schema 管理)
- PostgreSQL/MySQL`.env``DATABASE_URL` 决定)
- JWT认证
- Swagger接口文档
## 2. 目录结构
```text
src/
auth/ 认证与鉴权JWT、Guard、RBAC
users/ 用户与角色管理
tasks/ 调压任务流转(发布/接收/完成/取消)
patients/ 患者查询B 端范围 + C 端聚合)
hospitals/ 医院管理模块
departments/ 科室管理模块
groups/ 小组管理模块
organization-common/ 组织域共享 DTO/权限校验能力
organization/ 组织域聚合模块(仅负责引入子模块)
common/ 全局响应、异常、消息常量
generated/prisma/ Prisma 生成代码
prisma/
schema.prisma 数据模型定义
docs/
auth.md
users.md
tasks.md
patients.md
```
## 3. 环境变量
请在项目根目录创建 `.env`
```env
DATABASE_URL="postgresql://user:password@127.0.0.1:5432/tyt?schema=public"
JWT_SECRET="请替换为强随机密钥"
JWT_EXPIRES_IN="7d"
SYSTEM_ADMIN_BOOTSTRAP_KEY="初始化系统管理员用密钥"
```
## 4. 启动流程
```bash
pnpm install
pnpm prisma generate
pnpm prisma migrate dev
pnpm start:dev
```
## 5. 统一响应规范
- 成功:`{ code: 0, msg: "成功", data: ... }`
- 失败:`{ code: 4xx/5xx, msg: "中文错误信息", data: null }`
已通过全局拦截器与全局异常过滤器统一输出。
## 6. API 文档
- Swagger UI: `/api/docs`
- OpenAPI JSON: `/api/docs-json`
- 鉴权头:`Authorization: Bearer <token>`
## 7. 组织管理(医院/科室/小组 CRUD
统一前缀:`/b/organization`
- 医院:
- `POST /b/organization/hospitals`
- `GET /b/organization/hospitals`
- `GET /b/organization/hospitals/:id`
- `PATCH /b/organization/hospitals/:id`
- `DELETE /b/organization/hospitals/:id`
- 科室:
- `POST /b/organization/departments`
- `GET /b/organization/departments`
- `GET /b/organization/departments/:id`
- `PATCH /b/organization/departments/:id`
- `DELETE /b/organization/departments/:id`
- 小组:
- `POST /b/organization/groups`
- `GET /b/organization/groups`
- `GET /b/organization/groups/:id`
- `PATCH /b/organization/groups/:id`
- `DELETE /b/organization/groups/:id`
## 8. 模块文档
- 认证与登录:`docs/auth.md`
- 用户与权限:`docs/users.md`
- 任务流转:`docs/tasks.md`
- 患者查询:`docs/patients.md`
## 9. 常见改造入口
- 新增字段/关系:修改 `prisma/schema.prisma` 后执行 `prisma migrate`
- 调整中文提示:修改 `src/common/messages.ts`
- 调整全局响应壳:修改 `src/common/response-envelope.interceptor.ts``src/common/http-exception.filter.ts`
- 扩展 RBAC修改 `src/auth/roles.guard.ts` 与对应 Service 的权限断言。

32
docs/auth.md Normal file
View File

@ -0,0 +1,32 @@
# 认证模块说明(`src/auth`
## 1. 目标
- 提供注册、登录、`/me` 身份查询。
- 使用 JWT 做认证Guard 做鉴权RolesGuard 做 RBAC。
## 2. 核心接口
- `POST /auth/register`:注册账号(支持医生/工程师/院管等角色约束)
- `POST /auth/login`:手机号 + 角色 + 密码登录(支持同手机号多院场景)
- `GET /auth/me`:返回当前登录用户上下文
## 3. 鉴权流程
1. `AccessTokenGuard``Authorization` 读取 Bearer Token。
2. 校验 JWT 签名与载荷字段。
3. 载荷映射为 `ActorContext` 注入 `request.user`
4. `RolesGuard` 根据 `@Roles(...)` 判断角色是否允许访问。
## 4. Token 约定
- Header`Authorization: Bearer <token>`
- 载荷关键字段:`sub``role``hospitalId``departmentId``groupId`
## 5. 错误码与中文消息
- 未登录/Token 失效:`401` + 中文 `msg`
- 角色无权限:`403` + 中文 `msg`
- 参数非法:`400` + 中文 `msg`
统一由全局异常过滤器输出:`{ code, msg, data: null }`

60
docs/e2e-testing.md Normal file
View File

@ -0,0 +1,60 @@
# E2E 接口测试说明
## 1. 目标
- 覆盖 `src/**/*controller.ts` 当前全部 30 个业务接口。
- 采用 `supertest + @nestjs/testing` 进行真实 HTTP E2E 测试。
- 测试前固定执行数据库重置与 seed确保结果可重复。
## 2. 风险提示
`pnpm test:e2e` 会执行:
1. `prisma migrate reset --force`
2. `node prisma/seed.mjs`
这会清空 `.env``DATABASE_URL` 指向数据库的全部数据,请仅在测试库执行。
## 3. 运行命令
```bash
pnpm test:e2e
```
仅重置数据库并注入 seed
```bash
pnpm test:e2e:prepare
```
监听模式:
```bash
pnpm test:e2e:watch
```
## 4. 种子账号(默认密码:`Seed@1234`
- 系统管理员:`13800001000`
- 院管(医院 A`13800001001`
- 主任(医院 A`13800001002`
- 组长(医院 A`13800001003`
- 医生(医院 A`13800001004`
- 工程师(医院 A`13800001005`
## 5. 用例结构
- `test/e2e/specs/auth.e2e-spec.ts`
- `test/e2e/specs/users.e2e-spec.ts`
- `test/e2e/specs/organization.e2e-spec.ts`
- `test/e2e/specs/tasks.e2e-spec.ts`
- `test/e2e/specs/patients.e2e-spec.ts`
## 6. 覆盖策略
- 受保护接口27 个):每个接口覆盖 6 角色访问结果 + 未登录 401。
- 非受保护接口3 个):每个接口至少 1 个成功 + 1 个失败。
- 关键行为额外覆盖:
- 任务状态机冲突409
- 患者 B 端角色可见性
- 组织域院管作用域限制与删除冲突

View File

@ -0,0 +1,37 @@
# 前端接口接入说明(`tyt-admin`
## 1. 本次接入范围
- 登录页:`/auth/login`,支持可选 `hospitalId`
- 首页看板:按角色拉取组织与患者统计。
- 任务页:接入 `publish/accept/complete/cancel` 四个真实任务接口。
- 用户页:修复用户列表响应结构、组织字段联动、工程师分配医院参数。
- 患者页:接入真实患者字段与生命周期查询参数(`phone + idCardHash`)。
## 2. 接口契约对齐点
- `GET /users` 当前返回数组,前端已在 `api/users.js` 做本地分页与筛选适配。
- `PATCH /b/users/:id/assign-engineer-hospital` 参数为单个 `hospitalId`,非数组。
- `GET /b/patients` 返回数组,前端已改为本地分页与筛选。
- `GET /c/patients/lifecycle` 必须同时传 `phone``idCardHash`
- 任务模块暂无任务列表接口,前端改为“表单操作 + 最近结果”模式。
## 3. 角色权限提示
- 任务接口权限:
- `DOCTOR`:发布、取消
- `ENGINEER`:接收、完成
- 患者列表权限:
- `SYSTEM_ADMIN` 查询时必须传 `hospitalId`
- 用户管理接口:
- `SYSTEM_ADMIN/HOSPITAL_ADMIN` 可访问列表与创建
- 删除和工程师绑定医院仅 `SYSTEM_ADMIN`
## 4. 本地运行
`tyt-admin` 目录执行:
```bash
pnpm install
pnpm dev
```

45
docs/patients.md Normal file
View File

@ -0,0 +1,45 @@
# 患者模块说明(`src/patients`
## 1. 目标
- B 端:按组织与角色范围查询患者(强依赖 `hospitalId`)。
- C 端:按 `phone + idCardHash` 做跨院聚合查询。
## 2. B 端可见性
- `DOCTOR`:仅可查自己名下患者
- `LEADER`:可查本组医生名下患者(按医生当前 `groupId` 反查)
- `DIRECTOR`:可查本科室医生名下患者(按医生当前 `departmentId` 反查)
- `HOSPITAL_ADMIN`:可查本院全部患者
- `SYSTEM_ADMIN`:需显式传入目标 `hospitalId`
## 2.1 B 端 CRUD
- `GET /b/patients`:按角色查询可见患者
- `GET /b/patients/doctors`:查询当前角色可见的医生候选(用于患者表单)
- `POST /b/patients`:创建患者
- `GET /b/patients/:id`:查询患者详情
- `PATCH /b/patients/:id`:更新患者
- `DELETE /b/patients/:id`:删除患者(若存在关联设备返回 409
说明:
患者表只绑定 `doctorId + hospitalId`,不直接绑定小组/科室。医生调组或调科后,
可见范围会按医生当前组织归属自动变化,无需迁移患者数据。
## 3. C 端生命周期聚合
接口:`GET /c/patients/lifecycle?phone=...&idCardHash=...`
查询策略:
1. 不做医院隔离(跨租户)
2. 双字段精确匹配 `phone + idCardHash`
3. 关联查询 `Patient -> Device -> TaskItem -> Task`
4. 返回扁平生命周期列表(按 `Task.createdAt DESC`
## 4. 响应结构
全部接口统一返回:
- 成功:`{ code: 0, msg: "成功", data: ... }`
- 失败:`{ code: x, msg: "中文错误", data: null }`

40
docs/tasks.md Normal file
View File

@ -0,0 +1,40 @@
# 调压任务模块说明(`src/tasks`
## 1. 目标
- 管理调压主任务 `Task` 与明细 `TaskItem`
- 支持状态机流转与事件触发,保证设备压力同步更新。
## 2. 状态机
- `PENDING -> ACCEPTED -> COMPLETED`
- `PENDING/ACCEPTED -> CANCELLED`
非法流转会返回 `409` 冲突错误(中文消息)。
## 3. 角色权限
- 医生:发布任务、取消自己创建的任务
- 工程师:接收任务、完成自己接收的任务
- 其他角色:默认拒绝
## 4. 事件触发
状态变化后会发出事件:
- `task.published`
- `task.accepted`
- `task.completed`
- `task.cancelled`
用于后续接入微信通知或消息中心。
## 5. 完成任务时的设备同步
`completeTask` 在单事务中执行:
1. 更新任务状态为 `COMPLETED`
2. 读取 `TaskItem.targetPressure`
3. 批量更新关联 `Device.currentPressure`
确保任务状态与设备压力一致性。

38
docs/users.md Normal file
View File

@ -0,0 +1,38 @@
# 用户与权限模块说明(`src/users`
## 1. 目标
- 管理用户基础信息(姓名、手机号、角色、组织归属)。
- 维护 B 端角色权限边界和工程师绑定医院逻辑。
## 2. 角色枚举
- `SYSTEM_ADMIN`:系统管理员
- `HOSPITAL_ADMIN`:院管
- `DIRECTOR`:主任
- `LEADER`:组长
- `DOCTOR`:医生
- `ENGINEER`:工程师
## 3. 关键规则
- 医院内数据按 `hospitalId` 强隔离。
- 仅 `SYSTEM_ADMIN` 可执行工程师绑定医院。
- 用户组织字段校验:
- 院管/医生/工程师等需有医院归属;
- 主任/组长需有科室/小组等必要归属;
- 系统管理员不能绑定院内组织字段。
- 更新用户时,仅允许医生调整 `departmentId/groupId`(后端强约束)。
- 科室/小组父级关系冻结:不允许通过更新接口迁移科室所属医院或小组所属科室。
## 4. 典型接口
- `GET /users``GET /users/:id``PATCH /users/:id``DELETE /users/:id`
- `POST /b/users/:id/assign-engineer-hospital`
## 5. 开发改造建议
- 若增加角色,请同步修改:
- Prisma `Role` 枚举
- `roles.guard.ts` 与各 Service 权限判断
- Swagger DTO 中文说明

View File

@ -12,31 +12,47 @@
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main"
"start:prod": "node dist/main",
"test:e2e:prepare": "pnpm prisma migrate reset --force && pnpm prisma generate && node prisma/seed.mjs",
"test:e2e": "pnpm test:e2e:prepare && NODE_OPTIONS=--experimental-vm-modules pnpm exec jest --config ./test/jest-e2e.config.cjs --runInBand",
"test:e2e:watch": "NODE_OPTIONS=--experimental-vm-modules pnpm exec jest --config ./test/jest-e2e.config.cjs --watch"
},
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@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",
"@nestjs/schematics": "^11.0.0",
"@nestjs/testing": "^11.0.1",
"@types/bcrypt": "^6.0.0",
"@types/express": "^5.0.0",
"@types/jest": "^30.0.0",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^22.10.7",
"@types/supertest": "^6.0.2",
"globals": "^16.0.0",
"jest": "^30.3.0",
"prettier": "^3.4.2",
"prisma": "^7.4.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.4.6",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",

2688
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,192 @@
/*
Warnings:
- You are about to drop the column `email` on the `User` table. All the data in the column will be lost.
- You are about to drop the `Post` table. If the table is not empty, all the data it contains will be lost.
- A unique constraint covering the columns `[openId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
- Added the required column `phone` to the `User` table without a default value. This is not possible if the table is not empty.
- Added the required column `role` to the `User` table without a default value. This is not possible if the table is not empty.
- Made the column `name` on table `User` required. This step will fail if there are existing NULL values in that column.
*/
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('SYSTEM_ADMIN', 'HOSPITAL_ADMIN', 'DIRECTOR', 'LEADER', 'DOCTOR', 'ENGINEER');
-- CreateEnum
CREATE TYPE "DeviceStatus" AS ENUM ('ACTIVE', 'INACTIVE');
-- CreateEnum
CREATE TYPE "TaskStatus" AS ENUM ('PENDING', 'ACCEPTED', 'COMPLETED', 'CANCELLED');
-- DropForeignKey
ALTER TABLE "Post" DROP CONSTRAINT "Post_authorId_fkey";
-- DropIndex
DROP INDEX "User_email_key";
-- AlterTable
ALTER TABLE "User" DROP COLUMN "email",
ADD COLUMN "departmentId" INTEGER,
ADD COLUMN "groupId" INTEGER,
ADD COLUMN "hospitalId" INTEGER,
ADD COLUMN "openId" TEXT,
ADD COLUMN "passwordHash" TEXT,
ADD COLUMN "phone" TEXT NOT NULL,
ADD COLUMN "role" "Role" NOT NULL,
ALTER COLUMN "name" SET NOT NULL;
-- DropTable
DROP TABLE "Post";
-- CreateTable
CREATE TABLE "Hospital" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "Hospital_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Department" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"hospitalId" INTEGER NOT NULL,
CONSTRAINT "Department_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Group" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"departmentId" INTEGER NOT NULL,
CONSTRAINT "Group_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Patient" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"phone" TEXT NOT NULL,
"idCardHash" TEXT NOT NULL,
"hospitalId" INTEGER NOT NULL,
"doctorId" INTEGER NOT NULL,
CONSTRAINT "Patient_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Device" (
"id" SERIAL NOT NULL,
"snCode" TEXT NOT NULL,
"currentPressure" INTEGER NOT NULL,
"status" "DeviceStatus" NOT NULL DEFAULT 'ACTIVE',
"patientId" INTEGER NOT NULL,
CONSTRAINT "Device_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Task" (
"id" SERIAL NOT NULL,
"status" "TaskStatus" NOT NULL DEFAULT 'PENDING',
"creatorId" INTEGER NOT NULL,
"engineerId" INTEGER,
"hospitalId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Task_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "TaskItem" (
"id" SERIAL NOT NULL,
"taskId" INTEGER NOT NULL,
"deviceId" INTEGER NOT NULL,
"oldPressure" INTEGER NOT NULL,
"targetPressure" INTEGER NOT NULL,
CONSTRAINT "TaskItem_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Department_hospitalId_idx" ON "Department"("hospitalId");
-- CreateIndex
CREATE INDEX "Group_departmentId_idx" ON "Group"("departmentId");
-- CreateIndex
CREATE INDEX "Patient_phone_idCardHash_idx" ON "Patient"("phone", "idCardHash");
-- CreateIndex
CREATE INDEX "Patient_hospitalId_doctorId_idx" ON "Patient"("hospitalId", "doctorId");
-- CreateIndex
CREATE UNIQUE INDEX "Device_snCode_key" ON "Device"("snCode");
-- CreateIndex
CREATE INDEX "Device_patientId_status_idx" ON "Device"("patientId", "status");
-- CreateIndex
CREATE INDEX "Task_hospitalId_status_createdAt_idx" ON "Task"("hospitalId", "status", "createdAt");
-- CreateIndex
CREATE INDEX "TaskItem_taskId_idx" ON "TaskItem"("taskId");
-- CreateIndex
CREATE INDEX "TaskItem_deviceId_idx" ON "TaskItem"("deviceId");
-- CreateIndex
CREATE UNIQUE INDEX "User_openId_key" ON "User"("openId");
-- CreateIndex
CREATE INDEX "User_phone_idx" ON "User"("phone");
-- CreateIndex
CREATE INDEX "User_hospitalId_role_idx" ON "User"("hospitalId", "role");
-- CreateIndex
CREATE INDEX "User_departmentId_role_idx" ON "User"("departmentId", "role");
-- CreateIndex
CREATE INDEX "User_groupId_role_idx" ON "User"("groupId", "role");
-- AddForeignKey
ALTER TABLE "Department" ADD CONSTRAINT "Department_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Group" ADD CONSTRAINT "Group_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_departmentId_fkey" FOREIGN KEY ("departmentId") REFERENCES "Department"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "User" ADD CONSTRAINT "User_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Patient" ADD CONSTRAINT "Patient_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Patient" ADD CONSTRAINT "Patient_doctorId_fkey" FOREIGN KEY ("doctorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Device" ADD CONSTRAINT "Device_patientId_fkey" FOREIGN KEY ("patientId") REFERENCES "Patient"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_engineerId_fkey" FOREIGN KEY ("engineerId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Task" ADD CONSTRAINT "Task_hospitalId_fkey" FOREIGN KEY ("hospitalId") REFERENCES "Hospital"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TaskItem" ADD CONSTRAINT "TaskItem_taskId_fkey" FOREIGN KEY ("taskId") REFERENCES "Task"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "TaskItem" ADD CONSTRAINT "TaskItem_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "Device"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -1,30 +1,154 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
}
// 兼容 seed 脚本在 Node.js 直接运行时使用 @prisma/client runtime。
generator seed_client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
// 角色枚举:用于鉴权与数据可见性控制。
enum Role {
SYSTEM_ADMIN
HOSPITAL_ADMIN
DIRECTOR
LEADER
DOCTOR
ENGINEER
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean? @default(false)
author User? @relation(fields: [authorId], references: [id])
authorId Int?
// 设备状态枚举:表示设备是否处于使用中。
enum DeviceStatus {
ACTIVE
INACTIVE
}
// 任务状态枚举:定义任务流转状态机。
enum TaskStatus {
PENDING
ACCEPTED
COMPLETED
CANCELLED
}
// 医院主表:多租户顶层实体。
model Hospital {
id Int @id @default(autoincrement())
name String
departments Department[]
users User[]
patients Patient[]
tasks Task[]
}
// 科室表:归属于医院。
model Department {
id Int @id @default(autoincrement())
name String
hospitalId Int
hospital Hospital @relation(fields: [hospitalId], references: [id])
groups Group[]
users User[]
@@index([hospitalId])
}
// 小组表:归属于科室。
model Group {
id Int @id @default(autoincrement())
name String
departmentId Int
department Department @relation(fields: [departmentId], references: [id])
users User[]
@@index([departmentId])
}
// 用户表:支持后台密码登录与小程序 openId。
model User {
id Int @id @default(autoincrement())
name String
phone String
// 后台登录密码哈希bcrypt
passwordHash String?
openId String? @unique
role Role
hospitalId Int?
departmentId Int?
groupId Int?
hospital Hospital? @relation(fields: [hospitalId], references: [id])
department Department? @relation(fields: [departmentId], references: [id])
group Group? @relation(fields: [groupId], references: [id])
doctorPatients Patient[] @relation("DoctorPatients")
createdTasks Task[] @relation("TaskCreator")
acceptedTasks Task[] @relation("TaskEngineer")
@@index([phone])
@@index([hospitalId, role])
@@index([departmentId, role])
@@index([groupId, role])
}
// 患者表:院内患者档案,按医院隔离。
model Patient {
id Int @id @default(autoincrement())
name String
phone String
idCardHash String
hospitalId Int
doctorId Int
hospital Hospital @relation(fields: [hospitalId], references: [id])
doctor User @relation("DoctorPatients", fields: [doctorId], references: [id])
devices Device[]
@@index([phone, idCardHash])
@@index([hospitalId, doctorId])
}
// 设备表:患者可绑定多个分流设备。
model Device {
id Int @id @default(autoincrement())
snCode String @unique
currentPressure Int
status DeviceStatus @default(ACTIVE)
patientId Int
patient Patient @relation(fields: [patientId], references: [id])
taskItems TaskItem[]
@@index([patientId, status])
}
// 主任务表:记录调压任务主单。
model Task {
id Int @id @default(autoincrement())
status TaskStatus @default(PENDING)
creatorId Int
engineerId Int?
hospitalId Int
createdAt DateTime @default(now())
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
engineer User? @relation("TaskEngineer", fields: [engineerId], references: [id])
hospital Hospital @relation(fields: [hospitalId], references: [id])
items TaskItem[]
@@index([hospitalId, status, createdAt])
}
// 任务明细表:一个任务可包含多个设备调压项。
model TaskItem {
id Int @id @default(autoincrement())
taskId Int
deviceId Int
oldPressure Int
targetPressure Int
task Task @relation(fields: [taskId], references: [id], onDelete: Cascade)
device Device @relation(fields: [deviceId], references: [id])
@@index([taskId])
@@index([deviceId])
}

449
prisma/seed.mjs Normal file
View File

@ -0,0 +1,449 @@
import 'dotenv/config';
import { PrismaPg } from '@prisma/adapter-pg';
import { hash } from 'bcrypt';
import prismaClientPackage from '@prisma/client';
const { DeviceStatus, PrismaClient, Role, TaskStatus } = prismaClientPackage;
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error('DATABASE_URL is required to run seed');
}
const prisma = new PrismaClient({
adapter: new PrismaPg({ connectionString }),
});
const SEED_PASSWORD_PLAIN = 'Seed@1234';
async function ensureHospital(name) {
return (
(await prisma.hospital.findFirst({ where: { name } })) ??
prisma.hospital.create({ data: { name } })
);
}
async function ensureDepartment(hospitalId, name) {
return (
(await prisma.department.findFirst({
where: { hospitalId, name },
})) ??
prisma.department.create({
data: { hospitalId, name },
})
);
}
async function ensureGroup(departmentId, name) {
return (
(await prisma.group.findFirst({
where: { departmentId, name },
})) ??
prisma.group.create({
data: { departmentId, name },
})
);
}
async function upsertUserByOpenId(openId, data) {
return prisma.user.upsert({
where: { openId },
update: data,
create: {
...data,
openId,
},
});
}
async function ensurePatient({
hospitalId,
doctorId,
name,
phone,
idCardHash,
}) {
const existing = await prisma.patient.findFirst({
where: {
hospitalId,
phone,
idCardHash,
},
});
if (existing) {
if (existing.doctorId !== doctorId || existing.name !== name) {
return prisma.patient.update({
where: { id: existing.id },
data: { doctorId, name },
});
}
return existing;
}
return prisma.patient.create({
data: {
hospitalId,
doctorId,
name,
phone,
idCardHash,
},
});
}
async function main() {
const seedPasswordHash = await hash(SEED_PASSWORD_PLAIN, 12);
const hospitalA = await ensureHospital('Seed Hospital A');
const hospitalB = await ensureHospital('Seed Hospital B');
const departmentA1 = await ensureDepartment(hospitalA.id, 'Neurosurgery-A1');
const departmentA2 = await ensureDepartment(hospitalA.id, 'Cardiology-A2');
const departmentB1 = await ensureDepartment(hospitalB.id, 'Neurosurgery-B1');
const groupA1 = await ensureGroup(departmentA1.id, 'Shift-A1');
const groupA2 = await ensureGroup(departmentA2.id, 'Shift-A2');
const groupB1 = await ensureGroup(departmentB1.id, 'Shift-B1');
const systemAdmin = await upsertUserByOpenId('seed-system-admin-openid', {
name: 'Seed System Admin',
phone: '13800001000',
passwordHash: seedPasswordHash,
role: Role.SYSTEM_ADMIN,
hospitalId: null,
departmentId: null,
groupId: null,
});
const hospitalAdminA = await upsertUserByOpenId(
'seed-hospital-admin-a-openid',
{
name: 'Seed Hospital Admin A',
phone: '13800001001',
passwordHash: seedPasswordHash,
role: Role.HOSPITAL_ADMIN,
hospitalId: hospitalA.id,
departmentId: null,
groupId: null,
},
);
await upsertUserByOpenId('seed-hospital-admin-b-openid', {
name: 'Seed Hospital Admin B',
phone: '13800001101',
passwordHash: seedPasswordHash,
role: Role.HOSPITAL_ADMIN,
hospitalId: hospitalB.id,
departmentId: null,
groupId: null,
});
const directorA = await upsertUserByOpenId('seed-director-a-openid', {
name: 'Seed Director A',
phone: '13800001002',
passwordHash: seedPasswordHash,
role: Role.DIRECTOR,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: null,
});
const leaderA = await upsertUserByOpenId('seed-leader-a-openid', {
name: 'Seed Leader A',
phone: '13800001003',
passwordHash: seedPasswordHash,
role: Role.LEADER,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: groupA1.id,
});
const doctorA = await upsertUserByOpenId('seed-doctor-a-openid', {
name: 'Seed Doctor A',
phone: '13800001004',
passwordHash: seedPasswordHash,
role: Role.DOCTOR,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: groupA1.id,
});
const doctorA2 = await upsertUserByOpenId('seed-doctor-a2-openid', {
name: 'Seed Doctor A2',
phone: '13800001204',
passwordHash: seedPasswordHash,
role: Role.DOCTOR,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: groupA1.id,
});
const doctorA3 = await upsertUserByOpenId('seed-doctor-a3-openid', {
name: 'Seed Doctor A3',
phone: '13800001304',
passwordHash: seedPasswordHash,
role: Role.DOCTOR,
hospitalId: hospitalA.id,
departmentId: departmentA2.id,
groupId: groupA2.id,
});
const doctorB = await upsertUserByOpenId('seed-doctor-b-openid', {
name: 'Seed Doctor B',
phone: '13800001104',
passwordHash: seedPasswordHash,
role: Role.DOCTOR,
hospitalId: hospitalB.id,
departmentId: departmentB1.id,
groupId: groupB1.id,
});
const engineerA = await upsertUserByOpenId('seed-engineer-a-openid', {
name: 'Seed Engineer A',
phone: '13800001005',
passwordHash: seedPasswordHash,
role: Role.ENGINEER,
hospitalId: hospitalA.id,
departmentId: null,
groupId: null,
});
const engineerB = await upsertUserByOpenId('seed-engineer-b-openid', {
name: 'Seed Engineer B',
phone: '13800001105',
passwordHash: seedPasswordHash,
role: Role.ENGINEER,
hospitalId: hospitalB.id,
departmentId: null,
groupId: null,
});
const patientA1 = await ensurePatient({
hospitalId: hospitalA.id,
doctorId: doctorA.id,
name: 'Seed Patient A1',
phone: '13800002001',
idCardHash: 'seed-id-card-cross-hospital',
});
const patientA2 = await ensurePatient({
hospitalId: hospitalA.id,
doctorId: doctorA2.id,
name: 'Seed Patient A2',
phone: '13800002002',
idCardHash: 'seed-id-card-a2',
});
const patientA3 = await ensurePatient({
hospitalId: hospitalA.id,
doctorId: doctorA3.id,
name: 'Seed Patient A3',
phone: '13800002003',
idCardHash: 'seed-id-card-a3',
});
const patientB1 = await ensurePatient({
hospitalId: hospitalB.id,
doctorId: doctorB.id,
name: 'Seed Patient B1',
phone: '13800002001',
idCardHash: 'seed-id-card-cross-hospital',
});
const deviceA1 = await prisma.device.upsert({
where: { snCode: 'SEED-SN-A-001' },
update: {
patientId: patientA1.id,
currentPressure: 118,
status: DeviceStatus.ACTIVE,
},
create: {
snCode: 'SEED-SN-A-001',
patientId: patientA1.id,
currentPressure: 118,
status: DeviceStatus.ACTIVE,
},
});
const deviceA2 = await prisma.device.upsert({
where: { snCode: 'SEED-SN-A-002' },
update: {
patientId: patientA2.id,
currentPressure: 112,
status: DeviceStatus.ACTIVE,
},
create: {
snCode: 'SEED-SN-A-002',
patientId: patientA2.id,
currentPressure: 112,
status: DeviceStatus.ACTIVE,
},
});
await prisma.device.upsert({
where: { snCode: 'SEED-SN-A-003' },
update: {
patientId: patientA3.id,
currentPressure: 109,
status: DeviceStatus.ACTIVE,
},
create: {
snCode: 'SEED-SN-A-003',
patientId: patientA3.id,
currentPressure: 109,
status: DeviceStatus.ACTIVE,
},
});
const deviceB1 = await prisma.device.upsert({
where: { snCode: 'SEED-SN-B-001' },
update: {
patientId: patientB1.id,
currentPressure: 121,
status: DeviceStatus.ACTIVE,
},
create: {
snCode: 'SEED-SN-B-001',
patientId: patientB1.id,
currentPressure: 121,
status: DeviceStatus.ACTIVE,
},
});
await prisma.device.upsert({
where: { snCode: 'SEED-SN-A-004' },
update: {
patientId: patientA1.id,
currentPressure: 130,
status: DeviceStatus.INACTIVE,
},
create: {
snCode: 'SEED-SN-A-004',
patientId: patientA1.id,
currentPressure: 130,
status: DeviceStatus.INACTIVE,
},
});
// 清理与种子设备关联的历史任务,保证 seed 可重复执行且生命周期夹具稳定。
const seedTaskItems = await prisma.taskItem.findMany({
where: {
deviceId: {
in: [deviceA1.id, deviceB1.id],
},
},
select: { taskId: true },
});
const seedTaskIds = Array.from(
new Set(seedTaskItems.map((item) => item.taskId)),
);
if (seedTaskIds.length > 0) {
await prisma.task.deleteMany({
where: {
id: {
in: seedTaskIds,
},
},
});
}
const lifecycleTaskA = await prisma.task.create({
data: {
status: TaskStatus.COMPLETED,
creatorId: doctorA.id,
engineerId: engineerA.id,
hospitalId: hospitalA.id,
items: {
create: [
{
deviceId: deviceA1.id,
oldPressure: 118,
targetPressure: 120,
},
],
},
},
include: { items: true },
});
const lifecycleTaskB = await prisma.task.create({
data: {
status: TaskStatus.PENDING,
creatorId: doctorB.id,
engineerId: engineerB.id,
hospitalId: hospitalB.id,
items: {
create: [
{
deviceId: deviceB1.id,
oldPressure: 121,
targetPressure: 119,
},
],
},
},
include: { items: true },
});
console.log(
JSON.stringify(
{
ok: true,
seedPasswordPlain: SEED_PASSWORD_PLAIN,
hospitals: {
hospitalAId: hospitalA.id,
hospitalBId: hospitalB.id,
},
departments: {
departmentA1Id: departmentA1.id,
departmentA2Id: departmentA2.id,
departmentB1Id: departmentB1.id,
},
groups: {
groupA1Id: groupA1.id,
groupA2Id: groupA2.id,
groupB1Id: groupB1.id,
},
users: {
systemAdminId: systemAdmin.id,
hospitalAdminAId: hospitalAdminA.id,
directorAId: directorA.id,
leaderAId: leaderA.id,
doctorAId: doctorA.id,
doctorA2Id: doctorA2.id,
doctorA3Id: doctorA3.id,
doctorBId: doctorB.id,
engineerAId: engineerA.id,
engineerBId: engineerB.id,
},
patients: {
patientA1Id: patientA1.id,
patientA2Id: patientA2.id,
patientA3Id: patientA3.id,
patientB1Id: patientB1.id,
},
devices: {
deviceA1Id: deviceA1.id,
deviceA2Id: deviceA2.id,
deviceB1Id: deviceB1.id,
},
tasks: {
lifecycleTaskAId: lifecycleTaskA.id,
lifecycleTaskBId: lifecycleTaskB.id,
},
},
null,
2,
),
);
}
main()
.catch((error) => {
console.error('Seed failed:', error);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -1,7 +1,23 @@
import { Module } from '@nestjs/common';
import { UsersModule } from './users/users.module';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { PrismaModule } from './prisma.module.js';
import { UsersModule } from './users/users.module.js';
import { TasksModule } from './tasks/tasks.module.js';
import { PatientsModule } from './patients/patients.module.js';
import { AuthModule } from './auth/auth.module.js';
import { OrganizationModule } from './organization/organization.module.js';
import { NotificationsModule } from './notifications/notifications.module.js';
@Module({
imports: [UsersModule],
imports: [
PrismaModule,
EventEmitterModule.forRoot(),
UsersModule,
TasksModule,
PatientsModule,
AuthModule,
OrganizationModule,
NotificationsModule,
],
})
export class AppModule {}

View File

@ -0,0 +1,102 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import jwt from 'jsonwebtoken';
import { Role } from '../generated/prisma/enums.js';
import type { ActorContext } from '../common/actor-context.js';
import { MESSAGES } from '../common/messages.js';
/**
* AccessToken Bearer JWT actor request
*/
@Injectable()
export class AccessTokenGuard implements CanActivate {
/**
* true 401
*/
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<
{
headers: Record<string, string | string[] | undefined>;
actor?: unknown;
}
>();
const authorization = request.headers.authorization;
const headerValue = Array.isArray(authorization)
? authorization[0]
: authorization;
if (!headerValue || !headerValue.startsWith('Bearer ')) {
throw new UnauthorizedException(MESSAGES.AUTH.MISSING_BEARER);
}
const token = headerValue.slice('Bearer '.length).trim();
request.actor = this.verifyAndExtractActor(token);
return true;
}
/**
* token actor
*/
private verifyAndExtractActor(token: string): ActorContext {
const secret = process.env.AUTH_TOKEN_SECRET;
if (!secret) {
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_SECRET_MISSING);
}
let payload: string | jwt.JwtPayload;
try {
payload = jwt.verify(token, secret, {
algorithms: ['HS256'],
issuer: 'tyt-api-nest',
});
} catch {
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_INVALID);
}
if (typeof payload !== 'object') {
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_PAYLOAD_INVALID);
}
const role = payload.role;
if (typeof role !== 'string' || !Object.values(Role).includes(role as Role)) {
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_ROLE_INVALID);
}
return {
id: this.asInt(payload.id, 'id'),
role: role as Role,
hospitalId: this.asNullableInt(payload.hospitalId, 'hospitalId'),
departmentId: this.asNullableInt(payload.departmentId, 'departmentId'),
groupId: this.asNullableInt(payload.groupId, 'groupId'),
};
}
/**
* token
*/
private asInt(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isInteger(value)) {
throw new UnauthorizedException(`${MESSAGES.AUTH.TOKEN_FIELD_INVALID}: ${field}`);
}
return value;
}
/**
* token
*/
private asNullableInt(value: unknown, field: string): number | null {
if (value === null || value === undefined) {
return null;
}
if (typeof value !== 'number' || !Number.isInteger(value)) {
throw new UnauthorizedException(`${MESSAGES.AUTH.TOKEN_FIELD_INVALID}: ${field}`);
}
return value;
}
}

View File

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

16
src/auth/auth.module.ts Normal file
View File

@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service.js';
import { AuthController } from './auth.controller.js';
import { UsersModule } from '../users/users.module.js';
import { AccessTokenGuard } from './access-token.guard.js';
/**
*
*/
@Module({
imports: [UsersModule],
providers: [AuthService, AccessTokenGuard],
controllers: [AuthController],
exports: [AuthService, AccessTokenGuard],
})
export class AuthModule {}

34
src/auth/auth.service.ts Normal file
View File

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

View File

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

View File

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

40
src/auth/roles.guard.ts Normal file
View File

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

View File

@ -0,0 +1,9 @@
import { Role } from '../generated/prisma/enums.js';
export type ActorContext = {
id: number;
role: Role;
hospitalId: number | null;
departmentId: number | null;
groupId: number | null;
};

View File

@ -0,0 +1,120 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Response } from 'express';
import { Prisma } from '../generated/prisma/client.js';
import { MESSAGES } from './messages.js';
/**
* msg
*/
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
// 非 HttpException 统一记录堆栈,便于定位 500 根因。
if (!(exception instanceof HttpException)) {
const error = exception as { message?: string; stack?: string };
this.logger.error(
error?.message ?? 'Unhandled exception',
error?.stack,
);
}
const status = this.resolveStatus(exception);
const msg = this.resolveMessage(exception, status);
response.status(status).json({
code: status,
msg,
data: null,
});
}
/**
* HTTP HttpException 500
*/
private resolveStatus(exception: unknown): number {
if (exception instanceof Prisma.PrismaClientInitializationError) {
return HttpStatus.SERVICE_UNAVAILABLE;
}
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
switch (exception.code) {
case 'P2002':
return HttpStatus.CONFLICT;
case 'P2025':
return HttpStatus.NOT_FOUND;
default:
return HttpStatus.INTERNAL_SERVER_ERROR;
}
}
if (exception instanceof HttpException) {
return exception.getStatus();
}
return HttpStatus.INTERNAL_SERVER_ERROR;
}
/**
* 使 message
*/
private resolveMessage(exception: unknown, status: number): string {
if (exception instanceof Prisma.PrismaClientInitializationError) {
return MESSAGES.DB.CONNECTION_FAILED;
}
if (exception instanceof Prisma.PrismaClientKnownRequestError) {
switch (exception.code) {
case 'P2021':
return MESSAGES.DB.TABLE_MISSING;
case 'P2022':
return MESSAGES.DB.COLUMN_MISSING;
case 'P2002':
return MESSAGES.DEFAULT_CONFLICT;
case 'P2025':
return MESSAGES.DEFAULT_NOT_FOUND;
default:
return MESSAGES.DEFAULT_INTERNAL_ERROR;
}
}
if (exception instanceof HttpException) {
const payload = exception.getResponse();
if (typeof payload === 'string') {
return payload;
}
if (payload && typeof payload === 'object') {
const body = payload as Record<string, unknown>;
const message = body.message;
if (Array.isArray(message)) {
return message.join('');
}
if (typeof message === 'string' && message.trim()) {
return message;
}
}
}
switch (status) {
case HttpStatus.BAD_REQUEST:
return MESSAGES.DEFAULT_BAD_REQUEST;
case HttpStatus.UNAUTHORIZED:
return MESSAGES.DEFAULT_UNAUTHORIZED;
case HttpStatus.FORBIDDEN:
return MESSAGES.DEFAULT_FORBIDDEN;
case HttpStatus.NOT_FOUND:
return MESSAGES.DEFAULT_NOT_FOUND;
case HttpStatus.CONFLICT:
return MESSAGES.DEFAULT_CONFLICT;
default:
return MESSAGES.DEFAULT_INTERNAL_ERROR;
}
}
}

108
src/common/messages.ts Normal file
View File

@ -0,0 +1,108 @@
/**
*
*/
export const MESSAGES = {
SUCCESS: '成功',
DEFAULT_BAD_REQUEST: '请求参数不合法',
DEFAULT_UNAUTHORIZED: '未登录或登录已过期',
DEFAULT_FORBIDDEN: '无权限执行当前操作',
DEFAULT_NOT_FOUND: '请求资源不存在',
DEFAULT_CONFLICT: '请求冲突,请检查后重试',
DEFAULT_INTERNAL_ERROR: '服务器内部错误,请稍后重试',
DB: {
TABLE_MISSING: '数据库表不存在,请先执行数据库迁移',
COLUMN_MISSING: '数据库字段不存在,请先同步数据库结构',
CONNECTION_FAILED: '数据库连接失败,请检查 DATABASE_URL 与数据库服务状态',
},
AUTH: {
MISSING_BEARER: '缺少 Bearer Token',
TOKEN_SECRET_MISSING: '服务端未配置认证密钥',
TOKEN_INVALID: 'Token 无效或已过期',
TOKEN_PAYLOAD_INVALID: 'Token 载荷不合法',
TOKEN_ROLE_INVALID: 'Token 中角色信息不合法',
TOKEN_FIELD_INVALID: 'Token 中字段不合法',
INVALID_CREDENTIALS: '手机号、角色或密码错误',
PASSWORD_NOT_ENABLED: '该账号未启用密码登录',
},
USER: {
NOT_FOUND: '用户不存在',
DUPLICATE_OPEN_ID: 'openId 已被注册',
DUPLICATE_PHONE_ROLE_SCOPE: '同医院下该角色手机号已存在',
INVALID_ROLE: '角色不合法',
INVALID_PHONE: '手机号格式不合法',
INVALID_PASSWORD: '密码长度至少 8 位',
INVALID_OPEN_ID: 'openId 格式不合法',
HOSPITAL_REQUIRED: 'hospitalId 必填',
HOSPITAL_NOT_FOUND: 'hospitalId 对应医院不存在',
HOSPITAL_ID_INVALID: 'hospitalId 必须为整数',
TARGET_NOT_ENGINEER: '目标用户不是工程师',
ENGINEER_BIND_FORBIDDEN: '仅系统管理员可绑定工程师医院',
SYSTEM_ADMIN_REG_DISABLED: '系统管理员注册已关闭',
SYSTEM_ADMIN_BOOTSTRAP_KEY_INVALID: '系统管理员引导密钥错误',
SYSTEM_ADMIN_SCOPE_INVALID: '系统管理员不可绑定医院/科室/小组',
DEPARTMENT_REQUIRED: '当前角色必须绑定科室',
GROUP_REQUIRED: '当前角色必须绑定小组',
ENGINEER_SCOPE_INVALID: '工程师不可绑定科室/小组',
DEPARTMENT_HOSPITAL_MISMATCH: '科室不属于指定医院',
GROUP_DEPARTMENT_REQUIRED: '绑定小组时必须同时传入科室',
GROUP_DEPARTMENT_MISMATCH: '小组不属于指定科室',
DOCTOR_ONLY_SCOPE_CHANGE: '仅医生/主任/组长允许调整科室/小组归属',
DELETE_CONFLICT: '用户存在关联患者或任务,无法删除',
MULTI_ACCOUNT_REQUIRE_HOSPITAL:
'检测到多个同手机号账号,请传 hospitalId 指定登录医院',
},
TASK: {
ITEMS_REQUIRED: '任务明细 items 不能为空',
DEVICE_NOT_FOUND: '存在设备不在当前医院或设备不存在',
ENGINEER_INVALID: '工程师必须为当前医院有效工程师',
TASK_NOT_FOUND: '任务不存在或不属于当前医院',
ACCEPT_ONLY_PENDING: '仅待接收任务可执行接收',
COMPLETE_ONLY_ACCEPTED: '仅已接收任务可执行完成',
CANCEL_ONLY_PENDING_ACCEPTED: '仅待接收/已接收任务可取消',
ENGINEER_ALREADY_ASSIGNED: '任务已被其他工程师接收',
ENGINEER_ONLY_ASSIGNEE: '仅任务接收工程师可完成任务',
CANCEL_ONLY_CREATOR: '仅任务创建医生可取消任务',
ACTOR_ROLE_FORBIDDEN: '当前角色无权限执行该任务操作',
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
},
PATIENT: {
NOT_FOUND: '患者不存在或无权限访问',
ROLE_FORBIDDEN: '当前角色无权限查询患者列表',
GROUP_REQUIRED: '组长查询需携带 groupId',
DEPARTMENT_REQUIRED: '主任查询需携带 departmentId',
DOCTOR_NOT_FOUND: '归属医生不存在',
DOCTOR_ROLE_REQUIRED: '归属用户必须为医生角色',
DOCTOR_SCOPE_FORBIDDEN: '仅可选择当前权限范围内医生',
DELETE_CONFLICT: '患者存在关联设备,无法删除',
PHONE_IDCARD_REQUIRED: 'phone 与 idCardHash 均为必填',
LIFE_CYCLE_NOT_FOUND: '未找到匹配的患者档案,请先确认手机号与身份证哈希',
SYSTEM_ADMIN_HOSPITAL_REQUIRED: '系统管理员查询必须显式传入 hospitalId',
ACTOR_HOSPITAL_REQUIRED: '当前登录上下文缺少医院信息',
},
ORG: {
HOSPITAL_NOT_FOUND: '医院不存在',
DEPARTMENT_NOT_FOUND: '科室不存在',
GROUP_NOT_FOUND: '小组不存在',
HOSPITAL_ADMIN_SCOPE_INVALID: '院管仅可操作本院组织数据',
SYSTEM_ADMIN_ONLY_CREATE_HOSPITAL: '仅系统管理员可创建医院',
SYSTEM_ADMIN_ONLY_DELETE_HOSPITAL: '仅系统管理员可删除医院',
HOSPITAL_NAME_REQUIRED: '医院名称不能为空',
DEPARTMENT_NAME_REQUIRED: '科室名称不能为空',
GROUP_NAME_REQUIRED: '小组名称不能为空',
HOSPITAL_ID_REQUIRED: 'hospitalId 必填且必须为整数',
DEPARTMENT_ID_REQUIRED: 'departmentId 必填且必须为整数',
GROUP_ID_REQUIRED: 'groupId 必填且必须为整数',
DEPARTMENT_HOSPITAL_MISMATCH: '科室不属于指定医院',
GROUP_DEPARTMENT_MISMATCH: '小组不属于指定科室',
DEPARTMENT_REPARENT_FORBIDDEN: '科室不允许更换所属医院',
GROUP_REPARENT_FORBIDDEN: '小组不允许更换所属科室',
DELETE_CONFLICT:
'存在关联数据,无法删除,请先清理用户、患者、任务或下级组织后重试',
},
} as const;

View File

@ -0,0 +1,54 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { map, Observable } from 'rxjs';
import { MESSAGES } from './messages.js';
/**
* { code, msg, data }
*/
@Injectable()
export class ResponseEnvelopeInterceptor implements NestInterceptor {
intercept(
_context: ExecutionContext,
next: CallHandler,
): Observable<{ code: number; msg: string; data: unknown }> {
return next.handle().pipe(
map((data: unknown) => {
// 若业务已返回统一结构,直接透传,避免二次包裹。
if (this.isEnveloped(data)) {
return data;
}
return {
code: 0,
msg: MESSAGES.SUCCESS,
data,
};
}),
);
}
/**
*
*/
private isEnveloped(data: unknown): data is {
code: number;
msg: string;
data: unknown;
} {
if (!data || typeof data !== 'object') {
return false;
}
const target = data as Record<string, unknown>;
return (
typeof target.code === 'number' &&
typeof target.msg === 'string' &&
Object.prototype.hasOwnProperty.call(target, 'data')
);
}
}

View File

@ -0,0 +1,12 @@
import { Transform } from 'class-transformer';
/**
* undefined便 IsOptional
*/
export const EmptyStringToUndefined = () =>
Transform(({ value }) => {
if (typeof value === 'string' && value.trim() === '') {
return undefined;
}
return value;
});

View File

@ -0,0 +1,108 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import type { ActorContext } from '../common/actor-context.js';
import { AccessTokenGuard } from '../auth/access-token.guard.js';
import { CurrentActor } from '../auth/current-actor.decorator.js';
import { Roles } from '../auth/roles.decorator.js';
import { RolesGuard } from '../auth/roles.guard.js';
import { Role } from '../generated/prisma/enums.js';
import { DepartmentsService } from './departments.service.js';
import { CreateDepartmentDto } from './dto/create-department.dto.js';
import { UpdateDepartmentDto } from './dto/update-department.dto.js';
import { OrganizationQueryDto } from '../organization-common/dto/organization-query.dto.js';
/**
*
*/
@ApiTags('科室管理(B端)')
@ApiBearerAuth('bearer')
@Controller('b/organization/departments')
@UseGuards(AccessTokenGuard, RolesGuard)
export class DepartmentsController {
constructor(private readonly departmentsService: DepartmentsService) {}
/**
*
*/
@Post()
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '创建科室' })
create(
@CurrentActor() actor: ActorContext,
@Body() dto: CreateDepartmentDto,
) {
return this.departmentsService.create(actor, dto);
}
/**
*
*/
@Get()
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '查询科室列表' })
@ApiQuery({ name: 'hospitalId', required: false, description: '医院 ID' })
findAll(
@CurrentActor() actor: ActorContext,
@Query() query: OrganizationQueryDto,
) {
return this.departmentsService.findAll(actor, query);
}
/**
*
*/
@Get(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '查询科室详情' })
@ApiParam({ name: 'id', description: '科室 ID' })
findOne(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.departmentsService.findOne(actor, id);
}
/**
*
*/
@Patch(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '更新科室' })
update(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateDepartmentDto,
) {
return this.departmentsService.update(actor, id, dto);
}
/**
*
*/
@Delete(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '删除科室' })
remove(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.departmentsService.remove(actor, id);
}
}

View File

@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { DepartmentsService } from './departments.service.js';
import { DepartmentsController } from './departments.controller.js';
import { AccessTokenGuard } from '../auth/access-token.guard.js';
import { RolesGuard } from '../auth/roles.guard.js';
import { OrganizationAccessService } from '../organization-common/organization-access.service.js';
/**
*
*/
@Module({
controllers: [DepartmentsController],
providers: [
DepartmentsService,
OrganizationAccessService,
AccessTokenGuard,
RolesGuard,
],
exports: [DepartmentsService],
})
export class DepartmentsModule {}

View File

@ -0,0 +1,127 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '../generated/prisma/client.js';
import { Role } from '../generated/prisma/enums.js';
import type { ActorContext } from '../common/actor-context.js';
import { MESSAGES } from '../common/messages.js';
import { PrismaService } from '../prisma.service.js';
import { OrganizationAccessService } from '../organization-common/organization-access.service.js';
import { CreateDepartmentDto } from './dto/create-department.dto.js';
import { UpdateDepartmentDto } from './dto/update-department.dto.js';
import { OrganizationQueryDto } from '../organization-common/dto/organization-query.dto.js';
/**
* CRUD
*/
@Injectable()
export class DepartmentsService {
constructor(
private readonly prisma: PrismaService,
private readonly access: OrganizationAccessService,
) {}
/**
*
*/
async create(actor: ActorContext, dto: CreateDepartmentDto) {
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
const hospitalId = this.access.toInt(dto.hospitalId, MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
await this.access.ensureHospitalExists(hospitalId);
this.access.assertHospitalScope(actor, hospitalId);
return this.prisma.department.create({
data: {
name: this.access.normalizeName(dto.name, MESSAGES.ORG.DEPARTMENT_NAME_REQUIRED),
hospitalId,
},
});
}
/**
*
*/
async findAll(actor: ActorContext, query: OrganizationQueryDto) {
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
const paging = this.access.resolvePaging(query);
const where: Prisma.DepartmentWhereInput = {};
if (query.keyword) {
where.name = { contains: query.keyword.trim(), mode: 'insensitive' };
}
const targetHospitalId =
actor.role === Role.HOSPITAL_ADMIN ? actor.hospitalId : query.hospitalId;
if (targetHospitalId != null) {
where.hospitalId = this.access.toInt(targetHospitalId, MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
}
if (actor.role === Role.HOSPITAL_ADMIN && where.hospitalId == null) {
throw new BadRequestException(MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
}
const [total, list] = await this.prisma.$transaction([
this.prisma.department.count({ where }),
this.prisma.department.findMany({
where,
include: { hospital: true, _count: { select: { users: true, groups: true } } },
skip: paging.skip,
take: paging.take,
orderBy: { id: 'desc' },
}),
]);
return { total, ...paging, list };
}
/**
*
*/
async findOne(actor: ActorContext, id: number) {
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
const departmentId = this.access.toInt(id, MESSAGES.ORG.DEPARTMENT_ID_REQUIRED);
const department = await this.prisma.department.findUnique({
where: { id: departmentId },
include: {
hospital: true,
_count: { select: { users: true, groups: true } },
},
});
if (!department) {
throw new NotFoundException(MESSAGES.ORG.DEPARTMENT_NOT_FOUND);
}
this.access.assertHospitalScope(actor, department.hospitalId);
return department;
}
/**
*
*/
async update(actor: ActorContext, id: number, dto: UpdateDepartmentDto) {
const current = await this.findOne(actor, id);
const data: Prisma.DepartmentUpdateInput = {};
if (dto.hospitalId !== undefined) {
throw new BadRequestException(MESSAGES.ORG.DEPARTMENT_REPARENT_FORBIDDEN);
}
if (dto.name !== undefined) {
data.name = this.access.normalizeName(dto.name, MESSAGES.ORG.DEPARTMENT_NAME_REQUIRED);
}
return this.prisma.department.update({
where: { id: current.id },
data,
});
}
/**
*
*/
async remove(actor: ActorContext, id: number) {
const current = await this.findOne(actor, id);
try {
return await this.prisma.department.delete({ where: { id: current.id } });
} catch (error) {
this.access.handleDeleteConflict(error);
throw error;
}
}
}

View File

@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsString, Min } from 'class-validator';
/**
* DTO
*/
export class CreateDepartmentDto {
@ApiProperty({ description: '科室名称', example: '神经外科' })
@IsString({ message: 'name 必须是字符串' })
name!: string;
@ApiProperty({ description: '医院 ID', example: 1 })
@Type(() => Number)
@IsInt({ message: 'hospitalId 必须是整数' })
@Min(1, { message: 'hospitalId 必须大于 0' })
hospitalId!: number;
}

View File

@ -0,0 +1,18 @@
import { ApiHideProperty, OmitType, PartialType } from '@nestjs/swagger';
import { CreateDepartmentDto } from './create-department.dto.js';
import { IsEmpty, IsOptional } from 'class-validator';
import { MESSAGES } from '../../common/messages.js';
/**
* DTO
*/
class UpdateDepartmentNameDto extends PartialType(
OmitType(CreateDepartmentDto, ['hospitalId'] as const),
) {}
export class UpdateDepartmentDto extends UpdateDepartmentNameDto {
@ApiHideProperty()
@IsOptional()
@IsEmpty({ message: MESSAGES.ORG.DEPARTMENT_REPARENT_FORBIDDEN })
hospitalId?: unknown;
}

View File

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

View File

@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsString, Min } from 'class-validator';
/**
* DTO
*/
export class CreateGroupDto {
@ApiProperty({ description: '小组名称', example: 'A组' })
@IsString({ message: 'name 必须是字符串' })
name!: string;
@ApiProperty({ description: '科室 ID', example: 1 })
@Type(() => Number)
@IsInt({ message: 'departmentId 必须是整数' })
@Min(1, { message: 'departmentId 必须大于 0' })
departmentId!: number;
}

View File

@ -0,0 +1,18 @@
import { ApiHideProperty, OmitType, PartialType } from '@nestjs/swagger';
import { CreateGroupDto } from './create-group.dto.js';
import { IsEmpty, IsOptional } from 'class-validator';
import { MESSAGES } from '../../common/messages.js';
/**
* DTO
*/
class UpdateGroupNameDto extends PartialType(
OmitType(CreateGroupDto, ['departmentId'] as const),
) {}
export class UpdateGroupDto extends UpdateGroupNameDto {
@ApiHideProperty()
@IsOptional()
@IsEmpty({ message: MESSAGES.ORG.GROUP_REPARENT_FORBIDDEN })
departmentId?: unknown;
}

View File

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

View File

@ -0,0 +1,106 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import type { ActorContext } from '../common/actor-context.js';
import { AccessTokenGuard } from '../auth/access-token.guard.js';
import { CurrentActor } from '../auth/current-actor.decorator.js';
import { Roles } from '../auth/roles.decorator.js';
import { RolesGuard } from '../auth/roles.guard.js';
import { Role } from '../generated/prisma/enums.js';
import { GroupsService } from './groups.service.js';
import { CreateGroupDto } from './dto/create-group.dto.js';
import { UpdateGroupDto } from './dto/update-group.dto.js';
import { OrganizationQueryDto } from '../organization-common/dto/organization-query.dto.js';
/**
*
*/
@ApiTags('小组管理(B端)')
@ApiBearerAuth('bearer')
@Controller('b/organization/groups')
@UseGuards(AccessTokenGuard, RolesGuard)
export class GroupsController {
constructor(private readonly groupsService: GroupsService) {}
/**
*
*/
@Post()
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '创建小组' })
create(
@CurrentActor() actor: ActorContext,
@Body() dto: CreateGroupDto,
) {
return this.groupsService.create(actor, dto);
}
/**
*
*/
@Get()
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '查询小组列表' })
findAll(
@CurrentActor() actor: ActorContext,
@Query() query: OrganizationQueryDto,
) {
return this.groupsService.findAll(actor, query);
}
/**
*
*/
@Get(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '查询小组详情' })
@ApiParam({ name: 'id', description: '小组 ID' })
findOne(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.groupsService.findOne(actor, id);
}
/**
*
*/
@Patch(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '更新小组' })
update(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateGroupDto,
) {
return this.groupsService.update(actor, id, dto);
}
/**
*
*/
@Delete(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '删除小组' })
remove(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.groupsService.remove(actor, id);
}
}

View File

@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { GroupsService } from './groups.service.js';
import { GroupsController } from './groups.controller.js';
import { AccessTokenGuard } from '../auth/access-token.guard.js';
import { RolesGuard } from '../auth/roles.guard.js';
import { OrganizationAccessService } from '../organization-common/organization-access.service.js';
/**
*
*/
@Module({
controllers: [GroupsController],
providers: [GroupsService, OrganizationAccessService, AccessTokenGuard, RolesGuard],
exports: [GroupsService],
})
export class GroupsModule {}

View File

@ -0,0 +1,135 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '../generated/prisma/client.js';
import { Role } from '../generated/prisma/enums.js';
import type { ActorContext } from '../common/actor-context.js';
import { MESSAGES } from '../common/messages.js';
import { PrismaService } from '../prisma.service.js';
import { OrganizationAccessService } from '../organization-common/organization-access.service.js';
import { CreateGroupDto } from './dto/create-group.dto.js';
import { UpdateGroupDto } from './dto/update-group.dto.js';
import { OrganizationQueryDto } from '../organization-common/dto/organization-query.dto.js';
/**
* CRUD
*/
@Injectable()
export class GroupsService {
constructor(
private readonly prisma: PrismaService,
private readonly access: OrganizationAccessService,
) {}
/**
*
*/
async create(actor: ActorContext, dto: CreateGroupDto) {
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
const departmentId = this.access.toInt(dto.departmentId, MESSAGES.ORG.DEPARTMENT_ID_REQUIRED);
const department = await this.access.ensureDepartmentExists(departmentId);
this.access.assertHospitalScope(actor, department.hospitalId);
return this.prisma.group.create({
data: {
name: this.access.normalizeName(dto.name, MESSAGES.ORG.GROUP_NAME_REQUIRED),
departmentId,
},
});
}
/**
*
*/
async findAll(actor: ActorContext, query: OrganizationQueryDto) {
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
const paging = this.access.resolvePaging(query);
const where: Prisma.GroupWhereInput = {};
if (query.keyword) {
where.name = { contains: query.keyword.trim(), mode: 'insensitive' };
}
if (query.departmentId != null) {
where.departmentId = this.access.toInt(query.departmentId, MESSAGES.ORG.DEPARTMENT_ID_REQUIRED);
}
if (actor.role === Role.HOSPITAL_ADMIN) {
if (!actor.hospitalId) {
throw new BadRequestException(MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
}
where.department = { hospitalId: actor.hospitalId };
} else if (query.hospitalId != null) {
where.department = {
hospitalId: this.access.toInt(query.hospitalId, MESSAGES.ORG.HOSPITAL_ID_REQUIRED),
};
}
const [total, list] = await this.prisma.$transaction([
this.prisma.group.count({ where }),
this.prisma.group.findMany({
where,
include: {
department: { include: { hospital: true } },
_count: { select: { users: true } },
},
skip: paging.skip,
take: paging.take,
orderBy: { id: 'desc' },
}),
]);
return { total, ...paging, list };
}
/**
*
*/
async findOne(actor: ActorContext, id: number) {
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
const groupId = this.access.toInt(id, MESSAGES.ORG.GROUP_ID_REQUIRED);
const group = await this.prisma.group.findUnique({
where: { id: groupId },
include: {
department: { include: { hospital: true } },
_count: { select: { users: true } },
},
});
if (!group) {
throw new NotFoundException(MESSAGES.ORG.GROUP_NOT_FOUND);
}
this.access.assertHospitalScope(actor, group.department.hospital.id);
return group;
}
/**
*
*/
async update(actor: ActorContext, id: number, dto: UpdateGroupDto) {
const current = await this.findOne(actor, id);
const data: Prisma.GroupUpdateInput = {};
if (dto.departmentId !== undefined) {
throw new BadRequestException(MESSAGES.ORG.GROUP_REPARENT_FORBIDDEN);
}
if (dto.name !== undefined) {
data.name = this.access.normalizeName(dto.name, MESSAGES.ORG.GROUP_NAME_REQUIRED);
}
return this.prisma.group.update({
where: { id: current.id },
data,
});
}
/**
*
*/
async remove(actor: ActorContext, id: number) {
const current = await this.findOne(actor, id);
try {
return await this.prisma.group.delete({ where: { id: current.id } });
} catch (error) {
this.access.handleDeleteConflict(error);
throw error;
}
}
}

View File

@ -0,0 +1,11 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString } from 'class-validator';
/**
* DTO
*/
export class CreateHospitalDto {
@ApiProperty({ description: '医院名称', example: '示例人民医院' })
@IsString({ message: 'name 必须是字符串' })
name!: string;
}

View File

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

View File

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

View File

@ -0,0 +1,106 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiTags,
} from '@nestjs/swagger';
import type { ActorContext } from '../common/actor-context.js';
import { AccessTokenGuard } from '../auth/access-token.guard.js';
import { CurrentActor } from '../auth/current-actor.decorator.js';
import { Roles } from '../auth/roles.decorator.js';
import { RolesGuard } from '../auth/roles.guard.js';
import { Role } from '../generated/prisma/enums.js';
import { HospitalsService } from './hospitals.service.js';
import { CreateHospitalDto } from './dto/create-hospital.dto.js';
import { UpdateHospitalDto } from './dto/update-hospital.dto.js';
import { OrganizationQueryDto } from '../organization-common/dto/organization-query.dto.js';
/**
*
*/
@ApiTags('医院管理(B端)')
@ApiBearerAuth('bearer')
@Controller('b/organization/hospitals')
@UseGuards(AccessTokenGuard, RolesGuard)
export class HospitalsController {
constructor(private readonly hospitalsService: HospitalsService) {}
/**
*
*/
@Post()
@Roles(Role.SYSTEM_ADMIN)
@ApiOperation({ summary: '创建医院SYSTEM_ADMIN' })
create(
@CurrentActor() actor: ActorContext,
@Body() dto: CreateHospitalDto,
) {
return this.hospitalsService.create(actor, dto);
}
/**
*
*/
@Get()
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '查询医院列表' })
findAll(
@CurrentActor() actor: ActorContext,
@Query() query: OrganizationQueryDto,
) {
return this.hospitalsService.findAll(actor, query);
}
/**
*
*/
@Get(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '查询医院详情' })
@ApiParam({ name: 'id', description: '医院 ID' })
findOne(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.hospitalsService.findOne(actor, id);
}
/**
*
*/
@Patch(':id')
@Roles(Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN)
@ApiOperation({ summary: '更新医院信息' })
update(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateHospitalDto,
) {
return this.hospitalsService.update(actor, id, dto);
}
/**
*
*/
@Delete(':id')
@Roles(Role.SYSTEM_ADMIN)
@ApiOperation({ summary: '删除医院SYSTEM_ADMIN' })
remove(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.hospitalsService.remove(actor, id);
}
}

View File

@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { HospitalsService } from './hospitals.service.js';
import { HospitalsController } from './hospitals.controller.js';
import { AccessTokenGuard } from '../auth/access-token.guard.js';
import { RolesGuard } from '../auth/roles.guard.js';
import { OrganizationAccessService } from '../organization-common/organization-access.service.js';
/**
*
*/
@Module({
controllers: [HospitalsController],
providers: [
HospitalsService,
OrganizationAccessService,
AccessTokenGuard,
RolesGuard,
],
exports: [HospitalsService],
})
export class HospitalsModule {}

View File

@ -0,0 +1,116 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '../generated/prisma/client.js';
import { Role } from '../generated/prisma/enums.js';
import type { ActorContext } from '../common/actor-context.js';
import { MESSAGES } from '../common/messages.js';
import { PrismaService } from '../prisma.service.js';
import { OrganizationAccessService } from '../organization-common/organization-access.service.js';
import { CreateHospitalDto } from './dto/create-hospital.dto.js';
import { UpdateHospitalDto } from './dto/update-hospital.dto.js';
import { OrganizationQueryDto } from '../organization-common/dto/organization-query.dto.js';
/**
* CRUD
*/
@Injectable()
export class HospitalsService {
constructor(
private readonly prisma: PrismaService,
private readonly access: OrganizationAccessService,
) {}
/**
*
*/
async create(actor: ActorContext, dto: CreateHospitalDto) {
this.access.assertSystemAdmin(actor, MESSAGES.ORG.SYSTEM_ADMIN_ONLY_CREATE_HOSPITAL);
return this.prisma.hospital.create({
data: {
name: this.access.normalizeName(dto.name, MESSAGES.ORG.HOSPITAL_NAME_REQUIRED),
},
});
}
/**
*
*/
async findAll(actor: ActorContext, query: OrganizationQueryDto) {
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
const paging = this.access.resolvePaging(query);
const where: Prisma.HospitalWhereInput = {};
if (query.keyword) {
where.name = { contains: query.keyword.trim(), mode: 'insensitive' };
}
if (actor.role === Role.HOSPITAL_ADMIN) {
if (!actor.hospitalId) {
throw new BadRequestException(MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
}
where.id = actor.hospitalId ?? undefined;
}
const [total, list] = await this.prisma.$transaction([
this.prisma.hospital.count({ where }),
this.prisma.hospital.findMany({
where,
skip: paging.skip,
take: paging.take,
orderBy: { id: 'desc' },
}),
]);
return { total, ...paging, list };
}
/**
*
*/
async findOne(actor: ActorContext, id: number) {
this.access.assertRole(actor, [Role.SYSTEM_ADMIN, Role.HOSPITAL_ADMIN]);
const hospitalId = this.access.toInt(id, MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
const hospital = await this.prisma.hospital.findUnique({
where: { id: hospitalId },
include: {
_count: {
select: { departments: true, users: true, patients: true, tasks: true },
},
},
});
if (!hospital) {
throw new NotFoundException(MESSAGES.ORG.HOSPITAL_NOT_FOUND);
}
this.access.assertHospitalScope(actor, hospital.id);
return hospital;
}
/**
*
*/
async update(actor: ActorContext, id: number, dto: UpdateHospitalDto) {
const current = await this.findOne(actor, id);
const data: Prisma.HospitalUpdateInput = {};
if (dto.name !== undefined) {
data.name = this.access.normalizeName(dto.name, MESSAGES.ORG.HOSPITAL_NAME_REQUIRED);
}
return this.prisma.hospital.update({
where: { id: current.id },
data,
});
}
/**
*
*/
async remove(actor: ActorContext, id: number) {
this.access.assertSystemAdmin(actor, MESSAGES.ORG.SYSTEM_ADMIN_ONLY_DELETE_HOSPITAL);
const hospitalId = this.access.toInt(id, MESSAGES.ORG.HOSPITAL_ID_REQUIRED);
await this.access.ensureHospitalExists(hospitalId);
try {
return await this.prisma.hospital.delete({ where: { id: hospitalId } });
} catch (error) {
this.access.handleDeleteConflict(error);
throw error;
}
}
}

View File

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

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { WechatNotifyService } from './wechat-notify/wechat-notify.service.js';
import { TaskEventsListener } from './task-events.listener/task-events.listener.js';
/**
*
*/
@Module({
providers: [WechatNotifyService, TaskEventsListener],
exports: [WechatNotifyService],
})
export class NotificationsModule {}

View File

@ -0,0 +1,86 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { PrismaService } from '../../prisma.service.js';
import { WechatNotifyService } from '../wechat-notify/wechat-notify.service.js';
interface TaskEventPayload {
taskId: number;
hospitalId: number;
actorId: number;
status: string;
}
/**
*
*/
@Injectable()
export class TaskEventsListener {
private readonly logger = new Logger(TaskEventsListener.name);
constructor(
private readonly prisma: PrismaService,
private readonly wechatNotifyService: WechatNotifyService,
) {}
/**
*
*/
@OnEvent('task.published', { async: true })
async onTaskPublished(payload: TaskEventPayload) {
await this.dispatchTaskEvent('task.published', payload);
}
/**
*
*/
@OnEvent('task.accepted', { async: true })
async onTaskAccepted(payload: TaskEventPayload) {
await this.dispatchTaskEvent('task.accepted', payload);
}
/**
*
*/
@OnEvent('task.completed', { async: true })
async onTaskCompleted(payload: TaskEventPayload) {
await this.dispatchTaskEvent('task.completed', payload);
}
/**
*
*/
@OnEvent('task.cancelled', { async: true })
async onTaskCancelled(payload: TaskEventPayload) {
await this.dispatchTaskEvent('task.cancelled', payload);
}
/**
*
*/
private async dispatchTaskEvent(event: string, payload: TaskEventPayload) {
const task = await this.prisma.task.findUnique({
where: { id: payload.taskId },
select: {
id: true,
creator: { select: { id: true, openId: true } },
engineer: { select: { id: true, openId: true } },
},
});
if (!task) {
this.logger.warn(`任务事件监听未找到任务taskId=${payload.taskId}`);
return;
}
await this.wechatNotifyService.notifyTaskChange(
[task.creator.openId, task.engineer?.openId],
{
event,
taskId: payload.taskId,
hospitalId: payload.hospitalId,
actorId: payload.actorId,
status: payload.status,
},
);
}
}

View File

@ -0,0 +1,41 @@
import { Injectable, Logger } from '@nestjs/common';
export interface TaskNotifyPayload {
event: string;
taskId: number;
hospitalId: number;
actorId: number;
status: string;
}
@Injectable()
export class WechatNotifyService {
private readonly logger = new Logger(WechatNotifyService.name);
/**
* / API
*/
async notifyTaskChange(openIds: Array<string | null | undefined>, payload: TaskNotifyPayload) {
const targets = Array.from(
new Set(
openIds
.map((item) => item?.trim())
.filter((item): item is string => Boolean(item)),
),
);
if (targets.length === 0) {
this.logger.warn(
`任务事件 ${payload.event} 无可用 openIdtaskId=${payload.taskId}`,
);
return;
}
for (const openId of targets) {
// TODO: 在此处调用微信服务号/小程序消息推送 API。
this.logger.log(
`模拟推送任务通知 event=${payload.event}, taskId=${payload.taskId}, openId=${openId}`,
);
}
}
}

View File

@ -0,0 +1,51 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
/**
* DTO//
*/
export class OrganizationQueryDto {
@ApiPropertyOptional({ description: '关键词(按名称模糊匹配)', example: '神经' })
@IsOptional()
@IsString({ message: 'keyword 必须是字符串' })
keyword?: string;
@ApiPropertyOptional({ description: '医院 ID', example: 1 })
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'hospitalId 必须是整数' })
@Min(1, { message: 'hospitalId 必须大于 0' })
hospitalId?: number;
@ApiPropertyOptional({ description: '科室 ID', example: 1 })
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'departmentId 必须是整数' })
@Min(1, { message: 'departmentId 必须大于 0' })
departmentId?: number;
@ApiPropertyOptional({ description: '页码(默认 1', example: 1, default: 1 })
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'page 必须是整数' })
@Min(1, { message: 'page 最小为 1' })
page?: number = 1;
@ApiPropertyOptional({
description: '每页数量(默认 20最大 100',
example: 20,
default: 20,
})
@IsOptional()
@EmptyStringToUndefined()
@Type(() => Number)
@IsInt({ message: 'pageSize 必须是整数' })
@Min(1, { message: 'pageSize 最小为 1' })
@Max(100, { message: 'pageSize 最大为 100' })
pageSize?: number = 20;
}

View File

@ -0,0 +1,131 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '../generated/prisma/client.js';
import { Role } from '../generated/prisma/enums.js';
import type { ActorContext } from '../common/actor-context.js';
import { MESSAGES } from '../common/messages.js';
import { PrismaService } from '../prisma.service.js';
import type { OrganizationQueryDto } from './dto/organization-query.dto.js';
/**
*
*
*/
@Injectable()
export class OrganizationAccessService {
constructor(private readonly prisma: PrismaService) {}
/**
*
*/
assertRole(actor: ActorContext, roles: Role[]) {
if (!roles.includes(actor.role)) {
throw new ForbiddenException(MESSAGES.DEFAULT_FORBIDDEN);
}
}
/**
*
*/
assertSystemAdmin(actor: ActorContext, message: string) {
if (actor.role !== Role.SYSTEM_ADMIN) {
throw new ForbiddenException(message);
}
}
/**
*
*/
assertHospitalScope(actor: ActorContext, targetHospitalId: number) {
if (actor.role !== Role.HOSPITAL_ADMIN) {
return;
}
if (!actor.hospitalId || actor.hospitalId !== targetHospitalId) {
throw new ForbiddenException(MESSAGES.ORG.HOSPITAL_ADMIN_SCOPE_INVALID);
}
}
/**
*
*/
resolvePaging(query: OrganizationQueryDto) {
const page = query.page && query.page > 0 ? query.page : 1;
const pageSize =
query.pageSize && query.pageSize > 0 && query.pageSize <= 100
? query.pageSize
: 20;
return {
page,
pageSize,
skip: (page - 1) * pageSize,
take: pageSize,
};
}
/**
*
*/
normalizeName(value: string, message: string) {
const trimmed = value?.trim();
if (!trimmed) {
throw new BadRequestException(message);
}
return trimmed;
}
/**
*
*/
toInt(value: unknown, message: string) {
const parsed = Number(value);
if (!Number.isInteger(parsed)) {
throw new BadRequestException(message);
}
return parsed;
}
/**
*
*/
async ensureHospitalExists(id: number) {
const hospital = await this.prisma.hospital.findUnique({
where: { id },
select: { id: true },
});
if (!hospital) {
throw new NotFoundException(MESSAGES.ORG.HOSPITAL_NOT_FOUND);
}
return hospital;
}
/**
*
*/
async ensureDepartmentExists(id: number) {
const department = await this.prisma.department.findUnique({
where: { id },
select: { id: true, hospitalId: true },
});
if (!department) {
throw new NotFoundException(MESSAGES.ORG.DEPARTMENT_NOT_FOUND);
}
return department;
}
/**
*
*/
handleDeleteConflict(error: unknown) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
(error.code === 'P2003' || error.code === 'P2014')
) {
throw new ConflictException(MESSAGES.ORG.DELETE_CONFLICT);
}
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { HospitalsModule } from '../hospitals/hospitals.module.js';
import { DepartmentsModule } from '../departments/departments.module.js';
import { GroupsModule } from '../groups/groups.module.js';
/**
* //
*/
@Module({
imports: [HospitalsModule, DepartmentsModule, GroupsModule],
})
export class OrganizationModule {}

View File

@ -0,0 +1,169 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiParam,
ApiQuery,
ApiTags,
} from '@nestjs/swagger';
import type { ActorContext } from '../../common/actor-context.js';
import { CurrentActor } from '../../auth/current-actor.decorator.js';
import { AccessTokenGuard } from '../../auth/access-token.guard.js';
import { RolesGuard } from '../../auth/roles.guard.js';
import { Roles } from '../../auth/roles.decorator.js';
import { Role } from '../../generated/prisma/enums.js';
import { BPatientsService } from './b-patients.service.js';
import { CreatePatientDto } from '../dto/create-patient.dto.js';
import { UpdatePatientDto } from '../dto/update-patient.dto.js';
/**
* B
*/
@ApiTags('患者管理(B端)')
@ApiBearerAuth('bearer')
@Controller('b/patients')
@UseGuards(AccessTokenGuard, RolesGuard)
export class BPatientsController {
constructor(private readonly patientsService: BPatientsService) {}
/**
* /
*/
@Get('doctors')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '查询当前角色可见医生列表' })
@ApiQuery({
name: 'hospitalId',
required: false,
description: '系统管理员可显式指定医院',
})
findVisibleDoctors(
@CurrentActor() actor: ActorContext,
@Query('hospitalId') hospitalId?: string,
) {
const requestedHospitalId =
hospitalId == null || hospitalId === '' ? undefined : Number(hospitalId);
return this.patientsService.findVisibleDoctors(actor, requestedHospitalId);
}
/**
*
*/
@Get()
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '按角色查询可见患者列表' })
@ApiQuery({
name: 'hospitalId',
required: false,
description: '系统管理员可显式指定医院',
})
findVisiblePatients(
@CurrentActor() actor: ActorContext,
@Query('hospitalId') hospitalId?: string,
) {
const requestedHospitalId =
hospitalId == null || hospitalId === '' ? undefined : Number(hospitalId);
return this.patientsService.findVisiblePatients(actor, requestedHospitalId);
}
/**
*
*/
@Post()
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '创建患者' })
createPatient(@CurrentActor() actor: ActorContext, @Body() dto: CreatePatientDto) {
return this.patientsService.createPatient(actor, dto);
}
/**
*
*/
@Get(':id')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '查询患者详情' })
@ApiParam({ name: 'id', description: '患者 ID' })
findPatientById(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.patientsService.findPatientById(actor, id);
}
/**
*
*/
@Patch(':id')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '更新患者信息' })
@ApiParam({ name: 'id', description: '患者 ID' })
updatePatient(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdatePatientDto,
) {
return this.patientsService.updatePatient(actor, id, dto);
}
/**
*
*/
@Delete(':id')
@Roles(
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
)
@ApiOperation({ summary: '删除患者' })
@ApiParam({ name: 'id', description: '患者 ID' })
removePatient(
@CurrentActor() actor: ActorContext,
@Param('id', ParseIntPipe) id: number,
) {
return this.patientsService.removePatient(actor, id);
}
}

View File

@ -0,0 +1,391 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Prisma } from '../../generated/prisma/client.js';
import { Role } from '../../generated/prisma/enums.js';
import { PrismaService } from '../../prisma.service.js';
import type { ActorContext } from '../../common/actor-context.js';
import { MESSAGES } from '../../common/messages.js';
import { CreatePatientDto } from '../dto/create-patient.dto.js';
import { UpdatePatientDto } from '../dto/update-patient.dto.js';
/**
* B CRUD
*/
@Injectable()
export class BPatientsService {
constructor(private readonly prisma: PrismaService) {}
/**
*
*/
async findVisiblePatients(actor: ActorContext, requestedHospitalId?: number) {
const hospitalId = this.resolveHospitalId(actor, requestedHospitalId);
const where = this.buildVisiblePatientWhere(actor, hospitalId);
return this.prisma.patient.findMany({
where,
include: {
hospital: { select: { id: true, name: true } },
doctor: { select: { id: true, name: true, role: true } },
devices: true,
},
orderBy: { id: 'desc' },
});
}
/**
*
*/
async findVisibleDoctors(actor: ActorContext, requestedHospitalId?: number) {
const hospitalId = this.resolveHospitalId(actor, requestedHospitalId);
const where: Prisma.UserWhereInput = {
role: Role.DOCTOR,
hospitalId,
};
switch (actor.role) {
case Role.DOCTOR:
where.id = actor.id;
break;
case Role.LEADER:
if (!actor.groupId) {
throw new BadRequestException(MESSAGES.PATIENT.GROUP_REQUIRED);
}
where.groupId = actor.groupId;
break;
case Role.DIRECTOR:
if (!actor.departmentId) {
throw new BadRequestException(MESSAGES.PATIENT.DEPARTMENT_REQUIRED);
}
where.departmentId = actor.departmentId;
break;
case Role.HOSPITAL_ADMIN:
case Role.SYSTEM_ADMIN:
break;
default:
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
}
return this.prisma.user.findMany({
where,
select: {
id: true,
name: true,
phone: true,
hospitalId: true,
departmentId: true,
groupId: true,
role: true,
},
orderBy: { id: 'desc' },
});
}
/**
*
*/
async createPatient(actor: ActorContext, dto: CreatePatientDto) {
const doctor = await this.resolveWritableDoctor(actor, dto.doctorId);
return this.prisma.patient.create({
data: {
name: this.normalizeRequiredString(dto.name, 'name'),
phone: this.normalizePhone(dto.phone),
idCardHash: this.normalizeRequiredString(dto.idCardHash, 'idCardHash'),
hospitalId: doctor.hospitalId!,
doctorId: doctor.id,
},
include: {
hospital: { select: { id: true, name: true } },
doctor: { select: { id: true, name: true, role: true } },
devices: true,
},
});
}
/**
*
*/
async findPatientById(actor: ActorContext, id: number) {
const patient = await this.findPatientWithScope(id);
this.assertPatientScope(actor, patient);
return patient;
}
/**
*
*/
async updatePatient(actor: ActorContext, id: number, dto: UpdatePatientDto) {
const patient = await this.findPatientWithScope(id);
this.assertPatientScope(actor, patient);
const data: Prisma.PatientUpdateInput = {};
if (dto.name !== undefined) {
data.name = this.normalizeRequiredString(dto.name, 'name');
}
if (dto.phone !== undefined) {
data.phone = this.normalizePhone(dto.phone);
}
if (dto.idCardHash !== undefined) {
data.idCardHash = this.normalizeRequiredString(dto.idCardHash, 'idCardHash');
}
if (dto.doctorId !== undefined) {
const doctor = await this.resolveWritableDoctor(actor, dto.doctorId);
data.doctor = { connect: { id: doctor.id } };
data.hospital = { connect: { id: doctor.hospitalId! } };
}
return this.prisma.patient.update({
where: { id: patient.id },
data,
include: {
hospital: { select: { id: true, name: true } },
doctor: { select: { id: true, name: true, role: true } },
devices: true,
},
});
}
/**
*
*/
async removePatient(actor: ActorContext, id: number) {
const patient = await this.findPatientWithScope(id);
this.assertPatientScope(actor, patient);
try {
return await this.prisma.patient.delete({
where: { id: patient.id },
include: {
hospital: { select: { id: true, name: true } },
doctor: { select: { id: true, name: true, role: true } },
devices: true,
},
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2003'
) {
throw new ConflictException(MESSAGES.PATIENT.DELETE_CONFLICT);
}
throw error;
}
}
/**
*
*/
private async findPatientWithScope(id: number) {
const patientId = Number(id);
if (!Number.isInteger(patientId)) {
throw new BadRequestException('id 必须为整数');
}
const patient = await this.prisma.patient.findUnique({
where: { id: patientId },
include: {
hospital: { select: { id: true, name: true } },
doctor: {
select: {
id: true,
name: true,
role: true,
hospitalId: true,
departmentId: true,
groupId: true,
},
},
devices: true,
},
});
if (!patient) {
throw new NotFoundException(MESSAGES.PATIENT.NOT_FOUND);
}
return patient;
}
/**
*
*/
private assertPatientScope(
actor: ActorContext,
patient: {
hospitalId: number;
doctorId: number;
doctor: { departmentId: number | null; groupId: number | null };
},
) {
switch (actor.role) {
case Role.SYSTEM_ADMIN:
return;
case Role.HOSPITAL_ADMIN:
if (!actor.hospitalId || actor.hospitalId !== patient.hospitalId) {
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
}
return;
case Role.DIRECTOR:
if (!actor.departmentId || patient.doctor.departmentId !== actor.departmentId) {
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
}
return;
case Role.LEADER:
if (!actor.groupId || patient.doctor.groupId !== actor.groupId) {
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
}
return;
case Role.DOCTOR:
if (patient.doctorId !== actor.id) {
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
}
return;
default:
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
}
}
/**
*
*/
private async resolveWritableDoctor(actor: ActorContext, doctorId: number) {
const normalizedDoctorId = Number(doctorId);
if (!Number.isInteger(normalizedDoctorId)) {
throw new BadRequestException('doctorId 必须为整数');
}
const doctor = await this.prisma.user.findUnique({
where: { id: normalizedDoctorId },
select: {
id: true,
role: true,
hospitalId: true,
departmentId: true,
groupId: true,
},
});
if (!doctor) {
throw new NotFoundException(MESSAGES.PATIENT.DOCTOR_NOT_FOUND);
}
if (doctor.role !== Role.DOCTOR) {
throw new BadRequestException(MESSAGES.PATIENT.DOCTOR_ROLE_REQUIRED);
}
if (!doctor.hospitalId) {
throw new BadRequestException(MESSAGES.PATIENT.DOCTOR_NOT_FOUND);
}
switch (actor.role) {
case Role.SYSTEM_ADMIN:
return doctor;
case Role.HOSPITAL_ADMIN:
if (!actor.hospitalId || doctor.hospitalId !== actor.hospitalId) {
throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN);
}
return doctor;
case Role.DIRECTOR:
if (!actor.departmentId || doctor.departmentId !== actor.departmentId) {
throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN);
}
return doctor;
case Role.LEADER:
if (!actor.groupId || doctor.groupId !== actor.groupId) {
throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN);
}
return doctor;
case Role.DOCTOR:
if (doctor.id !== actor.id) {
throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN);
}
return doctor;
default:
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
}
}
/**
*
*/
private buildVisiblePatientWhere(actor: ActorContext, hospitalId: number) {
const where: Record<string, unknown> = { hospitalId };
switch (actor.role) {
case Role.DOCTOR:
where.doctorId = actor.id;
break;
case Role.LEADER:
if (!actor.groupId) {
throw new BadRequestException(MESSAGES.PATIENT.GROUP_REQUIRED);
}
where.doctor = {
groupId: actor.groupId,
role: Role.DOCTOR,
};
break;
case Role.DIRECTOR:
if (!actor.departmentId) {
throw new BadRequestException(MESSAGES.PATIENT.DEPARTMENT_REQUIRED);
}
where.doctor = {
departmentId: actor.departmentId,
role: Role.DOCTOR,
};
break;
case Role.HOSPITAL_ADMIN:
case Role.SYSTEM_ADMIN:
break;
default:
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
}
return where;
}
/**
* B hospitalId
*/
private resolveHospitalId(
actor: ActorContext,
requestedHospitalId?: number,
): number {
if (actor.role === Role.SYSTEM_ADMIN) {
const normalizedHospitalId = requestedHospitalId;
if (
normalizedHospitalId == null ||
!Number.isInteger(normalizedHospitalId)
) {
throw new BadRequestException(MESSAGES.PATIENT.SYSTEM_ADMIN_HOSPITAL_REQUIRED);
}
return normalizedHospitalId;
}
if (!actor.hospitalId) {
throw new BadRequestException(MESSAGES.PATIENT.ACTOR_HOSPITAL_REQUIRED);
}
return actor.hospitalId;
}
private normalizeRequiredString(value: unknown, fieldName: string) {
if (typeof value !== 'string') {
throw new BadRequestException(`${fieldName} 必须是字符串`);
}
const trimmed = value.trim();
if (!trimmed) {
throw new BadRequestException(`${fieldName} 不能为空`);
}
return trimmed;
}
private normalizePhone(phone: unknown) {
const normalized = this.normalizeRequiredString(phone, 'phone');
if (!/^1\d{10}$/.test(normalized)) {
throw new BadRequestException('phone 必须是合法手机号');
}
return normalized;
}
}

View File

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

View File

@ -0,0 +1,108 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../prisma.service.js';
import { MESSAGES } from '../../common/messages.js';
/**
* C
*/
@Injectable()
export class CPatientsService {
constructor(private readonly prisma: PrismaService) {}
/**
* C phone + idCardHash
*/
async getFamilyLifecycleByIdentity(phone: string, idCardHash: string) {
if (!phone || !idCardHash) {
throw new BadRequestException(MESSAGES.PATIENT.PHONE_IDCARD_REQUIRED);
}
const patients = await this.prisma.patient.findMany({
where: {
phone,
idCardHash,
},
include: {
hospital: { select: { id: true, name: true } },
devices: {
include: {
taskItems: {
include: {
task: true,
},
},
},
},
},
});
if (patients.length === 0) {
throw new NotFoundException(MESSAGES.PATIENT.LIFE_CYCLE_NOT_FOUND);
}
const lifecycle = patients
.flatMap((patient) =>
patient.devices.flatMap((device) =>
device.taskItems.flatMap((taskItem) => {
// 容错:若存在脏数据导致 task 为空,直接跳过该条明细,避免接口 500。
if (!taskItem.task) {
return [];
}
const task = taskItem.task;
return [
{
eventType: 'TASK_PRESSURE_ADJUSTMENT',
occurredAt: task.createdAt,
hospital: patient.hospital,
patient: {
id: this.toJsonNumber(patient.id),
name: patient.name,
phone: patient.phone,
},
device: {
id: this.toJsonNumber(device.id),
snCode: device.snCode,
status: device.status,
currentPressure: this.toJsonNumber(device.currentPressure),
},
task: {
id: this.toJsonNumber(task.id),
status: task.status,
creatorId: this.toJsonNumber(task.creatorId),
engineerId: this.toJsonNumber(task.engineerId),
hospitalId: this.toJsonNumber(task.hospitalId),
createdAt: task.createdAt,
},
taskItem: {
id: this.toJsonNumber(taskItem.id),
oldPressure: this.toJsonNumber(taskItem.oldPressure),
targetPressure: this.toJsonNumber(taskItem.targetPressure),
},
},
];
}),
),
)
.sort(
(a, b) =>
new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(),
);
return {
phone,
idCardHash,
patientCount: patients.length,
lifecycle,
};
}
/**
* number/bigint JSON number BigInt
*/
private toJsonNumber(value: number | bigint | null | undefined) {
if (value == null) {
return null;
}
return typeof value === 'bigint' ? Number(value) : value;
}
}

View File

@ -0,0 +1,35 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsInt,
IsString,
Matches,
Min,
} from 'class-validator';
/**
* DTOB 使
*/
export class CreatePatientDto {
@ApiProperty({ description: '患者姓名', example: '张三' })
@IsString({ message: 'name 必须是字符串' })
name!: string;
@ApiProperty({ description: '手机号', example: '13800002001' })
@IsString({ message: 'phone 必须是字符串' })
@Matches(/^1\d{10}$/, { message: 'phone 必须是合法手机号' })
phone!: string;
@ApiProperty({
description: '身份证哈希(前端传加密后值)',
example: 'id-card-hash-demo',
})
@IsString({ message: 'idCardHash 必须是字符串' })
idCardHash!: string;
@ApiProperty({ description: '归属医生 ID', example: 10001 })
@Type(() => Number)
@IsInt({ message: 'doctorId 必须是整数' })
@Min(1, { message: 'doctorId 必须大于 0' })
doctorId!: number;
}

View File

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

View File

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

View File

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

9
src/prisma.module.ts Normal file
View File

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { PrismaService } from './prisma.service.js';
@Global()
@Module({
providers: [PrismaService],
exports: [PrismaService],
})
export class PrismaModule {}

View File

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

View File

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

View File

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

View File

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

View File

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

283
src/tasks/task.service.ts Normal file
View File

@ -0,0 +1,283 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { DeviceStatus, Role, TaskStatus } from '../generated/prisma/enums.js';
import { PrismaService } from '../prisma.service.js';
import type { ActorContext } from '../common/actor-context.js';
import { PublishTaskDto } from './dto/publish-task.dto.js';
import { AcceptTaskDto } from './dto/accept-task.dto.js';
import { CompleteTaskDto } from './dto/complete-task.dto.js';
import { CancelTaskDto } from './dto/cancel-task.dto.js';
import { MESSAGES } from '../common/messages.js';
/**
*
*/
@Injectable()
export class TaskService {
constructor(
private readonly prisma: PrismaService,
private readonly eventEmitter: EventEmitter2,
) {}
/**
* PENDING
*/
async publishTask(actor: ActorContext, dto: PublishTaskDto) {
this.assertRole(actor, [Role.DOCTOR]);
const hospitalId = this.requireHospitalId(actor);
if (!Array.isArray(dto.items) || dto.items.length === 0) {
throw new BadRequestException(MESSAGES.TASK.ITEMS_REQUIRED);
}
const deviceIds = Array.from(
new Set(
dto.items.map((item) => {
if (!Number.isInteger(item.deviceId)) {
throw new BadRequestException(`deviceId 非法: ${item.deviceId}`);
}
if (!Number.isInteger(item.targetPressure)) {
throw new BadRequestException(`targetPressure 非法: ${item.targetPressure}`);
}
return item.deviceId;
}),
),
);
const devices = await this.prisma.device.findMany({
where: {
id: { in: deviceIds },
status: DeviceStatus.ACTIVE,
patient: { hospitalId },
},
select: { id: true, currentPressure: true },
});
if (devices.length !== deviceIds.length) {
throw new NotFoundException(MESSAGES.TASK.DEVICE_NOT_FOUND);
}
if (dto.engineerId != null) {
const engineer = await this.prisma.user.findFirst({
where: {
id: dto.engineerId,
role: Role.ENGINEER,
hospitalId,
},
select: { id: true },
});
if (!engineer) {
throw new BadRequestException(MESSAGES.TASK.ENGINEER_INVALID);
}
}
const pressureByDeviceId = new Map(
devices.map((device) => [device.id, device.currentPressure] as const),
);
const task = await this.prisma.task.create({
data: {
status: TaskStatus.PENDING,
creatorId: actor.id,
engineerId: dto.engineerId ?? null,
hospitalId,
items: {
create: dto.items.map((item) => ({
deviceId: item.deviceId,
oldPressure: pressureByDeviceId.get(item.deviceId) ?? 0,
targetPressure: item.targetPressure,
})),
},
},
include: { items: true },
});
await this.eventEmitter.emitAsync('task.published', {
taskId: task.id,
hospitalId: task.hospitalId,
actorId: actor.id,
status: task.status,
});
return task;
}
/**
* PENDING ACCEPTED
*/
async acceptTask(actor: ActorContext, dto: AcceptTaskDto) {
this.assertRole(actor, [Role.ENGINEER]);
const hospitalId = this.requireHospitalId(actor);
const task = await this.prisma.task.findFirst({
where: {
id: dto.taskId,
hospitalId,
},
select: {
id: true,
status: true,
hospitalId: true,
engineerId: true,
},
});
if (!task) {
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
}
if (task.status !== TaskStatus.PENDING) {
throw new ConflictException(MESSAGES.TASK.ACCEPT_ONLY_PENDING);
}
if (task.engineerId != null && task.engineerId !== actor.id) {
throw new ForbiddenException(MESSAGES.TASK.ENGINEER_ALREADY_ASSIGNED);
}
const updatedTask = await this.prisma.task.update({
where: { id: task.id },
data: {
status: TaskStatus.ACCEPTED,
engineerId: actor.id,
},
include: { items: true },
});
await this.eventEmitter.emitAsync('task.accepted', {
taskId: updatedTask.id,
hospitalId: updatedTask.hospitalId,
actorId: actor.id,
status: updatedTask.status,
});
return updatedTask;
}
/**
* COMPLETED
*/
async completeTask(actor: ActorContext, dto: CompleteTaskDto) {
this.assertRole(actor, [Role.ENGINEER]);
const hospitalId = this.requireHospitalId(actor);
const task = await this.prisma.task.findFirst({
where: {
id: dto.taskId,
hospitalId,
},
include: {
items: true,
},
});
if (!task) {
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
}
if (task.status !== TaskStatus.ACCEPTED) {
throw new ConflictException(MESSAGES.TASK.COMPLETE_ONLY_ACCEPTED);
}
if (task.engineerId !== actor.id) {
throw new ForbiddenException(MESSAGES.TASK.ENGINEER_ONLY_ASSIGNEE);
}
const completedTask = await this.prisma.$transaction(async (tx) => {
const nextTask = await tx.task.update({
where: { id: task.id },
data: { status: TaskStatus.COMPLETED },
include: { items: true },
});
await Promise.all(
task.items.map((item) =>
tx.device.update({
where: { id: item.deviceId },
data: { currentPressure: item.targetPressure },
}),
),
);
return nextTask;
});
await this.eventEmitter.emitAsync('task.completed', {
taskId: completedTask.id,
hospitalId: completedTask.hospitalId,
actorId: actor.id,
status: completedTask.status,
});
return completedTask;
}
/**
* PENDING/ACCEPTED
*/
async cancelTask(actor: ActorContext, dto: CancelTaskDto) {
this.assertRole(actor, [Role.DOCTOR]);
const hospitalId = this.requireHospitalId(actor);
const task = await this.prisma.task.findFirst({
where: {
id: dto.taskId,
hospitalId,
},
select: {
id: true,
status: true,
creatorId: true,
hospitalId: true,
},
});
if (!task) {
throw new NotFoundException(MESSAGES.TASK.TASK_NOT_FOUND);
}
if (task.creatorId !== actor.id) {
throw new ForbiddenException(MESSAGES.TASK.CANCEL_ONLY_CREATOR);
}
if (
task.status !== TaskStatus.PENDING &&
task.status !== TaskStatus.ACCEPTED
) {
throw new ConflictException(MESSAGES.TASK.CANCEL_ONLY_PENDING_ACCEPTED);
}
const cancelledTask = await this.prisma.task.update({
where: { id: task.id },
data: { status: TaskStatus.CANCELLED },
include: { items: true },
});
await this.eventEmitter.emitAsync('task.cancelled', {
taskId: cancelledTask.id,
hospitalId: cancelledTask.hospitalId,
actorId: actor.id,
status: cancelledTask.status,
});
return cancelledTask;
}
/**
*
*/
private assertRole(actor: ActorContext, allowedRoles: Role[]) {
if (!allowedRoles.includes(actor.role)) {
throw new ForbiddenException(MESSAGES.TASK.ACTOR_ROLE_FORBIDDEN);
}
}
/**
* hospitalIdB
*/
private requireHospitalId(actor: ActorContext): number {
if (!actor.hospitalId) {
throw new BadRequestException(MESSAGES.TASK.ACTOR_HOSPITAL_REQUIRED);
}
return actor.hospitalId;
}
}

12
src/tasks/tasks.module.ts Normal file
View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { BTasksController } from './b-tasks/b-tasks.controller.js';
import { TaskService } from './task.service.js';
import { AccessTokenGuard } from '../auth/access-token.guard.js';
import { RolesGuard } from '../auth/roles.guard.js';
@Module({
controllers: [BTasksController],
providers: [TaskService, AccessTokenGuard, RolesGuard],
exports: [TaskService],
})
export class TasksModule {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,13 @@
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { UsersService } from './users.service.js';
import { UsersController } from './users.controller.js';
import { BUsersController } from './b-users/b-users.controller.js';
import { AccessTokenGuard } from '../auth/access-token.guard.js';
import { RolesGuard } from '../auth/roles.guard.js';
@Module({
controllers: [UsersController],
providers: [UsersService],
controllers: [UsersController, BUsersController],
providers: [UsersService, AccessTokenGuard, RolesGuard],
exports: [UsersService],
})
export class UsersModule {}

View File

@ -1,26 +1,617 @@
import { Injectable } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { compare, hash } from 'bcrypt';
import jwt from 'jsonwebtoken';
import { Prisma } from '../generated/prisma/client.js';
import { CreateUserDto } from './dto/create-user.dto.js';
import { UpdateUserDto } from './dto/update-user.dto.js';
import { Role } from '../generated/prisma/enums.js';
import { PrismaService } from '../prisma.service.js';
import type { ActorContext } from '../common/actor-context.js';
import { AssignEngineerHospitalDto } from './dto/assign-engineer-hospital.dto.js';
import { RegisterUserDto } from './dto/register-user.dto.js';
import { LoginDto } from './dto/login.dto.js';
import { MESSAGES } from '../common/messages.js';
const SAFE_USER_SELECT = {
id: true,
name: true,
phone: true,
openId: true,
role: true,
hospitalId: true,
departmentId: true,
groupId: true,
} as const;
@Injectable()
export class UsersService {
create(createUserDto: CreateUserDto) {
return 'This action adds a new user';
constructor(private readonly prisma: PrismaService) {}
/**
* bcrypt
*/
async register(dto: RegisterUserDto) {
const role = this.normalizeRole(dto.role);
const name = this.normalizeRequiredString(dto.name, 'name');
const phone = this.normalizePhone(dto.phone);
const password = this.normalizePassword(dto.password);
const openId = this.normalizeOptionalString(dto.openId);
const hospitalId = this.normalizeOptionalInt(dto.hospitalId, 'hospitalId');
const departmentId = this.normalizeOptionalInt(
dto.departmentId,
'departmentId',
);
const groupId = this.normalizeOptionalInt(dto.groupId, 'groupId');
this.assertSystemAdminBootstrapKey(role, dto.systemAdminBootstrapKey);
await this.assertOrganizationScope(role, hospitalId, departmentId, groupId);
await this.assertOpenIdUnique(openId);
await this.assertPhoneRoleScopeUnique(phone, role, hospitalId);
const passwordHash = await hash(password, 12);
return this.prisma.user.create({
data: {
name,
phone,
passwordHash,
openId,
role,
hospitalId,
departmentId,
groupId,
},
select: SAFE_USER_SELECT,
});
}
findAll() {
return `This action returns all users`;
/**
* + JWT
*/
async login(dto: LoginDto) {
const role = this.normalizeRole(dto.role);
const phone = this.normalizePhone(dto.phone);
const password = this.normalizePassword(dto.password);
const hospitalId = this.normalizeOptionalInt(dto.hospitalId, 'hospitalId');
const users = await this.prisma.user.findMany({
where: {
phone,
role,
...(hospitalId != null ? { hospitalId } : {}),
},
select: {
...SAFE_USER_SELECT,
passwordHash: true,
},
take: 5,
});
if (users.length === 0) {
throw new UnauthorizedException(MESSAGES.AUTH.INVALID_CREDENTIALS);
}
if (users.length > 1 && hospitalId == null) {
throw new BadRequestException(
MESSAGES.USER.MULTI_ACCOUNT_REQUIRE_HOSPITAL,
);
}
const user = users[0];
if (!user?.passwordHash) {
throw new UnauthorizedException(MESSAGES.AUTH.PASSWORD_NOT_ENABLED);
}
const matched = await compare(password, user.passwordHash);
if (!matched) {
throw new UnauthorizedException(MESSAGES.AUTH.INVALID_CREDENTIALS);
}
const actor: ActorContext = {
id: user.id,
role: user.role,
hospitalId: user.hospitalId,
departmentId: user.departmentId,
groupId: user.groupId,
};
return {
tokenType: 'Bearer',
accessToken: this.signAccessToken(actor),
actor,
user: this.toSafeUser(user),
};
}
findOne(id: number) {
return `This action returns a #${id} user`;
/**
*
*/
async me(actor: ActorContext) {
return this.findOne(actor.id);
}
update(id: number, updateUserDto: UpdateUserDto) {
return `This action updates a #${id} user`;
/**
* B 使
*/
async create(createUserDto: CreateUserDto) {
const role = this.normalizeRole(createUserDto.role);
const name = this.normalizeRequiredString(createUserDto.name, 'name');
const phone = this.normalizePhone(createUserDto.phone);
const password = createUserDto.password
? this.normalizePassword(createUserDto.password)
: null;
const openId = this.normalizeOptionalString(createUserDto.openId);
const hospitalId = this.normalizeOptionalInt(
createUserDto.hospitalId,
'hospitalId',
);
const departmentId = this.normalizeOptionalInt(
createUserDto.departmentId,
'departmentId',
);
const groupId = this.normalizeOptionalInt(createUserDto.groupId, 'groupId');
await this.assertOrganizationScope(role, hospitalId, departmentId, groupId);
await this.assertOpenIdUnique(openId);
await this.assertPhoneRoleScopeUnique(phone, role, hospitalId);
return this.prisma.user.create({
data: {
name,
phone,
passwordHash: password ? await hash(password, 12) : null,
openId,
role,
hospitalId,
departmentId,
groupId,
},
select: SAFE_USER_SELECT,
});
}
remove(id: number) {
return `This action removes a #${id} user`;
/**
*
*/
async findAll() {
return this.prisma.user.findMany({
select: SAFE_USER_SELECT,
orderBy: { id: 'desc' },
});
}
/**
*
*/
async findOne(id: number) {
const userId = this.normalizeRequiredInt(id, 'id');
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: SAFE_USER_SELECT,
});
if (!user) {
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
}
return user;
}
/**
*
*/
async update(id: number, updateUserDto: UpdateUserDto) {
const userId = this.normalizeRequiredInt(id, 'id');
const current = await this.prisma.user.findUnique({
where: { id: userId },
select: {
...SAFE_USER_SELECT,
passwordHash: true,
},
});
if (!current) {
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
}
const nextRole =
updateUserDto.role != null ? this.normalizeRole(updateUserDto.role) : current.role;
const nextHospitalId =
updateUserDto.hospitalId !== undefined
? this.normalizeOptionalInt(updateUserDto.hospitalId, 'hospitalId')
: current.hospitalId;
const nextDepartmentId =
updateUserDto.departmentId !== undefined
? this.normalizeOptionalInt(updateUserDto.departmentId, 'departmentId')
: current.departmentId;
const nextGroupId =
updateUserDto.groupId !== undefined
? this.normalizeOptionalInt(updateUserDto.groupId, 'groupId')
: current.groupId;
const assigningDepartmentOrGroup =
(updateUserDto.departmentId !== undefined && nextDepartmentId != null) ||
(updateUserDto.groupId !== undefined && nextGroupId != null);
if (
assigningDepartmentOrGroup &&
nextRole !== Role.DOCTOR &&
nextRole !== Role.DIRECTOR &&
nextRole !== Role.LEADER
) {
throw new BadRequestException(MESSAGES.USER.DOCTOR_ONLY_SCOPE_CHANGE);
}
await this.assertOrganizationScope(
nextRole,
nextHospitalId,
nextDepartmentId,
nextGroupId,
);
const nextOpenId =
updateUserDto.openId !== undefined
? this.normalizeOptionalString(updateUserDto.openId)
: current.openId;
await this.assertOpenIdUnique(nextOpenId, userId);
const nextPhone =
updateUserDto.phone !== undefined
? this.normalizePhone(updateUserDto.phone)
: current.phone;
await this.assertPhoneRoleScopeUnique(
nextPhone,
nextRole,
nextHospitalId,
userId,
);
const data: Record<string, unknown> = {};
if (updateUserDto.name !== undefined) {
data.name = this.normalizeRequiredString(updateUserDto.name, 'name');
}
if (updateUserDto.phone !== undefined) {
data.phone = nextPhone;
}
if (updateUserDto.role !== undefined) {
data.role = nextRole;
}
if (updateUserDto.hospitalId !== undefined) {
data.hospitalId = nextHospitalId;
}
if (updateUserDto.departmentId !== undefined) {
data.departmentId = nextDepartmentId;
}
if (updateUserDto.groupId !== undefined) {
data.groupId = nextGroupId;
}
if (updateUserDto.openId !== undefined) {
data.openId = nextOpenId;
}
if (updateUserDto.password) {
data.passwordHash = await hash(
this.normalizePassword(updateUserDto.password),
12,
);
}
return this.prisma.user.update({
where: { id: userId },
data,
select: SAFE_USER_SELECT,
});
}
/**
*
*/
async remove(id: number) {
const userId = this.normalizeRequiredInt(id, 'id');
await this.findOne(userId);
try {
return await this.prisma.user.delete({
where: { id: userId },
select: SAFE_USER_SELECT,
});
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2003'
) {
throw new ConflictException(MESSAGES.USER.DELETE_CONFLICT);
}
throw error;
}
}
/**
*
*/
async assignEngineerHospital(
actor: ActorContext,
targetUserId: number,
dto: AssignEngineerHospitalDto,
) {
if (actor.role !== Role.SYSTEM_ADMIN) {
throw new ForbiddenException(MESSAGES.USER.ENGINEER_BIND_FORBIDDEN);
}
if (!Number.isInteger(dto.hospitalId)) {
throw new BadRequestException(MESSAGES.USER.HOSPITAL_ID_INVALID);
}
const hospital = await this.prisma.hospital.findUnique({
where: { id: dto.hospitalId },
select: { id: true },
});
if (!hospital) {
throw new NotFoundException(MESSAGES.ORG.HOSPITAL_NOT_FOUND);
}
const user = await this.prisma.user.findUnique({
where: { id: targetUserId },
select: { id: true, role: true },
});
if (!user) {
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
}
if (user.role !== Role.ENGINEER) {
throw new BadRequestException(MESSAGES.USER.TARGET_NOT_ENGINEER);
}
return this.prisma.user.update({
where: { id: targetUserId },
data: {
hospitalId: dto.hospitalId,
departmentId: null,
groupId: null,
},
select: SAFE_USER_SELECT,
});
}
/**
*
*/
private toSafeUser(user: { passwordHash?: string | null } & Record<string, unknown>) {
const { passwordHash, ...safe } = user;
return safe;
}
/**
*
*/
private assertSystemAdminBootstrapKey(
role: Role,
providedBootstrapKey?: string,
) {
if (role !== Role.SYSTEM_ADMIN) {
return;
}
const expectedBootstrapKey = process.env.SYSTEM_ADMIN_BOOTSTRAP_KEY;
if (!expectedBootstrapKey) {
throw new ForbiddenException(MESSAGES.USER.SYSTEM_ADMIN_REG_DISABLED);
}
if (providedBootstrapKey !== expectedBootstrapKey) {
throw new ForbiddenException(
MESSAGES.USER.SYSTEM_ADMIN_BOOTSTRAP_KEY_INVALID,
);
}
}
/**
* + +
*/
private async assertPhoneRoleScopeUnique(
phone: string,
role: Role,
hospitalId: number | null,
selfId?: number,
) {
const exists = await this.prisma.user.findFirst({
where: {
phone,
role,
hospitalId,
},
select: { id: true },
});
if (exists && exists.id !== selfId) {
throw new ConflictException(MESSAGES.USER.DUPLICATE_PHONE_ROLE_SCOPE);
}
}
/**
* openId
*/
private async assertOpenIdUnique(openId: string | null, selfId?: number) {
if (!openId) {
return;
}
const exists = await this.prisma.user.findUnique({
where: { openId },
select: { id: true },
});
if (exists && exists.id !== selfId) {
throw new ConflictException(MESSAGES.USER.DUPLICATE_OPEN_ID);
}
}
/**
*
*/
private async assertOrganizationScope(
role: Role,
hospitalId: number | null,
departmentId: number | null,
groupId: number | null,
) {
if (role === Role.SYSTEM_ADMIN) {
if (hospitalId || departmentId || groupId) {
throw new BadRequestException(MESSAGES.USER.SYSTEM_ADMIN_SCOPE_INVALID);
}
return;
}
if (!hospitalId) {
throw new BadRequestException(MESSAGES.USER.HOSPITAL_REQUIRED);
}
const hospital = await this.prisma.hospital.findUnique({
where: { id: hospitalId },
select: { id: true },
});
if (!hospital) {
throw new BadRequestException(MESSAGES.USER.HOSPITAL_NOT_FOUND);
}
const needsDepartment =
role === Role.DIRECTOR || role === Role.LEADER || role === Role.DOCTOR;
if (needsDepartment && !departmentId) {
throw new BadRequestException(MESSAGES.USER.DEPARTMENT_REQUIRED);
}
const needsGroup = role === Role.LEADER || role === Role.DOCTOR;
if (needsGroup && !groupId) {
throw new BadRequestException(MESSAGES.USER.GROUP_REQUIRED);
}
if (role === Role.ENGINEER && (departmentId || groupId)) {
throw new BadRequestException(MESSAGES.USER.ENGINEER_SCOPE_INVALID);
}
if (departmentId) {
const department = await this.prisma.department.findUnique({
where: { id: departmentId },
select: { id: true, hospitalId: true },
});
if (!department || department.hospitalId !== hospitalId) {
throw new BadRequestException(MESSAGES.USER.DEPARTMENT_HOSPITAL_MISMATCH);
}
}
if (groupId) {
if (!departmentId) {
throw new BadRequestException(MESSAGES.USER.GROUP_DEPARTMENT_REQUIRED);
}
const group = await this.prisma.group.findUnique({
where: { id: groupId },
select: { id: true, departmentId: true },
});
if (!group || group.departmentId !== departmentId) {
throw new BadRequestException(MESSAGES.USER.GROUP_DEPARTMENT_MISMATCH);
}
}
}
/**
*
*/
private normalizeRequiredInt(value: unknown, fieldName: string): number {
const parsed = Number(value);
if (!Number.isInteger(parsed)) {
throw new BadRequestException(`${fieldName} 必须为整数`);
}
return parsed;
}
/**
*
*/
private normalizeOptionalInt(
value: unknown,
fieldName: string,
): number | null {
if (value === undefined || value === null || value === '') {
return null;
}
return this.normalizeRequiredInt(value, fieldName);
}
/**
*
*/
private normalizeRequiredString(value: unknown, fieldName: string): string {
if (typeof value !== 'string') {
throw new BadRequestException(`${fieldName} 必须为字符串`);
}
const trimmed = value.trim();
if (!trimmed) {
throw new BadRequestException(`${fieldName} 不能为空`);
}
return trimmed;
}
/**
*
*/
private normalizeOptionalString(value: unknown): string | null {
if (value === undefined || value === null) {
return null;
}
if (typeof value !== 'string') {
throw new BadRequestException(MESSAGES.USER.INVALID_OPEN_ID);
}
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
/**
*
*/
private normalizePhone(phone: unknown): string {
const normalized = this.normalizeRequiredString(phone, 'phone');
if (!/^1\d{10}$/.test(normalized)) {
throw new BadRequestException(MESSAGES.USER.INVALID_PHONE);
}
return normalized;
}
/**
*
*/
private normalizePassword(password: unknown): string {
const normalized = this.normalizeRequiredString(password, 'password');
if (normalized.length < 8) {
throw new BadRequestException(MESSAGES.USER.INVALID_PASSWORD);
}
return normalized;
}
/**
*
*/
private normalizeRole(role: unknown): Role {
if (typeof role !== 'string') {
throw new BadRequestException(MESSAGES.USER.INVALID_ROLE);
}
if (!Object.values(Role).includes(role as Role)) {
throw new BadRequestException(MESSAGES.USER.INVALID_ROLE);
}
return role as Role;
}
/**
* 访
*/
private signAccessToken(actor: ActorContext): string {
const secret = process.env.AUTH_TOKEN_SECRET;
if (!secret) {
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_SECRET_MISSING);
}
return jwt.sign(actor, secret, {
algorithm: 'HS256',
expiresIn: '7d',
issuer: 'tyt-api-nest',
});
}
}

View File

@ -0,0 +1,59 @@
import { Role } from '../../../src/generated/prisma/enums.js';
export const E2E_SEED_PASSWORD = 'Seed@1234';
export const E2E_ROLE_LIST = [
Role.SYSTEM_ADMIN,
Role.HOSPITAL_ADMIN,
Role.DIRECTOR,
Role.LEADER,
Role.DOCTOR,
Role.ENGINEER,
] as const;
export type E2ERole = (typeof E2E_ROLE_LIST)[number];
export interface E2ESeedCredential {
role: E2ERole;
phone: string;
password: string;
hospitalId?: number;
}
export const E2E_SEED_CREDENTIALS: Record<E2ERole, E2ESeedCredential> = {
[Role.SYSTEM_ADMIN]: {
role: Role.SYSTEM_ADMIN,
phone: '13800001000',
password: E2E_SEED_PASSWORD,
},
[Role.HOSPITAL_ADMIN]: {
role: Role.HOSPITAL_ADMIN,
phone: '13800001001',
password: E2E_SEED_PASSWORD,
hospitalId: 1,
},
[Role.DIRECTOR]: {
role: Role.DIRECTOR,
phone: '13800001002',
password: E2E_SEED_PASSWORD,
hospitalId: 1,
},
[Role.LEADER]: {
role: Role.LEADER,
phone: '13800001003',
password: E2E_SEED_PASSWORD,
hospitalId: 1,
},
[Role.DOCTOR]: {
role: Role.DOCTOR,
phone: '13800001004',
password: E2E_SEED_PASSWORD,
hospitalId: 1,
},
[Role.ENGINEER]: {
role: Role.ENGINEER,
phone: '13800001005',
password: E2E_SEED_PASSWORD,
hospitalId: 1,
},
};

View File

@ -0,0 +1,41 @@
import 'dotenv/config';
import { BadRequestException, ValidationPipe } from '@nestjs/common';
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { AppModule } from '../../../src/app.module.js';
import { HttpExceptionFilter } from '../../../src/common/http-exception.filter.js';
import { MESSAGES } from '../../../src/common/messages.js';
import { ResponseEnvelopeInterceptor } from '../../../src/common/response-envelope.interceptor.js';
export async function createE2eApp(): Promise<INestApplication> {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
const app = moduleRef.createNestApplication();
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
exceptionFactory: (errors) => {
const messages = errors
.flatMap((error) => Object.values(error.constraints ?? {}))
.filter((item): item is string => Boolean(item));
return new BadRequestException(
messages.length > 0
? messages.join('')
: MESSAGES.DEFAULT_BAD_REQUEST,
);
},
}),
);
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new ResponseEnvelopeInterceptor());
await app.init();
return app;
}

View File

@ -0,0 +1,47 @@
import type { INestApplication } from '@nestjs/common';
import request from 'supertest';
import {
E2E_ROLE_LIST,
type E2ERole,
E2E_SEED_CREDENTIALS,
} from '../fixtures/e2e-roles.js';
import { expectSuccessEnvelope } from './e2e-http.helper.js';
export type E2EAccessTokenMap = Record<E2ERole, string>;
export async function loginAsRole(
app: INestApplication,
role: E2ERole,
): Promise<string> {
const credential = E2E_SEED_CREDENTIALS[role];
const payload: Record<string, unknown> = {
phone: credential.phone,
password: credential.password,
role: credential.role,
};
if (credential.hospitalId != null) {
payload.hospitalId = credential.hospitalId;
}
const response = await request(app.getHttpServer())
.post('/auth/login')
.send(payload);
expectSuccessEnvelope(response, 201);
expect(response.body.data?.accessToken).toEqual(expect.any(String));
return response.body.data.accessToken as string;
}
export async function loginAllRoles(
app: INestApplication,
): Promise<E2EAccessTokenMap> {
const tokenEntries = await Promise.all(
E2E_ROLE_LIST.map(
async (role) => [role, await loginAsRole(app, role)] as const,
),
);
return Object.fromEntries(tokenEntries) as E2EAccessTokenMap;
}

View File

@ -0,0 +1,38 @@
import type { INestApplication } from '@nestjs/common';
import { PrismaService } from '../../../src/prisma.service.js';
import { loginAllRoles, type E2EAccessTokenMap } from './e2e-auth.helper.js';
import { createE2eApp } from './e2e-app.helper.js';
import {
loadSeedFixtures,
type E2ESeedFixtures,
} from './e2e-fixtures.helper.js';
export interface E2EContext {
app: INestApplication;
prisma: PrismaService;
tokens: E2EAccessTokenMap;
fixtures: E2ESeedFixtures;
}
export async function createE2EContext(): Promise<E2EContext> {
const app = await createE2eApp();
const prisma = app.get(PrismaService);
const fixtures = await loadSeedFixtures(prisma);
const tokens = await loginAllRoles(app);
return {
app,
prisma,
fixtures,
tokens,
};
}
export async function closeE2EContext(ctx?: E2EContext) {
if (!ctx) {
return;
}
await ctx.prisma.$disconnect();
await ctx.app.close();
}

View File

@ -0,0 +1,195 @@
import { NotFoundException } from '@nestjs/common';
import { PrismaService } from '../../../src/prisma.service.js';
export interface E2ESeedFixtures {
hospitalAId: number;
hospitalBId: number;
departmentA1Id: number;
departmentA2Id: number;
departmentB1Id: number;
groupA1Id: number;
groupA2Id: number;
groupB1Id: number;
users: {
systemAdminId: number;
hospitalAdminAId: number;
directorAId: number;
leaderAId: number;
doctorAId: number;
doctorA2Id: number;
doctorA3Id: number;
doctorBId: number;
engineerAId: number;
engineerBId: number;
};
patients: {
patientA1Id: number;
patientA2Id: number;
patientA3Id: number;
patientB1Id: number;
};
devices: {
deviceA1Id: number;
deviceA2Id: number;
deviceA3Id: number;
deviceA4InactiveId: number;
deviceB1Id: number;
};
}
interface SeedUserScope {
id: number;
hospitalId: number | null;
departmentId: number | null;
groupId: number | null;
}
async function requireUserScope(
prisma: PrismaService,
openId: string,
): Promise<SeedUserScope> {
const user = await prisma.user.findUnique({
where: { openId },
select: {
id: true,
hospitalId: true,
departmentId: true,
groupId: true,
},
});
if (!user) {
throw new NotFoundException(`Seed user not found: ${openId}`);
}
return user;
}
async function requireDeviceId(
prisma: PrismaService,
snCode: string,
): Promise<number> {
const device = await prisma.device.findUnique({
where: { snCode },
select: { id: true },
});
if (!device) {
throw new NotFoundException(`Seed device not found: ${snCode}`);
}
return device.id;
}
async function requirePatientId(
prisma: PrismaService,
hospitalId: number,
phone: string,
idCardHash: string,
): Promise<number> {
const patient = await prisma.patient.findFirst({
where: { hospitalId, phone, idCardHash },
select: { id: true },
});
if (!patient) {
throw new NotFoundException(
`Seed patient not found: ${phone}/${idCardHash}`,
);
}
return patient.id;
}
export async function loadSeedFixtures(
prisma: PrismaService,
): Promise<E2ESeedFixtures> {
const systemAdmin = await requireUserScope(
prisma,
'seed-system-admin-openid',
);
const hospitalAdminA = await requireUserScope(
prisma,
'seed-hospital-admin-a-openid',
);
const directorA = await requireUserScope(prisma, 'seed-director-a-openid');
const leaderA = await requireUserScope(prisma, 'seed-leader-a-openid');
const doctorA = await requireUserScope(prisma, 'seed-doctor-a-openid');
const doctorA2 = await requireUserScope(prisma, 'seed-doctor-a2-openid');
const doctorA3 = await requireUserScope(prisma, 'seed-doctor-a3-openid');
const doctorB = await requireUserScope(prisma, 'seed-doctor-b-openid');
const engineerA = await requireUserScope(prisma, 'seed-engineer-a-openid');
const engineerB = await requireUserScope(prisma, 'seed-engineer-b-openid');
const hospitalAId = hospitalAdminA.hospitalId;
const hospitalBId = doctorB.hospitalId;
const departmentA1Id = doctorA.departmentId;
const departmentA2Id = doctorA3.departmentId;
const departmentB1Id = doctorB.departmentId;
const groupA1Id = doctorA.groupId;
const groupA2Id = doctorA3.groupId;
const groupB1Id = doctorB.groupId;
if (
hospitalAId == null ||
hospitalBId == null ||
departmentA1Id == null ||
departmentA2Id == null ||
departmentB1Id == null ||
groupA1Id == null ||
groupA2Id == null ||
groupB1Id == null
) {
throw new NotFoundException('Seed user scope is incomplete');
}
return {
hospitalAId,
hospitalBId,
departmentA1Id,
departmentA2Id,
departmentB1Id,
groupA1Id,
groupA2Id,
groupB1Id,
users: {
systemAdminId: systemAdmin.id,
hospitalAdminAId: hospitalAdminA.id,
directorAId: directorA.id,
leaderAId: leaderA.id,
doctorAId: doctorA.id,
doctorA2Id: doctorA2.id,
doctorA3Id: doctorA3.id,
doctorBId: doctorB.id,
engineerAId: engineerA.id,
engineerBId: engineerB.id,
},
patients: {
patientA1Id: await requirePatientId(
prisma,
hospitalAId,
'13800002001',
'seed-id-card-cross-hospital',
),
patientA2Id: await requirePatientId(
prisma,
hospitalAId,
'13800002002',
'seed-id-card-a2',
),
patientA3Id: await requirePatientId(
prisma,
hospitalAId,
'13800002003',
'seed-id-card-a3',
),
patientB1Id: await requirePatientId(
prisma,
hospitalBId,
'13800002001',
'seed-id-card-cross-hospital',
),
},
devices: {
deviceA1Id: await requireDeviceId(prisma, 'SEED-SN-A-001'),
deviceA2Id: await requireDeviceId(prisma, 'SEED-SN-A-002'),
deviceA3Id: await requireDeviceId(prisma, 'SEED-SN-A-003'),
deviceA4InactiveId: await requireDeviceId(prisma, 'SEED-SN-A-004'),
deviceB1Id: await requireDeviceId(prisma, 'SEED-SN-B-001'),
},
};
}

View File

@ -0,0 +1,37 @@
import type { Response } from 'supertest';
export function expectSuccessEnvelope(response: Response, status: number) {
expect(response.status).toBe(status);
expect(response.body).toEqual(
expect.objectContaining({
code: 0,
msg: '成功',
}),
);
expect(response.body).toHaveProperty('data');
}
export function expectErrorEnvelope(
response: Response,
status: number,
messageIncludes?: string,
) {
expect(response.status).toBe(status);
expect(response.body.code).toBe(status);
expect(response.body.data).toBeNull();
if (messageIncludes) {
expect(String(response.body.msg)).toContain(messageIncludes);
}
}
export function uniqueSeedValue(prefix: string): string {
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
export function uniquePhone(): string {
const suffix = `${Date.now()}${Math.floor(Math.random() * 1000)}`
.replace(/\D/g, '')
.slice(-10);
return `1${suffix.padStart(10, '0')}`.slice(0, 11);
}

View File

@ -0,0 +1,32 @@
import type { Response } from 'supertest';
import { E2E_ROLE_LIST, type E2ERole } from '../fixtures/e2e-roles.js';
import type { E2EAccessTokenMap } from './e2e-auth.helper.js';
interface RoleMatrixCase {
name: string;
tokens: E2EAccessTokenMap;
expectedStatusByRole: Record<E2ERole, number>;
sendAsRole: (role: E2ERole, token: string) => Promise<Response>;
sendWithoutToken: () => Promise<Response>;
expectedStatusWithoutToken?: number;
}
export async function assertRoleMatrix(matrixCase: RoleMatrixCase) {
for (const role of E2E_ROLE_LIST) {
const response = await matrixCase.sendAsRole(role, matrixCase.tokens[role]);
const expectedStatus = matrixCase.expectedStatusByRole[role];
const isSuccess = expectedStatus >= 200 && expectedStatus < 300;
expect(response.status).toBe(expectedStatus);
expect(response.body.code).toBe(isSuccess ? 0 : expectedStatus);
}
const unauthorizedResponse = await matrixCase.sendWithoutToken();
const unauthorizedStatus = matrixCase.expectedStatusWithoutToken ?? 401;
expect(unauthorizedResponse.status).toBe(unauthorizedStatus);
expect(unauthorizedResponse.body.code).toBe(
unauthorizedStatus >= 200 && unauthorizedStatus < 300
? 0
: unauthorizedStatus,
);
}

View File

@ -0,0 +1,129 @@
import request from 'supertest';
import { Role } from '../../../src/generated/prisma/enums.js';
import {
closeE2EContext,
createE2EContext,
type E2EContext,
} from '../helpers/e2e-context.helper.js';
import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js';
import {
expectErrorEnvelope,
expectSuccessEnvelope,
uniquePhone,
uniqueSeedValue,
} from '../helpers/e2e-http.helper.js';
describe('AuthController (e2e)', () => {
let ctx: E2EContext;
beforeAll(async () => {
ctx = await createE2EContext();
});
afterAll(async () => {
await closeE2EContext(ctx);
});
describe('POST /auth/register', () => {
it('成功:注册医生账号', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/auth/register')
.send({
name: uniqueSeedValue('Auth 注册医生'),
phone: uniquePhone(),
password: 'Seed@1234',
role: Role.DOCTOR,
hospitalId: ctx.fixtures.hospitalAId,
departmentId: ctx.fixtures.departmentA1Id,
groupId: ctx.fixtures.groupA1Id,
openId: uniqueSeedValue('auth-register-openid'),
});
expectSuccessEnvelope(response, 201);
expect(response.body.data.role).toBe(Role.DOCTOR);
});
it('失败:参数不合法返回 400', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/auth/register')
.send({
name: 'bad-register',
phone: '13800009999',
password: '123',
role: Role.DOCTOR,
hospitalId: ctx.fixtures.hospitalAId,
departmentId: ctx.fixtures.departmentA1Id,
groupId: ctx.fixtures.groupA1Id,
});
expectErrorEnvelope(response, 400, 'password 长度至少 8 位');
});
});
describe('POST /auth/login', () => {
it('成功seed 账号登录并拿到 token', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/auth/login')
.send({
phone: '13800001004',
password: 'Seed@1234',
role: Role.DOCTOR,
hospitalId: ctx.fixtures.hospitalAId,
});
expectSuccessEnvelope(response, 201);
expect(response.body.data.accessToken).toEqual(expect.any(String));
expect(response.body.data.actor.role).toBe(Role.DOCTOR);
});
it('失败:密码错误返回 401', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/auth/login')
.send({
phone: '13800001004',
password: 'Seed@12345',
role: Role.DOCTOR,
hospitalId: ctx.fixtures.hospitalAId,
});
expectErrorEnvelope(response, 401, '手机号、角色或密码错误');
});
});
describe('GET /auth/me', () => {
it('成功:已登录用户可读取当前信息', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/auth/me')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data.role).toBe(Role.DOCTOR);
});
it('失败:未登录返回 401', async () => {
const response = await request(ctx.app.getHttpServer()).get('/auth/me');
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
});
it('角色矩阵6 角色都可访问,未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /auth/me role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 200,
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 200,
[Role.LEADER]: 200,
[Role.DOCTOR]: 200,
[Role.ENGINEER]: 200,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.get('/auth/me')
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).get('/auth/me'),
});
});
});
});

View File

@ -0,0 +1,729 @@
import request from 'supertest';
import { Role } from '../../../src/generated/prisma/enums.js';
import {
closeE2EContext,
createE2EContext,
type E2EContext,
} from '../helpers/e2e-context.helper.js';
import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js';
import {
expectErrorEnvelope,
expectSuccessEnvelope,
uniqueSeedValue,
} from '../helpers/e2e-http.helper.js';
describe('Organization Controllers (e2e)', () => {
let ctx: E2EContext;
beforeAll(async () => {
ctx = await createE2EContext();
});
afterAll(async () => {
await closeE2EContext(ctx);
});
describe('HospitalsController', () => {
describe('POST /b/organization/hospitals', () => {
it('成功SYSTEM_ADMIN 可创建医院', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/organization/hospitals')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({ name: uniqueSeedValue('组织-医院') });
expectSuccessEnvelope(response, 201);
expect(response.body.data.name).toContain('组织-医院');
});
it('失败:非系统管理员创建返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/organization/hospitals')
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({ name: uniqueSeedValue('组织-医院-失败') });
expectErrorEnvelope(response, 403, '无权限执行当前操作');
});
it('角色矩阵:仅 SYSTEM_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /b/organization/hospitals role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 400,
[Role.HOSPITAL_ADMIN]: 403,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.post('/b/organization/hospitals')
.set('Authorization', `Bearer ${token}`)
.send({}),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.post('/b/organization/hospitals')
.send({}),
});
});
});
describe('GET /b/organization/hospitals', () => {
it('成功SYSTEM_ADMIN 可查询医院列表', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/b/organization/hospitals')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data).toHaveProperty('list');
});
it('失败:未登录返回 401', async () => {
const response = await request(ctx.app.getHttpServer()).get(
'/b/organization/hospitals',
);
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /b/organization/hospitals role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 200,
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.get('/b/organization/hospitals')
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).get('/b/organization/hospitals'),
});
});
});
describe('GET /b/organization/hospitals/:id', () => {
it('成功HOSPITAL_ADMIN 可查询本院详情', async () => {
const response = await request(ctx.app.getHttpServer())
.get(`/b/organization/hospitals/${ctx.fixtures.hospitalAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data.id).toBe(ctx.fixtures.hospitalAId);
});
it('失败HOSPITAL_ADMIN 查询他院返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.get(`/b/organization/hospitals/${ctx.fixtures.hospitalBId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /b/organization/hospitals/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 200,
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.get(`/b/organization/hospitals/${ctx.fixtures.hospitalAId}`)
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).get(
`/b/organization/hospitals/${ctx.fixtures.hospitalAId}`,
),
});
});
});
describe('PATCH /b/organization/hospitals/:id', () => {
it('成功HOSPITAL_ADMIN 可更新本院名称', async () => {
const originalName = 'Seed Hospital A';
const nextName = uniqueSeedValue('医院更新');
const response = await request(ctx.app.getHttpServer())
.patch(`/b/organization/hospitals/${ctx.fixtures.hospitalAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({ name: nextName });
expectSuccessEnvelope(response, 200);
const rollbackResponse = await request(ctx.app.getHttpServer())
.patch(`/b/organization/hospitals/${ctx.fixtures.hospitalAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({ name: originalName });
expectSuccessEnvelope(rollbackResponse, 200);
});
it('失败HOSPITAL_ADMIN 更新他院返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.patch(`/b/organization/hospitals/${ctx.fixtures.hospitalBId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({ name: uniqueSeedValue('跨院更新失败') });
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'PATCH /b/organization/hospitals/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 404,
[Role.HOSPITAL_ADMIN]: 404,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.patch('/b/organization/hospitals/99999999')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'matrix-hospital-patch' }),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.patch('/b/organization/hospitals/99999999')
.send({ name: 'matrix-hospital-patch' }),
});
});
});
describe('DELETE /b/organization/hospitals/:id', () => {
it('成功SYSTEM_ADMIN 可删除空医院', async () => {
const createResponse = await request(ctx.app.getHttpServer())
.post('/b/organization/hospitals')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({ name: uniqueSeedValue('医院待删') });
expectSuccessEnvelope(createResponse, 201);
const targetId = createResponse.body.data.id as number;
const deleteResponse = await request(ctx.app.getHttpServer())
.delete(`/b/organization/hospitals/${targetId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectSuccessEnvelope(deleteResponse, 200);
expect(deleteResponse.body.data.id).toBe(targetId);
});
it('失败HOSPITAL_ADMIN 删除医院返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.delete(`/b/organization/hospitals/${ctx.fixtures.hospitalAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
expectErrorEnvelope(response, 403, '无权限执行当前操作');
});
it('角色矩阵:仅 SYSTEM_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'DELETE /b/organization/hospitals/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 404,
[Role.HOSPITAL_ADMIN]: 403,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.delete('/b/organization/hospitals/99999999')
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).delete(
'/b/organization/hospitals/99999999',
),
});
});
});
});
describe('DepartmentsController', () => {
describe('POST /b/organization/departments', () => {
it('成功HOSPITAL_ADMIN 可在本院创建科室', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/organization/departments')
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({
name: uniqueSeedValue('组织-科室'),
hospitalId: ctx.fixtures.hospitalAId,
});
expectSuccessEnvelope(response, 201);
});
it('失败HOSPITAL_ADMIN 跨院创建返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/organization/departments')
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({
name: uniqueSeedValue('组织-跨院科室失败'),
hospitalId: ctx.fixtures.hospitalBId,
});
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /b/organization/departments role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 400,
[Role.HOSPITAL_ADMIN]: 400,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.post('/b/organization/departments')
.set('Authorization', `Bearer ${token}`)
.send({}),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.post('/b/organization/departments')
.send({}),
});
});
});
describe('GET /b/organization/departments', () => {
it('成功HOSPITAL_ADMIN 可查询本院科室列表', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/b/organization/departments')
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data).toHaveProperty('list');
});
it('失败:未登录返回 401', async () => {
const response = await request(ctx.app.getHttpServer()).get(
'/b/organization/departments',
);
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /b/organization/departments role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 200,
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.get('/b/organization/departments')
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).get('/b/organization/departments'),
});
});
});
describe('GET /b/organization/departments/:id', () => {
it('成功SYSTEM_ADMIN 可查询科室详情', async () => {
const response = await request(ctx.app.getHttpServer())
.get(`/b/organization/departments/${ctx.fixtures.departmentA1Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data.id).toBe(ctx.fixtures.departmentA1Id);
});
it('失败HOSPITAL_ADMIN 查询他院科室返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.get(`/b/organization/departments/${ctx.fixtures.departmentB1Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /b/organization/departments/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 200,
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.get(`/b/organization/departments/${ctx.fixtures.departmentA1Id}`)
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).get(
`/b/organization/departments/${ctx.fixtures.departmentA1Id}`,
),
});
});
});
describe('PATCH /b/organization/departments/:id', () => {
it('成功HOSPITAL_ADMIN 可更新本院科室', async () => {
const originalName = 'Cardiology-A2';
const nextName = uniqueSeedValue('科室更新');
const response = await request(ctx.app.getHttpServer())
.patch(`/b/organization/departments/${ctx.fixtures.departmentA2Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({ name: nextName });
expectSuccessEnvelope(response, 200);
const rollbackResponse = await request(ctx.app.getHttpServer())
.patch(`/b/organization/departments/${ctx.fixtures.departmentA2Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({ name: originalName });
expectSuccessEnvelope(rollbackResponse, 200);
});
it('失败HOSPITAL_ADMIN 更新他院科室返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.patch(`/b/organization/departments/${ctx.fixtures.departmentB1Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({ name: uniqueSeedValue('跨院科室更新失败') });
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'PATCH /b/organization/departments/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 404,
[Role.HOSPITAL_ADMIN]: 404,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.patch('/b/organization/departments/99999999')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'matrix-department-patch' }),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.patch('/b/organization/departments/99999999')
.send({ name: 'matrix-department-patch' }),
});
});
});
describe('DELETE /b/organization/departments/:id', () => {
it('成功SYSTEM_ADMIN 可删除无关联科室', async () => {
const createResponse = await request(ctx.app.getHttpServer())
.post('/b/organization/departments')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({
name: uniqueSeedValue('科室待删'),
hospitalId: ctx.fixtures.hospitalAId,
});
expectSuccessEnvelope(createResponse, 201);
const targetId = createResponse.body.data.id as number;
const deleteResponse = await request(ctx.app.getHttpServer())
.delete(`/b/organization/departments/${targetId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectSuccessEnvelope(deleteResponse, 200);
});
it('失败:存在关联数据删除返回 409', async () => {
const response = await request(ctx.app.getHttpServer())
.delete(`/b/organization/departments/${ctx.fixtures.departmentA1Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectErrorEnvelope(response, 409, '存在关联数据,无法删除');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'DELETE /b/organization/departments/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 404,
[Role.HOSPITAL_ADMIN]: 404,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.delete('/b/organization/departments/99999999')
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).delete(
'/b/organization/departments/99999999',
),
});
});
});
});
describe('GroupsController', () => {
describe('POST /b/organization/groups', () => {
it('成功HOSPITAL_ADMIN 可创建小组', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/organization/groups')
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({
name: uniqueSeedValue('组织-小组'),
departmentId: ctx.fixtures.departmentA1Id,
});
expectSuccessEnvelope(response, 201);
});
it('失败HOSPITAL_ADMIN 跨院创建小组返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/organization/groups')
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({
name: uniqueSeedValue('组织-跨院小组失败'),
departmentId: ctx.fixtures.departmentB1Id,
});
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /b/organization/groups role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 400,
[Role.HOSPITAL_ADMIN]: 400,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.post('/b/organization/groups')
.set('Authorization', `Bearer ${token}`)
.send({}),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.post('/b/organization/groups')
.send({}),
});
});
});
describe('GET /b/organization/groups', () => {
it('成功SYSTEM_ADMIN 可查询小组列表', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/b/organization/groups')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data).toHaveProperty('list');
});
it('失败:未登录返回 401', async () => {
const response = await request(ctx.app.getHttpServer()).get(
'/b/organization/groups',
);
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /b/organization/groups role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 200,
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.get('/b/organization/groups')
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).get('/b/organization/groups'),
});
});
});
describe('GET /b/organization/groups/:id', () => {
it('成功HOSPITAL_ADMIN 可查询本院小组详情', async () => {
const response = await request(ctx.app.getHttpServer())
.get(`/b/organization/groups/${ctx.fixtures.groupA1Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data.id).toBe(ctx.fixtures.groupA1Id);
});
it('失败HOSPITAL_ADMIN 查询他院小组返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.get(`/b/organization/groups/${ctx.fixtures.groupB1Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /b/organization/groups/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 200,
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.get(`/b/organization/groups/${ctx.fixtures.groupA1Id}`)
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).get(
`/b/organization/groups/${ctx.fixtures.groupA1Id}`,
),
});
});
});
describe('PATCH /b/organization/groups/:id', () => {
it('成功HOSPITAL_ADMIN 可更新本院小组', async () => {
const originalName = 'Shift-A2';
const nextName = uniqueSeedValue('小组更新');
const response = await request(ctx.app.getHttpServer())
.patch(`/b/organization/groups/${ctx.fixtures.groupA2Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({ name: nextName });
expectSuccessEnvelope(response, 200);
const rollbackResponse = await request(ctx.app.getHttpServer())
.patch(`/b/organization/groups/${ctx.fixtures.groupA2Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({ name: originalName });
expectSuccessEnvelope(rollbackResponse, 200);
});
it('失败HOSPITAL_ADMIN 更新他院小组返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.patch(`/b/organization/groups/${ctx.fixtures.groupB1Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`)
.send({ name: uniqueSeedValue('跨院小组更新失败') });
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'PATCH /b/organization/groups/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 404,
[Role.HOSPITAL_ADMIN]: 404,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.patch('/b/organization/groups/99999999')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'matrix-group-patch' }),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.patch('/b/organization/groups/99999999')
.send({ name: 'matrix-group-patch' }),
});
});
});
describe('DELETE /b/organization/groups/:id', () => {
it('成功SYSTEM_ADMIN 可删除无关联小组', async () => {
const createResponse = await request(ctx.app.getHttpServer())
.post('/b/organization/groups')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({
name: uniqueSeedValue('小组待删'),
departmentId: ctx.fixtures.departmentA1Id,
});
expectSuccessEnvelope(createResponse, 201);
const targetId = createResponse.body.data.id as number;
const deleteResponse = await request(ctx.app.getHttpServer())
.delete(`/b/organization/groups/${targetId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectSuccessEnvelope(deleteResponse, 200);
});
it('失败HOSPITAL_ADMIN 删除他院小组返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.delete(`/b/organization/groups/${ctx.fixtures.groupB1Id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
expectErrorEnvelope(response, 403, '院管仅可操作本院组织数据');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'DELETE /b/organization/groups/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 404,
[Role.HOSPITAL_ADMIN]: 404,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.delete('/b/organization/groups/99999999')
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).delete(
'/b/organization/groups/99999999',
),
});
});
});
});
});

View File

@ -0,0 +1,185 @@
import request from 'supertest';
import { Role } from '../../../src/generated/prisma/enums.js';
import {
closeE2EContext,
createE2EContext,
type E2EContext,
} from '../helpers/e2e-context.helper.js';
import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js';
import {
expectErrorEnvelope,
expectSuccessEnvelope,
} from '../helpers/e2e-http.helper.js';
describe('Patients Controllers (e2e)', () => {
let ctx: E2EContext;
beforeAll(async () => {
ctx = await createE2EContext();
});
afterAll(async () => {
await closeE2EContext(ctx);
});
describe('GET /b/patients', () => {
it('成功:按角色返回正确可见性范围', async () => {
const systemAdminResponse = await request(ctx.app.getHttpServer())
.get('/b/patients')
.query({ hospitalId: ctx.fixtures.hospitalAId })
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectSuccessEnvelope(systemAdminResponse, 200);
const systemPatientIds = (
systemAdminResponse.body.data as Array<{ id: number }>
).map((item) => item.id);
expect(systemPatientIds).toEqual(
expect.arrayContaining([
ctx.fixtures.patients.patientA1Id,
ctx.fixtures.patients.patientA2Id,
ctx.fixtures.patients.patientA3Id,
]),
);
const hospitalAdminResponse = await request(ctx.app.getHttpServer())
.get('/b/patients')
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
expectSuccessEnvelope(hospitalAdminResponse, 200);
const hospitalPatientIds = (
hospitalAdminResponse.body.data as Array<{ id: number }>
).map((item) => item.id);
expect(hospitalPatientIds).toEqual(
expect.arrayContaining([
ctx.fixtures.patients.patientA1Id,
ctx.fixtures.patients.patientA2Id,
ctx.fixtures.patients.patientA3Id,
]),
);
const directorResponse = await request(ctx.app.getHttpServer())
.get('/b/patients')
.set('Authorization', `Bearer ${ctx.tokens[Role.DIRECTOR]}`);
expectSuccessEnvelope(directorResponse, 200);
const directorPatientIds = (
directorResponse.body.data as Array<{ id: number }>
).map((item) => item.id);
expect(directorPatientIds).toEqual(
expect.arrayContaining([
ctx.fixtures.patients.patientA1Id,
ctx.fixtures.patients.patientA2Id,
]),
);
expect(directorPatientIds).not.toContain(
ctx.fixtures.patients.patientA3Id,
);
const leaderResponse = await request(ctx.app.getHttpServer())
.get('/b/patients')
.set('Authorization', `Bearer ${ctx.tokens[Role.LEADER]}`);
expectSuccessEnvelope(leaderResponse, 200);
const leaderPatientIds = (
leaderResponse.body.data as Array<{ id: number }>
).map((item) => item.id);
expect(leaderPatientIds).toEqual(
expect.arrayContaining([
ctx.fixtures.patients.patientA1Id,
ctx.fixtures.patients.patientA2Id,
]),
);
expect(leaderPatientIds).not.toContain(ctx.fixtures.patients.patientA3Id);
const doctorResponse = await request(ctx.app.getHttpServer())
.get('/b/patients')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`);
expectSuccessEnvelope(doctorResponse, 200);
const doctorPatientIds = (
doctorResponse.body.data as Array<{ id: number }>
).map((item) => item.id);
expect(doctorPatientIds).toContain(ctx.fixtures.patients.patientA1Id);
expect(doctorPatientIds).not.toContain(ctx.fixtures.patients.patientA2Id);
expect(doctorPatientIds).not.toContain(ctx.fixtures.patients.patientA3Id);
const engineerResponse = await request(ctx.app.getHttpServer())
.get('/b/patients')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`);
expectErrorEnvelope(engineerResponse, 403, '无权限执行当前操作');
});
it('失败SYSTEM_ADMIN 不传 hospitalId 返回 400', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/b/patients')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectErrorEnvelope(
response,
400,
'系统管理员查询必须显式传入 hospitalId',
);
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN/DIRECTOR/LEADER/DOCTOR 可访问ENGINEER 403未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /b/patients role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 200,
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 200,
[Role.LEADER]: 200,
[Role.DOCTOR]: 200,
[Role.ENGINEER]: 403,
},
sendAsRole: async (role, token) => {
const req = request(ctx.app.getHttpServer())
.get('/b/patients')
.set('Authorization', `Bearer ${token}`);
if (role === Role.SYSTEM_ADMIN) {
req.query({ hospitalId: ctx.fixtures.hospitalAId });
}
return req;
},
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).get('/b/patients'),
});
});
});
describe('GET /c/patients/lifecycle', () => {
it('成功:可按 phone + idCardHash 查询跨院生命周期', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/c/patients/lifecycle')
.query({
phone: '13800002001',
idCardHash: 'seed-id-card-cross-hospital',
});
expectSuccessEnvelope(response, 200);
expect(response.body.data.phone).toBe('13800002001');
expect(response.body.data.idCardHash).toBe('seed-id-card-cross-hospital');
expect(response.body.data.patientCount).toBeGreaterThanOrEqual(2);
expect(Array.isArray(response.body.data.lifecycle)).toBe(true);
});
it('失败:参数缺失返回 400', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/c/patients/lifecycle')
.query({
phone: '13800002001',
});
expectErrorEnvelope(response, 400, 'idCardHash 必须是字符串');
});
it('失败:不存在患者返回 404', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/c/patients/lifecycle')
.query({
phone: '13800009999',
idCardHash: 'not-exists-idcard-hash',
});
expectErrorEnvelope(response, 404, '未找到匹配的患者档案');
});
});
});

View File

@ -0,0 +1,325 @@
import request from 'supertest';
import { Role, TaskStatus } from '../../../src/generated/prisma/enums.js';
import {
closeE2EContext,
createE2EContext,
type E2EContext,
} from '../helpers/e2e-context.helper.js';
import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js';
import {
expectErrorEnvelope,
expectSuccessEnvelope,
} from '../helpers/e2e-http.helper.js';
describe('BTasksController (e2e)', () => {
let ctx: E2EContext;
beforeAll(async () => {
ctx = await createE2EContext();
});
afterAll(async () => {
await closeE2EContext(ctx);
});
async function publishPendingTask(deviceId: number, targetPressure: number) {
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
items: [
{
deviceId,
targetPressure,
},
],
});
expectSuccessEnvelope(response, 201);
return response.body.data as { id: number; status: TaskStatus };
}
describe('POST /b/tasks/publish', () => {
it('成功DOCTOR 可发布任务', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
engineerId: ctx.fixtures.users.engineerAId,
items: [
{
deviceId: ctx.fixtures.devices.deviceA2Id,
targetPressure: 126,
},
],
});
expectSuccessEnvelope(response, 201);
expect(response.body.data.status).toBe(TaskStatus.PENDING);
});
it('失败:发布跨院设备任务返回 404', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({
items: [
{
deviceId: ctx.fixtures.devices.deviceB1Id,
targetPressure: 120,
},
],
});
expectErrorEnvelope(response, 404, '存在设备不在当前医院或设备不存在');
});
it('角色矩阵:仅 DOCTOR 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /b/tasks/publish role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 403,
[Role.HOSPITAL_ADMIN]: 403,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 400,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.post('/b/tasks/publish')
.set('Authorization', `Bearer ${token}`)
.send({}),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).post('/b/tasks/publish').send({}),
});
});
});
describe('POST /b/tasks/accept', () => {
it('成功ENGINEER 可接收待处理任务', async () => {
const task = await publishPendingTask(
ctx.fixtures.devices.deviceA2Id,
127,
);
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectSuccessEnvelope(response, 201);
expect(response.body.data.status).toBe(TaskStatus.ACCEPTED);
expect(response.body.data.engineerId).toBe(
ctx.fixtures.users.engineerAId,
);
});
it('失败:接收不存在任务返回 404', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: 99999999 });
expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院');
});
it('状态机失败:重复接收返回 409', async () => {
const task = await publishPendingTask(
ctx.fixtures.devices.deviceA3Id,
122,
);
const firstAccept = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectSuccessEnvelope(firstAccept, 201);
const secondAccept = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectErrorEnvelope(secondAccept, 409, '仅待接收任务可执行接收');
});
it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /b/tasks/accept role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 403,
[Role.HOSPITAL_ADMIN]: 403,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 404,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${token}`)
.send({ taskId: 99999999 }),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.send({ taskId: 99999999 }),
});
});
});
describe('POST /b/tasks/complete', () => {
it('成功ENGINEER 完成已接收任务并同步设备压力', async () => {
const targetPressure = 135;
const task = await publishPendingTask(
ctx.fixtures.devices.deviceA1Id,
targetPressure,
);
const acceptResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectSuccessEnvelope(acceptResponse, 201);
const completeResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectSuccessEnvelope(completeResponse, 201);
expect(completeResponse.body.data.status).toBe(TaskStatus.COMPLETED);
const device = await ctx.prisma.device.findUnique({
where: { id: ctx.fixtures.devices.deviceA1Id },
select: { currentPressure: true },
});
expect(device?.currentPressure).toBe(targetPressure);
});
it('失败:完成不存在任务返回 404', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: 99999999 });
expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院');
});
it('状态机失败:未接收任务直接完成返回 409', async () => {
const task = await publishPendingTask(
ctx.fixtures.devices.deviceA2Id,
124,
);
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectErrorEnvelope(response, 409, '仅已接收任务可执行完成');
});
it('角色矩阵:仅 ENGINEER 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /b/tasks/complete role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 403,
[Role.HOSPITAL_ADMIN]: 403,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 404,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${token}`)
.send({ taskId: 99999999 }),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.send({ taskId: 99999999 }),
});
});
});
describe('POST /b/tasks/cancel', () => {
it('成功DOCTOR 可取消自己创建的任务', async () => {
const task = await publishPendingTask(
ctx.fixtures.devices.deviceA3Id,
120,
);
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/cancel')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({ taskId: task.id });
expectSuccessEnvelope(response, 201);
expect(response.body.data.status).toBe(TaskStatus.CANCELLED);
});
it('失败:取消不存在任务返回 404', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/b/tasks/cancel')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({ taskId: 99999999 });
expectErrorEnvelope(response, 404, '任务不存在或不属于当前医院');
});
it('状态机失败:已完成任务不可取消返回 409', async () => {
const task = await publishPendingTask(
ctx.fixtures.devices.deviceA2Id,
123,
);
const acceptResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/accept')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectSuccessEnvelope(acceptResponse, 201);
const completeResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/complete')
.set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`)
.send({ taskId: task.id });
expectSuccessEnvelope(completeResponse, 201);
const cancelResponse = await request(ctx.app.getHttpServer())
.post('/b/tasks/cancel')
.set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`)
.send({ taskId: task.id });
expectErrorEnvelope(cancelResponse, 409, '仅待接收/已接收任务可取消');
});
it('角色矩阵:仅 DOCTOR 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /b/tasks/cancel role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 403,
[Role.HOSPITAL_ADMIN]: 403,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 404,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.post('/b/tasks/cancel')
.set('Authorization', `Bearer ${token}`)
.send({ taskId: 99999999 }),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.post('/b/tasks/cancel')
.send({ taskId: 99999999 }),
});
});
});
});

View File

@ -0,0 +1,340 @@
import request from 'supertest';
import { Role } from '../../../src/generated/prisma/enums.js';
import {
closeE2EContext,
createE2EContext,
type E2EContext,
} from '../helpers/e2e-context.helper.js';
import { assertRoleMatrix } from '../helpers/e2e-matrix.helper.js';
import {
expectErrorEnvelope,
expectSuccessEnvelope,
uniquePhone,
uniqueSeedValue,
} from '../helpers/e2e-http.helper.js';
describe('UsersController + BUsersController (e2e)', () => {
let ctx: E2EContext;
beforeAll(async () => {
ctx = await createE2EContext();
});
afterAll(async () => {
await closeE2EContext(ctx);
});
async function createDoctorUser(token: string) {
const response = await request(ctx.app.getHttpServer())
.post('/users')
.set('Authorization', `Bearer ${token}`)
.send({
name: uniqueSeedValue('用户-医生'),
phone: uniquePhone(),
password: 'Seed@1234',
role: Role.DOCTOR,
hospitalId: ctx.fixtures.hospitalAId,
departmentId: ctx.fixtures.departmentA1Id,
groupId: ctx.fixtures.groupA1Id,
openId: uniqueSeedValue('users-doctor-openid'),
});
expectSuccessEnvelope(response, 201);
return response.body.data as { id: number; name: string };
}
async function createEngineerUser(token: string) {
const response = await request(ctx.app.getHttpServer())
.post('/users')
.set('Authorization', `Bearer ${token}`)
.send({
name: uniqueSeedValue('用户-工程师'),
phone: uniquePhone(),
password: 'Seed@1234',
role: Role.ENGINEER,
hospitalId: ctx.fixtures.hospitalAId,
openId: uniqueSeedValue('users-engineer-openid'),
});
expectSuccessEnvelope(response, 201);
return response.body.data as { id: number; name: string };
}
describe('POST /users', () => {
it('成功SYSTEM_ADMIN 可创建用户', async () => {
await createDoctorUser(ctx.tokens[Role.SYSTEM_ADMIN]);
});
it('失败:参数校验失败返回 400', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/users')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({
name: 'bad-user',
phone: '123',
password: 'short',
role: Role.DOCTOR,
hospitalId: ctx.fixtures.hospitalAId,
departmentId: ctx.fixtures.departmentA1Id,
groupId: ctx.fixtures.groupA1Id,
});
expectErrorEnvelope(response, 400, 'phone 必须是合法手机号');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'POST /users role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 400,
[Role.HOSPITAL_ADMIN]: 400,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.post('/users')
.set('Authorization', `Bearer ${token}`)
.send({}),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).post('/users').send({}),
});
});
});
describe('GET /users', () => {
it('成功SYSTEM_ADMIN 可查询用户列表', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/users')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectSuccessEnvelope(response, 200);
expect(Array.isArray(response.body.data)).toBe(true);
});
it('失败:未登录返回 401', async () => {
const response = await request(ctx.app.getHttpServer()).get('/users');
expectErrorEnvelope(response, 401, '缺少 Bearer Token');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /users role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 200,
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.get('/users')
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).get('/users'),
});
});
});
describe('GET /users/:id', () => {
it('成功SYSTEM_ADMIN 可查询用户详情', async () => {
const response = await request(ctx.app.getHttpServer())
.get(`/users/${ctx.fixtures.users.doctorAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data.id).toBe(ctx.fixtures.users.doctorAId);
});
it('失败:查询不存在用户返回 404', async () => {
const response = await request(ctx.app.getHttpServer())
.get('/users/99999999')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectErrorEnvelope(response, 404, '用户不存在');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可访问,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'GET /users/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 200,
[Role.HOSPITAL_ADMIN]: 200,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.get(`/users/${ctx.fixtures.users.doctorAId}`)
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).get(
`/users/${ctx.fixtures.users.doctorAId}`,
),
});
});
});
describe('PATCH /users/:id', () => {
it('成功SYSTEM_ADMIN 可更新用户姓名', async () => {
const created = await createDoctorUser(ctx.tokens[Role.SYSTEM_ADMIN]);
const nextName = uniqueSeedValue('更新后医生名');
const response = await request(ctx.app.getHttpServer())
.patch(`/users/${created.id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({ name: nextName });
expectSuccessEnvelope(response, 200);
expect(response.body.data.name).toBe(nextName);
});
it('失败:非医生调整科室/小组返回 400', async () => {
const response = await request(ctx.app.getHttpServer())
.patch(`/users/${ctx.fixtures.users.engineerAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({
departmentId: ctx.fixtures.departmentA1Id,
groupId: ctx.fixtures.groupA1Id,
});
expectErrorEnvelope(response, 400, '仅医生/主任/组长允许调整科室/小组归属');
});
it('角色矩阵SYSTEM_ADMIN/HOSPITAL_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'PATCH /users/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 404,
[Role.HOSPITAL_ADMIN]: 404,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.patch('/users/99999999')
.set('Authorization', `Bearer ${token}`)
.send({ name: 'matrix-patch' }),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.patch('/users/99999999')
.send({ name: 'matrix-patch' }),
});
});
});
describe('DELETE /users/:id', () => {
it('成功SYSTEM_ADMIN 可删除用户', async () => {
const created = await createEngineerUser(ctx.tokens[Role.SYSTEM_ADMIN]);
const response = await request(ctx.app.getHttpServer())
.delete(`/users/${created.id}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectSuccessEnvelope(response, 200);
expect(response.body.data.id).toBe(created.id);
});
it('失败:存在关联患者/任务时返回 409', async () => {
const response = await request(ctx.app.getHttpServer())
.delete(`/users/${ctx.fixtures.users.doctorAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`);
expectErrorEnvelope(response, 409, '用户存在关联患者或任务,无法删除');
});
it('失败HOSPITAL_ADMIN 无法删除返回 403', async () => {
const response = await request(ctx.app.getHttpServer())
.delete(`/users/${ctx.fixtures.users.doctorAId}`)
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
expectErrorEnvelope(response, 403, '无权限执行当前操作');
});
it('角色矩阵:仅 SYSTEM_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'DELETE /users/:id role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 404,
[Role.HOSPITAL_ADMIN]: 403,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.delete('/users/99999999')
.set('Authorization', `Bearer ${token}`),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer()).delete('/users/99999999'),
});
});
});
describe('PATCH /b/users/:id/assign-engineer-hospital', () => {
it('成功SYSTEM_ADMIN 可绑定工程师医院', async () => {
const response = await request(ctx.app.getHttpServer())
.patch(
`/b/users/${ctx.fixtures.users.engineerAId}/assign-engineer-hospital`,
)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({ hospitalId: ctx.fixtures.hospitalAId });
expectSuccessEnvelope(response, 200);
expect(response.body.data.hospitalId).toBe(ctx.fixtures.hospitalAId);
expect(response.body.data.role).toBe(Role.ENGINEER);
});
it('失败:目标用户不是工程师返回 400', async () => {
const response = await request(ctx.app.getHttpServer())
.patch(
`/b/users/${ctx.fixtures.users.doctorAId}/assign-engineer-hospital`,
)
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({ hospitalId: ctx.fixtures.hospitalAId });
expectErrorEnvelope(response, 400, '目标用户不是工程师');
});
it('角色矩阵:仅 SYSTEM_ADMIN 可进入业务,其他角色 403未登录 401', async () => {
await assertRoleMatrix({
name: 'PATCH /b/users/:id/assign-engineer-hospital role matrix',
tokens: ctx.tokens,
expectedStatusByRole: {
[Role.SYSTEM_ADMIN]: 400,
[Role.HOSPITAL_ADMIN]: 403,
[Role.DIRECTOR]: 403,
[Role.LEADER]: 403,
[Role.DOCTOR]: 403,
[Role.ENGINEER]: 403,
},
sendAsRole: async (_role, token) =>
request(ctx.app.getHttpServer())
.patch(
`/b/users/${ctx.fixtures.users.engineerAId}/assign-engineer-hospital`,
)
.set('Authorization', `Bearer ${token}`)
.send({}),
sendWithoutToken: async () =>
request(ctx.app.getHttpServer())
.patch(
`/b/users/${ctx.fixtures.users.engineerAId}/assign-engineer-hospital`,
)
.send({}),
});
});
});
});

20
test/jest-e2e.config.cjs Normal file
View File

@ -0,0 +1,20 @@
module.exports = {
rootDir: '../',
testEnvironment: 'node',
moduleFileExtensions: ['js', 'json', 'ts'],
testRegex: 'test/e2e/specs/.*\\.e2e-spec\\.ts$',
transform: {
'^.+\\.(t|j)s$': [
'ts-jest',
{
useESM: true,
tsconfig: '<rootDir>/test/tsconfig.e2e.json',
},
],
},
extensionsToTreatAsEsm: ['.ts'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
maxWorkers: 1,
};

10
test/tsconfig.e2e.json Normal file
View File

@ -0,0 +1,10 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"types": ["node", "jest"],
"noEmit": true
},
"include": ["./e2e/**/*.ts", "../src/**/*.ts"]
}

24
tyt-admin/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
tyt-admin/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

10
tyt-admin/auto-imports.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
}

54
tyt-admin/components.d.ts vendored Normal file
View File

@ -0,0 +1,54 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ElAlert: typeof import('element-plus/es')['ElAlert']
ElAside: typeof import('element-plus/es')['ElAside']
ElButton: typeof import('element-plus/es')['ElButton']
ElCard: typeof import('element-plus/es')['ElCard']
ElCol: typeof import('element-plus/es')['ElCol']
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
ElContainer: typeof import('element-plus/es')['ElContainer']
ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDropdown: typeof import('element-plus/es')['ElDropdown']
ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem']
ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu']
ElEmpty: typeof import('element-plus/es')['ElEmpty']
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElHeader: typeof import('element-plus/es')['ElHeader']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElMain: typeof import('element-plus/es')['ElMain']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
ElPagination: typeof import('element-plus/es')['ElPagination']
ElRow: typeof import('element-plus/es')['ElRow']
ElSelect: typeof import('element-plus/es')['ElSelect']
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
ElTable: typeof import('element-plus/es')['ElTable']
ElTableColumn: typeof import('element-plus/es')['ElTableColumn']
ElTag: typeof import('element-plus/es')['ElTag']
ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElTree: typeof import('element-plus/es')['ElTree']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
export interface GlobalDirectives {
vLoading: typeof import('element-plus/es')['ElLoadingDirective']
}
}

13
tyt-admin/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>tyt-admin</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

27
tyt-admin/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "tyt-admin",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.13.6",
"element-plus": "^2.13.5",
"nprogress": "^0.2.0",
"pinia": "^3.0.4",
"vue": "^3.5.30",
"vue-router": "^5.0.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.5",
"sass": "^1.98.0",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^31.0.0",
"vite": "^8.0.0"
}
}

1758
tyt-admin/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

23
tyt-admin/src/App.vue Normal file
View File

@ -0,0 +1,23 @@
<template>
<el-config-provider :locale="locale">
<router-view />
</el-config-provider>
</template>
<script setup>
import { ref } from 'vue';
import zhCn from 'element-plus/es/locale/lang/zh-cn';
const locale = ref(zhCn);
</script>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background-color: #f0f2f5;
}
#app {
height: 100vh;
}
</style>

Some files were not shown because too many files have changed in this diff Show More