Files
thebet365/apps/api/src/domains/agent/agents.service.ts
2026-06-13 17:38:25 +08:00

2098 lines
66 KiB
TypeScript
Raw Blame History

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