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,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)
|
||||
Reference in New Issue
Block a user