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