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

View File

@@ -43,6 +43,9 @@ instance.interceptors.response.use(
case 429: case 429:
message = data?.detail || '请求过于频繁,请稍后再试~' message = data?.detail || '请求过于频繁,请稍后再试~'
break break
case 503:
message = data?.detail || '正在同步数据,请稍后再试~'
break
case 500: case 500:
message = '服务器内部错误~' message = '服务器内部错误~'
break break

52
WebUI/src/api/sync.ts Normal file
View File

@@ -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<SyncConfig>('/sync/config'),
updateConfig: (data: SyncConfigUpdate) => put<SyncConfig>('/sync/config', data),
testConnection: () => post<SyncResult>('/sync/test'),
push: () => post<SyncResult>('/sync/push'),
pull: () => post<SyncResult>('/sync/pull'),
sync: () => post<SyncResult>('/sync/sync'),
getStatus: () => get<SyncStatus>('/sync/status'),
clearRemote: () => del<SyncResult>('/sync/remote'),
}

View File

@@ -2,6 +2,7 @@ export type QuadrantPriority = 'q1' | 'q2' | 'q3' | 'q4'
export interface Task { export interface Task {
id: number id: number
uuid?: string
title: string title: string
description?: string description?: string
priority: QuadrantPriority priority: QuadrantPriority
@@ -16,6 +17,7 @@ export interface Task {
export interface Category { export interface Category {
id: number id: number
uuid?: string
name: string name: string
color: string color: string
icon: string icon: string
@@ -23,6 +25,7 @@ export interface Category {
export interface Tag { export interface Tag {
id: number id: number
uuid?: string
name: string name: string
} }
@@ -88,6 +91,7 @@ export interface UserSettingsUpdate {
export interface HabitGroup { export interface HabitGroup {
id: number id: number
uuid?: string
name: string name: string
color: string color: string
icon: string icon: string
@@ -105,6 +109,7 @@ export type HabitFrequency = 'daily' | 'weekly'
export interface Habit { export interface Habit {
id: number id: number
uuid?: string
name: string name: string
description?: string description?: string
group_id?: number group_id?: number
@@ -128,6 +133,7 @@ export interface HabitFormData {
export interface HabitCheckin { export interface HabitCheckin {
id: number id: number
uuid?: string
habit_id: number habit_id: number
checkin_date: string checkin_date: string
count: number count: number
@@ -146,6 +152,7 @@ export interface HabitStats {
export interface AnniversaryCategory { export interface AnniversaryCategory {
id: number id: number
uuid?: string
name: string name: string
icon: string icon: string
color: string color: string
@@ -161,6 +168,7 @@ export interface AnniversaryCategoryFormData {
export interface Anniversary { export interface Anniversary {
id: number id: number
uuid?: string
title: string title: string
date: string date: string
year?: number | null year?: number | null
@@ -195,6 +203,7 @@ export type StepStatus = 'pending' | 'in_progress' | 'completed'
export interface Goal { export interface Goal {
id: number id: number
uuid?: string
title: string title: string
description?: string | null description?: string | null
status: GoalStatus status: GoalStatus
@@ -220,6 +229,7 @@ export interface GoalDetail extends Goal {
export interface GoalStep { export interface GoalStep {
id: number id: number
uuid?: string
goal_id: number goal_id: number
parent_id?: number | null parent_id?: number | null
title: string title: string
@@ -234,6 +244,7 @@ export interface GoalStep {
export interface GoalReview { export interface GoalReview {
id: number id: number
uuid?: string
goal_id: number goal_id: number
content: string content: string
rating?: number | null rating?: number | null

View File

@@ -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<SyncConfig | null>(null)
const status = ref<SyncStatus | null>(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,
}
})

View File

@@ -6,6 +6,7 @@ import { useTaskStore } from '@/stores/useTaskStore'
import { useCategoryStore } from '@/stores/useCategoryStore' import { useCategoryStore } from '@/stores/useCategoryStore'
import { useTagStore } from '@/stores/useTagStore' import { useTagStore } from '@/stores/useTagStore'
import { useHabitStore } from '@/stores/useHabitStore' import { useHabitStore } from '@/stores/useHabitStore'
import { useSyncStore, type SyncDirection } from '@/stores/useSyncStore'
import { get, post, del } from '@/api/request' import { get, post, del } from '@/api/request'
import type { Task, Category, Tag, HabitGroup, Habit } from '@/api/types' import type { Task, Category, Tag, HabitGroup, Habit } from '@/api/types'
@@ -14,6 +15,7 @@ const taskStore = useTaskStore()
const categoryStore = useCategoryStore() const categoryStore = useCategoryStore()
const tagStore = useTagStore() const tagStore = useTagStore()
const habitStore = useHabitStore() const habitStore = useHabitStore()
const syncStore = useSyncStore()
const saving = ref(false) const saving = ref(false)
const exporting = ref(false) const exporting = ref(false)
@@ -42,13 +44,48 @@ const prefs = ref({
default_sort_order: 'desc' 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<SyncDirection>('sync')
let pollTimer: ReturnType<typeof setInterval> | null = null
onMounted(() => { onMounted(() => {
prefs.value.site_name = userStore.siteName || '爱莉希雅待办' prefs.value.site_name = userStore.siteName || '爱莉希雅待办'
prefs.value.default_view = userStore.defaultView || 'list' prefs.value.default_view = userStore.defaultView || 'list'
prefs.value.default_sort_by = userStore.defaultSortBy || 'priority' prefs.value.default_sort_by = userStore.defaultSortBy || 'priority'
prefs.value.default_sort_order = userStore.defaultSortOrder || 'desc' 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() { async function handleSave() {
saving.value = true saving.value = true
try { try {
@@ -60,7 +97,6 @@ async function handleSave() {
}) })
userStore.syncFromSettings(userStore.settings!) userStore.syncFromSettings(userStore.settings!)
// 保存排序后立即应用
taskStore.setFilters({ taskStore.setFilters({
sort_by: prefs.value.default_sort_by as 'priority' | 'due_date' | 'created_at', sort_by: prefs.value.default_sort_by as 'priority' | 'due_date' | 'created_at',
sort_order: prefs.value.default_sort_order as 'asc' | 'desc' sort_order: prefs.value.default_sort_order as 'asc' | 'desc'
@@ -74,6 +110,28 @@ async function handleSave() {
} }
} }
async function saveWebdavConfig() {
const data: Record<string, unknown> = {
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() { async function exportData() {
exporting.value = true exporting.value = true
try { try {
@@ -444,6 +502,166 @@ async function clearCompleted() {
</div> </div>
</div> </div>
</div> </div>
<!-- 数据同步 -->
<div class="settings-card">
<div class="card-header">
<div class="card-icon sync-icon">
<el-icon :size="24"><Connection /></el-icon>
</div>
<div>
<h3 class="card-title">数据同步</h3>
<p class="card-subtitle">通过 WebDAV 同步你的数据</p>
</div>
</div>
<div class="card-body">
<div class="setting-item">
<div class="setting-label">
<span class="label-text">服务器地址</span>
<span class="label-desc">Alist WebDAV 地址</span>
</div>
<el-input
v-model="webdavConfig.webdav_url"
placeholder="https://alist.example.com/dav"
style="width: 260px"
/>
</div>
<div class="setting-item">
<div class="setting-label">
<span class="label-text">用户名</span>
<span class="label-desc">WebDAV 登录用户名</span>
</div>
<el-input
v-model="webdavConfig.webdav_username"
placeholder="用户名"
style="width: 200px"
/>
</div>
<div class="setting-item">
<div class="setting-label">
<span class="label-text">密码</span>
<span class="label-desc">WebDAV 登录密码</span>
</div>
<div style="display: flex; gap: 8px; align-items: center;">
<el-input
v-model="webdavConfig.webdav_password"
type="password"
show-password
:placeholder="syncStore.config?.webdav_password ? '已保存(留空不变更)' : '输入密码'"
style="width: 200px"
/>
<el-button @click="testWebdavConnection" :loading="syncStore.loading" size="default">
测试连接
</el-button>
</div>
</div>
<div class="setting-item">
<div class="setting-label">
<span class="label-text">远端路径</span>
<span class="label-desc">WebDAV 上的存储路径</span>
</div>
<el-input
v-model="webdavConfig.webdav_path"
placeholder="/elysia-todo/"
style="width: 200px"
/>
</div>
<div class="form-actions">
<el-button
type="primary"
:loading="syncStore.loading"
@click="saveWebdavConfig"
class="save-btn"
>
保存配置
</el-button>
</div>
</div>
</div>
<!-- 同步操作 -->
<div class="settings-card" v-if="syncStore.isConfigured">
<div class="card-header">
<div class="card-icon sync-icon">
<el-icon :size="24"><Refresh /></el-icon>
</div>
<div>
<h3 class="card-title">同步操作</h3>
<p class="card-subtitle">选择同步方向并执行</p>
</div>
</div>
<div class="card-body">
<div class="setting-item">
<div class="setting-label">
<span class="label-text">同步方向</span>
<span class="label-desc">选择数据同步的方向</span>
</div>
<el-radio-group v-model="syncDirection">
<el-radio-button value="sync">双向合并</el-radio-button>
<el-radio-button value="push">推送</el-radio-button>
<el-radio-button value="pull">拉取</el-radio-button>
</el-radio-group>
</div>
<div class="sync-info" v-if="syncStore.status">
<span class="info-item">
<el-icon><Clock /></el-icon>
上次同步: {{ syncStore.status.last_sync_at ? new Date(syncStore.status.last_sync_at).toLocaleString() : '从未同步' }}
</span>
<span class="info-item" v-if="syncStore.status.last_sync_version > 0">
版本: v{{ syncStore.status.last_sync_version }}
</span>
</div>
<div class="sync-overlay-hint" v-if="syncStore.syncing">
<el-icon class="sync-spin" :size="20"><Loading /></el-icon>
<span>{{ syncStore.syncMessage || '正在同步...' }}</span>
</div>
<div class="data-actions">
<div class="data-action-item">
<div class="action-info">
<span class="action-title">执行同步</span>
<span class="action-desc" v-if="syncDirection === 'push'">将本地数据推送到远端远端数据将被覆盖</span>
<span class="action-desc warning" v-else-if="syncDirection === 'pull'">从远端拉取数据本地数据将被覆盖</span>
<span class="action-desc" v-else>合并本地和远端数据以最新版本为准</span>
</div>
<el-button
type="primary"
:loading="syncStore.syncing"
:disabled="syncStore.syncing"
@click="startSync"
class="action-btn"
>
<el-icon><Refresh /></el-icon>
{{ syncStore.syncing ? syncStore.syncMessage : '开始同步' }}
</el-button>
</div>
<div class="data-action-item">
<div class="action-info">
<span class="action-title">清空远端数据</span>
<span class="action-desc danger">删除 WebDAV 上的所有同步数据不可恢复</span>
</div>
<el-button
type="danger"
plain
@click="syncStore.clearRemote()"
class="action-btn"
>
<el-icon><Delete /></el-icon>
清空远端
</el-button>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@@ -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) { @media (max-width: 768px) {
.settings-page { .settings-page {
padding: 16px; padding: 16px;

View File

@@ -1,23 +1,18 @@
from sqlalchemy import create_engine, inspect, text, String, Integer, Text, Boolean, Float, DateTime, Date from sqlalchemy import create_engine, inspect, text, String, Integer, Text, Boolean, Float, DateTime, Date, event
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import declarative_base, sessionmaker
from sqlalchemy.orm import sessionmaker
import os
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( engine = create_engine(
DATABASE_URL, 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) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# 创建基类
Base = declarative_base() Base = declarative_base()
@@ -30,20 +25,18 @@ def get_db():
db.close() db.close()
# SQLAlchemy 类型到 SQLite 类型名的映射
_TYPE_MAP = { _TYPE_MAP = {
String: "VARCHAR", String: "VARCHAR",
Integer: "INTEGER", Integer: "INTEGER",
Text: "TEXT", Text: "TEXT",
Boolean: "BOOLEAN", Boolean: "BOOLEAN",
Float: "REAL", Float: "DOUBLE PRECISION",
DateTime: "DATETIME", DateTime: "TIMESTAMP",
Date: "DATE", Date: "DATE",
} }
def _col_type_str(col_type) -> str: def _col_type_str(col_type) -> str:
"""将 SQLAlchemy 列类型转为 SQLite 类型字符串"""
if col_type.__class__ in _TYPE_MAP: if col_type.__class__ in _TYPE_MAP:
base = _TYPE_MAP[col_type.__class__] base = _TYPE_MAP[col_type.__class__]
else: else:
@@ -56,14 +49,13 @@ def _col_type_str(col_type) -> str:
def init_db(): def init_db():
"""初始化数据库表,自动补充新增的列""" """初始化数据库表,自动补充新增的列,并为缺少 uuid 的记录回填"""
# 导入所有模型,确保 Base.metadata 包含全部表定义 from app.utils.logger import logger # 避免循环导入
from app.models import ( # noqa: F401 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) Base.metadata.create_all(bind=engine)
# 通用自动迁移:对比 ORM 模型与实际表结构补充缺失的列SQLite 兼容)
inspector = inspect(engine) inspector = inspect(engine)
table_names = set(inspector.get_table_names()) table_names = set(inspector.get_table_names())
@@ -78,24 +70,75 @@ def init_db():
for col in table_cls.columns: for col in table_cls.columns:
if col.name in existing_cols: if col.name in existing_cols:
continue continue
# 跳过无服务端默认值且不可为空的列(容易出错)
if col.nullable is False and col.server_default is None and col.default is None: if col.nullable is False and col.server_default is None and col.default is None:
continue 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: 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}" 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 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}'" ddl += f" DEFAULT '{default_val}'"
elif isinstance(default_val, bool):
ddl += f" DEFAULT {1 if default_val else 0}"
else: else:
ddl += f" DEFAULT {default_val}" 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)) 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)

View File

@@ -11,7 +11,8 @@ from app.database import init_db, SessionLocal
from app.models.user_settings import UserSettings from app.models.user_settings import UserSettings
from app.routers import api_router from app.routers import api_router
from app.utils.logger import logger 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 from jose import JWTError
@@ -111,19 +112,39 @@ async def auth_middleware(request: Request, call_next):
except JWTError: except JWTError:
return JSONResponse(status_code=401, content={"detail": "登录已过期,请重新登录"}) return JSONResponse(status_code=401, content={"detail": "登录已过期,请重新登录"})
db = SessionLocal() user_id = payload.get("sub", "")
try: token_tv = payload.get("tv")
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
if settings and payload.get("tv") != settings.token_version: 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": "密码已修改,请重新登录"}) return JSONResponse(status_code=401, content={"detail": "密码已修改,请重新登录"})
finally:
db.close()
request.state.user = payload request.state.user = payload
return await call_next(request) 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) @app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception): async def global_exception_handler(request: Request, exc: Exception):

View File

@@ -5,10 +5,12 @@ from app.models.user_settings import UserSettings
from app.models.habit import HabitGroup, Habit, HabitCheckin from app.models.habit import HabitGroup, Habit, HabitCheckin
from app.models.anniversary import AnniversaryCategory, Anniversary from app.models.anniversary import AnniversaryCategory, Anniversary
from app.models.goal import Goal, GoalStep, GoalReview, goal_tasks from app.models.goal import Goal, GoalStep, GoalReview, goal_tasks
from app.models.sync_settings import SyncSettings
__all__ = [ __all__ = [
"Task", "Category", "Tag", "task_tags", "UserSettings", "Task", "Category", "Tag", "task_tags", "UserSettings",
"HabitGroup", "Habit", "HabitCheckin", "HabitGroup", "Habit", "HabitCheckin",
"AnniversaryCategory", "Anniversary", "AnniversaryCategory", "Anniversary",
"Goal", "GoalStep", "GoalReview", "goal_tasks", "Goal", "GoalStep", "GoalReview", "goal_tasks",
"SyncSettings",
] ]

View File

@@ -1,3 +1,4 @@
import uuid as _uuid
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Date from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Date
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.database import Base from app.database import Base
@@ -9,10 +10,13 @@ class AnniversaryCategory(Base):
__tablename__ = "anniversary_categories" __tablename__ = "anniversary_categories"
id = Column(Integer, primary_key=True, index=True) 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) name = Column(String(50), nullable=False)
icon = Column(String(50), default="calendar") icon = Column(String(50), default="calendar")
color = Column(String(20), default="#FFB7C5") color = Column(String(20), default="#FFB7C5")
sort_order = Column(Integer, default=0) sort_order = Column(Integer, default=0)
is_deleted = Column(Boolean, default=False)
sync_version = Column(Integer, default=1)
# 关联关系 # 关联关系
anniversaries = relationship("Anniversary", back_populates="category") anniversaries = relationship("Anniversary", back_populates="category")
@@ -23,6 +27,7 @@ class Anniversary(Base):
__tablename__ = "anniversaries" __tablename__ = "anniversaries"
id = Column(Integer, primary_key=True, index=True) 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) title = Column(String(200), nullable=False)
date = Column(Date, nullable=False) # 月-日,年份部分可选 date = Column(Date, nullable=False) # 月-日,年份部分可选
year = Column(Integer, nullable=True) # 年份,用于计算第 N 个周年 year = Column(Integer, nullable=True) # 年份,用于计算第 N 个周年
@@ -30,6 +35,8 @@ class Anniversary(Base):
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
is_recurring = Column(Boolean, default=True) is_recurring = Column(Boolean, default=True)
remind_days_before = Column(Integer, default=3) 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) created_at = Column(DateTime, default=utcnow)
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)

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 sqlalchemy.orm import relationship
from app.database import Base from app.database import Base
@@ -8,9 +9,12 @@ class Category(Base):
__tablename__ = "categories" __tablename__ = "categories"
id = Column(Integer, primary_key=True, index=True) 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) name = Column(String(100), nullable=False)
color = Column(String(20), default="#FFB7C5") # 默认樱花粉 color = Column(String(20), default="#FFB7C5") # 默认樱花粉
icon = Column(String(50), default="folder") # 默认图标 icon = Column(String(50), default="folder") # 默认图标
is_deleted = Column(Boolean, default=False)
sync_version = Column(Integer, default=1)
# 关联关系 # 关联关系
tasks = relationship("Task", back_populates="category") tasks = relationship("Task", back_populates="category")

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 sqlalchemy.orm import relationship
from app.database import Base from app.database import Base
from app.utils.datetime import utcnow from app.utils.datetime import utcnow
@@ -18,6 +19,7 @@ class Goal(Base):
__tablename__ = "goals" __tablename__ = "goals"
id = Column(Integer, primary_key=True, index=True) 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) title = Column(String(200), nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
status = Column(String(20), default="active") # active/paused/completed/abandoned status = Column(String(20), default="active") # active/paused/completed/abandoned
@@ -28,6 +30,8 @@ class Goal(Base):
color = Column(String(20), default="#FFB7C5") color = Column(String(20), default="#FFB7C5")
icon = Column(String(50), default="flag") icon = Column(String(50), default="flag")
sort_order = Column(Integer, default=0) sort_order = Column(Integer, default=0)
is_deleted = Column(Boolean, default=False)
sync_version = Column(Integer, default=1)
created_at = Column(DateTime, default=utcnow) created_at = Column(DateTime, default=utcnow)
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
@@ -51,6 +55,7 @@ class GoalStep(Base):
__tablename__ = "goal_steps" __tablename__ = "goal_steps"
id = Column(Integer, primary_key=True, index=True) 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) goal_id = Column(Integer, ForeignKey("goals.id"), nullable=False)
parent_id = Column(Integer, ForeignKey("goal_steps.id"), nullable=True) parent_id = Column(Integer, ForeignKey("goal_steps.id"), nullable=True)
title = Column(String(200), nullable=False) title = Column(String(200), nullable=False)
@@ -59,6 +64,8 @@ class GoalStep(Base):
target_date = Column(Date, nullable=True) target_date = Column(Date, nullable=True)
reached_at = Column(DateTime, nullable=True) reached_at = Column(DateTime, nullable=True)
sort_order = Column(Integer, default=0) sort_order = Column(Integer, default=0)
is_deleted = Column(Boolean, default=False)
sync_version = Column(Integer, default=1)
created_at = Column(DateTime, default=utcnow) created_at = Column(DateTime, default=utcnow)
# 关联关系 # 关联关系
@@ -71,9 +78,12 @@ class GoalReview(Base):
__tablename__ = "goal_reviews" __tablename__ = "goal_reviews"
id = Column(Integer, primary_key=True, index=True) 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) goal_id = Column(Integer, ForeignKey("goals.id"), nullable=False)
content = Column(Text, nullable=False) content = Column(Text, nullable=False)
rating = Column(Integer, nullable=True) # 1-5 自评 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) created_at = Column(DateTime, default=utcnow)
# 关联关系 # 关联关系

View File

@@ -1,3 +1,4 @@
import uuid as _uuid
from sqlalchemy import Column, Integer, String, Text, Boolean, Date, DateTime, ForeignKey, UniqueConstraint from sqlalchemy import Column, Integer, String, Text, Boolean, Date, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.database import Base from app.database import Base
@@ -9,10 +10,13 @@ class HabitGroup(Base):
__tablename__ = "habit_groups" __tablename__ = "habit_groups"
id = Column(Integer, primary_key=True, index=True) 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) name = Column(String(100), nullable=False)
color = Column(String(20), default="#FFB7C5") color = Column(String(20), default="#FFB7C5")
icon = Column(String(50), default="flag") icon = Column(String(50), default="flag")
sort_order = Column(Integer, default=0) 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") habits = relationship("Habit", back_populates="group", order_by="Habit.created_at")
@@ -23,6 +27,7 @@ class Habit(Base):
__tablename__ = "habits" __tablename__ = "habits"
id = Column(Integer, primary_key=True, index=True) 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) name = Column(String(200), nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
group_id = Column(Integer, ForeignKey("habit_groups.id"), 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 frequency = Column(String(20), default="daily") # daily / weekly
active_days = Column(String(100), nullable=True) # JSON 数组, 如 [0,2,4] 表示周一三五 active_days = Column(String(100), nullable=True) # JSON 数组, 如 [0,2,4] 表示周一三五
is_archived = Column(Boolean, default=False) is_archived = Column(Boolean, default=False)
is_deleted = Column(Boolean, default=False)
sync_version = Column(Integer, default=1)
created_at = Column(DateTime, default=utcnow) created_at = Column(DateTime, default=utcnow)
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
@@ -46,9 +53,12 @@ class HabitCheckin(Base):
) )
id = Column(Integer, primary_key=True, index=True) 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) habit_id = Column(Integer, ForeignKey("habits.id"), nullable=False)
checkin_date = Column(Date, nullable=False) checkin_date = Column(Date, nullable=False)
count = Column(Integer, default=0) count = Column(Integer, default=0)
is_deleted = Column(Boolean, default=False)
sync_version = Column(Integer, default=1)
created_at = Column(DateTime, default=utcnow) created_at = Column(DateTime, default=utcnow)
# 关联关系 # 关联关系

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)

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 sqlalchemy.orm import relationship
from app.database import Base from app.database import Base
@@ -17,7 +18,10 @@ class Tag(Base):
__tablename__ = "tags" __tablename__ = "tags"
id = Column(Integer, primary_key=True, index=True) 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) 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") tasks = relationship("Task", secondary=task_tags, back_populates="tags")

View File

@@ -1,3 +1,4 @@
import uuid as _uuid
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from app.database import Base from app.database import Base
@@ -9,12 +10,15 @@ class Task(Base):
__tablename__ = "tasks" __tablename__ = "tasks"
id = Column(Integer, primary_key=True, index=True) 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) title = Column(String(200), nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
priority = Column(String(20), default="q4") # q1(重要紧急), q2(重要不紧急), q3(不重要紧急), q4(不重要不紧急) priority = Column(String(20), default="q4") # q1(重要紧急), q2(重要不紧急), q3(不重要紧急), q4(不重要不紧急)
due_date = Column(DateTime, nullable=True) due_date = Column(DateTime, nullable=True)
is_completed = Column(Boolean, default=False) is_completed = Column(Boolean, default=False)
is_deleted = Column(Boolean, default=False)
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True) category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
sync_version = Column(Integer, default=1)
created_at = Column(DateTime, default=utcnow) created_at = Column(DateTime, default=utcnow)
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow) updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter 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() api_router = APIRouter()
@@ -11,3 +11,4 @@ api_router.include_router(user_settings.router)
api_router.include_router(habits.router) api_router.include_router(habits.router)
api_router.include_router(anniversaries.router) api_router.include_router(anniversaries.router)
api_router.include_router(goals.router) api_router.include_router(goals.router)
api_router.include_router(sync.router)

152
api/app/routers/sync.py Normal file
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="清空远端数据失败")

View File

@@ -54,6 +54,7 @@ class AnniversaryCategoryUpdate(BaseModel):
class AnniversaryCategoryResponse(AnniversaryCategoryBase): class AnniversaryCategoryResponse(AnniversaryCategoryBase):
"""纪念日分类响应模型""" """纪念日分类响应模型"""
id: int id: int
uuid: Optional[str] = None
class Config: class Config:
from_attributes = True from_attributes = True
@@ -111,6 +112,7 @@ class AnniversaryUpdate(BaseModel):
class AnniversaryResponse(AnniversaryBase): class AnniversaryResponse(AnniversaryBase):
"""纪念日响应模型""" """纪念日响应模型"""
id: int id: int
uuid: Optional[str] = None
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
category: Optional[AnniversaryCategoryResponse] = None category: Optional[AnniversaryCategoryResponse] = None

View File

@@ -1,4 +1,5 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Optional
class CategoryBase(BaseModel): class CategoryBase(BaseModel):
@@ -23,6 +24,7 @@ class CategoryUpdate(BaseModel):
class CategoryResponse(CategoryBase): class CategoryResponse(CategoryBase):
"""分类响应模型""" """分类响应模型"""
id: int id: int
uuid: Optional[str] = None
class Config: class Config:
from_attributes = True from_attributes = True

View File

@@ -36,6 +36,7 @@ class GoalStepUpdate(BaseModel):
class GoalStepResponse(GoalStepBase): class GoalStepResponse(GoalStepBase):
id: int id: int
uuid: Optional[str] = None
goal_id: int goal_id: int
reached_at: Optional[datetime] = None reached_at: Optional[datetime] = None
created_at: datetime created_at: datetime
@@ -54,6 +55,7 @@ class GoalReviewCreate(BaseModel):
class GoalReviewResponse(BaseModel): class GoalReviewResponse(BaseModel):
id: int id: int
uuid: Optional[str] = None
goal_id: int goal_id: int
content: str content: str
rating: Optional[int] = None rating: Optional[int] = None
@@ -97,6 +99,7 @@ class GoalUpdate(BaseModel):
class GoalListResponse(GoalBase): class GoalListResponse(GoalBase):
id: int id: int
uuid: Optional[str] = None
progress: int progress: int
completed_at: Optional[datetime] = None completed_at: Optional[datetime] = None
created_at: datetime created_at: datetime

View File

@@ -29,6 +29,7 @@ class HabitGroupUpdate(BaseModel):
class HabitGroupResponse(HabitGroupBase): class HabitGroupResponse(HabitGroupBase):
"""习惯分组响应模型""" """习惯分组响应模型"""
id: int id: int
uuid: Optional[str] = None
class Config: class Config:
from_attributes = True from_attributes = True
@@ -68,6 +69,7 @@ class HabitUpdate(BaseModel):
class HabitResponse(HabitBase): class HabitResponse(HabitBase):
"""习惯响应模型""" """习惯响应模型"""
id: int id: int
uuid: Optional[str] = None
is_archived: bool is_archived: bool
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -87,6 +89,7 @@ class CheckinCreate(BaseModel):
class CheckinResponse(BaseModel): class CheckinResponse(BaseModel):
"""打卡记录响应模型""" """打卡记录响应模型"""
id: int id: int
uuid: Optional[str] = None
habit_id: int habit_id: int
checkin_date: date checkin_date: date
count: int count: int

50
api/app/schemas/sync.py Normal file
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

View File

@@ -1,4 +1,5 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Optional
class TagBase(BaseModel): class TagBase(BaseModel):
@@ -14,6 +15,7 @@ class TagCreate(TagBase):
class TagResponse(TagBase): class TagResponse(TagBase):
"""标签响应模型""" """标签响应模型"""
id: int id: int
uuid: Optional[str] = None
class Config: class Config:
from_attributes = True from_attributes = True

View File

@@ -75,6 +75,7 @@ class TaskUpdate(BaseModel):
class TaskResponse(TaskBase): class TaskResponse(TaskBase):
"""任务响应模型""" """任务响应模型"""
id: int id: int
uuid: Optional[str] = None
is_completed: bool is_completed: bool
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime

53
api/app/utils/crypto.py Normal file
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

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)

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
api/app/utils/webdav.py Normal file
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

View File

@@ -0,0 +1,431 @@
# WebDAV 同步功能设计文档
日期: 2026-05-17
## 1. 概述
**目标**: 支持通过 WebDAVAlist在多设备间同步所有待办数据。
**成功指标**:
- 可以配置 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/ |

53
scripts/start.bat Normal file
View File

@@ -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

42
scripts/stop.bat Normal file
View File

@@ -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