# WebDAV 同步功能设计文档 日期: 2026-05-17 ## 1. 概述 **目标**: 支持通过 WebDAV(Alist)在多设备间同步所有待办数据。 **成功指标**: - 可以配置 Alist WebDAV 连接并测试连通性 - 支持 push(本地→远端)、pull(远端→本地)、sync(双向合并)三种同步方向 - 同步期间禁止所有前端写操作,显示同步遮罩 - 数据不会因同步而丢失(自动备份机制) **范围内**: 所有数据模型的全量同步 **范围外**: 增量同步、实时同步、冲突解决 UI、多用户协作 ## 2. 架构 ``` ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ 前端 (Vue) │────▶│ 后端 (FastAPI) │────▶│ Alist WebDAV │ │ 同步设置页面 │ │ 同步 API + 锁 │ │ JSON 文件存储 │ └──────────────┘ └──────────────┘ └──────────────┘ │ ▼ ┌──────────────┐ │ PostgreSQL │ │ (本地数据) │ └──────────────┘ ``` ### 数据流 1. 前端发起同步请求 (push/pull/sync) 2. 后端获取同步锁 → 禁止其他写操作 3. 从 PostgreSQL 读取/写入本地数据 4. 通过 WebDAV HTTP 客户端与 Alist 交互(读写 JSON 文件) 5. 释放同步锁 → 前端恢复正常操作 6. 前端轮询同步状态,显示进度 ## 3. 数据模型变更 ### 3.1 所有可同步模型新增字段 | 字段 | 类型 | 默认值 | 说明 | |------|------|--------|------| | `uuid` | `String(36)` | UUID4 自动生成 | 全局唯一标识,用于同步匹配 | | `sync_version` | `Integer` | `1` | 每次修改 +1,用于 LWW 判定 | | `is_deleted` | `Boolean` | `False` | 软删除墓碑标记 | **需要变更的模型**: - `Task` - `Category` - `Tag` - `HabitGroup` - `Habit` - `HabitCheckin` - `AnniversaryCategory` - `Anniversary` - `Goal` - `GoalStep` - `GoalReview` **关联表** (`task_tags`, `goal_tasks`): 不加字段,序列化时带上两边实体的 uuid,反序列化时用 uuid 查找对应本地 ID 重建关联。 ### 3.2 新增 SyncSettings 模型 ```python class SyncSettings(Base): __tablename__ = "sync_settings" id = Column(Integer, primary_key=True, default=1) # WebDAV 连接配置 webdav_url = Column(String(500), nullable=True) webdav_username = Column(String(200), nullable=True) webdav_password = Column(String(500), nullable=True) # AES-256-GCM 加密存储 webdav_path = Column(String(200), default="/elysia-todo/") # 同步状态 sync_enabled = Column(Boolean, default=False) last_sync_at = Column(DateTime, nullable=True) last_sync_version = Column(Integer, default=0) auto_sync = Column(Boolean, default=False) auto_sync_interval = Column(Integer, default=300) # 秒 # 创建时间 created_at = Column(DateTime, default=utcnow) updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) ``` ### 3.3 现有模型自动迁移 `init_db()` 已有的 `ALTER TABLE ADD COLUMN` 机制会自动为现有表添加新列。需要注意: - `uuid` 列需要为已有记录回填 UUID4 - `sync_version` 列默认设为 1 - `is_deleted` 列默认设为 False ## 4. AES 加密方案 ```python # api/app/utils/crypto.py 算法: AES-256-GCM (认证加密) 密钥派生: PBKDF2-SHA256(JWT_SECRET, salt="elysia-todo-sync", iterations=480000) 存储格式: base64(iv[12] + ciphertext + tag[16]) ``` - 密钥从现有的 `JWT_SECRET` 派生,无需额外的密钥管理 - 旋转 JWT_SECRET 时旧密码解密失败 → 需要重新配置 WebDAV 密码 - 解密失败时返回 Null,前端提示 "请重新配置 WebDAV 密码" ## 5. WebDAV 文件结构 ``` /elysia-todo/ ├── manifest.json # 同步元数据 ├── data/ │ ├── user_settings.json # 仅偏好字段,不含密码 │ ├── categories.json │ ├── tasks.json │ ├── tags.json │ ├── task_tags.json # [{task_uuid, tag_uuid}] │ ├── habit_groups.json │ ├── habits.json │ ├── habit_checkins.json │ ├── anniversary_categories.json │ ├── anniversaries.json │ ├── goals.json │ ├── goal_steps.json │ ├── goal_reviews.json │ └── goal_tasks.json # [{goal_uuid, task_uuid}] └── backups/ └── 2026-05-17T10-00-00/ # push 前自动备份远端数据 └── data/ └── ... (被覆盖前的快照) ``` ### manifest.json 格式 ```json { "version": 1, "last_sync_at": "2026-05-17T10:00:00Z", "collections": { "categories": { "count": 5, "updated_at": "2026-05-17T10:00:00Z" }, "tasks": { "count": 42, "updated_at": "2026-05-17T10:00:00Z" }, ... } } ``` ### data/*.json 格式 ```json { "version": 1, "collection": "tasks", "updated_at": "2026-05-17T10:00:00Z", "items": [ { "uuid": "a1b2c3d4-...", "sync_version": 3, "is_deleted": false, "id": 1, "title": "买牛奶", ... } ] } ``` ## 6. 同步协议 ### 6.1 Push(本地 → 远端) ``` 1. 获取同步锁 2. 备份远端当前数据到 /backups/{timestamp}/ 3. 从 PostgreSQL 读取所有本地数据 4. 序列化为 JSON 文件 5. 逐个 PUT 到 WebDAV(先 data/,再 manifest.json) 6. 更新本地 sync_settings.last_sync_at 7. 释放同步锁 ``` ### 6.2 Pull(远端 → 本地) ``` 1. 获取同步锁 2. 备份本地数据到 api/data/backups/{timestamp}/ (JSON 快照) 3. 从 WebDAV GET manifest.json 4. 逐个 GET data/*.json 5. 清空本地数据库(DELETE 所有表) 6. 按 FK 依赖顺序插入远端数据: user_settings → categories → tags → habits/habit_groups → ... → task_tags/goal_tasks 7. 为缺少 uuid 的记录生成 uuid 8. 更新 sync_settings.last_sync_at 9. 释放同步锁 ``` ### 6.3 Sync(双向合并,LWW) ``` 1. 获取同步锁 2. 从 WebDAV GET manifest.json + data/*.json (远端快照) 3. 从 PostgreSQL 读取本地数据 (本地快照) 4. 对每个 collection 做合并: a. 以 uuid 为 key 建立两边的索引 b. 遍历所有 uuid 的并集: - 仅本地有 → 推送到远端 - 仅远端有 → 插入本地(分配新本地 ID) - 两边都有: - compare sync_version: 大的覆盖小的 - sync_version 相同 → 以远端为准 - 任何一边 is_deleted=True → 在两边都标记删除 c. 关联表: 合并去重 (以 uuid 对组合为 key) 5. 将合并结果写回本地 DB 和远端 WebDAV 6. 更新 sync_settings.last_sync_at 7. 释放同步锁 ``` ### 6.4 冲突策略 - **LWW (Last Write Wins)**: 比较 `sync_version`,数值大的赢 - **同版本冲突**: 以远端为准 - **删除传播**: `is_deleted=True` 的墓碑会在双向同步中传播,不对已删除记录做内容合并 - **墓碑清理**: 不自动清理,后续可加手动清理功能 ## 7. 全局同步锁 ```python # api/app/utils/sync_lock.py _sync_lock = threading.Lock() _sync_in_progress = False def acquire_sync_lock() -> bool: """非阻塞获取同步锁""" acquired = _sync_lock.acquire(blocking=False) if acquired: global _sync_in_progress _sync_in_progress = True return acquired def release_sync_lock(): global _sync_in_progress _sync_in_progress = False _sync_lock.release() def is_syncing() -> bool: return _sync_in_progress ``` ### 中间件拦截 在 `auth_middleware` 之后、路由处理之前,检查同步状态: ```python # 如果正在同步,对所有 /api/* 写请求(非 /api/sync/*)返回 503 if is_syncing() and request.method in ("POST", "PUT", "PATCH", "DELETE"): if not path.startswith("/api/sync"): return JSONResponse(status_code=503, content={"detail": "正在同步,请稍后"}) ``` ## 8. API 端点 | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/sync/config` | 获取 WebDAV 配置(密码脱敏为 `***`) | | PUT | `/api/sync/config` | 保存 WebDAV 配置(密码 AES 加密存储) | | POST | `/api/sync/test` | 测试 WebDAV 连接 | | POST | `/api/sync/push` | 推送本地数据到远端 | | POST | `/api/sync/pull` | 从远端拉取数据覆盖本地 | | POST | `/api/sync/sync` | 双向合并同步 | | GET | `/api/sync/status` | 查询同步状态(是否正在同步、上次时间、版本号) | | DELETE | `/api/sync/remote` | 清空远端数据(需二次确认) | ### 请求/响应示例 **PUT /api/sync/config** ```json { "webdav_url": "https://alist.example.com/dav", "webdav_username": "user", "webdav_password": "mypassword", "webdav_path": "/elysia-todo/", "auto_sync": false, "auto_sync_interval": 300 } ``` **GET /api/sync/config** (响应,密码脱敏) ```json { "webdav_url": "https://alist.example.com/dav", "webdav_username": "user", "webdav_password": "***", "webdav_path": "/elysia-todo/", "sync_enabled": true, "last_sync_at": "2026-05-17T10:00:00Z", "auto_sync": false, "auto_sync_interval": 300 } ``` **GET /api/sync/status** ```json { "syncing": false, "last_sync_at": "2026-05-17T10:00:00Z", "last_sync_version": 15, "sync_enabled": true } ``` ## 9. user_settings 同步范围 **同步**: nickname, avatar, signature, birthday, email, site_name, theme, language, default_view, default_sort_by, default_sort_order **不同步**: password_hash, token_version, id, created_at, updated_at ## 10. 前端设计 ### 设置页面新增 "数据同步" 标签页 ``` ┌─────────────────────────────────────────┐ │ 数据同步 │ ├─────────────────────────────────────────┤ │ WebDAV 配置 │ │ ┌─────────────────────────────────────┐ │ │ │ 服务器地址: [_____________________] │ │ │ │ 用户名: [_____________________] │ │ │ │ 密码: [••••••••••] [测试连接] │ │ │ │ 远端路径: [/elysia-todo/______] │ │ │ └─────────────────────────────────────┘ │ │ │ │ 同步操作 │ │ ┌─────────────────────────────────────┐ │ │ │ ○ 推送 (本地 → 远端) [危险操作] │ │ │ │ ○ 拉取 (远端 → 本地) [危险操作] │ │ │ │ ● 双向合并 (推荐) │ │ │ │ │ │ │ │ [开始同步] │ │ │ └─────────────────────────────────────┘ │ │ │ │ 自动同步 │ │ ┌─────────────────────────────────────┐ │ │ │ [开关] 自动同步 间隔: [300] 秒 │ │ │ └─────────────────────────────────────┘ │ │ │ │ 上次同步: 2026-05-17 10:00:00 │ └─────────────────────────────────────────┘ ``` ### 同步遮罩 UI 同步进行中时,全屏半透明遮罩: - "正在同步数据,请勿关闭页面..." - 进度指示:正在处理哪个 collection - 禁止所有操作 ### 前端 Store `useSyncStore.ts`: - `config`: WebDAV 配置 - `syncing`: 是否正在同步 - `progress`: 同步进度信息 - `fetchConfig()`: GET /api/sync/config - `saveConfig(config)`: PUT /api/sync/config - `testConnection()`: POST /api/sync/test - `startSync(direction)`: POST /api/sync/{direction} - `pollStatus()`: 轮询 GET /api/sync/status ## 11. 依赖项 ### Python 新增依赖 ``` webdavclient3>=4.0 # WebDAV 客户端 pycryptodome>=3.20 # AES-256-GCM 加密 (如不用 hashlib + cryptography) ``` 实际上可以用 `requests` 手写 WebDAV 操作(PUT/GET/PROPFIND/MKCOL),避免 `webdavclient3` 的兼容性问题。Alist 的 WebDAV 实现比较标准,用 `requests` 就够了。 **决定**: 使用 `requests` + 手写 WebDAV 操作,不引入额外 WebDAV 库。 ## 12. 文件结构 ### 后端新增文件 ``` api/app/ ├── models/ │ └── sync_settings.py # 新增 ├── schemas/ │ └── sync.py # 新增 ├── routers/ │ └── sync.py # 新增 └── utils/ ├── crypto.py # 新增: AES-256-GCM 加解密 ├── sync_lock.py # 新增: 全局同步锁 ├── webdav.py # 新增: WebDAV 客户端 └── sync_service.py # 新增: 同步核心逻辑 ``` ### 前端新增文件 ``` WebUI/src/ ├── api/ │ └── sync.ts # 新增: 同步 API ├── stores/ │ └── useSyncStore.ts # 新增: 同步状态管理 └── views/ └── settings/ └── SyncView.vue # 新增: 同步设置页面 ``` ## 13. 风险与注意 | 风险 | 缓解措施 | |------|----------| | 同步锁粒度过粗(锁住全部写操作) | 单用户 App,锁住期间显示遮罩,体验可接受 | | 大数据量同步超时 | 设置合理的 requests timeout,分片上传 | | Alist WebDAV 兼容性 | 用标准 HTTP 方法 (PUT/GET/MKCOL/DELETE),避免非标准扩展 | | JWT_SECRET 旋转导致密码解密失败 | 前端提示 "请重新配置 WebDAV 密码" | | 并发同步 | 非阻塞锁,同一时间只允许一个同步过程 | | pull 操作清空本地数据 | pull 前自动备份到 api/data/backups/ |