feat: 手动充值、邀请码注册与后台管理增强

新增玩家手动充值全流程(收款方式配置、充值下单/审核、钱包上分),
支持邀请码注册、邀请历史与专属返水率;完善后台代理/玩家管理与响应式操作栏,
并补充前台注册、充值页及多语言错误码。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-11 12:20:11 +08:00
parent 618fb49511
commit 10485ecfaf
98 changed files with 7908 additions and 856 deletions

View File

@@ -1,10 +1,13 @@
import { Injectable } from '@nestjs/common';
import { appForbidden, appUnauthorized } from '../../shared/common/app-error';
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';
const MAX_LOGIN_FAILS = 5;
const LOCK_DURATION_MS = 15 * 60 * 1000;
@@ -23,6 +26,7 @@ export class AuthService {
private jwt: JwtService,
private config: ConfigService,
private systemConfig: SystemConfigService,
private invites: InvitesService,
) {}
/** 平台管理员 / 代理统一登录(按 userType 签发对应 JWT */
@@ -129,6 +133,125 @@ export class AuthService {
};
}
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;
password: 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');
}
if (!data.password || data.password.length < 8) {
throw appBadRequest('PASSWORD_MIN_LENGTH');
}
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('USERNAME_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 },
});
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');