C端 my-lifecycle 由跨院多患者聚合改为单患者返回,结构统一为 patient + lifecycle 生命周期事件移除事件内重复 patient 字段,减少冗余 B端患者生命周期接口同步采用 patient + lifecycle 结构 新增并接入生命周期 Swagger 响应模型,补齐接口文档 更新 auth/patients/frontend 集成文档说明 增加 e2e:多患者冲突、C端/B端新返回结构、权限失败场景
278 lines
7.2 KiB
TypeScript
278 lines
7.2 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
Injectable,
|
|
NotFoundException,
|
|
UnauthorizedException,
|
|
} from '@nestjs/common';
|
|
import jwt from 'jsonwebtoken';
|
|
import { Role } from '../../generated/prisma/enums.js';
|
|
import { MESSAGES } from '../../common/messages.js';
|
|
import { PrismaService } from '../../prisma.service.js';
|
|
import { UsersService } from '../../users/users.service.js';
|
|
import { MiniappPhoneLoginConfirmDto } from '../dto/miniapp-phone-login-confirm.dto.js';
|
|
import { MiniappPhoneLoginDto } from '../dto/miniapp-phone-login.dto.js';
|
|
import { WechatMiniAppService } from '../wechat-miniapp/wechat-miniapp.service.js';
|
|
|
|
type LoginTicketPayload = {
|
|
purpose: 'MINIAPP_B_LOGIN_TICKET';
|
|
phone: string;
|
|
openId: string;
|
|
userIds: number[];
|
|
};
|
|
|
|
/**
|
|
* 小程序登录服务:承载 B/C 端手机号快捷登录链路。
|
|
*/
|
|
@Injectable()
|
|
export class MiniAppAuthService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly usersService: UsersService,
|
|
private readonly wechatMiniAppService: WechatMiniAppService,
|
|
) {}
|
|
|
|
/**
|
|
* B 端手机号登录:单账号直接签发,多账号返回候选列表。
|
|
*/
|
|
async loginForB(dto: MiniappPhoneLoginDto) {
|
|
const identity = await this.wechatMiniAppService.resolvePhoneIdentity(
|
|
dto.loginCode,
|
|
dto.phoneCode,
|
|
);
|
|
const accounts = await this.prisma.user.findMany({
|
|
where: { phone: identity.phone },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
phone: true,
|
|
openId: true,
|
|
role: true,
|
|
hospitalId: true,
|
|
departmentId: true,
|
|
groupId: true,
|
|
hospital: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
},
|
|
},
|
|
},
|
|
orderBy: [{ hospitalId: 'asc' }, { id: 'asc' }],
|
|
});
|
|
|
|
if (accounts.length === 0) {
|
|
throw new NotFoundException(MESSAGES.AUTH.MINIAPP_NO_MATCHED_USER);
|
|
}
|
|
|
|
if (accounts.length === 1) {
|
|
const [user] = accounts;
|
|
await this.usersService.bindOpenIdForMiniAppLogin(
|
|
user.id,
|
|
identity.openId,
|
|
);
|
|
return this.usersService.loginByUserId(user.id);
|
|
}
|
|
|
|
return {
|
|
needSelect: true,
|
|
loginTicket: this.signLoginTicket({
|
|
purpose: 'MINIAPP_B_LOGIN_TICKET',
|
|
phone: identity.phone,
|
|
openId: identity.openId,
|
|
userIds: accounts.map((account) => account.id),
|
|
}),
|
|
accounts: accounts.map((account) => ({
|
|
id: account.id,
|
|
name: account.name,
|
|
role: account.role,
|
|
hospitalId: account.hospitalId,
|
|
hospitalName: account.hospital?.name ?? null,
|
|
departmentId: account.departmentId,
|
|
groupId: account.groupId,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* B 端多账号确认登录。
|
|
*/
|
|
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,
|
|
);
|
|
}
|
|
|
|
await this.usersService.bindOpenIdForMiniAppLogin(
|
|
dto.userId,
|
|
payload.openId,
|
|
);
|
|
return this.usersService.loginByUserId(dto.userId);
|
|
}
|
|
|
|
/**
|
|
* C 端手机号登录:要求手机号唯一命中患者档案。
|
|
*/
|
|
async loginForC(dto: MiniappPhoneLoginDto) {
|
|
const identity = await this.wechatMiniAppService.resolvePhoneIdentity(
|
|
dto.loginCode,
|
|
dto.phoneCode,
|
|
);
|
|
const matchedPatients = await this.prisma.patient.findMany({
|
|
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,
|
|
);
|
|
}
|
|
|
|
const existingByOpenId = await this.prisma.familyMiniAppAccount.findUnique({
|
|
where: { openId: identity.openId },
|
|
select: { id: true, phone: true },
|
|
});
|
|
if (existingByOpenId && existingByOpenId.phone !== identity.phone) {
|
|
throw new ConflictException(
|
|
MESSAGES.AUTH.MINIAPP_OPEN_ID_BOUND_OTHER_FAMILY,
|
|
);
|
|
}
|
|
|
|
const current = await this.prisma.familyMiniAppAccount.findUnique({
|
|
where: { phone: identity.phone },
|
|
select: {
|
|
id: true,
|
|
phone: true,
|
|
openId: true,
|
|
serviceUid: true,
|
|
},
|
|
});
|
|
|
|
if (current?.openId && current.openId !== identity.openId) {
|
|
throw new ConflictException(
|
|
MESSAGES.AUTH.MINIAPP_OPEN_ID_BOUND_OTHER_FAMILY,
|
|
);
|
|
}
|
|
|
|
const familyAccount = current
|
|
? await this.prisma.familyMiniAppAccount.update({
|
|
where: { id: current.id },
|
|
data: {
|
|
openId: current.openId ?? identity.openId,
|
|
lastLoginAt: new Date(),
|
|
},
|
|
select: {
|
|
id: true,
|
|
phone: true,
|
|
openId: true,
|
|
serviceUid: true,
|
|
},
|
|
})
|
|
: await this.prisma.familyMiniAppAccount.create({
|
|
data: {
|
|
phone: identity.phone,
|
|
openId: identity.openId,
|
|
lastLoginAt: new Date(),
|
|
},
|
|
select: {
|
|
id: true,
|
|
phone: true,
|
|
openId: true,
|
|
serviceUid: true,
|
|
},
|
|
});
|
|
|
|
return {
|
|
tokenType: 'Bearer',
|
|
accessToken: this.signFamilyAccessToken(familyAccount.id),
|
|
familyAccount,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 短期登录票据签发。
|
|
*/
|
|
private signLoginTicket(payload: LoginTicketPayload) {
|
|
const secret = this.requireAuthSecret();
|
|
return jwt.sign(payload, secret, {
|
|
algorithm: 'HS256',
|
|
expiresIn: '5m',
|
|
issuer: 'tyt-api-nest',
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 校验短期登录票据。
|
|
*/
|
|
private verifyLoginTicket(token: string): LoginTicketPayload {
|
|
const secret = this.requireAuthSecret();
|
|
let payload: string | jwt.JwtPayload;
|
|
|
|
try {
|
|
payload = jwt.verify(token, secret, {
|
|
algorithms: ['HS256'],
|
|
issuer: 'tyt-api-nest',
|
|
});
|
|
} catch {
|
|
throw new UnauthorizedException(
|
|
MESSAGES.AUTH.MINIAPP_LOGIN_TICKET_INVALID,
|
|
);
|
|
}
|
|
|
|
if (
|
|
typeof payload !== 'object' ||
|
|
payload.purpose !== 'MINIAPP_B_LOGIN_TICKET' ||
|
|
typeof payload.phone !== 'string' ||
|
|
typeof payload.openId !== 'string' ||
|
|
!Array.isArray(payload.userIds) ||
|
|
payload.userIds.some(
|
|
(item) => typeof item !== 'number' || !Number.isInteger(item),
|
|
)
|
|
) {
|
|
throw new UnauthorizedException(
|
|
MESSAGES.AUTH.MINIAPP_LOGIN_TICKET_INVALID,
|
|
);
|
|
}
|
|
|
|
return payload as unknown as LoginTicketPayload;
|
|
}
|
|
|
|
/**
|
|
* 签发 C 端访问 token。
|
|
*/
|
|
private signFamilyAccessToken(accountId: number) {
|
|
const secret = this.requireAuthSecret();
|
|
return jwt.sign(
|
|
{
|
|
id: accountId,
|
|
type: 'FAMILY_MINIAPP',
|
|
},
|
|
secret,
|
|
{
|
|
algorithm: 'HS256',
|
|
expiresIn: '7d',
|
|
issuer: 'tyt-api-nest-family',
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 读取认证密钥。
|
|
*/
|
|
private requireAuthSecret() {
|
|
const secret = process.env.AUTH_TOKEN_SECRET;
|
|
if (!secret) {
|
|
throw new UnauthorizedException(MESSAGES.AUTH.TOKEN_SECRET_MISSING);
|
|
}
|
|
return secret;
|
|
}
|
|
}
|