feat(admin,api,player): 代理层级管理、额度上下分与玩家钱包详情

新增代理管理器与二级代理体系,完善信用额度/上下分上下文与冻结策略;代理端玩家与子代理管理增强;玩家端新增钱包详情页与交易筛选优化。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-08 15:34:12 +08:00
parent b2216abd0c
commit 414998ce36
54 changed files with 6641 additions and 481 deletions

View File

@@ -101,6 +101,15 @@ class CreatePlayerAdminDto {
@IsOptional()
asTier1Agent?: boolean;
/** 创建为二级代理(需要 parentAgentId */
@IsOptional()
asSubAgent?: boolean;
/** 二级代理的上级代理 ID */
@IsOptional()
@IsString()
parentAgentId?: string;
@IsOptional()
@IsNumber()
@Min(0)
@@ -110,6 +119,16 @@ class CreatePlayerAdminDto {
@IsNumber()
@Min(0)
cashbackRate?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxSingleDeposit?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxDailyDeposit?: number;
}
class UpdatePlayerAdminDto {
@@ -154,6 +173,16 @@ class PlayerAccountSettingsDto {
allowUsernameChange?: boolean;
}
class AgentSuspendSettingsDto {
@IsOptional()
@IsBoolean()
suspendFreezeDirectPlayers?: boolean;
@IsOptional()
@IsBoolean()
suspendBlockPlayerLogin?: boolean;
}
class ResetDatabaseDto {
@IsString()
@Equals('RESET')
@@ -181,6 +210,16 @@ class CreateAgentAdminDto {
@IsNumber()
@Min(0)
cashbackRate?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxSingleDeposit?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxDailyDeposit?: number;
}
class UpdateAgentAdminDto {
@@ -204,6 +243,29 @@ class UpdateAgentAdminDto {
@IsNumber()
@Min(0)
cashbackRate?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxSingleDeposit?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxDailyDeposit?: number;
@IsOptional()
@IsString()
username?: string;
@IsOptional()
@IsString()
password?: string;
/** 冻结时是否级联冻结直属玩家 */
@IsOptional()
@IsBoolean()
freezeDirectPlayers?: boolean;
}
class DepositDto {
@@ -717,6 +779,30 @@ export class AdminController {
return jsonResponse(settings);
}
@Get('agents/settings/suspend')
@RequirePermissions(P.settings)
async getAgentSuspendSettings() {
const settings = await this.systemConfig.getAgentSuspendSettings();
return jsonResponse(settings);
}
@Put('agents/settings/suspend')
@RequirePermissions(P.settings)
async updateAgentSuspendSettings(
@CurrentUser('id') operatorId: bigint,
@Body() dto: AgentSuspendSettingsDto,
) {
const settings = await this.systemConfig.updateAgentSuspendSettings(dto);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'UPDATE_AGENT_SUSPEND_SETTINGS',
module: 'AGENTS',
afterData: JSON.stringify(settings),
});
return jsonResponse(settings);
}
@Get('settings/betting-limits')
@RequirePermissions(P.settings)
async getBettingLimits() {
@@ -830,17 +916,21 @@ export class AdminController {
depositRemark: dto.remark,
depositRequestId: `create-player-${dto.username}-${Date.now()}`,
asTier1Agent: dto.asTier1Agent,
asSubAgent: dto.asSubAgent,
parentAgentId: dto.parentAgentId ? BigInt(dto.parentAgentId) : undefined,
creditLimit: dto.creditLimit,
cashbackRate: dto.cashbackRate,
maxSingleDeposit: dto.maxSingleDeposit,
maxDailyDeposit: dto.maxDailyDeposit,
});
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: dto.asTier1Agent ? 'CREATE_AGENT' : 'CREATE_PLAYER',
module: dto.asTier1Agent ? 'AGENTS' : 'USERS',
action: dto.asTier1Agent || dto.asSubAgent ? 'CREATE_AGENT' : 'CREATE_PLAYER',
module: dto.asTier1Agent || dto.asSubAgent ? 'AGENTS' : 'USERS',
targetId: user.id.toString(),
});
if (dto.asTier1Agent) {
if (dto.asTier1Agent || dto.asSubAgent) {
const detail = await this.agents.getAgentAdminDetail(user.id);
return jsonResponse(detail);
}
@@ -884,11 +974,13 @@ export class AdminController {
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('parentAgentId') parentAgentId?: string,
) {
const result = await this.agents.listAgentsAdmin({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 10,
keyword,
parentAgentId: parentAgentId ? BigInt(parentAgentId) : undefined,
});
return jsonResponse(result);
}
@@ -929,6 +1021,8 @@ export class AdminController {
phone: dto.phone,
email: dto.email,
cashbackRate: dto.cashbackRate,
maxSingleDeposit: dto.maxSingleDeposit,
maxDailyDeposit: dto.maxDailyDeposit,
});
await this.audit.log({
operatorId,
@@ -961,7 +1055,7 @@ export class AdminController {
@Post('wallet/deposit')
@RequirePermissions(P.walletDeposit)
async deposit(@CurrentUser('id') operatorId: bigint, @Body() dto: DepositDto & { userId: string }) {
const result = await this.wallet.deposit(
const result = await this.agents.adminDepositToPlayer(
BigInt(dto.userId),
dto.amount,
operatorId,
@@ -971,6 +1065,13 @@ export class AdminController {
return jsonResponse(result);
}
@Get('wallet/transfer-context/:userId')
@RequirePermissions(P.walletDeposit, P.walletWithdraw)
async walletTransferContext(@Param('userId') userId: string) {
const ctx = await this.agents.getPlayerTransferContext(BigInt(userId), { forAdmin: true });
return jsonResponse(ctx);
}
@Post('wallet/withdraw')
@RequirePermissions(P.walletWithdraw)
async withdraw(@CurrentUser('id') operatorId: bigint, @Body() dto: DepositDto & { userId: string }) {

View File

@@ -2,6 +2,7 @@ import {
Controller,
Get,
Post,
Put,
Body,
Param,
Query,
@@ -15,7 +16,7 @@ import { AgentsService } from '../../domains/agent/agents.service';
import { WalletService } from '../../domains/ledger/wallet.service';
import { BetsService } from '../../domains/betting/bets.service';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { IsString, IsNumber, MinLength, IsOptional } from 'class-validator';
import { IsString, IsNumber, MinLength, IsOptional, Min, IsBoolean } from 'class-validator';
class CreatePlayerDto {
@IsString()
@@ -24,12 +25,71 @@ class CreatePlayerDto {
@IsString()
@MinLength(8)
password!: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@IsString()
locale?: string;
@IsOptional()
@IsNumber()
@Min(0)
initialDeposit?: number;
@IsOptional()
@IsString()
remark?: string;
}
class CreateSubAgentDto extends CreatePlayerDto {
@IsOptional()
@IsNumber()
creditLimit?: number;
@IsOptional()
@IsNumber()
@Min(0)
cashbackRate?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxSingleDeposit?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxDailyDeposit?: number;
}
class UpdatePlayerDto {
@IsOptional()
@IsString()
username?: string;
@IsOptional()
@IsString()
@MinLength(8)
password?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@IsString()
status?: string;
}
class TransferDto {
@@ -46,6 +106,33 @@ class CreditDto extends TransferDto {
remark?: string;
}
class UpdateSubAgentDto {
@IsOptional()
@IsString()
username?: string;
@IsOptional()
@IsString()
@MinLength(8)
password?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsBoolean()
freezeDirectPlayers?: boolean;
}
@ApiTags('Agent Portal')
@Controller('agent')
@UseGuards(JwtAuthGuard, AgentGuard)
@@ -76,8 +163,57 @@ export class AgentPortalController {
username: dto.username,
password: dto.password,
parentId: agentId,
locale: dto.locale,
phone: dto.phone,
email: dto.email,
});
return jsonResponse(user);
if (dto.initialDeposit != null && dto.initialDeposit > 0) {
await this.agents.depositToPlayer(
agentId,
user.id,
dto.initialDeposit,
`agent-create-${user.id}-${Date.now()}`,
dto.remark ?? '开户初始余额',
);
}
const wallet = await this.prisma.wallet.findUnique({ where: { userId: user.id } });
return jsonResponse({
id: user.id,
username: user.username,
status: user.status,
createdAt: user.createdAt,
availableBalance: wallet?.availableBalance ?? 0,
});
}
@Get('players/:id')
async getPlayer(@CurrentUser('id') agentId: bigint, @Param('id') playerId: string) {
const detail = await this.agents.getDirectPlayerDetail(agentId, BigInt(playerId));
return jsonResponse(detail);
}
@Get('players/:id/transfer-context')
async getPlayerTransferContext(
@CurrentUser('id') agentId: bigint,
@Param('id') playerId: string,
) {
const ctx = await this.agents.getPlayerTransferContext(BigInt(playerId), {
actingAgentId: agentId,
});
return jsonResponse(ctx);
}
@Put('players/:id')
async updatePlayer(
@CurrentUser('id') agentId: bigint,
@Param('id') playerId: string,
@Body() dto: UpdatePlayerDto,
) {
const detail = await this.agents.updateDirectPlayer(agentId, BigInt(playerId), dto);
return jsonResponse(detail);
}
@Get('agents')
@@ -85,7 +221,7 @@ export class AgentPortalController {
if (level !== 1) {
return jsonResponse([]);
}
const agents = await this.agents.getChildAgents(agentId);
const agents = await this.agents.listChildAgentsSummary(agentId);
return jsonResponse(agents);
}
@@ -100,6 +236,9 @@ export class AgentPortalController {
level: 2,
parentAgentId: agentId,
creditLimit: dto.creditLimit,
cashbackRate: dto.cashbackRate,
maxSingleDeposit: dto.maxSingleDeposit,
maxDailyDeposit: dto.maxDailyDeposit,
});
return jsonResponse(user);
}
@@ -124,6 +263,47 @@ export class AgentPortalController {
return jsonResponse(result);
}
@Get('agents/:id')
async getSubAgent(
@CurrentUser('id') agentId: bigint,
@CurrentUser('agentLevel') level: number,
@Param('id') subAgentId: string,
) {
if (level !== 1) {
return jsonResponse(null, 'Only level 1 agents can manage sub-agents');
}
const detail = await this.agents.getSubAgentForParent(agentId, BigInt(subAgentId));
return jsonResponse(detail);
}
@Put('agents/:id')
async updateSubAgent(
@CurrentUser('id') agentId: bigint,
@CurrentUser('agentLevel') level: number,
@Param('id') subAgentId: string,
@Body() dto: UpdateSubAgentDto,
) {
if (level !== 1) {
return jsonResponse(null, 'Only level 1 agents can manage sub-agents');
}
const detail = await this.agents.updateSubAgentForParent(agentId, BigInt(subAgentId), dto);
return jsonResponse(detail);
}
@Get('agents/:id/players')
async listSubAgentPlayers(
@CurrentUser('id') agentId: bigint,
@CurrentUser('agentLevel') level: number,
@Param('id') subAgentId: string,
) {
if (level !== 1) {
return jsonResponse([]);
}
await this.agents.assertDirectChildAgent(agentId, BigInt(subAgentId));
const players = await this.agents.getDirectPlayers(BigInt(subAgentId));
return jsonResponse(players);
}
@Post('agents/:id/credit')
async allocateCredit(
@CurrentUser('id') agentId: bigint,

View File

@@ -250,8 +250,9 @@ export class PlayerController {
async transactions(
@CurrentUser('id') userId: bigint,
@Query('page') page?: string,
@Query('type') type?: string,
) {
const result = await this.wallet.getTransactions(userId, page ? parseInt(page) : 1);
const result = await this.wallet.getTransactions(userId, page ? parseInt(page) : 1, 20, type);
return jsonResponse(result);
}

View File

@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { AgentsService } from './agents.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],
imports: [WalletModule, AuthModule, SystemConfigModule],
providers: [AgentsService],
exports: [AgentsService],
})

View File

@@ -4,19 +4,30 @@ import {
ForbiddenException,
NotFoundException,
} 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';
import { generateBatchNo } from '../../shared/common/decorators';
function dec(v: Decimal | null | undefined) {
return v?.toString() ?? '0';
}
function sub(a: Decimal | null | undefined, b: Decimal | null | undefined) {
return new Decimal(a ?? 0).sub(b ?? 0).toString();
}
@Injectable()
export class AgentsService {
constructor(
private prisma: PrismaService,
private wallet: WalletService,
private auth: AuthService,
private systemConfig: SystemConfigService,
) {}
async getProfile(agentId: bigint) {
@@ -84,6 +95,10 @@ export class AgentsService {
const creditAfter = creditBefore.add(amt);
if (creditAfter.lt(0)) throw new BadRequestException('Credit limit cannot be negative');
if (profile.parentAgentId) {
await this.assertChildCreditWithinParent(profile.parentAgentId, profile, creditAfter);
}
await this.prisma.$transaction(async (tx) => {
await tx.agentProfile.update({
where: { userId: agentId },
@@ -111,16 +126,275 @@ export class AgentsService {
return { creditAfter };
}
/** 代理只能操作直属玩家parentId === 当前代理) */
private async requireDirectPlayer(agentId: bigint, playerId: bigint) {
const player = await this.prisma.user.findFirst({
where: { id: playerId, userType: 'PLAYER', deletedAt: null },
include: { auth: true, wallet: true, preferences: true },
});
if (!player) throw new NotFoundException('玩家不存在');
if (player.parentId !== agentId) {
throw new ForbiddenException('Can only manage direct players');
}
return player;
}
private async assertChildAgentWithinParent(
parentAgentId: bigint,
child: {
creditLimit?: number | Decimal;
cashbackRate?: number | Decimal;
maxSingleDeposit?: number | Decimal | null;
maxDailyDeposit?: number | Decimal | null;
},
) {
const parent = await this.prisma.agentProfile.findUnique({
where: { userId: parentAgentId },
});
if (!parent) throw new BadRequestException('上级代理不存在');
if (child.creditLimit !== undefined) {
const limit = new Decimal(child.creditLimit);
if (limit.lt(0)) throw new BadRequestException('授信额度不能为负');
if (limit.gt(parent.creditLimit)) {
throw new BadRequestException('下级代理授信不能超过上级授信额度');
}
}
if (child.cashbackRate !== undefined) {
const rate = new Decimal(child.cashbackRate);
if (rate.lt(0)) throw new BadRequestException('回水比例不能为负');
if (rate.gt(parent.cashbackRate)) {
throw new BadRequestException('下级代理回水比例不能超过上级');
}
}
if (child.maxSingleDeposit != null && parent.maxSingleDeposit != null) {
if (new Decimal(child.maxSingleDeposit).gt(parent.maxSingleDeposit)) {
throw new BadRequestException('下级代理单笔限额不能超过上级');
}
}
if (child.maxSingleDeposit != null && new Decimal(child.maxSingleDeposit).lt(0)) {
throw new BadRequestException('单笔限额不能为负');
}
if (child.maxDailyDeposit != null && parent.maxDailyDeposit != null) {
if (new Decimal(child.maxDailyDeposit).gt(parent.maxDailyDeposit)) {
throw new BadRequestException('下级代理日限额不能超过上级');
}
}
if (child.maxDailyDeposit != null && new Decimal(child.maxDailyDeposit).lt(0)) {
throw new BadRequestException('日限额不能为负');
}
}
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 normalizeOptionalLimit(value?: number | null) {
if (value == null || value <= 0) return null;
return new Decimal(value);
}
/** 玩家有所属代理时,上分金额不得超过该代理当前可用授信(会先重算 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 new BadRequestException('超过玩家上级代理可用授信,无法上分');
}
}
/** 管理员给玩家上分:校验上级授信后入账,并刷新代理占用额度 */
async adminDepositToPlayer(
playerId: bigint,
amount: number,
operatorId: bigint,
remark?: string,
requestId?: string,
) {
await this.assertPlayerParentCreditForDeposit(playerId, amount);
const result = await this.wallet.deposit(
playerId,
amount,
operatorId,
remark,
requestId,
);
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 new NotFoundException('玩家不存在');
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,
};
}
private async assertAgentDepositLimits(creditAgentId: bigint, amount: Decimal) {
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: creditAgentId },
});
if (!profile) return;
const parent = profile.parentAgentId
? await this.prisma.agentProfile.findUnique({
where: { userId: profile.parentAgentId },
})
: null;
const { maxSingleDeposit, maxDailyDeposit } = this.resolveEffectiveDepositLimits(profile, parent);
if (maxSingleDeposit && amount.gt(maxSingleDeposit)) {
throw new BadRequestException('超过代理单笔上分限额');
}
if (maxDailyDeposit) {
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 },
});
const dailyTotal = new Decimal(dailyAgg._sum.amount ?? 0).add(amount);
if (dailyTotal.gt(maxDailyDeposit)) {
throw new BadRequestException('超过代理日上分限额');
}
}
}
private async assertChildCreditWithinParent(
parentAgentId: bigint,
childProfile: { userId: bigint; creditLimit: Decimal; usedCredit: Decimal },
creditAfter: Decimal,
) {
await this.assertChildAgentWithinParent(parentAgentId, { creditLimit: creditAfter });
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 (exposureDelta.gt(0) && exposureDelta.gt(parentAvailable)) {
throw new BadRequestException('上级可用授信不足');
}
}
async depositToPlayer(
agentId: bigint,
playerId: bigint,
amount: number,
requestId: string,
remark?: string,
) {
const player = await this.prisma.user.findUnique({ where: { id: playerId } });
if (!player || player.parentId !== agentId) {
throw new ForbiddenException('Can only deposit to direct players');
}
await this.requireDirectPlayer(agentId, playerId);
const profile = await this.getProfile(agentId);
const available = new Decimal(profile.creditLimit).sub(profile.usedCredit);
@@ -130,7 +404,9 @@ export class AgentsService {
throw new BadRequestException('Insufficient agent credit');
}
await this.wallet.deposit(playerId, amt, agentId, 'Agent deposit', requestId);
await this.assertAgentDepositLimits(agentId, amt);
await this.wallet.deposit(playerId, amt, agentId, remark ?? 'Agent deposit', requestId);
await this.recalculateUsedCredit(agentId);
return { success: true };
@@ -142,10 +418,7 @@ export class AgentsService {
amount: number,
requestId: string,
) {
const player = await this.prisma.user.findUnique({ where: { id: playerId } });
if (!player || player.parentId !== agentId) {
throw new ForbiddenException('Can only withdraw from direct players');
}
await this.requireDirectPlayer(agentId, playerId);
await this.wallet.withdraw(playerId, amount, agentId, 'Agent withdraw', requestId);
await this.recalculateUsedCredit(agentId);
@@ -153,16 +426,124 @@ export class AgentsService {
return { success: true };
}
async getDirectPlayerDetail(agentId: bigint, playerId: bigint) {
const user = await this.requireDirectPlayer(agentId, playerId);
const [betCount, betStake] = await Promise.all([
this.prisma.bet.count({ where: { userId: playerId } }),
this.prisma.bet.aggregate({
where: { userId: playerId },
_sum: { stake: true, actualReturn: true },
}),
]);
return {
id: user.id.toString(),
username: user.username,
status: user.status,
phone: user.preferences?.phone ?? null,
email: user.preferences?.email ?? null,
managedPassword: user.preferences?.managedPassword ?? null,
availableBalance: user.wallet?.availableBalance?.toString() ?? '0',
frozenBalance: user.wallet?.frozenBalance?.toString() ?? '0',
lastLoginAt: user.auth?.lastLoginAt ?? null,
loginFailCount: user.auth?.loginFailCount ?? 0,
betCount,
totalStake: betStake._sum.stake?.toString() ?? '0',
totalReturn: betStake._sum.actualReturn?.toString() ?? '0',
createdAt: user.createdAt,
};
}
async updateDirectPlayer(
agentId: bigint,
playerId: bigint,
data: {
username?: string;
password?: string;
phone?: string;
email?: string;
status?: string;
},
) {
const user = await this.requireDirectPlayer(agentId, playerId);
if (data.status && !['ACTIVE', 'SUSPENDED'].includes(data.status)) {
throw new BadRequestException('无效状态');
}
if (data.username !== undefined) {
const nextUsername = data.username.trim();
if (!nextUsername) throw new BadRequestException('账号名称不能为空');
if (nextUsername !== user.username) {
const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } });
if (taken) throw new BadRequestException('账号名称已被占用');
await this.prisma.user.update({
where: { id: playerId },
data: { username: nextUsername },
});
}
}
if (data.password !== undefined) {
const nextPassword = data.password;
if (nextPassword.length < 8) throw new BadRequestException('密码至少 8 位');
if (!user.auth) throw new BadRequestException('账号认证信息缺失');
const hash = await bcrypt.hash(nextPassword, 10);
await this.prisma.userAuth.update({
where: { userId: playerId },
data: { passwordHash: hash, loginFailCount: 0, lockedUntil: null },
});
await this.prisma.userPreference.upsert({
where: { userId: playerId },
create: { userId: playerId, managedPassword: nextPassword },
update: { managedPassword: nextPassword },
});
}
if (data.status) {
await this.prisma.user.update({
where: { id: playerId },
data: { status: data.status },
});
}
const prefPatch: { phone?: string | null; email?: string | null } = {};
if (data.phone !== undefined) prefPatch.phone = data.phone.trim() || null;
if (data.email !== undefined) prefPatch.email = data.email.trim() || null;
if (Object.keys(prefPatch).length > 0) {
await this.prisma.userPreference.upsert({
where: { userId: playerId },
create: {
userId: playerId,
phone: prefPatch.phone ?? null,
email: prefPatch.email ?? null,
},
update: prefPatch,
});
}
return this.getDirectPlayerDetail(agentId, playerId);
}
async listAgentsAdmin(params?: {
page?: number;
pageSize?: number;
keyword?: string;
parentAgentId?: bigint;
}) {
const page = Math.max(1, params?.page ?? 1);
const pageSize = Math.min(Math.max(1, params?.pageSize ?? 10), 100);
const skip = (page - 1) * pageSize;
const where: Prisma.AgentProfileWhereInput = {};
if (params?.parentAgentId !== undefined) {
where.parentAgentId = params.parentAgentId;
} else {
// Default: only show top-level agents (no parent)
where.parentAgentId = null;
}
const kw = params?.keyword?.trim();
if (kw) {
where.user = { username: { contains: kw, mode: 'insensitive' } };
@@ -199,6 +580,18 @@ export class AgentsService {
playerCounts.map((g) => [g.parentId?.toString(), g._count._all]),
);
const childAgentCounts =
agentIds.length > 0
? await this.prisma.agentProfile.groupBy({
by: ['parentAgentId'],
where: { parentAgentId: { in: agentIds } },
_count: { _all: true },
})
: [];
const childAgentCountMap = new Map(
childAgentCounts.map((g) => [g.parentAgentId?.toString(), g._count._all]),
);
const items = profiles.map((p) => {
const available = new Decimal(p.creditLimit).sub(p.usedCredit);
return {
@@ -215,7 +608,10 @@ export class AgentsService {
directPlayerLiability: p.directPlayerLiability.toString(),
childAgentExposure: p.childAgentExposure.toString(),
cashbackRate: p.cashbackRate.toString(),
maxSingleDeposit: p.maxSingleDeposit?.toString() ?? null,
maxDailyDeposit: p.maxDailyDeposit?.toString() ?? null,
directPlayerCount: countMap.get(p.userId.toString()) ?? 0,
childAgentCount: childAgentCountMap.get(p.userId.toString()) ?? 0,
phone: p.user.preferences?.phone ?? null,
email: p.user.preferences?.email ?? null,
locale: p.user.locale,
@@ -234,10 +630,13 @@ export class AgentsService {
});
if (!profile) throw new NotFoundException('代理不存在');
const [directPlayerCount, recentCredits] = await Promise.all([
const [directPlayerCount, childAgentCount, recentCredits] = await Promise.all([
this.prisma.user.count({
where: { parentId: agentId, userType: 'PLAYER', deletedAt: null },
}),
this.prisma.agentProfile.count({
where: { parentAgentId: agentId },
}),
this.prisma.agentCreditTransaction.findMany({
where: { agentId },
orderBy: { createdAt: 'desc' },
@@ -270,11 +669,16 @@ export class AgentsService {
directPlayerLiability: profile.directPlayerLiability.toString(),
childAgentExposure: profile.childAgentExposure.toString(),
cashbackRate: profile.cashbackRate.toString(),
maxSingleDeposit: profile.maxSingleDeposit?.toString() ?? null,
maxDailyDeposit: profile.maxDailyDeposit?.toString() ?? null,
directPlayerCount,
childAgentCount,
phone: profile.user.preferences?.phone ?? null,
email: profile.user.preferences?.email ?? null,
managedPassword: profile.user.preferences?.managedPassword ?? null,
locale: profile.user.locale,
lastLoginAt: profile.user.auth?.lastLoginAt ?? null,
loginFailCount: profile.user.auth?.loginFailCount ?? 0,
createdAt: profile.createdAt,
updatedAt: profile.updatedAt,
recentCreditTransactions: recentCredits.map((t) => ({
@@ -297,6 +701,11 @@ export class AgentsService {
phone?: string;
email?: string;
cashbackRate?: number;
maxSingleDeposit?: number | null;
maxDailyDeposit?: number | null;
username?: string;
password?: string;
freezeDirectPlayers?: boolean;
},
) {
const profile = await this.prisma.agentProfile.findUnique({
@@ -309,6 +718,38 @@ export class AgentsService {
throw new BadRequestException('无效状态');
}
// Handle username change
if (data.username !== undefined) {
const nextUsername = data.username.trim();
if (!nextUsername) throw new BadRequestException('账号名称不能为空');
if (nextUsername !== profile.user.username) {
const taken = await this.prisma.user.findUnique({ where: { username: nextUsername } });
if (taken) throw new BadRequestException('账号名称已被占用');
await this.prisma.user.update({
where: { id: agentId },
data: { username: nextUsername },
});
}
}
// Handle password change
if (data.password !== undefined) {
const nextPassword = data.password;
if (nextPassword.length < 8) throw new BadRequestException('密码至少 8 位');
const hash = await bcrypt.hash(nextPassword, 10);
await this.prisma.userAuth.upsert({
where: { userId: agentId },
create: { userId: agentId, passwordHash: hash, loginFailCount: 0, lockedUntil: null },
update: { passwordHash: hash, loginFailCount: 0, lockedUntil: null },
});
await this.prisma.userPreference.upsert({
where: { userId: agentId },
create: { userId: agentId, managedPassword: nextPassword },
update: { managedPassword: nextPassword },
});
}
// Handle status change (with optional cascade freeze)
if (data.status) {
await this.prisma.$transaction([
this.prisma.user.update({
@@ -320,6 +761,19 @@ export class AgentsService {
data: { status: data.status },
}),
]);
// 级联冻结:需后台开启且管理员/操作方显式勾选MVP 默认不冻结玩家)
const suspendSettings = await this.systemConfig.getAgentSuspendSettings();
if (
data.status === 'SUSPENDED' &&
data.freezeDirectPlayers &&
suspendSettings.suspendFreezeDirectPlayers
) {
await this.prisma.user.updateMany({
where: { parentId: agentId, userType: 'PLAYER', deletedAt: null },
data: { status: 'SUSPENDED' },
});
}
}
if (data.locale) {
@@ -330,12 +784,40 @@ export class AgentsService {
}
if (data.cashbackRate !== undefined) {
if (profile.parentAgentId) {
await this.assertChildAgentWithinParent(profile.parentAgentId, {
cashbackRate: data.cashbackRate,
});
}
await this.prisma.agentProfile.update({
where: { userId: agentId },
data: { cashbackRate: data.cashbackRate },
});
}
const limitPatch: {
maxSingleDeposit?: Decimal | null;
maxDailyDeposit?: Decimal | null;
} = {};
if (data.maxSingleDeposit !== undefined) {
limitPatch.maxSingleDeposit = this.normalizeOptionalLimit(data.maxSingleDeposit);
}
if (data.maxDailyDeposit !== undefined) {
limitPatch.maxDailyDeposit = this.normalizeOptionalLimit(data.maxDailyDeposit);
}
if (Object.keys(limitPatch).length > 0) {
if (profile.parentAgentId) {
await this.assertChildAgentWithinParent(profile.parentAgentId, {
maxSingleDeposit: limitPatch.maxSingleDeposit ?? undefined,
maxDailyDeposit: limitPatch.maxDailyDeposit ?? undefined,
});
}
await this.prisma.agentProfile.update({
where: { userId: agentId },
data: limitPatch,
});
}
if (data.phone !== undefined || data.email !== undefined || data.locale) {
const phone = data.phone !== undefined ? data.phone?.trim() || null : undefined;
const email = data.email !== undefined ? data.email?.trim() || null : undefined;
@@ -389,6 +871,8 @@ export class AgentsService {
data: {
creditLimit: number;
cashbackRate?: number;
maxSingleDeposit?: number | null;
maxDailyDeposit?: number | null;
phone?: string;
email?: string;
},
@@ -450,6 +934,8 @@ export class AgentsService {
parentAgentId: null,
creditLimit: data.creditLimit,
cashbackRate: data.cashbackRate ?? 0,
maxSingleDeposit: this.normalizeOptionalLimit(data.maxSingleDeposit),
maxDailyDeposit: this.normalizeOptionalLimit(data.maxDailyDeposit),
},
});
@@ -481,6 +967,8 @@ export class AgentsService {
phone?: string;
email?: string;
cashbackRate?: number;
maxSingleDeposit?: number | null;
maxDailyDeposit?: number | null;
},
) {
if (data.level !== 1 && data.level !== 2) {
@@ -490,6 +978,18 @@ export class AgentsService {
throw new BadRequestException('Level 2 agent requires parent');
}
if (data.parentAgentId) {
await this.assertChildAgentWithinParent(data.parentAgentId, {
creditLimit: data.creditLimit ?? 0,
cashbackRate: data.cashbackRate ?? 0,
maxSingleDeposit: data.maxSingleDeposit,
maxDailyDeposit: data.maxDailyDeposit,
});
}
const maxSingleDeposit = this.normalizeOptionalLimit(data.maxSingleDeposit);
const maxDailyDeposit = this.normalizeOptionalLimit(data.maxDailyDeposit);
const hash = await this.auth.hashPassword(data.password);
return this.prisma.$transaction(async (tx) => {
@@ -524,6 +1024,8 @@ export class AgentsService {
parentAgentId: data.parentAgentId,
creditLimit: data.creditLimit ?? 0,
cashbackRate: data.cashbackRate ?? 0,
maxSingleDeposit,
maxDailyDeposit,
},
});
@@ -567,8 +1069,12 @@ export class AgentsService {
depositRemark?: string;
depositRequestId?: string;
asTier1Agent?: boolean;
asSubAgent?: boolean;
parentAgentId?: bigint;
creditLimit?: number;
cashbackRate?: number;
maxSingleDeposit?: number | null;
maxDailyDeposit?: number | null;
},
) {
if (data.asTier1Agent) {
@@ -590,6 +1096,29 @@ export class AgentsService {
});
}
if (data.asSubAgent) {
if (data.parentAgentId == null && data.parentId == null) {
throw new BadRequestException('二级代理必须指定上级代理');
}
if (data.initialDeposit && data.initialDeposit > 0) {
throw new BadRequestException('设为代理时请使用授信额度,勿填玩家初始余额');
}
const parentAgentId = data.parentAgentId ?? data.parentId;
return this.createAgent(operatorId, {
username: data.username,
password: data.password,
level: 2,
parentAgentId,
creditLimit: data.creditLimit ?? 0,
cashbackRate: data.cashbackRate ?? 0,
maxSingleDeposit: data.maxSingleDeposit,
maxDailyDeposit: data.maxDailyDeposit,
locale: data.locale,
phone: data.phone,
email: data.email,
});
}
let parentId: bigint | null = null;
if (data.parentId != null) {
const parent = await this.prisma.user.findUnique({ where: { id: data.parentId } });
@@ -597,6 +1126,11 @@ export class AgentsService {
throw new BadRequestException('上级必须为代理账号');
}
parentId = data.parentId;
const operator = await this.prisma.user.findUnique({ where: { id: operatorId } });
if (operator?.userType === 'AGENT' && parentId !== operatorId) {
throw new ForbiddenException('Can only create direct players');
}
}
const hash = await this.auth.hashPassword(data.password);
@@ -641,6 +1175,7 @@ export class AgentsService {
if (initial > 0) {
const requestId =
data.depositRequestId ?? `admin-create-${user.id}-${Date.now()}`;
await this.assertPlayerParentCreditForDeposit(user.id, initial);
await this.wallet.deposit(
user.id,
initial,
@@ -660,6 +1195,7 @@ export class AgentsService {
return this.prisma.user.findMany({
where: { parentId: agentId, userType: 'PLAYER' },
include: { wallet: true },
orderBy: { createdAt: 'desc' },
});
}
@@ -670,34 +1206,253 @@ export class AgentsService {
});
}
async listChildAgentsSummary(parentAgentId: bigint) {
const profiles = await this.getChildAgents(parentAgentId);
const agentIds = profiles.map((p) => p.userId);
const playerCounts =
agentIds.length > 0
? await this.prisma.user.groupBy({
by: ['parentId'],
where: {
userType: 'PLAYER',
parentId: { in: agentIds },
deletedAt: null,
},
_count: { _all: true },
})
: [];
const countMap = new Map(
playerCounts.map((g) => [g.parentId?.toString(), g._count._all]),
);
return profiles.map((p) => {
const available = new Decimal(p.creditLimit).sub(p.usedCredit);
return {
userId: p.userId.toString(),
username: p.user.username,
userStatus: p.user.status,
status: p.status,
level: p.level,
creditLimit: dec(p.creditLimit),
usedCredit: dec(p.usedCredit),
availableCredit: available.toString(),
directPlayerCount: countMap.get(p.userId.toString()) ?? 0,
createdAt: p.createdAt,
};
});
}
async assertDirectChildAgent(parentAgentId: bigint, subAgentId: bigint) {
const profile = await this.prisma.agentProfile.findUnique({
where: { userId: subAgentId },
});
if (!profile || profile.parentAgentId !== parentAgentId) {
throw new ForbiddenException('Not your sub-agent');
}
return profile;
}
async getSubAgentForParent(parentAgentId: bigint, subAgentId: bigint) {
await this.assertDirectChildAgent(parentAgentId, subAgentId);
return this.getAgentAdminDetail(subAgentId);
}
async updateSubAgentForParent(
parentAgentId: bigint,
subAgentId: bigint,
data: {
username?: string;
password?: string;
phone?: string;
email?: string;
status?: string;
freezeDirectPlayers?: boolean;
},
) {
await this.assertDirectChildAgent(parentAgentId, subAgentId);
const { freezeDirectPlayers: _ignored, ...safeData } = data;
return this.updateAgentAdmin(subAgentId, safeData);
}
async getSubtreeAgentIds(agentId: bigint) {
const descendants = await this.prisma.agentClosure.findMany({
where: { ancestorId: agentId },
select: { descendantId: true },
});
return descendants.map((d) => d.descendantId);
}
async getReportSummary(agentId: bigint) {
const profile = await this.getProfile(agentId);
const players = await this.getDirectPlayers(agentId);
const agentIds = await this.getSubtreeAgentIds(agentId);
const betScope = { agentId: { in: agentIds } };
const playerWhere = {
parentId: agentId,
userType: 'PLAYER' as const,
deletedAt: null,
};
const today = new Date();
today.setHours(0, 0, 0, 0);
const yesterday = new Date(today.getTime() - 86400000);
const todayBets = await this.prisma.bet.aggregate({
where: {
agentId,
placedAt: { gte: today },
},
_sum: { stake: true, actualReturn: true },
_count: true,
});
const trend7d = await Promise.all(
Array.from({ length: 7 }, (_, i) => {
const dayStart = new Date(today);
dayStart.setDate(dayStart.getDate() - (6 - i));
const dayEnd = new Date(dayStart);
dayEnd.setDate(dayEnd.getDate() + 1);
return this.prisma.bet
.aggregate({
where: { ...betScope, placedAt: { gte: dayStart, lt: dayEnd } },
_sum: { stake: true, actualReturn: true },
_count: true,
})
.then((agg) => ({
date: dayStart.toISOString().slice(0, 10),
label: `${dayStart.getMonth() + 1}/${dayStart.getDate()}`,
betCount: agg._count,
stake: dec(agg._sum.stake),
payout: dec(agg._sum.actualReturn),
ggr: sub(agg._sum.stake, agg._sum.actualReturn),
}));
}),
);
const [
todayBets,
yesterdayBets,
pendingBets,
betStatusToday,
playerTotal,
playerActive,
playerSuspended,
newPlayersToday,
subAgentTotal,
subAgentsActive,
walletAgg,
recentBets,
recentPlayers,
] = await Promise.all([
this.prisma.bet.aggregate({
where: { ...betScope, placedAt: { gte: today } },
_sum: { stake: true, actualReturn: true },
_count: true,
}),
this.prisma.bet.aggregate({
where: { ...betScope, placedAt: { gte: yesterday, lt: today } },
_sum: { stake: true, actualReturn: true },
_count: true,
}),
this.prisma.bet.count({ where: { ...betScope, status: 'PENDING' } }),
this.prisma.bet.groupBy({
by: ['status'],
where: { ...betScope, placedAt: { gte: today } },
_count: { _all: true },
_sum: { stake: true },
}),
this.prisma.user.count({ where: playerWhere }),
this.prisma.user.count({ where: { ...playerWhere, status: 'ACTIVE' } }),
this.prisma.user.count({ where: { ...playerWhere, status: 'SUSPENDED' } }),
this.prisma.user.count({
where: { ...playerWhere, createdAt: { gte: today } },
}),
this.prisma.agentProfile.count({ where: { parentAgentId: agentId } }),
this.prisma.agentProfile.count({
where: { parentAgentId: agentId, status: 'ACTIVE' },
}),
this.prisma.wallet.aggregate({
where: { user: playerWhere },
_sum: { availableBalance: true, frozenBalance: true },
_count: { _all: true },
}),
this.prisma.bet.findMany({
where: betScope,
take: 8,
orderBy: { placedAt: 'desc' },
include: { user: { select: { username: true } } },
}),
this.prisma.user.findMany({
where: playerWhere,
take: 6,
orderBy: { createdAt: 'desc' },
select: {
id: true,
username: true,
status: true,
createdAt: true,
},
}),
]);
const todayBetByStatus: Record<string, { count: number; stake: string }> = {};
for (const g of betStatusToday) {
todayBetByStatus[g.status] = {
count: g._count._all,
stake: dec(g._sum.stake),
};
}
const creditLimit = profile.creditLimit ?? new Decimal(0);
const usedCredit = profile.usedCredit ?? new Decimal(0);
const availableCredit = new Decimal(creditLimit).sub(usedCredit);
return {
profile,
directPlayerCount: players.length,
directPlayerTotalBalance: players.reduce(
(sum, p) =>
sum +
Number(p.wallet?.availableBalance ?? 0) +
Number(p.wallet?.frozenBalance ?? 0),
0,
),
todayBetCount: todayBets._count,
todayStake: todayBets._sum.stake,
todayReturn: todayBets._sum.actualReturn,
generatedAt: new Date().toISOString(),
trend7d,
today: {
betCount: todayBets._count,
stake: dec(todayBets._sum.stake),
payout: dec(todayBets._sum.actualReturn),
ggr: sub(todayBets._sum.stake, todayBets._sum.actualReturn),
newPlayers: newPlayersToday,
},
yesterday: {
betCount: yesterdayBets._count,
stake: dec(yesterdayBets._sum.stake),
payout: dec(yesterdayBets._sum.actualReturn),
ggr: sub(yesterdayBets._sum.stake, yesterdayBets._sum.actualReturn),
},
players: {
directTotal: playerTotal,
active: playerActive,
suspended: playerSuspended,
newToday: newPlayersToday,
},
subAgents: {
total: subAgentTotal,
active: subAgentsActive,
},
wallets: {
totalAvailable: dec(walletAgg._sum.availableBalance),
totalFrozen: dec(walletAgg._sum.frozenBalance),
playerWalletCount: walletAgg._count._all,
},
credit: {
creditLimit: dec(creditLimit),
usedCredit: dec(usedCredit),
availableCredit: availableCredit.toString(),
directPlayerLiability: dec(profile.directPlayerLiability),
childAgentExposure: dec(profile.childAgentExposure),
},
bets: {
pendingTotal: pendingBets,
todayByStatus: todayBetByStatus,
},
recentBets: recentBets.map((b) => ({
betNo: b.betNo,
username: b.user.username,
stake: dec(b.stake),
status: b.status,
placedAt: b.placedAt,
})),
recentPlayers: recentPlayers.map((p) => ({
id: p.id.toString(),
username: p.username,
status: p.status,
createdAt: p.createdAt,
})),
};
}
}

View File

@@ -1,4 +1,4 @@
import { Controller, Post, Body, UseGuards } from '@nestjs/common';
import { Controller, Get, Post, Body, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AuthService } from './auth.service';
import { LoginDto, ChangePasswordDto } from './auth.dto';
@@ -39,6 +39,27 @@ export class AuthController {
return jsonResponse(result);
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Get('manage/auth/me')
async manageMe(
@CurrentUser('id') userId: bigint,
@CurrentUser('username') username: string,
@CurrentUser('userType') userType: string,
@CurrentUser('locale') locale: string | undefined,
@CurrentUser('role') role: string | undefined,
@CurrentUser('agentLevel') agentLevel: number | null | undefined,
) {
return jsonResponse({
id: userId.toString(),
username,
userType,
locale,
role,
agentLevel: userType === 'AGENT' ? agentLevel ?? null : null,
});
}
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@Post('player/auth/change-password')

View File

@@ -56,6 +56,23 @@ export class AuthService {
throw new ForbiddenException('Account disabled');
}
if (portal === 'agent' && user.status === 'SUSPENDED') {
throw new ForbiddenException('Agent account suspended');
}
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 new ForbiddenException('上级代理已停用,暂无法登录');
}
}
}
if (user.auth.lockedUntil && user.auth.lockedUntil > new Date()) {
throw new ForbiddenException('Account locked, try again later');
}
@@ -101,6 +118,7 @@ export class AuthService {
userType: user.userType,
locale: user.locale,
role: user.adminRole?.role?.code,
agentLevel: user.userType === 'AGENT' ? user.agentLevel : null,
},
};
}

View File

@@ -281,16 +281,29 @@ export class WalletService {
};
}
async getTransactions(userId: bigint, page = 1, pageSize = 20) {
async getTransactions(userId: bigint, page = 1, pageSize = 20, typeFilter?: string) {
const skip = (page - 1) * pageSize;
let typeWhere: Record<string, unknown> = {};
if (typeFilter === 'deposit') {
typeWhere = { transactionType: { in: ['MANUAL_DEPOSIT', 'DEPOSIT', 'MANUAL_ADJUST'] } };
} else if (typeFilter === 'withdraw') {
typeWhere = { transactionType: { in: ['MANUAL_WITHDRAW', 'WITHDRAW'] } };
} else if (typeFilter === 'bet') {
typeWhere = { 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'] } };
} else if (typeFilter === 'cashback') {
typeWhere = { transactionType: { in: ['CASHBACK', 'CASHBACK_DEPOSIT'] } };
}
const where = { userId, ...typeWhere };
const [items, total] = await Promise.all([
this.prisma.walletTransaction.findMany({
where: { userId },
where,
orderBy: { createdAt: 'desc' },
skip,
take: pageSize,
}),
this.prisma.walletTransaction.count({ where: { userId } }),
this.prisma.walletTransaction.count({ where }),
]);
return { items, total, page, pageSize };
}

View File

@@ -63,6 +63,16 @@ describe('Agent Credit Rules', () => {
const canDeposit = agentId === playerParentId;
expect(canDeposit).toBe(false);
});
it('A008: admin deposit cannot exceed parent agent available credit', () => {
const creditLimit = 5000;
const usedCredit = 4800;
const depositAmount = 300;
const available = creditLimit - usedCredit;
expect(depositAmount).toBeGreaterThan(available);
const allowed = depositAmount <= available;
expect(allowed).toBe(false);
});
});
describe('Bet Validation Rules (B001-B010)', () => {

View File

@@ -3,12 +3,21 @@ import { PrismaService } from '../prisma/prisma.service';
export const PLAYER_ALLOW_PASSWORD_CHANGE = 'player.allow_password_change';
export const PLAYER_ALLOW_USERNAME_CHANGE = 'player.allow_username_change';
export const AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS = 'agent.suspend_freeze_direct_players';
export const AGENT_SUSPEND_BLOCK_PLAYER_LOGIN = 'agent.suspend_block_player_login';
export type PlayerAccountSettings = {
allowPasswordChange: boolean;
allowUsernameChange: boolean;
};
export type AgentSuspendSettings = {
/** 停用代理时是否允许级联冻结其直属玩家(需管理员显式勾选) */
suspendFreezeDirectPlayers: boolean;
/** 上级代理停用时是否禁止其直属玩家登录 */
suspendBlockPlayerLogin: boolean;
};
@Injectable()
export class SystemConfigService {
constructor(private prisma: PrismaService) {}
@@ -56,4 +65,30 @@ export class SystemConfigService {
}
return this.getPlayerAccountSettings();
}
async getAgentSuspendSettings(): Promise<AgentSuspendSettings> {
const [suspendFreezeDirectPlayers, suspendBlockPlayerLogin] = await Promise.all([
this.getBoolean(AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS, false),
this.getBoolean(AGENT_SUSPEND_BLOCK_PLAYER_LOGIN, false),
]);
return { suspendFreezeDirectPlayers, suspendBlockPlayerLogin };
}
async updateAgentSuspendSettings(data: Partial<AgentSuspendSettings>) {
if (data.suspendFreezeDirectPlayers !== undefined) {
await this.setBoolean(
AGENT_SUSPEND_FREEZE_DIRECT_PLAYERS,
data.suspendFreezeDirectPlayers,
'停用代理时是否允许级联冻结直属玩家',
);
}
if (data.suspendBlockPlayerLogin !== undefined) {
await this.setBoolean(
AGENT_SUSPEND_BLOCK_PLAYER_LOGIN,
data.suspendBlockPlayerLogin,
'上级代理停用时是否禁止直属玩家登录',
);
}
return this.getAgentSuspendSettings();
}
}