fix: 同步引擎重写——外键重映射、自然键去重、墓碑传播;服务端冲突比较修正(双设备测试 12/12)
This commit is contained in:
-26
File diff suppressed because one or more lines are too long
+2
-2
@@ -1,2 +1,2 @@
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-Cr7cLzMr.js","./index-D4g5kDSe.js","./index-B9eVtom_.css"])))=>i.map(i=>d[i]);
|
||||
import{r as s,_ as i}from"./index-D4g5kDSe.js";var t;(function(e){e.Documents="DOCUMENTS",e.Data="DATA",e.Library="LIBRARY",e.Cache="CACHE",e.External="EXTERNAL",e.ExternalStorage="EXTERNAL_STORAGE"})(t||(t={}));var a;(function(e){e.UTF8="utf8",e.ASCII="ascii",e.UTF16="utf16"})(a||(a={}));const E=s("Filesystem",{web:()=>i(()=>import("./web-Cr7cLzMr.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
|
||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-C9gz15N3.js","./index-o1hiWX9Y.js","./index-B9eVtom_.css"])))=>i.map(i=>d[i]);
|
||||
import{r as s,_ as i}from"./index-o1hiWX9Y.js";var t;(function(e){e.Documents="DOCUMENTS",e.Data="DATA",e.Library="LIBRARY",e.Cache="CACHE",e.External="EXTERNAL",e.ExternalStorage="EXTERNAL_STORAGE"})(t||(t={}));var a;(function(e){e.UTF8="utf8",e.ASCII="ascii",e.UTF16="utf16"})(a||(a={}));const E=s("Filesystem",{web:()=>i(()=>import("./web-C9gz15N3.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
|
||||
+26
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -8,7 +8,7 @@
|
||||
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
|
||||
<title>实习工作日志</title>
|
||||
<script type="module" crossorigin src="./assets/index-D4g5kDSe.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-o1hiWX9Y.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-B9eVtom_.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,114 @@
|
||||
import 'fake-indexeddb/auto'
|
||||
import { bindDatabase, WorklogDB } from '../src/db/schema'
|
||||
|
||||
const BASE = 'http://127.0.0.1:3780'
|
||||
let pass = 0
|
||||
let fail = 0
|
||||
function check(name: string, ok: boolean, detail?: string): void {
|
||||
if (ok) { pass++; console.log(' PASS ' + name) }
|
||||
else { fail++; console.log(' FAIL ' + name + (detail ? ' -> ' + detail : '')) }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// 云端注册(清空服务端数据后全新注册)
|
||||
await fetch(BASE + '/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'twodev', password: 'dev123456' })
|
||||
}).catch(() => { /* 已存在则忽略 */ })
|
||||
|
||||
const engine = await import('../src/sync/engine')
|
||||
|
||||
// ===== 设备 A:创建项目、工作日志、日志条目、设置 =====
|
||||
bindDatabase(new WorklogDB('dev_a'))
|
||||
const a = (await import('../src/db/schema')).db
|
||||
await a.project.bulkAdd([{ name: '项目A', archived: 0, created_at: '2025-01-01T00:00:00' }])
|
||||
await a.work_log.bulkAdd([{ date: '2025-01-02', created_at: '2025-01-02T00:00:00' }])
|
||||
await a.setting.bulkAdd([{ key: 'work_start', value: '09:00' }])
|
||||
await a.table('log_item').bulkAdd([{
|
||||
work_log_id: 1, content: 'A 设备的日志', project_id: 1, category: 'dev',
|
||||
minutes: 60, is_milestone: 1, needs_followup: 0, takeaway: null,
|
||||
remark: null, sort_order: 0, created_at: '2025-01-02T10:00:00'
|
||||
}])
|
||||
const rA = await engine.runSync({ baseUrl: BASE, username: 'twodev', password: 'dev123456' })
|
||||
console.log('设备A 推送:', JSON.stringify({ p: rA.pushed, e: rA.errors }))
|
||||
|
||||
// ===== 设备 B:全新空库,拉取设备 A 数据 =====
|
||||
bindDatabase(new WorklogDB('dev_b'))
|
||||
const b = (await import('../src/db/schema')).db
|
||||
const rB1 = await engine.runSync({ baseUrl: BASE, username: 'twodev', password: 'dev123456' })
|
||||
console.log('设备B 拉取:', JSON.stringify({ p: rB1.pushed, e: rB1.errors }))
|
||||
|
||||
const bProjects = await b.project.toArray()
|
||||
const bWorkLogs = await b.work_log.toArray()
|
||||
const bItems = await b.log_item.toArray()
|
||||
const bSettings = await b.setting.toArray()
|
||||
check('B 拉到项目', bProjects.length === 1 && bProjects[0].name === '项目A')
|
||||
check('B 拉到工作日志', bWorkLogs.length === 1 && bWorkLogs[0].date === '2025-01-02')
|
||||
check('B 拉到日志条目', bItems.length === 1 && bItems[0].content === 'A 设备的日志')
|
||||
check('B 外键重映射(log_item.work_log_id 指向本地 work_log)', bItems[0].work_log_id === bWorkLogs[0].id)
|
||||
check('B 外键重映射(log_item.project_id 指向本地 project)', bItems[0].project_id === bProjects[0].id)
|
||||
check('B 拉到设置', bSettings.some(s => s.key === 'work_start' && s.value === '09:00'))
|
||||
|
||||
// ===== 设备 B:打卡(创建当日考勤与工作日志)+ 新日志条目,推送 =====
|
||||
await b.attendance.bulkAdd([{ date: '2025-01-02', clock_in_at: '2025-01-02T09:00:00', clock_out_at: null, kind: 'normal', note: null, created_at: '2025-01-02T09:00:00' }])
|
||||
await b.table('log_item').add({
|
||||
work_log_id: bWorkLogs[0].id, content: 'B 设备的日志', project_id: null, category: 'dev',
|
||||
minutes: null, is_milestone: 0, needs_followup: 1, takeaway: null,
|
||||
remark: null, sort_order: 0, created_at: '2025-01-02T11:00:00'
|
||||
})
|
||||
const rB2 = await engine.runSync({ baseUrl: BASE, username: 'twodev', password: 'dev123456' })
|
||||
console.log('设备B 二次推送:', JSON.stringify({ p: rB2.pushed, e: rB2.errors }))
|
||||
|
||||
// ===== 设备 A:拉取 B 的数据(考勤 + 新日志条目,含自然键去重场景)=====
|
||||
bindDatabase(new WorklogDB('dev_a'))
|
||||
const a2 = (await import('../src/db/schema')).db
|
||||
const rA2 = await engine.runSync({ baseUrl: BASE, username: 'twodev', password: 'dev123456' })
|
||||
console.log('设备A 拉取:', JSON.stringify({ p: rA2.pushed, e: rA2.errors }))
|
||||
|
||||
const aAtt = await a2.attendance.toArray()
|
||||
const aItems = await a2.log_item.toArray()
|
||||
const aWorkLogs = await a2.work_log.toArray()
|
||||
check('A 拉到 B 的考勤', aAtt.length === 1 && aAtt[0].date === '2025-01-02')
|
||||
check('A 拉到 B 的日志条目', aItems.some(i => i.content === 'B 设备的日志'))
|
||||
check('A 无重复工作日志(自然键去重)', aWorkLogs.length === 1)
|
||||
check('A 的日志条目仍然可见(未因去重丢失)', aItems.length >= 2)
|
||||
|
||||
// ===== 冲突场景:两端为同一天各自创建工作日志后合并 =====
|
||||
bindDatabase(new WorklogDB('dev_b'))
|
||||
const b2 = (await import('../src/db/schema')).db
|
||||
await b2.work_log.bulkPut([{ date: '2025-01-03', created_at: '2025-01-03T00:00:00', uuid: 'b-day3', updated_at: '2025-01-03T08:00:00' }])
|
||||
await b2.table('log_item').add({
|
||||
work_log_id: 2, content: 'B 的 01-03 记录', project_id: null, category: 'dev',
|
||||
minutes: null, is_milestone: 0, needs_followup: 0, takeaway: null,
|
||||
remark: null, sort_order: 0, created_at: '2025-01-03T08:00:00', uuid: 'item-b-day3', updated_at: '2025-01-03T08:00:00'
|
||||
})
|
||||
await b2.table('log_item').add({
|
||||
work_log_id: 999, content: 'B 的 01-03 记录(重映射目标)', project_id: null, category: 'dev',
|
||||
minutes: null, is_milestone: 0, needs_followup: 0, takeaway: null,
|
||||
remark: null, sort_order: 0, created_at: '2025-01-03T08:30:00', uuid: 'item-b-day3b', updated_at: '2025-01-03T08:30:00', wuuid: 'b-day3'
|
||||
})
|
||||
await engine.runSync({ baseUrl: BASE, username: 'twodev', password: 'dev123456' })
|
||||
|
||||
bindDatabase(new WorklogDB('dev_a'))
|
||||
const a3 = (await import('../src/db/schema')).db
|
||||
await a3.work_log.bulkAdd([{ date: '2025-01-03', created_at: '2025-01-03T07:00:00', uuid: 'a-day3', updated_at: '2025-01-03T07:00:00' }])
|
||||
await engine.runSync({ baseUrl: BASE, username: 'twodev', password: 'dev123456' })
|
||||
|
||||
const a3Logs = await a3.work_log.toArray()
|
||||
const day3 = a3Logs.filter(l => l.date === '2025-01-03')
|
||||
check('冲突合并后同日仅一条工作日志', day3.length === 1)
|
||||
console.log(' DEBUG day3:', JSON.stringify(day3))
|
||||
const a3Items = await a3.log_item.toArray()
|
||||
const day3Items = a3Items.filter(i => i.content.indexOf('01-03') >= 0)
|
||||
console.log(' DEBUG day3Items:', JSON.stringify(day3Items))
|
||||
check('冲突合并后 B 的 01-03 条目保留且外键正确', day3Items.length === 2 && day3Items.every(i => i.work_log_id === day3[0].id))
|
||||
|
||||
console.log('==== 结果: PASS ' + pass + ' / FAIL ' + fail + ' ====')
|
||||
process.exit(fail > 0 ? 1 : 0)
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error('FAILED:', (e && (e.stack || e.message)) || e)
|
||||
process.exit(1)
|
||||
})
|
||||
+234
-79
@@ -1,4 +1,4 @@
|
||||
// 多端同步引擎:全量差异对比 + 记录级新者胜 + 软删除墓碑
|
||||
// 多端同步引擎:全量差异对比 + 记录级新者胜 + 自然键去重 + 外键重映射 + 软删除墓碑
|
||||
// 实时性:本地变更 3 秒内推送;服务端变更通过 SSE 秒级通知其他端;60 秒轮询兜底
|
||||
import Dexie, { liveQuery } from 'dexie'
|
||||
import { db } from '../db/schema'
|
||||
@@ -7,13 +7,20 @@ import { getCloudCreds } from '../db/auth'
|
||||
import { nowLocalISO } from '../lib/date'
|
||||
import { notifyAttendanceChanged } from '../lib/bus'
|
||||
|
||||
export const DEFAULT_SYNC_BASE = 'https://daywk.linyux.cn'
|
||||
|
||||
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', 'sync_token', 'last_project', 'last_category'])
|
||||
|
||||
// 自然键:同一逻辑记录在两台设备上独立创建时的去重依据(冲突时保留 updated_at 较新者)
|
||||
const NATURAL_KEYS: Record<string, string> = {
|
||||
project: 'name',
|
||||
work_log: 'date',
|
||||
attendance: 'date',
|
||||
weekly_note: 'week_start',
|
||||
setting: 'key'
|
||||
}
|
||||
|
||||
export const SYNC_TABLES = TABLES
|
||||
|
||||
interface AnyRow { [key: string]: any }
|
||||
@@ -64,6 +71,7 @@ export interface SyncResult {
|
||||
pushed: number
|
||||
pulled: number
|
||||
removed: number
|
||||
errors: number
|
||||
at: string
|
||||
}
|
||||
|
||||
@@ -73,43 +81,6 @@ export interface SyncConfig {
|
||||
password: string
|
||||
}
|
||||
|
||||
// 同步基址:设置页可覆盖,默认使用独立子域名
|
||||
export async function getSyncBase(): Promise<string> {
|
||||
return (await getSetting('sync_base_url', '')) || DEFAULT_SYNC_BASE
|
||||
}
|
||||
|
||||
async function loadSyncConfig(): Promise<SyncConfig> {
|
||||
const creds = getCloudCreds()
|
||||
if (!creds) throw new Error('未登录,无法同步')
|
||||
return { baseUrl: await getSyncBase(), username: creds.username, password: creds.password }
|
||||
}
|
||||
|
||||
// 使用当前登录凭据执行同步(供设置页与自动同步调用)
|
||||
export async function syncWithStoredConfig(): Promise<SyncResult> {
|
||||
return runSync(await loadSyncConfig())
|
||||
}
|
||||
|
||||
// 登录后自动绑定云端账号:云端不存在则注册,同名则校验密码一致
|
||||
export async function ensureCloudBinding(): Promise<void> {
|
||||
const creds = getCloudCreds()
|
||||
if (!creds) throw new Error('未登录')
|
||||
const base = await getSyncBase()
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
const body = JSON.stringify({ username: creds.username, password: creds.password })
|
||||
let resp = await fetch(base + '/api/auth/register', { method: 'POST', headers, body })
|
||||
if (resp.status === 409) {
|
||||
resp = await fetch(base + '/api/auth/login', { method: 'POST', headers, body })
|
||||
if (!resp.ok) throw new Error('云端已存在同名账号且密码不一致,请检查')
|
||||
} else if (!resp.ok) {
|
||||
throw new Error('云端绑定失败:' + resp.status)
|
||||
}
|
||||
}
|
||||
|
||||
export function getCloudUser(): string | null {
|
||||
const c = getCloudCreds()
|
||||
return c ? c.username : null
|
||||
}
|
||||
|
||||
export async function isSyncConfigured(): Promise<boolean> {
|
||||
return getCloudCreds() != null
|
||||
}
|
||||
@@ -219,15 +190,91 @@ function ensureSSE(base: string, token: string): void {
|
||||
} catch { sse = null }
|
||||
}
|
||||
|
||||
// 同步基址:设置页可覆盖,默认使用独立子域名
|
||||
export async function getSyncBase(): Promise<string> {
|
||||
return (await getSetting('sync_base_url', '')) || DEFAULT_SYNC_BASE
|
||||
}
|
||||
export const DEFAULT_SYNC_BASE = 'https://daywk.linyux.cn'
|
||||
|
||||
async function loadSyncConfig(): Promise<SyncConfig> {
|
||||
const creds = getCloudCreds()
|
||||
if (!creds) throw new Error('未登录,无法同步')
|
||||
return { baseUrl: await getSyncBase(), username: creds.username, password: creds.password }
|
||||
}
|
||||
|
||||
// 登录后自动绑定云端账号:云端不存在则注册,同名则校验密码一致
|
||||
export async function ensureCloudBinding(): Promise<void> {
|
||||
const creds = getCloudCreds()
|
||||
if (!creds) throw new Error('未登录')
|
||||
const base = await getSyncBase()
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
const body = JSON.stringify({ username: creds.username, password: creds.password })
|
||||
let resp = await fetch(base + '/api/auth/register', { method: 'POST', headers, body })
|
||||
if (resp.status === 409) {
|
||||
resp = await fetch(base + '/api/auth/login', { method: 'POST', headers, body })
|
||||
if (!resp.ok) throw new Error('云端已存在同名账号且密码不一致,请检查')
|
||||
} else if (!resp.ok) {
|
||||
throw new Error('云端绑定失败:' + resp.status)
|
||||
}
|
||||
}
|
||||
|
||||
export function getCloudUser(): string | null {
|
||||
const c = getCloudCreds()
|
||||
return c ? c.username : null
|
||||
}
|
||||
|
||||
// 使用当前登录凭据执行同步(供设置页与自动同步调用)
|
||||
export async function syncWithStoredConfig(): Promise<SyncResult> {
|
||||
return runSync(await loadSyncConfig())
|
||||
}
|
||||
|
||||
// ---------- 推送规范化:剥离本地 id,外键转换为 uuid 引用 ----------
|
||||
|
||||
interface RefMaps {
|
||||
workLog: Map<number, AnyRow>
|
||||
project: Map<number, AnyRow>
|
||||
}
|
||||
|
||||
function normalizeRow(t: string, row: AnyRow, refs: RefMaps): AnyRow {
|
||||
const clean: AnyRow = {}
|
||||
for (const k of Object.keys(row)) {
|
||||
if (k === 'id') continue
|
||||
clean[k] = row[k]
|
||||
}
|
||||
if (t === 'log_item') {
|
||||
delete clean.work_log_id
|
||||
delete clean.project_id
|
||||
const wl = row.work_log_id ? refs.workLog.get(row.work_log_id) : null
|
||||
if (wl && wl.uuid) clean.wuuid = wl.uuid
|
||||
const pj = row.project_id ? refs.project.get(row.project_id) : null
|
||||
if (pj && pj.uuid) clean.puuid = pj.uuid
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
async function buildRefMaps(): Promise<RefMaps> {
|
||||
const wlRows = await db.work_log.toArray()
|
||||
const pjRows = await db.project.toArray()
|
||||
return {
|
||||
workLog: new Map(wlRows.map(r => [r.id as number, r])),
|
||||
project: new Map(pjRows.map(p => [p.id as number, p]))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 执行同步 ----------
|
||||
|
||||
let applyErrors = 0
|
||||
let lastApplyError = ''
|
||||
|
||||
export async function runSync(config: SyncConfig): Promise<SyncResult> {
|
||||
if (syncRunning) {
|
||||
pendingSync = true
|
||||
return { pushed: 0, pulled: 0, removed: 0, at: '' }
|
||||
return { pushed: 0, pulled: 0, removed: 0, errors: 0, at: '' }
|
||||
}
|
||||
syncRunning = true
|
||||
changesSuppressed = true
|
||||
applyErrors = 0
|
||||
lastApplyError = ''
|
||||
try {
|
||||
const result = await doRunSync(config)
|
||||
if (pendingSync) {
|
||||
@@ -245,8 +292,9 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
|
||||
const base = config.baseUrl.trim().replace(/\/+$/, '')
|
||||
if (!base) throw new Error('请先配置同步服务地址')
|
||||
await ensureSyncFields()
|
||||
const refs = await buildRefMaps()
|
||||
|
||||
// 1. 载入镜像,计算本地差异
|
||||
// 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)
|
||||
@@ -262,7 +310,7 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
|
||||
for (const row of rows) current.set(row.uuid, row)
|
||||
|
||||
for (const [u, row] of current) {
|
||||
const clean = stripId(row)
|
||||
const clean = normalizeRow(t, row, refs)
|
||||
const h = rowHash(clean)
|
||||
const m = mirror.get(t + '|' + u)
|
||||
if (!m || m.hash !== h) {
|
||||
@@ -279,6 +327,12 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
|
||||
pushTables[t] = pushTables[t] || []
|
||||
}
|
||||
|
||||
// 本地待推送的删除墓碑
|
||||
const localTombs = await db.tombstone.toArray()
|
||||
for (const tb of localTombs) {
|
||||
tombstones.push({ table: tb.table, uuid: tb.uuid, deleted_at: tb.deleted_at })
|
||||
}
|
||||
|
||||
// 2. 认证并与服务端交换(token 失效自动重登一次)
|
||||
const token = await ensureToken(base, config)
|
||||
const doPush = (tk: string) => fetch(base + '/api/sync', {
|
||||
@@ -299,58 +353,159 @@ async function doRunSync(config: SyncConfig): Promise<SyncResult> {
|
||||
const serverTombstones: { table: string; uuid: string; deleted_at: string }[] = data.tombstones || []
|
||||
|
||||
// 3. 应用服务端权威数据
|
||||
const applyResult = await applyServerTables(serverTables, serverTombstones)
|
||||
|
||||
// 4. 重建镜像 + 建立 SSE 实时通道 + 广播考勤变更
|
||||
const refsNow = await buildRefMaps()
|
||||
const finalMirror: { table: string; uuid: string; hash: string }[] = []
|
||||
for (const t of TABLES) {
|
||||
const rows: AnyRow[] = await db.table(t).toArray()
|
||||
for (const row of rows) {
|
||||
if (t === 'setting' && EXCLUDE_SETTING_KEYS.has(row.key)) continue
|
||||
finalMirror.push({ table: t, uuid: row.uuid, hash: rowHash(normalizeRow(t, row, refsNow)) })
|
||||
}
|
||||
}
|
||||
await db.sync_mirror.clear()
|
||||
if (finalMirror.length) await db.sync_mirror.bulkAdd(finalMirror.map(m => ({ ...m })))
|
||||
ensureSSE(base, token)
|
||||
const at = nowLocalISO()
|
||||
await setSetting('sync_last_time', at)
|
||||
if (applyResult.pulled > 0 || applyResult.removed > 0) notifyAttendanceChanged()
|
||||
// 清除已确认推送的本地墓碑(保留本轮新产生的去重墓碑)
|
||||
if (localTombs.length) {
|
||||
const pushedIds = new Set(localTombs.map(t => t.id))
|
||||
await db.tombstone.where('id').anyOf(Array.from(pushedIds)).delete()
|
||||
}
|
||||
return { pushed, pulled: applyResult.pulled, removed: applyResult.removed, errors: applyResult.errors, at }
|
||||
}
|
||||
|
||||
// ---------- 应用服务端数据(含自然键去重与外键重映射) ----------
|
||||
|
||||
let localWorkLogIdByUuid = new Map<string, number>()
|
||||
let localProjectIdByUuid = new Map<string, number>()
|
||||
let applyConflicts = 0
|
||||
|
||||
async function applyServerTables(
|
||||
serverTables: Record<string, AnyRow[]>,
|
||||
serverTombstones: { table: string; uuid: string; deleted_at: string }[]
|
||||
): Promise<{ pulled: number; removed: number; errors: number }> {
|
||||
let pulled = 0
|
||||
let removed = 0
|
||||
const finalMirror: { table: string; uuid: string; hash: string }[] = []
|
||||
|
||||
for (const t of TABLES) {
|
||||
const table = db.table(t)
|
||||
const localRows: AnyRow[] = await table.toArray()
|
||||
const byUuid = new Map<string, AnyRow>()
|
||||
for (const row of localRows) byUuid.set(row.uuid, row)
|
||||
const nk = NATURAL_KEYS[t] || null
|
||||
const byNatural = new Map<string, AnyRow>()
|
||||
if (nk) {
|
||||
for (const row of localRows) {
|
||||
const v = row[nk]
|
||||
if (v != null && v !== '') byNatural.set(String(v), row)
|
||||
}
|
||||
}
|
||||
|
||||
const serverRows: AnyRow[] = serverTables[t] || []
|
||||
const serverByUuid = new Map<string, AnyRow>()
|
||||
for (const row of serverRows) serverByUuid.set(row.uuid, row)
|
||||
const dead = new Set(serverTombstones.filter(x => x.table === t).map(x => x.uuid))
|
||||
const serverRows: AnyRow[] = serverTables[t] || []
|
||||
|
||||
for (const [u, row] of byUuid) {
|
||||
if (dead.has(u)) {
|
||||
// 阶段一:应用未删除行的增改
|
||||
for (const srow of serverRows) {
|
||||
try {
|
||||
if (dead.has(srow.uuid)) continue
|
||||
const local = byUuid.get(srow.uuid)
|
||||
if (local) {
|
||||
const localUa = String(local.updated_at || '')
|
||||
const serverUa = String(srow.updated_at || '')
|
||||
if (serverUa > localUa) {
|
||||
const patch: AnyRow = { ...srow }
|
||||
if (t === 'setting') delete patch.key
|
||||
if (t === 'log_item') {
|
||||
const wlId = srow.wuuid ? localWorkLogIdByUuid.get(srow.wuuid) : null
|
||||
if (!wlId) continue
|
||||
patch.work_log_id = wlId
|
||||
delete patch.wuuid
|
||||
patch.project_id = srow.puuid ? (localProjectIdByUuid.get(srow.puuid) ?? null) : (local.project_id ?? null)
|
||||
delete patch.puuid
|
||||
}
|
||||
await table.update(local.id, patch)
|
||||
pulled++
|
||||
}
|
||||
} else {
|
||||
const nkVal = nk && srow[nk] != null && srow[nk] !== '' ? String(srow[nk]) : null
|
||||
const dup = nkVal ? byNatural.get(nkVal) : undefined
|
||||
if (dup && dup.uuid !== srow.uuid) {
|
||||
const dupUa = String(dup.updated_at || '')
|
||||
const serverUa = String(srow.updated_at || '')
|
||||
if (serverUa >= dupUa) {
|
||||
// 服务端较新:先迁移关联数据,删除本地重复行,写入服务端行
|
||||
if (t === 'work_log') {
|
||||
await db.log_item.where('work_log_id').equals(dup.id as number).modify({ work_log_id: -1 })
|
||||
}
|
||||
await table.delete(pkOf(t, dup))
|
||||
await db.tombstone.add({ table: t, uuid: dup.uuid, deleted_at: nowLocalISO() })
|
||||
const newId = await table.add({ ...srow })
|
||||
if (t === 'work_log') {
|
||||
await db.log_item.where('work_log_id').equals(-1).modify({ work_log_id: newId })
|
||||
}
|
||||
byUuid.set(srow.uuid, { ...srow, id: newId })
|
||||
if (nk && nkVal) byNatural.set(nkVal, { ...srow, id: newId })
|
||||
pulled++
|
||||
} else {
|
||||
// 本地较新:跳过服务端行,记墓碑待下次推送清除服务端副本
|
||||
await db.tombstone.add({ table: t, uuid: srow.uuid, deleted_at: nowLocalISO() })
|
||||
applyConflicts++
|
||||
}
|
||||
} else {
|
||||
if (t === 'log_item') {
|
||||
const wlId = srow.wuuid ? localWorkLogIdByUuid.get(srow.wuuid) : null
|
||||
if (!wlId) continue
|
||||
srow.work_log_id = wlId
|
||||
delete srow.wuuid
|
||||
srow.project_id = srow.puuid ? (localProjectIdByUuid.get(srow.puuid) ?? null) : null
|
||||
delete srow.puuid
|
||||
}
|
||||
const newId = await table.add({ ...srow })
|
||||
byUuid.set(srow.uuid, { ...srow, id: newId })
|
||||
if (nk && nkVal) byNatural.set(nkVal, { ...srow, id: newId })
|
||||
pulled++
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
applyErrors++
|
||||
lastApplyError = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 阶段二:应用墓碑删除(work_log 删除时把本地条目迁移到同日幸存者)
|
||||
for (const row of localRows) {
|
||||
if (!dead.has(row.uuid)) continue
|
||||
try {
|
||||
if (t === 'work_log') {
|
||||
const freshRows: AnyRow[] = await table.toArray()
|
||||
const survivor = freshRows.find(r2 => r2.date === row.date && !dead.has(r2.uuid) && r2.uuid !== row.uuid)
|
||||
if (survivor && row.id) {
|
||||
await db.log_item.where('work_log_id').equals(row.id as number).modify({ work_log_id: survivor.id as number })
|
||||
}
|
||||
}
|
||||
await table.delete(pkOf(t, row))
|
||||
removed++
|
||||
} catch (err) {
|
||||
applyErrors++
|
||||
}
|
||||
}
|
||||
for (const srow of serverRows) {
|
||||
if (dead.has(srow.uuid)) continue
|
||||
const local = byUuid.get(srow.uuid)
|
||||
if (!local) {
|
||||
await table.add({ ...srow })
|
||||
pulled++
|
||||
} else {
|
||||
const localUa = String(local.updated_at || '')
|
||||
const serverUa = String(srow.updated_at || '')
|
||||
if (serverUa > localUa) {
|
||||
const patch: AnyRow = { ...srow }
|
||||
// setting 表主键为 key,禁止通过 update 修改主键字段
|
||||
if (t === 'setting') delete patch.key
|
||||
await table.update(pkOf(t, local), patch)
|
||||
pulled++
|
||||
}
|
||||
}
|
||||
|
||||
// 外键重映射表:work_log 与 project 处理完成后构建
|
||||
if (t === 'work_log') {
|
||||
const fresh: AnyRow[] = await table.toArray()
|
||||
localWorkLogIdByUuid = new Map(fresh.map(r => [r.uuid as string, r.id as number]))
|
||||
}
|
||||
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)) })
|
||||
if (t === 'project') {
|
||||
const fresh: AnyRow[] = await table.toArray()
|
||||
localProjectIdByUuid = new Map(fresh.map(r => [r.uuid as string, r.id as number]))
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 重建镜像 + 建立 SSE 实时通道 + 广播考勤变更
|
||||
await db.sync_mirror.clear()
|
||||
if (finalMirror.length) await db.sync_mirror.bulkAdd(finalMirror.map(m => ({ ...m })))
|
||||
ensureSSE(base, token)
|
||||
const at = nowLocalISO()
|
||||
await setSetting('sync_last_time', at)
|
||||
if (pulled > 0 || removed > 0) notifyAttendanceChanged()
|
||||
return { pushed, pulled, removed, at }
|
||||
void lastApplyError
|
||||
void applyConflicts
|
||||
return { pulled, removed, errors: applyErrors }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user