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
This commit is contained in:
祀梦
2026-05-17 21:18:54 +08:00
parent 944d20dcc7
commit 0ab719500b
31 changed files with 2194 additions and 41 deletions
+72 -29
View File
@@ -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)
+28 -7
View File
@@ -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):
+2
View File
@@ -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",
]
+7
View File
@@ -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)
+5 -1
View File
@@ -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")
+11 -1
View File
@@ -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)
# 关联关系
+10
View File
@@ -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)
# 关联关系
+28
View File
@@ -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)
+5 -1
View File
@@ -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")
+4
View File
@@ -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)
+2 -1
View File
@@ -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)
+152
View File
@@ -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="清空远端数据失败")
+2
View File
@@ -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
+2
View File
@@ -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
+3
View File
@@ -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
+3
View File
@@ -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
+50
View File
@@ -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
+2
View File
@@ -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
+1
View File
@@ -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
+53
View File
@@ -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
+37
View File
@@ -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)
+567
View File
@@ -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)
+150
View File
@@ -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