fix: 二道防线(服务器行数少于镜像即判定数据丢失,自动全量重推自救,首次升级即可生效) + 灾难恢复冒烟7项

This commit is contained in:
root
2026-10-26 19:58:59 +08:00
parent dc5ab15ae1
commit d48330e5c4
8 changed files with 126 additions and 16 deletions
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-BuQ7VOet.js","./index-B3bd8iWX.js","./index-OQmtzryQ.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-B3bd8iWX.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-BuQ7VOet.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-B-qBD-4O.js","./index-DFZaRaeX.js","./index-OQmtzryQ.css"])))=>i.map(i=>d[i]);
import{r as s,_ as i}from"./index-DFZaRaeX.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-B-qBD-4O.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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-B3bd8iWX.js"></script>
<script type="module" crossorigin src="./assets/index-DFZaRaeX.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-OQmtzryQ.css">
</head>
<body>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "intern-log",
"private": true,
"version": "0.6.1",
"version": "0.6.2",
"type": "module",
"scripts": {
"dev": "vite",
+82
View File
@@ -0,0 +1,82 @@
import 'fake-indexeddb/auto'
import { bindDatabase, WorklogDB } from '../src/db/schema'
// node 环境的 localStorage 垫片(引擎的纪元/idMap 存取需要)
const lsMap = new Map<string, string>()
;(globalThis as any).localStorage = {
getItem: (k: string) => (lsMap.has(k) ? lsMap.get(k)! : null),
setItem: (k: string, v: string) => { lsMap.set(k, String(v)) },
removeItem: (k: string) => { lsMap.delete(k) }
}
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: 'restore', password: 'dev123456' })
}).catch(() => { /* 已存在忽略 */ })
const engine = await import('../src/sync/engine')
const CONF = { baseUrl: BASE, username: 'restore', password: 'dev123456' }
// ===== 设备 A:备忘录 x3 + 密码库密文 x1 + 设置 =====
bindDatabase(new WorklogDB('restore_a'))
const a = (await import('../src/db/schema')).db
const now = '2026-10-26T10:00:00'
await a.memo.bulkAdd([
{ title: '备忘一', content: '内容一', created_at: now, updated_at: now },
{ title: '备忘二', content: '内容二', created_at: now, updated_at: now },
{ title: '备忘三', content: '内容三', created_at: now, updated_at: now }
])
await a.credential.bulkAdd([
{ system: '企业邮箱', account: 'dev@x.com', password: 'enc:v1:FAKECIPHER', remark: '', created_at: now, updated_at: now }
])
await a.setting.bulkAdd([
{ key: 'work_start', value: '09:00' },
{ key: 'pwd_salt', value: 'SALTSALT' },
{ key: 'pwd_verifier', value: 'enc:v1:VERIFIER' }
])
const rA = await engine.runSync(CONF)
console.log('设备A 首次推送:', JSON.stringify({ p: rA.pushed, e: rA.errors }))
// ===== 灾难:服务器用户数据文件被删(模拟 rm -rf data / 目录重建)=====
const fsx = await import('fs')
const usersDir = 'server/data/users'
for (const f of fsx.readdirSync(usersDir)) fsx.rmSync(usersDir + '/' + f)
console.log('已删除服务器用户数据文件')
// ===== 设备 A 再次同步:应触发二道防线,自动全量重推自救 =====
// (设备 A 此前从未存过纪元 —— 恰好覆盖「首次升级即遇到灾难」的窗口)
const rA2 = await engine.runSync(CONF)
console.log('设备A 自救同步:', JSON.stringify({ p: rA2.pushed, e: rA2.errors }))
check('自救时自动重推了全部本地数据(pushed>=7)', rA2.pushed >= 7, 'pushed=' + rA2.pushed)
const aMemo = await a.memo.toArray()
const aCred = await a.credential.toArray()
check('设备A 本地数据未被清空(备忘录仍 3 条)', aMemo.length === 3, 'len=' + aMemo.length)
check('设备A 本地密码库未被清空(仍 1 条)', aCred.length === 1, 'len=' + aCred.length)
// ===== 全新设备 C:拉取验证服务器已被 A 恢复 =====
bindDatabase(new WorklogDB('restore_c'))
const c = (await import('../src/db/schema')).db
const rC = await engine.runSync(CONF)
console.log('设备C 拉取:', JSON.stringify({ p: rC.pushed, e: rC.errors }))
const cMemo = await c.memo.toArray()
const cCred = await c.credential.toArray()
const cSet = await c.setting.toArray()
check('设备C 拉到 3 条备忘录', cMemo.length === 3, 'len=' + cMemo.length)
check('设备C 拉到密码库密文', cCred.length === 1 && cCred[0].system === '企业邮箱', 'len=' + cCred.length)
check('设备C 拉到主密码盐与校验值(密码库可解锁前提)', cSet.some(s => s.key === 'pwd_salt' && s.value === 'SALTSALT') && cSet.some(s => s.key === 'pwd_verifier'))
check('设备C 拉到考勤规则', cSet.some(s => s.key === 'work_start' && s.value === '09:00'))
console.log('RESULT: ' + (fail === 0 ? 'ALL PASS ' + pass : 'FAIL ' + fail + '/' + (pass + fail)))
if (fail > 0) process.exit(1)
}
main().catch(e => { console.error('SMOKE ERROR', e); process.exit(1) })
+10
View File
@@ -373,6 +373,16 @@ async function doRunSync(config: SyncConfig, epochRetry = false): Promise<SyncRe
const serverTables: Record<string, AnyRow[]> = data.tables || {}
const serverTombstones: { table: string; uuid: string; deleted_at: string }[] = data.tombstones || []
// 二道防线(不依赖纪元存储,首次升级即可生效):
// 本地镜像认为服务器应有 N 行,但服务器实际行数更少 —— 说明服务器数据发生过丢失/重建。
// 此时若照常拉快照,会把本地缓存也清空。处置:作废镜像并以本地全量数据重推自救。
const serverCount = TABLES.reduce((s, tn) => s + ((serverTables[tn] || []).length), 0)
const expectedMin = mirrorRows.length - tombstones.length
if (!epochRetry && serverCount < expectedMin) {
await db.sync_mirror.clear()
return doRunSync(config, true)
}
// 3. 用服务器快照全量重建本地缓存(本地不再有映射逻辑,服务器永远是对的)
const applyResult = await applySnapshot(serverTables, serverTombstones)