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