Compare commits

..

3 Commits

Author SHA1 Message Date
EL
d77627e44b 统一医生可见范围并优化患者归属医生选择 2026-04-02 05:29:00 +08:00
EL
8f7e13bf2b 新增C端患者me接口并补充文档说明 2026-04-02 05:19:52 +08:00
EL
ab17204739 C端 miniapp 登录增加手机号唯一命中患者规则,命中多份档案返回 409
C端 my-lifecycle 由跨院多患者聚合改为单患者返回,结构统一为 patient + lifecycle
生命周期事件移除事件内重复 patient 字段,减少冗余
B端患者生命周期接口同步采用 patient + lifecycle 结构
新增并接入生命周期 Swagger 响应模型,补齐接口文档
更新 auth/patients/frontend 集成文档说明
增加 e2e:多患者冲突、C端/B端新返回结构、权限失败场景
2026-04-02 04:07:40 +08:00
17 changed files with 968 additions and 218 deletions

View File

@ -2,8 +2,8 @@
## 1. 目标
- 提供系统管理员创建、院内账号密码登录、B 端小程序手机号登录、C 端小程序手机号登录、`/me` 身份查询。
- 使用 JWT 做认证,院内账号与家属小程序账号走两套守卫。
- 提供系统管理员创建、院内账号密码登录、B 端小程序手机号登录、C 端小程序手机号登录、身份查询。
- 使用 JWT 做认证,院内账号与 C 端小程序账号走两套守卫。
## 2. 核心接口
@ -14,6 +14,7 @@
- `POST /auth/miniapp/b/phone-login/confirm`B 端同手机号多账号确认登录
- `POST /auth/miniapp/c/phone-login`C 端小程序手机号登录
- `GET /auth/me`:返回当前院内登录用户上下文
- `GET /c/patients/me`:返回当前 C 端登录账号绑定的患者基础信息
## 3. 院内账号密码登录流程
@ -36,7 +37,7 @@
### C 端
1. 前端同样传 `loginCode + phoneCode`
2. 后端先校验该手机号是否已存在于 `Patient.phone`
2. 后端先校验该手机号是否唯一命中 `Patient.phone`
3. 校验通过后创建或绑定 `FamilyMiniAppAccount`,并返回 C 端 JWT。
## 5. 鉴权流程
@ -48,12 +49,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 +71,7 @@
- B 端账号未绑定 `openId` 时,首次小程序登录自动绑定。
- 同一个 `openId` 可绑定多个院内账号,支持同一微信号切换多角色/多院区账号。
- 若目标院内账号已绑定其他微信号,仍需先在用户管理中清空该账号的 `openId` 再重新绑定。
- C 端家属账号独立存放在 `FamilyMiniAppAccount`
- C 端手机号必须先存在于患者档案,否则拒绝登录。
- C 端账号独立存放在 `FamilyMiniAppAccount`
- C 端手机号必须唯一命中患者档案,否则拒绝登录。
- C 端登录后读取“我的信息”请调用 `GET /c/patients/me`,不要复用 `GET /auth/me`
- `serviceUid` 仅预留字段,本次不提供绑定接口。

View File

@ -30,12 +30,16 @@
- 入参:
- `loginCode`
- `phoneCode`
- 要求当前手机号唯一关联 1 份患者档案,否则返回冲突错误
## 2. C 端生命周期
- 登录成功后可先调用:`GET /c/patients/me`
- 返回当前 C 端账号信息与当前手机号唯一命中的患者基础档案
- 登录成功后调用:`GET /c/patients/my-lifecycle`
- 不再需要传 `phone``idCard`
- Bearer Token 使用 C 端家属登录返回的 `accessToken`
- Bearer Token 使用 C 端患者登录返回的 `accessToken`
- 返回结构改为顶层 `patient + lifecycle`,事件项内不再重复返回 `patient`
## 3. B 端说明

View File

@ -3,7 +3,7 @@
## 1. 目标
- B 端:维护患者、手术、植入设备及生命周期数据。
- C 端:家属小程序登录后,按已绑定手机号聚合查询跨院患者生命周期。
- C 端:患者本人小程序登录后,按当前手机号查询自己的生命周期。
## 2. B 端能力
@ -13,17 +13,20 @@
## 3. C 端能力
- 家属账号通过小程序手机号登录
- 患者本人通过小程序手机号登录
- `GET /c/patients/me`
- `GET /c/patients/my-lifecycle`
- 查询口径:按 `FamilyMiniAppAccount.phone` 聚合 `Patient.phone`
- 返回内容:手术事件 + 调压事件的时间线
- 查询口径:按 `FamilyMiniAppAccount.phone` 唯一命中 `Patient.phone`
- `me` 返回内容:当前 C 端账号信息 + 当前手机号命中的患者基础档案
- `my-lifecycle` 返回内容:顶层患者信息 + 手术事件/调压事件时间线
## 4. 当前规则
- 同一个手机号可关联多个患者C 端会统一聚合返回。
- 同一个手机号在 C 端只允许命中 1 份患者档案。
- 若同一个手机号命中多份患者档案,登录阶段直接返回冲突错误。
- C 端手机号来源于患者手术/档案中维护的联系电话。
- 仅已登录的家属小程序账号可访问 `my-lifecycle`
- 家属账号不存在或 token 无效时返回 `401`
- 仅已登录的 C 端小程序账号可访问 `me` `my-lifecycle`
- C 端登录账号不存在或 token 无效时返回 `401`
- 手机号下无患者档案时,登录阶段直接拦截,不进入生命周期查询。
## 5. 典型接口
@ -31,4 +34,5 @@
- `GET /b/patients`
- `POST /b/patients`
- `POST /b/patients/:id/surgeries`
- `GET /c/patients/me`
- `GET /c/patients/my-lifecycle`

View File

@ -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;
},
);

View File

@ -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);

View File

@ -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();

View File

@ -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: '操作过于频繁,请稍后再试',
},

View File

@ -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,

View File

@ -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';
@ -186,36 +186,8 @@ export class BPatientsService {
*/
async findVisibleDoctors(actor: ActorContext, requestedHospitalId?: number) {
const hospitalId = this.resolveHospitalId(actor, requestedHospitalId);
const where: Prisma.UserWhereInput = {
role: { in: PATIENT_OWNER_ROLES },
hospitalId,
};
switch (actor.role) {
case Role.DOCTOR:
where.id = actor.id;
break;
case Role.LEADER:
if (!actor.groupId) {
throw new BadRequestException(MESSAGES.PATIENT.GROUP_REQUIRED);
}
where.groupId = actor.groupId;
break;
case Role.DIRECTOR:
if (!actor.departmentId) {
throw new BadRequestException(MESSAGES.PATIENT.DEPARTMENT_REQUIRED);
}
where.departmentId = actor.departmentId;
break;
case Role.HOSPITAL_ADMIN:
case Role.SYSTEM_ADMIN:
break;
default:
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
}
return this.prisma.user.findMany({
where,
where: this.buildVisibleDoctorWhere(actor, hospitalId),
select: {
id: true,
name: true,
@ -367,11 +339,7 @@ export class BPatientsService {
this.assertPatientScope(actor, patient);
return {
patientId: patient.id,
patientCount: 1,
lifecycle: buildPatientLifecycleRecords([patient]),
};
return buildPatientLifecyclePayload(patient);
}
/**
@ -588,29 +556,51 @@ export class BPatientsService {
throw new BadRequestException(MESSAGES.PATIENT.DOCTOR_NOT_FOUND);
}
const visibleDoctor = await this.prisma.user.findFirst({
where: {
id: doctor.id,
...this.buildVisibleDoctorWhere(actor, doctor.hospitalId),
},
select: { id: true },
});
if (!visibleDoctor) {
throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN);
}
return doctor;
}
/**
*
*/
private buildVisibleDoctorWhere(actor: ActorContext, hospitalId: number) {
const where: Prisma.UserWhereInput = {
role: { in: PATIENT_OWNER_ROLES },
hospitalId,
};
switch (actor.role) {
case Role.SYSTEM_ADMIN:
return doctor;
case Role.HOSPITAL_ADMIN:
if (!actor.hospitalId || doctor.hospitalId !== actor.hospitalId) {
throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN);
}
return doctor;
return where;
case Role.DIRECTOR:
if (!actor.departmentId || doctor.departmentId !== actor.departmentId) {
throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN);
if (!actor.departmentId) {
throw new BadRequestException(MESSAGES.PATIENT.DEPARTMENT_REQUIRED);
}
return doctor;
where.departmentId = actor.departmentId;
return where;
case Role.LEADER:
if (!actor.groupId || doctor.groupId !== actor.groupId) {
throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN);
if (!actor.groupId) {
throw new BadRequestException(MESSAGES.PATIENT.GROUP_REQUIRED);
}
return doctor;
where.groupId = actor.groupId;
return where;
case Role.DOCTOR:
if (doctor.id !== actor.id) {
throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN);
if (!actor.departmentId) {
throw new BadRequestException(MESSAGES.PATIENT.DEPARTMENT_REQUIRED);
}
return doctor;
where.departmentId = actor.departmentId;
return where;
default:
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
}

View File

@ -1,12 +1,23 @@
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 { CPatientMeResponseDto } from '../dto/c-patient-me-response.dto.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 +27,28 @@ export class CPatientsController {
constructor(private readonly patientsService: CPatientsService) {}
/**
*
*
*/
@Get('me')
@ApiOperation({ summary: '获取当前登录患者信息' })
@ApiOkResponse({
description: '返回当前 C 端登录账号绑定的患者档案与账号信息',
type: CPatientMeResponseDto,
})
me(@CurrentFamilyActor() actor: FamilyActorContext) {
return this.patientsService.getMeByAccount(actor.id);
}
/**
*
*/
@Get('my-lifecycle')
@ApiOperation({ summary: '按当前登录手机号查询患者生命周期' })
@ApiExtraModels(...PATIENT_LIFECYCLE_SWAGGER_MODELS)
@ApiOkResponse({
description: '按当前登录手机号返回唯一患者的生命周期数据',
type: CPatientLifecycleResponseDto,
})
getMyLifecycle(@CurrentFamilyActor() actor: FamilyActorContext) {
return this.patientsService.getFamilyLifecycleByAccount(actor.id);
}

View File

@ -1,41 +1,134 @@
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';
const C_PATIENT_ME_INCLUDE = {
hospital: {
select: {
id: true,
name: true,
},
},
doctor: {
select: {
id: true,
name: true,
role: true,
},
},
} as const;
@Injectable()
export class CPatientsService {
constructor(private readonly prisma: PrismaService) {}
async getFamilyLifecycleByAccount(accountId: number) {
const account = await this.getFamilyAccountById(accountId);
const patient = await this.findUniqueLifecyclePatientByPhone(account.phone);
return {
phone: account.phone,
...buildPatientLifecyclePayload(patient),
};
}
/**
* C
*/
async getMeByAccount(accountId: number) {
const account = await this.getFamilyAccountById(accountId);
const patient = await this.findUniqueMePatientByPhone(account.phone);
return {
familyAccount: account,
patient,
};
}
private async getFamilyAccountById(accountId: number) {
const account = await this.prisma.familyMiniAppAccount.findUnique({
where: { id: accountId },
select: {
id: true,
phone: true,
openId: true,
serviceUid: true,
lastLoginAt: true,
},
});
if (!account) {
throw new NotFoundException(MESSAGES.AUTH.FAMILY_ACCOUNT_NOT_FOUND);
}
return account;
}
private async findUniqueLifecyclePatientByPhone(phone: string) {
const patients = await this.prisma.patient.findMany({
where: {
phone: account.phone,
},
where: { phone },
include: PATIENT_LIFECYCLE_INCLUDE,
take: 2,
orderBy: { id: 'asc' },
});
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,
);
}
return patients[0];
}
private async findUniqueMePatientByPhone(phone: string) {
const patients = await this.prisma.patient.findMany({
where: { phone },
include: C_PATIENT_ME_INCLUDE,
take: 2,
orderBy: { id: 'asc' },
});
if (patients.length === 0) {
throw new NotFoundException(
MESSAGES.AUTH.FAMILY_PHONE_NOT_LINKED_PATIENT,
);
}
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),
id: patient.id,
name: patient.name,
phone: patient.phone,
idCard: patient.idCard,
inpatientNo: patient.inpatientNo,
projectName: patient.projectName,
hospitalId: patient.hospitalId,
doctorId: patient.doctorId,
createdAt: patient.createdAt,
hospital: patient.hospital,
doctor: {
id: patient.doctor.id,
name: patient.doctor.name,
role: patient.doctor.role,
},
};
}
}

View File

@ -0,0 +1,126 @@
import { ApiProperty } from '@nestjs/swagger';
import { Role } from '../../generated/prisma/enums.js';
export class CPatientMeFamilyAccountDto {
@ApiProperty({ description: 'C 端账号 ID', example: 1 })
id!: number;
@ApiProperty({ description: '当前登录手机号', example: '13800002002' })
phone!: string;
@ApiProperty({
description: '微信 openId',
example: 'oAbcDefGhIjKlMn',
nullable: true,
type: String,
})
openId!: string | null;
@ApiProperty({
description: '客服体系用户标识',
example: null,
nullable: true,
type: String,
})
serviceUid!: string | null;
@ApiProperty({
description: '最近登录时间',
example: '2026-04-02T08:00:00.000Z',
format: 'date-time',
})
lastLoginAt!: string;
}
export class CPatientMeHospitalDto {
@ApiProperty({ description: '医院 ID', example: 1 })
id!: number;
@ApiProperty({ description: '医院名称', example: '珠江医院' })
name!: string;
}
export class CPatientMeDoctorDto {
@ApiProperty({ description: '归属医生 ID', example: 10001 })
id!: number;
@ApiProperty({ description: '归属医生姓名', example: '张医生' })
name!: string;
@ApiProperty({ description: '归属医生角色', enum: Role })
role!: Role;
}
export class CPatientMePatientDto {
@ApiProperty({ description: '患者 ID', example: 8 })
id!: number;
@ApiProperty({ description: '患者姓名', example: '张三' })
name!: string;
@ApiProperty({ description: '患者手机号', example: '13800002002' })
phone!: string;
@ApiProperty({ description: '患者身份证号', example: '440102199901010011' })
idCard!: string;
@ApiProperty({
description: '住院号',
example: 'ZY20260402001',
nullable: true,
type: String,
})
inpatientNo!: string | null;
@ApiProperty({
description: '项目名称',
example: '脑积水随访项目',
nullable: true,
type: String,
})
projectName!: string | null;
@ApiProperty({ description: '所属医院 ID', example: 1 })
hospitalId!: number;
@ApiProperty({ description: '归属医生 ID', example: 10001 })
doctorId!: number;
@ApiProperty({
description: '建档时间',
example: '2026-04-02T08:00:00.000Z',
format: 'date-time',
})
createdAt!: string;
@ApiProperty({ description: '所属医院', type: CPatientMeHospitalDto })
hospital!: CPatientMeHospitalDto;
@ApiProperty({ description: '归属医生', type: CPatientMeDoctorDto })
doctor!: CPatientMeDoctorDto;
}
export class CPatientMeDataDto {
@ApiProperty({
description: '当前登录的 C 端账号信息',
type: CPatientMeFamilyAccountDto,
})
familyAccount!: CPatientMeFamilyAccountDto;
@ApiProperty({
description: '当前手机号唯一命中的患者档案',
type: CPatientMePatientDto,
})
patient!: CPatientMePatientDto;
}
export class CPatientMeResponseDto {
@ApiProperty({ description: '业务状态码', example: 0 })
code!: number;
@ApiProperty({ description: '响应消息', example: '成功' })
msg!: string;
@ApiProperty({ description: '当前登录患者信息', type: CPatientMeDataDto })
data!: CPatientMeDataDto;
}

View 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;

View File

@ -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(),

View File

@ -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', () => {

View File

@ -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())

View File

@ -216,12 +216,10 @@
v-model="patientForm.doctorId"
:data="doctorTreeOptions"
:props="doctorTreeProps"
check-strictly
filterable
clearable
placeholder="请选择归属医生(按科室/小组)"
style="width: 100%"
:disabled="userStore.role === 'DOCTOR'"
/>
</el-form-item>
</el-col>
@ -1588,10 +1586,6 @@ async function openCreateDialog() {
isEdit.value = false;
resetPatientForm();
if (userStore.role === 'DOCTOR') {
patientForm.doctorId = userStore.userInfo?.id || null;
}
dialogVisible.value = true;
}