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>
2226 lines
56 KiB
TypeScript
2226 lines
56 KiB
TypeScript
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);
|
||
}
|
||
}
|