feat: 多端同步服务端与引擎 + Capacitor 安卓工程(v1.13)

This commit is contained in:
root
2026-10-16 11:07:37 +08:00
parent ee795eeefb
commit de3add8881
70 changed files with 2039 additions and 12 deletions
+205
View File
@@ -0,0 +1,205 @@
// 多端同步引擎:全量差异对比 + 记录级新者胜 + 软删除墓碑
import { db } from '../db/schema'
import { getSetting, setSetting } from '../db/repo'
import { nowLocalISO } from '../lib/date'
const TABLES = ['project', 'work_log', 'log_item', 'attendance', 'weekly_note', 'setting', 'memo', 'credential', 'transaction']
// 本地敏感或临时性的设置不参与同步
const EXCLUDE_SETTING_KEYS = new Set(['sync_cursor', 'sync_last_time', 'last_project', 'last_category'])
export const SYNC_TABLES = TABLES
interface AnyRow { [key: string]: any }
function newUuid(): string {
if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID()
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
const r = (Math.random() * 16) | 0
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
})
}
function stripId(row: AnyRow): AnyRow {
const copy: AnyRow = { ...row }
delete copy.id
return copy
}
function rowHash(row: AnyRow): string {
return JSON.stringify(row)
}
async function getTable(name: string): Promise<AnyRow[]> {
return (db as unknown as AnyRow)[name].toArray()
}
// 一次性补齐 uuid 与 updated_at,缺失时回填
async function ensureSyncFields(): Promise<void> {
for (const t of TABLES) {
const table = (db as unknown as AnyRow)[t]
const rows: AnyRow[] = await table.toArray()
for (const row of rows) {
if (t === 'setting' && EXCLUDE_SETTING_KEYS.has(row.key)) continue
const patch: AnyRow = {}
if (!row.uuid) patch.uuid = newUuid()
if (!row.updated_at) patch.updated_at = row.created_at || nowLocalISO()
if (Object.keys(patch).length) await table.update(row.id, patch)
}
}
}
export interface SyncResult {
pushed: number
pulled: number
removed: number
at: string
}
export interface SyncConfig {
baseUrl: string
username: string
password: string
}
export async function isSyncConfigured(): Promise<boolean> {
const values = await Promise.all([
getSetting('sync_base_url', ''),
getSetting('sync_username', ''),
getSetting('sync_password', '')
])
return !!(values[0].trim() && values[1].trim() && values[2].trim())
}
export async function getSyncLastTime(): Promise<string> {
return getSetting('sync_last_time', '')
}
// 云端账号注册(服务端存 PBKDF2 哈希)
export async function registerCloudAccount(baseUrl: string, username: string, password: string): Promise<void> {
const base = baseUrl.trim().replace(/\/+$/, '')
if (!base || !username.trim() || password.length < 6) {
throw new Error('请完整填写同步地址、用户名与至少 6 位的密码')
}
let resp: Response
try {
resp = await fetch(base + '/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.trim(), password })
})
} catch {
throw new Error('无法访问同步服务:请检查地址、端口与网络')
}
if (resp.status === 409) throw new Error('云端用户名已存在,可直接同步')
if (!resp.ok) throw new Error('云端注册失败:' + resp.status)
}
// 执行一次完整同步
export async function runSync(config: SyncConfig): Promise<SyncResult> {
const base = config.baseUrl.trim().replace(/\/+$/, '')
if (!base) throw new Error('请先配置同步服务地址')
await ensureSyncFields()
// 1. 载入镜像,计算本地差异
const mirrorRows = await db.sync_mirror.toArray()
const mirror = new Map<string, { table: string; uuid: string; hash: string }>()
for (const m of mirrorRows) mirror.set(m.table + '|' + m.uuid, m)
const pushTables: Record<string, AnyRow[]> = {}
const tombstones: { table: string; uuid: string; deleted_at: string }[] = []
let pushed = 0
for (const t of TABLES) {
let rows: AnyRow[] = await getTable(t)
if (t === 'setting') rows = rows.filter(r => !EXCLUDE_SETTING_KEYS.has(r.key))
const current = new Map<string, AnyRow>()
for (const row of rows) current.set(row.uuid, row)
for (const [u, row] of current) {
const clean = stripId(row)
const h = rowHash(clean)
const m = mirror.get(t + '|' + u)
if (!m || m.hash !== h) {
pushTables[t] = pushTables[t] || []
pushTables[t].push(clean)
pushed++
}
}
for (const [key, m] of mirror) {
if (key.startsWith(t + '|') && !current.has(m.uuid)) {
tombstones.push({ table: t, uuid: m.uuid, deleted_at: nowLocalISO() })
}
}
pushTables[t] = pushTables[t] || []
}
// 2. 与服务端交换
let resp: Response
try {
resp = await fetch(base + '/api/sync', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + config.username.trim() + ':' + config.password
},
body: JSON.stringify({ device: 'client', tables: pushTables, tombstones })
})
} catch {
throw new Error('无法访问同步服务:请检查地址、端口与网络')
}
if (resp.status === 401) throw new Error('云端账号或密码不正确')
if (!resp.ok) throw new Error('同步服务返回 ' + resp.status)
const data = await resp.json()
const serverTables: Record<string, AnyRow[]> = data.tables || {}
const serverTombstones: { table: string; uuid: string; deleted_at: string }[] = data.tombstones || []
// 3. 应用服务端权威数据
let pulled = 0
let removed = 0
const finalMirror: { table: string; uuid: string; hash: string }[] = []
for (const t of TABLES) {
const table = (db as unknown as AnyRow)[t]
const localRows: AnyRow[] = await table.toArray()
const byUuid = new Map<string, AnyRow>()
for (const row of localRows) byUuid.set(row.uuid, row)
const serverRows: AnyRow[] = serverTables[t] || []
const dead = new Set(serverTombstones.filter(x => x.table === t).map(x => x.uuid))
for (const [u, row] of byUuid) {
if (dead.has(u)) {
await table.delete(row.id)
removed++
}
}
for (const [u, srow] of serverRows) {
if (dead.has(u)) continue
const local = byUuid.get(u)
if (!local) {
await table.add({ ...srow })
pulled++
} else {
const localUa = String(local.updated_at || '')
const serverUa = String(srow.updated_at || '')
if (serverUa > localUa) {
await table.update(local.id, { ...srow })
pulled++
}
}
}
const finalRows: AnyRow[] = await table.toArray()
for (const row of finalRows) {
if (t === 'setting' && EXCLUDE_SETTING_KEYS.has(row.key)) continue
finalMirror.push({ table: t, uuid: row.uuid, hash: rowHash(stripId(row)) })
}
}
// 4. 重建镜像
await db.sync_mirror.clear()
if (finalMirror.length) await db.sync_mirror.bulkAdd(finalMirror.map(m => ({ ...m })))
const at = nowLocalISO()
await setSetting('sync_last_time', at)
return { pushed, pulled, removed, at }
}