Compare commits
No commits in common. "d77627e44b17b923e07e5003e731f53f18b6f3dc" and "c830a2131e7724988ede0b86e941d5a5e99dcad0" have entirely different histories.
d77627e44b
...
c830a2131e
16
docs/auth.md
16
docs/auth.md
@ -2,8 +2,8 @@
|
||||
|
||||
## 1. 目标
|
||||
|
||||
- 提供系统管理员创建、院内账号密码登录、B 端小程序手机号登录、C 端小程序手机号登录、身份查询。
|
||||
- 使用 JWT 做认证,院内账号与 C 端小程序账号走两套守卫。
|
||||
- 提供系统管理员创建、院内账号密码登录、B 端小程序手机号登录、C 端小程序手机号登录、`/me` 身份查询。
|
||||
- 使用 JWT 做认证,院内账号与家属小程序账号走两套守卫。
|
||||
|
||||
## 2. 核心接口
|
||||
|
||||
@ -14,7 +14,6 @@
|
||||
- `POST /auth/miniapp/b/phone-login/confirm`:B 端同手机号多账号确认登录
|
||||
- `POST /auth/miniapp/c/phone-login`:C 端小程序手机号登录
|
||||
- `GET /auth/me`:返回当前院内登录用户上下文
|
||||
- `GET /c/patients/me`:返回当前 C 端登录账号绑定的患者基础信息
|
||||
|
||||
## 3. 院内账号密码登录流程
|
||||
|
||||
@ -37,7 +36,7 @@
|
||||
### C 端
|
||||
|
||||
1. 前端同样传 `loginCode + phoneCode`。
|
||||
2. 后端先校验该手机号是否唯一命中 `Patient.phone`。
|
||||
2. 后端先校验该手机号是否已存在于 `Patient.phone`。
|
||||
3. 校验通过后创建或绑定 `FamilyMiniAppAccount`,并返回 C 端 JWT。
|
||||
|
||||
## 5. 鉴权流程
|
||||
@ -49,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. 将 C 端账号上下文注入 `request.familyActor`。
|
||||
4. 将家属账号上下文注入 `request.familyActor`。
|
||||
|
||||
## 6. 环境变量
|
||||
|
||||
@ -71,7 +70,6 @@
|
||||
- B 端账号未绑定 `openId` 时,首次小程序登录自动绑定。
|
||||
- 同一个 `openId` 可绑定多个院内账号,支持同一微信号切换多角色/多院区账号。
|
||||
- 若目标院内账号已绑定其他微信号,仍需先在用户管理中清空该账号的 `openId` 再重新绑定。
|
||||
- C 端账号独立存放在 `FamilyMiniAppAccount`。
|
||||
- C 端手机号必须唯一命中患者档案,否则拒绝登录。
|
||||
- C 端登录后读取“我的信息”请调用 `GET /c/patients/me`,不要复用 `GET /auth/me`。
|
||||
- C 端家属账号独立存放在 `FamilyMiniAppAccount`。
|
||||
- C 端手机号必须先存在于患者档案,否则拒绝登录。
|
||||
- `serviceUid` 仅预留字段,本次不提供绑定接口。
|
||||
|
||||
@ -30,16 +30,12 @@
|
||||
- 入参:
|
||||
- `loginCode`
|
||||
- `phoneCode`
|
||||
- 要求当前手机号唯一关联 1 份患者档案,否则返回冲突错误
|
||||
|
||||
## 2. C 端生命周期
|
||||
|
||||
- 登录成功后可先调用:`GET /c/patients/me`
|
||||
- 返回当前 C 端账号信息与当前手机号唯一命中的患者基础档案
|
||||
- 登录成功后调用:`GET /c/patients/my-lifecycle`
|
||||
- 不再需要传 `phone` 或 `idCard`
|
||||
- Bearer Token 使用 C 端患者登录返回的 `accessToken`
|
||||
- 返回结构改为顶层 `patient + lifecycle`,事件项内不再重复返回 `patient`
|
||||
- Bearer Token 使用 C 端家属登录返回的 `accessToken`
|
||||
|
||||
## 3. B 端说明
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
## 1. 目标
|
||||
|
||||
- B 端:维护患者、手术、植入设备及生命周期数据。
|
||||
- C 端:患者本人小程序登录后,按当前手机号查询自己的生命周期。
|
||||
- C 端:家属小程序登录后,按已绑定手机号聚合查询跨院患者生命周期。
|
||||
|
||||
## 2. B 端能力
|
||||
|
||||
@ -13,20 +13,17 @@
|
||||
|
||||
## 3. C 端能力
|
||||
|
||||
- 患者本人通过小程序手机号登录
|
||||
- `GET /c/patients/me`
|
||||
- 家属账号通过小程序手机号登录
|
||||
- `GET /c/patients/my-lifecycle`
|
||||
- 查询口径:按 `FamilyMiniAppAccount.phone` 唯一命中 `Patient.phone`
|
||||
- `me` 返回内容:当前 C 端账号信息 + 当前手机号命中的患者基础档案
|
||||
- `my-lifecycle` 返回内容:顶层患者信息 + 手术事件/调压事件时间线
|
||||
- 查询口径:按 `FamilyMiniAppAccount.phone` 聚合 `Patient.phone`
|
||||
- 返回内容:手术事件 + 调压事件的时间线
|
||||
|
||||
## 4. 当前规则
|
||||
|
||||
- 同一个手机号在 C 端只允许命中 1 份患者档案。
|
||||
- 若同一个手机号命中多份患者档案,登录阶段直接返回冲突错误。
|
||||
- 同一个手机号可关联多个患者,C 端会统一聚合返回。
|
||||
- C 端手机号来源于患者手术/档案中维护的联系电话。
|
||||
- 仅已登录的 C 端小程序账号可访问 `me` 与 `my-lifecycle`。
|
||||
- C 端登录账号不存在或 token 无效时返回 `401`。
|
||||
- 仅已登录的家属小程序账号可访问 `my-lifecycle`。
|
||||
- 家属账号不存在或 token 无效时返回 `401`。
|
||||
- 手机号下无患者档案时,登录阶段直接拦截,不进入生命周期查询。
|
||||
|
||||
## 5. 典型接口
|
||||
@ -34,5 +31,4 @@
|
||||
- `GET /b/patients`
|
||||
- `POST /b/patients`
|
||||
- `POST /b/patients/:id/surgeries`
|
||||
- `GET /c/patients/me`
|
||||
- `GET /c/patients/my-lifecycle`
|
||||
|
||||
@ -2,13 +2,11 @@ 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,11 +37,9 @@ export class FamilyAccessTokenGuard implements CanActivate {
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 C 端 token 并回库确认账号仍存在。
|
||||
* 校验家属 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,10 +67,7 @@ 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);
|
||||
}
|
||||
|
||||
@ -100,40 +97,27 @@ 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 matchedPatients = await this.prisma.patient.findMany({
|
||||
const patientExists = await this.prisma.patient.findFirst({
|
||||
where: { phone: identity.phone },
|
||||
select: { id: true },
|
||||
take: 2,
|
||||
});
|
||||
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,
|
||||
);
|
||||
if (!patientExists) {
|
||||
throw new NotFoundException(MESSAGES.AUTH.FAMILY_PHONE_NOT_LINKED_PATIENT);
|
||||
}
|
||||
|
||||
const existingByOpenId = await this.prisma.familyMiniAppAccount.findUnique({
|
||||
@ -141,9 +125,7 @@ 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({
|
||||
@ -157,9 +139,7 @@ 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
|
||||
@ -222,9 +202,7 @@ 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 (
|
||||
@ -233,20 +211,16 @@ 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,11 +37,9 @@ export const MESSAGES = {
|
||||
MINIAPP_LOGIN_TICKET_INVALID: '账号选择票据无效或已过期',
|
||||
MINIAPP_ACCOUNT_SELECTION_INVALID: '请选择有效的候选账号',
|
||||
MINIAPP_OPEN_ID_BOUND_OTHER_USER: '当前院内账号已绑定其他微信账号',
|
||||
MINIAPP_OPEN_ID_BOUND_OTHER_FAMILY: '当前微信账号已绑定其他 C 端账号',
|
||||
MINIAPP_OPEN_ID_BOUND_OTHER_FAMILY: '当前微信账号已绑定其他家属账号',
|
||||
FAMILY_PHONE_NOT_LINKED_PATIENT: '当前手机号未关联患者档案',
|
||||
FAMILY_PHONE_LINKED_MULTI_PATIENTS:
|
||||
'当前手机号关联了多份患者档案,请联系管理员处理',
|
||||
FAMILY_ACCOUNT_NOT_FOUND: 'C端登录账号不存在,请重新登录',
|
||||
FAMILY_ACCOUNT_NOT_FOUND: '家属登录账号不存在,请重新登录',
|
||||
THROTTLED: '操作过于频繁,请稍后再试',
|
||||
},
|
||||
|
||||
|
||||
@ -12,8 +12,6 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiExtraModels,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiQuery,
|
||||
@ -28,10 +26,6 @@ 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';
|
||||
@ -146,11 +140,6 @@ 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 {
|
||||
buildPatientLifecyclePayload,
|
||||
buildPatientLifecycleRecords,
|
||||
PATIENT_LIFECYCLE_INCLUDE,
|
||||
} from '../patient-lifecycle.util.js';
|
||||
import { normalizePatientIdCard } from '../patient-id-card.util.js';
|
||||
@ -186,8 +186,36 @@ 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: this.buildVisibleDoctorWhere(actor, hospitalId),
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
@ -339,7 +367,11 @@ export class BPatientsService {
|
||||
|
||||
this.assertPatientScope(actor, patient);
|
||||
|
||||
return buildPatientLifecyclePayload(patient);
|
||||
return {
|
||||
patientId: patient.id,
|
||||
patientCount: 1,
|
||||
lifecycle: buildPatientLifecycleRecords([patient]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -556,51 +588,29 @@ 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:
|
||||
return where;
|
||||
if (!actor.hospitalId || doctor.hospitalId !== actor.hospitalId) {
|
||||
throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN);
|
||||
}
|
||||
return doctor;
|
||||
case Role.DIRECTOR:
|
||||
if (!actor.departmentId) {
|
||||
throw new BadRequestException(MESSAGES.PATIENT.DEPARTMENT_REQUIRED);
|
||||
if (!actor.departmentId || doctor.departmentId !== actor.departmentId) {
|
||||
throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN);
|
||||
}
|
||||
where.departmentId = actor.departmentId;
|
||||
return where;
|
||||
return doctor;
|
||||
case Role.LEADER:
|
||||
if (!actor.groupId) {
|
||||
throw new BadRequestException(MESSAGES.PATIENT.GROUP_REQUIRED);
|
||||
if (!actor.groupId || doctor.groupId !== actor.groupId) {
|
||||
throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN);
|
||||
}
|
||||
where.groupId = actor.groupId;
|
||||
return where;
|
||||
return doctor;
|
||||
case Role.DOCTOR:
|
||||
if (!actor.departmentId) {
|
||||
throw new BadRequestException(MESSAGES.PATIENT.DEPARTMENT_REQUIRED);
|
||||
if (doctor.id !== actor.id) {
|
||||
throw new ForbiddenException(MESSAGES.PATIENT.DOCTOR_SCOPE_FORBIDDEN);
|
||||
}
|
||||
where.departmentId = actor.departmentId;
|
||||
return where;
|
||||
return doctor;
|
||||
default:
|
||||
throw new ForbiddenException(MESSAGES.PATIENT.ROLE_FORBIDDEN);
|
||||
}
|
||||
|
||||
@ -1,23 +1,12 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiExtraModels,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { ApiBearerAuth, 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')
|
||||
@ -27,28 +16,10 @@ 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);
|
||||
}
|
||||
|
||||
@ -1,134 +1,41 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { MESSAGES } from '../../common/messages.js';
|
||||
import { PrismaService } from '../../prisma.service.js';
|
||||
import {
|
||||
buildPatientLifecyclePayload,
|
||||
buildPatientLifecycleRecords,
|
||||
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 },
|
||||
where: {
|
||||
phone: account.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 {
|
||||
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,
|
||||
},
|
||||
phone: account.phone,
|
||||
patientCount: patients.length,
|
||||
lifecycle: buildPatientLifecycleRecords(patients),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,126 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,408 +0,0 @@
|
||||
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,27 +51,21 @@ export type PatientLifecycleSource = Prisma.PatientGetPayload<{
|
||||
include: typeof PATIENT_LIFECYCLE_INCLUDE;
|
||||
}>;
|
||||
|
||||
export function buildPatientLifecyclePayload(patient: PatientLifecycleSource) {
|
||||
return {
|
||||
patient: buildPatientLifecyclePatient(patient),
|
||||
lifecycle: buildPatientLifecycleEvents(patient),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPatientLifecyclePatient(patient: PatientLifecycleSource) {
|
||||
return {
|
||||
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 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,
|
||||
@ -110,9 +104,15 @@ export function buildPatientLifecycleEvents(patient: PatientLifecycleSource) {
|
||||
const task = taskItem.task;
|
||||
return [
|
||||
{
|
||||
eventType: 'TASK_PRESSURE_ADJUSTMENT' as const,
|
||||
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,
|
||||
@ -146,7 +146,9 @@ export function buildPatientLifecycleEvents(patient: PatientLifecycleSource) {
|
||||
}),
|
||||
);
|
||||
|
||||
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('成功:手机号唯一关联患者时可创建 C 端账号并返回 token', async () => {
|
||||
it('成功:手机号关联患者时可创建家属账号并返回 token', async () => {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/auth/miniapp/c/phone-login')
|
||||
.send(buildMiniAppMockPayload('13800002002', 'seed-family-a2-openid'));
|
||||
.send(buildMiniAppMockPayload('13800002001', 'seed-family-a1-openid'));
|
||||
|
||||
expectSuccessEnvelope(response, 201);
|
||||
expect(response.body.data.accessToken).toEqual(expect.any(String));
|
||||
expect(response.body.data.familyAccount.phone).toBe('13800002002');
|
||||
expect(response.body.data.familyAccount.phone).toBe('13800002001');
|
||||
});
|
||||
|
||||
it('失败:手机号未关联患者档案返回 404', async () => {
|
||||
@ -306,18 +306,6 @@ 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,15 +63,10 @@ describe('Patients Controllers (e2e)', () => {
|
||||
};
|
||||
}
|
||||
|
||||
async function loginPatientByPhone(phone: string, openId?: string) {
|
||||
async function loginFamilyByPhone(phone: string, openId?: string) {
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.post('/auth/miniapp/c/phone-login')
|
||||
.send(
|
||||
buildMiniAppMockPayload(
|
||||
phone,
|
||||
openId ?? uniqueSeedValue('patient-openid'),
|
||||
),
|
||||
);
|
||||
.send(buildMiniAppMockPayload(phone, openId ?? uniqueSeedValue('family-openid')));
|
||||
|
||||
expectSuccessEnvelope(response, 201);
|
||||
return response.body.data.accessToken as string;
|
||||
@ -218,81 +213,39 @@ describe('Patients Controllers (e2e)', () => {
|
||||
});
|
||||
|
||||
describe('GET /c/patients/my-lifecycle', () => {
|
||||
it('成功:患者小程序登录后可按手机号查询自己的生命周期', async () => {
|
||||
const patientToken = await loginPatientByPhone(
|
||||
'13800002002',
|
||||
'seed-family-a2-openid',
|
||||
it('成功:家属小程序登录后可按绑定手机号查询跨院生命周期', async () => {
|
||||
const familyToken = await loginFamilyByPhone(
|
||||
'13800002001',
|
||||
'seed-family-a1-openid',
|
||||
);
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.get('/c/patients/my-lifecycle')
|
||||
.set('Authorization', `Bearer ${patientToken}`);
|
||||
.set('Authorization', `Bearer ${familyToken}`);
|
||||
|
||||
expectSuccessEnvelope(response, 200);
|
||||
expect(response.body.data.phone).toBe('13800002002');
|
||||
expect(response.body.data).toHaveProperty('patient');
|
||||
expect(response.body.data).not.toHaveProperty('patientCount');
|
||||
expect(response.body.data.phone).toBe('13800002001');
|
||||
expect(response.body.data.patientCount).toBeGreaterThanOrEqual(2);
|
||||
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('成功:已存在 C 端账号再次登录后仍可查询', async () => {
|
||||
const patientToken = await loginPatientByPhone(
|
||||
'13800002002',
|
||||
'seed-family-a2-openid',
|
||||
);
|
||||
it('成功:已存在家属账号再次登录后仍可查询', async () => {
|
||||
const familyToken = await loginFamilyByPhone('13800002002', 'seed-family-a2-openid');
|
||||
const response = await request(ctx.app.getHttpServer())
|
||||
.get('/c/patients/my-lifecycle')
|
||||
.set('Authorization', `Bearer ${patientToken}`);
|
||||
.set('Authorization', `Bearer ${familyToken}`);
|
||||
|
||||
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())
|
||||
|
||||
@ -216,10 +216,12 @@
|
||||
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>
|
||||
@ -1586,6 +1588,10 @@ async function openCreateDialog() {
|
||||
isEdit.value = false;
|
||||
resetPatientForm();
|
||||
|
||||
if (userStore.role === 'DOCTOR') {
|
||||
patientForm.doctorId = userStore.userInfo?.id || null;
|
||||
}
|
||||
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user