2098 lines
66 KiB
TypeScript
2098 lines
66 KiB
TypeScript
import { Injectable, BadRequestException } from '@nestjs/common';
|
||
import * as bcrypt from 'bcryptjs';
|
||
import { Prisma } from '@prisma/client';
|
||
import { PrismaService } from '../../shared/prisma/prisma.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, validateInitialDepositRemark } from '@thebet365/shared';
|
||
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
|
||
import { assignInviteCodeWithHistory } from '../../shared/common/invite-code.util';
|
||
import { AgentNetworkService, type AgentScope } from './agent-network.service';
|
||
import { AgentCreditService } from './agent-credit.service';
|
||
|
||
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 auth: AuthService,
|
||
private systemConfig: SystemConfigService,
|
||
private network: AgentNetworkService,
|
||
private credit: AgentCreditService,
|
||
) {}
|
||
|
||
async getMaxAgentLevel(): Promise<number> {
|
||
const settings = await this.systemConfig.getAgentHierarchySettings();
|
||
return settings.maxAgentLevel;
|
||
}
|
||
|
||
canCreateSubAgent(agentLevel: number, maxLevel: number): boolean {
|
||
if (maxLevel === 0) return true;
|
||
return agentLevel < maxLevel;
|
||
}
|
||
|
||
private async buildAgentAncestorChainMap(parentAgentIds: (bigint | null | undefined)[]) {
|
||
return this.network.buildAgentAncestorChainMap(parentAgentIds);
|
||
}
|
||
|
||
private async agentCashbackRateMap(agentUserIds: bigint[]): Promise<Map<string, string>> {
|
||
if (agentUserIds.length === 0) return new Map();
|
||
const profiles = await this.prisma.agentProfile.findMany({
|
||
where: { userId: { in: agentUserIds } },
|
||
select: { userId: true, cashbackRate: true },
|
||
});
|
||
return new Map(profiles.map((p) => [p.userId.toString(), p.cashbackRate.toString()]));
|
||
}
|
||
|
||
private async playerEffectiveCashbackRateMap(
|
||
players: Array<{ id: bigint; parentId: bigint | null }>,
|
||
parentCashbackMap: Map<string, string>,
|
||
): Promise<Map<string, string>> {
|
||
if (players.length === 0) return new Map();
|
||
const playerIds = players.map((p) => p.id);
|
||
const customRules = await this.prisma.cashbackRule.findMany({
|
||
where: {
|
||
targetType: 'USER',
|
||
targetId: { in: playerIds },
|
||
isActive: true,
|
||
marketType: null,
|
||
},
|
||
orderBy: { updatedAt: 'desc' },
|
||
});
|
||
const customMap = new Map<string, string>();
|
||
for (const rule of customRules) {
|
||
if (!rule.targetId) continue;
|
||
const id = rule.targetId.toString();
|
||
if (!customMap.has(id) && new Decimal(rule.rate).gt(0)) {
|
||
customMap.set(id, rule.rate.toString());
|
||
}
|
||
}
|
||
|
||
const result = new Map<string, string>();
|
||
for (const p of players) {
|
||
const key = p.id.toString();
|
||
const custom = customMap.get(key);
|
||
if (custom) {
|
||
result.set(key, custom);
|
||
continue;
|
||
}
|
||
if (p.parentId) {
|
||
result.set(key, parentCashbackMap.get(p.parentId.toString()) ?? '0');
|
||
} else {
|
||
result.set(key, '0');
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/** 代理端:从当前登录代理向下构建上级链,不包含更上层代理 */
|
||
private async buildScopedAncestorChainMap(
|
||
parentAgentIds: (bigint | null | undefined)[],
|
||
rootAgentId: bigint,
|
||
) {
|
||
return this.network.buildScopedAncestorChainMap(parentAgentIds, rootAgentId);
|
||
}
|
||
|
||
private async getAgentPortalScope(rootAgentId: bigint): Promise<AgentScope> {
|
||
return this.network.resolveScope(rootAgentId);
|
||
}
|
||
|
||
private assertAgentInPortalSubtree(
|
||
scope: AgentScope,
|
||
agentId: bigint,
|
||
) {
|
||
this.network.assertAgentInScope(scope, agentId);
|
||
}
|
||
|
||
/** 代理端:玩家必须挂在当前代理子树内某代理下(自己或下级) */
|
||
async requirePlayerInPortalSubtree(rootAgentId: bigint, playerId: bigint) {
|
||
return this.network.requirePlayerInPortalSubtree(rootAgentId, playerId);
|
||
}
|
||
|
||
private async validateAgentLevel(level: number, parentAgentId?: bigint) {
|
||
if (!Number.isInteger(level) || level < 1) {
|
||
throw appBadRequest('AGENT_LEVEL_INVALID');
|
||
}
|
||
|
||
const maxLevel = await this.getMaxAgentLevel();
|
||
if (maxLevel > 0 && level > maxLevel) {
|
||
throw appBadRequest('AGENT_MAX_LEVEL_REACHED');
|
||
}
|
||
|
||
if (level === 1) {
|
||
if (parentAgentId) throw appBadRequest('AGENT_LEVEL_ROOT_INVALID');
|
||
return;
|
||
}
|
||
|
||
if (!parentAgentId) {
|
||
throw appBadRequest('LEVEL2_REQUIRES_PARENT');
|
||
}
|
||
|
||
const parent = await this.prisma.agentProfile.findUnique({
|
||
where: { userId: parentAgentId },
|
||
});
|
||
if (!parent) throw appBadRequest('PARENT_AGENT_NOT_FOUND');
|
||
if (parent.level !== level - 1) {
|
||
throw appBadRequest('AGENT_PARENT_LEVEL_MISMATCH');
|
||
}
|
||
if (maxLevel > 0 && !this.canCreateSubAgent(parent.level, maxLevel)) {
|
||
throw appBadRequest('AGENT_MAX_LEVEL_REACHED');
|
||
}
|
||
}
|
||
|
||
async getProfile(agentId: bigint) {
|
||
return this.credit.getProfile(agentId);
|
||
}
|
||
|
||
async recalculateUsedCredit(agentId: bigint) {
|
||
return this.credit.recalculateUsedCredit(agentId);
|
||
}
|
||
|
||
async adjustCredit(
|
||
agentId: bigint,
|
||
amount: Decimal | number,
|
||
operatorId: bigint,
|
||
requestId: string,
|
||
remark?: string,
|
||
) {
|
||
return this.credit.adjustCredit(agentId, amount, operatorId, requestId, remark);
|
||
}
|
||
|
||
/** 代理只能操作直属玩家(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 appNotFound('PLAYER_NOT_FOUND');
|
||
if (player.parentId !== agentId) {
|
||
throw appForbidden('MANAGE_DIRECT_PLAYERS_ONLY');
|
||
}
|
||
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 appBadRequest('PARENT_AGENT_NOT_FOUND');
|
||
|
||
if (child.creditLimit !== undefined) {
|
||
const limit = new Decimal(child.creditLimit);
|
||
if (limit.lt(0)) throw appBadRequest('CREDIT_LIMIT_NEGATIVE');
|
||
if (limit.gt(parent.creditLimit)) {
|
||
throw appBadRequest('CREDIT_EXCEEDS_PARENT');
|
||
}
|
||
}
|
||
|
||
if (child.cashbackRate !== undefined) {
|
||
const rate = new Decimal(child.cashbackRate);
|
||
if (rate.lt(0)) throw appBadRequest('CASHBACK_RATE_NEGATIVE');
|
||
if (rate.gt(parent.cashbackRate)) {
|
||
throw appBadRequest('CASHBACK_RATE_EXCEEDS_PARENT');
|
||
}
|
||
}
|
||
|
||
if (child.maxSingleDeposit != null && parent.maxSingleDeposit != null) {
|
||
if (new Decimal(child.maxSingleDeposit).gt(parent.maxSingleDeposit)) {
|
||
throw appBadRequest('BET_LIMIT_EXCEEDS_PARENT');
|
||
}
|
||
}
|
||
if (child.maxSingleDeposit != null && new Decimal(child.maxSingleDeposit).lt(0)) {
|
||
throw appBadRequest('BET_LIMIT_NEGATIVE');
|
||
}
|
||
|
||
if (child.maxDailyDeposit != null && parent.maxDailyDeposit != null) {
|
||
if (new Decimal(child.maxDailyDeposit).gt(parent.maxDailyDeposit)) {
|
||
throw appBadRequest('DAILY_LIMIT_EXCEEDS_PARENT');
|
||
}
|
||
}
|
||
if (child.maxDailyDeposit != null && new Decimal(child.maxDailyDeposit).lt(0)) {
|
||
throw appBadRequest('DAILY_LIMIT_NEGATIVE');
|
||
}
|
||
}
|
||
|
||
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) {
|
||
return this.credit.assertPlayerParentCreditForDeposit(playerId, amount);
|
||
}
|
||
|
||
/** 管理员给玩家上分:校验上级授信后入账,并刷新代理占用额度 */
|
||
async adminDepositToPlayer(
|
||
playerId: bigint,
|
||
amount: number,
|
||
operatorId: bigint,
|
||
remark?: string,
|
||
requestId?: string,
|
||
) {
|
||
return this.credit.adminDepositToPlayer(playerId, amount, operatorId, remark, requestId);
|
||
}
|
||
|
||
/** 管理员给玩家下分:扣款后刷新上级代理占用额度 */
|
||
async adminWithdrawFromPlayer(
|
||
playerId: bigint,
|
||
amount: number,
|
||
operatorId: bigint,
|
||
remark?: string,
|
||
requestId?: string,
|
||
) {
|
||
return this.credit.adminWithdrawFromPlayer(playerId, amount, operatorId, remark, requestId);
|
||
}
|
||
|
||
/** 上下分弹窗:玩家余额 + 授信代理可用额度/限额上下文 */
|
||
async getPlayerTransferContext(
|
||
playerId: bigint,
|
||
options: { forAdmin?: boolean; actingAgentId?: bigint } = {},
|
||
) {
|
||
return this.credit.getPlayerTransferContext(playerId, options);
|
||
}
|
||
|
||
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 appBadRequest('AGENT_SINGLE_TOPUP_LIMIT');
|
||
}
|
||
|
||
if (maxDailyDeposit) {
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
const dailyAgg = await this.prisma.walletTransaction.aggregate({
|
||
where: {
|
||
operatorId: creditAgentId,
|
||
transactionType: { in: ['AGENT_DEPOSIT', 'MANUAL_DEPOSIT'] },
|
||
createdAt: { gte: today },
|
||
},
|
||
_sum: { amount: true },
|
||
});
|
||
const dailyTotal = new Decimal(dailyAgg._sum.amount ?? 0).add(amount);
|
||
if (dailyTotal.gt(maxDailyDeposit)) {
|
||
throw appBadRequest('AGENT_DAILY_TOPUP_LIMIT');
|
||
}
|
||
}
|
||
}
|
||
|
||
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 appBadRequest('INSUFFICIENT_AGENT_CREDIT');
|
||
}
|
||
}
|
||
|
||
async depositToPlayer(
|
||
agentId: bigint,
|
||
playerId: bigint,
|
||
amount: number,
|
||
requestId: string,
|
||
remark?: string,
|
||
) {
|
||
return this.credit.depositToPlayer(agentId, playerId, amount, requestId, remark);
|
||
}
|
||
|
||
async withdrawFromPlayer(
|
||
agentId: bigint,
|
||
playerId: bigint,
|
||
amount: number,
|
||
requestId: string,
|
||
remark?: string,
|
||
) {
|
||
return this.credit.withdrawFromPlayer(agentId, playerId, amount, requestId, remark);
|
||
}
|
||
|
||
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 appBadRequest('INVALID_STATUS');
|
||
}
|
||
|
||
if (data.username !== undefined) {
|
||
const nextUsername = data.username.trim();
|
||
if (!nextUsername) throw appBadRequest('USERNAME_REQUIRED');
|
||
try {
|
||
assertPlayerUsername(nextUsername);
|
||
} catch {
|
||
throw appBadRequest('USERNAME_FORMAT_INVALID');
|
||
}
|
||
if (nextUsername !== user.username) {
|
||
const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } });
|
||
if (taken) throw appBadRequest('USERNAME_TAKEN');
|
||
await this.prisma.user.update({
|
||
where: { id: playerId },
|
||
data: { username: nextUsername },
|
||
});
|
||
}
|
||
}
|
||
|
||
if (data.password !== undefined) {
|
||
const nextPassword = data.password;
|
||
if (nextPassword.length < 8) throw appBadRequest('PASSWORD_MIN_LENGTH');
|
||
if (!user.auth) throw appBadRequest('AUTH_INFO_MISSING');
|
||
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 deleteDirectPlayer(agentId: bigint, playerId: bigint) {
|
||
await this.requireDirectPlayer(agentId, playerId);
|
||
|
||
const betCount = await this.prisma.bet.count({
|
||
where: {
|
||
userId: playerId,
|
||
status: 'PENDING',
|
||
},
|
||
});
|
||
if (betCount > 0) {
|
||
throw appBadRequest('PLAYER_HAS_PENDING_BETS');
|
||
}
|
||
|
||
const wallet = await this.prisma.wallet.findUnique({ where: { userId: playerId } });
|
||
if (wallet) {
|
||
const available = new Decimal(wallet.availableBalance);
|
||
const frozen = new Decimal(wallet.frozenBalance);
|
||
if (available.gt(0) || frozen.gt(0)) {
|
||
throw appBadRequest('PLAYER_HAS_BALANCE');
|
||
}
|
||
}
|
||
|
||
return this.prisma.user.update({
|
||
where: { id: playerId },
|
||
data: { deletedAt: new Date(), status: 'SUSPENDED' },
|
||
});
|
||
}
|
||
|
||
async listAgentsAdmin(params?: {
|
||
page?: number;
|
||
pageSize?: number;
|
||
keyword?: string;
|
||
status?: string;
|
||
level?: number;
|
||
minLevel?: number;
|
||
maxLevel?: number;
|
||
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 != null) {
|
||
where.level = params.level;
|
||
} else if (params?.minLevel != null || params?.maxLevel != null) {
|
||
const levelFilter: { gte?: number; lte?: number } = {};
|
||
if (params.minLevel != null) levelFilter.gte = params.minLevel;
|
||
if (params.maxLevel != null) levelFilter.lte = params.maxLevel;
|
||
where.level = levelFilter;
|
||
} 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 parentChainMap = await this.buildAgentAncestorChainMap(
|
||
profiles.map((p) => p.parentAgentId),
|
||
);
|
||
|
||
const items = profiles.map((p) => {
|
||
const available = new Decimal(p.creditLimit).sub(p.usedCredit);
|
||
const parentChain = p.parentAgentId
|
||
? (parentChainMap.get(p.parentAgentId.toString()) ?? [])
|
||
: [];
|
||
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,
|
||
parentChain,
|
||
parentChainLabel: parentChain.length ? parentChain.join(' / ') : 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,
|
||
inviteCode: p.user.inviteCode ?? null,
|
||
createdAt: p.createdAt,
|
||
updatedAt: p.updatedAt,
|
||
};
|
||
});
|
||
|
||
return { items, total, page, pageSize };
|
||
}
|
||
|
||
async countAgentsByLevel(): Promise<Record<number, number>> {
|
||
const groups = await this.prisma.agentProfile.groupBy({
|
||
by: ['level'],
|
||
where: { user: { deletedAt: null } },
|
||
_count: { _all: true },
|
||
});
|
||
const out: Record<number, number> = {};
|
||
for (const g of groups) {
|
||
out[g.level] = g._count._all;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
async getAgentAdminDetail(agentId: bigint) {
|
||
const profile = await this.prisma.agentProfile.findUnique({
|
||
where: { userId: agentId },
|
||
include: { user: { include: { preferences: true, auth: true } } },
|
||
});
|
||
if (!profile) throw appNotFound('AGENT_NOT_FOUND');
|
||
|
||
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,
|
||
inviteCode: profile.user.inviteCode ?? null,
|
||
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;
|
||
blockDirectPlayerLogin?: boolean;
|
||
unfreezeDirectPlayers?: boolean;
|
||
},
|
||
) {
|
||
const profile = await this.prisma.agentProfile.findUnique({
|
||
where: { userId: agentId },
|
||
include: { user: true },
|
||
});
|
||
if (!profile) throw appNotFound('AGENT_NOT_FOUND');
|
||
|
||
if (data.status && !['ACTIVE', 'SUSPENDED'].includes(data.status)) {
|
||
throw appBadRequest('INVALID_STATUS');
|
||
}
|
||
|
||
// Handle username change
|
||
if (data.username !== undefined) {
|
||
const nextUsername = data.username.trim();
|
||
if (!nextUsername) throw appBadRequest('USERNAME_REQUIRED');
|
||
if (nextUsername !== profile.user.username) {
|
||
const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } });
|
||
if (taken) throw appBadRequest('USERNAME_TAKEN');
|
||
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 appBadRequest('PASSWORD_MIN_LENGTH');
|
||
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 (per-action cascade freeze / login block)
|
||
if (data.status) {
|
||
const profilePatch: Prisma.AgentProfileUpdateInput = { status: data.status };
|
||
if (data.status === 'SUSPENDED') {
|
||
profilePatch.blockDirectPlayerLogin = data.blockDirectPlayerLogin === true;
|
||
} else if (data.status === 'ACTIVE') {
|
||
profilePatch.blockDirectPlayerLogin = false;
|
||
}
|
||
|
||
await this.prisma.$transaction([
|
||
this.prisma.user.update({
|
||
where: { id: agentId },
|
||
data: { status: data.status },
|
||
}),
|
||
this.prisma.agentProfile.update({
|
||
where: { userId: agentId },
|
||
data: profilePatch,
|
||
}),
|
||
]);
|
||
|
||
if (data.status === 'SUSPENDED' && data.freezeDirectPlayers) {
|
||
await this.prisma.user.updateMany({
|
||
where: { parentId: agentId, userType: 'PLAYER', deletedAt: null },
|
||
data: { status: 'SUSPENDED' },
|
||
});
|
||
}
|
||
|
||
if (data.status === 'ACTIVE' && data.unfreezeDirectPlayers) {
|
||
await this.prisma.user.updateMany({
|
||
where: { parentId: agentId, userType: 'PLAYER', deletedAt: null, status: 'SUSPENDED' },
|
||
data: { status: 'ACTIVE' },
|
||
});
|
||
}
|
||
}
|
||
|
||
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 appNotFound('USER_NOT_FOUND');
|
||
}
|
||
if (user.userType !== 'PLAYER') {
|
||
throw appBadRequest('PROMOTE_PLAYER_ONLY');
|
||
}
|
||
if (user.agentProfile) {
|
||
throw appBadRequest('ALREADY_AGENT');
|
||
}
|
||
|
||
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,
|
||
},
|
||
});
|
||
await assignInviteCodeWithHistory(tx, userId);
|
||
|
||
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 appNotFound('USER_NOT_FOUND');
|
||
}
|
||
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;
|
||
},
|
||
) {
|
||
await this.validateAgentLevel(data.level, data.parentAgentId);
|
||
|
||
let resolvedCashbackRate = data.cashbackRate ?? 0;
|
||
if (data.parentAgentId) {
|
||
const parentProfile = await this.prisma.agentProfile.findUnique({
|
||
where: { userId: data.parentAgentId },
|
||
select: { cashbackRate: true },
|
||
});
|
||
resolvedCashbackRate =
|
||
data.cashbackRate ?? (parentProfile ? Number(parentProfile.cashbackRate) : 0);
|
||
await this.assertChildAgentWithinParent(data.parentAgentId, {
|
||
creditLimit: data.creditLimit ?? 0,
|
||
cashbackRate: resolvedCashbackRate,
|
||
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);
|
||
|
||
const user = await 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 assignInviteCodeWithHistory(tx, user.id);
|
||
|
||
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: resolvedCashbackRate,
|
||
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,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
return user;
|
||
});
|
||
|
||
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 appBadRequest('TIER1_NO_PARENT_PLAYER');
|
||
}
|
||
if (data.initialDeposit && data.initialDeposit > 0) {
|
||
throw appBadRequest('PROMOTE_USE_CREDIT_NOT_BALANCE');
|
||
}
|
||
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 appBadRequest('TIER2_REQUIRES_PARENT_AGENT');
|
||
}
|
||
if (data.initialDeposit && data.initialDeposit > 0) {
|
||
throw appBadRequest('PROMOTE_USE_CREDIT_NOT_BALANCE');
|
||
}
|
||
const parentAgentId = data.parentAgentId ?? data.parentId;
|
||
if (parentAgentId == null) {
|
||
throw appBadRequest('TIER2_REQUIRES_PARENT_AGENT');
|
||
}
|
||
const parentProfile = await this.prisma.agentProfile.findUnique({
|
||
where: { userId: parentAgentId },
|
||
});
|
||
if (!parentProfile) throw appBadRequest('PARENT_AGENT_NOT_FOUND');
|
||
return this.createAgent(operatorId, {
|
||
username: data.username,
|
||
password: data.password,
|
||
level: parentProfile.level + 1,
|
||
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 appBadRequest('PARENT_MUST_BE_AGENT');
|
||
}
|
||
parentId = data.parentId;
|
||
|
||
const operator = await this.prisma.user.findUnique({ where: { id: operatorId } });
|
||
if (operator?.userType === 'AGENT' && parentId !== operatorId) {
|
||
throw appForbidden('CREATE_DIRECT_PLAYERS_ONLY');
|
||
}
|
||
}
|
||
|
||
try {
|
||
assertPlayerUsername(data.username);
|
||
} catch {
|
||
throw appBadRequest('USERNAME_FORMAT_INVALID');
|
||
}
|
||
|
||
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 remarkResult = validateInitialDepositRemark(initial, data.depositRemark, 'admin');
|
||
if (!remarkResult.ok) throw appBadRequest(remarkResult.code);
|
||
const requestId =
|
||
data.depositRequestId ?? `admin-create-${user.id}-${Date.now()}`;
|
||
await this.credit.adminDepositToPlayer(user.id, initial, operatorId, remarkResult.remark, requestId);
|
||
}
|
||
|
||
return user;
|
||
}
|
||
|
||
async getPortalAgentDirectPlayers(
|
||
rootAgentId: bigint,
|
||
targetAgentId: bigint,
|
||
opts?: { page?: number; pageSize?: number },
|
||
) {
|
||
await this.assertDescendantAgent(rootAgentId, targetAgentId);
|
||
const page = Math.max(1, opts?.page ?? 1);
|
||
const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 20));
|
||
const { items: players, total } = await this.getDirectPlayers(targetAgentId, {
|
||
page,
|
||
pageSize,
|
||
});
|
||
const profile = await this.prisma.agentProfile.findUnique({
|
||
where: { userId: targetAgentId },
|
||
select: {
|
||
cashbackRate: true,
|
||
user: { select: { username: true } },
|
||
},
|
||
});
|
||
const rootKey = rootAgentId.toString();
|
||
const targetKey = targetAgentId.toString();
|
||
const parentAgentUsername = profile?.user.username ?? '—';
|
||
const parentCashbackMap = await this.agentCashbackRateMap([targetAgentId]);
|
||
const playerCashbackMap = await this.playerEffectiveCashbackRateMap(
|
||
players.map((p) => ({ id: BigInt(p.id), parentId: targetAgentId })),
|
||
parentCashbackMap,
|
||
);
|
||
const mapped = players.map((p) => ({
|
||
...p,
|
||
parentAgentId: targetKey,
|
||
parentAgentUsername,
|
||
cashbackRate: playerCashbackMap.get(p.id) ?? '0',
|
||
inChain: true,
|
||
isDirect: targetKey === rootKey,
|
||
}));
|
||
return { items: mapped, total, page, pageSize };
|
||
}
|
||
|
||
async getDirectPlayers(
|
||
agentId: bigint,
|
||
opts?: { page?: number; pageSize?: number },
|
||
) {
|
||
const where = { parentId: agentId, userType: 'PLAYER' as const, deletedAt: null };
|
||
const page = Math.max(1, opts?.page ?? 1);
|
||
const pageSize = Math.min(100, Math.max(1, opts?.pageSize ?? 20));
|
||
const [total, rows] = await Promise.all([
|
||
this.prisma.user.count({ where }),
|
||
this.prisma.user.findMany({
|
||
where,
|
||
include: {
|
||
wallet: true,
|
||
usedInvite: { select: { code: true } },
|
||
},
|
||
orderBy: { createdAt: 'desc' },
|
||
skip: (page - 1) * pageSize,
|
||
take: pageSize,
|
||
}),
|
||
]);
|
||
const items = rows.map((u) => ({
|
||
id: u.id.toString(),
|
||
username: u.username,
|
||
status: u.status,
|
||
createdAt: u.createdAt,
|
||
inviteCode: u.usedInvite?.code ?? null,
|
||
wallet: u.wallet
|
||
? {
|
||
availableBalance: u.wallet.availableBalance.toString(),
|
||
frozenBalance: u.wallet.frozenBalance.toString(),
|
||
}
|
||
: undefined,
|
||
}));
|
||
return { items, total, page, pageSize };
|
||
}
|
||
|
||
async getChildAgents(agentId: bigint) {
|
||
return this.network.getChildAgents(agentId);
|
||
}
|
||
|
||
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,
|
||
parentAgentId: p.parentAgentId?.toString() ?? null,
|
||
cashbackRate: p.cashbackRate.toString(),
|
||
creditLimit: dec(p.creditLimit),
|
||
usedCredit: dec(p.usedCredit),
|
||
availableCredit: available.toString(),
|
||
directPlayerCount: countMap.get(p.userId.toString()) ?? 0,
|
||
createdAt: p.createdAt,
|
||
};
|
||
});
|
||
}
|
||
|
||
/** Read-only downline under a direct sub-agent (all descendant agents + subtree players). */
|
||
async getDirectChildDownlineView(parentAgentId: bigint, subAgentId: bigint) {
|
||
await this.assertDirectChildAgent(parentAgentId, subAgentId);
|
||
|
||
const subtreeIds = await this.getSubtreeAgentIds(subAgentId);
|
||
const descendantAgentIds = subtreeIds.filter((id) => id !== subAgentId);
|
||
|
||
let agents: Array<{
|
||
userId: string;
|
||
username: string;
|
||
userStatus: string;
|
||
status: string;
|
||
level: number;
|
||
parentUsername: string;
|
||
creditLimit: string;
|
||
usedCredit: string;
|
||
availableCredit: string;
|
||
directPlayerCount: number;
|
||
createdAt: Date;
|
||
}> = [];
|
||
|
||
if (descendantAgentIds.length > 0) {
|
||
const profiles = await this.prisma.agentProfile.findMany({
|
||
where: { userId: { in: descendantAgentIds } },
|
||
include: { user: true },
|
||
orderBy: [{ level: 'asc' }, { createdAt: 'desc' }],
|
||
});
|
||
|
||
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 parentNameMap = new Map(
|
||
parentUsers.map((u) => [u.id.toString(), u.username]),
|
||
);
|
||
|
||
const playerCounts = await this.prisma.user.groupBy({
|
||
by: ['parentId'],
|
||
where: {
|
||
userType: 'PLAYER',
|
||
parentId: { in: descendantAgentIds },
|
||
deletedAt: null,
|
||
},
|
||
_count: { _all: true },
|
||
});
|
||
const countMap = new Map(
|
||
playerCounts.map((g) => [g.parentId?.toString(), g._count._all]),
|
||
);
|
||
|
||
agents = 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,
|
||
parentUsername: p.parentAgentId
|
||
? parentNameMap.get(p.parentAgentId.toString()) ?? '—'
|
||
: '—',
|
||
cashbackRate: p.cashbackRate.toString(),
|
||
creditLimit: dec(p.creditLimit),
|
||
usedCredit: dec(p.usedCredit),
|
||
availableCredit: available.toString(),
|
||
directPlayerCount: countMap.get(p.userId.toString()) ?? 0,
|
||
createdAt: p.createdAt,
|
||
};
|
||
});
|
||
}
|
||
|
||
const playerRows =
|
||
subtreeIds.length > 0
|
||
? await this.prisma.user.findMany({
|
||
where: {
|
||
userType: 'PLAYER',
|
||
deletedAt: null,
|
||
parentId: { in: subtreeIds },
|
||
},
|
||
include: {
|
||
wallet: true,
|
||
usedInvite: { select: { code: true } },
|
||
parent: { select: { username: true } },
|
||
},
|
||
orderBy: { createdAt: 'desc' },
|
||
})
|
||
: [];
|
||
|
||
const parentAgentIdsForPlayers = [
|
||
...new Set(
|
||
playerRows
|
||
.map((u) => u.parentId)
|
||
.filter((id): id is bigint => id != null),
|
||
),
|
||
];
|
||
const parentCashbackMap = await this.agentCashbackRateMap(parentAgentIdsForPlayers);
|
||
const playerCashbackMap = await this.playerEffectiveCashbackRateMap(
|
||
playerRows.map((u) => ({ id: u.id, parentId: u.parentId })),
|
||
parentCashbackMap,
|
||
);
|
||
|
||
const players = playerRows.map((u) => ({
|
||
id: u.id.toString(),
|
||
username: u.username,
|
||
status: u.status,
|
||
createdAt: u.createdAt,
|
||
inviteCode: u.usedInvite?.code ?? null,
|
||
parentAgentUsername: u.parent?.username ?? '—',
|
||
cashbackRate: playerCashbackMap.get(u.id.toString()) ?? '0',
|
||
wallet: u.wallet
|
||
? {
|
||
availableBalance: u.wallet.availableBalance.toString(),
|
||
frozenBalance: u.wallet.frozenBalance.toString(),
|
||
}
|
||
: undefined,
|
||
}));
|
||
|
||
return {
|
||
agents: agents.map((a) => ({
|
||
...a,
|
||
createdAt: a.createdAt.toISOString(),
|
||
})),
|
||
players,
|
||
};
|
||
}
|
||
|
||
async assertDescendantAgent(rootAgentId: bigint, targetAgentId: bigint) {
|
||
return this.network.assertDescendantAgent(rootAgentId, targetAgentId);
|
||
}
|
||
|
||
async countSubtreeAgentsByLevel(rootAgentId: bigint) {
|
||
const scope = await this.getAgentPortalScope(rootAgentId);
|
||
if (scope.descendantIds.length === 0) return {} as Record<number, number>;
|
||
|
||
const groups = await this.prisma.agentProfile.groupBy({
|
||
by: ['level'],
|
||
where: {
|
||
userId: { in: scope.descendantIds },
|
||
level: { gt: scope.rootLevel },
|
||
user: { deletedAt: null },
|
||
},
|
||
_count: { _all: true },
|
||
});
|
||
const out: Record<number, number> = {};
|
||
for (const g of groups) {
|
||
if (g.level > scope.rootLevel) {
|
||
out[g.level] = g._count._all;
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
async listSubtreeAgentsAtLevel(
|
||
rootAgentId: bigint,
|
||
level: number,
|
||
params?: { page?: number; pageSize?: number; keyword?: string; status?: string },
|
||
) {
|
||
const page = Math.max(1, params?.page ?? 1);
|
||
const pageSize = Math.min(Math.max(1, params?.pageSize ?? 20), 100);
|
||
const skip = (page - 1) * pageSize;
|
||
|
||
const scope = await this.getAgentPortalScope(rootAgentId);
|
||
if (level <= scope.rootLevel || scope.descendantIds.length === 0) {
|
||
return { items: [], total: 0, page, pageSize };
|
||
}
|
||
|
||
const where: Prisma.AgentProfileWhereInput = {
|
||
userId: { in: scope.descendantIds },
|
||
level,
|
||
};
|
||
const kw = params?.keyword?.trim();
|
||
const status = params?.status?.trim();
|
||
const userWhere: Prisma.UserWhereInput = { deletedAt: null };
|
||
if (status && ['ACTIVE', 'SUSPENDED'].includes(status)) {
|
||
userWhere.status = status;
|
||
}
|
||
if (kw) {
|
||
userWhere.username = { contains: kw, mode: 'insensitive' };
|
||
}
|
||
where.user = userWhere;
|
||
|
||
const [profiles, total] = await Promise.all([
|
||
this.prisma.agentProfile.findMany({
|
||
where,
|
||
include: { user: 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 parentAgentIds = [
|
||
...new Set(
|
||
profiles
|
||
.map((p) => p.parentAgentId)
|
||
.filter((id): id is bigint => id != null && scope.subtreeIdSet.has(id.toString())),
|
||
),
|
||
];
|
||
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 parentChainMap = await this.buildScopedAncestorChainMap(
|
||
profiles.map((p) => p.parentAgentId),
|
||
rootAgentId,
|
||
);
|
||
|
||
const items = profiles.map((p) => {
|
||
const available = new Decimal(p.creditLimit).sub(p.usedCredit);
|
||
const parentChain = p.parentAgentId
|
||
? (parentChainMap.get(p.parentAgentId.toString()) ?? [])
|
||
: [];
|
||
const parentUsername =
|
||
p.parentAgentId && scope.subtreeIdSet.has(p.parentAgentId.toString())
|
||
? (parentUsernameMap.get(p.parentAgentId.toString()) ?? null)
|
||
: null;
|
||
return {
|
||
userId: p.userId.toString(),
|
||
username: p.user.username,
|
||
userStatus: p.user.status,
|
||
status: p.status,
|
||
level: p.level,
|
||
parentAgentId: p.parentAgentId?.toString() ?? null,
|
||
parentUsername,
|
||
parentChainLabel: parentChain.length ? parentChain.join(' / ') : null,
|
||
cashbackRate: p.cashbackRate.toString(),
|
||
creditLimit: dec(p.creditLimit),
|
||
usedCredit: dec(p.usedCredit),
|
||
availableCredit: available.toString(),
|
||
directPlayerCount: countMap.get(p.userId.toString()) ?? 0,
|
||
createdAt: p.createdAt.toISOString(),
|
||
};
|
||
});
|
||
|
||
return { items, total, page, pageSize };
|
||
}
|
||
|
||
async listSubtreePlayersForPortal(
|
||
rootAgentId: bigint,
|
||
params?: {
|
||
page?: number;
|
||
pageSize?: number;
|
||
keyword?: string;
|
||
status?: string;
|
||
parentAgentId?: string;
|
||
},
|
||
) {
|
||
const page = Math.max(1, params?.page ?? 1);
|
||
const pageSize = Math.min(Math.max(1, params?.pageSize ?? 20), 100);
|
||
const skip = (page - 1) * pageSize;
|
||
|
||
const scope = await this.getAgentPortalScope(rootAgentId);
|
||
const where: Prisma.UserWhereInput = {
|
||
userType: 'PLAYER',
|
||
deletedAt: null,
|
||
parentId: { in: scope.subtreeIds },
|
||
};
|
||
if (params?.parentAgentId) {
|
||
this.assertAgentInPortalSubtree(scope, BigInt(params.parentAgentId));
|
||
where.parentId = BigInt(params.parentAgentId);
|
||
}
|
||
const kw = params?.keyword?.trim();
|
||
if (kw) {
|
||
where.username = { contains: kw, mode: 'insensitive' };
|
||
}
|
||
const status = params?.status?.trim();
|
||
if (status && ['ACTIVE', 'SUSPENDED'].includes(status)) {
|
||
where.status = status;
|
||
}
|
||
|
||
const [rows, total] = await Promise.all([
|
||
this.prisma.user.findMany({
|
||
where,
|
||
include: {
|
||
wallet: true,
|
||
usedInvite: { select: { code: true } },
|
||
parent: { select: { id: true, username: true } },
|
||
},
|
||
orderBy: { createdAt: 'desc' },
|
||
skip,
|
||
take: pageSize,
|
||
}),
|
||
this.prisma.user.count({ where }),
|
||
]);
|
||
|
||
const parentIds = [
|
||
...new Set(
|
||
rows
|
||
.map((u) => u.parentId)
|
||
.filter((id): id is bigint => id != null),
|
||
),
|
||
];
|
||
const parentCashbackMap = await this.agentCashbackRateMap(parentIds);
|
||
const playerCashbackMap = await this.playerEffectiveCashbackRateMap(
|
||
rows.map((u) => ({ id: u.id, parentId: u.parentId })),
|
||
parentCashbackMap,
|
||
);
|
||
|
||
const rootKey = rootAgentId.toString();
|
||
const items = rows.map((u) => {
|
||
const parentId = u.parentId!.toString();
|
||
return {
|
||
id: u.id.toString(),
|
||
username: u.username,
|
||
status: u.status,
|
||
createdAt: u.createdAt.toISOString(),
|
||
inviteCode: u.usedInvite?.code ?? null,
|
||
parentAgentId: parentId,
|
||
parentAgentUsername: u.parent?.username ?? '—',
|
||
cashbackRate: playerCashbackMap.get(u.id.toString()) ?? '0',
|
||
inChain: true,
|
||
isDirect: parentId === rootKey,
|
||
wallet: u.wallet
|
||
? {
|
||
availableBalance: u.wallet.availableBalance.toString(),
|
||
frozenBalance: u.wallet.frozenBalance.toString(),
|
||
}
|
||
: undefined,
|
||
};
|
||
});
|
||
|
||
return { items, total, page, pageSize };
|
||
}
|
||
|
||
async listSubtreeAgentOptions(rootAgentId: bigint) {
|
||
const scope = await this.getAgentPortalScope(rootAgentId);
|
||
const profiles = await this.prisma.agentProfile.findMany({
|
||
where: {
|
||
userId: { in: scope.subtreeIds },
|
||
OR: [{ userId: rootAgentId }, { level: { gt: scope.rootLevel } }],
|
||
},
|
||
include: { user: { select: { username: true } } },
|
||
orderBy: [{ level: 'asc' }, { createdAt: 'desc' }],
|
||
});
|
||
|
||
const parentIds = profiles
|
||
.map((p) => p.parentAgentId)
|
||
.filter((id): id is bigint => id != null && scope.subtreeIdSet.has(id.toString()));
|
||
const parentUsers =
|
||
parentIds.length > 0
|
||
? await this.prisma.user.findMany({
|
||
where: { id: { in: [...new Set(parentIds)] } },
|
||
select: { id: true, username: true },
|
||
})
|
||
: [];
|
||
const parentUsernameMap = new Map(parentUsers.map((u) => [u.id.toString(), u.username]));
|
||
|
||
return profiles.map((p) => ({
|
||
id: p.userId.toString(),
|
||
username: p.user.username,
|
||
level: p.level,
|
||
parentUsername:
|
||
p.parentAgentId && scope.subtreeIdSet.has(p.parentAgentId.toString())
|
||
? (parentUsernameMap.get(p.parentAgentId.toString()) ?? null)
|
||
: null,
|
||
}));
|
||
}
|
||
|
||
async assertDirectChildAgent(parentAgentId: bigint, subAgentId: bigint) {
|
||
return this.network.assertDirectChildAgent(parentAgentId, subAgentId);
|
||
}
|
||
|
||
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;
|
||
blockDirectPlayerLogin?: boolean;
|
||
unfreezeDirectPlayers?: boolean;
|
||
},
|
||
) {
|
||
await this.assertDirectChildAgent(parentAgentId, subAgentId);
|
||
return this.updateAgentAdmin(subAgentId, data);
|
||
}
|
||
|
||
async getSubtreeAgentIds(agentId: bigint) {
|
||
return this.network.getSubtreeAgentIds(agentId);
|
||
}
|
||
|
||
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,
|
||
})),
|
||
};
|
||
}
|
||
}
|