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