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

@@ -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;