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