Files
ToDoList/docs/plan/webdav-sync-design.md
祀梦 0ab719500b feat: add WebDAV sync support and startup/shutdown scripts
Backend:
- Add uuid, sync_version, is_deleted fields to all syncable models
- Add SyncSettings model for WebDAV configuration (AES-256-GCM encrypted passwords)
- Add crypto.py: AES-256-GCM encryption derived from JWT_SECRET via PBKDF2
- Add sync_lock.py: thread-level sync lock with 503 middleware for write blocking
- Add webdav.py: WebDAV client using requests (PUT/GET/MKCOL/DELETE)
- Add sync_service.py: push/pull/bidirectional merge with LWW conflict resolution
- Add sync router with 8 endpoints: config, test, push, pull, sync, status, remote delete
- Add UUID backfill for existing records in init_db()
- Add SQLAlchemy before_update event to auto-increment sync_version
- Register sync middleware to block writes during sync (503)

Frontend:
- Add sync API client (WebUI/src/api/sync.ts)
- Add useSyncStore with config, test, push/pull/sync operations
- Add WebDAV config + sync UI in SettingsView
- Add 503 status code handling in axios interceptor
- Add uuid field to all TypeScript type definitions

Scripts:
- Add scripts/start.bat and scripts/stop.bat for project management

Design doc: docs/plan/webdav-sync-design.md
2026-05-17 21:18:54 +08:00

14 KiB
Raw Blame History

WebDAV 同步功能设计文档

日期: 2026-05-17

1. 概述

目标: 支持通过 WebDAVAlist在多设备间同步所有待办数据。

成功指标:

  • 可以配置 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 模型

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 加密方案

# 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 格式

{
  "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 格式

{
  "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. 全局同步锁

# 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 之后、路由处理之前,检查同步状态:

# 如果正在同步,对所有 /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

{
  "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 (响应,密码脱敏)

{
  "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

{
  "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/