从 main 同步站内信手动发送、媒体库选择、列表子路由重构等 API/Admin 改动;玩家端仅合并 ADMIN_CUSTOM 富文本、消息预览与充值截图压缩逻辑,保留 theme-4 样式。
122 lines
4.2 KiB
TypeScript
122 lines
4.2 KiB
TypeScript
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 };
|
|
}
|
|
}
|