From ab1720473984b08bd45daf0a6b658c7e431afb9b Mon Sep 17 00:00:00 2001 From: EL <1175065040@qq.com> Date: Thu, 2 Apr 2026 04:07:40 +0800 Subject: [PATCH] =?UTF-8?q?C=E7=AB=AF=20miniapp=20=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=89=8B=E6=9C=BA=E5=8F=B7=E5=94=AF=E4=B8=80?= =?UTF-8?q?=E5=91=BD=E4=B8=AD=E6=82=A3=E8=80=85=E8=A7=84=E5=88=99=EF=BC=8C?= =?UTF-8?q?=E5=91=BD=E4=B8=AD=E5=A4=9A=E4=BB=BD=E6=A1=A3=E6=A1=88=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=20409=20C=E7=AB=AF=20my-lifecycle=20=E7=94=B1?= =?UTF-8?q?=E8=B7=A8=E9=99=A2=E5=A4=9A=E6=82=A3=E8=80=85=E8=81=9A=E5=90=88?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E5=8D=95=E6=82=A3=E8=80=85=E8=BF=94=E5=9B=9E?= =?UTF-8?q?=EF=BC=8C=E7=BB=93=E6=9E=84=E7=BB=9F=E4=B8=80=E4=B8=BA=20patien?= =?UTF-8?q?t=20+=20lifecycle=20=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E7=A7=BB=E9=99=A4=E4=BA=8B=E4=BB=B6=E5=86=85?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=20patient=20=E5=AD=97=E6=AE=B5=EF=BC=8C?= =?UTF-8?q?=E5=87=8F=E5=B0=91=E5=86=97=E4=BD=99=20B=E7=AB=AF=E6=82=A3?= =?UTF-8?q?=E8=80=85=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E9=87=87=E7=94=A8=20patient=20+=20lifecycle?= =?UTF-8?q?=20=E7=BB=93=E6=9E=84=20=E6=96=B0=E5=A2=9E=E5=B9=B6=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E7=94=9F=E5=91=BD=E5=91=A8=E6=9C=9F=20Swagger=20?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E6=A8=A1=E5=9E=8B=EF=BC=8C=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E6=96=87=E6=A1=A3=20=E6=9B=B4=E6=96=B0=20aut?= =?UTF-8?q?h/patients/frontend=20=E9=9B=86=E6=88=90=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E8=AF=B4=E6=98=8E=20=E5=A2=9E=E5=8A=A0=20e2e=EF=BC=9A=E5=A4=9A?= =?UTF-8?q?=E6=82=A3=E8=80=85=E5=86=B2=E7=AA=81=E3=80=81C=E7=AB=AF/B?= =?UTF-8?q?=E7=AB=AF=E6=96=B0=E8=BF=94=E5=9B=9E=E7=BB=93=E6=9E=84=E3=80=81?= =?UTF-8?q?=E6=9D=83=E9=99=90=E5=A4=B1=E8=B4=A5=E5=9C=BA=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/auth.md | 12 +- docs/frontend-api-integration.md | 4 +- docs/patients.md | 15 +- src/auth/current-family-actor.decorator.ts | 6 +- src/auth/family-access/family-access.guard.ts | 8 +- src/auth/miniapp-auth/miniapp-auth.service.ts | 52 ++- src/common/messages.ts | 6 +- .../b-patients/b-patients.controller.ts | 11 + src/patients/b-patients/b-patients.service.ts | 8 +- .../c-patients/c-patients.controller.ts | 21 +- src/patients/c-patients/c-patients.service.ts | 18 +- .../dto/patient-lifecycle-response.dto.ts | 408 ++++++++++++++++++ src/patients/patient-lifecycle.util.ts | 198 +++++---- test/e2e/specs/auth.e2e-spec.ts | 18 +- test/e2e/specs/patients.e2e-spec.ts | 75 +++- 15 files changed, 696 insertions(+), 164 deletions(-) create mode 100644 src/patients/dto/patient-lifecycle-response.dto.ts diff --git a/docs/auth.md b/docs/auth.md index a5361cf..a17f720 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -3,7 +3,7 @@ ## 1. 目标 - 提供系统管理员创建、院内账号密码登录、B 端小程序手机号登录、C 端小程序手机号登录、`/me` 身份查询。 -- 使用 JWT 做认证,院内账号与家属小程序账号走两套守卫。 +- 使用 JWT 做认证,院内账号与 C 端小程序账号走两套守卫。 ## 2. 核心接口 @@ -36,7 +36,7 @@ ### C 端 1. 前端同样传 `loginCode + phoneCode`。 -2. 后端先校验该手机号是否已存在于 `Patient.phone`。 +2. 后端先校验该手机号是否唯一命中 `Patient.phone`。 3. 校验通过后创建或绑定 `FamilyMiniAppAccount`,并返回 C 端 JWT。 ## 5. 鉴权流程 @@ -48,12 +48,12 @@ 3. 根据 `id` 回库读取 `User` 当前角色与组织归属。 4. 校验 `iat >= user.tokenValidAfter`,保证旧 token 失效。 -### 家属小程序账号 +### C 端小程序账号 1. `FamilyAccessTokenGuard` 从 `Authorization` 读取 Bearer Token。 2. 校验 C 端 token 的 `id + type=FAMILY_MINIAPP`。 3. 根据 `id` 回库读取 `FamilyMiniAppAccount`。 -4. 将家属账号上下文注入 `request.familyActor`。 +4. 将 C 端账号上下文注入 `request.familyActor`。 ## 6. 环境变量 @@ -70,6 +70,6 @@ - B 端账号未绑定 `openId` 时,首次小程序登录自动绑定。 - 同一个 `openId` 可绑定多个院内账号,支持同一微信号切换多角色/多院区账号。 - 若目标院内账号已绑定其他微信号,仍需先在用户管理中清空该账号的 `openId` 再重新绑定。 -- C 端家属账号独立存放在 `FamilyMiniAppAccount`。 -- C 端手机号必须先存在于患者档案,否则拒绝登录。 +- C 端账号独立存放在 `FamilyMiniAppAccount`。 +- C 端手机号必须唯一命中患者档案,否则拒绝登录。 - `serviceUid` 仅预留字段,本次不提供绑定接口。 diff --git a/docs/frontend-api-integration.md b/docs/frontend-api-integration.md index 46616eb..b77ebb5 100644 --- a/docs/frontend-api-integration.md +++ b/docs/frontend-api-integration.md @@ -30,12 +30,14 @@ - 入参: - `loginCode` - `phoneCode` +- 要求当前手机号唯一关联 1 份患者档案,否则返回冲突错误 ## 2. C 端生命周期 - 登录成功后调用:`GET /c/patients/my-lifecycle` - 不再需要传 `phone` 或 `idCard` -- Bearer Token 使用 C 端家属登录返回的 `accessToken` +- Bearer Token 使用 C 端患者登录返回的 `accessToken` +- 返回结构改为顶层 `patient + lifecycle`,事件项内不再重复返回 `patient` ## 3. B 端说明 diff --git a/docs/patients.md b/docs/patients.md index d637a18..ba13499 100644 --- a/docs/patients.md +++ b/docs/patients.md @@ -3,7 +3,7 @@ ## 1. 目标 - B 端:维护患者、手术、植入设备及生命周期数据。 -- C 端:家属小程序登录后,按已绑定手机号聚合查询跨院患者生命周期。 +- C 端:患者本人小程序登录后,按当前手机号查询自己的生命周期。 ## 2. B 端能力 @@ -13,17 +13,18 @@ ## 3. C 端能力 -- 家属账号通过小程序手机号登录 +- 患者本人通过小程序手机号登录 - `GET /c/patients/my-lifecycle` -- 查询口径:按 `FamilyMiniAppAccount.phone` 聚合 `Patient.phone` -- 返回内容:手术事件 + 调压事件的时间线 +- 查询口径:按 `FamilyMiniAppAccount.phone` 唯一命中 `Patient.phone` +- 返回内容:顶层患者信息 + 手术事件/调压事件时间线 ## 4. 当前规则 -- 同一个手机号可关联多个患者,C 端会统一聚合返回。 +- 同一个手机号在 C 端只允许命中 1 份患者档案。 +- 若同一个手机号命中多份患者档案,登录阶段直接返回冲突错误。 - C 端手机号来源于患者手术/档案中维护的联系电话。 -- 仅已登录的家属小程序账号可访问 `my-lifecycle`。 -- 家属账号不存在或 token 无效时返回 `401`。 +- 仅已登录的 C 端小程序账号可访问 `my-lifecycle`。 +- C 端登录账号不存在或 token 无效时返回 `401`。 - 手机号下无患者档案时,登录阶段直接拦截,不进入生命周期查询。 ## 5. 典型接口 diff --git a/src/auth/current-family-actor.decorator.ts b/src/auth/current-family-actor.decorator.ts index 822c824..0c75e93 100644 --- a/src/auth/current-family-actor.decorator.ts +++ b/src/auth/current-family-actor.decorator.ts @@ -2,11 +2,13 @@ import { createParamDecorator, type ExecutionContext } from '@nestjs/common'; import type { FamilyActorContext } from '../common/family-actor-context.js'; /** - * 读取当前已认证的家属小程序账号上下文。 + * 读取当前已认证的 C 端小程序账号上下文。 */ export const CurrentFamilyActor = createParamDecorator( (_data: unknown, ctx: ExecutionContext): FamilyActorContext | undefined => { - const request = ctx.switchToHttp().getRequest<{ familyActor?: FamilyActorContext }>(); + const request = ctx + .switchToHttp() + .getRequest<{ familyActor?: FamilyActorContext }>(); return request.familyActor; }, ); diff --git a/src/auth/family-access/family-access.guard.ts b/src/auth/family-access/family-access.guard.ts index 0048a58..571d202 100644 --- a/src/auth/family-access/family-access.guard.ts +++ b/src/auth/family-access/family-access.guard.ts @@ -10,7 +10,7 @@ import { MESSAGES } from '../../common/messages.js'; import { PrismaService } from '../../prisma.service.js'; /** - * C 端家属小程序登录守卫。 + * C 端小程序登录守卫。 */ @Injectable() export class FamilyAccessTokenGuard implements CanActivate { @@ -37,9 +37,11 @@ export class FamilyAccessTokenGuard implements CanActivate { } /** - * 校验家属 token 并回库确认账号仍存在。 + * 校验 C 端 token 并回库确认账号仍存在。 */ - private async verifyAndExtractActor(token: string): Promise { + private async verifyAndExtractActor( + token: string, + ): Promise { const secret = process.env.AUTH_TOKEN_SECRET; if (!secret) { throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_SECRET_MISSING); diff --git a/src/auth/miniapp-auth/miniapp-auth.service.ts b/src/auth/miniapp-auth/miniapp-auth.service.ts index 9108653..4c9c7d9 100644 --- a/src/auth/miniapp-auth/miniapp-auth.service.ts +++ b/src/auth/miniapp-auth/miniapp-auth.service.ts @@ -67,7 +67,10 @@ export class MiniAppAuthService { if (accounts.length === 1) { const [user] = accounts; - await this.usersService.bindOpenIdForMiniAppLogin(user.id, identity.openId); + await this.usersService.bindOpenIdForMiniAppLogin( + user.id, + identity.openId, + ); return this.usersService.loginByUserId(user.id); } @@ -97,27 +100,40 @@ export class MiniAppAuthService { async confirmLoginForB(dto: MiniappPhoneLoginConfirmDto) { const payload = this.verifyLoginTicket(dto.loginTicket); if (!payload.userIds.includes(dto.userId)) { - throw new BadRequestException(MESSAGES.AUTH.MINIAPP_ACCOUNT_SELECTION_INVALID); + throw new BadRequestException( + MESSAGES.AUTH.MINIAPP_ACCOUNT_SELECTION_INVALID, + ); } - await this.usersService.bindOpenIdForMiniAppLogin(dto.userId, payload.openId); + await this.usersService.bindOpenIdForMiniAppLogin( + dto.userId, + payload.openId, + ); return this.usersService.loginByUserId(dto.userId); } /** - * C 端手机号登录:要求手机号已存在于患者档案。 + * C 端手机号登录:要求手机号唯一命中患者档案。 */ async loginForC(dto: MiniappPhoneLoginDto) { const identity = await this.wechatMiniAppService.resolvePhoneIdentity( dto.loginCode, dto.phoneCode, ); - const patientExists = await this.prisma.patient.findFirst({ + const matchedPatients = await this.prisma.patient.findMany({ where: { phone: identity.phone }, select: { id: true }, + take: 2, }); - if (!patientExists) { - throw new NotFoundException(MESSAGES.AUTH.FAMILY_PHONE_NOT_LINKED_PATIENT); + if (matchedPatients.length === 0) { + throw new NotFoundException( + MESSAGES.AUTH.FAMILY_PHONE_NOT_LINKED_PATIENT, + ); + } + if (matchedPatients.length > 1) { + throw new ConflictException( + MESSAGES.AUTH.FAMILY_PHONE_LINKED_MULTI_PATIENTS, + ); } const existingByOpenId = await this.prisma.familyMiniAppAccount.findUnique({ @@ -125,7 +141,9 @@ export class MiniAppAuthService { select: { id: true, phone: true }, }); if (existingByOpenId && existingByOpenId.phone !== identity.phone) { - throw new ConflictException(MESSAGES.AUTH.MINIAPP_OPEN_ID_BOUND_OTHER_FAMILY); + throw new ConflictException( + MESSAGES.AUTH.MINIAPP_OPEN_ID_BOUND_OTHER_FAMILY, + ); } const current = await this.prisma.familyMiniAppAccount.findUnique({ @@ -139,7 +157,9 @@ export class MiniAppAuthService { }); if (current?.openId && current.openId !== identity.openId) { - throw new ConflictException(MESSAGES.AUTH.MINIAPP_OPEN_ID_BOUND_OTHER_FAMILY); + throw new ConflictException( + MESSAGES.AUTH.MINIAPP_OPEN_ID_BOUND_OTHER_FAMILY, + ); } const familyAccount = current @@ -202,7 +222,9 @@ export class MiniAppAuthService { issuer: 'tyt-api-nest', }); } catch { - throw new UnauthorizedException(MESSAGES.AUTH.MINIAPP_LOGIN_TICKET_INVALID); + throw new UnauthorizedException( + MESSAGES.AUTH.MINIAPP_LOGIN_TICKET_INVALID, + ); } if ( @@ -211,16 +233,20 @@ export class MiniAppAuthService { typeof payload.phone !== 'string' || typeof payload.openId !== 'string' || !Array.isArray(payload.userIds) || - payload.userIds.some((item) => typeof item !== 'number' || !Number.isInteger(item)) + payload.userIds.some( + (item) => typeof item !== 'number' || !Number.isInteger(item), + ) ) { - throw new UnauthorizedException(MESSAGES.AUTH.MINIAPP_LOGIN_TICKET_INVALID); + throw new UnauthorizedException( + MESSAGES.AUTH.MINIAPP_LOGIN_TICKET_INVALID, + ); } return payload as unknown as LoginTicketPayload; } /** - * 签发 C 端家属 token。 + * 签发 C 端访问 token。 */ private signFamilyAccessToken(accountId: number) { const secret = this.requireAuthSecret(); diff --git a/src/common/messages.ts b/src/common/messages.ts index 2c7d9e6..9c8a671 100644 --- a/src/common/messages.ts +++ b/src/common/messages.ts @@ -37,9 +37,11 @@ export const MESSAGES = { MINIAPP_LOGIN_TICKET_INVALID: '账号选择票据无效或已过期', MINIAPP_ACCOUNT_SELECTION_INVALID: '请选择有效的候选账号', MINIAPP_OPEN_ID_BOUND_OTHER_USER: '当前院内账号已绑定其他微信账号', - MINIAPP_OPEN_ID_BOUND_OTHER_FAMILY: '当前微信账号已绑定其他家属账号', + MINIAPP_OPEN_ID_BOUND_OTHER_FAMILY: '当前微信账号已绑定其他 C 端账号', FAMILY_PHONE_NOT_LINKED_PATIENT: '当前手机号未关联患者档案', - FAMILY_ACCOUNT_NOT_FOUND: '家属登录账号不存在,请重新登录', + FAMILY_PHONE_LINKED_MULTI_PATIENTS: + '当前手机号关联了多份患者档案,请联系管理员处理', + FAMILY_ACCOUNT_NOT_FOUND: 'C端登录账号不存在,请重新登录', THROTTLED: '操作过于频繁,请稍后再试', }, diff --git a/src/patients/b-patients/b-patients.controller.ts b/src/patients/b-patients/b-patients.controller.ts index 089cb24..cc78cdb 100644 --- a/src/patients/b-patients/b-patients.controller.ts +++ b/src/patients/b-patients/b-patients.controller.ts @@ -12,6 +12,8 @@ import { } from '@nestjs/common'; import { ApiBearerAuth, + ApiExtraModels, + ApiOkResponse, ApiOperation, ApiParam, ApiQuery, @@ -26,6 +28,10 @@ import { Role } from '../../generated/prisma/enums.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 { + BPatientLifecycleResponseDto, + PATIENT_LIFECYCLE_SWAGGER_MODELS, +} from '../dto/patient-lifecycle-response.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'; @@ -140,6 +146,11 @@ export class BPatientsController { ) @ApiOperation({ summary: '查询患者生命周期事件(B端详情页)' }) @ApiParam({ name: 'id', description: '患者编号' }) + @ApiExtraModels(...PATIENT_LIFECYCLE_SWAGGER_MODELS) + @ApiOkResponse({ + description: '返回指定患者的生命周期数据', + type: BPatientLifecycleResponseDto, + }) findPatientLifecycle( @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 c42f717..bfd1777 100644 --- a/src/patients/b-patients/b-patients.service.ts +++ b/src/patients/b-patients/b-patients.service.ts @@ -17,7 +17,7 @@ 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, + buildPatientLifecyclePayload, PATIENT_LIFECYCLE_INCLUDE, } from '../patient-lifecycle.util.js'; import { normalizePatientIdCard } from '../patient-id-card.util.js'; @@ -367,11 +367,7 @@ export class BPatientsService { this.assertPatientScope(actor, patient); - return { - patientId: patient.id, - patientCount: 1, - lifecycle: buildPatientLifecycleRecords([patient]), - }; + return buildPatientLifecyclePayload(patient); } /** diff --git a/src/patients/c-patients/c-patients.controller.ts b/src/patients/c-patients/c-patients.controller.ts index 6d4ebfe..0596cf8 100644 --- a/src/patients/c-patients/c-patients.controller.ts +++ b/src/patients/c-patients/c-patients.controller.ts @@ -1,12 +1,22 @@ import { Controller, Get, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiExtraModels, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; import { CurrentFamilyActor } from '../../auth/current-family-actor.decorator.js'; import { FamilyAccessTokenGuard } from '../../auth/family-access/family-access.guard.js'; import type { FamilyActorContext } from '../../common/family-actor-context.js'; +import { + CPatientLifecycleResponseDto, + PATIENT_LIFECYCLE_SWAGGER_MODELS, +} from '../dto/patient-lifecycle-response.dto.js'; import { CPatientsService } from './c-patients.service.js'; /** - * C 端患者控制器:家属跨院聚合查询。 + * C 端患者控制器:患者本人按登录手机号查询自己的生命周期。 */ @ApiTags('患者管理(C端)') @ApiBearerAuth('bearer') @@ -16,10 +26,15 @@ export class CPatientsController { constructor(private readonly patientsService: CPatientsService) {} /** - * 根据当前登录手机号查询跨院生命周期。 + * 根据当前登录手机号查询单患者生命周期。 */ @Get('my-lifecycle') @ApiOperation({ summary: '按当前登录手机号查询患者生命周期' }) + @ApiExtraModels(...PATIENT_LIFECYCLE_SWAGGER_MODELS) + @ApiOkResponse({ + description: '按当前登录手机号返回唯一患者的生命周期数据', + type: CPatientLifecycleResponseDto, + }) getMyLifecycle(@CurrentFamilyActor() actor: FamilyActorContext) { return this.patientsService.getFamilyLifecycleByAccount(actor.id); } diff --git a/src/patients/c-patients/c-patients.service.ts b/src/patients/c-patients/c-patients.service.ts index ca533bd..9c3eeae 100644 --- a/src/patients/c-patients/c-patients.service.ts +++ b/src/patients/c-patients/c-patients.service.ts @@ -1,8 +1,12 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { MESSAGES } from '../../common/messages.js'; import { PrismaService } from '../../prisma.service.js'; import { - buildPatientLifecycleRecords, + buildPatientLifecyclePayload, PATIENT_LIFECYCLE_INCLUDE, } from '../patient-lifecycle.util.js'; @@ -31,11 +35,17 @@ export class CPatientsService { if (patients.length === 0) { throw new NotFoundException(MESSAGES.PATIENT.LIFE_CYCLE_NOT_FOUND); } + if (patients.length > 1) { + throw new ConflictException( + MESSAGES.AUTH.FAMILY_PHONE_LINKED_MULTI_PATIENTS, + ); + } + + const [patient] = patients; return { phone: account.phone, - patientCount: patients.length, - lifecycle: buildPatientLifecycleRecords(patients), + ...buildPatientLifecyclePayload(patient), }; } } diff --git a/src/patients/dto/patient-lifecycle-response.dto.ts b/src/patients/dto/patient-lifecycle-response.dto.ts new file mode 100644 index 0000000..20d9ea7 --- /dev/null +++ b/src/patients/dto/patient-lifecycle-response.dto.ts @@ -0,0 +1,408 @@ +import { + ApiProperty, + ApiPropertyOptional, + getSchemaPath, +} from '@nestjs/swagger'; +import { DeviceStatus, TaskStatus } from '../../generated/prisma/enums.js'; + +export class PatientLifecycleHospitalDto { + @ApiProperty({ description: '医院 ID', example: 1 }) + id!: number; + + @ApiProperty({ description: '医院名称', example: '珠江医院' }) + name!: string; +} + +export class PatientLifecyclePatientDto { + @ApiProperty({ description: '患者 ID', example: 4 }) + id!: number; + + @ApiProperty({ description: '患者姓名', example: '测试222' }) + name!: string; + + @ApiProperty({ + description: '住院号', + example: '123321', + nullable: true, + type: String, + }) + inpatientNo!: string | null; + + @ApiProperty({ + description: '项目名称', + example: '脑积水随访项目', + nullable: true, + type: String, + }) + projectName!: string | null; +} + +export class PatientLifecycleImplantCatalogDto { + @ApiProperty({ description: '植入物型号 ID', example: 1 }) + id!: number; + + @ApiProperty({ description: '型号编码', example: '823832' }) + modelCode!: string; + + @ApiProperty({ description: '厂家', example: 'Codman' }) + manufacturer!: string; + + @ApiProperty({ description: '名称', example: '可调节压力分流管' }) + name!: string; + + @ApiProperty({ description: '是否为阀门', example: true }) + isValve!: boolean; + + @ApiProperty({ + description: '可选压力挡位列表', + type: [String], + example: ['30', '40', '50', '60', '70'], + }) + pressureLevels!: string[]; + + @ApiProperty({ description: '是否可调压', example: true }) + isPressureAdjustable!: boolean; + + @ApiProperty({ + description: '备注', + example: null, + nullable: true, + type: String, + }) + notes!: string | null; +} + +export class PatientLifecycleDeviceSummaryDto { + @ApiProperty({ description: '设备 ID', example: 3 }) + id!: number; + + @ApiProperty({ description: '设备状态', enum: DeviceStatus }) + status!: DeviceStatus; + + @ApiProperty({ description: '是否已弃用', example: false }) + isAbandoned!: boolean; + + @ApiProperty({ description: '当前压力挡位', example: '50' }) + currentPressure!: string; + + @ApiProperty({ + description: '植入型号快照', + example: '823832', + nullable: true, + type: String, + }) + implantModel!: string | null; + + @ApiProperty({ + description: '植入厂家快照', + example: 'Codman', + nullable: true, + type: String, + }) + implantManufacturer!: string | null; + + @ApiProperty({ + description: '植入名称快照', + example: '可调节压力分流管', + nullable: true, + type: String, + }) + implantName!: string | null; + + @ApiProperty({ description: '是否为阀门', example: true }) + isValve!: boolean; + + @ApiProperty({ description: '是否可调压', example: true }) + isPressureAdjustable!: boolean; +} + +export class PatientLifecycleSurgeryDeviceDto extends PatientLifecycleDeviceSummaryDto { + @ApiProperty({ + description: '初始压力挡位', + example: '50', + nullable: true, + type: String, + }) + initialPressure!: string | null; + + @ApiProperty({ + description: '分流方式', + example: 'V_P', + nullable: true, + type: String, + }) + shuntMode!: string | null; + + @ApiProperty({ + description: '远端分流方向', + example: '右麦氏点', + nullable: true, + type: String, + }) + distalShuntDirection!: string | null; + + @ApiProperty({ + description: '近端穿刺部位', + type: [String], + example: ['椎间隙'], + }) + proximalPunctureAreas!: string[]; + + @ApiProperty({ + description: '阀门放置位置', + type: [String], + example: ['耳后'], + }) + valvePlacementSites!: string[]; + + @ApiPropertyOptional({ + description: '植入物目录信息', + type: PatientLifecycleImplantCatalogDto, + nullable: true, + }) + implantCatalog!: PatientLifecycleImplantCatalogDto | null; +} + +export class PatientLifecycleSurgeryDetailDto { + @ApiProperty({ description: '手术 ID', example: 3 }) + id!: number; + + @ApiProperty({ + description: '手术日期', + example: '2026-01-13T16:00:00.000Z', + format: 'date-time', + }) + surgeryDate!: string; + + @ApiProperty({ description: '手术名称', example: '脑室腹' }) + surgeryName!: string; + + @ApiProperty({ description: '主刀医生姓名', example: '徐玉婷' }) + surgeonName!: string; + + @ApiProperty({ description: '原发病', example: '颅脑损伤' }) + primaryDisease!: string; + + @ApiProperty({ + description: '脑积水类型', + type: [String], + example: ['高压性'], + }) + hydrocephalusTypes!: string[]; + + @ApiProperty({ + description: '上次分流手术时间', + example: null, + nullable: true, + format: 'date-time', + type: String, + }) + previousShuntSurgeryDate!: string | null; + + @ApiProperty({ description: '当前为第几次分流手术', example: 1 }) + shuntSurgeryCount!: number; +} + +export class PatientLifecycleTaskSurgeryDto { + @ApiProperty({ description: '手术 ID', example: 3 }) + id!: number; + + @ApiProperty({ + description: '手术日期', + example: '2026-01-13T16:00:00.000Z', + format: 'date-time', + }) + surgeryDate!: string; + + @ApiProperty({ description: '手术名称', example: '脑室腹' }) + surgeryName!: string; +} + +export class PatientLifecycleTaskDto { + @ApiProperty({ description: '任务 ID', example: 5 }) + id!: number; + + @ApiProperty({ description: '任务状态', enum: TaskStatus }) + status!: TaskStatus; + + @ApiProperty({ + description: '任务创建时间', + example: '2026-03-25T09:27:26.857Z', + format: 'date-time', + }) + createdAt!: string; +} + +export class PatientLifecycleTaskItemDto { + @ApiProperty({ description: '任务明细 ID', example: 5 }) + id!: number; + + @ApiProperty({ description: '原压力挡位', example: '50' }) + oldPressure!: string; + + @ApiProperty({ description: '目标压力挡位', example: '40' }) + targetPressure!: string; +} + +export class PatientLifecycleSurgeryEventDto { + @ApiProperty({ description: '事件类型', example: 'SURGERY' }) + eventType!: 'SURGERY'; + + @ApiProperty({ + description: '事件发生时间', + example: '2026-01-13T16:00:00.000Z', + format: 'date-time', + }) + occurredAt!: string; + + @ApiProperty({ + description: '所属医院', + type: PatientLifecycleHospitalDto, + }) + hospital!: PatientLifecycleHospitalDto; + + @ApiProperty({ + description: '手术详情', + type: PatientLifecycleSurgeryDetailDto, + }) + surgery!: PatientLifecycleSurgeryDetailDto; + + @ApiProperty({ + description: '本次手术植入设备列表', + type: [PatientLifecycleSurgeryDeviceDto], + }) + devices!: PatientLifecycleSurgeryDeviceDto[]; +} + +export class PatientLifecycleTaskAdjustmentEventDto { + @ApiProperty({ + description: '事件类型', + example: 'TASK_PRESSURE_ADJUSTMENT', + }) + eventType!: 'TASK_PRESSURE_ADJUSTMENT'; + + @ApiProperty({ + description: '事件发生时间', + example: '2026-03-25T09:27:26.857Z', + format: 'date-time', + }) + occurredAt!: string; + + @ApiProperty({ + description: '所属医院', + type: PatientLifecycleHospitalDto, + }) + hospital!: PatientLifecycleHospitalDto; + + @ApiProperty({ + description: '关联设备', + type: PatientLifecycleDeviceSummaryDto, + }) + device!: PatientLifecycleDeviceSummaryDto; + + @ApiProperty({ + description: '关联手术摘要', + type: PatientLifecycleTaskSurgeryDto, + nullable: true, + }) + surgery!: PatientLifecycleTaskSurgeryDto | null; + + @ApiProperty({ description: '任务主单', type: PatientLifecycleTaskDto }) + task!: PatientLifecycleTaskDto; + + @ApiProperty({ + description: '任务明细', + type: PatientLifecycleTaskItemDto, + }) + taskItem!: PatientLifecycleTaskItemDto; +} + +export class CPatientLifecycleDataDto { + @ApiProperty({ description: '当前登录手机号', example: '18027269840' }) + phone!: string; + + @ApiProperty({ + description: '当前手机号唯一命中的患者信息', + type: PatientLifecyclePatientDto, + }) + patient!: PatientLifecyclePatientDto; + + @ApiProperty({ + description: '按时间倒序排列的生命周期事件列表', + type: 'array', + items: { + oneOf: [ + { $ref: getSchemaPath(PatientLifecycleSurgeryEventDto) }, + { $ref: getSchemaPath(PatientLifecycleTaskAdjustmentEventDto) }, + ], + }, + }) + lifecycle!: Array< + PatientLifecycleSurgeryEventDto | PatientLifecycleTaskAdjustmentEventDto + >; +} + +export class BPatientLifecycleDataDto { + @ApiProperty({ description: '患者信息', type: PatientLifecyclePatientDto }) + patient!: PatientLifecyclePatientDto; + + @ApiProperty({ + description: '按时间倒序排列的生命周期事件列表', + type: 'array', + items: { + oneOf: [ + { $ref: getSchemaPath(PatientLifecycleSurgeryEventDto) }, + { $ref: getSchemaPath(PatientLifecycleTaskAdjustmentEventDto) }, + ], + }, + }) + lifecycle!: Array< + PatientLifecycleSurgeryEventDto | PatientLifecycleTaskAdjustmentEventDto + >; +} + +export class CPatientLifecycleResponseDto { + @ApiProperty({ description: '业务状态码', example: 0 }) + code!: number; + + @ApiProperty({ description: '响应消息', example: '成功' }) + msg!: string; + + @ApiProperty({ + description: '生命周期数据', + type: CPatientLifecycleDataDto, + }) + data!: CPatientLifecycleDataDto; +} + +export class BPatientLifecycleResponseDto { + @ApiProperty({ description: '业务状态码', example: 0 }) + code!: number; + + @ApiProperty({ description: '响应消息', example: '成功' }) + msg!: string; + + @ApiProperty({ + description: '生命周期数据', + type: BPatientLifecycleDataDto, + }) + data!: BPatientLifecycleDataDto; +} + +export const PATIENT_LIFECYCLE_SWAGGER_MODELS = [ + PatientLifecycleHospitalDto, + PatientLifecyclePatientDto, + PatientLifecycleImplantCatalogDto, + PatientLifecycleDeviceSummaryDto, + PatientLifecycleSurgeryDeviceDto, + PatientLifecycleSurgeryDetailDto, + PatientLifecycleTaskSurgeryDto, + PatientLifecycleTaskDto, + PatientLifecycleTaskItemDto, + PatientLifecycleSurgeryEventDto, + PatientLifecycleTaskAdjustmentEventDto, + CPatientLifecycleDataDto, + BPatientLifecycleDataDto, + CPatientLifecycleResponseDto, + BPatientLifecycleResponseDto, +] as const; diff --git a/src/patients/patient-lifecycle.util.ts b/src/patients/patient-lifecycle.util.ts index cfd5dca..2195f39 100644 --- a/src/patients/patient-lifecycle.util.ts +++ b/src/patients/patient-lifecycle.util.ts @@ -51,108 +51,106 @@ 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, +export function buildPatientLifecyclePayload(patient: PatientLifecycleSource) { + return { + patient: buildPatientLifecyclePatient(patient), + lifecycle: buildPatientLifecycleEvents(patient), + }; +} + +export function buildPatientLifecyclePatient(patient: PatientLifecycleSource) { + return { + id: toJsonNumber(patient.id), + name: patient.name, + inpatientNo: patient.inpatientNo, + projectName: patient.projectName, + }; +} + +export function buildPatientLifecycleEvents(patient: PatientLifecycleSource) { + const surgeryEvents = patient.surgeries.map((surgery, index, surgeries) => ({ + eventType: 'SURGERY' as const, + occurredAt: surgery.surgeryDate, + hospital: patient.hospital, + 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' as const, + occurredAt: task.createdAt, + hospital: patient.hospital, + 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, + }, }, - 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(), - ); + return [...surgeryEvents, ...taskEvents].sort( + (left, right) => + new Date(right.occurredAt).getTime() - + new Date(left.occurredAt).getTime(), + ); } function toJsonNumber(value: number | bigint | null | undefined) { diff --git a/test/e2e/specs/auth.e2e-spec.ts b/test/e2e/specs/auth.e2e-spec.ts index 79ab754..a8469c9 100644 --- a/test/e2e/specs/auth.e2e-spec.ts +++ b/test/e2e/specs/auth.e2e-spec.ts @@ -284,14 +284,14 @@ describe('AuthController (e2e)', () => { }); describe('POST /auth/miniapp/c/phone-login', () => { - it('成功:手机号关联患者时可创建家属账号并返回 token', async () => { + it('成功:手机号唯一关联患者时可创建 C 端账号并返回 token', async () => { const response = await request(ctx.app.getHttpServer()) .post('/auth/miniapp/c/phone-login') - .send(buildMiniAppMockPayload('13800002001', 'seed-family-a1-openid')); + .send(buildMiniAppMockPayload('13800002002', 'seed-family-a2-openid')); expectSuccessEnvelope(response, 201); expect(response.body.data.accessToken).toEqual(expect.any(String)); - expect(response.body.data.familyAccount.phone).toBe('13800002001'); + expect(response.body.data.familyAccount.phone).toBe('13800002002'); }); it('失败:手机号未关联患者档案返回 404', async () => { @@ -306,6 +306,18 @@ describe('AuthController (e2e)', () => { expectErrorEnvelope(response, 404, '当前手机号未关联患者档案'); }); + + it('失败:手机号关联多份患者档案返回 409', async () => { + const response = await request(ctx.app.getHttpServer()) + .post('/auth/miniapp/c/phone-login') + .send(buildMiniAppMockPayload('13800002001', 'seed-family-a1-openid')); + + expectErrorEnvelope( + response, + 409, + '当前手机号关联了多份患者档案,请联系管理员处理', + ); + }); }); describe('GET /auth/me', () => { diff --git a/test/e2e/specs/patients.e2e-spec.ts b/test/e2e/specs/patients.e2e-spec.ts index 5729b3f..5a93bbc 100644 --- a/test/e2e/specs/patients.e2e-spec.ts +++ b/test/e2e/specs/patients.e2e-spec.ts @@ -63,10 +63,15 @@ describe('Patients Controllers (e2e)', () => { }; } - async function loginFamilyByPhone(phone: string, openId?: string) { + async function loginPatientByPhone(phone: string, openId?: string) { const response = await request(ctx.app.getHttpServer()) .post('/auth/miniapp/c/phone-login') - .send(buildMiniAppMockPayload(phone, openId ?? uniqueSeedValue('family-openid'))); + .send( + buildMiniAppMockPayload( + phone, + openId ?? uniqueSeedValue('patient-openid'), + ), + ); expectSuccessEnvelope(response, 201); return response.body.data.accessToken as string; @@ -213,39 +218,81 @@ describe('Patients Controllers (e2e)', () => { }); describe('GET /c/patients/my-lifecycle', () => { - it('成功:家属小程序登录后可按绑定手机号查询跨院生命周期', async () => { - const familyToken = await loginFamilyByPhone( - '13800002001', - 'seed-family-a1-openid', + it('成功:患者小程序登录后可按手机号查询自己的生命周期', async () => { + const patientToken = await loginPatientByPhone( + '13800002002', + 'seed-family-a2-openid', ); const response = await request(ctx.app.getHttpServer()) .get('/c/patients/my-lifecycle') - .set('Authorization', `Bearer ${familyToken}`); + .set('Authorization', `Bearer ${patientToken}`); expectSuccessEnvelope(response, 200); - expect(response.body.data.phone).toBe('13800002001'); - expect(response.body.data.patientCount).toBeGreaterThanOrEqual(2); + expect(response.body.data.phone).toBe('13800002002'); + expect(response.body.data).toHaveProperty('patient'); + expect(response.body.data).not.toHaveProperty('patientCount'); expect(Array.isArray(response.body.data.lifecycle)).toBe(true); + const firstEvent = response.body.data.lifecycle[0] as + | Record + | undefined; + if (firstEvent) { + expect(firstEvent).not.toHaveProperty('patient'); + } }); it('失败:未登录返回 401', async () => { - const response = await request(ctx.app.getHttpServer()) - .get('/c/patients/my-lifecycle'); + const response = await request(ctx.app.getHttpServer()).get( + '/c/patients/my-lifecycle', + ); expectErrorEnvelope(response, 401, '缺少 Bearer Token'); }); - it('成功:已存在家属账号再次登录后仍可查询', async () => { - const familyToken = await loginFamilyByPhone('13800002002', 'seed-family-a2-openid'); + it('成功:已存在 C 端账号再次登录后仍可查询', async () => { + const patientToken = await loginPatientByPhone( + '13800002002', + 'seed-family-a2-openid', + ); const response = await request(ctx.app.getHttpServer()) .get('/c/patients/my-lifecycle') - .set('Authorization', `Bearer ${familyToken}`); + .set('Authorization', `Bearer ${patientToken}`); expectSuccessEnvelope(response, 200); expect(response.body.data.phone).toBe('13800002002'); }); }); + describe('GET /b/patients/:id/lifecycle', () => { + it('成功:返回顶层 patient 与 lifecycle 结构', async () => { + const response = await request(ctx.app.getHttpServer()) + .get(`/b/patients/${ctx.fixtures.patients.patientA1Id}/lifecycle`) + .set('Authorization', `Bearer ${ctx.tokens[Role.DOCTOR]}`); + + expectSuccessEnvelope(response, 200); + expect(response.body.data).toHaveProperty('patient'); + expect(response.body.data.patient.id).toBe( + ctx.fixtures.patients.patientA1Id, + ); + expect(response.body.data).not.toHaveProperty('patientId'); + expect(response.body.data).not.toHaveProperty('patientCount'); + expect(Array.isArray(response.body.data.lifecycle)).toBe(true); + const firstEvent = response.body.data.lifecycle[0] as + | Record + | undefined; + if (firstEvent) { + expect(firstEvent).not.toHaveProperty('patient'); + } + }); + + it('失败:ENGINEER 无权限访问返回 403', async () => { + const response = await request(ctx.app.getHttpServer()) + .get(`/b/patients/${ctx.fixtures.patients.patientA1Id}/lifecycle`) + .set('Authorization', `Bearer ${ctx.tokens[Role.ENGINEER]}`); + + expectErrorEnvelope(response, 403, '无权限执行当前操作'); + }); + }); + describe('患者手术录入', () => { it('成功:DOCTOR 可创建患者并附带首台手术和植入设备', async () => { const response = await request(ctx.app.getHttpServer())