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