Files
thebet365/apps/api/src/applications/admin/admin.controller.ts
Mars 641c92a5f5 feat: internationalize API error responses by locale
Add shared error codes with zh/en/ms messages, coded app exceptions,
and locale-aware global filter. Frontends send X-Locale so error text
matches the active UI language.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 13:36:38 +08:00

2226 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
Controller,
Delete,
Get,
Post,
Put,
Patch,
Body,
Param,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { randomUUID } from 'crypto';
import { mkdir, writeFile, unlink } from 'fs/promises';
import { extname, join } from 'path';
import { JwtAuthGuard, AdminGuard, PermissionsGuard } from '../../domains/identity/guards';
import { ContentService } from '../../domains/operations/content/content.service';
import { CurrentUser, RequirePermissions } from '../../shared/common/decorators';
import { jsonResponse } from '../../shared/common/filters';
import { appBadRequest, appForbidden } from '../../shared/common/app-error';
import { getUploadRoot } from '../../shared/uploads/upload-paths';
import { UsersService } from '../../domains/identity/users.service';
import { AgentsService } from '../../domains/agent/agents.service';
import { WalletService } from '../../domains/ledger/wallet.service';
import { MatchesService } from '../../domains/catalog/matches.service';
import { OutrightService } from '../../domains/catalog/outright.service';
import { MarketsService } from '../../domains/odds/markets.service';
import { SettlementService } from '../../domains/settlement/settlement.service';
import { CashbackService } from '../../domains/operations/cashback/cashback.service';
import { I18nService } from '../../domains/operations/i18n/i18n.service';
import { AuditService } from '../../domains/operations/audit/audit.service';
import { BetsService } from '../../domains/betting/bets.service';
import { BettingLimitsService } from '../../domains/betting/betting-limits.service';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { AdminDashboardService } from './admin-dashboard.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
import { P } from './admin-permissions';
import { DatabaseResetService } from '../../infrastructure/database/database-reset.service';
import { SmokeTestService } from '../../domains/operations/smoke-tests/smoke-test.service';
import {
IsString,
IsNumber,
IsOptional,
IsArray,
IsBoolean,
MinLength,
IsIn,
Min,
Equals,
ValidateIf,
} from 'class-validator';
import type { ZhiboMatchExport, ZhiboMatchesBundleExport } from '../../domains/catalog/zhibo-match.types';
const UPLOAD_CATEGORIES = ['banners', 'teams', 'contents'] as const;
type UploadCategory = (typeof UPLOAD_CATEGORIES)[number];
const IMAGE_MIME_EXT: Record<string, string> = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/webp': '.webp',
'image/gif': '.gif',
'image/svg+xml': '.svg',
};
type UploadedImage = {
originalname: string;
mimetype: string;
buffer: Buffer;
size: number;
};
type AdminUploadUser = {
role?: string;
permissions?: string[];
};
function uploadCategory(value?: string): UploadCategory {
const category = (value || 'contents').trim();
if (UPLOAD_CATEGORIES.includes(category as UploadCategory)) {
return category as UploadCategory;
}
throw appBadRequest('UPLOAD_CATEGORY_UNSUPPORTED');
}
function requiredUploadPermission(category: UploadCategory) {
return category === 'teams' ? P.matches : P.content;
}
function assertUploadPermission(user: AdminUploadUser | undefined, category: UploadCategory) {
if (user?.role === 'SUPER_ADMIN') return;
const required = requiredUploadPermission(category);
if (!user?.permissions?.includes(required)) {
throw appForbidden('INSUFFICIENT_PERMISSIONS');
}
}
function assertImageFile(file: UploadedImage | undefined): asserts file is UploadedImage {
if (!file?.buffer?.length) {
throw appBadRequest('UPLOAD_IMAGE_REQUIRED');
}
if (!IMAGE_MIME_EXT[file.mimetype]) {
throw appBadRequest('UPLOAD_IMAGE_TYPE_INVALID');
}
if (file.mimetype === 'image/svg+xml') {
const sample = file.buffer.toString('utf8', 0, Math.min(file.buffer.length, 8192)).toLowerCase();
if (sample.includes('<script') || sample.includes('javascript:') || /\son[a-z]+\s*=/.test(sample)) {
throw appBadRequest('UPLOAD_SVG_UNSAFE');
}
}
}
function uploadFilename(file: UploadedImage) {
const fromMime = IMAGE_MIME_EXT[file.mimetype];
const fromName = extname(file.originalname || '').toLowerCase();
const ext = fromMime || fromName || '.img';
const base = (file.originalname || 'asset')
.replace(/\.[^.]+$/, '')
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 42) || 'asset';
return `${Date.now()}-${base}-${randomUUID().slice(0, 8)}${ext}`;
}
class CreateUserDto {
@IsString()
username!: string;
@IsString()
@MinLength(8)
password!: string;
@IsOptional()
@IsString()
parentId?: string;
@IsOptional()
@IsNumber()
creditLimit?: number;
}
class CreatePlayerAdminDto {
@IsString()
username!: string;
@IsString()
@MinLength(8)
password!: string;
@IsOptional()
@IsString()
parentId?: string;
@IsOptional()
@IsString()
locale?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@IsNumber()
@Min(0)
initialDeposit?: number;
@IsOptional()
@IsString()
remark?: string;
/** 创建为一级代理(非玩家) */
@IsOptional()
asTier1Agent?: boolean;
/** 创建为二级代理(需要 parentAgentId */
@IsOptional()
asSubAgent?: boolean;
/** 二级代理的上级代理 ID */
@IsOptional()
@IsString()
parentAgentId?: string;
@IsOptional()
@IsNumber()
@Min(0)
creditLimit?: number;
@IsOptional()
@IsNumber()
@Min(0)
cashbackRate?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxSingleDeposit?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxDailyDeposit?: number;
}
class UpdatePlayerAdminDto {
@IsOptional()
@IsIn(['ACTIVE', 'SUSPENDED'])
status?: string;
@IsOptional()
@IsString()
locale?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@IsString()
username?: string;
@IsOptional()
@IsString()
@MinLength(8)
password?: string;
}
class PlayerAccountSettingsDto {
@IsOptional()
@IsBoolean()
allowPasswordChange?: boolean;
@IsOptional()
@IsBoolean()
allowUsernameChange?: boolean;
}
class AgentSuspendSettingsDto {
@IsOptional()
@IsBoolean()
suspendFreezeDirectPlayers?: boolean;
@IsOptional()
@IsBoolean()
suspendBlockPlayerLogin?: boolean;
}
class ResetDatabaseDto {
@IsString()
@Equals('RESET')
confirmPhrase!: string;
}
class CreateAgentAdminDto {
/** 已有玩家用户 ID升级为一级代理 */
@IsString()
userId!: string;
@IsNumber()
@Min(0)
creditLimit!: number;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@IsNumber()
@Min(0)
cashbackRate?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxSingleDeposit?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxDailyDeposit?: number;
}
class UpdateAgentAdminDto {
@IsOptional()
@IsIn(['ACTIVE', 'SUSPENDED'])
status?: string;
@IsOptional()
@IsString()
locale?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
email?: string;
@IsOptional()
@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 {
@IsNumber()
amount!: number;
@IsString()
requestId!: string;
@IsOptional()
@IsString()
remark?: string;
}
class CreatePlatformLeagueDto {
@IsString()
leagueEn!: string;
@IsString()
leagueZh!: string;
@IsOptional()
@IsString()
leagueMs?: string;
@IsOptional()
@IsString()
logoUrl?: string;
@IsOptional()
@IsNumber()
displayOrder?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
class CreatePlatformMatchDto {
@IsOptional()
@IsString()
leagueId?: string;
@ValidateIf((o: CreatePlatformMatchDto) => !o.leagueId)
@IsString()
leagueEn?: string;
@ValidateIf((o: CreatePlatformMatchDto) => !o.leagueId)
@IsString()
leagueZh?: string;
@IsOptional()
@IsString()
leagueMs?: string;
@IsOptional()
@IsString()
homeTeamCode?: string;
@IsOptional()
@IsString()
awayTeamCode?: string;
@IsString()
homeTeamEn!: string;
@IsString()
homeTeamZh!: string;
@IsOptional()
@IsString()
homeTeamMs?: string;
@IsString()
awayTeamEn!: string;
@IsString()
awayTeamZh!: string;
@IsOptional()
@IsString()
awayTeamMs?: string;
@IsString()
startTime!: string;
@IsOptional()
@IsBoolean()
isHot?: boolean;
@IsOptional()
@IsNumber()
displayOrder?: number;
@IsOptional()
@IsString()
matchName?: string;
@IsOptional()
@IsString()
stage?: string;
@IsOptional()
@IsString()
groupName?: string;
@IsOptional()
@IsString()
leagueLogoUrl?: string;
@IsOptional()
@IsString()
homeTeamLogoUrl?: string;
@IsOptional()
@IsString()
awayTeamLogoUrl?: string;
}
class UpdatePlatformMatchDto {
@IsString()
homeTeamEn!: string;
@IsString()
homeTeamZh!: string;
@IsOptional()
@IsString()
homeTeamMs?: string;
@IsString()
awayTeamEn!: string;
@IsString()
awayTeamZh!: string;
@IsOptional()
@IsString()
awayTeamMs?: string;
@IsString()
startTime!: string;
@IsOptional()
@IsBoolean()
isHot?: boolean;
@IsOptional()
@IsNumber()
displayOrder?: number;
@IsOptional()
@IsString()
matchName?: string;
@IsOptional()
@IsString()
stage?: string;
@IsOptional()
@IsString()
groupName?: string;
@IsOptional()
@IsString()
homeTeamLogoUrl?: string;
@IsOptional()
@IsString()
awayTeamLogoUrl?: string;
}
class BatchMatchOddsDto {
@IsArray()
updates!: OutrightOddsUpdateItemDto[];
}
class UpdateMarketDto {
@IsOptional()
@IsString()
promoLabel?: string | null;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsNumber()
lineValue?: number | null;
}
class UpdateSelectionDto {
@IsOptional()
@IsString()
selectionName?: string;
@IsOptional()
@IsNumber()
@Min(1.01)
odds?: number;
@IsOptional()
@IsString()
status?: string;
}
function isZhiboBundlePayload(body: unknown): body is ZhiboMatchesBundleExport {
if (!body || typeof body !== 'object') return false;
return Array.isArray((body as ZhiboMatchesBundleExport).matches);
}
class ScoreDto {
@IsOptional()
@IsNumber()
htHome?: number;
@IsOptional()
@IsNumber()
htAway?: number;
@IsOptional()
@IsNumber()
ftHome?: number;
@IsOptional()
@IsNumber()
ftAway?: number;
/** 冠军盘结算:获胜球队 ID */
@IsOptional()
@IsNumber()
winnerTeamId?: number;
}
class SettlementPreviewDto extends ScoreDto {
@IsOptional()
@IsNumber()
page?: number;
@IsOptional()
@IsNumber()
pageSize?: number;
}
/* 智能比分推荐已关闭
class SmartScoreSuggestDto {
@IsOptional()
@IsArray()
strategies?: Array<'MIN_PAYOUT' | 'MAX_PAYOUT' | 'BALANCED' | 'TARGET_HOLD'>;
@IsOptional()
@IsNumber()
targetHoldPct?: number;
@IsOptional()
@IsNumber()
maxGoals?: number;
}
*/
class MarketTemplatesDto {
@IsArray()
marketTypes!: string[];
}
class UpdateOddsDto {
@IsNumber()
odds!: number;
}
class OutrightOddsUpdateItemDto {
@IsString()
selectionId!: string;
@IsNumber()
@Min(1.01)
odds!: number;
}
class BatchOutrightOddsDto {
@IsArray()
updates!: OutrightOddsUpdateItemDto[];
}
class CreateOutrightDto {
@IsString()
leagueId!: string;
@IsString()
titleZh!: string;
@IsString()
titleEn!: string;
@IsOptional()
@IsString()
titleMs?: string;
@IsOptional()
@IsString()
status?: string;
}
class UpdateOutrightDto {
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
matchName?: string;
@IsOptional()
@IsString()
titleZh?: string;
@IsOptional()
@IsString()
titleEn?: string;
@IsOptional()
@IsString()
titleMs?: string;
@IsOptional()
isHot?: boolean;
@IsOptional()
displayOrder?: number;
}
class AddOutrightSelectionDto {
@IsString()
teamCode!: string;
@IsString()
teamZh!: string;
@IsString()
teamEn!: string;
@IsNumber()
@Min(1.01)
odds!: number;
@IsOptional()
@IsString()
logoUrl?: string;
}
class AddOutrightSelectionsBatchDto {
@IsArray()
items!: AddOutrightSelectionDto[];
}
class UpdateOutrightSelectionTeamDto {
@IsOptional()
@IsString()
teamCode?: string;
@IsOptional()
@IsString()
teamZh?: string;
@IsOptional()
@IsString()
teamEn?: string;
@IsOptional()
@IsString()
logoUrl?: string | null;
}
class ContentTranslationDto {
@IsString()
locale!: string;
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
body?: string;
@IsOptional()
@IsString()
imageUrl?: string;
}
class CreateContentDto {
@IsString()
@IsIn(['BANNER', 'NOTICE', 'TICKER'])
contentType!: string;
@IsOptional()
@IsNumber()
sortOrder?: number;
@IsOptional()
@IsIn(['DRAFT', 'ACTIVE', 'INACTIVE'])
status?: string;
@IsOptional()
@IsString()
linkType?: string | null;
@IsOptional()
@IsString()
linkTarget?: string | null;
@IsOptional()
@IsString()
startTime?: string | null;
@IsOptional()
@IsString()
endTime?: string | null;
@IsArray()
translations!: ContentTranslationDto[];
}
class UpdateContentDto {
@IsOptional()
@IsNumber()
sortOrder?: number;
@IsOptional()
@IsIn(['DRAFT', 'ACTIVE', 'INACTIVE'])
status?: string;
@IsOptional()
@IsString()
linkType?: string | null;
@IsOptional()
@IsString()
linkTarget?: string | null;
@IsOptional()
@IsString()
startTime?: string | null;
@IsOptional()
@IsString()
endTime?: string | null;
@IsOptional()
@IsArray()
translations?: ContentTranslationDto[];
}
class ContentStatusDto {
@IsIn(['DRAFT', 'ACTIVE', 'INACTIVE'])
status!: string;
}
class CashbackPreviewDto {
@IsString()
periodStart!: string;
@IsString()
periodEnd!: string;
}
class ResettlePreviewDto {
@IsOptional()
@IsNumber()
htHome?: number;
@IsOptional()
@IsNumber()
htAway?: number;
@IsOptional()
@IsNumber()
ftHome?: number;
@IsOptional()
@IsNumber()
ftAway?: number;
@IsOptional()
@IsString()
reason?: string;
@IsOptional()
@IsNumber()
winnerTeamId?: number;
}
class BettingLimitsDto {
@IsOptional()
@IsNumber()
@Min(0)
minStake?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxStakeSingle?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxStakeParlay?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxPayoutSingle?: number;
@IsOptional()
@IsNumber()
@Min(0)
maxPayoutParlay?: number;
@IsOptional()
@IsNumber()
@Min(0)
dailyStakeLimit?: number;
}
@ApiTags('Admin')
@Controller('admin')
@UseGuards(JwtAuthGuard, AdminGuard, PermissionsGuard)
@ApiBearerAuth()
export class AdminController {
constructor(
private users: UsersService,
private agents: AgentsService,
private wallet: WalletService,
private matches: MatchesService,
private outright: OutrightService,
private markets: MarketsService,
private settlement: SettlementService,
private cashback: CashbackService,
private content: ContentService,
private i18n: I18nService,
private audit: AuditService,
private bets: BetsService,
private prisma: PrismaService,
private readonly dashboardService: AdminDashboardService,
private systemConfig: SystemConfigService,
private bettingLimits: BettingLimitsService,
private databaseReset: DatabaseResetService,
private smokeTests: SmokeTestService,
) {}
@Get('dashboard')
@RequirePermissions(P.reports)
async getDashboard() {
const overview = await this.dashboardService.getOverview();
return jsonResponse(overview);
}
@Get('users/settings/account')
@RequirePermissions(P.settings)
async getPlayerAccountSettings() {
const settings = await this.systemConfig.getPlayerAccountSettings();
return jsonResponse(settings);
}
@Put('users/settings/account')
@RequirePermissions(P.settings)
async updatePlayerAccountSettings(
@CurrentUser('id') operatorId: bigint,
@Body() dto: PlayerAccountSettingsDto,
) {
const settings = await this.systemConfig.updatePlayerAccountSettings(dto);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'UPDATE_PLAYER_ACCOUNT_SETTINGS',
module: 'USERS',
afterData: JSON.stringify(settings),
});
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() {
const limits = await this.bettingLimits.getLimits();
return jsonResponse(limits);
}
@Put('settings/betting-limits')
@RequirePermissions(P.settings)
async updateBettingLimits(
@CurrentUser('id') operatorId: bigint,
@Body() dto: BettingLimitsDto,
) {
const limits = await this.bettingLimits.updateLimits(dto);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'UPDATE_BETTING_LIMITS',
module: 'SETTINGS',
afterData: limits,
});
return jsonResponse(limits);
}
@Get('system/reset-database')
@RequirePermissions(P.resetDatabase)
getResetDatabaseStatus() {
return jsonResponse({ allowed: this.databaseReset.isAllowed() });
}
@Post('system/reset-database')
@RequirePermissions(P.resetDatabase)
async resetDatabase(
@CurrentUser('id') operatorId: bigint,
@Body() dto: ResetDatabaseDto,
) {
if (dto.confirmPhrase !== 'RESET') {
throw appBadRequest('DB_RESET_PHRASE_INVALID');
}
const result = await this.databaseReset.resetDatabase();
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'RESET_DATABASE',
module: 'SYSTEM',
afterData: { demoAccounts: result.demoAccounts },
});
return jsonResponse(result);
}
@Get('users')
@RequirePermissions(P.usersView)
async listUsers(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('parentId') parentId?: string,
@Query('platformDirect') platformDirect?: string,
@Query('status') status?: string,
) {
const result = await this.users.listPlayers(
page ? parseInt(page, 10) : 1,
pageSize ? parseInt(pageSize, 10) : 10,
{
keyword,
parentId: parentId ? BigInt(parentId) : undefined,
platformDirect: platformDirect === 'true' || platformDirect === '1',
status,
},
);
return jsonResponse(result);
}
@Get('users/:id')
@RequirePermissions(P.usersView)
async getUserDetail(@Param('id') id: string) {
const detail = await this.users.getPlayerAdminDetail(BigInt(id));
return jsonResponse(detail);
}
@Put('users/:id')
@RequirePermissions(P.usersCreate)
async updateUser(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: UpdatePlayerAdminDto,
) {
const detail = await this.users.updatePlayerAdmin(BigInt(id), dto);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'UPDATE_PLAYER',
module: 'USERS',
targetId: id,
});
return jsonResponse(detail);
}
@Post('users')
@RequirePermissions(P.usersCreate)
async createPlayer(
@CurrentUser('id') operatorId: bigint,
@Body() dto: CreatePlayerAdminDto,
) {
const user = await this.agents.createPlayer(operatorId, {
username: dto.username,
password: dto.password,
parentId: dto.parentId ? BigInt(dto.parentId) : undefined,
locale: dto.locale,
phone: dto.phone,
email: dto.email,
initialDeposit: dto.initialDeposit,
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 || dto.asSubAgent ? 'CREATE_AGENT' : 'CREATE_PLAYER',
module: dto.asTier1Agent || dto.asSubAgent ? 'AGENTS' : 'USERS',
targetId: user.id.toString(),
});
if (dto.asTier1Agent || dto.asSubAgent) {
const detail = await this.agents.getAgentAdminDetail(user.id);
return jsonResponse(detail);
}
const detail = await this.users.getPlayerAdminDetail(user.id);
return jsonResponse(detail);
}
@Get('users/promotable-for-agent')
@RequirePermissions(P.usersView)
async listPromotableForAgent(@Query('keyword') keyword?: string) {
const rows = await this.agents.listPromotablePlayers(keyword);
return jsonResponse(
rows.map((u) => ({
id: u.id.toString(),
username: u.username,
status: u.status,
parentId: u.parentId?.toString() ?? null,
parentUsername: u.parent?.username ?? null,
phone: u.preferences?.phone ?? null,
email: u.preferences?.email ?? null,
})),
);
}
@Get('agents/options')
@RequirePermissions(P.agentsView)
async listAgentOptions() {
const agents = await this.prisma.user.findMany({
where: { userType: 'AGENT', deletedAt: null },
select: {
id: true,
username: true,
agentLevel: true,
parent: { select: { username: true } },
},
orderBy: [{ agentLevel: 'asc' }, { username: 'asc' }],
});
return jsonResponse(
agents.map((a) => ({
id: a.id.toString(),
username: a.username,
level: a.agentLevel ?? 1,
parentUsername: a.parent?.username ?? null,
})),
);
}
@Get('agents')
@RequirePermissions(P.agentsView)
async listAgents(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('status') status?: string,
@Query('level') level?: string,
@Query('parentAgentId') parentAgentId?: string,
) {
const parsedLevel = level === '2' ? 2 : level === '1' ? 1 : undefined;
const result = await this.agents.listAgentsAdmin({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 10,
keyword,
status,
level: parsedLevel,
parentAgentId: parentAgentId ? BigInt(parentAgentId) : undefined,
});
return jsonResponse(result);
}
@Get('agents/credit-transactions')
@RequirePermissions(P.agentsView, P.reports)
async listAgentCreditTransactions(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('agentId') agentId?: string,
@Query('keyword') keyword?: string,
@Query('operatorKeyword') operatorKeyword?: string,
@Query('transactionType') transactionType?: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
) {
const result = await this.agents.listCreditTransactions({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 20,
agentId: agentId ? BigInt(agentId) : undefined,
keyword,
operatorKeyword,
transactionType,
dateFrom: dateFrom ? new Date(dateFrom) : undefined,
dateTo: dateTo ? new Date(dateTo) : undefined,
});
return jsonResponse(result);
}
@Get('agents/:id')
@RequirePermissions(P.agentsView)
async getAgentDetail(@Param('id') id: string) {
const detail = await this.agents.getAgentAdminDetail(BigInt(id));
return jsonResponse(detail);
}
@Put('agents/:id')
@RequirePermissions(P.agentsCreate)
async updateAgent(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: UpdateAgentAdminDto,
) {
const detail = await this.agents.updateAgentAdmin(BigInt(id), dto);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'UPDATE_AGENT',
module: 'AGENTS',
targetId: id,
});
return jsonResponse(detail);
}
@Post('agents')
@RequirePermissions(P.agentsCreate)
async createAgent(
@CurrentUser('id') operatorId: bigint,
@Body() dto: CreateAgentAdminDto,
) {
const user = await this.agents.promotePlayerToTier1Agent(BigInt(dto.userId), {
creditLimit: dto.creditLimit,
phone: dto.phone,
email: dto.email,
cashbackRate: dto.cashbackRate,
maxSingleDeposit: dto.maxSingleDeposit,
maxDailyDeposit: dto.maxDailyDeposit,
});
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'CREATE_AGENT',
module: 'AGENTS',
targetId: user.id.toString(),
});
const detail = await this.agents.getAgentAdminDetail(user.id);
return jsonResponse(detail);
}
@Post('agents/:id/credit')
@RequirePermissions(P.agentsCredit)
async adjustCredit(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: DepositDto,
) {
const result = await this.agents.adjustCredit(
BigInt(id),
dto.amount,
operatorId,
dto.requestId,
dto.remark,
);
return jsonResponse(result);
}
@Post('wallet/deposit')
@RequirePermissions(P.walletDeposit)
async deposit(@CurrentUser('id') operatorId: bigint, @Body() dto: DepositDto & { userId: string }) {
const result = await this.agents.adminDepositToPlayer(
BigInt(dto.userId),
dto.amount,
operatorId,
dto.remark,
dto.requestId,
);
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 }) {
const result = await this.agents.adminWithdrawFromPlayer(
BigInt(dto.userId),
dto.amount,
operatorId,
dto.remark,
dto.requestId,
);
return jsonResponse(result);
}
@Get('wallet/transactions')
@RequirePermissions(P.walletDeposit, P.walletWithdraw)
async walletTransactions(@Query('userId') userId: string, @Query('page') page?: string) {
const result = await this.wallet.getTransactions(BigInt(userId), page ? parseInt(page) : 1);
return jsonResponse(result);
}
@Get('wallet/transfer-transactions')
@RequirePermissions(P.walletDeposit, P.walletWithdraw, P.reports)
async listWalletTransferTransactions(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('playerId') playerId?: string,
@Query('parentAgentId') parentAgentId?: string,
@Query('parentAgentKeyword') parentAgentKeyword?: string,
@Query('keyword') keyword?: string,
@Query('operatorKeyword') operatorKeyword?: string,
@Query('transactionType') transactionType?: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
) {
const result = await this.wallet.listTransferTransactions({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 20,
playerId: playerId ? BigInt(playerId) : undefined,
parentAgentId: parentAgentId ? BigInt(parentAgentId) : undefined,
parentAgentKeyword,
keyword,
operatorKeyword,
transactionType,
dateFrom: dateFrom ? new Date(dateFrom) : undefined,
dateTo: dateTo ? new Date(dateTo) : undefined,
});
return jsonResponse(result);
}
@Post('leagues')
@RequirePermissions(P.matches)
async createLeague(
@Body() dto: CreatePlatformLeagueDto | { code: string; translations: Record<string, string> },
) {
if ('leagueZh' in dto || 'leagueEn' in dto) {
const body = dto as CreatePlatformLeagueDto;
const league = await this.matches.createPlatformLeague({
leagueEn: body.leagueEn,
leagueZh: body.leagueZh,
leagueMs: body.leagueMs,
logoUrl: body.logoUrl,
displayOrder: body.displayOrder,
isActive: body.isActive,
});
return jsonResponse(league);
}
const legacy = dto as { code: string; translations: Record<string, string> };
const league = await this.matches.createLeague(legacy.code, legacy.translations);
return jsonResponse(league);
}
@Put('leagues/:leagueId')
@RequirePermissions(P.matches)
async updateLeague(
@Param('leagueId') leagueId: string,
@Body() dto: CreatePlatformLeagueDto,
) {
const league = await this.matches.updatePlatformLeague(BigInt(leagueId), {
leagueEn: dto.leagueEn,
leagueZh: dto.leagueZh,
leagueMs: dto.leagueMs,
logoUrl: dto.logoUrl,
displayOrder: dto.displayOrder,
isActive: dto.isActive,
});
return jsonResponse(league);
}
@Get('leagues')
@RequirePermissions(P.matches, P.reports)
async listLeagues(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('status') status?: string,
@Query('keyword') keyword?: string,
) {
const p = Math.max(1, page ? parseInt(page, 10) : 1);
const size = Math.min(Math.max(1, pageSize ? parseInt(pageSize, 10) : 10), 100);
const result = await this.matches.listAdminLeagues({
page: p,
pageSize: size,
status: status || undefined,
keyword: keyword || undefined,
});
return jsonResponse(result);
}
@Get('leagues/:leagueId/outright')
@RequirePermissions(P.matches, P.reports)
async getLeagueOutright(@Param('leagueId') leagueId: string) {
const data = await this.outright.getOrCreateAndSyncForLeague(
BigInt(leagueId),
);
return jsonResponse(data);
}
@Get('leagues/:leagueId/matches')
@RequirePermissions(P.matches, P.reports)
async listLeagueMatches(
@Param('leagueId') leagueId: string,
@Query('status') status?: string,
@Query('keyword') keyword?: string,
@Query('locale') locale?: string,
) {
const items = await this.matches.listAdminLeagueMatches(BigInt(leagueId), {
status: status || undefined,
keyword: keyword || undefined,
locale: locale || undefined,
});
return jsonResponse({ items });
}
@Post('teams')
@RequirePermissions(P.matches)
async createTeam(@Body() dto: { code: string; translations: Record<string, string> }) {
const team = await this.matches.createTeam(dto.code, dto.translations);
return jsonResponse(team);
}
@Get('matches')
@RequirePermissions(P.matches, P.reports)
async listMatches(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('status') status?: string,
@Query('keyword') keyword?: string,
) {
const p = Math.max(1, page ? parseInt(page, 10) : 1);
const size = Math.min(Math.max(1, pageSize ? parseInt(pageSize, 10) : 10), 100);
const skip = (p - 1) * size;
const where: { deletedAt: null; status?: string; OR?: object[] } = { deletedAt: null };
if (status) where.status = status;
const kw = keyword?.trim();
if (kw) {
where.OR = [
{ matchName: { contains: kw, mode: 'insensitive' } },
{ homeTeam: { code: { contains: kw, mode: 'insensitive' } } },
{ awayTeam: { code: { contains: kw, mode: 'insensitive' } } },
];
}
const [items, total] = await Promise.all([
this.prisma.match.findMany({
where,
include: {
homeTeam: true,
awayTeam: true,
},
orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }],
skip,
take: size,
}),
this.prisma.match.count({ where }),
]);
return jsonResponse({ items, total, page: p, pageSize: size });
}
@Get('matches/:id')
@RequirePermissions(P.matches, P.reports)
async getMatch(@Param('id') id: string) {
const match = await this.matches.getAdminMatchDetail(BigInt(id));
return jsonResponse(match);
}
@Put('matches/:id')
@RequirePermissions(P.matches)
async updateMatch(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: UpdatePlatformMatchDto,
) {
const match = await this.matches.updatePlatformMatch(BigInt(id), {
homeTeamEn: dto.homeTeamEn,
homeTeamZh: dto.homeTeamZh,
homeTeamMs: dto.homeTeamMs,
awayTeamEn: dto.awayTeamEn,
awayTeamZh: dto.awayTeamZh,
awayTeamMs: dto.awayTeamMs,
startTime: new Date(dto.startTime),
isHot: dto.isHot,
displayOrder: dto.displayOrder,
matchName: dto.matchName,
stage: dto.stage,
groupName: dto.groupName,
homeTeamLogoUrl: dto.homeTeamLogoUrl,
awayTeamLogoUrl: dto.awayTeamLogoUrl,
updatedBy: operatorId,
});
await this.outright.syncOutrightTeamsForLeagueIfExists(match.leagueId);
return jsonResponse(match);
}
@Delete('matches/:id')
@RequirePermissions(P.matches)
async deleteMatch(@Param('id') id: string) {
await this.matches.deleteMatch(BigInt(id));
return jsonResponse({ deleted: true });
}
@Post('matches')
@RequirePermissions(P.matches)
async createMatch(@CurrentUser('id') operatorId: bigint, @Body() dto: CreatePlatformMatchDto) {
const match = await this.matches.createPlatformMatch({
leagueId: dto.leagueId ? BigInt(dto.leagueId) : undefined,
leagueEn: dto.leagueEn ?? '',
leagueZh: dto.leagueZh ?? '',
leagueMs: dto.leagueMs,
homeTeamCode: dto.homeTeamCode,
awayTeamCode: dto.awayTeamCode,
homeTeamEn: dto.homeTeamEn,
homeTeamZh: dto.homeTeamZh,
homeTeamMs: dto.homeTeamMs,
awayTeamEn: dto.awayTeamEn,
awayTeamZh: dto.awayTeamZh,
awayTeamMs: dto.awayTeamMs,
startTime: new Date(dto.startTime),
isHot: dto.isHot,
displayOrder: dto.displayOrder,
matchName: dto.matchName,
stage: dto.stage,
groupName: dto.groupName,
leagueLogoUrl: dto.leagueLogoUrl,
homeTeamLogoUrl: dto.homeTeamLogoUrl,
awayTeamLogoUrl: dto.awayTeamLogoUrl,
createdBy: operatorId,
});
await this.outright.syncOutrightTeamsForLeagueIfExists(match.leagueId);
return jsonResponse(match);
}
@Post('matches/import')
@RequirePermissions(P.matches)
async importMatches(@CurrentUser('id') operatorId: bigint, @Body() dto: ZhiboMatchesBundleExport) {
if (!isZhiboBundlePayload(dto)) {
throw appBadRequest('IMPORT_MATCHES_REQUIRED');
}
const result = await this.matches.importZhiboMatchesBundle(dto, operatorId);
return jsonResponse(result);
}
@Post('matches/:id/publish')
@RequirePermissions(P.matches)
async publishMatch(@Param('id') id: string) {
const match = await this.matches.publishMatch(BigInt(id));
return jsonResponse(match);
}
@Post('matches/:id/close')
@RequirePermissions(P.matches)
async closeMatch(@Param('id') id: string) {
const match = await this.matches.closeMatch(BigInt(id));
return jsonResponse(match);
}
@Post('matches/:id/cancel')
@RequirePermissions(P.matches)
async cancelMatch(@Param('id') id: string) {
await this.matches.cancelMatch(BigInt(id));
const voided = await this.settlement.voidMatchBets(BigInt(id));
return jsonResponse(voided);
}
@Post('matches/:id/markets/templates')
@RequirePermissions(P.matches)
async generateTemplates(@Param('id') id: string, @Body() dto: MarketTemplatesDto) {
const markets = await this.markets.generateTemplates(BigInt(id), dto.marketTypes);
return jsonResponse(markets);
}
@Put('matches/:id/odds')
@RequirePermissions(P.matches)
async batchUpdateMatchOdds(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: BatchMatchOddsDto,
) {
const updates = dto.updates.map((u) => ({
selectionId: BigInt(u.selectionId),
odds: u.odds,
}));
const results = await this.markets.batchUpdateOdds(updates, operatorId);
return jsonResponse({ matchId: id, updated: results.length });
}
@Patch('markets/:id')
@RequirePermissions(P.matches)
async updateMarket(@Param('id') id: string, @Body() dto: UpdateMarketDto) {
const market = await this.markets.updateMarket(BigInt(id), {
promoLabel: dto.promoLabel,
status: dto.status,
lineValue: dto.lineValue,
});
return jsonResponse(market);
}
@Patch('selections/:id')
@RequirePermissions(P.matches)
async updateSelection(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: UpdateSelectionDto,
) {
const selection = await this.markets.updateSelection(
BigInt(id),
{
selectionName: dto.selectionName,
odds: dto.odds,
status: dto.status,
},
operatorId,
);
return jsonResponse(selection);
}
@Put('selections/:id/odds')
@RequirePermissions(P.matches)
async updateOdds(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: UpdateOddsDto,
) {
const selection = await this.markets.updateOdds(BigInt(id), dto.odds, operatorId);
return jsonResponse(selection);
}
@Get('outrights')
@RequirePermissions(P.matches, P.reports)
async listOutrights() {
const data = await this.outright.listForAdmin();
return jsonResponse(data);
}
@Get('outrights/leagues')
@RequirePermissions(P.matches, P.reports)
async listOutrightLeagues() {
const data = await this.outright.listLeagueOptions();
return jsonResponse(data);
}
@Post('outrights')
@RequirePermissions(P.matches)
async createOutright(@Body() dto: CreateOutrightDto) {
const data = await this.outright.createForAdmin({
leagueId: BigInt(dto.leagueId),
titleZh: dto.titleZh,
titleEn: dto.titleEn,
titleMs: dto.titleMs,
status: dto.status,
});
return jsonResponse(data);
}
@Post('outrights/import/wc2026')
@RequirePermissions(P.matches)
async importWc2026Outright() {
const data = await this.outright.importWc2026Canonical();
return jsonResponse(data);
}
/** @deprecated */
@Get('outrights/wc2026')
@RequirePermissions(P.matches, P.reports)
async getWc2026OutrightLegacy() {
const list = await this.outright.listForAdmin();
const wc = list.find((e) => e.leagueCode === 'WC2026');
if (!wc) throw appBadRequest('WC_OUTRIGHT_NOT_FOUND');
return jsonResponse(await this.outright.getForAdmin(BigInt(wc.id)));
}
/** @deprecated */
@Put('outrights/wc2026/odds')
@RequirePermissions(P.matches)
async updateWc2026OutrightOddsLegacy(
@CurrentUser('id') operatorId: bigint,
@Body() dto: BatchOutrightOddsDto,
) {
const list = await this.outright.listForAdmin();
const wc = list.find((e) => e.leagueCode === 'WC2026');
if (!wc) throw appBadRequest('WC_OUTRIGHT_NOT_FOUND');
return jsonResponse(
await this.outright.batchUpdateOdds(BigInt(wc.id), dto.updates, operatorId),
);
}
/** @deprecated */
@Post('outrights/wc2026/apply-canonical')
@RequirePermissions(P.matches)
async applyWc2026CanonicalLegacy() {
return jsonResponse(await this.outright.importWc2026Canonical());
}
@Get('outrights/:matchId')
@RequirePermissions(P.matches, P.reports)
async getOutright(@Param('matchId') matchId: string) {
const data = await this.outright.getForAdmin(BigInt(matchId));
return jsonResponse(data);
}
@Put('outrights/:matchId')
@RequirePermissions(P.matches)
async updateOutright(
@Param('matchId') matchId: string,
@Body() dto: UpdateOutrightDto,
) {
const data = await this.outright.updateForAdmin(BigInt(matchId), dto);
return jsonResponse(data);
}
@Put('outrights/:matchId/odds')
@RequirePermissions(P.matches)
async updateOutrightOdds(
@CurrentUser('id') operatorId: bigint,
@Param('matchId') matchId: string,
@Body() dto: BatchOutrightOddsDto,
) {
const data = await this.outright.batchUpdateOdds(
BigInt(matchId),
dto.updates,
operatorId,
);
return jsonResponse(data);
}
@Post('outrights/:matchId/selections')
@RequirePermissions(P.matches)
async addOutrightSelection(
@Param('matchId') matchId: string,
@Body() dto: AddOutrightSelectionDto,
) {
const data = await this.outright.addSelection(BigInt(matchId), dto);
return jsonResponse(data);
}
@Post('outrights/:matchId/selections/batch')
@RequirePermissions(P.matches)
async addOutrightSelectionsBatch(
@Param('matchId') matchId: string,
@Body() dto: AddOutrightSelectionsBatchDto,
) {
if (!dto.items?.length) {
throw appBadRequest('OUTRIGHT_TEAMS_REQUIRED');
}
const data = await this.outright.addSelectionsBatch(
BigInt(matchId),
dto.items,
);
return jsonResponse(data);
}
@Patch('outrights/:matchId/selections/:selectionId')
@RequirePermissions(P.matches)
async updateOutrightSelectionTeam(
@Param('matchId') matchId: string,
@Param('selectionId') selectionId: string,
@Body() dto: UpdateOutrightSelectionTeamDto,
) {
const data = await this.outright.updateSelectionTeam(
BigInt(matchId),
BigInt(selectionId),
dto,
);
return jsonResponse(data);
}
@Delete('outrights/:matchId/selections/:selectionId')
@RequirePermissions(P.matches)
async removeOutrightSelection(
@Param('matchId') matchId: string,
@Param('selectionId') selectionId: string,
) {
const data = await this.outright.closeSelection(
BigInt(matchId),
BigInt(selectionId),
);
return jsonResponse(data);
}
@Get('matches/:id/settlement/stats')
@RequirePermissions(P.settlement, P.reports)
async getMatchSettlementStats(
@Param('id') id: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const data = await this.settlement.getMatchBetStats(BigInt(id), {
page: page ? Math.max(1, parseInt(page, 10) || 1) : 1,
pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 10)) : 10,
});
return jsonResponse(data);
}
// 智能比分推荐已关闭
// @Post('matches/:id/settlement/smart-score')
// async suggestSmartScore(...) { ... }
@Post('matches/:id/settlement/score')
@RequirePermissions(P.settlement)
async recordScore(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: ScoreDto,
) {
const result = await this.settlement.recordScore(
BigInt(id),
dto.htHome ?? 0,
dto.htAway ?? 0,
dto.ftHome ?? 0,
dto.ftAway ?? 0,
operatorId,
dto.winnerTeamId != null ? BigInt(dto.winnerTeamId) : undefined,
);
return jsonResponse(result);
}
@Post('matches/:id/settlement/preview')
@RequirePermissions(P.settlement)
async settlementPreview(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto?: SettlementPreviewDto,
) {
const matchId = BigInt(id);
const hasScore =
dto?.htHome !== undefined ||
dto?.htAway !== undefined ||
dto?.ftHome !== undefined ||
dto?.ftAway !== undefined ||
dto?.winnerTeamId !== undefined;
if (hasScore) {
await this.settlement.recordScore(
matchId,
dto!.htHome ?? 0,
dto!.htAway ?? 0,
dto!.ftHome ?? 0,
dto!.ftAway ?? 0,
operatorId,
dto!.winnerTeamId != null ? BigInt(dto!.winnerTeamId) : undefined,
);
}
const preview = await this.settlement.previewSettlement(matchId, operatorId, {
page: dto?.page ? Math.max(1, dto.page) : 1,
pageSize: dto?.pageSize ? Math.min(100, Math.max(1, dto.pageSize)) : 10,
});
return jsonResponse(preview);
}
@Get('settlement/:batchId/preview-items')
@RequirePermissions(P.settlement)
async getSettlementPreviewItems(
@Param('batchId') batchId: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const data = await this.settlement.getPreviewSettlementItems(BigInt(batchId), {
page: page ? Math.max(1, parseInt(page, 10) || 1) : 1,
pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 10)) : 10,
});
return jsonResponse(data);
}
@Post('settlement/:batchId/confirm')
@RequirePermissions(P.settlement)
async confirmSettlement(@CurrentUser('id') operatorId: bigint, @Param('batchId') batchId: string) {
const result = await this.settlement.confirmSettlement(BigInt(batchId), operatorId);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'CONFIRM_SETTLEMENT',
module: 'SETTLEMENT',
targetId: batchId,
});
return jsonResponse(result);
}
@Post('matches/:id/resettle/preview')
@RequirePermissions(P.resettle)
async resettlePreview(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: ResettlePreviewDto,
) {
const preview = await this.settlement.previewResettlement(
BigInt(id),
{
htHome: dto.htHome ?? 0,
htAway: dto.htAway ?? 0,
ftHome: dto.ftHome ?? 0,
ftAway: dto.ftAway ?? 0,
},
operatorId,
dto.reason,
dto.winnerTeamId != null ? BigInt(dto.winnerTeamId) : undefined,
);
return jsonResponse(preview);
}
@Post('resettle/:batchId/confirm')
@RequirePermissions(P.resettle)
async confirmResettlement(
@CurrentUser('id') operatorId: bigint,
@Param('batchId') batchId: string,
) {
const result = await this.settlement.confirmResettlement(BigInt(batchId), operatorId);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'CONFIRM_RESETTLE',
module: 'SETTLEMENT',
targetId: batchId,
});
return jsonResponse(result);
}
@Get('bets')
@RequirePermissions(P.bets)
async listBets(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('status') status?: string,
@Query('betType') betType?: string,
@Query('placedFrom') placedFrom?: string,
@Query('placedTo') placedTo?: string,
) {
const result = await this.bets.listBetsAdmin({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 10,
keyword,
status: status || undefined,
betType: betType || undefined,
placedFrom,
placedTo,
});
return jsonResponse(result);
}
@Get('bets/:id')
@RequirePermissions(P.bets)
async getBet(@Param('id') id: string) {
const detail = await this.bets.getBetAdminDetail(BigInt(id));
return jsonResponse(detail);
}
@Post('cashbacks/preview')
@RequirePermissions(P.cashback, P.reports)
async cashbackPreview(@Body() dto: CashbackPreviewDto) {
const preview = await this.cashback.previewBatch(
new Date(dto.periodStart),
new Date(dto.periodEnd),
);
return jsonResponse(preview);
}
@Get('cashbacks')
@RequirePermissions(P.cashback, P.reports)
async listCashbacks(
@Query('page') page = '1',
@Query('pageSize') pageSize = '10',
@Query('status') status?: string,
) {
const result = await this.cashback.listBatches({
page: Number(page) || 1,
pageSize: Number(pageSize) || 10,
status,
});
return jsonResponse(result);
}
@Get('cashbacks/:batchId')
@RequirePermissions(P.cashback, P.reports)
async getCashbackBatch(@Param('batchId') batchId: string) {
const detail = await this.cashback.getBatchDetail(BigInt(batchId));
return jsonResponse(detail);
}
@Post('cashbacks/:batchId/confirm')
@RequirePermissions(P.cashback)
async cashbackConfirm(@CurrentUser('id') operatorId: bigint, @Param('batchId') batchId: string) {
const result = await this.cashback.confirmBatch(BigInt(batchId), operatorId);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'CONFIRM_CASHBACK',
module: 'CASHBACK',
targetId: batchId,
});
return jsonResponse(result);
}
@Post('cashbacks/:batchId/cancel')
@RequirePermissions(P.cashback)
async cashbackCancel(@CurrentUser('id') operatorId: bigint, @Param('batchId') batchId: string) {
const result = await this.cashback.cancelBatch(BigInt(batchId));
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'CANCEL_CASHBACK',
module: 'CASHBACK',
targetId: batchId,
});
return jsonResponse(result);
}
@Post('uploads')
@RequirePermissions(P.content, P.matches)
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
async uploadAsset(
@CurrentUser() user: AdminUploadUser & { id?: bigint },
@UploadedFile() file: UploadedImage | undefined,
@Query('category') rawCategory?: string,
) {
const category = uploadCategory(rawCategory);
assertUploadPermission(user, category);
assertImageFile(file);
const filename = uploadFilename(file);
const root = getUploadRoot();
const targetDir = join(root, category);
await mkdir(targetDir, { recursive: true });
await writeFile(join(targetDir, filename), file.buffer);
const url = `/uploads/${category}/${filename}`;
await this.prisma.uploadedFile.create({
data: {
filename,
category,
mimeType: file.mimetype,
size: file.size,
url,
uploadedBy: user.id ?? null,
},
});
return jsonResponse({ category, filename, size: file.size, mimeType: file.mimetype, url });
}
@Get('files')
@RequirePermissions(P.content, P.matches)
async listFiles(
@Query('category') category?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const where = category && UPLOAD_CATEGORIES.includes(category as any) ? { category } : {};
const take = Math.min(parseInt(pageSize ?? '50', 10) || 50, 200);
const skip = (Math.max(parseInt(page ?? '1', 10) || 1, 1) - 1) * take;
const [files, total] = await Promise.all([
this.prisma.uploadedFile.findMany({ where, orderBy: { createdAt: 'desc' }, take, skip }),
this.prisma.uploadedFile.count({ where }),
]);
const usedUrls = await this.getUsedFileUrls();
const items = files.map((f) => ({ ...f, inUse: usedUrls.has(f.url) }));
return jsonResponse({ items, total, page: skip / take + 1, pageSize: take });
}
@Delete('files/unused')
@RequirePermissions(P.content)
async purgeUnusedFiles(@CurrentUser('id') operatorId: bigint) {
const all = await this.prisma.uploadedFile.findMany();
const usedUrls = await this.getUsedFileUrls();
const unused = all.filter((f) => !usedUrls.has(f.url));
const root = getUploadRoot();
let deleted = 0;
for (const f of unused) {
try {
await unlink(join(root, f.category, f.filename));
} catch { /* file already missing from disk */ }
await this.prisma.uploadedFile.delete({ where: { id: f.id } });
deleted++;
}
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'PURGE_UNUSED_FILES',
module: 'MEDIA',
afterData: JSON.stringify({ deleted }),
});
return jsonResponse({ deleted });
}
@Delete('files/:id')
@RequirePermissions(P.content, P.matches)
async deleteFile(
@CurrentUser() user: AdminUploadUser,
@Param('id') id: string,
) {
const record = await this.prisma.uploadedFile.findUnique({ where: { id } });
if (!record) throw appBadRequest('FILE_NOT_FOUND');
assertUploadPermission(user, record.category as any);
const root = getUploadRoot();
try {
await unlink(join(root, record.category, record.filename));
} catch { /* already gone */ }
await this.prisma.uploadedFile.delete({ where: { id } });
return jsonResponse({ ok: true });
}
@Delete('uploads/by-url')
@RequirePermissions(P.content, P.matches)
async deleteFileByUrl(@Body() body: { url: string }) {
const { url } = body;
if (!url || typeof url !== 'string') throw appBadRequest('URL_REQUIRED');
const record = await this.prisma.uploadedFile.findFirst({ where: { url } });
if (!record) return jsonResponse({ ok: true, note: 'not_found' });
const root = getUploadRoot();
try {
await unlink(join(root, record.category, record.filename));
} catch { /* already gone */ }
await this.prisma.uploadedFile.delete({ where: { id: record.id } });
return jsonResponse({ ok: true });
}
private async getUsedFileUrls(): Promise<Set<string>> {
const [ctRows, leagueRows, teamRows, prefRows] = await Promise.all([
this.prisma.contentTranslation.findMany({ select: { imageUrl: true } }),
this.prisma.league.findMany({ select: { logoUrl: true } }),
this.prisma.team.findMany({ select: { logoUrl: true } }),
this.prisma.userPreference.findMany({ select: { avatarKey: true } }),
]);
const urls = new Set<string>();
for (const r of ctRows) if (r.imageUrl) urls.add(r.imageUrl);
for (const r of leagueRows) if (r.logoUrl) urls.add(r.logoUrl);
for (const r of teamRows) if (r.logoUrl) urls.add(r.logoUrl);
for (const r of prefRows) if (r.avatarKey) urls.add(r.avatarKey);
return urls;
}
@Get('contents')
@RequirePermissions(P.content, P.reports)
async listContents(
@Query('type') type?: string,
@Query('status') status?: string,
) {
const items = await this.content.listForAdmin(type, status);
return jsonResponse(items);
}
@Get('contents/:id')
@RequirePermissions(P.content, P.reports)
async getContent(@Param('id') id: string) {
const item = await this.content.getForAdmin(BigInt(id));
return jsonResponse(item);
}
@Post('contents')
@RequirePermissions(P.content)
async createContent(@Body() dto: CreateContentDto) {
const item = await this.content.create(dto);
return jsonResponse(item);
}
@Put('contents/:id')
@RequirePermissions(P.content)
async updateContent(@Param('id') id: string, @Body() dto: UpdateContentDto) {
const item = await this.content.update(BigInt(id), dto);
return jsonResponse(item);
}
@Patch('contents/:id/status')
@RequirePermissions(P.content)
async updateContentStatus(
@Param('id') id: string,
@Body() dto: ContentStatusDto,
) {
const item = await this.content.updateStatus(BigInt(id), dto.status);
return jsonResponse(item);
}
@Delete('contents/:id')
@RequirePermissions(P.content)
async deleteContent(@Param('id') id: string) {
const result = await this.content.remove(BigInt(id));
return jsonResponse(result);
}
@Get('i18n/messages')
@RequirePermissions(P.settings, P.reports)
async getMessages(@Query('locale') locale = 'en-US') {
const messages = await this.i18n.getMessages(locale);
return jsonResponse(messages);
}
@Get('smoke-tests/suites')
@RequirePermissions(P.settings)
async smokeTestSuites() {
return jsonResponse({
suites: this.smokeTests.listSuites(),
cases: this.smokeTests.listCases(),
lastRun: this.smokeTests.getLastRun(),
});
}
@Get('smoke-tests/last-run')
@RequirePermissions(P.settings)
async smokeTestLastRun() {
return jsonResponse(this.smokeTests.getLastRun());
}
@Post('smoke-tests/run')
@RequirePermissions(P.settings)
async runSmokeTests(
@CurrentUser('id') operatorId: bigint,
@Body() body: { suites?: string[] },
) {
const summary = await this.smokeTests.run(body?.suites, operatorId);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'RUN_SMOKE_TESTS',
module: 'SYSTEM',
targetId: summary.runId,
afterData: {
passed: summary.passed,
failed: summary.failed,
total: summary.total,
suites: summary.suites,
},
});
return jsonResponse(summary);
}
@Get('audit-logs')
@RequirePermissions(P.audit)
async auditLogs(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('module') module?: string,
) {
const result = await this.audit.list(
page ? parseInt(page, 10) : 1,
pageSize ? parseInt(pageSize, 10) : 10,
module || undefined,
);
return jsonResponse(result);
}
}