sync(theme-4): 同步 main 最新 API/Admin 与玩家端逻辑
从 main 同步站内信手动发送、媒体库选择、列表子路由重构等 API/Admin 改动;玩家端仅合并 ADMIN_CUSTOM 富文本、消息预览与充值截图压缩逻辑,保留 theme-4 样式。
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user