feat(admin+api): 列表页子路由重构、结算历史与充值截图清理

赛事/代理管理从表格展开改为子路由详情页;结算页增加预览弹窗与历史记录;媒体库支持截图存储统计与自动/手动清理;Player 端充值截图压缩为 WebP 300KB。
This commit is contained in:
2026-06-22 12:28:25 +08:00
parent 499522f3fb
commit 648c314e23
38 changed files with 3298 additions and 1147 deletions

View File

@@ -15,10 +15,12 @@ import {
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { randomUUID } from 'crypto';
import { mkdir, writeFile, unlink } from 'fs/promises';
import { mkdir, writeFile, unlink, readdir, stat } from 'fs/promises';
import { existsSync } from 'fs';
import { extname, join } from 'path';
import { JwtAuthGuard, AdminGuard, PermissionsGuard } from '../../domains/identity/guards';
import { ContentService } from '../../domains/operations/content/content.service';
import { DepositScreenshotCleanupService } from '../../domains/deposit/deposit-screenshot-cleanup.service';
import { CurrentUser, RequirePermissions } from '../../shared/common/decorators';
import { jsonResponse } from '../../shared/common/filters';
import { appBadRequest, appForbidden } from '../../shared/common/app-error';
@@ -1109,6 +1111,17 @@ class InboxNotifySettingsDto {
deposit?: boolean;
}
class UpdateDepositCleanupConfigDto {
@IsOptional()
@IsBoolean()
enabled?: boolean;
@IsOptional()
@IsNumber()
@Min(1)
keepDays?: number;
}
class CashbackPreviewDto {
@IsString()
periodStart!: string;
@@ -1236,6 +1249,7 @@ export class AdminController {
private playerMessages: PlayerMessagesService,
private staff: AdminStaffService,
private presence: PresenceService,
private depositCleanup: DepositScreenshotCleanupService,
) {}
@Get('presence/online-count')
@@ -1997,6 +2011,8 @@ export class AdminController {
@Query('pageSize') pageSize?: string,
@Query('hasBets') hasBets?: string,
@Query('orderBy') orderBy?: string,
@Query('startFrom') startFrom?: string,
@Query('startTo') startTo?: string,
) {
const result = await this.matches.listAdminLeagueMatches(BigInt(leagueId), {
status: status || undefined,
@@ -2006,6 +2022,8 @@ export class AdminController {
pageSize: pageSize ? Math.min(100, Math.max(1, parseInt(pageSize, 10) || 20)) : 20,
hasBets: hasBets || undefined,
orderBy: orderBy || undefined,
startFrom: startFrom ? new Date(startFrom) : undefined,
startTo: startTo ? new Date(startTo) : undefined,
});
return jsonResponse(result);
}
@@ -2843,6 +2861,29 @@ export class AdminController {
return jsonResponse(preview);
}
@Get('matches/:id/settlement/preview')
@RequirePermissions(P.settlement, P.reports)
async getActiveSettlementPreview(
@Param('id') id: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const matchId = BigInt(id);
const preview = await this.settlement.getActivePreview(matchId, {
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(preview);
}
@Get('matches/:id/settlement/history')
@RequirePermissions(P.settlement, P.reports)
async getMatchSettlementHistory(@Param('id') id: string) {
const matchId = BigInt(id);
const history = await this.settlement.getMatchSettlementHistory(matchId);
return jsonResponse(history);
}
@Get('settlement/:batchId/preview-items')
@RequirePermissions(P.settlement)
async getSettlementPreviewItems(
@@ -3158,6 +3199,103 @@ export class AdminController {
return urls;
}
@Get('files/storage-stats')
@RequirePermissions(P.content)
async getStorageStats() {
const statsGroup = await this.prisma.uploadedFile.groupBy({
by: ['category'],
_count: { _all: true },
_sum: { size: true }
});
const categories = statsGroup.map((g) => ({
category: g.category,
count: g._count._all,
sizeBytes: g._sum.size ?? 0,
}));
// Calculate deposits on disk
let depositCount = 0;
let depositSizeBytes = 0;
const depositsDir = join(getUploadRoot(), 'deposits');
try {
const files = await readdir(depositsDir);
for (const file of files) {
const filePath = join(depositsDir, file);
try {
const fileStats = await stat(filePath);
if (fileStats.isFile()) {
depositCount++;
depositSizeBytes += fileStats.size;
}
} catch {}
}
} catch {}
categories.push({
category: 'deposits',
count: depositCount,
sizeBytes: depositSizeBytes,
});
const totalCount = categories.reduce((sum, c) => sum + c.count, 0);
const totalSizeBytes = categories.reduce((sum, c) => sum + c.sizeBytes, 0);
return jsonResponse({
categories,
total: {
count: totalCount,
sizeBytes: totalSizeBytes,
},
});
}
@Get('deposits/screenshot-cleanup-config')
@RequirePermissions(P.content)
async getScreenshotCleanupConfig() {
const config = await this.systemConfig.getDepositScreenshotCleanupConfig();
return jsonResponse(config);
}
@Put('deposits/screenshot-cleanup-config')
@RequirePermissions(P.content)
async updateScreenshotCleanupConfig(@Body() body: UpdateDepositCleanupConfigDto) {
const config = await this.systemConfig.updateDepositScreenshotCleanupConfig(body);
return jsonResponse(config);
}
@Delete('deposits/screenshots')
@RequirePermissions(P.content)
async cleanOldScreenshots(
@CurrentUser('id') operatorId: bigint,
@Query('before') beforeStr?: string,
) {
if (!beforeStr) throw appBadRequest('BEFORE_DATE_REQUIRED');
const beforeDate = new Date(beforeStr);
if (Number.isNaN(beforeDate.getTime())) {
throw appBadRequest('INVALID_BEFORE_DATE');
}
if (beforeDate.getTime() > Date.now()) {
throw appBadRequest('BEFORE_DATE_CANNOT_BE_FUTURE');
}
const result = await this.depositCleanup.cleanOldDepositScreenshots(beforeDate);
await this.audit.log({
operatorId,
operatorType: 'ADMIN',
action: 'PURGE_DEPOSIT_SCREENSHOTS',
module: 'MEDIA',
afterData: JSON.stringify({
before: beforeDate.toISOString(),
cleanedCount: result.cleaned,
freedBytes: result.freedBytes,
}),
});
return jsonResponse(result);
}
@Get('contents/inbox-notify-settings')
@RequirePermissions(P.content, P.reports)
async getInboxNotifySettings() {

View File

@@ -558,6 +558,8 @@ export class MatchesService {
pageSize?: number;
hasBets?: string;
orderBy?: string;
startFrom?: Date;
startTo?: Date;
},
) {
const where: Prisma.MatchWhereInput = {
@@ -566,6 +568,11 @@ export class MatchesService {
isOutright: false,
};
if (opts.status) where.status = opts.status;
if (opts.startFrom || opts.startTo) {
where.startTime = {};
if (opts.startFrom) where.startTime.gte = opts.startFrom;
if (opts.startTo) where.startTime.lte = opts.startTo;
}
const kw = opts.keyword?.trim();
if (kw) {
where.OR = [
@@ -626,6 +633,10 @@ export class MatchesService {
const stakeB = parseFloat(b.totalStake);
return stakeB - stakeA;
});
} else if (opts.orderBy === 'kickoffAsc') {
filteredItems.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
} else if (opts.orderBy === 'kickoffDesc') {
filteredItems.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime());
} else {
filteredItems.sort((a, b) => {
if (a.displayOrder !== b.displayOrder) {
@@ -640,12 +651,19 @@ export class MatchesService {
return { items: paginatedItems, total, page, pageSize };
}
const orderBy =
opts.orderBy === 'kickoffAsc'
? [{ startTime: 'asc' as const }, { displayOrder: 'asc' as const }]
: opts.orderBy === 'kickoffDesc'
? [{ startTime: 'desc' as const }, { displayOrder: 'asc' as const }]
: [{ displayOrder: 'asc' as const }, { startTime: 'desc' as const }];
const [total, rows] = await Promise.all([
this.prisma.match.count({ where }),
this.prisma.match.findMany({
where,
include: { homeTeam: true, awayTeam: true },
orderBy: [{ displayOrder: 'asc' }, { startTime: 'desc' }],
orderBy,
skip: (page - 1) * pageSize,
take: pageSize,
}),
@@ -865,9 +883,32 @@ export class MatchesService {
async getAdminMatchDetail(matchId: bigint) {
const match = await this.requireAdminMatch(matchId);
const scoreRow = await this.prisma.matchScore.findUnique({
let scoreRow = await this.prisma.matchScore.findUnique({
where: { matchId },
});
if (!scoreRow) {
const previewBatch = await this.prisma.settlementBatch.findFirst({
where: { matchId, status: 'PREVIEW' },
orderBy: { createdAt: 'desc' },
});
if (previewBatch) {
scoreRow = {
htHomeScore: previewBatch.htHomeScore,
htAwayScore: previewBatch.htAwayScore,
ftHomeScore: previewBatch.ftHomeScore,
ftAwayScore: previewBatch.ftAwayScore,
homeCorners: previewBatch.homeCorners,
awayCorners: previewBatch.awayCorners,
homeYellowCards: previewBatch.homeYellowCards,
awayYellowCards: previewBatch.awayYellowCards,
homeRedCards: previewBatch.homeRedCards,
awayRedCards: previewBatch.awayRedCards,
homeCards: previewBatch.homeCards,
awayCards: previewBatch.awayCards,
winnerTeamId: null,
} as any;
}
}
const markets = await this.prisma.market.findMany({
where: { matchId },
include: { selections: { orderBy: { sortOrder: 'asc' } } },

View File

@@ -0,0 +1,121 @@
import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { PrismaService } from '../../shared/prisma/prisma.service';
import { SystemConfigService } from '../../shared/config/system-config.service';
import { getUploadRoot } from '../../shared/uploads/upload-paths';
import { join, dirname } from 'path';
import { existsSync } from 'fs';
import { mkdir, stat, unlink, writeFile } from 'fs/promises';
@Injectable()
export class DepositScreenshotCleanupService implements OnModuleInit {
private readonly logger = new Logger(DepositScreenshotCleanupService.name);
constructor(
private prisma: PrismaService,
private systemConfigService: SystemConfigService,
) {}
async onModuleInit() {
await this.ensureExpiredPlaceholderExists();
}
/**
* 确保已过期/已清理的截图占位图存在
*/
async ensureExpiredPlaceholderExists() {
// 1x1 像素透明 PNG
const defaultExpiredPngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
const root = getUploadRoot();
const expiredPath = join(root, 'defaults', 'expired.png');
try {
await mkdir(dirname(expiredPath), { recursive: true });
// 强制写入以确保生成最新的透明 1x1 占位图
await writeFile(expiredPath, Buffer.from(defaultExpiredPngBase64, 'base64'));
this.logger.log('Ensured expired screenshot default placeholder (transparent 1x1).');
} catch (err) {
this.logger.error('Failed to create default expired screenshot placeholder', err);
}
}
/**
* 每日凌晨3点定时执行清理
*/
@Cron('0 0 3 * * *')
async handleScheduledCleanup() {
this.logger.log('Scheduled deposit screenshot cleanup job started');
try {
const config = await this.systemConfigService.getDepositScreenshotCleanupConfig();
if (!config.enabled) {
this.logger.log('Scheduled cleanup is disabled, skipping');
return;
}
const beforeDate = new Date();
beforeDate.setDate(beforeDate.getDate() - config.keepDays);
this.logger.log(`Cleaning deposit screenshots older than ${config.keepDays} days (before ${beforeDate.toISOString()})`);
const result = await this.cleanOldDepositScreenshots(beforeDate);
this.logger.log(`Scheduled cleanup completed. Cleaned: ${result.cleaned} screenshots, Freed: ${(result.freedBytes / 1024 / 1024).toFixed(2)} MB`);
} catch (err) {
this.logger.error('Scheduled cleanup failed', err);
}
}
/**
* 清理指定日期之前的已处理充值订单截图
*/
async cleanOldDepositScreenshots(before: Date): Promise<{ cleaned: number; freedBytes: number }> {
const orders = await this.prisma.depositOrder.findMany({
where: {
createdAt: { lt: before },
status: { in: ['APPROVED', 'REJECTED'] },
screenshotUrl: {
startsWith: '/uploads/',
not: { startsWith: '/uploads/defaults/' },
},
},
select: {
id: true,
screenshotUrl: true,
},
});
let cleaned = 0;
let freedBytes = 0;
const batchSize = 100;
const root = getUploadRoot();
for (let i = 0; i < orders.length; i += batchSize) {
const chunk = orders.slice(i, i + batchSize);
await Promise.all(
chunk.map(async (order) => {
const url = order.screenshotUrl;
if (!url.startsWith('/uploads/')) return;
const relative = url.slice('/uploads/'.length);
// 安全路径校验,防止目录穿越
if (!relative || relative.includes('..') || relative.includes('\\')) return;
const filePath = join(root, relative);
let size = 0;
try {
const fileStats = await stat(filePath);
size = fileStats.size;
await unlink(filePath);
freedBytes += size;
} catch {
// 文件不存在或已被删除,静默跳过,但依然更新数据库
}
await this.prisma.depositOrder.update({
where: { id: order.id },
data: { screenshotUrl: '/uploads/defaults/expired.png' },
});
cleaned++;
})
);
}
return { cleaned, freedBytes };
}
}

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { DepositService } from './deposit.service';
import { DepositScreenshotCleanupService } from './deposit-screenshot-cleanup.service';
import { WalletModule } from '../ledger/wallet.module';
import { AgentsModule } from '../agent/agents.module';
import { PlayerMessagesModule } from '../player-messages/player-messages.module';
@@ -7,7 +8,7 @@ import { SystemConfigModule } from '../../shared/config/system-config.module';
@Module({
imports: [WalletModule, AgentsModule, PlayerMessagesModule, SystemConfigModule],
providers: [DepositService],
exports: [DepositService],
providers: [DepositService, DepositScreenshotCleanupService],
exports: [DepositService, DepositScreenshotCleanupService],
})
export class DepositModule {}

View File

@@ -388,6 +388,83 @@ export class SettlementService {
};
}
async getActivePreview(
matchId: bigint,
opts?: { page?: number; pageSize?: number },
) {
const batch = await this.prisma.settlementBatch.findFirst({
where: { matchId, status: 'PREVIEW' },
orderBy: { createdAt: 'desc' },
});
if (!batch) return null;
const existingScore = await this.prisma.matchScore.findUnique({
where: { matchId },
});
const computation = await this.computePreviewComputation(matchId, {
htHome: batch.htHomeScore ?? 0,
htAway: batch.htAwayScore ?? 0,
ftHome: batch.ftHomeScore ?? 0,
ftAway: batch.ftAwayScore ?? 0,
homeCorners: batch.homeCorners ?? null,
awayCorners: batch.awayCorners ?? null,
homeYellowCards: batch.homeYellowCards ?? null,
awayYellowCards: batch.awayYellowCards ?? null,
homeRedCards: batch.homeRedCards ?? null,
awayRedCards: batch.awayRedCards ?? null,
homeCards: batch.homeCards ?? null,
awayCards: batch.awayCards ?? null,
winnerTeamId: existingScore?.winnerTeamId ?? null,
});
return this.buildPreviewResponse(computation, batch, opts);
}
async getMatchSettlementHistory(matchId: bigint) {
const batches = await this.prisma.settlementBatch.findMany({
where: { matchId, status: 'CONFIRMED' },
orderBy: { confirmedAt: 'desc' },
});
const operatorIds = batches
.map((b) => b.operatorId)
.filter((id): id is bigint => id !== null);
const operators =
operatorIds.length > 0
? await this.prisma.user.findMany({
where: { id: { in: operatorIds } },
select: { id: true, username: true },
})
: [];
const operatorMap = new Map(operators.map((o) => [o.id.toString(), o.username]));
return batches.map((b) => ({
id: b.id.toString(),
batchNo: b.batchNo,
htHomeScore: b.htHomeScore,
htAwayScore: b.htAwayScore,
ftHomeScore: b.ftHomeScore,
ftAwayScore: b.ftAwayScore,
homeCorners: b.homeCorners,
awayCorners: b.awayCorners,
homeYellowCards: b.homeYellowCards,
awayYellowCards: b.awayYellowCards,
homeRedCards: b.homeRedCards,
awayRedCards: b.awayRedCards,
homeCards: b.homeCards,
awayCards: b.awayCards,
totalBets: b.totalBets,
totalPayout: b.totalPayout.toString(),
totalRefund: b.totalRefund.toString(),
confirmedAt: b.confirmedAt?.toISOString() ?? null,
isResettle: b.isResettle,
reason: b.reason,
operatorUsername: b.operatorId ? operatorMap.get(b.operatorId.toString()) ?? '—' : '—',
}));
}
private buildPreviewResponse(
computation: {
scoreInput: ScoreInput;

View File

@@ -263,4 +263,31 @@ export class SystemConfigService {
}
return this.getInboxNotifySettings();
}
async getDepositScreenshotCleanupConfig(): Promise<{ enabled: boolean; keepDays: number }> {
const enabled = await this.getBoolean('deposit.cleanup.enabled', false);
const keepDays = await this.getInt('deposit.cleanup.keep_days', 180);
return { enabled, keepDays };
}
async updateDepositScreenshotCleanupConfig(data: { enabled?: boolean; keepDays?: number }) {
if (data.enabled !== undefined) {
await this.setBoolean(
'deposit.cleanup.enabled',
data.enabled,
'是否开启定时清理充值截图',
);
}
if (data.keepDays !== undefined) {
if (!Number.isInteger(data.keepDays) || data.keepDays <= 0) {
throw new Error('keepDays must be a positive integer');
}
await this.setInt(
'deposit.cleanup.keep_days',
data.keepDays,
'充值截图保留天数',
);
}
return this.getDepositScreenshotCleanupConfig();
}
}