sync(theme-4): 同步 main 最新 API/Admin 与玩家端逻辑

从 main 同步站内信手动发送、媒体库选择、列表子路由重构等 API/Admin 改动;玩家端仅合并 ADMIN_CUSTOM 富文本、消息预览与充值截图压缩逻辑,保留 theme-4 样式。
This commit is contained in:
2026-06-22 14:12:40 +08:00
parent 5fbb1fd1f6
commit cffad00652
50 changed files with 4654 additions and 1186 deletions

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