feat(documents): delete_file=true 큐드-감사삭제 — purge 마커 + retention sweep (R7)

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>
This commit is contained in:
hyungi
2026-06-16 13:48:25 +09:00
parent 8a625bfb27
commit 3a22d225a0
5 changed files with 92 additions and 8 deletions
+14 -8
View File
@@ -1433,18 +1433,24 @@ async def delete_document(
doc_id: int, doc_id: int,
user: Annotated[User, Depends(get_current_user)], user: Annotated[User, Depends(get_current_user)],
session: Annotated[AsyncSession, Depends(get_session)], session: Annotated[AsyncSession, Depends(get_session)],
delete_file: bool = Query(False, description="NAS 파일도 함께 삭제"), delete_file: bool = Query(False, description="NAS 원본도 삭제 (grace 후 retention sweep 이 물리삭제)"),
): ):
"""문서 삭제 (기본: DB만 삭제, 파일 유지)""" """문서 삭제. 기본: soft-delete(숨김, 파일 보존). delete_file=true: purge 예약 (R7)."""
doc = await session.get(Document, doc_id) doc = await get_live_document(session, doc_id)
if not doc:
raise HTTPException(status_code=404, detail="문서를 찾을 수 없습니다")
# soft-delete (물리 파일은 cleanup job에서 나중에 정리) # soft-delete(숨김). delete_file=true 면 purge_requested_at 마커를 추가로 set —
doc.deleted_at = datetime.now(timezone.utc) # 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() 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") @router.get("/{doc_id}/content")
+4
View File
@@ -51,6 +51,7 @@ async def lifespan(app: FastAPI):
from workers.briefing_worker import run as morning_briefing_run from workers.briefing_worker import run as morning_briefing_run
from workers.daily_digest import run as daily_digest_run from workers.daily_digest import run as daily_digest_run
from workers.dedup_reconcile import run as dedup_reconcile_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.digest_worker import run as global_digest_run
from workers.file_watcher import watch_inbox from workers.file_watcher import watch_inbox
from workers.mailplus_archive import run as mailplus_run 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) 야간 절대 재계산. # plan ds-s1-backend-1 B-4: dedup 컬럼(duplicate_of/duplicate_count) 야간 절대 재계산.
# soft-delete 잔여 드리프트 정리(멱등, 드리프트 없으면 no-op). cron 03:30 (다른 잡과 비충돌). # soft-delete 잔여 드리프트 정리(멱등, 드리프트 없으면 no-op). cron 03:30 (다른 잡과 비충돌).
scheduler.add_job(dedup_reconcile_run, CronTrigger(hour=3, minute=30, timezone=KST), id="dedup_reconcile") 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. # B-3 PR4: 레거시 paper 행 arXiv DataCite DOI 스탬프(재유입 차단). keyless·in-DB·enqueue 0.
# dedup_reconcile(03:30)·fulltext_reconcile(03:40) 와 별 worker·비충돌 슬롯. # 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") scheduler.add_job(paper_doi_reconcile_run, CronTrigger(hour=3, minute=50, timezone=KST), id="paper_doi_reconcile")
+3
View File
@@ -105,6 +105,9 @@ class Document(Base):
# 승인/삭제 # 승인/삭제
review_status: Mapped[str | None] = mapped_column(String(20), default="pending") review_status: Mapped[str | None] = mapped_column(String(20), default="pending")
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) 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 # 외부 편집 URL
edit_url: Mapped[str | None] = mapped_column(Text) edit_url: Mapped[str | None] = mapped_column(Text)
+65
View File
@@ -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
@@ -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;