From c830a2131e7724988ede0b86e941d5a5e99dcad0 Mon Sep 17 00:00:00 2001 From: EL <1175065040@qq.com> Date: Thu, 26 Mar 2026 03:47:52 +0800 Subject: [PATCH] =?UTF-8?q?=E6=82=A3=E8=80=85=E5=88=97=E8=A1=A8=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=88=86=E9=A1=B5/?= =?UTF-8?q?=E7=AD=9B=E9=80=89=EF=BC=9A=E6=94=AF=E6=8C=81=20page=E3=80=81pa?= =?UTF-8?q?geSize=E3=80=81keyword=E3=80=81doctorId=20=E5=8F=82=E6=95=B0=20?= =?UTF-8?q?=E6=82=A3=E8=80=85=E8=AF=A6=E6=83=85=E9=A1=B5=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E2=80=9C=E7=BC=96=E8=BE=91=E6=89=8B=E6=9C=AF=E2=80=9D=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=EF=BC=9A=E5=8D=95=E6=89=8B=E6=9C=AF=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E7=BC=96=E8=BE=91=EF=BC=8C=E5=A4=9A=E6=89=8B=E6=9C=AF=E5=85=88?= =?UTF-8?q?=E8=BF=9B=E5=85=A5=E6=89=8B=E6=9C=AF=E5=88=97=E8=A1=A8=E9=80=89?= =?UTF-8?q?=E6=8B=A9=20=E6=89=8B=E6=9C=AF=E5=BC=B9=E7=AA=97=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E7=BC=96=E8=BE=91=E6=A8=A1=E5=BC=8F=EF=BC=9A=E7=A6=81?= =?UTF-8?q?=E7=94=A8=E8=AE=BE=E5=A4=87=E7=BB=93=E6=9E=84=E5=A2=9E=E5=88=A0?= =?UTF-8?q?=EF=BC=8C=E4=BB=85=E5=85=81=E8=AE=B8=E4=BF=AE=E6=94=B9=E6=97=A2?= =?UTF-8?q?=E6=9C=89=E6=89=8B=E6=9C=AF=E5=8F=8A=E8=AE=BE=E5=A4=87=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=20=E6=96=B0=E5=A2=9E=E6=9B=B4=E6=96=B0=E6=89=8B?= =?UTF-8?q?=E6=9C=AF=20API=EF=BC=9APATCH=20/b/patients/:patientId/surgerie?= =?UTF-8?q?s/:surgeryId=20=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E6=94=B9=E4=B8=BA=20B=20=E7=AB=AF=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=EF=BC=9AGET=20/b/patients/:id/lifecycle=20=E6=89=8B=E6=9C=AF?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E5=90=8E=E6=94=AF=E6=8C=81=E5=9B=9E=E5=88=B0?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E5=B9=B6=E4=BF=9D=E6=8C=81=E6=89=8B=E6=9C=AF?= =?UTF-8?q?=E6=A0=87=E7=AD=BE=E9=A1=B5=EF=BC=8C=E6=8F=90=E5=8D=87=E8=BF=9E?= =?UTF-8?q?=E7=BB=AD=E6=93=8D=E4=BD=9C=E6=95=88=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 + pnpm-lock.yaml | 25 + pnpm-workspace.yaml | 6 + src/app.module.ts | 18 + src/auth/auth.controller.ts | 7 + .../wechat-miniapp/wechat-miniapp.service.ts | 49 +- src/common/messages.ts | 7 + src/main.ts | 146 ++++- .../b-patients/b-patients.controller.ts | 103 ++-- src/patients/b-patients/b-patients.service.ts | 518 ++++++++++++++++-- src/patients/c-patients/c-patients.service.ts | 185 +------ .../dto/create-patient-surgery.dto.ts | 2 +- src/patients/dto/create-patient.dto.ts | 2 +- src/patients/dto/create-surgery-device.dto.ts | 2 +- src/patients/dto/patient-query.dto.ts | 51 ++ .../dto/update-patient-surgery.dto.ts | 17 + src/patients/dto/update-surgery-device.dto.ts | 16 + src/patients/patient-lifecycle.util.ts | 164 ++++++ src/uploads/b-uploads/b-uploads.controller.ts | 34 +- src/uploads/uploads.service.ts | 82 ++- tyt-admin/src/api/patients.js | 8 +- tyt-admin/src/constants/role-labels.js | 16 + tyt-admin/src/layouts/AdminLayout.vue | 7 +- tyt-admin/src/views/Dashboard.vue | 18 +- tyt-admin/src/views/Login.vue | 7 +- tyt-admin/src/views/patients/Patients.vue | 315 ++++++++--- .../components/SurgeryFormSection.vue | 12 +- 27 files changed, 1424 insertions(+), 395 deletions(-) create mode 100644 src/patients/dto/patient-query.dto.ts create mode 100644 src/patients/dto/update-patient-surgery.dto.ts create mode 100644 src/patients/dto/update-surgery-device.dto.ts create mode 100644 src/patients/patient-lifecycle.util.ts create mode 100644 tyt-admin/src/constants/role-labels.js diff --git a/package.json b/package.json index 2ded8a2..1466af5 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@nestjs/mapped-types": "*", "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.2.6", + "@nestjs/throttler": "^6.5.0", "@prisma/adapter-pg": "^7.5.0", "@prisma/client": "^7.5.0", "bcrypt": "^6.0.0", @@ -31,6 +32,7 @@ "class-validator": "^0.15.1", "dotenv": "^17.3.1", "ffmpeg-static": "^5.3.0", + "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "multer": "^2.1.1", "pg": "^8.20.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b51083e..2b1a681 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@nestjs/swagger': specifier: ^11.2.6 version: 11.2.6(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2) + '@nestjs/throttler': + specifier: ^6.5.0 + version: 6.5.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2) '@prisma/adapter-pg': specifier: ^7.5.0 version: 7.5.0 @@ -47,6 +50,9 @@ importers: ffmpeg-static: specifier: ^5.3.0 version: 5.3.0 + helmet: + specifier: ^8.1.0 + version: 8.1.0 jsonwebtoken: specifier: ^9.0.3 version: 9.0.3 @@ -917,6 +923,13 @@ packages: '@nestjs/platform-express': optional: true + '@nestjs/throttler@6.5.0': + resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==} + peerDependencies: + '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + reflect-metadata: ^0.1.13 || ^0.2.0 + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} @@ -2079,6 +2092,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + helmet@8.1.0: + resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} + engines: {node: '>=18.0.0'} + hono@4.11.4: resolution: {integrity: sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==} engines: {node: '>=16.9.0'} @@ -4332,6 +4349,12 @@ snapshots: optionalDependencies: '@nestjs/platform-express': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16) + '@nestjs/throttler@6.5.0(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.16)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + '@noble/hashes@1.8.0': {} '@nuxt/opencollective@0.4.1': @@ -5559,6 +5582,8 @@ snapshots: dependencies: function-bind: 1.1.2 + helmet@8.1.0: {} + hono@4.11.4: {} html-escaper@2.0.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f44389a..daad73b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,3 +1,9 @@ onlyBuiltDependencies: + - '@nestjs/core' + - '@prisma/engines' + - '@scarf/scarf' + - bcrypt - ffmpeg-static + - prisma - sharp + - unrs-resolver diff --git a/src/app.module.ts b/src/app.module.ts index 504ad66..ae1581d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,5 +1,7 @@ import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; import { EventEmitterModule } from '@nestjs/event-emitter'; +import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { PrismaModule } from './prisma.module.js'; import { UsersModule } from './users/users.module.js'; import { TasksModule } from './tasks/tasks.module.js'; @@ -15,6 +17,16 @@ import { UploadsModule } from './uploads/uploads.module.js'; imports: [ PrismaModule, EventEmitterModule.forRoot(), + ThrottlerModule.forRoot({ + errorMessage: '操作过于频繁,请稍后再试', + throttlers: [ + { + name: 'default', + ttl: 60_000, + limit: 120, + }, + ], + }), UsersModule, TasksModule, PatientsModule, @@ -25,5 +37,11 @@ import { UploadsModule } from './uploads/uploads.module.js'; DictionariesModule, UploadsModule, ], + providers: [ + { + provide: APP_GUARD, + useClass: ThrottlerGuard, + }, + ], }) export class AppModule {} diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 74a4c8e..9f99f16 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,5 +1,6 @@ import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; import { AuthService } from './auth.service.js'; import { AccessTokenGuard } from './access-token.guard.js'; import { CurrentActor } from './current-actor.decorator.js'; @@ -22,6 +23,7 @@ export class AuthController { * 创建系统管理员(需引导密钥)。 */ @Post('system-admin') + @Throttle({ default: { limit: 3, ttl: 60_000 } }) @ApiOperation({ summary: '创建系统管理员' }) createSystemAdmin(@Body() dto: CreateSystemAdminDto) { return this.authService.createSystemAdmin(dto); @@ -31,30 +33,35 @@ export class AuthController { * 院内账号密码登录:后台与小程序均可复用。 */ @Post('login') + @Throttle({ default: { limit: 5, ttl: 60_000 } }) @ApiOperation({ summary: '院内账号密码登录' }) login(@Body() dto: LoginDto) { return this.authService.login(dto); } @Post('login/confirm') + @Throttle({ default: { limit: 10, ttl: 60_000 } }) @ApiOperation({ summary: '院内账号密码多账号确认登录' }) confirmLogin(@Body() dto: PasswordLoginConfirmDto) { return this.authService.confirmLogin(dto); } @Post('miniapp/b/phone-login') + @Throttle({ default: { limit: 5, ttl: 60_000 } }) @ApiOperation({ summary: 'B 端小程序手机号登录' }) miniAppBLogin(@Body() dto: MiniappPhoneLoginDto) { return this.authService.miniAppBLogin(dto); } @Post('miniapp/b/phone-login/confirm') + @Throttle({ default: { limit: 10, ttl: 60_000 } }) @ApiOperation({ summary: 'B 端小程序多账号确认登录' }) miniAppBConfirmLogin(@Body() dto: MiniappPhoneLoginConfirmDto) { return this.authService.miniAppBConfirmLogin(dto); } @Post('miniapp/c/phone-login') + @Throttle({ default: { limit: 5, ttl: 60_000 } }) @ApiOperation({ summary: 'C 端小程序手机号登录' }) miniAppCLogin(@Body() dto: MiniappPhoneLoginDto) { return this.authService.miniAppCLogin(dto); diff --git a/src/auth/wechat-miniapp/wechat-miniapp.service.ts b/src/auth/wechat-miniapp/wechat-miniapp.service.ts index 16b7bd9..cce8a7a 100644 --- a/src/auth/wechat-miniapp/wechat-miniapp.service.ts +++ b/src/auth/wechat-miniapp/wechat-miniapp.service.ts @@ -72,7 +72,12 @@ export class WechatMiniAppService { (await response.json().catch(() => null)) as WechatCode2SessionResponse | null; if (!response.ok || !payload?.openid || payload.errcode) { - throw new UnauthorizedException(MESSAGES.AUTH.WECHAT_MINIAPP_LOGIN_FAILED); + throw new UnauthorizedException( + this.buildWechatAuthErrorMessage( + MESSAGES.AUTH.WECHAT_MINIAPP_LOGIN_FAILED, + payload, + ), + ); } return payload.openid; @@ -100,7 +105,12 @@ export class WechatMiniAppService { const phone = payload?.phone_info?.phoneNumber; if (!response.ok || !phone || payload?.errcode) { - throw new UnauthorizedException(MESSAGES.AUTH.WECHAT_MINIAPP_PHONE_FAILED); + throw new UnauthorizedException( + this.buildWechatAuthErrorMessage( + MESSAGES.AUTH.WECHAT_MINIAPP_PHONE_FAILED, + payload, + ), + ); } return phone; @@ -128,7 +138,12 @@ export class WechatMiniAppService { (await response.json().catch(() => null)) as WechatAccessTokenResponse | null; if (!response.ok || !payload?.access_token || payload.errcode) { - throw new UnauthorizedException(MESSAGES.AUTH.WECHAT_MINIAPP_PHONE_FAILED); + throw new UnauthorizedException( + this.buildWechatAuthErrorMessage( + MESSAGES.AUTH.WECHAT_MINIAPP_PHONE_FAILED, + payload, + ), + ); } this.accessTokenCache = { @@ -173,4 +188,32 @@ export class WechatMiniAppService { return trimmed; } + + /** + * 拼接微信开放平台原始错误,便于定位 appid/code/授权态问题。 + */ + private buildWechatAuthErrorMessage( + fallbackMessage: string, + payload: + | WechatAccessTokenResponse + | WechatCode2SessionResponse + | WechatPhoneResponse + | null, + ) { + const errcode = + payload && typeof payload.errcode === 'number' ? payload.errcode : null; + const errmsg = + payload && typeof payload.errmsg === 'string' ? payload.errmsg.trim() : ''; + + if (errcode == null && !errmsg) { + return fallbackMessage; + } + + const details = [`errcode=${errcode ?? 'unknown'}`]; + if (errmsg) { + details.push(`errmsg=${errmsg}`); + } + + return `${fallbackMessage}(${details.join(', ')})`; + } } diff --git a/src/common/messages.ts b/src/common/messages.ts index 8c13a53..2c7d9e6 100644 --- a/src/common/messages.ts +++ b/src/common/messages.ts @@ -40,6 +40,7 @@ export const MESSAGES = { MINIAPP_OPEN_ID_BOUND_OTHER_FAMILY: '当前微信账号已绑定其他家属账号', FAMILY_PHONE_NOT_LINKED_PATIENT: '当前手机号未关联患者档案', FAMILY_ACCOUNT_NOT_FOUND: '家属登录账号不存在,请重新登录', + THROTTLED: '操作过于频繁,请稍后再试', }, USER: { @@ -108,6 +109,11 @@ export const MESSAGES = { IMPLANT_CATALOG_NOT_FOUND: '植入物型号不存在或不在当前医院可见范围内', SURGERY_UPDATE_NOT_SUPPORTED: '患者更新接口不支持直接修改手术,请使用新增手术接口', + SURGERY_DEVICE_SET_UPDATE_NOT_SUPPORTED: + '编辑手术暂不支持新增或删除植入设备,请逐项修改现有设备信息', + SURGERY_ABANDON_UPDATE_NOT_SUPPORTED: + '编辑手术暂不支持修改弃用旧设备,请通过追加手术处理', + SURGERY_DEVICE_TASK_CONFLICT: '存在调压任务记录的植入设备不支持修改型号', ABANDON_DEVICE_SCOPE_FORBIDDEN: '仅可弃用当前患者名下设备', }, @@ -144,6 +150,7 @@ export const MESSAGES = { '系统管理员上传文件时必须显式指定 hospitalId', INVALID_IMAGE_FILE: '上传的图片无法解析或压缩失败', INVALID_VIDEO_FILE: '上传的视频无法解析或压缩失败', + INVALID_FILE_SIGNATURE: '上传文件内容与声明类型不匹配', FFMPEG_NOT_AVAILABLE: '服务端缺少视频压缩能力', }, diff --git a/src/main.ts b/src/main.ts index 33be212..7c04231 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,24 +1,60 @@ import 'dotenv/config'; import { mkdirSync } from 'node:fs'; +import { basename, extname } from 'node:path'; import { BadRequestException, ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import helmet from 'helmet'; 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'; import { resolveUploadRootDir } from './uploads/upload-path.util.js'; +const INLINE_UPLOAD_EXTENSIONS = new Set([ + '.png', + '.jpg', + '.jpeg', + '.gif', + '.bmp', + '.webp', + '.mp4', + '.mov', + '.avi', + '.mkv', + '.webm', +]); + async function bootstrap() { - // 创建应用实例并加载核心模块。 const app = await NestFactory.create(AppModule); - app.enableCors(); + + app.use( + helmet({ + contentSecurityPolicy: false, + crossOriginResourcePolicy: false, + }), + ); + app.enableCors(buildCorsOptions()); + mkdirSync(resolveUploadRootDir(), { recursive: true }); app.useStaticAssets(resolveUploadRootDir(), { prefix: '/uploads/', + setHeaders: (res, filePath) => { + const extension = extname(filePath).toLowerCase(); + res.setHeader('X-Content-Type-Options', 'nosniff'); + + if (!INLINE_UPLOAD_EXTENSIONS.has(extension)) { + const fileName = basename(filePath); + res.setHeader( + 'Content-Disposition', + `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`, + ); + res.setHeader('Content-Type', 'application/octet-stream'); + } + }, }); - // 全局校验:清理未知字段、自动类型转换,并将校验错误统一为中文信息。 + app.useGlobalPipes( new ValidationPipe({ whitelist: true, @@ -30,40 +66,98 @@ async function bootstrap() { .filter((item): item is string => Boolean(item)); return new BadRequestException( messages.length > 0 - ? messages.join(';') + ? 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://192.168.0.140:3000', 'localhost') - .addBearerAuth( - { - type: 'http', - scheme: 'bearer', - bearerFormat: 'JWT', - description: '在此输入登录接口返回的 accessToken', - }, - 'bearer', - ) - .build(); + if (shouldEnableSwagger()) { + const swaggerBuilder = new DocumentBuilder() + .setTitle('调压通医疗平台接口文档') + .setDescription('包含认证、权限、患者、手术、任务和上传等后端接口。') + .setVersion('1.0.0') + .addBearerAuth( + { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + description: '请填写登录接口返回的访问令牌。', + }, + 'bearer', + ); - const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig); - SwaggerModule.setup('api/docs', app, swaggerDocument, { - jsonDocumentUrl: 'api/docs-json', - }); + const publicBaseUrl = normalizeOrigin(process.env.PUBLIC_BASE_URL); + if (publicBaseUrl) { + swaggerBuilder.addServer(publicBaseUrl, '公开地址'); + } + + const swaggerDocument = SwaggerModule.createDocument( + app, + swaggerBuilder.build(), + ); + SwaggerModule.setup('api/docs', app, swaggerDocument, { + jsonDocumentUrl: 'api/docs-json', + }); + } - // 启动 HTTP 服务。 await app.listen(process.env.PORT ?? 3000); } + bootstrap(); + +function buildCorsOptions() { + const allowedOrigins = parseAllowedOrigins(process.env.CORS_ALLOWED_ORIGINS); + + if (allowedOrigins.length === 0) { + return { + origin: process.env.NODE_ENV === 'production' ? false : true, + credentials: true, + }; + } + + return { + credentials: true, + origin: ( + origin: string | undefined, + callback: (error: Error | null, allow?: boolean) => void, + ) => { + if (!origin) { + callback(null, true); + return; + } + + callback(null, allowedOrigins.includes(normalizeOrigin(origin))); + }, + }; +} + +function parseAllowedOrigins(value: string | undefined) { + if (!value) { + return []; + } + + return Array.from( + new Set( + value + .split(',') + .map((origin) => normalizeOrigin(origin)) + .filter(Boolean), + ), + ); +} + +function normalizeOrigin(origin: string | undefined) { + return String(origin ?? '').trim().replace(/\/+$/, ''); +} + +function shouldEnableSwagger() { + return ( + process.env.ENABLE_SWAGGER === 'true' || + process.env.NODE_ENV !== 'production' + ); +} diff --git a/src/patients/b-patients/b-patients.controller.ts b/src/patients/b-patients/b-patients.controller.ts index 0e96af6..089cb24 100644 --- a/src/patients/b-patients/b-patients.controller.ts +++ b/src/patients/b-patients/b-patients.controller.ts @@ -17,20 +17,19 @@ import { 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 { CurrentActor } from '../../auth/current-actor.decorator.js'; import { Roles } from '../../auth/roles.decorator.js'; +import { RolesGuard } from '../../auth/roles.guard.js'; +import type { ActorContext } from '../../common/actor-context.js'; import { Role } from '../../generated/prisma/enums.js'; -import { BPatientsService } from './b-patients.service.js'; import { CreatePatientDto } from '../dto/create-patient.dto.js'; import { CreatePatientSurgeryDto } from '../dto/create-patient-surgery.dto.js'; +import { PatientQueryDto } from '../dto/patient-query.dto.js'; +import { UpdatePatientSurgeryDto } from '../dto/update-patient-surgery.dto.js'; import { UpdatePatientDto } from '../dto/update-patient.dto.js'; +import { BPatientsService } from './b-patients.service.js'; -/** - * B 端患者控制器:院内可见性隔离查询。 - */ @ApiTags('患者管理(B端)') @ApiBearerAuth('bearer') @Controller('b/patients') @@ -38,9 +37,6 @@ import { UpdatePatientDto } from '../dto/update-patient.dto.js'; export class BPatientsController { constructor(private readonly patientsService: BPatientsService) {} - /** - * 查询当前角色可选择的归属人员列表(医生/主任/组长)。 - */ @Get('doctors') @Roles( Role.SYSTEM_ADMIN, @@ -49,11 +45,11 @@ export class BPatientsController { Role.LEADER, Role.DOCTOR, ) - @ApiOperation({ summary: '查询当前角色可见归属人员列表' }) + @ApiOperation({ summary: '查询当前登录人可见的医生列表' }) @ApiQuery({ name: 'hospitalId', required: false, - description: '系统管理员可显式指定医院', + description: '医院编号,仅系统管理员可选传', }) findVisibleDoctors( @CurrentActor() actor: ActorContext, @@ -64,9 +60,6 @@ export class BPatientsController { return this.patientsService.findVisibleDoctors(actor, requestedHospitalId); } - /** - * 按角色返回可见患者列表。 - */ @Get() @Roles( Role.SYSTEM_ADMIN, @@ -75,25 +68,14 @@ export class BPatientsController { Role.LEADER, Role.DOCTOR, ) - @ApiOperation({ summary: '按角色查询可见患者列表' }) - @ApiQuery({ - name: 'hospitalId', - required: false, - description: '系统管理员可显式指定医院', - }) + @ApiOperation({ summary: '按服务端分页查询当前登录人可见的患者列表' }) findVisiblePatients( @CurrentActor() actor: ActorContext, - @Query('hospitalId') hospitalId?: string, + @Query() query: PatientQueryDto, ) { - const requestedHospitalId = - hospitalId == null || hospitalId === '' ? undefined : Number(hospitalId); - - return this.patientsService.findVisiblePatients(actor, requestedHospitalId); + return this.patientsService.findVisiblePatients(actor, query); } - /** - * 创建患者。 - */ @Post() @Roles( Role.SYSTEM_ADMIN, @@ -102,7 +84,7 @@ export class BPatientsController { Role.LEADER, Role.DOCTOR, ) - @ApiOperation({ summary: '创建患者' }) + @ApiOperation({ summary: '创建患者档案' }) createPatient( @CurrentActor() actor: ActorContext, @Body() dto: CreatePatientDto, @@ -110,9 +92,6 @@ export class BPatientsController { return this.patientsService.createPatient(actor, dto); } - /** - * 为患者新增手术记录。 - */ @Post(':id/surgeries') @Roles( Role.SYSTEM_ADMIN, @@ -122,7 +101,7 @@ export class BPatientsController { Role.DOCTOR, ) @ApiOperation({ summary: '为患者新增手术记录' }) - @ApiParam({ name: 'id', description: '患者 ID' }) + @ApiParam({ name: 'id', description: '患者编号' }) createPatientSurgery( @CurrentActor() actor: ActorContext, @Param('id', ParseIntPipe) id: number, @@ -131,9 +110,43 @@ export class BPatientsController { return this.patientsService.createPatientSurgery(actor, id, dto); } - /** - * 查询患者详情。 - */ + @Patch(':id/surgeries/:surgeryId') + @Roles( + Role.SYSTEM_ADMIN, + Role.HOSPITAL_ADMIN, + Role.DIRECTOR, + Role.LEADER, + Role.DOCTOR, + ) + @ApiOperation({ summary: '更新患者手术记录' }) + @ApiParam({ name: 'id', description: '患者编号' }) + @ApiParam({ name: 'surgeryId', description: '手术编号' }) + updatePatientSurgery( + @CurrentActor() actor: ActorContext, + @Param('id', ParseIntPipe) id: number, + @Param('surgeryId', ParseIntPipe) surgeryId: number, + @Body() dto: UpdatePatientSurgeryDto, + ) { + return this.patientsService.updatePatientSurgery(actor, id, surgeryId, dto); + } + + @Get(':id/lifecycle') + @Roles( + Role.SYSTEM_ADMIN, + Role.HOSPITAL_ADMIN, + Role.DIRECTOR, + Role.LEADER, + Role.DOCTOR, + ) + @ApiOperation({ summary: '查询患者生命周期事件(B端详情页)' }) + @ApiParam({ name: 'id', description: '患者编号' }) + findPatientLifecycle( + @CurrentActor() actor: ActorContext, + @Param('id', ParseIntPipe) id: number, + ) { + return this.patientsService.findPatientLifecycle(actor, id); + } + @Get(':id') @Roles( Role.SYSTEM_ADMIN, @@ -143,7 +156,7 @@ export class BPatientsController { Role.DOCTOR, ) @ApiOperation({ summary: '查询患者详情' }) - @ApiParam({ name: 'id', description: '患者 ID' }) + @ApiParam({ name: 'id', description: '患者编号' }) findPatientById( @CurrentActor() actor: ActorContext, @Param('id', ParseIntPipe) id: number, @@ -151,9 +164,6 @@ export class BPatientsController { return this.patientsService.findPatientById(actor, id); } - /** - * 更新患者信息。 - */ @Patch(':id') @Roles( Role.SYSTEM_ADMIN, @@ -162,8 +172,8 @@ export class BPatientsController { Role.LEADER, Role.DOCTOR, ) - @ApiOperation({ summary: '更新患者信息' }) - @ApiParam({ name: 'id', description: '患者 ID' }) + @ApiOperation({ summary: '更新患者档案' }) + @ApiParam({ name: 'id', description: '患者编号' }) updatePatient( @CurrentActor() actor: ActorContext, @Param('id', ParseIntPipe) id: number, @@ -172,9 +182,6 @@ export class BPatientsController { return this.patientsService.updatePatient(actor, id, dto); } - /** - * 删除患者。 - */ @Delete(':id') @Roles( Role.SYSTEM_ADMIN, @@ -183,8 +190,8 @@ export class BPatientsController { Role.LEADER, Role.DOCTOR, ) - @ApiOperation({ summary: '删除患者' }) - @ApiParam({ name: 'id', description: '患者 ID' }) + @ApiOperation({ summary: '删除患者档案' }) + @ApiParam({ name: 'id', description: '患者编号' }) removePatient( @CurrentActor() actor: ActorContext, @Param('id', ParseIntPipe) id: number, diff --git a/src/patients/b-patients/b-patients.service.ts b/src/patients/b-patients/b-patients.service.ts index 669080a..c42f717 100644 --- a/src/patients/b-patients/b-patients.service.ts +++ b/src/patients/b-patients/b-patients.service.ts @@ -13,7 +13,13 @@ import { MESSAGES } from '../../common/messages.js'; import { normalizePressureLabel } from '../../common/pressure-level.util.js'; import { CreatePatientDto } from '../dto/create-patient.dto.js'; import { CreatePatientSurgeryDto } from '../dto/create-patient-surgery.dto.js'; +import { PatientQueryDto } from '../dto/patient-query.dto.js'; +import { UpdatePatientSurgeryDto } from '../dto/update-patient-surgery.dto.js'; import { UpdatePatientDto } from '../dto/update-patient.dto.js'; +import { + buildPatientLifecycleRecords, + PATIENT_LIFECYCLE_INCLUDE, +} from '../patient-lifecycle.util.js'; import { normalizePatientIdCard } from '../patient-id-card.util.js'; type PrismaExecutor = Prisma.TransactionClient | PrismaService; @@ -147,46 +153,32 @@ export class BPatientsService { /** * 查询当前角色可见患者列表。 */ - async findVisiblePatients(actor: ActorContext, requestedHospitalId?: number) { - const hospitalId = this.resolveHospitalId(actor, requestedHospitalId); - const where = this.buildVisiblePatientWhere(actor, hospitalId); + async findVisiblePatients(actor: ActorContext, query: PatientQueryDto = {}) { + const hospitalId = this.resolveHospitalId(actor, query.hospitalId); + const where = this.buildVisiblePatientWhere(actor, hospitalId, query); + const page = query.page && query.page > 0 ? query.page : 1; + const pageSize = + query.pageSize && query.pageSize > 0 && query.pageSize <= 100 + ? query.pageSize + : 20; - const patients = await this.prisma.patient.findMany({ - where, - include: PATIENT_LIST_INCLUDE, - orderBy: { id: 'desc' }, - }); + const [total, patients] = await this.prisma.$transaction([ + this.prisma.patient.count({ where }), + this.prisma.patient.findMany({ + where, + include: PATIENT_LIST_INCLUDE, + orderBy: { id: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + ]); - return patients.map((patient) => { - const { _count, surgeries, ...rest } = patient; - const latestSurgery = surgeries[0] ?? null; - const currentDevice = - patient.devices.find( - (device) => - device.status === DeviceStatus.ACTIVE && !device.isAbandoned, - ) ?? - patient.devices.find((device) => !device.isAbandoned) ?? - patient.devices[0] ?? - null; - - return { - ...rest, - primaryDisease: latestSurgery?.primaryDisease ?? null, - hydrocephalusTypes: latestSurgery?.hydrocephalusTypes ?? [], - surgeryDate: latestSurgery?.surgeryDate ?? null, - currentPressure: currentDevice?.currentPressure ?? null, - initialPressure: currentDevice?.initialPressure ?? null, - shuntSurgeryCount: _count.surgeries, - latestSurgery, - activeDeviceCount: patient.devices.filter( - (device) => - device.status === DeviceStatus.ACTIVE && !device.isAbandoned, - ).length, - abandonedDeviceCount: patient.devices.filter( - (device) => device.isAbandoned, - ).length, - }; - }); + return { + total, + page, + pageSize, + list: patients.map((patient) => this.decoratePatientListItem(patient)), + }; } /** @@ -313,6 +305,47 @@ export class BPatientsService { }); } + /** + * 编辑患者既有手术记录,仅允许修改手术主信息与既有设备信息。 + */ + async updatePatientSurgery( + actor: ActorContext, + patientId: number, + surgeryId: number, + dto: UpdatePatientSurgeryDto, + ) { + const patient = await this.findPatientWithScope(patientId); + this.assertPatientScope(actor, patient); + + return this.prisma.$transaction(async (tx) => { + const currentSurgery = await this.findPatientSurgeryForUpdate( + tx, + patient.id, + surgeryId, + ); + const updatedSurgery = await this.updatePatientSurgeryRecord( + tx, + actor, + patient.id, + patient.doctorId, + currentSurgery, + dto, + ); + + const detail = await this.loadPatientDetail(tx, patient.id); + const decoratedPatient = this.decoratePatientDetail(detail); + const updated = decoratedPatient.surgeries.find( + (surgery) => surgery.id === updatedSurgery.id, + ); + + if (!updated) { + throw new NotFoundException(MESSAGES.PATIENT.SURGERY_NOT_FOUND); + } + + return updated; + }); + } + /** * 查询患者详情。 */ @@ -322,6 +355,25 @@ export class BPatientsService { return this.decoratePatientDetail(patient); } + async findPatientLifecycle(actor: ActorContext, id: number) { + const patient = await this.prisma.patient.findUnique({ + where: { id }, + include: PATIENT_LIFECYCLE_INCLUDE, + }); + + if (!patient) { + throw new NotFoundException(MESSAGES.PATIENT.NOT_FOUND); + } + + this.assertPatientScope(actor, patient); + + return { + patientId: patient.id, + patientCount: 1, + lifecycle: buildPatientLifecycleRecords([patient]), + }; + } + /** * 更新患者基础信息。 */ @@ -425,6 +477,44 @@ export class BPatientsService { return patient; } + private async findPatientSurgeryForUpdate( + prisma: PrismaExecutor, + patientId: number, + surgeryId: number, + ) { + const normalizedSurgeryId = Number(surgeryId); + if (!Number.isInteger(normalizedSurgeryId)) { + throw new BadRequestException('surgeryId 必须为整数'); + } + + const surgery = await prisma.patientSurgery.findFirst({ + where: { + id: normalizedSurgeryId, + patientId, + }, + include: { + devices: { + select: { + id: true, + implantCatalogId: true, + currentPressure: true, + taskItems: { + select: { id: true }, + take: 1, + }, + }, + orderBy: { id: 'desc' }, + }, + }, + }); + + if (!surgery) { + throw new NotFoundException(MESSAGES.PATIENT.SURGERY_NOT_FOUND); + } + + return surgery; + } + /** * 校验当前角色是否可操作该患者。 */ @@ -529,8 +619,12 @@ export class BPatientsService { /** * 按角色构造患者可见性查询条件。 */ - private buildVisiblePatientWhere(actor: ActorContext, hospitalId: number) { - const where: Record = { hospitalId }; + private buildVisiblePatientWhere( + actor: ActorContext, + hospitalId: number, + query: PatientQueryDto = {}, + ) { + const where: Prisma.PatientWhereInput = { hospitalId }; switch (actor.role) { case Role.DOCTOR: where.doctorId = actor.id; @@ -559,6 +653,71 @@ export class BPatientsService { default: throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN); } + + if (query.doctorId != null) { + const doctorId = this.toInt(query.doctorId, 'doctorId'); + if (actor.role === Role.DOCTOR && doctorId !== actor.id) { + throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN); + } + where.doctorId = doctorId; + } + + const keyword = query.keyword?.trim(); + if (keyword) { + where.OR = [ + { + name: { + contains: keyword, + mode: 'insensitive', + }, + }, + { + phone: { + contains: keyword, + mode: 'insensitive', + }, + }, + { + idCard: { + contains: keyword, + mode: 'insensitive', + }, + }, + { + inpatientNo: { + contains: keyword, + mode: 'insensitive', + }, + }, + { + doctor: { + name: { + contains: keyword, + mode: 'insensitive', + }, + }, + }, + { + hospital: { + name: { + contains: keyword, + mode: 'insensitive', + }, + }, + }, + { + surgeries: { + some: { + surgeryName: { + contains: keyword, + mode: 'insensitive', + }, + }, + }, + }, + ]; + } + return where; } @@ -773,6 +932,204 @@ export class BPatientsService { return surgery; } + private async updatePatientSurgeryRecord( + prisma: PrismaExecutor, + actor: ActorContext, + patientId: number, + patientDoctorId: number, + currentSurgery: Awaited< + ReturnType + >, + dto: UpdatePatientSurgeryDto, + ) { + if ( + Array.isArray(dto.abandonedDeviceIds) && + dto.abandonedDeviceIds.length > 0 + ) { + throw new BadRequestException( + MESSAGES.PATIENT.SURGERY_ABANDON_UPDATE_NOT_SUPPORTED, + ); + } + + const existingDeviceMap = new Map( + currentSurgery.devices.map((device) => [device.id, device]), + ); + const requestedDeviceIds = dto.devices.map((device, index) => + this.toInt(device.id, `devices[${index}].id`), + ); + const uniqueRequestedIds = Array.from(new Set(requestedDeviceIds)); + + if ( + requestedDeviceIds.length !== currentSurgery.devices.length || + uniqueRequestedIds.length !== currentSurgery.devices.length || + uniqueRequestedIds.some((deviceId) => !existingDeviceMap.has(deviceId)) + ) { + throw new BadRequestException( + MESSAGES.PATIENT.SURGERY_DEVICE_SET_UPDATE_NOT_SUPPORTED, + ); + } + + const catalogIds = Array.from( + new Set( + dto.devices.map((device) => + this.toInt(device.implantCatalogId, 'implantCatalogId'), + ), + ), + ); + + const [catalogMap, surgeon] = await Promise.all([ + this.resolveImplantCatalogMap(prisma, catalogIds), + this.resolveWritableDoctor(actor, patientDoctorId), + ]); + + await prisma.patientSurgery.update({ + where: { id: currentSurgery.id }, + data: { + surgeryDate: this.normalizeIsoDate(dto.surgeryDate, 'surgeryDate'), + surgeryName: this.normalizeRequiredString( + dto.surgeryName, + 'surgeryName', + ), + surgeonId: surgeon.id, + surgeonName: surgeon.name, + preOpPressure: + dto.preOpPressure == null + ? null + : this.normalizeNonNegativeInteger( + dto.preOpPressure, + 'preOpPressure', + ), + primaryDisease: this.normalizeRequiredString( + dto.primaryDisease, + 'primaryDisease', + ), + hydrocephalusTypes: this.normalizeStringArray( + dto.hydrocephalusTypes, + 'hydrocephalusTypes', + ), + previousShuntSurgeryDate: dto.previousShuntSurgeryDate + ? this.normalizeIsoDate( + dto.previousShuntSurgeryDate, + 'previousShuntSurgeryDate', + ) + : null, + preOpMaterials: + dto.preOpMaterials == null + ? Prisma.DbNull + : this.normalizePreOpMaterials(dto.preOpMaterials), + notes: + dto.notes === undefined + ? null + : this.normalizeNullableString(dto.notes, 'notes'), + }, + }); + + for (const device of dto.devices) { + const deviceId = this.toInt(device.id, 'devices.id'); + const currentDevice = existingDeviceMap.get(deviceId); + if (!currentDevice) { + throw new BadRequestException( + MESSAGES.PATIENT.SURGERY_DEVICE_SET_UPDATE_NOT_SUPPORTED, + ); + } + + const catalogId = this.toInt(device.implantCatalogId, 'implantCatalogId'); + const catalog = catalogMap.get(catalogId); + if (!catalog) { + throw new NotFoundException(MESSAGES.PATIENT.IMPLANT_CATALOG_NOT_FOUND); + } + + const catalogChanged = currentDevice.implantCatalogId !== catalog.id; + if (catalogChanged && currentDevice.taskItems.length > 0) { + throw new ConflictException( + MESSAGES.PATIENT.SURGERY_DEVICE_TASK_CONFLICT, + ); + } + + const initialPressure = + !catalog.isValve || device.initialPressure == null + ? null + : this.assertPressureLevelAllowed( + catalog, + this.normalizePressureLevel( + device.initialPressure, + 'initialPressure', + ), + ); + const currentPressure = catalogChanged + ? this.resolveUpdatedSurgeryDeviceCurrentPressure( + currentDevice.currentPressure, + catalog, + initialPressure, + ) + : currentDevice.currentPressure; + + await prisma.device.update({ + where: { + id: currentDevice.id, + }, + data: { + patient: { connect: { id: patientId } }, + surgery: { connect: { id: currentSurgery.id } }, + implantCatalog: { connect: { id: catalog.id } }, + currentPressure, + implantModel: catalog.modelCode, + implantManufacturer: catalog.manufacturer, + implantName: catalog.name, + isValve: catalog.isValve, + isPressureAdjustable: catalog.isPressureAdjustable, + shuntMode: this.normalizeRequiredString( + device.shuntMode, + 'shuntMode', + ), + proximalPunctureAreas: this.normalizeStringArray( + device.proximalPunctureAreas, + 'proximalPunctureAreas', + ), + valvePlacementSites: catalog.isValve + ? this.normalizeStringArray( + device.valvePlacementSites, + 'valvePlacementSites', + ) + : this.normalizeOptionalStringArray( + device.valvePlacementSites, + 'valvePlacementSites', + ), + distalShuntDirection: this.normalizeRequiredString( + device.distalShuntDirection, + 'distalShuntDirection', + ), + initialPressure, + implantNotes: + device.implantNotes === undefined + ? null + : this.normalizeNullableString( + device.implantNotes, + 'implantNotes', + ), + labelImageUrl: + device.labelImageUrl === undefined + ? null + : this.normalizeNullableString( + device.labelImageUrl, + 'labelImageUrl', + ), + }, + }); + } + + const updatedSurgery = await prisma.patientSurgery.findUnique({ + where: { id: currentSurgery.id }, + include: PATIENT_SURGERY_DETAIL_INCLUDE, + }); + + if (!updatedSurgery) { + throw new NotFoundException(MESSAGES.PATIENT.SURGERY_NOT_FOUND); + } + + return updatedSurgery; + } + /** * 解析并校验植入物型号字典。 */ @@ -825,9 +1182,90 @@ export class BPatientsService { return pressure; } + private resolveUpdatedSurgeryDeviceCurrentPressure( + currentPressure: string, + catalog: { + isValve: boolean; + isPressureAdjustable: boolean; + pressureLevels: string[]; + }, + initialPressure: string | null, + ) { + if (!catalog.isValve) { + return '0'; + } + + const fallbackPressureLevel = + catalog.pressureLevels.length > 0 ? catalog.pressureLevels[0] : '0'; + const normalizedCurrentPressure = this.normalizePressureLevel( + currentPressure, + 'currentPressure', + ); + + if ( + catalog.isPressureAdjustable && + catalog.pressureLevels.length > 0 && + catalog.pressureLevels.includes(normalizedCurrentPressure) + ) { + return normalizedCurrentPressure; + } + + if (!catalog.isPressureAdjustable) { + return ( + normalizedCurrentPressure || initialPressure || fallbackPressureLevel + ); + } + + return this.assertPressureLevelAllowed( + catalog, + initialPressure ?? fallbackPressureLevel, + ); + } + /** * 将患者详情补全为前端可直接消费的结构。 */ + private decoratePatientListItem( + patient: Prisma.PatientGetPayload<{ include: typeof PATIENT_LIST_INCLUDE }>, + ) { + const latestSurgery = patient.surgeries[0] ?? null; + const currentDevice = + patient.devices.find( + (device) => + device.status === DeviceStatus.ACTIVE && !device.isAbandoned, + ) ?? + patient.devices.find((device) => !device.isAbandoned) ?? + patient.devices[0] ?? + null; + + return { + id: patient.id, + name: patient.name, + inpatientNo: patient.inpatientNo, + phone: patient.phone, + idCard: patient.idCard, + hospitalId: patient.hospitalId, + doctorId: patient.doctorId, + hospital: patient.hospital, + doctor: patient.doctor, + devices: patient.devices, + primaryDisease: latestSurgery?.primaryDisease ?? null, + hydrocephalusTypes: latestSurgery?.hydrocephalusTypes ?? [], + surgeryDate: latestSurgery?.surgeryDate ?? null, + currentPressure: currentDevice?.currentPressure ?? null, + initialPressure: currentDevice?.initialPressure ?? null, + shuntSurgeryCount: patient._count.surgeries, + latestSurgery, + activeDeviceCount: patient.devices.filter( + (device) => + device.status === DeviceStatus.ACTIVE && !device.isAbandoned, + ).length, + abandonedDeviceCount: patient.devices.filter( + (device) => device.isAbandoned, + ).length, + }; + } + private decoratePatientDetail( patient: Awaited>, ) { diff --git a/src/patients/c-patients/c-patients.service.ts b/src/patients/c-patients/c-patients.service.ts index bb58454..ca533bd 100644 --- a/src/patients/c-patients/c-patients.service.ts +++ b/src/patients/c-patients/c-patients.service.ts @@ -1,20 +1,15 @@ -import { - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { PrismaService } from '../../prisma.service.js'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { MESSAGES } from '../../common/messages.js'; +import { PrismaService } from '../../prisma.service.js'; +import { + buildPatientLifecycleRecords, + PATIENT_LIFECYCLE_INCLUDE, +} from '../patient-lifecycle.util.js'; -/** - * C 端患者服务:承载家属跨院生命周期聚合查询。 - */ @Injectable() export class CPatientsService { constructor(private readonly prisma: PrismaService) {} - /** - * C 端查询:按当前家属账号绑定手机号跨院聚合患者生命周期记录。 - */ async getFamilyLifecycleByAccount(accountId: number) { const account = await this.prisma.familyMiniAppAccount.findUnique({ where: { id: accountId }, @@ -31,180 +26,16 @@ export class CPatientsService { where: { phone: account.phone, }, - include: { - hospital: { select: { id: true, name: true } }, - surgeries: { - include: { - devices: { - include: { - implantCatalog: { - select: { - id: true, - modelCode: true, - manufacturer: true, - name: true, - isValve: true, - pressureLevels: true, - isPressureAdjustable: true, - notes: true, - }, - }, - }, - }, - }, - orderBy: { surgeryDate: 'desc' }, - }, - devices: { - include: { - surgery: { - select: { - id: true, - surgeryDate: true, - surgeryName: true, - }, - }, - implantCatalog: { - select: { - id: true, - modelCode: true, - manufacturer: true, - name: true, - isValve: true, - pressureLevels: true, - isPressureAdjustable: true, - notes: true, - }, - }, - taskItems: { - include: { - task: true, - }, - }, - }, - }, - }, + include: PATIENT_LIFECYCLE_INCLUDE, }); if (patients.length === 0) { throw new NotFoundException(MESSAGES.PATIENT.LIFE_CYCLE_NOT_FOUND); } - const lifecycle = patients - .flatMap((patient) => { - const surgeryEvents = patient.surgeries.map( - (surgery, index, surgeries) => ({ - eventType: 'SURGERY', - occurredAt: surgery.surgeryDate, - hospital: patient.hospital, - patient: { - id: this.toJsonNumber(patient.id), - name: patient.name, - inpatientNo: patient.inpatientNo, - projectName: patient.projectName, - }, - surgery: { - id: this.toJsonNumber(surgery.id), - surgeryDate: surgery.surgeryDate, - surgeryName: surgery.surgeryName, - surgeonName: surgery.surgeonName, - primaryDisease: surgery.primaryDisease, - hydrocephalusTypes: surgery.hydrocephalusTypes, - previousShuntSurgeryDate: surgery.previousShuntSurgeryDate, - shuntSurgeryCount: surgeries.length - index, - }, - devices: surgery.devices.map((device) => ({ - id: this.toJsonNumber(device.id), - status: device.status, - isAbandoned: device.isAbandoned, - currentPressure: device.currentPressure, - initialPressure: device.initialPressure, - implantModel: device.implantModel, - implantManufacturer: device.implantManufacturer, - implantName: device.implantName, - isValve: device.isValve, - isPressureAdjustable: device.isPressureAdjustable, - shuntMode: device.shuntMode, - distalShuntDirection: device.distalShuntDirection, - proximalPunctureAreas: device.proximalPunctureAreas, - valvePlacementSites: device.valvePlacementSites, - implantCatalog: device.implantCatalog, - })), - }), - ); - - const taskEvents = 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, - inpatientNo: patient.inpatientNo, - projectName: patient.projectName, - }, - device: { - id: this.toJsonNumber(device.id), - status: device.status, - isAbandoned: device.isAbandoned, - currentPressure: device.currentPressure, - implantModel: device.implantModel, - implantManufacturer: device.implantManufacturer, - implantName: device.implantName, - isValve: device.isValve, - isPressureAdjustable: device.isPressureAdjustable, - }, - surgery: device.surgery - ? { - id: this.toJsonNumber(device.surgery.id), - surgeryDate: device.surgery.surgeryDate, - surgeryName: device.surgery.surgeryName, - } - : null, - task: { - id: this.toJsonNumber(task.id), - status: task.status, - createdAt: task.createdAt, - }, - taskItem: { - id: this.toJsonNumber(taskItem.id), - oldPressure: taskItem.oldPressure, - targetPressure: taskItem.targetPressure, - }, - }, - ]; - }), - ); - - return [...surgeryEvents, ...taskEvents]; - }) - .sort( - (a, b) => - new Date(b.occurredAt).getTime() - new Date(a.occurredAt).getTime(), - ); - return { - // 前端详情弹窗和现有 E2E 都依赖这两个回显字段。 phone: account.phone, patientCount: patients.length, - lifecycle, + lifecycle: buildPatientLifecycleRecords(patients), }; } - - /** - * 统一将 number/bigint 转为可 JSON 序列化的 number,避免 BigInt 序列化异常。 - */ - private toJsonNumber(value: number | bigint | null | undefined) { - if (value == null) { - return null; - } - return typeof value === 'bigint' ? Number(value) : value; - } } diff --git a/src/patients/dto/create-patient-surgery.dto.ts b/src/patients/dto/create-patient-surgery.dto.ts index 741532f..8ba5a67 100644 --- a/src/patients/dto/create-patient-surgery.dto.ts +++ b/src/patients/dto/create-patient-surgery.dto.ts @@ -95,7 +95,7 @@ export class CreatePatientSurgeryDto { devices!: CreateSurgeryDeviceDto[]; @ApiPropertyOptional({ - description: '本次手术后需弃用的历史设备 ID 列表', + description: '本次手术后需弃用的历史设备编号列表', type: [Number], example: [1], }) diff --git a/src/patients/dto/create-patient.dto.ts b/src/patients/dto/create-patient.dto.ts index 8b93273..bc0b8ef 100644 --- a/src/patients/dto/create-patient.dto.ts +++ b/src/patients/dto/create-patient.dto.ts @@ -40,7 +40,7 @@ export class CreatePatientDto { @IsString({ message: 'idCard 必须是字符串' }) idCard!: string; - @ApiProperty({ description: '归属人员 ID(医生/主任/组长)', example: 10001 }) + @ApiProperty({ description: '归属人员编号(医生/主任/组长)', example: 10001 }) @Type(() => Number) @IsInt({ message: 'doctorId 必须是整数' }) @Min(1, { message: 'doctorId 必须大于 0' }) diff --git a/src/patients/dto/create-surgery-device.dto.ts b/src/patients/dto/create-surgery-device.dto.ts index f7a47ca..6881804 100644 --- a/src/patients/dto/create-surgery-device.dto.ts +++ b/src/patients/dto/create-surgery-device.dto.ts @@ -15,7 +15,7 @@ import { */ export class CreateSurgeryDeviceDto { @ApiProperty({ - description: '植入物型号 ID,选中后自动回填厂家与名称', + description: '植入物型号编号,选中后自动回填厂家与名称', example: 1, }) @Type(() => Number) diff --git a/src/patients/dto/patient-query.dto.ts b/src/patients/dto/patient-query.dto.ts new file mode 100644 index 0000000..0bbbef4 --- /dev/null +++ b/src/patients/dto/patient-query.dto.ts @@ -0,0 +1,51 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { EmptyStringToUndefined } from '../../common/transforms/empty-string-to-undefined.transform.js'; +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class PatientQueryDto { + @ApiPropertyOptional({ + description: '患者名称、手机号、住院号、医生、医院或手术名称关键字', + example: 'VPS', + }) + @IsOptional() + @IsString({ message: 'keyword 必须为字符串' }) + keyword?: string; + + @ApiPropertyOptional({ description: '医院编号', example: 1 }) + @IsOptional() + @EmptyStringToUndefined() + @Type(() => Number) + @IsInt({ message: 'hospitalId 必须为整数' }) + @Min(1, { message: 'hospitalId 必须大于 0' }) + hospitalId?: number; + + @ApiPropertyOptional({ description: '医生编号', example: 1 }) + @IsOptional() + @EmptyStringToUndefined() + @Type(() => Number) + @IsInt({ message: 'doctorId 必须为整数' }) + @Min(1, { message: 'doctorId 必须大于 0' }) + doctorId?: number; + + @ApiPropertyOptional({ description: '页码', example: 1, default: 1 }) + @IsOptional() + @EmptyStringToUndefined() + @Type(() => Number) + @IsInt({ message: 'page 必须为整数' }) + @Min(1, { message: 'page 最小值为 1' }) + page?: number = 1; + + @ApiPropertyOptional({ + description: '每页条数', + example: 20, + default: 20, + }) + @IsOptional() + @EmptyStringToUndefined() + @Type(() => Number) + @IsInt({ message: 'pageSize 必须为整数' }) + @Min(1, { message: 'pageSize 最小值为 1' }) + @Max(100, { message: 'pageSize 最大值为 100' }) + pageSize?: number = 20; +} diff --git a/src/patients/dto/update-patient-surgery.dto.ts b/src/patients/dto/update-patient-surgery.dto.ts new file mode 100644 index 0000000..3a792e4 --- /dev/null +++ b/src/patients/dto/update-patient-surgery.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { ArrayMinSize, IsArray, ValidateNested } from 'class-validator'; +import { CreatePatientSurgeryDto } from './create-patient-surgery.dto.js'; +import { UpdateSurgeryDeviceDto } from './update-surgery-device.dto.js'; + +export class UpdatePatientSurgeryDto extends CreatePatientSurgeryDto { + @ApiProperty({ + description: '本次手术植入设备列表,编辑时需回传已有设备编号', + type: [UpdateSurgeryDeviceDto], + }) + @IsArray({ message: 'devices 必须是数组' }) + @ArrayMinSize(1, { message: 'devices 至少录入 1 个设备' }) + @ValidateNested({ each: true }) + @Type(() => UpdateSurgeryDeviceDto) + declare devices: UpdateSurgeryDeviceDto[]; +} diff --git a/src/patients/dto/update-surgery-device.dto.ts b/src/patients/dto/update-surgery-device.dto.ts new file mode 100644 index 0000000..72f4195 --- /dev/null +++ b/src/patients/dto/update-surgery-device.dto.ts @@ -0,0 +1,16 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Min } from 'class-validator'; +import { CreateSurgeryDeviceDto } from './create-surgery-device.dto.js'; + +export class UpdateSurgeryDeviceDto extends CreateSurgeryDeviceDto { + @ApiPropertyOptional({ + description: '已存在的手术设备编号,编辑手术时必传', + example: 12, + }) + @IsOptional() + @Type(() => Number) + @IsInt({ message: 'devices.id 必须为整数' }) + @Min(1, { message: 'devices.id 必须大于 0' }) + id?: number; +} diff --git a/src/patients/patient-lifecycle.util.ts b/src/patients/patient-lifecycle.util.ts new file mode 100644 index 0000000..cfd5dca --- /dev/null +++ b/src/patients/patient-lifecycle.util.ts @@ -0,0 +1,164 @@ +import { Prisma } from '../generated/prisma/client.js'; + +const IMPLANT_CATALOG_SELECT = { + id: true, + modelCode: true, + manufacturer: true, + name: true, + isValve: true, + pressureLevels: true, + isPressureAdjustable: true, + notes: true, +} as const; + +export const PATIENT_LIFECYCLE_INCLUDE = { + hospital: { select: { id: true, name: true } }, + doctor: { select: { departmentId: true, groupId: true } }, + surgeries: { + include: { + devices: { + include: { + implantCatalog: { + select: IMPLANT_CATALOG_SELECT, + }, + }, + }, + }, + orderBy: { surgeryDate: 'desc' }, + }, + devices: { + include: { + surgery: { + select: { + id: true, + surgeryDate: true, + surgeryName: true, + }, + }, + implantCatalog: { + select: IMPLANT_CATALOG_SELECT, + }, + taskItems: { + include: { + task: true, + }, + }, + }, + }, +} as const satisfies Prisma.PatientInclude; + +export type PatientLifecycleSource = Prisma.PatientGetPayload<{ + include: typeof PATIENT_LIFECYCLE_INCLUDE; +}>; + +export function buildPatientLifecycleRecords( + patients: PatientLifecycleSource[], +) { + return patients + .flatMap((patient) => { + const surgeryEvents = patient.surgeries.map((surgery, index, surgeries) => ({ + eventType: 'SURGERY', + occurredAt: surgery.surgeryDate, + hospital: patient.hospital, + patient: { + id: toJsonNumber(patient.id), + name: patient.name, + inpatientNo: patient.inpatientNo, + projectName: patient.projectName, + }, + surgery: { + id: toJsonNumber(surgery.id), + surgeryDate: surgery.surgeryDate, + surgeryName: surgery.surgeryName, + surgeonName: surgery.surgeonName, + primaryDisease: surgery.primaryDisease, + hydrocephalusTypes: surgery.hydrocephalusTypes, + previousShuntSurgeryDate: surgery.previousShuntSurgeryDate, + shuntSurgeryCount: surgeries.length - index, + }, + devices: surgery.devices.map((device) => ({ + id: toJsonNumber(device.id), + status: device.status, + isAbandoned: device.isAbandoned, + currentPressure: device.currentPressure, + initialPressure: device.initialPressure, + implantModel: device.implantModel, + implantManufacturer: device.implantManufacturer, + implantName: device.implantName, + isValve: device.isValve, + isPressureAdjustable: device.isPressureAdjustable, + shuntMode: device.shuntMode, + distalShuntDirection: device.distalShuntDirection, + proximalPunctureAreas: device.proximalPunctureAreas, + valvePlacementSites: device.valvePlacementSites, + implantCatalog: device.implantCatalog, + })), + })); + + const taskEvents = patient.devices.flatMap((device) => + device.taskItems.flatMap((taskItem) => { + if (!taskItem.task) { + return []; + } + + const task = taskItem.task; + return [ + { + eventType: 'TASK_PRESSURE_ADJUSTMENT', + occurredAt: task.createdAt, + hospital: patient.hospital, + patient: { + id: toJsonNumber(patient.id), + name: patient.name, + inpatientNo: patient.inpatientNo, + projectName: patient.projectName, + }, + device: { + id: toJsonNumber(device.id), + status: device.status, + isAbandoned: device.isAbandoned, + currentPressure: device.currentPressure, + implantModel: device.implantModel, + implantManufacturer: device.implantManufacturer, + implantName: device.implantName, + isValve: device.isValve, + isPressureAdjustable: device.isPressureAdjustable, + }, + surgery: device.surgery + ? { + id: toJsonNumber(device.surgery.id), + surgeryDate: device.surgery.surgeryDate, + surgeryName: device.surgery.surgeryName, + } + : null, + task: { + id: toJsonNumber(task.id), + status: task.status, + createdAt: task.createdAt, + }, + taskItem: { + id: toJsonNumber(taskItem.id), + oldPressure: taskItem.oldPressure, + targetPressure: taskItem.targetPressure, + }, + }, + ]; + }), + ); + + return [...surgeryEvents, ...taskEvents]; + }) + .sort( + (left, right) => + new Date(right.occurredAt).getTime() - + new Date(left.occurredAt).getTime(), + ); +} + +function toJsonNumber(value: number | bigint | null | undefined) { + if (value == null) { + return null; + } + + return typeof value === 'bigint' ? Number(value) : value; +} diff --git a/src/uploads/b-uploads/b-uploads.controller.ts b/src/uploads/b-uploads/b-uploads.controller.ts index f1b4664..8d54763 100644 --- a/src/uploads/b-uploads/b-uploads.controller.ts +++ b/src/uploads/b-uploads/b-uploads.controller.ts @@ -36,6 +36,19 @@ import { extname } from 'node:path'; import { randomUUID } from 'node:crypto'; const MAX_UPLOAD_SIZE = 1024 * 1024 * 200; +const ALLOWED_FILE_EXTENSIONS_BY_MIME = new Map([ + ['application/pdf', ['.pdf']], + ['application/msword', ['.doc']], + [ + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + ['.docx'], + ], + ['application/vnd.ms-excel', ['.xls']], + [ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ['.xlsx'], + ], +]); function isAllowedMimeType(mimeType: string) { return ( @@ -51,6 +64,22 @@ function isAllowedMimeType(mimeType: string) { ); } +function isAllowedOriginalExtension( + originalName: string, + mimeType: string, +) { + if (mimeType.startsWith('image/') || mimeType.startsWith('video/')) { + return true; + } + + const allowedExtensions = ALLOWED_FILE_EXTENSIONS_BY_MIME.get(mimeType); + if (!allowedExtensions) { + return false; + } + + return allowedExtensions.includes(extname(originalName).toLowerCase()); +} + @ApiTags('上传资产(B端)') @ApiBearerAuth('bearer') @Controller('b/uploads') @@ -83,7 +112,10 @@ export class BUploadsController { }), limits: { fileSize: MAX_UPLOAD_SIZE }, fileFilter: (_req, file, cb) => { - if (!isAllowedMimeType(file.mimetype)) { + if ( + !isAllowedMimeType(file.mimetype) || + !isAllowedOriginalExtension(file.originalname, file.mimetype) + ) { cb( new BadRequestException(MESSAGES.UPLOAD.UNSUPPORTED_FILE_TYPE), false, diff --git a/src/uploads/uploads.service.ts b/src/uploads/uploads.service.ts index 11221f6..407d748 100644 --- a/src/uploads/uploads.service.ts +++ b/src/uploads/uploads.service.ts @@ -5,7 +5,7 @@ import { NotFoundException, } from '@nestjs/common'; import { spawn } from 'node:child_process'; -import { access, mkdir, rename, stat, unlink } from 'node:fs/promises'; +import { access, mkdir, open, rename, stat, unlink } from 'node:fs/promises'; import { extname, join } from 'node:path'; import { randomUUID } from 'node:crypto'; import ffmpegPath from 'ffmpeg-static'; @@ -22,6 +22,20 @@ import { resolveUploadTempDir, } from './upload-path.util.js'; +const SAFE_FILE_MIME_TYPES: Record = { + '.pdf': 'application/pdf', + '.doc': 'application/msword', + '.docx': + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + '.xls': 'application/vnd.ms-excel', + '.xlsx': + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', +}; + +const OLE_SIGNATURE = Buffer.from([ + 0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, +]); + @Injectable() export class UploadsService { constructor(private readonly prisma: PrismaService) {} @@ -237,12 +251,7 @@ export class UploadsService { }; } - return { - tempPath: file.path, - outputExtension: extname(file.originalname), - mimeType: file.mimetype, - fileSize: file.size, - }; + return this.prepareGenericFile(file); } private async buildFinalFileName( @@ -299,6 +308,65 @@ export class UploadsService { } } + private async prepareGenericFile(file: Express.Multer.File) { + const outputExtension = this.normalizeSafeFileExtension(file.originalname); + const signature = await this.readFileSignature(file.path, 8); + + if (!this.isSafeFileSignature(outputExtension, signature)) { + throw new BadRequestException(MESSAGES.UPLOAD.INVALID_FILE_SIGNATURE); + } + + const fileInfo = await stat(file.path); + return { + tempPath: file.path, + outputExtension, + mimeType: SAFE_FILE_MIME_TYPES[outputExtension], + fileSize: fileInfo.size, + }; + } + + private normalizeSafeFileExtension(originalName: string) { + const extension = extname(originalName).toLowerCase(); + if (!SAFE_FILE_MIME_TYPES[extension]) { + throw new BadRequestException(MESSAGES.UPLOAD.UNSUPPORTED_FILE_TYPE); + } + return extension; + } + + private async readFileSignature(path: string, byteLength: number) { + const handle = await open(path, 'r'); + + try { + const buffer = Buffer.alloc(byteLength); + const { bytesRead } = await handle.read(buffer, 0, byteLength, 0); + return buffer.subarray(0, bytesRead); + } finally { + await handle.close(); + } + } + + private isSafeFileSignature(extension: string, signature: Buffer) { + switch (extension) { + case '.pdf': + return signature.subarray(0, 5).toString('ascii') === '%PDF-'; + case '.doc': + case '.xls': + return signature.length >= OLE_SIGNATURE.length && + signature.subarray(0, OLE_SIGNATURE.length).equals(OLE_SIGNATURE); + case '.docx': + case '.xlsx': + return ( + signature.length >= 4 && + signature[0] === 0x50 && + signature[1] === 0x4b && + [0x03, 0x05, 0x07].includes(signature[2]) && + [0x04, 0x06, 0x08].includes(signature[3]) + ); + default: + return false; + } + } + private async compressVideo(inputPath: string, outputPath: string) { const command = ffmpegPath as unknown as string | null; if (!command) { diff --git a/tyt-admin/src/api/patients.js b/tyt-admin/src/api/patients.js index 8ddbf45..797fe89 100644 --- a/tyt-admin/src/api/patients.js +++ b/tyt-admin/src/api/patients.js @@ -24,10 +24,14 @@ export const createPatientSurgery = (id, data) => { return request.post(`/b/patients/${id}/surgeries`, data); }; +export const updatePatientSurgery = (patientId, surgeryId, data) => { + return request.patch(`/b/patients/${patientId}/surgeries/${surgeryId}`, data); +}; + export const deletePatient = (id) => { return request.delete(`/b/patients/${id}`); }; -export const getPatientLifecycle = (params) => { - return request.get('/c/patients/lifecycle', { params }); +export const getPatientLifecycle = (id) => { + return request.get(`/b/patients/${id}/lifecycle`); }; diff --git a/tyt-admin/src/constants/role-labels.js b/tyt-admin/src/constants/role-labels.js new file mode 100644 index 0000000..74e4144 --- /dev/null +++ b/tyt-admin/src/constants/role-labels.js @@ -0,0 +1,16 @@ +export const ROLE_LABELS = Object.freeze({ + SYSTEM_ADMIN: '系统管理员', + HOSPITAL_ADMIN: '医院管理员', + DIRECTOR: '科室主任', + LEADER: '小组组长', + DOCTOR: '医生', + ENGINEER: '工程师', +}); + +export function getRoleLabel(role) { + if (!role) { + return ''; + } + + return ROLE_LABELS[role] || role; +} diff --git a/tyt-admin/src/layouts/AdminLayout.vue b/tyt-admin/src/layouts/AdminLayout.vue index 2abcfac..2765e0c 100644 --- a/tyt-admin/src/layouts/AdminLayout.vue +++ b/tyt-admin/src/layouts/AdminLayout.vue @@ -84,12 +84,14 @@
- +