新增 SMS 验证码注册、8 国手机号选择与 Redis 频控;优化登录/注册 UI 及图形验证码样式。 Co-authored-by: Cursor <cursoragent@cursor.com>
308 lines
9.1 KiB
TypeScript
308 lines
9.1 KiB
TypeScript
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<string> {
|
||
return bcrypt.hash(password, 10);
|
||
}
|
||
}
|