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
152 lines
5.7 KiB
Python
152 lines
5.7 KiB
Python
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="清空远端数据失败") |