fix: 服务端表结构自愈(数组/非法行治愈为uuid键对象,根因修复500与重启丢数据) + 客户端快照防御
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
# v0.6.6 发布说明(2026-10-26)
|
||||||
|
|
||||||
|
## 修复「同步服务返回 500」与二次数据丢失(根因修复)
|
||||||
|
|
||||||
|
**根因**:此前数据恢复用的合并脚本把用户数据文件中的表写成了**数组**形态,而服务端按「uuid 键对象」读写。后果:数组上的附加属性在序列化时被丢弃(**每次容器重启丢失合并后新写的数据**)、删除/半途状态产生 null 行(**自然键扫描读到 null.date → 每次同步 500**)。该脚本缺陷由开发者引入,本版彻底治愈。
|
||||||
|
|
||||||
|
## 修复内容
|
||||||
|
|
||||||
|
- **服务端加载即自愈**:读取用户数据时自动把数组形态转回 uuid 键对象、剔除 null/非法行(一次性治愈线上文件,无需任何手动操作),自愈结果立即持久化
|
||||||
|
- **自然键扫描 / 墓碑生效循环**:对非对象行防御性跳过
|
||||||
|
- **客户端快照重建**:跳过 null / 无 uuid 行,垃圾行不再进入本地缓存
|
||||||
|
- 合并后丢失的增量数据:各端升级后首次同步会触发行数比对防线,**自动把设备缓存中的数据重推回服务器**
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 表结构自愈冒烟 8/8(数组+null 混合文件 → 同步一次 → 治愈且数据完整、快照无 null)
|
||||||
|
- 存储冒烟 9/9 · 协议审计 36/36 · 用户管理审计 16/16 · 灾难恢复自救 7/7
|
||||||
|
|
||||||
|
## 部署
|
||||||
|
|
||||||
|
- 服务器:`cd /opt/daywork && git pull && cd server && docker compose up -d --build`
|
||||||
|
- 桌面 / 安卓:安装 0.6.6(网页端刷新即新版)
|
||||||
|
- 部署后各端打开一次,核对数据(工作记录/记账等);若仍有缺,检查设置页「立即同步」的结果行并反馈
|
||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-D_9V1YkE.js","./index-BfUIIJpW.js","./index-BuZbSc5_.css"])))=>i.map(i=>d[i]);
|
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["./web-B4MkFHMk.js","./index-DP7vL7H5.js","./index-BuZbSc5_.css"])))=>i.map(i=>d[i]);
|
||||||
import{r as s,_ as i}from"./index-BfUIIJpW.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-D_9V1YkE.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-DP7vL7H5.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-B4MkFHMk.js"),__vite__mapDeps([0,1,2]),import.meta.url).then(e=>new e.FilesystemWeb)});export{t as Directory,a as Encoding,E as Filesystem};
|
||||||
+2
-2
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="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-BfUIIJpW.js"></script>
|
<script type="module" crossorigin src="./assets/index-DP7vL7H5.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-BuZbSc5_.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-BuZbSc5_.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "intern-log",
|
"name": "intern-log",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.6.5",
|
"version": "0.6.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -438,7 +438,9 @@ async function applySnapshot(
|
|||||||
const prevById = new Map(prevRows.map(r => [pkOf(t, r), r]))
|
const prevById = new Map(prevRows.map(r => [pkOf(t, r), r]))
|
||||||
const prevByUuid = new Map(prevRows.filter(r => r.uuid).map(r => [r.uuid as string, r]))
|
const prevByUuid = new Map(prevRows.filter(r => r.uuid).map(r => [r.uuid as string, r]))
|
||||||
|
|
||||||
const live: AnyRow[] = (serverTables[t] || []).filter(r => !dead.has(t + '|' + r.uuid))
|
const live: AnyRow[] = (serverTables[t] || [])
|
||||||
|
.filter(r => r && typeof r === 'object' && r.uuid)
|
||||||
|
.filter(r => !dead.has(t + '|' + r.uuid))
|
||||||
// 设置表的临时键:不写回本地缓存;顺手在服务端也清掉(下一轮推送墓碑)
|
// 设置表的临时键:不写回本地缓存;顺手在服务端也清掉(下一轮推送墓碑)
|
||||||
const filtered = live.filter(r => !(t === 'setting' && EXCLUDE_SETTING_KEYS.has(r.key)))
|
const filtered = live.filter(r => !(t === 'setting' && EXCLUDE_SETTING_KEYS.has(r.key)))
|
||||||
const tmpKeys = live.filter(r => t === 'setting' && EXCLUDE_SETTING_KEYS.has(r.key))
|
const tmpKeys = live.filter(r => t === 'setting' && EXCLUDE_SETTING_KEYS.has(r.key))
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,52 @@
|
|||||||
|
// 表结构自愈冒烟:数组形态 + null 行 + 合法行混合 → 同步一次 → 治愈为 uuid 键对象且数据完整
|
||||||
|
const BASE = 'http://127.0.0.1:3780'
|
||||||
|
let pass = 0, fail = 0
|
||||||
|
function check(name, ok, detail) {
|
||||||
|
if (ok) { pass++; console.log(' PASS ' + name) } else { fail++; console.log(' FAIL ' + name + (detail ? ' -> ' + detail : '')) }
|
||||||
|
}
|
||||||
|
async function j(path, body, token) {
|
||||||
|
const r = await fetch(BASE + path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) }, body: body ? JSON.stringify(body) : undefined })
|
||||||
|
return { status: r.status, json: await r.json().catch(() => ({})) }
|
||||||
|
}
|
||||||
|
async function main() {
|
||||||
|
const fsp = await import('fs')
|
||||||
|
const reg = await j('/api/auth/register', { username: 'healtest', password: 'test123456' })
|
||||||
|
const tok = reg.json.token
|
||||||
|
const file = 'server/data/users/' + reg.json.userId + '.json'
|
||||||
|
// 构造坏文件:work_log 是数组 + 含 null 槽位;attendance 数组含 null;memo 正常对象但混入 null 行
|
||||||
|
const bad = {
|
||||||
|
userId: reg.json.userId, cursor: 0,
|
||||||
|
created_at: '2026-10-26T12:00:00.000Z',
|
||||||
|
tables: {
|
||||||
|
work_log: [ { uuid: 'wl1', date: '2026-10-20', created_at: '2026-10-20T00:00:00', updated_at: '2026-10-20T00:00:00' }, null, { uuid: 'wl2', date: '2026-10-21', created_at: '2026-10-21T00:00:00', updated_at: '2026-10-21T00:00:00' } ],
|
||||||
|
attendance: [ null, { uuid: 'at1', date: '2026-10-20', created_at: '2026-10-20T00:00:00', updated_at: '2026-10-20T00:00:00' } ],
|
||||||
|
memo: { m1: { uuid: 'm1', title: '幸存备忘', content: '', created_at: '2026-10-20T00:00:00', updated_at: '2026-10-20T00:00:00' }, bad: null },
|
||||||
|
plan: []
|
||||||
|
},
|
||||||
|
tombstones: { memo: { g1: null }, work_log: {} }
|
||||||
|
}
|
||||||
|
fsp.mkdirSync('server/data/users', { recursive: true })
|
||||||
|
fsp.writeFileSync(file, JSON.stringify(bad))
|
||||||
|
|
||||||
|
// 推送一行 work_log(自然键 date 扫描会遍历表)→ 修复前这里 500,修复后应 200
|
||||||
|
const r1 = await j('/api/sync', { device: 't', tables: { work_log: [ { uuid: 'wl3', date: '2026-10-22', created_at: '2026-10-22T00:00:00', updated_at: '2026-10-22T00:00:00' } ] }, tombstones: [] }, tok)
|
||||||
|
check('同步 200(不再读 null.date)', r1.status === 200, 'status=' + r1.status)
|
||||||
|
|
||||||
|
const healed = JSON.parse(fsp.readFileSync(file, 'utf8'))
|
||||||
|
check('work_log 已转 uuid 键对象', !Array.isArray(healed.tables.work_log) && !!healed.tables.work_log.wl1 && !!healed.tables.work_log.wl3)
|
||||||
|
check('null 行已剔除', !('1' in (healed.tables.work_log || {})) && !healed.tables.attendance['0'])
|
||||||
|
check('attendance 幸存行保留', healed.tables.attendance && healed.tables.attendance.at1)
|
||||||
|
check('memo 正常对象保留', healed.tables.memo && healed.tables.memo.m1)
|
||||||
|
check('null 墓碑已清理', !healed.tombstones.memo || !healed.tombstones.memo.g1)
|
||||||
|
check('全表均为对象形态', Object.values(healed.tables).every(x => x && !Array.isArray(x) && typeof x === 'object'))
|
||||||
|
|
||||||
|
// 推送 m1 更新(memo 无自然键)→ 再拉全量,快照中不得出现 null
|
||||||
|
await j('/api/sync', { device: 't', tables: { memo: [ { uuid: 'm2', title: '新备忘', content: '', created_at: '2026-10-26T00:00:00', updated_at: '2026-10-26T00:00:00' } ] }, tombstones: [] }, tok)
|
||||||
|
const r3 = await j('/api/sync', { device: 't', tables: {}, tombstones: [] }, tok)
|
||||||
|
const flat = Object.values(r3.json.tables || {}).flat()
|
||||||
|
check('权威快照不含 null/无uuid行', r3.status === 200 && flat.every(x => x && typeof x === 'object' && x.uuid), JSON.stringify(flat.filter(x => !x || !x.uuid)).slice(0, 100))
|
||||||
|
|
||||||
|
console.log('RESULT: ' + (fail === 0 ? 'ALL PASS ' + pass : 'FAIL ' + fail + '/' + (pass + fail)))
|
||||||
|
if (fail) process.exit(1)
|
||||||
|
}
|
||||||
|
main().catch(e => { console.error('SMOKE ERROR', e); process.exit(1) })
|
||||||
+50
-7
@@ -140,20 +140,60 @@ function userFile(userId) { return path.join(DATA_DIR, 'users', userId + '.json'
|
|||||||
function emptyUserData(userId) {
|
function emptyUserData(userId) {
|
||||||
return { userId, cursor: 0, tables: {}, tombstones: {}, created_at: '' }
|
return { userId, cursor: 0, tables: {}, tombstones: {}, created_at: '' }
|
||||||
}
|
}
|
||||||
|
// 表结构自愈:tables[t] 必须是「uuid -> 行对象」;
|
||||||
|
// 兼容历史误存为数组的形态(并剔除 null / 非法行),避免自然键扫描在 null 上崩溃、避免序列化丢弃行
|
||||||
|
function sanitizeTables(data) {
|
||||||
|
let healed = false
|
||||||
|
const src = data.tables && typeof data.tables === 'object' && !Array.isArray(data.tables) ? data.tables : null
|
||||||
|
if (!src) { data.tables = {}; return true }
|
||||||
|
const out = {}
|
||||||
|
for (const t of Object.keys(src)) {
|
||||||
|
const container = src[t]
|
||||||
|
if (Array.isArray(container)) {
|
||||||
|
healed = true
|
||||||
|
const m = {}
|
||||||
|
for (const row of container) {
|
||||||
|
if (row && typeof row === 'object' && row.uuid) m[row.uuid] = row
|
||||||
|
}
|
||||||
|
out[t] = m
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!container || typeof container !== 'object') { healed = true; out[t] = {}; continue }
|
||||||
|
const m = {}
|
||||||
|
for (const k of Object.keys(container)) {
|
||||||
|
const row = container[k]
|
||||||
|
if (!row || typeof row !== 'object' || Array.isArray(row) || !row.uuid) { healed = true; continue }
|
||||||
|
m[k] = row
|
||||||
|
}
|
||||||
|
out[t] = m
|
||||||
|
}
|
||||||
|
data.tables = out
|
||||||
|
if (!data.tombstones || typeof data.tombstones !== 'object' || Array.isArray(data.tombstones)) { data.tombstones = {}; healed = true }
|
||||||
|
for (const t of Object.keys(data.tombstones)) {
|
||||||
|
const bucket = data.tombstones[t]
|
||||||
|
if (!bucket || typeof bucket !== 'object' || Array.isArray(bucket)) { delete data.tombstones[t]; healed = true; continue }
|
||||||
|
for (const k of Object.keys(bucket)) {
|
||||||
|
if (!bucket[k]) { delete bucket[k]; healed = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return healed
|
||||||
|
}
|
||||||
function loadUserData(userId) {
|
function loadUserData(userId) {
|
||||||
const full = userFile(userId)
|
const full = userFile(userId)
|
||||||
if (fs.existsSync(full)) {
|
if (fs.existsSync(full)) {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(fs.readFileSync(full, 'utf-8'))
|
const data = JSON.parse(fs.readFileSync(full, 'utf-8'))
|
||||||
|
const healed = sanitizeTables(data)
|
||||||
|
return { data, healed }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 数据文件损坏:隔离保留原件并大声报错,绝不静默当作空数据(否则客户端同步会引发连锁覆盖)
|
// 数据文件损坏:隔离保留原件并大声报错,绝不静默当作空数据(否则客户端同步会引发连锁覆盖)
|
||||||
const quarantine = full + '.corrupt-' + new Date().toISOString().replace(/[:.]/g, '-')
|
const quarantine = full + '.corrupt-' + new Date().toISOString().replace(/[:.]/g, '-')
|
||||||
try { fs.renameSync(full, quarantine) } catch { /* 忽略 */ }
|
try { fs.renameSync(full, quarantine) } catch { /* 忽略 */ }
|
||||||
console.error('[存储] 用户数据文件解析失败,已隔离:' + quarantine + '(' + (e && e.message) + ')')
|
console.error('[存储] 用户数据文件解析失败,已隔离:' + quarantine + '(' + (e && e.message) + ')')
|
||||||
return emptyUserData(userId)
|
return { data: emptyUserData(userId), healed: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return emptyUserData(userId)
|
return { data: emptyUserData(userId), healed: false }
|
||||||
}
|
}
|
||||||
// 墓碑回收:超过保留期的墓碑删除(全量快照重建的客户端不依赖过期墓碑也能收敛)
|
// 墓碑回收:超过保留期的墓碑删除(全量快照重建的客户端不依赖过期墓碑也能收敛)
|
||||||
function gcTombstones(data) {
|
function gcTombstones(data) {
|
||||||
@@ -403,8 +443,9 @@ async function handle(req, res) {
|
|||||||
const auth = verifyToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim())
|
const auth = verifyToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim())
|
||||||
if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return }
|
if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return }
|
||||||
const body = await readBody(req)
|
const body = await readBody(req)
|
||||||
const userData = loadUserData(auth.userId)
|
const loaded = loadUserData(auth.userId)
|
||||||
let dirty = false
|
const userData = loaded.data
|
||||||
|
let dirty = loaded.healed
|
||||||
if (userData.cursor == null) { userData.cursor = 0; dirty = true }
|
if (userData.cursor == null) { userData.cursor = 0; dirty = true }
|
||||||
if (!userData.tables) { userData.tables = {}; dirty = true }
|
if (!userData.tables) { userData.tables = {}; dirty = true }
|
||||||
if (!userData.tombstones) { userData.tombstones = {}; dirty = true }
|
if (!userData.tombstones) { userData.tombstones = {}; dirty = true }
|
||||||
@@ -429,7 +470,9 @@ async function handle(req, res) {
|
|||||||
if (nkVal) {
|
if (nkVal) {
|
||||||
for (const u2 of Object.keys(userData.tables[t])) {
|
for (const u2 of Object.keys(userData.tables[t])) {
|
||||||
if (u2 === row.uuid) continue
|
if (u2 === row.uuid) continue
|
||||||
if (String(userData.tables[t][u2][nk] || '') === nkVal) { conflictUuid = u2; break }
|
const other = userData.tables[t][u2]
|
||||||
|
if (!other || typeof other !== 'object') continue
|
||||||
|
if (String(other[nk] || '') === nkVal) { conflictUuid = u2; break }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -484,7 +527,7 @@ async function handle(req, res) {
|
|||||||
const tombs = userData.tombstones[t] || {}
|
const tombs = userData.tombstones[t] || {}
|
||||||
for (const [u, deletedAt] of Object.entries(tombs)) {
|
for (const [u, deletedAt] of Object.entries(tombs)) {
|
||||||
const row = userData.tables[t][u]
|
const row = userData.tables[t][u]
|
||||||
if (row && String(row.updated_at || '') <= String(deletedAt)) delete userData.tables[t][u]
|
if (row && typeof row === 'object' && String(row.updated_at || '') <= String(deletedAt)) delete userData.tables[t][u]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (gcTombstones(userData) > 0) dirty = true
|
if (gcTombstones(userData) > 0) dirty = true
|
||||||
|
|||||||
Reference in New Issue
Block a user