重构
This commit is contained in:
97
apps/api/src/domains/agent/agent-credit.service.spec.ts
Normal file
97
apps/api/src/domains/agent/agent-credit.service.spec.ts
Normal 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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
474
apps/api/src/domains/agent/agent-credit.service.ts
Normal file
474
apps/api/src/domains/agent/agent-credit.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
92
apps/api/src/domains/agent/agent-network.service.spec.ts
Normal file
92
apps/api/src/domains/agent/agent-network.service.spec.ts
Normal 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' }),
|
||||
});
|
||||
});
|
||||
});
|
||||
337
apps/api/src/domains/agent/agent-network.service.ts
Normal file
337
apps/api/src/domains/agent/agent-network.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
100
apps/api/src/domains/agent/agents.service.spec.ts
Normal file
100
apps/api/src/domains/agent/agents.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user