Files
thebet365/apps/api/src/applications/admin/admin.controller.ts

3514 lines
92 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 { AdminStaffService } from '../../domains/identity/admin-staff.service';
import { AgentsService } from '../../domains/agent/agents.service';
import { WalletService } from '../../domains/ledger/wallet.service';
import { MatchesService } from '../../domains/catalog/matches.service';
import { CatalogArchiveService } from '../../domains/catalog/catalog-archive.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 {
CatalogAuditAction,
CATALOG_AUDIT_MODULE,
logCatalogAudit,
summarizeOddsUpdates,
} from '../../domains/operations/audit/catalog-audit';
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 { DepositService } from '../../domains/deposit/deposit.service';
import { PlayerMessagesService } from '../../domains/player-messages/player-messages.service';
import { PresenceService } from '../../domains/presence/presence.service';
import {
IsString,
IsNumber,
IsOptional,
IsArray,
IsBoolean,
MinLength,
IsIn,
Min,
Max,
Equals,
ValidateIf,
} from 'class-validator';
import type { ZhiboMatchExport, ZhiboMatchesBundleExport } from '../../domains/catalog/zhibo-match.types';
const UPLOAD_CATEGORIES = ['banners', 'teams', 'contents', 'payments', 'deposits'] 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 parseMatchStartTime(value: string): Date {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
throw appBadRequest('MATCH_START_TIME_INVALID');
}
return date;
}
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;
/** 玩家专属返水比例小数null 或 0 表示清除单独设置、使用默认 */
@IsOptional()
@ValidateIf((_, v) => v != null)
@IsNumber()
@Min(0)
cashbackRate?: number | null;
}
class CreateStaffDto {
@IsString()
username!: string;
@IsString()
@MinLength(8)
password!: string;
@IsString()
roleCode!: string;
@IsOptional()
@IsString()
visibleMenus?: string;
}
class UpdateStaffDto {
@IsOptional()
@IsIn(['ACTIVE', 'SUSPENDED', 'DISABLED'])
status?: string;
@IsOptional()
@IsString()
roleCode?: string;
@IsOptional()
@IsString()
@MinLength(8)
password?: string;
@IsOptional()
@IsString()
visibleMenus?: string;
}
class ResetPlayerPasswordDto {
@IsOptional()
@IsString()
@MinLength(8)
password?: string;
}
class PlatformDirectCashbackSettingsDto {
@IsOptional()
@IsNumber()
@Min(0)
platformDirectRate?: number;
@IsOptional()
@IsNumber()
@Min(0)
adminInviteRate?: number;
}
class PlayerAccountSettingsDto {
@IsOptional()
@IsBoolean()
allowPasswordChange?: boolean;
@IsOptional()
@IsBoolean()
allowUsernameChange?: boolean;
}
class AgentSuspendSettingsDto {
@IsOptional()
@IsBoolean()
suspendFreezeDirectPlayers?: boolean;
@IsOptional()
@IsBoolean()
suspendBlockPlayerLogin?: boolean;
}
class AgentHierarchySettingsDto {
@IsOptional()
@IsNumber()
@Min(0)
maxAgentLevel?: number;
@IsOptional()
@IsNumber()
@Min(1)
@Max(100)
defaultSubAgentCreditRatio?: number;
}
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;
/** 冻结时是否禁止直属玩家登录 */
@IsOptional()
@IsBoolean()
blockDirectPlayerLogin?: boolean;
/** 解冻时是否级联解冻直属玩家 */
@IsOptional()
@IsBoolean()
unfreezeDirectPlayers?: 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 ReopenMatchDto {
@IsOptional()
@IsString()
startTime?: string;
}
class ArchiveMatchDto {
@IsOptional()
@IsBoolean()
force?: boolean;
@IsOptional()
@IsBoolean()
refundPendingBets?: boolean;
}
class BatchMatchOddsDto {
@IsArray()
updates!: OutrightOddsUpdateItemDto[];
}
class UpdateMarketDto {
@IsOptional()
@IsString()
promoLabel?: string | null;
@IsOptional()
promoLabelI18n?: Record<string, string> | null;
@IsOptional()
nameI18n?: Record<string, string> | null;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsNumber()
lineValue?: number | null;
@IsOptional()
@IsBoolean()
showOnPlayer?: boolean;
}
class UpdateSelectionDto {
@IsOptional()
@IsString()
selectionName?: string;
@IsOptional()
nameI18n?: Record<string, string> | null;
@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;
@IsOptional()
@IsNumber()
homeCorners?: number;
@IsOptional()
@IsNumber()
awayCorners?: number;
@IsOptional()
@IsNumber()
homeYellowCards?: number;
@IsOptional()
@IsNumber()
awayYellowCards?: number;
@IsOptional()
@IsNumber()
homeRedCards?: number;
@IsOptional()
@IsNumber()
awayRedCards?: number;
@IsOptional()
@IsNumber()
homeCards?: number;
@IsOptional()
@IsNumber()
awayCards?: 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 MarketDraftSelectionDto {
@IsOptional()
@IsString()
id?: string;
@IsString()
selectionCode!: string;
@IsOptional()
@IsString()
selectionName?: string;
@IsOptional()
nameI18n?: Record<string, string> | null;
@IsNumber()
@Min(1.01)
odds!: number;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsNumber()
sortOrder?: number;
}
class MarketDraftDto {
@IsOptional()
@IsString()
id?: string;
@IsString()
marketType!: string;
@IsOptional()
@IsString()
marketKey?: string | null;
@IsOptional()
@IsString()
lineKey?: string | null;
@IsOptional()
@IsString()
period?: string;
@IsOptional()
@IsNumber()
lineValue?: number | null;
@IsOptional()
paramsJson?: Record<string, unknown> | null;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsBoolean()
allowSingle?: boolean;
@IsOptional()
@IsBoolean()
allowParlay?: boolean;
@IsOptional()
@IsBoolean()
showOnPlayer?: boolean;
@IsOptional()
@IsNumber()
sortOrder?: number;
@IsOptional()
@IsString()
promoLabel?: string | null;
@IsOptional()
promoLabelI18n?: Record<string, string> | null;
@IsOptional()
nameI18n?: Record<string, string> | null;
@IsOptional()
@IsArray()
selections?: MarketDraftSelectionDto[];
}
class MarketTemplateSaveDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
nameI18n?: Record<string, string> | null;
@IsOptional()
@IsString()
description?: string | null;
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsNumber()
sortOrder?: number;
@IsOptional()
@IsArray()
items?: MarketDraftDto[];
}
class ApplyMarketTemplateDto {
@IsOptional()
@IsString()
templateId?: string;
}
class BulkMatchMarketsDto {
@IsArray()
markets!: MarketDraftDto[];
}
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;
@IsOptional()
@IsBoolean()
notifyInbox?: boolean;
@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 InboxNotifySettingsDto {
@IsOptional()
@IsBoolean()
inboxEnabled?: boolean;
@IsOptional()
@IsBoolean()
deposit?: boolean;
}
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()
@IsNumber()
homeCorners?: number;
@IsOptional()
@IsNumber()
awayCorners?: number;
@IsOptional()
@IsNumber()
homeYellowCards?: number;
@IsOptional()
@IsNumber()
awayYellowCards?: number;
@IsOptional()
@IsNumber()
homeRedCards?: number;
@IsOptional()
@IsNumber()
awayRedCards?: number;
@IsOptional()
@IsNumber()
homeCards?: number;
@IsOptional()
@IsNumber()
awayCards?: 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 catalogArchive: CatalogArchiveService,
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,
private depositService: DepositService,
private playerMessages: PlayerMessagesService,
private staff: AdminStaffService,
private presence: PresenceService,
) {}
@Get('presence/online-count')
@RequirePermissions(P.usersView)
async getOnlinePlayerCount() {
const count = await this.presence.getOnlineCount();
return jsonResponse({ count, asOf: new Date().toISOString() });
}
@Get('dashboard')
@RequirePermissions(P.reports)
async getDashboard() {
const overview = await this.dashboardService.getOverview();
return jsonResponse(overview);
}
@Get('users/page-init')
@RequirePermissions(P.agentsView, P.usersView)
async getUsersPageInit() {
const [
playerSettings,
bettingLimits,
hierarchySettings,
platformDirect,
agentLevelCounts,
] = await Promise.all([
this.systemConfig.getPlayerAccountSettings(),
this.bettingLimits.getLimits(),
this.systemConfig.getAgentHierarchySettings(),
this.systemConfig.getPlatformDirectCashbackSettings(),
this.agents.countAgentsByLevel(),
]);
return jsonResponse({
playerSettings,
bettingLimits,
hierarchySettings,
platformDirect,
agentLevelCounts,
});
}
@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('agents/settings/hierarchy')
@RequirePermissions(P.settings)
async getAgentHierarchySettings() {
const settings = await this.systemConfig.getAgentHierarchySettings();
return jsonResponse(settings);
}
@Put('agents/settings/hierarchy')
@RequirePermissions(P.settings)
async updateAgentHierarchySettings(
@CurrentUser('id') operatorId: bigint,
@Body() dto: AgentHierarchySettingsDto,
) {
const settings = await this.systemConfig.updateAgentHierarchySettings(dto);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'UPDATE_AGENT_HIERARCHY_SETTINGS',
module: 'AGENTS',
afterData: JSON.stringify(settings),
});
return jsonResponse(settings);
}
@Get('settings/betting-limits')
@RequirePermissions(P.settings)
async getBettingLimits() {
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), {
status: dto.status,
locale: dto.locale,
phone: dto.phone,
email: dto.email,
username: dto.username,
password: dto.password,
cashbackRate: dto.cashbackRate,
});
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'UPDATE_PLAYER',
module: 'USERS',
targetId: id,
});
return jsonResponse(detail);
}
@Post('users/:id/reset-password')
@RequirePermissions(P.usersResetPassword)
async resetPlayerPassword(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: ResetPlayerPasswordDto,
) {
const { password } = await this.staff.resetPlayerPassword(BigInt(id), dto.password);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'RESET_PLAYER_PASSWORD',
module: 'USERS',
targetId: id,
});
const detail = await this.users.getPlayerAdminDetail(BigInt(id));
return jsonResponse({ ...detail, password });
}
@Get('staff/roles')
@RequirePermissions(P.settings)
async listStaffRoles() {
const roles = await this.staff.listRoles();
return jsonResponse(roles);
}
@Get('staff')
@RequirePermissions(P.settings)
async listStaff(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
) {
const result = await this.staff.listStaff(
page ? parseInt(page, 10) : 1,
pageSize ? parseInt(pageSize, 10) : 20,
keyword,
);
return jsonResponse(result);
}
@Post('staff')
@RequirePermissions(P.settings)
async createStaff(
@CurrentUser('id') operatorId: bigint,
@Body() dto: CreateStaffDto,
) {
const created = await this.staff.createStaff(dto);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'CREATE_STAFF',
module: 'STAFF',
targetId: created.id,
afterData: { username: created.username, role: created.role },
});
return jsonResponse(created);
}
@Patch('staff/:id')
@RequirePermissions(P.settings)
async updateStaff(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: UpdateStaffDto,
) {
const updated = await this.staff.updateStaff(BigInt(id), dto);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'UPDATE_STAFF',
module: 'STAFF',
targetId: id,
afterData: JSON.stringify({ status: dto.status, roleCode: dto.roleCode }),
});
return jsonResponse(updated);
}
@Delete('staff/:id')
@RequirePermissions(P.settings)
async deleteStaff(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
) {
await this.staff.deleteStaff(BigInt(id), operatorId);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'DELETE_STAFF',
module: 'STAFF',
targetId: id,
});
return jsonResponse({ deleted: true });
}
@Delete('users/:id')
@RequirePermissions(P.usersCreate)
async deletePlayer(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
) {
await this.users.softDeletePlayer(BigInt(id));
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'DELETE_PLAYER',
module: 'USERS',
targetId: id,
});
return jsonResponse({ deleted: true });
}
@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(
@Query('keyword') keyword?: string,
@Query('limit') limit?: string,
) {
const take = Math.min(100, Math.max(1, parseInt(limit ?? '50', 10) || 50));
const kw = keyword?.trim();
const agents = await this.prisma.user.findMany({
where: {
userType: 'AGENT',
deletedAt: null,
...(kw
? { username: { contains: kw, mode: 'insensitive' as const } }
: {}),
},
select: {
id: true,
username: true,
agentLevel: true,
parent: { select: { username: true } },
},
orderBy: [{ agentLevel: 'asc' }, { username: 'asc' }],
take,
});
return jsonResponse(
agents.map((a) => ({
id: a.id.toString(),
username: a.username,
level: a.agentLevel ?? 1,
parentUsername: a.parent?.username ?? null,
})),
);
}
@Get('agents/level-counts')
@RequirePermissions(P.agentsView)
async getAgentLevelCounts() {
const counts = await this.agents.countAgentsByLevel();
return jsonResponse(counts);
}
@Get('agents')
@RequirePermissions(P.agentsView)
async listAgents(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('status') status?: string,
@Query('level') level?: string,
@Query('minLevel') minLevel?: string,
@Query('maxLevel') maxLevel?: string,
@Query('parentAgentId') parentAgentId?: string,
) {
const parsedLevel = level != null && level !== '' ? parseInt(level, 10) : undefined;
const parsedMinLevel = minLevel != null && minLevel !== '' ? parseInt(minLevel, 10) : undefined;
const parsedMaxLevel = maxLevel != null && maxLevel !== '' ? parseInt(maxLevel, 10) : undefined;
const result = await this.agents.listAgentsAdmin({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 10,
keyword,
status,
level: Number.isFinite(parsedLevel) ? parsedLevel : undefined,
minLevel: Number.isFinite(parsedMinLevel) ? parsedMinLevel : undefined,
maxLevel: Number.isFinite(parsedMaxLevel) ? parsedMaxLevel : undefined,
parentAgentId: parentAgentId ? BigInt(parentAgentId) : undefined,
});
return jsonResponse(result);
}
@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, P.reports)
async walletTransactions(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('playerId') playerId?: string,
@Query('parentAgentId') parentAgentId?: string,
@Query('parentAgentKeyword') parentAgentKeyword?: string,
@Query('keyword') keyword?: string,
@Query('operatorKeyword') operatorKeyword?: string,
@Query('transactionType') transactionType?: string,
@Query('typeCategory') typeCategory?: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
) {
const result = await this.wallet.listWalletTransactionsAdmin({
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 20,
playerId: playerId ? BigInt(playerId) : undefined,
parentAgentId: parentAgentId ? BigInt(parentAgentId) : undefined,
parentAgentKeyword,
keyword,
operatorKeyword,
transactionType,
typeCategory,
dateFrom: dateFrom ? new Date(dateFrom) : undefined,
dateTo: dateTo ? new Date(dateTo) : undefined,
});
return jsonResponse(result);
}
@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(
@CurrentUser('id') operatorId: bigint,
@Body() dto: CreatePlatformLeagueDto | { code: string; translations: Record<string, string> },
) {
let league;
if ('leagueZh' in dto || 'leagueEn' in dto) {
const body = dto as CreatePlatformLeagueDto;
league = await this.matches.createPlatformLeague({
leagueEn: body.leagueEn,
leagueZh: body.leagueZh,
leagueMs: body.leagueMs,
logoUrl: body.logoUrl,
displayOrder: body.displayOrder,
isActive: body.isActive,
});
} else {
const legacy = dto as { code: string; translations: Record<string, string> };
league = await this.matches.createLeague(legacy.code, legacy.translations);
}
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.CREATE_LEAGUE,
targetId: league.id,
afterData:
'leagueZh' in dto || 'leagueEn' in dto
? { leagueEn: (dto as CreatePlatformLeagueDto).leagueEn, leagueZh: (dto as CreatePlatformLeagueDto).leagueZh }
: { code: (dto as { code: string }).code },
});
return jsonResponse(league);
}
@Put('leagues/:leagueId')
@RequirePermissions(P.matches)
async updateLeague(
@CurrentUser('id') operatorId: bigint,
@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,
});
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.UPDATE_LEAGUE,
targetId: leagueId,
afterData: { leagueEn: dto.leagueEn, leagueZh: dto.leagueZh, isActive: dto.isActive },
});
return jsonResponse(league);
}
@Get('leagues/:leagueId/archive-preview')
@RequirePermissions(P.matches)
async getLeagueArchivePreview(@Param('leagueId') leagueId: string) {
const preview = await this.catalogArchive.getLeagueArchivePreview(BigInt(leagueId));
return jsonResponse(preview);
}
@Post('leagues/:leagueId/archive')
@RequirePermissions(P.matches)
async archiveLeague(
@CurrentUser('id') operatorId: bigint,
@Param('leagueId') leagueId: string,
) {
const result = await this.catalogArchive.archiveLeague(BigInt(leagueId));
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.ARCHIVE_LEAGUE,
targetId: leagueId,
afterData: result,
});
return jsonResponse(result);
}
@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,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const result = await this.matches.listAdminLeagueMatches(BigInt(leagueId), {
status: status || undefined,
keyword: keyword || undefined,
locale: locale || undefined,
page: page ? Math.max(1, parseInt(page, 10) || 1) : 1,
pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 20)) : 20,
});
return jsonResponse(result);
}
@Post('teams')
@RequirePermissions(P.matches)
async createTeam(
@CurrentUser('id') operatorId: bigint,
@Body() dto: { code: string; translations: Record<string, string> },
) {
const team = await this.matches.createTeam(dto.code, dto.translations);
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.CREATE_TEAM,
targetId: team.id,
afterData: { code: dto.code },
});
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: parseMatchStartTime(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);
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.UPDATE_MATCH,
targetId: id,
afterData: {
status: match.status,
startTime: match.startTime,
isHot: match.isHot,
matchName: match.matchName,
},
});
return jsonResponse(match);
}
@Delete('matches/:id')
@RequirePermissions(P.matches)
async deleteMatch(@CurrentUser('id') operatorId: bigint, @Param('id') id: string) {
await this.matches.deleteMatch(BigInt(id));
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.DELETE_MATCH,
targetId: id,
});
return jsonResponse({ deleted: true });
}
@Get('matches/:id/archive-preview')
@RequirePermissions(P.matches)
async getMatchArchivePreview(@Param('id') id: string) {
const preview = await this.catalogArchive.getMatchArchivePreview(BigInt(id));
return jsonResponse(preview);
}
@Post('matches/:id/archive')
@RequirePermissions(P.matches)
async archiveMatch(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: ArchiveMatchDto,
) {
const matchId = BigInt(id);
const result = await this.catalogArchive.archiveMatch(matchId, {
force: dto.force === true,
});
let voidedCount = 0;
if (dto.refundPendingBets) {
const voided = await this.settlement.voidMatchBets(matchId);
voidedCount = voided.voidedCount;
}
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.ARCHIVE_MATCH,
targetId: id,
afterData: { ...result, force: dto.force === true, voidedCount },
});
return jsonResponse({ ...result, voidedCount });
}
@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: parseMatchStartTime(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);
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.CREATE_MATCH,
targetId: match.id,
afterData: {
status: match.status,
startTime: match.startTime,
leagueId: match.leagueId?.toString(),
matchName: match.matchName,
},
});
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);
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.IMPORT_MATCHES,
afterData: {
total: result.total,
imported: result.imported,
skipped: result.skipped,
failed: result.failed,
},
});
return jsonResponse(result);
}
@Post('matches/:id/publish')
@RequirePermissions(P.matches)
async publishMatch(@CurrentUser('id') operatorId: bigint, @Param('id') id: string) {
const match = await this.matches.publishMatch(BigInt(id));
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.PUBLISH_MATCH,
targetId: id,
afterData: { status: match.status, publishTime: match.publishTime },
});
return jsonResponse(match);
}
@Post('matches/:id/unpublish')
@RequirePermissions(P.matches)
async unpublishMatch(@CurrentUser('id') operatorId: bigint, @Param('id') id: string) {
const match = await this.matches.unpublishMatch(BigInt(id));
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.UNPUBLISH_MATCH,
targetId: id,
afterData: { status: match.status },
});
return jsonResponse(match);
}
@Post('matches/:id/close')
@RequirePermissions(P.matches)
async closeMatch(@CurrentUser('id') operatorId: bigint, @Param('id') id: string) {
const match = await this.matches.closeMatch(BigInt(id));
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.CLOSE_MATCH,
targetId: id,
afterData: { status: match.status, closeTime: match.closeTime },
});
return jsonResponse(match);
}
@Post('matches/:id/reopen')
@RequirePermissions(P.matches)
async reopenMatch(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: ReopenMatchDto,
) {
const startTime = dto.startTime ? parseMatchStartTime(dto.startTime) : undefined;
const match = await this.matches.reopenMatch(BigInt(id), startTime);
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.REOPEN_MATCH,
targetId: id,
afterData: { status: match.status, startTime: match.startTime },
});
return jsonResponse(match);
}
@Post('matches/:id/cancel')
@RequirePermissions(P.matches)
async cancelMatch(@CurrentUser('id') operatorId: bigint, @Param('id') id: string) {
const voided = await this.settlement.cancelMatchAndVoidBets(BigInt(id));
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.CANCEL_MATCH,
targetId: id,
afterData: voided,
});
return jsonResponse(voided);
}
@Get('market-definitions')
@RequirePermissions(P.matches)
async listMarketDefinitions() {
return jsonResponse(this.markets.listMarketDefinitions());
}
@Get('market-templates')
@RequirePermissions(P.matches)
async listMarketTemplates() {
const templates = await this.markets.listTemplates();
return jsonResponse(templates);
}
@Post('market-templates')
@RequirePermissions(P.matches)
async createMarketTemplate(
@CurrentUser('id') operatorId: bigint,
@Body() dto: MarketTemplateSaveDto,
) {
const template = await this.markets.createTemplate(dto);
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.CREATE_MARKET_TEMPLATE,
targetId: template.id,
afterData: { name: template.name, sportType: template.sportType },
});
return jsonResponse(template);
}
@Get('market-templates/:id')
@RequirePermissions(P.matches)
async getMarketTemplate(@Param('id') id: string) {
const template = await this.markets.getTemplate(BigInt(id));
return jsonResponse(template);
}
@Put('market-templates/:id')
@RequirePermissions(P.matches)
async updateMarketTemplate(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: MarketTemplateSaveDto,
) {
const template = await this.markets.updateTemplate(BigInt(id), dto);
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.UPDATE_MARKET_TEMPLATE,
targetId: id,
afterData: { name: template.name, sportType: template.sportType },
});
return jsonResponse(template);
}
@Post('market-templates/:id/duplicate')
@RequirePermissions(P.matches)
async duplicateMarketTemplate(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
) {
const template = await this.markets.duplicateTemplate(BigInt(id));
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.DUPLICATE_MARKET_TEMPLATE,
targetId: template.id,
afterData: { sourceTemplateId: id, name: template.name },
});
return jsonResponse(template);
}
@Post('market-templates/:id/set-default')
@RequirePermissions(P.matches)
async setDefaultMarketTemplate(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
) {
const template = await this.markets.setDefaultTemplate(BigInt(id));
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.SET_DEFAULT_MARKET_TEMPLATE,
targetId: id,
afterData: { name: template.name },
});
return jsonResponse(template);
}
@Post('matches/:id/markets/templates')
@RequirePermissions(P.matches)
async generateTemplates(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: MarketTemplatesDto,
) {
const markets = await this.markets.generateTemplates(BigInt(id), dto.marketTypes);
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.GENERATE_MATCH_MARKETS,
targetId: id,
afterData: { marketTypes: dto.marketTypes, ...markets },
});
return jsonResponse(markets);
}
@Post('matches/:id/markets/apply-template')
@RequirePermissions(P.matches)
async applyMarketTemplate(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: ApplyMarketTemplateDto,
) {
const result = await this.markets.applyTemplateToMatch(
BigInt(id),
dto.templateId ? BigInt(dto.templateId) : null,
);
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.APPLY_MARKET_TEMPLATE,
targetId: id,
afterData: { requestedTemplateId: dto.templateId, ...result },
});
return jsonResponse(result);
}
@Put('matches/:id/markets/bulk')
@RequirePermissions(P.matches)
async bulkSaveMatchMarkets(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: BulkMatchMarketsDto,
) {
const result = await this.markets.saveMatchMarkets(BigInt(id), dto.markets, operatorId);
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.BULK_SAVE_MATCH_MARKETS,
targetId: id,
afterData: { ...result, marketCount: dto.markets.length },
});
return jsonResponse(result);
}
@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);
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.UPDATE_MATCH_ODDS,
targetId: id,
afterData: summarizeOddsUpdates(updates.map((u) => u.selectionId)),
});
return jsonResponse({ matchId: id, updated: results.length });
}
@Patch('markets/:id')
@RequirePermissions(P.matches)
async updateMarket(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() dto: UpdateMarketDto,
) {
const market = await this.markets.updateMarket(BigInt(id), {
promoLabel: dto.promoLabel,
promoLabelI18n: dto.promoLabelI18n,
nameI18n: dto.nameI18n,
status: dto.status,
lineValue: dto.lineValue,
showOnPlayer: dto.showOnPlayer,
});
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.UPDATE_MARKET,
targetId: id,
afterData: {
matchId: market.matchId.toString(),
status: market.status,
lineValue: market.lineValue,
showOnPlayer: market.showOnPlayer,
},
});
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,
nameI18n: dto.nameI18n,
odds: dto.odds,
status: dto.status,
},
operatorId,
);
const market = await this.prisma.market.findUnique({
where: { id: selection.marketId },
select: { matchId: true },
});
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.UPDATE_SELECTION,
targetId: id,
afterData: {
matchId: market?.matchId?.toString(),
odds: dto.odds,
status: dto.status,
},
});
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);
const market = await this.prisma.market.findUnique({
where: { id: selection.marketId },
select: { matchId: true },
});
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.UPDATE_MATCH_ODDS,
targetId: market?.matchId ?? id,
afterData: { selectionId: id, odds: dto.odds },
});
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(
@CurrentUser('id') operatorId: bigint,
@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,
});
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.CREATE_OUTRIGHT,
targetId: data.id,
afterData: { leagueId: dto.leagueId, status: dto.status, titleZh: dto.titleZh },
});
return jsonResponse(data);
}
@Post('outrights/import/wc2026')
@RequirePermissions(P.matches)
async importWc2026Outright(@CurrentUser('id') operatorId: bigint) {
const data = await this.outright.importWc2026Canonical();
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.IMPORT_WC2026_OUTRIGHT,
afterData: data,
});
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');
const data = await this.outright.batchUpdateOdds(BigInt(wc.id), dto.updates, operatorId);
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.UPDATE_OUTRIGHT_ODDS,
targetId: wc.id,
afterData: summarizeOddsUpdates(dto.updates.map((u) => u.selectionId)),
});
return jsonResponse(data);
}
/** @deprecated */
@Post('outrights/wc2026/apply-canonical')
@RequirePermissions(P.matches)
async applyWc2026CanonicalLegacy(@CurrentUser('id') operatorId: bigint) {
const data = await this.outright.importWc2026Canonical();
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.IMPORT_WC2026_OUTRIGHT,
afterData: data,
});
return jsonResponse(data);
}
@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(
@CurrentUser('id') operatorId: bigint,
@Param('matchId') matchId: string,
@Body() dto: UpdateOutrightDto,
) {
const data = await this.outright.updateForAdmin(BigInt(matchId), dto);
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.UPDATE_OUTRIGHT,
targetId: matchId,
afterData: { status: dto.status, matchName: dto.matchName, isHot: dto.isHot },
});
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,
);
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.UPDATE_OUTRIGHT_ODDS,
targetId: matchId,
afterData: summarizeOddsUpdates(dto.updates.map((u) => u.selectionId)),
});
return jsonResponse(data);
}
@Post('outrights/:matchId/selections')
@RequirePermissions(P.matches)
async addOutrightSelection(
@CurrentUser('id') operatorId: bigint,
@Param('matchId') matchId: string,
@Body() dto: AddOutrightSelectionDto,
) {
const data = await this.outright.addSelection(BigInt(matchId), dto);
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.ADD_OUTRIGHT_SELECTION,
targetId: matchId,
afterData: dto,
});
return jsonResponse(data);
}
@Post('outrights/:matchId/selections/batch')
@RequirePermissions(P.matches)
async addOutrightSelectionsBatch(
@CurrentUser('id') operatorId: bigint,
@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,
);
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.BATCH_ADD_OUTRIGHT_SELECTIONS,
targetId: matchId,
afterData: { count: dto.items.length },
});
return jsonResponse(data);
}
@Patch('outrights/:matchId/selections/:selectionId')
@RequirePermissions(P.matches)
async updateOutrightSelectionTeam(
@CurrentUser('id') operatorId: bigint,
@Param('matchId') matchId: string,
@Param('selectionId') selectionId: string,
@Body() dto: UpdateOutrightSelectionTeamDto,
) {
const data = await this.outright.updateSelectionTeam(
BigInt(matchId),
BigInt(selectionId),
dto,
);
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.UPDATE_OUTRIGHT_SELECTION,
targetId: selectionId,
afterData: { matchId, ...dto },
});
return jsonResponse(data);
}
@Delete('outrights/:matchId/selections/:selectionId')
@RequirePermissions(P.matches)
async removeOutrightSelection(
@CurrentUser('id') operatorId: bigint,
@Param('matchId') matchId: string,
@Param('selectionId') selectionId: string,
) {
const data = await this.outright.closeSelection(
BigInt(matchId),
BigInt(selectionId),
);
await logCatalogAudit(this.audit, {
operatorId,
action: CatalogAuditAction.REMOVE_OUTRIGHT_SELECTION,
targetId: selectionId,
afterData: { matchId },
});
return jsonResponse(data);
}
@Get('matches/:id/settlement/summary')
@RequirePermissions(P.settlement, P.reports)
async getMatchSettlementSummary(@Param('id') id: string) {
const data = await this.settlement.getMatchBetStatsSummary(BigInt(id));
return jsonResponse(data);
}
@Get('matches/:id/settlement/bets')
@RequirePermissions(P.settlement, P.reports)
async getMatchSettlementBets(
@Param('id') id: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const data = await this.settlement.getMatchBetStatsBets(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);
}
@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,
{
homeCorners: dto.homeCorners ?? null,
awayCorners: dto.awayCorners ?? null,
homeYellowCards: dto.homeYellowCards ?? null,
awayYellowCards: dto.awayYellowCards ?? null,
homeRedCards: dto.homeRedCards ?? null,
awayRedCards: dto.awayRedCards ?? null,
homeCards: dto.homeCards ?? null,
awayCards: dto.awayCards ?? null,
},
);
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 preview = await this.settlement.previewSettlement(matchId, operatorId, {
htHome: dto?.htHome,
htAway: dto?.htAway,
ftHome: dto?.ftHome,
ftAway: dto?.ftAway,
homeCorners: dto?.homeCorners,
awayCorners: dto?.awayCorners,
homeYellowCards: dto?.homeYellowCards,
awayYellowCards: dto?.awayYellowCards,
homeRedCards: dto?.homeRedCards,
awayRedCards: dto?.awayRedCards,
homeCards: dto?.homeCards,
awayCards: dto?.awayCards,
winnerTeamId: dto?.winnerTeamId != null ? BigInt(dto.winnerTeamId) : undefined,
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,
homeCorners: dto.homeCorners ?? null,
awayCorners: dto.awayCorners ?? null,
homeYellowCards: dto.homeYellowCards ?? null,
awayYellowCards: dto.awayYellowCards ?? null,
homeRedCards: dto.homeRedCards ?? null,
awayRedCards: dto.awayRedCards ?? null,
homeCards: dto.homeCards ?? null,
awayCards: dto.awayCards ?? null,
},
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);
}
@Get('settings/cashback/platform-direct')
@RequirePermissions(P.cashback, P.reports)
async getPlatformDirectCashbackSettings() {
const settings = await this.systemConfig.getPlatformDirectCashbackSettings();
return jsonResponse(settings);
}
@Put('settings/cashback/platform-direct')
@RequirePermissions(P.cashback)
async updatePlatformDirectCashbackSettings(@Body() dto: PlatformDirectCashbackSettingsDto) {
const settings = await this.systemConfig.updatePlatformDirectCashbackSettings(dto);
return jsonResponse(settings);
}
@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/inbox-notify-settings')
@RequirePermissions(P.content, P.reports)
async getInboxNotifySettings() {
const settings = await this.systemConfig.getInboxNotifySettings();
return jsonResponse(settings);
}
@Put('contents/inbox-notify-settings')
@RequirePermissions(P.content)
async updateInboxNotifySettings(@Body() dto: InboxNotifySettingsDto) {
const settings = await this.systemConfig.updateInboxNotifySettings(dto);
return jsonResponse(settings);
}
@Get('contents')
@RequirePermissions(P.content, P.reports)
async listContents(
@Query('type') type?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const p = Math.max(1, page ? parseInt(page, 10) || 1 : 1);
const size = Math.min(Math.max(1, pageSize ? parseInt(pageSize, 10) : 20), 100);
const result = await this.content.listForAdmin(type, status, p, size);
return jsonResponse(result);
}
@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 { notifyInbox, ...createDto } = dto;
const item = await this.content.create(createDto);
let notifiedCount: number | undefined;
if (notifyInbox && (createDto.status ?? 'DRAFT') === 'ACTIVE') {
const inboxEnabled = await this.systemConfig.getInboxFeatureEnabled();
if (inboxEnabled) {
const translations = createDto.translations.map((tr) => ({
locale: tr.locale,
title: tr.title,
body: tr.body,
}));
if (createDto.contentType === 'BANNER') {
notifiedCount = await this.playerMessages.broadcastBannerPromotion({
contentId: BigInt(item.id),
translations,
});
} else if (
createDto.contentType === 'NOTICE' ||
createDto.contentType === 'TICKER'
) {
notifiedCount = await this.playerMessages.broadcastAnnouncementPromotion({
contentId: BigInt(item.id),
translations,
});
}
}
}
return jsonResponse({ ...item, notifiedCount });
}
@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('system/smoke-tests')
@RequirePermissions(P.settings)
getSmokeTestsStatus() {
return jsonResponse({ allowed: this.smokeTests.isAllowed() });
}
@Get('smoke-tests/suites')
@RequirePermissions(P.settings)
async smokeTestSuites() {
this.smokeTests.assertAllowed();
return jsonResponse({
suites: this.smokeTests.listSuites(),
cases: this.smokeTests.listCases(),
lastRun: this.smokeTests.getLastRun(),
});
}
@Get('smoke-tests/last-run')
@RequirePermissions(P.settings)
async smokeTestLastRun() {
this.smokeTests.assertAllowed();
return jsonResponse(this.smokeTests.getLastRun());
}
@Post('smoke-tests/run')
@RequirePermissions(P.settings)
async runSmokeTests(
@CurrentUser('id') operatorId: bigint,
@Body() body: { suites?: string[] },
) {
this.smokeTests.assertAllowed();
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(
@CurrentUser('id') viewerId: bigint,
@CurrentUser('role') viewerRole: string | undefined,
@CurrentUser('userType') viewerUserType: string,
@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: module || undefined,
excludeModule: CATALOG_AUDIT_MODULE,
},
{ viewerId, viewerRole, viewerUserType },
);
return jsonResponse(result);
}
@Get('catalog-audit-logs')
@RequirePermissions(P.matches)
async catalogAuditLogs(
@CurrentUser('id') viewerId: bigint,
@CurrentUser('role') viewerRole: string | undefined,
@CurrentUser('userType') viewerUserType: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const result = await this.audit.list(
page ? parseInt(page, 10) : 1,
pageSize ? parseInt(pageSize, 10) : 10,
{ module: CATALOG_AUDIT_MODULE },
{ viewerId, viewerRole, viewerUserType },
);
return jsonResponse(result);
}
// ============ Payment Methods ============
@Post('payment-methods')
@RequirePermissions(P.depositManage)
async createPaymentMethod(
@CurrentUser('id') operatorId: bigint,
@Body() body: {
methodType: string;
bankName?: string;
accountHolder?: string;
accountNumber?: string;
usdtAddress?: string;
qrCodeUrl?: string;
displayName?: string;
sortOrder?: number;
isActive?: boolean;
showOnPlayer?: boolean;
translations?: {
displayName?: Record<string, string>;
bankName?: Record<string, string>;
};
},
) {
if (!body.methodType || !['BANK', 'USDT'].includes(body.methodType)) {
throw appBadRequest('INVALID_METHOD_TYPE');
}
const method = await this.depositService.createPaymentMethod({
...body,
createdBy: operatorId,
});
return jsonResponse(method);
}
@Get('payment-methods')
@RequirePermissions(P.depositManage)
async listPaymentMethods(@Query('methodType') methodType?: string) {
const items = await this.depositService.listPaymentMethods({
methodType: methodType || undefined,
});
return jsonResponse(items);
}
@Put('payment-methods/:id')
@RequirePermissions(P.depositManage)
async updatePaymentMethod(
@Param('id') id: string,
@Body() body: {
bankName?: string;
accountHolder?: string;
accountNumber?: string;
usdtAddress?: string;
qrCodeUrl?: string;
displayName?: string;
sortOrder?: number;
isActive?: boolean;
showOnPlayer?: boolean;
translations?: {
displayName?: Record<string, string>;
bankName?: Record<string, string>;
};
},
) {
const method = await this.depositService.updatePaymentMethod(BigInt(id), body);
return jsonResponse(method);
}
@Delete('payment-methods/:id')
@RequirePermissions(P.depositManage)
async deletePaymentMethod(@Param('id') id: string) {
await this.depositService.deletePaymentMethod(BigInt(id));
return jsonResponse({ success: true });
}
// ============ Deposit Orders (Admin Review) ============
@Get('deposit-orders')
@RequirePermissions(P.depositReview)
async listDepositOrders(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('status') status?: string,
@Query('keyword') keyword?: string,
@Query('methodType') methodType?: string,
@Query('dateFrom') dateFrom?: string,
@Query('dateTo') dateTo?: string,
) {
const result = await this.depositService.listDepositOrders({
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
status: status || undefined,
keyword: keyword || undefined,
methodType: methodType || undefined,
dateFrom: dateFrom ? new Date(dateFrom) : undefined,
dateTo: dateTo ? new Date(dateTo) : undefined,
});
return jsonResponse(result);
}
@Get('deposit-orders/pending-count')
@RequirePermissions(P.depositReview)
async depositPendingCount() {
const count = await this.depositService.countPendingDepositOrders();
return jsonResponse({ count });
}
@Get('deposit-orders/:id/audit-logs')
@RequirePermissions(P.depositReview)
async depositOrderAuditLogs(@Param('id') id: string) {
const items = await this.depositService.getDepositOrderAuditLogs(BigInt(id));
return jsonResponse({ items });
}
@Post('deposit-orders/:id/approve')
@RequirePermissions(P.depositReview)
async approveDepositOrder(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() body: { approvedAmount?: number; remark?: string },
) {
const result = await this.depositService.approveDepositOrder(
BigInt(id),
operatorId,
body.approvedAmount,
body.remark,
);
return jsonResponse(result);
}
@Post('deposit-orders/:id/reject')
@RequirePermissions(P.depositReview)
async rejectDepositOrder(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
@Body() body: { reason: string },
) {
if (!body.reason?.trim()) throw appBadRequest('REASON_REQUIRED');
const result = await this.depositService.rejectDepositOrder(
BigInt(id),
operatorId,
body.reason.trim(),
);
return jsonResponse(result);
}
@Post('deposit-orders/:id/reopen')
@RequirePermissions(P.depositReview)
async reopenDepositOrder(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
) {
const result = await this.depositService.reopenDepositOrderForReview(
BigInt(id),
operatorId,
);
return jsonResponse(result);
}
@Delete('deposit-orders/:id')
@RequirePermissions(P.depositReview)
async deleteDepositOrder(
@CurrentUser('id') operatorId: bigint,
@Param('id') id: string,
) {
const result = await this.depositService.deleteDepositOrder(
BigInt(id),
operatorId,
);
return jsonResponse(result);
}
}