fix: 服务端表结构自愈(数组/非法行治愈为uuid键对象,根因修复500与重启丢数据) + 客户端快照防御
This commit is contained in:
+50
-7
@@ -140,20 +140,60 @@ function userFile(userId) { return path.join(DATA_DIR, 'users', userId + '.json'
|
||||
function emptyUserData(userId) {
|
||||
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) {
|
||||
const full = userFile(userId)
|
||||
if (fs.existsSync(full)) {
|
||||
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) {
|
||||
// 数据文件损坏:隔离保留原件并大声报错,绝不静默当作空数据(否则客户端同步会引发连锁覆盖)
|
||||
const quarantine = full + '.corrupt-' + new Date().toISOString().replace(/[:.]/g, '-')
|
||||
try { fs.renameSync(full, quarantine) } catch { /* 忽略 */ }
|
||||
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) {
|
||||
@@ -403,8 +443,9 @@ async function handle(req, res) {
|
||||
const auth = verifyToken(String(req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim())
|
||||
if (!auth || !tokenUsable(auth)) { json(res, 401, { error: '未认证' }); return }
|
||||
const body = await readBody(req)
|
||||
const userData = loadUserData(auth.userId)
|
||||
let dirty = false
|
||||
const loaded = loadUserData(auth.userId)
|
||||
const userData = loaded.data
|
||||
let dirty = loaded.healed
|
||||
if (userData.cursor == null) { userData.cursor = 0; dirty = true }
|
||||
if (!userData.tables) { userData.tables = {}; dirty = true }
|
||||
if (!userData.tombstones) { userData.tombstones = {}; dirty = true }
|
||||
@@ -429,7 +470,9 @@ async function handle(req, res) {
|
||||
if (nkVal) {
|
||||
for (const u2 of Object.keys(userData.tables[t])) {
|
||||
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] || {}
|
||||
for (const [u, deletedAt] of Object.entries(tombs)) {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user