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