Admin: add match/player overview sub-nav; refine settlement flow and league match management UI; improve action button enabled/disabled styles; enhance logo upload and outright odds sync. API: expose matchPhase/bettingOpen for closed matches; league publish guards; settlement preview with auto score save; outright team auto-sync. Player: watermark for closed/settled states; keep match and bet details visible; remove default login credentials. Co-authored-by: Cursor <cursoragent@cursor.com>
1681 lines
52 KiB
TypeScript
1681 lines
52 KiB
TypeScript
import {
|
||
Injectable,
|
||
BadRequestException,
|
||
ForbiddenException,
|
||
NotFoundException,
|
||
} from '@nestjs/common';
|
||
import * as bcrypt from 'bcryptjs';
|
||
import { Prisma } from '@prisma/client';
|
||
import { PrismaService } from '../../shared/prisma/prisma.service';
|
||
import { WalletService } from '../ledger/wallet.service';
|
||
import { AuthService } from '../identity/auth.service';
|
||
import { SystemConfigService } from '../../shared/config/system-config.service';
|
||
import { Decimal } from '@prisma/client/runtime/library';
|
||
import { generateBatchNo } from '../../shared/common/decorators';
|
||
import { assertPlayerUsername } from '@thebet365/shared';
|
||
|
||
function dec(v: Decimal | null | undefined) {
|
||
return v?.toString() ?? '0';
|
||
}
|
||
|
||
function sub(a: Decimal | null | undefined, b: Decimal | null | undefined) {
|
||
return new Decimal(a ?? 0).sub(b ?? 0).toString();
|
||
}
|
||
|
||
@Injectable()
|
||
export class AgentsService {
|
||
constructor(
|
||
private prisma: PrismaService,
|
||
private wallet: WalletService,
|
||
private auth: AuthService,
|
||
private systemConfig: SystemConfigService,
|
||
) {}
|
||
|
||
async getProfile(agentId: bigint) {
|
||
const profile = await this.prisma.agentProfile.findUnique({
|
||
where: { userId: agentId },
|
||
});
|
||
if (!profile) throw new BadRequestException('Agent profile not found');
|
||
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
|
||
return { ...profile, availableCredit: available };
|
||
}
|
||
|
||
async recalculateUsedCredit(agentId: bigint) {
|
||
const directPlayers = await this.prisma.user.findMany({
|
||
where: { parentId: agentId, userType: 'PLAYER' },
|
||
include: { wallet: true },
|
||
});
|
||
|
||
let directLiability = new Decimal(0);
|
||
for (const p of directPlayers) {
|
||
if (p.wallet) {
|
||
directLiability = directLiability
|
||
.add(p.wallet.availableBalance)
|
||
.add(p.wallet.frozenBalance);
|
||
}
|
||
}
|
||
|
||
const childAgents = await this.prisma.agentProfile.findMany({
|
||
where: { parentAgentId: agentId },
|
||
});
|
||
|
||
let childExposure = new Decimal(0);
|
||
for (const child of childAgents) {
|
||
const exposure = Decimal.max(child.creditLimit, child.usedCredit);
|
||
childExposure = childExposure.add(exposure);
|
||
}
|
||
|
||
const usedCredit = directLiability.add(childExposure);
|
||
|
||
await this.prisma.agentProfile.update({
|
||
where: { userId: agentId },
|
||
data: {
|
||
usedCredit,
|
||
directPlayerLiability: directLiability,
|
||
childAgentExposure: childExposure,
|
||
},
|
||
});
|
||
|
||
return usedCredit;
|
||
}
|
||
|
||
async adjustCredit(
|
||
agentId: bigint,
|
||
amount: Decimal | number,
|
||
operatorId: bigint,
|
||
requestId: string,
|
||
remark?: string,
|
||
) {
|
||
const amt = new Decimal(amount);
|
||
const profile = await this.prisma.agentProfile.findUnique({
|
||
where: { userId: agentId },
|
||
});
|
||
if (!profile) throw new BadRequestException('Agent not found');
|
||
|
||
const creditBefore = profile.creditLimit;
|
||
const creditAfter = creditBefore.add(amt);
|
||
if (creditAfter.lt(0)) throw new BadRequestException('Credit limit cannot be negative');
|
||
|
||
if (profile.parentAgentId) {
|
||
await this.assertChildCreditWithinParent(profile.parentAgentId, profile, creditAfter);
|
||
}
|
||
|
||
await this.prisma.$transaction(async (tx) => {
|
||
await tx.agentProfile.update({
|
||
where: { userId: agentId },
|
||
data: { creditLimit: creditAfter },
|
||
});
|
||
|
||
await tx.agentCreditTransaction.create({
|
||
data: {
|
||
agentId,
|
||
transactionType: amt.gte(0) ? 'CREDIT_INCREASE' : 'CREDIT_DECREASE',
|
||
amount: amt,
|
||
creditBefore,
|
||
creditAfter,
|
||
operatorId,
|
||
requestId,
|
||
remark,
|
||
},
|
||
});
|
||
});
|
||
|
||
if (profile.parentAgentId) {
|
||
await this.recalculateUsedCredit(profile.parentAgentId);
|
||
}
|
||
|
||
return { creditAfter };
|
||
}
|
||
|
||
/** 代理只能操作直属玩家(parentId === 当前代理) */
|
||
private async requireDirectPlayer(agentId: bigint, playerId: bigint) {
|
||
const player = await this.prisma.user.findFirst({
|
||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||
include: { auth: true, wallet: true, preferences: true },
|
||
});
|
||
if (!player) throw new NotFoundException('玩家不存在');
|
||
if (player.parentId !== agentId) {
|
||
throw new ForbiddenException('Can only manage direct players');
|
||
}
|
||
return player;
|
||
}
|
||
|
||
private async assertChildAgentWithinParent(
|
||
parentAgentId: bigint,
|
||
child: {
|
||
creditLimit?: number | Decimal;
|
||
cashbackRate?: number | Decimal;
|
||
maxSingleDeposit?: number | Decimal | null;
|
||
maxDailyDeposit?: number | Decimal | null;
|
||
},
|
||
) {
|
||
const parent = await this.prisma.agentProfile.findUnique({
|
||
where: { userId: parentAgentId },
|
||
});
|
||
if (!parent) throw new BadRequestException('上级代理不存在');
|
||
|
||
if (child.creditLimit !== undefined) {
|
||
const limit = new Decimal(child.creditLimit);
|
||
if (limit.lt(0)) throw new BadRequestException('授信额度不能为负');
|
||
if (limit.gt(parent.creditLimit)) {
|
||
throw new BadRequestException('下级代理授信不能超过上级授信额度');
|
||
}
|
||
}
|
||
|
||
if (child.cashbackRate !== undefined) {
|
||
const rate = new Decimal(child.cashbackRate);
|
||
if (rate.lt(0)) throw new BadRequestException('回水比例不能为负');
|
||
if (rate.gt(parent.cashbackRate)) {
|
||
throw new BadRequestException('下级代理回水比例不能超过上级');
|
||
}
|
||
}
|
||
|
||
if (child.maxSingleDeposit != null && parent.maxSingleDeposit != null) {
|
||
if (new Decimal(child.maxSingleDeposit).gt(parent.maxSingleDeposit)) {
|
||
throw new BadRequestException('下级代理单笔限额不能超过上级');
|
||
}
|
||
}
|
||
if (child.maxSingleDeposit != null && new Decimal(child.maxSingleDeposit).lt(0)) {
|
||
throw new BadRequestException('单笔限额不能为负');
|
||
}
|
||
|
||
if (child.maxDailyDeposit != null && parent.maxDailyDeposit != null) {
|
||
if (new Decimal(child.maxDailyDeposit).gt(parent.maxDailyDeposit)) {
|
||
throw new BadRequestException('下级代理日限额不能超过上级');
|
||
}
|
||
}
|
||
if (child.maxDailyDeposit != null && new Decimal(child.maxDailyDeposit).lt(0)) {
|
||
throw new BadRequestException('日限额不能为负');
|
||
}
|
||
}
|
||
|
||
private resolveEffectiveDepositLimits(
|
||
profile: {
|
||
maxSingleDeposit: Decimal | null;
|
||
maxDailyDeposit: Decimal | null;
|
||
},
|
||
parent?: { maxSingleDeposit: Decimal | null; maxDailyDeposit: Decimal | null } | null,
|
||
) {
|
||
let maxSingleDeposit = profile.maxSingleDeposit;
|
||
let maxDailyDeposit = profile.maxDailyDeposit;
|
||
|
||
if (parent) {
|
||
if (parent.maxSingleDeposit != null) {
|
||
maxSingleDeposit =
|
||
maxSingleDeposit != null
|
||
? Decimal.min(maxSingleDeposit, parent.maxSingleDeposit)
|
||
: parent.maxSingleDeposit;
|
||
}
|
||
if (parent.maxDailyDeposit != null) {
|
||
maxDailyDeposit =
|
||
maxDailyDeposit != null
|
||
? Decimal.min(maxDailyDeposit, parent.maxDailyDeposit)
|
||
: parent.maxDailyDeposit;
|
||
}
|
||
}
|
||
|
||
return { maxSingleDeposit, maxDailyDeposit };
|
||
}
|
||
|
||
private normalizeOptionalLimit(value?: number | null) {
|
||
if (value == null || value <= 0) return null;
|
||
return new Decimal(value);
|
||
}
|
||
|
||
/** 玩家有所属代理时,上分金额不得超过该代理当前可用授信(会先重算 usedCredit) */
|
||
async assertPlayerParentCreditForDeposit(playerId: bigint, amount: Decimal | number) {
|
||
const user = await this.prisma.user.findFirst({
|
||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||
select: { parentId: true },
|
||
});
|
||
if (!user?.parentId) return;
|
||
|
||
await this.recalculateUsedCredit(user.parentId);
|
||
const profile = await this.getProfile(user.parentId);
|
||
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
|
||
const amt = new Decimal(amount);
|
||
if (available.lt(amt)) {
|
||
throw new BadRequestException('超过玩家上级代理可用授信,无法上分');
|
||
}
|
||
}
|
||
|
||
/** 管理员给玩家上分:校验上级授信后入账,并刷新代理占用额度 */
|
||
async adminDepositToPlayer(
|
||
playerId: bigint,
|
||
amount: number,
|
||
operatorId: bigint,
|
||
remark?: string,
|
||
requestId?: string,
|
||
) {
|
||
await this.assertPlayerParentCreditForDeposit(playerId, amount);
|
||
const result = await this.wallet.deposit(
|
||
playerId,
|
||
amount,
|
||
operatorId,
|
||
remark ?? '管理员上分',
|
||
requestId,
|
||
);
|
||
const player = await this.prisma.user.findUnique({
|
||
where: { id: playerId },
|
||
select: { parentId: true },
|
||
});
|
||
if (player?.parentId) {
|
||
await this.recalculateUsedCredit(player.parentId);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/** 管理员给玩家下分:扣款后刷新上级代理占用额度 */
|
||
async adminWithdrawFromPlayer(
|
||
playerId: bigint,
|
||
amount: number,
|
||
operatorId: bigint,
|
||
remark?: string,
|
||
requestId?: string,
|
||
) {
|
||
const result = await this.wallet.withdraw(
|
||
playerId,
|
||
amount,
|
||
operatorId,
|
||
remark ?? '管理员下分',
|
||
requestId,
|
||
);
|
||
const player = await this.prisma.user.findUnique({
|
||
where: { id: playerId },
|
||
select: { parentId: true },
|
||
});
|
||
if (player?.parentId) {
|
||
await this.recalculateUsedCredit(player.parentId);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/** 上下分弹窗:玩家余额 + 授信代理可用额度/限额上下文 */
|
||
async getPlayerTransferContext(
|
||
playerId: bigint,
|
||
options: { forAdmin?: boolean; actingAgentId?: bigint } = {},
|
||
) {
|
||
const player = await this.prisma.user.findFirst({
|
||
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
|
||
include: { wallet: true },
|
||
});
|
||
if (!player) throw new NotFoundException('玩家不存在');
|
||
|
||
if (options.actingAgentId) {
|
||
await this.requireDirectPlayer(options.actingAgentId, playerId);
|
||
}
|
||
|
||
const creditAgentId = options.forAdmin ? player.parentId : (options.actingAgentId ?? null);
|
||
|
||
let credit: Record<string, unknown> | null = null;
|
||
if (creditAgentId) {
|
||
await this.recalculateUsedCredit(creditAgentId);
|
||
const profile = await this.getProfile(creditAgentId);
|
||
const parent = profile.parentAgentId
|
||
? await this.prisma.agentProfile.findUnique({ where: { userId: profile.parentAgentId } })
|
||
: null;
|
||
const { maxSingleDeposit, maxDailyDeposit } = this.resolveEffectiveDepositLimits(profile, parent);
|
||
|
||
let dailyDepositUsed: string | null = null;
|
||
if (!options.forAdmin) {
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
const dailyAgg = await this.prisma.walletTransaction.aggregate({
|
||
where: {
|
||
operatorId: creditAgentId,
|
||
transactionType: 'MANUAL_DEPOSIT',
|
||
createdAt: { gte: today },
|
||
},
|
||
_sum: { amount: true },
|
||
});
|
||
dailyDepositUsed = dec(dailyAgg._sum.amount);
|
||
}
|
||
|
||
const agentUser = await this.prisma.user.findUnique({
|
||
where: { id: creditAgentId },
|
||
select: { username: true },
|
||
});
|
||
|
||
credit = {
|
||
agentId: creditAgentId.toString(),
|
||
agentUsername: agentUser?.username ?? '',
|
||
agentLevel: profile.level,
|
||
creditLimit: dec(profile.creditLimit),
|
||
usedCredit: dec(profile.usedCredit),
|
||
availableCredit: dec(profile.availableCredit),
|
||
maxSingleDeposit: maxSingleDeposit?.toString() ?? null,
|
||
maxDailyDeposit: maxDailyDeposit?.toString() ?? null,
|
||
dailyDepositUsed,
|
||
appliesDepositLimits: !options.forAdmin,
|
||
};
|
||
}
|
||
|
||
return {
|
||
player: {
|
||
id: player.id.toString(),
|
||
username: player.username,
|
||
availableBalance: dec(player.wallet?.availableBalance),
|
||
frozenBalance: dec(player.wallet?.frozenBalance),
|
||
},
|
||
credit,
|
||
};
|
||
}
|
||
|
||
private async assertAgentDepositLimits(creditAgentId: bigint, amount: Decimal) {
|
||
const profile = await this.prisma.agentProfile.findUnique({
|
||
where: { userId: creditAgentId },
|
||
});
|
||
if (!profile) return;
|
||
|
||
const parent = profile.parentAgentId
|
||
? await this.prisma.agentProfile.findUnique({
|
||
where: { userId: profile.parentAgentId },
|
||
})
|
||
: null;
|
||
const { maxSingleDeposit, maxDailyDeposit } = this.resolveEffectiveDepositLimits(profile, parent);
|
||
|
||
if (maxSingleDeposit && amount.gt(maxSingleDeposit)) {
|
||
throw new BadRequestException('超过代理单笔上分限额');
|
||
}
|
||
|
||
if (maxDailyDeposit) {
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
const dailyAgg = await this.prisma.walletTransaction.aggregate({
|
||
where: {
|
||
operatorId: creditAgentId,
|
||
transactionType: 'MANUAL_DEPOSIT',
|
||
createdAt: { gte: today },
|
||
},
|
||
_sum: { amount: true },
|
||
});
|
||
const dailyTotal = new Decimal(dailyAgg._sum.amount ?? 0).add(amount);
|
||
if (dailyTotal.gt(maxDailyDeposit)) {
|
||
throw new BadRequestException('超过代理日上分限额');
|
||
}
|
||
}
|
||
}
|
||
|
||
private async assertChildCreditWithinParent(
|
||
parentAgentId: bigint,
|
||
childProfile: { userId: bigint; creditLimit: Decimal; usedCredit: Decimal },
|
||
creditAfter: Decimal,
|
||
) {
|
||
await this.assertChildAgentWithinParent(parentAgentId, { creditLimit: creditAfter });
|
||
|
||
const parent = await this.getProfile(parentAgentId);
|
||
const parentAvailable = new Decimal(parent.creditLimit).sub(parent.usedCredit);
|
||
const oldExposure = Decimal.max(childProfile.creditLimit, childProfile.usedCredit);
|
||
const newExposure = Decimal.max(creditAfter, childProfile.usedCredit);
|
||
const exposureDelta = newExposure.sub(oldExposure);
|
||
if (exposureDelta.gt(0) && exposureDelta.gt(parentAvailable)) {
|
||
throw new BadRequestException('上级可用授信不足');
|
||
}
|
||
}
|
||
|
||
async depositToPlayer(
|
||
agentId: bigint,
|
||
playerId: bigint,
|
||
amount: number,
|
||
requestId: string,
|
||
remark?: string,
|
||
) {
|
||
await this.requireDirectPlayer(agentId, playerId);
|
||
|
||
const profile = await this.getProfile(agentId);
|
||
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
|
||
const amt = new Decimal(amount);
|
||
|
||
if (available.lt(amt)) {
|
||
throw new BadRequestException('Insufficient agent credit');
|
||
}
|
||
|
||
await this.assertAgentDepositLimits(agentId, amt);
|
||
|
||
await this.wallet.deposit(playerId, amt, agentId, remark ?? '代理上分', requestId);
|
||
await this.recalculateUsedCredit(agentId);
|
||
|
||
return { success: true };
|
||
}
|
||
|
||
async withdrawFromPlayer(
|
||
agentId: bigint,
|
||
playerId: bigint,
|
||
amount: number,
|
||
requestId: string,
|
||
remark?: string,
|
||
) {
|
||
await this.requireDirectPlayer(agentId, playerId);
|
||
|
||
await this.wallet.withdraw(playerId, amount, agentId, remark ?? '代理下分', requestId);
|
||
await this.recalculateUsedCredit(agentId);
|
||
|
||
return { success: true };
|
||
}
|
||
|
||
async getDirectPlayerDetail(agentId: bigint, playerId: bigint) {
|
||
const user = await this.requireDirectPlayer(agentId, playerId);
|
||
|
||
const [betCount, betStake] = await Promise.all([
|
||
this.prisma.bet.count({ where: { userId: playerId } }),
|
||
this.prisma.bet.aggregate({
|
||
where: { userId: playerId },
|
||
_sum: { stake: true, actualReturn: true },
|
||
}),
|
||
]);
|
||
|
||
return {
|
||
id: user.id.toString(),
|
||
username: user.username,
|
||
status: user.status,
|
||
phone: user.preferences?.phone ?? null,
|
||
email: user.preferences?.email ?? null,
|
||
managedPassword: user.preferences?.managedPassword ?? null,
|
||
availableBalance: user.wallet?.availableBalance?.toString() ?? '0',
|
||
frozenBalance: user.wallet?.frozenBalance?.toString() ?? '0',
|
||
lastLoginAt: user.auth?.lastLoginAt ?? null,
|
||
loginFailCount: user.auth?.loginFailCount ?? 0,
|
||
betCount,
|
||
totalStake: betStake._sum.stake?.toString() ?? '0',
|
||
totalReturn: betStake._sum.actualReturn?.toString() ?? '0',
|
||
createdAt: user.createdAt,
|
||
};
|
||
}
|
||
|
||
async updateDirectPlayer(
|
||
agentId: bigint,
|
||
playerId: bigint,
|
||
data: {
|
||
username?: string;
|
||
password?: string;
|
||
phone?: string;
|
||
email?: string;
|
||
status?: string;
|
||
},
|
||
) {
|
||
const user = await this.requireDirectPlayer(agentId, playerId);
|
||
|
||
if (data.status && !['ACTIVE', 'SUSPENDED'].includes(data.status)) {
|
||
throw new BadRequestException('无效状态');
|
||
}
|
||
|
||
if (data.username !== undefined) {
|
||
const nextUsername = data.username.trim();
|
||
if (!nextUsername) throw new BadRequestException('账号名称不能为空');
|
||
try {
|
||
assertPlayerUsername(nextUsername);
|
||
} catch (e) {
|
||
throw new BadRequestException(e instanceof Error ? e.message : '玩家用户名格式无效');
|
||
}
|
||
if (nextUsername !== user.username) {
|
||
const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } });
|
||
if (taken) throw new BadRequestException('账号名称已被占用');
|
||
await this.prisma.user.update({
|
||
where: { id: playerId },
|
||
data: { username: nextUsername },
|
||
});
|
||
}
|
||
}
|
||
|
||
if (data.password !== undefined) {
|
||
const nextPassword = data.password;
|
||
if (nextPassword.length < 8) throw new BadRequestException('密码至少 8 位');
|
||
if (!user.auth) throw new BadRequestException('账号认证信息缺失');
|
||
const hash = await bcrypt.hash(nextPassword, 10);
|
||
await this.prisma.userAuth.update({
|
||
where: { userId: playerId },
|
||
data: { passwordHash: hash, loginFailCount: 0, lockedUntil: null },
|
||
});
|
||
await this.prisma.userPreference.upsert({
|
||
where: { userId: playerId },
|
||
create: { userId: playerId, managedPassword: nextPassword },
|
||
update: { managedPassword: nextPassword },
|
||
});
|
||
}
|
||
|
||
if (data.status) {
|
||
await this.prisma.user.update({
|
||
where: { id: playerId },
|
||
data: { status: data.status },
|
||
});
|
||
}
|
||
|
||
const prefPatch: { phone?: string | null; email?: string | null } = {};
|
||
if (data.phone !== undefined) prefPatch.phone = data.phone.trim() || null;
|
||
if (data.email !== undefined) prefPatch.email = data.email.trim() || null;
|
||
|
||
if (Object.keys(prefPatch).length > 0) {
|
||
await this.prisma.userPreference.upsert({
|
||
where: { userId: playerId },
|
||
create: {
|
||
userId: playerId,
|
||
phone: prefPatch.phone ?? null,
|
||
email: prefPatch.email ?? null,
|
||
},
|
||
update: prefPatch,
|
||
});
|
||
}
|
||
|
||
return this.getDirectPlayerDetail(agentId, playerId);
|
||
}
|
||
|
||
async listAgentsAdmin(params?: {
|
||
page?: number;
|
||
pageSize?: number;
|
||
keyword?: string;
|
||
status?: string;
|
||
level?: 1 | 2;
|
||
parentAgentId?: bigint;
|
||
}) {
|
||
const page = Math.max(1, params?.page ?? 1);
|
||
const pageSize = Math.min(Math.max(1, params?.pageSize ?? 10), 100);
|
||
const skip = (page - 1) * pageSize;
|
||
|
||
const where: Prisma.AgentProfileWhereInput = {};
|
||
if (params?.level === 2) {
|
||
where.level = 2;
|
||
} else if (params?.level === 1) {
|
||
where.level = 1;
|
||
} else if (params?.parentAgentId !== undefined) {
|
||
where.parentAgentId = params.parentAgentId;
|
||
} else {
|
||
where.parentAgentId = null;
|
||
}
|
||
const kw = params?.keyword?.trim();
|
||
const status = params?.status?.trim();
|
||
const userWhere: Prisma.UserWhereInput = {};
|
||
if (status && ['ACTIVE', 'SUSPENDED'].includes(status)) {
|
||
userWhere.status = status;
|
||
}
|
||
if (kw) {
|
||
userWhere.username = { contains: kw, mode: 'insensitive' };
|
||
}
|
||
if (Object.keys(userWhere).length > 0) {
|
||
where.user = userWhere;
|
||
}
|
||
|
||
const [profiles, total] = await Promise.all([
|
||
this.prisma.agentProfile.findMany({
|
||
where,
|
||
include: {
|
||
user: { include: { preferences: true } },
|
||
},
|
||
orderBy: { createdAt: 'desc' },
|
||
skip,
|
||
take: pageSize,
|
||
}),
|
||
this.prisma.agentProfile.count({ where }),
|
||
]);
|
||
|
||
const agentIds = profiles.map((p) => p.userId);
|
||
const playerCounts =
|
||
agentIds.length > 0
|
||
? await this.prisma.user.groupBy({
|
||
by: ['parentId'],
|
||
where: {
|
||
userType: 'PLAYER',
|
||
parentId: { in: agentIds },
|
||
deletedAt: null,
|
||
},
|
||
_count: { _all: true },
|
||
})
|
||
: [];
|
||
|
||
const countMap = new Map(
|
||
playerCounts.map((g) => [g.parentId?.toString(), g._count._all]),
|
||
);
|
||
|
||
const childAgentCounts =
|
||
agentIds.length > 0
|
||
? await this.prisma.agentProfile.groupBy({
|
||
by: ['parentAgentId'],
|
||
where: { parentAgentId: { in: agentIds } },
|
||
_count: { _all: true },
|
||
})
|
||
: [];
|
||
const childAgentCountMap = new Map(
|
||
childAgentCounts.map((g) => [g.parentAgentId?.toString(), g._count._all]),
|
||
);
|
||
|
||
const parentAgentIds = [
|
||
...new Set(profiles.map((p) => p.parentAgentId).filter((id): id is bigint => id != null)),
|
||
];
|
||
const parentUsers =
|
||
parentAgentIds.length > 0
|
||
? await this.prisma.user.findMany({
|
||
where: { id: { in: parentAgentIds } },
|
||
select: { id: true, username: true },
|
||
})
|
||
: [];
|
||
const parentUsernameMap = new Map(parentUsers.map((u) => [u.id.toString(), u.username]));
|
||
|
||
const items = profiles.map((p) => {
|
||
const available = new Decimal(p.creditLimit).sub(p.usedCredit);
|
||
return {
|
||
id: p.id.toString(),
|
||
userId: p.userId.toString(),
|
||
username: p.user.username,
|
||
userStatus: p.user.status,
|
||
level: p.level,
|
||
status: p.status,
|
||
parentAgentId: p.parentAgentId?.toString() ?? null,
|
||
parentUsername: p.parentAgentId
|
||
? parentUsernameMap.get(p.parentAgentId.toString()) ?? null
|
||
: null,
|
||
creditLimit: p.creditLimit.toString(),
|
||
usedCredit: p.usedCredit.toString(),
|
||
availableCredit: available.toString(),
|
||
directPlayerLiability: p.directPlayerLiability.toString(),
|
||
childAgentExposure: p.childAgentExposure.toString(),
|
||
cashbackRate: p.cashbackRate.toString(),
|
||
maxSingleDeposit: p.maxSingleDeposit?.toString() ?? null,
|
||
maxDailyDeposit: p.maxDailyDeposit?.toString() ?? null,
|
||
directPlayerCount: countMap.get(p.userId.toString()) ?? 0,
|
||
childAgentCount: childAgentCountMap.get(p.userId.toString()) ?? 0,
|
||
phone: p.user.preferences?.phone ?? null,
|
||
email: p.user.preferences?.email ?? null,
|
||
locale: p.user.locale,
|
||
createdAt: p.createdAt,
|
||
updatedAt: p.updatedAt,
|
||
};
|
||
});
|
||
|
||
return { items, total, page, pageSize };
|
||
}
|
||
|
||
async getAgentAdminDetail(agentId: bigint) {
|
||
const profile = await this.prisma.agentProfile.findUnique({
|
||
where: { userId: agentId },
|
||
include: { user: { include: { preferences: true, auth: true } } },
|
||
});
|
||
if (!profile) throw new NotFoundException('代理不存在');
|
||
|
||
const [directPlayerCount, childAgentCount, recentCredits] = await Promise.all([
|
||
this.prisma.user.count({
|
||
where: { parentId: agentId, userType: 'PLAYER', deletedAt: null },
|
||
}),
|
||
this.prisma.agentProfile.count({
|
||
where: { parentAgentId: agentId },
|
||
}),
|
||
this.prisma.agentCreditTransaction.findMany({
|
||
where: { agentId },
|
||
orderBy: { createdAt: 'desc' },
|
||
take: 10,
|
||
}),
|
||
]);
|
||
|
||
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
|
||
let parentUsername: string | null = null;
|
||
if (profile.parentAgentId) {
|
||
const parent = await this.prisma.user.findUnique({
|
||
where: { id: profile.parentAgentId },
|
||
select: { username: true },
|
||
});
|
||
parentUsername = parent?.username ?? null;
|
||
}
|
||
|
||
return {
|
||
id: profile.id.toString(),
|
||
userId: profile.userId.toString(),
|
||
username: profile.user.username,
|
||
userStatus: profile.user.status,
|
||
level: profile.level,
|
||
status: profile.status,
|
||
parentAgentId: profile.parentAgentId?.toString() ?? null,
|
||
parentUsername,
|
||
creditLimit: profile.creditLimit.toString(),
|
||
usedCredit: profile.usedCredit.toString(),
|
||
availableCredit: available.toString(),
|
||
directPlayerLiability: profile.directPlayerLiability.toString(),
|
||
childAgentExposure: profile.childAgentExposure.toString(),
|
||
cashbackRate: profile.cashbackRate.toString(),
|
||
maxSingleDeposit: profile.maxSingleDeposit?.toString() ?? null,
|
||
maxDailyDeposit: profile.maxDailyDeposit?.toString() ?? null,
|
||
directPlayerCount,
|
||
childAgentCount,
|
||
phone: profile.user.preferences?.phone ?? null,
|
||
email: profile.user.preferences?.email ?? null,
|
||
managedPassword: profile.user.preferences?.managedPassword ?? null,
|
||
locale: profile.user.locale,
|
||
lastLoginAt: profile.user.auth?.lastLoginAt ?? null,
|
||
loginFailCount: profile.user.auth?.loginFailCount ?? 0,
|
||
createdAt: profile.createdAt,
|
||
updatedAt: profile.updatedAt,
|
||
recentCreditTransactions: recentCredits.map((t) => ({
|
||
id: t.id.toString(),
|
||
transactionType: t.transactionType,
|
||
amount: t.amount.toString(),
|
||
creditBefore: t.creditBefore.toString(),
|
||
creditAfter: t.creditAfter.toString(),
|
||
remark: t.remark,
|
||
createdAt: t.createdAt,
|
||
})),
|
||
};
|
||
}
|
||
|
||
async listCreditTransactions(params: {
|
||
page?: number;
|
||
pageSize?: number;
|
||
agentId?: bigint;
|
||
keyword?: string;
|
||
operatorKeyword?: string;
|
||
transactionType?: string;
|
||
scopedAgentIds?: bigint[];
|
||
dateFrom?: Date;
|
||
dateTo?: Date;
|
||
}) {
|
||
const page = Math.max(1, params.page ?? 1);
|
||
const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 20));
|
||
const skip = (page - 1) * pageSize;
|
||
|
||
const where: Prisma.AgentCreditTransactionWhereInput = {};
|
||
|
||
if (params.transactionType?.trim()) {
|
||
where.transactionType = params.transactionType.trim();
|
||
}
|
||
|
||
if (params.dateFrom || params.dateTo) {
|
||
where.createdAt = {};
|
||
if (params.dateFrom) where.createdAt.gte = params.dateFrom;
|
||
if (params.dateTo) where.createdAt.lte = params.dateTo;
|
||
}
|
||
|
||
const scopedIds = params.scopedAgentIds?.length ? params.scopedAgentIds : undefined;
|
||
|
||
const operatorKeyword = params.operatorKeyword?.trim();
|
||
if (operatorKeyword) {
|
||
const matchedOps = await this.prisma.user.findMany({
|
||
where: {
|
||
deletedAt: null,
|
||
username: { contains: operatorKeyword, mode: 'insensitive' },
|
||
},
|
||
select: { id: true },
|
||
take: 50,
|
||
});
|
||
const operatorIds = matchedOps.map((u) => u.id);
|
||
if (!operatorIds.length) {
|
||
return { items: [], total: 0, page, pageSize };
|
||
}
|
||
where.operatorId = { in: operatorIds };
|
||
}
|
||
|
||
const keyword = params.keyword?.trim();
|
||
if (keyword) {
|
||
const matched = await this.prisma.user.findMany({
|
||
where: {
|
||
userType: 'AGENT',
|
||
deletedAt: null,
|
||
username: { contains: keyword, mode: 'insensitive' },
|
||
},
|
||
select: { id: true },
|
||
take: 50,
|
||
});
|
||
let agentUserIds = matched.map((u) => u.id);
|
||
if (scopedIds) {
|
||
const scopedSet = new Set(scopedIds.map((id) => id.toString()));
|
||
agentUserIds = agentUserIds.filter((id) => scopedSet.has(id.toString()));
|
||
}
|
||
if (!agentUserIds.length) {
|
||
return { items: [], total: 0, page, pageSize };
|
||
}
|
||
if (params.agentId) {
|
||
if (!agentUserIds.some((id) => id === params.agentId)) {
|
||
return { items: [], total: 0, page, pageSize };
|
||
}
|
||
where.agentId = params.agentId;
|
||
} else {
|
||
where.agentId = { in: agentUserIds };
|
||
}
|
||
} else if (params.agentId) {
|
||
if (scopedIds && !scopedIds.some((id) => id === params.agentId)) {
|
||
return { items: [], total: 0, page, pageSize };
|
||
}
|
||
where.agentId = params.agentId;
|
||
} else if (scopedIds) {
|
||
where.agentId = { in: scopedIds };
|
||
}
|
||
|
||
const [rows, total] = await Promise.all([
|
||
this.prisma.agentCreditTransaction.findMany({
|
||
where,
|
||
orderBy: { createdAt: 'desc' },
|
||
skip,
|
||
take: pageSize,
|
||
}),
|
||
this.prisma.agentCreditTransaction.count({ where }),
|
||
]);
|
||
|
||
const agentIds = [...new Set(rows.map((r) => r.agentId))];
|
||
const operatorIds = [
|
||
...new Set(rows.map((r) => r.operatorId).filter((id): id is bigint => id != null)),
|
||
];
|
||
|
||
const [agentUsers, operators] = await Promise.all([
|
||
agentIds.length
|
||
? this.prisma.user.findMany({
|
||
where: { id: { in: agentIds } },
|
||
select: { id: true, username: true },
|
||
})
|
||
: [],
|
||
operatorIds.length
|
||
? this.prisma.user.findMany({
|
||
where: { id: { in: operatorIds } },
|
||
select: { id: true, username: true },
|
||
})
|
||
: [],
|
||
]);
|
||
|
||
const agentNameById = new Map(agentUsers.map((u) => [u.id.toString(), u.username]));
|
||
const operatorNameById = new Map(operators.map((u) => [u.id.toString(), u.username]));
|
||
|
||
return {
|
||
items: rows.map((row) => ({
|
||
id: row.id.toString(),
|
||
agentId: row.agentId.toString(),
|
||
agentUsername: agentNameById.get(row.agentId.toString()) ?? null,
|
||
transactionType: row.transactionType,
|
||
amount: row.amount.toString(),
|
||
creditBefore: row.creditBefore.toString(),
|
||
creditAfter: row.creditAfter.toString(),
|
||
referenceType: row.referenceType,
|
||
referenceId: row.referenceId,
|
||
operatorId: row.operatorId?.toString() ?? null,
|
||
operatorUsername: row.operatorId
|
||
? (operatorNameById.get(row.operatorId.toString()) ?? null)
|
||
: null,
|
||
requestId: row.requestId,
|
||
remark: row.remark,
|
||
createdAt: row.createdAt,
|
||
})),
|
||
total,
|
||
page,
|
||
pageSize,
|
||
};
|
||
}
|
||
|
||
async updateAgentAdmin(
|
||
agentId: bigint,
|
||
data: {
|
||
status?: string;
|
||
locale?: string;
|
||
phone?: string;
|
||
email?: string;
|
||
cashbackRate?: number;
|
||
maxSingleDeposit?: number | null;
|
||
maxDailyDeposit?: number | null;
|
||
username?: string;
|
||
password?: string;
|
||
freezeDirectPlayers?: boolean;
|
||
},
|
||
) {
|
||
const profile = await this.prisma.agentProfile.findUnique({
|
||
where: { userId: agentId },
|
||
include: { user: true },
|
||
});
|
||
if (!profile) throw new NotFoundException('代理不存在');
|
||
|
||
if (data.status && !['ACTIVE', 'SUSPENDED'].includes(data.status)) {
|
||
throw new BadRequestException('无效状态');
|
||
}
|
||
|
||
// Handle username change
|
||
if (data.username !== undefined) {
|
||
const nextUsername = data.username.trim();
|
||
if (!nextUsername) throw new BadRequestException('账号名称不能为空');
|
||
if (nextUsername !== profile.user.username) {
|
||
const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } });
|
||
if (taken) throw new BadRequestException('账号名称已被占用');
|
||
await this.prisma.user.update({
|
||
where: { id: agentId },
|
||
data: { username: nextUsername },
|
||
});
|
||
}
|
||
}
|
||
|
||
// Handle password change
|
||
if (data.password !== undefined) {
|
||
const nextPassword = data.password;
|
||
if (nextPassword.length < 8) throw new BadRequestException('密码至少 8 位');
|
||
const hash = await bcrypt.hash(nextPassword, 10);
|
||
await this.prisma.userAuth.upsert({
|
||
where: { userId: agentId },
|
||
create: { userId: agentId, passwordHash: hash, loginFailCount: 0, lockedUntil: null },
|
||
update: { passwordHash: hash, loginFailCount: 0, lockedUntil: null },
|
||
});
|
||
await this.prisma.userPreference.upsert({
|
||
where: { userId: agentId },
|
||
create: { userId: agentId, managedPassword: nextPassword },
|
||
update: { managedPassword: nextPassword },
|
||
});
|
||
}
|
||
|
||
// Handle status change (with optional cascade freeze)
|
||
if (data.status) {
|
||
await this.prisma.$transaction([
|
||
this.prisma.user.update({
|
||
where: { id: agentId },
|
||
data: { status: data.status },
|
||
}),
|
||
this.prisma.agentProfile.update({
|
||
where: { userId: agentId },
|
||
data: { status: data.status },
|
||
}),
|
||
]);
|
||
|
||
// 级联冻结:需后台开启且管理员/操作方显式勾选(MVP 默认不冻结玩家)
|
||
const suspendSettings = await this.systemConfig.getAgentSuspendSettings();
|
||
if (
|
||
data.status === 'SUSPENDED' &&
|
||
data.freezeDirectPlayers &&
|
||
suspendSettings.suspendFreezeDirectPlayers
|
||
) {
|
||
await this.prisma.user.updateMany({
|
||
where: { parentId: agentId, userType: 'PLAYER', deletedAt: null },
|
||
data: { status: 'SUSPENDED' },
|
||
});
|
||
}
|
||
}
|
||
|
||
if (data.locale) {
|
||
await this.prisma.user.update({
|
||
where: { id: agentId },
|
||
data: { locale: data.locale },
|
||
});
|
||
}
|
||
|
||
if (data.cashbackRate !== undefined) {
|
||
if (profile.parentAgentId) {
|
||
await this.assertChildAgentWithinParent(profile.parentAgentId, {
|
||
cashbackRate: data.cashbackRate,
|
||
});
|
||
}
|
||
await this.prisma.agentProfile.update({
|
||
where: { userId: agentId },
|
||
data: { cashbackRate: data.cashbackRate },
|
||
});
|
||
}
|
||
|
||
const limitPatch: {
|
||
maxSingleDeposit?: Decimal | null;
|
||
maxDailyDeposit?: Decimal | null;
|
||
} = {};
|
||
if (data.maxSingleDeposit !== undefined) {
|
||
limitPatch.maxSingleDeposit = this.normalizeOptionalLimit(data.maxSingleDeposit);
|
||
}
|
||
if (data.maxDailyDeposit !== undefined) {
|
||
limitPatch.maxDailyDeposit = this.normalizeOptionalLimit(data.maxDailyDeposit);
|
||
}
|
||
if (Object.keys(limitPatch).length > 0) {
|
||
if (profile.parentAgentId) {
|
||
await this.assertChildAgentWithinParent(profile.parentAgentId, {
|
||
maxSingleDeposit: limitPatch.maxSingleDeposit ?? undefined,
|
||
maxDailyDeposit: limitPatch.maxDailyDeposit ?? undefined,
|
||
});
|
||
}
|
||
await this.prisma.agentProfile.update({
|
||
where: { userId: agentId },
|
||
data: limitPatch,
|
||
});
|
||
}
|
||
|
||
if (data.phone !== undefined || data.email !== undefined || data.locale) {
|
||
const phone = data.phone !== undefined ? data.phone?.trim() || null : undefined;
|
||
const email = data.email !== undefined ? data.email?.trim() || null : undefined;
|
||
await this.prisma.userPreference.upsert({
|
||
where: { userId: agentId },
|
||
create: {
|
||
userId: agentId,
|
||
locale: data.locale ?? profile.user.locale,
|
||
phone: phone ?? null,
|
||
email: email ?? null,
|
||
},
|
||
update: {
|
||
...(data.locale ? { locale: data.locale } : {}),
|
||
...(phone !== undefined ? { phone } : {}),
|
||
...(email !== undefined ? { email } : {}),
|
||
},
|
||
});
|
||
}
|
||
|
||
return this.getAgentAdminDetail(agentId);
|
||
}
|
||
|
||
/** 可升级为一级代理的玩家(尚无代理档案) */
|
||
async listPromotablePlayers(keyword?: string) {
|
||
const q = keyword?.trim();
|
||
return this.prisma.user.findMany({
|
||
where: {
|
||
userType: 'PLAYER',
|
||
deletedAt: null,
|
||
agentProfile: null,
|
||
...(q
|
||
? { username: { contains: q, mode: 'insensitive' } }
|
||
: {}),
|
||
},
|
||
select: {
|
||
id: true,
|
||
username: true,
|
||
status: true,
|
||
parentId: true,
|
||
preferences: { select: { phone: true, email: true } },
|
||
parent: { select: { username: true } },
|
||
},
|
||
orderBy: { id: 'desc' },
|
||
take: 50,
|
||
});
|
||
}
|
||
|
||
/** 将已有玩家账号升级为一级代理(不新建用户) */
|
||
async promotePlayerToTier1Agent(
|
||
userId: bigint,
|
||
data: {
|
||
creditLimit: number;
|
||
cashbackRate?: number;
|
||
maxSingleDeposit?: number | null;
|
||
maxDailyDeposit?: number | null;
|
||
phone?: string;
|
||
email?: string;
|
||
},
|
||
) {
|
||
const user = await this.prisma.user.findUnique({
|
||
where: { id: userId },
|
||
include: { agentProfile: true, preferences: true },
|
||
});
|
||
if (!user || user.deletedAt) {
|
||
throw new NotFoundException('用户不存在');
|
||
}
|
||
if (user.userType !== 'PLAYER') {
|
||
throw new BadRequestException('仅玩家账号可设为代理');
|
||
}
|
||
if (user.agentProfile) {
|
||
throw new BadRequestException('该用户已是代理');
|
||
}
|
||
|
||
const oldParentId = user.parentId;
|
||
const phone =
|
||
data.phone !== undefined
|
||
? data.phone.trim() || null
|
||
: user.preferences?.phone ?? null;
|
||
const email =
|
||
data.email !== undefined
|
||
? data.email.trim() || null
|
||
: user.preferences?.email ?? null;
|
||
|
||
await this.prisma.$transaction(async (tx) => {
|
||
await tx.user.update({
|
||
where: { id: userId },
|
||
data: {
|
||
userType: 'AGENT',
|
||
agentLevel: 1,
|
||
parentId: null,
|
||
},
|
||
});
|
||
|
||
if (user.preferences) {
|
||
await tx.userPreference.update({
|
||
where: { userId },
|
||
data: { phone, email },
|
||
});
|
||
} else {
|
||
await tx.userPreference.create({
|
||
data: {
|
||
userId,
|
||
locale: user.locale,
|
||
phone,
|
||
email,
|
||
},
|
||
});
|
||
}
|
||
|
||
await tx.agentProfile.create({
|
||
data: {
|
||
userId,
|
||
level: 1,
|
||
parentAgentId: null,
|
||
creditLimit: data.creditLimit,
|
||
cashbackRate: data.cashbackRate ?? 0,
|
||
maxSingleDeposit: this.normalizeOptionalLimit(data.maxSingleDeposit),
|
||
maxDailyDeposit: this.normalizeOptionalLimit(data.maxDailyDeposit),
|
||
},
|
||
});
|
||
|
||
await tx.agentClosure.create({
|
||
data: { ancestorId: userId, descendantId: userId, depth: 0 },
|
||
});
|
||
});
|
||
|
||
if (oldParentId) {
|
||
await this.recalculateUsedCredit(oldParentId);
|
||
}
|
||
|
||
const updated = await this.prisma.user.findUnique({ where: { id: userId } });
|
||
if (!updated) {
|
||
throw new NotFoundException('用户不存在');
|
||
}
|
||
return updated;
|
||
}
|
||
|
||
async createAgent(
|
||
operatorId: bigint,
|
||
data: {
|
||
username: string;
|
||
password: string;
|
||
level: number;
|
||
parentAgentId?: bigint;
|
||
creditLimit?: number;
|
||
locale?: string;
|
||
phone?: string;
|
||
email?: string;
|
||
cashbackRate?: number;
|
||
maxSingleDeposit?: number | null;
|
||
maxDailyDeposit?: number | null;
|
||
},
|
||
) {
|
||
if (data.level !== 1 && data.level !== 2) {
|
||
throw new BadRequestException('Agent level must be 1 or 2');
|
||
}
|
||
if (data.level === 2 && !data.parentAgentId) {
|
||
throw new BadRequestException('Level 2 agent requires parent');
|
||
}
|
||
|
||
if (data.parentAgentId) {
|
||
await this.assertChildAgentWithinParent(data.parentAgentId, {
|
||
creditLimit: data.creditLimit ?? 0,
|
||
cashbackRate: data.cashbackRate ?? 0,
|
||
maxSingleDeposit: data.maxSingleDeposit,
|
||
maxDailyDeposit: data.maxDailyDeposit,
|
||
});
|
||
}
|
||
|
||
const maxSingleDeposit = this.normalizeOptionalLimit(data.maxSingleDeposit);
|
||
const maxDailyDeposit = this.normalizeOptionalLimit(data.maxDailyDeposit);
|
||
|
||
const hash = await this.auth.hashPassword(data.password);
|
||
|
||
return this.prisma.$transaction(async (tx) => {
|
||
const locale = data.locale ?? 'zh-CN';
|
||
const user = await tx.user.create({
|
||
data: {
|
||
username: data.username,
|
||
userType: 'AGENT',
|
||
parentId: data.parentAgentId,
|
||
agentLevel: data.level,
|
||
locale,
|
||
},
|
||
});
|
||
|
||
await tx.userAuth.create({
|
||
data: { userId: user.id, passwordHash: hash },
|
||
});
|
||
|
||
await tx.userPreference.create({
|
||
data: {
|
||
userId: user.id,
|
||
locale,
|
||
phone: data.phone?.trim() || null,
|
||
email: data.email?.trim() || null,
|
||
},
|
||
});
|
||
|
||
await tx.agentProfile.create({
|
||
data: {
|
||
userId: user.id,
|
||
level: data.level,
|
||
parentAgentId: data.parentAgentId,
|
||
creditLimit: data.creditLimit ?? 0,
|
||
cashbackRate: data.cashbackRate ?? 0,
|
||
maxSingleDeposit,
|
||
maxDailyDeposit,
|
||
},
|
||
});
|
||
|
||
// Build closure table
|
||
await tx.agentClosure.create({
|
||
data: { ancestorId: user.id, descendantId: user.id, depth: 0 },
|
||
});
|
||
|
||
if (data.parentAgentId) {
|
||
const ancestors = await tx.agentClosure.findMany({
|
||
where: { descendantId: data.parentAgentId },
|
||
});
|
||
for (const a of ancestors) {
|
||
await tx.agentClosure.create({
|
||
data: {
|
||
ancestorId: a.ancestorId,
|
||
descendantId: user.id,
|
||
depth: a.depth + 1,
|
||
},
|
||
});
|
||
}
|
||
if (data.parentAgentId) {
|
||
await this.recalculateUsedCredit(data.parentAgentId);
|
||
}
|
||
}
|
||
|
||
return user;
|
||
});
|
||
}
|
||
|
||
async createPlayer(
|
||
operatorId: bigint,
|
||
data: {
|
||
username: string;
|
||
password: string;
|
||
parentId?: bigint;
|
||
locale?: string;
|
||
phone?: string;
|
||
email?: string;
|
||
initialDeposit?: number;
|
||
depositRemark?: string;
|
||
depositRequestId?: string;
|
||
asTier1Agent?: boolean;
|
||
asSubAgent?: boolean;
|
||
parentAgentId?: bigint;
|
||
creditLimit?: number;
|
||
cashbackRate?: number;
|
||
maxSingleDeposit?: number | null;
|
||
maxDailyDeposit?: number | null;
|
||
},
|
||
) {
|
||
if (data.asTier1Agent) {
|
||
if (data.parentId != null) {
|
||
throw new BadRequestException('一级代理不可设置上级玩家');
|
||
}
|
||
if (data.initialDeposit && data.initialDeposit > 0) {
|
||
throw new BadRequestException('设为代理时请使用授信额度,勿填玩家初始余额');
|
||
}
|
||
return this.createAgent(operatorId, {
|
||
username: data.username,
|
||
password: data.password,
|
||
level: 1,
|
||
creditLimit: data.creditLimit ?? 0,
|
||
cashbackRate: data.cashbackRate ?? 0,
|
||
locale: data.locale,
|
||
phone: data.phone,
|
||
email: data.email,
|
||
});
|
||
}
|
||
|
||
if (data.asSubAgent) {
|
||
if (data.parentAgentId == null && data.parentId == null) {
|
||
throw new BadRequestException('二级代理必须指定上级代理');
|
||
}
|
||
if (data.initialDeposit && data.initialDeposit > 0) {
|
||
throw new BadRequestException('设为代理时请使用授信额度,勿填玩家初始余额');
|
||
}
|
||
const parentAgentId = data.parentAgentId ?? data.parentId;
|
||
return this.createAgent(operatorId, {
|
||
username: data.username,
|
||
password: data.password,
|
||
level: 2,
|
||
parentAgentId,
|
||
creditLimit: data.creditLimit ?? 0,
|
||
cashbackRate: data.cashbackRate ?? 0,
|
||
maxSingleDeposit: data.maxSingleDeposit,
|
||
maxDailyDeposit: data.maxDailyDeposit,
|
||
locale: data.locale,
|
||
phone: data.phone,
|
||
email: data.email,
|
||
});
|
||
}
|
||
|
||
let parentId: bigint | null = null;
|
||
if (data.parentId != null) {
|
||
const parent = await this.prisma.user.findUnique({ where: { id: data.parentId } });
|
||
if (!parent || parent.userType !== 'AGENT') {
|
||
throw new BadRequestException('上级必须为代理账号');
|
||
}
|
||
parentId = data.parentId;
|
||
|
||
const operator = await this.prisma.user.findUnique({ where: { id: operatorId } });
|
||
if (operator?.userType === 'AGENT' && parentId !== operatorId) {
|
||
throw new ForbiddenException('Can only create direct players');
|
||
}
|
||
}
|
||
|
||
try {
|
||
assertPlayerUsername(data.username);
|
||
} catch (e) {
|
||
throw new BadRequestException(e instanceof Error ? e.message : '玩家用户名格式无效');
|
||
}
|
||
|
||
const hash = await this.auth.hashPassword(data.password);
|
||
const locale = data.locale ?? 'zh-CN';
|
||
|
||
const user = await this.prisma.$transaction(async (tx) => {
|
||
const created = await tx.user.create({
|
||
data: {
|
||
username: data.username,
|
||
userType: 'PLAYER',
|
||
parentId,
|
||
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: data.phone?.trim() || null,
|
||
email: data.email?.trim() || null,
|
||
managedPassword: data.password,
|
||
},
|
||
});
|
||
|
||
return created;
|
||
});
|
||
|
||
if (parentId) {
|
||
await this.recalculateUsedCredit(parentId);
|
||
}
|
||
|
||
const initial = data.initialDeposit ?? 0;
|
||
if (initial > 0) {
|
||
const requestId =
|
||
data.depositRequestId ?? `admin-create-${user.id}-${Date.now()}`;
|
||
await this.assertPlayerParentCreditForDeposit(user.id, initial);
|
||
await this.wallet.deposit(
|
||
user.id,
|
||
initial,
|
||
operatorId,
|
||
data.depositRemark ?? '开户初始余额',
|
||
requestId,
|
||
);
|
||
if (parentId) {
|
||
await this.recalculateUsedCredit(parentId);
|
||
}
|
||
}
|
||
|
||
return user;
|
||
}
|
||
|
||
async getDirectPlayers(agentId: bigint) {
|
||
return this.prisma.user.findMany({
|
||
where: { parentId: agentId, userType: 'PLAYER' },
|
||
include: { wallet: true },
|
||
orderBy: { createdAt: 'desc' },
|
||
});
|
||
}
|
||
|
||
async getChildAgents(agentId: bigint) {
|
||
return this.prisma.agentProfile.findMany({
|
||
where: { parentAgentId: agentId },
|
||
include: { user: true },
|
||
});
|
||
}
|
||
|
||
async listChildAgentsSummary(parentAgentId: bigint) {
|
||
const profiles = await this.getChildAgents(parentAgentId);
|
||
const agentIds = profiles.map((p) => p.userId);
|
||
const playerCounts =
|
||
agentIds.length > 0
|
||
? await this.prisma.user.groupBy({
|
||
by: ['parentId'],
|
||
where: {
|
||
userType: 'PLAYER',
|
||
parentId: { in: agentIds },
|
||
deletedAt: null,
|
||
},
|
||
_count: { _all: true },
|
||
})
|
||
: [];
|
||
|
||
const countMap = new Map(
|
||
playerCounts.map((g) => [g.parentId?.toString(), g._count._all]),
|
||
);
|
||
|
||
return profiles.map((p) => {
|
||
const available = new Decimal(p.creditLimit).sub(p.usedCredit);
|
||
return {
|
||
userId: p.userId.toString(),
|
||
username: p.user.username,
|
||
userStatus: p.user.status,
|
||
status: p.status,
|
||
level: p.level,
|
||
creditLimit: dec(p.creditLimit),
|
||
usedCredit: dec(p.usedCredit),
|
||
availableCredit: available.toString(),
|
||
directPlayerCount: countMap.get(p.userId.toString()) ?? 0,
|
||
createdAt: p.createdAt,
|
||
};
|
||
});
|
||
}
|
||
|
||
async assertDirectChildAgent(parentAgentId: bigint, subAgentId: bigint) {
|
||
const profile = await this.prisma.agentProfile.findUnique({
|
||
where: { userId: subAgentId },
|
||
});
|
||
if (!profile || profile.parentAgentId !== parentAgentId) {
|
||
throw new ForbiddenException('Not your sub-agent');
|
||
}
|
||
return profile;
|
||
}
|
||
|
||
async getSubAgentForParent(parentAgentId: bigint, subAgentId: bigint) {
|
||
await this.assertDirectChildAgent(parentAgentId, subAgentId);
|
||
return this.getAgentAdminDetail(subAgentId);
|
||
}
|
||
|
||
async updateSubAgentForParent(
|
||
parentAgentId: bigint,
|
||
subAgentId: bigint,
|
||
data: {
|
||
username?: string;
|
||
password?: string;
|
||
phone?: string;
|
||
email?: string;
|
||
status?: string;
|
||
freezeDirectPlayers?: boolean;
|
||
},
|
||
) {
|
||
await this.assertDirectChildAgent(parentAgentId, subAgentId);
|
||
const { freezeDirectPlayers: _ignored, ...safeData } = data;
|
||
return this.updateAgentAdmin(subAgentId, safeData);
|
||
}
|
||
|
||
async getSubtreeAgentIds(agentId: bigint) {
|
||
const ids: bigint[] = [];
|
||
const queue: bigint[] = [agentId];
|
||
const seen = new Set<string>();
|
||
|
||
while (queue.length > 0) {
|
||
const current = queue.shift()!;
|
||
const key = current.toString();
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
ids.push(current);
|
||
|
||
const children = await this.prisma.agentProfile.findMany({
|
||
where: { parentAgentId: current },
|
||
select: { userId: true },
|
||
});
|
||
for (const child of children) {
|
||
queue.push(child.userId);
|
||
}
|
||
}
|
||
|
||
return ids;
|
||
}
|
||
|
||
async getReportSummary(agentId: bigint) {
|
||
const profile = await this.getProfile(agentId);
|
||
const agentIds = await this.getSubtreeAgentIds(agentId);
|
||
const betScope = { agentId: { in: agentIds } };
|
||
const playerWhere = {
|
||
parentId: agentId,
|
||
userType: 'PLAYER' as const,
|
||
deletedAt: null,
|
||
};
|
||
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
const yesterday = new Date(today.getTime() - 86400000);
|
||
|
||
const trend7d = await Promise.all(
|
||
Array.from({ length: 7 }, (_, i) => {
|
||
const dayStart = new Date(today);
|
||
dayStart.setDate(dayStart.getDate() - (6 - i));
|
||
const dayEnd = new Date(dayStart);
|
||
dayEnd.setDate(dayEnd.getDate() + 1);
|
||
return this.prisma.bet
|
||
.aggregate({
|
||
where: { ...betScope, placedAt: { gte: dayStart, lt: dayEnd } },
|
||
_sum: { stake: true, actualReturn: true },
|
||
_count: true,
|
||
})
|
||
.then((agg) => ({
|
||
date: dayStart.toISOString().slice(0, 10),
|
||
label: `${dayStart.getMonth() + 1}/${dayStart.getDate()}`,
|
||
betCount: agg._count,
|
||
stake: dec(agg._sum.stake),
|
||
payout: dec(agg._sum.actualReturn),
|
||
ggr: sub(agg._sum.stake, agg._sum.actualReturn),
|
||
}));
|
||
}),
|
||
);
|
||
|
||
const [
|
||
todayBets,
|
||
yesterdayBets,
|
||
pendingBets,
|
||
betStatusToday,
|
||
playerTotal,
|
||
playerActive,
|
||
playerSuspended,
|
||
newPlayersToday,
|
||
subAgentTotal,
|
||
subAgentsActive,
|
||
walletAgg,
|
||
recentBets,
|
||
recentPlayers,
|
||
] = await Promise.all([
|
||
this.prisma.bet.aggregate({
|
||
where: { ...betScope, placedAt: { gte: today } },
|
||
_sum: { stake: true, actualReturn: true },
|
||
_count: true,
|
||
}),
|
||
this.prisma.bet.aggregate({
|
||
where: { ...betScope, placedAt: { gte: yesterday, lt: today } },
|
||
_sum: { stake: true, actualReturn: true },
|
||
_count: true,
|
||
}),
|
||
this.prisma.bet.count({ where: { ...betScope, status: 'PENDING' } }),
|
||
this.prisma.bet.groupBy({
|
||
by: ['status'],
|
||
where: { ...betScope, placedAt: { gte: today } },
|
||
_count: { _all: true },
|
||
_sum: { stake: true },
|
||
}),
|
||
this.prisma.user.count({ where: playerWhere }),
|
||
this.prisma.user.count({ where: { ...playerWhere, status: 'ACTIVE' } }),
|
||
this.prisma.user.count({ where: { ...playerWhere, status: 'SUSPENDED' } }),
|
||
this.prisma.user.count({
|
||
where: { ...playerWhere, createdAt: { gte: today } },
|
||
}),
|
||
this.prisma.agentProfile.count({ where: { parentAgentId: agentId } }),
|
||
this.prisma.agentProfile.count({
|
||
where: { parentAgentId: agentId, status: 'ACTIVE' },
|
||
}),
|
||
this.prisma.wallet.aggregate({
|
||
where: { user: playerWhere },
|
||
_sum: { availableBalance: true, frozenBalance: true },
|
||
_count: { _all: true },
|
||
}),
|
||
this.prisma.bet.findMany({
|
||
where: betScope,
|
||
take: 8,
|
||
orderBy: { placedAt: 'desc' },
|
||
include: { user: { select: { username: true } } },
|
||
}),
|
||
this.prisma.user.findMany({
|
||
where: playerWhere,
|
||
take: 6,
|
||
orderBy: { createdAt: 'desc' },
|
||
select: {
|
||
id: true,
|
||
username: true,
|
||
status: true,
|
||
createdAt: true,
|
||
},
|
||
}),
|
||
]);
|
||
|
||
const todayBetByStatus: Record<string, { count: number; stake: string }> = {};
|
||
for (const g of betStatusToday) {
|
||
todayBetByStatus[g.status] = {
|
||
count: g._count._all,
|
||
stake: dec(g._sum.stake),
|
||
};
|
||
}
|
||
|
||
const creditLimit = profile.creditLimit ?? new Decimal(0);
|
||
const usedCredit = profile.usedCredit ?? new Decimal(0);
|
||
const availableCredit = new Decimal(creditLimit).sub(usedCredit);
|
||
|
||
return {
|
||
generatedAt: new Date().toISOString(),
|
||
trend7d,
|
||
today: {
|
||
betCount: todayBets._count,
|
||
stake: dec(todayBets._sum.stake),
|
||
payout: dec(todayBets._sum.actualReturn),
|
||
ggr: sub(todayBets._sum.stake, todayBets._sum.actualReturn),
|
||
newPlayers: newPlayersToday,
|
||
},
|
||
yesterday: {
|
||
betCount: yesterdayBets._count,
|
||
stake: dec(yesterdayBets._sum.stake),
|
||
payout: dec(yesterdayBets._sum.actualReturn),
|
||
ggr: sub(yesterdayBets._sum.stake, yesterdayBets._sum.actualReturn),
|
||
},
|
||
players: {
|
||
directTotal: playerTotal,
|
||
active: playerActive,
|
||
suspended: playerSuspended,
|
||
newToday: newPlayersToday,
|
||
},
|
||
subAgents: {
|
||
total: subAgentTotal,
|
||
active: subAgentsActive,
|
||
},
|
||
wallets: {
|
||
totalAvailable: dec(walletAgg._sum.availableBalance),
|
||
totalFrozen: dec(walletAgg._sum.frozenBalance),
|
||
playerWalletCount: walletAgg._count._all,
|
||
},
|
||
credit: {
|
||
creditLimit: dec(creditLimit),
|
||
usedCredit: dec(usedCredit),
|
||
availableCredit: availableCredit.toString(),
|
||
directPlayerLiability: dec(profile.directPlayerLiability),
|
||
childAgentExposure: dec(profile.childAgentExposure),
|
||
},
|
||
bets: {
|
||
pendingTotal: pendingBets,
|
||
todayByStatus: todayBetByStatus,
|
||
},
|
||
recentBets: recentBets.map((b) => ({
|
||
betNo: b.betNo,
|
||
username: b.user.username,
|
||
stake: dec(b.stake),
|
||
status: b.status,
|
||
placedAt: b.placedAt,
|
||
})),
|
||
recentPlayers: recentPlayers.map((p) => ({
|
||
id: p.id.toString(),
|
||
username: p.username,
|
||
status: p.status,
|
||
createdAt: p.createdAt,
|
||
})),
|
||
};
|
||
}
|
||
}
|