fix: 状态镜像防冲掉——服务端按 key 合并,客户端读不到宿主就不写

This commit is contained in:
2026-11-02 23:14:04 +08:00
parent 26c7dee390
commit 8ae28b9a92
8 changed files with 115 additions and 39 deletions
+45 -12
View File
@@ -8,6 +8,11 @@
* 做法:把「项目 + 视图」这份 JSON 镜像到宿主侧文件(/api/worktable/state),
* 启动时若宿主侧比本地新,就用宿主侧恢复。localStorage 仍是第一落点(同步、快),
* 宿主文件是跨重启的兜底。
*
* 两条安全约束(都来自一次真实事故:空状态把宿主文件冲掉):
* 1. 读不到宿主状态就**不写**——宁可这次会话只在本地,也不拿空值覆盖镜像。
* 2. POST 始终带上完整的 parts(projects 与 view 都在内存里各留一份),
* 服务端再做一次按 key 合并,半截写入不会丢另一半。
*/
const ENDPOINT = '/api/worktable/state'
@@ -17,16 +22,35 @@ export type HostStateParts = { projects?: unknown; view?: unknown }
/** 宿主镜像里可能带的写入时间戳(毫秒) */
export type HostState = HostStateParts & { updatedAt?: number }
/** 拉取宿主侧镜像;没有/不可用都返回 null(不抛错,不影响工作台本身) */
export async function loadHostState(): Promise<HostState | null> {
try {
const r = await fetch(ENDPOINT, { cache: 'no-store', headers: { accept: 'application/json' } })
if (!r.ok) return null
const j: any = await r.json()
return j && typeof j === 'object' && j.state && typeof j.state === 'object' ? (j.state as HostState) : null
} catch {
return null
/** reachable=false 表示宿主这次根本没读到(连接没就绪/路由不可用),不能据此覆盖镜像 */
export type HostReadResult =
| { reachable: true; state: HostState | null }
| { reachable: false }
const RETRY_DELAYS = [0, 300, 700, 1200, 2000, 3000]
/**
* 拉取宿主侧镜像;不抛错,不影响工作台本身。
* 页面刚挂载时宿主连接可能还没就绪,所以这里带退避重试。
* - reachable: true, state: null → 宿主可达但还没有镜像(首次使用),可以写
* - reachable: false → 读不到,本次会话禁止写
*/
export async function loadHostState(): Promise<HostReadResult> {
for (const delay of RETRY_DELAYS) {
if (delay) await new Promise((r) => setTimeout(r, delay))
try {
const r = await fetch(ENDPOINT, { cache: 'no-store', headers: { accept: 'application/json' } })
if (!r.ok) continue
const j: any = await r.json()
if (j && typeof j === 'object' && j.state && typeof j.state === 'object') {
return { reachable: true, state: j.state as HostState }
}
return { reachable: true, state: null }
} catch {
// 继续重试
}
}
return { reachable: false }
}
/** 累积中的镜像内容:POST 只带最新值,避免每次编辑都发整包 */
@@ -34,7 +58,7 @@ let parts: HostStateParts = {}
let timer: any = null
let inflight = false
let dirty = false
/** 恢复完成前不写宿主,否则首屏的默认值会把宿主里的旧数据覆盖掉 */
/** 读完宿主之前不写宿主:首屏默认值不能覆盖镜像,读失败就整场不写 */
let hydrated = false
function schedule() {
@@ -77,11 +101,20 @@ export function queueHostSave(patch: HostStateParts): void {
schedule()
}
/** 首次恢复判定结束:之后再放行宿主写入 */
export function markHydrated(): void {
/**
* 宿主镜像已成功读到,放行写入。
* 传入读到的内容,让之后的半截写入也能带上另一半。
*/
export function markHydrated(seed?: HostStateParts): void {
if (seed) parts = { ...seed, ...parts }
hydrated = true
}
/** 本次会话没有读到宿主:禁止写入,避免空状态覆盖镜像 */
export function staysReadOnly(): void {
hydrated = false
}
/** 立即冲刷(页面隐藏/关闭前调用,避免丢掉最后一个 400ms 内的改动) */
export function flushHostSave(): void {
if (timer) {