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 = { '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(' !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 }, ) { 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 }; 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 }) { 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> { 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(); 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); } }