188 lines
5.4 KiB
TypeScript
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 },
|
|
})
|
|
}
|
|
}
|