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