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