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