// 表结构自愈冒烟:数组形态 + 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) })