diff --git a/app/api/documents.py b/app/api/documents.py index 55dda6c..2d5ddde 100644 --- a/app/api/documents.py +++ b/app/api/documents.py @@ -1433,18 +1433,24 @@ async def delete_document( doc_id: int, user: Annotated[User, Depends(get_current_user)], session: Annotated[AsyncSession, Depends(get_session)], - delete_file: bool = Query(False, description="NAS 파일도 함께 삭제"), + delete_file: bool = Query(False, description="NAS 원본도 삭제 (grace 후 retention sweep 이 물리삭제)"), ): - """문서 삭제 (기본: DB만 삭제, 파일 유지)""" - doc = await session.get(Document, doc_id) - if not doc: - raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다") + """문서 삭제. 기본: soft-delete(숨김, 파일 보존). delete_file=true: purge 예약 (R7).""" + doc = await get_live_document(session, doc_id) - # soft-delete (물리 파일은 cleanup job에서 나중에 정리) - doc.deleted_at = datetime.now(timezone.utc) + # soft-delete(숨김). delete_file=true 면 purge_requested_at 마커를 추가로 set — + # retention sweep cron(document_purge_sweep)이 grace(30일) 경과 후 NAS 원본 물리삭제 + # + audit-log. ★일반 숨김(delete_file=false)은 파일 보존 = undelete 가능. sweep 는 + # deleted_at 이 아니라 purge_requested_at 기준이라 단순 숨김이 영구삭제되지 않는다. + now = datetime.now(timezone.utc) + doc.deleted_at = now + if delete_file: + doc.purge_requested_at = now await session.commit() - return {"message": f"문서 {doc_id} soft-delete 완료"} + if delete_file: + return {"message": f"문서 {doc_id} 삭제 — NAS 원본은 30일 후 정리 예약"} + return {"message": f"문서 {doc_id} soft-delete 완료 (파일 보존)"} @router.get("/{doc_id}/content") diff --git a/app/main.py b/app/main.py index 3c240ac..9710ae2 100644 --- a/app/main.py +++ b/app/main.py @@ -51,6 +51,7 @@ async def lifespan(app: FastAPI): from workers.briefing_worker import run as morning_briefing_run from workers.daily_digest import run as daily_digest_run from workers.dedup_reconcile import run as dedup_reconcile_run + from workers.document_purge_sweep import run as purge_sweep_run from workers.digest_worker import run as global_digest_run from workers.file_watcher import watch_inbox from workers.mailplus_archive import run as mailplus_run @@ -150,6 +151,9 @@ async def lifespan(app: FastAPI): # plan ds-s1-backend-1 B-4: dedup 컬럼(duplicate_of/duplicate_count) 야간 절대 재계산. # soft-delete 잔여 드리프트 정리(멱등, 드리프트 없으면 no-op). cron 03:30 (다른 잡과 비충돌). scheduler.add_job(dedup_reconcile_run, CronTrigger(hour=3, minute=30, timezone=KST), id="dedup_reconcile") + # R7: delete_file=true purge 요청 문서의 NAS 원본 grace(30일) 후 물리삭제 + audit. + # purge_requested_at 마커 기준(단순 숨김은 보존). 03:20 = 다른 새벽 잡과 비충돌 슬롯. + scheduler.add_job(purge_sweep_run, CronTrigger(hour=3, minute=20, timezone=KST), id="purge_sweep") # B-3 PR4: 레거시 paper 행 arXiv DataCite DOI 스탬프(재유입 차단). keyless·in-DB·enqueue 0. # dedup_reconcile(03:30)·fulltext_reconcile(03:40) 와 별 worker·비충돌 슬롯. scheduler.add_job(paper_doi_reconcile_run, CronTrigger(hour=3, minute=50, timezone=KST), id="paper_doi_reconcile") diff --git a/app/models/document.py b/app/models/document.py index f5b0abf..9fdacf4 100644 --- a/app/models/document.py +++ b/app/models/document.py @@ -105,6 +105,9 @@ class Document(Base): # 승인/삭제 review_status: Mapped[str | None] = mapped_column(String(20), default="pending") deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # delete_file=true 명시 삭제 요청 마커 (R7) — retention sweep(document_purge_sweep)이 + # grace 후 NAS 원본 물리삭제. deleted_at(단순 숨김, 파일 보존)과 분리. + purge_requested_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # 외부 편집 URL edit_url: Mapped[str | None] = mapped_column(Text) diff --git a/app/workers/document_purge_sweep.py b/app/workers/document_purge_sweep.py new file mode 100644 index 0000000..77dc473 --- /dev/null +++ b/app/workers/document_purge_sweep.py @@ -0,0 +1,65 @@ +"""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 diff --git a/migrations/359_documents_purge_requested_at.sql b/migrations/359_documents_purge_requested_at.sql new file mode 100644 index 0000000..0fb9d6d --- /dev/null +++ b/migrations/359_documents_purge_requested_at.sql @@ -0,0 +1,6 @@ +-- 359: delete_file=true 명시 삭제 요청 마커 (R7 delete_file 큐드삭제). +-- retention sweep(document_purge_sweep) 이 이 컬럼 + grace(30일) 기준으로 NAS 원본을 +-- 물리삭제한다. deleted_at(단순 숨김)과 분리 — 숨김(delete_file=false)은 파일 보존(undelete +-- 가능). sweep 가 deleted_at 기준이면 모든 숨김이 30일 후 물리삭제되는 데이터 손실이 되므로 +-- 명시 purge 요청만 대상으로 한다. +ALTER TABLE documents ADD COLUMN IF NOT EXISTS purge_requested_at TIMESTAMPTZ;