feat(admin,api,player): 代理层级管理、额度上下分与玩家钱包详情
新增代理管理器与二级代理体系,完善信用额度/上下分上下文与冻结策略;代理端玩家与子代理管理增强;玩家端新增钱包详情页与交易筛选优化。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 }) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user