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