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