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