import time import uuid from datetime import timedelta from pathlib import Path from django.conf import settings from django.core.management.base import BaseCommand from django.db import transaction from django.utils import timezone from finance.models import FormulaExport ORPHAN_RETENTION_HOURS = 24 class Command(BaseCommand): help = "Remove expired formula workbooks and reclaim orphan files" def add_arguments(self, parser): parser.add_argument("--loop", action="store_true") parser.add_argument("--interval", type=int, default=3600) def handle(self, *args, **options): while True: jobs, files, orphans = self.clean_expired() self.stdout.write( f"Expired {jobs} jobs; removed {files} files; reclaimed {orphans} orphan files" ) if not options["loop"]: return time.sleep(max(60, options["interval"])) def clean_expired(self): export_root = Path(settings.MEDIA_ROOT) / "formula-exports" if export_root.is_symlink(): raise ValueError("公式导出目录不能是符号链接") root = export_root.resolve() now = timezone.now() jobs = files = orphans = 0 for job in FormulaExport.objects.filter(status="succeeded", expires_at__lte=now).iterator(): path = Path(job.path) if job.path else None resolved = path.resolve() if path and path.is_absolute() and not path.is_symlink() else None in_root = bool(resolved and resolved.is_relative_to(root)) try: with transaction.atomic(): if in_root and resolved.is_file(): resolved.unlink() files += 1 job.status, job.path = "expired", "" job.save(update_fields=["status", "path"]) jobs += 1 except OSError as exc: self.stderr.write(f"Could not remove expired export {job.pk}: {exc}") cutoff = (now - timedelta(hours=24)).timestamp() if root.is_dir() and not root.is_symlink(): for user_dir in root.iterdir(): if user_dir.is_symlink() or not user_dir.is_dir(): continue for path in user_dir.glob("*.tmp"): try: if path.is_symlink() or not path.is_file() or path.stat().st_mtime > cutoff: continue path.unlink() files += 1 except OSError as exc: self.stderr.write(f"Could not remove stale temporary export {path.name}: {exc}") orphans += self._reclaim_orphan_workbooks(user_dir, now) return jobs, files, orphans def _reclaim_orphan_workbooks(self, user_dir, now): """回收超过保留期且不再属于任何有效导出任务的 .xlsx 孤儿文件。 有效任务指状态 succeeded、路径一致且尚未过期的任务;失败任务残留的文件和任务行 已被删除(如数据集级联删除)的文件在保留期后回收,绝不触碰活动任务的文件。 """ retention = (now - timedelta(hours=ORPHAN_RETENTION_HOURS)).timestamp() removed = 0 for path in user_dir.glob("*.xlsx"): try: if path.is_symlink() or not path.is_file() or path.stat().st_mtime > retention: continue job_id = uuid.UUID(path.stem) except (OSError, ValueError): continue job = FormulaExport.objects.filter(pk=job_id).first() if ( job is not None and job.status == "succeeded" and job.path == str(path) and job.expires_at is not None and job.expires_at > now ): continue try: path.unlink() removed += 1 if job is not None and job.path == str(path): job.path = "" job.save(update_fields=["path"]) self.stdout.write(f"Reclaimed leftover workbook of job {job.pk}") except OSError as exc: self.stderr.write(f"Could not reclaim orphan export {path.stem}: {exc}") return removed