import { Injectable, Logger } 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 { AuditService } from '../operations/audit/audit.service'; import { normalizePhone, resolvePlayerLoginCandidates, stripLocalPhoneDigits } from './sms/phone.util'; import { maskPhoneForLog } from './sms/sms-log.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 { private readonly logger = new Logger(AuthService.name); constructor( private prisma: PrismaService, private jwt: JwtService, private config: ConfigService, private systemConfig: SystemConfigService, private invites: InvitesService, private sms: SmsService, private audit: AuditService, ) {} /** 平台管理员 / 代理统一登录(按 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, ) { let user; if (portal === 'player') { const trimmed = username.trim(); if (/[a-zA-Z]/.test(trimmed)) { user = await this.prisma.user.findUnique({ where: { username: trimmed }, include: { auth: true, adminRole: { include: { role: true } } }, }); } else { const candidates = resolvePlayerLoginCandidates(username, countryCode); let matches = await this.prisma.user.findMany({ where: { username: { in: candidates }, userType: 'PLAYER', deletedAt: null, }, include: { auth: true, adminRole: { include: { role: true } } }, }); if (matches.length === 0 && countryCode?.trim()) { const dial = countryCode.replace(/\D/g, ''); const local = stripLocalPhoneDigits(username); matches = await this.prisma.user.findMany({ where: { userType: 'PLAYER', deletedAt: null, preferences: { phoneCountryDial: dial, phoneLocal: local, }, }, include: { auth: true, adminRole: { include: { role: true } } }, }); } if (matches.length === 1) { user = matches[0]; } } } else { user = await this.prisma.user.findUnique({ where: { username: username.trim() }, include: { auth: true, adminRole: { include: { role: { include: { permissions: { include: { permission: 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.status === 'SUSPENDED') { throw appForbidden('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 }); const rolePerms = user.adminRole?.role as | { permissions?: Array<{ permission: { code: string } }> } | undefined | null; const adminPermissions = user.userType === 'ADMIN' ? (rolePerms?.permissions?.map((rp) => rp.permission.code) ?? []) : undefined; 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, visibleMenus: user.visibleMenus, ...(adminPermissions ? { permissions: adminPermissions } : {}), }, }; } 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: { username: string; phone: string; countryCode: string; password: string; smsCode: string; sessionId: string; inviteCode?: string; locale?: string; }) { const username = data.username.trim(); if (!username) { throw appBadRequest('USERNAME_REQUIRED'); } try { assertPlayerUsername(username); } catch { throw appBadRequest('USERNAME_FORMAT_INVALID'); } const dial = data.countryCode.replace(/\D/g, ''); const phoneLocal = stripLocalPhoneDigits(data.phone); const phone = normalizePhone(dial, data.phone); 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(), expectedPurpose: 'register', }); const { parentId, sponsorId, inviteId } = await this.resolveInviteSponsor(data.inviteCode); const inviteSponsorId = parentId == null && sponsorId != null ? sponsorId : null; const existingUser = await this.prisma.user.findUnique({ where: { username }, select: { id: true }, }); if (existingUser) { throw appBadRequest('USERNAME_TAKEN'); } const existingPhone = await this.prisma.userPreference.findFirst({ where: { phoneCountryDial: dial, phoneLocal, user: { deletedAt: null, userType: 'PLAYER' }, }, select: { userId: true }, }); if (existingPhone) { 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, phoneCountryDial: dial, phoneLocal, }, }); 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; }); this.logger.log(`Player registered username=${username} phone=${maskPhoneForLog(phone)}`); 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 resetPasswordByPhone(data: { phone: string; countryCode: string; smsCode: string; sessionId: string; newPassword: string; ipAddress?: string; }) { const settings = await this.systemConfig.getPlayerAccountSettings(); if (!settings.allowPasswordChange) { throw appForbidden('PASSWORD_CHANGE_DISABLED'); } if (!data.newPassword || data.newPassword.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(), expectedPurpose: 'reset_password', }); const player = await this.findPlayerByPhone(data.countryCode, data.phone); if (!player) { throw appBadRequest('PHONE_NOT_REGISTERED'); } if (player.status === 'DISABLED') { throw appForbidden('ACCOUNT_DISABLED'); } const hash = await this.hashPassword(data.newPassword); await this.prisma.userAuth.update({ where: { userId: player.id }, data: { passwordHash: hash, loginFailCount: 0, lockedUntil: null, }, }); await this.prisma.userPreference.updateMany({ where: { userId: player.id }, data: { managedPassword: null }, }); await this.audit.log({ operatorId: player.id, operatorType: 'PLAYER', action: 'FORGOT_PASSWORD_RESET', module: 'identity', targetType: 'user', targetId: player.id.toString(), ipAddress: data.ipAddress, }); this.logger.log( `Password reset by phone userId=${player.id.toString()} phone=${maskPhoneForLog(normalizePhone(data.countryCode, data.phone))} ip=${data.ipAddress ?? 'n/a'}`, ); return { success: true }; } private async findPlayerByPhone(countryCode: string, phone: string) { const dial = countryCode.replace(/\D/g, ''); const phoneLocal = stripLocalPhoneDigits(phone); const pref = await this.prisma.userPreference.findFirst({ where: { phoneCountryDial: dial, phoneLocal, user: { deletedAt: null, userType: 'PLAYER' }, }, select: { user: { select: { id: true, status: true } } }, }); return pref?.user ?? null; } async hashPassword(password: string): Promise { return bcrypt.hash(password, 10); } }