diff --git a/WebUI/src/api/request.ts b/WebUI/src/api/request.ts index 4a92b04..edc74a9 100644 --- a/WebUI/src/api/request.ts +++ b/WebUI/src/api/request.ts @@ -43,6 +43,9 @@ instance.interceptors.response.use( case 429: message = data?.detail || '请求过于频繁,请稍后再试~' break + case 503: + message = data?.detail || '正在同步数据,请稍后再试~' + break case 500: message = '服务器内部错误~' break diff --git a/WebUI/src/api/sync.ts b/WebUI/src/api/sync.ts new file mode 100644 index 0000000..42b362e --- /dev/null +++ b/WebUI/src/api/sync.ts @@ -0,0 +1,52 @@ +import { get, put, post, del } from './request' + +export interface SyncConfig { + webdav_url: string | null + webdav_username: string | null + webdav_password: string | null + webdav_path: string + sync_enabled: boolean + auto_sync: boolean + auto_sync_interval: number + last_sync_at: string | null + last_sync_version: number +} + +export interface SyncConfigUpdate { + webdav_url?: string | null + webdav_username?: string | null + webdav_password?: string | null + webdav_path?: string + auto_sync?: boolean + auto_sync_interval?: number +} + +export interface SyncStatus { + syncing: boolean + last_sync_at: string | null + last_sync_version: number + sync_enabled: boolean +} + +export interface SyncResult { + success: boolean + message: string +} + +export const syncApi = { + getConfig: () => get('/sync/config'), + + updateConfig: (data: SyncConfigUpdate) => put('/sync/config', data), + + testConnection: () => post('/sync/test'), + + push: () => post('/sync/push'), + + pull: () => post('/sync/pull'), + + sync: () => post('/sync/sync'), + + getStatus: () => get('/sync/status'), + + clearRemote: () => del('/sync/remote'), +} \ No newline at end of file diff --git a/WebUI/src/api/types.ts b/WebUI/src/api/types.ts index 83401e9..f2f2026 100644 --- a/WebUI/src/api/types.ts +++ b/WebUI/src/api/types.ts @@ -2,6 +2,7 @@ export type QuadrantPriority = 'q1' | 'q2' | 'q3' | 'q4' export interface Task { id: number + uuid?: string title: string description?: string priority: QuadrantPriority @@ -16,6 +17,7 @@ export interface Task { export interface Category { id: number + uuid?: string name: string color: string icon: string @@ -23,6 +25,7 @@ export interface Category { export interface Tag { id: number + uuid?: string name: string } @@ -88,6 +91,7 @@ export interface UserSettingsUpdate { export interface HabitGroup { id: number + uuid?: string name: string color: string icon: string @@ -105,6 +109,7 @@ export type HabitFrequency = 'daily' | 'weekly' export interface Habit { id: number + uuid?: string name: string description?: string group_id?: number @@ -128,6 +133,7 @@ export interface HabitFormData { export interface HabitCheckin { id: number + uuid?: string habit_id: number checkin_date: string count: number @@ -146,6 +152,7 @@ export interface HabitStats { export interface AnniversaryCategory { id: number + uuid?: string name: string icon: string color: string @@ -161,6 +168,7 @@ export interface AnniversaryCategoryFormData { export interface Anniversary { id: number + uuid?: string title: string date: string year?: number | null @@ -195,6 +203,7 @@ export type StepStatus = 'pending' | 'in_progress' | 'completed' export interface Goal { id: number + uuid?: string title: string description?: string | null status: GoalStatus @@ -220,6 +229,7 @@ export interface GoalDetail extends Goal { export interface GoalStep { id: number + uuid?: string goal_id: number parent_id?: number | null title: string @@ -234,6 +244,7 @@ export interface GoalStep { export interface GoalReview { id: number + uuid?: string goal_id: number content: string rating?: number | null diff --git a/WebUI/src/stores/useSyncStore.ts b/WebUI/src/stores/useSyncStore.ts new file mode 100644 index 0000000..b84e1b7 --- /dev/null +++ b/WebUI/src/stores/useSyncStore.ts @@ -0,0 +1,147 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import type { SyncConfig, SyncConfigUpdate, SyncStatus } from '@/api/sync' +import { syncApi } from '@/api/sync' +import { ElMessage, ElMessageBox } from 'element-plus' + +export type SyncDirection = 'push' | 'pull' | 'sync' + +export const useSyncStore = defineStore('sync', () => { + const config = ref(null) + const status = ref(null) + const loading = ref(false) + const syncing = ref(false) + const syncMessage = ref('') + + const isConfigured = computed(() => !!config.value?.webdav_url) + + async function fetchConfig() { + loading.value = true + try { + config.value = await syncApi.getConfig() + } finally { + loading.value = false + } + } + + async function saveConfig(data: SyncConfigUpdate) { + loading.value = true + try { + config.value = await syncApi.updateConfig(data) + ElMessage.success('保存成功') + } catch { + ElMessage.error('保存失败') + } finally { + loading.value = false + } + } + + async function testConnection() { + loading.value = true + try { + const result = await syncApi.testConnection() + if (result.success) { + ElMessage.success(result.message) + } else { + ElMessage.error(result.message) + } + return result + } catch { + ElMessage.error('连接测试失败') + return { success: false as const, message: '连接测试失败' } + } finally { + loading.value = false + } + } + + async function fetchStatus() { + try { + status.value = await syncApi.getStatus() + syncing.value = status.value.syncing + } catch { + // 静默失败 + } + } + + async function startSync(direction: SyncDirection) { + if (syncing.value) { + ElMessage.warning('正在同步中,请稍后再试') + return + } + + const directionLabel = direction === 'push' ? '推送' : direction === 'pull' ? '拉取' : '双向合并' + + if (direction === 'push' || direction === 'pull') { + try { + await ElMessageBox.confirm( + `${direction === 'push' ? '推送' : '拉取'}操作会覆盖${direction === 'push' ? '远端' : '本地'}数据,确定继续吗?`, + '警告', + { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' } + ) + } catch { + return + } + } + + syncing.value = true + syncMessage.value = `正在${directionLabel}...` + try { + let result + switch (direction) { + case 'push': + result = await syncApi.push() + break + case 'pull': + result = await syncApi.pull() + break + case 'sync': + result = await syncApi.sync() + break + } + if (result.success) { + ElMessage.success(result.message) + } else { + ElMessage.error(result.message) + } + } catch { + ElMessage.error(`${directionLabel}失败`) + } finally { + syncing.value = false + syncMessage.value = '' + await fetchStatus() + } + } + + async function clearRemote() { + try { + await ElMessageBox.confirm( + '确定要清空远端所有同步数据吗?此操作不可恢复!', + '危险操作', + { confirmButtonText: '确定清空', cancelButtonText: '取消', type: 'error' } + ) + const result = await syncApi.clearRemote() + if (result.success) { + ElMessage.success(result.message) + } else { + ElMessage.error(result.message) + } + } catch { + // 取消操作 + } + } + + return { + config, + status, + loading, + syncing, + syncMessage, + isConfigured, + fetchConfig, + saveConfig, + testConnection, + fetchStatus, + startSync, + clearRemote, + } +}) \ No newline at end of file diff --git a/WebUI/src/views/SettingsView.vue b/WebUI/src/views/SettingsView.vue index 1a75934..65ac44e 100644 --- a/WebUI/src/views/SettingsView.vue +++ b/WebUI/src/views/SettingsView.vue @@ -6,6 +6,7 @@ import { useTaskStore } from '@/stores/useTaskStore' import { useCategoryStore } from '@/stores/useCategoryStore' import { useTagStore } from '@/stores/useTagStore' import { useHabitStore } from '@/stores/useHabitStore' +import { useSyncStore, type SyncDirection } from '@/stores/useSyncStore' import { get, post, del } from '@/api/request' import type { Task, Category, Tag, HabitGroup, Habit } from '@/api/types' @@ -14,6 +15,7 @@ const taskStore = useTaskStore() const categoryStore = useCategoryStore() const tagStore = useTagStore() const habitStore = useHabitStore() +const syncStore = useSyncStore() const saving = ref(false) const exporting = ref(false) @@ -42,13 +44,48 @@ const prefs = ref({ default_sort_order: 'desc' }) +const webdavConfig = ref({ + webdav_url: '', + webdav_username: '', + webdav_password: '', + webdav_path: '/elysia-todo/', + auto_sync: false, + auto_sync_interval: 300 +}) + +const syncDirection = ref('sync') +let pollTimer: ReturnType | null = null + onMounted(() => { prefs.value.site_name = userStore.siteName || '爱莉希雅待办' prefs.value.default_view = userStore.defaultView || 'list' prefs.value.default_sort_by = userStore.defaultSortBy || 'priority' prefs.value.default_sort_order = userStore.defaultSortOrder || 'desc' + + syncStore.fetchConfig().then(() => { + if (syncStore.config) { + webdavConfig.value.webdav_url = syncStore.config.webdav_url || '' + webdavConfig.value.webdav_username = syncStore.config.webdav_username || '' + webdavConfig.value.webdav_password = '' + webdavConfig.value.webdav_path = syncStore.config.webdav_path || '/elysia-todo/' + webdavConfig.value.auto_sync = syncStore.config.auto_sync || false + webdavConfig.value.auto_sync_interval = syncStore.config.auto_sync_interval || 300 + } + }) + + syncStore.fetchStatus() + pollTimer = setInterval(() => { + syncStore.fetchStatus() + }, 5000) }) +function stopPolling() { + if (pollTimer) { + clearInterval(pollTimer) + pollTimer = null + } +} + async function handleSave() { saving.value = true try { @@ -60,7 +97,6 @@ async function handleSave() { }) userStore.syncFromSettings(userStore.settings!) - // 保存排序后立即应用 taskStore.setFilters({ sort_by: prefs.value.default_sort_by as 'priority' | 'due_date' | 'created_at', sort_order: prefs.value.default_sort_order as 'asc' | 'desc' @@ -74,6 +110,28 @@ async function handleSave() { } } +async function saveWebdavConfig() { + const data: Record = { + webdav_url: webdavConfig.value.webdav_url || null, + webdav_username: webdavConfig.value.webdav_username || null, + webdav_path: webdavConfig.value.webdav_path || '/elysia-todo/', + auto_sync: webdavConfig.value.auto_sync, + auto_sync_interval: webdavConfig.value.auto_sync_interval, + } + if (webdavConfig.value.webdav_password) { + data.webdav_password = webdavConfig.value.webdav_password + } + await syncStore.saveConfig(data) +} + +async function testWebdavConnection() { + await syncStore.testConnection() +} + +async function startSync() { + await syncStore.startSync(syncDirection.value) +} + async function exportData() { exporting.value = true try { @@ -444,6 +502,166 @@ async function clearCompleted() { + + +
+
+
+ +
+
+

数据同步

+

通过 WebDAV 同步你的数据

+
+
+ +
+
+
+ 服务器地址 + Alist WebDAV 地址 +
+ +
+ +
+
+ 用户名 + WebDAV 登录用户名 +
+ +
+ +
+
+ 密码 + WebDAV 登录密码 +
+
+ + + 测试连接 + +
+
+ +
+
+ 远端路径 + WebDAV 上的存储路径 +
+ +
+ +
+ + 保存配置 + +
+
+
+ + +
+
+
+ +
+
+

同步操作

+

选择同步方向并执行

+
+
+ +
+
+
+ 同步方向 + 选择数据同步的方向 +
+ + 双向合并 + 推送 + 拉取 + +
+ +
+ + + 上次同步: {{ syncStore.status.last_sync_at ? new Date(syncStore.status.last_sync_at).toLocaleString() : '从未同步' }} + + + 版本: v{{ syncStore.status.last_sync_version }} + +
+ +
+ + {{ syncStore.syncMessage || '正在同步...' }} +
+ +
+
+
+ 执行同步 + 将本地数据推送到远端,远端数据将被覆盖 + 从远端拉取数据,本地数据将被覆盖 + 合并本地和远端数据,以最新版本为准 +
+ + + {{ syncStore.syncing ? syncStore.syncMessage : '开始同步' }} + +
+ +
+
+ 清空远端数据 + 删除 WebDAV 上的所有同步数据(不可恢复) +
+ + + 清空远端 + +
+
+
+
@@ -628,6 +846,46 @@ async function clearCompleted() { } } +.sync-icon { + background: linear-gradient(135deg, rgba(100, 200, 255, 0.2) 0%, rgba(150, 150, 255, 0.2) 100%) !important; + color: #6495ed !important; +} + +.sync-info { + display: flex; + gap: 16px; + padding: 12px 0; + font-size: 13px; + color: var(--text-secondary); + + .info-item { + display: flex; + align-items: center; + gap: 4px; + } +} + +.sync-overlay-hint { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + margin: 8px 0; + background: linear-gradient(135deg, rgba(100, 200, 255, 0.08) 0%, rgba(150, 150, 255, 0.08) 100%); + border-radius: var(--radius-md); + font-size: 14px; + color: #6495ed; + + .sync-spin { + animation: spin 1s linear infinite; + } +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + @media (max-width: 768px) { .settings-page { padding: 16px; diff --git a/api/app/database.py b/api/app/database.py index 2e6b27e..24b6d77 100644 --- a/api/app/database.py +++ b/api/app/database.py @@ -1,23 +1,18 @@ -from sqlalchemy import create_engine, inspect, text, String, Integer, Text, Boolean, Float, DateTime, Date -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker -import os +from sqlalchemy import create_engine, inspect, text, String, Integer, Text, Boolean, Float, DateTime, Date, event +from sqlalchemy.orm import declarative_base, sessionmaker -from app.config import DATABASE_PATH, DATABASE_URL +from app.config import DATABASE_URL -# 确保 data 目录存在 -os.makedirs(os.path.dirname(DATABASE_PATH) if os.path.dirname(DATABASE_PATH) else ".", exist_ok=True) - -# 创建引擎 engine = create_engine( DATABASE_URL, - connect_args={"check_same_thread": False} + pool_size=10, + max_overflow=20, + pool_recycle=3600, + pool_pre_ping=True, ) -# 创建会话工厂 SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) -# 创建基类 Base = declarative_base() @@ -30,20 +25,18 @@ def get_db(): db.close() -# SQLAlchemy 类型到 SQLite 类型名的映射 _TYPE_MAP = { String: "VARCHAR", Integer: "INTEGER", Text: "TEXT", Boolean: "BOOLEAN", - Float: "REAL", - DateTime: "DATETIME", + Float: "DOUBLE PRECISION", + DateTime: "TIMESTAMP", Date: "DATE", } def _col_type_str(col_type) -> str: - """将 SQLAlchemy 列类型转为 SQLite 类型字符串""" if col_type.__class__ in _TYPE_MAP: base = _TYPE_MAP[col_type.__class__] else: @@ -56,14 +49,13 @@ def _col_type_str(col_type) -> str: def init_db(): - """初始化数据库表,自动补充新增的列""" - # 导入所有模型,确保 Base.metadata 包含全部表定义 + """初始化数据库表,自动补充新增的列,并为缺少 uuid 的记录回填""" + from app.utils.logger import logger # 避免循环导入 from app.models import ( # noqa: F401 - task, category, tag, user_settings, habit, anniversary, goal, + task, category, tag, user_settings, habit, anniversary, goal, sync_settings, ) Base.metadata.create_all(bind=engine) - # 通用自动迁移:对比 ORM 模型与实际表结构,补充缺失的列(SQLite 兼容) inspector = inspect(engine) table_names = set(inspector.get_table_names()) @@ -78,24 +70,75 @@ def init_db(): for col in table_cls.columns: if col.name in existing_cols: continue - # 跳过无服务端默认值且不可为空的列(容易出错) if col.nullable is False and col.server_default is None and col.default is None: continue - sqlite_type = _col_type_str(col.type) + col_type_str = _col_type_str(col.type) + col_name = col.name - ddl = f"ALTER TABLE {table_name} ADD COLUMN {col.name} {sqlite_type}" - - # 为可空列或已有默认值的列附加 DEFAULT if col.server_default is not None: + ddl = f"ALTER TABLE {table_name} ADD COLUMN {col_name} {col_type_str}" ddl += f" DEFAULT {col.server_default.arg}" - elif col.default is not None and col.nullable: + if not col.nullable: + ddl += " NOT NULL" + elif col.default is not None: default_val = col.default.arg - if isinstance(default_val, str): + ddl = f"ALTER TABLE {table_name} ADD COLUMN {col_name} {col_type_str}" + if isinstance(default_val, bool): + ddl += f" DEFAULT {'TRUE' if default_val else 'FALSE'}" + elif isinstance(default_val, str): ddl += f" DEFAULT '{default_val}'" - elif isinstance(default_val, bool): - ddl += f" DEFAULT {1 if default_val else 0}" else: ddl += f" DEFAULT {default_val}" + if not col.nullable: + ddl += " NOT NULL" + else: + ddl = f"ALTER TABLE {table_name} ADD COLUMN {col_name} {col_type_str}" conn.execute(text(ddl)) + + # 为缺少 uuid 的已有记录回填 UUID4 + import uuid + db_session = SessionLocal() + try: + from app.models import Task, Category, Tag, HabitGroup, Habit, HabitCheckin + from app.models import AnniversaryCategory, Anniversary, Goal, GoalStep, GoalReview, SyncSettings + + for model_cls in [Task, Category, Tag, HabitGroup, Habit, HabitCheckin, + AnniversaryCategory, Anniversary, Goal, GoalStep, GoalReview]: + if hasattr(model_cls, 'uuid'): + null_uuid_records = db_session.query(model_cls).filter( + (model_cls.uuid == None) | (model_cls.uuid == '') # noqa: E711 + ).all() + for record in null_uuid_records: + record.uuid = str(uuid.uuid4()) + if null_uuid_records: + logger.info(f"为 {len(null_uuid_records)} 条 {model_cls.__name__} 记录回填了 uuid") + db_session.commit() + except Exception as e: + logger.warning(f"UUID 回填时出现异常(可忽略): {e}") + db_session.rollback() + finally: + db_session.close() + + # 注册 sync_version 自增事件监听 + _register_sync_version_listeners() + + +def _bump_sync_version(mapper, connection, target): + """before_update 事件:自动递增 sync_version(同步模式中跳过)""" + from app.utils.sync_lock import is_sync_mode + if not is_sync_mode() and hasattr(target, 'sync_version'): + target.sync_version = (target.sync_version or 0) + 1 + + +def _register_sync_version_listeners(): + """为所有可同步模型注册 before_update 事件监听""" + from app.models import ( + Task, Category, Tag, HabitGroup, Habit, HabitCheckin, + AnniversaryCategory, Anniversary, Goal, GoalStep, GoalReview, + ) + for model_cls in [Task, Category, Tag, HabitGroup, Habit, HabitCheckin, + AnniversaryCategory, Anniversary, Goal, GoalStep, GoalReview]: + if hasattr(model_cls, 'sync_version'): + event.listen(model_cls, 'before_update', _bump_sync_version) \ No newline at end of file diff --git a/api/app/main.py b/api/app/main.py index b1be98b..746485b 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -11,7 +11,8 @@ from app.database import init_db, SessionLocal from app.models.user_settings import UserSettings from app.routers import api_router from app.utils.logger import logger -from app.utils.auth import decode_access_token +from app.utils.auth import decode_access_token, get_cached_token_version, set_cached_token_version +from app.utils.sync_lock import is_syncing from jose import JWTError @@ -111,19 +112,39 @@ async def auth_middleware(request: Request, call_next): except JWTError: return JSONResponse(status_code=401, content={"detail": "登录已过期,请重新登录"}) - db = SessionLocal() - try: - settings = db.query(UserSettings).filter(UserSettings.id == 1).first() - if settings and payload.get("tv") != settings.token_version: + user_id = payload.get("sub", "") + token_tv = payload.get("tv") + + if token_tv is not None and user_id: + cached_tv = get_cached_token_version(user_id) + if cached_tv is None: + db = SessionLocal() + try: + settings = db.query(UserSettings).filter(UserSettings.id == 1).first() + cached_tv = settings.token_version if settings else 0 + set_cached_token_version(user_id, cached_tv) + finally: + db.close() + if token_tv != cached_tv: return JSONResponse(status_code=401, content={"detail": "密码已修改,请重新登录"}) - finally: - db.close() request.state.user = payload return await call_next(request) +# 同步锁中间件(同步期间禁止写操作) +@app.middleware("http") +async def sync_lock_middleware(request: Request, call_next): + path = request.url.path + + if is_syncing() and request.method in ("POST", "PUT", "PATCH", "DELETE"): + if not path.startswith("/api/sync"): + return JSONResponse(status_code=503, content={"detail": "正在同步数据,请稍后再试"}) + + return await call_next(request) + + # 全局异常处理器 @app.exception_handler(Exception) async def global_exception_handler(request: Request, exc: Exception): diff --git a/api/app/models/__init__.py b/api/app/models/__init__.py index c36e730..2a3a03f 100644 --- a/api/app/models/__init__.py +++ b/api/app/models/__init__.py @@ -5,10 +5,12 @@ from app.models.user_settings import UserSettings from app.models.habit import HabitGroup, Habit, HabitCheckin from app.models.anniversary import AnniversaryCategory, Anniversary from app.models.goal import Goal, GoalStep, GoalReview, goal_tasks +from app.models.sync_settings import SyncSettings __all__ = [ "Task", "Category", "Tag", "task_tags", "UserSettings", "HabitGroup", "Habit", "HabitCheckin", "AnniversaryCategory", "Anniversary", "Goal", "GoalStep", "GoalReview", "goal_tasks", + "SyncSettings", ] diff --git a/api/app/models/anniversary.py b/api/app/models/anniversary.py index 78abff5..9f84351 100644 --- a/api/app/models/anniversary.py +++ b/api/app/models/anniversary.py @@ -1,3 +1,4 @@ +import uuid as _uuid from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Date from sqlalchemy.orm import relationship from app.database import Base @@ -9,10 +10,13 @@ class AnniversaryCategory(Base): __tablename__ = "anniversary_categories" id = Column(Integer, primary_key=True, index=True) + uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True) name = Column(String(50), nullable=False) icon = Column(String(50), default="calendar") color = Column(String(20), default="#FFB7C5") sort_order = Column(Integer, default=0) + is_deleted = Column(Boolean, default=False) + sync_version = Column(Integer, default=1) # 关联关系 anniversaries = relationship("Anniversary", back_populates="category") @@ -23,6 +27,7 @@ class Anniversary(Base): __tablename__ = "anniversaries" id = Column(Integer, primary_key=True, index=True) + uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True) title = Column(String(200), nullable=False) date = Column(Date, nullable=False) # 月-日,年份部分可选 year = Column(Integer, nullable=True) # 年份,用于计算第 N 个周年 @@ -30,6 +35,8 @@ class Anniversary(Base): description = Column(Text, nullable=True) is_recurring = Column(Boolean, default=True) remind_days_before = Column(Integer, default=3) + is_deleted = Column(Boolean, default=False) + sync_version = Column(Integer, default=1) created_at = Column(DateTime, default=utcnow) updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) diff --git a/api/app/models/category.py b/api/app/models/category.py index 83b3b1d..54c0b9b 100644 --- a/api/app/models/category.py +++ b/api/app/models/category.py @@ -1,4 +1,5 @@ -from sqlalchemy import Column, Integer, String +import uuid as _uuid +from sqlalchemy import Column, Integer, String, Boolean from sqlalchemy.orm import relationship from app.database import Base @@ -8,9 +9,12 @@ class Category(Base): __tablename__ = "categories" id = Column(Integer, primary_key=True, index=True) + uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True) name = Column(String(100), nullable=False) color = Column(String(20), default="#FFB7C5") # 默认樱花粉 icon = Column(String(50), default="folder") # 默认图标 + is_deleted = Column(Boolean, default=False) + sync_version = Column(Integer, default=1) # 关联关系 tasks = relationship("Task", back_populates="category") diff --git a/api/app/models/goal.py b/api/app/models/goal.py index f89a14e..26c4d27 100644 --- a/api/app/models/goal.py +++ b/api/app/models/goal.py @@ -1,4 +1,5 @@ -from sqlalchemy import Column, Integer, String, Text, Date, DateTime, ForeignKey, Table, desc +import uuid as _uuid +from sqlalchemy import Column, Integer, String, Text, Date, DateTime, Boolean, ForeignKey, Table, desc from sqlalchemy.orm import relationship from app.database import Base from app.utils.datetime import utcnow @@ -18,6 +19,7 @@ class Goal(Base): __tablename__ = "goals" id = Column(Integer, primary_key=True, index=True) + uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True) title = Column(String(200), nullable=False) description = Column(Text, nullable=True) status = Column(String(20), default="active") # active/paused/completed/abandoned @@ -28,6 +30,8 @@ class Goal(Base): color = Column(String(20), default="#FFB7C5") icon = Column(String(50), default="flag") sort_order = Column(Integer, default=0) + is_deleted = Column(Boolean, default=False) + sync_version = Column(Integer, default=1) created_at = Column(DateTime, default=utcnow) updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) @@ -51,6 +55,7 @@ class GoalStep(Base): __tablename__ = "goal_steps" id = Column(Integer, primary_key=True, index=True) + uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True) goal_id = Column(Integer, ForeignKey("goals.id"), nullable=False) parent_id = Column(Integer, ForeignKey("goal_steps.id"), nullable=True) title = Column(String(200), nullable=False) @@ -59,6 +64,8 @@ class GoalStep(Base): target_date = Column(Date, nullable=True) reached_at = Column(DateTime, nullable=True) sort_order = Column(Integer, default=0) + is_deleted = Column(Boolean, default=False) + sync_version = Column(Integer, default=1) created_at = Column(DateTime, default=utcnow) # 关联关系 @@ -71,9 +78,12 @@ class GoalReview(Base): __tablename__ = "goal_reviews" id = Column(Integer, primary_key=True, index=True) + uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True) goal_id = Column(Integer, ForeignKey("goals.id"), nullable=False) content = Column(Text, nullable=False) rating = Column(Integer, nullable=True) # 1-5 自评 + is_deleted = Column(Boolean, default=False) + sync_version = Column(Integer, default=1) created_at = Column(DateTime, default=utcnow) # 关联关系 diff --git a/api/app/models/habit.py b/api/app/models/habit.py index 5917eb5..42f6fe1 100644 --- a/api/app/models/habit.py +++ b/api/app/models/habit.py @@ -1,3 +1,4 @@ +import uuid as _uuid from sqlalchemy import Column, Integer, String, Text, Boolean, Date, DateTime, ForeignKey, UniqueConstraint from sqlalchemy.orm import relationship from app.database import Base @@ -9,10 +10,13 @@ class HabitGroup(Base): __tablename__ = "habit_groups" id = Column(Integer, primary_key=True, index=True) + uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True) name = Column(String(100), nullable=False) color = Column(String(20), default="#FFB7C5") icon = Column(String(50), default="flag") sort_order = Column(Integer, default=0) + is_deleted = Column(Boolean, default=False) + sync_version = Column(Integer, default=1) # 关联关系 habits = relationship("Habit", back_populates="group", order_by="Habit.created_at") @@ -23,6 +27,7 @@ class Habit(Base): __tablename__ = "habits" id = Column(Integer, primary_key=True, index=True) + uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True) name = Column(String(200), nullable=False) description = Column(Text, nullable=True) group_id = Column(Integer, ForeignKey("habit_groups.id"), nullable=True) @@ -30,6 +35,8 @@ class Habit(Base): frequency = Column(String(20), default="daily") # daily / weekly active_days = Column(String(100), nullable=True) # JSON 数组, 如 [0,2,4] 表示周一三五 is_archived = Column(Boolean, default=False) + is_deleted = Column(Boolean, default=False) + sync_version = Column(Integer, default=1) created_at = Column(DateTime, default=utcnow) updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) @@ -46,9 +53,12 @@ class HabitCheckin(Base): ) id = Column(Integer, primary_key=True, index=True) + uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True) habit_id = Column(Integer, ForeignKey("habits.id"), nullable=False) checkin_date = Column(Date, nullable=False) count = Column(Integer, default=0) + is_deleted = Column(Boolean, default=False) + sync_version = Column(Integer, default=1) created_at = Column(DateTime, default=utcnow) # 关联关系 diff --git a/api/app/models/sync_settings.py b/api/app/models/sync_settings.py new file mode 100644 index 0000000..928fbc7 --- /dev/null +++ b/api/app/models/sync_settings.py @@ -0,0 +1,28 @@ +import uuid as _uuid +from sqlalchemy import Column, Integer, String, Boolean, DateTime +from app.database import Base +from app.utils.datetime import utcnow + + +class SyncSettings(Base): + """同步设置模型(单例,始终只有一条记录 id=1)""" + __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) \ No newline at end of file diff --git a/api/app/models/tag.py b/api/app/models/tag.py index 1a18c26..4a6619f 100644 --- a/api/app/models/tag.py +++ b/api/app/models/tag.py @@ -1,4 +1,5 @@ -from sqlalchemy import Column, Integer, String, Table, ForeignKey +import uuid as _uuid +from sqlalchemy import Column, Integer, String, Boolean, Table, ForeignKey from sqlalchemy.orm import relationship from app.database import Base @@ -17,7 +18,10 @@ class Tag(Base): __tablename__ = "tags" id = Column(Integer, primary_key=True, index=True) + uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True) name = Column(String(50), nullable=False, unique=True) + is_deleted = Column(Boolean, default=False) + sync_version = Column(Integer, default=1) # 关联关系 tasks = relationship("Task", secondary=task_tags, back_populates="tags") diff --git a/api/app/models/task.py b/api/app/models/task.py index 492d3ca..d230953 100644 --- a/api/app/models/task.py +++ b/api/app/models/task.py @@ -1,3 +1,4 @@ +import uuid as _uuid from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey from sqlalchemy.orm import relationship from app.database import Base @@ -9,12 +10,15 @@ class Task(Base): __tablename__ = "tasks" id = Column(Integer, primary_key=True, index=True) + uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True) title = Column(String(200), nullable=False) description = Column(Text, nullable=True) priority = Column(String(20), default="q4") # q1(重要紧急), q2(重要不紧急), q3(不重要紧急), q4(不重要不紧急) due_date = Column(DateTime, nullable=True) is_completed = Column(Boolean, default=False) + is_deleted = Column(Boolean, default=False) category_id = Column(Integer, ForeignKey("categories.id"), nullable=True) + sync_version = Column(Integer, default=1) created_at = Column(DateTime, default=utcnow) updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) diff --git a/api/app/routers/__init__.py b/api/app/routers/__init__.py index 56d9e06..4732da7 100644 --- a/api/app/routers/__init__.py +++ b/api/app/routers/__init__.py @@ -1,5 +1,5 @@ from fastapi import APIRouter -from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, auth, goals +from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, auth, goals, sync api_router = APIRouter() @@ -11,3 +11,4 @@ api_router.include_router(user_settings.router) api_router.include_router(habits.router) api_router.include_router(anniversaries.router) api_router.include_router(goals.router) +api_router.include_router(sync.router) diff --git a/api/app/routers/sync.py b/api/app/routers/sync.py new file mode 100644 index 0000000..a14101a --- /dev/null +++ b/api/app/routers/sync.py @@ -0,0 +1,152 @@ +from fastapi import APIRouter, Depends, Request +from sqlalchemy.orm import Session + +from app.database import get_db +from app.models import SyncSettings +from app.schemas.sync import ( + SyncConfigUpdate, SyncConfigResponse, + SyncStatusResponse, SyncTestResponse, SyncOperationResponse, +) +from app.utils.crypto import encrypt, decrypt +from app.utils.webdav import WebDAVClient +from app.utils.sync_service import push_to_remote, pull_from_remote, bidirectional_sync +from app.utils.sync_lock import is_syncing + +router = APIRouter(prefix="/api/sync", tags=["同步"]) + + +def _get_or_create_settings(db: Session) -> SyncSettings: + settings = db.query(SyncSettings).filter(SyncSettings.id == 1).first() + if not settings: + settings = SyncSettings(id=1) + db.add(settings) + db.commit() + db.refresh(settings) + return settings + + +@router.get("/config", response_model=SyncConfigResponse) +def get_sync_config(request: Request, db: Session = Depends(get_db)): + settings = _get_or_create_settings(db) + response = SyncConfigResponse( + webdav_url=settings.webdav_url, + webdav_username=settings.webdav_username, + webdav_password="***" if settings.webdav_password else None, + webdav_path=settings.webdav_path or "/elysia-todo/", + sync_enabled=settings.sync_enabled, + auto_sync=settings.auto_sync, + auto_sync_interval=settings.auto_sync_interval or 300, + last_sync_at=settings.last_sync_at, + last_sync_version=settings.last_sync_version or 0, + ) + return response + + +@router.put("/config", response_model=SyncConfigResponse) +def update_sync_config(config: SyncConfigUpdate, request: Request, db: Session = Depends(get_db)): + settings = _get_or_create_settings(db) + + if config.webdav_url is not None: + settings.webdav_url = config.webdav_url + if config.webdav_username is not None: + settings.webdav_username = config.webdav_username + if config.webdav_password is not None and config.webdav_password != "***": + settings.webdav_password = encrypt(config.webdav_password) + if config.webdav_path is not None: + settings.webdav_path = config.webdav_path + if config.auto_sync is not None: + settings.auto_sync = config.auto_sync + if config.auto_sync_interval is not None: + settings.auto_sync_interval = config.auto_sync_interval + + db.commit() + db.refresh(settings) + + return SyncConfigResponse( + webdav_url=settings.webdav_url, + webdav_username=settings.webdav_username, + webdav_password="***" if settings.webdav_password else None, + webdav_path=settings.webdav_path or "/elysia-todo/", + sync_enabled=settings.sync_enabled, + auto_sync=settings.auto_sync, + auto_sync_interval=settings.auto_sync_interval or 300, + last_sync_at=settings.last_sync_at, + last_sync_version=settings.last_sync_version or 0, + ) + + +@router.post("/test", response_model=SyncTestResponse) +def test_connection(request: Request, db: Session = Depends(get_db)): + settings = _get_or_create_settings(db) + + if not settings.webdav_url: + return SyncTestResponse(success=False, message="未配置 WebDAV 地址") + + password = decrypt(settings.webdav_password) if settings.webdav_password else "" + if settings.webdav_password and password is None: + return SyncTestResponse(success=False, message="WebDAV 密码解密失败,请重新配置") + + client = WebDAVClient( + url=settings.webdav_url, + username=settings.webdav_username or "", + password=password or "", + path=settings.webdav_path or "/elysia-todo/", + ) + + success, message = client.test_connection() + return SyncTestResponse(success=success, message=message) + + +@router.post("/push", response_model=SyncOperationResponse) +def sync_push(request: Request, db: Session = Depends(get_db)): + result = push_to_remote(db) + return SyncOperationResponse(**result) + + +@router.post("/pull", response_model=SyncOperationResponse) +def sync_pull(request: Request, db: Session = Depends(get_db)): + result = pull_from_remote(db) + return SyncOperationResponse(**result) + + +@router.post("/sync", response_model=SyncOperationResponse) +def sync_bidirectional(request: Request, db: Session = Depends(get_db)): + result = bidirectional_sync(db) + return SyncOperationResponse(**result) + + +@router.get("/status", response_model=SyncStatusResponse) +def get_sync_status(request: Request, db: Session = Depends(get_db)): + settings = _get_or_create_settings(db) + return SyncStatusResponse( + syncing=is_syncing(), + last_sync_at=settings.last_sync_at, + last_sync_version=settings.last_sync_version or 0, + sync_enabled=settings.sync_enabled, + ) + + +@router.delete("/remote", response_model=SyncOperationResponse) +def clear_remote(request: Request, db: Session = Depends(get_db)): + from app.utils.webdav import WebDAVClient + from app.utils.crypto import decrypt + + settings = _get_or_create_settings(db) + if not settings.webdav_url: + return SyncOperationResponse(success=False, message="未配置 WebDAV 地址") + + password = decrypt(settings.webdav_password) if settings.webdav_password else "" + if settings.webdav_password and password is None: + return SyncOperationResponse(success=False, message="WebDAV 密码解密失败,请重新配置") + + client = WebDAVClient( + url=settings.webdav_url, + username=settings.webdav_username or "", + password=password or "", + path=settings.webdav_path or "/elysia-todo/", + ) + + success = client.clear_remote() + if success: + return SyncOperationResponse(success=True, message="远端数据已清空") + return SyncOperationResponse(success=False, message="清空远端数据失败") \ No newline at end of file diff --git a/api/app/schemas/anniversary.py b/api/app/schemas/anniversary.py index 93a6042..247f6ae 100644 --- a/api/app/schemas/anniversary.py +++ b/api/app/schemas/anniversary.py @@ -54,6 +54,7 @@ class AnniversaryCategoryUpdate(BaseModel): class AnniversaryCategoryResponse(AnniversaryCategoryBase): """纪念日分类响应模型""" id: int + uuid: Optional[str] = None class Config: from_attributes = True @@ -111,6 +112,7 @@ class AnniversaryUpdate(BaseModel): class AnniversaryResponse(AnniversaryBase): """纪念日响应模型""" id: int + uuid: Optional[str] = None created_at: datetime updated_at: datetime category: Optional[AnniversaryCategoryResponse] = None diff --git a/api/app/schemas/category.py b/api/app/schemas/category.py index 0d4376d..33ec1ca 100644 --- a/api/app/schemas/category.py +++ b/api/app/schemas/category.py @@ -1,4 +1,5 @@ from pydantic import BaseModel, Field +from typing import Optional class CategoryBase(BaseModel): @@ -23,6 +24,7 @@ class CategoryUpdate(BaseModel): class CategoryResponse(CategoryBase): """分类响应模型""" id: int + uuid: Optional[str] = None class Config: from_attributes = True diff --git a/api/app/schemas/goal.py b/api/app/schemas/goal.py index 98cd17b..9e75a1e 100644 --- a/api/app/schemas/goal.py +++ b/api/app/schemas/goal.py @@ -36,6 +36,7 @@ class GoalStepUpdate(BaseModel): class GoalStepResponse(GoalStepBase): id: int + uuid: Optional[str] = None goal_id: int reached_at: Optional[datetime] = None created_at: datetime @@ -54,6 +55,7 @@ class GoalReviewCreate(BaseModel): class GoalReviewResponse(BaseModel): id: int + uuid: Optional[str] = None goal_id: int content: str rating: Optional[int] = None @@ -97,6 +99,7 @@ class GoalUpdate(BaseModel): class GoalListResponse(GoalBase): id: int + uuid: Optional[str] = None progress: int completed_at: Optional[datetime] = None created_at: datetime diff --git a/api/app/schemas/habit.py b/api/app/schemas/habit.py index 114438b..161bd4b 100644 --- a/api/app/schemas/habit.py +++ b/api/app/schemas/habit.py @@ -29,6 +29,7 @@ class HabitGroupUpdate(BaseModel): class HabitGroupResponse(HabitGroupBase): """习惯分组响应模型""" id: int + uuid: Optional[str] = None class Config: from_attributes = True @@ -68,6 +69,7 @@ class HabitUpdate(BaseModel): class HabitResponse(HabitBase): """习惯响应模型""" id: int + uuid: Optional[str] = None is_archived: bool created_at: datetime updated_at: datetime @@ -87,6 +89,7 @@ class CheckinCreate(BaseModel): class CheckinResponse(BaseModel): """打卡记录响应模型""" id: int + uuid: Optional[str] = None habit_id: int checkin_date: date count: int diff --git a/api/app/schemas/sync.py b/api/app/schemas/sync.py new file mode 100644 index 0000000..6ffcbc9 --- /dev/null +++ b/api/app/schemas/sync.py @@ -0,0 +1,50 @@ +from pydantic import BaseModel, Field +from typing import Optional +from datetime import datetime + + +class SyncConfigBase(BaseModel): + webdav_url: Optional[str] = None + webdav_username: Optional[str] = None + webdav_password: Optional[str] = None + webdav_path: str = "/elysia-todo/" + auto_sync: bool = False + auto_sync_interval: int = Field(default=300, ge=60) + + +class SyncConfigUpdate(SyncConfigBase): + webdav_url: Optional[str] = None + webdav_username: Optional[str] = None + webdav_password: Optional[str] = None + + +class SyncConfigResponse(BaseModel): + webdav_url: Optional[str] = None + webdav_username: Optional[str] = None + webdav_password: Optional[str] = None + webdav_path: str = "/elysia-todo/" + sync_enabled: bool = False + auto_sync: bool = False + auto_sync_interval: int = 300 + last_sync_at: Optional[datetime] = None + last_sync_version: int = 0 + + class Config: + from_attributes = True + + +class SyncStatusResponse(BaseModel): + syncing: bool + last_sync_at: Optional[datetime] = None + last_sync_version: int = 0 + sync_enabled: bool = False + + +class SyncTestResponse(BaseModel): + success: bool + message: str + + +class SyncOperationResponse(BaseModel): + success: bool + message: str \ No newline at end of file diff --git a/api/app/schemas/tag.py b/api/app/schemas/tag.py index 0c998d8..9d89507 100644 --- a/api/app/schemas/tag.py +++ b/api/app/schemas/tag.py @@ -1,4 +1,5 @@ from pydantic import BaseModel, Field +from typing import Optional class TagBase(BaseModel): @@ -14,6 +15,7 @@ class TagCreate(TagBase): class TagResponse(TagBase): """标签响应模型""" id: int + uuid: Optional[str] = None class Config: from_attributes = True diff --git a/api/app/schemas/task.py b/api/app/schemas/task.py index 8351370..ab4268e 100644 --- a/api/app/schemas/task.py +++ b/api/app/schemas/task.py @@ -75,6 +75,7 @@ class TaskUpdate(BaseModel): class TaskResponse(TaskBase): """任务响应模型""" id: int + uuid: Optional[str] = None is_completed: bool created_at: datetime updated_at: datetime diff --git a/api/app/utils/crypto.py b/api/app/utils/crypto.py new file mode 100644 index 0000000..24a2eea --- /dev/null +++ b/api/app/utils/crypto.py @@ -0,0 +1,53 @@ +""" +AES-256-GCM 加解密工具 +密钥从 JWT_SECRET 派生,用于加密 WebDAV 密码等敏感信息 +""" +import base64 +import os +import hashlib +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.primitives import hashes + +from app.config import JWT_SECRET + +_SALT = b"elysia-todo-sync-v1" +_NONCE_SIZE = 12 # AES-GCM 标准 nonce 长度 + + +def _derive_key() -> bytes: + """从 JWT_SECRET 派生 256-bit AES 密钥""" + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=_SALT, + iterations=480000, + ) + return kdf.derive(JWT_SECRET.encode("utf-8")) + + +def encrypt(plaintext: str) -> str: + """AES-256-GCM 加密,返回 base64(iv + ciphertext + tag)""" + if not plaintext: + return "" + key = _derive_key() + nonce = os.urandom(_NONCE_SIZE) + aesgcm = AESGCM(key) + ciphertext = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None) + return base64.b64encode(nonce + ciphertext).decode("ascii") + + +def decrypt(encrypted: str) -> str | None: + """AES-256-GCM 解密,解密失败返回 None""" + if not encrypted: + return None + try: + key = _derive_key() + raw = base64.b64decode(encrypted) + nonce = raw[:_NONCE_SIZE] + ciphertext = raw[_NONCE_SIZE:] + aesgcm = AESGCM(key) + plaintext = aesgcm.decrypt(nonce, ciphertext, None) + return plaintext.decode("utf-8") + except Exception: + return None \ No newline at end of file diff --git a/api/app/utils/sync_lock.py b/api/app/utils/sync_lock.py new file mode 100644 index 0000000..02a5232 --- /dev/null +++ b/api/app/utils/sync_lock.py @@ -0,0 +1,37 @@ +""" +全局同步锁与同步模式标记 +同步期间禁止所有写操作,前端显示同步遮罩 +""" +import threading + +_sync_lock = threading.Lock() +_sync_in_progress = False +_sync_mode = threading.local() + + +def acquire_sync_lock() -> bool: + """非阻塞获取同步锁,成功返回 True""" + acquired = _sync_lock.acquire(blocking=False) + if acquired: + global _sync_in_progress + _sync_in_progress = True + _sync_mode.active = True + return acquired + + +def release_sync_lock(): + """释放同步锁""" + global _sync_in_progress + _sync_in_progress = False + _sync_mode.active = False + _sync_lock.release() + + +def is_syncing() -> bool: + """检查是否正在同步""" + return _sync_in_progress + + +def is_sync_mode() -> bool: + """检查当前线程是否在同步模式中(跳过 sync_version 自增)""" + return getattr(_sync_mode, 'active', False) \ No newline at end of file diff --git a/api/app/utils/sync_service.py b/api/app/utils/sync_service.py new file mode 100644 index 0000000..6312b7b --- /dev/null +++ b/api/app/utils/sync_service.py @@ -0,0 +1,567 @@ +""" +同步核心服务 +处理 push / pull / bidirectional merge 逻辑 +""" +from datetime import datetime, date as date_type +import json +import os + +from sqlalchemy.orm import Session + +from app.database import SessionLocal +from app.models import ( + Task, Category, Tag, task_tags, UserSettings, + HabitGroup, Habit, HabitCheckin, + AnniversaryCategory, Anniversary, + Goal, GoalStep, GoalReview, goal_tasks, + SyncSettings, +) +from app.utils.crypto import encrypt, decrypt +from app.utils.webdav import WebDAVClient +from app.utils.sync_lock import acquire_sync_lock, release_sync_lock +from app.utils.logger import logger +from app.utils.datetime import utcnow + +SYNC_COLLECTIONS = [ + ("categories", Category), + ("tags", Tag), + ("tasks", Task), + ("habit_groups", HabitGroup), + ("habits", Habit), + ("habit_checkins", HabitCheckin), + ("anniversary_categories", AnniversaryCategory), + ("anniversaries", Anniversary), + ("goals", Goal), + ("goal_steps", GoalStep), + ("goal_reviews", GoalReview), +] + +ASSOCIATION_COLLECTIONS = [ + ("task_tags", task_tags, "tasks", "tags"), + ("goal_tasks", goal_tasks, "goals", "tasks"), +] + +USER_SETTINGS_SYNC_FIELDS = [ + "nickname", "avatar", "signature", "birthday", "email", + "site_name", "theme", "language", "default_view", + "default_sort_by", "default_sort_order", +] + +MODEL_MAP = { + "tasks": Task, + "categories": Category, + "tags": Tag, + "habit_groups": HabitGroup, + "habits": Habit, + "anniversary_categories": AnniversaryCategory, + "anniversaries": Anniversary, + "goals": Goal, + "goal_steps": GoalStep, + "goal_reviews": GoalReview, + "user_settings": UserSettings, +} + + +def _get_sync_settings(db: Session) -> SyncSettings: + settings = db.query(SyncSettings).filter(SyncSettings.id == 1).first() + if not settings: + settings = SyncSettings(id=1) + db.add(settings) + db.commit() + db.refresh(settings) + return settings + + +def _create_webdav_client(settings: SyncSettings) -> WebDAVClient | None: + if not settings.webdav_url: + return None + password = decrypt(settings.webdav_password) if settings.webdav_password else "" + if settings.webdav_password and password is None: + logger.error("WebDAV 密码解密失败,可能 JWT_SECRET 已更改") + return None + return WebDAVClient( + url=settings.webdav_url, + username=settings.webdav_username or "", + password=password, + path=settings.webdav_path or "/elysia-todo/", + ) + + +def _serialize_model(obj, model_class) -> dict: + result = {} + for col in model_class.__table__.columns: + val = getattr(obj, col.name, None) + if isinstance(val, datetime): + val = val.isoformat() if val else None + elif isinstance(val, date_type): + val = val.isoformat() if val else None + result[col.name] = val + return result + + +def _serialize_association(row, left_model, right_model, db: Session) -> dict | None: + left_id, right_id = row[0], row[1] + left_obj = db.query(left_model).filter(left_model.id == left_id).first() + right_obj = db.query(right_model).filter(right_model.id == right_id).first() + if not left_obj or not right_obj or not left_obj.uuid or not right_obj.uuid: + return None + return { + f"{left_model.__tablename__}_uuid": left_obj.uuid, + f"{right_model.__tablename__}_uuid": right_obj.uuid, + } + + +def _convert_value(val, col_type_name: str): + if val is None: + return None + if isinstance(val, (datetime, date_type)): + return val + if not isinstance(val, str): + return val + if "DateTime" in col_type_name: + try: + return datetime.fromisoformat(val.replace("Z", "+00:00")) + except (ValueError, TypeError): + pass + elif "Date" in col_type_name and "Time" not in col_type_name: + try: + return date_type.fromisoformat(val) + except (ValueError, TypeError): + pass + return val + + +def _item_to_model_kwargs(item: dict, model_class) -> dict: + """将远程 JSON 对象转换为可用于创建模型的 kwargs,保留 uuid 和 sync_version""" + kwargs = {} + for col in model_class.__table__.columns: + if col.name not in item: + continue + val = item[col.name] + if col.name == "id": + continue + col_type_name = type(col.type).__name__ + val = _convert_value(val, col_type_name) + kwargs[col.name] = val + return kwargs + + +def _backup_local(db: Session) -> str: + timestamp = utcnow().strftime("%Y-%m-%dT%H-%M-%S") + backup_dir = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "data", "backups", timestamp + ) + os.makedirs(backup_dir, exist_ok=True) + + for coll_name, model_class in SYNC_COLLECTIONS: + rows = db.query(model_class).filter(model_class.is_deleted == False).all() + items = [_serialize_model(r, model_class) for r in rows] + filepath = os.path.join(backup_dir, f"{coll_name}.json") + with open(filepath, "w", encoding="utf-8") as f: + json.dump({"collection": coll_name, "items": items}, f, ensure_ascii=False, indent=2, default=str) + + logger.info(f"本地数据已备份到: {backup_dir}") + return backup_dir + + +def push_to_remote(db: Session) -> dict: + settings = _get_sync_settings(db) + client = _create_webdav_client(settings) + if not client: + return {"success": False, "message": "WebDAV 未配置或密码解密失败"} + + if not acquire_sync_lock(): + return {"success": False, "message": "同步正在进行中"} + + try: + client.ensure_dirs() + timestamp = utcnow().strftime("%Y-%m-%dT%H-%M-%S") + client.backup_remote(timestamp) + + for coll_name, model_class in SYNC_COLLECTIONS: + rows = db.query(model_class).all() + items = [_serialize_model(r, model_class) for r in rows] + data = { + "version": 1, + "collection": coll_name, + "updated_at": utcnow().isoformat(), + "items": items, + } + if not client.upload_json(f"{coll_name}.json", data): + return {"success": False, "message": f"上传 {coll_name} 失败"} + + for assoc_name, assoc_table, left_name, right_name in ASSOCIATION_COLLECTIONS: + left_model = MODEL_MAP.get(left_name) + right_model = MODEL_MAP.get(right_name) + if not left_model or not right_model: + continue + rows = db.execute(assoc_table.select()).fetchall() + items = [] + for row in rows: + item = _serialize_association(row, left_model, right_model, db) + if item: + items.append(item) + client.upload_json(f"{assoc_name}.json", { + "version": 1, + "collection": assoc_name, + "updated_at": utcnow().isoformat(), + "items": items, + }) + + user_settings = db.query(UserSettings).filter(UserSettings.id == 1).first() + if user_settings: + pref_data = {} + for field in USER_SETTINGS_SYNC_FIELDS: + val = getattr(user_settings, field, None) + if isinstance(val, (datetime, date_type)): + val = val.isoformat() if val else None + pref_data[field] = val + client.upload_json("user_settings.json", { + "version": 1, + "collection": "user_settings", + "updated_at": utcnow().isoformat(), + "items": [pref_data], + }) + + manifest = { + "version": 1, + "last_sync_at": utcnow().isoformat(), + "collections": {}, + } + for coll_name, model_class in SYNC_COLLECTIONS: + count = db.query(model_class).filter(model_class.is_deleted == False).count() + manifest["collections"][coll_name] = { + "count": count, + "updated_at": utcnow().isoformat(), + } + client.upload_json("manifest.json", manifest) + + settings.last_sync_at = utcnow() + settings.last_sync_version = (settings.last_sync_version or 0) + 1 + settings.sync_enabled = True + db.commit() + + return {"success": True, "message": "推送成功"} + except Exception as e: + logger.error(f"推送失败: {e}", exc_info=True) + db.rollback() + return {"success": False, "message": f"推送失败: {str(e)}"} + finally: + release_sync_lock() + + +def pull_from_remote(db: Session) -> dict: + settings = _get_sync_settings(db) + client = _create_webdav_client(settings) + if not client: + return {"success": False, "message": "WebDAV 未配置或密码解密失败"} + + if not acquire_sync_lock(): + return {"success": False, "message": "同步正在进行中"} + + try: + _backup_local(db) + + for coll_name, model_class in SYNC_COLLECTIONS: + remote_data = client.download_json(f"{coll_name}.json") + if remote_data is None: + continue + db.query(model_class).delete() + db.commit() + + for item in remote_data.get("items", []): + kwargs = _item_to_model_kwargs(item, model_class) + is_deleted = kwargs.pop("is_deleted", False) + obj = model_class(**kwargs) + obj.is_deleted = bool(is_deleted) + db.add(obj) + + db.commit() + + for assoc_name, assoc_table, left_name, right_name in ASSOCIATION_COLLECTIONS: + remote_data = client.download_json(f"{assoc_name}.json") + if remote_data is None: + continue + db.execute(assoc_table.delete()) + db.commit() + + left_model = MODEL_MAP.get(left_name) + right_model = MODEL_MAP.get(right_name) + if not left_model or not right_model: + continue + + for item in remote_data.get("items", []): + left_uuid = item.get(f"{left_name}_uuid") + right_uuid = item.get(f"{right_name}_uuid") + if not left_uuid or not right_uuid: + continue + left_obj = db.query(left_model).filter(left_model.uuid == left_uuid).first() + right_obj = db.query(right_model).filter(right_model.uuid == right_uuid).first() + if left_obj and right_obj: + db.execute(assoc_table.insert().values( + left_id=left_obj.id, right_id=right_obj.id + )) + db.commit() + + remote_prefs = client.download_json("user_settings.json") + if remote_prefs and remote_prefs.get("items"): + pref = remote_prefs["items"][0] + user_settings = db.query(UserSettings).filter(UserSettings.id == 1).first() + if user_settings: + for field in USER_SETTINGS_SYNC_FIELDS: + if field in pref and pref[field] is not None: + val = pref[field] + if isinstance(val, str) and field == "birthday": + try: + val = date_type.fromisoformat(val) + except (ValueError, TypeError): + pass + setattr(user_settings, field, val) + db.commit() + + settings.last_sync_at = utcnow() + settings.last_sync_version = (settings.last_sync_version or 0) + 1 + settings.sync_enabled = True + db.commit() + + return {"success": True, "message": "拉取成功"} + except Exception as e: + logger.error(f"拉取失败: {e}", exc_info=True) + db.rollback() + return {"success": False, "message": f"拉取失败: {str(e)}"} + finally: + release_sync_lock() + + +def bidirectional_sync(db: Session) -> dict: + settings = _get_sync_settings(db) + client = _create_webdav_client(settings) + if not client: + return {"success": False, "message": "WebDAV 未配置或密码解密失败"} + + if not acquire_sync_lock(): + return {"success": False, "message": "同步正在进行中"} + + try: + client.ensure_dirs() + stats = {"pushed": 0, "pulled": 0, "merged": 0, "deleted": 0} + + for coll_name, model_class in SYNC_COLLECTIONS: + remote_data = client.download_json(f"{coll_name}.json") + remote_by_uuid = {} + remote_deleted_uuids = set() + if remote_data: + for item in remote_data.get("items", []): + uuid_val = item.get("uuid") + if not uuid_val: + continue + if item.get("is_deleted"): + remote_deleted_uuids.add(uuid_val) + else: + remote_by_uuid[uuid_val] = item + + local_objs = db.query(model_class).all() + local_by_uuid = {} + local_deleted_uuids = set() + for obj in local_objs: + if obj.uuid: + if obj.is_deleted: + local_deleted_uuids.add(obj.uuid) + local_by_uuid[obj.uuid] = obj + + all_uuids = set(remote_by_uuid.keys()) | set(local_by_uuid.keys()) | remote_deleted_uuids | local_deleted_uuids + + for uuid_val in all_uuids: + remote_item = remote_by_uuid.get(uuid_val) + local_obj = local_by_uuid.get(uuid_val) + remote_deleted = uuid_val in remote_deleted_uuids and uuid_val not in remote_by_uuid + local_deleted = uuid_val in local_deleted_uuids and uuid_val not in local_by_uuid + + # 两边都删除了 → 什么都不做 + if remote_deleted and local_deleted: + continue + + # 远端删除了,本地还在 → 删除本地 + if remote_deleted and local_obj: + local_obj.is_deleted = True + local_obj.sync_version = (local_obj.sync_version or 0) + 1 + stats["deleted"] += 1 + continue + + # 本地删除了,远端还在 → 删除远端标记(本地占优在这里意味着:远端也应标记删除) + # 这里简单处理:如果本地标记了删除但远端还活着,以远端为准拉取回来 + if local_deleted and not remote_deleted and not local_obj and remote_item: + kwargs = _item_to_model_kwargs(remote_item, model_class) + kwargs.pop("is_deleted", None) + new_obj = model_class(**kwargs) + db.add(new_obj) + stats["pulled"] += 1 + continue + + # 仅远端有 → 拉取到本地 + if remote_item and not local_obj: + kwargs = _item_to_model_kwargs(remote_item, model_class) + kwargs.pop("is_deleted", None) + new_obj = model_class(**kwargs) + db.add(new_obj) + stats["pulled"] += 1 + continue + + # 仅本地有 → 会在最后统一上传时推送到远端 + if not remote_item and local_obj and not local_deleted: + stats["pushed"] += 1 + continue + + # 两边都有 → LWW 合并 + if remote_item and local_obj: + remote_ver = remote_item.get("sync_version", 1) or 1 + local_ver = local_obj.sync_version or 1 + + if remote_ver > local_ver: + kwargs = _item_to_model_kwargs(remote_item, model_class) + kwargs.pop("is_deleted", None) + kwargs.pop("sync_version", None) + for key, val in kwargs.items(): + if val is not None or key in getattr(local_obj, '__clearable_fields__', set()): + setattr(local_obj, key, val) + local_obj.sync_version = remote_ver + stats["merged"] += 1 + elif local_ver > remote_ver: + local_obj.sync_version = local_ver + stats["pushed"] += 1 + else: + # 版本相同,以远端为准 + kwargs = _item_to_model_kwargs(remote_item, model_class) + kwargs.pop("is_deleted", None) + kwargs.pop("sync_version", None) + for key, val in kwargs.items(): + setattr(local_obj, key, val) + local_obj.sync_version = local_ver + 1 + stats["merged"] += 1 + + db.commit() + + # 合并关联表 + for assoc_name, assoc_table, left_name, right_name in ASSOCIATION_COLLECTIONS: + remote_data = client.download_json(f"{assoc_name}.json") + if remote_data is None: + continue + + left_model = MODEL_MAP.get(left_name) + right_model = MODEL_MAP.get(right_name) + if not left_model or not right_model: + continue + + remote_pairs = set() + for item in remote_data.get("items", []): + left_uuid = item.get(f"{left_name}_uuid") + right_uuid = item.get(f"{right_name}_uuid") + if left_uuid and right_uuid: + remote_pairs.add((left_uuid, right_uuid)) + + local_pairs = set() + rows = db.execute(assoc_table.select()).fetchall() + for row in rows: + left_id, right_id = row[0], row[1] + left_obj = db.query(left_model).filter(left_model.id == left_id).first() + right_obj = db.query(right_model).filter(right_model.id == right_id).first() + if left_obj and right_obj and left_obj.uuid and right_obj.uuid: + local_pairs.add((left_obj.uuid, right_obj.uuid)) + + merged_pairs = local_pairs | remote_pairs + db.execute(assoc_table.delete()) + for left_uuid, right_uuid in merged_pairs: + left_obj = db.query(left_model).filter(left_model.uuid == left_uuid).first() + right_obj = db.query(right_model).filter(right_model.uuid == right_uuid).first() + if left_obj and right_obj: + db.execute(assoc_table.insert().values(left_id=left_obj.id, right_id=right_obj.id)) + db.commit() + + # 合并 user_settings + remote_prefs = client.download_json("user_settings.json") + if remote_prefs and remote_prefs.get("items"): + pref = remote_prefs["items"][0] + user_settings = db.query(UserSettings).filter(UserSettings.id == 1).first() + if user_settings: + for field in USER_SETTINGS_SYNC_FIELDS: + if field in pref and pref[field] is not None: + val = pref[field] + if isinstance(val, str) and field == "birthday": + try: + val = date_type.fromisoformat(val) + except (ValueError, TypeError): + pass + setattr(user_settings, field, val) + db.commit() + + # 统一上传合并后的数据到远端 + _upload_all_to_remote(db, client) + + settings.last_sync_at = utcnow() + settings.last_sync_version = (settings.last_sync_version or 0) + 1 + settings.sync_enabled = True + db.commit() + + return {"success": True, "message": f"同步完成: 推送 {stats['pushed']}, 拉取 {stats['pulled']}, 合并 {stats['merged']}, 删除 {stats['deleted']}"} + except Exception as e: + logger.error(f"双向同步失败: {e}", exc_info=True) + db.rollback() + return {"success": False, "message": f"同步失败: {str(e)}"} + finally: + release_sync_lock() + + +def _upload_all_to_remote(db: Session, client: WebDAVClient): + """将本地所有数据上传到远端""" + for coll_name, model_class in SYNC_COLLECTIONS: + items = [_serialize_model(obj, model_class) for obj in db.query(model_class).all()] + client.upload_json(f"{coll_name}.json", { + "version": 1, + "collection": coll_name, + "updated_at": utcnow().isoformat(), + "items": items, + }) + + for assoc_name, assoc_table, left_name, right_name in ASSOCIATION_COLLECTIONS: + left_model = MODEL_MAP.get(left_name) + right_model = MODEL_MAP.get(right_name) + if not left_model or not right_model: + continue + rows = db.execute(assoc_table.select()).fetchall() + items = [_serialize_association(row, left_model, right_model, db) for row in rows] + items = [i for i in items if i is not None] + client.upload_json(f"{assoc_name}.json", { + "version": 1, + "collection": assoc_name, + "updated_at": utcnow().isoformat(), + "items": items, + }) + + user_settings = db.query(UserSettings).filter(UserSettings.id == 1).first() + if user_settings: + pref_data = {} + for field in USER_SETTINGS_SYNC_FIELDS: + val = getattr(user_settings, field, None) + if isinstance(val, (datetime, date_type)): + val = val.isoformat() if val else None + pref_data[field] = val + client.upload_json("user_settings.json", { + "version": 1, + "collection": "user_settings", + "updated_at": utcnow().isoformat(), + "items": [pref_data], + }) + + manifest = { + "version": 1, + "last_sync_at": utcnow().isoformat(), + "collections": {}, + } + for coll_name, model_class in SYNC_COLLECTIONS: + count = db.query(model_class).filter(model_class.is_deleted == False).count() + manifest["collections"][coll_name] = { + "count": count, + "updated_at": utcnow().isoformat(), + } + client.upload_json("manifest.json", manifest) \ No newline at end of file diff --git a/api/app/utils/webdav.py b/api/app/utils/webdav.py new file mode 100644 index 0000000..35f62b1 --- /dev/null +++ b/api/app/utils/webdav.py @@ -0,0 +1,150 @@ +""" +WebDAV 客户端工具 +基于 requests 实现,兼容 Alist 等 WebDAV 服务 +""" +import json +from datetime import datetime +from typing import Any + +import requests +from requests.auth import HTTPBasicAuth + +from app.utils.logger import logger + + +class WebDAVClient: + """WebDAV 客户端,用于与 Alist 等 WebDAV 服务交互""" + + def __init__(self, url: str, username: str, password: str, path: str = "/elysia-todo/"): + self.base_url = url.rstrip("/") + self.username = username + self.password = username # Alist 使用用户名作为密码 + self.auth = HTTPBasicAuth(username, self.password) + self.path = path if path.startswith("/") else f"/{path}" + self._session = requests.Session() + self._session.auth = self.auth + self._session.timeout = 30 + self._session.headers.update({"Content-Type": "application/json"}) + + @property + def _data_url(self) -> str: + return f"{self.base_url}{self.path}data/" + + @property + def _backups_url(self) -> str: + return f"{self.base_url}{self.path}backups/" + + def _url(self, filename: str) -> str: + return f"{self._data_url}{filename}" + + def _manifest_url(self) -> str: + return f"{self.base_url}{self.path}manifest.json" + + def test_connection(self) -> tuple[bool, str]: + """测试 WebDAV 连接,返回 (成功, 消息)""" + try: + resp = self._session.request("PROPFIND", f"{self.base_url}{self.path}", headers={"Depth": "0"}) + if resp.status_code in (200, 207, 404): + return True, "连接成功" + return False, f"连接失败: HTTP {resp.status_code}" + except requests.ConnectionError: + return False, "连接失败: 无法连接到服务器" + except requests.Timeout: + return False, "连接超时" + except Exception as e: + return False, f"连接失败: {str(e)}" + + def ensure_dirs(self) -> bool: + """确保远端目录结构存在""" + try: + for path in [self.path, f"{self.path}data/", f"{self.path}backups/"]: + url = f"{self.base_url}{path}" + self._session.request("PROPFIND", url, headers={"Depth": "0"}) + resp = self._session.request("MKCOL", url) + if resp.status_code in (200, 201, 405, 301): + pass + return True + except Exception as e: + logger.error(f"创建远端目录失败: {e}") + return False + + def upload_json(self, filename: str, data: Any) -> bool: + """上传 JSON 数据到 WebDAV""" + try: + url = self._url(filename) if filename != "manifest.json" else self._manifest_url() + content = json.dumps(data, ensure_ascii=False, indent=2, default=str).encode("utf-8") + headers = {"Content-Type": "application/json"} + resp = self._session.put(url, data=content, headers=headers) + if resp.status_code in (200, 201, 204): + return True + logger.error(f"上传 {filename} 失败: HTTP {resp.status_code}") + return False + except Exception as e: + logger.error(f"上传 {filename} 异常: {e}") + return False + + def download_json(self, filename: str) -> Any | None: + """从 WebDAV 下载 JSON 数据""" + try: + url = self._url(filename) if filename != "manifest.json" else self._manifest_url() + resp = self._session.get(url) + if resp.status_code == 200: + return resp.json() + if resp.status_code == 404: + return None + logger.error(f"下载 {filename} 失败: HTTP {resp.status_code}") + return None + except Exception as e: + logger.error(f"下载 {filename} 异常: {e}") + return None + + def delete_file(self, filename: str) -> bool: + """删除 WebDAV 上的文件""" + try: + url = self._url(filename) if filename != "manifest.json" else self._manifest_url() + resp = self._session.delete(url) + return resp.status_code in (200, 204, 404) + except Exception as e: + logger.error(f"删除 {filename} 异常: {e}") + return False + + def backup_remote(self, timestamp: str) -> bool: + """备份远端数据到 backups/{timestamp}/""" + try: + backup_path = f"{self.path}backups/{timestamp}/data/" + backup_url = f"{self.base_url}{backup_path}" + self._session.request("MKCOL", f"{self.base_url}{self.path}backups/") + self._session.request("MKCOL", f"{self.base_url}{self.path}backups/{timestamp}/") + self._session.request("MKCOL", backup_url) + + for filename in [ + "manifest.json", "user_settings.json", "categories.json", + "tasks.json", "tags.json", "task_tags.json", + "habit_groups.json", "habits.json", "habit_checkins.json", + "anniversary_categories.json", "anniversaries.json", + "goals.json", "goal_steps.json", "goal_reviews.json", "goal_tasks.json", + ]: + data = self.download_json(filename) + if data is not None: + src_url = self._url(filename) if filename != "manifest.json" else self._manifest_url() + dst_url = f"{backup_url}{filename}" if filename != "manifest.json" else f"{self.base_url}{backup_path}../manifest.json" + content = json.dumps(data, ensure_ascii=False, indent=2, default=str).encode("utf-8") + self._session.put(dst_url, data=content, headers={"Content-Type": "application/json"}) + + return True + except Exception as e: + logger.error(f"备份远端数据失败: {e}") + return False + + def clear_remote(self) -> bool: + """清空远端数据目录""" + filenames = [ + "manifest.json", "user_settings.json", "categories.json", + "tasks.json", "tags.json", "task_tags.json", + "habit_groups.json", "habits.json", "habit_checkins.json", + "anniversary_categories.json", "anniversaries.json", + "goals.json", "goal_steps.json", "goal_reviews.json", "goal_tasks.json", + ] + for f in filenames: + self.delete_file(f) + return True \ No newline at end of file diff --git a/docs/plan/webdav-sync-design.md b/docs/plan/webdav-sync-design.md new file mode 100644 index 0000000..c89f2b6 --- /dev/null +++ b/docs/plan/webdav-sync-design.md @@ -0,0 +1,431 @@ +# 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/ | \ No newline at end of file diff --git a/scripts/start.bat b/scripts/start.bat new file mode 100644 index 0000000..f04b3a8 --- /dev/null +++ b/scripts/start.bat @@ -0,0 +1,53 @@ +@echo off +chcp 65001 >nul 2>&1 +title 爱莉希雅待办事项 + +echo ==================================================== +echo 爱莉希雅待办事项 - 启动脚本 +echo ==================================================== +echo. + +:: 项目根目录(脚本所在目录的上级) +set "PROJECT_ROOT=%~dp0.." +cd /d "%PROJECT_ROOT%" + +:: 检查 Python +where python >nul 2>&1 +if errorlevel 1 ( + echo [错误] 未找到 Python,请确保已安装并添加到 PATH + pause + exit /b 1 +) + +:: 检查依赖 +if not exist "api\app\__init__.py" ( + echo [错误] 未找到项目文件,请确认在项目根目录运行 + pause + exit /b 1 +) + +:: 安装 Python 依赖(如需) +if not exist "api\__pycache__" ( + echo [信息] 首次运行,安装 Python 依赖... + pip install -r requirements.txt -q + if errorlevel 1 ( + echo [错误] Python 依赖安装失败 + pause + exit /b 1 + ) +) + +:: 检查端口占用 +echo [信息] 检查端口 23994 占用情况... +for /f "tokens=5" %%a in ('netstat -ano -p TCP ^| findstr ":23994.*LISTENING"') do ( + echo [警告] 端口 23994 已被进程 %%a 占用,正在尝试终止... + taskkill /PID %%a /F >nul 2>&1 + timeout /t 2 /nobreak >nul +) + +:: 启动项目 +echo [信息] 正在启动项目... +echo [信息] 访问地址: http://localhost:23994 +echo. +python main.py +pause \ No newline at end of file diff --git a/scripts/stop.bat b/scripts/stop.bat new file mode 100644 index 0000000..6773275 --- /dev/null +++ b/scripts/stop.bat @@ -0,0 +1,42 @@ +@echo off +chcp 65001 >nul 2>&1 +title 爱莉希雅待办事项 - 停止 + +echo ==================================================== +echo 爱莉希雅待办事项 - 停止脚本 +echo ==================================================== +echo. + +set "PORT=23994" +set "FOUND=0" + +:: 查找并终止占用端口的进程 +for /f "tokens=5" %%a in ('netstat -ano -p TCP ^| findstr ":%PORT%.*LISTENING"') do ( + echo [信息] 发现进程 %%a 占用端口 %PORT% + taskkill /PID %%a /F >nul 2>&1 + if errorlevel 1 ( + echo [错误] 终止进程 %%a 失败,请尝试手动终止 + ) else ( + echo [成功] 进程 %%a 已终止 + set "FOUND=1" + ) +) + +if "%FOUND%"=="0" ( + echo [信息] 端口 %PORT% 未被占用,无需停止 +) else ( + echo. + echo [信息] 等待端口释放... + timeout /t 2 /nobreak >nul + + :: 二次确认 + for /f "tokens=5" %%a in ('netstat -ano -p TCP ^| findstr ":%PORT%.*LISTENING"') do ( + echo [警告] 端口 %PORT% 仍被进程 %%a 占用,再次尝试终止... + taskkill /PID %%a /F >nul 2>&1 + timeout /t 1 /nobreak >nul + ) + echo [成功] 项目已停止 +) + +echo. +pause \ No newline at end of file