import { Inject, Injectable, UnauthorizedException } from '@nestjs/common' import { JwtService } from '@nestjs/jwt' import { User } from '@prisma/client' import { MembershipStatus, SubscriptionMessageScene, type SubscriptionMessageTemplate, type SubscriptionMessageTemplateConfig, type UserProfileResponse, UserRole, } from '@mp-pilates/shared' import { ConfigService } from '@nestjs/config' import { PrismaService } from '../prisma/prisma.service' import { WechatService } from './wechat.service' import { InviteService } from '../invite/invite.service' export interface LoginResult { token: string user: UserProfileResponse isNewUser: boolean } export interface JwtPayload { sub: string role: UserRole } /** * In-memory session key store. * TODO: Replace with Redis for production multi-instance deployments. * Key: userId, Value: WeChat sessionKey */ const sessionKeyStore = new Map() export const RANDOM_FN_TOKEN = 'RANDOM_FN_TOKEN' const DEFAULT_NICKNAMES = [ '优雅普拉提', '柔韧时光', '轻盈姿态', '身心合一', '舒展生活', '静享流动', '普拉提修行者', '姿态雕塑师', '呼吸艺术家', '柔美力量', '线条雕刻师', '优雅行者', '轻盈韵律', '内在平和', '舒展之美', ] function generateDefaultNickname( randomFn: () => number = Math.random, ): string { return DEFAULT_NICKNAMES[Math.floor(randomFn() * DEFAULT_NICKNAMES.length)] } @Injectable() export class AuthService { constructor( private readonly prisma: PrismaService, private readonly jwtService: JwtService, private readonly wechatService: WechatService, private readonly inviteService: InviteService, private readonly configService: ConfigService, @Inject(RANDOM_FN_TOKEN) private readonly randomFn: () => number = Math.random, ) {} private buildSubscriptionTemplateConfig(): SubscriptionMessageTemplateConfig { const templates = [ { templateId: this.configService.get('WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW', ''), scene: SubscriptionMessageScene.CLASS_REVIEW, description: '课程完成 24 小时后提醒评价', usageTarget: 'consent' as const }, { templateId: this.configService.get('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''), scene: SubscriptionMessageScene.BOOKING_CREATED, description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送', usageTarget: 'consent' as const, }, { templateId: this.configService.get('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''), scene: SubscriptionMessageScene.ADMIN_BOOKING_CREATED, description: '管理员主动增加预约提醒次数,用于接收学员新预约通知', usageTarget: 'counter' as const, }, ] satisfies SubscriptionMessageTemplate[] return { templates: templates.filter((item) => item.templateId), } } private async mapLoginUser(user: User): Promise { const activeMembershipCount = await this.prisma.membership.count({ where: { userId: user.id, status: MembershipStatus.ACTIVE, }, }) return { id: user.id, phone: user.phone, nickname: user.nickname, avatarUrl: user.avatarUrl, role: user.role as UserRole, activeMembershipCount, inviteShareEligible: activeMembershipCount > 0, adminBookingSubscriptionCount: user.adminBookingSubscriptionCount, subscriptionMessageTemplates: this.buildSubscriptionTemplateConfig(), createdAt: user.createdAt.toISOString(), } } async login( code: string, nickname?: string, avatarUrl?: string, inviterId?: string, ): Promise { const { openid, unionid, sessionKey } = await this.wechatService.code2Session(code) const existingUser = await this.prisma.user.findUnique({ where: { openid }, }) const isNewUser = existingUser === null const now = new Date() const user = isNewUser ? await this.prisma.user.create({ data: { openid, ...(unionid !== undefined && { unionid }), nickname: nickname || generateDefaultNickname(this.randomFn), ...(avatarUrl && { avatarUrl }), adminBookingSubscriptionCount: 0, lastLoginAt: now, }, }) : await this.prisma.user.update({ where: { id: existingUser.id }, data: { lastLoginAt: now, ...(avatarUrl && { avatarUrl, ...(nickname && { nickname }) }), }, }) sessionKeyStore.set(user.id, sessionKey) if (isNewUser) { await this.inviteService.bindInviterToUser(user.id, inviterId) } const payload: JwtPayload = { sub: user.id, role: user.role as UserRole } const token = this.jwtService.sign(payload) return { token, user: await this.mapLoginUser(user), isNewUser } } async bindPhone( userId: string, encryptedData: string, iv: string, ): Promise { const sessionKey = sessionKeyStore.get(userId) if (!sessionKey) { throw new UnauthorizedException( 'Session expired. Please log in again to bind your phone number.', ) } const phoneInfo = this.wechatService.decryptData( sessionKey, encryptedData, iv, ) return this.prisma.user.update({ where: { id: userId }, data: { phone: phoneInfo.phoneNumber }, }) } }