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