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:
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user