feat(auth): 支持同一微信 openId 绑定多个院内账号
feat(patients): 增强 B 端患者列表返回原发病/压力/手术日期字段
This commit is contained in:
parent
19c08a7618
commit
7c4ba1e1a0
@ -21,6 +21,7 @@
|
|||||||
2. 若仅匹配到 1 个院内账号,后端直接返回 JWT。
|
2. 若仅匹配到 1 个院内账号,后端直接返回 JWT。
|
||||||
3. 若匹配到多个院内账号,后端返回 `loginTicket + accounts` 候选列表。
|
3. 若匹配到多个院内账号,后端返回 `loginTicket + accounts` 候选列表。
|
||||||
4. 前端带 `loginTicket + userId` 调用确认接口获取最终 JWT。
|
4. 前端带 `loginTicket + userId` 调用确认接口获取最终 JWT。
|
||||||
|
|
||||||
## 4. 微信小程序登录流程
|
## 4. 微信小程序登录流程
|
||||||
|
|
||||||
### B 端
|
### B 端
|
||||||
@ -67,7 +68,8 @@
|
|||||||
- 密码登录命中多个院内账号时,不再强制前端手填 `hospitalId`,改为后端返回候选账号供选择。
|
- 密码登录命中多个院内账号时,不再强制前端手填 `hospitalId`,改为后端返回候选账号供选择。
|
||||||
- B 端小程序登录复用 `User` 表,继续使用 `openId`。
|
- B 端小程序登录复用 `User` 表,继续使用 `openId`。
|
||||||
- B 端账号未绑定 `openId` 时,首次小程序登录自动绑定。
|
- B 端账号未绑定 `openId` 时,首次小程序登录自动绑定。
|
||||||
- 同一个 `openId` 不能绑定多个院内账号。
|
- 同一个 `openId` 可绑定多个院内账号,支持同一微信号切换多角色/多院区账号。
|
||||||
|
- 若目标院内账号已绑定其他微信号,仍需先在用户管理中清空该账号的 `openId` 再重新绑定。
|
||||||
- C 端家属账号独立存放在 `FamilyMiniAppAccount`。
|
- C 端家属账号独立存放在 `FamilyMiniAppAccount`。
|
||||||
- C 端手机号必须先存在于患者档案,否则拒绝登录。
|
- C 端手机号必须先存在于患者档案,否则拒绝登录。
|
||||||
- `serviceUid` 仅预留字段,本次不提供绑定接口。
|
- `serviceUid` 仅预留字段,本次不提供绑定接口。
|
||||||
|
|||||||
@ -43,3 +43,4 @@
|
|||||||
- 后台管理端与小程序都可以复用 `POST /auth/login` 做账号密码登录
|
- 后台管理端与小程序都可以复用 `POST /auth/login` 做账号密码登录
|
||||||
- `GET /auth/me` 仍可读取当前院内账号信息
|
- `GET /auth/me` 仍可读取当前院内账号信息
|
||||||
- 同手机号多账号时,前端必须先让用户选定账号,再提交确认登录
|
- 同手机号多账号时,前端必须先让用户选定账号,再提交确认登录
|
||||||
|
- 同一个微信号可绑定多个院内账号,切换账号时继续走“小程序登录 -> 候选账号选择”即可
|
||||||
|
|||||||
@ -0,0 +1,4 @@
|
|||||||
|
-- 允许同一个微信 openId 绑定多个院内账号,保留普通索引供查询复用。
|
||||||
|
DROP INDEX IF EXISTS "User_openId_key";
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "User_openId_idx" ON "User"("openId");
|
||||||
@ -55,7 +55,7 @@ enum UploadAssetType {
|
|||||||
|
|
||||||
// 医院主表:多租户顶层实体。
|
// 医院主表:多租户顶层实体。
|
||||||
model Hospital {
|
model Hospital {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
departments Department[]
|
departments Department[]
|
||||||
users User[]
|
users User[]
|
||||||
@ -88,6 +88,7 @@ model Group {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 用户表:支持后台密码登录与小程序 openId。
|
// 用户表:支持后台密码登录与小程序 openId。
|
||||||
|
// 同一个微信 openId 允许绑定多个院内账号,便于多角色/多院区切换。
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String
|
name String
|
||||||
@ -96,7 +97,7 @@ model User {
|
|||||||
passwordHash String?
|
passwordHash String?
|
||||||
// 该时间点之前签发的 token 一律失效。
|
// 该时间点之前签发的 token 一律失效。
|
||||||
tokenValidAfter DateTime @default(now())
|
tokenValidAfter DateTime @default(now())
|
||||||
openId String? @unique
|
openId String?
|
||||||
role Role
|
role Role
|
||||||
hospitalId Int?
|
hospitalId Int?
|
||||||
departmentId Int?
|
departmentId Int?
|
||||||
@ -114,6 +115,7 @@ model User {
|
|||||||
|
|
||||||
@@unique([phone, role, hospitalId])
|
@@unique([phone, role, hospitalId])
|
||||||
@@index([phone])
|
@@index([phone])
|
||||||
|
@@index([openId])
|
||||||
@@index([hospitalId, role])
|
@@index([hospitalId, role])
|
||||||
@@index([departmentId, role])
|
@@index([departmentId, role])
|
||||||
@@index([groupId, role])
|
@@index([groupId, role])
|
||||||
@ -274,18 +276,18 @@ model Device {
|
|||||||
|
|
||||||
// 主任务表:记录调压任务主单。
|
// 主任务表:记录调压任务主单。
|
||||||
model Task {
|
model Task {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
status TaskStatus @default(PENDING)
|
status TaskStatus @default(PENDING)
|
||||||
creatorId Int
|
creatorId Int
|
||||||
engineerId Int?
|
engineerId Int?
|
||||||
hospitalId Int
|
hospitalId Int
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
// 工程师完成任务时上传的图片/视频凭证。
|
// 工程师完成任务时上传的图片/视频凭证。
|
||||||
completionMaterials Json?
|
completionMaterials Json?
|
||||||
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
|
creator User @relation("TaskCreator", fields: [creatorId], references: [id])
|
||||||
engineer User? @relation("TaskEngineer", fields: [engineerId], references: [id])
|
engineer User? @relation("TaskEngineer", fields: [engineerId], references: [id])
|
||||||
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
hospital Hospital @relation(fields: [hospitalId], references: [id])
|
||||||
items TaskItem[]
|
items TaskItem[]
|
||||||
|
|
||||||
@@index([hospitalId, status, createdAt])
|
@@index([hospitalId, status, createdAt])
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,18 +46,21 @@ async function ensureGroup(departmentId, name) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upsertUserByOpenId(openId, data) {
|
async function upsertUserByScope(data) {
|
||||||
return prisma.user.upsert({
|
return prisma.user.upsert({
|
||||||
where: { openId },
|
where: {
|
||||||
|
phone_role_hospitalId: {
|
||||||
|
phone: data.phone,
|
||||||
|
role: data.role,
|
||||||
|
hospitalId: data.hospitalId,
|
||||||
|
},
|
||||||
|
},
|
||||||
// 每次重置/补种子时推进失效时间,确保历史 token 无法继续访问。
|
// 每次重置/补种子时推进失效时间,确保历史 token 无法继续访问。
|
||||||
update: {
|
update: {
|
||||||
...data,
|
...data,
|
||||||
tokenValidAfter: new Date(),
|
tokenValidAfter: new Date(),
|
||||||
},
|
},
|
||||||
create: {
|
create: data,
|
||||||
...data,
|
|
||||||
openId,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -320,113 +323,121 @@ async function main() {
|
|||||||
const groupA2 = await ensureGroup(departmentA2.id, 'Shift-A2');
|
const groupA2 = await ensureGroup(departmentA2.id, 'Shift-A2');
|
||||||
const groupB1 = await ensureGroup(departmentB1.id, 'Shift-B1');
|
const groupB1 = await ensureGroup(departmentB1.id, 'Shift-B1');
|
||||||
|
|
||||||
const systemAdmin = await upsertUserByOpenId('seed-system-admin-openid', {
|
const systemAdmin = await upsertUserByScope({
|
||||||
name: 'Seed System Admin',
|
name: 'Seed System Admin',
|
||||||
phone: '13800001000',
|
phone: '13800001000',
|
||||||
passwordHash: seedPasswordHash,
|
passwordHash: seedPasswordHash,
|
||||||
|
openId: 'seed-system-admin-openid',
|
||||||
role: Role.SYSTEM_ADMIN,
|
role: Role.SYSTEM_ADMIN,
|
||||||
hospitalId: null,
|
hospitalId: null,
|
||||||
departmentId: null,
|
departmentId: null,
|
||||||
groupId: null,
|
groupId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const hospitalAdminA = await upsertUserByOpenId(
|
const hospitalAdminA = await upsertUserByScope({
|
||||||
'seed-hospital-admin-a-openid',
|
name: 'Seed Hospital Admin A',
|
||||||
{
|
phone: '13800001001',
|
||||||
name: 'Seed Hospital Admin A',
|
passwordHash: seedPasswordHash,
|
||||||
phone: '13800001001',
|
openId: 'seed-hospital-admin-a-openid',
|
||||||
passwordHash: seedPasswordHash,
|
role: Role.HOSPITAL_ADMIN,
|
||||||
role: Role.HOSPITAL_ADMIN,
|
hospitalId: hospitalA.id,
|
||||||
hospitalId: hospitalA.id,
|
departmentId: null,
|
||||||
departmentId: null,
|
groupId: null,
|
||||||
groupId: null,
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
await upsertUserByOpenId('seed-hospital-admin-b-openid', {
|
await upsertUserByScope({
|
||||||
name: 'Seed Hospital Admin B',
|
name: 'Seed Hospital Admin B',
|
||||||
phone: '13800001101',
|
phone: '13800001101',
|
||||||
passwordHash: seedPasswordHash,
|
passwordHash: seedPasswordHash,
|
||||||
|
openId: 'seed-hospital-admin-b-openid',
|
||||||
role: Role.HOSPITAL_ADMIN,
|
role: Role.HOSPITAL_ADMIN,
|
||||||
hospitalId: hospitalB.id,
|
hospitalId: hospitalB.id,
|
||||||
departmentId: null,
|
departmentId: null,
|
||||||
groupId: null,
|
groupId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const directorA = await upsertUserByOpenId('seed-director-a-openid', {
|
const directorA = await upsertUserByScope({
|
||||||
name: 'Seed Director A',
|
name: 'Seed Director A',
|
||||||
phone: '13800001002',
|
phone: '13800001002',
|
||||||
passwordHash: seedPasswordHash,
|
passwordHash: seedPasswordHash,
|
||||||
|
openId: 'seed-director-a-openid',
|
||||||
role: Role.DIRECTOR,
|
role: Role.DIRECTOR,
|
||||||
hospitalId: hospitalA.id,
|
hospitalId: hospitalA.id,
|
||||||
departmentId: departmentA1.id,
|
departmentId: departmentA1.id,
|
||||||
groupId: null,
|
groupId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const leaderA = await upsertUserByOpenId('seed-leader-a-openid', {
|
const leaderA = await upsertUserByScope({
|
||||||
name: 'Seed Leader A',
|
name: 'Seed Leader A',
|
||||||
phone: '13800001003',
|
phone: '13800001003',
|
||||||
passwordHash: seedPasswordHash,
|
passwordHash: seedPasswordHash,
|
||||||
|
openId: 'seed-leader-a-openid',
|
||||||
role: Role.LEADER,
|
role: Role.LEADER,
|
||||||
hospitalId: hospitalA.id,
|
hospitalId: hospitalA.id,
|
||||||
departmentId: departmentA1.id,
|
departmentId: departmentA1.id,
|
||||||
groupId: groupA1.id,
|
groupId: groupA1.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const doctorA = await upsertUserByOpenId('seed-doctor-a-openid', {
|
const doctorA = await upsertUserByScope({
|
||||||
name: 'Seed Doctor A',
|
name: 'Seed Doctor A',
|
||||||
phone: '13800001004',
|
phone: '13800001004',
|
||||||
passwordHash: seedPasswordHash,
|
passwordHash: seedPasswordHash,
|
||||||
|
openId: 'seed-doctor-a-openid',
|
||||||
role: Role.DOCTOR,
|
role: Role.DOCTOR,
|
||||||
hospitalId: hospitalA.id,
|
hospitalId: hospitalA.id,
|
||||||
departmentId: departmentA1.id,
|
departmentId: departmentA1.id,
|
||||||
groupId: groupA1.id,
|
groupId: groupA1.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const doctorA2 = await upsertUserByOpenId('seed-doctor-a2-openid', {
|
const doctorA2 = await upsertUserByScope({
|
||||||
name: 'Seed Doctor A2',
|
name: 'Seed Doctor A2',
|
||||||
phone: '13800001204',
|
phone: '13800001204',
|
||||||
passwordHash: seedPasswordHash,
|
passwordHash: seedPasswordHash,
|
||||||
|
openId: 'seed-doctor-a2-openid',
|
||||||
role: Role.DOCTOR,
|
role: Role.DOCTOR,
|
||||||
hospitalId: hospitalA.id,
|
hospitalId: hospitalA.id,
|
||||||
departmentId: departmentA1.id,
|
departmentId: departmentA1.id,
|
||||||
groupId: groupA1.id,
|
groupId: groupA1.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const doctorA3 = await upsertUserByOpenId('seed-doctor-a3-openid', {
|
const doctorA3 = await upsertUserByScope({
|
||||||
name: 'Seed Doctor A3',
|
name: 'Seed Doctor A3',
|
||||||
phone: '13800001304',
|
phone: '13800001304',
|
||||||
passwordHash: seedPasswordHash,
|
passwordHash: seedPasswordHash,
|
||||||
|
openId: 'seed-doctor-a3-openid',
|
||||||
role: Role.DOCTOR,
|
role: Role.DOCTOR,
|
||||||
hospitalId: hospitalA.id,
|
hospitalId: hospitalA.id,
|
||||||
departmentId: departmentA2.id,
|
departmentId: departmentA2.id,
|
||||||
groupId: groupA2.id,
|
groupId: groupA2.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const doctorB = await upsertUserByOpenId('seed-doctor-b-openid', {
|
const doctorB = await upsertUserByScope({
|
||||||
name: 'Seed Doctor B',
|
name: 'Seed Doctor B',
|
||||||
phone: '13800001104',
|
phone: '13800001104',
|
||||||
passwordHash: seedPasswordHash,
|
passwordHash: seedPasswordHash,
|
||||||
|
openId: 'seed-doctor-b-openid',
|
||||||
role: Role.DOCTOR,
|
role: Role.DOCTOR,
|
||||||
hospitalId: hospitalB.id,
|
hospitalId: hospitalB.id,
|
||||||
departmentId: departmentB1.id,
|
departmentId: departmentB1.id,
|
||||||
groupId: groupB1.id,
|
groupId: groupB1.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const engineerA = await upsertUserByOpenId('seed-engineer-a-openid', {
|
const engineerA = await upsertUserByScope({
|
||||||
name: 'Seed Engineer A',
|
name: 'Seed Engineer A',
|
||||||
phone: '13800001005',
|
phone: '13800001005',
|
||||||
passwordHash: seedPasswordHash,
|
passwordHash: seedPasswordHash,
|
||||||
|
openId: 'seed-engineer-a-openid',
|
||||||
role: Role.ENGINEER,
|
role: Role.ENGINEER,
|
||||||
hospitalId: hospitalA.id,
|
hospitalId: hospitalA.id,
|
||||||
departmentId: null,
|
departmentId: null,
|
||||||
groupId: null,
|
groupId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const engineerB = await upsertUserByOpenId('seed-engineer-b-openid', {
|
const engineerB = await upsertUserByScope({
|
||||||
name: 'Seed Engineer B',
|
name: 'Seed Engineer B',
|
||||||
phone: '13800001105',
|
phone: '13800001105',
|
||||||
passwordHash: seedPasswordHash,
|
passwordHash: seedPasswordHash,
|
||||||
|
openId: 'seed-engineer-b-openid',
|
||||||
role: Role.ENGINEER,
|
role: Role.ENGINEER,
|
||||||
hospitalId: hospitalB.id,
|
hospitalId: hospitalB.id,
|
||||||
departmentId: null,
|
departmentId: null,
|
||||||
|
|||||||
@ -15,7 +15,7 @@ export class CreateSystemAdminDto {
|
|||||||
password!: string;
|
password!: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: '可选微信 openId',
|
description: '可选微信 openId(院内账号间可复用)',
|
||||||
example: 'o123abcxyz',
|
example: 'o123abcxyz',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@ -36,7 +36,7 @@ export const MESSAGES = {
|
|||||||
MINIAPP_NO_MATCHED_USER: '手机号未匹配到院内账号',
|
MINIAPP_NO_MATCHED_USER: '手机号未匹配到院内账号',
|
||||||
MINIAPP_LOGIN_TICKET_INVALID: '账号选择票据无效或已过期',
|
MINIAPP_LOGIN_TICKET_INVALID: '账号选择票据无效或已过期',
|
||||||
MINIAPP_ACCOUNT_SELECTION_INVALID: '请选择有效的候选账号',
|
MINIAPP_ACCOUNT_SELECTION_INVALID: '请选择有效的候选账号',
|
||||||
MINIAPP_OPEN_ID_BOUND_OTHER_USER: '当前微信账号已绑定其他院内账号',
|
MINIAPP_OPEN_ID_BOUND_OTHER_USER: '当前院内账号已绑定其他微信账号',
|
||||||
MINIAPP_OPEN_ID_BOUND_OTHER_FAMILY: '当前微信账号已绑定其他家属账号',
|
MINIAPP_OPEN_ID_BOUND_OTHER_FAMILY: '当前微信账号已绑定其他家属账号',
|
||||||
FAMILY_PHONE_NOT_LINKED_PATIENT: '当前手机号未关联患者档案',
|
FAMILY_PHONE_NOT_LINKED_PATIENT: '当前手机号未关联患者档案',
|
||||||
FAMILY_ACCOUNT_NOT_FOUND: '家属登录账号不存在,请重新登录',
|
FAMILY_ACCOUNT_NOT_FOUND: '家属登录账号不存在,请重新登录',
|
||||||
@ -44,7 +44,6 @@ export const MESSAGES = {
|
|||||||
|
|
||||||
USER: {
|
USER: {
|
||||||
NOT_FOUND: '用户不存在',
|
NOT_FOUND: '用户不存在',
|
||||||
DUPLICATE_OPEN_ID: 'openId 已被注册',
|
|
||||||
DUPLICATE_PHONE_ROLE_SCOPE: '同医院下该角色手机号已存在',
|
DUPLICATE_PHONE_ROLE_SCOPE: '同医院下该角色手机号已存在',
|
||||||
INVALID_ROLE: '角色不合法',
|
INVALID_ROLE: '角色不合法',
|
||||||
INVALID_PHONE: '手机号格式不合法',
|
INVALID_PHONE: '手机号格式不合法',
|
||||||
|
|||||||
@ -40,6 +40,7 @@ const PATIENT_LIST_INCLUDE = {
|
|||||||
id: true,
|
id: true,
|
||||||
status: true,
|
status: true,
|
||||||
currentPressure: true,
|
currentPressure: true,
|
||||||
|
initialPressure: true,
|
||||||
isAbandoned: true,
|
isAbandoned: true,
|
||||||
implantModel: true,
|
implantModel: true,
|
||||||
implantManufacturer: true,
|
implantManufacturer: true,
|
||||||
@ -54,6 +55,8 @@ const PATIENT_LIST_INCLUDE = {
|
|||||||
id: true,
|
id: true,
|
||||||
surgeryDate: true,
|
surgeryDate: true,
|
||||||
surgeryName: true,
|
surgeryName: true,
|
||||||
|
primaryDisease: true,
|
||||||
|
hydrocephalusTypes: true,
|
||||||
surgeonId: true,
|
surgeonId: true,
|
||||||
surgeonName: true,
|
surgeonName: true,
|
||||||
},
|
},
|
||||||
@ -156,10 +159,25 @@ export class BPatientsService {
|
|||||||
|
|
||||||
return patients.map((patient) => {
|
return patients.map((patient) => {
|
||||||
const { _count, surgeries, ...rest } = 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 {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
|
primaryDisease: latestSurgery?.primaryDisease ?? null,
|
||||||
|
hydrocephalusTypes: latestSurgery?.hydrocephalusTypes ?? [],
|
||||||
|
surgeryDate: latestSurgery?.surgeryDate ?? null,
|
||||||
|
currentPressure: currentDevice?.currentPressure ?? null,
|
||||||
|
initialPressure: currentDevice?.initialPressure ?? null,
|
||||||
shuntSurgeryCount: _count.surgeries,
|
shuntSurgeryCount: _count.surgeries,
|
||||||
latestSurgery: surgeries[0] ?? null,
|
latestSurgery,
|
||||||
activeDeviceCount: patient.devices.filter(
|
activeDeviceCount: patient.devices.filter(
|
||||||
(device) =>
|
(device) =>
|
||||||
device.status === DeviceStatus.ACTIVE && !device.isAbandoned,
|
device.status === DeviceStatus.ACTIVE && !device.isAbandoned,
|
||||||
|
|||||||
@ -32,7 +32,7 @@ export class CreateUserDto {
|
|||||||
password?: string;
|
password?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: '微信 openId',
|
description: '微信 openId(院内账号间可复用)',
|
||||||
example: 'wx-open-id-demo',
|
example: 'wx-open-id-demo',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@ -35,7 +35,7 @@ export class RegisterUserDto {
|
|||||||
role!: Role;
|
role!: Role;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: '微信 openId(可选)',
|
description: '微信 openId(可选,院内账号间可复用)',
|
||||||
example: 'wx-open-id-demo',
|
example: 'wx-open-id-demo',
|
||||||
})
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
66
src/users/users.service.spec.ts
Normal file
66
src/users/users.service.spec.ts
Normal 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('用户不存在'));
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -29,6 +29,11 @@ const SAFE_USER_SELECT = {
|
|||||||
hospitalId: true,
|
hospitalId: true,
|
||||||
departmentId: true,
|
departmentId: true,
|
||||||
groupId: true,
|
groupId: true,
|
||||||
|
hospital: {
|
||||||
|
select:{
|
||||||
|
name:true
|
||||||
|
}
|
||||||
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const DIRECTOR_VISIBLE_ROLES = [Role.LEADER, Role.DOCTOR] as const;
|
const DIRECTOR_VISIBLE_ROLES = [Role.LEADER, Role.DOCTOR] as const;
|
||||||
@ -63,7 +68,6 @@ export class UsersService {
|
|||||||
Role.SYSTEM_ADMIN,
|
Role.SYSTEM_ADMIN,
|
||||||
dto.systemAdminBootstrapKey,
|
dto.systemAdminBootstrapKey,
|
||||||
);
|
);
|
||||||
await this.assertOpenIdUnique(openId);
|
|
||||||
await this.assertPhoneRoleScopeUnique(phone, Role.SYSTEM_ADMIN, null);
|
await this.assertPhoneRoleScopeUnique(phone, Role.SYSTEM_ADMIN, null);
|
||||||
|
|
||||||
const passwordHash = await hash(password, 12);
|
const passwordHash = await hash(password, 12);
|
||||||
@ -185,6 +189,7 @@ export class UsersService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 小程序登录时绑定 openId。
|
* 小程序登录时绑定 openId。
|
||||||
|
* 同一个微信账号允许绑定多个院内账号,但单个院内账号仅允许绑定一个微信账号。
|
||||||
*/
|
*/
|
||||||
async bindOpenIdForMiniAppLogin(userId: number, openId: string) {
|
async bindOpenIdForMiniAppLogin(userId: number, openId: string) {
|
||||||
const normalizedOpenId = this.normalizeRequiredString(openId, 'openId');
|
const normalizedOpenId = this.normalizeRequiredString(openId, 'openId');
|
||||||
@ -199,15 +204,9 @@ export class UsersService {
|
|||||||
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
|
throw new NotFoundException(MESSAGES.USER.NOT_FOUND);
|
||||||
}
|
}
|
||||||
if (current.openId && current.openId !== normalizedOpenId) {
|
if (current.openId && current.openId !== normalizedOpenId) {
|
||||||
throw new ConflictException(MESSAGES.AUTH.MINIAPP_OPEN_ID_BOUND_OTHER_USER);
|
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!current.openId) {
|
if (!current.openId) {
|
||||||
@ -260,7 +259,6 @@ export class UsersService {
|
|||||||
scoped.departmentId,
|
scoped.departmentId,
|
||||||
scoped.groupId,
|
scoped.groupId,
|
||||||
);
|
);
|
||||||
await this.assertOpenIdUnique(openId);
|
|
||||||
await this.assertPhoneRoleScopeUnique(phone, role, scoped.hospitalId);
|
await this.assertPhoneRoleScopeUnique(phone, role, scoped.hospitalId);
|
||||||
|
|
||||||
return this.prisma.user.create({
|
return this.prisma.user.create({
|
||||||
@ -393,7 +391,6 @@ export class UsersService {
|
|||||||
updateUserDto.openId !== undefined
|
updateUserDto.openId !== undefined
|
||||||
? this.normalizeOptionalString(updateUserDto.openId)
|
? this.normalizeOptionalString(updateUserDto.openId)
|
||||||
: current.openId;
|
: current.openId;
|
||||||
await this.assertOpenIdUnique(nextOpenId, userId);
|
|
||||||
const nextPhone =
|
const nextPhone =
|
||||||
updateUserDto.phone !== undefined
|
updateUserDto.phone !== undefined
|
||||||
? this.normalizePhone(updateUserDto.phone)
|
? this.normalizePhone(updateUserDto.phone)
|
||||||
@ -525,19 +522,17 @@ export class UsersService {
|
|||||||
/**
|
/**
|
||||||
* 统一构造院内账号登录响应。
|
* 统一构造院内账号登录响应。
|
||||||
*/
|
*/
|
||||||
private buildUserLoginResponse(
|
private buildUserLoginResponse(user: {
|
||||||
user: {
|
id: number;
|
||||||
id: number;
|
name: string;
|
||||||
name: string;
|
phone: string;
|
||||||
phone: string;
|
openId: string | null;
|
||||||
openId: string | null;
|
role: Role;
|
||||||
role: Role;
|
hospitalId: number | null;
|
||||||
hospitalId: number | null;
|
departmentId: number | null;
|
||||||
departmentId: number | null;
|
groupId: number | null;
|
||||||
groupId: number | null;
|
passwordHash?: string | null;
|
||||||
passwordHash?: string | null;
|
}) {
|
||||||
},
|
|
||||||
) {
|
|
||||||
const actor: ActorContext = {
|
const actor: ActorContext = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
role: user.role,
|
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' ||
|
typeof payload !== 'object' ||
|
||||||
payload.purpose !== 'PASSWORD_LOGIN_TICKET' ||
|
payload.purpose !== 'PASSWORD_LOGIN_TICKET' ||
|
||||||
!Array.isArray(payload.userIds) ||
|
!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(
|
throw new UnauthorizedException(
|
||||||
MESSAGES.AUTH.PASSWORD_LOGIN_TICKET_INVALID,
|
MESSAGES.AUTH.PASSWORD_LOGIN_TICKET_INVALID,
|
||||||
|
|||||||
@ -1095,7 +1095,7 @@ async function requireUserScope(
|
|||||||
prisma: PrismaService,
|
prisma: PrismaService,
|
||||||
openId: string,
|
openId: string,
|
||||||
): Promise<SeedUserScope> {
|
): Promise<SeedUserScope> {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findFirst({
|
||||||
where: { openId },
|
where: { openId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|||||||
@ -178,7 +178,9 @@ describe('AuthController (e2e)', () => {
|
|||||||
|
|
||||||
const firstStage = await request(ctx.app.getHttpServer())
|
const firstStage = await request(ctx.app.getHttpServer())
|
||||||
.post('/auth/miniapp/b/phone-login')
|
.post('/auth/miniapp/b/phone-login')
|
||||||
.send(buildMiniAppMockPayload(sharedPhone, uniqueSeedValue('multi-openid')));
|
.send(
|
||||||
|
buildMiniAppMockPayload(sharedPhone, uniqueSeedValue('multi-openid')),
|
||||||
|
);
|
||||||
|
|
||||||
expectSuccessEnvelope(firstStage, 201);
|
expectSuccessEnvelope(firstStage, 201);
|
||||||
expect(firstStage.body.data.needSelect).toBe(true);
|
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);
|
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 () => {
|
it('失败:手机号未匹配院内账号返回 404', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.post('/auth/miniapp/b/phone-login')
|
.post('/auth/miniapp/b/phone-login')
|
||||||
.send(buildMiniAppMockPayload(uniquePhone(), uniqueSeedValue('no-user-openid')));
|
.send(
|
||||||
|
buildMiniAppMockPayload(
|
||||||
|
uniquePhone(),
|
||||||
|
uniqueSeedValue('no-user-openid'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
expectErrorEnvelope(response, 404, '手机号未匹配到院内账号');
|
expectErrorEnvelope(response, 404, '手机号未匹配到院内账号');
|
||||||
});
|
});
|
||||||
@ -218,7 +297,12 @@ describe('AuthController (e2e)', () => {
|
|||||||
it('失败:手机号未关联患者档案返回 404', async () => {
|
it('失败:手机号未关联患者档案返回 404', async () => {
|
||||||
const response = await request(ctx.app.getHttpServer())
|
const response = await request(ctx.app.getHttpServer())
|
||||||
.post('/auth/miniapp/c/phone-login')
|
.post('/auth/miniapp/c/phone-login')
|
||||||
.send(buildMiniAppMockPayload(uniquePhone(), uniqueSeedValue('family-openid')));
|
.send(
|
||||||
|
buildMiniAppMockPayload(
|
||||||
|
uniquePhone(),
|
||||||
|
uniqueSeedValue('family-openid'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
expectErrorEnvelope(response, 404, '当前手机号未关联患者档案');
|
expectErrorEnvelope(response, 404, '当前手机号未关联患者档案');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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())
|
const hospitalAdminResponse = await request(ctx.app.getHttpServer())
|
||||||
.get('/b/patients')
|
.get('/b/patients')
|
||||||
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
|
.set('Authorization', `Bearer ${ctx.tokens[Role.HOSPITAL_ADMIN]}`);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user