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:
@@ -27,6 +27,96 @@ export class AgentsService {
|
||||
private systemConfig: SystemConfigService,
|
||||
) {}
|
||||
|
||||
async getMaxAgentLevel(): Promise<number> {
|
||||
const settings = await this.systemConfig.getAgentHierarchySettings();
|
||||
return settings.maxAgentLevel;
|
||||
}
|
||||
|
||||
canCreateSubAgent(agentLevel: number, maxLevel: number): boolean {
|
||||
if (maxLevel === 0) return true;
|
||||
return agentLevel < maxLevel;
|
||||
}
|
||||
|
||||
private async buildAgentAncestorChainMap(parentAgentIds: (bigint | null | undefined)[]) {
|
||||
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;
|
||||
}
|
||||
|
||||
private async validateAgentLevel(level: number, parentAgentId?: bigint) {
|
||||
if (!Number.isInteger(level) || level < 1) {
|
||||
throw appBadRequest('AGENT_LEVEL_INVALID');
|
||||
}
|
||||
|
||||
const maxLevel = await this.getMaxAgentLevel();
|
||||
if (maxLevel > 0 && level > maxLevel) {
|
||||
throw appBadRequest('AGENT_MAX_LEVEL_REACHED');
|
||||
}
|
||||
|
||||
if (level === 1) {
|
||||
if (parentAgentId) throw appBadRequest('AGENT_LEVEL_ROOT_INVALID');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!parentAgentId) {
|
||||
throw appBadRequest('LEVEL2_REQUIRES_PARENT');
|
||||
}
|
||||
|
||||
const parent = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: parentAgentId },
|
||||
});
|
||||
if (!parent) throw appBadRequest('PARENT_AGENT_NOT_FOUND');
|
||||
if (parent.level !== level - 1) {
|
||||
throw appBadRequest('AGENT_PARENT_LEVEL_MISMATCH');
|
||||
}
|
||||
if (maxLevel > 0 && !this.canCreateSubAgent(parent.level, maxLevel)) {
|
||||
throw appBadRequest('AGENT_MAX_LEVEL_REACHED');
|
||||
}
|
||||
}
|
||||
|
||||
async getProfile(agentId: bigint) {
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: agentId },
|
||||
@@ -560,7 +650,9 @@ export class AgentsService {
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
level?: 1 | 2;
|
||||
level?: number;
|
||||
minLevel?: number;
|
||||
maxLevel?: number;
|
||||
parentAgentId?: bigint;
|
||||
}) {
|
||||
const page = Math.max(1, params?.page ?? 1);
|
||||
@@ -568,10 +660,13 @@ export class AgentsService {
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: Prisma.AgentProfileWhereInput = {};
|
||||
if (params?.level === 2) {
|
||||
where.level = 2;
|
||||
} else if (params?.level === 1) {
|
||||
where.level = 1;
|
||||
if (params?.level != null) {
|
||||
where.level = params.level;
|
||||
} else if (params?.minLevel != null || params?.maxLevel != null) {
|
||||
const levelFilter: { gte?: number; lte?: number } = {};
|
||||
if (params.minLevel != null) levelFilter.gte = params.minLevel;
|
||||
if (params.maxLevel != null) levelFilter.lte = params.maxLevel;
|
||||
where.level = levelFilter;
|
||||
} else if (params?.parentAgentId !== undefined) {
|
||||
where.parentAgentId = params.parentAgentId;
|
||||
} else {
|
||||
@@ -644,9 +739,15 @@ export class AgentsService {
|
||||
})
|
||||
: [];
|
||||
const parentUsernameMap = new Map(parentUsers.map((u) => [u.id.toString(), u.username]));
|
||||
const parentChainMap = await this.buildAgentAncestorChainMap(
|
||||
profiles.map((p) => p.parentAgentId),
|
||||
);
|
||||
|
||||
const items = profiles.map((p) => {
|
||||
const available = new Decimal(p.creditLimit).sub(p.usedCredit);
|
||||
const parentChain = p.parentAgentId
|
||||
? (parentChainMap.get(p.parentAgentId.toString()) ?? [])
|
||||
: [];
|
||||
return {
|
||||
id: p.id.toString(),
|
||||
userId: p.userId.toString(),
|
||||
@@ -658,6 +759,8 @@ export class AgentsService {
|
||||
parentUsername: p.parentAgentId
|
||||
? parentUsernameMap.get(p.parentAgentId.toString()) ?? null
|
||||
: null,
|
||||
parentChain,
|
||||
parentChainLabel: parentChain.length ? parentChain.join(' / ') : null,
|
||||
creditLimit: p.creditLimit.toString(),
|
||||
usedCredit: p.usedCredit.toString(),
|
||||
availableCredit: available.toString(),
|
||||
@@ -679,6 +782,19 @@ export class AgentsService {
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async countAgentsByLevel(): Promise<Record<number, number>> {
|
||||
const groups = await this.prisma.agentProfile.groupBy({
|
||||
by: ['level'],
|
||||
where: { user: { deletedAt: null } },
|
||||
_count: { _all: true },
|
||||
});
|
||||
const out: Record<number, number> = {};
|
||||
for (const g of groups) {
|
||||
out[g.level] = g._count._all;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async getAgentAdminDetail(agentId: bigint) {
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: agentId },
|
||||
@@ -902,6 +1018,8 @@ export class AgentsService {
|
||||
username?: string;
|
||||
password?: string;
|
||||
freezeDirectPlayers?: boolean;
|
||||
blockDirectPlayerLogin?: boolean;
|
||||
unfreezeDirectPlayers?: boolean;
|
||||
},
|
||||
) {
|
||||
const profile = await this.prisma.agentProfile.findUnique({
|
||||
@@ -945,8 +1063,15 @@ export class AgentsService {
|
||||
});
|
||||
}
|
||||
|
||||
// Handle status change (with optional cascade freeze)
|
||||
// Handle status change (per-action cascade freeze / login block)
|
||||
if (data.status) {
|
||||
const profilePatch: Prisma.AgentProfileUpdateInput = { status: data.status };
|
||||
if (data.status === 'SUSPENDED') {
|
||||
profilePatch.blockDirectPlayerLogin = data.blockDirectPlayerLogin === true;
|
||||
} else if (data.status === 'ACTIVE') {
|
||||
profilePatch.blockDirectPlayerLogin = false;
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.user.update({
|
||||
where: { id: agentId },
|
||||
@@ -954,22 +1079,23 @@ export class AgentsService {
|
||||
}),
|
||||
this.prisma.agentProfile.update({
|
||||
where: { userId: agentId },
|
||||
data: { status: data.status },
|
||||
data: profilePatch,
|
||||
}),
|
||||
]);
|
||||
|
||||
// 级联冻结:需后台开启且管理员/操作方显式勾选(MVP 默认不冻结玩家)
|
||||
const suspendSettings = await this.systemConfig.getAgentSuspendSettings();
|
||||
if (
|
||||
data.status === 'SUSPENDED' &&
|
||||
data.freezeDirectPlayers &&
|
||||
suspendSettings.suspendFreezeDirectPlayers
|
||||
) {
|
||||
if (data.status === 'SUSPENDED' && data.freezeDirectPlayers) {
|
||||
await this.prisma.user.updateMany({
|
||||
where: { parentId: agentId, userType: 'PLAYER', deletedAt: null },
|
||||
data: { status: 'SUSPENDED' },
|
||||
});
|
||||
}
|
||||
|
||||
if (data.status === 'ACTIVE' && data.unfreezeDirectPlayers) {
|
||||
await this.prisma.user.updateMany({
|
||||
where: { parentId: agentId, userType: 'PLAYER', deletedAt: null, status: 'SUSPENDED' },
|
||||
data: { status: 'ACTIVE' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (data.locale) {
|
||||
@@ -1167,12 +1293,7 @@ export class AgentsService {
|
||||
maxDailyDeposit?: number | null;
|
||||
},
|
||||
) {
|
||||
if (data.level !== 1 && data.level !== 2) {
|
||||
throw appBadRequest('AGENT_LEVEL_INVALID');
|
||||
}
|
||||
if (data.level === 2 && !data.parentAgentId) {
|
||||
throw appBadRequest('LEVEL2_REQUIRES_PARENT');
|
||||
}
|
||||
await this.validateAgentLevel(data.level, data.parentAgentId);
|
||||
|
||||
if (data.parentAgentId) {
|
||||
await this.assertChildAgentWithinParent(data.parentAgentId, {
|
||||
@@ -1300,10 +1421,17 @@ export class AgentsService {
|
||||
throw appBadRequest('PROMOTE_USE_CREDIT_NOT_BALANCE');
|
||||
}
|
||||
const parentAgentId = data.parentAgentId ?? data.parentId;
|
||||
if (parentAgentId == null) {
|
||||
throw appBadRequest('TIER2_REQUIRES_PARENT_AGENT');
|
||||
}
|
||||
const parentProfile = await this.prisma.agentProfile.findUnique({
|
||||
where: { userId: parentAgentId },
|
||||
});
|
||||
if (!parentProfile) throw appBadRequest('PARENT_AGENT_NOT_FOUND');
|
||||
return this.createAgent(operatorId, {
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
level: 2,
|
||||
level: parentProfile.level + 1,
|
||||
parentAgentId,
|
||||
creditLimit: data.creditLimit ?? 0,
|
||||
cashbackRate: data.cashbackRate ?? 0,
|
||||
@@ -1470,11 +1598,12 @@ export class AgentsService {
|
||||
email?: string;
|
||||
status?: string;
|
||||
freezeDirectPlayers?: boolean;
|
||||
blockDirectPlayerLogin?: boolean;
|
||||
unfreezeDirectPlayers?: boolean;
|
||||
},
|
||||
) {
|
||||
await this.assertDirectChildAgent(parentAgentId, subAgentId);
|
||||
const { freezeDirectPlayers: _ignored, ...safeData } = data;
|
||||
return this.updateAgentAdmin(subAgentId, safeData);
|
||||
return this.updateAgentAdmin(subAgentId, data);
|
||||
}
|
||||
|
||||
async getSubtreeAgentIds(agentId: bigint) {
|
||||
|
||||
Reference in New Issue
Block a user