feat(auth): 支持同一微信 openId 绑定多个院内账号

feat(patients): 增强 B 端患者列表返回原发病/压力/手术日期字段
This commit is contained in:
EL 2026-03-24 16:51:37 +08:00
parent 19c08a7618
commit 7c4ba1e1a0
15 changed files with 278 additions and 94 deletions

View File

@ -21,6 +21,7 @@
2. 若仅匹配到 1 个院内账号,后端直接返回 JWT。
3. 若匹配到多个院内账号,后端返回 `loginTicket + accounts` 候选列表。
4. 前端带 `loginTicket + userId` 调用确认接口获取最终 JWT。
## 4. 微信小程序登录流程
### B 端
@ -67,7 +68,8 @@
- 密码登录命中多个院内账号时,不再强制前端手填 `hospitalId`,改为后端返回候选账号供选择。
- B 端小程序登录复用 `User` 表,继续使用 `openId`
- B 端账号未绑定 `openId` 时,首次小程序登录自动绑定。
- 同一个 `openId` 不能绑定多个院内账号。
- 同一个 `openId` 可绑定多个院内账号,支持同一微信号切换多角色/多院区账号。
- 若目标院内账号已绑定其他微信号,仍需先在用户管理中清空该账号的 `openId` 再重新绑定。
- C 端家属账号独立存放在 `FamilyMiniAppAccount`
- C 端手机号必须先存在于患者档案,否则拒绝登录。
- `serviceUid` 仅预留字段,本次不提供绑定接口。

View File

@ -43,3 +43,4 @@
- 后台管理端与小程序都可以复用 `POST /auth/login` 做账号密码登录
- `GET /auth/me` 仍可读取当前院内账号信息
- 同手机号多账号时,前端必须先让用户选定账号,再提交确认登录
- 同一个微信号可绑定多个院内账号,切换账号时继续走“小程序登录 -> 候选账号选择”即可

View File

@ -0,0 +1,4 @@
-- 允许同一个微信 openId 绑定多个院内账号,保留普通索引供查询复用。
DROP INDEX IF EXISTS "User_openId_key";
CREATE INDEX IF NOT EXISTS "User_openId_idx" ON "User"("openId");

View File

@ -55,7 +55,7 @@ enum UploadAssetType {
// 医院主表:多租户顶层实体。
model Hospital {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
name String
departments Department[]
users User[]
@ -88,6 +88,7 @@ model Group {
}
// 用户表:支持后台密码登录与小程序 openId。
// 同一个微信 openId 允许绑定多个院内账号,便于多角色/多院区切换。
model User {
id Int @id @default(autoincrement())
name String
@ -96,7 +97,7 @@ model User {
passwordHash String?
// 该时间点之前签发的 token 一律失效。
tokenValidAfter DateTime @default(now())
openId String? @unique
openId String?
role Role
hospitalId Int?
departmentId Int?
@ -114,6 +115,7 @@ model User {
@@unique([phone, role, hospitalId])
@@index([phone])
@@index([openId])
@@index([hospitalId, role])
@@index([departmentId, role])
@@index([groupId, role])
@ -274,18 +276,18 @@ model Device {
// 主任务表:记录调压任务主单。
model Task {
id Int @id @default(autoincrement())
status TaskStatus @default(PENDING)
creatorId Int
engineerId Int?
hospitalId Int
createdAt DateTime @default(now())
id Int @id @default(autoincrement())
status TaskStatus @default(PENDING)
creatorId Int
engineerId Int?
hospitalId Int
createdAt DateTime @default(now())
// 工程师完成任务时上传的图片/视频凭证。
completionMaterials Json?
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
engineer User? @relation("TaskEngineer", fields: [engineerId], references: [id])
hospital Hospital @relation(fields: [hospitalId], references: [id])
items TaskItem[]
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
engineer User? @relation("TaskEngineer", fields: [engineerId], references: [id])
hospital Hospital @relation(fields: [hospitalId], references: [id])
items TaskItem[]
@@index([hospitalId, status, createdAt])
}

View File

@ -46,18 +46,21 @@ async function ensureGroup(departmentId, name) {
);
}
async function upsertUserByOpenId(openId, data) {
async function upsertUserByScope(data) {
return prisma.user.upsert({
where: { openId },
where: {
phone_role_hospitalId: {
phone: data.phone,
role: data.role,
hospitalId: data.hospitalId,
},
},
// 每次重置/补种子时推进失效时间,确保历史 token 无法继续访问。
update: {
...data,
tokenValidAfter: new Date(),
},
create: {
...data,
openId,
},
create: data,
});
}
@ -320,113 +323,121 @@ async function main() {
const groupA2 = await ensureGroup(departmentA2.id, 'Shift-A2');
const groupB1 = await ensureGroup(departmentB1.id, 'Shift-B1');
const systemAdmin = await upsertUserByOpenId('seed-system-admin-openid', {
const systemAdmin = await upsertUserByScope({
name: 'Seed System Admin',
phone: '13800001000',
passwordHash: seedPasswordHash,
openId: 'seed-system-admin-openid',
role: Role.SYSTEM_ADMIN,
hospitalId: null,
departmentId: null,
groupId: null,
});
const hospitalAdminA = await upsertUserByOpenId(
'seed-hospital-admin-a-openid',
{
name: 'Seed Hospital Admin A',
phone: '13800001001',
passwordHash: seedPasswordHash,
role: Role.HOSPITAL_ADMIN,
hospitalId: hospitalA.id,
departmentId: null,
groupId: null,
},
);
const hospitalAdminA = await upsertUserByScope({
name: 'Seed Hospital Admin A',
phone: '13800001001',
passwordHash: seedPasswordHash,
openId: 'seed-hospital-admin-a-openid',
role: Role.HOSPITAL_ADMIN,
hospitalId: hospitalA.id,
departmentId: null,
groupId: null,
});
await upsertUserByOpenId('seed-hospital-admin-b-openid', {
await upsertUserByScope({
name: 'Seed Hospital Admin B',
phone: '13800001101',
passwordHash: seedPasswordHash,
openId: 'seed-hospital-admin-b-openid',
role: Role.HOSPITAL_ADMIN,
hospitalId: hospitalB.id,
departmentId: null,
groupId: null,
});
const directorA = await upsertUserByOpenId('seed-director-a-openid', {
const directorA = await upsertUserByScope({
name: 'Seed Director A',
phone: '13800001002',
passwordHash: seedPasswordHash,
openId: 'seed-director-a-openid',
role: Role.DIRECTOR,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: null,
});
const leaderA = await upsertUserByOpenId('seed-leader-a-openid', {
const leaderA = await upsertUserByScope({
name: 'Seed Leader A',
phone: '13800001003',
passwordHash: seedPasswordHash,
openId: 'seed-leader-a-openid',
role: Role.LEADER,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: groupA1.id,
});
const doctorA = await upsertUserByOpenId('seed-doctor-a-openid', {
const doctorA = await upsertUserByScope({
name: 'Seed Doctor A',
phone: '13800001004',
passwordHash: seedPasswordHash,
openId: 'seed-doctor-a-openid',
role: Role.DOCTOR,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: groupA1.id,
});
const doctorA2 = await upsertUserByOpenId('seed-doctor-a2-openid', {
const doctorA2 = await upsertUserByScope({
name: 'Seed Doctor A2',
phone: '13800001204',
passwordHash: seedPasswordHash,
openId: 'seed-doctor-a2-openid',
role: Role.DOCTOR,
hospitalId: hospitalA.id,
departmentId: departmentA1.id,
groupId: groupA1.id,
});
const doctorA3 = await upsertUserByOpenId('seed-doctor-a3-openid', {
const doctorA3 = await upsertUserByScope({
name: 'Seed Doctor A3',
phone: '13800001304',
passwordHash: seedPasswordHash,
openId: 'seed-doctor-a3-openid',
role: Role.DOCTOR,
hospitalId: hospitalA.id,
departmentId: departmentA2.id,
groupId: groupA2.id,
});
const doctorB = await upsertUserByOpenId('seed-doctor-b-openid', {
const doctorB = await upsertUserByScope({
name: 'Seed Doctor B',
phone: '13800001104',
passwordHash: seedPasswordHash,
openId: 'seed-doctor-b-openid',
role: Role.DOCTOR,
hospitalId: hospitalB.id,
departmentId: departmentB1.id,
groupId: groupB1.id,
});
const engineerA = await upsertUserByOpenId('seed-engineer-a-openid', {
const engineerA = await upsertUserByScope({
name: 'Seed Engineer A',
phone: '13800001005',
passwordHash: seedPasswordHash,
openId: 'seed-engineer-a-openid',
role: Role.ENGINEER,
hospitalId: hospitalA.id,
departmentId: null,
groupId: null,
});
const engineerB = await upsertUserByOpenId('seed-engineer-b-openid', {
const engineerB = await upsertUserByScope({
name: 'Seed Engineer B',
phone: '13800001105',
passwordHash: seedPasswordHash,
openId: 'seed-engineer-b-openid',
role: Role.ENGINEER,
hospitalId: hospitalB.id,
departmentId: null,

View File

@ -15,7 +15,7 @@ export class CreateSystemAdminDto {
password!: string;
@ApiPropertyOptional({
description: '可选微信 openId',
description: '可选微信 openId(院内账号间可复用)',
example: 'o123abcxyz',
})
@IsOptional()

View File

@ -36,7 +36,7 @@ export const MESSAGES = {
MINIAPP_NO_MATCHED_USER: '手机号未匹配到院内账号',
MINIAPP_LOGIN_TICKET_INVALID: '账号选择票据无效或已过期',
MINIAPP_ACCOUNT_SELECTION_INVALID: '请选择有效的候选账号',
MINIAPP_OPEN_ID_BOUND_OTHER_USER: '当前微信账号已绑定其他院内账号',
MINIAPP_OPEN_ID_BOUND_OTHER_USER: '当前院内账号已绑定其他微信账号',
MINIAPP_OPEN_ID_BOUND_OTHER_FAMILY: '当前微信账号已绑定其他家属账号',
FAMILY_PHONE_NOT_LINKED_PATIENT: '当前手机号未关联患者档案',
FAMILY_ACCOUNT_NOT_FOUND: '家属登录账号不存在,请重新登录',
@ -44,7 +44,6 @@ export const MESSAGES = {
USER: {
NOT_FOUND: '用户不存在',
DUPLICATE_OPEN_ID: 'openId 已被注册',
DUPLICATE_PHONE_ROLE_SCOPE: '同医院下该角色手机号已存在',
INVALID_ROLE: '角色不合法',
INVALID_PHONE: '手机号格式不合法',

View File

@ -40,6 +40,7 @@ const PATIENT_LIST_INCLUDE = {
id: true,
status: true,
currentPressure: true,
initialPressure: true,
isAbandoned: true,
implantModel: true,
implantManufacturer: true,
@ -54,6 +55,8 @@ const PATIENT_LIST_INCLUDE = {
id: true,
surgeryDate: true,
surgeryName: true,
primaryDisease: true,
hydrocephalusTypes: true,
surgeonId: true,
surgeonName: true,
},
@ -156,10 +159,25 @@ export class BPatientsService {
return patients.map((patient) => {
const { _count, surgeries, ...rest } = patient;
const latestSurgery = surgeries[0] ?? null;
const currentDevice =
patient.devices.find(
(device) =>
device.status === DeviceStatus.ACTIVE && !device.isAbandoned,
) ??
patient.devices.find((device) => !device.isAbandoned) ??
patient.devices[0] ??
null;
return {
...rest,
primaryDisease: latestSurgery?.primaryDisease ?? null,
hydrocephalusTypes: latestSurgery?.hydrocephalusTypes ?? [],
surgeryDate: latestSurgery?.surgeryDate ?? null,
currentPressure: currentDevice?.currentPressure ?? null,
initialPressure: currentDevice?.initialPressure ?? null,
shuntSurgeryCount: _count.surgeries,
latestSurgery: surgeries[0] ?? null,
latestSurgery,
activeDeviceCount: patient.devices.filter(
(device) =>
device.status === DeviceStatus.ACTIVE && !device.isAbandoned,

View File

@ -32,7 +32,7 @@ export class CreateUserDto {
password?: string;
@ApiPropertyOptional({
description: '微信 openId',
description: '微信 openId(院内账号间可复用)',
example: 'wx-open-id-demo',
})
@IsOptional()

View File

@ -35,7 +35,7 @@ export class RegisterUserDto {
role!: Role;
@ApiPropertyOptional({
description: '微信 openId可选',
description: '微信 openId可选,院内账号间可复用',
example: 'wx-open-id-demo',
})
@IsOptional()

View File

@ -0,0 +1,66 @@
import { ConflictException, NotFoundException } from '@nestjs/common';
import { jest } from '@jest/globals';
import { UsersService } from './users.service.js';
import type { PrismaService } from '../prisma.service.js';
describe('UsersService.bindOpenIdForMiniAppLogin', () => {
function createService() {
const prisma = {
user: {
findUnique: jest.fn(),
update: jest.fn(),
},
} as unknown as PrismaService;
return {
prisma,
service: new UsersService(prisma),
};
}
it('允许同一个 openId 绑定多个院内账号', async () => {
const { prisma, service } = createService();
const sharedOpenId = 'shared-openid';
(prisma.user.findUnique as jest.Mock)
.mockResolvedValueOnce({ id: 1, openId: null })
.mockResolvedValueOnce({ id: 2, openId: null });
(prisma.user.update as jest.Mock).mockResolvedValue(undefined);
await service.bindOpenIdForMiniAppLogin(1, sharedOpenId);
await service.bindOpenIdForMiniAppLogin(2, sharedOpenId);
expect(prisma.user.update).toHaveBeenNthCalledWith(1, {
where: { id: 1 },
data: { openId: sharedOpenId },
});
expect(prisma.user.update).toHaveBeenNthCalledWith(2, {
where: { id: 2 },
data: { openId: sharedOpenId },
});
});
it('拒绝用新的微信账号覆盖已绑定院内账号', async () => {
const { prisma, service } = createService();
(prisma.user.findUnique as jest.Mock).mockResolvedValue({
id: 1,
openId: 'bound-openid',
});
await expect(
service.bindOpenIdForMiniAppLogin(1, 'other-openid'),
).rejects.toThrow(new ConflictException('当前院内账号已绑定其他微信账号'));
expect(prisma.user.update).not.toHaveBeenCalled();
});
it('目标院内账号不存在时返回 404', async () => {
const { prisma, service } = createService();
(prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
await expect(
service.bindOpenIdForMiniAppLogin(999, 'shared-openid'),
).rejects.toThrow(new NotFoundException('用户不存在'));
});
});

View File

@ -29,6 +29,11 @@ const SAFE_USER_SELECT = {
hospitalId: true,
departmentId: true,
groupId: true,
hospital: {
select:{
name:true
}
}
} as const;
const DIRECTOR_VISIBLE_ROLES = [Role.LEADER, Role.DOCTOR] as const;
@ -63,7 +68,6 @@ export class UsersService {
Role.SYSTEM_ADMIN,
dto.systemAdminBootstrapKey,
);
await this.assertOpenIdUnique(openId);
await this.assertPhoneRoleScopeUnique(phone, Role.SYSTEM_ADMIN, null);
const passwordHash = await hash(password, 12);
@ -185,6 +189,7 @@ export class UsersService {
/**
* openId
*
*/
async bindOpenIdForMiniAppLogin(userId: number, openId: string) {
const normalizedOpenId = this.normalizeRequiredString(openId, 'openId');
@ -199,15 +204,9 @@ export class UsersService {
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
}
if (current.openId && current.openId !== normalizedOpenId) {
throw new ConflictException(MESSAGES.AUTH.MINIAPP_OPEN_ID_BOUND_OTHER_USER);
}
const existing = await this.prisma.user.findUnique({
where: { openId: normalizedOpenId },
select: { id: true },
});
if (existing && existing.id !== current.id) {
throw new ConflictException(MESSAGES.AUTH.MINIAPP_OPEN_ID_BOUND_OTHER_USER);
throw new ConflictException(
MESSAGES.AUTH.MINIAPP_OPEN_ID_BOUND_OTHER_USER,
);
}
if (!current.openId) {
@ -260,7 +259,6 @@ export class UsersService {
scoped.departmentId,
scoped.groupId,
);
await this.assertOpenIdUnique(openId);
await this.assertPhoneRoleScopeUnique(phone, role, scoped.hospitalId);
return this.prisma.user.create({
@ -393,7 +391,6 @@ export class UsersService {
updateUserDto.openId !== undefined
? this.normalizeOptionalString(updateUserDto.openId)
: current.openId;
await this.assertOpenIdUnique(nextOpenId, userId);
const nextPhone =
updateUserDto.phone !== undefined
? this.normalizePhone(updateUserDto.phone)
@ -525,19 +522,17 @@ export class UsersService {
/**
*
*/
private buildUserLoginResponse(
user: {
id: number;
name: string;
phone: string;
openId: string | null;
role: Role;
hospitalId: number | null;
departmentId: number | null;
groupId: number | null;
passwordHash?: string | null;
},
) {
private buildUserLoginResponse(user: {
id: number;
name: string;
phone: string;
openId: string | null;
role: Role;
hospitalId: number | null;
departmentId: number | null;
groupId: number | null;
passwordHash?: string | null;
}) {
const actor: ActorContext = {
id: user.id,
role: user.role,
@ -598,23 +593,6 @@ export class UsersService {
}
}
/**
* openId
*/
private async assertOpenIdUnique(openId: string | null, selfId?: number) {
if (!openId) {
return;
}
const exists = await this.prisma.user.findUnique({
where: { openId },
select: { id: true },
});
if (exists && exists.id !== selfId) {
throw new ConflictException(MESSAGES.USER.DUPLICATE_OPEN_ID);
}
}
/**
*
*/
@ -1027,7 +1005,9 @@ export class UsersService {
typeof payload !== 'object' ||
payload.purpose !== 'PASSWORD_LOGIN_TICKET' ||
!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.PASSWORD_LOGIN_TICKET_INVALID,

View File

@ -1095,7 +1095,7 @@ async function requireUserScope(
prisma: PrismaService,
openId: string,
): Promise<SeedUserScope> {
const user = await prisma.user.findUnique({
const user = await prisma.user.findFirst({
where: { openId },
select: {
id: true,

View File

@ -178,7 +178,9 @@ describe('AuthController (e2e)', () => {
const firstStage = await request(ctx.app.getHttpServer())
.post('/auth/miniapp/b/phone-login')
.send(buildMiniAppMockPayload(sharedPhone, uniqueSeedValue('multi-openid')));
.send(
buildMiniAppMockPayload(sharedPhone, uniqueSeedValue('multi-openid')),
);
expectSuccessEnvelope(firstStage, 201);
expect(firstStage.body.data.needSelect).toBe(true);
@ -195,10 +197,87 @@ describe('AuthController (e2e)', () => {
expect(confirmResponse.body.data.user.id).toBe(firstUser.body.data.id);
});
it('成功:同一微信 openId 可切换绑定多个院内账号', async () => {
const sharedPhone = uniquePhone();
const sharedOpenId = uniqueSeedValue('shared-openid');
const firstUser = await request(ctx.app.getHttpServer())
.post('/users')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({
name: uniqueSeedValue('MiniApp 切换账号医生'),
phone: sharedPhone,
role: Role.DOCTOR,
hospitalId: ctx.fixtures.hospitalAId,
departmentId: ctx.fixtures.departmentA1Id,
groupId: ctx.fixtures.groupA1Id,
});
expectSuccessEnvelope(firstUser, 201);
const secondUser = await request(ctx.app.getHttpServer())
.post('/users')
.set('Authorization', `Bearer ${ctx.tokens[Role.SYSTEM_ADMIN]}`)
.send({
name: uniqueSeedValue('MiniApp 切换账号工程师'),
phone: sharedPhone,
role: Role.ENGINEER,
hospitalId: ctx.fixtures.hospitalAId,
});
expectSuccessEnvelope(secondUser, 201);
const firstStageForDoctor = await request(ctx.app.getHttpServer())
.post('/auth/miniapp/b/phone-login')
.send(buildMiniAppMockPayload(sharedPhone, sharedOpenId));
expectSuccessEnvelope(firstStageForDoctor, 201);
const doctorConfirm = await request(ctx.app.getHttpServer())
.post('/auth/miniapp/b/phone-login/confirm')
.send({
loginTicket: firstStageForDoctor.body.data.loginTicket,
userId: firstUser.body.data.id,
});
expectSuccessEnvelope(doctorConfirm, 201);
const firstStageForEngineer = await request(ctx.app.getHttpServer())
.post('/auth/miniapp/b/phone-login')
.send(buildMiniAppMockPayload(sharedPhone, sharedOpenId));
expectSuccessEnvelope(firstStageForEngineer, 201);
const engineerConfirm = await request(ctx.app.getHttpServer())
.post('/auth/miniapp/b/phone-login/confirm')
.send({
loginTicket: firstStageForEngineer.body.data.loginTicket,
userId: secondUser.body.data.id,
});
expectSuccessEnvelope(engineerConfirm, 201);
const users = await ctx.prisma.user.findMany({
where: {
id: {
in: [firstUser.body.data.id, secondUser.body.data.id],
},
},
select: {
id: true,
openId: true,
},
orderBy: { id: 'asc' },
});
expect(users).toEqual([
{ id: firstUser.body.data.id, openId: sharedOpenId },
{ id: secondUser.body.data.id, openId: sharedOpenId },
]);
});
it('失败:手机号未匹配院内账号返回 404', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/auth/miniapp/b/phone-login')
.send(buildMiniAppMockPayload(uniquePhone(), uniqueSeedValue('no-user-openid')));
.send(
buildMiniAppMockPayload(
uniquePhone(),
uniqueSeedValue('no-user-openid'),
),
);
expectErrorEnvelope(response, 404, '手机号未匹配到院内账号');
});
@ -218,7 +297,12 @@ describe('AuthController (e2e)', () => {
it('失败:手机号未关联患者档案返回 404', async () => {
const response = await request(ctx.app.getHttpServer())
.post('/auth/miniapp/c/phone-login')
.send(buildMiniAppMockPayload(uniquePhone(), uniqueSeedValue('family-openid')));
.send(
buildMiniAppMockPayload(
uniquePhone(),
uniqueSeedValue('family-openid'),
),
);
expectErrorEnvelope(response, 404, '当前手机号未关联患者档案');
});

View File

@ -90,6 +90,23 @@ describe('Patients Controllers (e2e)', () => {
]),
);
const patientA1 = (
systemAdminResponse.body.data as Array<{
id: number;
primaryDisease: string | null;
hydrocephalusTypes: string[];
surgeryDate: string | null;
currentPressure: string | null;
initialPressure: string | null;
}>
).find((item) => item.id === ctx.fixtures.patients.patientA1Id);
expect(patientA1).toBeDefined();
expect(patientA1?.primaryDisease).toBeTruthy();
expect(Array.isArray(patientA1?.hydrocephalusTypes)).toBe(true);
expect(patientA1?.surgeryDate).toBeTruthy();
expect(patientA1?.currentPressure).toBeTruthy();
expect(patientA1?.initialPressure).toBeTruthy();
const hospitalAdminResponse = await request(ctx.app.getHttpServer())
.get('/b/patients')
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);