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

@@ -50,6 +50,7 @@ import {
MinLength,
IsIn,
Min,
Max,
Equals,
ValidateIf,
} from 'class-validator';
@@ -257,6 +258,19 @@ class AgentSuspendSettingsDto {
suspendBlockPlayerLogin?: boolean;
}
class AgentHierarchySettingsDto {
@IsOptional()
@IsNumber()
@Min(0)
maxAgentLevel?: number;
@IsOptional()
@IsNumber()
@Min(1)
@Max(100)
defaultSubAgentCreditRatio?: number;
}
class ResetDatabaseDto {
@IsString()
@Equals('RESET')
@@ -340,6 +354,16 @@ class UpdateAgentAdminDto {
@IsOptional()
@IsBoolean()
freezeDirectPlayers?: boolean;
/** 冻结时是否禁止直属玩家登录 */
@IsOptional()
@IsBoolean()
blockDirectPlayerLogin?: boolean;
/** 解冻时是否级联解冻直属玩家 */
@IsOptional()
@IsBoolean()
unfreezeDirectPlayers?: boolean;
}
class DepositDto {
@@ -945,6 +969,30 @@ export class AdminController {
return jsonResponse(settings);
}
@Get('agents/settings/hierarchy')
@RequirePermissions(P.settings)
async getAgentHierarchySettings() {
const settings = await this.systemConfig.getAgentHierarchySettings();
return jsonResponse(settings);
}
@Put('agents/settings/hierarchy')
@RequirePermissions(P.settings)
async updateAgentHierarchySettings(
@CurrentUser('id') operatorId: bigint,
@Body() dto: AgentHierarchySettingsDto,
) {
const settings = await this.systemConfig.updateAgentHierarchySettings(dto);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'UPDATE_AGENT_HIERARCHY_SETTINGS',
module: 'AGENTS',
afterData: JSON.stringify(settings),
});
return jsonResponse(settings);
}
@Get('settings/betting-limits')
@RequirePermissions(P.settings)
async getBettingLimits() {
@@ -1122,6 +1170,13 @@ export class AdminController {
);
}
@Get('agents/level-counts')
@RequirePermissions(P.agentsView)
async getAgentLevelCounts() {
const counts = await this.agents.countAgentsByLevel();
return jsonResponse(counts);
}
@Get('agents')
@RequirePermissions(P.agentsView)
async listAgents(
@@ -1130,15 +1185,21 @@ export class AdminController {
@Query('keyword') keyword?: string,
@Query('status') status?: string,
@Query('level') level?: string,
@Query('minLevel') minLevel?: string,
@Query('maxLevel') maxLevel?: string,
@Query('parentAgentId') parentAgentId?: string,
) {
const parsedLevel = level === '2' ? 2 : level === '1' ? 1 : undefined;
const parsedLevel = level != null && level !== '' ? parseInt(level, 10) : undefined;
const parsedMinLevel = minLevel != null && minLevel !== '' ? parseInt(minLevel, 10) : undefined;
const parsedMaxLevel = maxLevel != null && maxLevel !== '' ? parseInt(maxLevel, 10) : undefined;
const result = await this.agents.listAgentsAdmin({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 10,
keyword,
status,
level: parsedLevel,
level: Number.isFinite(parsedLevel) ? parsedLevel : undefined,
minLevel: Number.isFinite(parsedMinLevel) ? parsedMinLevel : undefined,
maxLevel: Number.isFinite(parsedMaxLevel) ? parsedMaxLevel : undefined,
parentAgentId: parentAgentId ? BigInt(parentAgentId) : undefined,
});
return jsonResponse(result);
@@ -1270,9 +1331,33 @@ export class AdminController {
}
@Get('wallet/transactions')
@RequirePermissions(P.walletDeposit, P.walletWithdraw)
async walletTransactions(@Query('userId') userId: string, @Query('page') page?: string) {
const result = await this.wallet.getTransactions(BigInt(userId), page ? parseInt(page) : 1);
@RequirePermissions(P.walletDeposit, P.walletWithdraw, P.reports)
async walletTransactions(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('playerId') playerId?: string,
@Query('parentAgentId') parentAgentId?: string,
@Query('parentAgentKeyword') parentAgentKeyword?: string,
@Query('keyword') keyword?: string,
@Query('operatorKeyword') operatorKeyword?: string,
@Query('transactionType') transactionType?: string,
@Query('typeCategory') typeCategory?: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
) {
const result = await this.wallet.listWalletTransactionsAdmin({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 20,
playerId: playerId ? BigInt(playerId) : undefined,
parentAgentId: parentAgentId ? BigInt(parentAgentId) : undefined,
parentAgentKeyword,
keyword,
operatorKeyword,
transactionType,
typeCategory,
dateFrom: dateFrom ? new Date(dateFrom) : undefined,
dateTo: dateTo ? new Date(dateTo) : undefined,
});
return jsonResponse(result);
}

View File

@@ -12,6 +12,7 @@ import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { JwtAuthGuard, AgentGuard } from '../../domains/identity/guards';
import { CurrentUser } from '../../shared/common/decorators';
import { jsonResponse } from '../../shared/common/filters';
import { appForbidden } from '../../shared/common/app-error';
import { AgentsService } from '../../domains/agent/agents.service';
import { WalletService } from '../../domains/ledger/wallet.service';
import { BetsService } from '../../domains/betting/bets.service';
@@ -131,6 +132,14 @@ class UpdateSubAgentDto {
@IsOptional()
@IsBoolean()
freezeDirectPlayers?: boolean;
@IsOptional()
@IsBoolean()
blockDirectPlayerLogin?: boolean;
@IsOptional()
@IsBoolean()
unfreezeDirectPlayers?: boolean;
}
@ApiTags('Agent Portal')
@@ -145,10 +154,20 @@ export class AgentPortalController {
private prisma: PrismaService,
) {}
private async canManageSubAgents(level: number) {
const maxLevel = await this.agents.getMaxAgentLevel();
return this.agents.canCreateSubAgent(level, maxLevel);
}
@Get('profile')
async profile(@CurrentUser('id') agentId: bigint) {
async profile(@CurrentUser('id') agentId: bigint, @CurrentUser('agentLevel') level: number) {
const profile = await this.agents.getProfile(agentId);
return jsonResponse(profile);
const maxLevel = await this.agents.getMaxAgentLevel();
return jsonResponse({
...profile,
maxAgentLevel: maxLevel,
canManageSubAgents: this.agents.canCreateSubAgent(level, maxLevel),
});
}
@Get('players')
@@ -218,7 +237,8 @@ export class AgentPortalController {
@Get('agents')
async listSubAgents(@CurrentUser('id') agentId: bigint, @CurrentUser('agentLevel') level: number) {
if (level !== 1) {
const maxLevel = await this.agents.getMaxAgentLevel();
if (!this.agents.canCreateSubAgent(level, maxLevel)) {
return jsonResponse([]);
}
const agents = await this.agents.listChildAgentsSummary(agentId);
@@ -227,13 +247,14 @@ export class AgentPortalController {
@Post('agents')
async createSubAgent(@CurrentUser('id') agentId: bigint, @CurrentUser('agentLevel') level: number, @Body() dto: CreateSubAgentDto) {
if (level !== 1) {
return jsonResponse(null, 'Only level 1 agents can create sub-agents');
const maxLevel = await this.agents.getMaxAgentLevel();
if (!this.agents.canCreateSubAgent(level, maxLevel)) {
throw appForbidden('AGENT_MAX_LEVEL_REACHED');
}
const user = await this.agents.createAgent(agentId, {
username: dto.username,
password: dto.password,
level: 2,
level: level + 1,
parentAgentId: agentId,
creditLimit: dto.creditLimit,
cashbackRate: dto.cashbackRate,
@@ -281,8 +302,8 @@ export class AgentPortalController {
@CurrentUser('agentLevel') level: number,
@Param('id') subAgentId: string,
) {
if (level !== 1) {
return jsonResponse(null, 'Only level 1 agents can manage sub-agents');
if (!(await this.canManageSubAgents(level))) {
throw appForbidden('AGENT_MAX_LEVEL_REACHED');
}
const detail = await this.agents.getSubAgentForParent(agentId, BigInt(subAgentId));
return jsonResponse(detail);
@@ -295,8 +316,8 @@ export class AgentPortalController {
@Param('id') subAgentId: string,
@Body() dto: UpdateSubAgentDto,
) {
if (level !== 1) {
return jsonResponse(null, 'Only level 1 agents can manage sub-agents');
if (!(await this.canManageSubAgents(level))) {
throw appForbidden('AGENT_MAX_LEVEL_REACHED');
}
const detail = await this.agents.updateSubAgentForParent(agentId, BigInt(subAgentId), dto);
return jsonResponse(detail);
@@ -308,7 +329,7 @@ export class AgentPortalController {
@CurrentUser('agentLevel') level: number,
@Param('id') subAgentId: string,
) {
if (level !== 1) {
if (!(await this.canManageSubAgents(level))) {
return jsonResponse([]);
}
await this.agents.assertDirectChildAgent(agentId, BigInt(subAgentId));
@@ -448,4 +469,56 @@ export class AgentPortalController {
});
return jsonResponse(result);
}
@Get('wallet/ledger-transactions')
async walletLedgerTransactions(
@CurrentUser('id') agentId: bigint,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('playerId') playerId?: string,
@Query('parentAgentId') parentAgentId?: string,
@Query('parentAgentKeyword') parentAgentKeyword?: string,
@Query('keyword') keyword?: string,
@Query('operatorKeyword') operatorKeyword?: string,
@Query('transactionType') transactionType?: string,
@Query('typeCategory') typeCategory?: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
) {
const scopedParentAgentIds = await this.agents.getSubtreeAgentIds(agentId);
const parsedParentAgentId = parentAgentId ? BigInt(parentAgentId) : undefined;
if (
parsedParentAgentId &&
!scopedParentAgentIds.some((id) => id === parsedParentAgentId)
) {
return jsonResponse({ items: [], total: 0, page: 1, pageSize: 20 });
}
if (playerId) {
const player = await this.prisma.user.findFirst({
where: { id: BigInt(playerId), userType: 'PLAYER', deletedAt: null },
select: { id: true, parentId: true },
});
if (
!player?.parentId ||
!scopedParentAgentIds.some((id) => id === player.parentId)
) {
return jsonResponse({ items: [], total: 0, page: 1, pageSize: 20 });
}
}
const result = await this.wallet.listWalletTransactionsAdmin({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 20,
playerId: playerId ? BigInt(playerId) : undefined,
parentAgentId: parsedParentAgentId,
parentAgentKeyword,
scopedParentAgentIds,
keyword,
operatorKeyword,
transactionType,
typeCategory,
dateFrom: dateFrom ? new Date(dateFrom) : undefined,
dateTo: dateTo ? new Date(dateTo) : undefined,
});
return jsonResponse(result);
}
}