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:
@@ -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
api/app/routers/sync.py
Normal file
152
api/app/routers/sync.py
Normal 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="清空远端数据失败")
|
||||
Reference in New Issue
Block a user