Files
fa-agent/finance/management/commands/cleanup_formula_exports.py
T

105 lines
4.3 KiB
Python

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