"""delete_file=true 로 요청된 문서의 NAS 원본을 grace 후 물리삭제 (R7 retention sweep). purge_requested_at 마커 기준(deleted_at 아님 — 일반 soft-delete/숨김은 파일 보존, undelete 가능). grace(30일) 경과 + 파일 존재 시 unlink + AUDIT 로그. 파일 존재 체크로 멱등 (재실행 시 이미 삭제된 건 skip). 요청 경로(DELETE)엔 동기 비가역 op 0 — 모두 이 cron 으로. """ import asyncio import logging from datetime import datetime, timedelta, timezone from pathlib import Path from sqlalchemy import select from core.config import settings from core.database import async_session from models.document import Document logger = logging.getLogger("purge_sweep") PURGE_GRACE_DAYS = 30 def _unlink_if_exists(p: Path) -> bool: """파일이 있으면 unlink (blocking — caller 가 to_thread). 존재 여부 반환(멱등).""" if p.exists(): p.unlink() return True return False async def run() -> int: """purge 요청 + grace 경과 문서의 NAS 원본 물리삭제. 삭제 건수 반환.""" cutoff = datetime.now(timezone.utc) - timedelta(days=PURGE_GRACE_DAYS) async with async_session() as session: rows = ( await session.execute( select(Document.id, Document.file_path, Document.purge_requested_at).where( Document.purge_requested_at.is_not(None), Document.purge_requested_at < cutoff, Document.file_path.is_not(None), ) ) ).all() purged = 0 for doc_id, file_path, requested_at in rows: nas_path = Path(settings.nas_mount_path) / file_path try: existed = await asyncio.to_thread(_unlink_if_exists, nas_path) if existed: purged += 1 # AUDIT — 물리삭제 기록 (가시화). doc_id / 경로 / 요청일 / grace. logger.warning( "PURGE doc_id=%s file=%s requested_at=%s grace_days=%s", doc_id, file_path, requested_at.isoformat() if requested_at else None, PURGE_GRACE_DAYS, ) except OSError as e: logger.error("PURGE 실패 doc_id=%s file=%s: %s", doc_id, file_path, e) if purged: logger.info("[purge_sweep] NAS 원본 %d건 물리삭제 (grace %d일)", purged, PURGE_GRACE_DAYS) return purged