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