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 { 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> { 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, ): Promise> { 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(); 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(); 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 { 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> { const groups = await this.prisma.agentProfile.groupBy({ by: ['level'], where: { user: { deletedAt: null } }, _count: { _all: true }, }); const out: Record = {}; 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; 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 = {}; 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 = {}; 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, })), }; } }