[{"data":1,"prerenderedAt":2650},["ShallowReactive",2],{"\u002F2026-05-04-fa-004-300ms":3,"\u002F2026-05-04-fa-004-300ms-rel":902},{"id":4,"title":5,"body":6,"column":884,"date":885,"description":37,"extension":886,"hero_image":887,"meta":888,"navigation":151,"path":889,"seo":890,"series_id":891,"severity":887,"stem":892,"summary":893,"tags":894,"__hash__":901},"posts\u002F2026-05-04-FA-004-300ms防抖切页丢配置.md","配置填好了，切个页面——没了",{"type":7,"value":8,"toc":873},"minimark",[9,13,17,20,24,27,38,44,47,53,60,63,77,80,83,89,92,115,118,121,131,177,184,213,228,237,261,267,276,300,305,349,354,361,369,389,394,404,445,448,451,459,466,590,609,616,648,654,661,734,752,758,777,782,802,805,808,811,820,823,826,829,843,850,853,860,863,866,869],[10,11,12],"h2",{"id":12},"矛盾的事实",[14,15,16],"p",{},"用户在面板里填好 OpenAI 模型配置，等了一下，看起来 UI 响应了。关窗。重启面板。模型列表为空。配置消失得无声无息——没有错误提示、没有\"保存失败\"的警告、没有任何异常迹象。这个沉默正是最危险的地方。",[14,18,19],{},"让我查了客户的数据目录。",[10,21,23],{"id":22},"现象从三个矛盾开始","现象：从三个矛盾开始",[14,25,26],{},"SSH 进去查数据目录：",[28,29,34],"pre",{"className":30,"code":32,"language":33},[31],"language-text","data\\openclaw\\openclaw.json                  1687B  mtime 2026\u002F5\u002F4 0:17:00\n  顶层字段: gateway, plugins, tools          ← 缺 models \u002F agents \u002F skills\n\ndata\\openclaw\\openclaw.json.bak              无 models 字段\ndata\\openclaw\\agents\\                        目录完全不存在\n","text",[35,36,32],"code",{"__ignoreMap":37},"",[14,39,40,43],{},[35,41,42],{},".bak"," 的修改时间停在 2026-05-01 20:33:42——面板首次启动时 calibration baseline 的时刻。之后三天，面板被打开过好多次，但这个文件的 mtime 一动不动。",[14,45,46],{},"gateway.log 里什么都有：",[28,48,51],{"className":49,"code":50,"language":33},[31],"2026-05-03 23:56:51  [reload] config change detected (models.providers.openai.models)\n2026-05-03 23:56:51  [reload] config hot reload applied (models.providers.openai.models)\n2026-05-04 00:16:40  [reload] config change detected (models)\n2026-05-04 00:16:40  [reload] config hot reload applied (models)\n",[35,52,50],{"__ignoreMap":37},[14,54,55,56,59],{},"日志明确说配置被加载过。那为什么备份文件从不更新？只有一个解释：",[35,57,58],{},"write_openclaw_config"," 几乎没被成功调用过。不是\"保存成功了又被擦除\"，而是\"压根没写盘\"。",[10,61,62],{"id":62},"排查路线",[14,64,65,66,68,69,72,73,76],{},"对比所有备份版本（",[35,67,42],{},"、",[35,70,71],{},".pre-portable-repair.bak","）与 USB 缓存，全部缺 ",[35,74,75],{},"models"," 字段。如果真是\"曾保存过被清掉\"，至少某个备份该有完整字段。结论：从未成功写入过。",[14,78,79],{},"这排除了\"后端某个清理逻辑删了配置\"的假设。",[14,81,82],{},"让客户在面板重新配置一个 OpenAI 模型，明确点击保存。立即看磁盘：",[28,84,87],{"className":85,"code":86,"language":33},[31],"data\\openclaw\\openclaw.json  ← 立即写入，mtime 更新\nagents\\main\\agent\\          ← 目录被创建\ngateway.log:  [reload] config hot reload applied (models)  ← 即时加载\n",[35,88,86],{"__ignoreMap":37},[14,90,91],{},"写盘链路本身完全正常。问题不在落盘机制，在触发时机。",[14,93,94,95,98,99,102,103,106,107,110,111,114],{},"grep ",[35,96,97],{},"writeOpenclawConfig","，找到 ",[35,100,101],{},"src\u002Fpages\u002Fmodels.js","。整个页面没有显式\"保存\"按钮。用户所有的配置变化都依赖 ",[35,104,105],{},"onChange"," \u002F ",[35,108,109],{},"onInput"," 的防抖自动保存 ",[35,112,113],{},"autoSave","。",[14,116,117],{},"打开 router 导航逻辑，看 cleanup 怎么被调用。这才是关键。",[10,119,120],{"id":120},"根因链路",[14,122,123,127,128,130],{},[124,125,126],"strong",{},"前端 autoSave 的实现"," (",[35,129,101],{}," 第 634-637 行)：",[28,132,136],{"className":133,"code":134,"language":135,"meta":37,"style":37},"language-javascript shiki shiki-themes github-light github-dark","let _saveTimer = null\n\nfunction autoSave(state) {\n  clearTimeout(_saveTimer)\n  _saveTimer = setTimeout(() => doAutoSave(state), 300)  \u002F\u002F 防抖 300ms\n}\n","javascript",[35,137,138,146,153,159,165,171],{"__ignoreMap":37},[139,140,143],"span",{"class":141,"line":142},"line",1,[139,144,145],{},"let _saveTimer = null\n",[139,147,149],{"class":141,"line":148},2,[139,150,152],{"emptyLinePlaceholder":151},true,"\n",[139,154,156],{"class":141,"line":155},3,[139,157,158],{},"function autoSave(state) {\n",[139,160,162],{"class":141,"line":161},4,[139,163,164],{},"  clearTimeout(_saveTimer)\n",[139,166,168],{"class":141,"line":167},5,[139,169,170],{},"  _saveTimer = setTimeout(() => doAutoSave(state), 300)  \u002F\u002F 防抖 300ms\n",[139,172,174],{"class":141,"line":173},6,[139,175,176],{},"}\n",[14,178,179,180,183],{},"每次字段变化都重置计时器，300ms 无新变化时才执行 ",[35,181,182],{},"doAutoSave","。这个设计本身无问题，问题在 cleanup：",[28,185,187],{"className":133,"code":186,"language":135,"meta":37,"style":37},"export function cleanup() {\n  clearTimeout(_saveTimer)                    \u002F\u002F ← 直接丢弃，无 flush\n  _saveTimer = null\n  \u002F\u002F ...\n}\n",[35,188,189,194,199,204,209],{"__ignoreMap":37},[139,190,191],{"class":141,"line":142},[139,192,193],{},"export function cleanup() {\n",[139,195,196],{"class":141,"line":148},[139,197,198],{},"  clearTimeout(_saveTimer)                    \u002F\u002F ← 直接丢弃，无 flush\n",[139,200,201],{"class":141,"line":155},[139,202,203],{},"  _saveTimer = null\n",[139,205,206],{"class":141,"line":161},[139,207,208],{},"  \u002F\u002F ...\n",[139,210,211],{"class":141,"line":167},[139,212,176],{},[14,214,215,216,219,220,223,224,227],{},"当用户导航离开本页面时，",[35,217,218],{},"cleanup()"," 被同步调用，计时器被 ",[35,221,222],{},"clearTimeout","。如果此时 pending 的 ",[35,225,226],{},"setTimeout"," 还没来得及触发（即用户改字段后不足 300ms），那个写盘操作就永远不会发生。",[14,229,230,127,233,236],{},[124,231,232],{},"路由切换时的调度",[35,234,235],{},"src\u002Frouter.js"," 第 49-52 行)：",[28,238,240],{"className":133,"code":239,"language":135,"meta":37,"style":37},"if (_currentCleanup) {\n  try { _currentCleanup() } catch (_) {}     \u002F\u002F 同步调用，无 await 语义\n  _currentCleanup = null\n}\n",[35,241,242,247,252,257],{"__ignoreMap":37},[139,243,244],{"class":141,"line":142},[139,245,246],{},"if (_currentCleanup) {\n",[139,248,249],{"class":141,"line":148},[139,250,251],{},"  try { _currentCleanup() } catch (_) {}     \u002F\u002F 同步调用，无 await 语义\n",[139,253,254],{"class":141,"line":155},[139,255,256],{},"  _currentCleanup = null\n",[139,258,259],{"class":141,"line":161},[139,260,176],{},[14,262,263,266],{},[35,264,265],{},"navigate()"," 本身是 async，但这里对 cleanup 的调用是同步的。cleanup 返回 Promise 也没人 await，pending 的写盘任务就被留在半空。",[14,268,269,127,272,275],{},[124,270,271],{},"Tauri 侧的应用关闭",[35,273,274],{},"src-tauri\u002Fsrc\u002Flib.rs"," 第 281-298 行)：",[277,278,279,290],"ul",{},[280,281,282,285,286,289],"li",{},[124,283,284],{},"便携版","：直接 ",[35,287,288],{},"std::process::exit(0)","，无任何 flush 通知前端",[280,291,292,295,296,299],{},[124,293,294],{},"标准版","：",[35,297,298],{},"window.hide()"," 隐藏到托盘，计时器理论上还在，但用户已认为应用关闭",[14,301,302,295],{},[124,303,304],{},"三种典型触发场景",[306,307,308,321],"table",{},[309,310,311],"thead",{},[312,313,314,318],"tr",{},[315,316,317],"th",{},"动作",[315,319,320],{},"结果",[322,323,324,333,341],"tbody",{},[312,325,326,330],{},[327,328,329],"td",{},"填完字段后 \u003C 300ms 切到其他页面",[327,331,332],{},"router cleanup → clearTimeout → setTimeout 永不执行",[312,334,335,338],{},[327,336,337],{},"填完字段后 \u003C 300ms 关闭面板（便携版）",[327,339,340],{},"进程立即退出，setTimeout 无机会运行",[312,342,343,346],{},[327,344,345],{},"填完字段后 \u003C 300ms 切其他模型 provider 再切回",[327,347,348],{},"同一计时器可能被新 state 覆盖",[14,350,351,295],{},[124,352,353],{},"UX 层的雪上加霜",[14,355,356,357,360],{},"页面无显式保存按钮、无\"已保存\"角标、无 dirty 指示、关窗不弹确认对话。用户填完字段看到 UI 响应，主观认为\"配置已生效\"。等到进程重启读 ",[35,358,359],{},"openclaw.json"," 才发现一切归零——这时已太晚。",[14,362,363,295],{},[124,364,365,366,368],{},"为什么 ",[35,367,42],{}," 的 mtime 停在初始化时刻",[14,370,371,372,375,376,378,379,381,382,384,385,388],{},"calibration baseline 的同步代码走的是 ",[35,373,374],{},"fs::write"," 直接写入路径，UI 阻塞，必然落盘成功。之后每次用户修改配置全走 ",[35,377,113],{}," 防抖。只要 300ms 内导航走，",[35,380,182],{}," 的调用链路就不会触发，",[35,383,58],{}," 从不被调用，",[35,386,387],{},"fs::copy(&path, &bak)"," 也不会执行。备份文件 mtime 永久停留在 5\u002F1 20:33:42，反向锁死了根因。",[14,390,391,295],{},[124,392,393],{},"扩散范围",[14,395,396,397,399,400,403],{},"grep 扫描所有页面的 ",[35,398,97],{}," 与 ",[35,401,402],{},"_saveTimer"," 模式，确认下列页面均受同一缺陷影响：",[277,405,406,411,417,423,429,435],{},[280,407,408,410],{},[35,409,101],{}," — 现场客户触发的主页面",[280,412,413,416],{},[35,414,415],{},"src\u002Fpages\u002Fgateway.js"," 第 296 行",[280,418,419,422],{},[35,420,421],{},"src\u002Fpages\u002Fcron.js"," 第 91 行",[280,424,425,428],{},[35,426,427],{},"src\u002Fpages\u002Fcommunication.js"," 第 71 行",[280,430,431,434],{},[35,432,433],{},"src\u002Fpages\u002Fservices.js"," 第 871 行",[280,436,437,440,441,444],{},[35,438,439],{},"src\u002Fpages\u002Fassistant.js"," — 多处 ",[35,442,443],{},"saveConfig()"," 调用",[14,446,447],{},"至少 5 个以上的配置页面都有相同的 300ms 防抖 + 无 flush cleanup 问题。",[10,449,450],{"id":450},"修复方案",[14,452,453,127,456,458],{},[124,454,455],{},"P0 — cleanup 改造成 async + flush 待命",[35,457,101],{}," 第 628 行)：",[14,460,461,462,465],{},"需要先把 ",[35,463,464],{},"state"," 提升到模块级变量，使 cleanup 能访问：",[28,467,469],{"className":133,"code":468,"language":135,"meta":37,"style":37},"let _lastState = null\nlet _saveTimer = null\n\nfunction autoSave(state) {\n  _lastState = state                                    \u002F\u002F 保存当前 state\n  clearTimeout(_saveTimer)\n  _saveTimer = setTimeout(() => doAutoSave(state), 300)\n}\n\nexport async function cleanup() {\n  if (_saveTimer) {\n    clearTimeout(_saveTimer)\n    _saveTimer = null\n    try {\n      await doAutoSave(_lastState)                      \u002F\u002F flush pending 写盘\n    } catch (e) {\n      console.error('[models] flush on cleanup failed:', e)\n    }\n  }\n  if (_batchTestAbort) { _batchTestAbort.abort = true; _batchTestAbort = null }\n  cancelPendingRestart()\n}\n",[35,470,471,476,480,484,488,493,497,503,508,513,519,525,531,537,543,549,555,561,567,573,579,585],{"__ignoreMap":37},[139,472,473],{"class":141,"line":142},[139,474,475],{},"let _lastState = null\n",[139,477,478],{"class":141,"line":148},[139,479,145],{},[139,481,482],{"class":141,"line":155},[139,483,152],{"emptyLinePlaceholder":151},[139,485,486],{"class":141,"line":161},[139,487,158],{},[139,489,490],{"class":141,"line":167},[139,491,492],{},"  _lastState = state                                    \u002F\u002F 保存当前 state\n",[139,494,495],{"class":141,"line":173},[139,496,164],{},[139,498,500],{"class":141,"line":499},7,[139,501,502],{},"  _saveTimer = setTimeout(() => doAutoSave(state), 300)\n",[139,504,506],{"class":141,"line":505},8,[139,507,176],{},[139,509,511],{"class":141,"line":510},9,[139,512,152],{"emptyLinePlaceholder":151},[139,514,516],{"class":141,"line":515},10,[139,517,518],{},"export async function cleanup() {\n",[139,520,522],{"class":141,"line":521},11,[139,523,524],{},"  if (_saveTimer) {\n",[139,526,528],{"class":141,"line":527},12,[139,529,530],{},"    clearTimeout(_saveTimer)\n",[139,532,534],{"class":141,"line":533},13,[139,535,536],{},"    _saveTimer = null\n",[139,538,540],{"class":141,"line":539},14,[139,541,542],{},"    try {\n",[139,544,546],{"class":141,"line":545},15,[139,547,548],{},"      await doAutoSave(_lastState)                      \u002F\u002F flush pending 写盘\n",[139,550,552],{"class":141,"line":551},16,[139,553,554],{},"    } catch (e) {\n",[139,556,558],{"class":141,"line":557},17,[139,559,560],{},"      console.error('[models] flush on cleanup failed:', e)\n",[139,562,564],{"class":141,"line":563},18,[139,565,566],{},"    }\n",[139,568,570],{"class":141,"line":569},19,[139,571,572],{},"  }\n",[139,574,576],{"class":141,"line":575},20,[139,577,578],{},"  if (_batchTestAbort) { _batchTestAbort.abort = true; _batchTestAbort = null }\n",[139,580,582],{"class":141,"line":581},21,[139,583,584],{},"  cancelPendingRestart()\n",[139,586,588],{"class":141,"line":587},22,[139,589,176],{},[14,591,592,593,106,596,106,599,106,602,106,605,608],{},"其他 5 个页面（",[35,594,595],{},"gateway.js",[35,597,598],{},"cron.js",[35,600,601],{},"communication.js",[35,603,604],{},"services.js",[35,606,607],{},"assistant.js","）同样改造。",[14,610,611,127,614,236],{},[124,612,613],{},"P0 — router 导航改成 await cleanup",[35,615,235],{},[28,617,619],{"className":133,"code":618,"language":135,"meta":37,"style":37},"if (_currentCleanup) {\n  try {\n    await _currentCleanup()                             \u002F\u002F await 而非直接调\n  } catch (_) {}\n  _currentCleanup = null\n}\n",[35,620,621,625,630,635,640,644],{"__ignoreMap":37},[139,622,623],{"class":141,"line":142},[139,624,246],{},[139,626,627],{"class":141,"line":148},[139,628,629],{},"  try {\n",[139,631,632],{"class":141,"line":155},[139,633,634],{},"    await _currentCleanup()                             \u002F\u002F await 而非直接调\n",[139,636,637],{"class":141,"line":161},[139,638,639],{},"  } catch (_) {}\n",[139,641,642],{"class":141,"line":167},[139,643,256],{},[139,645,646],{"class":141,"line":173},[139,647,176],{},[14,649,650,653],{},[35,651,652],{},"navigate"," 函数本身已是 async，加 await 不影响外部 API 的同步表现。",[14,655,656,127,659,275],{},[124,657,658],{},"P0 — Tauri CloseRequested 拦截 flush",[35,660,274],{},[28,662,666],{"className":663,"code":664,"language":665,"meta":37,"style":37},"language-rust shiki shiki-themes github-light github-dark","CloseRequested => {\n  match config.mode {\n    \"portable\" => {\n      window.emit(\"app:will-close\", ()).ok();          \u002F\u002F 通知前端开始 flush\n      \u002F\u002F 前端监听此事件，同步执行 doAutoSave，完成后调用 confirm_close\n      \u002F\u002F Rust 侧等待 confirm_close invoke 或 5 秒超时后 exit\n    }\n    \"standard\" => {\n      \u002F\u002F 同样 flush，再 hide 到托盘\n      window.emit(\"app:will-close\", ()).ok();\n      window.hide().ok();\n    }\n  }\n}\n","rust",[35,667,668,673,678,683,688,693,698,702,707,712,717,722,726,730],{"__ignoreMap":37},[139,669,670],{"class":141,"line":142},[139,671,672],{},"CloseRequested => {\n",[139,674,675],{"class":141,"line":148},[139,676,677],{},"  match config.mode {\n",[139,679,680],{"class":141,"line":155},[139,681,682],{},"    \"portable\" => {\n",[139,684,685],{"class":141,"line":161},[139,686,687],{},"      window.emit(\"app:will-close\", ()).ok();          \u002F\u002F 通知前端开始 flush\n",[139,689,690],{"class":141,"line":167},[139,691,692],{},"      \u002F\u002F 前端监听此事件，同步执行 doAutoSave，完成后调用 confirm_close\n",[139,694,695],{"class":141,"line":173},[139,696,697],{},"      \u002F\u002F Rust 侧等待 confirm_close invoke 或 5 秒超时后 exit\n",[139,699,700],{"class":141,"line":499},[139,701,566],{},[139,703,704],{"class":141,"line":505},[139,705,706],{},"    \"standard\" => {\n",[139,708,709],{"class":141,"line":510},[139,710,711],{},"      \u002F\u002F 同样 flush，再 hide 到托盘\n",[139,713,714],{"class":141,"line":515},[139,715,716],{},"      window.emit(\"app:will-close\", ()).ok();\n",[139,718,719],{"class":141,"line":521},[139,720,721],{},"      window.hide().ok();\n",[139,723,724],{"class":141,"line":527},[139,725,566],{},[139,727,728],{"class":141,"line":533},[139,729,572],{},[139,731,732],{"class":141,"line":539},[139,733,176],{},[14,735,736,737,740,741,744,745,748,749,114],{},"前端需要监听 ",[35,738,739],{},"app:will-close"," 事件，若有 pending autoSave 立即同步执行 ",[35,742,743],{},"doAutoSave()","，完成或超时后调用 ",[35,746,747],{},"invoke('confirm_close')","。Rust 侧收到确认或等待超时（5 秒兜底）后才执行 ",[35,750,751],{},"exit",[14,753,754,757],{},[124,755,756],{},"P1 — UI 反馈层面","（不紧急但重要）：",[277,759,760,769,772],{},[280,761,762,763,399,766,768],{},"模型页右上角加状态角标：\"已保存\" \u002F \"保存中\" \u002F \"未保存\"，来源于 ",[35,764,765],{},"_saveTimer != null",[35,767,182],{}," 的 Promise 状态",[280,770,771],{},"模型页加显式\"保存\"按钮（虽然 autoSave 已覆盖大多数场景，但用户心理预期需要这个 button）",[280,773,774,776],{},[35,775,182],{}," 写盘成功立即吐 toast \"配置已保存\"（无需等 gateway 重启），gateway 重启后再吐 \"配置已应用\"",[14,778,779,295],{},[124,780,781],{},"P2 — 全局 beforeunload 兜底",[28,783,785],{"className":133,"code":784,"language":135,"meta":37,"style":37},"window.addEventListener('beforeunload', () => {\n  if (_saveTimer) doAutoSave(_lastState)                \u002F\u002F 同步 flush\n})\n",[35,786,787,792,797],{"__ignoreMap":37},[139,788,789],{"class":141,"line":142},[139,790,791],{},"window.addEventListener('beforeunload', () => {\n",[139,793,794],{"class":141,"line":148},[139,795,796],{},"  if (_saveTimer) doAutoSave(_lastState)                \u002F\u002F 同步 flush\n",[139,798,799],{"class":141,"line":155},[139,800,801],{},"})\n",[14,803,804],{},"浏览器\u002FWebView 异常关闭或系统强制杀进程时的最后防线。",[10,806,807],{"id":807},"复现与缓解",[14,809,810],{},"修复发布前，客户想保护数据可以这样做：",[812,813,814,817],"ol",{},[280,815,816],{},"改完字段后等待 1 秒，看右下角是否吐出 toast",[280,818,819],{},"或填完后点页面空白处，再等 1 秒再切走\u002F关闭",[14,821,822],{},"便携版用户一个额外细节：即便这个 bug 修好了，关闭 panel 后仍需等 2 分钟让 guardian 完成到 USB 的反向同步，才能安心重启。",[10,824,825],{"id":825},"防回归措施",[14,827,828],{},"当前素材里尚无已实施的防回归。需要的包括：",[277,830,831,834,837,840],{},[280,832,833],{},"单元测试：快速导航场景，验证 cleanup 能 flush 待命任务",[280,835,836],{},"E2E 测试：填字段立即切页\u002F关窗，确保落盘",[280,838,839],{},"构建检查：扫描所有 cleanup，确保返回 Promise 且被正确 await",[280,841,842],{},"CI 巡检：定期测试便携版快速改配置并重启",[14,844,845,846,849],{},"修复前复现（任意模式）：启动 panel → 进模型页 → 添加 provider → 填 baseURL + apiKey + 选模型 → ",[124,847,848],{},"最后一字段后 100ms 内立即切侧边栏到\"对话\""," → 关闭面板 → 重启。修复前模型列表空，修复后保留。",[10,851,852],{"id":852},"关联问题",[14,854,855,856,859],{},"素材中记录了另一个独立根因：便携启动序列 ",[35,857,858],{},"Sync-UsbToLocal"," 时把 cache 新版覆盖回 USB 旧版。那个 bug（FA-003）的症状也是\"重启配置消失\"，但根因完全不同——启动脚本的同步顺序。",[14,861,862],{},"本 bug（FA-004）也导致配置消失，但根因是前端写盘没被触发。两个独立问题，在便携模式下症状叠加：修好这个 bug 的 P0（flush 成功）后，用户还需等 guardian 完成 120 秒间隔的反向同步到 USB，否则 2 分钟内重启 panel 仍可能被 USB 旧版本回滚。便携版用户两个修复都需要。",[10,864,865],{"id":865},"深层教训",[14,867,868],{},"静默失败比报错更危险——用户会继续信任 UI，不知道磁盘没落盘。防抖保存必须满足三个条件：cleanup 必须 flush（不能丢待命任务）、应用关闭必须被拦截并等待 flush、保存状态必须对用户可见。缺一，就出现\"一切看起来都对，但数据其实没保存\"的局面。",[870,871,872],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":37,"searchDepth":148,"depth":148,"links":874},[875,876,877,878,879,880,881,882,883],{"id":12,"depth":148,"text":12},{"id":22,"depth":148,"text":23},{"id":62,"depth":148,"text":62},{"id":120,"depth":148,"text":120},{"id":450,"depth":148,"text":450},{"id":807,"depth":148,"text":807},{"id":825,"depth":148,"text":825},{"id":852,"depth":148,"text":852},{"id":865,"depth":148,"text":865},"故障档案","2026-05-04","md",null,{},"\u002F2026-05-04-fa-004-300ms",{"title":5,"description":37},"FA-004","2026-05-04-FA-004-300ms防抖切页丢配置","防抖保存依赖 300ms 计时器，cleanup 直接丢弃 pending 任务，导致页面切换或应用关闭时配置静默丢失。",[895,896,897,898,899,900],"JavaScript","防抖","状态管理","前端架构","数据持久化","Tauri","Q78CGs3bePtx-vqY_kiWp1q055NpVRSu-zdTH2zX_bM",[903,1056,1964],{"id":904,"title":905,"body":906,"column":884,"date":1042,"description":910,"extension":886,"hero_image":887,"meta":1043,"navigation":151,"path":1044,"seo":1045,"series_id":887,"severity":887,"stem":1046,"summary":1047,"tags":1048,"__hash__":1055},"posts\u002F2026-09-10-登录失效不能只显示空白页.md","登录失效不能只显示空白页",{"type":7,"value":907,"toc":1035},[908,911,914,917,921,924,927,930,942,946,953,968,971,974,978,981,984,987,990,994,997,1008,1011,1015,1018,1021,1032],[14,909,910],{},"用户看到的是空白、卡住或一直加载，系统里却可能已经有一个明确的信号：接口返回了 401。",[14,912,913],{},"最近一次修复针对的就是这种错位。登录令牌过期后，前端仍只检查本地存储里有没有令牌字符串，不检查令牌是否还有效。页面因此保持“已登录”的外观，但余额停在同步中，会话列表没有内容，用户也收不到提示。",[14,915,916],{},"这不是一个单独页面的 Bug，而是登录状态判断与接口真实状态脱节。",[10,918,920],{"id":919},"现象页面还在业务已经失效","现象：页面还在，业务已经失效",[14,922,923],{},"问题出现时，用户仍然可以看到应用外壳。真正需要数据的请求陆续返回 401，页面组件各自处理失败结果，有的停在 loading，有的显示空列表，有的没有任何反馈。",[14,925,926],{},"这种状态比直接跳转登录页更难排查，因为页面没有崩溃，浏览器也没有明显报错。用户的描述通常是“突然不能用了”。",[14,928,929],{},"根因有两个：",[812,931,932,939],{},[280,933,934,935,938],{},"本地 ",[35,936,937],{},"yc_token"," 只作为“字符串是否存在”的标记；",[280,940,941],{},"应用内的请求分散在几十个文件，没有统一的 401 出口。",[10,943,945],{"id":944},"修复在请求边界识别失效","修复：在请求边界识别失效",[14,947,948,949,952],{},"这次修改没有要求逐个页面补判断，而是在应用入口包一层 ",[35,950,951],{},"window.fetch","，把判定条件收紧到三点：",[277,954,955,962,965],{},[280,956,957,958,961],{},"请求是同源 ",[35,959,960],{},"\u002Fapi\u002F"," 路径；",[280,963,964],{},"请求不在登录、注册和票据登录等合法返回 401 的白名单里；",[280,966,967],{},"本地确实存在登录令牌。",[14,969,970],{},"满足条件后，才把 401 解释为登录失效，并触发统一提示。外部签名 URL 不会被误判，流式响应也不读取 body，只观察 HTTP status，避免影响聊天和视频编导流程。",[14,972,973],{},"这个边界很重要。全局拦截器如果只看状态码，容易把登录接口本身的“凭据错误”当成“会话过期”；如果不区分同源请求，也可能误伤对象存储或其他外部服务。",[10,975,977],{"id":976},"交互先保住用户正在做的事","交互：先保住用户正在做的事",[14,979,980],{},"修复没有直接清空登录态，而是分两层提示。",[14,982,983],{},"第一层是弹窗，告诉用户会话已失效，并提供重新登录入口。用户可以选择稍后处理，先复制还没有保存的内容。",[14,985,986],{},"第二层是顶部常驻提示条。用户关掉弹窗后，页面仍保留可见的恢复入口，避免回到“没有提示的死状态”。",[14,988,989],{},"多标签页也是一个边界：如果另一个标签页已经完成续期，当前页面重新检查后应接管新的有效令牌，而不是把它删掉。否则用户在一个标签页登录，另一个标签页却把新令牌清除，问题会从单页失效变成跨标签页互相干扰。",[10,991,993],{"id":992},"测试验证状态转换而不是只测组件渲染","测试：验证状态转换，而不是只测组件渲染",[14,995,996],{},"这次变更补了三类测试：",[277,998,999,1002,1005],{},[280,1000,1001],{},"失效令牌触发弹窗和常驻提示；",[280,1003,1004],{},"登录、注册等白名单接口的 401 不触发全局失效；",[280,1006,1007],{},"多标签页已有新令牌时，当前页面能复用有效状态。",[14,1009,1010],{},"测试重点不是“按钮是否出现”，而是确认请求状态、登录状态和用户反馈之间的转换。只有把这三者放在一起验证，才能避免页面看似正常、用户却无法继续工作的情况。",[10,1012,1014],{"id":1013},"结论鉴权错误也是产品状态","结论：鉴权错误也是产品状态",[14,1016,1017],{},"401 不是只留给开发者看的网络状态。对用户来说，它意味着“当前工作无法继续”，必须有明确的恢复路径。",[14,1019,1020],{},"前端鉴权设计至少要回答三个问题：",[812,1022,1023,1026,1029],{},[280,1024,1025],{},"哪些 401 表示凭据错误，哪些表示会话过期；",[280,1027,1028],{},"失效时用户正在编辑的内容如何保留；",[280,1030,1031],{},"多标签页和重试发生时，哪个令牌拥有更高的有效性。",[14,1033,1034],{},"把这三个问题写进代码和测试，登录失效就不再是一张空白页，而是一个可理解、可恢复的产品状态。",{"title":37,"searchDepth":148,"depth":148,"links":1036},[1037,1038,1039,1040,1041],{"id":919,"depth":148,"text":920},{"id":944,"depth":148,"text":945},{"id":976,"depth":148,"text":977},{"id":992,"depth":148,"text":993},{"id":1013,"depth":148,"text":1014},"2026-09-10",{},"\u002F2026-09-10",{"title":905,"description":910},"2026-09-10-登录失效不能只显示空白页","一次登录令牌过期问题的修复：从分散的接口错误，收口到同源 API 的统一失效提示，同时保留未保存内容和多标签页续期场景。",[1049,1050,1051,1052,1053,1054],"前端","鉴权",401,"fetch","用户体验","测试","mDBl0HrrKMlN2mL_SZGdBwS6YpvL96QS8WWVfThP2c4",{"id":1057,"title":1058,"body":1059,"column":884,"date":1949,"description":1063,"extension":886,"hero_image":887,"meta":1950,"navigation":151,"path":1951,"seo":1952,"series_id":1953,"severity":887,"stem":1954,"summary":1955,"tags":1956,"__hash__":1963},"posts\u002F2026-08-31-FA-024-同一批活儿扣了两遍钱.md","同一批活儿，扣了两遍钱",{"type":7,"value":1060,"toc":1941},[1061,1064,1075,1078,1082,1085,1161,1168,1175,1178,1181,1239,1246,1249,1343,1350,1354,1357,1364,1441,1455,1462,1465,1468,1488,1491,1495,1498,1501,1504,1507,1522,1529,1533,1536,1539,1600,1611,1614,1688,1691,1757,1760,1767,1770,1798,1801,1805,1808,1811,1849,1860,1863,1874,1877,1883,1887,1890,1905,1908,1911,1917,1931,1938],[14,1062,1063],{},"用户点「批量生视频」，10 张已经出好的分镜图被整套重跑，再扣一遍钱。",[14,1065,1066,1067,1070,1071,1074],{},"生产数据核实过：两次运行的 ",[35,1068,1069],{},"image.generate"," 都是 ",[35,1072,1073],{},"charged","。不是显示问题，是真的扣了两次。",[14,1076,1077],{},"修的过程里翻出两处独立成因，以及一条让它们同时隐身的原因。",[10,1079,1081],{"id":1080},"一祖先闭包不该当目标传","一、祖先闭包不该当目标传",[14,1083,1084],{},"画布上有一个「运行整组」的入口。前端传目标节点给后端：",[28,1086,1090],{"className":1087,"code":1088,"language":1089,"meta":37,"style":37},"language-tsx shiki shiki-themes github-light github-dark","onRunGroup={\n  onRunTargets && runState && !['estimating', 'submitted', 'running'].includes(runState)\n    ? (groupId) => {\n        const targetNodes = ancestorClosure(group.nodeIds, flow.edges);\n        onRunTargets(targetNodes);\n      }\n    : undefined\n}\n","tsx",[35,1091,1092,1105,1128,1133,1138,1143,1148,1157],{"__ignoreMap":37},[139,1093,1094,1098,1102],{"class":141,"line":142},[139,1095,1097],{"class":1096},"sVt8B","onRunGroup",[139,1099,1101],{"class":1100},"szBVR","=",[139,1103,1104],{"class":1096},"{\n",[139,1106,1107,1110,1114,1117,1120,1122,1125],{"class":141,"line":148},[139,1108,1109],{"class":1096},"  onRunTargets && runState && ![",[139,1111,1113],{"class":1112},"sZZnC","'estimating'",[139,1115,1116],{"class":1096},", ",[139,1118,1119],{"class":1112},"'submitted'",[139,1121,1116],{"class":1096},[139,1123,1124],{"class":1112},"'running'",[139,1126,1127],{"class":1096},"].includes(runState)\n",[139,1129,1130],{"class":141,"line":155},[139,1131,1132],{"class":1096},"    ? (groupId) => {\n",[139,1134,1135],{"class":141,"line":161},[139,1136,1137],{"class":1096},"        const targetNodes = ancestorClosure(group.nodeIds, flow.edges);\n",[139,1139,1140],{"class":141,"line":167},[139,1141,1142],{"class":1096},"        onRunTargets(targetNodes);\n",[139,1144,1145],{"class":141,"line":173},[139,1146,1147],{"class":1096},"      }\n",[139,1149,1150,1153],{"class":141,"line":499},[139,1151,1152],{"class":1096},"    : ",[139,1154,1156],{"class":1155},"sj4cs","undefined\n",[139,1158,1159],{"class":141,"line":505},[139,1160,176],{"class":1096},[14,1162,1163,1164,1167],{},"它把组内节点连同",[124,1165,1166],{},"祖先闭包","一起传了过去。",[14,1169,1170,1171,1174],{},"后端的复用逻辑是这样的：",[35,1172,1173],{},"reuseCandidates = runSet − targetSet","。已经在目标集合里的节点不会被复用，要重新执行。祖先一旦也算目标，复用候选就成了空集——本来该被复用的上游，全部重跑。",[14,1176,1177],{},"而那 10 张分镜图正是祖先。",[14,1179,1180],{},"修法是把这一步交回给后端：",[28,1182,1184],{"className":1087,"code":1183,"language":1089,"meta":37,"style":37},"? () => {\n    \u002F\u002F 只传组内节点当目标，**绝不能把祖先闭包当目标传**：\n    \u002F\u002F 后端 reuseCandidates = runSet − targetSet，祖先若也算目标，\n    \u002F\u002F 复用候选就成了空集，已出图的上游会被整套重跑并再次扣费\n    \u002F\u002F （线上真实事故：点「批量生视频」把 10 张分镜图重扣了一遍）。\n    \u002F\u002F 祖先闭包由后端 pruneFlowForTargets 自己算。\n    onRunTargets(group.nodeIds);\n  }\n",[35,1185,1186,1200,1206,1211,1216,1221,1226,1235],{"__ignoreMap":37},[139,1187,1188,1191,1194,1197],{"class":141,"line":142},[139,1189,1190],{"class":1100},"?",[139,1192,1193],{"class":1096}," () ",[139,1195,1196],{"class":1100},"=>",[139,1198,1199],{"class":1096}," {\n",[139,1201,1202],{"class":141,"line":148},[139,1203,1205],{"class":1204},"sJ8bj","    \u002F\u002F 只传组内节点当目标，**绝不能把祖先闭包当目标传**：\n",[139,1207,1208],{"class":141,"line":155},[139,1209,1210],{"class":1204},"    \u002F\u002F 后端 reuseCandidates = runSet − targetSet，祖先若也算目标，\n",[139,1212,1213],{"class":141,"line":161},[139,1214,1215],{"class":1204},"    \u002F\u002F 复用候选就成了空集，已出图的上游会被整套重跑并再次扣费\n",[139,1217,1218],{"class":141,"line":167},[139,1219,1220],{"class":1204},"    \u002F\u002F （线上真实事故：点「批量生视频」把 10 张分镜图重扣了一遍）。\n",[139,1222,1223],{"class":141,"line":173},[139,1224,1225],{"class":1204},"    \u002F\u002F 祖先闭包由后端 pruneFlowForTargets 自己算。\n",[139,1227,1228,1232],{"class":141,"line":499},[139,1229,1231],{"class":1230},"sScJk","    onRunTargets",[139,1233,1234],{"class":1096},"(group.nodeIds);\n",[139,1236,1237],{"class":141,"line":505},[139,1238,572],{"class":1096},[14,1240,1241,1242,1245],{},"祖先闭包本来就由后端的 ",[35,1243,1244],{},"pruneFlowForTargets"," 算。前端多算一遍，算的不只是重复劳动，而是一个语义不同的集合：后端要的是「用户想跑的」，前端给的是「跑这个需要的」。",[14,1247,1248],{},"补的守卫测试很直白——组件层的回调只能接收一个组 ID：",[28,1250,1252],{"className":1087,"code":1251,"language":1089,"meta":37,"style":37},"it(\"运行整组时只把组内节点当目标，不能传祖先闭包（线上重复扣费事故）\", () => {\n  fireEvent.click(screen.getByRole(\"button\", { name: \"整组执行\" }));\n  expect(onRunGroup).toHaveBeenCalledWith(mockGroup.id);\n  expect(onRunGroup.mock.calls[0]).toHaveLength(1);\n});\n",[35,1253,1254,1272,1300,1314,1338],{"__ignoreMap":37},[139,1255,1256,1259,1262,1265,1268,1270],{"class":141,"line":142},[139,1257,1258],{"class":1230},"it",[139,1260,1261],{"class":1096},"(",[139,1263,1264],{"class":1112},"\"运行整组时只把组内节点当目标，不能传祖先闭包（线上重复扣费事故）\"",[139,1266,1267],{"class":1096},", () ",[139,1269,1196],{"class":1100},[139,1271,1199],{"class":1096},[139,1273,1274,1277,1280,1283,1286,1288,1291,1294,1297],{"class":141,"line":148},[139,1275,1276],{"class":1096},"  fireEvent.",[139,1278,1279],{"class":1230},"click",[139,1281,1282],{"class":1096},"(screen.",[139,1284,1285],{"class":1230},"getByRole",[139,1287,1261],{"class":1096},[139,1289,1290],{"class":1112},"\"button\"",[139,1292,1293],{"class":1096},", { name: ",[139,1295,1296],{"class":1112},"\"整组执行\"",[139,1298,1299],{"class":1096}," }));\n",[139,1301,1302,1305,1308,1311],{"class":141,"line":155},[139,1303,1304],{"class":1230},"  expect",[139,1306,1307],{"class":1096},"(onRunGroup).",[139,1309,1310],{"class":1230},"toHaveBeenCalledWith",[139,1312,1313],{"class":1096},"(mockGroup.id);\n",[139,1315,1316,1318,1321,1324,1327,1330,1332,1335],{"class":141,"line":161},[139,1317,1304],{"class":1230},[139,1319,1320],{"class":1096},"(onRunGroup.mock.calls[",[139,1322,1323],{"class":1155},"0",[139,1325,1326],{"class":1096},"]).",[139,1328,1329],{"class":1230},"toHaveLength",[139,1331,1261],{"class":1096},[139,1333,1334],{"class":1155},"1",[139,1336,1337],{"class":1096},");\n",[139,1339,1340],{"class":141,"line":167},[139,1341,1342],{"class":1096},"});\n",[14,1344,1345,1346,1349],{},"同一处 ",[35,1347,1348],{},"ancestorClosure"," 在运行面板里保留着，那里是用它显示「运行 N 个（复用 M 个）」的，不参与提交。",[10,1351,1353],{"id":1352},"二落库的行对不上","二、落库的行对不上",[14,1355,1356],{},"第二处成因更早，也更深。",[14,1358,1359,1360,1363],{},"画布运行会把每个节点展开成若干行写入 ",[35,1361,1362],{},"CanvasFlowNodeRun","。批量框要求一个节点跑 N 份，所以展开后的行需要一个复合标识：",[28,1365,1369],{"className":1366,"code":1367,"language":1368,"meta":37,"style":37},"language-ts shiki shiki-themes github-light github-dark","const nodeRuns = expandedNodes.map((node) => ({\n  id: generateNodeRunId(),\n  runId,\n  nodeId: node.nodeId,   \u002F\u002F 展开后的复合 id\n  batchIndex: node.batchIndex,\n  \u002F\u002F ...\n}))\n","ts",[35,1370,1371,1403,1414,1419,1427,1432,1436],{"__ignoreMap":37},[139,1372,1373,1376,1379,1382,1385,1388,1391,1395,1398,1400],{"class":141,"line":142},[139,1374,1375],{"class":1100},"const",[139,1377,1378],{"class":1155}," nodeRuns",[139,1380,1381],{"class":1100}," =",[139,1383,1384],{"class":1096}," expandedNodes.",[139,1386,1387],{"class":1230},"map",[139,1389,1390],{"class":1096},"((",[139,1392,1394],{"class":1393},"s4XuR","node",[139,1396,1397],{"class":1096},") ",[139,1399,1196],{"class":1100},[139,1401,1402],{"class":1096}," ({\n",[139,1404,1405,1408,1411],{"class":141,"line":148},[139,1406,1407],{"class":1096},"  id: ",[139,1409,1410],{"class":1230},"generateNodeRunId",[139,1412,1413],{"class":1096},"(),\n",[139,1415,1416],{"class":141,"line":155},[139,1417,1418],{"class":1096},"  runId,\n",[139,1420,1421,1424],{"class":141,"line":161},[139,1422,1423],{"class":1096},"  nodeId: node.nodeId,   ",[139,1425,1426],{"class":1204},"\u002F\u002F 展开后的复合 id\n",[139,1428,1429],{"class":141,"line":167},[139,1430,1431],{"class":1096},"  batchIndex: node.batchIndex,\n",[139,1433,1434],{"class":141,"line":173},[139,1435,208],{"class":1204},[139,1437,1438],{"class":141,"line":499},[139,1439,1440],{"class":1096},"}))\n",[14,1442,1443,1444,1447,1448,1451,1452,114],{},"问题在这张表的唯一约束是 ",[35,1445,1446],{},"@@unique([runId, nodeId, batchIndex])","，而 ",[35,1449,1450],{},"nodeId"," 落的是",[124,1453,1454],{},"展开后的复合 id",[14,1456,1457,1458,1461],{},"于是 executor 那一侧全线对不上。它按 ",[35,1459,1460],{},"nodeId + batchIndex"," 反查行、统计每份的项数、构建调度状态——落库的键和它查的键不是一套。",[14,1463,1464],{},"调度器找不到已派发的记录，于是重复派发同一个批量单元。周期的量级是 100 毫秒。",[14,1466,1467],{},"修法是把复合 id 留在内存里，落库只落原始 ID：",[28,1469,1471],{"className":1366,"code":1470,"language":1368,"meta":37,"style":37},"\u002F\u002F 行必须以「原始 nodeId + batchIndex」落库（对齐 @@unique([runId, nodeId, batchIndex])）。\n\u002F\u002F 展开后的复合 id 只活在调度器内存里：落了复合 id，executor 的\n\u002F\u002F itemCountsFromRows\u002FbuildSchedulerState 就全都对不上行。\n",[35,1472,1473,1478,1483],{"__ignoreMap":37},[139,1474,1475],{"class":141,"line":142},[139,1476,1477],{"class":1204},"\u002F\u002F 行必须以「原始 nodeId + batchIndex」落库（对齐 @@unique([runId, nodeId, batchIndex])）。\n",[139,1479,1480],{"class":141,"line":148},[139,1481,1482],{"class":1204},"\u002F\u002F 展开后的复合 id 只活在调度器内存里：落了复合 id，executor 的\n",[139,1484,1485],{"class":141,"line":155},[139,1486,1487],{"class":1204},"\u002F\u002F itemCountsFromRows\u002FbuildSchedulerState 就全都对不上行。\n",[14,1489,1490],{},"配套改了 executor 三处状态写库的定位方式，并在 ready 循环里加了一层 in-flight 防重派兜底，防止同类问题再犯。",[10,1492,1494],{"id":1493},"三为什么单测全绿","三、为什么单测全绿",[14,1496,1497],{},"这一处的成因能藏住，是因为测试的写法。",[14,1499,1500],{},"批量链路的每个环节都有自己的单测：创建函数有自己的用例，executor 也有。两组用例各自手写 fixture——创建函数的用例断言它写出了正确的行，executor 的用例喂给它一组正确的行，断言它正确调度。两组都绿。",[14,1502,1503],{},"错的正是「创建函数写出的行」和「executor 期望的行」之间的那个接口。",[14,1505,1506],{},"修的时候补了一个贯通测试环境，理由写在文件头：",[28,1508,1510],{"className":1366,"code":1509,"language":1368,"meta":37,"style":37},"存在的意义是让「写读贯通」测试成为可能——行由真实的创建函数产生、由真实的 executor 消费，\n中间不允许手写 fixture（批量行约定矛盾就是靠各自手写 fixture 的单测互相全绿才漏网的）。\n",[35,1511,1512,1517],{"__ignoreMap":37},[139,1513,1514],{"class":141,"line":142},[139,1515,1516],{"class":1096},"存在的意义是让「写读贯通」测试成为可能——行由真实的创建函数产生、由真实的 executor 消费，\n",[139,1518,1519],{"class":141,"line":148},[139,1520,1521],{"class":1096},"中间不允许手写 fixture（批量行约定矛盾就是靠各自手写 fixture 的单测互相全绿才漏网的）。\n",[14,1523,1524,1525,1528],{},"新的契约测试从创建一路跑到执行，中间不插桩。断言的是端到端的账：",[35,1526,1527],{},"itemCount=3"," 时，成员 3 行、每份恰好执行一次。",[10,1530,1532],{"id":1531},"四项数从哪来","四、项数从哪来",[14,1534,1535],{},"修完上面两处，还有一个数对不上：预估和实扣。",[14,1537,1538],{},"批量框跑几份，原先是从上游推断的：",[28,1540,1542],{"className":1366,"code":1541,"language":1368,"meta":37,"style":37},"if (upstreamNode.nodeDefId === 'material.input') {\n  const count = (upstreamNode.params as any)?.count ?? 1;\n  \u002F\u002F ...\n}\n",[35,1543,1544,1561,1592,1596],{"__ignoreMap":37},[139,1545,1546,1549,1552,1555,1558],{"class":141,"line":142},[139,1547,1548],{"class":1100},"if",[139,1550,1551],{"class":1096}," (upstreamNode.nodeDefId ",[139,1553,1554],{"class":1100},"===",[139,1556,1557],{"class":1112}," 'material.input'",[139,1559,1560],{"class":1096},") {\n",[139,1562,1563,1566,1569,1571,1574,1577,1580,1583,1586,1589],{"class":141,"line":148},[139,1564,1565],{"class":1100},"  const",[139,1567,1568],{"class":1155}," count",[139,1570,1381],{"class":1100},[139,1572,1573],{"class":1096}," (upstreamNode.params ",[139,1575,1576],{"class":1100},"as",[139,1578,1579],{"class":1155}," any",[139,1581,1582],{"class":1096},")?.count ",[139,1584,1585],{"class":1100},"??",[139,1587,1588],{"class":1155}," 1",[139,1590,1591],{"class":1096},";\n",[139,1593,1594],{"class":141,"line":155},[139,1595,208],{"class":1204},[139,1597,1598],{"class":141,"line":161},[139,1599,176],{"class":1096},[14,1601,1602,1603,1606,1607,1610],{},"注册表里从来没有 ",[35,1604,1605],{},"material.input"," 这个节点——真名是 ",[35,1608,1609],{},"asset.input","。这个分支从未命中过，项数永远回落 1。",[14,1612,1613],{},"改成框上显式配置：",[28,1615,1617],{"className":1366,"code":1616,"language":1368,"meta":37,"style":37},"\u002F**\n * 框内子图跑几份（1~50，缺省 1）。配在框上、由用户手动设置——\n * 「按上游 list 项数自动展开」的推断从未走通过（框架节点没注册），\n * 手动份数是当前唯一的项数来源。\n *\u002F\nitemCount: z.number().int().min(1).max(50).optional(),\n",[35,1618,1619,1624,1629,1634,1639,1644],{"__ignoreMap":37},[139,1620,1621],{"class":141,"line":142},[139,1622,1623],{"class":1204},"\u002F**\n",[139,1625,1626],{"class":141,"line":148},[139,1627,1628],{"class":1204}," * 框内子图跑几份（1~50，缺省 1）。配在框上、由用户手动设置——\n",[139,1630,1631],{"class":141,"line":155},[139,1632,1633],{"class":1204}," * 「按上游 list 项数自动展开」的推断从未走通过（框架节点没注册），\n",[139,1635,1636],{"class":141,"line":161},[139,1637,1638],{"class":1204}," * 手动份数是当前唯一的项数来源。\n",[139,1640,1641],{"class":141,"line":167},[139,1642,1643],{"class":1204}," *\u002F\n",[139,1645,1646,1649,1652,1655,1658,1661,1663,1666,1668,1670,1673,1676,1678,1681,1683,1686],{"class":141,"line":173},[139,1647,1648],{"class":1230},"itemCount",[139,1650,1651],{"class":1096},": z.",[139,1653,1654],{"class":1230},"number",[139,1656,1657],{"class":1096},"().",[139,1659,1660],{"class":1230},"int",[139,1662,1657],{"class":1096},[139,1664,1665],{"class":1230},"min",[139,1667,1261],{"class":1096},[139,1669,1334],{"class":1155},[139,1671,1672],{"class":1096},").",[139,1674,1675],{"class":1230},"max",[139,1677,1261],{"class":1096},[139,1679,1680],{"class":1155},"50",[139,1682,1672],{"class":1096},[139,1684,1685],{"class":1230},"optional",[139,1687,1413],{"class":1096},[14,1689,1690],{},"同时把预估也切到同一个来源：",[28,1692,1694],{"className":1366,"code":1693,"language":1368,"meta":37,"style":37},"\u002F**\n * 批量倍数：与执行侧同源——run-create 落行的份数就是框上的 itemCount，\n * 预估必须用同一个数，否则「预计 1 份、实扣 3 份」。\n *\u002F\nexport function batchMultipliersFromFlow(flow: CanvasFlow): Record\u003Cstring, number> {\n",[35,1695,1696,1700,1705,1710,1714],{"__ignoreMap":37},[139,1697,1698],{"class":141,"line":142},[139,1699,1623],{"class":1204},[139,1701,1702],{"class":141,"line":148},[139,1703,1704],{"class":1204}," * 批量倍数：与执行侧同源——run-create 落行的份数就是框上的 itemCount，\n",[139,1706,1707],{"class":141,"line":155},[139,1708,1709],{"class":1204}," * 预估必须用同一个数，否则「预计 1 份、实扣 3 份」。\n",[139,1711,1712],{"class":141,"line":161},[139,1713,1643],{"class":1204},[139,1715,1716,1719,1722,1725,1727,1730,1733,1736,1739,1741,1744,1747,1750,1752,1754],{"class":141,"line":167},[139,1717,1718],{"class":1100},"export",[139,1720,1721],{"class":1100}," function",[139,1723,1724],{"class":1230}," batchMultipliersFromFlow",[139,1726,1261],{"class":1096},[139,1728,1729],{"class":1393},"flow",[139,1731,1732],{"class":1100},":",[139,1734,1735],{"class":1230}," CanvasFlow",[139,1737,1738],{"class":1096},")",[139,1740,1732],{"class":1100},[139,1742,1743],{"class":1230}," Record",[139,1745,1746],{"class":1096},"\u003C",[139,1748,1749],{"class":1155},"string",[139,1751,1116],{"class":1096},[139,1753,1654],{"class":1155},[139,1755,1756],{"class":1096},"> {\n",[14,1758,1759],{},"创建、预估、重试三个路由统一用这个函数。",[14,1761,1762,1763,1766],{},"同一轮里还修了预估的一个老问题：视频节点的预估用的是后台的一口价资源键 ",[35,1764,1765],{},"canvas_video_generate","，而执行侧用的是「模型 + 分辨率 + 是否带上游视频」算出的键，按秒计价。两套键，两套价——预估和实扣自然对不上。预估改用执行同款键。",[14,1768,1769],{},"以及一处显眼的占位：",[28,1771,1773],{"className":1366,"code":1772,"language":1368,"meta":37,"style":37},"\u002F\u002F 改前\nconst totalEstimatedCost = 0; \u002F\u002F TODO: Pass in actual estimate\n",[35,1774,1775,1780],{"__ignoreMap":37},[139,1776,1777],{"class":141,"line":142},[139,1778,1779],{"class":1204},"\u002F\u002F 改前\n",[139,1781,1782,1784,1787,1789,1792,1795],{"class":141,"line":148},[139,1783,1375],{"class":1100},[139,1785,1786],{"class":1155}," totalEstimatedCost",[139,1788,1381],{"class":1100},[139,1790,1791],{"class":1155}," 0",[139,1793,1794],{"class":1096},"; ",[139,1796,1797],{"class":1204},"\u002F\u002F TODO: Pass in actual estimate\n",[14,1799,1800],{},"运行记录里的预估成本一直是 0。",[10,1802,1804],{"id":1803},"五续跑为什么不重复扣费","五、续跑为什么不重复扣费",[14,1806,1807],{},"同一轮加了单节点重试。既然重复扣费是这个月的主线，这条功能的实现方式值得记下来。",[14,1809,1810],{},"失败运行的续跑不是「重新跑一遍」：",[28,1812,1814],{"className":1366,"code":1813,"language":1368,"meta":37,"style":37},"\u002F**\n * 单节点重试：给失败\u002F被取消的运行造一个「续跑」运行。成功节点的行原样回填\n * （产物、billingRef、时间戳都保留）——executor 的调度器见到 succeeded 行\n * 会直接当作上游已就绪，不会重新执行，也就不会重复扣费；其余节点\n * （failed \u002F cancelled \u002F pending）重置成全新的 pending 行，正常调度重跑。\n * 不修改原运行：重试是一条新的 CanvasFlowRun，历史记录保持完整。\n *\u002F\n",[35,1815,1816,1820,1825,1830,1835,1840,1845],{"__ignoreMap":37},[139,1817,1818],{"class":141,"line":142},[139,1819,1623],{"class":1204},[139,1821,1822],{"class":141,"line":148},[139,1823,1824],{"class":1204}," * 单节点重试：给失败\u002F被取消的运行造一个「续跑」运行。成功节点的行原样回填\n",[139,1826,1827],{"class":141,"line":155},[139,1828,1829],{"class":1204}," * （产物、billingRef、时间戳都保留）——executor 的调度器见到 succeeded 行\n",[139,1831,1832],{"class":141,"line":161},[139,1833,1834],{"class":1204}," * 会直接当作上游已就绪，不会重新执行，也就不会重复扣费；其余节点\n",[139,1836,1837],{"class":141,"line":167},[139,1838,1839],{"class":1204}," * （failed \u002F cancelled \u002F pending）重置成全新的 pending 行，正常调度重跑。\n",[139,1841,1842],{"class":141,"line":173},[139,1843,1844],{"class":1204}," * 不修改原运行：重试是一条新的 CanvasFlowRun，历史记录保持完整。\n",[139,1846,1847],{"class":141,"line":499},[139,1848,1643],{"class":1204},[14,1850,1851,1852,1855,1856,1859],{},"关键在于复用判定落在",[124,1853,1854],{},"行状态","上，而不是「这次运行是新是旧」。所以续跑不需要额外的豁免逻辑：被判成功的节点带上原来的 ",[35,1857,1858],{},"billingRef","，调度器看到它就不再执行。",[14,1861,1862],{},"预估也跟着只算子集，余额检查同理。",[14,1864,1865,1866,1869,1870,1873],{},"同一次改动里还补了一个幂等入口——按 ",[35,1867,1868],{},"userId + clientRequestId"," 查重，重复提交返回已有的运行 ID。归属校验用带 ",[35,1871,1872],{},"userId"," 的查询。",[14,1875,1876],{},"界面文案也改了，从「重试」改成「重试失败节点」，带一句说明：",[1878,1879,1880],"blockquote",{},[14,1881,1882],{},"成功节点的产物直接沿用，只有失败的节点会重新执行并计费。",[10,1884,1886],{"id":1885},"六重复扣费这道题","六、重复扣费这道题",[14,1888,1889],{},"两处成因的形式完全不同：",[277,1891,1892,1899],{},[280,1893,1894,1895,1898],{},"一处是",[124,1896,1897],{},"前端算了一个它不该算的集合","。祖先闭包的计算本身没错，错在把它当成了「目标」。",[280,1900,1894,1901,1904],{},[124,1902,1903],{},"落库的键和查库的键不是一套","。两边各自都有定义，各自都有测试，只是没人对着比过一次。",[14,1906,1907],{},"两条链路的共同点是：扣费发生的时刻离「用户点了什么」很远。用户在界面上点的是一个按钮，扣费发生在调度器认为某个节点未被执行的那一刻。中间隔着目标集合的计算、行的展开、行的落库、调度状态的重建。",[14,1909,1910],{},"链路上每一段都「合理」，叠起来就是收两次钱。",[14,1912,1913,1914,295],{},"防守这类问题的办法不是加校验，是",[124,1915,1916],{},"把口径收成一份",[277,1918,1919,1922,1925,1928],{},[280,1920,1921],{},"目标集合由后端算，前端只传用户选了什么。",[280,1923,1924],{},"批量份数由框上配置，预估与执行读同一个函数。",[280,1926,1927],{},"复用与否由行状态决定，续跑沿用这套判定，不加例外。",[280,1929,1930],{},"贯通测试不允许手写 fixture——两侧各自造数据，就只能测出两侧各自的正确性。",[14,1932,1933,1934,1937],{},"最后那条是这次最实在的收获。一组测试全绿但系统是错的，通常不是因为用例写得不好，而是因为",[124,1935,1936],{},"用例的输入各自构造","。第三方造的数据一定和另一方一致，因为它们都来自同一个人的同一份理解；真实的接口不一致，恰恰是因为两边由不同的人、在不同的时间实现。",[870,1939,1940],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":37,"searchDepth":148,"depth":148,"links":1942},[1943,1944,1945,1946,1947,1948],{"id":1080,"depth":148,"text":1081},{"id":1352,"depth":148,"text":1353},{"id":1493,"depth":148,"text":1494},{"id":1531,"depth":148,"text":1532},{"id":1803,"depth":148,"text":1804},{"id":1885,"depth":148,"text":1886},"2026-08-31",{},"\u002F2026-08-31-fa-024",{"title":1058,"description":1063},"FA-024","2026-08-31-FA-024-同一批活儿扣了两遍钱","点「批量生视频」把 10 张已经出好的分镜图整套重跑并再次扣费。两处独立成因：前端把祖先闭包当目标传给后端，后端落库的行 ID 与唯一约束不一致导致重复派发。",[1957,1958,1959,1960,1961,1962],"画布","批量执行","幂等","重复扣费","契约测试","计费","ajZtLrM5ge42AeND6nbuv6CH5k69juZtT88VhFe_hNc",{"id":1965,"title":1966,"body":1967,"column":884,"date":2636,"description":1971,"extension":886,"hero_image":887,"meta":2637,"navigation":151,"path":2638,"seo":2639,"series_id":2640,"severity":887,"stem":2641,"summary":2642,"tags":2643,"__hash__":2649},"posts\u002F2026-08-28-FA-023-图是好的链接死了.md","图是好的，链接死了",{"type":7,"value":1968,"toc":2629},[1969,1972,1975,1978,1982,1985,1991,1994,1997,2003,2006,2013,2016,2068,2071,2074,2078,2081,2084,2152,2155,2161,2208,2211,2215,2218,2221,2227,2230,2330,2337,2340,2419,2430,2436,2440,2443,2449,2452,2457,2460,2533,2539,2546,2549,2553,2559,2570,2573,2623,2626],[14,1970,1971],{},"画布开着十几分钟之后，上面的素材图集体变成「图片加载失败」。",[14,1973,1974],{},"刷新能好，但好十几分钟，然后再裂。用户以为是网络问题，我们一开始也这么以为——直到发现刷新只是换来另一条同样会死的链接。",[14,1976,1977],{},"三处独立的问题叠在一起。",[10,1979,1981],{"id":1980},"一每请求重建一次签名客户端","一、每请求重建一次签名客户端",[14,1983,1984],{},"起因是压测。素材库场景下测图片端点的吞吐：",[28,1986,1989],{"className":1987,"code":1988,"language":33},[31],"素材库场景压测（100 用户 × 24 张图）实测 \u002Fapi\u002Fmedia 卡在约 1400 req\u002Fs，\n同等并发下 \u002Fhealth 有 4100 req\u002Fs。\n",[35,1990,1988],{"__ignoreMap":37},[14,1992,1993],{},"差了三倍。定位到签名逻辑：每次请求都新建一个 S3 客户端。",[14,1995,1996],{},"对比两种写法：",[28,1998,2001],{"className":1999,"code":2000,"language":33},[31],"每请求新建 client + 签名  1.223ms\u002F次 → 818 次\u002F秒\u002F核\n复用 client 只签名        0.385ms\u002F次 → 2598 次\u002F秒\u002F核\n",[35,2002,2000],{"__ignoreMap":37},[14,2004,2005],{},"818 × 2 个 api 副本约等于 1636，与实测的 1400 吻合。",[14,2007,2008,2009,2012],{},"差的那部分不在构造函数上。构造函数本身只要 0.069ms，剩下的一毫秒多花在",[124,2010,2011],{},"首次签名","：新客户端要重新解析中间件栈、区域、凭证链，复用之后这些被记住。",[14,2014,2015],{},"改法是缓存，但缓存键不是「无脑单例」：",[28,2017,2019],{"className":1366,"code":2018,"language":1368,"meta":37,"style":37},"签名客户端按配置缓存。素材库一屏 24 张图，每张都重建 client 时实测 1.223ms\u002F次，\n复用后 0.385ms\u002F次——快 3.2 倍。\n\n按配置做键而不是裸单例：配置换了必须重建，否则会拿旧域名的 client 继续签，\n生产改配置不生效，测试之间也会互相污染。\n",[35,2020,2021,2038,2054,2058,2063],{"__ignoreMap":37},[139,2022,2023,2026,2029,2032,2035],{"class":141,"line":142},[139,2024,2025],{"class":1096},"签名客户端按配置缓存。素材库一屏 ",[139,2027,2028],{"class":1155},"24",[139,2030,2031],{"class":1096}," 张图，每张都重建 client 时实测 1.223ms",[139,2033,2034],{"class":1100},"\u002F",[139,2036,2037],{"class":1096},"次，\n",[139,2039,2040,2043,2045,2048,2051],{"class":141,"line":148},[139,2041,2042],{"class":1096},"复用后 0.385ms",[139,2044,2034],{"class":1100},[139,2046,2047],{"class":1096},"次——快 ",[139,2049,2050],{"class":1155},"3.2",[139,2052,2053],{"class":1096}," 倍。\n",[139,2055,2056],{"class":141,"line":155},[139,2057,152],{"emptyLinePlaceholder":151},[139,2059,2060],{"class":141,"line":161},[139,2061,2062],{"class":1096},"按配置做键而不是裸单例：配置换了必须重建，否则会拿旧域名的 client 继续签，\n",[139,2064,2065],{"class":141,"line":167},[139,2066,2067],{"class":1096},"生产改配置不生效，测试之间也会互相污染。\n",[14,2069,2070],{},"键包含公开域名、端点、区域、桶名、路径风格、访问凭证。反向验证过：注入一个裸单例，三条测试转红。",[14,2072,2073],{},"这一处只解决吞吐，不解决裂图。",[10,2075,2077],{"id":2076},"二前端把签名-url-当永久地址存","二、前端把签名 URL 当永久地址存",[14,2079,2080],{},"裂图的直接原因在前端。",[14,2082,2083],{},"签名 URL 的有效期是 15 分钟（900 秒）。后端每次都现签，这个行为是对的。错的是前端把签好的 URL 存进了缓存，且没有有效期：",[28,2085,2087],{"className":1366,"code":2086,"language":1368,"meta":37,"style":37},"\u002F**\n * 签名 URL 的缓存寿命。\n *\n * 服务端签的是 900 秒，这里只敢存一半多一点——差值是留给「拿到 URL 到图片\n * 真正加载完」这段时间的。\n * 缓存不设期限会让画布开着超过 15 分钟后素材图**集体裂**：\n * 后端每次都现签是对的，栽的是前端把签好的 URL 当永久地址存了。\n *\u002F\nconst URL_CACHE_TTL_MS = 8 * 60 * 1000;\n",[35,2088,2089,2093,2098,2103,2108,2113,2118,2123,2127],{"__ignoreMap":37},[139,2090,2091],{"class":141,"line":142},[139,2092,1623],{"class":1204},[139,2094,2095],{"class":141,"line":148},[139,2096,2097],{"class":1204}," * 签名 URL 的缓存寿命。\n",[139,2099,2100],{"class":141,"line":155},[139,2101,2102],{"class":1204}," *\n",[139,2104,2105],{"class":141,"line":161},[139,2106,2107],{"class":1204}," * 服务端签的是 900 秒，这里只敢存一半多一点——差值是留给「拿到 URL 到图片\n",[139,2109,2110],{"class":141,"line":167},[139,2111,2112],{"class":1204}," * 真正加载完」这段时间的。\n",[139,2114,2115],{"class":141,"line":173},[139,2116,2117],{"class":1204}," * 缓存不设期限会让画布开着超过 15 分钟后素材图**集体裂**：\n",[139,2119,2120],{"class":141,"line":499},[139,2121,2122],{"class":1204}," * 后端每次都现签是对的，栽的是前端把签好的 URL 当永久地址存了。\n",[139,2124,2125],{"class":141,"line":505},[139,2126,1643],{"class":1204},[139,2128,2129,2131,2134,2136,2139,2142,2145,2147,2150],{"class":141,"line":510},[139,2130,1375],{"class":1100},[139,2132,2133],{"class":1155}," URL_CACHE_TTL_MS",[139,2135,1381],{"class":1100},[139,2137,2138],{"class":1155}," 8",[139,2140,2141],{"class":1100}," *",[139,2143,2144],{"class":1155}," 60",[139,2146,2141],{"class":1100},[139,2148,2149],{"class":1155}," 1000",[139,2151,1591],{"class":1096},[14,2153,2154],{},"8 分钟：服务端签 15 分钟，前端只存一半多，剩下的留给加载。",[14,2156,2157,2158,295],{},"光靠 TTL 还不够。页面可能在后台挂着很久，计时器不准；而且用户的操作序列无法预判。所以补第二个机制——",[124,2159,2160],{},"把裂图当作过期信号",[28,2162,2164],{"className":1366,"code":2163,"language":1368,"meta":37,"style":37},"图片加载失败时调用：作废该 URL 的缓存并重新现签一次。\n签名 URL 会过期，光靠 TTL 猜不准（页面可能在后台挂很久）。裂图本身\n就是最可靠的过期信号——收到它就重签一次，让节点自己好起来。\n同一个 URL 只重试一次，避免真·坏图把请求打成死循环。\n",[35,2165,2166,2177,2193,2198],{"__ignoreMap":37},[139,2167,2168,2171,2174],{"class":141,"line":142},[139,2169,2170],{"class":1096},"图片加载失败时调用：作废该 ",[139,2172,2173],{"class":1155},"URL",[139,2175,2176],{"class":1096}," 的缓存并重新现签一次。\n",[139,2178,2179,2182,2184,2187,2190],{"class":141,"line":148},[139,2180,2181],{"class":1096},"签名 ",[139,2183,2173],{"class":1155},[139,2185,2186],{"class":1096}," 会过期，光靠 ",[139,2188,2189],{"class":1155},"TTL",[139,2191,2192],{"class":1096}," 猜不准（页面可能在后台挂很久）。裂图本身\n",[139,2194,2195],{"class":141,"line":155},[139,2196,2197],{"class":1096},"就是最可靠的过期信号——收到它就重签一次，让节点自己好起来。\n",[139,2199,2200,2203,2205],{"class":141,"line":161},[139,2201,2202],{"class":1096},"同一个 ",[139,2204,2173],{"class":1155},[139,2206,2207],{"class":1096}," 只重试一次，避免真·坏图把请求打成死循环。\n",[14,2209,2210],{},"一个 URL 只重试一次。真的坏图不能把请求打成死循环——测试里连续上报 5 次加载失败，断言请求数不超过 2。",[10,2212,2214],{"id":2213},"三面向浏览器的链路签了-15-分钟的直链","三、面向浏览器的链路，签了 15 分钟的直链",[14,2216,2217],{},"做到这里，症状没了。但同一个坑之前已经踩过一次，这一次是第三次。",[14,2219,2220],{},"前两次都在画布上：",[28,2222,2225],{"className":2223,"code":2224,"language":33},[31],"线上报障：画布上素材图集体「图片加载失败」。根因是这个端点返回了\nresolveAssetUrl 签的 15 分钟 S3 直链，而不是 \u002Fapi\u002Fmedia 稳定路径。\n只断言「有 url 字段」的用例抓不到这种错，必须钉住 URL 的形态。\n",[35,2226,2224],{"__ignoreMap":37},[14,2228,2229],{},"另一次在画布运行产物：",[28,2231,2233],{"className":1366,"code":2232,"language":1368,"meta":37,"style":37},"-    \u002F\u002F 读时现签，复用素材那边同一个签名实现\n-    signObjectUrl: (objectKey: string) => createPrivateObjectReadUrl(objectKey),\n+    \u002F\u002F 必须是 stableAssetUrl（\u002Fapi\u002Fmedia 稳定路径、7 天有效、走后端代理），\n+    \u002F\u002F 不能是 createPrivateObjectReadUrl 签的 15 分钟 S3 直链——这条 URL 是\n+    \u002F\u002F 直接交给浏览器渲染 \u003Cimg> 的，画布开十几分钟产物图就集体裂（线上报障过）。\n+    \u002F\u002F 「读时现签」只解决了「不存旧 URL」，没解决「签出来的只活 15 分钟」。\n+    signObjectUrl: (objectKey: string) => stableAssetUrl(objectKey, ''),\n",[35,2234,2235,2243,2271,2279,2286,2293,2300],{"__ignoreMap":37},[139,2236,2237,2240],{"class":141,"line":142},[139,2238,2239],{"class":1100},"-",[139,2241,2242],{"class":1204},"    \u002F\u002F 读时现签，复用素材那边同一个签名实现\n",[139,2244,2245,2247,2250,2253,2256,2258,2261,2263,2265,2268],{"class":141,"line":148},[139,2246,2239],{"class":1100},[139,2248,2249],{"class":1230},"    signObjectUrl",[139,2251,2252],{"class":1096},": (",[139,2254,2255],{"class":1393},"objectKey",[139,2257,1732],{"class":1100},[139,2259,2260],{"class":1155}," string",[139,2262,1397],{"class":1096},[139,2264,1196],{"class":1100},[139,2266,2267],{"class":1230}," createPrivateObjectReadUrl",[139,2269,2270],{"class":1096},"(objectKey),\n",[139,2272,2273,2276],{"class":141,"line":155},[139,2274,2275],{"class":1100},"+",[139,2277,2278],{"class":1204},"    \u002F\u002F 必须是 stableAssetUrl（\u002Fapi\u002Fmedia 稳定路径、7 天有效、走后端代理），\n",[139,2280,2281,2283],{"class":141,"line":161},[139,2282,2275],{"class":1100},[139,2284,2285],{"class":1204},"    \u002F\u002F 不能是 createPrivateObjectReadUrl 签的 15 分钟 S3 直链——这条 URL 是\n",[139,2287,2288,2290],{"class":141,"line":167},[139,2289,2275],{"class":1100},[139,2291,2292],{"class":1204},"    \u002F\u002F 直接交给浏览器渲染 \u003Cimg> 的，画布开十几分钟产物图就集体裂（线上报障过）。\n",[139,2294,2295,2297],{"class":141,"line":173},[139,2296,2275],{"class":1100},[139,2298,2299],{"class":1204},"    \u002F\u002F 「读时现签」只解决了「不存旧 URL」，没解决「签出来的只活 15 分钟」。\n",[139,2301,2302,2304,2306,2308,2310,2312,2314,2316,2318,2321,2324,2327],{"class":141,"line":499},[139,2303,2275],{"class":1100},[139,2305,2249],{"class":1230},[139,2307,2252],{"class":1096},[139,2309,2255],{"class":1393},[139,2311,1732],{"class":1100},[139,2313,2260],{"class":1155},[139,2315,1397],{"class":1096},[139,2317,1196],{"class":1100},[139,2319,2320],{"class":1230}," stableAssetUrl",[139,2322,2323],{"class":1096},"(objectKey, ",[139,2325,2326],{"class":1112},"''",[139,2328,2329],{"class":1096},"),\n",[14,2331,2332,2333,2336],{},"最后那句是这次的实质收获。「读时现签」听起来已经解决了过期问题——每次读都是新的。但签出来的东西只活 15 分钟，而浏览器渲染 ",[35,2334,2335],{},"\u003Cimg>"," 的 URL 会被页面持有到下一次刷新。两个时间尺度不匹配。",[14,2338,2339],{},"真正的修法是给「吐给浏览器」这条路单独一档地址，不再用签名直链，改走后端代理的稳定路径：",[28,2341,2343],{"className":1366,"code":2342,"language":1368,"meta":37,"style":37},"\u002F\u002F 稳定媒体 URL：给前端 \u003Cimg>\u002F\u003Cvideo> 用的持久地址，替代 15 分钟就过期的 S3 签名 URL——\n\u002F\u002F 页面长驻后图片重新加载 403 裂图的根治方案。请求到达 \u002Fapi\u002Fmedia 验签后 302 到读时现签的 S3 URL。\n\u002F\u002F HMAC 即凭证（与 slides raw 下载、local-business-promo blob 同范式，域分隔前缀防跨用），\n\u002F\u002F 不依赖登录态：img 标签发不出 Authorization 头。\n\u002F\u002F\n\u002F\u002F TTL 7 天：覆盖任何真实的页面停留场景。\n\u002F\u002F exp 对齐到天窗口：同一对象同一天内产出字节级相同的 URL，浏览器缓存可命中；\n\u002F\u002F 任意时刻拿到的 URL 剩余有效期至少 6 天，不存在「刚拿到就过期」。\nexport const MEDIA_URL_TTL_MS = 7 * 24 * 60 * 60 * 1000;\n",[35,2344,2345,2350,2355,2360,2365,2370,2375,2380,2385],{"__ignoreMap":37},[139,2346,2347],{"class":141,"line":142},[139,2348,2349],{"class":1204},"\u002F\u002F 稳定媒体 URL：给前端 \u003Cimg>\u002F\u003Cvideo> 用的持久地址，替代 15 分钟就过期的 S3 签名 URL——\n",[139,2351,2352],{"class":141,"line":148},[139,2353,2354],{"class":1204},"\u002F\u002F 页面长驻后图片重新加载 403 裂图的根治方案。请求到达 \u002Fapi\u002Fmedia 验签后 302 到读时现签的 S3 URL。\n",[139,2356,2357],{"class":141,"line":155},[139,2358,2359],{"class":1204},"\u002F\u002F HMAC 即凭证（与 slides raw 下载、local-business-promo blob 同范式，域分隔前缀防跨用），\n",[139,2361,2362],{"class":141,"line":161},[139,2363,2364],{"class":1204},"\u002F\u002F 不依赖登录态：img 标签发不出 Authorization 头。\n",[139,2366,2367],{"class":141,"line":167},[139,2368,2369],{"class":1204},"\u002F\u002F\n",[139,2371,2372],{"class":141,"line":173},[139,2373,2374],{"class":1204},"\u002F\u002F TTL 7 天：覆盖任何真实的页面停留场景。\n",[139,2376,2377],{"class":141,"line":499},[139,2378,2379],{"class":1204},"\u002F\u002F exp 对齐到天窗口：同一对象同一天内产出字节级相同的 URL，浏览器缓存可命中；\n",[139,2381,2382],{"class":141,"line":505},[139,2383,2384],{"class":1204},"\u002F\u002F 任意时刻拿到的 URL 剩余有效期至少 6 天，不存在「刚拿到就过期」。\n",[139,2386,2387,2389,2392,2395,2397,2400,2402,2405,2407,2409,2411,2413,2415,2417],{"class":141,"line":510},[139,2388,1718],{"class":1100},[139,2390,2391],{"class":1100}," const",[139,2393,2394],{"class":1155}," MEDIA_URL_TTL_MS",[139,2396,1381],{"class":1100},[139,2398,2399],{"class":1155}," 7",[139,2401,2141],{"class":1100},[139,2403,2404],{"class":1155}," 24",[139,2406,2141],{"class":1100},[139,2408,2144],{"class":1155},[139,2410,2141],{"class":1100},[139,2412,2144],{"class":1155},[139,2414,2141],{"class":1100},[139,2416,2149],{"class":1155},[139,2418,1591],{"class":1096},[14,2420,2421,2422,2425,2426,2429],{},"路径形态是 ",[35,2423,2424],{},"\u002Fapi\u002Fmedia?key=…&exp=…&sig=…","。签名用 HMAC，不依赖登录态——",[35,2427,2428],{},"img"," 标签发不出 Authorization 头。",[14,2431,2432,2435],{},[35,2433,2434],{},"exp"," 对齐到天窗口这一条值得单独说：同一对象同一天内产出的 URL 字节级相同，浏览器的缓存能命中；而因为窗口按天滚动，任何时刻拿到的 URL 剩余有效期至少 6 天。",[10,2437,2439],{"id":2438},"四把规矩变成测试","四、把规矩变成测试",[14,2441,2442],{},"三档地址的分工写进了代码注释，放在存储工具的末尾：",[28,2444,2447],{"className":2445,"code":2446,"language":33},[31],"吐给浏览器（渲染 \u003Cimg>\u002F\u003Cvideo>）→ stableAssetUrl：返回 \u002Fapi\u002Fmedia 稳定路径，\n7 天有效，根治「页面长驻后签名过期裂图」\n交给上游拉取 → resolveUpstreamAssetUrl：6 小时\n本进程内立刻用 → resolveAssetUrl：15 分钟\n",[35,2448,2446],{"__ignoreMap":37},[14,2450,2451],{},"注释拦不住。这事已经栽过两次，两次的症状一模一样：",[1878,2453,2454],{},[14,2455,2456],{},"两次的症状一模一样：图刚打开好好的，画布放十几分钟就集体「图片加载失败」，刷新只是换来另一条同样 15 分钟后会死的链接，排查时极容易误判成前端缓存问题。",[14,2458,2459],{},"所以加了一条源码级断言。它不是接口测试——它读源码字符串，剥掉注释之后断言不该出现的函数名：",[28,2461,2463],{"className":1366,"code":2462,"language":1368,"meta":37,"style":37},"\u002F** 只活 15 分钟、且指向对象存储域名的签名函数——绝不能出现在面向浏览器的链路里 *\u002F\nconst SHORT_LIVED_SIGNERS = [\"createPrivateObjectReadUrl\", \"resolveAssetUrl\"];\n\n\u002F** 这些文件产出的 URL 会被前端直接塞进 \u003Cimg>\u002F\u003Cvideo> *\u002F\nconst BROWSER_FACING_FILES = [\n  \"canvas-flow\u002Frun-routes.ts\",\n  \"canvas-adapter\u002Fmaterial-url-route.ts\",\n];\n",[35,2464,2465,2470,2493,2497,2502,2514,2522,2529],{"__ignoreMap":37},[139,2466,2467],{"class":141,"line":142},[139,2468,2469],{"class":1204},"\u002F** 只活 15 分钟、且指向对象存储域名的签名函数——绝不能出现在面向浏览器的链路里 *\u002F\n",[139,2471,2472,2474,2477,2479,2482,2485,2487,2490],{"class":141,"line":148},[139,2473,1375],{"class":1100},[139,2475,2476],{"class":1155}," SHORT_LIVED_SIGNERS",[139,2478,1381],{"class":1100},[139,2480,2481],{"class":1096}," [",[139,2483,2484],{"class":1112},"\"createPrivateObjectReadUrl\"",[139,2486,1116],{"class":1096},[139,2488,2489],{"class":1112},"\"resolveAssetUrl\"",[139,2491,2492],{"class":1096},"];\n",[139,2494,2495],{"class":141,"line":155},[139,2496,152],{"emptyLinePlaceholder":151},[139,2498,2499],{"class":141,"line":161},[139,2500,2501],{"class":1204},"\u002F** 这些文件产出的 URL 会被前端直接塞进 \u003Cimg>\u002F\u003Cvideo> *\u002F\n",[139,2503,2504,2506,2509,2511],{"class":141,"line":167},[139,2505,1375],{"class":1100},[139,2507,2508],{"class":1155}," BROWSER_FACING_FILES",[139,2510,1381],{"class":1100},[139,2512,2513],{"class":1096}," [\n",[139,2515,2516,2519],{"class":141,"line":173},[139,2517,2518],{"class":1112},"  \"canvas-flow\u002Frun-routes.ts\"",[139,2520,2521],{"class":1096},",\n",[139,2523,2524,2527],{"class":141,"line":499},[139,2525,2526],{"class":1112},"  \"canvas-adapter\u002Fmaterial-url-route.ts\"",[139,2528,2521],{"class":1096},[139,2530,2531],{"class":141,"line":505},[139,2532,2492],{"class":1096},[14,2534,2535,2536,114],{},"再加一条断言钉住 URL 的形态：产出的地址里不能出现 ",[35,2537,2538],{},"X-Amz-Signature",[14,2540,2541,2542,2545],{},"这是这次修法里唯一带点非常规的做法：",[124,2543,2544],{},"用测试来守一条架构约定","。普通的测试守行为，这条守的是「哪个函数可以在哪里被调用」。",[14,2547,2548],{},"之所以需要它，是因为这类错误的三个特征叠在一起：编译期查不出（函数签名都一样）、单测查不出（旧用例只断言「有 url 字段」）、运行时查不出（图在头十几分钟是好的）。三个环节都不拦，只能靠一条读源码的断言。",[10,2550,2552],{"id":2551},"五时间尺度","五、时间尺度",[14,2554,2555,2556,114],{},"回头看，这三处其实是同一个问题在三个层面的表现：",[124,2557,2558],{},"链接的有效期和它的使用方式不匹配",[277,2560,2561,2564,2567],{},[280,2562,2563],{},"签名客户端：复用的时间尺度是「进程生命周期」，实现给的是「单次请求」。",[280,2565,2566],{},"前端缓存：URL 被持有的时间尺度是「页面停留时长」，缓存给的是「无限」。",[280,2568,2569],{},"直链：浏览器的取用方式是「随时重新加载」，签名给的是 15 分钟。",[14,2571,2572],{},"修完之后的取值是有依据的，不是拍的：",[306,2574,2575,2588],{},[309,2576,2577],{},[312,2578,2579,2582,2585],{},[315,2580,2581],{},"用途",[315,2583,2584],{},"有效期",[315,2586,2587],{},"依据",[322,2589,2590,2601,2612],{},[312,2591,2592,2595,2598],{},[327,2593,2594],{},"本进程立刻取用",[327,2596,2597],{},"15 分钟",[327,2599,2600],{},"签完就 fetch，够用且泄露窗口小",[312,2602,2603,2606,2609],{},[327,2604,2605],{},"交给上游排队拉取",[327,2607,2608],{},"6 小时",[327,2610,2611],{},"实测排队 10~25 分钟才轮到上游下载",[312,2613,2614,2617,2620],{},[327,2615,2616],{},"浏览器渲染",[327,2618,2619],{},"7 天（按天对齐）",[327,2621,2622],{},"覆盖任意页面停留，且同一天 URL 相同可命中缓存",[14,2624,2625],{},"最后一条还有一层：它不走签名直链，走后端代理。这换来两个好处——不受对象存储域名可达性影响，也不受签名有效期约束。代价是流量过后端，但素材图不走这条路的代价更大。",[870,2627,2628],{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":37,"searchDepth":148,"depth":148,"links":2630},[2631,2632,2633,2634,2635],{"id":1980,"depth":148,"text":1981},{"id":2076,"depth":148,"text":2077},{"id":2213,"depth":148,"text":2214},{"id":2438,"depth":148,"text":2439},{"id":2551,"depth":148,"text":2552},"2026-08-28",{},"\u002F2026-08-28-fa-023",{"title":1966,"description":1971},"FA-023","2026-08-28-FA-023-图是好的链接死了","画布上的素材图集体裂开，刷新只换来另一条同样会死的链接。三处独立问题：签名客户端每请求重建、前端把签名 URL 当永久地址存、面向浏览器的链路签了 15 分钟的直链。",[2644,2645,2646,2647,1961,2648],"S3","签名URL","缓存","性能","对象存储","LNHg4QB5lztHWhSI-OvrOM4XrnhLpHHPMlNgjTOxMA4",1789212573068]