Files
thebet365/apps/api/src/domains/identity/auth.service.ts
Mars db28390be9 feat(player): 接入创蓝短信手机注册与登录页优化
新增 SMS 验证码注册、8 国手机号选择与 Redis 频控;优化登录/注册 UI 及图形验证码样式。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 10:25:59 +08:00

308 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}