feat(auth): 支持同一微信 openId 绑定多个院内账号
feat(patients): 增强 B 端患者列表返回原发病/压力/手术日期字段
This commit is contained in:
parent
19c08a7618
commit
7c4ba1e1a0
@ -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` 仅预留字段,本次不提供绑定接口。
|
||||
|
||||
@ -43,3 +43,4 @@
|
||||
- 后台管理端与小程序都可以复用 `POST /auth/login` 做账号密码登录
|
||||
- `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");
|
||||
@ -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])
|
||||
|
||||
@ -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',
|
||||
{
|
||||
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,
|
||||
|
||||
@ -15,7 +15,7 @@ export class CreateSystemAdminDto {
|
||||
password!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '可选微信 openId',
|
||||
description: '可选微信 openId(院内账号间可复用)',
|
||||
example: 'o123abcxyz',
|
||||
})
|
||||
@IsOptional()
|
||||
|
||||
@ -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: '手机号格式不合法',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -32,7 +32,7 @@ export class CreateUserDto {
|
||||
password?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '微信 openId',
|
||||
description: '微信 openId(院内账号间可复用)',
|
||||
example: 'wx-open-id-demo',
|
||||
})
|
||||
@IsOptional()
|
||||
|
||||
@ -35,7 +35,7 @@ export class RegisterUserDto {
|
||||
role!: Role;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '微信 openId(可选)',
|
||||
description: '微信 openId(可选,院内账号间可复用)',
|
||||
example: 'wx-open-id-demo',
|
||||
})
|
||||
@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,
|
||||
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,8 +522,7 @@ export class UsersService {
|
||||
/**
|
||||
* 统一构造院内账号登录响应。
|
||||
*/
|
||||
private buildUserLoginResponse(
|
||||
user: {
|
||||
private buildUserLoginResponse(user: {
|
||||
id: number;
|
||||
name: string;
|
||||
phone: string;
|
||||
@ -536,8 +532,7 @@ export class UsersService {
|
||||
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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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, '当前手机号未关联患者档案');
|
||||
});
|
||||
|
||||
@ -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]}`);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user