From d49c81212d87a65f14181fa9c6ff9d32a0473ad6 Mon Sep 17 00:00:00 2001 From: bboysoul Date: Wed, 9 Sep 2026 21:34:49 +0800 Subject: [PATCH] feat: permanently delete archived lists --- backend/main.py | 253 ++++++++- backend/models.py | 10 + frontend/src/App.vue | 68 ++- frontend/src/lib/list-purge.test.ts | 15 + frontend/src/lib/list-purge.ts | 6 + frontend/src/style.css | 2 +- frontend/src/style.test.ts | 28 + migrations/versions/0015_purge_operations.py | 36 ++ tests/test_list_purge.py | 545 +++++++++++++++++++ 9 files changed, 957 insertions(+), 6 deletions(-) create mode 100644 frontend/src/lib/list-purge.test.ts create mode 100644 frontend/src/lib/list-purge.ts create mode 100644 migrations/versions/0015_purge_operations.py create mode 100644 tests/test_list_purge.py diff --git a/backend/main.py b/backend/main.py index dc2e846..8711a46 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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() diff --git a/backend/models.py b/backend/models.py index 877967c..7298977 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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__ = ( diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 03c35ee..2525730 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -10,6 +10,7 @@ import { formatApiErrorDetail, isTaskView, nextTotalAfterLocalTaskAdd, normalize import { csrfHeader } from './lib/csrf' import { createCompletionPulse } from './lib/completion-motion' import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag' +import { nextDialogFocusIndex } from './lib/list-purge' import MvpPanel from './MvpPanel.vue' import CountdownPanel from './CountdownPanel.vue' import FloatingAddButton from './components/FloatingAddButton.vue' @@ -30,6 +31,12 @@ const password = ref('') const folders = ref([]) const lists = ref([]) const archivedLists = ref([]) +const purgeListTarget = ref(null) +const purgeListSubmitting = ref(false) +const purgeListError = ref('') +const purgeCancelButton = ref(null) +const purgeListDialog = ref(null) +let purgeListTrigger: HTMLElement | null = null const tasks = ref([]) const overdueTasks = ref([]) const trash = ref([]) @@ -779,7 +786,19 @@ async function deleteEntity(kind: 'folders' | 'lists', item: FolderItem | TaskLi if (kind === 'lists') { const answer = await askText(`归档清单「${item.name}」?`, '', '', '归档') if (answer === null) return - try { await api(`/${kind}/${item.id}`, { method: 'DELETE' }); await loadArchivedLists(); await refreshAll(); toast('清单已归档') } catch (reason) { fail(reason) } + try { + const wasCurrentList = activeView.value === 'tasks' && activeList.value === item.id + await api(`/${kind}/${item.id}`, { method: 'DELETE' }) + await loadArchivedLists() + await refreshAll() + if (wasCurrentList) { + selectedTask.value = null + mobileDetail.value = false + const inboxId = lists.value.find((list) => list.is_inbox)?.id || '' + await switchView('tasks', inboxId) + } + toast('清单已归档') + } catch (reason) { fail(reason) } return } const answer = await askText(`删除文件夹「${item.name}」?`, '', '', '删除') @@ -792,6 +811,43 @@ async function loadArchivedLists() { async function restoreList(item: TaskList) { try { await api(`/lists/${item.id}/restore`, { method: 'POST' }); await loadArchivedLists(); await refreshAll(); toast('清单已恢复') } catch (reason) { fail(reason) } } +function openPurgeList(item: TaskList, trigger?: EventTarget | null) { + purgeListTarget.value = item + purgeListError.value = '' + purgeListTrigger = trigger instanceof HTMLElement ? trigger : document.activeElement as HTMLElement | null + nextTick(() => purgeCancelButton.value?.focus()) +} +function closePurgeList() { + if (purgeListSubmitting.value) return + purgeListTarget.value = null + purgeListError.value = '' + nextTick(() => purgeListTrigger?.focus()) +} +function handlePurgeDialogKeydown(event: KeyboardEvent) { + if (event.key === 'Escape' && !purgeListSubmitting.value) closePurgeList() + if (event.key !== 'Tab' || !purgeListDialog.value) return + const controls = [...purgeListDialog.value.querySelectorAll('button:not(:disabled)')] + if (!controls.length) return + const activeIndex = controls.indexOf(document.activeElement as HTMLElement) + const nextIndex = nextDialogFocusIndex(activeIndex, controls.length, event.shiftKey) + if (nextIndex !== null) { event.preventDefault(); controls[nextIndex].focus() } +} +async function confirmPurgeList() { + if (!purgeListTarget.value || purgeListSubmitting.value) return + purgeListSubmitting.value = true + purgeListError.value = '' + try { + const purgedId = purgeListTarget.value.id + await api(`/lists/${purgeListTarget.value.id}/purge`, { method: 'DELETE' }) + archivedLists.value = archivedLists.value.filter((list) => list.id !== purgedId) + purgeListTarget.value = null + toast('清单已永久删除') + } catch (reason) { + purgeListError.value = reason instanceof Error ? reason.message : '永久删除失败' + } finally { + purgeListSubmitting.value = false + } +} function toggleSidebarCreate() { sidebarCreateOpen.value = !sidebarCreateOpen.value; sidebarAction.value = null } function openSidebarAction(kind: 'folders' | 'lists', item: FolderItem | TaskList) { sidebarAction.value = sidebarAction.value?.item.id === item.id ? null : { kind, item } @@ -984,7 +1040,7 @@ onMounted(bootstrap)