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