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