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:
@@ -43,6 +43,9 @@ instance.interceptors.response.use(
|
||||
case 429:
|
||||
message = data?.detail || '请求过于频繁,请稍后再试~'
|
||||
break
|
||||
case 503:
|
||||
message = data?.detail || '正在同步数据,请稍后再试~'
|
||||
break
|
||||
case 500:
|
||||
message = '服务器内部错误~'
|
||||
break
|
||||
|
||||
52
WebUI/src/api/sync.ts
Normal file
52
WebUI/src/api/sync.ts
Normal 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'),
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export type QuadrantPriority = 'q1' | 'q2' | 'q3' | 'q4'
|
||||
|
||||
export interface Task {
|
||||
id: number
|
||||
uuid?: string
|
||||
title: string
|
||||
description?: string
|
||||
priority: QuadrantPriority
|
||||
@@ -16,6 +17,7 @@ export interface Task {
|
||||
|
||||
export interface Category {
|
||||
id: number
|
||||
uuid?: string
|
||||
name: string
|
||||
color: string
|
||||
icon: string
|
||||
@@ -23,6 +25,7 @@ export interface Category {
|
||||
|
||||
export interface Tag {
|
||||
id: number
|
||||
uuid?: string
|
||||
name: string
|
||||
}
|
||||
|
||||
@@ -88,6 +91,7 @@ export interface UserSettingsUpdate {
|
||||
|
||||
export interface HabitGroup {
|
||||
id: number
|
||||
uuid?: string
|
||||
name: string
|
||||
color: string
|
||||
icon: string
|
||||
@@ -105,6 +109,7 @@ export type HabitFrequency = 'daily' | 'weekly'
|
||||
|
||||
export interface Habit {
|
||||
id: number
|
||||
uuid?: string
|
||||
name: string
|
||||
description?: string
|
||||
group_id?: number
|
||||
@@ -128,6 +133,7 @@ export interface HabitFormData {
|
||||
|
||||
export interface HabitCheckin {
|
||||
id: number
|
||||
uuid?: string
|
||||
habit_id: number
|
||||
checkin_date: string
|
||||
count: number
|
||||
@@ -146,6 +152,7 @@ export interface HabitStats {
|
||||
|
||||
export interface AnniversaryCategory {
|
||||
id: number
|
||||
uuid?: string
|
||||
name: string
|
||||
icon: string
|
||||
color: string
|
||||
@@ -161,6 +168,7 @@ export interface AnniversaryCategoryFormData {
|
||||
|
||||
export interface Anniversary {
|
||||
id: number
|
||||
uuid?: string
|
||||
title: string
|
||||
date: string
|
||||
year?: number | null
|
||||
@@ -195,6 +203,7 @@ export type StepStatus = 'pending' | 'in_progress' | 'completed'
|
||||
|
||||
export interface Goal {
|
||||
id: number
|
||||
uuid?: string
|
||||
title: string
|
||||
description?: string | null
|
||||
status: GoalStatus
|
||||
@@ -220,6 +229,7 @@ export interface GoalDetail extends Goal {
|
||||
|
||||
export interface GoalStep {
|
||||
id: number
|
||||
uuid?: string
|
||||
goal_id: number
|
||||
parent_id?: number | null
|
||||
title: string
|
||||
@@ -234,6 +244,7 @@ export interface GoalStep {
|
||||
|
||||
export interface GoalReview {
|
||||
id: number
|
||||
uuid?: string
|
||||
goal_id: number
|
||||
content: string
|
||||
rating?: number | null
|
||||
|
||||
147
WebUI/src/stores/useSyncStore.ts
Normal file
147
WebUI/src/stores/useSyncStore.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import { useTaskStore } from '@/stores/useTaskStore'
|
||||
import { useCategoryStore } from '@/stores/useCategoryStore'
|
||||
import { useTagStore } from '@/stores/useTagStore'
|
||||
import { useHabitStore } from '@/stores/useHabitStore'
|
||||
import { useSyncStore, type SyncDirection } from '@/stores/useSyncStore'
|
||||
import { get, post, del } from '@/api/request'
|
||||
import type { Task, Category, Tag, HabitGroup, Habit } from '@/api/types'
|
||||
|
||||
@@ -14,6 +15,7 @@ const taskStore = useTaskStore()
|
||||
const categoryStore = useCategoryStore()
|
||||
const tagStore = useTagStore()
|
||||
const habitStore = useHabitStore()
|
||||
const syncStore = useSyncStore()
|
||||
|
||||
const saving = ref(false)
|
||||
const exporting = ref(false)
|
||||
@@ -42,13 +44,48 @@ const prefs = ref({
|
||||
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(() => {
|
||||
prefs.value.site_name = userStore.siteName || '爱莉希雅待办'
|
||||
prefs.value.default_view = userStore.defaultView || 'list'
|
||||
prefs.value.default_sort_by = userStore.defaultSortBy || 'priority'
|
||||
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() {
|
||||
saving.value = true
|
||||
try {
|
||||
@@ -60,7 +97,6 @@ async function handleSave() {
|
||||
})
|
||||
userStore.syncFromSettings(userStore.settings!)
|
||||
|
||||
// 保存排序后立即应用
|
||||
taskStore.setFilters({
|
||||
sort_by: prefs.value.default_sort_by as 'priority' | 'due_date' | 'created_at',
|
||||
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() {
|
||||
exporting.value = true
|
||||
try {
|
||||
@@ -444,6 +502,166 @@ async function clearCompleted() {
|
||||
</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>
|
||||
</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) {
|
||||
.settings-page {
|
||||
padding: 16px;
|
||||
|
||||
@@ -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)
|
||||
@@ -11,7 +11,8 @@ from app.database import init_db, SessionLocal
|
||||
from app.models.user_settings import UserSettings
|
||||
from app.routers import api_router
|
||||
from app.utils.logger import logger
|
||||
from app.utils.auth import decode_access_token
|
||||
from app.utils.auth import decode_access_token, get_cached_token_version, set_cached_token_version
|
||||
from app.utils.sync_lock import is_syncing
|
||||
from jose import JWTError
|
||||
|
||||
|
||||
@@ -111,19 +112,39 @@ async def auth_middleware(request: Request, call_next):
|
||||
except JWTError:
|
||||
return JSONResponse(status_code=401, content={"detail": "登录已过期,请重新登录"})
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
|
||||
if settings and payload.get("tv") != settings.token_version:
|
||||
user_id = payload.get("sub", "")
|
||||
token_tv = payload.get("tv")
|
||||
|
||||
if token_tv is not None and user_id:
|
||||
cached_tv = get_cached_token_version(user_id)
|
||||
if cached_tv is None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
settings = db.query(UserSettings).filter(UserSettings.id == 1).first()
|
||||
cached_tv = settings.token_version if settings else 0
|
||||
set_cached_token_version(user_id, cached_tv)
|
||||
finally:
|
||||
db.close()
|
||||
if token_tv != cached_tv:
|
||||
return JSONResponse(status_code=401, content={"detail": "密码已修改,请重新登录"})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
request.state.user = payload
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# 同步锁中间件(同步期间禁止写操作)
|
||||
@app.middleware("http")
|
||||
async def sync_lock_middleware(request: Request, call_next):
|
||||
path = request.url.path
|
||||
|
||||
if is_syncing() and request.method in ("POST", "PUT", "PATCH", "DELETE"):
|
||||
if not path.startswith("/api/sync"):
|
||||
return JSONResponse(status_code=503, content={"detail": "正在同步数据,请稍后再试"})
|
||||
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
# 全局异常处理器
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
|
||||
@@ -5,10 +5,12 @@ from app.models.user_settings import UserSettings
|
||||
from app.models.habit import HabitGroup, Habit, HabitCheckin
|
||||
from app.models.anniversary import AnniversaryCategory, Anniversary
|
||||
from app.models.goal import Goal, GoalStep, GoalReview, goal_tasks
|
||||
from app.models.sync_settings import SyncSettings
|
||||
|
||||
__all__ = [
|
||||
"Task", "Category", "Tag", "task_tags", "UserSettings",
|
||||
"HabitGroup", "Habit", "HabitCheckin",
|
||||
"AnniversaryCategory", "Anniversary",
|
||||
"Goal", "GoalStep", "GoalReview", "goal_tasks",
|
||||
"SyncSettings",
|
||||
]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import uuid as _uuid
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Date
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
@@ -9,10 +10,13 @@ class AnniversaryCategory(Base):
|
||||
__tablename__ = "anniversary_categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||
name = Column(String(50), nullable=False)
|
||||
icon = Column(String(50), default="calendar")
|
||||
color = Column(String(20), default="#FFB7C5")
|
||||
sort_order = Column(Integer, default=0)
|
||||
is_deleted = Column(Boolean, default=False)
|
||||
sync_version = Column(Integer, default=1)
|
||||
|
||||
# 关联关系
|
||||
anniversaries = relationship("Anniversary", back_populates="category")
|
||||
@@ -23,6 +27,7 @@ class Anniversary(Base):
|
||||
__tablename__ = "anniversaries"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||
title = Column(String(200), nullable=False)
|
||||
date = Column(Date, nullable=False) # 月-日,年份部分可选
|
||||
year = Column(Integer, nullable=True) # 年份,用于计算第 N 个周年
|
||||
@@ -30,6 +35,8 @@ class Anniversary(Base):
|
||||
description = Column(Text, nullable=True)
|
||||
is_recurring = Column(Boolean, default=True)
|
||||
remind_days_before = Column(Integer, default=3)
|
||||
is_deleted = Column(Boolean, default=False)
|
||||
sync_version = Column(Integer, default=1)
|
||||
created_at = Column(DateTime, default=utcnow)
|
||||
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from sqlalchemy import Column, Integer, String
|
||||
import uuid as _uuid
|
||||
from sqlalchemy import Column, Integer, String, Boolean
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
@@ -8,9 +9,12 @@ class Category(Base):
|
||||
__tablename__ = "categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
color = Column(String(20), default="#FFB7C5") # 默认樱花粉
|
||||
icon = Column(String(50), default="folder") # 默认图标
|
||||
is_deleted = Column(Boolean, default=False)
|
||||
sync_version = Column(Integer, default=1)
|
||||
|
||||
# 关联关系
|
||||
tasks = relationship("Task", back_populates="category")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from sqlalchemy import Column, Integer, String, Text, Date, DateTime, ForeignKey, Table, desc
|
||||
import uuid as _uuid
|
||||
from sqlalchemy import Column, Integer, String, Text, Date, DateTime, Boolean, ForeignKey, Table, desc
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
from app.utils.datetime import utcnow
|
||||
@@ -18,6 +19,7 @@ class Goal(Base):
|
||||
__tablename__ = "goals"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||
title = Column(String(200), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
status = Column(String(20), default="active") # active/paused/completed/abandoned
|
||||
@@ -28,6 +30,8 @@ class Goal(Base):
|
||||
color = Column(String(20), default="#FFB7C5")
|
||||
icon = Column(String(50), default="flag")
|
||||
sort_order = Column(Integer, default=0)
|
||||
is_deleted = Column(Boolean, default=False)
|
||||
sync_version = Column(Integer, default=1)
|
||||
created_at = Column(DateTime, default=utcnow)
|
||||
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
||||
|
||||
@@ -51,6 +55,7 @@ class GoalStep(Base):
|
||||
__tablename__ = "goal_steps"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||
goal_id = Column(Integer, ForeignKey("goals.id"), nullable=False)
|
||||
parent_id = Column(Integer, ForeignKey("goal_steps.id"), nullable=True)
|
||||
title = Column(String(200), nullable=False)
|
||||
@@ -59,6 +64,8 @@ class GoalStep(Base):
|
||||
target_date = Column(Date, nullable=True)
|
||||
reached_at = Column(DateTime, nullable=True)
|
||||
sort_order = Column(Integer, default=0)
|
||||
is_deleted = Column(Boolean, default=False)
|
||||
sync_version = Column(Integer, default=1)
|
||||
created_at = Column(DateTime, default=utcnow)
|
||||
|
||||
# 关联关系
|
||||
@@ -71,9 +78,12 @@ class GoalReview(Base):
|
||||
__tablename__ = "goal_reviews"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||
goal_id = Column(Integer, ForeignKey("goals.id"), nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
rating = Column(Integer, nullable=True) # 1-5 自评
|
||||
is_deleted = Column(Boolean, default=False)
|
||||
sync_version = Column(Integer, default=1)
|
||||
created_at = Column(DateTime, default=utcnow)
|
||||
|
||||
# 关联关系
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import uuid as _uuid
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, Date, DateTime, ForeignKey, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
@@ -9,10 +10,13 @@ class HabitGroup(Base):
|
||||
__tablename__ = "habit_groups"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
color = Column(String(20), default="#FFB7C5")
|
||||
icon = Column(String(50), default="flag")
|
||||
sort_order = Column(Integer, default=0)
|
||||
is_deleted = Column(Boolean, default=False)
|
||||
sync_version = Column(Integer, default=1)
|
||||
|
||||
# 关联关系
|
||||
habits = relationship("Habit", back_populates="group", order_by="Habit.created_at")
|
||||
@@ -23,6 +27,7 @@ class Habit(Base):
|
||||
__tablename__ = "habits"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
group_id = Column(Integer, ForeignKey("habit_groups.id"), nullable=True)
|
||||
@@ -30,6 +35,8 @@ class Habit(Base):
|
||||
frequency = Column(String(20), default="daily") # daily / weekly
|
||||
active_days = Column(String(100), nullable=True) # JSON 数组, 如 [0,2,4] 表示周一三五
|
||||
is_archived = Column(Boolean, default=False)
|
||||
is_deleted = Column(Boolean, default=False)
|
||||
sync_version = Column(Integer, default=1)
|
||||
created_at = Column(DateTime, default=utcnow)
|
||||
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
||||
|
||||
@@ -46,9 +53,12 @@ class HabitCheckin(Base):
|
||||
)
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||
habit_id = Column(Integer, ForeignKey("habits.id"), nullable=False)
|
||||
checkin_date = Column(Date, nullable=False)
|
||||
count = Column(Integer, default=0)
|
||||
is_deleted = Column(Boolean, default=False)
|
||||
sync_version = Column(Integer, default=1)
|
||||
created_at = Column(DateTime, default=utcnow)
|
||||
|
||||
# 关联关系
|
||||
|
||||
28
api/app/models/sync_settings.py
Normal file
28
api/app/models/sync_settings.py
Normal 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)
|
||||
@@ -1,4 +1,5 @@
|
||||
from sqlalchemy import Column, Integer, String, Table, ForeignKey
|
||||
import uuid as _uuid
|
||||
from sqlalchemy import Column, Integer, String, Boolean, Table, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
@@ -17,7 +18,10 @@ class Tag(Base):
|
||||
__tablename__ = "tags"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||
name = Column(String(50), nullable=False, unique=True)
|
||||
is_deleted = Column(Boolean, default=False)
|
||||
sync_version = Column(Integer, default=1)
|
||||
|
||||
# 关联关系
|
||||
tasks = relationship("Task", secondary=task_tags, back_populates="tags")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import uuid as _uuid
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
@@ -9,12 +10,15 @@ class Task(Base):
|
||||
__tablename__ = "tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
uuid = Column(String(36), default=lambda: str(_uuid.uuid4()), unique=True, index=True)
|
||||
title = Column(String(200), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
priority = Column(String(20), default="q4") # q1(重要紧急), q2(重要不紧急), q3(不重要紧急), q4(不重要不紧急)
|
||||
due_date = Column(DateTime, nullable=True)
|
||||
is_completed = Column(Boolean, default=False)
|
||||
is_deleted = Column(Boolean, default=False)
|
||||
category_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
|
||||
sync_version = Column(Integer, default=1)
|
||||
created_at = Column(DateTime, default=utcnow)
|
||||
updated_at = Column(DateTime, default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from fastapi import APIRouter
|
||||
from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, auth, goals
|
||||
from app.routers import tasks, categories, tags, user_settings, habits, anniversaries, auth, goals, sync
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -11,3 +11,4 @@ api_router.include_router(user_settings.router)
|
||||
api_router.include_router(habits.router)
|
||||
api_router.include_router(anniversaries.router)
|
||||
api_router.include_router(goals.router)
|
||||
api_router.include_router(sync.router)
|
||||
|
||||
152
api/app/routers/sync.py
Normal file
152
api/app/routers/sync.py
Normal file
@@ -0,0 +1,152 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import SyncSettings
|
||||
from app.schemas.sync import (
|
||||
SyncConfigUpdate, SyncConfigResponse,
|
||||
SyncStatusResponse, SyncTestResponse, SyncOperationResponse,
|
||||
)
|
||||
from app.utils.crypto import encrypt, decrypt
|
||||
from app.utils.webdav import WebDAVClient
|
||||
from app.utils.sync_service import push_to_remote, pull_from_remote, bidirectional_sync
|
||||
from app.utils.sync_lock import is_syncing
|
||||
|
||||
router = APIRouter(prefix="/api/sync", tags=["同步"])
|
||||
|
||||
|
||||
def _get_or_create_settings(db: Session) -> SyncSettings:
|
||||
settings = db.query(SyncSettings).filter(SyncSettings.id == 1).first()
|
||||
if not settings:
|
||||
settings = SyncSettings(id=1)
|
||||
db.add(settings)
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
return settings
|
||||
|
||||
|
||||
@router.get("/config", response_model=SyncConfigResponse)
|
||||
def get_sync_config(request: Request, db: Session = Depends(get_db)):
|
||||
settings = _get_or_create_settings(db)
|
||||
response = SyncConfigResponse(
|
||||
webdav_url=settings.webdav_url,
|
||||
webdav_username=settings.webdav_username,
|
||||
webdav_password="***" if settings.webdav_password else None,
|
||||
webdav_path=settings.webdav_path or "/elysia-todo/",
|
||||
sync_enabled=settings.sync_enabled,
|
||||
auto_sync=settings.auto_sync,
|
||||
auto_sync_interval=settings.auto_sync_interval or 300,
|
||||
last_sync_at=settings.last_sync_at,
|
||||
last_sync_version=settings.last_sync_version or 0,
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
@router.put("/config", response_model=SyncConfigResponse)
|
||||
def update_sync_config(config: SyncConfigUpdate, request: Request, db: Session = Depends(get_db)):
|
||||
settings = _get_or_create_settings(db)
|
||||
|
||||
if config.webdav_url is not None:
|
||||
settings.webdav_url = config.webdav_url
|
||||
if config.webdav_username is not None:
|
||||
settings.webdav_username = config.webdav_username
|
||||
if config.webdav_password is not None and config.webdav_password != "***":
|
||||
settings.webdav_password = encrypt(config.webdav_password)
|
||||
if config.webdav_path is not None:
|
||||
settings.webdav_path = config.webdav_path
|
||||
if config.auto_sync is not None:
|
||||
settings.auto_sync = config.auto_sync
|
||||
if config.auto_sync_interval is not None:
|
||||
settings.auto_sync_interval = config.auto_sync_interval
|
||||
|
||||
db.commit()
|
||||
db.refresh(settings)
|
||||
|
||||
return SyncConfigResponse(
|
||||
webdav_url=settings.webdav_url,
|
||||
webdav_username=settings.webdav_username,
|
||||
webdav_password="***" if settings.webdav_password else None,
|
||||
webdav_path=settings.webdav_path or "/elysia-todo/",
|
||||
sync_enabled=settings.sync_enabled,
|
||||
auto_sync=settings.auto_sync,
|
||||
auto_sync_interval=settings.auto_sync_interval or 300,
|
||||
last_sync_at=settings.last_sync_at,
|
||||
last_sync_version=settings.last_sync_version or 0,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test", response_model=SyncTestResponse)
|
||||
def test_connection(request: Request, db: Session = Depends(get_db)):
|
||||
settings = _get_or_create_settings(db)
|
||||
|
||||
if not settings.webdav_url:
|
||||
return SyncTestResponse(success=False, message="未配置 WebDAV 地址")
|
||||
|
||||
password = decrypt(settings.webdav_password) if settings.webdav_password else ""
|
||||
if settings.webdav_password and password is None:
|
||||
return SyncTestResponse(success=False, message="WebDAV 密码解密失败,请重新配置")
|
||||
|
||||
client = WebDAVClient(
|
||||
url=settings.webdav_url,
|
||||
username=settings.webdav_username or "",
|
||||
password=password or "",
|
||||
path=settings.webdav_path or "/elysia-todo/",
|
||||
)
|
||||
|
||||
success, message = client.test_connection()
|
||||
return SyncTestResponse(success=success, message=message)
|
||||
|
||||
|
||||
@router.post("/push", response_model=SyncOperationResponse)
|
||||
def sync_push(request: Request, db: Session = Depends(get_db)):
|
||||
result = push_to_remote(db)
|
||||
return SyncOperationResponse(**result)
|
||||
|
||||
|
||||
@router.post("/pull", response_model=SyncOperationResponse)
|
||||
def sync_pull(request: Request, db: Session = Depends(get_db)):
|
||||
result = pull_from_remote(db)
|
||||
return SyncOperationResponse(**result)
|
||||
|
||||
|
||||
@router.post("/sync", response_model=SyncOperationResponse)
|
||||
def sync_bidirectional(request: Request, db: Session = Depends(get_db)):
|
||||
result = bidirectional_sync(db)
|
||||
return SyncOperationResponse(**result)
|
||||
|
||||
|
||||
@router.get("/status", response_model=SyncStatusResponse)
|
||||
def get_sync_status(request: Request, db: Session = Depends(get_db)):
|
||||
settings = _get_or_create_settings(db)
|
||||
return SyncStatusResponse(
|
||||
syncing=is_syncing(),
|
||||
last_sync_at=settings.last_sync_at,
|
||||
last_sync_version=settings.last_sync_version or 0,
|
||||
sync_enabled=settings.sync_enabled,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/remote", response_model=SyncOperationResponse)
|
||||
def clear_remote(request: Request, db: Session = Depends(get_db)):
|
||||
from app.utils.webdav import WebDAVClient
|
||||
from app.utils.crypto import decrypt
|
||||
|
||||
settings = _get_or_create_settings(db)
|
||||
if not settings.webdav_url:
|
||||
return SyncOperationResponse(success=False, message="未配置 WebDAV 地址")
|
||||
|
||||
password = decrypt(settings.webdav_password) if settings.webdav_password else ""
|
||||
if settings.webdav_password and password is None:
|
||||
return SyncOperationResponse(success=False, message="WebDAV 密码解密失败,请重新配置")
|
||||
|
||||
client = WebDAVClient(
|
||||
url=settings.webdav_url,
|
||||
username=settings.webdav_username or "",
|
||||
password=password or "",
|
||||
path=settings.webdav_path or "/elysia-todo/",
|
||||
)
|
||||
|
||||
success = client.clear_remote()
|
||||
if success:
|
||||
return SyncOperationResponse(success=True, message="远端数据已清空")
|
||||
return SyncOperationResponse(success=False, message="清空远端数据失败")
|
||||
@@ -54,6 +54,7 @@ class AnniversaryCategoryUpdate(BaseModel):
|
||||
class AnniversaryCategoryResponse(AnniversaryCategoryBase):
|
||||
"""纪念日分类响应模型"""
|
||||
id: int
|
||||
uuid: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -111,6 +112,7 @@ class AnniversaryUpdate(BaseModel):
|
||||
class AnniversaryResponse(AnniversaryBase):
|
||||
"""纪念日响应模型"""
|
||||
id: int
|
||||
uuid: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
category: Optional[AnniversaryCategoryResponse] = None
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class CategoryBase(BaseModel):
|
||||
@@ -23,6 +24,7 @@ class CategoryUpdate(BaseModel):
|
||||
class CategoryResponse(CategoryBase):
|
||||
"""分类响应模型"""
|
||||
id: int
|
||||
uuid: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -36,6 +36,7 @@ class GoalStepUpdate(BaseModel):
|
||||
|
||||
class GoalStepResponse(GoalStepBase):
|
||||
id: int
|
||||
uuid: Optional[str] = None
|
||||
goal_id: int
|
||||
reached_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
@@ -54,6 +55,7 @@ class GoalReviewCreate(BaseModel):
|
||||
|
||||
class GoalReviewResponse(BaseModel):
|
||||
id: int
|
||||
uuid: Optional[str] = None
|
||||
goal_id: int
|
||||
content: str
|
||||
rating: Optional[int] = None
|
||||
@@ -97,6 +99,7 @@ class GoalUpdate(BaseModel):
|
||||
|
||||
class GoalListResponse(GoalBase):
|
||||
id: int
|
||||
uuid: Optional[str] = None
|
||||
progress: int
|
||||
completed_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
|
||||
@@ -29,6 +29,7 @@ class HabitGroupUpdate(BaseModel):
|
||||
class HabitGroupResponse(HabitGroupBase):
|
||||
"""习惯分组响应模型"""
|
||||
id: int
|
||||
uuid: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -68,6 +69,7 @@ class HabitUpdate(BaseModel):
|
||||
class HabitResponse(HabitBase):
|
||||
"""习惯响应模型"""
|
||||
id: int
|
||||
uuid: Optional[str] = None
|
||||
is_archived: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -87,6 +89,7 @@ class CheckinCreate(BaseModel):
|
||||
class CheckinResponse(BaseModel):
|
||||
"""打卡记录响应模型"""
|
||||
id: int
|
||||
uuid: Optional[str] = None
|
||||
habit_id: int
|
||||
checkin_date: date
|
||||
count: int
|
||||
|
||||
50
api/app/schemas/sync.py
Normal file
50
api/app/schemas/sync.py
Normal 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
|
||||
@@ -1,4 +1,5 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class TagBase(BaseModel):
|
||||
@@ -14,6 +15,7 @@ class TagCreate(TagBase):
|
||||
class TagResponse(TagBase):
|
||||
"""标签响应模型"""
|
||||
id: int
|
||||
uuid: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
@@ -75,6 +75,7 @@ class TaskUpdate(BaseModel):
|
||||
class TaskResponse(TaskBase):
|
||||
"""任务响应模型"""
|
||||
id: int
|
||||
uuid: Optional[str] = None
|
||||
is_completed: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
53
api/app/utils/crypto.py
Normal file
53
api/app/utils/crypto.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
AES-256-GCM 加解密工具
|
||||
密钥从 JWT_SECRET 派生,用于加密 WebDAV 密码等敏感信息
|
||||
"""
|
||||
import base64
|
||||
import os
|
||||
import hashlib
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
|
||||
from app.config import JWT_SECRET
|
||||
|
||||
_SALT = b"elysia-todo-sync-v1"
|
||||
_NONCE_SIZE = 12 # AES-GCM 标准 nonce 长度
|
||||
|
||||
|
||||
def _derive_key() -> bytes:
|
||||
"""从 JWT_SECRET 派生 256-bit AES 密钥"""
|
||||
kdf = PBKDF2HMAC(
|
||||
algorithm=hashes.SHA256(),
|
||||
length=32,
|
||||
salt=_SALT,
|
||||
iterations=480000,
|
||||
)
|
||||
return kdf.derive(JWT_SECRET.encode("utf-8"))
|
||||
|
||||
|
||||
def encrypt(plaintext: str) -> str:
|
||||
"""AES-256-GCM 加密,返回 base64(iv + ciphertext + tag)"""
|
||||
if not plaintext:
|
||||
return ""
|
||||
key = _derive_key()
|
||||
nonce = os.urandom(_NONCE_SIZE)
|
||||
aesgcm = AESGCM(key)
|
||||
ciphertext = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
|
||||
return base64.b64encode(nonce + ciphertext).decode("ascii")
|
||||
|
||||
|
||||
def decrypt(encrypted: str) -> str | None:
|
||||
"""AES-256-GCM 解密,解密失败返回 None"""
|
||||
if not encrypted:
|
||||
return None
|
||||
try:
|
||||
key = _derive_key()
|
||||
raw = base64.b64decode(encrypted)
|
||||
nonce = raw[:_NONCE_SIZE]
|
||||
ciphertext = raw[_NONCE_SIZE:]
|
||||
aesgcm = AESGCM(key)
|
||||
plaintext = aesgcm.decrypt(nonce, ciphertext, None)
|
||||
return plaintext.decode("utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
37
api/app/utils/sync_lock.py
Normal file
37
api/app/utils/sync_lock.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
全局同步锁与同步模式标记
|
||||
同步期间禁止所有写操作,前端显示同步遮罩
|
||||
"""
|
||||
import threading
|
||||
|
||||
_sync_lock = threading.Lock()
|
||||
_sync_in_progress = False
|
||||
_sync_mode = threading.local()
|
||||
|
||||
|
||||
def acquire_sync_lock() -> bool:
|
||||
"""非阻塞获取同步锁,成功返回 True"""
|
||||
acquired = _sync_lock.acquire(blocking=False)
|
||||
if acquired:
|
||||
global _sync_in_progress
|
||||
_sync_in_progress = True
|
||||
_sync_mode.active = True
|
||||
return acquired
|
||||
|
||||
|
||||
def release_sync_lock():
|
||||
"""释放同步锁"""
|
||||
global _sync_in_progress
|
||||
_sync_in_progress = False
|
||||
_sync_mode.active = False
|
||||
_sync_lock.release()
|
||||
|
||||
|
||||
def is_syncing() -> bool:
|
||||
"""检查是否正在同步"""
|
||||
return _sync_in_progress
|
||||
|
||||
|
||||
def is_sync_mode() -> bool:
|
||||
"""检查当前线程是否在同步模式中(跳过 sync_version 自增)"""
|
||||
return getattr(_sync_mode, 'active', False)
|
||||
567
api/app/utils/sync_service.py
Normal file
567
api/app/utils/sync_service.py
Normal 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
150
api/app/utils/webdav.py
Normal 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
|
||||
431
docs/plan/webdav-sync-design.md
Normal file
431
docs/plan/webdav-sync-design.md
Normal file
@@ -0,0 +1,431 @@
|
||||
# WebDAV 同步功能设计文档
|
||||
|
||||
日期: 2026-05-17
|
||||
|
||||
## 1. 概述
|
||||
|
||||
**目标**: 支持通过 WebDAV(Alist)在多设备间同步所有待办数据。
|
||||
|
||||
**成功指标**:
|
||||
- 可以配置 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
53
scripts/start.bat
Normal 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
42
scripts/stop.bat
Normal 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
|
||||
Reference in New Issue
Block a user