feat: multi-tier agent hierarchy, wallet ledger, and player UX polish

Add configurable agent max level and default sub-agent credit ratio, per-agent block direct player login on suspend, admin/agent wallet transaction views, and match detail my-bets section with refreshed player card styling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-10 16:15:34 +08:00
parent 641c92a5f5
commit ef6b15f119
39 changed files with 2398 additions and 410 deletions

View File

@@ -25,14 +25,61 @@ export class UsersService {
parent?: {
username: string;
agentLevel: number | null;
parent?: { username: string; agentLevel: number | null } | null;
parent?: { username: string; agentLevel: number | null; parent?: unknown } | null;
} | null,
chain?: string[],
): string[] {
if (chain?.length) return chain;
if (!parent) return [];
if (parent.agentLevel === 2 && parent.parent?.username) {
return [parent.parent.username, parent.username];
const ancestors: string[] = [];
let cur: typeof parent | null | undefined = parent;
while (cur) {
ancestors.unshift(cur.username);
cur = cur.parent as typeof parent | null | undefined;
}
return [parent.username];
return ancestors;
}
private async buildAffiliationChainMap(parentIds: (bigint | null | undefined)[]) {
const map = new Map<string, string[]>();
const pending = new Set<bigint>();
const cache = new Map<string, { username: string; parentId: bigint | null }>();
for (const pid of parentIds) {
if (pid) pending.add(pid);
}
while (pending.size > 0) {
const batch = [...pending];
pending.clear();
const agents = await this.prisma.user.findMany({
where: { id: { in: batch }, userType: 'AGENT', deletedAt: null },
select: { id: true, username: true, parentId: true },
});
for (const agent of agents) {
cache.set(agent.id.toString(), { username: agent.username, parentId: agent.parentId });
if (agent.parentId && !cache.has(agent.parentId.toString())) {
pending.add(agent.parentId);
}
}
}
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.parentId;
}
return chain;
};
for (const pid of parentIds) {
if (pid) map.set(pid.toString(), build(pid));
}
return map;
}
private formatPlayerRow(
@@ -58,8 +105,9 @@ export class UsersService {
auth?: { lastLoginAt: Date | null } | null;
},
bet?: { count: number; totalStake: string; totalReturn: string },
affiliationChain?: string[],
) {
const affiliationAgents = this.buildAffiliationAgents(u.parent);
const affiliationAgents = this.buildAffiliationAgents(u.parent, affiliationChain);
return {
id: u.id.toString(),
username: u.username,
@@ -242,8 +290,15 @@ export class UsersService {
]);
const betMap = await this.loadBetStatsMap(rows.map((r) => r.id));
const affiliationMap = await this.buildAffiliationChainMap(rows.map((r) => r.parentId));
return {
items: rows.map((u) => this.formatPlayerRow(u, betMap.get(u.id.toString()))),
items: rows.map((u) =>
this.formatPlayerRow(
u,
betMap.get(u.id.toString()),
u.parentId ? affiliationMap.get(u.parentId.toString()) : undefined,
),
),
total,
page,
pageSize,
@@ -269,6 +324,7 @@ export class UsersService {
});
if (!user) throw appNotFound('PLAYER_NOT_FOUND');
const affiliationMap = await this.buildAffiliationChainMap([user.parentId]);
const [betCount, betStake] = await Promise.all([
this.prisma.bet.count({ where: { userId: playerId } }),
this.prisma.bet.aggregate({
@@ -278,7 +334,11 @@ export class UsersService {
]);
return {
...this.formatPlayerRow(user),
...this.formatPlayerRow(
user,
undefined,
user.parentId ? affiliationMap.get(user.parentId.toString()) : undefined,
),
lastLoginAt: user.auth?.lastLoginAt ?? null,
loginFailCount: user.auth?.loginFailCount ?? 0,
lockedUntil: user.auth?.lockedUntil ?? null,