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