This commit is contained in:
wchino
2026-06-13 17:38:25 +08:00
parent e7e938f261
commit 7b33d9f9fa
190 changed files with 23222 additions and 4336 deletions

View File

@@ -0,0 +1,97 @@
import { Decimal } from '@prisma/client/runtime/library';
import { AgentCreditService } from './agent-credit.service';
import { expectAppError } from '../../testing/prisma-mock';
describe('AgentCreditService', () => {
const tx = {
$queryRaw: jest.fn(),
user: {
findFirst: jest.fn(),
findMany: jest.fn(),
},
agentClosure: {
findMany: jest.fn(),
},
agentProfile: {
findUnique: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
},
walletTransaction: {
findUnique: jest.fn(),
aggregate: jest.fn(),
},
};
const prisma = {
...tx,
$transaction: jest.fn(async (fn: (client: typeof tx) => Promise<unknown>) => fn(tx)),
};
const funds = {
deposit: jest.fn(),
withdraw: jest.fn(),
};
let service: AgentCreditService;
beforeEach(() => {
jest.clearAllMocks();
service = new AgentCreditService(prisma as never, funds as never);
tx.walletTransaction.findUnique.mockResolvedValue(null);
tx.user.findFirst.mockResolvedValue({ id: 10n, parentId: 1n, userType: 'PLAYER' });
tx.user.findMany.mockResolvedValue([]);
tx.agentClosure.findMany.mockResolvedValue([{ ancestorId: 1n, depth: 0 }]);
tx.agentProfile.findMany.mockResolvedValue([]);
tx.agentProfile.update.mockResolvedValue({});
tx.$queryRaw.mockResolvedValue([
{
user_id: 1n,
parent_agent_id: null,
credit_limit: new Decimal(1000),
used_credit: new Decimal(0),
max_single_deposit: null,
max_daily_deposit: new Decimal(100),
},
]);
tx.agentProfile.findUnique.mockResolvedValue({
userId: 1n,
parentAgentId: null,
creditLimit: new Decimal(1000),
usedCredit: new Decimal(0),
maxSingleDeposit: null,
maxDailyDeposit: new Decimal(100),
});
tx.walletTransaction.aggregate.mockResolvedValue({ _sum: { amount: new Decimal(80) } });
});
it('counts prior AGENT_DEPOSIT postings when enforcing the daily top-up limit', async () => {
await expect(
service.depositToPlayer(1n, 10n, 30, 'REQ-DAILY-LIMIT'),
).rejects.toMatchObject(expectAppError('AGENT_DAILY_TOPUP_LIMIT'));
expect(tx.walletTransaction.aggregate).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
operatorId: 1n,
transactionType: { in: ['AGENT_DEPOSIT', 'MANUAL_DEPOSIT'] },
}),
}),
);
expect(funds.deposit).not.toHaveBeenCalled();
});
it('recalculates the changed agent and every ancestor in closure order', async () => {
tx.agentClosure.findMany.mockResolvedValue([
{ ancestorId: 3n, depth: 0 },
{ ancestorId: 2n, depth: 1 },
{ ancestorId: 1n, depth: 2 },
]);
await service.recalculateUsedCredit(3n);
expect(tx.agentProfile.update.mock.calls.map(([arg]) => arg.where.userId)).toEqual([
3n,
2n,
1n,
]);
});
});

View File

@@ -0,0 +1,474 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Decimal } from '@prisma/client/runtime/library';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { FundsPostingService } from '../ledger/funds-posting.service';
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
type TxClient = Prisma.TransactionClient;
type PrismaClientLike = PrismaService | TxClient;
const AGENT_DAILY_DEPOSIT_TX_TYPES = ['AGENT_DEPOSIT', 'MANUAL_DEPOSIT'] as const;
function dec(v: Decimal | null | undefined) {
return v?.toString() ?? '0';
}
@Injectable()
export class AgentCreditService {
constructor(
private prisma: PrismaService,
private funds: FundsPostingService,
) {}
async getProfile(agentId: bigint) {
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: agentId },
});
if (!profile) throw appBadRequest('AGENT_PROFILE_NOT_FOUND');
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
return { ...profile, availableCredit: available };
}
async recalculateUsedCredit(agentId: bigint, tx?: TxClient) {
const client = tx ?? this.prisma;
const cascadeIds = await this.getCreditCascadeIds(agentId, client);
let requestedUsedCredit = new Decimal(0);
for (const id of cascadeIds) {
const usedCredit = await this.recalculateOneUsedCredit(id, client);
if (id === agentId) requestedUsedCredit = usedCredit;
}
return requestedUsedCredit;
}
private async recalculateOneUsedCredit(agentId: bigint, client: PrismaClientLike) {
const directPlayers = await client.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 client.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 client.agentProfile.update({
where: { userId: agentId },
data: {
usedCredit,
directPlayerLiability: directLiability,
childAgentExposure: childExposure,
},
});
return usedCredit;
}
private async getCreditCascadeIds(agentId: bigint, client: PrismaClientLike) {
const closureRows = await client.agentClosure.findMany({
where: { descendantId: agentId },
select: { ancestorId: true, depth: true },
orderBy: { depth: 'asc' },
});
if (closureRows.length > 0) {
return closureRows.map((row) => row.ancestorId);
}
const ids: bigint[] = [];
let current: bigint | null = agentId;
while (current) {
ids.push(current);
const profile: { parentAgentId: bigint | null } | null = await client.agentProfile.findUnique({
where: { userId: current },
select: { parentAgentId: true },
});
current = profile?.parentAgentId ?? null;
}
return ids;
}
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 appBadRequest('AGENT_NOT_FOUND');
const creditBefore = profile.creditLimit;
const creditAfter = creditBefore.add(amt);
if (creditAfter.lt(0)) throw appBadRequest('CREDIT_LIMIT_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,
},
});
});
await this.recalculateUsedCredit(agentId);
return { creditAfter };
}
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 appBadRequest('CREDIT_TOPUP_EXCEEDED');
}
}
async adminDepositToPlayer(
playerId: bigint,
amount: number,
operatorId: bigint,
remark?: string,
requestId?: string,
) {
await this.assertPlayerParentCreditForDeposit(playerId, amount);
const result = await this.funds.deposit({
userId: playerId,
amount,
operatorId,
remark: remark ?? '管理员上分',
referenceId: requestId,
transactionType: 'ADMIN_DEPOSIT',
businessKey: requestId ? `admin-deposit:${operatorId}:${requestId}` : undefined,
});
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.funds.withdraw({
userId: playerId,
amount,
operatorId,
remark: remark ?? '管理员下分',
referenceId: requestId,
transactionType: 'ADMIN_WITHDRAW',
businessKey: requestId ? `admin-withdraw:${operatorId}:${requestId}` : undefined,
});
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 appNotFound('PLAYER_NOT_FOUND');
if (options.actingAgentId) {
await this.requireDirectPlayer(options.actingAgentId, playerId);
}
const creditAgentId = options.forAdmin ? player.parentId : (options.actingAgentId ?? null);
let credit: Record<string, unknown> | null = null;
if (creditAgentId) {
await this.recalculateUsedCredit(creditAgentId);
const profile = await this.getProfile(creditAgentId);
const parent = profile.parentAgentId
? await this.prisma.agentProfile.findUnique({ where: { userId: profile.parentAgentId } })
: null;
const { maxSingleDeposit, maxDailyDeposit } = this.resolveEffectiveDepositLimits(profile, parent);
let dailyDepositUsed: string | null = null;
if (!options.forAdmin) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const dailyAgg = await this.prisma.walletTransaction.aggregate({
where: {
operatorId: creditAgentId,
transactionType: { in: [...AGENT_DAILY_DEPOSIT_TX_TYPES] },
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,
};
}
async depositToPlayer(
agentId: bigint,
playerId: bigint,
amount: number,
requestId: string,
remark?: string,
) {
const amt = new Decimal(amount);
const businessKey = `agent-deposit:${agentId}:${requestId}`;
await this.prisma.$transaction(async (tx) => {
const existing = await tx.walletTransaction.findUnique({ where: { businessKey } });
if (existing) return;
await this.requireDirectPlayer(agentId, playerId, tx);
await this.recalculateUsedCredit(agentId, tx);
const profile = await this.lockAgentProfile(tx, agentId);
const available = new Decimal(profile.credit_limit).sub(profile.used_credit);
if (available.lt(amt)) {
throw appBadRequest('INSUFFICIENT_AGENT_CREDIT');
}
await this.assertAgentDepositLimits(agentId, amt, tx);
await this.funds.deposit({
userId: playerId,
amount: amt,
operatorId: agentId,
remark: remark ?? '代理上分',
referenceId: requestId,
transactionType: 'AGENT_DEPOSIT',
businessKey,
tx,
});
await this.recalculateUsedCredit(agentId, tx);
});
return { success: true };
}
async withdrawFromPlayer(
agentId: bigint,
playerId: bigint,
amount: number,
requestId: string,
remark?: string,
) {
await this.requireDirectPlayer(agentId, playerId);
await this.funds.withdraw({
userId: playerId,
amount,
operatorId: agentId,
remark: remark ?? '代理下分',
referenceId: requestId,
transactionType: 'AGENT_WITHDRAW',
businessKey: `agent-withdraw:${agentId}:${requestId}`,
});
await this.recalculateUsedCredit(agentId);
return { success: true };
}
private async requireDirectPlayer(agentId: bigint, playerId: bigint, tx?: TxClient) {
const client = tx ?? this.prisma;
const player = await client.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 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 async assertAgentDepositLimits(
creditAgentId: bigint,
amount: Decimal,
tx?: TxClient,
) {
const client = tx ?? this.prisma;
const profile = await client.agentProfile.findUnique({
where: { userId: creditAgentId },
});
if (!profile) return;
const parent = profile.parentAgentId
? await client.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 client.walletTransaction.aggregate({
where: {
operatorId: creditAgentId,
transactionType: { in: [...AGENT_DAILY_DEPOSIT_TX_TYPES] },
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 lockAgentProfile(tx: TxClient, agentId: bigint) {
const profiles = await tx.$queryRaw<
Array<{
user_id: bigint;
parent_agent_id: bigint | null;
credit_limit: Decimal;
used_credit: Decimal;
max_single_deposit: Decimal | null;
max_daily_deposit: Decimal | null;
}>
>`
SELECT user_id, parent_agent_id, credit_limit, used_credit, max_single_deposit, max_daily_deposit
FROM agent_profiles
WHERE user_id = ${agentId}
FOR UPDATE
`;
if (!profiles.length) throw appBadRequest('AGENT_PROFILE_NOT_FOUND');
return profiles[0];
}
private async assertChildCreditWithinParent(
parentAgentId: bigint,
childProfile: { userId: bigint; creditLimit: Decimal; usedCredit: Decimal },
creditAfter: Decimal,
) {
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 (creditAfter.lt(0)) throw appBadRequest('CREDIT_LIMIT_NEGATIVE');
if (creditAfter.gt(parent.creditLimit)) throw appBadRequest('CREDIT_EXCEEDS_PARENT');
if (exposureDelta.gt(0) && exposureDelta.gt(parentAvailable)) {
throw appBadRequest('INSUFFICIENT_AGENT_CREDIT');
}
}
}

View File

@@ -0,0 +1,92 @@
import { AgentNetworkService } from './agent-network.service';
describe('AgentNetworkService', () => {
let prisma: {
agentClosure: { findMany: jest.Mock };
agentProfile: { findUnique: jest.Mock; findMany: jest.Mock };
user: { findMany: jest.Mock; findFirst: jest.Mock };
};
let service: AgentNetworkService;
beforeEach(() => {
prisma = {
agentClosure: { findMany: jest.fn() },
agentProfile: {
findUnique: jest.fn(),
findMany: jest.fn(),
},
user: {
findMany: jest.fn(),
findFirst: jest.fn(),
},
};
service = new AgentNetworkService(prisma as never);
});
it('resolves portal scope from AgentClosure rows', async () => {
prisma.agentProfile.findUnique.mockResolvedValue({ userId: 1n, level: 2 });
prisma.agentClosure.findMany.mockResolvedValue([
{ descendantId: 1n, depth: 0 },
{ descendantId: 2n, depth: 1 },
{ descendantId: 4n, depth: 1 },
{ descendantId: 3n, depth: 2 },
]);
const scope = await service.resolveScope(1n);
expect(scope.rootAgentId).toBe(1n);
expect(scope.rootLevel).toBe(2);
expect(scope.subtreeIds).toEqual([1n, 2n, 4n, 3n]);
expect(scope.descendantIds).toEqual([2n, 4n, 3n]);
expect(scope.directChildIds).toEqual([2n, 4n]);
expect(scope.subtreeIdSet.has('3')).toBe(true);
expect(prisma.agentProfile.findMany).not.toHaveBeenCalled();
});
it('falls back to AgentProfile adjacency when closure rows are absent', async () => {
prisma.agentProfile.findUnique.mockResolvedValue({ userId: 1n, level: 1 });
prisma.agentClosure.findMany.mockResolvedValue([]);
prisma.agentProfile.findMany.mockImplementation(({ where }: { where: { parentAgentId: bigint } }) => {
const childrenByParent = new Map<bigint, Array<{ userId: bigint }>>([
[1n, [{ userId: 2n }, { userId: 4n }]],
[2n, [{ userId: 3n }]],
[3n, []],
[4n, []],
]);
return Promise.resolve(childrenByParent.get(where.parentAgentId) ?? []);
});
const scope = await service.resolveScope(1n);
expect(scope.subtreeIds).toEqual([1n, 2n, 4n, 3n]);
expect(scope.descendantIds).toEqual([2n, 4n, 3n]);
expect(scope.directChildIds).toEqual([2n, 4n]);
});
it('rejects agent outside descendant scope', async () => {
prisma.agentClosure.findMany.mockResolvedValue([
{ descendantId: 1n, depth: 0 },
{ descendantId: 2n, depth: 1 },
]);
await expect(service.assertDescendantAgent(1n, 9n)).rejects.toMatchObject({
response: expect.objectContaining({ code: 'NOT_SUB_AGENT' }),
});
expect(prisma.agentProfile.findUnique).not.toHaveBeenCalled();
});
it('returns direct child profile for matching parent', async () => {
const profile = { userId: 2n, parentAgentId: 1n };
prisma.agentProfile.findUnique.mockResolvedValue(profile);
await expect(service.assertDirectChildAgent(1n, 2n)).resolves.toBe(profile);
});
it('rejects non-direct child agent', async () => {
prisma.agentProfile.findUnique.mockResolvedValue({ userId: 2n, parentAgentId: 9n });
await expect(service.assertDirectChildAgent(1n, 2n)).rejects.toMatchObject({
response: expect.objectContaining({ code: 'NOT_SUB_AGENT' }),
});
});
});

View File

@@ -0,0 +1,337 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { appBadRequest, appForbidden, appNotFound } from '../../shared/common/app-error';
export interface AgentScope {
rootAgentId: bigint;
rootLevel: number;
subtreeIds: bigint[];
descendantIds: bigint[];
directChildIds: bigint[];
subtreeIdSet: Set<string>;
}
type ClosureRow = {
ancestorId?: bigint;
descendantId: bigint;
depth: number;
};
function uniqueBigints(values: bigint[]) {
const seen = new Set<string>();
const out: bigint[] = [];
for (const value of values) {
const key = value.toString();
if (seen.has(key)) continue;
seen.add(key);
out.push(value);
}
return out;
}
@Injectable()
export class AgentNetworkService {
constructor(private prisma: PrismaService) {}
async buildAgentAncestorChainMap(parentAgentIds: (bigint | null | undefined)[]) {
return this.buildAncestorChainMap(parentAgentIds, undefined);
}
async buildScopedAncestorChainMap(
parentAgentIds: (bigint | null | undefined)[],
rootAgentId: bigint,
) {
return this.buildAncestorChainMap(parentAgentIds, rootAgentId);
}
async resolveScope(rootAgentId: bigint): Promise<AgentScope> {
const [profile, closureRows] = await Promise.all([
this.prisma.agentProfile.findUnique({
where: { userId: rootAgentId },
}),
this.findSubtreeClosureRows(rootAgentId),
]);
if (!profile) throw appBadRequest('AGENT_PROFILE_NOT_FOUND');
const hasClosureRows = closureRows.length > 0;
const subtreeIds = hasClosureRows
? this.idsFromSubtreeClosure(rootAgentId, closureRows)
: await this.getSubtreeAgentIdsFromProfiles(rootAgentId);
const descendantIds = subtreeIds.filter((id) => id !== rootAgentId);
const directChildIds = hasClosureRows
? uniqueBigints(
closureRows
.filter((row) => row.depth === 1)
.map((row) => row.descendantId),
)
: await this.getDirectChildAgentIds(rootAgentId);
return {
rootAgentId,
rootLevel: profile.level,
subtreeIds,
descendantIds,
directChildIds,
subtreeIdSet: new Set(subtreeIds.map((id) => id.toString())),
};
}
assertAgentInScope(scope: AgentScope, agentId: bigint) {
if (!scope.subtreeIdSet.has(agentId.toString())) {
throw appForbidden('NOT_SUB_AGENT');
}
}
async requirePlayerInPortalSubtree(rootAgentId: bigint, playerId: bigint) {
const scope = await this.resolveScope(rootAgentId);
const player = await this.prisma.user.findFirst({
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
include: { wallet: true, preferences: true, auth: true },
});
if (!player?.parentId || !scope.subtreeIdSet.has(player.parentId.toString())) {
throw appForbidden('MANAGE_DIRECT_PLAYERS_ONLY');
}
return { player, scope, isDirect: player.parentId.toString() === rootAgentId.toString() };
}
async getChildAgents(agentId: bigint) {
return this.prisma.agentProfile.findMany({
where: { parentAgentId: agentId },
include: { user: true },
});
}
async assertDescendantAgent(rootAgentId: bigint, targetAgentId: bigint) {
const subtreeIds = await this.getSubtreeAgentIds(rootAgentId);
if (!subtreeIds.some((id) => id === targetAgentId)) {
throw appForbidden('NOT_SUB_AGENT');
}
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: targetAgentId },
});
if (!profile) throw appNotFound('AGENT_NOT_FOUND');
return profile;
}
async assertDirectChildAgent(parentAgentId: bigint, subAgentId: bigint) {
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: subAgentId },
});
if (!profile || profile.parentAgentId !== parentAgentId) {
throw appForbidden('NOT_SUB_AGENT');
}
return profile;
}
async getSubtreeAgentIds(agentId: bigint) {
const closureRows = await this.findSubtreeClosureRows(agentId);
if (closureRows.length > 0) {
return this.idsFromSubtreeClosure(agentId, closureRows);
}
return this.getSubtreeAgentIdsFromProfiles(agentId);
}
private async buildAncestorChainMap(
parentAgentIds: (bigint | null | undefined)[],
scopedRootAgentId: bigint | undefined,
) {
const agentIds = uniqueBigints(parentAgentIds.filter((id): id is bigint => id != null));
const map = new Map<string, string[]>();
if (agentIds.length === 0) return map;
const closureRows = await this.prisma.agentClosure.findMany({
where: { descendantId: { in: agentIds } },
select: { ancestorId: true, descendantId: true, depth: true },
orderBy: [{ descendantId: 'asc' }, { depth: 'desc' }],
});
const rowsByDescendant = new Map<string, ClosureRow[]>();
for (const row of closureRows) {
const key = row.descendantId.toString();
rowsByDescendant.set(key, [...(rowsByDescendant.get(key) ?? []), row]);
}
const idsNeedingProfileFallback: bigint[] = [];
const ancestorIds = new Set<bigint>();
for (const id of agentIds) {
const rows = rowsByDescendant.get(id.toString());
if (!rows || rows.length === 0) {
idsNeedingProfileFallback.push(id);
continue;
}
const scopedRows = scopedRootAgentId
? this.sliceRowsToScopedRoot(rows, scopedRootAgentId)
: rows;
for (const row of scopedRows) {
if (row.ancestorId) ancestorIds.add(row.ancestorId);
}
}
const users =
ancestorIds.size > 0
? await this.prisma.user.findMany({
where: { id: { in: [...ancestorIds] } },
select: { id: true, username: true },
})
: [];
const usernameMap = new Map(users.map((u) => [u.id.toString(), u.username]));
for (const id of agentIds) {
const rows = rowsByDescendant.get(id.toString());
if (!rows || rows.length === 0) continue;
const scopedRows = scopedRootAgentId
? this.sliceRowsToScopedRoot(rows, scopedRootAgentId)
: rows;
const chain = scopedRows
.map((row) => (row.ancestorId ? usernameMap.get(row.ancestorId.toString()) : undefined))
.filter((username): username is string => Boolean(username));
map.set(id.toString(), chain.length === scopedRows.length ? chain : []);
}
if (idsNeedingProfileFallback.length > 0) {
const fallbackMap = scopedRootAgentId
? await this.buildScopedAncestorChainMapFromProfiles(idsNeedingProfileFallback, scopedRootAgentId)
: await this.buildAncestorChainMapFromProfiles(idsNeedingProfileFallback);
for (const [key, value] of fallbackMap) {
map.set(key, value);
}
}
for (const id of parentAgentIds) {
if (id && !map.has(id.toString())) {
map.set(id.toString(), []);
}
}
return map;
}
private sliceRowsToScopedRoot(rows: ClosureRow[], rootAgentId: bigint) {
const rootRow = rows.find((row) => row.ancestorId === rootAgentId);
if (!rootRow) return [];
return rows.filter((row) => row.depth <= rootRow.depth);
}
private async buildAncestorChainMapFromProfiles(agentIds: bigint[]) {
const cache = await this.loadAncestorProfileCache(agentIds, undefined);
const build = (startId: bigint | null | undefined): string[] => {
const chain: string[] = [];
let cur = startId ?? null;
while (cur) {
const hit = cache.get(cur.toString());
if (!hit) break;
chain.unshift(hit.username);
cur = hit.parentAgentId;
}
return chain;
};
return new Map(agentIds.map((id) => [id.toString(), build(id)]));
}
private async buildScopedAncestorChainMapFromProfiles(agentIds: bigint[], rootAgentId: bigint) {
const rootKey = rootAgentId.toString();
const cache = await this.loadAncestorProfileCache(agentIds, rootAgentId);
const build = (startId: bigint | null | undefined): string[] => {
if (!startId) return [];
const chain: string[] = [];
let cur: bigint | null = startId;
let reachedRoot = false;
while (cur) {
const hit = cache.get(cur.toString());
if (!hit) return [];
chain.unshift(hit.username);
if (cur.toString() === rootKey) {
reachedRoot = true;
break;
}
cur = hit.parentAgentId;
}
return reachedRoot ? chain : [];
};
return new Map(agentIds.map((id) => [id.toString(), build(id)]));
}
private async loadAncestorProfileCache(agentIds: bigint[], stopBeforeParentOf?: bigint) {
const cache = new Map<string, { username: string; parentAgentId: bigint | null }>();
const pending = new Set(agentIds);
if (stopBeforeParentOf) pending.add(stopBeforeParentOf);
const stopKey = stopBeforeParentOf?.toString();
while (pending.size > 0) {
const batch = [...pending];
pending.clear();
const profiles = await this.prisma.agentProfile.findMany({
where: { userId: { in: batch } },
select: {
userId: true,
parentAgentId: true,
user: { select: { username: true } },
},
});
for (const profile of profiles) {
cache.set(profile.userId.toString(), {
username: profile.user.username,
parentAgentId: profile.parentAgentId,
});
if (
profile.parentAgentId &&
profile.parentAgentId.toString() !== stopKey &&
!cache.has(profile.parentAgentId.toString())
) {
pending.add(profile.parentAgentId);
}
}
}
return cache;
}
private async findSubtreeClosureRows(agentId: bigint) {
return this.prisma.agentClosure.findMany({
where: { ancestorId: agentId },
select: { descendantId: true, depth: true },
orderBy: [{ depth: 'asc' }, { descendantId: 'asc' }],
});
}
private idsFromSubtreeClosure(agentId: bigint, rows: ClosureRow[]) {
return uniqueBigints([agentId, ...rows.map((row) => row.descendantId)]);
}
private async getDirectChildAgentIds(agentId: bigint) {
const children = await this.prisma.agentProfile.findMany({
where: { parentAgentId: agentId },
select: { userId: true },
orderBy: { createdAt: 'desc' },
});
return children.map((child) => child.userId);
}
private async getSubtreeAgentIdsFromProfiles(agentId: bigint) {
const ids: bigint[] = [];
const queue: bigint[] = [agentId];
const seen = new Set<string>();
while (queue.length > 0) {
const current = queue.shift()!;
const key = current.toString();
if (seen.has(key)) continue;
seen.add(key);
ids.push(current);
const children = await this.prisma.agentProfile.findMany({
where: { parentAgentId: current },
select: { userId: true },
});
for (const child of children) {
queue.push(child.userId);
}
}
return ids;
}
}

View File

@@ -1,12 +1,14 @@
import { Module } from '@nestjs/common';
import { AgentsService } from './agents.service';
import { AgentNetworkService } from './agent-network.service';
import { AgentCreditService } from './agent-credit.service';
import { WalletModule } from '../ledger/wallet.module';
import { AuthModule } from '../identity/auth.module';
import { SystemConfigModule } from '../../shared/config/system-config.module';
@Module({
imports: [WalletModule, AuthModule, SystemConfigModule],
providers: [AgentsService],
exports: [AgentsService],
providers: [AgentsService, AgentNetworkService, AgentCreditService],
exports: [AgentsService, AgentNetworkService, AgentCreditService],
})
export class AgentsModule {}

View File

@@ -0,0 +1,100 @@
import { Decimal } from '@prisma/client/runtime/library';
import { AgentsService } from './agents.service';
import { createPrismaMock } from '../../testing/prisma-mock';
describe('AgentsService', () => {
const parentAgentId = 1n;
const createdAgentId = 2n;
const tx = {
user: {
create: jest.fn(),
findUnique: jest.fn(),
update: jest.fn(),
},
userAuth: {
create: jest.fn(),
},
userPreference: {
create: jest.fn(),
},
userInvite: {
create: jest.fn(),
findUnique: jest.fn(),
},
agentProfile: {
create: jest.fn(),
findUnique: jest.fn(),
},
agentClosure: {
create: jest.fn(),
findMany: jest.fn(),
},
};
const prisma = createPrismaMock(tx);
const auth = {
hashPassword: jest.fn(),
};
const systemConfig = {
getAgentHierarchySettings: jest.fn(),
};
const network = {};
const credit = {
recalculateUsedCredit: jest.fn(),
};
let service: AgentsService;
beforeEach(() => {
jest.clearAllMocks();
service = new AgentsService(
prisma as never,
auth as never,
systemConfig as never,
network as never,
credit as never,
);
systemConfig.getAgentHierarchySettings.mockResolvedValue({ maxAgentLevel: 3 });
auth.hashPassword.mockResolvedValue('hashed-password');
tx.agentProfile.findUnique.mockResolvedValue({
userId: parentAgentId,
level: 1,
creditLimit: new Decimal(1000),
usedCredit: new Decimal(0),
cashbackRate: new Decimal(10),
maxSingleDeposit: null,
maxDailyDeposit: null,
});
tx.user.create.mockResolvedValue({
id: createdAgentId,
username: 'agent-child',
userType: 'AGENT',
});
tx.user.findUnique.mockResolvedValue(null);
tx.user.update.mockResolvedValue({});
tx.userInvite.findUnique.mockResolvedValue(null);
tx.userInvite.create.mockResolvedValue({});
tx.userAuth.create.mockResolvedValue({});
tx.userPreference.create.mockResolvedValue({});
tx.agentProfile.create.mockResolvedValue({});
tx.agentClosure.create.mockResolvedValue({});
tx.agentClosure.findMany.mockResolvedValue([
{ ancestorId: parentAgentId, depth: 0 },
]);
});
it('recalculates parent credit exposure after creating a child agent', async () => {
await service.createAgent(99n, {
username: 'agent-child',
password: 'secret',
level: 2,
parentAgentId,
creditLimit: 300,
});
expect(credit.recalculateUsedCredit).toHaveBeenCalledWith(parentAgentId);
expect(credit.recalculateUsedCredit).not.toHaveBeenCalledWith(createdAgentId);
});
});

View File

@@ -2,7 +2,6 @@ import { Injectable, BadRequestException } 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';
@@ -10,6 +9,8 @@ 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';
@@ -23,9 +24,10 @@ function sub(a: Decimal | null | undefined, b: Decimal | null | undefined) {
export class AgentsService {
constructor(
private prisma: PrismaService,
private wallet: WalletService,
private auth: AuthService,
private systemConfig: SystemConfigService,
private network: AgentNetworkService,
private credit: AgentCreditService,
) {}
async getMaxAgentLevel(): Promise<number> {
@@ -39,52 +41,7 @@ export class AgentsService {
}
private async buildAgentAncestorChainMap(parentAgentIds: (bigint | null | undefined)[]) {
const cache = new Map<string, { username: string; parentAgentId: bigint | null }>();
const pending = new Set<bigint>();
for (const id of parentAgentIds) {
if (id) pending.add(id);
}
while (pending.size > 0) {
const batch = [...pending];
pending.clear();
const profiles = await this.prisma.agentProfile.findMany({
where: { userId: { in: batch } },
select: {
userId: true,
parentAgentId: true,
user: { select: { username: true } },
},
});
for (const profile of profiles) {
cache.set(profile.userId.toString(), {
username: profile.user.username,
parentAgentId: profile.parentAgentId,
});
if (profile.parentAgentId && !cache.has(profile.parentAgentId.toString())) {
pending.add(profile.parentAgentId);
}
}
}
const build = (startId: bigint | null | undefined): string[] => {
const chain: string[] = [];
let cur = startId ?? null;
while (cur) {
const hit = cache.get(cur.toString());
if (!hit) break;
chain.unshift(hit.username);
cur = hit.parentAgentId;
}
return chain;
};
const map = new Map<string, string[]>();
for (const id of parentAgentIds) {
if (id) map.set(id.toString(), build(id));
}
return map;
return this.network.buildAgentAncestorChainMap(parentAgentIds);
}
private async agentCashbackRateMap(agentUserIds: bigint[]): Promise<Map<string, string>> {
@@ -142,100 +99,23 @@ export class AgentsService {
parentAgentIds: (bigint | null | undefined)[],
rootAgentId: bigint,
) {
const cache = new Map<string, { username: string; parentAgentId: bigint | null }>();
const pending = new Set<bigint>();
const rootKey = rootAgentId.toString();
for (const id of parentAgentIds) {
if (id) pending.add(id);
}
pending.add(rootAgentId);
while (pending.size > 0) {
const batch = [...pending];
pending.clear();
const profiles = await this.prisma.agentProfile.findMany({
where: { userId: { in: batch } },
select: {
userId: true,
parentAgentId: true,
user: { select: { username: true } },
},
});
for (const profile of profiles) {
cache.set(profile.userId.toString(), {
username: profile.user.username,
parentAgentId: profile.parentAgentId,
});
if (
profile.parentAgentId &&
profile.parentAgentId.toString() !== rootKey &&
!cache.has(profile.parentAgentId.toString())
) {
pending.add(profile.parentAgentId);
}
}
}
const build = (startId: bigint | null | undefined): string[] => {
if (!startId) return [];
const chain: string[] = [];
let cur: bigint | null = startId;
let reachedRoot = false;
while (cur) {
const hit = cache.get(cur.toString());
if (!hit) return [];
chain.unshift(hit.username);
if (cur.toString() === rootKey) {
reachedRoot = true;
break;
}
cur = hit.parentAgentId;
}
return reachedRoot ? chain : [];
};
const map = new Map<string, string[]>();
for (const id of parentAgentIds) {
if (id) map.set(id.toString(), build(id));
}
return map;
return this.network.buildScopedAncestorChainMap(parentAgentIds, rootAgentId);
}
private async getAgentPortalScope(rootAgentId: bigint) {
const profile = await this.getProfile(rootAgentId);
const subtreeIds = await this.getSubtreeAgentIds(rootAgentId);
const descendantIds = subtreeIds.filter((id) => id !== rootAgentId);
const subtreeIdSet = new Set(subtreeIds.map((id) => id.toString()));
return {
rootAgentId,
rootLevel: profile.level,
subtreeIds,
descendantIds,
subtreeIdSet,
};
private async getAgentPortalScope(rootAgentId: bigint): Promise<AgentScope> {
return this.network.resolveScope(rootAgentId);
}
private assertAgentInPortalSubtree(
scope: { subtreeIdSet: Set<string> },
scope: AgentScope,
agentId: bigint,
) {
if (!scope.subtreeIdSet.has(agentId.toString())) {
throw appForbidden('NOT_SUB_AGENT');
}
this.network.assertAgentInScope(scope, agentId);
}
/** 代理端:玩家必须挂在当前代理子树内某代理下(自己或下级) */
async requirePlayerInPortalSubtree(rootAgentId: bigint, playerId: bigint) {
const scope = await this.getAgentPortalScope(rootAgentId);
const player = await this.prisma.user.findFirst({
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
include: { wallet: true, preferences: true, auth: true },
});
if (!player?.parentId || !scope.subtreeIdSet.has(player.parentId.toString())) {
throw appForbidden('MANAGE_DIRECT_PLAYERS_ONLY');
}
return { player, scope, isDirect: player.parentId.toString() === rootAgentId.toString() };
return this.network.requirePlayerInPortalSubtree(rootAgentId, playerId);
}
private async validateAgentLevel(level: number, parentAgentId?: bigint) {
@@ -270,51 +150,11 @@ export class AgentsService {
}
async getProfile(agentId: bigint) {
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: agentId },
});
if (!profile) throw appBadRequest('AGENT_PROFILE_NOT_FOUND');
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
return { ...profile, availableCredit: available };
return this.credit.getProfile(agentId);
}
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;
return this.credit.recalculateUsedCredit(agentId);
}
async adjustCredit(
@@ -324,45 +164,7 @@ export class AgentsService {
requestId: string,
remark?: string,
) {
const amt = new Decimal(amount);
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: agentId },
});
if (!profile) throw appBadRequest('AGENT_NOT_FOUND');
const creditBefore = profile.creditLimit;
const creditAfter = creditBefore.add(amt);
if (creditAfter.lt(0)) throw appBadRequest('CREDIT_LIMIT_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 };
return this.credit.adjustCredit(agentId, amount, operatorId, requestId, remark);
}
/** 代理只能操作直属玩家parentId === 当前代理) */
@@ -462,19 +264,7 @@ export class AgentsService {
/** 玩家有所属代理时,上分金额不得超过该代理当前可用授信(会先重算 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 appBadRequest('CREDIT_TOPUP_EXCEEDED');
}
return this.credit.assertPlayerParentCreditForDeposit(playerId, amount);
}
/** 管理员给玩家上分:校验上级授信后入账,并刷新代理占用额度 */
@@ -485,23 +275,7 @@ export class AgentsService {
remark?: string,
requestId?: string,
) {
await this.assertPlayerParentCreditForDeposit(playerId, amount);
const result = await this.wallet.deposit(
playerId,
amount,
operatorId,
remark ?? '管理员上分',
requestId,
'ADMIN_DEPOSIT',
);
const player = await this.prisma.user.findUnique({
where: { id: playerId },
select: { parentId: true },
});
if (player?.parentId) {
await this.recalculateUsedCredit(player.parentId);
}
return result;
return this.credit.adminDepositToPlayer(playerId, amount, operatorId, remark, requestId);
}
/** 管理员给玩家下分:扣款后刷新上级代理占用额度 */
@@ -512,22 +286,7 @@ export class AgentsService {
remark?: string,
requestId?: string,
) {
const result = await this.wallet.withdraw(
playerId,
amount,
operatorId,
remark ?? '管理员下分',
requestId,
'ADMIN_WITHDRAW',
);
const player = await this.prisma.user.findUnique({
where: { id: playerId },
select: { parentId: true },
});
if (player?.parentId) {
await this.recalculateUsedCredit(player.parentId);
}
return result;
return this.credit.adminWithdrawFromPlayer(playerId, amount, operatorId, remark, requestId);
}
/** 上下分弹窗:玩家余额 + 授信代理可用额度/限额上下文 */
@@ -535,70 +294,7 @@ export class AgentsService {
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 appNotFound('PLAYER_NOT_FOUND');
if (options.actingAgentId) {
await this.requireDirectPlayer(options.actingAgentId, playerId);
}
const creditAgentId = options.forAdmin ? player.parentId : (options.actingAgentId ?? null);
let credit: Record<string, unknown> | null = null;
if (creditAgentId) {
await this.recalculateUsedCredit(creditAgentId);
const profile = await this.getProfile(creditAgentId);
const parent = profile.parentAgentId
? await this.prisma.agentProfile.findUnique({ where: { userId: profile.parentAgentId } })
: null;
const { maxSingleDeposit, maxDailyDeposit } = this.resolveEffectiveDepositLimits(profile, parent);
let dailyDepositUsed: string | null = null;
if (!options.forAdmin) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const dailyAgg = await this.prisma.walletTransaction.aggregate({
where: {
operatorId: creditAgentId,
transactionType: 'MANUAL_DEPOSIT',
createdAt: { gte: today },
},
_sum: { amount: true },
});
dailyDepositUsed = dec(dailyAgg._sum.amount);
}
const agentUser = await this.prisma.user.findUnique({
where: { id: creditAgentId },
select: { username: true },
});
credit = {
agentId: creditAgentId.toString(),
agentUsername: agentUser?.username ?? '',
agentLevel: profile.level,
creditLimit: dec(profile.creditLimit),
usedCredit: dec(profile.usedCredit),
availableCredit: dec(profile.availableCredit),
maxSingleDeposit: maxSingleDeposit?.toString() ?? null,
maxDailyDeposit: maxDailyDeposit?.toString() ?? null,
dailyDepositUsed,
appliesDepositLimits: !options.forAdmin,
};
}
return {
player: {
id: player.id.toString(),
username: player.username,
availableBalance: dec(player.wallet?.availableBalance),
frozenBalance: dec(player.wallet?.frozenBalance),
},
credit,
};
return this.credit.getPlayerTransferContext(playerId, options);
}
private async assertAgentDepositLimits(creditAgentId: bigint, amount: Decimal) {
@@ -624,9 +320,9 @@ export class AgentsService {
const dailyAgg = await this.prisma.walletTransaction.aggregate({
where: {
operatorId: creditAgentId,
transactionType: 'MANUAL_DEPOSIT',
createdAt: { gte: today },
},
transactionType: { in: ['AGENT_DEPOSIT', 'MANUAL_DEPOSIT'] },
createdAt: { gte: today },
},
_sum: { amount: true },
});
const dailyTotal = new Decimal(dailyAgg._sum.amount ?? 0).add(amount);
@@ -660,22 +356,7 @@ export class AgentsService {
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 appBadRequest('INSUFFICIENT_AGENT_CREDIT');
}
await this.assertAgentDepositLimits(agentId, amt);
await this.wallet.deposit(playerId, amt, agentId, remark ?? '代理上分', requestId, 'AGENT_DEPOSIT');
await this.recalculateUsedCredit(agentId);
return { success: true };
return this.credit.depositToPlayer(agentId, playerId, amount, requestId, remark);
}
async withdrawFromPlayer(
@@ -685,19 +366,7 @@ export class AgentsService {
requestId: string,
remark?: string,
) {
await this.requireDirectPlayer(agentId, playerId);
await this.wallet.withdraw(
playerId,
amount,
agentId,
remark ?? '代理下分',
requestId,
'AGENT_WITHDRAW',
);
await this.recalculateUsedCredit(agentId);
return { success: true };
return this.credit.withdrawFromPlayer(agentId, playerId, amount, requestId, remark);
}
async getDirectPlayerDetail(agentId: bigint, playerId: bigint) {
@@ -1508,7 +1177,7 @@ export class AgentsService {
const hash = await this.auth.hashPassword(data.password);
return this.prisma.$transaction(async (tx) => {
const user = await this.prisma.$transaction(async (tx) => {
const locale = data.locale ?? 'zh-CN';
const user = await tx.user.create({
data: {
@@ -1564,13 +1233,16 @@ export class AgentsService {
},
});
}
if (data.parentAgentId) {
await this.recalculateUsedCredit(data.parentAgentId);
}
}
return user;
});
if (data.parentAgentId) {
await this.recalculateUsedCredit(data.parentAgentId);
}
return user;
}
async createPlayer(
@@ -1707,18 +1379,7 @@ export class AgentsService {
if (!remarkResult.ok) throw appBadRequest(remarkResult.code);
const requestId =
data.depositRequestId ?? `admin-create-${user.id}-${Date.now()}`;
await this.assertPlayerParentCreditForDeposit(user.id, initial);
await this.wallet.deposit(
user.id,
initial,
operatorId,
remarkResult.remark,
requestId,
'ADMIN_DEPOSIT',
);
if (parentId) {
await this.recalculateUsedCredit(parentId);
}
await this.credit.adminDepositToPlayer(user.id, initial, operatorId, remarkResult.remark, requestId);
}
return user;
@@ -1799,10 +1460,7 @@ export class AgentsService {
}
async getChildAgents(agentId: bigint) {
return this.prisma.agentProfile.findMany({
where: { parentAgentId: agentId },
include: { user: true },
});
return this.network.getChildAgents(agentId);
}
async listChildAgentsSummary(parentAgentId: bigint) {
@@ -1980,15 +1638,7 @@ export class AgentsService {
}
async assertDescendantAgent(rootAgentId: bigint, targetAgentId: bigint) {
const subtreeIds = await this.getSubtreeAgentIds(rootAgentId);
if (!subtreeIds.some((id) => id === targetAgentId)) {
throw appForbidden('NOT_SUB_AGENT');
}
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: targetAgentId },
});
if (!profile) throw appNotFound('AGENT_NOT_FOUND');
return profile;
return this.network.assertDescendantAgent(rootAgentId, targetAgentId);
}
async countSubtreeAgentsByLevel(rootAgentId: bigint) {
@@ -2242,13 +1892,7 @@ export class AgentsService {
}
async assertDirectChildAgent(parentAgentId: bigint, subAgentId: bigint) {
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: subAgentId },
});
if (!profile || profile.parentAgentId !== parentAgentId) {
throw appForbidden('NOT_SUB_AGENT');
}
return profile;
return this.network.assertDirectChildAgent(parentAgentId, subAgentId);
}
async getSubAgentForParent(parentAgentId: bigint, subAgentId: bigint) {
@@ -2275,27 +1919,7 @@ export class AgentsService {
}
async getSubtreeAgentIds(agentId: bigint) {
const ids: bigint[] = [];
const queue: bigint[] = [agentId];
const seen = new Set<string>();
while (queue.length > 0) {
const current = queue.shift()!;
const key = current.toString();
if (seen.has(key)) continue;
seen.add(key);
ids.push(current);
const children = await this.prisma.agentProfile.findMany({
where: { parentAgentId: current },
select: { userId: true },
});
for (const child of children) {
queue.push(child.userId);
}
}
return ids;
return this.network.getSubtreeAgentIds(agentId);
}
async getReportSummary(agentId: bigint) {