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