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

@@ -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) {

View File

@@ -1,6 +1,7 @@
import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
import { LoginDto, ChangePasswordDto } from './auth.dto';
import { Public, CurrentUser } from '../../shared/common/decorators';
import { JwtAuthGuard } from './guards';
@@ -9,7 +10,10 @@ import { jsonResponse } from '../../shared/common/filters';
@ApiTags('Auth')
@Controller()
export class AuthController {
constructor(private auth: AuthService) {}
constructor(
private auth: AuthService,
private systemConfig: SystemConfigService,
) {}
@Public()
@Post('player/auth/login')
@@ -50,13 +54,25 @@ export class AuthController {
@CurrentUser('role') role: string | undefined,
@CurrentUser('agentLevel') agentLevel: number | null | undefined,
) {
const level = userType === 'AGENT' ? agentLevel ?? null : null;
let maxAgentLevel: number | null = null;
let canManageSubAgents = false;
if (userType === 'AGENT' && level != null && level > 0) {
const hierarchy = await this.systemConfig.getAgentHierarchySettings();
maxAgentLevel = hierarchy.maxAgentLevel;
canManageSubAgents =
maxAgentLevel === 0 || level < maxAgentLevel;
}
return jsonResponse({
id: userId.toString(),
username,
userType,
locale,
role,
agentLevel: userType === 'AGENT' ? agentLevel ?? null : null,
agentLevel: level,
maxAgentLevel,
canManageSubAgents,
});
}

View File

@@ -5,9 +5,11 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { JwtStrategy } from './jwt.strategy';
import { AuthController } from './auth.controller';
import { SystemConfigModule } from '../../shared/config/system-config.module';
@Module({
imports: [
SystemConfigModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
imports: [ConfigModule],

View File

@@ -62,15 +62,20 @@ export class AuthService {
}
if (portal === 'player' && user.parentId) {
const agentSettings = await this.systemConfig.getAgentSuspendSettings();
if (agentSettings.suspendBlockPlayerLogin) {
const parentAgent = await this.prisma.user.findUnique({
where: { id: user.parentId },
select: { userType: true, status: true },
});
if (parentAgent?.userType === 'AGENT' && parentAgent.status !== 'ACTIVE') {
throw appForbidden('PARENT_AGENT_SUSPENDED');
}
const parentAgent = await this.prisma.user.findUnique({
where: { id: user.parentId },
select: {
userType: true,
status: true,
agentProfile: { select: { blockDirectPlayerLogin: true } },
},
});
if (
parentAgent?.userType === 'AGENT' &&
parentAgent.status !== 'ACTIVE' &&
parentAgent.agentProfile?.blockDirectPlayerLogin
) {
throw appForbidden('PARENT_AGENT_SUSPENDED');
}
}

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,

View File

@@ -309,6 +309,227 @@ export class WalletService {
return { items, total, page, pageSize };
}
private walletTypeCategoryWhere(category?: string): Prisma.WalletTransactionWhereInput {
const cat = category?.trim();
if (cat === 'deposit') {
return { transactionType: { in: ['MANUAL_DEPOSIT', 'DEPOSIT', 'MANUAL_ADJUST'] } };
}
if (cat === 'withdraw') {
return { transactionType: { in: ['MANUAL_WITHDRAW', 'WITHDRAW'] } };
}
if (cat === 'bet') {
return {
transactionType: {
in: [
'BET_FREEZE',
'BET_DEDUCT',
'BET_SETTLE_WIN',
'BET_SETTLE_LOSE',
'BET_SETTLE_PUSH',
'BET_WIN',
'BET_REFUND',
'BET_VOID',
'BET_VOID_REFUND',
'RESETTLE_REVERSE',
],
},
};
}
if (cat === 'cashback') {
return { transactionType: { in: ['CASHBACK', 'CASHBACK_DEPOSIT'] } };
}
return {};
}
async listWalletTransactionsAdmin(params: {
page?: number;
pageSize?: number;
playerId?: bigint;
parentAgentId?: bigint;
parentAgentKeyword?: string;
scopedParentAgentIds?: bigint[];
keyword?: string;
operatorKeyword?: string;
transactionType?: string;
typeCategory?: string;
dateFrom?: Date;
dateTo?: Date;
}) {
const page = Math.max(1, params.page ?? 1);
const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 20));
const skip = (page - 1) * pageSize;
const where: Prisma.WalletTransactionWhereInput = {};
const explicitType = params.transactionType?.trim();
if (explicitType) {
where.transactionType = explicitType;
} else if (params.typeCategory?.trim()) {
Object.assign(where, this.walletTypeCategoryWhere(params.typeCategory));
}
if (params.dateFrom || params.dateTo) {
where.createdAt = {};
if (params.dateFrom) where.createdAt.gte = params.dateFrom;
if (params.dateTo) where.createdAt.lte = params.dateTo;
}
const operatorKeyword = params.operatorKeyword?.trim();
if (operatorKeyword) {
const matchedOps = await this.prisma.user.findMany({
where: {
deletedAt: null,
username: { contains: operatorKeyword, mode: 'insensitive' },
},
select: { id: true },
take: 50,
});
const operatorIds = matchedOps.map((u) => u.id);
if (!operatorIds.length) {
return { items: [], total: 0, page, pageSize };
}
where.operatorId = { in: operatorIds };
}
let playerIds: bigint[] | undefined;
if (params.playerId) {
playerIds = [params.playerId];
} else {
const playerWhere: Prisma.UserWhereInput = {
userType: 'PLAYER',
deletedAt: null,
};
if (params.parentAgentId) {
playerWhere.parentId = params.parentAgentId;
} else if (params.parentAgentKeyword?.trim()) {
const matchedAgents = await this.prisma.user.findMany({
where: {
userType: 'AGENT',
deletedAt: null,
username: { contains: params.parentAgentKeyword.trim(), mode: 'insensitive' },
...(params.scopedParentAgentIds?.length
? { id: { in: params.scopedParentAgentIds } }
: {}),
},
select: { id: true },
take: 50,
});
const agentIds = matchedAgents.map((a) => a.id);
if (!agentIds.length) {
return { items: [], total: 0, page, pageSize };
}
playerWhere.parentId = { in: agentIds };
} else if (params.scopedParentAgentIds?.length) {
playerWhere.parentId = { in: params.scopedParentAgentIds };
}
const keyword = params.keyword?.trim();
if (keyword) {
playerWhere.username = { contains: keyword, mode: 'insensitive' };
}
if (
params.parentAgentId ||
params.parentAgentKeyword?.trim() ||
params.scopedParentAgentIds?.length ||
keyword
) {
const players = await this.prisma.user.findMany({
where: playerWhere,
select: { id: true },
take: 500,
});
playerIds = players.map((p) => p.id);
if (!playerIds.length) {
return { items: [], total: 0, page, pageSize };
}
}
}
if (playerIds) {
where.userId = { in: playerIds };
}
const [rows, total] = await Promise.all([
this.prisma.walletTransaction.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.walletTransaction.count({ where }),
]);
const userIds = [...new Set(rows.map((r) => r.userId))];
const operatorIds = [
...new Set(rows.map((r) => r.operatorId).filter((id): id is bigint => id != null)),
];
const [players, operators] = await Promise.all([
userIds.length
? this.prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, username: true, parentId: true },
})
: [],
operatorIds.length
? this.prisma.user.findMany({
where: { id: { in: operatorIds } },
select: { id: true, username: true },
})
: [],
]);
const parentIds = [
...new Set(players.map((p) => p.parentId).filter((id): id is bigint => id != null)),
];
const parentAgents = parentIds.length
? await this.prisma.user.findMany({
where: { id: { in: parentIds } },
select: { id: true, username: true },
})
: [];
const playerById = new Map(players.map((p) => [p.id.toString(), p]));
const operatorById = new Map(operators.map((u) => [u.id.toString(), u.username]));
const parentById = new Map(parentAgents.map((a) => [a.id.toString(), a.username]));
return {
items: rows.map((row) => {
const player = playerById.get(row.userId.toString());
const parentId = player?.parentId;
return {
id: row.id.toString(),
transactionId: row.transactionId,
playerId: row.userId.toString(),
playerUsername: player?.username ?? null,
parentAgentId: parentId?.toString() ?? null,
parentAgentUsername: parentId ? (parentById.get(parentId.toString()) ?? null) : null,
transactionType: row.transactionType,
amount: row.amount.toString(),
balanceBefore: row.balanceBefore.toString(),
balanceAfter: row.balanceAfter.toString(),
frozenBefore: row.frozenBefore.toString(),
frozenAfter: row.frozenAfter.toString(),
referenceType: row.referenceType,
referenceId: row.referenceId,
betNo: row.referenceType === 'BET' ? row.referenceId : null,
operatorId: row.operatorId?.toString() ?? null,
operatorUsername: row.operatorId
? (operatorById.get(row.operatorId.toString()) ?? null)
: null,
remark: row.remark,
createdAt: row.createdAt,
};
}),
total,
page,
pageSize,
};
}
async listTransferTransactions(params: {
page?: number;
pageSize?: number;