feat: 手动充值、邀请码注册与后台管理增强
新增玩家手动充值全流程(收款方式配置、充值下单/审核、钱包上分), 支持邀请码注册、邀请历史与专属返水率;完善后台代理/玩家管理与响应式操作栏, 并补充前台注册、充值页及多语言错误码。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
114
apps/api/src/shared/common/invite-code.util.ts
Normal file
114
apps/api/src/shared/common/invite-code.util.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const INVITE_CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||
const INVITE_CODE_LENGTH = 8;
|
||||
|
||||
export const INVITE_STATUS_ACTIVE = 'ACTIVE';
|
||||
export const INVITE_STATUS_REVOKED = 'REVOKED';
|
||||
|
||||
export function normalizeInviteCode(input: string): string {
|
||||
return input.trim().toUpperCase();
|
||||
}
|
||||
|
||||
export function generateInviteCodeCandidate(): string {
|
||||
let code = '';
|
||||
for (let i = 0; i < INVITE_CODE_LENGTH; i++) {
|
||||
code += INVITE_CODE_CHARS[Math.floor(Math.random() * INVITE_CODE_CHARS.length)];
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
type InviteCodeDb = Pick<PrismaService, 'user' | 'userInvite'>;
|
||||
|
||||
function isInviteCodeUniqueViolation(err: unknown): boolean {
|
||||
return err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002';
|
||||
}
|
||||
|
||||
async function isCodeTaken(db: InviteCodeDb, code: string): Promise<boolean> {
|
||||
const [userHit, inviteHit] = await Promise.all([
|
||||
db.user.findUnique({ where: { inviteCode: code }, select: { id: true } }),
|
||||
db.userInvite.findUnique({ where: { code }, select: { id: true } }),
|
||||
]);
|
||||
return Boolean(userHit || inviteHit);
|
||||
}
|
||||
|
||||
export async function generateUniqueInviteCode(
|
||||
db: InviteCodeDb,
|
||||
maxAttempts = 12,
|
||||
): Promise<string> {
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const code = generateInviteCodeCandidate();
|
||||
if (!(await isCodeTaken(db, code))) return code;
|
||||
}
|
||||
throw new Error('Failed to generate unique invite code');
|
||||
}
|
||||
|
||||
/** Create history record and set as the user's current invite code. */
|
||||
export async function assignInviteCodeWithHistory(
|
||||
db: InviteCodeDb,
|
||||
sponsorId: bigint,
|
||||
cashbackRate?: Prisma.Decimal | null,
|
||||
): Promise<string> {
|
||||
for (let attempt = 0; attempt < 12; attempt++) {
|
||||
const candidate = generateInviteCodeCandidate();
|
||||
if (await isCodeTaken(db, candidate)) continue;
|
||||
try {
|
||||
await db.userInvite.create({
|
||||
data: {
|
||||
code: candidate,
|
||||
sponsorId,
|
||||
status: INVITE_STATUS_ACTIVE,
|
||||
...(cashbackRate != null ? { cashbackRate } : {}),
|
||||
},
|
||||
});
|
||||
await db.user.update({
|
||||
where: { id: sponsorId },
|
||||
data: { inviteCode: candidate },
|
||||
});
|
||||
return candidate;
|
||||
} catch (err) {
|
||||
if (!isInviteCodeUniqueViolation(err)) throw err;
|
||||
}
|
||||
}
|
||||
throw new Error('Failed to assign invite code');
|
||||
}
|
||||
|
||||
/** Revoke active codes and assign a new one with history. */
|
||||
export async function rotateUserInviteCode(
|
||||
db: InviteCodeDb,
|
||||
userId: bigint,
|
||||
cashbackRate?: Prisma.Decimal | null,
|
||||
): Promise<string> {
|
||||
await db.userInvite.updateMany({
|
||||
where: { sponsorId: userId, status: INVITE_STATUS_ACTIVE },
|
||||
data: { status: INVITE_STATUS_REVOKED, revokedAt: new Date() },
|
||||
});
|
||||
return assignInviteCodeWithHistory(db, userId, cashbackRate);
|
||||
}
|
||||
|
||||
/** Assign a stable invite code once per staff user (admin/agent). */
|
||||
export async function ensureUserInviteCode(db: InviteCodeDb, userId: bigint): Promise<string> {
|
||||
const existing = await db.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { inviteCode: true },
|
||||
});
|
||||
if (existing?.inviteCode) {
|
||||
const history = await db.userInvite.findUnique({
|
||||
where: { code: existing.inviteCode },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!history) {
|
||||
await db.userInvite.create({
|
||||
data: {
|
||||
code: existing.inviteCode,
|
||||
sponsorId: userId,
|
||||
status: INVITE_STATUS_ACTIVE,
|
||||
},
|
||||
});
|
||||
}
|
||||
return existing.inviteCode;
|
||||
}
|
||||
|
||||
return assignInviteCodeWithHistory(db, userId);
|
||||
}
|
||||
@@ -7,6 +7,15 @@ export const AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS = 'agent.suspend_freeze_direct_
|
||||
export const AGENT_SUSPEND_BLOCK_PLAYER_LOGIN = 'agent.suspend_block_player_login';
|
||||
export const AGENT_MAX_LEVEL = 'agent.max_level';
|
||||
export const AGENT_DEFAULT_SUB_CREDIT_RATIO = 'agent.default_sub_credit_ratio';
|
||||
export const CASHBACK_PLATFORM_DIRECT_RATE = 'cashback.platform_direct_rate';
|
||||
export const CASHBACK_ADMIN_INVITE_RATE = 'cashback.admin_invite_rate';
|
||||
|
||||
export type PlatformDirectCashbackSettings = {
|
||||
/** 平台直属玩家默认返水比例(小数,0.01 = 1%) */
|
||||
platformDirectRate: number;
|
||||
/** 管理员邀请注册玩家返水比例;未配置时与 platformDirectRate 相同 */
|
||||
adminInviteRate: number;
|
||||
};
|
||||
|
||||
export type AgentHierarchySettings = {
|
||||
/** 最大代理层级;0 = 不限制 */
|
||||
@@ -155,4 +164,62 @@ export class SystemConfigService {
|
||||
}
|
||||
return this.getAgentHierarchySettings();
|
||||
}
|
||||
|
||||
async getDecimalRate(key: string, defaultValue = 0): Promise<number> {
|
||||
const row = await this.prisma.systemConfig.findUnique({ where: { configKey: key } });
|
||||
if (!row) return defaultValue;
|
||||
const parsed = parseFloat(row.configValue);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : defaultValue;
|
||||
}
|
||||
|
||||
async setDecimalRate(key: string, value: number, description?: string) {
|
||||
const safe = Math.max(0, value);
|
||||
await this.prisma.systemConfig.upsert({
|
||||
where: { configKey: key },
|
||||
create: {
|
||||
configKey: key,
|
||||
configValue: String(safe),
|
||||
description,
|
||||
},
|
||||
update: { configValue: String(safe) },
|
||||
});
|
||||
}
|
||||
|
||||
async getPlatformDirectCashbackSettings(): Promise<PlatformDirectCashbackSettings> {
|
||||
const platformDirectRate = await this.getDecimalRate(CASHBACK_PLATFORM_DIRECT_RATE, 0);
|
||||
const adminInviteConfigured = await this.prisma.systemConfig.findUnique({
|
||||
where: { configKey: CASHBACK_ADMIN_INVITE_RATE },
|
||||
});
|
||||
const adminInviteRate = adminInviteConfigured
|
||||
? await this.getDecimalRate(CASHBACK_ADMIN_INVITE_RATE, platformDirectRate)
|
||||
: platformDirectRate;
|
||||
return { platformDirectRate, adminInviteRate };
|
||||
}
|
||||
|
||||
async updatePlatformDirectCashbackSettings(data: {
|
||||
platformDirectRate?: number;
|
||||
adminInviteRate?: number;
|
||||
}) {
|
||||
if (data.platformDirectRate !== undefined) {
|
||||
if (!Number.isFinite(data.platformDirectRate) || data.platformDirectRate < 0) {
|
||||
throw new Error('platformDirectRate must be a non-negative number');
|
||||
}
|
||||
await this.setDecimalRate(
|
||||
CASHBACK_PLATFORM_DIRECT_RATE,
|
||||
data.platformDirectRate,
|
||||
'平台直属玩家默认返水比例(小数,如 0.01 = 1%)',
|
||||
);
|
||||
}
|
||||
if (data.adminInviteRate !== undefined) {
|
||||
if (!Number.isFinite(data.adminInviteRate) || data.adminInviteRate < 0) {
|
||||
throw new Error('adminInviteRate must be a non-negative number');
|
||||
}
|
||||
await this.setDecimalRate(
|
||||
CASHBACK_ADMIN_INVITE_RATE,
|
||||
data.adminInviteRate,
|
||||
'管理员邀请注册玩家返水比例(小数,如 0.01 = 1%)',
|
||||
);
|
||||
}
|
||||
return this.getPlatformDirectCashbackSettings();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user