Files
mp-pilates/packages/server/src/auth/auth.service.ts
richarjiang 139882d7a1 feat: 支持课后评价与成长档案
完成后即可评价并订阅提醒,馆主可记录体测、笔记与成长照片,首页展示匿名星级均分。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 15:55:48 +08:00

188 lines
5.4 KiB
TypeScript

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<string, string>()
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<string>('WX_SUBSCRIBE_TEMPLATE_CLASS_REVIEW', ''), scene: SubscriptionMessageScene.CLASS_REVIEW, description: '课程完成 24 小时后提醒评价', usageTarget: 'consent' as const },
{
templateId: this.configService.get<string>('WX_SUBSCRIBE_TEMPLATE_BOOKING_CONFIRMED', ''),
scene: SubscriptionMessageScene.BOOKING_CREATED,
description: '购卡或预约时请求一次订阅,用于后续预约确认通知推送',
usageTarget: 'consent' as const,
},
{
templateId: this.configService.get<string>('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<UserProfileResponse> {
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<LoginResult> {
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<User> {
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 },
})
}
}