import { Injectable } from '@nestjs/common'; import { Decimal } from '@prisma/client/runtime/library'; import { appForbidden, appUnauthorized, appBadRequest, appNotFound } from '../../shared/common/app-error'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; import * as bcrypt from 'bcryptjs'; import { assertPlayerUsername } from '@thebet365/shared'; import { PrismaService } from '../../shared/prisma/prisma.service'; import { SystemConfigService } from '../../shared/config/system-config.service'; import { InvitesService } from './invites.service'; import { SmsService } from './sms/sms.service'; import { normalizePhone, resolvePlayerLoginUsername } from './sms/phone.util'; const MAX_LOGIN_FAILS = 5; const LOCK_DURATION_MS = 15 * 60 * 1000; export interface JwtPayload { sub: string; username: string; userType: string; role?: string; } @Injectable() export class AuthService { constructor( private prisma: PrismaService, private jwt: JwtService, private config: ConfigService, private systemConfig: SystemConfigService, private invites: InvitesService, private sms: SmsService, ) {} /** 平台管理员 / 代理统一登录(按 userType 签发对应 JWT) */ async staffLogin(username: string, password: string) { const user = await this.prisma.user.findUnique({ where: { username }, select: { userType: true }, }); if (!user || (user.userType !== 'ADMIN' && user.userType !== 'AGENT')) { throw appUnauthorized('INVALID_CREDENTIALS'); } const portal = user.userType === 'ADMIN' ? 'admin' : 'agent'; return this.login(username, password, portal); } async login( username: string, password: string, portal: 'player' | 'admin' | 'agent', countryCode?: string, ) { const lookupUsername = portal === 'player' ? resolvePlayerLoginUsername(username, countryCode) : username.trim(); const user = await this.prisma.user.findUnique({ where: { username: lookupUsername }, include: { auth: true, adminRole: { include: { role: true } } }, }); if (!user || !user.auth) { throw appUnauthorized('INVALID_CREDENTIALS'); } const expectedType = portal === 'admin' ? 'ADMIN' : portal === 'agent' ? 'AGENT' : 'PLAYER'; if (user.userType !== expectedType) { throw appUnauthorized('INVALID_CREDENTIALS'); } if (user.status === 'DISABLED') { throw appForbidden('ACCOUNT_DISABLED'); } if (portal === 'agent' && user.status === 'SUSPENDED') { throw appForbidden('AGENT_ACCOUNT_SUSPENDED'); } if (portal === 'player' && user.parentId) { const parentAgent = await this.prisma.user.findUnique({ where: { id: user.parentId }, select: { userType: true, status: true, agentProfile: { select: { blockDirectPlayerLogin: true } }, }, }); if ( parentAgent?.userType === 'AGENT' && parentAgent.status !== 'ACTIVE' && parentAgent.agentProfile?.blockDirectPlayerLogin ) { throw appForbidden('PARENT_AGENT_SUSPENDED'); } } if (user.auth.lockedUntil && user.auth.lockedUntil > new Date()) { throw appForbidden('ACCOUNT_LOCKED'); } const valid = await bcrypt.compare(password, user.auth.passwordHash); if (!valid) { const failCount = user.auth.loginFailCount + 1; const lockedUntil = failCount >= MAX_LOGIN_FAILS ? new Date(Date.now() + LOCK_DURATION_MS) : null; await this.prisma.userAuth.update({ where: { userId: user.id }, data: { loginFailCount: failCount, lockedUntil }, }); throw appUnauthorized('INVALID_CREDENTIALS'); } await this.prisma.userAuth.update({ where: { userId: user.id }, data: { loginFailCount: 0, lockedUntil: null, lastLoginAt: new Date() }, }); const expiresIn = portal === 'admin' ? this.config.get('JWT_ADMIN_EXPIRES', '2h') : portal === 'agent' ? this.config.get('JWT_AGENT_EXPIRES', '8h') : this.config.get('JWT_PLAYER_EXPIRES', '24h'); const payload: JwtPayload = { sub: user.id.toString(), username: user.username, userType: user.userType, role: user.adminRole?.role?.code, }; const token = this.jwt.sign(payload, { expiresIn }); return { token, user: { id: user.id.toString(), username: user.username, userType: user.userType, locale: user.locale, role: user.adminRole?.role?.code, agentLevel: user.userType === 'AGENT' ? user.agentLevel : null, }, }; } async resolveInviteSponsor(inviteCodeRaw?: string | null) { const resolved = await this.invites.resolveActiveInvite(inviteCodeRaw); return { sponsorId: resolved.sponsorId, parentId: resolved.parentId, inviteId: resolved.inviteId, }; } async registerPlayer(data: { phone: string; countryCode: string; password: string; smsCode: string; sessionId: string; inviteCode?: string; locale?: string; }) { const phone = normalizePhone(data.countryCode, data.phone); const username = phone; try { assertPlayerUsername(username); } catch { throw appBadRequest('PHONE_INVALID'); } if (!data.password || data.password.length < 8) { throw appBadRequest('PASSWORD_MIN_LENGTH'); } if (!data.smsCode?.trim() || !data.sessionId?.trim()) { throw appBadRequest('SMS_CODE_REQUIRED'); } await this.sms.verifyCode({ phone: data.phone, countryCode: data.countryCode, code: data.smsCode.trim(), sessionId: data.sessionId.trim(), }); const { parentId, sponsorId, inviteId } = await this.resolveInviteSponsor(data.inviteCode); const inviteSponsorId = parentId == null && sponsorId != null ? sponsorId : null; const existing = await this.prisma.user.findUnique({ where: { username }, select: { id: true }, }); if (existing) { throw appBadRequest('PHONE_TAKEN'); } const hash = await this.hashPassword(data.password); const locale = data.locale?.trim() || 'zh-CN'; const user = await this.prisma.$transaction(async (tx) => { const created = await tx.user.create({ data: { username, userType: 'PLAYER', parentId, inviteSponsorId, locale, }, }); await tx.userAuth.create({ data: { userId: created.id, passwordHash: hash }, }); await tx.wallet.create({ data: { userId: created.id }, }); await tx.userPreference.create({ data: { userId: created.id, locale, phone }, }); if (inviteId) { await this.invites.recordRegistration(inviteId, created.id, tx); const invite = await tx.userInvite.findUnique({ where: { id: inviteId }, select: { cashbackRate: true }, }); if (invite?.cashbackRate != null && new Decimal(invite.cashbackRate).gt(0)) { await tx.cashbackRule.updateMany({ where: { targetType: 'USER', targetId: created.id }, data: { isActive: false }, }); await tx.cashbackRule.create({ data: { name: `Player ${created.id.toString()}`, targetType: 'USER', targetId: created.id, rate: invite.cashbackRate, isActive: true, }, }); } } return created; }); return this.login(username, data.password, 'player'); } async getInviteInfo(userId: bigint) { const user = await this.prisma.user.findUnique({ where: { id: userId }, select: { inviteCode: true, userType: true, deletedAt: true }, }); if (!user || user.deletedAt) { throw appNotFound('USER_NOT_FOUND'); } if (user.userType !== 'ADMIN' && user.userType !== 'AGENT') { throw appForbidden('ACCESS_DENIED_PORTAL'); } return { inviteCode: user.inviteCode ?? null }; } async generateInviteCode( userId: bigint, userType: string, cashbackRate?: number, ) { return this.invites.generateInviteCode(userId, { userType, cashbackRate: cashbackRate ?? null, }); } async changePassword(userId: bigint, oldPassword: string, newPassword: string) { const auth = await this.prisma.userAuth.findUnique({ where: { userId } }); if (!auth) throw appUnauthorized('USER_NOT_FOUND'); const settings = await this.systemConfig.getPlayerAccountSettings(); if (!settings.allowPasswordChange) { throw appForbidden('PASSWORD_CHANGE_DISABLED'); } const valid = await bcrypt.compare(oldPassword, auth.passwordHash); if (!valid) throw appUnauthorized('INVALID_OLD_PASSWORD'); const hash = await bcrypt.hash(newPassword, 10); await this.prisma.userAuth.update({ where: { userId }, data: { passwordHash: hash }, }); await this.prisma.userPreference.updateMany({ where: { userId }, data: { managedPassword: null }, }); return { success: true }; } async hashPassword(password: string): Promise { return bcrypt.hash(password, 10); } }