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