Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d48330e5c4 |
@@ -0,0 +1,18 @@
|
|||||||
|
# v0.6.2 发布说明(2026-10-26)
|
||||||
|
|
||||||
|
## 数据安全:补上「首次升级即遇灾」的恢复漏洞(关键)
|
||||||
|
|
||||||
|
- v0.6.1 的数据纪元保护只对「已存过纪元」的设备生效;所有设备第一次跑新版时检测不到服务器数据被重建。
|
||||||
|
- 本版本增加**二道防线**(不依赖任何历史状态):若本地镜像认为服务器应有 N 行,而服务器实际行数更少——即服务器数据发生过丢失/重建——立即作废本地镜像,把本机全量数据重推回服务器,再拉取快照,本地与服务器同时恢复。
|
||||||
|
- 已通过灾难恢复冒烟测试(7/7):删除服务器用户数据文件后,保留缓存的设备下一次同步自动重推全部数据(备忘录、密码库密文、主密码盐与校验值、考勤规则),全新设备随后完整拉回。
|
||||||
|
|
||||||
|
## 与数据丢失相关的说明
|
||||||
|
|
||||||
|
- 密码库:密文行与主密码盐/校验值均随同步存储于服务器;只要任一快照存活即可整体恢复,主密码不变。
|
||||||
|
- 备忘录:明文行随同步存储,恢复机制相同。
|
||||||
|
- 若所有设备都已在数据丢失后打开过应用(本地缓存同样被旧版清空),且服务器 data 目录被删、COS 未配置,则丢失部分无法找回;建议尽快配置 COS 异地备份。
|
||||||
|
|
||||||
|
## 升级
|
||||||
|
|
||||||
|
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`(切勿删除 server/data)
|
||||||
|
- 桌面 / 安卓:安装 0.6.2;网页端刷新页面即为新版。
|
||||||
+11
-11
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-BuQ7VOet.js","./index-B3bd8iWX.js","./index-OQmtzryQ.css"])))=>i.map(i=>d[i]);
|
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-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};
|
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};
|
||||||
+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="icon" href="./icons/icon.svg" type="image/svg+xml" />
|
||||||
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
|
<link rel="apple-touch-icon" href="./icons/icon-192.png" />
|
||||||
<title>实习工作日志</title>
|
<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">
|
<link rel="stylesheet" crossorigin href="./assets/index-OQmtzryQ.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "intern-log",
|
"name": "intern-log",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.6.1",
|
"version": "0.6.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -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) })
|
||||||
@@ -373,6 +373,16 @@ async function doRunSync(config: SyncConfig, epochRetry = false): Promise<SyncRe
|
|||||||
const serverTables: Record<string, AnyRow[]> = data.tables || {}
|
const serverTables: Record<string, AnyRow[]> = data.tables || {}
|
||||||
const serverTombstones: { table: string; uuid: string; deleted_at: string }[] = data.tombstones || []
|
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. 用服务器快照全量重建本地缓存(本地不再有映射逻辑,服务器永远是对的)
|
// 3. 用服务器快照全量重建本地缓存(本地不再有映射逻辑,服务器永远是对的)
|
||||||
const applyResult = await applySnapshot(serverTables, serverTombstones)
|
const applyResult = await applySnapshot(serverTables, serverTombstones)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user