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