feat: permanently delete archived lists
This commit is contained in:
+250
-3
@@ -1,11 +1,14 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
from pathlib import Path, PureWindowsPath
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
|
||||
from fastapi.openapi.docs import get_swagger_ui_html
|
||||
@@ -26,7 +29,19 @@ from .auth import (
|
||||
verify_password,
|
||||
)
|
||||
from .db import create_schema, get_db
|
||||
from .models import AppState, Folder, RecurrenceTemplate, Session, Task, TaskList, User, utcnow
|
||||
from .models import (
|
||||
AppState,
|
||||
Attachment,
|
||||
Folder,
|
||||
PurgeOperation,
|
||||
RecurrenceException,
|
||||
RecurrenceTemplate,
|
||||
Session,
|
||||
Task,
|
||||
TaskList,
|
||||
User,
|
||||
utcnow,
|
||||
)
|
||||
from .mvp import audit, occurrences
|
||||
from .mvp import router as mvp_router
|
||||
from .schemas import (
|
||||
@@ -99,6 +114,7 @@ async def openapi(_: User = Depends(current_user)):
|
||||
|
||||
|
||||
app.include_router(mvp_router)
|
||||
logger = logging.getLogger(__name__)
|
||||
_login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque)
|
||||
|
||||
|
||||
@@ -569,6 +585,237 @@ async def restore_list(
|
||||
return item
|
||||
|
||||
|
||||
def _attachment_path(attachment_root: Path, storage_name: str) -> Path:
|
||||
if not storage_name or "\x00" in storage_name:
|
||||
raise ValueError(storage_name)
|
||||
windows_path = PureWindowsPath(storage_name)
|
||||
if windows_path.is_absolute() or windows_path.drive:
|
||||
raise ValueError(storage_name)
|
||||
parts = storage_name.replace("\\", "/").split("/")
|
||||
if any(part in {"", ".", ".."} for part in parts):
|
||||
raise ValueError(storage_name)
|
||||
relative_path = Path(*parts)
|
||||
resolved_path = (attachment_root / relative_path).resolve()
|
||||
try:
|
||||
resolved_path.relative_to(attachment_root)
|
||||
except ValueError as exc:
|
||||
raise ValueError(storage_name) from exc
|
||||
return resolved_path
|
||||
|
||||
|
||||
def _create_unique_quarantine(attachment_root: Path) -> tuple[UUID, Path]:
|
||||
while True:
|
||||
operation_id = uuid4()
|
||||
trash_dir = attachment_root / ".purge-trash" / str(operation_id)
|
||||
try:
|
||||
trash_dir.mkdir(parents=True, exist_ok=False)
|
||||
except FileExistsError:
|
||||
continue
|
||||
return operation_id, trash_dir
|
||||
|
||||
|
||||
def _remove_owned_quarantine(trash_dir: Path) -> None:
|
||||
shutil.rmtree(trash_dir)
|
||||
try:
|
||||
trash_dir.parent.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _restore_quarantined_files(
|
||||
moved_files: list[tuple[Path, Path]], *, list_id: UUID, operation_id: UUID
|
||||
) -> list[str]:
|
||||
restore_errors: list[str] = []
|
||||
for source_path, trash_path in reversed(moved_files):
|
||||
try:
|
||||
source_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.replace(trash_path, source_path)
|
||||
except OSError:
|
||||
restore_errors.append(str(trash_path))
|
||||
logger.exception(
|
||||
"List purge attachment restore failed",
|
||||
extra={
|
||||
"list_id": str(list_id),
|
||||
"operation_id": str(operation_id),
|
||||
"trash_path": str(trash_path),
|
||||
},
|
||||
)
|
||||
return restore_errors
|
||||
|
||||
|
||||
async def _retry_purge_cleanup(db: AsyncSession, operation: PurgeOperation) -> Response:
|
||||
trash_dir = Path(operation.trash_dir)
|
||||
try:
|
||||
if trash_dir.exists():
|
||||
_remove_owned_quarantine(trash_dir)
|
||||
except OSError as exc:
|
||||
logger.exception(
|
||||
"List purge retry cleanup failed: %s",
|
||||
trash_dir,
|
||||
extra={
|
||||
"list_id": str(operation.list_id),
|
||||
"operation_id": str(operation.id),
|
||||
"trash_path": str(trash_dir),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
"清单数据已删除,但附件清理未完成,"
|
||||
f"操作ID: {operation.id}, 隔离路径: {trash_dir}"
|
||||
),
|
||||
) from exc
|
||||
await db.delete(operation)
|
||||
await db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@app.delete("/api/v1/lists/{list_id}/purge", status_code=204)
|
||||
async def purge_list(
|
||||
list_id: UUID,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
from .config import get_settings
|
||||
|
||||
item = await db.scalar(
|
||||
select(TaskList)
|
||||
.where(TaskList.id == list_id, TaskList.user_id == user.id)
|
||||
.with_for_update()
|
||||
)
|
||||
if item is None:
|
||||
pending_operation = await db.scalar(
|
||||
select(PurgeOperation)
|
||||
.where(PurgeOperation.list_id == list_id, PurgeOperation.user_id == user.id)
|
||||
.with_for_update()
|
||||
)
|
||||
if pending_operation is not None:
|
||||
return await _retry_purge_cleanup(db, pending_operation)
|
||||
raise HTTPException(status_code=404, detail="清单不存在")
|
||||
if item.is_inbox:
|
||||
raise HTTPException(status_code=409, detail="系统收集箱不能永久删除")
|
||||
if item.deleted_at is None:
|
||||
raise HTTPException(status_code=409, detail="请先归档再永久删除")
|
||||
|
||||
task_ids = select(Task.id).where(Task.list_id == item.id)
|
||||
template_ids = select(RecurrenceTemplate.id).where(RecurrenceTemplate.task_id.in_(task_ids))
|
||||
storage_names = list(
|
||||
(await db.scalars(select(Attachment.storage_name).where(Attachment.task_id.in_(task_ids)))).all()
|
||||
)
|
||||
attachment_root = Path(get_settings().attachment_dir).resolve()
|
||||
try:
|
||||
attachment_paths = [_attachment_path(attachment_root, name) for name in storage_names]
|
||||
except ValueError as exc:
|
||||
await db.rollback()
|
||||
logger.error(
|
||||
"List purge rejected unsafe attachment path",
|
||||
extra={"list_id": str(list_id), "storage_name": str(exc)},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=409, detail="附件存储路径无效,无法永久删除清单"
|
||||
) from exc
|
||||
|
||||
moved_files: list[tuple[Path, Path]] = []
|
||||
owns_trash_dir = False
|
||||
operation_id = uuid4()
|
||||
trash_dir = attachment_root / ".purge-trash" / str(operation_id)
|
||||
try:
|
||||
operation_id, trash_dir = _create_unique_quarantine(attachment_root)
|
||||
owns_trash_dir = True
|
||||
for source_path in attachment_paths:
|
||||
if not source_path.is_file():
|
||||
continue
|
||||
trash_path = trash_dir / source_path.relative_to(attachment_root)
|
||||
trash_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.replace(source_path, trash_path)
|
||||
moved_files.append((source_path, trash_path))
|
||||
except OSError as exc:
|
||||
restore_errors = _restore_quarantined_files(
|
||||
moved_files, list_id=list_id, operation_id=operation_id
|
||||
)
|
||||
if owns_trash_dir and not restore_errors:
|
||||
_remove_owned_quarantine(trash_dir)
|
||||
await db.rollback()
|
||||
if restore_errors:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
"附件隔离失败且部分附件恢复失败,"
|
||||
f"操作ID: {operation_id}, 隔离路径: {trash_dir}, "
|
||||
f"未恢复: {', '.join(restore_errors)}"
|
||||
),
|
||||
) from exc
|
||||
raise HTTPException(status_code=500, detail="附件隔离失败,清单未删除") from exc
|
||||
|
||||
operation = PurgeOperation(
|
||||
id=operation_id,
|
||||
list_id=list_id,
|
||||
user_id=user.id,
|
||||
trash_dir=str(trash_dir),
|
||||
status="cleanup_pending",
|
||||
)
|
||||
try:
|
||||
await db.execute(
|
||||
delete(RecurrenceException).where(RecurrenceException.template_id.in_(template_ids))
|
||||
)
|
||||
await db.execute(delete(RecurrenceTemplate).where(RecurrenceTemplate.task_id.in_(task_ids)))
|
||||
await db.execute(delete(Attachment).where(Attachment.task_id.in_(task_ids)))
|
||||
await db.execute(delete(Task).where(Task.list_id == item.id))
|
||||
await db.delete(item)
|
||||
db.add(operation)
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
restore_errors = _restore_quarantined_files(
|
||||
moved_files, list_id=list_id, operation_id=operation_id
|
||||
)
|
||||
if not restore_errors:
|
||||
_remove_owned_quarantine(trash_dir)
|
||||
raise HTTPException(status_code=500, detail="数据库提交失败,清单未删除") from exc
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
"数据库提交失败且部分附件恢复失败,"
|
||||
f"操作ID: {operation_id}, 隔离路径: {trash_dir}, "
|
||||
f"未恢复: {', '.join(restore_errors)}"
|
||||
),
|
||||
) from exc
|
||||
|
||||
try:
|
||||
_remove_owned_quarantine(trash_dir)
|
||||
except OSError as exc:
|
||||
logger.exception(
|
||||
"List purge committed but quarantine cleanup failed: %s",
|
||||
trash_dir,
|
||||
extra={
|
||||
"list_id": str(list_id),
|
||||
"operation_id": str(operation_id),
|
||||
"trash_path": str(trash_dir),
|
||||
},
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
"清单数据已删除,但附件清理未完成,"
|
||||
f"操作ID: {operation_id}, 隔离路径: {trash_dir}"
|
||||
),
|
||||
) from exc
|
||||
|
||||
await db.delete(operation)
|
||||
try:
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
await db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
"清单数据及附件已删除,但清理操作状态未完成,"
|
||||
f"操作ID: {operation_id};请重试永久删除"
|
||||
),
|
||||
) from exc
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
|
||||
def _encode_cursor(position: int, created_at: datetime, task_id: UUID) -> str:
|
||||
raw = json.dumps([position, created_at.isoformat(), str(task_id)]).encode()
|
||||
|
||||
@@ -78,6 +78,16 @@ class TaskList(Base):
|
||||
|
||||
|
||||
|
||||
class PurgeOperation(Base):
|
||||
__tablename__ = "purge_operations"
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
list_id: Mapped[UUID] = mapped_column(unique=True, index=True)
|
||||
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
trash_dir: Mapped[str] = mapped_column(String(1024))
|
||||
status: Mapped[str] = mapped_column(String(32), default="cleanup_pending")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
class Task(Base):
|
||||
__tablename__ = "tasks"
|
||||
__table_args__ = (
|
||||
|
||||
Reference in New Issue
Block a user