feat: add smoke tests, agent credit ledger, and player cashback page

Introduce admin smoke-test suite with API probes, agent credit transaction history, and player cashback records; fix SmokeTestModule DI and polish admin/player UI assets.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-09 16:05:48 +08:00
parent 9c6c5e51f3
commit d5e7c8edb3
52 changed files with 3357 additions and 67 deletions

View File

@@ -2,6 +2,7 @@ import {
BadRequestException,
Controller,
Delete,
ForbiddenException,
Get,
Post,
Put,
@@ -9,13 +10,20 @@ import {
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 } 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 { 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';
@@ -33,6 +41,7 @@ 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,
@@ -47,6 +56,77 @@ import {
} 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 new BadRequestException('Unsupported upload category');
}
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 new ForbiddenException('Insufficient permissions');
}
}
function assertImageFile(file: UploadedImage | undefined): asserts file is UploadedImage {
if (!file?.buffer?.length) {
throw new BadRequestException('Image file is required');
}
if (!IMAGE_MIME_EXT[file.mimetype]) {
throw new BadRequestException('Only PNG, JPG, WEBP, GIF or SVG images are allowed');
}
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 new BadRequestException('Unsafe SVG content is not allowed');
}
}
}
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;
@@ -746,6 +826,7 @@ export class AdminController {
private systemConfig: SystemConfigService,
private bettingLimits: BettingLimitsService,
private databaseReset: DatabaseResetService,
private smokeTests: SmokeTestService,
) {}
@Get('dashboard')
@@ -985,6 +1066,25 @@ export class AdminController {
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('transactionType') transactionType?: 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,
transactionType,
});
return jsonResponse(result);
}
@Get('agents/:id')
@RequirePermissions(P.agentsView)
async getAgentDetail(@Param('id') id: string) {
@@ -1731,6 +1831,33 @@ export class AdminController {
return jsonResponse(result);
}
@Post('uploads')
@RequirePermissions(P.content, P.matches)
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
async uploadAsset(
@CurrentUser() user: AdminUploadUser,
@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);
return jsonResponse({
category,
filename,
size: file.size,
mimeType: file.mimetype,
url: `/uploads/${category}/${filename}`,
});
}
@Get('contents')
@RequirePermissions(P.content, P.reports)
async listContents(
@@ -1786,6 +1913,45 @@ export class AdminController {
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(

View File

@@ -13,6 +13,7 @@ import { ContentModule } from '../../domains/operations/content/content.module';
import { I18nModule } from '../../domains/operations/i18n/i18n.module';
import { BetsModule } from '../../domains/betting/bets.module';
import { DatabaseModule } from '../../infrastructure/database/database.module';
import { SmokeTestModule } from '../../domains/operations/smoke-tests/smoke-test.module';
@Module({
imports: [
@@ -27,6 +28,7 @@ import { DatabaseModule } from '../../infrastructure/database/database.module';
I18nModule,
BetsModule,
DatabaseModule,
SmokeTestModule,
],
controllers: [AdminController],
providers: [AdminDashboardService, PermissionsGuard],