tyt-api-nest/src/auth/miniapp-auth/miniapp-auth.service.ts
EL ab17204739 C端 miniapp 登录增加手机号唯一命中患者规则,命中多份档案返回 409
C端 my-lifecycle 由跨院多患者聚合改为单患者返回,结构统一为 patient + lifecycle
生命周期事件移除事件内重复 patient 字段,减少冗余
B端患者生命周期接口同步采用 patient + lifecycle 结构
新增并接入生命周期 Swagger 响应模型,补齐接口文档
更新 auth/patients/frontend 集成文档说明
增加 e2e:多患者冲突、C端/B端新返回结构、权限失败场景
2026-04-02 04:07:40 +08:00

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;
}
}