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
14 KiB
14 KiB
WebDAV 同步功能设计文档
日期: 2026-05-17
1. 概述
目标: 支持通过 WebDAV(Alist)在多设备间同步所有待办数据。
成功指标:
- 可以配置 Alist WebDAV 连接并测试连通性
- 支持 push(本地→远端)、pull(远端→本地)、sync(双向合并)三种同步方向
- 同步期间禁止所有前端写操作,显示同步遮罩
- 数据不会因同步而丢失(自动备份机制)
范围内: 所有数据模型的全量同步 范围外: 增量同步、实时同步、冲突解决 UI、多用户协作
2. 架构
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 前端 (Vue) │────▶│ 后端 (FastAPI) │────▶│ Alist WebDAV │
│ 同步设置页面 │ │ 同步 API + 锁 │ │ JSON 文件存储 │
└──────────────┘ └──────────────┘ └──────────────┘
│
▼
┌──────────────┐
│ PostgreSQL │
│ (本地数据) │
└──────────────┘
数据流
- 前端发起同步请求 (push/pull/sync)
- 后端获取同步锁 → 禁止其他写操作
- 从 PostgreSQL 读取/写入本地数据
- 通过 WebDAV HTTP 客户端与 Alist 交互(读写 JSON 文件)
- 释放同步锁 → 前端恢复正常操作
- 前端轮询同步状态,显示进度
3. 数据模型变更
3.1 所有可同步模型新增字段
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
uuid |
String(36) |
UUID4 自动生成 | 全局唯一标识,用于同步匹配 |
sync_version |
Integer |
1 |
每次修改 +1,用于 LWW 判定 |
is_deleted |
Boolean |
False |
软删除墓碑标记 |
需要变更的模型:
TaskCategoryTagHabitGroupHabitHabitCheckinAnniversaryCategoryAnniversaryGoalGoalStepGoalReview
关联表 (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列需要为已有记录回填 UUID4sync_version列默认设为 1is_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/configsaveConfig(config): PUT /api/sync/configtestConnection(): POST /api/sync/teststartSync(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/ |