3a22d225a0
delete_file 파라미터가 광고만 하고 본문에서 0회 참조(soft-delete만, 파일 영구 잔존 + 프론트가 실제 호출)되던 거짓 계약 구현. (c) 큐드삭제: - 마이그 359: documents.purge_requested_at 컬럼(ADD COLUMN IF NOT EXISTS, replayable). - delete_document: delete_file=true 시 purge_requested_at 마커 set(deleted_at 과 별도). - document_purge_sweep cron(03:20 KST): purge_requested_at + grace(30일) 경과 + 파일 존재 시 NAS 원본 unlink + AUDIT 로그. ★sweep 는 deleted_at 아니라 purge_requested_at 기준 — 일반 숨김(delete_file=false)은 파일 보존(undelete 가능), 명시 purge 만 물리삭제(데이터 안전). - DELETE 요청 경로엔 동기 비가역 op 0. 파일 존재 체크로 멱등. unlink 는 to_thread(R5 일관). 검증: py_compile 통과. migration txn 제어문 없음. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
66 lines
2.4 KiB
Python
66 lines
2.4 KiB
Python
"""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
|