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