C端 miniapp 登录增加手机号唯一命中患者规则,命中多份档案返回 409
C端 my-lifecycle 由跨院多患者聚合改为单患者返回,结构统一为 patient + lifecycle 生命周期事件移除事件内重复 patient 字段,减少冗余 B端患者生命周期接口同步采用 patient + lifecycle 结构 新增并接入生命周期 Swagger 响应模型,补齐接口文档 更新 auth/patients/frontend 集成文档说明 增加 e2e:多患者冲突、C端/B端新返回结构、权限失败场景
This commit is contained in:
parent
c830a2131e
commit
ab17204739
12
docs/auth.md
12
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` 仅预留字段,本次不提供绑定接口。
|
||||
|
||||
@ -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 端说明
|
||||
|
||||
|
||||
@ -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. 典型接口
|
||||
|
||||
@ -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;
|
||||
},
|
||||
);
|
||||
|
||||
@ -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<FamilyActorContext> {
|
||||
private async verifyAndExtractActor(
|
||||
token: string,
|
||||
): Promise<FamilyActorContext> {
|
||||
const secret = process.env.AUTH_TOKEN_SECRET;
|
||||
if (!secret) {
|
||||
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_SECRET_MISSING);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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: '操作过于频繁,请稍后再试',
|
||||
},
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
408
src/patients/dto/patient-lifecycle-response.dto.ts
Normal file
408
src/patients/dto/patient-lifecycle-response.dto.ts
Normal file
@ -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;
|
||||
@ -51,21 +51,27 @@ 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: {
|
||||
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,
|
||||
@ -104,15 +110,9 @@ export function buildPatientLifecycleRecords(
|
||||
const task = taskItem.task;
|
||||
return [
|
||||
{
|
||||
eventType: 'TASK_PRESSURE_ADJUSTMENT',
|
||||
eventType: 'TASK_PRESSURE_ADJUSTMENT' as const,
|
||||
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,
|
||||
@ -146,9 +146,7 @@ export function buildPatientLifecycleRecords(
|
||||
}),
|
||||
);
|
||||
|
||||
return [...surgeryEvents, ...taskEvents];
|
||||
})
|
||||
.sort(
|
||||
return [...surgeryEvents, ...taskEvents].sort(
|
||||
(left, right) =>
|
||||
new Date(right.occurredAt).getTime() -
|
||||
new Date(left.occurredAt).getTime(),
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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<string, unknown>
|
||||
| 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<string, unknown>
|
||||
| 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())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user