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

431 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 模型
```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/ |