新增C端患者me接口并补充文档说明

This commit is contained in:
EL 2026-04-02 05:19:52 +08:00
parent ab17204739
commit 8f7e13bf2b
6 changed files with 239 additions and 9 deletions

View File

@ -2,7 +2,7 @@
## 1. 目标 ## 1. 目标
- 提供系统管理员创建、院内账号密码登录、B 端小程序手机号登录、C 端小程序手机号登录、`/me` 身份查询。 - 提供系统管理员创建、院内账号密码登录、B 端小程序手机号登录、C 端小程序手机号登录、身份查询。
- 使用 JWT 做认证,院内账号与 C 端小程序账号走两套守卫。 - 使用 JWT 做认证,院内账号与 C 端小程序账号走两套守卫。
## 2. 核心接口 ## 2. 核心接口
@ -14,6 +14,7 @@
- `POST /auth/miniapp/b/phone-login/confirm`B 端同手机号多账号确认登录 - `POST /auth/miniapp/b/phone-login/confirm`B 端同手机号多账号确认登录
- `POST /auth/miniapp/c/phone-login`C 端小程序手机号登录 - `POST /auth/miniapp/c/phone-login`C 端小程序手机号登录
- `GET /auth/me`:返回当前院内登录用户上下文 - `GET /auth/me`:返回当前院内登录用户上下文
- `GET /c/patients/me`:返回当前 C 端登录账号绑定的患者基础信息
## 3. 院内账号密码登录流程 ## 3. 院内账号密码登录流程
@ -72,4 +73,5 @@
- 若目标院内账号已绑定其他微信号,仍需先在用户管理中清空该账号的 `openId` 再重新绑定。 - 若目标院内账号已绑定其他微信号,仍需先在用户管理中清空该账号的 `openId` 再重新绑定。
- C 端账号独立存放在 `FamilyMiniAppAccount` - C 端账号独立存放在 `FamilyMiniAppAccount`
- C 端手机号必须唯一命中患者档案,否则拒绝登录。 - C 端手机号必须唯一命中患者档案,否则拒绝登录。
- C 端登录后读取“我的信息”请调用 `GET /c/patients/me`,不要复用 `GET /auth/me`
- `serviceUid` 仅预留字段,本次不提供绑定接口。 - `serviceUid` 仅预留字段,本次不提供绑定接口。

View File

@ -34,6 +34,8 @@
## 2. C 端生命周期 ## 2. C 端生命周期
- 登录成功后可先调用:`GET /c/patients/me`
- 返回当前 C 端账号信息与当前手机号唯一命中的患者基础档案
- 登录成功后调用:`GET /c/patients/my-lifecycle` - 登录成功后调用:`GET /c/patients/my-lifecycle`
- 不再需要传 `phone``idCard` - 不再需要传 `phone``idCard`
- Bearer Token 使用 C 端患者登录返回的 `accessToken` - Bearer Token 使用 C 端患者登录返回的 `accessToken`

View File

@ -14,16 +14,18 @@
## 3. C 端能力 ## 3. C 端能力
- 患者本人通过小程序手机号登录 - 患者本人通过小程序手机号登录
- `GET /c/patients/me`
- `GET /c/patients/my-lifecycle` - `GET /c/patients/my-lifecycle`
- 查询口径:按 `FamilyMiniAppAccount.phone` 唯一命中 `Patient.phone` - 查询口径:按 `FamilyMiniAppAccount.phone` 唯一命中 `Patient.phone`
- 返回内容:顶层患者信息 + 手术事件/调压事件时间线 - `me` 返回内容:当前 C 端账号信息 + 当前手机号命中的患者基础档案
- `my-lifecycle` 返回内容:顶层患者信息 + 手术事件/调压事件时间线
## 4. 当前规则 ## 4. 当前规则
- 同一个手机号在 C 端只允许命中 1 份患者档案。 - 同一个手机号在 C 端只允许命中 1 份患者档案。
- 若同一个手机号命中多份患者档案,登录阶段直接返回冲突错误。 - 若同一个手机号命中多份患者档案,登录阶段直接返回冲突错误。
- C 端手机号来源于患者手术/档案中维护的联系电话。 - C 端手机号来源于患者手术/档案中维护的联系电话。
- 仅已登录的 C 端小程序账号可访问 `my-lifecycle`。 - 仅已登录的 C 端小程序账号可访问 `me` 与 `my-lifecycle`。
- C 端登录账号不存在或 token 无效时返回 `401` - C 端登录账号不存在或 token 无效时返回 `401`
- 手机号下无患者档案时,登录阶段直接拦截,不进入生命周期查询。 - 手机号下无患者档案时,登录阶段直接拦截,不进入生命周期查询。
@ -32,4 +34,5 @@
- `GET /b/patients` - `GET /b/patients`
- `POST /b/patients` - `POST /b/patients`
- `POST /b/patients/:id/surgeries` - `POST /b/patients/:id/surgeries`
- `GET /c/patients/me`
- `GET /c/patients/my-lifecycle` - `GET /c/patients/my-lifecycle`

View File

@ -9,6 +9,7 @@ import {
import { CurrentFamilyActor } from '../../auth/current-family-actor.decorator.js'; import { CurrentFamilyActor } from '../../auth/current-family-actor.decorator.js';
import { FamilyAccessTokenGuard } from '../../auth/family-access/family-access.guard.js'; import { FamilyAccessTokenGuard } from '../../auth/family-access/family-access.guard.js';
import type { FamilyActorContext } from '../../common/family-actor-context.js'; import type { FamilyActorContext } from '../../common/family-actor-context.js';
import { CPatientMeResponseDto } from '../dto/c-patient-me-response.dto.js';
import { import {
CPatientLifecycleResponseDto, CPatientLifecycleResponseDto,
PATIENT_LIFECYCLE_SWAGGER_MODELS, PATIENT_LIFECYCLE_SWAGGER_MODELS,
@ -16,7 +17,7 @@ import {
import { CPatientsService } from './c-patients.service.js'; import { CPatientsService } from './c-patients.service.js';
/** /**
* C * C
*/ */
@ApiTags('患者管理(C端)') @ApiTags('患者管理(C端)')
@ApiBearerAuth('bearer') @ApiBearerAuth('bearer')
@ -25,6 +26,19 @@ import { CPatientsService } from './c-patients.service.js';
export class CPatientsController { export class CPatientsController {
constructor(private readonly patientsService: CPatientsService) {} constructor(private readonly patientsService: CPatientsService) {}
/**
*
*/
@Get('me')
@ApiOperation({ summary: '获取当前登录患者信息' })
@ApiOkResponse({
description: '返回当前 C 端登录账号绑定的患者档案与账号信息',
type: CPatientMeResponseDto,
})
me(@CurrentFamilyActor() actor: FamilyActorContext) {
return this.patientsService.getMeByAccount(actor.id);
}
/** /**
* *
*/ */

View File

@ -10,28 +10,76 @@ import {
PATIENT_LIFECYCLE_INCLUDE, PATIENT_LIFECYCLE_INCLUDE,
} from '../patient-lifecycle.util.js'; } 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() @Injectable()
export class CPatientsService { export class CPatientsService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async getFamilyLifecycleByAccount(accountId: number) { 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({ const account = await this.prisma.familyMiniAppAccount.findUnique({
where: { id: accountId }, where: { id: accountId },
select: { select: {
id: true, id: true,
phone: true, phone: true,
openId: true,
serviceUid: true,
lastLoginAt: true,
}, },
}); });
if (!account) { if (!account) {
throw new NotFoundException(MESSAGES.AUTH.FAMILY_ACCOUNT_NOT_FOUND); throw new NotFoundException(MESSAGES.AUTH.FAMILY_ACCOUNT_NOT_FOUND);
} }
return account;
}
private async findUniqueLifecyclePatientByPhone(phone: string) {
const patients = await this.prisma.patient.findMany({ const patients = await this.prisma.patient.findMany({
where: { where: { phone },
phone: account.phone,
},
include: PATIENT_LIFECYCLE_INCLUDE, include: PATIENT_LIFECYCLE_INCLUDE,
take: 2,
orderBy: { id: 'asc' },
}); });
if (patients.length === 0) { if (patients.length === 0) {
throw new NotFoundException(MESSAGES.PATIENT.LIFE_CYCLE_NOT_FOUND); throw new NotFoundException(MESSAGES.PATIENT.LIFE_CYCLE_NOT_FOUND);
} }
@ -41,11 +89,46 @@ export class CPatientsService {
); );
} }
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; const [patient] = patients;
return { return {
phone: account.phone, id: patient.id,
...buildPatientLifecyclePayload(patient), 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;
}