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:
+14
-8
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user