From 6c234d7d82c9458280fa819947d958e9053c8d3e Mon Sep 17 00:00:00 2001 From: bboysoul Date: Wed, 16 Sep 2026 21:12:52 +0800 Subject: [PATCH] feat: strengthen backup and mobile workflows --- .env.example | 12 + .gitignore | 1 + README.md | 50 +- backend/backup/__init__.py | 3 + backend/backup/archive.py | 195 +++++ backend/backup/legacy.py | 5 + backend/backup/router.py | 274 +++++++ backend/backup/schemas.py | 6 + backend/backup/service.py | 771 ++++++++++++++++++ backend/backup/storage.py | 52 ++ backend/config.py | 5 + backend/main.py | 2 + backend/models.py | 46 ++ backend/mvp.py | 77 +- docs/api.md | 94 ++- docs/data-model.md | 109 +-- docs/decisions.md | 133 +-- frontend/e2e/backup-roundtrip.spec.ts | 115 +++ frontend/e2e/fixtures.ts | 29 + frontend/e2e/global-setup.ts | 37 + frontend/e2e/mobile-journeys.spec.ts | 112 +++ frontend/e2e/mobile-ui.spec.ts | 118 +++ frontend/package.json | 2 +- frontend/playwright.config.ts | 41 + frontend/pnpm-lock.yaml | 36 + frontend/scripts/playwright-mobile-server.mjs | 57 ++ frontend/scripts/playwright-mobile.mjs | 15 + frontend/src/App.vue | 114 +-- frontend/src/CountdownPanel.test.ts | 139 +++- frontend/src/CountdownPanel.vue | 132 +-- frontend/src/MemoIntegration.test.ts | 13 +- frontend/src/MemoPanel.test.ts | 61 +- frontend/src/MemoPanel.vue | 24 +- frontend/src/MvpPanel.vue | 164 ++-- frontend/src/api/backups.test.ts | 70 ++ frontend/src/api/backups.ts | 28 + frontend/src/api/errors.ts | 31 + frontend/src/api/http.ts | 48 ++ frontend/src/api/index.ts | 3 + frontend/src/components/AppDialog.vue | 58 ++ frontend/src/components/AppSheet.test.ts | 262 ++++++ frontend/src/components/AppSheet.vue | 83 ++ .../CalendarPicker.integration.test.ts | 1 + frontend/src/components/CalendarPicker.vue | 2 +- frontend/src/components/MemoEditor.test.ts | 48 +- frontend/src/components/MemoEditor.vue | 24 +- frontend/src/composables/useOverlayStack.ts | 82 ++ .../src/lib/backup-preflight-state.test.ts | 39 + frontend/src/lib/backup-preflight-state.ts | 36 + frontend/src/lib/list-purge.test.ts | 15 - frontend/src/lib/list-purge.ts | 6 - frontend/src/memo.css | 4 +- frontend/src/style.css | 25 +- frontend/src/style.test.ts | 123 +-- migrations/versions/0019_backup_imports.py | 91 +++ tests/conftest.py | 13 +- tests/test_after_completion_recurrence.py | 4 +- tests/test_app.py | 5 +- tests/test_backup_blockers.py | 717 ++++++++++++++++ tests/test_backup_v2.py | 760 +++++++++++++++++ tests/test_countdowns.py | 14 +- tests/test_memos.py | 4 +- tests/test_migration_backup_imports.py | 46 ++ tests/test_mvp_backend.py | 8 +- 64 files changed, 5059 insertions(+), 635 deletions(-) create mode 100644 backend/backup/__init__.py create mode 100644 backend/backup/archive.py create mode 100644 backend/backup/legacy.py create mode 100644 backend/backup/router.py create mode 100644 backend/backup/schemas.py create mode 100644 backend/backup/service.py create mode 100644 backend/backup/storage.py create mode 100644 frontend/e2e/backup-roundtrip.spec.ts create mode 100644 frontend/e2e/fixtures.ts create mode 100644 frontend/e2e/global-setup.ts create mode 100644 frontend/e2e/mobile-journeys.spec.ts create mode 100644 frontend/e2e/mobile-ui.spec.ts create mode 100644 frontend/playwright.config.ts create mode 100644 frontend/scripts/playwright-mobile-server.mjs create mode 100644 frontend/scripts/playwright-mobile.mjs create mode 100644 frontend/src/api/backups.test.ts create mode 100644 frontend/src/api/backups.ts create mode 100644 frontend/src/api/errors.ts create mode 100644 frontend/src/api/http.ts create mode 100644 frontend/src/api/index.ts create mode 100644 frontend/src/components/AppDialog.vue create mode 100644 frontend/src/components/AppSheet.test.ts create mode 100644 frontend/src/components/AppSheet.vue create mode 100644 frontend/src/composables/useOverlayStack.ts create mode 100644 frontend/src/lib/backup-preflight-state.test.ts create mode 100644 frontend/src/lib/backup-preflight-state.ts delete mode 100644 frontend/src/lib/list-purge.test.ts delete mode 100644 frontend/src/lib/list-purge.ts create mode 100644 migrations/versions/0019_backup_imports.py create mode 100644 tests/test_backup_blockers.py create mode 100644 tests/test_backup_v2.py create mode 100644 tests/test_migration_backup_imports.py diff --git a/.env.example b/.env.example index 14279de..369698d 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,15 @@ DODO_DATABASE_URL=postgresql+asyncpg://postgres:change-me@postgres.example:5432/ DODO_COOKIE_SECURE=true DODO_SESSION_DAYS=30 DODO_TRUSTED_PROXIES=127.0.0.1 + +# Task attachment storage and per-file upload limit. +DODO_ATTACHMENT_DIR=./data/attachments +DODO_ATTACHMENT_MAX_MB=20 + +# Complete ZIP v2 backup limits and preflight staging. +# Keep the staging directory and attachment directory on storage with enough free space. +DODO_BACKUP_MAX_ARCHIVE_MB=256 +DODO_BACKUP_MAX_PENDING_PER_USER=3 +DODO_BACKUP_MAX_STAGED_MB_PER_USER=768 +DODO_BACKUP_PREFLIGHT_TTL_SECONDS=900 +DODO_BACKUP_STAGING_DIR=./data/backup-staging diff --git a/.gitignore b/.gitignore index f168ec5..b716c3d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ dist/ coverage/ playwright-report/ test-results/ +frontend/playwright-runtime/ .DS_Store *.db uploads/ diff --git a/README.md b/README.md index 3305599..523ee1e 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,32 @@ # dodo -一个自托管的任务与习惯管理工具,目标是做一个温暖、紧凑、可自己掌控数据的 TickTick-like 应用。 +一个自托管、移动端友好的任务与生活管理 PWA,数据由自己掌控。 -## 第一阶段能力 +## 当前主要能力 -- 首次初始化管理员 -- 用户名密码登录,Cookie Session -- 文件夹、清单、任务基础 CRUD -- 任务支持截止时间,以及每天 / 每周 / 每月 / 每年和自定义重复(间隔、星期、月日期、次数或截止日期) -- 收集箱系统清单 -- 习惯打卡与倒数纪念日 -- 倒数日支持倒数日、纪念日、生日,以及每周/月/年重复 -- Vue 3 + PWA 应用外壳 -- 手账生活感浅色 UI +- 文件夹、收集箱与自定义清单;清单归档后保留任务归属,恢复后原样可见 +- 任务与一层子任务、优先级、Markdown 备注、日期/时间、回收站、拖拽排序 +- RFC 5545 计划重复与“完成后重复”;乐观锁避免并发覆盖 +- 今日页按逾期任务、今日任务、今日习惯分组,并提供进度与环境信息 +- 完成型/数值型习惯、日/周/月/间隔计划、暂停、历史与归档 +- 倒数日、纪念日、生日及公历/农历重复 +- Markdown 备忘录及软删除/恢复 +- 任务附件、登录设备管理与审计日志 +- 完整 ZIP v2 备份(含附件字节、manifest 与 SHA-256)及预检后合并/替换恢复 +- 兼容旧版 CSV / JSON v1 恢复 + +## 界面约定 + +- 弹层统一使用 `AppSheet` / `AppDialog`,共享遮罩、焦点陷阱、Escape、背景 inert 和嵌套栈行为 +- 设置页按“数据、账户与安全、登录设备、活动、危险操作”连续分组 +- 手机底栏直接进入今天、习惯、倒数日、设置,不再使用“更多”中转 +- 新增入口是普通的圆形 Plus FAB,共用于任务、习惯、倒数日和备忘录 ## 技术栈 - Frontend: Vue 3 + TypeScript + Vite + Tailwind CSS -- Backend: FastAPI + SQLAlchemy 2 Async -- DB: PostgreSQL(测试环境使用 SQLite) +- Backend: FastAPI + Pydantic v2 + SQLAlchemy 2 Async + Alembic +- DB: PostgreSQL(测试使用 SQLite) - Package: uv + pnpm ## 本地开发 @@ -33,18 +41,16 @@ pnpm install pnpm run dev ``` -## 环境变量 - -```bash -DODO_DATABASE_URL=postgresql+asyncpg://user:pass@host:5432/dodo -DODO_COOKIE_SECURE=false -DODO_SESSION_DAYS=30 -``` +配置项见 [`.env.example`](.env.example),API 与数据合同见 [`docs/api.md`](docs/api.md) 和 [`docs/data-model.md`](docs/data-model.md)。 ## 验证 ```bash -uv run pytest -q uv run ruff check backend tests -cd frontend && pnpm run build +uv run pytest -q +cd frontend +pnpm test +pnpm build +cd .. +git diff --check ``` diff --git a/backend/backup/__init__.py b/backend/backup/__init__.py new file mode 100644 index 0000000..5bc0c2e --- /dev/null +++ b/backend/backup/__init__.py @@ -0,0 +1,3 @@ +from .router import router + +__all__ = ["router"] diff --git a/backend/backup/archive.py b/backend/backup/archive.py new file mode 100644 index 0000000..ca928d7 --- /dev/null +++ b/backend/backup/archive.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import hashlib +import json +import shutil +import tempfile +import zipfile +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from uuid import UUID + +from fastapi import HTTPException + +MAX_ARCHIVE_BYTES = 256 * 1024 * 1024 +MAX_ENTRIES = 10_000 +MAX_COMPRESSION_RATIO = 200 +MAX_METADATA_ENTRY_BYTES = 16 * 1024 * 1024 +_CHUNK_SIZE = 1024 * 1024 + + +@dataclass(frozen=True) +class StagedBlob: + archive_path: str + staging_path: Path + size: int + sha256: str + + +@dataclass(frozen=True) +class ParsedArchive: + backup_id: UUID + archive_sha256: str + entities: dict[str, list[dict]] + files: dict[str, StagedBlob] + staging_dir: Path + + + +def backup_error(code: str, message: str, status_code: int = 422) -> HTTPException: + return HTTPException(status_code, {"code": code, "message": message}) + + + +def canonical_json(value) -> bytes: + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode() + + + +def build_archive_to_path( + output_path: Path, backup_id: UUID, entities: dict[str, list[dict]], files: dict[str, Path] +) -> None: + entity_entries = {f"data/{name}.json": canonical_json(rows) for name, rows in entities.items()} + checksums = {name: hashlib.sha256(data).hexdigest() for name, data in entity_entries.items()} + for name, path in files.items(): + digest = hashlib.sha256() + with path.open("rb") as source: + while chunk := source.read(_CHUNK_SIZE): + digest.update(chunk) + checksums[name] = digest.hexdigest() + manifest = { + "format": "dodo-backup", "version": 2, "backup_id": str(backup_id), + "entities": {name: len(rows) for name, rows in entities.items()}, "checksums": checksums, + } + with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive: + archive.writestr("manifest.json", canonical_json(manifest)) + for name, data in entity_entries.items(): + archive.writestr(name, data) + for name, path in files.items(): + archive.write(path, name) + + + +def build_archive(backup_id: UUID, entities: dict[str, list[dict]], files: dict[str, bytes]) -> bytes: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + file_paths = {} + for index, (name, data) in enumerate(files.items()): + path = root / str(index) + path.write_bytes(data) + file_paths[name] = path + output = root / "backup.zip" + build_archive_to_path(output, backup_id, entities, file_paths) + return output.read_bytes() + + + +def _safe_name(name: str) -> bool: + path = PurePosixPath(name) + return bool(name) and not name.startswith("/") and "\\" not in name and ".." not in path.parts + + + +def _read_metadata(archive: zipfile.ZipFile, info: zipfile.ZipInfo) -> bytes: + if info.file_size > MAX_METADATA_ENTRY_BYTES: + raise backup_error("backup_size_invalid", "备份元数据过大") + with archive.open(info) as source: + data = source.read(MAX_METADATA_ENTRY_BYTES + 1) + if len(data) > MAX_METADATA_ENTRY_BYTES: + raise backup_error("backup_size_invalid", "备份元数据过大") + return data + + + +def _stream_blob(archive: zipfile.ZipFile, info: zipfile.ZipInfo, destination: Path) -> tuple[int, str]: + digest = hashlib.sha256() + size = 0 + with archive.open(info) as source, destination.open("xb") as output: + while chunk := source.read(_CHUNK_SIZE): + size += len(chunk) + digest.update(chunk) + output.write(chunk) + return size, digest.hexdigest() + + + +def parse_archive_path(path: Path, *, max_archive_bytes: int = MAX_ARCHIVE_BYTES) -> ParsedArchive: + size = path.stat().st_size + if size <= 0 or size > max_archive_bytes: + raise backup_error("backup_size_invalid", "备份文件大小无效") + digest = hashlib.sha256() + with path.open("rb") as source: + while chunk := source.read(_CHUNK_SIZE): + digest.update(chunk) + staging_dir = Path(tempfile.mkdtemp(prefix="dodo-backup-blobs-")) + try: + with zipfile.ZipFile(path) as archive: + infos = archive.infolist() + names = [item.filename for item in infos] + if len(infos) > MAX_ENTRIES: + raise backup_error("backup_too_many_entries", "备份条目过多") + if len(names) != len(set(names)): + raise backup_error("backup_duplicate_entry", "备份包含重复条目") + if any(not _safe_name(item.filename) or item.is_dir() or item.flag_bits & 1 for item in infos): + raise backup_error("backup_unsafe_path", "备份包含不安全路径") + if "manifest.json" not in names: + raise backup_error("backup_manifest_missing", "备份缺少 manifest") + if sum(item.file_size for item in infos) > max_archive_bytes: + raise backup_error("backup_size_invalid", "备份解压后过大") + if any(item.file_size and item.compress_size == 0 for item in infos): + raise backup_error("backup_compression_invalid", "备份压缩比异常") + if any(item.compress_size and item.file_size / item.compress_size > MAX_COMPRESSION_RATIO for item in infos): + raise backup_error("backup_compression_invalid", "备份压缩比异常") + by_name = {item.filename: item for item in infos} + manifest = json.loads(_read_metadata(archive, by_name["manifest.json"])) + if manifest.get("format") != "dodo-backup" or manifest.get("version") != 2: + raise backup_error("backup_version_unsupported", "不支持的备份版本") + backup_id = UUID(manifest["backup_id"]) + checksums = manifest["checksums"] + declared_entities = manifest["entities"] + if not isinstance(checksums, dict) or not isinstance(declared_entities, dict): + raise TypeError + content_names = set(names) - {"manifest.json"} + if set(checksums) != content_names: + raise backup_error("backup_manifest_mismatch", "manifest 与 ZIP 条目不一致") + entities: dict[str, list[dict]] = {} + files: dict[str, StagedBlob] = {} + for index, name in enumerate(sorted(content_names)): + info = by_name[name] + if name.startswith("data/") and name.endswith(".json"): + data = _read_metadata(archive, info) + actual_digest = hashlib.sha256(data).hexdigest() + value = json.loads(data) + if not isinstance(value, list) or any(not isinstance(row, dict) for row in value): + raise ValueError + entities[name[5:-5]] = value + else: + blob_path = staging_dir / str(index) + blob_size, actual_digest = _stream_blob(archive, info, blob_path) + files[name] = StagedBlob(name, blob_path, blob_size, actual_digest) + if not isinstance(checksums[name], str) or actual_digest != checksums[name]: + raise backup_error("backup_checksum_mismatch", "备份校验和不匹配") + if set(declared_entities) != set(entities): + raise backup_error("backup_manifest_mismatch", "manifest 实体清单不一致") + if any(type(count) is not int or count < 0 or count != len(entities[name]) for name, count in declared_entities.items()): + raise backup_error("backup_manifest_mismatch", "manifest 实体数量不一致") + except HTTPException: + shutil.rmtree(staging_dir, ignore_errors=True) + raise + except (zipfile.BadZipFile, OSError) as exc: + shutil.rmtree(staging_dir, ignore_errors=True) + raise backup_error("backup_invalid_zip", "无效的 ZIP 备份") from exc + except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc: + shutil.rmtree(staging_dir, ignore_errors=True) + raise backup_error("backup_manifest_invalid", "无效的备份 manifest 或数据") from exc + return ParsedArchive(backup_id, digest.hexdigest(), entities, files, staging_dir) + + + +def parse_archive(content: bytes) -> ParsedArchive: + if not content or len(content) > MAX_ARCHIVE_BYTES: + raise backup_error("backup_size_invalid", "备份文件大小无效") + with tempfile.NamedTemporaryFile() as staged: + staged.write(content) + staged.flush() + return parse_archive_path(Path(staged.name)) diff --git a/backend/backup/legacy.py b/backend/backup/legacy.py new file mode 100644 index 0000000..3858672 --- /dev/null +++ b/backend/backup/legacy.py @@ -0,0 +1,5 @@ +"""Legacy JSON/CSV backup compatibility remains in backend.mvp. + +The v2 ZIP implementation is isolated in this package. This marker module documents +that the old endpoints intentionally remain available during the gradual migration. +""" diff --git a/backend/backup/router.py b/backend/backup/router.py new file mode 100644 index 0000000..7de7c43 --- /dev/null +++ b/backend/backup/router.py @@ -0,0 +1,274 @@ +from __future__ import annotations + +import hashlib +import secrets +import shutil +from datetime import timedelta +from pathlib import Path + +from fastapi import APIRouter, Depends, File, Query, UploadFile +from fastapi.responses import FileResponse +from sqlalchemy import func, select, update +from sqlalchemy.ext.asyncio import AsyncSession +from starlette.background import BackgroundTask + +from backend.auth import current_user, hash_token +from backend.config import get_settings +from backend.db import get_db +from backend.models import BackupPreflight, Countdown, User, new_id, utcnow + +from .archive import MAX_ARCHIVE_BYTES as ARCHIVE_DEFAULT_LIMIT +from .archive import backup_error, parse_archive_path +from .schemas import RestoreRequest +from .service import BackupRepairPending, export_v2, restore_v2, validate_archive + +router = APIRouter(prefix="/api/v1/backup", tags=["backup"]) +MAX_ARCHIVE_BYTES = ARCHIVE_DEFAULT_LIMIT +MAX_PENDING_PREFLIGHTS_PER_USER = 3 +_READ_CHUNK = 1024 * 1024 + + +def _staging_root() -> Path: + root = Path(get_settings().backup_staging_dir).resolve() + root.mkdir(parents=True, exist_ok=True) + return root + + +def _safe_staging_path(value: str) -> Path: + root = _staging_root() + path = Path(value).resolve() + if path.parent != root: + raise backup_error("backup_preflight_invalid", "预检暂存无效", 409) + return path + + +def _path_size(path: str | None) -> int: + if not path: + return 0 + root = Path(path) + if not root.exists(): + return 0 + if root.is_file(): + return root.stat().st_size + return sum(item.stat().st_size for item in root.rglob("*") if item.is_file()) + + +async def _prune(db: AsyncSession) -> None: + now = utcnow() + rows = list((await db.scalars(select(BackupPreflight).where( + BackupPreflight.expires_at <= now, + BackupPreflight.status.in_(( + "reserved", "pending", "failed", "consuming", "cleanup_pending", + "repair_pending", + )), + ).with_for_update())).all()) + from .storage import remove_quarantine, restore_quarantine_dir + + attachment_root = Path(get_settings().attachment_dir).resolve() + for row in rows: + try: + if row.status == "repair_pending": + if row.cleanup_path: + restore_quarantine_dir(attachment_root, Path(row.cleanup_path)) + row.cleanup_path = None + _safe_staging_path(row.staging_path).unlink(missing_ok=True) + row.status = "failed" + continue + if row.cleanup_path: + remove_quarantine(Path(row.cleanup_path)) + row.cleanup_path = None + _safe_staging_path(row.staging_path).unlink(missing_ok=True) + except (OSError, ValueError): + continue + if row.status == "cleanup_pending": + row.status = "consumed" + row.consumed_at = row.consumed_at or now + else: + await db.delete(row) + if rows: + await db.commit() + + +@router.get("/export.zip") +async def export_zip(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): + path = await export_v2(db, user) + await db.commit() + return FileResponse( + path, + media_type="application/zip", + filename="dodo-backup-v2.zip", + background=BackgroundTask(path.unlink, missing_ok=True), + ) + + +@router.post("/preflight") +async def preflight( + mode: str = Query(pattern="^(merge|replace)$"), + file: UploadFile = File(...), + user: User = Depends(current_user), + db: AsyncSession = Depends(get_db), +): + await _prune(db) + settings = get_settings() + configured_limit = settings.backup_max_archive_mb * 1024 * 1024 + limit = min(MAX_ARCHIVE_BYTES, configured_limit) + max_pending = min(MAX_PENDING_PREFLIGHTS_PER_USER, settings.backup_max_pending_per_user) + max_staged = settings.backup_max_staged_mb_per_user * 1024 * 1024 + token = secrets.token_urlsafe(32) + staging = _staging_root() / f"{new_id()}.zip" + reservation = BackupPreflight( + token_hash=hash_token(token), user_id=user.id, backup_id=new_id(), + archive_sha256="0" * 64, archive_size=limit, staging_path=str(staging), mode=mode, + status="reserved", expires_at=utcnow() + timedelta(seconds=settings.backup_preflight_ttl_seconds), + ) + # A user-row write serializes quota decisions across workers on PostgreSQL; + # SQLite serializes writers at the database level. + await db.execute(update(User).where(User.id == user.id).values(username=User.username)) + pending_count, pending_bytes = (await db.execute(select( + func.count(BackupPreflight.id), func.coalesce(func.sum(BackupPreflight.archive_size), 0) + ).where( + BackupPreflight.user_id == user.id, + BackupPreflight.status.in_(( + "reserved", "pending", "failed", "consuming", "cleanup_pending", + "repair_pending", + )), + ))).one() + pending_rows = list((await db.scalars(select(BackupPreflight).where( + BackupPreflight.user_id == user.id, + BackupPreflight.status.in_(( + "reserved", "pending", "failed", "consuming", "cleanup_pending", + "repair_pending", + )), + ))).all()) + pending_bytes += sum(_path_size(item.cleanup_path) for item in pending_rows) + if pending_count >= max_pending or pending_bytes + limit > max_staged: + await db.rollback() + raise backup_error("backup_preflight_quota", "待处理预检配额已达上限", 429) + db.add(reservation) + await db.flush() + reservation_id = reservation.id + await db.commit() + + size = 0 + digest = hashlib.sha256() + archive = None + try: + with staging.open("xb") as output: + while chunk := await file.read(_READ_CHUNK): + size += len(chunk) + if size > limit: + raise backup_error("backup_size_invalid", "备份文件大小无效") + digest.update(chunk) + output.write(chunk) + archive = parse_archive_path(staging, max_archive_bytes=limit) + validate_archive(archive) + if mode == "merge": + incoming_pin_ids = { + str(item["id"]) + for item in archive.entities["countdowns"] + if item.get("pinned") is True and item.get("archived_at") is None + } + existing_pin_ids = set((await db.scalars(select(Countdown.id).where( + Countdown.user_id == user.id, + Countdown.pinned.is_(True), + Countdown.archived_at.is_(None), + ))).all()) + if ( + incoming_pin_ids + and existing_pin_ids + and incoming_pin_ids != {str(item) for item in existing_pin_ids} + ): + raise backup_error("backup_constraint_invalid", "合并恢复会产生多个置顶倒数日") + shutil.rmtree(archive.staging_dir, ignore_errors=True) + if archive.archive_sha256 != digest.hexdigest(): + raise backup_error("backup_checksum_mismatch", "备份校验和不匹配") + reservation.backup_id = archive.backup_id + reservation.archive_sha256 = archive.archive_sha256 + reservation.archive_size = size + reservation.status = "pending" + await db.commit() + except Exception: + await db.rollback() + if archive is not None: + shutil.rmtree(archive.staging_dir, ignore_errors=True) + failed = await db.get(BackupPreflight, reservation_id) + if failed is not None: + await db.delete(failed) + await db.commit() + staging.unlink(missing_ok=True) + raise + return {"valid": True, "preflight_token": token, "backup_id": archive.backup_id, + "archive_sha256": archive.archive_sha256, + "entities": {name: len(rows) for name, rows in archive.entities.items()}} + + +@router.post("/restore") +async def restore( + payload: RestoreRequest, + user: User = Depends(current_user), + db: AsyncSession = Depends(get_db), +): + await _prune(db) + token_hash = hash_token(payload.preflight_token) + row = await db.scalar(select(BackupPreflight).where( + BackupPreflight.token_hash == token_hash, BackupPreflight.user_id == user.id + ).with_for_update()) + if row is None or row.mode != payload.mode or row.expires_at <= utcnow(): + raise backup_error("backup_preflight_invalid", "预检令牌无效或已过期", 409) + if row.status == "repair_pending" and row.cleanup_path: + from .storage import restore_quarantine_dir + root = Path(get_settings().attachment_dir).resolve() + try: + restore_quarantine_dir(root, Path(row.cleanup_path)) + except OSError as exc: + raise backup_error("backup_repair_pending", "原附件复原尚未完成,请稍后重试", 503) from exc + row.status = "failed" + row.cleanup_path = None + await db.commit() + raise backup_error("backup_restore_retry", "附件已复原,请重新预检后重试", 409) + if row.status == "cleanup_pending": + from .storage import remove_quarantine + try: + if row.cleanup_path: + remove_quarantine(Path(row.cleanup_path)) + _safe_staging_path(row.staging_path).unlink(missing_ok=True) + except OSError as exc: + raise backup_error("backup_cleanup_pending", "清理尚未完成,请稍后重试", 503) from exc + row.status = "consumed" + row.cleanup_path = None + await db.commit() + return {"restored": 0, "mode": payload.mode, "already_imported": False, "cleanup_retried": True} + claimed = await db.execute(update(BackupPreflight).where( + BackupPreflight.id == row.id, BackupPreflight.status.in_(("pending", "failed")) + ).values(status="consuming", consumed_at=utcnow())) + if claimed.rowcount != 1: + await db.rollback() + raise backup_error("backup_preflight_invalid", "预检令牌已使用", 409) + await db.commit() + staging = _safe_staging_path(row.staging_path) + archive = parse_archive_path(staging) + try: + if archive.archive_sha256 != row.archive_sha256 or archive.backup_id != row.backup_id: + row.status = "failed" + await db.commit() + raise backup_error("backup_preflight_invalid", "预检暂存已改变", 409) + result = await restore_v2(db, user, archive, payload.mode, operation=row) + except BackupRepairPending: + raise + except Exception: + await db.refresh(row) + if row.status not in {"cleanup_pending", "repair_pending"}: + row.status = "failed" + await db.commit() + raise + finally: + shutil.rmtree(archive.staging_dir, ignore_errors=True) + row.status = "cleanup_pending" + await db.commit() + try: + staging.unlink(missing_ok=True) + except OSError as exc: + raise backup_error("backup_cleanup_pending", "数据已恢复,但暂存清理未完成;请使用同一令牌重试", 500) from exc + row.status = "consumed" + await db.commit() + return result diff --git a/backend/backup/schemas.py b/backend/backup/schemas.py new file mode 100644 index 0000000..8d8ff83 --- /dev/null +++ b/backend/backup/schemas.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel, Field + + +class RestoreRequest(BaseModel): + preflight_token: str = Field(min_length=32, max_length=128) + mode: str = Field(pattern="^(merge|replace)$") diff --git a/backend/backup/service.py b/backend/backup/service.py new file mode 100644 index 0000000..42ad0cc --- /dev/null +++ b/backend/backup/service.py @@ -0,0 +1,771 @@ +from __future__ import annotations + +import hashlib +import math +import tempfile +from datetime import date, datetime +from itertools import pairwise +from pathlib import Path +from uuid import UUID, uuid5 + +from fastapi import HTTPException +from pydantic import ValidationError +from sqlalchemy import Boolean, Date, DateTime, Float, Integer, String, Text, Uuid, delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.config import get_settings +from backend.models import ( + Attachment, + BackupImport, + BackupImportEntity, + BackupPreflight, + Countdown, + Folder, + Habit, + HabitLog, + HabitPause, + Memo, + RecurrenceException, + RecurrenceTemplate, + Task, + TaskList, + User, + new_id, +) +from backend.mvp import ( + CountdownInput, + HabitCreate, + MemoCreate, + PauseInput, + RecurrenceCreate, + is_occurrence, + parse_rrule, +) +from backend.schemas import FolderCreate, ListCreate, TaskCreate + +from .archive import ParsedArchive, backup_error, build_archive_to_path, canonical_json +from .storage import contained_file, quarantine_files, remove_quarantine, restore_quarantine + + +class BackupRepairPending(HTTPException): + def __init__(self) -> None: + super().__init__(500, { + "code": "backup_repair_pending", + "message": "恢复失败且原附件尚未完全复原;请使用同一令牌重试", + }) + +ENTITY_MODELS = { + "folders": Folder, + "lists": TaskList, + "tasks": Task, + "recurrences": RecurrenceTemplate, + "recurrence_exceptions": RecurrenceException, + "habits": Habit, + "habit_logs": HabitLog, + "habit_pauses": HabitPause, + "countdowns": Countdown, + "memos": Memo, + "attachments": Attachment, +} +RELATIONS = { + "lists": {"folder_id": "folders"}, + "tasks": {"list_id": "lists", "parent_id": "tasks"}, + "recurrences": {"task_id": "tasks"}, + "recurrence_exceptions": {"template_id": "recurrences"}, + "habit_logs": {"habit_id": "habits"}, + "habit_pauses": {"habit_id": "habits"}, + "attachments": {"task_id": "tasks"}, +} +ORDER = tuple(ENTITY_MODELS) + + +def _value(value): + if isinstance(value, (UUID, date, datetime)): + return value.isoformat() if not isinstance(value, UUID) else str(value) + return value + + +def _row(row, *, attachment_path: str | None = None) -> dict: + result = { + column.name: _value(getattr(row, column.name)) + for column in row.__table__.columns + if column.name != "user_id" and column.name != "storage_name" + } + if attachment_path is not None: + result["archive_path"] = attachment_path + return result + + +async def export_v2(db: AsyncSession, user: User) -> Path: + # Serialize exports with restores for this user. PostgreSQL keeps the row + # lock until this function's transaction is committed by the route. + await db.scalar(select(User).where(User.id == user.id).with_for_update()) + entities: dict[str, list[dict]] = {} + files: dict[str, Path] = {} + root = Path(get_settings().attachment_dir).resolve() + total_size = 0 + limit = get_settings().backup_max_archive_mb * 1024 * 1024 + for name, model in ENTITY_MODELS.items(): + if name in {"recurrence_exceptions", "habit_logs", "habit_pauses"}: + if name == "recurrence_exceptions": + ids = select(RecurrenceTemplate.id).where(RecurrenceTemplate.user_id == user.id) + rows = list((await db.scalars(select(model).where(model.template_id.in_(ids)))).all()) + else: + ids = select(Habit.id).where(Habit.user_id == user.id) + rows = list((await db.scalars(select(model).where(model.habit_id.in_(ids)))).all()) + else: + rows = list((await db.scalars(select(model).where(model.user_id == user.id))).all()) + output = [] + for item in rows: + if isinstance(item, Attachment): + archive_path = f"attachments/{item.id}/content" + try: + path = contained_file(root, item.storage_name) + except ValueError as exc: + raise backup_error("backup_attachment_path_invalid", "附件存储路径无效") from exc + if not path.is_file(): + raise backup_error("backup_attachment_missing", "附件文件缺失") + actual_size = path.stat().st_size + if actual_size != item.size: + raise backup_error("backup_attachment_size_mismatch", "附件大小不匹配") + total_size += actual_size + if total_size > limit: + raise backup_error("backup_size_invalid", "备份文件大小超出导入合同") + files[archive_path] = path + output.append(_row(item, attachment_path=archive_path)) + else: + output.append(_row(item)) + entities[name] = output + handle, output_name = tempfile.mkstemp(prefix="dodo-backup-", suffix=".zip") + import os + os.close(handle) + output_path = Path(output_name) + try: + build_archive_to_path(output_path, new_id(), entities, files) + if output_path.stat().st_size > limit: + raise backup_error("backup_size_invalid", "备份文件大小超出导入合同") + return output_path + except Exception: + output_path.unlink(missing_ok=True) + raise + + +def _validate_scalar(entity: str, column, row: dict) -> None: + name = column.name + if name in {"user_id", "storage_name"}: + return + if name not in row: + if not column.nullable and column.default is None and not column.primary_key: + raise backup_error("backup_entity_invalid", f"{entity} 缺少必填字段") + return + value = row[name] + if value is None: + if not column.nullable and not column.primary_key: + raise backup_error("backup_entity_invalid", f"{entity}.{name} 不可为空") + return + try: + effective_type = getattr(column.type, "impl", column.type) + if isinstance(effective_type, Uuid): + UUID(str(value)) + elif isinstance(effective_type, DateTime): + if not isinstance(value, str): + raise TypeError + datetime.fromisoformat(value) + elif isinstance(effective_type, Date): + if not isinstance(value, str): + raise TypeError + date.fromisoformat(value) + elif isinstance(effective_type, Boolean): + if type(value) is not bool: + raise TypeError + elif isinstance(effective_type, Integer): + if type(value) is not int: + raise TypeError + elif isinstance(effective_type, Float): + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise TypeError + elif isinstance(effective_type, (String, Text)): + if not isinstance(value, str): + raise TypeError + if isinstance(effective_type, String) and effective_type.length is not None and len(value) > effective_type.length: + raise ValueError + except (TypeError, ValueError, OverflowError) as exc: + raise backup_error("backup_entity_invalid", f"{entity}.{name} 类型无效") from exc + + +def _parse_habit_days(value: object, *, minimum: int, maximum: int) -> list[int] | None: + if value is None: + return None + if not isinstance(value, str) or not value or any(part.strip() != part for part in value.split(",")): + raise ValueError + parts = value.split(",") + if any(not part.isascii() or not part.isdecimal() for part in parts): + raise ValueError + days = [int(part) for part in parts] + if value != ",".join(map(str, days)): + raise ValueError + if len(days) != len(set(days)) or any(day < minimum or day > maximum for day in days): + raise ValueError + return days + + +def _validate_habit_graph(parsed: ParsedArchive) -> None: + habits: dict[str, HabitCreate] = {} + try: + for row in parsed.entities["habits"]: + weekdays = _parse_habit_days(row.get("weekdays"), minimum=0, maximum=6) + month_days = _parse_habit_days(row.get("month_days"), minimum=1, maximum=31) + payload = { + field: row.get(field) + for field in HabitCreate.model_fields + if field in row + } + payload["weekdays"] = weekdays + payload["month_days"] = month_days + habit = HabitCreate.model_validate(payload) + if habit.kind == "boolean" and (row.get("target") != 1 or row.get("max_value") != 1): + raise ValueError + if habit.weekdays != weekdays or habit.month_days != month_days: + raise ValueError + if habit.interval_days != row.get("interval_days"): + raise ValueError + habits[str(row["id"])] = habit + + for row in parsed.entities["habit_logs"]: + habit = habits[str(row["habit_id"])] + value = row["value"] + if not math.isfinite(value) or value < 0: + raise ValueError + if habit.kind == "boolean": + if value not in (0, 1): + raise ValueError + elif habit.max_value is not None and value > habit.max_value: + raise ValueError + + pauses_by_habit: dict[str, list[tuple[date, date]]] = {} + for row in parsed.entities["habit_pauses"]: + pause = PauseInput.model_validate({ + "start_date": row.get("start_date"), + "end_date": row.get("end_date"), + }) + pauses_by_habit.setdefault(str(row["habit_id"]), []).append( + (pause.start_date, pause.end_date) + ) + for pauses in pauses_by_habit.values(): + pauses.sort() + if any(current[0] <= previous[1] for previous, current in pairwise(pauses)): + raise ValueError + except (KeyError, TypeError, ValueError, ValidationError) as exc: + raise backup_error("backup_habit_invalid", "习惯数据不符合业务规则") from exc + + +def _required_trimmed(value: object, *, maximum: int | None = None) -> str: + if not isinstance(value, str) or value != value.strip() or not value: + raise ValueError + if maximum is not None and len(value) > maximum: + raise ValueError + return value + + +def _ordered_datetimes(row: dict, earlier: str, later: str) -> None: + if row.get(earlier) and row.get(later) and datetime.fromisoformat(row[later]) < datetime.fromisoformat(row[earlier]): + raise ValueError + + +def _validate_entity_contracts(parsed: ParsedArchive) -> None: + try: + for row in parsed.entities["folders"]: + FolderCreate.model_validate({"name": row.get("name")}) + _required_trimmed(row.get("name"), maximum=120) + if row.get("position", 0) < 0: + raise ValueError + _ordered_datetimes(row, "created_at", "deleted_at") + for row in parsed.entities["lists"]: + ListCreate.model_validate({"name": row.get("name"), "folder_id": row.get("folder_id")}) + _required_trimmed(row.get("name"), maximum=120) + if row.get("position", 0) < 0: + raise ValueError + _ordered_datetimes(row, "created_at", "deleted_at") + for row in parsed.entities["tasks"]: + task = TaskCreate.model_validate({ + "title": row.get("title"), + "list_id": row.get("list_id"), + "description": row.get("description", ""), + "priority": row.get("priority", 0), + "due_at": row.get("due_at"), + "due_has_time": row.get("due_has_time", False), + "parent_id": row.get("parent_id"), + }) + _required_trimmed(row.get("title"), maximum=500) + if row.get("version", 1) < 1 or row.get("position", 0) < 0: + raise ValueError + if task.due_at is None and task.due_has_time: + raise ValueError + completed_at = row.get("completed_at") + if row.get("completed") is True and completed_at is None: + raise ValueError + if row.get("completed") is False and completed_at is not None: + raise ValueError + _ordered_datetimes(row, "created_at", "updated_at") + _ordered_datetimes(row, "created_at", "deleted_at") + for row in parsed.entities["countdowns"]: + CountdownInput.model_validate({ + field: row[field] for field in CountdownInput.model_fields if field in row + }) + _required_trimmed(row.get("title"), maximum=200) + _required_trimmed(row.get("icon"), maximum=32) + _ordered_datetimes(row, "created_at", "updated_at") + _ordered_datetimes(row, "created_at", "archived_at") + for row in parsed.entities["memos"]: + MemoCreate.model_validate({"title": row.get("title"), "content": row.get("content", "")}) + _required_trimmed(row.get("title"), maximum=200) + if row.get("version", 1) < 1: + raise ValueError + _ordered_datetimes(row, "created_at", "updated_at") + _ordered_datetimes(row, "created_at", "deleted_at") + allowed_mime = { + "text/plain", "text/csv", "application/pdf", "image/jpeg", "image/png", + "image/gif", "application/json", "application/zip", + } + for row in parsed.entities["attachments"]: + filename = row.get("filename") + mime_type = row.get("mime_type") + if ( + not isinstance(filename, str) + or not filename.strip() + or Path(filename).name != filename + or mime_type not in allowed_mime + or row.get("size", -1) < 0 + ): + raise ValueError + except (KeyError, TypeError, ValueError, ValidationError) as exc: + raise backup_error("backup_entity_invalid", "实体数据不符合业务规则") from exc + + +def _validate_recurrence_graph(parsed: ParsedArchive) -> None: + tasks = {str(row["id"]): row for row in parsed.entities["tasks"]} + try: + for row in parsed.entities["recurrences"]: + task = tasks[str(row["task_id"])] + recurrence = RecurrenceCreate.model_validate({ + "task_id": row["task_id"], + "rrule": row.get("rrule"), + "trigger_mode": row.get("trigger_mode", "scheduled"), + "after_completion_days": row.get("after_completion_days"), + }) + starts_at = datetime.fromisoformat(row["starts_at"]) + ends_at = datetime.fromisoformat(row["ends_at"]) if row.get("ends_at") else None + last_completed_at = ( + datetime.fromisoformat(row["last_completed_at"]) + if row.get("last_completed_at") + else None + ) + if task.get("parent_id") is not None or task.get("due_at") is None: + raise ValueError + due_at = datetime.fromisoformat(task["due_at"]) + if recurrence.trigger_mode == "scheduled": + if recurrence.rrule is None: + raise ValueError + parse_rrule(recurrence.rrule) + if starts_at != due_at: + raise ValueError + if ends_at is not None and ends_at < starts_at: + raise ValueError + if last_completed_at is not None and last_completed_at > starts_at: + raise ValueError + + recurrences = {str(row["id"]): row for row in parsed.entities["recurrences"]} + for row in parsed.entities["recurrence_exceptions"]: + recurrence = recurrences[str(row["template_id"])] + if recurrence.get("trigger_mode", "scheduled") != "scheduled": + raise ValueError + occurrence_at = datetime.fromisoformat(row["occurrence_at"]) + if not is_occurrence( + recurrence["rrule"], datetime.fromisoformat(recurrence["starts_at"]), occurrence_at + ): + raise ValueError + if recurrence.get("ends_at") and occurrence_at > datetime.fromisoformat(recurrence["ends_at"]): + raise ValueError + except (HTTPException, KeyError, TypeError, ValueError, ValidationError) as exc: + raise backup_error("backup_recurrence_invalid", "重复规则不符合业务规则") from exc + + +def validate_archive(parsed: ParsedArchive) -> None: + unknown = set(parsed.entities) - set(ENTITY_MODELS) + missing = set(ENTITY_MODELS) - set(parsed.entities) + if unknown: + raise backup_error("backup_entity_unknown", "备份包含未知实体") + if missing: + raise backup_error("backup_entity_missing", "备份缺少必需实体") + ids: dict[str, set[str]] = {} + for entity in ORDER: + rows = parsed.entities.get(entity, []) + model = ENTITY_MODELS[entity] + allowed = {column.name for column in model.__table__.columns} | ({"archive_path"} if entity == "attachments" else set()) + entity_ids = [] + for row in rows: + if set(row) - allowed or "user_id" in row or "storage_name" in row: + raise backup_error("backup_entity_invalid", f"{entity} 包含未知或受保护字段") + for column in model.__table__.columns: + _validate_scalar(entity, column, row) + try: + entity_ids.append(str(UUID(str(row["id"])))) + except (KeyError, TypeError, ValueError) as exc: + raise backup_error("backup_entity_invalid", f"{entity} 包含无效 ID") from exc + if len(entity_ids) != len(set(entity_ids)): + raise backup_error("backup_duplicate_id", f"{entity} 包含重复 ID") + ids[entity] = set(entity_ids) + for entity, fields in RELATIONS.items(): + for row in parsed.entities.get(entity, []): + for field, target in fields.items(): + value = row.get(field) + if value is not None and str(value) not in ids[target]: + raise backup_error("backup_reference_invalid", f"{entity}.{field} 引用不存在") + attachment_paths = set() + for row in parsed.entities.get("attachments", []): + path = row.get("archive_path") + if not isinstance(path, str) or path not in parsed.files or not path.startswith("attachments/"): + raise backup_error("backup_attachment_missing", "附件内容缺失") + if path in attachment_paths: + raise backup_error("backup_attachment_duplicate", "附件内容被重复引用") + attachment_paths.add(path) + blob = parsed.files[path] + if row.get("size") != blob.size: + raise backup_error("backup_attachment_size_mismatch", "附件大小不匹配") + if set(parsed.files) != attachment_paths: + raise backup_error("backup_unreferenced_file", "备份包含未引用文件") + + def unique(entity: str, fields: tuple[str, ...], *, ignore_null: bool = False) -> None: + seen = set() + for row in parsed.entities[entity]: + key = tuple(row.get(field) for field in fields) + if ignore_null and any(value is None for value in key): + continue + if key in seen: + raise backup_error("backup_constraint_invalid", f"{entity} 唯一约束冲突") + seen.add(key) + + unique("recurrences", ("task_id",)) + unique("recurrence_exceptions", ("template_id", "occurrence_at")) + unique("habit_logs", ("habit_id", "day")) + unique("tasks", ("external_id",), ignore_null=True) + active_pinned = sum( + row.get("pinned") is True and row.get("archived_at") is None + for row in parsed.entities["countdowns"] + ) + if active_pinned > 1: + raise backup_error("backup_constraint_invalid", "最多只能有一个置顶倒数日") + _validate_entity_contracts(parsed) + _validate_habit_graph(parsed) + _validate_recurrence_graph(parsed) + inboxes = [row for row in parsed.entities["lists"] if row.get("is_inbox") is True] + if len(inboxes) != 1: + raise backup_error("backup_constraint_invalid", "备份必须包含且仅包含一个收集箱") + tasks = {str(row["id"]): row for row in parsed.entities["tasks"]} + for task_id, row in tasks.items(): + parent_id = row.get("parent_id") + if parent_id is None: + continue + parent_id = str(parent_id) + if parent_id == task_id: + raise backup_error("backup_constraint_invalid", "任务不能以自身为父任务") + parent = tasks[parent_id] + if parent.get("parent_id") is not None: + raise backup_error("backup_constraint_invalid", "任务父子关系仅支持一层") + if parent.get("list_id") != row.get("list_id"): + raise backup_error("backup_constraint_invalid", "父子任务必须属于同一清单") + + +def _ordered_rows(entity: str, rows: list[dict]) -> list[dict]: + if entity != "tasks": + return rows + pending = {str(row["id"]): row for row in rows} + ordered: list[dict] = [] + while pending: + ready = [row for row in pending.values() if row.get("parent_id") is None or str(row["parent_id"]) not in pending] + if not ready: + raise backup_error("backup_constraint_invalid", "任务父子关系存在环") + for row in ready: + ordered.append(row) + pending.pop(str(row["id"])) + return ordered + + +def _normalized_values(values: dict) -> dict: + return {key: _value(value) for key, value in values.items() if key not in {"created_at", "updated_at"}} + + +def _coerce(model, raw: dict, mapping: dict[str, dict[str, UUID]], user_id: UUID) -> dict: + values = {} + relations = RELATIONS.get(next(name for name, item in ENTITY_MODELS.items() if item is model), {}) + for column in model.__table__.columns: + name = column.name + if name == "user_id": + values[name] = user_id + elif name == "storage_name": + continue + elif name in raw: + value = raw[name] + if name in relations and value is not None: + value = mapping[relations[name]][str(value)] + elif value is not None: + column_type = column.type + effective_type = getattr(column_type, "impl", column_type) + if isinstance(effective_type, Uuid): + value = UUID(str(value)) + elif isinstance(effective_type, DateTime) and isinstance(value, str): + value = datetime.fromisoformat(value) + elif isinstance(effective_type, Date) and isinstance(value, str): + value = date.fromisoformat(value) + values[name] = value + return values + + +def _content_digest(entity: str, raw: dict) -> str: + # Source-form relations are deliberate: digest identity remains stable even + # when another user needs different target UUIDs. + payload = {key: value for key, value in raw.items() if key not in {"created_at", "updated_at", "storage_name"}} + return hashlib.sha256(canonical_json(payload)).hexdigest() + + +async def _all_uuid_primary_keys(db: AsyncSession) -> set[UUID]: + occupied: set[UUID] = set() + for table in User.metadata.sorted_tables: + primary_keys = list(table.primary_key.columns) + if len(primary_keys) != 1: + continue + column = primary_keys[0] + effective_type = getattr(column.type, "impl", column.type) + if isinstance(effective_type, Uuid): + occupied.update((await db.scalars(select(column))).all()) + return occupied + + +async def _mapped_id( + db: AsyncSession, model, source: UUID, user_id: UUID, backup_id: UUID, entity: str, + reserved: set[UUID], incoming: set[UUID], +) -> UUID: + ledger = await db.scalar(select(BackupImportEntity).where( + BackupImportEntity.user_id == user_id, + BackupImportEntity.backup_id == backup_id, + BackupImportEntity.entity_type == entity, + BackupImportEntity.source_id == source, + )) + if ledger is not None: + reserved.add(ledger.target_id) + return ledger.target_id + existing = await db.get(model, source) + owner_id = getattr(existing, "user_id", None) + if owner_id is None: + if isinstance(existing, RecurrenceException): + owner_id = await db.scalar(select(RecurrenceTemplate.user_id).where(RecurrenceTemplate.id == existing.template_id)) + elif isinstance(existing, (HabitLog, HabitPause)): + owner_id = await db.scalar(select(Habit.user_id).where(Habit.id == existing.habit_id)) + if existing is not None and owner_id == user_id: + reserved.add(source) + return source + if source not in reserved: + reserved.add(source) + return source + candidate = uuid5(user_id, str(source)) + while candidate in reserved or candidate in incoming: + candidate = uuid5(user_id, str(candidate)) + reserved.add(candidate) + return candidate + + +async def restore_v2( + db: AsyncSession, user: User, parsed: ParsedArchive, mode: str, + *, operation: BackupPreflight | None = None, +) -> dict: + validate_archive(parsed) + # The user row is the cross-worker restore/export mutex on PostgreSQL. + await db.scalar(select(User).where(User.id == user.id).with_for_update()) + existing_import = await db.scalar(select(BackupImport).where( + BackupImport.user_id == user.id, BackupImport.backup_id == parsed.backup_id + )) + already_imported = existing_import is not None + if mode == "merge" and existing_import and existing_import.archive_sha256 != parsed.archive_sha256: + raise HTTPException(409, {"code": "backup_id_conflict", "message": "备份标识与内容不一致"}) + + root = Path(get_settings().attachment_dir).resolve() + root.mkdir(parents=True, exist_ok=True) + quarantine = root.parent / f".backup-quarantine-{new_id()}" + moved = [] + written: list[Path] = [] + try: + if mode == "replace": + old_names = list((await db.scalars( + select(Attachment.storage_name).where(Attachment.user_id == user.id) + )).all()) + moved = quarantine_files(root, old_names, quarantine) + await db.execute(delete(RecurrenceException).where( + RecurrenceException.template_id.in_( + select(RecurrenceTemplate.id).where(RecurrenceTemplate.user_id == user.id) + ) + )) + await db.execute(delete(RecurrenceTemplate).where(RecurrenceTemplate.user_id == user.id)) + await db.execute(delete(HabitLog).where( + HabitLog.habit_id.in_(select(Habit.id).where(Habit.user_id == user.id)) + )) + await db.execute(delete(HabitPause).where( + HabitPause.habit_id.in_(select(Habit.id).where(Habit.user_id == user.id)) + )) + for model in (Attachment, Memo, Countdown, Task, Habit, TaskList, Folder): + await db.execute(delete(model).where(model.user_id == user.id)) + await db.execute(delete(BackupImportEntity).where(BackupImportEntity.user_id == user.id)) + await db.execute(delete(BackupImport).where(BackupImport.user_id == user.id)) + + mapping: dict[str, dict[str, UUID]] = {name: {} for name in ORDER} + incoming_ids: set[UUID] = { + UUID(str(raw["id"])) + for rows in parsed.entities.values() + for raw in rows + } + reserved_ids = await _all_uuid_primary_keys(db) + for entity in ORDER: + model = ENTITY_MODELS[entity] + for raw in _ordered_rows(entity, parsed.entities[entity]): + source = UUID(str(raw["id"])) + mapping[entity][str(source)] = await _mapped_id( + db, model, source, user.id, parsed.backup_id, entity, + reserved_ids, incoming_ids - {source}, + ) + + restored = 0 + for entity in ORDER: + model = ENTITY_MODELS[entity] + for raw in _ordered_rows(entity, parsed.entities[entity]): + source = UUID(str(raw["id"])) + target = mapping[entity][str(source)] + digest_payload = dict(raw) + if model is Attachment: + digest_payload["archive_sha256"] = parsed.files[raw["archive_path"]].sha256 + content_digest = _content_digest(entity, digest_payload) + ledger = await db.scalar(select(BackupImportEntity).where( + BackupImportEntity.user_id == user.id, + BackupImportEntity.backup_id == parsed.backup_id, + BackupImportEntity.entity_type == entity, + BackupImportEntity.source_id == source, + )) + existing = await db.get(model, target) + values = _coerce(model, raw, mapping, user.id) + values["id"] = target + if ledger is not None: + if ledger.target_id != target or ledger.content_digest != content_digest: + raise HTTPException(409, {"code": "backup_entity_conflict", "message": f"{entity} 映射账本冲突"}) + if existing is None: + raise HTTPException(409, {"code": "backup_entity_missing", "message": f"{entity} 映射目标不存在"}) + current = _row(existing, attachment_path=raw.get("archive_path") if model is Attachment else None) + expected = {key: _value(value) for key, value in values.items() if key not in {"user_id", "storage_name"}} + if model is Attachment: + expected["archive_path"] = raw["archive_path"] + stored = contained_file(root, existing.storage_name) + file_digest = hashlib.sha256() + actual_size = 0 + try: + with stored.open("rb") as source_file: + while chunk := source_file.read(1024 * 1024): + actual_size += len(chunk) + file_digest.update(chunk) + except OSError as exc: + raise HTTPException(409, {"code": "backup_entity_missing", "message": "attachments 映射目标文件不存在"}) from exc + blob = parsed.files[raw["archive_path"]] + if actual_size != blob.size or file_digest.hexdigest() != blob.sha256: + raise HTTPException(409, {"code": "backup_entity_conflict", "message": "attachments 映射目标内容冲突"}) + if _normalized_values(current) != _normalized_values(expected): + raise HTTPException(409, {"code": "backup_entity_conflict", "message": f"{entity} 映射目标内容冲突"}) + continue + if existing is not None and mode == "merge": + current = _row(existing, attachment_path=raw.get("archive_path") if model is Attachment else None) + expected = {key: _value(value) for key, value in values.items() if key not in {"user_id", "storage_name"}} + if model is Attachment: + expected["archive_path"] = raw["archive_path"] + stored = contained_file(root, existing.storage_name) + blob = parsed.files[raw["archive_path"]] + file_digest = hashlib.sha256() + actual_size = 0 + try: + with stored.open("rb") as source_file: + while chunk := source_file.read(1024 * 1024): + actual_size += len(chunk) + file_digest.update(chunk) + except OSError as exc: + raise HTTPException(409, { + "code": "backup_entity_missing", + "message": "attachments 已存在但文件不存在", + }) from exc + if actual_size != blob.size or file_digest.hexdigest() != blob.sha256: + raise HTTPException(409, { + "code": "backup_entity_conflict", + "message": "attachments 已存在不同内容", + }) + if _normalized_values(current) != _normalized_values(expected): + raise HTTPException(409, {"code": "backup_entity_conflict", "message": f"{entity} 已存在不同内容"}) + else: + if model is Attachment: + archive_path = raw["archive_path"] + storage_name = str(new_id()) + destination = contained_file(root, storage_name) + digest = hashlib.sha256() + copied_size = 0 + blob = parsed.files[archive_path] + with blob.staging_path.open("rb") as source_file, destination.open("xb") as output: + while chunk := source_file.read(1024 * 1024): + copied_size += len(chunk) + digest.update(chunk) + output.write(chunk) + if copied_size != blob.size or digest.hexdigest() != blob.sha256: + destination.unlink(missing_ok=True) + raise backup_error("backup_checksum_mismatch", "附件暂存校验失败", 409) + written.append(destination) + values["storage_name"] = storage_name + db.add(model(**values)) + await db.flush() + restored += 1 + db.add(BackupImportEntity( + user_id=user.id, backup_id=parsed.backup_id, entity_type=entity, + source_id=source, target_id=target, content_digest=content_digest, + )) + await db.flush() + if mode == "replace" or existing_import is None: + db.add(BackupImport( + user_id=user.id, backup_id=parsed.backup_id, + archive_sha256=parsed.archive_sha256, mode=mode, + )) + await db.commit() + except HTTPException: + await db.rollback() + for path in written: + path.unlink(missing_ok=True) + try: + restore_quarantine(moved) + except (OSError, ValueError): + if operation is not None: + operation.status = "repair_pending" + operation.cleanup_path = str(quarantine) + await db.commit() + raise BackupRepairPending() + raise + except Exception: + await db.rollback() + for path in written: + path.unlink(missing_ok=True) + try: + restore_quarantine(moved) + except (OSError, ValueError): + if operation is not None: + operation.status = "repair_pending" + operation.cleanup_path = str(quarantine) + await db.commit() + raise BackupRepairPending() + raise + try: + remove_quarantine(quarantine) + except OSError as exc: + if operation is not None: + operation.status = "cleanup_pending" + operation.cleanup_path = str(quarantine) + await db.commit() + raise HTTPException(500, {"code": "backup_cleanup_pending", "message": "数据已恢复,但旧附件清理未完成;请使用同一令牌重试"}) from exc + return {"restored": restored, "mode": mode, "already_imported": already_imported, "cleanup_retried": False} diff --git a/backend/backup/storage.py b/backend/backup/storage.py new file mode 100644 index 0000000..105944c --- /dev/null +++ b/backend/backup/storage.py @@ -0,0 +1,52 @@ +import shutil +from pathlib import Path + + +def contained_file(root: Path, storage_name: str) -> Path: + root = root.resolve() + candidate = (root / storage_name).resolve() + if candidate.parent != root: + raise ValueError("attachment path escapes storage root") + return candidate + + +def quarantine_files(root: Path, storage_names: list[str], quarantine: Path) -> list[tuple[Path, Path]]: + moved = [] + quarantine.mkdir(parents=True, exist_ok=True) + try: + for name in storage_names: + source = contained_file(root, name) + if source.exists(): + target = quarantine / name + target.parent.mkdir(parents=True, exist_ok=True) + source.replace(target) + moved.append((source, target)) + return moved + except Exception: + restore_quarantine(moved) + raise + + +def restore_quarantine(moved: list[tuple[Path, Path]]) -> None: + for original, quarantined in reversed(moved): + if quarantined.exists(): + original.parent.mkdir(parents=True, exist_ok=True) + quarantined.replace(original) + + +def restore_quarantine_dir(root: Path, quarantine: Path) -> None: + if not quarantine.exists(): + return + for quarantined in sorted(quarantine.rglob("*")): + if not quarantined.is_file(): + continue + relative = quarantined.relative_to(quarantine) + original = contained_file(root, relative.as_posix()) + original.parent.mkdir(parents=True, exist_ok=True) + quarantined.replace(original) + shutil.rmtree(quarantine) + + +def remove_quarantine(path: Path) -> None: + if path.exists(): + shutil.rmtree(path) diff --git a/backend/config.py b/backend/config.py index 67d7fc6..29731e9 100644 --- a/backend/config.py +++ b/backend/config.py @@ -12,6 +12,11 @@ class Settings(BaseSettings): auto_create_schema: bool = False attachment_dir: str = "./data/attachments" attachment_max_mb: int = 20 + backup_max_archive_mb: int = 256 + backup_max_pending_per_user: int = 3 + backup_max_staged_mb_per_user: int = 768 + backup_preflight_ttl_seconds: int = 900 + backup_staging_dir: str = "./data/backup-staging" login_attempts: int = 5 login_window_seconds: int = 300 diff --git a/backend/main.py b/backend/main.py index 0cf2e85..6ac6022 100644 --- a/backend/main.py +++ b/backend/main.py @@ -29,6 +29,7 @@ from .auth import ( session_token, verify_password, ) +from .backup import router as backup_router from .db import create_schema, get_db from .models import ( AppState, @@ -118,6 +119,7 @@ async def openapi(_: User = Depends(current_user)): app.include_router(mvp_router) +app.include_router(backup_router) logger = logging.getLogger(__name__) _login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque) diff --git a/backend/models.py b/backend/models.py index 43ee71f..cd11619 100644 --- a/backend/models.py +++ b/backend/models.py @@ -255,6 +255,52 @@ class Memo(Base): deleted_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True) +class BackupPreflight(Base): + __tablename__ = "backup_preflights" + id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True) + user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + backup_id: Mapped[UUID] = mapped_column(index=True) + archive_sha256: Mapped[str] = mapped_column(String(64)) + archive_size: Mapped[int] = mapped_column(Integer) + staging_path: Mapped[str] = mapped_column(String(1024), unique=True) + mode: Mapped[str] = mapped_column(String(16)) + status: Mapped[str] = mapped_column(String(32), default="pending", index=True) + expires_at: Mapped[datetime] = mapped_column(UTCDateTime(), index=True) + consumed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True) + cleanup_path: Mapped[str | None] = mapped_column(String(1024), nullable=True) + created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow) + + +class BackupImport(Base): + __tablename__ = "backup_imports" + __table_args__ = (UniqueConstraint("user_id", "backup_id", name="uq_backup_import_user_backup"),) + id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id) + user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + backup_id: Mapped[UUID] = mapped_column(index=True) + archive_sha256: Mapped[str] = mapped_column(String(64)) + mode: Mapped[str] = mapped_column(String(16)) + created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow) + + +class BackupImportEntity(Base): + __tablename__ = "backup_import_entities" + __table_args__ = ( + UniqueConstraint( + "user_id", "backup_id", "entity_type", "source_id", + name="uq_backup_import_entity_source", + ), + ) + id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id) + user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + backup_id: Mapped[UUID] = mapped_column(index=True) + entity_type: Mapped[str] = mapped_column(String(64)) + source_id: Mapped[UUID] = mapped_column(index=True) + target_id: Mapped[UUID] = mapped_column(index=True) + content_digest: Mapped[str] = mapped_column(String(64)) + created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow) + + class AuditLog(Base): __tablename__ = "audit_logs" id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id) diff --git a/backend/mvp.py b/backend/mvp.py index a2f5642..01d908f 100644 --- a/backend/mvp.py +++ b/backend/mvp.py @@ -11,7 +11,7 @@ from zoneinfo import ZoneInfo from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile from fastapi.responses import FileResponse from pydantic import BaseModel, Field, StrictBool, field_validator, model_validator -from sqlalchemy import case, delete, func, select, update +from sqlalchemy import case, func, select, update from sqlalchemy.ext.asyncio import AsyncSession from .auth import current_user @@ -44,6 +44,42 @@ from .models import ( router = APIRouter(prefix="/api/v1") +LEGACY_BACKUP_MAX_BYTES = 16 * 1024 * 1024 +LEGACY_BACKUP_MAX_RECORDS = 10_000 +LEGACY_BACKUP_MAX_FIELD_BYTES = 1024 * 1024 +_LEGACY_READ_CHUNK = 64 * 1024 +_LEGACY_ENTITIES = ("folders", "lists", "tasks", "recurrences", "habits", "countdowns", "memos") + + +def _legacy_error(code: str, message: str) -> HTTPException: + return HTTPException(422, {"code": code, "message": message}) + + +def _validate_legacy_payload_limits(payload: dict) -> None: + total = 0 + for entity in _LEGACY_ENTITIES: + rows = payload.get(entity, []) + if not isinstance(rows, list): + raise _legacy_error("legacy_backup_invalid", "旧版备份实体格式无效") + total += len(rows) + if total > LEGACY_BACKUP_MAX_RECORDS: + raise _legacy_error("legacy_backup_too_many_records", "旧版备份记录过多") + for row in rows: + if not isinstance(row, dict): + raise _legacy_error("legacy_backup_invalid", "旧版备份记录格式无效") + for value in row.values(): + if isinstance(value, str) and len(value.encode("utf-8")) > LEGACY_BACKUP_MAX_FIELD_BYTES: + raise _legacy_error("legacy_backup_field_too_large", "旧版备份字段过大") + + +async def _read_legacy_upload(file: UploadFile) -> bytes: + content = bytearray() + while chunk := await file.read(_LEGACY_READ_CHUNK): + content.extend(chunk) + if len(content) > LEGACY_BACKUP_MAX_BYTES: + raise _legacy_error("legacy_backup_too_large", "旧版备份文件过大") + return bytes(content) + def audit(db: AsyncSession, user_id: UUID, action: str, entity_type: str, entity_id=None, **details): db.add(AuditLog(user_id=user_id, action=action, entity_type=entity_type, entity_id=entity_id, details=details)) @@ -223,8 +259,10 @@ class RecurrenceCreate(BaseModel): @model_validator(mode="after") def validate_mode(self): - if self.trigger_mode == "scheduled" and self.rrule is None: - raise ValueError("scheduled recurrence requires rrule") + if self.trigger_mode == "scheduled" and ( + self.rrule is None or self.after_completion_days is not None + ): + raise ValueError("scheduled recurrence requires rrule and no completion interval") if self.trigger_mode == "after_completion" and ( self.after_completion_days is None or self.rrule is not None ): @@ -1209,6 +1247,16 @@ async def habit_stats(habit_id: UUID, user: User = Depends(current_user), db: As _ALLOWED_MIME = {"text/plain", "text/csv", "application/pdf", "image/jpeg", "image/png", "image/gif", "application/json", "application/zip"} +@router.get("/tasks/{task_id}/attachments") +async def list_attachments(task_id: UUID, user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): + await owned_task(db, user.id, task_id) + rows = (await db.scalars(select(Attachment).where( + Attachment.task_id == task_id, Attachment.user_id == user.id + ).order_by(Attachment.created_at, Attachment.id))).all() + return [{"id": row.id, "task_id": row.task_id, "filename": row.filename, + "mime_type": row.mime_type, "size": row.size} for row in rows] + + @router.post("/tasks/{task_id}/attachments", status_code=201) async def upload_attachment(task_id: UUID, file: UploadFile = File(...), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): await owned_task(db, user.id, task_id) @@ -1349,7 +1397,11 @@ async def restore_csv( user: User = Depends(current_user), db: AsyncSession = Depends(get_db), ): - text = (await file.read()).decode("utf-8-sig") + try: + raw_content = await _read_legacy_upload(file) + text = raw_content.decode("utf-8-sig") + except UnicodeDecodeError as exc: + raise _legacy_error("legacy_backup_invalid", "无效的 Dodo CSV 备份") from exc payload = { "version": 1, "folders": [], @@ -1363,9 +1415,14 @@ async def restore_csv( try: for row in csv.DictReader(io.StringIO(text)): entity = row.get("entity", "") + data = row.get("data") if entity not in payload or entity == "version": raise ValueError("unknown entity") - payload[entity].append(json.loads(row["data"])) + if not isinstance(data, str) or len(data.encode("utf-8")) > LEGACY_BACKUP_MAX_FIELD_BYTES: + raise ValueError("field too large") + payload[entity].append(json.loads(data)) + if sum(len(payload[name]) for name in _LEGACY_ENTITIES) > LEGACY_BACKUP_MAX_RECORDS: + raise _legacy_error("legacy_backup_too_many_records", "旧版备份记录过多") except (csv.Error, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc: raise HTTPException(422, "无效的 Dodo CSV 备份") from exc return await restore_json(payload, mode, user, db) @@ -1436,16 +1493,12 @@ def _validate_countdown_backups(payload: dict) -> list[dict]: @router.post("/restore") async def restore_json(payload: dict, mode: str = Query("merge", pattern="^(merge|replace)$"), user: User = Depends(current_user), db: AsyncSession = Depends(get_db)): + if mode == "replace": + raise _legacy_error("legacy_replace_unsupported", "旧版备份仅支持合并恢复") if payload.get("version") != 1: raise HTTPException(422, "不支持的备份版本") + _validate_legacy_payload_limits(payload) parsed_countdowns = _validate_countdown_backups(payload) - if mode == "replace": - await db.execute(delete(Memo).where(Memo.user_id == user.id)) - await db.execute(delete(Countdown).where(Countdown.user_id == user.id)) - await db.execute(delete(Task).where(Task.user_id == user.id)) - await db.execute(delete(Habit).where(Habit.user_id == user.id)) - await db.execute(delete(TaskList).where(TaskList.user_id == user.id)) - await db.execute(delete(Folder).where(Folder.user_id == user.id)) id_map = {} task_id_map = {} for raw in payload.get("folders", []): diff --git a/docs/api.md b/docs/api.md index 0cef176..fd3f558 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,59 +1,75 @@ # API -Base path: `/api/v1`. 除初始化、登录和健康检查外均需 `dodo_session` Cookie。 +Base path: `/api/v1`。除初始化、登录和健康检查外,接口均要求 `dodo_session` Cookie;同源写请求使用双提交 CSRF 校验。登录后可访问 `/api/docs` 和 `/api/openapi.json`。 -## Setup / Auth +## 账户与会话 -- `GET /setup/status` -- `POST /setup/initialize` -- `POST /auth/login` -- `POST /auth/logout` -- `GET /me` +- `GET /setup/status`、`POST /setup/initialize` +- `POST /auth/login`、`POST /auth/logout`、`POST /auth/change-password` +- `GET/PATCH /me` +- `GET /sessions`、`DELETE /sessions/{session_id}`、`DELETE /sessions/others` +- `GET /audit-logs` -## Folders +## 文件夹、清单与任务 -- `GET /folders` — 仅返回未删除文件夹 -- `POST /folders` — 创建文件夹 -- `PATCH /folders/{folder_id}` — 重命名 -- `DELETE /folders/{folder_id}` — 软删除;其清单移到根层级 +- 文件夹:`GET/POST /folders`,`PATCH/DELETE /folders/{id}`,以及排序接口 +- 清单:`GET/POST /lists`,`PATCH/DELETE /lists/{id}`,归档恢复、永久删除、移动与排序接口 +- 归档清单只标记 `deleted_at`,不会把任务移到收集箱;清单归档期间,其任务在普通列表和详情中不可见,恢复清单后重新出现 +- 任务:`GET/POST /tasks`、`GET/PATCH/DELETE /tasks/{id}`、`POST /tasks/{id}/restore`、`DELETE /trash/{id}` +- `GET /tasks` 和 `GET /trash` 使用不透明游标;`limit` 为 1–100 +- `POST /tasks/batch` 支持批量完成、移动、截止时间与软删除;任务写入使用 `version` 乐观锁 +- 一层子任务必须与父任务同清单;任务可带日期型或具体时间型截止时间 -## Lists +## 重复任务 -- `GET /lists` — 仅返回未删除清单,系统收集箱排首位 -- `POST /lists` — 创建清单,可指定 `folder_id` -- `PATCH /lists/{list_id}` — 重命名;系统收集箱返回 409 -- `DELETE /lists/{list_id}` — 软删除并将任务移入系统收集箱;系统收集箱返回 409 +- `GET /tasks/{task_id}/recurrence` +- `POST /recurrences`、`PATCH/DELETE /recurrences/{id}` +- `POST /recurrences/{id}/complete` +- 支持 RRULE 计划重复与按用户本地完成日期计算的“完成后重复” -## Tasks +## 习惯、倒数日与备忘录 -- `GET /tasks?q=&limit=&cursor=` — 顶层未删除任务的游标分页;`q` 匹配标题、描述和清单名 -- `POST /tasks` — 创建任务;`parent_id` 只允许指向同清单顶层任务 -- `GET /tasks/{task_id}` — 返回任务和一层子任务 -- `PATCH /tasks/{task_id}` — 必须携带当前 `version`,原子比较更新;版本冲突返回 409 -- `DELETE /tasks/{task_id}` — 软删除任务及其直接子任务 -- `POST /tasks/{task_id}/restore` — 恢复任务及其直接子任务 -- `POST /tasks/batch` — 原子批量完成、移动、设置截止时间或软删除 +- 习惯:创建、列表/周网格、部分更新、排序、归档/恢复/永久删除、日志、暂停和统计 +- 倒数日:创建、列表、编辑、置顶、归档/恢复/永久删除;支持公历/农历及周/月/年重复 +- 备忘录:游标列表、创建、读取、乐观锁更新、软删除、恢复和归档后永久删除 -批量请求字段:`task_ids`、`completed`、`list_id`、`due_at`、`soft_delete`。所有任务和目标清单在写入前完成归属校验;任一不存在则整批不修改。 +## 附件 -## Countdowns +- `GET/POST /tasks/{task_id}/attachments` +- `GET/DELETE /attachments/{attachment_id}` +- 文件保存在服务端附件目录;上传受大小与类型限制,访问始终校验当前用户归属 -- `GET /countdowns?archived=false` — 查询倒数日;置顶项优先,其余按下一次发生日期排序 -- `POST /countdowns` — 创建倒数日、纪念日或生日;支持 `none/weekly/monthly/yearly` 重复 -- `PATCH /countdowns/{countdown_id}` — 编辑名称、日期、类型、重复与图标 -- `POST /countdowns/{countdown_id}/pin` — 单一置顶,自动取消其他置顶项 -- `DELETE /countdowns/{countdown_id}` — 归档 -- `POST /countdowns/{countdown_id}/restore` — 恢复归档项 -- `DELETE /countdowns/{countdown_id}/purge` — 永久删除已归档项 +## 完整备份 ZIP v2 -## Recycle bin +### `GET /backup/export.zip` -- `GET /trash?limit=&cursor=` — 已删除顶层任务的游标分页 -- `DELETE /trash/{task_id}` — 永久删除任务及其子任务 +导出 `dodo-backup` version 2 ZIP。归档包含 `manifest.json`、每类实体的 `data/*.json`、附件元数据和附件原始字节。manifest 声明实体数量及每个条目的 SHA-256。 -游标是不透明字符串。无效游标返回 422;`limit` 范围为 1–100。 +实体范围:`folders`、`lists`、`tasks`、`recurrences`、`recurrence_exceptions`、`habits`、`habit_logs`、`habit_pauses`、`countdowns`、`memos`、`attachments`。会话、密码散列、审计日志及备份内部账本不导出。 -## Health +### `POST /backup/preflight?mode=merge|replace` + +以 multipart 字段 `file` 上传 ZIP。服务端流式暂存,并在返回令牌前验证:ZIP 路径与条目、压缩比/容量、manifest 版本与计数、全部校验和、字段与业务约束、关系拓扑、一层任务树、用户隔离以及附件元数据/字节一致性。 + +成功返回 `valid`、短期 `preflight_token`、`backup_id`、归档摘要和各实体数量。预检有每用户待处理数量/容量配额和过期时间;令牌绑定用户、文件摘要和恢复模式。 + +### `POST /backup/restore` + +请求体: + +```json +{"preflight_token":"...","mode":"merge"} +``` + +`merge` 使用持久化 source→target ID/内容摘要账本实现可重试合并;跨用户或同 ID 不同内容冲突会拒绝。`replace` 在事务内替换当前用户业务实体,并通过同文件系统隔离区协调附件删除和失败补偿。令牌单次消费;若数据库已提交但隔离区清理失败,同一令牌仅重试清理,不重复导入。 + +## 旧格式兼容 + +- `GET /export`、`GET /export.csv`:旧版 JSON/UTF-8-BOM CSV v1 轻量导出,不是完整备份 +- `POST /restore?mode=merge|replace`、`POST /restore.csv?mode=merge|replace`:兼容旧 JSON/CSV v1 +- 旧格式只覆盖文件夹、清单、任务、重复模板、习惯、倒数日和备忘录;不包含日志、暂停、重复例外和附件字节 + +## 健康检查 - `GET /health/live` - `GET /health/ready` diff --git a/docs/data-model.md b/docs/data-model.md index f5c571f..4dbe82c 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -1,97 +1,46 @@ # dodo 数据模型 -所有业务实体使用 UUIDv7 主键并按 `user_id` 隔离。生产使用 PostgreSQL,测试使用 SQLite;模型保持两者兼容。 +业务主键使用 UUIDv7;带 `user_id` 的实体按用户隔离。时间点按 UTC 保存,用户时区用于日期语义与展示。生产使用 PostgreSQL,测试使用 SQLite。 -## app_state +## 账户 -- `key` 主键 -- `created_at` +- `app_state`:初始化状态 +- `users`:用户名、密码散列、时区 +- `sessions`:会话令牌散列、过期/最近访问时间、IP 与 User-Agent +- `audit_logs`:用户、动作、实体、非敏感摘要和时间 -## users +## 任务域 -- `id` -- `username` 唯一 -- `password_hash` -- `timezone` -- `created_at` +- `folders`:名称、位置、软删除时间;删除文件夹只解除清单分组 +- `task_lists`:文件夹、名称、收集箱标记、位置、软删除时间 +- `tasks`:清单、可空父任务、标题、Markdown 描述、优先级、完成/完成时间、截止时间、`due_has_time`、位置、版本、软删除与外部 ID +- `recurrence_templates`:任务的一对一重复规则、开始/结束、计划或完成后触发模式、完成后间隔与最近完成时间 +- `recurrence_exceptions`:模板发生时间及标题/截止/完成/删除覆盖 +- `attachments`:任务、原始文件名、服务端存储名、MIME、大小和创建时间 +- `purge_operations`:清单永久删除时附件隔离区清理的补偿状态 -## sessions +任务树只允许一层,父子任务属于同一清单。清单归档仅设置清单 `deleted_at`,保留所有任务的 `list_id`;查询隐藏归档清单内任务,恢复清单后原任务和完成状态重新可见。任务与子任务软删除/恢复按生命周期规则处理,永久删除会清理关联重复数据和附件。 -- `id` -- `token_hash` 唯一 -- `user_id` → users,级联删除 -- `expires_at` -- `created_at` +## 习惯、倒数日与备忘录 -## folders +- `habits`:完成型/数值型、目标/上限、日/周/月/间隔计划、开始日、排序和归档 +- `habit_logs`:习惯与日期唯一的数值记录 +- `habit_pauses`:习惯暂停区间 +- `countdowns`:标题、日期、公历/农历字段、类型、重复、兼容保留的图标字段、置顶与归档 +- `memos`:标题、Markdown 内容、乐观锁版本、创建/更新时间和软删除时间 -- `id` -- `user_id` → users -- `name` -- `position` -- `created_at` -- `deleted_at`,非空表示软删除 +## 完整备份 v2 内部状态 -删除文件夹不会删除清单;清单的 `folder_id` 被置空。 +- `backup_preflights`:令牌散列、用户、备份 ID/摘要/大小、暂存路径、模式、状态、过期/消费时间和待清理路径 +- `backup_imports`:每用户已导入备份 ID、归档摘要、模式和时间;用于幂等判断 +- `backup_import_entities`:源实体 ID 到目标 ID 的映射及内容摘要;用于 merge 冲突检测与可重试导入 -## task_lists +这些表和账户/会话/审计信息不属于用户可迁移业务实体。ZIP v2 只导出: -- `id` -- `user_id` → users -- `folder_id` → folders,可空 -- `name` -- `is_inbox`,每个用户初始化时创建一个受保护的系统收集箱 -- `position` -- `created_at` -- `deleted_at`,非空表示软删除 +`folders`、`lists`、`tasks`、`recurrences`、`recurrence_exceptions`、`habits`、`habit_logs`、`habit_pauses`、`countdowns`、`memos`、`attachments`。 -删除普通清单时,其未删除任务原子移动到系统收集箱。系统收集箱不可重命名或删除。 - -## tasks - -- `id` -- `user_id` → users -- `list_id` → task_lists -- `parent_id` → tasks,可空;仅允许一层子任务且必须与父任务同清单 -- `title` -- `description` -- `priority`(0–3) -- `completed` -- `due_at`,可空 -- `version`,乐观锁版本;单任务更新用 `id + user_id + version` 原子比较更新 -- `position` -- `created_at` -- `updated_at` -- `deleted_at`,非空表示进入回收站 - -顶层任务软删除、恢复或永久删除时同步处理直接子任务。列表与回收站使用 `(created_at, id)` 作为稳定游标排序键。 - -## countdowns - -- `id` -- `user_id` → users,级联删除 -- `title` -- `event_date`,仅日期 -- `kind`:`countdown` / `anniversary` / `birthday` -- `repeat_rule`:`none` / `weekly` / `monthly` / `yearly` -- `icon` -- `pinned`,每个用户仅保留一个置顶项 -- `archived_at`,非空表示归档 -- `created_at` / `updated_at` +附件导出时去掉内部 `storage_name`,改用归档内安全路径并携带真实字节;恢复时生成目标存储名。每个实体文件和附件字节均由 manifest SHA-256 覆盖。 ## 迁移 -- `0001_initial.py`:已部署的初始模式,不修改 -- `0002_task_management.py`:新增文件夹/清单软删除列、历史标签表及游标/回收站索引 -- `0008_remove_calendar_subscriptions.py`:移除日历订阅表 -- `0009_remove_tags.py`:移除历史标签表及任务标签关联表 -- `0010_countdowns.py`:新增倒数日、纪念日与生日表 - -## 后续阶段预留 - -- task_reminders -- task_recurrence_templates -- task_recurrence_exceptions -- habits / habit_logs / habit_reminders -- attachments -- audit_logs +迁移按 `0001` 至 `0019` 顺序应用;当前最新 `0019_backup_imports.py` 增加完整备份预检、导入及实体映射账本。历史迁移还覆盖任务管理、会话元数据、查询索引、习惯排序、倒数日/农历、日期型截止语义、重复触发模式、备忘录与 `completed_at` 等演进。 diff --git a/docs/decisions.md b/docs/decisions.md index 05cd804..2ae2cd8 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -1,117 +1,46 @@ # dodo 产品与技术决策记录 -## 定位 +## 定位与当前范围 -dodo 是一个纯自托管的 TickTick-like 任务与习惯管理工具。目标不是一比一复刻 TickTick,而是做一个数据归自己、界面温暖紧凑、适合个人长期使用的任务系统。 +dodo 是纯自托管、面向个人长期使用的任务与生活管理 PWA。当前包含任务/子任务、文件夹与清单、今日视图、重复任务、习惯、倒数日、Markdown 备忘录、附件、会话管理、审计和数据备份;不提供番茄钟、自然语言建任务或外部通知渠道。 -## 当前边界 +## 技术与数据 -- 首版只做本地开发验证,不部署。 -- 首版不做通知渠道:Web Push、Telegram、SMTP 暂不实现。 -- 首版附件只做本地存储,不实现 S3。 -- 首版不做番茄钟。 -- 首版不做自然语言创建任务。 - -## 技术栈 - -- Monorepo:`frontend/`、`backend/` -- 前端:Vue 3 + TypeScript + Vite + Tailwind CSS + Shadcn-vue/Reka UI -- 后端:FastAPI + Pydantic v2 + SQLAlchemy 2 Async + Alembic -- 数据库:PostgreSQL,主键 UUIDv7,时间统一 UTC,用户配置时区 -- 包管理:uv + pnpm -- 交付:单 Docker 镜像,外部 PostgreSQL -- 许可证:AGPL-3.0 - -## 数据库 - -本地开发数据库: - -```text -postgresql+asyncpg://postgres:***@10.10.100.99:5433/dodo -``` - -已从默认 `postgres` 库迁移到独立 `dodo` 库。误建在 `postgres.public` 的 dodo 表已清理。 +- Monorepo:Vue 3 + TypeScript + Vite 前端,FastAPI + Pydantic v2 + SQLAlchemy 2 Async + Alembic 后端 +- PostgreSQL 生产、SQLite 测试;UUIDv7 主键,时间点使用 UTC,日历语义使用用户时区 +- 单 Docker 镜像,外部 PostgreSQL;AGPL-3.0 +- 用户业务读写必须按归属过滤;更新任务/备忘录使用乐观锁 ## 产品模型 -### 任务 +- 文件夹 → 清单 → 一层任务树;系统收集箱受保护 +- 清单删除定义为归档:保留任务成员关系,归档期间隐藏,恢复后原样出现;永久删除仅允许作用于已归档清单 +- 截止日期区分全天日期和具体时间;重复任务支持 RFC 5545 计划重复及“完成后重复” +- 习惯支持完成型/数值型、日/周/月/间隔计划、暂停、历史和归档 +- 倒数日支持公历/农历、生日/纪念日、重复、置顶与归档 +- 备忘录使用 Markdown,支持软删除、恢复及归档后永久删除 -- 文件夹 → 清单 → 任务 -- 系统内置收集箱,不允许删除 -- 任务支持一层子任务 -- 状态:未完成 / 已完成 -- 优先级:无 / 低 / 中 / 高 -- Markdown 描述 -- 截止日期 + 具体时间 -- 多提醒设计预留 -- 删除为软删除,回收站手动清空 -- 支持创建、修改、完成、恢复、删除操作历史 -- 并发编辑使用原子乐观锁 +## UI 决策 -### 重复任务 +- 桌面保留左导航/内容/可选详情三栏;移动端使用底部导航 +- 手机底栏固定为“今天、习惯、倒数日、设置”,精确匹配当前页面;不使用“更多”中转 +- 新建入口使用同一个普通圆形 Plus FAB,禁止装饰性光环或吉祥物 +- 设置页使用连续分组:数据、账户与安全、登录设备、活动、危险操作 +- 任务、习惯、倒数日、备忘录、操作菜单和确认框统一走 `AppSheet` / `AppDialog` 覆盖层栈;共享背景 inert、焦点陷阱、Escape、忙碌态和嵌套焦点恢复 +- 桌面任务/备忘录详情可保持非模态,移动端由同一组件切为底部模态弹层 -- RFC 5545 RRULE -- 模板 + 实例 -- 修改范围:仅本次 / 本次及以后 / 全部 -- 删除单次保存为例外 -- 每月 31 日在无 31 日月份跳过 -- 逾期完成不影响下次计划日期 +## 备份决策 -### 习惯 - -- 完成型 + 数值型 -- 每天 / 每周 / 每月 / 间隔天数 -- 数值型当日累计,达标后封顶 -- 允许补打和修改历史 -- 支持暂停区间,暂停期不破坏连续记录 -- 归档后保留历史统计 - -### 倒数纪念日 - -- 支持倒数日、纪念日、生日 -- 支持不重复、每周、每月、每年重复 -- 未来显示“还有 N 天”,当天显示“就是今天”,过去显示“已经 N 天” -- 支持单一置顶、归档恢复、编辑和删除 - -## UI 方向 - -- 手账生活感 -- 中高信息密度 -- 10–12px 中等圆角 -- 细分割线为主,少量浅底色 -- 强调色:`#F15A29` -- 只做浅色模式 -- 系统字体栈 -- 不使用猫猫元素 -- 轻微动效 - -## 页面结构 - -- 桌面三栏:左导航 / 中任务列表 / 右任务详情 -- 手机底部导航 -- 顶部快速输入,手机悬浮新增按钮 -- 桌面右侧详情栏,手机底部弹层 -- 习惯首页:今日习惯列表 + 一周打卡格 -- 搜索:顶部搜索框 + 全局搜索快捷键 +- “完整备份”专指 `dodo-backup` ZIP version 2,而不是旧 JSON/CSV +- v2 覆盖全部用户业务实体、历史/例外、附件元数据与附件字节;manifest 记录实体数量和每个条目的 SHA-256 +- 恢复必须先预检,再用绑定用户、文件摘要和模式的短期单次令牌执行 +- 预检拒绝未知/缺失实体、不安全 ZIP 路径、重复条目、异常压缩比/容量、校验和错误、非法字段、破坏关系拓扑或一层任务树的数据 +- `merge` 通过持久化 ID/摘要账本保证幂等与冲突可见;`replace` 仅替换当前用户业务数据 +- 附件恢复采用同文件系统暂存/隔离与补偿;数据库提交后的清理失败可用同一令牌重试清理,不会再次导入 +- 保留 JSON/CSV v1 恢复兼容,但明确其不包含日志、暂停、重复例外和附件字节,仅用于旧数据迁移 ## 工程质量 -- `/api/v1` API 路径 -- 统一错误码、可读提示和字段详情 -- 页面内诊断信息 + Toast -- readiness 检查数据库 -- 首版不提供 Prometheus metrics -- 审计日志记录操作人、实体、动作、时间和变更摘要 -- 手动 JSON 全量导出 -- 附件默认 20MB,可用环境变量调整 -- 默认允许常用文档与图片,拒绝危险文件类型 - -## 第一阶段验收 - -- 可初始化管理员 -- 可登录 -- 可创建清单和任务 -- 后端测试通过 -- 前端生产构建通过 -- PostgreSQL 迁移成功 -- 本地应用可以启动并访问 +- API 基路径 `/api/v1`;Cookie Session、同源 CSRF、安全响应头、登录限流 +- 附件与备份均有容量限制、路径包含检查和用户归属校验 +- 后端使用 pytest + ruff,前端使用 Vitest + vue-tsc/Vite;变更结束运行全量测试、构建和 `git diff --check` diff --git a/frontend/e2e/backup-roundtrip.spec.ts b/frontend/e2e/backup-roundtrip.spec.ts new file mode 100644 index 0000000..3a13c42 --- /dev/null +++ b/frontend/e2e/backup-roundtrip.spec.ts @@ -0,0 +1,115 @@ +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import type { APIRequestContext, Page } from '@playwright/test' +import { expect, test } from './fixtures' +import { unzipSync } from 'fflate' + +function bottomTab(page: Page, name: string) { + return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true }) +} + +async function csrf(request: APIRequestContext, baseURL: string) { + const state = await request.storageState() + return state.cookies.find(cookie => cookie.name === 'dodo_csrf' && baseURL.includes(cookie.domain))?.value + ?? state.cookies.find(cookie => cookie.name === 'dodo_csrf')?.value + ?? '' +} + +async function mutate(request: APIRequestContext, baseURL: string, path: string, options: Parameters[1]) { + const token = await csrf(request, baseURL) + return request.fetch(path, { ...options, headers: { ...options?.headers, 'x-csrf-token': token, origin: baseURL } }) +} + +test('complete ZIP backup preflights and replace-restores task, habit history, countdown, and attachment bytes', async ({ page, request, baseURL }, testInfo) => { + const suffix = testInfo.project.name + const taskTitle = `E2E 备份任务 ${suffix}` + const habitName = `E2E 备份习惯 ${suffix}` + const countdownTitle = `E2E 备份倒数日 ${suffix}` + const bootstrap = await request.get('/api/v1/bootstrap') + expect(bootstrap.ok()).toBeTruthy() + const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox) + expect(inbox).toBeTruthy() + + const task = await mutate(request, baseURL!, '/api/v1/tasks', { method: 'POST', data: { title: taskTitle, list_id: inbox.id } }) + expect(task.ok()).toBeTruthy() + const taskData = await task.json() as { id: string; version: number } + const attachmentName = `原始附件-${suffix}.txt` + const attachmentBytes = Buffer.from([0, 1, 2, 3, 10, 13, 127, 128, 254, 255]) + const attachmentResponse = await mutate(request, baseURL!, `/api/v1/tasks/${taskData.id}/attachments`, { + method: 'POST', multipart: { file: { name: attachmentName, mimeType: 'text/plain', buffer: attachmentBytes } }, + }) + expect(attachmentResponse.ok()).toBeTruthy() + const attachment = await attachmentResponse.json() as { id: string; filename: string; size: number; mime_type: string } + expect(attachment).toMatchObject({ filename: attachmentName, size: attachmentBytes.length, mime_type: 'text/plain' }) + const habitResponse = await mutate(request, baseURL!, '/api/v1/habits', { method: 'POST', data: { name: habitName, kind: 'numeric', target: 2, max_value: 3, schedule_type: 'daily' } }) + expect(habitResponse.ok()).toBeTruthy() + const habit = await habitResponse.json() + const day = new Date().toLocaleDateString('sv-SE') + expect((await mutate(request, baseURL!, `/api/v1/habits/${habit.id}/logs/${day}`, { method: 'PUT', data: { value: 2 } })).ok()).toBeTruthy() + const countdownResponse = await mutate(request, baseURL!, '/api/v1/countdowns', { method: 'POST', data: { title: countdownTitle, event_date: day, kind: 'countdown', repeat_rule: 'none', calendar_mode: 'solar', ignore_year: false } }) + expect(countdownResponse.ok()).toBeTruthy() + + await page.goto('/') + await bottomTab(page, '设置').click() + const downloadPromise = page.waitForEvent('download') + await page.getByRole('button', { name: '导出 ZIP' }).click() + const download = await downloadPromise + const zipPath = await download.path() + expect(zipPath).not.toBeNull() + const zipBytes = new Uint8Array(await readFile(zipPath!)) + const files = unzipSync(zipBytes) + const manifest = JSON.parse(new TextDecoder().decode(files['manifest.json'])) as { format: string; version: number; entities: Record; checksums: Record } + expect(manifest.format).toBe('dodo-backup') + expect(manifest.version).toBe(2) + expect(manifest.entities.tasks).toBeGreaterThan(0) + expect(manifest.entities.habit_logs).toBeGreaterThan(0) + expect(manifest.entities.attachments).toBeGreaterThan(0) + const attachmentRows = JSON.parse(new TextDecoder().decode(files['data/attachments.json'])) as Array<{ id: string; task_id: string; filename: string; mime_type: string; size: number; archive_path: string }> + const archivedAttachment = attachmentRows.find(item => item.id === attachment.id) + expect(archivedAttachment).toMatchObject({ task_id: taskData.id, filename: attachmentName, mime_type: 'text/plain', size: attachmentBytes.length }) + expect(Buffer.from(files[archivedAttachment!.archive_path])).toEqual(attachmentBytes) + expect(manifest.checksums[archivedAttachment!.archive_path]).toBe(createHash('sha256').update(attachmentBytes).digest('hex')) + for (const [entry, digest] of Object.entries(manifest.checksums)) { + expect(files[entry], `declared ZIP entry ${entry}`).toBeTruthy() + expect(createHash('sha256').update(files[entry]).digest('hex')).toBe(digest) + } + + expect((await mutate(request, baseURL!, `/api/v1/tasks/${taskData.id}`, { method: 'PATCH', data: { title: `${taskTitle} 已破坏`, version: taskData.version } })).ok()).toBeTruthy() + expect((await mutate(request, baseURL!, `/api/v1/habits/${habit.id}/logs/${day}`, { method: 'PUT', data: { value: 0 } })).ok()).toBeTruthy() + expect((await mutate(request, baseURL!, `/api/v1/countdowns/${(await countdownResponse.json()).id}`, { method: 'DELETE' })).ok()).toBeTruthy() + expect((await mutate(request, baseURL!, `/api/v1/attachments/${attachment.id}`, { method: 'DELETE' })).ok()).toBeTruthy() + expect(await (await request.get(`/api/v1/tasks/${taskData.id}/attachments`)).json()).toEqual([]) + + const chooser = page.locator('input[type=file]') + await chooser.setInputFiles({ name: 'dodo-backup-v2.zip', mimeType: 'application/zip', buffer: Buffer.from(zipBytes) }) + await page.getByLabel('恢复方式').selectOption('replace') + await page.getByRole('button', { name: '开始预检' }).click() + const preflight = page.locator('.backup-preflight') + await expect(preflight).toContainText('预检通过') + await expect(preflight).toContainText('附件') + await page.getByRole('button', { name: '替换并恢复' }).click() + const confirm = page.getByRole('dialog', { name: '确认替换全部数据?' }) + await confirm.getByRole('button', { name: '确认', exact: true }).click() + await expect(page.getByRole('status')).toContainText('数据已恢复') + + const restoredTasksResponse = await request.get(`/api/v1/tasks?q=${encodeURIComponent(taskTitle)}&limit=100`) + expect(restoredTasksResponse.ok()).toBeTruthy() + const restoredTasks = (await restoredTasksResponse.json()).items as Array<{ id: string; title: string }> + expect(restoredTasks.filter(item => item.title === taskTitle)).toHaveLength(1) + const restoredTask = restoredTasks.find(item => item.title === taskTitle)! + const restoredAttachmentsResponse = await request.get(`/api/v1/tasks/${restoredTask.id}/attachments`) + expect(restoredAttachmentsResponse.ok()).toBeTruthy() + const restoredAttachments = await restoredAttachmentsResponse.json() as Array<{ id: string; filename: string; mime_type: string; size: number }> + expect(restoredAttachments).toHaveLength(1) + expect(restoredAttachments[0]).toMatchObject({ filename: attachmentName, mime_type: 'text/plain', size: attachmentBytes.length }) + const restoredBlob = await request.get(`/api/v1/attachments/${restoredAttachments[0].id}`) + expect(restoredBlob.ok()).toBeTruthy() + expect(Buffer.from(await restoredBlob.body())).toEqual(attachmentBytes) + await bottomTab(page, '习惯').click() + const habitRow = page.locator('.habit-row').filter({ hasText: habitName }) + await habitRow.getByRole('button', { name: `查看习惯详情:${habitName}` }).click() + await expect(page.locator('.habit-history__row')).toContainText('2 / 2') + await page.getByRole('button', { name: '关闭习惯详情' }).click() + await bottomTab(page, '倒数日').click() + await expect(page.getByText(countdownTitle, { exact: true })).toHaveCount(1) +}) diff --git a/frontend/e2e/fixtures.ts b/frontend/e2e/fixtures.ts new file mode 100644 index 0000000..63462f2 --- /dev/null +++ b/frontend/e2e/fixtures.ts @@ -0,0 +1,29 @@ +import { expect, test as base } from '@playwright/test' + +export const expectedErrors = new WeakMap>() +export function allowExpectedError(page: object, fragment: string) { + expectedErrors.get(page)?.add(fragment) +} + +export const test = base.extend({ + page: async ({ page }, use) => { + const failures: string[] = [] + const allowed = new Set() + expectedErrors.set(page, allowed) + const record = (message: string) => { + if (![...allowed].some(pattern => message.includes(pattern))) failures.push(message) + } + page.on('pageerror', error => record(`pageerror: ${error.message}`)) + page.on('console', message => { if (message.type() === 'error') record(`console.error: ${message.text()}`) }) + page.on('requestfailed', request => record(`requestfailed: ${request.method()} ${request.url()} ${request.failure()?.errorText ?? ''}`)) + page.on('response', response => { + const url = new URL(response.url()) + const baseURL = new URL(page.url() || 'http://127.0.0.1:5173') + if (url.origin === baseURL.origin && response.status() >= 400) record(`http ${response.status()}: ${response.request().method()} ${url.pathname}`) + }) + await use(page) + expect(failures, 'unexpected browser/runtime errors').toEqual([]) + }, +}) + +export { expect } diff --git a/frontend/e2e/global-setup.ts b/frontend/e2e/global-setup.ts new file mode 100644 index 0000000..b872e59 --- /dev/null +++ b/frontend/e2e/global-setup.ts @@ -0,0 +1,37 @@ +import { chromium, type FullConfig } from '@playwright/test' +import { mkdirSync } from 'node:fs' +import path from 'node:path' + +export default async function globalSetup(config: FullConfig) { + const projectName = process.env.DODO_E2E_PROJECT + if (!projectName) throw new Error('DODO_E2E_PROJECT is required') + const baseURL = config.projects[0]?.use.baseURL as string + const storageState = path.resolve('playwright-runtime', projectName, 'auth.json') + mkdirSync(path.dirname(storageState), { recursive: true }) + + const browser = await chromium.launch() + try { + const context = await browser.newContext({ baseURL }) + const page = await context.newPage() + const readyDeadline = Date.now() + 90_000 + let ready = false + while (Date.now() < readyDeadline) { + try { + const [health, frontend, proxy] = await Promise.all([ + page.request.get('/health/ready'), page.request.get('/'), page.request.get('/api/v1/setup/status'), + ]) + if (health.ok() && frontend.ok() && proxy.ok()) { ready = true; break } + } catch {} + await new Promise(resolve => setTimeout(resolve, 250)) + } + if (!ready) throw new Error('isolated frontend/backend/proxy did not become ready') + const initialized = await page.request.get('/api/v1/setup/status') + if (!initialized.ok()) throw new Error(`setup status failed: ${initialized.status()}`) + if ((await initialized.json()).initialized) throw new Error(`isolated ${projectName} runtime was already initialized`) + const response = await page.request.post('/api/v1/setup/initialize', { data: { username: `e2e-owner-${projectName}`, password: 'e2e-password-1234' } }) + if (!response.ok()) throw new Error(`initialize failed: ${response.status()} ${await response.text()}`) + await context.storageState({ path: storageState }) + } finally { + await browser.close() + } +} diff --git a/frontend/e2e/mobile-journeys.spec.ts b/frontend/e2e/mobile-journeys.spec.ts new file mode 100644 index 0000000..1db5145 --- /dev/null +++ b/frontend/e2e/mobile-journeys.spec.ts @@ -0,0 +1,112 @@ +import type { Locator, Page } from '@playwright/test' +import { allowExpectedError, expect, test } from './fixtures' + +async function bottomTab(page: Page, name: string) { + return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true }) +} + +function taskRow(page: Page, title: string) { + return page.locator('.task-row').filter({ has: page.locator('strong', { hasText: title }) }) +} + +async function assertInsideViewport(locator: Locator, page: Page) { + const box = await locator.boundingBox() + const viewport = page.viewportSize() + expect(box).not.toBeNull() + expect(viewport).not.toBeNull() + expect(box!.x).toBeGreaterThanOrEqual(0) + expect(box!.y).toBeGreaterThanOrEqual(0) + expect(box!.x + box!.width).toBeLessThanOrEqual(viewport!.width + 1) + expect(box!.y + box!.height).toBeLessThanOrEqual(viewport!.height + 1) +} + +test('Today task persists through detail, completion and reopen', async ({ page }, testInfo) => { + const title = `E2E 今日任务 ${testInfo.project.name}` + await page.goto('/') + await expect(await bottomTab(page, '今天')).toHaveAttribute('aria-current', 'page') + await page.getByRole('button', { name: '添加任务' }).click() + await page.getByLabel('任务名称').fill(title) + await page.getByRole('button', { name: '添加任务', exact: true }).click() + + const row = taskRow(page, title) + await expect(row).toHaveCount(1) + const rowMain = row.locator('.task-main') + await rowMain.click() + const detail = page.getByRole('dialog', { name: '任务详情' }) + await expect(detail).toBeVisible() + await assertInsideViewport(detail, page) + await page.getByRole('button', { name: '关闭详情' }).click() + await expect(rowMain).toBeFocused() + + await row.getByRole('button', { name: `完成${title}` }).click() + await expect(row).toHaveClass(/done/) + await page.reload() + const persisted = taskRow(page, title) + await expect(persisted).toHaveCount(1) + await expect(persisted.getByRole('button', { name: `重新打开${title}` })).toBeVisible() + await persisted.getByRole('button', { name: `重新打开${title}` }).click() + await expect(persisted.getByRole('button', { name: `完成${title}` })).toBeVisible() +}) + +test('numeric habit records history, edits target, and continues', async ({ page }, testInfo) => { + const name = `E2E 数量习惯 ${testInfo.project.name}` + await page.goto('/') + await (await bottomTab(page, '习惯')).click() + await expect(await bottomTab(page, '习惯')).toHaveAttribute('aria-current', 'page') + await page.getByRole('button', { name: '添加习惯' }).click() + await page.getByLabel('新习惯名称').fill(name) + await page.getByLabel('习惯类型').selectOption('numeric') + await page.getByLabel('目标值').fill('2') + await page.getByRole('button', { name: '添加习惯', exact: true }).click() + + const row = page.locator('.habit-row').filter({ hasText: name }) + await expect(row).toHaveCount(1) + const check = row.getByRole('button', { name: `完成${name}一次` }) + await check.click() + await expect(row).toContainText('1 / 2') + await check.click() + await expect(row).toContainText('2 / 2') + await row.getByRole('button', { name: `查看习惯详情:${name}` }).click() + const detail = page.getByRole('dialog', { name: name }) + await expect(detail.getByRole('heading', { name: '历史记录' })).toBeVisible() + await expect(detail.locator('.habit-history__row')).toContainText('2 / 2') + await detail.getByRole('button', { name: '编辑习惯' }).click() + await page.getByLabel('目标值').fill('3') + await page.getByRole('button', { name: '保存修改' }).click() + await expect(row).toContainText('2 / 3') + await row.getByRole('button', { name: `完成${name}一次` }).click() + await expect(row).toContainText('3 / 3') + await page.reload() + const persistedRow = page.locator('.habit-row').filter({ hasText: name }) + await expect(persistedRow).toContainText('3 / 3') + await persistedRow.getByRole('button', { name: `查看习惯详情:${name}` }).click() + const persistedDetail = page.getByRole('dialog', { name }) + await expect(persistedDetail.locator('.habit-history__row')).toContainText('3 / 3') +}) + +test('countdown archive and restore keeps one entity after refresh', async ({ page }, testInfo) => { + const title = `E2E 倒数日 ${testInfo.project.name}` + await page.goto('/') + await (await bottomTab(page, '倒数日')).click() + await page.getByRole('button', { name: '添加倒数日' }).click() + await page.getByLabel('倒数日名称').fill(title) + await page.getByRole('button', { name: '保存', exact: true }).click() + + const item = page.getByRole('button').filter({ hasText: title }) + await expect(item).toHaveCount(1) + await item.click() + const detail = page.getByRole('dialog', { name: title }) + await expect(detail).toBeVisible() + // The UI intentionally aborts its DELETE fetch after the 204 response while closing the detail sheet. + allowExpectedError(page, 'requestfailed: DELETE http://127.0.0.1:5173/api/v1/countdowns/') + await Promise.all([ + page.waitForResponse(response => response.url().includes(`/api/v1/countdowns/`) && response.request().method() === 'DELETE' && response.status() === 204), + detail.getByRole('button', { name: '归档' }).click(), + ]) + await page.getByRole('button', { name: /已归档(1)/ }).click() + const archived = page.locator('.archived-countdowns article').filter({ hasText: title }) + await expect(archived).toHaveCount(1) + await archived.getByRole('button', { name: '恢复' }).click() + await page.reload() + await expect(page.getByText(title, { exact: true })).toHaveCount(1) +}) diff --git a/frontend/e2e/mobile-ui.spec.ts b/frontend/e2e/mobile-ui.spec.ts new file mode 100644 index 0000000..f2ed2fa --- /dev/null +++ b/frontend/e2e/mobile-ui.spec.ts @@ -0,0 +1,118 @@ +import type { Page } from '@playwright/test' +import { expect, test } from './fixtures' + +function bottomTab(page: Page, name: string) { + return page.getByRole('navigation', { name: '主要导航' }).getByRole('button', { name, exact: true }) +} + +async function openTaskComposer(page: Page) { + await page.getByRole('button', { name: '添加任务' }).click() + return page.getByRole('dialog', { name: /添加(?:今天)?任务/ }) +} + +test('settings are continuous, fit viewport, and controls are touch sized', async ({ page }) => { + await page.goto('/') + await bottomTab(page, '设置').click() + await expect(bottomTab(page, '设置')).toHaveAttribute('aria-current', 'page') + const groups = page.locator('.settings-group') + await expect(groups).toHaveCount(5) + const layout = await page.locator('.settings-sections').evaluate(element => { + const groups = [...element.querySelectorAll(':scope > .settings-group')] + return { + bodyOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + gaps: groups.slice(1).map((group, index) => group.getBoundingClientRect().top - groups[index].getBoundingClientRect().bottom), + } + }) + expect(layout.bodyOverflow).toBe(0) + expect(layout.gaps.every(gap => gap >= 0 && gap <= 20)).toBeTruthy() + const sessionButtons = page.getByRole('button', { name: /撤销会话|撤销其他会话/ }) + for (const target of await page.locator('.settings-row button, .settings-row .file-button, .settings-row select').all()) { + const box = await target.boundingBox() + expect(box).not.toBeNull() + expect(box!.width).toBeGreaterThanOrEqual(44) + expect(box!.height).toBeGreaterThanOrEqual(44) + } + for (const target of await sessionButtons.all()) { + const box = await target.boundingBox() + expect(box).not.toBeNull() + expect(box!.width).toBeGreaterThanOrEqual(44) + expect(box!.height).toBeGreaterThanOrEqual(44) + } +}) + +test('task, habit, countdown, memo, action and confirmation overlays share the modal contract', async ({ page }, testInfo) => { + await page.goto('/') + const assertModal = async (dialog: ReturnType) => { + await expect(dialog).toBeVisible() + await expect(page.locator('#app')).toHaveAttribute('inert', '') + const metrics = await dialog.evaluate(element => { + const panel = element as HTMLElement + const header = panel.querySelector('.app-sheet__header, header') + const footer = panel.querySelector('.app-sheet__footer, footer') + return { + horizontalOverflow: panel.scrollWidth - panel.clientWidth, + headerVisible: !header || header.getBoundingClientRect().top >= 0, + footerVisible: !footer || footer.getBoundingClientRect().bottom <= innerHeight + 1, + } + }) + expect(metrics).toEqual({ horizontalOverflow: 0, headerVisible: true, footerVisible: true }) + } + + await openTaskComposer(page) + await assertModal(page.getByRole('dialog', { name: /添加(?:今天)?任务/ })) + await page.keyboard.press('Escape') + await bottomTab(page, '习惯').click() + await page.getByRole('button', { name: '添加习惯' }).click() + await assertModal(page.getByRole('dialog', { name: '添加习惯' })) + await page.keyboard.press('Escape') + await bottomTab(page, '倒数日').click() + await page.getByRole('button', { name: '添加倒数日' }).click() + await assertModal(page.getByRole('dialog', { name: '新建倒数日' })) + await page.keyboard.press('Escape') + + await page.getByRole('button', { name: /展开菜单|收起菜单/ }).click() + await page.locator('.sidebar').getByRole('button', { name: '备忘录', exact: true }).click() + await page.getByRole('button', { name: '添加备忘录' }).click() + await assertModal(page.getByRole('dialog', { name: '备忘录详情' })) + await page.getByLabel('备忘录标题').fill(`未保存 ${testInfo.project.name}`) + await page.getByRole('button', { name: '关闭备忘录' }).click() + const confirmation = page.getByRole('dialog', { name: '放弃未保存的更改?' }) + await assertModal(confirmation) + await confirmation.getByRole('button', { name: '取消' }).click() + await expect(page.getByRole('dialog', { name: '备忘录详情' })).toBeVisible() + await expect(page.getByLabel('备忘录标题')).toBeFocused() +}) + +test('AppSheet traps focus, Escape closes, and scrim owns outside hit testing', async ({ page }) => { + await page.goto('/') + const opener = page.getByRole('button', { name: '添加任务' }) + await opener.focus() + const dialog = await openTaskComposer(page) + await expect(dialog).toBeVisible() + await expect(page.getByLabel('任务名称')).toBeFocused() + const background = page.locator('#app') + await expect(background).toHaveAttribute('aria-hidden', 'true') + await expect(background).toHaveAttribute('inert', '') + const geometry = await page.locator('.app-overlay').evaluate(element => { + const rect = element.getBoundingClientRect() + const hit = document.elementFromPoint(2, 2) + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height, hitIsScrim: hit === element } + }) + expect(geometry).toEqual({ x: 0, y: 0, width: page.viewportSize()!.width, height: page.viewportSize()!.height, hitIsScrim: true }) + + const focusable = dialog.locator('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [href], [tabindex]:not([tabindex="-1"])').filter({ visible: true }) + const first = focusable.first() + const last = focusable.last() + await first.focus() + await page.keyboard.press('Shift+Tab') + await expect(last).toBeFocused() + await page.keyboard.press('Tab') + await expect(first).toBeFocused() + await page.keyboard.press('Escape') + await expect(dialog).toBeHidden() + await expect(opener).toBeFocused() + + await openTaskComposer(page) + await page.mouse.click(2, 2) + await expect(page.getByRole('dialog', { name: /添加(?:今天)?任务/ })).toBeHidden() +}) diff --git a/frontend/package.json b/frontend/package.json index e5d9650..44ab839 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1 +1 @@ -{"name":"dodo-frontend","private":true,"version":"0.1.0","type":"module","packageManager":"pnpm@9.15.9","scripts":{"dev":"vite --host 0.0.0.0","build":"vue-tsc -b && vite build","test":"vitest run"},"dependencies":{"@vitejs/plugin-vue":"latest","class-variance-authority":"latest","clsx":"latest","lucide-vue-next":"^0.468.0","markdown-it":"^15.0.2","markdown-it-task-lists":"^2.1.1","reka-ui":"latest","tailwind-merge":"latest","vue":"latest","vue-router":"latest"},"devDependencies":{"@tailwindcss/vite":"latest","@types/markdown-it":"^14.2.0","@types/node":"latest","jsdom":"^30.0.1","tailwindcss":"latest","typescript":"^5.7.2","vite":"latest","vitest":"latest","vue-tsc":"latest"},"pnpm":{"onlyBuiltDependencies":["vue-demi"]}} \ No newline at end of file +{"name":"dodo-frontend","private":true,"version":"0.1.0","type":"module","packageManager":"pnpm@9.15.9","scripts":{"dev":"vite --host 0.0.0.0","build":"vue-tsc -b && vite build","test":"vitest run --exclude 'e2e/**'","test:e2e:mobile":"node scripts/playwright-mobile.mjs"},"dependencies":{"@vitejs/plugin-vue":"latest","class-variance-authority":"latest","clsx":"latest","lucide-vue-next":"^0.468.0","markdown-it":"^15.0.2","markdown-it-task-lists":"^2.1.1","reka-ui":"latest","tailwind-merge":"latest","vue":"latest","vue-router":"latest"},"devDependencies":{"@playwright/test":"^1.63.0","@tailwindcss/vite":"latest","@types/markdown-it":"^14.2.0","@types/node":"latest","fflate":"^0.8.3","jsdom":"^30.0.1","tailwindcss":"latest","typescript":"^5.7.2","vite":"latest","vitest":"latest","vue-tsc":"latest"},"pnpm":{"onlyBuiltDependencies":["vue-demi"]}} \ No newline at end of file diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts new file mode 100644 index 0000000..f466e19 --- /dev/null +++ b/frontend/playwright.config.ts @@ -0,0 +1,41 @@ +import { defineConfig } from '@playwright/test' +import path from 'node:path' + +const projectName = process.env.DODO_E2E_PROJECT +if (!projectName) throw new Error('DODO_E2E_PROJECT is required; use pnpm test:e2e:mobile') +const project = projectName === 'mobile-390' + ? { name: 'mobile-390', use: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true } } + : projectName === 'mobile-375' + ? { name: 'mobile-375', testIgnore: /backup-roundtrip\.spec\.ts/, use: { viewport: { width: 375, height: 667 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } } + : null +if (!project) throw new Error(`unknown DODO_E2E_PROJECT: ${projectName}`) + +const runtimeRoot = path.resolve('playwright-runtime', projectName) + +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + workers: 1, + timeout: 45_000, + expect: { timeout: 8_000 }, + outputDir: path.join(runtimeRoot, 'test-results'), + reporter: [['line'], ['html', { outputFolder: path.join(runtimeRoot, 'report'), open: 'never' }]], + globalSetup: './e2e/global-setup.ts', + use: { + baseURL: 'http://127.0.0.1:5173', + storageState: path.join(runtimeRoot, 'auth.json'), + reducedMotion: 'reduce', + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [project], + webServer: { + command: 'node scripts/playwright-mobile-server.mjs', + url: 'http://127.0.0.1:5173', + reuseExistingServer: false, + timeout: 120_000, + stdout: 'pipe', + stderr: 'pipe', + }, +}) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index f5a122d..df28aa3 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: specifier: latest version: 5.3.1(@vue/compiler-sfc@3.5.42)(rolldown@1.2.7)(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0))(vue@3.5.42(typescript@5.9.3)) devDependencies: + '@playwright/test': + specifier: ^1.63.0 + version: 1.63.0 '@tailwindcss/vite': specifier: latest version: 4.3.3(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)) @@ -48,6 +51,9 @@ importers: '@types/node': specifier: latest version: 26.4.1 + fflate: + specifier: ^0.8.3 + version: 0.8.3 jsdom: specifier: ^30.0.1 version: 30.0.1 @@ -180,6 +186,11 @@ packages: '@oxc-project/types@0.148.0': resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} + hasBin: true + '@rolldown/binding-android-arm-eabi@1.2.7': resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -614,6 +625,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -878,6 +892,16 @@ packages: pkg-types@2.3.2: resolution: {integrity: sha512-v0sVXzj7oPGysr543YYZLYbcJNJsKikSsp/fFzoxQ12ewY3ZZr7oCPC8y7OlmxfYB3QPvriXmuPD8KZggE1vqg==} + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + postcss@8.5.28: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} @@ -1303,6 +1327,10 @@ snapshots: '@oxc-project/types@0.148.0': {} + '@playwright/test@1.63.0': + dependencies: + playwright: 1.63.0 + '@rolldown/binding-android-arm-eabi@1.2.7': optional: true @@ -1674,6 +1702,8 @@ snapshots: optionalDependencies: picomatch: 4.0.7 + fflate@0.8.3: {} + fsevents@2.3.3: optional: true @@ -1901,6 +1931,12 @@ snapshots: exsolve: 1.1.1 pathe: 2.0.3 + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + postcss@8.5.28: dependencies: nanoid: 3.3.18 diff --git a/frontend/scripts/playwright-mobile-server.mjs b/frontend/scripts/playwright-mobile-server.mjs new file mode 100644 index 0000000..3dc0933 --- /dev/null +++ b/frontend/scripts/playwright-mobile-server.mjs @@ -0,0 +1,57 @@ +import { spawn } from 'node:child_process' +import { createWriteStream, mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' + +const frontend = process.cwd() +const root = path.resolve(frontend, '..') +const project = process.env.DODO_E2E_PROJECT +if (!project) throw new Error('DODO_E2E_PROJECT is required') +const runtimeBase = path.join(frontend, 'playwright-runtime', project) +const runtime = path.join(runtimeBase, `run-${new Date().toISOString().replace(/[:.]/g, '-')}-${process.pid}`) +const attachments = path.join(runtime, 'attachments') +const staging = path.join(runtime, 'backup-staging') +mkdirSync(attachments, { recursive: true }) +mkdirSync(staging, { recursive: true }) +writeFileSync(path.join(runtimeBase, 'latest.json'), JSON.stringify({ runtime, database: path.join(runtime, 'dodo.sqlite3'), attachments, staging }, null, 2)) +writeFileSync(path.join(runtime, 'runtime.json'), JSON.stringify({ database: path.join(runtime, 'dodo.sqlite3'), attachments, staging }, null, 2)) + +const logs = { + backend: createWriteStream(path.join(runtime, 'backend.log'), { flags: 'a' }), + frontend: createWriteStream(path.join(runtime, 'frontend.log'), { flags: 'a' }), +} +const children = [] +function start(command, args, options, log) { + const child = spawn(command, args, { ...options, stdio: ['ignore', 'pipe', 'pipe'] }) + child.stdout.pipe(log) + child.stderr.pipe(log) + children.push(child) + return child +} + +start(path.join(root, '.venv/bin/uvicorn'), ['backend.main:app', '--host', '127.0.0.1', '--port', '8781'], { + cwd: root, + env: { + ...process.env, + DODO_DATABASE_URL: `sqlite+aiosqlite:///${path.join(runtime, 'dodo.sqlite3')}`, + DODO_AUTO_CREATE_SCHEMA: 'true', + DODO_COOKIE_SECURE: 'false', + DODO_ATTACHMENT_DIR: attachments, + DODO_BACKUP_STAGING_DIR: staging, + }, +}, logs.backend) +start('pnpm', ['exec', 'vite', '--host', '127.0.0.1', '--port', '5173', '--strictPort'], { cwd: frontend, env: process.env }, logs.frontend) + +let stopping = false +function stop(signal = 'SIGTERM') { + if (stopping) return + stopping = true + for (const child of children) if (!child.killed) child.kill(signal) + setTimeout(() => { for (const child of children) if (!child.killed) child.kill('SIGKILL') }, 3000).unref() +} +process.on('SIGTERM', () => stop()) +process.on('SIGINT', () => stop()) +process.on('exit', () => stop()) + +await Promise.all(children.map(child => new Promise((resolve, reject) => { + child.once('exit', (code, signal) => stopping ? resolve() : reject(new Error(`server exited code=${code} signal=${signal}`))) +}))) diff --git a/frontend/scripts/playwright-mobile.mjs b/frontend/scripts/playwright-mobile.mjs new file mode 100644 index 0000000..4b9c12d --- /dev/null +++ b/frontend/scripts/playwright-mobile.mjs @@ -0,0 +1,15 @@ +import { spawn } from 'node:child_process' + +const projects = ['mobile-390', 'mobile-375'] +for (const project of projects) { + const code = await new Promise((resolve, reject) => { + const child = spawn('pnpm', ['exec', 'playwright', 'test', '--project', project], { + cwd: process.cwd(), + env: { ...process.env, DODO_E2E_PROJECT: project }, + stdio: 'inherit', + }) + child.once('error', reject) + child.once('exit', value => resolve(value ?? 1)) + }) + if (code !== 0) process.exit(code) +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 6d37d27..2c2c5a9 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -11,7 +11,6 @@ import { csrfHeader } from './lib/csrf' import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion' import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag' import { clampSearchPullDistance, isAtSearchPullOrigin, isSearchShortcut, shouldHideSearchAfterSwipe, shouldRevealSearchAfterPull } from './lib/mobile-search' -import { nextDialogFocusIndex } from './lib/list-purge' import { deriveMemoShellState } from './lib/app-shell-state' import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu' import MvpPanel from './MvpPanel.vue' @@ -22,6 +21,8 @@ import CompletedFilterPill from './components/CompletedFilterPill.vue' import CalendarPicker from './components/CalendarPicker.vue' import TaskDueDisplay from './components/TaskDueDisplay.vue' import TodayEnvironmentStrip, { type TodayEnvironment } from './components/TodayEnvironmentStrip.vue' +import AppSheet from './components/AppSheet.vue' +import AppDialog, { type AppDialogOptions } from './components/AppDialog.vue' import { shanghaiDateKey, useTaskDueClock, watchShanghaiDateRollover } from './lib/task-due-clock' import { readTodaySectionCollapse, writeTodaySectionCollapse, type TodaySectionCollapse } from './lib/today-section-collapse' @@ -49,8 +50,6 @@ let archivedListActionTrigger: HTMLElement | null = null 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([]) @@ -93,7 +92,6 @@ const taskDueNowMs = useTaskDueClock() const mobileSidebar = ref(false) const sidebarCollapsed = ref(false) const mobileDetail = ref(false) -const mobileMore = ref(false) const moreSettingsOpen = ref(false) const markdownPreview = ref(false) const taskNoteEditor = ref(null) @@ -296,39 +294,16 @@ function toggleSidebar() { } } -const modalVisible = ref(false) -const modalTitle = ref('') -const modalLabel = ref('') -const modalValue = ref('') -const modalError = ref('') -const modalConfirmText = ref('确定') -const modalResolve = ref<((value: string | null) => void) | null>(null) -function askText(title: string, label = '', initial = '', confirmText = '确定') { - return new Promise((resolve) => { - modalTitle.value = title - modalLabel.value = label - modalValue.value = initial - modalError.value = '' - modalConfirmText.value = confirmText - modalVisible.value = true - modalResolve.value = resolve +const appDialog = ref<{ show: (options: AppDialogOptions) => Promise } | null>(null) +async function confirmAction(title: string, description?: string, danger = false) { + return await appDialog.value?.show({ title, description, danger, confirmText: danger ? '确认' : '确定' }) === true +} +async function askText(title: string, label = '', initial = '', confirmText = '确定') { + const result = await appDialog.value?.show({ + title, label, initial, confirmText, + validate: label ? (value) => normalizeRequiredName(value).error : undefined, }) -} -function closeModal() { - modalVisible.value = false - if (modalResolve.value) { modalResolve.value(null); modalResolve.value = null } -} -function confirmModal() { - if (modalLabel.value) { - const normalized = normalizeRequiredName(modalValue.value) - if (normalized.error) { - modalError.value = normalized.error - return - } - modalValue.value = normalized.value - } - modalVisible.value = false - if (modalResolve.value) { modalResolve.value(modalValue.value); modalResolve.value = null } + return typeof result === 'string' ? result.trim() : null } const activeName = computed(() => { @@ -679,7 +654,7 @@ async function loadTrash() { }) } async function switchView(view: View, listId?: string) { - if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !window.confirm('有未保存的更改,确定离开吗?')) return + if (activeView.value === 'memos' && view !== 'memos' && memoPanel.value?.dirty && !(await confirmAction('有未保存的更改', '确定离开当前备忘录吗?'))) return taskMutationNavigation.value += 1 taskReorderMode.value = false cancelTaskReorder() @@ -699,7 +674,7 @@ async function switchView(view: View, listId?: string) { if (listId) activeList.value = listId writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value) page.value = 1 - selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false; mobileMore.value = false; taskComposeOpen.value = false; sidebarCreateOpen.value = false; sidebarAction.value = null + selectedTask.value = null; mobileSidebar.value = false; mobileDetail.value = false; taskComposeOpen.value = false; sidebarCreateOpen.value = false; sidebarAction.value = null if (view !== 'memos') memoDetailOpen.value = false if (view === 'trash') await loadTrash() else if (view === 'today') await loadTodayView() @@ -983,7 +958,7 @@ async function saveSelectedTaskChanges() { } } async function removeTask(task: Task) { - if (!window.confirm(`把“${task.title}”移到回收站?`)) return + if (!(await confirmAction(`把“${task.title}”移到回收站?`, undefined, true))) return try { await api(`/tasks/${task.id}`, { method: 'DELETE' }) tasks.value = tasks.value.filter((item) => item.id !== task.id && item.parent_id !== task.id) @@ -1014,7 +989,7 @@ async function restoreTask(task: Task) { await mutateTrashTask(task, () => api(`/tasks/${task.id}/restore`, { method: 'POST' }), '任务已恢复') } async function purgeTask(task: Task) { - if (!window.confirm(`永久删除“${task.title}”?这个操作不能撤销。`)) return + if (!(await confirmAction(`永久删除“${task.title}”?`, '这个操作不能撤销。', true))) return await mutateTrashTask(task, () => api(`/trash/${task.id}`, { method: 'DELETE' }), '任务已永久删除') } async function addSubtask() { @@ -1151,7 +1126,6 @@ function openPurgeList(item: TaskList) { purgeListError.value = '' archivedListAction.value = null archivedListActionTrigger = null - nextTick(() => purgeCancelButton.value?.focus()) } function focusPurgeListTrigger() { const target = purgeListTrigger?.isConnected ? purgeListTrigger : archivedListsToggle.value @@ -1164,15 +1138,6 @@ function closePurgeList() { purgeListError.value = '' focusPurgeListTrigger() } -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 @@ -1467,8 +1432,8 @@ onUnmounted(() => { -