[{"data":1,"prerenderedAt":4182},["ShallowReactive",2],{"\u002F2026-04-21-tauri2electron":3,"\u002F2026-04-21-tauri2electron-rel":3245},{"id":4,"title":5,"body":6,"column":3230,"date":3231,"description":12,"extension":3232,"hero_image":3233,"meta":3234,"navigation":963,"path":3235,"seo":3236,"series_id":3233,"severity":3233,"stem":3237,"summary":3238,"tags":3239,"__hash__":3244},"posts\u002F2026-04-21-Tauri2而不是Electron四个理由.md","Electron 要 60MB——在 U 盘上这不成比例",{"type":7,"value":8,"toc":3198},"minimark",[9,13,17,20,23,27,32,35,38,54,57,60,63,67,70,73,176,179,254,257,261,264,267,278,281,284,288,292,295,300,303,308,311,316,323,328,331,334,337,340,343,346,352,434,438,441,447,450,456,471,474,488,494,505,511,526,529,533,536,542,548,551,662,665,670,673,724,727,744,747,782,785,789,792,795,800,803,814,819,822,833,836,841,902,905,908,911,916,919,1141,1146,1149,1286,1291,1294,1302,1305,1308,1319,1322,1325,1419,1422,1428,1439,1443,1446,1460,1464,1468,1471,1476,1479,1585,1588,1603,1606,1611,1614,1810,1813,1830,1833,1838,1841,1969,1972,1977,1980,2037,2040,2051,2055,2058,2112,2115,2118,2121,2124,2130,2158,2164,2187,2190,2193,2196,2200,2203,2208,2234,2237,2243,2246,2260,2263,2274,2279,2287,2291,2294,2299,2310,2315,2318,2402,2405,2416,2421,2435,2440,2649,2652,2666,2670,2673,2676,2745,2748,2753,2758,2761,2775,2778,2783,2786,2791,2794,2799,2802,2805,2816,2819,2980,2986,3089,3092,3096,3099,3104,3118,3123,3149,3152,3157,3168,3173,3181,3186,3194],[10,11,12],"p",{},"便携 AI 助手需要一个配置中心——这是常见的桌面应用需求。选型时 Electron 是行业默认答案，却因为四个约束选了 Tauri 2。这篇文章把决策过程和权衡写清楚。",[14,15,16],"h2",{"id":16},"约束背景",[10,18,19],{},"整个产品装在 U 盘里交付。U 盘容量有限，内置常驻启动器（Launcher）已占据部分空间；配置中心是按需启动的配套工具，不能无限膨胀。同时，Launcher 用 Rust 写的，已有完整的进程管理、文件 I\u002FO、Windows API 调用能力——如果前端框架能复用这套基础设施，可以省掉大量重复实现。",[10,21,22],{},"这两个条件合在一起，决定了选型的方向。不是\"选最新的框架\"，而是\"在约束下找最优解\"。",[14,24,26],{"id":25},"理由一体积约束10-倍差距的现实","理由一：体积约束——10 倍差距的现实",[28,29,31],"h3",{"id":30},"electron-的包体积现实","Electron 的包体积现实",[10,33,34],{},"Electron 自带完整的 Chromium 浏览器。这听起来很方便（开箱即用的跨平台 WebView），但成本是无法回避的。一个最小化的 Electron 应用，构建后体积在 150MB 左右；经过压缩和精心优化后，可以缩小到 60-80MB。但这已经是压缩限制，再往下很难。",[10,36,37],{},"为什么这么大？核心原因是 Chromium 本身就是一个 100MB+ 的浏览器引擎。Chromium 包含：",[39,40,41,45,48,51],"ul",{},[42,43,44],"li",{},"渲染引擎（Blink）：处理 HTML\u002FCSS 的布局和绘制，约 30MB",[42,46,47],{},"JavaScript 引擎（V8）：JIT 编译、垃圾回收等，约 10MB",[42,49,50],{},"网络栈、媒体解码、GPU 命令处理：约 50MB",[42,52,53],{},"第三方库和字体：约 10MB",[10,55,56],{},"即使不加任何应用代码，空的 Electron 壳都要这个量级。Slack、VS Code、Figma 等大型应用都是 100MB+，人们习以为常。但这个数字对 U 盘便携应用是灾难性的。",[10,58,59],{},"问题不在于 60MB 本身大不大，而在于它要和什么东西抢空间。U 盘上还得放捆绑的 Node.js 运行时、Python、工具链、openclaw 本体和它的依赖树，往后还有会逐月增长的模型本地缓存。在这个清单里，一个配置界面占掉 60MB 是不成比例的——它是整套东西里最不该占地方的那部分。",[10,61,62],{},"更关键的是分发成本。如果用户想把产品拷到多个 U 盘，或者网络分发时需要压缩传输，每个版本都是 60MB 的包。假设产品月更新一次，一年就是 720MB 的总流量（对个人开发者或初创团队来说是明显的成本）。",[28,64,66],{"id":65},"tauri-的体积目标与实施","Tauri 的体积目标与实施",[10,68,69],{},"Tauri 不内置浏览器。它调用操作系统已有的 WebView 实现：Windows 上是 WebView2，macOS 上是 WKWebView，Linux 上是 GTK WebKit。这意味着应用本身只需打包前端资源和 Rust 后端，不需要背负一个完整浏览器引擎。",[10,71,72],{},"这个项目的 Tauri 构建目标是：",[74,75,76,92],"table",{},[77,78,79],"thead",{},[80,81,82,86,89],"tr",{},[83,84,85],"th",{},"组件",[83,87,88],{},"大小限制",[83,90,91],{},"说明",[93,94,95,107,118,129,140,151,162],"tbody",{},[80,96,97,101,104],{},[98,99,100],"td",{},"YourBrand.exe（Tauri Rust 二进制）",[98,102,103],{},"≤ 18 MB",[98,105,106],{},"包含 tokio、serde、windows-rs、tauri-runtime 等依赖",[80,108,109,112,115],{},[98,110,111],{},"Vue.js 打包输出（JS）",[98,113,114],{},"≤ 250 KB gzip",[98,116,117],{},"Vite 构建 + Tree shaking + 代码分割",[80,119,120,123,126],{},[98,121,122],{},"Tailwind CSS 样式表",[98,124,125],{},"≤ 80 KB gzip",[98,127,128],{},"JIT 编译后去除未使用的 utility class",[80,130,131,134,137],{},[98,132,133],{},"Inter 字体（4 字重）",[98,135,136],{},"≤ 60 KB × 4",[98,138,139],{},"woff2 格式本地嵌入，按需加载",[80,141,142,145,148],{},[98,143,144],{},"Material Symbols 图标集",[98,146,147],{},"≤ 80 KB",[98,149,150],{},"按页面功能子集，避免加载整个 45MB icon library",[80,152,153,156,159],{},[98,154,155],{},"业务 SVG 和静态资源",[98,157,158],{},"≤ 50 KB",[98,160,161],{},"本地 logo、icon、占位图等",[80,163,164,167,173],{},[98,165,166],{},"总计",[98,168,169],{},[170,171,172],"strong",{},"≤ 20 MB",[98,174,175],{},"包括所有资源和二进制",[10,177,178],{},"对比分析：",[74,180,181,197],{},[77,182,183],{},[80,184,185,188,191,194],{},[83,186,187],{},"场景",[83,189,190],{},"Electron",[83,192,193],{},"Tauri",[83,195,196],{},"差异",[93,198,199,213,226,240],{},[80,200,201,204,207,210],{},[98,202,203],{},"初次下载",[98,205,206],{},"60 MB",[98,208,209],{},"20 MB",[98,211,212],{},"节省 40 MB（-67%）",[80,214,215,218,221,223],{},[98,216,217],{},"装 3 个版本",[98,219,220],{},"180 MB",[98,222,206],{},[98,224,225],{},"节省 120 MB",[80,227,228,231,234,237],{},[98,229,230],{},"3 年（36 个版本）",[98,232,233],{},"2.16 GB",[98,235,236],{},"0.72 GB",[98,238,239],{},"节省 1.44 GB",[80,241,242,245,248,251],{},[98,243,244],{},"同时运行 10 个进程",[98,246,247],{},"600 MB 内存",[98,249,250],{},"50 MB 内存*",[98,252,253],{},"节省 550 MB",[10,255,256],{},"*Tauri 的内存占用是 Electron 的 1\u002F10 级别，因为没有每个进程独立的 Chromium 引擎。这在多窗口场景下优势明显。",[28,258,260],{"id":259},"webview2-的前置条件","WebView2 的前置条件",[10,262,263],{},"当然，代价是 WebView2 不一定预装。新安装的 Windows 系统或者长期未更新的老机器上，可能没有 WebView2。这时需要用户下载，大约 100MB 左右（一次性）。",[10,265,266],{},"项目中的应对是在 Launcher 侧做检测（注册表查询），如果缺失则提示用户：",[268,269,274],"pre",{"className":270,"code":272,"language":273},[271],"language-text","检测流程：\n  1. Launcher 启动时查询注册表\n     HKEY_LOCAL_MACHINE\\Software\\WOW6432Node\\Microsoft\\EdgeUpdate\\ClientState\\{F3017226...}\n  2. 读取版本号 pv 字段（如 \"123.0.0.0\"）\n  3. 若版本 >= 90，直接启动 Tauri\n  4. 若版本 \u003C 90 或不存在，弹 Win32 对话框\n     \"配置中心首次使用需下载 WebView2（约 100MB）\"\n  5. 用户点击\"立即下载\"\n     执行 webview2-bootstrap.exe（MS 官方下载器）\n  6. 下载完成自动重新启动配置中心\n","text",[275,276,272],"code",{"__ignoreMap":277},"",[10,279,280],{},"这是折衷方案：常见场景（WebView2 已装）下体积优势完全发挥；少数场景（首次装 WebView2）下推给用户一次性下载。",[10,282,283],{},"对于 U 盘产品而言，这个折衷是合理的。因为 WebView2 作为系统组件，用户装过一次后就永久有效，下一次拷贝不同版本的 openclaw 到另一个 U 盘时就不需要再装了。而 Electron 的 60MB 体积却是每个应用版本都绕不过的。",[14,285,287],{"id":286},"理由二rust-启动器代码复用","理由二：Rust 启动器代码复用",[28,289,291],{"id":290},"现有-launcher-的能力清单","现有 Launcher 的能力清单",[10,293,294],{},"这个产品的 Launcher（启动.exe）是用 Rust 从零写的，已有完整的五层堆栈，每层职责明确：",[10,296,297],{},[170,298,299],{},"Layer 1-2：环境自检与缓存初始化",[10,301,302],{},"启动时查注册表确认 WebView2 是否可用、检查 VC++ 运行库版本、探测捆绑的 Node.js，然后做 U 盘挂载点检测、建缓存目录、拉起日志系统。这些工作一次性完成，为后面几层打好基础。",[10,304,305],{},[170,306,307],{},"Layer 3：请求路由与状态维护",[10,309,310],{},"来自 WebUI、CLI 工具、其他组件的请求需要被正确路由到 Gateway。同时维护 Gateway 的状态机（启动中、运行中、故障、重启中）。这层处理的是应用的控制流。",[10,312,313],{},[170,314,315],{},"Layer 4：守护与恢复",[10,317,318,319,322],{},"定时检测 Gateway health（每 5 秒轮询 ",[275,320,321],{},"\u002Fhealth"," 端点），若发现故障则自动重启 Gateway 子进程。故障恢复后记录详细日志。这层是系统可靠性的保证。",[10,324,325],{},[170,326,327],{},"Layer 5：系统集成",[10,329,330],{},"Win32 托盘菜单（Shell_NotifyIcon）、文件原子操作（atomic rename）、进程管理（CreateProcess、互斥量、事件信号）。这层与操作系统底层交互。",[28,332,333],{"id":333},"为何代码复用很关键",[10,335,336],{},"这五层的代码都是可以被配置中心复用的。具体例子：",[10,338,339],{},"配置中心需要向 Gateway 发送请求（修改配置、查询状态），可以直接导入 Launcher 的 HTTP 代理层。不需要在前端再实现一套网络栈。",[10,341,342],{},"配置中心需要原子写入 user.yml 到 U 盘（确保部分写入不会导致配置文件损坏），可以复用 Launcher 的原子文件操作库。",[10,344,345],{},"配置中心需要通知 Launcher 某个事件已完成（比如首启引导完成），可以复用 Launcher 的进程通信机制（命名管道）。",[10,347,348,351],{},[170,349,350],{},"具体的 invoke 命令清单","（所有后端实现都可导入 Launcher 代码）：",[74,353,354,367],{},[77,355,356],{},[80,357,358,361,364],{},[83,359,360],{},"命令",[83,362,363],{},"来源 Launcher 模块",[83,365,366],{},"复用度",[93,368,369,382,395,408,421],{},[80,370,371,376,379],{},[98,372,373],{},[275,374,375],{},"gateway_request",[98,377,378],{},"launcher\u002Fsrc\u002Fgateway.rs 的 HTTP proxy",[98,380,381],{},"直接复用 reqwest client + error handling",[80,383,384,389,392],{},[98,385,386],{},[275,387,388],{},"user_yml_write",[98,390,391],{},"launcher\u002Fsrc\u002Ffs\u002Fatomic.rs + serde",[98,393,394],{},"导入 atomic_write 函数，套上 YAML schema",[80,396,397,402,405],{},[98,398,399],{},[275,400,401],{},"single_instance",[98,403,404],{},"launcher\u002Fsrc\u002Fipc\u002Fnamed_pipe.rs + launcher\u002Fsrc\u002Fwin32\u002Fmutex.rs",[98,406,407],{},"直接复用 CreateMutexW 和 pipe 逻辑",[80,409,410,415,418],{},[98,411,412],{},[275,413,414],{},"get_runtime_info",[98,416,417],{},"launcher\u002Fsrc\u002Fbootstrap.rs",[98,419,420],{},"读取 Launcher 写入的 runtime-info.json",[80,422,423,428,431],{},[98,424,425],{},[275,426,427],{},"get_brand",[98,429,430],{},"launcher\u002Fsrc\u002Fbrand.rs",[98,432,433],{},"直接导入常量定义",[28,435,437],{"id":436},"electron-vs-tauri-的架构对比","Electron vs Tauri 的架构对比",[10,439,440],{},"如果用 Electron，前端和后端分属两个不同的技术栈：",[268,442,445],{"className":443,"code":444,"language":273},[271],"Launcher (Rust 生态)\n├─ tokio async runtime\n├─ reqwest HTTP client\n├─ serde JSON parsing\n├─ fs2 file locking\n└─ windows-rs Win32 API\n\n↓ (跨语言通信，需要新的 IPC 和 native binding 层)\n\nElectron (Node.js 生态)\n├─ express 或 koa 本地 HTTP server（暴露 REST API 给前端）\n├─ node-ffi 或 N-API native module（调用 Launcher 的 Rust DLL）\n├─ node-gyp 编译脚本（为不同 Node 版本编译 native extension）\n└─ npm dependencies（400+ 个包）\n",[275,446,444],{"__ignoreMap":277},[10,448,449],{},"这个架构有多个问题：",[10,451,452,455],{},[170,453,454],{},"编译复杂性","：为了让 Electron 调用 Launcher 的 Rust 库，需要：",[457,458,459,462,465,468],"ol",{},[42,460,461],{},"把 Launcher 代码编译为 CDyLib（动态库）",[42,463,464],{},"用 N-API 包装暴露给 Node.js",[42,466,467],{},"用 node-gyp 配置编译脚本",[42,469,470],{},"在 CI 中为 Windows + Node 14\u002F16\u002F18\u002F20 等版本交叉编译",[10,472,473],{},"这意味着每个操作系统\u002FNode 版本组合都需要重新编译。Node.js 主版本升级时，整个构建流程都要调整。维护成本非常高。",[10,475,476,479,480,483,484,487],{},[170,477,478],{},"类型一致性","：一个数据结构（比如 Gateway HTTP 响应的 HealthStatus）在 Launcher 中定义为 Rust struct，在 native module 中序列化为 JSON，再在 Electron 中重新定义为 TypeScript interface。三层映射，容易出现脱同步。某个字段从 ",[275,481,482],{},"u64"," 改为 ",[275,485,486],{},"i32","，native module 中要改，TypeScript 中也要改。改漏了就是 runtime error。",[10,489,490,493],{},[170,491,492],{},"调试困难","：错误发生在 native module 与 Node.js 边界时，堆栈跟踪跨越两种语言，调试工具和资料都很少。比如 Launcher 的 HTTP 代理层在 Rust 侧发生了某个网络超时，这个异常需要：",[457,495,496,499,502],{},[42,497,498],{},"从 Rust panic 转换为 Node.js exception",[42,500,501],{},"通过 N-API 传递",[42,503,504],{},"在 Electron 前端被 catch\n这个过程中很容易丢失上下文信息。",[10,506,507,510],{},[170,508,509],{},"维护债务","：任何底层库升级都会触发重新编译。比如：",[39,512,513,520,523],{},[42,514,515,516,519],{},"更新 tokio 版本（从 1.x 升 1.y）：Rust side 简单 ",[275,517,518],{},"cargo update","，但 native module 需要重新编译和测试",[42,521,522],{},"升级 Node.js（18 → 20）：N-API 的行为改变，可能需要调整绑定代码",[42,524,525],{},"升级 Electron（25 → 26）：可能有 V8 引擎版本变化，影响 native module 兼容性",[10,527,528],{},"总之，跨语言的架构引入了显著的复杂性。",[28,530,532],{"id":531},"tauri-方案的优势","Tauri 方案的优势",[10,534,535],{},"选 Tauri 后，整个后端都是 Rust：",[268,537,540],{"className":538,"code":539,"language":273},[271],"Launcher (Rust)\n├─ tokio async runtime\n├─ reqwest HTTP client  ← 可以引入这个库\n├─ serde JSON parsing   ← 可以引入这个库\n├─ fs2 file locking     ← 可以引入这个库\n└─ windows-rs Win32 API ← 可以引入这个库\n\nTauri 桌面应用 (Rust + Vue 3)\n├─ src-tauri\u002F（Rust 后端）\n│  ├─ Cargo.toml: 引入 launcher 相关 crate\n│  │  reqwest = \"0.11\"  \u002F\u002F 与 Launcher 共用版本\n│  │  serde = { version = \"1.0\", features = [\"derive\"] }\n│  │  windows = { version = \"0.52\", features = [...] }\n│  ├─ commands\u002F\n│  │  ├─ gateway.rs     : 封装 reqwest client 为 invoke 命令\n│  │  ├─ user_config.rs : 使用 fs2 做原子写\n│  │  └─ runtime.rs     : 读取 runtime-info.json\n│  └─ src\u002Fmain.rs       : Tauri builder 入口\n├─ src\u002F（Vue 3 前端）\n│  ├─ stores\u002F\n│  │  ├─ gateway.ts\n│  │  ├─ userConfig.ts\n│  │  └─ runtime.ts\n│  └─ services\u002F\n│     └─ tauri.ts       : 包装 invoke() 调用\n",[275,541,539],{"__ignoreMap":277},[10,543,544,547],{},[170,545,546],{},"代码复用清单","：",[10,549,550],{},"实际计算可复用的代码行数：",[74,552,553,569],{},[77,554,555],{},[80,556,557,560,563,566],{},[83,558,559],{},"模块",[83,561,562],{},"Launcher 行数",[83,564,565],{},"复用方式",[83,567,568],{},"节省",[93,570,571,585,599,613,627,640],{},[80,572,573,576,579,582],{},[98,574,575],{},"HTTP gateway proxy",[98,577,578],{},"500 行",[98,580,581],{},"直接导入 reqwest client，wrapper 只需 50 行",[98,583,584],{},"-90%",[80,586,587,590,593,596],{},[98,588,589],{},"原子文件写",[98,591,592],{},"200 行",[98,594,595],{},"导入 fs2，wrapper 只需 30 行",[98,597,598],{},"-85%",[80,600,601,604,607,610],{},[98,602,603],{},"命名管道 IPC",[98,605,606],{},"300 行",[98,608,609],{},"导入 windows-rs，wrapper 只需 50 行",[98,611,612],{},"-83%",[80,614,615,618,621,624],{},[98,616,617],{},"错误类型",[98,619,620],{},"150 行",[98,622,623],{},"直接导入 errors module",[98,625,626],{},"-100%",[80,628,629,632,635,638],{},[98,630,631],{},"品牌常量",[98,633,634],{},"100 行",[98,636,637],{},"直接导入 brand module",[98,639,626],{},[80,641,642,647,652,657],{},[98,643,644],{},[170,645,646],{},"小计",[98,648,649],{},[170,650,651],{},"1250 行",[98,653,654],{},[170,655,656],{},"总计复用 ~900 行",[98,658,659],{},[170,660,661],{},"-72%",[10,663,664],{},"这意味着配置中心的 Rust 后端大约可以节省 900 行代码。900 行按每小时 50 行的编码速度计算，节省了 18 小时的开发时间。而且这些是业务关键的代码——用现成的、已测试过的实现，质量有保证。",[10,666,667],{},[170,668,669],{},"被放弃的选项",[10,671,672],{},"也考虑过 Electron + native module 的混合方案。伪代码：",[268,674,678],{"className":675,"code":676,"language":677,"meta":277,"style":277},"language-rust shiki shiki-themes github-light github-dark","\u002F\u002F 作为 native module 暴露给 Electron\n#[napi]\npub fn gateway_request(url: String, opts: String) -> napi::Result\u003CString> {\n    let client = reqwest::Client::new();\n    \u002F\u002F 实现 HTTP 请求\n    Ok(response_json)\n}\n","rust",[275,679,680,688,694,700,706,712,718],{"__ignoreMap":277},[681,682,685],"span",{"class":683,"line":684},"line",1,[681,686,687],{},"\u002F\u002F 作为 native module 暴露给 Electron\n",[681,689,691],{"class":683,"line":690},2,[681,692,693],{},"#[napi]\n",[681,695,697],{"class":683,"line":696},3,[681,698,699],{},"pub fn gateway_request(url: String, opts: String) -> napi::Result\u003CString> {\n",[681,701,703],{"class":683,"line":702},4,[681,704,705],{},"    let client = reqwest::Client::new();\n",[681,707,709],{"class":683,"line":708},5,[681,710,711],{},"    \u002F\u002F 实现 HTTP 请求\n",[681,713,715],{"class":683,"line":714},6,[681,716,717],{},"    Ok(response_json)\n",[681,719,721],{"class":683,"line":720},7,[681,722,723],{},"}\n",[10,725,726],{},"然后在 Electron 中：",[268,728,732],{"className":729,"code":730,"language":731,"meta":277,"style":277},"language-javascript shiki shiki-themes github-light github-dark","const { gateway_request } = require('.\u002Fnative');\nconst response = gateway_request(url, opts);\n","javascript",[275,733,734,739],{"__ignoreMap":277},[681,735,736],{"class":683,"line":684},[681,737,738],{},"const { gateway_request } = require('.\u002Fnative');\n",[681,740,741],{"class":683,"line":690},[681,742,743],{},"const response = gateway_request(url, opts);\n",[10,745,746],{},"这样可以在 Electron 中调用 Rust 代码。但成本包括：",[39,748,749,755,761,771,777],{},[42,750,751,754],{},[170,752,753],{},"构建脚本复杂度提升 3 倍","：需要 node-gyp、MSVC 工具链配置、目标 triple 管理",[42,756,757,760],{},[170,758,759],{},"分发体积增加","：预编译的 .node 文件（Node Native Extension）体积大（通常 5-10MB），且不同 Node 版本不兼容，需要发布多个版本到 npm",[42,762,763,766,767,770],{},[170,764,765],{},"开发反馈慢","：改一行 Rust 代码都要重新 ",[275,768,769],{},"npm run build:native","，速度慢",[42,772,773,776],{},[170,774,775],{},"错误报告不清楚","：JSON 序列化\u002F反序列化过程中容易丢失类型信息或堆栈跟踪",[42,778,779,781],{},[170,780,509],{},"：任何 Node 版本升级都需要重新编译",[10,783,784],{},"Tauri 的 invoke 机制绕过了所有这些问题。WebView JS 与 Rust 的通道由 Tauri 框架管理，类型通过 serde derive 自动序列化，错误处理一致。",[14,786,788],{"id":787},"理由三更新包体积更小","理由三：更新包体积更小",[28,790,791],{"id":791},"更新流程中的成本",[10,793,794],{},"便携应用的更新路径有两种：",[10,796,797],{},[170,798,799],{},"路径 A：物理 U 盘",[10,801,802],{},"用户从官网下载新版本到 U 盘，拷贝覆盖旧版本。成本是：",[39,804,805,808,811],{},[42,806,807],{},"用户的网络带宽（下载新版本）",[42,809,810],{},"用户的等待时间",[42,812,813],{},"官网的服务器流量",[10,815,816],{},[170,817,818],{},"路径 B：应用内网络更新",[10,820,821],{},"应用内检测更新，自动下载最新版本。成本是：",[39,823,824,827,830],{},[42,825,826],{},"用户的网络带宽（同上）",[42,828,829],{},"开发者的 CDN 费用（流量计费）",[42,831,832],{},"服务器存储成本（保存多个历史版本）",[10,834,835],{},"无论哪种路径，包体积都直接影响成本。60MB vs 20MB，相差整整 3 倍。",[10,837,838,547],{},[170,839,840],{},"成本计算（假设 1000 活跃用户，月更新一次）",[74,842,843,856],{},[77,844,845],{},[80,846,847,849,851,853],{},[83,848,187],{},[83,850,190],{},[83,852,193],{},[83,854,855],{},"成本差",[93,857,858,872,886],{},[80,859,860,863,866,869],{},[98,861,862],{},"用户网络（下载 1000 次 60MB）",[98,864,865],{},"60 GB 月",[98,867,868],{},"20 GB 月",[98,870,871],{},"省 40 GB",[80,873,874,877,880,883],{},[98,875,876],{},"CDN 流量费（$0.1\u002FGB）",[98,878,879],{},"$600\u002F月",[98,881,882],{},"$200\u002F月",[98,884,885],{},"省 $400\u002F月",[80,887,888,891,894,897],{},[98,889,890],{},"年度成本",[98,892,893],{},"$7200",[98,895,896],{},"$2400",[98,898,899],{},[170,900,901],{},"省 $4800\u002F年",[10,903,904],{},"对于商业应用，省钱就是收益。对于个人开发者，更新不了的快速迭代也是成本。",[28,906,907],{"id":907},"分层配置的增量优势",[10,909,910],{},"进一步优化的策略是配置文件分层。这个项目将配置拆成两个 YAML：",[10,912,913],{},[170,914,915],{},"user.yml（本体配置，改动触发 Gateway reload）",[10,917,918],{},"这个文件存储用户真正配置的内容：API Key、模型选择、渠道连接状态等。改动时需要通知 Gateway 重新加载。",[268,920,924],{"className":921,"code":922,"language":923,"meta":277,"style":277},"language-yaml shiki shiki-themes github-light github-dark","schema_version: 1\nbrand:\n  name: \"openclaw\"\n\nmodels:\n  providers:\n    deepseek:\n      base_url: \"https:\u002F\u002Fapi.deepseek.com\u002Fv1\"\n      api_key: \"sk-xxx...\"\n      models:\n        - id: \"deepseek-chat\"\n          name: \"DeepSeek Chat\"\n          context_window: 64000\n          max_tokens: 8192\n\nchannels:\n  feishu: { enabled: false }\n  webchat: { enabled: true }\n\nplugins:\n  allow: [\"skill-basic-chat\"]\n","yaml",[275,925,926,940,948,959,965,972,979,986,997,1008,1016,1030,1041,1052,1063,1068,1076,1096,1113,1118,1126],{"__ignoreMap":277},[681,927,928,932,936],{"class":683,"line":684},[681,929,931],{"class":930},"s9eBZ","schema_version",[681,933,935],{"class":934},"sVt8B",": ",[681,937,939],{"class":938},"sj4cs","1\n",[681,941,942,945],{"class":683,"line":690},[681,943,944],{"class":930},"brand",[681,946,947],{"class":934},":\n",[681,949,950,953,955],{"class":683,"line":696},[681,951,952],{"class":930},"  name",[681,954,935],{"class":934},[681,956,958],{"class":957},"sZZnC","\"openclaw\"\n",[681,960,961],{"class":683,"line":702},[681,962,964],{"emptyLinePlaceholder":963},true,"\n",[681,966,967,970],{"class":683,"line":708},[681,968,969],{"class":930},"models",[681,971,947],{"class":934},[681,973,974,977],{"class":683,"line":714},[681,975,976],{"class":930},"  providers",[681,978,947],{"class":934},[681,980,981,984],{"class":683,"line":720},[681,982,983],{"class":930},"    deepseek",[681,985,947],{"class":934},[681,987,989,992,994],{"class":683,"line":988},8,[681,990,991],{"class":930},"      base_url",[681,993,935],{"class":934},[681,995,996],{"class":957},"\"https:\u002F\u002Fapi.deepseek.com\u002Fv1\"\n",[681,998,1000,1003,1005],{"class":683,"line":999},9,[681,1001,1002],{"class":930},"      api_key",[681,1004,935],{"class":934},[681,1006,1007],{"class":957},"\"sk-xxx...\"\n",[681,1009,1011,1014],{"class":683,"line":1010},10,[681,1012,1013],{"class":930},"      models",[681,1015,947],{"class":934},[681,1017,1019,1022,1025,1027],{"class":683,"line":1018},11,[681,1020,1021],{"class":934},"        - ",[681,1023,1024],{"class":930},"id",[681,1026,935],{"class":934},[681,1028,1029],{"class":957},"\"deepseek-chat\"\n",[681,1031,1033,1036,1038],{"class":683,"line":1032},12,[681,1034,1035],{"class":930},"          name",[681,1037,935],{"class":934},[681,1039,1040],{"class":957},"\"DeepSeek Chat\"\n",[681,1042,1044,1047,1049],{"class":683,"line":1043},13,[681,1045,1046],{"class":930},"          context_window",[681,1048,935],{"class":934},[681,1050,1051],{"class":938},"64000\n",[681,1053,1055,1058,1060],{"class":683,"line":1054},14,[681,1056,1057],{"class":930},"          max_tokens",[681,1059,935],{"class":934},[681,1061,1062],{"class":938},"8192\n",[681,1064,1066],{"class":683,"line":1065},15,[681,1067,964],{"emptyLinePlaceholder":963},[681,1069,1071,1074],{"class":683,"line":1070},16,[681,1072,1073],{"class":930},"channels",[681,1075,947],{"class":934},[681,1077,1079,1082,1085,1088,1090,1093],{"class":683,"line":1078},17,[681,1080,1081],{"class":930},"  feishu",[681,1083,1084],{"class":934},": { ",[681,1086,1087],{"class":930},"enabled",[681,1089,935],{"class":934},[681,1091,1092],{"class":938},"false",[681,1094,1095],{"class":934}," }\n",[681,1097,1099,1102,1104,1106,1108,1111],{"class":683,"line":1098},18,[681,1100,1101],{"class":930},"  webchat",[681,1103,1084],{"class":934},[681,1105,1087],{"class":930},[681,1107,935],{"class":934},[681,1109,1110],{"class":938},"true",[681,1112,1095],{"class":934},[681,1114,1116],{"class":683,"line":1115},19,[681,1117,964],{"emptyLinePlaceholder":963},[681,1119,1121,1124],{"class":683,"line":1120},20,[681,1122,1123],{"class":930},"plugins",[681,1125,947],{"class":934},[681,1127,1129,1132,1135,1138],{"class":683,"line":1128},21,[681,1130,1131],{"class":930},"  allow",[681,1133,1134],{"class":934},": [",[681,1136,1137],{"class":957},"\"skill-basic-chat\"",[681,1139,1140],{"class":934},"]\n",[10,1142,1143],{},[170,1144,1145],{},"app-prefs.yml（桌面偏好，改动不触发 reload）",[10,1147,1148],{},"这个文件存储用户界面的临时状态：主题、窗口大小、上次访问的页面等。改动完全是本地的，不影响 Gateway。",[268,1150,1152],{"className":921,"code":1151,"language":923,"meta":277,"style":277},"schema_version: 1\n\nui:\n  theme: dark\n  language: zh-CN\n  \nwindow:\n  x: 120\n  y: 80\n  w: 1280\n  h: 800\n  maximized: false\n\nlast_selected:\n  model: \"deepseek\u002Fdeepseek-chat\"\n  page: \"\u002Fhome\"\n",[275,1153,1154,1162,1166,1173,1183,1193,1198,1205,1215,1225,1235,1245,1255,1259,1266,1276],{"__ignoreMap":277},[681,1155,1156,1158,1160],{"class":683,"line":684},[681,1157,931],{"class":930},[681,1159,935],{"class":934},[681,1161,939],{"class":938},[681,1163,1164],{"class":683,"line":690},[681,1165,964],{"emptyLinePlaceholder":963},[681,1167,1168,1171],{"class":683,"line":696},[681,1169,1170],{"class":930},"ui",[681,1172,947],{"class":934},[681,1174,1175,1178,1180],{"class":683,"line":702},[681,1176,1177],{"class":930},"  theme",[681,1179,935],{"class":934},[681,1181,1182],{"class":957},"dark\n",[681,1184,1185,1188,1190],{"class":683,"line":708},[681,1186,1187],{"class":930},"  language",[681,1189,935],{"class":934},[681,1191,1192],{"class":957},"zh-CN\n",[681,1194,1195],{"class":683,"line":714},[681,1196,1197],{"class":934},"  \n",[681,1199,1200,1203],{"class":683,"line":720},[681,1201,1202],{"class":930},"window",[681,1204,947],{"class":934},[681,1206,1207,1210,1212],{"class":683,"line":988},[681,1208,1209],{"class":930},"  x",[681,1211,935],{"class":934},[681,1213,1214],{"class":938},"120\n",[681,1216,1217,1220,1222],{"class":683,"line":999},[681,1218,1219],{"class":938},"  y",[681,1221,935],{"class":934},[681,1223,1224],{"class":938},"80\n",[681,1226,1227,1230,1232],{"class":683,"line":1010},[681,1228,1229],{"class":930},"  w",[681,1231,935],{"class":934},[681,1233,1234],{"class":938},"1280\n",[681,1236,1237,1240,1242],{"class":683,"line":1018},[681,1238,1239],{"class":930},"  h",[681,1241,935],{"class":934},[681,1243,1244],{"class":938},"800\n",[681,1246,1247,1250,1252],{"class":683,"line":1032},[681,1248,1249],{"class":930},"  maximized",[681,1251,935],{"class":934},[681,1253,1254],{"class":938},"false\n",[681,1256,1257],{"class":683,"line":1043},[681,1258,964],{"emptyLinePlaceholder":963},[681,1260,1261,1264],{"class":683,"line":1054},[681,1262,1263],{"class":930},"last_selected",[681,1265,947],{"class":934},[681,1267,1268,1271,1273],{"class":683,"line":1065},[681,1269,1270],{"class":930},"  model",[681,1272,935],{"class":934},[681,1274,1275],{"class":957},"\"deepseek\u002Fdeepseek-chat\"\n",[681,1277,1278,1281,1283],{"class":683,"line":1070},[681,1279,1280],{"class":930},"  page",[681,1282,935],{"class":934},[681,1284,1285],{"class":957},"\"\u002Fhome\"\n",[10,1287,1288],{},[170,1289,1290],{},"为什么要分离？",[10,1292,1293],{},"这两类数据的更新频率和影响范围完全不同：",[39,1295,1296,1299],{},[42,1297,1298],{},"user.yml 改动很少见（用户配置 API Key 时、添加新模型时），但一旦改动需要通知 Gateway 重新加载配置。用户可能几个月都不碰这个文件。",[42,1300,1301],{},"app-prefs.yml 改动很频繁（每次调整窗口大小、切换页面、改主题），但完全是本地状态，不影响 Gateway。",[10,1303,1304],{},"如果没有这个分层，每次用户调整窗口大小（1280×800 → 1920×1080）都会被记录到 user.yml，然后在下一次产品更新时被打包进去。这意味着文件内容频繁变化，增量更新时无法有效压缩（diff 算法看到的是文件内容变化，难以识别哪些是有意义的改动）。",[10,1306,1307],{},"分层后，只有 user.yml 的变化才需要在更新包中传输。假设统计 10000 个用户：",[39,1309,1310,1313,1316],{},[42,1311,1312],{},"其中 9500 个用户的 user.yml 一个月都没改（API Key 没变、模型配置没变）",[42,1314,1315],{},"只有 500 个用户改过配置",[42,1317,1318],{},"在这 500 个人中，改动大小平均只有 2-3KB（比如加一个新模型、禁用一个渠道）",[10,1320,1321],{},"增量更新时，这 9500 个用户的包甚至可以是 0 字节（user.yml 部分完全无变化）。前端资源（Vue.js + CSS）倒是会经常改动，但总量只有 250-80 = 330KB，即使完整传输也很小。",[28,1323,1324],{"id":1324},"分发成本的数字对比",[74,1326,1327,1341],{},[77,1328,1329],{},[80,1330,1331,1333,1336,1339],{},[83,1332,187],{},[83,1334,1335],{},"Electron 策略",[83,1337,1338],{},"Tauri 策略",[83,1340,568],{},[93,1342,1343,1360,1376,1392,1406],{},[80,1344,1345,1351,1354,1357],{},[98,1346,1347,1350],{},[170,1348,1349],{},"场景 1：前端 UI 改动","（改首页样式、加按钮）",[98,1352,1353],{},"完整 60 MB",[98,1355,1356],{},"前端资源 Δ ≈ 50 KB",[98,1358,1359],{},"-99.9%",[80,1361,1362,1368,1370,1373],{},[98,1363,1364,1367],{},[170,1365,1366],{},"场景 2：模型列表更新","（加新 provider）",[98,1369,1353],{},[98,1371,1372],{},"Rust Δ ≈ 0-2 MB",[98,1374,1375],{},"-96%",[80,1377,1378,1384,1386,1389],{},[98,1379,1380,1383],{},[170,1381,1382],{},"场景 3：Bug 修复","（修复一个 config 读写 bug）",[98,1385,1353],{},[98,1387,1388],{},"Rust Δ ≈ 100 KB",[98,1390,1391],{},"-99.8%",[80,1393,1394,1399,1401,1404],{},[98,1395,1396],{},[170,1397,1398],{},"场景 4：前端 + 后端同时改",[98,1400,1353],{},[98,1402,1403],{},"50 KB + 2 MB = 2 MB",[98,1405,1375],{},[80,1407,1408,1411,1413,1416],{},[98,1409,1410],{},"用户装 3 个版本（总成本）",[98,1412,220],{},[98,1414,1415],{},"20 + 0.05 + 2 = 22 MB",[98,1417,1418],{},"-87%",[10,1420,1421],{},"这里的 Tauri 增量假设采用了 differential patching（只传输二进制的变化，而非完整重新下载）。这个项目在 Plan 03（MVP）阶段还没有实装自动增量（技术债务在 Plan 04 的 debt-03-F 中记录），但架构上完全支持这种优化。",[10,1423,1424,1427],{},[170,1425,1426],{},"实装策略","（后续）：",[10,1429,1430,1431,1434,1435,1438],{},"用 ",[275,1432,1433],{},"diffpatch"," 或 ",[275,1436,1437],{},"rsync"," 算法生成二进制 delta 文件。即使 18MB 的 Rust binary 只改了 2MB 的代码，可以只传输那 2MB 的差异。对前端资源采用 Vite 的资源 hash 策略，只重新下载改动过的 JS\u002FCSS 文件。",[10,1440,1441],{},[170,1442,669],{},[10,1444,1445],{},"考虑过为 Electron 实装类似的增量更新（electron-updater 支持 delta 更新），但有两个问题：",[457,1447,1448,1454],{},[42,1449,1450,1453],{},[170,1451,1452],{},"基数过大","：即使差异只有 5-10%，增量也是 3-6MB。维护增量分发流程（计算 patch、上传到 CDN、验证签名）的工程成本相对收益不大。而 Tauri 的增量通常在 50KB-2MB 之间，工程复杂度一样，收益却 10 倍。",[42,1455,1456,1459],{},[170,1457,1458],{},"更新可靠性","：增量更新依赖于用户已有的旧版本文件完整无损。一旦用户的本地文件被损坏（磁盘错误、杀毒软件误删、手动修改等），增量更新就会失败，还得回退到完整下载。Tauri + 分层配置的方案更简单：完整下载一次 18MB（秒级），之后 95% 的更新都是资源级别的小补丁。",[14,1461,1463],{"id":1462},"理由四windows-系统集成更直接","理由四：Windows 系统集成更直接",[28,1465,1467],{"id":1466},"需要的-windows-能力清单","需要的 Windows 能力清单",[10,1469,1470],{},"配置中心需要与 Windows 操作系统进行多种交互，每一个都涉及底层 API：",[10,1472,1473],{},[170,1474,1475],{},"1. 进程管理与单实例约束",[10,1477,1478],{},"用户不应该能同时打开两个配置中心窗口。实现这个需要创建一个全局互斥量（named mutex），在进程启动时尝试获取，失败说明已有实例在运行：",[268,1480,1482],{"className":675,"code":1481,"language":677,"meta":277,"style":277},"use windows::Win32::System::Threading::CreateMutexW;\nuse windows::core::HSTRING;\n\nlet mutex_name = format!(\"Global\\\\openclaw-tauri-singleton-{}\", &usb_hash[..8]);\nlet mutex = CreateMutexW(\n    None,                          \u002F\u002F lpMutexAttributes\n    false,                         \u002F\u002F bInitialOwner (not initially owned)\n    &HSTRING::from(mutex_name)\n)?;\n\nmatch GetLastError() {\n    0 => {\n        \u002F\u002F 互斥量创建成功，说明这是第一个实例\n        \u002F\u002F 创建命名管道监听其他实例的信号\n    }\n    ERROR_ALREADY_EXISTS => {\n        \u002F\u002F 互斥量已存在，说明有实例在运行\n        \u002F\u002F 连接管道，发送 \"RAISE\" 信号给已有实例\n        \u002F\u002F 然后退出\n    }\n}\n",[275,1483,1484,1489,1494,1498,1503,1508,1513,1518,1523,1528,1532,1537,1542,1547,1552,1557,1562,1567,1572,1577,1581],{"__ignoreMap":277},[681,1485,1486],{"class":683,"line":684},[681,1487,1488],{},"use windows::Win32::System::Threading::CreateMutexW;\n",[681,1490,1491],{"class":683,"line":690},[681,1492,1493],{},"use windows::core::HSTRING;\n",[681,1495,1496],{"class":683,"line":696},[681,1497,964],{"emptyLinePlaceholder":963},[681,1499,1500],{"class":683,"line":702},[681,1501,1502],{},"let mutex_name = format!(\"Global\\\\openclaw-tauri-singleton-{}\", &usb_hash[..8]);\n",[681,1504,1505],{"class":683,"line":708},[681,1506,1507],{},"let mutex = CreateMutexW(\n",[681,1509,1510],{"class":683,"line":714},[681,1511,1512],{},"    None,                          \u002F\u002F lpMutexAttributes\n",[681,1514,1515],{"class":683,"line":720},[681,1516,1517],{},"    false,                         \u002F\u002F bInitialOwner (not initially owned)\n",[681,1519,1520],{"class":683,"line":988},[681,1521,1522],{},"    &HSTRING::from(mutex_name)\n",[681,1524,1525],{"class":683,"line":999},[681,1526,1527],{},")?;\n",[681,1529,1530],{"class":683,"line":1010},[681,1531,964],{"emptyLinePlaceholder":963},[681,1533,1534],{"class":683,"line":1018},[681,1535,1536],{},"match GetLastError() {\n",[681,1538,1539],{"class":683,"line":1032},[681,1540,1541],{},"    0 => {\n",[681,1543,1544],{"class":683,"line":1043},[681,1545,1546],{},"        \u002F\u002F 互斥量创建成功，说明这是第一个实例\n",[681,1548,1549],{"class":683,"line":1054},[681,1550,1551],{},"        \u002F\u002F 创建命名管道监听其他实例的信号\n",[681,1553,1554],{"class":683,"line":1065},[681,1555,1556],{},"    }\n",[681,1558,1559],{"class":683,"line":1070},[681,1560,1561],{},"    ERROR_ALREADY_EXISTS => {\n",[681,1563,1564],{"class":683,"line":1078},[681,1565,1566],{},"        \u002F\u002F 互斥量已存在，说明有实例在运行\n",[681,1568,1569],{"class":683,"line":1098},[681,1570,1571],{},"        \u002F\u002F 连接管道，发送 \"RAISE\" 信号给已有实例\n",[681,1573,1574],{"class":683,"line":1115},[681,1575,1576],{},"        \u002F\u002F 然后退出\n",[681,1578,1579],{"class":683,"line":1120},[681,1580,1556],{},[681,1582,1583],{"class":683,"line":1128},[681,1584,723],{},[10,1586,1587],{},"如果用 Electron 做这个，需要一个原生模块。Node.js 本身没有直接的 CreateMutexW 绑定。有几种选择：",[39,1589,1590,1597,1600],{},[42,1591,1592,1593,1596],{},"npm 包 ",[275,1594,1595],{},"windows-mutex","（社区维护，成熟度不明）",[42,1598,1599],{},"node-gyp + C++ 自己写（编译复杂，维护成本高）",[42,1601,1602],{},"node-ffi（动态调用 DLL，运行时性能损失）",[10,1604,1605],{},"都不如直接 Rust 调用来得清爽。",[10,1607,1608],{},[170,1609,1610],{},"2. 进程间通信（IPC）",[10,1612,1613],{},"当用户第二次启动配置中心时（实例已存在），新进程需要通知旧进程：\"嘿，用户又点了一次，请把窗口拉到前台\"。实现这个用 Windows 命名管道（Named Pipe）：",[268,1615,1617],{"className":675,"code":1616,"language":677,"meta":277,"style":277},"use windows::Win32::Storage::FileSystem::{CreateNamedPipeW, ConnectNamedPipe};\nuse windows::core::HSTRING;\n\nlet pipe_name = format!(\"\\\\\\\\?\\\\pipe\\\\openclaw-tauri-{}\", &usb_hash[..8]);\n\n\u002F\u002F 主实例：创建管道并监听\nlet pipe = CreateNamedPipeW(\n    &HSTRING::from(&pipe_name),\n    PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED,  \u002F\u002F 双向 + 异步\n    PIPE_TYPE_BYTE,\n    4,                                           \u002F\u002F maxInstances\n    0, 0, None\n)?;\n\n\u002F\u002F 在 tokio task 中持续监听\ntokio::spawn(async move {\n    loop {\n        if ConnectNamedPipe(pipe, None).is_ok() {\n            \u002F\u002F 有其他实例连接了，读取它的消息\n            \u002F\u002F 消息是 \"RAISE\"，表示要求拉起窗口\n            \n            \u002F\u002F 通过 Tauri AppHandle 调用前端事件\n            app_handle.emit_all(\"window-raise-requested\", ())?;\n            \n            \u002F\u002F 前端收到事件后调用 webview.set_focus() 和 webview.show()\n        }\n    }\n});\n\n\u002F\u002F 二次实例：连接管道并发送信号\nConnectNamedPipeW(\n    &HSTRING::from(&pipe_name),\n    GENERIC_WRITE,\n    None\n)?;\nWriteFile(pipe, b\"RAISE\", None)?;\n\u002F\u002F 然后退出进程\n",[275,1618,1619,1624,1628,1632,1637,1641,1646,1651,1656,1661,1666,1671,1676,1680,1684,1689,1694,1699,1704,1709,1714,1719,1725,1731,1736,1742,1748,1753,1759,1764,1770,1776,1781,1787,1793,1798,1804],{"__ignoreMap":277},[681,1620,1621],{"class":683,"line":684},[681,1622,1623],{},"use windows::Win32::Storage::FileSystem::{CreateNamedPipeW, ConnectNamedPipe};\n",[681,1625,1626],{"class":683,"line":690},[681,1627,1493],{},[681,1629,1630],{"class":683,"line":696},[681,1631,964],{"emptyLinePlaceholder":963},[681,1633,1634],{"class":683,"line":702},[681,1635,1636],{},"let pipe_name = format!(\"\\\\\\\\?\\\\pipe\\\\openclaw-tauri-{}\", &usb_hash[..8]);\n",[681,1638,1639],{"class":683,"line":708},[681,1640,964],{"emptyLinePlaceholder":963},[681,1642,1643],{"class":683,"line":714},[681,1644,1645],{},"\u002F\u002F 主实例：创建管道并监听\n",[681,1647,1648],{"class":683,"line":720},[681,1649,1650],{},"let pipe = CreateNamedPipeW(\n",[681,1652,1653],{"class":683,"line":988},[681,1654,1655],{},"    &HSTRING::from(&pipe_name),\n",[681,1657,1658],{"class":683,"line":999},[681,1659,1660],{},"    PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED,  \u002F\u002F 双向 + 异步\n",[681,1662,1663],{"class":683,"line":1010},[681,1664,1665],{},"    PIPE_TYPE_BYTE,\n",[681,1667,1668],{"class":683,"line":1018},[681,1669,1670],{},"    4,                                           \u002F\u002F maxInstances\n",[681,1672,1673],{"class":683,"line":1032},[681,1674,1675],{},"    0, 0, None\n",[681,1677,1678],{"class":683,"line":1043},[681,1679,1527],{},[681,1681,1682],{"class":683,"line":1054},[681,1683,964],{"emptyLinePlaceholder":963},[681,1685,1686],{"class":683,"line":1065},[681,1687,1688],{},"\u002F\u002F 在 tokio task 中持续监听\n",[681,1690,1691],{"class":683,"line":1070},[681,1692,1693],{},"tokio::spawn(async move {\n",[681,1695,1696],{"class":683,"line":1078},[681,1697,1698],{},"    loop {\n",[681,1700,1701],{"class":683,"line":1098},[681,1702,1703],{},"        if ConnectNamedPipe(pipe, None).is_ok() {\n",[681,1705,1706],{"class":683,"line":1115},[681,1707,1708],{},"            \u002F\u002F 有其他实例连接了，读取它的消息\n",[681,1710,1711],{"class":683,"line":1120},[681,1712,1713],{},"            \u002F\u002F 消息是 \"RAISE\"，表示要求拉起窗口\n",[681,1715,1716],{"class":683,"line":1128},[681,1717,1718],{},"            \n",[681,1720,1722],{"class":683,"line":1721},22,[681,1723,1724],{},"            \u002F\u002F 通过 Tauri AppHandle 调用前端事件\n",[681,1726,1728],{"class":683,"line":1727},23,[681,1729,1730],{},"            app_handle.emit_all(\"window-raise-requested\", ())?;\n",[681,1732,1734],{"class":683,"line":1733},24,[681,1735,1718],{},[681,1737,1739],{"class":683,"line":1738},25,[681,1740,1741],{},"            \u002F\u002F 前端收到事件后调用 webview.set_focus() 和 webview.show()\n",[681,1743,1745],{"class":683,"line":1744},26,[681,1746,1747],{},"        }\n",[681,1749,1751],{"class":683,"line":1750},27,[681,1752,1556],{},[681,1754,1756],{"class":683,"line":1755},28,[681,1757,1758],{},"});\n",[681,1760,1762],{"class":683,"line":1761},29,[681,1763,964],{"emptyLinePlaceholder":963},[681,1765,1767],{"class":683,"line":1766},30,[681,1768,1769],{},"\u002F\u002F 二次实例：连接管道并发送信号\n",[681,1771,1773],{"class":683,"line":1772},31,[681,1774,1775],{},"ConnectNamedPipeW(\n",[681,1777,1779],{"class":683,"line":1778},32,[681,1780,1655],{},[681,1782,1784],{"class":683,"line":1783},33,[681,1785,1786],{},"    GENERIC_WRITE,\n",[681,1788,1790],{"class":683,"line":1789},34,[681,1791,1792],{},"    None\n",[681,1794,1796],{"class":683,"line":1795},35,[681,1797,1527],{},[681,1799,1801],{"class":683,"line":1800},36,[681,1802,1803],{},"WriteFile(pipe, b\"RAISE\", None)?;\n",[681,1805,1807],{"class":683,"line":1806},37,[681,1808,1809],{},"\u002F\u002F 然后退出进程\n",[10,1811,1812],{},"Electron 中要实现同样的效果，又要用到原生模块。Windows Named Pipe 不在 Node.js 的标准库中。通常的做法是：",[39,1814,1815,1821,1827],{},[42,1816,1592,1817,1820],{},[275,1818,1819],{},"node-ipc","（跨平台，但主要用 TCP socket，不用 Named Pipe）",[42,1822,1592,1823,1826],{},[275,1824,1825],{},"named-pipe","（仅 Windows，但星数少，维护度不明）",[42,1828,1829],{},"node-gyp 自己写（复杂）",[10,1831,1832],{},"而且很难保证这些包与 Tauri 的兼容性和性能。Tauri 用 Rust 后端的好处就是不用担心这些。",[10,1834,1835],{},[170,1836,1837],{},"3. WebView2 运行时检测与版本管理",[10,1839,1840],{},"在启动前检查系统是否安装了 WebView2，以及版本号是否足够新（≥ v90）。这需要查询 Windows 注册表：",[268,1842,1844],{"className":675,"code":1843,"language":677,"meta":277,"style":277},"use windows::Win32::System::Registry::*;\nuse windows::core::*;\n\n\u002F\u002F 打开注册表键\nlet hkey_local_machine = HKEY_LOCAL_MACHINE;\nlet subkey = w!(\"Software\\\\WOW6432Node\\\\Microsoft\\\\EdgeUpdate\\\\ClientState\\\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}\");\n\nlet mut key: HKEY = Default::default();\nRegOpenKeyExW(hkey_local_machine, subkey, 0, KEY_QUERY_VALUE, &mut key)?;\n\n\u002F\u002F 读取版本号\nlet mut version = [0u16; 256];\nlet mut size = std::mem::size_of_val(&version) as u32;\nRegQueryValueExW(key, w!(\"pv\"), None, None, Some(version.as_mut_ptr() as *mut _), Some(&mut size))?;\n\n\u002F\u002F 将 UTF-16 转换为字符串，解析版本号\nlet version_str = String::from_utf16(&version[..size as usize \u002F 2])\n    .unwrap_or_default();\n\nlet major_version = version_str.split('.').next()\n    .and_then(|s| s.parse::\u003Ci32>().ok())\n    .unwrap_or(0);\n\nif major_version \u003C 90 {\n    \u002F\u002F 版本太旧，提示用户下载\n}\n",[275,1845,1846,1851,1856,1860,1865,1870,1875,1879,1884,1889,1893,1898,1903,1908,1913,1917,1922,1927,1932,1936,1941,1946,1951,1955,1960,1965],{"__ignoreMap":277},[681,1847,1848],{"class":683,"line":684},[681,1849,1850],{},"use windows::Win32::System::Registry::*;\n",[681,1852,1853],{"class":683,"line":690},[681,1854,1855],{},"use windows::core::*;\n",[681,1857,1858],{"class":683,"line":696},[681,1859,964],{"emptyLinePlaceholder":963},[681,1861,1862],{"class":683,"line":702},[681,1863,1864],{},"\u002F\u002F 打开注册表键\n",[681,1866,1867],{"class":683,"line":708},[681,1868,1869],{},"let hkey_local_machine = HKEY_LOCAL_MACHINE;\n",[681,1871,1872],{"class":683,"line":714},[681,1873,1874],{},"let subkey = w!(\"Software\\\\WOW6432Node\\\\Microsoft\\\\EdgeUpdate\\\\ClientState\\\\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}\");\n",[681,1876,1877],{"class":683,"line":720},[681,1878,964],{"emptyLinePlaceholder":963},[681,1880,1881],{"class":683,"line":988},[681,1882,1883],{},"let mut key: HKEY = Default::default();\n",[681,1885,1886],{"class":683,"line":999},[681,1887,1888],{},"RegOpenKeyExW(hkey_local_machine, subkey, 0, KEY_QUERY_VALUE, &mut key)?;\n",[681,1890,1891],{"class":683,"line":1010},[681,1892,964],{"emptyLinePlaceholder":963},[681,1894,1895],{"class":683,"line":1018},[681,1896,1897],{},"\u002F\u002F 读取版本号\n",[681,1899,1900],{"class":683,"line":1032},[681,1901,1902],{},"let mut version = [0u16; 256];\n",[681,1904,1905],{"class":683,"line":1043},[681,1906,1907],{},"let mut size = std::mem::size_of_val(&version) as u32;\n",[681,1909,1910],{"class":683,"line":1054},[681,1911,1912],{},"RegQueryValueExW(key, w!(\"pv\"), None, None, Some(version.as_mut_ptr() as *mut _), Some(&mut size))?;\n",[681,1914,1915],{"class":683,"line":1065},[681,1916,964],{"emptyLinePlaceholder":963},[681,1918,1919],{"class":683,"line":1070},[681,1920,1921],{},"\u002F\u002F 将 UTF-16 转换为字符串，解析版本号\n",[681,1923,1924],{"class":683,"line":1078},[681,1925,1926],{},"let version_str = String::from_utf16(&version[..size as usize \u002F 2])\n",[681,1928,1929],{"class":683,"line":1098},[681,1930,1931],{},"    .unwrap_or_default();\n",[681,1933,1934],{"class":683,"line":1115},[681,1935,964],{"emptyLinePlaceholder":963},[681,1937,1938],{"class":683,"line":1120},[681,1939,1940],{},"let major_version = version_str.split('.').next()\n",[681,1942,1943],{"class":683,"line":1128},[681,1944,1945],{},"    .and_then(|s| s.parse::\u003Ci32>().ok())\n",[681,1947,1948],{"class":683,"line":1721},[681,1949,1950],{},"    .unwrap_or(0);\n",[681,1952,1953],{"class":683,"line":1727},[681,1954,964],{"emptyLinePlaceholder":963},[681,1956,1957],{"class":683,"line":1733},[681,1958,1959],{},"if major_version \u003C 90 {\n",[681,1961,1962],{"class":683,"line":1738},[681,1963,1964],{},"    \u002F\u002F 版本太旧，提示用户下载\n",[681,1966,1967],{"class":683,"line":1744},[681,1968,723],{},[10,1970,1971],{},"Electron 中查注册表也要用原生模块或 node-gyp 脚本。而且 Node.js 访问注册表的方式都不够优雅。",[10,1973,1974],{},[170,1975,1976],{},"4. 外部程序启动（浏览器打开）",[10,1978,1979],{},"用户点\"打开 WebUI\"按钮，应该用系统默认浏览器打开 Gateway 的地址。在 Windows 上用 ShellExecuteW：",[268,1981,1983],{"className":675,"code":1982,"language":677,"meta":277,"style":277},"use windows::Win32::System::Com::ShellExecuteW;\nuse windows::core::*;\n\nShellExecuteW(\n    None,                                           \u002F\u002F hwnd\n    w!(\"open\"),                                     \u002F\u002F lpOperation\n    w!(\"http:\u002F\u002F127.0.0.1:53721\"),                  \u002F\u002F lpFile (URL)\n    None,                                           \u002F\u002F lpParameters\n    None,                                           \u002F\u002F lpDirectory\n    windows::Win32::System::Com::SW_SHOW\n)?;\n",[275,1984,1985,1990,1994,1998,2003,2008,2013,2018,2023,2028,2033],{"__ignoreMap":277},[681,1986,1987],{"class":683,"line":684},[681,1988,1989],{},"use windows::Win32::System::Com::ShellExecuteW;\n",[681,1991,1992],{"class":683,"line":690},[681,1993,1855],{},[681,1995,1996],{"class":683,"line":696},[681,1997,964],{"emptyLinePlaceholder":963},[681,1999,2000],{"class":683,"line":702},[681,2001,2002],{},"ShellExecuteW(\n",[681,2004,2005],{"class":683,"line":708},[681,2006,2007],{},"    None,                                           \u002F\u002F hwnd\n",[681,2009,2010],{"class":683,"line":714},[681,2011,2012],{},"    w!(\"open\"),                                     \u002F\u002F lpOperation\n",[681,2014,2015],{"class":683,"line":720},[681,2016,2017],{},"    w!(\"http:\u002F\u002F127.0.0.1:53721\"),                  \u002F\u002F lpFile (URL)\n",[681,2019,2020],{"class":683,"line":988},[681,2021,2022],{},"    None,                                           \u002F\u002F lpParameters\n",[681,2024,2025],{"class":683,"line":999},[681,2026,2027],{},"    None,                                           \u002F\u002F lpDirectory\n",[681,2029,2030],{"class":683,"line":1010},[681,2031,2032],{},"    windows::Win32::System::Com::SW_SHOW\n",[681,2034,2035],{"class":683,"line":1018},[681,2036,1527],{},[10,2038,2039],{},"这会调用系统的 URL 处理器，用用户设置的默认浏览器打开链接。非常可靠。",[10,2041,2042,2043,2046,2047,2050],{},"Electron 中可以用 ",[275,2044,2045],{},"child_process.exec('start http:\u002F\u002F...')","，但这是调用系统 shell，不如直接 ShellExecuteW 可靠。更好的方案是 npm 包 ",[275,2048,2049],{},"open","，但又多了一个依赖，还要担心它的维护状态。",[28,2052,2054],{"id":2053},"tauri-的系统集成优势","Tauri 的系统集成优势",[10,2056,2057],{},"用 Tauri + Rust 后端的好处是：所有这些 Windows API 都直接可用，通过 windows-rs crate。无需额外的原生模块编译、无需第三方包的兼容性顾虑。",[268,2059,2063],{"className":2060,"code":2061,"language":2062,"meta":277,"style":277},"language-toml shiki shiki-themes github-light github-dark","[dependencies]\nwindows = { version = \"0.52\", features = [\n    \"Win32_System_Threading\",      \u002F\u002F CreateMutexW\n    \"Win32_Storage_FileSystem\",    \u002F\u002F CreateNamedPipeW\n    \"Win32_System_Registry\",       \u002F\u002F RegOpenKeyExW\n    \"Win32_System_Com\"             \u002F\u002F ShellExecuteW\n] }\n","toml",[275,2064,2065,2070,2075,2083,2091,2099,2107],{"__ignoreMap":277},[681,2066,2067],{"class":683,"line":684},[681,2068,2069],{},"[dependencies]\n",[681,2071,2072],{"class":683,"line":690},[681,2073,2074],{},"windows = { version = \"0.52\", features = [\n",[681,2076,2077,2080],{"class":683,"line":696},[681,2078,2079],{},"    \"Win32_System_Threading\",",[681,2081,2082],{},"      \u002F\u002F CreateMutexW\n",[681,2084,2085,2088],{"class":683,"line":702},[681,2086,2087],{},"    \"Win32_Storage_FileSystem\",",[681,2089,2090],{},"    \u002F\u002F CreateNamedPipeW\n",[681,2092,2093,2096],{"class":683,"line":708},[681,2094,2095],{},"    \"Win32_System_Registry\",",[681,2097,2098],{},"       \u002F\u002F RegOpenKeyExW\n",[681,2100,2101,2104],{"class":683,"line":714},[681,2102,2103],{},"    \"Win32_System_Com\"",[681,2105,2106],{},"             \u002F\u002F ShellExecuteW\n",[681,2108,2109],{"class":683,"line":720},[681,2110,2111],{},"] }\n",[10,2113,2114],{},"一个 Cargo.toml 的配置，所有 Win32 API 都在手边。调用时就是普通的 Rust 代码，IDE 有完整的类型检查和代码补全。性能上也没有任何中间层开销——直接调用系统 API。",[10,2116,2117],{},"windows-rs 是 Microsoft 官方维护的库，API coverage 和更新速度都很快。比依赖社区维护的小 npm 包可靠得多。",[28,2119,2120],{"id":2120},"调试和维护的成本差异",[10,2122,2123],{},"遇到 Windows 系统级的 Bug 时，两种方案的成本差异很大：",[10,2125,2126,2129],{},[170,2127,2128],{},"Tauri 方案","（问题快速修复）：",[457,2131,2132,2142,2145,2148,2155],{},[42,2133,2134,2135,1434,2138,2141],{},"在 ",[275,2136,2137],{},"single_instance.rs",[275,2139,2140],{},"gateway_proxy.rs"," 中加 debug 日志",[42,2143,2144],{},"用 eprintln! 或 log crate 打印详细信息",[42,2146,2147],{},"本地测试或连接远程 Windows 机器调试（RDP）",[42,2149,2150,2151,2154],{},"修改 Rust 代码，",[275,2152,2153],{},"cargo build"," 重新编译（秒级）",[42,2156,2157],{},"0 额外依赖，0 兼容性问题",[10,2159,2160,2163],{},[170,2161,2162],{},"Electron + native module 方案","（修复慢且容易出问题）：",[457,2165,2166,2169,2172,2175,2178,2181,2184],{},[42,2167,2168],{},"在 C++\u002FRust 代码中加日志",[42,2170,2171],{},"用 node-gyp rebuild 重新编译（分钟级）",[42,2173,2174],{},"检查 npm 版本、Node 版本是否匹配",[42,2176,2177],{},"本地测试通过后，上传新的 prebuilt binary 到 npm registry",[42,2179,2180],{},"通知用户升级 npm 依赖，等待 npm install 下载新版本",[42,2182,2183],{},"可能需要等待用户反馈是否真的修复了",[42,2185,2186],{},"如果问题涉及 Node 或 Electron 版本，可能需要多个版本的 binary",[10,2188,2189],{},"调试一个互斥量或管道相关的 Windows Bug，Tauri 方案可能 1-2 小时搞定，Electron 方案可能要 1-2 天（包括编译、上传、用户反馈周期）。",[14,2191,2192],{"id":2192},"代价与现实",[10,2194,2195],{},"选择 Tauri 不是银弹。三个代价必须正视、明确记录：",[28,2197,2199],{"id":2198},"代价一前端生态成熟度低于-electron社区库工具文档","代价一：前端生态成熟度低于 Electron（社区库、工具、文档）",[10,2201,2202],{},"Electron 有 10+ 年的积累，社区工具、UI 库、启动模板、学习资料极其丰富。Tauri 是后来者，虽然发展快速（v2.0 在 2024 年发布），但生态仍在追赶阶段。",[10,2204,2205,547],{},[170,2206,2207],{},"具体体现",[39,2209,2210,2216,2222,2228],{},[42,2211,2212,2215],{},[170,2213,2214],{},"UI 组件库","：Electron 生态有 electron-react-boilerplate、electron-vue 等现成启动项目，开箱即用。Tauri 则需要自己选择前端框架（React \u002F Vue \u002F Svelte），然后手工集成 Tauri 框架。",[42,2217,2218,2221],{},[170,2219,2220],{},"设计系统","：基于 Electron 的大型应用（VS Code、Slack、Figma）都有完整的设计系统文档公开。Tauri 应用还相对少。",[42,2223,2224,2227],{},[170,2225,2226],{},"问题解答","：Stack Overflow 搜 Electron 问题，通常有现成解答。搜 Tauri 问题时经常找不到人遇过同样的坑。",[42,2229,2230,2233],{},[170,2231,2232],{},"第三方集成","：很多 SaaS 工具（Sentry、Segment、Logrocket）都有 Electron 集成，Tauri 支持相对较弱。",[10,2235,2236],{},"这个项目的应对策略是自建组件库，不依赖第三方 UI 框架。具体规划是：",[268,2238,2241],{"className":2239,"code":2240,"language":273},[271],"自建 16 个组件库\n├─ 基础控件（AppButton、AppInput、AppToggle、AppRadio）\n├─ 反馈组件（AppStatusBadge、AppStatusDot、AppToast、AppModal）\n├─ 容器组件（AppCard、AppGlassPanel、AppPageHeader）\n└─ 布局组件（AppBentoGrid、AppSectionGroup）\n\n工作量估算：\n├─ 设计 token 体系（Tailwind + Material Design 3）：2 天\n├─ 16 个组件实装 + 单测：6 天\n├─ 5 个业务页面集成这些组件：3 天\n└─ 总计：11 天（占 Plan 03 总工期 25 天的 44%）\n",[275,2242,2240],{"__ignoreMap":277},[10,2244,2245],{},"优点：",[39,2247,2248,2251,2254,2257],{},[42,2249,2250],{},"完全掌控 UI 外观和行为",[42,2252,2253],{},"与 Tauri 的集成 100% 可靠（没有第三方包的兼容性问题）",[42,2255,2256],{},"日后维护和修改只需改自己的代码",[42,2258,2259],{},"可以为特定功能优化（比如 Material Design 3 的暗黑主题优化）",[10,2261,2262],{},"缺点：",[39,2264,2265,2268,2271],{},[42,2266,2267],{},"工作量大（44% 的工期用在组件库）",[42,2269,2270],{},"不能复用成熟的设计系统（比如 Ant Design、shadcn\u002Fui）",[42,2272,2273],{},"团队成员需要熟悉 Vue 3 + Tailwind，学习曲线更陡",[10,2275,2276,547],{},[170,2277,2278],{},"适用场景",[39,2280,2281,2284],{},[42,2282,2283],{},"小到中等规模应用（5-20 个页面）：自建组件库是合理的投资",[42,2285,2286],{},"大型应用（50+ 页面、复杂交互）：可能需要重新评估，找现成的 Tauri-compatible UI 库或投入更多资源",[28,2288,2290],{"id":2289},"代价二webview2-版本差异带来的兼容性问题","代价二：WebView2 版本差异带来的兼容性问题",[10,2292,2293],{},"Electron 内置特定版本的 Chromium，所有用户体验完全一致。WebView2 则取决于用户机器上安装的版本。这引入了环境差异：",[10,2295,2296,547],{},[170,2297,2298],{},"版本时间线与分布",[39,2300,2301,2304,2307],{},[42,2302,2303],{},"Windows 11：内置 WebView2（版本通常是最新的或接近最新）",[42,2305,2306],{},"Windows 10：需要从 Microsoft 下载（版本取决于用户是否启用 auto-update）",[42,2308,2309],{},"非常旧的 Windows 版本（Win7、Win8）：不支持 WebView2",[10,2311,2312,547],{},[170,2313,2314],{},"兼容性风险",[10,2316,2317],{},"不同版本的 WebView2 对新 CSS 特性、JavaScript API、性能特征的支持差异很大。例如：",[74,2319,2320,2333],{},[77,2321,2322],{},[80,2323,2324,2327,2330],{},[83,2325,2326],{},"特性",[83,2328,2329],{},"最低版本",[83,2331,2332],{},"常见问题",[93,2334,2335,2350,2361,2375,2391],{},[80,2336,2337,2344,2347],{},[98,2338,2339,2340,2343],{},"CSS Grid ",[275,2341,2342],{},"gap"," 属性",[98,2345,2346],{},"v87",[98,2348,2349],{},"v86 及以下需要用 margin\u002Fpadding",[80,2351,2352,2355,2358],{},[98,2353,2354],{},"CSS Subgrid",[98,2356,2357],{},"v101",[98,2359,2360],{},"对于复杂布局有 workaround",[80,2362,2363,2369,2372],{},[98,2364,2365,2366],{},"JS ",[275,2367,2368],{},"Promise.allSettled()",[98,2370,2371],{},"v85",[98,2373,2374],{},"老版本需要 polyfill",[80,2376,2377,2382,2385],{},[98,2378,2365,2379],{},[275,2380,2381],{},"globalThis",[98,2383,2384],{},"v83",[98,2386,2387,2388,2390],{},"v82 及以下用 ",[275,2389,1202],{}," 代替",[80,2392,2393,2396,2399],{},[98,2394,2395],{},"SharedArrayBuffer",[98,2397,2398],{},"v91",[98,2400,2401],{},"Web Worker 中的高性能共享内存",[10,2403,2404],{},"这个项目设置的最低版本是 v90，意味着：",[39,2406,2407,2410,2413],{},[42,2408,2409],{},"可以用绝大多数现代 CSS 特性",[42,2411,2412],{},"可以用 ES2021 特性",[42,2414,2415],{},"但要避免最新的 v120+ 才有的特性",[10,2417,2418,547],{},[170,2419,2420],{},"对 CSS\u002FJS 开发的影响",[457,2422,2423,2426,2429,2432],{},[42,2424,2425],{},"需要在构建流程中加 polyfill 和 transpile 步骤",[42,2427,2428],{},"某些新 CSS 特性需要 fallback（比如 CSS Anchor Positioning 在 v125+ 才支持，需要降级方案）",[42,2430,2431],{},"测试矩阵需要覆盖多个 WebView2 版本",[42,2433,2434],{},"用户首次启动时可能遇到环境问题，需要友好的错误提示",[10,2436,2437],{},[170,2438,2439],{},"示例：最小兼容性处理",[268,2441,2445],{"className":2442,"code":2443,"language":2444,"meta":277,"style":277},"language-typescript shiki shiki-themes github-light github-dark","\u002F\u002F services\u002Fcompatibility.ts\nexport const features = {\n  cssGap: () => {\n    \u002F\u002F 检测浏览器是否支持 CSS Gap\n    const el = document.createElement('div');\n    el.style.gap = '1px';\n    return el.style.gap !== '';\n  },\n  \n  promiseAllSettled: () => {\n    return typeof Promise.allSettled === 'function';\n  }\n};\n\n\u002F\u002F 在应用启动时检查\nif (!features.cssGap()) {\n  \u002F\u002F 加载 polyfill 或使用 margin-based layout\n  document.body.classList.add('no-css-gap');\n}\n","typescript",[275,2446,2447,2453,2471,2485,2490,2515,2529,2545,2550,2554,2565,2586,2591,2596,2600,2605,2625,2630,2645],{"__ignoreMap":277},[681,2448,2449],{"class":683,"line":684},[681,2450,2452],{"class":2451},"sJ8bj","\u002F\u002F services\u002Fcompatibility.ts\n",[681,2454,2455,2459,2462,2465,2468],{"class":683,"line":690},[681,2456,2458],{"class":2457},"szBVR","export",[681,2460,2461],{"class":2457}," const",[681,2463,2464],{"class":938}," features",[681,2466,2467],{"class":2457}," =",[681,2469,2470],{"class":934}," {\n",[681,2472,2473,2477,2480,2483],{"class":683,"line":696},[681,2474,2476],{"class":2475},"sScJk","  cssGap",[681,2478,2479],{"class":934},": () ",[681,2481,2482],{"class":2457},"=>",[681,2484,2470],{"class":934},[681,2486,2487],{"class":683,"line":702},[681,2488,2489],{"class":2451},"    \u002F\u002F 检测浏览器是否支持 CSS Gap\n",[681,2491,2492,2495,2498,2500,2503,2506,2509,2512],{"class":683,"line":708},[681,2493,2494],{"class":2457},"    const",[681,2496,2497],{"class":938}," el",[681,2499,2467],{"class":2457},[681,2501,2502],{"class":934}," document.",[681,2504,2505],{"class":2475},"createElement",[681,2507,2508],{"class":934},"(",[681,2510,2511],{"class":957},"'div'",[681,2513,2514],{"class":934},");\n",[681,2516,2517,2520,2523,2526],{"class":683,"line":714},[681,2518,2519],{"class":934},"    el.style.gap ",[681,2521,2522],{"class":2457},"=",[681,2524,2525],{"class":957}," '1px'",[681,2527,2528],{"class":934},";\n",[681,2530,2531,2534,2537,2540,2543],{"class":683,"line":720},[681,2532,2533],{"class":2457},"    return",[681,2535,2536],{"class":934}," el.style.gap ",[681,2538,2539],{"class":2457},"!==",[681,2541,2542],{"class":957}," ''",[681,2544,2528],{"class":934},[681,2546,2547],{"class":683,"line":988},[681,2548,2549],{"class":934},"  },\n",[681,2551,2552],{"class":683,"line":999},[681,2553,1197],{"class":934},[681,2555,2556,2559,2561,2563],{"class":683,"line":1010},[681,2557,2558],{"class":2475},"  promiseAllSettled",[681,2560,2479],{"class":934},[681,2562,2482],{"class":2457},[681,2564,2470],{"class":934},[681,2566,2567,2569,2572,2575,2578,2581,2584],{"class":683,"line":1018},[681,2568,2533],{"class":2457},[681,2570,2571],{"class":2457}," typeof",[681,2573,2574],{"class":938}," Promise",[681,2576,2577],{"class":934},".allSettled ",[681,2579,2580],{"class":2457},"===",[681,2582,2583],{"class":957}," 'function'",[681,2585,2528],{"class":934},[681,2587,2588],{"class":683,"line":1032},[681,2589,2590],{"class":934},"  }\n",[681,2592,2593],{"class":683,"line":1043},[681,2594,2595],{"class":934},"};\n",[681,2597,2598],{"class":683,"line":1054},[681,2599,964],{"emptyLinePlaceholder":963},[681,2601,2602],{"class":683,"line":1065},[681,2603,2604],{"class":2451},"\u002F\u002F 在应用启动时检查\n",[681,2606,2607,2610,2613,2616,2619,2622],{"class":683,"line":1070},[681,2608,2609],{"class":2457},"if",[681,2611,2612],{"class":934}," (",[681,2614,2615],{"class":2457},"!",[681,2617,2618],{"class":934},"features.",[681,2620,2621],{"class":2475},"cssGap",[681,2623,2624],{"class":934},"()) {\n",[681,2626,2627],{"class":683,"line":1078},[681,2628,2629],{"class":2451},"  \u002F\u002F 加载 polyfill 或使用 margin-based layout\n",[681,2631,2632,2635,2638,2640,2643],{"class":683,"line":1098},[681,2633,2634],{"class":934},"  document.body.classList.",[681,2636,2637],{"class":2475},"add",[681,2639,2508],{"class":934},[681,2641,2642],{"class":957},"'no-css-gap'",[681,2644,2514],{"class":934},[681,2646,2647],{"class":683,"line":1115},[681,2648,723],{"class":934},[10,2650,2651],{},"这个项目中的策略是：",[39,2653,2654,2657,2660,2663],{},[42,2655,2656],{},"用 Vite + TypeScript 做编译时检查",[42,2658,2659],{},"用 Tailwind CSS 的兼容性模式",[42,2661,2662],{},"用 @vitejs\u002Fplugin-legacy 处理 ES 新特性",[42,2664,2665],{},"在运行时检查某些关键特性，不支持时给出明确提示",[28,2667,2669],{"id":2668},"代价三社区方案远少于-electron依赖库第三方集成","代价三：社区方案远少于 Electron（依赖库、第三方集成）",[10,2671,2672],{},"Electron 生态中有大量现成的工具和库。需要用户认证？有 electron-oauth2。需要自动更新？有 electron-updater。需要加密存储敏感信息？有 keytar。",[10,2674,2675],{},"Tauri 也有 plugin 系统和逐渐增长的生态，但数量和成熟度都差一截。官方维护的 plugin 包括：",[74,2677,2678,2691],{},[77,2679,2680],{},[80,2681,2682,2685,2688],{},[83,2683,2684],{},"Plugin",[83,2686,2687],{},"功能",[83,2689,2690],{},"成熟度",[93,2692,2693,2704,2715,2725,2735],{},[80,2694,2695,2698,2701],{},[98,2696,2697],{},"tauri-plugin-updater",[98,2699,2700],{},"应用自动更新",[98,2702,2703],{},"⭐⭐⭐⭐",[80,2705,2706,2709,2712],{},[98,2707,2708],{},"tauri-plugin-global-shortcut",[98,2710,2711],{},"全局快捷键",[98,2713,2714],{},"⭐⭐⭐",[80,2716,2717,2720,2723],{},[98,2718,2719],{},"tauri-plugin-shell",[98,2721,2722],{},"执行系统命令",[98,2724,2703],{},[80,2726,2727,2730,2733],{},[98,2728,2729],{},"tauri-plugin-clipboard",[98,2731,2732],{},"剪贴板访问",[98,2734,2714],{},[80,2736,2737,2740,2743],{},[98,2738,2739],{},"tauri-plugin-http",[98,2741,2742],{},"HTTP 请求",[98,2744,2703],{},[10,2746,2747],{},"与 Electron 的生态相比，覆盖面积大约是 40-50%。某些需求可能需要自己实现。",[10,2749,2750,547],{},[170,2751,2752],{},"这个项目中的例子",[10,2754,2755],{},[170,2756,2757],{},"自更新（tauri-plugin-updater）",[10,2759,2760],{},"tauri-plugin-updater 支持检查更新、下载、验证签名、安装，整个流程都有。但文档相对基础，没有像 Electron Updater 那样完整的例子。第一次使用时需要研究源码才能理解：",[39,2762,2763,2766,2769,2772],{},[42,2764,2765],{},"manifest 文件的确切格式",[42,2767,2768],{},"签名验证的密钥配置",[42,2770,2771],{},"不同 platform 的版本管理",[42,2773,2774],{},"回滚和降级的策略",[10,2776,2777],{},"Electron Updater 在这些方面文档更详尽。",[10,2779,2780],{},[170,2781,2782],{},"关键词搜索的缺失",[10,2784,2785],{},"想做个全局快捷键（比如 Ctrl+Shift+A 快速打开配置中心）？Electron 有 electron-shortcut。Tauri 有 tauri-plugin-global-shortcut，功能也不差。但如果想要更复杂的行为（快捷键冲突检测、快捷键绑定记录、动态解绑等），生态里没有现成的轮子。需要自己在插件基础上二次开发。",[10,2787,2788],{},[170,2789,2790],{},"后台进程管理",[10,2792,2793],{},"想让配置中心在后台运行时保持某些定时任务（比如每分钟检查一次 Gateway 健康状态）？Electron 可以用 ipcMain 和 Node.js 的 setInterval。Tauri 中这些能力也有（tokio task、async\u002Fawait），但需要对 Rust async 编程有一定理解。如果前端开发者不熟悉 Rust，这会成为障碍。",[10,2795,2796,547],{},[170,2797,2798],{},"影响评估",[10,2800,2801],{},"这个项目的范围相对有限（5 个主页面 + 首启引导 + 托盘菜单），所以社区方案少带来的影响有限。",[10,2803,2804],{},"若规模变大：",[39,2806,2807,2810,2813],{},[42,2808,2809],{},"50+ 页面的应用：社区工具的缺失会成为显著的痛点",[42,2811,2812],{},"需要复杂的权限系统、日志收集、错误追踪的应用：生态支持不足会推高开发成本",[42,2814,2815],{},"需要跨多个平台的应用（Windows\u002FMac\u002FLinux）：Tauri 的优势最大（WebView 差异多）",[14,2817,2818],{"id":2818},"数字汇总与成本效益",[74,2820,2821,2836],{},[77,2822,2823],{},[80,2824,2825,2828,2831,2833],{},[83,2826,2827],{},"指标",[83,2829,2830],{},"Electron 方案",[83,2832,2128],{},[83,2834,2835],{},"收益",[93,2837,2838,2855,2873,2891,2908,2926,2944,2962],{},[80,2839,2840,2845,2848,2850],{},[98,2841,2842],{},[170,2843,2844],{},"应用体积",[98,2846,2847],{},"60-80 MB",[98,2849,103],{},[98,2851,2852],{},[170,2853,2854],{},"-75% 体积",[80,2856,2857,2862,2865,2868],{},[98,2858,2859],{},[170,2860,2861],{},"首次启动时间",[98,2863,2864],{},"~2-3 秒",[98,2866,2867],{},"≤ 1 秒",[98,2869,2870],{},[170,2871,2872],{},"-67% 启动",[80,2874,2875,2880,2883,2886],{},[98,2876,2877],{},[170,2878,2879],{},"运行时内存",[98,2881,2882],{},"300-500 MB（单实例）",[98,2884,2885],{},"30-50 MB",[98,2887,2888],{},[170,2889,2890],{},"-90% 内存",[80,2892,2893,2898,2900,2903],{},[98,2894,2895],{},[170,2896,2897],{},"版本更新包（前端改动）",[98,2899,206],{},[98,2901,2902],{},"50-300 KB",[98,2904,2905],{},[170,2906,2907],{},"-99% 增量",[80,2909,2910,2915,2918,2921],{},[98,2911,2912],{},[170,2913,2914],{},"代码复用率（与 Launcher）",[98,2916,2917],{},"~15%（需要 native module）",[98,2919,2920],{},"~60%（直接导入）",[98,2922,2923],{},[170,2924,2925],{},"+45% 复用",[80,2927,2928,2933,2936,2939],{},[98,2929,2930],{},[170,2931,2932],{},"前端编译时间",[98,2934,2935],{},"~60s",[98,2937,2938],{},"~10s",[98,2940,2941],{},[170,2942,2943],{},"-83% 编译",[80,2945,2946,2951,2954,2957],{},[98,2947,2948],{},[170,2949,2950],{},"开发依赖数",[98,2952,2953],{},"400+ npm packages",[98,2955,2956],{},"180+ npm + 50 cargo",[98,2958,2959],{},[170,2960,2961],{},"-40% 总依赖",[80,2963,2964,2969,2972,2975],{},[98,2965,2966],{},[170,2967,2968],{},"系统 API 调用",[98,2970,2971],{},"需要原生模块 + npm 包",[98,2973,2974],{},"直接 windows-rs",[98,2976,2977],{},[170,2978,2979],{},"0 中间层",[10,2981,2982,2985],{},[170,2983,2984],{},"成本效益计算","（针对 1000 活跃用户的产品）：",[74,2987,2988,3003],{},[77,2989,2990],{},[80,2991,2992,2995,2998,3001],{},[83,2993,2994],{},"维度",[83,2996,2997],{},"Electron 年成本",[83,2999,3000],{},"Tauri 年成本",[83,3002,568],{},[93,3004,3005,3021,3037,3053,3069],{},[80,3006,3007,3010,3013,3016],{},[98,3008,3009],{},"网络流量（CDN）",[98,3011,3012],{},"$7,200",[98,3014,3015],{},"$2,400",[98,3017,3018],{},[170,3019,3020],{},"$4,800",[80,3022,3023,3026,3029,3032],{},[98,3024,3025],{},"服务器存储（版本历史）",[98,3027,3028],{},"$1,200",[98,3030,3031],{},"$400",[98,3033,3034],{},[170,3035,3036],{},"$800",[80,3038,3039,3042,3045,3048],{},[98,3040,3041],{},"开发维护（系统集成 Bug）",[98,3043,3044],{},"$6,000（3 人月）",[98,3046,3047],{},"$2,000（1 人月）",[98,3049,3050],{},[170,3051,3052],{},"$4,000",[80,3054,3055,3058,3061,3064],{},[98,3056,3057],{},"编译 + 构建时间成本",[98,3059,3060],{},"$3,000（150 小时）",[98,3062,3063],{},"$500（25 小时）",[98,3065,3066],{},[170,3067,3068],{},"$2,500",[80,3070,3071,3074,3079,3084],{},[98,3072,3073],{},"总成本",[98,3075,3076],{},[170,3077,3078],{},"$17,400",[98,3080,3081],{},[170,3082,3083],{},"$5,300",[98,3085,3086],{},[170,3087,3088],{},"$12,100（69% 节省）",[10,3090,3091],{},"假设开发人员时薪 $100\u002F小时，这个节省对小团队来说很显著。",[14,3093,3095],{"id":3094},"决策总结约束驱动的选择","决策总结：约束驱动的选择",[10,3097,3098],{},"这不是\"Tauri 比 Electron 更好\"的宣言，而是\"在这个具体约束集合下，Tauri 的权衡更优\"的决策记录。",[10,3100,3101,547],{},[170,3102,3103],{},"约束集合",[457,3105,3106,3109,3112,3115],{},[42,3107,3108],{},"便携产品，U 盘交付，体积敏感（约束 A）",[42,3110,3111],{},"已有 Rust 后端（Launcher），代码复用机会大（约束 B）",[42,3113,3114],{},"Windows 系统集成需求明确（约束 C）",[42,3116,3117],{},"团队熟悉 Rust，学习新框架成本可控（约束 D）",[10,3119,3120,547],{},[170,3121,3122],{},"若约束变化，决策也会变化",[39,3124,3125,3131,3137,3143],{},[42,3126,3127,3130],{},[170,3128,3129],{},"约束 A 不再存在","（比如改为云应用，不再需要便携）：Electron 的价值中立化（不需要考虑体积）。此时代价一（前端生态）会变成主导因素，Electron 重新成为首选。",[42,3132,3133,3136],{},[170,3134,3135],{},"约束 B 变弱","（比如后端改用 Node.js）：代码复用的优势消失。此时需要重新评估 Electron 的前端生态优势 vs Tauri 的内存\u002F性能优势。",[42,3138,3139,3142],{},[170,3140,3141],{},"约束 C 减弱","（比如 Linux 支持变重要）：Windows API 的优势边际化。Tauri 的跨平台 WebView 差异会变成劣势（需要在三个平台上调试兼容性）。Electron 自带 Chromium 的\"一致性\"优势会相对凸显。",[42,3144,3145,3148],{},[170,3146,3147],{},"约束 D 变化","（比如加入前端工程师，Rust 不熟悉）：自建组件库的成本会上升，Electron 的成熟生态会更有吸引力。",[14,3150,3151],{"id":3151},"后续债务与演进规划",[10,3153,3154,547],{},[170,3155,3156],{},"Plan 03（当前）实装",[39,3158,3159,3162,3165],{},[42,3160,3161],{},"Tauri 2 MVP（5 主页 + 首启引导）",[42,3163,3164],{},"基础系统集成（托盘、WebView2 检测、单实例）",[42,3166,3167],{},"自建 16 组件库",[10,3169,3170,547],{},[170,3171,3172],{},"Plan 04（后续债务）",[39,3174,3175,3178],{},[42,3176,3177],{},"应用自更新真实集成（tauri-plugin-updater + KMS 签名，debt-03-F）",[42,3179,3180],{},"Gateway 状态事件化（Launcher NamedPipe 推送 vs 轮询，debt-03-E）",[10,3182,3183,547],{},[170,3184,3185],{},"Plan 08（更远期）",[39,3187,3188,3191],{},[42,3189,3190],{},"Gateway 重启能力（GatewayController 状态机，debt-08-G）",[42,3192,3193],{},"更多 Windows 系统集成（文件关联、快捷方式等）",[3195,3196,3197],"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 .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}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 .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}",{"title":277,"searchDepth":690,"depth":690,"links":3199},[3200,3201,3206,3212,3217,3222,3227,3228,3229],{"id":16,"depth":690,"text":16},{"id":25,"depth":690,"text":26,"children":3202},[3203,3204,3205],{"id":30,"depth":696,"text":31},{"id":65,"depth":696,"text":66},{"id":259,"depth":696,"text":260},{"id":286,"depth":690,"text":287,"children":3207},[3208,3209,3210,3211],{"id":290,"depth":696,"text":291},{"id":333,"depth":696,"text":333},{"id":436,"depth":696,"text":437},{"id":531,"depth":696,"text":532},{"id":787,"depth":690,"text":788,"children":3213},[3214,3215,3216],{"id":791,"depth":696,"text":791},{"id":907,"depth":696,"text":907},{"id":1324,"depth":696,"text":1324},{"id":1462,"depth":690,"text":1463,"children":3218},[3219,3220,3221],{"id":1466,"depth":696,"text":1467},{"id":2053,"depth":696,"text":2054},{"id":2120,"depth":696,"text":2120},{"id":2192,"depth":690,"text":2192,"children":3223},[3224,3225,3226],{"id":2198,"depth":696,"text":2199},{"id":2289,"depth":696,"text":2290},{"id":2668,"depth":696,"text":2669},{"id":2818,"depth":690,"text":2818},{"id":3094,"depth":690,"text":3095},{"id":3151,"depth":690,"text":3151},"Agent 平台","2026-04-21","md",null,{},"\u002F2026-04-21-tauri2electron",{"title":5,"description":12},"2026-04-21-Tauri2而不是Electron四个理由","便携 AI 助手的配置中心为什么选 Tauri 2 而不是默认答案 Electron，涉及包体积、工程复用、更新成本、系统集成四个决策点与代价分析。",[193,190,3240,3241,3242,3243],"桌面应用","Rust","Windows","架构设计","-op-BrmElvU89k3rhmsjnenCe2G8Dnq8eiTVpRX8D8w",[3246,3565,3862],{"id":3247,"title":3248,"body":3249,"column":3230,"date":3551,"description":3253,"extension":3232,"hero_image":3233,"meta":3552,"navigation":963,"path":3553,"seo":3554,"series_id":3233,"severity":3233,"stem":3555,"summary":3556,"tags":3557,"__hash__":3564},"posts\u002F2026-07-26-峰值1987一个人维护AI平台的边界.md","一个人，能维护到多大规模？",{"type":7,"value":3250,"toc":3536},[3251,3254,3257,3260,3263,3295,3298,3301,3305,3308,3315,3330,3333,3340,3344,3347,3350,3353,3360,3367,3371,3374,3381,3385,3388,3391,3402,3413,3416,3420,3423,3426,3433,3437,3440,3443,3446,3450,3453,3456,3482,3485,3488,3491,3497,3511,3516,3530],[10,3252,3253],{},"从 2026-05-22 的 270 个用户到 07 下旬的峰值 1987 日活，这个平台的增长不是线性的。中间隔着四次明确的容量撞墙，每一次都留下了可复查的根因记录。这篇文章讲的是，什么条件下一个人能维护一个到达四位数日活规模的多租户平台。",[14,3255,3256],{"id":3256},"成长曲线与撞墙的四个节点",[10,3258,3259],{},"架构的设计前提是\"1 用户 = 1 容器\"。这个决策确定了，用户数与容器数就是同一个口径。没有\"日活 X 万、同时在线 Y 万\"这种双指标的混淆。",[10,3261,3262],{},"实际的规模序列是这样的：",[39,3264,3265,3271,3277,3283,3289],{},[42,3266,3267,3270],{},[170,3268,3269],{},"270 个 service","（05-22）：刚完成 ffmpeg 升级后的测量",[42,3272,3273,3276],{},[170,3274,3275],{},"372 RUNNING","（05-24）：滚动升级前的容器计数",[42,3278,3279,3282],{},[170,3280,3281],{},"379","（05-25）：五个 P0 patch 部署后稳定",[42,3284,3285,3288],{},[170,3286,3287],{},"574 用户","（06-03）：迁移到 K8s 时的基线，539 个 RUNNING",[42,3290,3291,3294],{},[170,3292,3293],{},"1987","（07 下旬）：最近测得的峰值",[10,3296,3297],{},"跨度是两个月，增速从 Swarm 期间的两周内 270→379（40% 增长）、到迁 K8s 后约八周 574→1987（约 3.5 倍）。这个加速度不是\"计划好的伸缩\"，而是每一次解决了瓶颈后，下一个瓶颈暴露出来。",[14,3299,3300],{"id":3300},"四堵撞过的墙",[28,3302,3304],{"id":3303},"第一堵容器网络-ip-池05-22fa-012","第一堵：容器网络 IP 池（05-22，FA-012）",[10,3306,3307],{},"用户报告\"AI 容器里没 ffmpeg\"。这本来是个 Dockerfile 一行 apt 的事。但打算上线这个修改时，意外发现 gwbridge（Docker Swarm 默认的容器网络）IP 地址段是 \u002F24，总共 253 个 IP，已经被 270 个用户的容器占满。13 个用户容器长期处于启动失败的循环中。",[10,3309,3310,3311,3314],{},"排查逻辑是这样的：一行 Dockerfile 改动不至于触发灰度风险，但灰度一个用户时，Swarm 需要给新容器分配 gwbridge 的 IP。如果池子满了，IP 分配失败，新容器启动失败，再加上老容器还没完全释放（endpoint 还在占位），就陷入了\"申请 → 失败 → retry\"的循环。这种症状看起来随机，用户看到的是\"我的容器启不来\"，系统看到的是\"又一个容器 cycling\"。直到看 ",[275,3312,3313],{},"docker info"," 的输出，才发现 gwbridge 已经 100% 满用。",[10,3316,3317,3318,3321,3322,3329],{},"根本原因在于一个不易察觉的配置陷阱：Docker Daemon 的配置文件里改了 ",[275,3319,3320],{},"default-address-pools","，但这个配置",[170,3323,3324,3325,3328],{},"只在 ",[275,3326,3327],{},"swarm init"," 那一刻消费一次","。已经创建的网络不会动。之前改过这个配置的人可能不知道这个行为，改了等于没改。",[10,3331,3332],{},"修复不能简单地改配置重启。我试过在 staging 环境用 dry-run 验证，写脚本探测不冲突的子网段，然后在 production 的 canary 验证阶段发现\"释放的 IP 立刻被其它 cycling 任务抢走\"这样的负反馈。所以流程变成：先 scale 0 所有失联的服务（停止它们 cycling，释放的位置不会被抢），然后手动删除旧的 gwbridge、用新 subnet 重建。最终把容量从 253 扩到了 4094，增长了 16 倍。13 个失联用户全部恢复。",[10,3334,3335,3336,3339],{},"这次的启示是",[170,3337,3338],{},"配置陷阱往往比代码 bug 更隐蔽","。因为配置改了看不出效果，维护者会觉得没改上去、会反复尝试，但每次尝试的假设都错了。",[28,3341,3343],{"id":3342},"第二堵单容器资源限制与内核参数05-25fa-013","第二堵：单容器资源限制与内核参数（05-25，FA-013）",[10,3345,3346],{},"五天后，部署了五个 P0 patch：B1 容器内存 burst factor 调整（硬限制改为内存 × 4）、B2 mcp register 的假失败救活逻辑、B3 ulimit nofile 扩大到 65536、B4 mcp cooldown 清理、B6 provision 接口幂等性。这一次的滚动升级从 05-25 凌晨 0:41 一直跑到 07:34，实际耗时 6 小时 42 分钟，原本预估只要 3 小时。",[10,3348,3349],{},"但更关键的数据是这个：升级前，平台里有一个重灾户容器一天被 OOM kill 了 247 次。这不是偶发故障——是每一天都在重复。升级完成一小时后，这个数字变成了 0。",[10,3351,3352],{},"这次的根因是九个缺陷的叠加。B1 的根因是硬限制设得太低（原来 93 个用户只有 500MB 硬限，远低于实际需要），B2 是 mcp register exit code 的错误判断（非零 exit 自动当做失败，但有时是因为配置覆盖了），B3 是文件描述符不足导致新连接打开失败。单个缺陷可能不致命，但聚在一起就是 OOM 风暴。更严重的是，一个用户的 OOM 不只影响那个用户——每次 OOM 都会触发内核的 swap 操作，拖累整机的 I\u002FO，让 cpa-api 主进程的事件循环卡顿 18 秒。前端看到的是全站变慢，实际根因可能是某个用户容器在反复 OOM。",[10,3354,3355,3356,3359],{},"部署前做了充分的 staging 验证和 production canary，分阶段升级了重灾户、然后全量升级、最后等完全收敛。期间遇到的问题是单容器的 ",[275,3357,3358],{},"docker service update"," 实际耗时约 60 秒（含 Swarm scheduler 延迟），并发调度起来比预期慢 2 倍。",[10,3361,3362,3363,3366],{},"这次的启示是：",[170,3364,3365],{},"当多个独立缺陷同时叠加时，表象看起来是单一故障（OOM 风暴），但根因分散在配置、参数、逻辑判断的不同层","。修复必须从全景取证开始，先理清每个环节的缺陷，再按优先级有序修复。一次部署才能彻底收敛，局部修复反而会留下隐患。",[28,3368,3370],{"id":3369},"第三堵编排层与自愈能力06-03迁-k8s","第三堵：编排层与自愈能力（06-03，迁 K8s）",[10,3372,3373],{},"到 06-03，用户已经涨到 574 个。继续打补丁的成本已经高于\"一次性迁移编排平台\"。Swarm 的问题不只是容量——还有调度自愈的缺失。任何故障都依赖人工介入，一个人无法 24 小时在线。决策很简单：从 Docker Swarm 迁到 Kubernetes。",[10,3375,3376,3377,3380],{},"这次迁移的复杂性不在于技术实现本身（用 COS 中转数据、转换 sub2api 密钥、把 124G 数据分批导入），而在于",[170,3378,3379],{},"理解新平台引入的新故障域","。K8s 有更细的控制粒度和自动调度，但同时也暴露了之前 Swarm 隐藏的问题。比如共享存储（NFS\u002FSFS）的客户端卡死，在 Swarm 时期可能因为容器分散在不同节点而被掩盖；但在 K8s 这样的细粒度编排下，如果一个节点的 NFS 客户端出问题，节点上的所有 pod 都会受影响。所以迁移不是终点，而是暴露新问题的起点。",[28,3382,3384],{"id":3383},"第四堵节点级存储与可观测性06-05fa-015","第四堵：节点级存储与可观测性（06-05，FA-015）",[10,3386,3387],{},"迁移两天后，某个节点上的 NFS 客户端在某个时刻 hang 住了。Kubernetes 不知道发生了什么，kubelet 仍然报告节点 Ready（因为 kubelet 本身没卡），但这个节点上 52 个实例的网关全部 DOWN 或 HUNG。这 52 个实例对应的是本该分散部署的用户容器，因为某种原因堆在了同一个节点上。",[10,3389,3390],{},"故障的症状可以分成几类。单个实例网关的 DOWN（容器无法启动、schema 非法）、HUNG（反复重启导致 adapter 504）、节点级的卡死（整节点上的容器创建\u002F删除都阻塞）、数据库与实际状态割裂（DB 里是 ERROR、但 pod 健康了）。每种症状对应不同的根因，需要不同的检测和自愈手段。",[10,3392,3393,3394,3397,3398,3401],{},"最严重的是，",[170,3395,3396],{},"这个故障没有任何告警","。平台的监控指标全部正常，绿灯一片。直到用户反馈\"连不上面板\"，才被发现。这已经是故障发生几小时以后的事。故障本身是可逆的——节点冻死就人工 cordon、删除卡住的 pod、让其它节点重新调度。但",[170,3399,3400],{},"不可见的故障比故障本身更致命","。",[10,3403,3404,3405,3408,3409,3412],{},"这次事故直接导出了四层兜底的设计：L0 从配置和资源限制层面降低故障触发（比如 NFS 改 ",[275,3406,3407],{},"soft"," 参数而不是 ",[275,3410,3411],{},"hard","，这样网络抖动时容器报错退出而不是整个节点冻）；L1 加检测让故障可见（每 60 秒检测一遍\"节点上是否有 pod 卡 ContainerCreating、网关探测失败率多少\"）；L2 对低风险故障自动自愈（网关单次 DOWN 就重建 pod、DB 对账失败就修复状态）；L3 对高风险动作告警优先（节点冻死先推送告警、等人工确认再 drain）。",[14,3414,3415],{"id":3415},"三件真正决定可行性的事",[28,3417,3419],{"id":3418},"_1-文档即基础设施","1. 文档即基础设施",[10,3421,3422],{},"这个平台现在有 200+ 份设计文档与 80 份故障档案。这些不是为了\"好看\"存在的。",[10,3424,3425],{},"一个人无法对整个系统保持完整的心智模型。200+ 文档是唯一能让一个人记住系统全貌的方式。每当遇到新故障，能快速检索以前遇过的类似问题。每当需要做架构决策，能回溯当初为什么这样设计、放弃过哪些选项。",[10,3427,3428,3429,3432],{},"同时，",[170,3430,3431],{},"这些文档也是 AI 能有效介入的前提","。我可以把这些故障档案喂给模型，让它帮助排查新问题、验证修复方案、甚至生成监控规则。但前提是要把故障记录得清楚。空洞的\"修好了\"没有任何价值。",[28,3434,3436],{"id":3435},"_2-故障必须被归档","2. 故障必须被归档",[10,3438,3439],{},"同一个症状反复出现，每次修复可能只治了表象的一个侧面，真正的收敛需要理解全部的根因。这只有在每一次都写清档案的情况下才可能。",[10,3441,3442],{},"如果没有归档制度，第二次遇到类似症状时，维护者根本不知道第一次修复做过什么、为什么还没有彻底解决。\"又来了\"和\"这个问题还有遗留\"的反应完全不同。前者是被动应对、逐次救火，后者是主动追踪、系统解决。",[10,3444,3445],{},"实际上，平台里有不少故障走过了四到五次的修复周期。每一次修复时，回看之前的档案，能快速理清\"这一层已经改过，那一层还没触及\"。这种\"有案可查、有据可循\"的状态，把修复从赌博变成了可重复的流程——每一次问题复发时，不是从零开始排查，而是从已知的检查点继续。",[28,3447,3449],{"id":3448},"_3-自愈优先于告警告警优先于人工","3. 自愈优先于告警，告警优先于人工",[10,3451,3452],{},"在 FA-015 之前，平台大量依赖人工值守。任何问题都需要运维看到日志、理解现象、手动操作。一个人无法 24 小时在线。",[10,3454,3455],{},"FA-015 的教训直接导出了四层兜底的设计：",[39,3457,3458,3464,3470,3476],{},[42,3459,3460,3463],{},[170,3461,3462],{},"L0 预防","：从配置和资源限制的层面降低故障触发的概率。",[42,3465,3466,3469],{},[170,3467,3468],{},"L1 检测","：让不可见的故障变成可见——节点卡死、实例网关异常、数据库与实际状态割裂，全部要有独立的检测逻辑。",[42,3471,3472,3475],{},[170,3473,3474],{},"L2 自愈","：对于低风险的故障（单实例网关重启、DB 状态对账），直接自动修复。高风险的动作（节点 drain）先告警、等人工确认。",[42,3477,3478,3481],{},[170,3479,3480],{},"L3 告警","：自愈失败时、检测到新的异常时，推送给人。",[10,3483,3484],{},"这样的设计下，一个人维护平台的上限大幅抬高。不是因为个人能力变强了，而是系统能自动处理大多数故障，只把人类的决策能力用在最关键的地方。",[14,3486,3487],{"id":3487},"诚实的边界在哪",[10,3489,3490],{},"但这个设计也有明显的天花板：",[10,3492,3493,3496],{},[170,3494,3495],{},"真正撑不住的","（需要六小时以上连续操作、需要跨时区响应、需要多人交叉验证）：",[39,3498,3499,3502,3505,3508],{},[42,3500,3501],{},"数据库或存储层的重大故障。恢复涉及数据一致性检查，无法完全自动化。",[42,3503,3504],{},"涉及业务逻辑的错误。修复需要理解用户意图，不只是系统恢复。",[42,3506,3507],{},"密钥泄露或安全事件。需要立刻通知客户、协调应急处置、事后全面审计。",[42,3509,3510],{},"多个独立故障同时发生、相互放大的情况。需要多个人在不同维度分别操作。",[10,3512,3513,547],{},[170,3514,3515],{},"如果重来一次，优先级这样排",[457,3517,3518,3521,3524,3527],{},[42,3519,3520],{},"最先做的是 L1 检测——让故障可见。这是一切自动化的前提。宁可产生虚报，也不能漏掉真实故障。",[42,3522,3523],{},"其次是 L0 预防——从配置、资源限制、网络参数这些基础设施层降低故障率。这些改动成本低、收益高。",[42,3525,3526],{},"然后才是 L2 自愈——只对低风险的故障做自动恢复。对于高风险操作，即使多花一个人工确认的时间，也要确保不会进一步破坏系统。",[42,3528,3529],{},"最后是文档和监控。这些不是\"最后的事情\"，而是贯穿全过程的——每个改动都要同步更新文档、每个故障都要写进档案。",[10,3531,3532,3533,3535],{},"那些回避的成本很高。曾经因为 NFS 挂载的 ",[275,3534,3411],{}," 参数导致节点冻死，这个参数的改动只需要改一行配置文件、然后滚动重启一次实例。但因为这个调整一直没做，就承受了几小时的无声故障。反过来说，那些看起来\"小\"的改动——改配置参数、改资源限制、加一个监控规则——才是最划算的投资。",{"title":277,"searchDepth":690,"depth":690,"links":3537},[3538,3539,3545,3550],{"id":3256,"depth":690,"text":3256},{"id":3300,"depth":690,"text":3300,"children":3540},[3541,3542,3543,3544],{"id":3303,"depth":696,"text":3304},{"id":3342,"depth":696,"text":3343},{"id":3369,"depth":696,"text":3370},{"id":3383,"depth":696,"text":3384},{"id":3415,"depth":690,"text":3415,"children":3546},[3547,3548,3549],{"id":3418,"depth":696,"text":3419},{"id":3435,"depth":696,"text":3436},{"id":3448,"depth":696,"text":3449},{"id":3487,"depth":690,"text":3487},"2026-07-26",{},"\u002F2026-07-26-1987ai",{"title":3248,"description":3253},"2026-07-26-峰值1987一个人维护AI平台的边界","从 270 到 1987 日活，每一次规模跃升前都先撞了一次墙。这条增长曲线记录的不是预设设计，而是每次故障都被完整归档后逐步演进出来的可行性边界。",[3558,3559,3560,3561,3562,3563],"Docker Swarm","Kubernetes","容器编排","运维自动化","故障自愈","规模扩展","D-mYcaOLOBT0PhwjmrqVVEwCE7hl8Tqi9I57HrRlt5U",{"id":3566,"title":3567,"body":3568,"column":3230,"date":3848,"description":3849,"extension":3232,"hero_image":3233,"meta":3850,"navigation":963,"path":3851,"seo":3852,"series_id":3233,"severity":3233,"stem":3853,"summary":3854,"tags":3855,"__hash__":3861},"posts\u002F2026-07-14-分销体系的账本设计.md","一笔充值，要拆成几条流水？",{"type":7,"value":3569,"toc":3838},[3570,3577,3580,3584,3587,3590,3597,3608,3615,3619,3622,3628,3631,3634,3637,3648,3651,3659,3666,3670,3673,3684,3687,3698,3705,3709,3712,3718,3721,3732,3735,3739,3742,3745,3748,3759,3762,3770,3773,3784,3787,3790,3795,3802,3807,3817,3820,3823,3826,3832,3835],[10,3571,3572,3573,3576],{},"单笔用户充值，背后是一次复杂的资金拆分：用户充值 100 元，既是平台的收入，也是分销代理的佣金来源，可能还有上级代理的层级提成。这些数字必须同时记录、互相平衡、永不重复。这不是数据流通的问题，是",[170,3574,3575],{},"现金流的问题","——差一分钱就是漏账，重复一次就是挪用。",[10,3578,3579],{},"分销账本设计的核心就四条铁律和一个恒等式。遵循它们，系统能撑到任何规模；跳过其中任何一条，早晚会在对账时翻车。",[14,3581,3583],{"id":3582},"规则一佣金计算基数要先定死","规则一：佣金计算基数要先定死",[10,3585,3586],{},"从什么数字出发算佣金？这个问题比看起来复杂。",[10,3588,3589],{},"通常的选项有三个：订单总金额、用户实付金额、或者订单到账净额。乍看没区别，一旦遇上退款就完全不同。",[10,3591,3592,3593,3596],{},"采用的方案是",[170,3594,3595],{},"用户充值净额","（billing 系统中已确认到账的实付分）。理由很直白：",[457,3598,3599,3602,3605],{},[42,3600,3601],{},"退款处理天然免疫。用户充值后退款，billing 的该用户账户余额已经扣掉，充值净额自动反映了这笔冲销。分成计算只需聚合这个净额乘以比例，不用单独写退款冲正逻辑。",[42,3603,3604],{},"避免应收账款。如果以订单金额算，还没到账时代理已经看得到分成，这在 reporting-only 设计下容易造成认知错位（代理以为钱已经是他的，实际还在支付处理中）。",[42,3606,3607],{},"同源唯一。billing 是平台的权威账本，分成的基数来自这里，对账时只需验证\"代理分成之和 + 平台收入 = billing 总充值\"，一个公式搞定。",[10,3609,3610,3611,3614],{},"反过来说，如果公司后续引入退款主动冲补（而非被动扣减），这个基数设定会变得复杂。但在初期，",[170,3612,3613],{},"基数 = billing 已确认充值"," 是最简洁的切口。",[14,3616,3618],{"id":3617},"规则二结算时点决定了数据流向","规则二：结算时点决定了数据流向",[10,3620,3621],{},"到底是在订单成交时计提佣金，还是账期结束时一次性结算？",[10,3623,3624,3625,3401],{},"这里的选择是 ",[170,3626,3627],{},"reporting-only 只读聚合，实时查询，不计提、不累积",[10,3629,3630],{},"具体含义是：代理看到的\"我的分成\"不是一条条流水记录，而是每次查询时现场计算出来的聚合数字。算法是\"我名下所有用户的充值净额总和 × 我的佣金比例\"。没有单独的\"分成计提\"操作，没有一条条的\"分成到账\"记录。",[10,3632,3633],{},"好处和代价是对偶的。",[10,3635,3636],{},"好处：",[39,3638,3639,3642,3645],{},[42,3640,3641],{},"免除计提时点的争议。不用决定在订单成交时、支付完成时、还是 T+1 时计提，因为根本不计提。",[42,3643,3644],{},"天然避免双扣。既然分成不落库不累积，就不存在\"发放一次、又重复发放一次\"的并发风险。同一笔充值无论被查询多少次，贡献的佣金永远相同。",[42,3646,3647],{},"简化对账。代理的分成数字永远等于\"最新充值净额 × 比例\"，无需追溯历史。",[10,3649,3650],{},"代价：",[39,3652,3653,3656],{},[42,3654,3655],{},"代理无法看到\"分成流水\"。有些运营场景下，需要展示\"哪笔订单产生了多少佣金\"这样的明细，reporting-only 做不了（可以通过关联用户的充值明细变通，但那是用户维度的流水，不是分成维度的）。",[42,3657,3658],{},"退款时必须同步。如果用户退了 50 块钱，billing 系统立刻反映这笔扣减，代理的分成下一秒查询就会跌下来。这对代理来说是透明的（分成就是动态的），但运营沟通时需要提前说清楚。",[10,3660,3661,3662,3665],{},"选择 reporting-only 的核心原因是：",[170,3663,3664],{},"初期不出金、无提现","。既然分成只是一个数字展示、不涉及真金白银的打款，那就不用建立复杂的流水账体系。等到未来做提现时，可以在 reporting-only 的基础上加一层\"快照 + 冻结\"机制（即每个提现周期开始时拍一个快照，这个快照才是可提的分成额）。",[14,3667,3669],{"id":3668},"规则三层级上限和循环检测","规则三：层级上限和循环检测",[10,3671,3672],{},"分销是分多少层级？",[10,3674,3675,3676,3679,3680,3683],{},"首期方案是",[170,3677,3678],{},"单层","。用户通过一个特定的渠道码注册（如 ",[275,3681,3682],{},"AB-48210377","），永久绑定到某个代理。代理无法有\"上级代理\"，也就无法有\"上级佣金\"这样的递归结构。",[10,3685,3686],{},"这一约束看起来很强，但在无提现的 reporting-only 下，是合理的。理由是：",[457,3688,3689,3692,3695],{},[42,3690,3691],{},"简化代理运维。总台只需管理一套代理的佣金比例（per-代理），不用维护代理之间的树形关系。",[42,3693,3694],{},"避免环形链。单层天然杜绝了\"A 的上级是 B，B 的上级是 A\"这类配置错误。多层结构下，环形检测本身又是一个故障点。",[42,3696,3697],{},"初期够用。大多数分销场景早期就是\"直销商（代理）→ 用户\"的二元关系，不需要分级。",[10,3699,3700,3701,3704],{},"但要注意，这个约束是",[170,3702,3703],{},"数据模型层的","，不是业务规则层的。如果未来需要升级到多层结构，数据模型需要改（Channel 表可能要加 parentResellerId 等），但已经发出去的单层记录无需回溯改造——它们天然是单层的。",[14,3706,3708],{"id":3707},"规则四幂等键设计防重复计提","规则四：幂等键设计（防重复计提）",[10,3710,3711],{},"同一笔充值可能被多个系统调用、被回调多次。分成必须严格幂等：无论这笔充值被聚合几次，贡献给代理的佣金永远是\"充值额 × 比例\"这一个数字，不能是两倍、三倍。",[10,3713,3714,3715,3401],{},"因为采用 reporting-only + 只读聚合的设计，幂等性",[170,3716,3717],{},"自动满足",[10,3719,3720],{},"推理如下：",[457,3722,3723,3726,3729],{},[42,3724,3725],{},"billing 侧的充值流水本身是幂等的。同一个订单号的充值，billing 确保只入账一次（通过订单号的唯一性约束）。",[42,3727,3728],{},"分成聚合是无状态的。每次查询时，服务端都是\"遍历该代理名下的用户 → 调用 billing 的 summaryByUsers 接口 → 汇总充值净额 → 乘以比例\"。这个聚合过程不依赖任何之前的计提记录。",[42,3730,3731],{},"结论：即使 billing 错误地返回了同一笔充值两次，聚合结果也只会包含一次（因为底层是\"用户ID → 总充值净额\"的映射，不是\"订单 → 充值\"的流水列表）。",[10,3733,3734],{},"相反，如果设计成\"订单成交时计提一条分成记录\"的模式，就需要在分成记录上加幂等键（如 orderId），确保同一订单的分成只计提一次。这个幂等键检查本身就是一个额外的故障点。",[14,3736,3738],{"id":3737},"一个恒等式对账的唯一标准","一个恒等式：对账的唯一标准",[10,3740,3741],{},"前面四条规则规范了流程，但最后的验证还是要靠一个简单的数学公式。",[10,3743,3744],{},"$$\n\\sum_^{n} \\text{Commission}_i + \\text{PlatformNetIncome} = \\text{TotalTopup}\n$$",[10,3746,3747],{},"其中：",[39,3749,3750,3753,3756],{},[42,3751,3752],{},"$\\text{Commission}_i$ 是第 $i$ 个代理的分成（所有名下用户的充值净额 × 佣金比例）",[42,3754,3755],{},"$\\text{PlatformNetIncome}$ 是平台的净收入（总充值 - 所有代理的分成总额）",[42,3757,3758],{},"$\\text{TotalTopup}$ 是 billing 系统的总充值额（所有用户的到账充值之和）",[10,3760,3761],{},"这个等式是唯一可靠的对账标尺。任何时刻，只要这个等式不成立，就说明某个环节出了问题：",[39,3763,3764,3767],{},[42,3765,3766],{},"等式左侧大于右侧 → 某个代理的分成算重了，或者平台收入算多了",[42,3768,3769],{},"等式左侧小于右侧 → 某个代理的分成算少了，或者某笔收入漏了",[10,3771,3772],{},"而且这个等式不需要建立任何新表。完全可以通过查询三个现成的数据源验证：",[457,3774,3775,3778,3781],{},[42,3776,3777],{},"billing 的 SummaryByUsers（每个用户的充值净额）",[42,3779,3780],{},"代理表的 commissionRate（每个代理的佣金比例）",[42,3782,3783],{},"一行 SQL 的聚合（sum 和乘法）",[14,3785,3786],{"id":3786},"技术实现的两个关键点",[10,3788,3789],{},"光有规则还不够，实现层要支撑这些规则。素材中的设计有两个细节值得指出。",[10,3791,3792,3401],{},[170,3793,3794],{},"其一，billing 要暴露 summaryByUsers 接口",[10,3796,3797,3798,3801],{},"分成聚合依赖\"按用户ID汇总充值净额和消耗\"这个操作。如果 billing 侧没有这个批量接口，代理端就得自己拼接多个单用户查询，性能和一致性都会打折扣。实现计划里新增的 ",[275,3799,3800],{},"POST \u002Fanalytics\u002Fsummary-by-users"," 就是为了这个。",[10,3803,3804,3401],{},[170,3805,3806],{},"其二，reseller 侧的所有查询要服务端注入 channelId",[10,3808,3809,3810,1434,3813,3816],{},"代理登录后调用 ",[275,3811,3812],{},"\u002Fapi\u002Freseller\u002Fsummary",[275,3814,3815],{},"\u002Fapi\u002Freseller\u002Fusers","，后端不能信任请求体里的 channelId 参数。而是从 token 解析出代理身份 → 查表得出该代理对应的 channelId → 强制注入到查询条件里。这是防代理 A 越权查看代理 B 渠道的唯一有效方式。",[10,3818,3819],{},"代码里体现为：从 Admin token 反查 Channel 表的 resellerId 字段，确保该 Admin 只能看自己那行 Channel。",[14,3821,3822],{"id":3822},"与既有分账设计的呼应",[10,3824,3825],{},"这套账本设计不是凭空造出来的。平台此前在另一个系统里实现过五类角色的分账体系（Platform、Agency、Escort、Distributor、Merchant），每个角色各维护一张 Ledger 流水表。那个设计的核心思想是\"分账入表\"——即每一笔影响各角色收入的交易，都要对应地在各自的 Ledger 表里落一条记录。",[10,3827,3828,3829,3401],{},"当前这套分销设计采用了相反的思路：不建 Ledger 表，而是在查询时通过聚合来推导分成。这的背景是 reporting-only 属性（不出金、只展示），使得可以接受动态聚合的方案。但底层的思想是一致的——",[170,3830,3831],{},"通过对账恒等式来保证多角色之间的收支平衡",[14,3833,3834],{"id":3834},"结尾",[10,3836,3837],{},"账本设计的目标不是漂亮的表格或丰富的报表，而是一个简单的数学等式永远成立。一旦等式破裂，对账人员立刻能定位是哪个环节失守。这比事后扑火要高效得多。",{"title":277,"searchDepth":690,"depth":690,"links":3839},[3840,3841,3842,3843,3844,3845,3846,3847],{"id":3582,"depth":690,"text":3583},{"id":3617,"depth":690,"text":3618},{"id":3668,"depth":690,"text":3669},{"id":3707,"depth":690,"text":3708},{"id":3737,"depth":690,"text":3738},{"id":3786,"depth":690,"text":3786},{"id":3822,"depth":690,"text":3822},{"id":3834,"depth":690,"text":3834},"2026-07-14","单笔用户充值，背后是一次复杂的资金拆分：用户充值 100 元，既是平台的收入，也是分销代理的佣金来源，可能还有上级代理的层级提成。这些数字必须同时记录、互相平衡、永不重复。这不是数据流通的问题，是现金流的问题——差一分钱就是漏账，重复一次就是挪用。",{},"\u002F2026-07-14",{"title":3567,"description":3849},"2026-07-14-分销体系的账本设计","分销系统最容易在财务对账出错。单笔充值需同时产生平台收入、代理佣金等多条记录，必须在同一事务内闭合。四条规则与一个恒等式是账本设计的全部。",[3856,3857,3858,3859,3860],"分销","账本设计","对账","幂等性","佣金结算","Q3NC9Z8JBJ7e2MLQFH10q68Igpbp2fMF5gT-do6Rxaw",{"id":3863,"title":3864,"body":3865,"column":3230,"date":4169,"description":3869,"extension":3232,"hero_image":3233,"meta":4170,"navigation":963,"path":4171,"seo":4172,"series_id":3233,"severity":3233,"stem":4173,"summary":4174,"tags":4175,"__hash__":4181},"posts\u002F2026-06-29-记忆星系长期记忆可视化工作台.md","模型记错了，用户得能删掉",{"type":7,"value":3866,"toc":4159},[3867,3870,3873,3877,3883,3886,3889,3892,3895,3902,3905,3908,3940,3943,3946,4013,4020,4027,4030,4033,4077,4080,4083,4087,4090,4093,4096,4099,4102,4105,4108,4111,4114,4121,4128,4131,4134,4141,4144,4147,4150,4153,4156],[10,3868,3869],{},"长期记忆存起来容易，用户看不见也管不了。记忆一旦不可见，就会累积错误信息并持续污染后续对话——模型出错时没有纠正入口，错误就永久留存。",[10,3871,3872],{},"我在 yun-claude 的记忆设计中遇到的问题正是这个。聊天系统能自动从对话抽取持久要点并入库，但页面还是传统的卡片列表，看不出记忆的类型、重要性、使用频次。更严重的是，用户无法编辑或删除那些被错误标记的记忆。所以这次升级的核心不是加一个炫彩的可视化，而是把记忆管理变成一个真正可用的信息工作台。",[14,3874,3876],{"id":3875},"表格优先而不是星河优先","表格优先，而不是星河优先",[10,3878,3879,3880,3401],{},"设计的第一个决策是：",[170,3881,3882],{},"主界面用表格承载记忆列表，不用星河画布作主体交互",[10,3884,3885],{},"这听起来反直觉。当初考虑过让星河画布成为核心——节点按重要性大小分布、按创建时间环形排列、搜索命中时高亮。视觉上是漂亮的，也更有\"知识宇宙沉淀\"的产品感。但约束改变了这个选择：",[10,3887,3888],{},"第一，信息密度。表格一屏可以显示 10-20 条记忆的核心属性（标题、类型、重要性、标签、创建时间），并支持排序和筛选。星河画布要展示相同的信息量，就必须让节点变小、缩放调整、甚至分屏展示——交互成本陡升。而用户——AI agent 的主人——需要快速浏览和定位记忆，不是浏览艺术装置。",[10,3890,3891],{},"第二，编辑成本。星河上的节点编辑通常要额外打开面板或弹窗。如果记忆管理的主要工作是\"检查是否有错记、删除重复、调整分类\"，那星河就不是最优方案。对比之下，表格 + 右侧详情面板的结构让用户可以同时看到列表和正在编辑的项目，没有上下文切换。",[10,3893,3894],{},"第三，用户规模。当前 yun-claude 没有真实用户，推断单个用户的记忆条数会比较少（几十到几百）。在这个规模下，表格就足够快了，不必依赖图形加速或向量索引的复杂优化。如果将来规模增大再考虑换方案。",[10,3896,3897,3898,3901],{},"所以最终设计是：",[170,3899,3900],{},"表格为主工作台，右侧详情面板负责编辑与整理，星系可视化只作为辅助视图（后续可选）","。这不是缺乏视觉想象力，而是对工作流的务实权衡。代价是产品感稍弱，但可用性强得多。",[14,3903,3904],{"id":3904},"五类语义记忆与设计令牌",[10,3906,3907],{},"为了让记忆可见可管，将长期记忆分成五类：",[39,3909,3910,3916,3922,3928,3934],{},[42,3911,3912,3915],{},[170,3913,3914],{},"核心记忆","（CORE）：与用户身份、核心项目、重要约束直接相关。模型在每轮对话发送前都应该检索。",[42,3917,3918,3921],{},[170,3919,3920],{},"常驻记忆","（PERMANENT）：用户的工作背景、技术栈偏好、团队结构等长期背景。",[42,3923,3924,3927],{},[170,3925,3926],{},"临时记忆","（TEMPORARY）：短期的任务进度、当前问题、临时约束。生命周期短。",[42,3929,3930,3933],{},[170,3931,3932],{},"知识星云","（KNOWLEDGE）：用户分享的文档要点、API 文档摘录、最佳实践。主要用于 RAG 增强。",[42,3935,3936,3939],{},[170,3937,3938],{},"其他","（OTHER）：模型无法明确分类或用户手动标记的记忆。",[10,3941,3942],{},"每一类的关键差异是生命周期和召回策略。核心记忆应该高频被注入 prompt，临时记忆应该自动清理，知识类应该被 RAG 系统共同使用。",[10,3944,3945],{},"为了在视觉上强化这个分类，为每类配置了一个独立的语义色：",[74,3947,3948,3961],{},[77,3949,3950],{},[80,3951,3952,3955,3958],{},[83,3953,3954],{},"类型",[83,3956,3957],{},"颜色",[83,3959,3960],{},"用途",[93,3962,3963,3974,3984,3994,4004],{},[80,3964,3965,3968,3971],{},[98,3966,3967],{},"核心",[98,3969,3970],{},"Amber-500",[98,3972,3973],{},"节点、筛选按钮、卡片左边线",[80,3975,3976,3979,3982],{},[98,3977,3978],{},"常驻",[98,3980,3981],{},"Sky-500",[98,3983,3973],{},[80,3985,3986,3989,3992],{},[98,3987,3988],{},"临时",[98,3990,3991],{},"Teal-500",[98,3993,3973],{},[80,3995,3996,3999,4002],{},[98,3997,3998],{},"知识",[98,4000,4001],{},"Violet-500",[98,4003,3973],{},[80,4005,4006,4008,4011],{},[98,4007,3938],{},[98,4009,4010],{},"Slate-500",[98,4012,3973],{},[10,4014,4015,4016,4019],{},"关键的设计决策是：",[170,4017,4018],{},"颜色不只是装饰，而是结构的一部分","。在表格、筛选条、详情面板的边线上，用户都能看到同一个颜色，强化类型认知。这样的一致性是可信的信息工作台的标志——用户一眼知道哪条记忆是核心、哪条是临时。",[10,4021,4022,4023,4026],{},"颜色值不是散落在各个组件里的魔法数字，而是集中在一个 ",[275,4024,4025],{},"memoryStyles.ts"," 文件中管理。任何需要记忆类型颜色的地方都从这里引用。这样改一个颜色时不必跨多个文件搜索替换，也不会出现同类型在不同地方显示不同色的尴尬局面。",[14,4028,4029],{"id":4029},"记忆模型与后端约束",[10,4031,4032],{},"后端为每条记忆增加了结构化字段：",[39,4034,4035,4041,4047,4053,4059,4065,4071],{},[42,4036,4037,4040],{},[275,4038,4039],{},"title","：节点或列表行的标题，从正文前 18 个字符生成",[42,4042,4043,4046],{},[275,4044,4045],{},"type","：五类之一",[42,4048,4049,4052],{},[275,4050,4051],{},"importance","：1-100 的重要性分值，决定节点大小和列表排序",[42,4054,4055,4058],{},[275,4056,4057],{},"tags","：字符串数组，最多 8 个标签",[42,4060,4061,4064],{},[275,4062,4063],{},"lastUsedAt","：最近被召回的时间",[42,4066,4067,4070],{},[275,4068,4069],{},"usedCount","：被召回次数",[42,4072,4073,4076],{},[275,4074,4075],{},"metadata","：扩展字段，保存来源会话、模型、抽取动作等",[10,4078,4079],{},"当模型抽取记忆时，输出包含这些结构化字段。服务端必须做兜底和校验：type 非法时改为 OTHER、importance 超范围时裁剪、tags 去重去空白最多保留 8 个。如果抽取失败，仍然保存正文并用默认字段（importance=50, type=OTHER）。",[10,4081,4082],{},"这些默认值的设计是为了降级优雅。即便结构化抽取失败，记忆也不会丢失，只是分类不精准——这是可以接受的，因为用户随后可以手动调整。",[14,4084,4086],{"id":4085},"搜索筛选与编辑","搜索、筛选与编辑",[10,4088,4089],{},"用户在表格上方有一个搜索框和类型筛选条。搜索时调用后端的语义搜索接口，命中的记忆在表格中高亮，并自动选中最高相关性的一条，打开右侧详情面板。语义搜索基于向量数据库的余弦相似度匹配——记忆文本被嵌入为 4096 维向量，查询时也转换为向量并与库内存储按相似度排序召回，只返回分值达到阈值（≥0.3）的结果。这样的匹配比关键词搜索精准度高，也支持语义近似的记忆关联（比如\"TypeScript 后端\"和\"TS 服务端\"会被认为相关）。搜索失败时保留当前星河状态并 toast 提示，不中断工作流。",[10,4091,4092],{},"筛选可以按五类过滤，清除筛选则回到全量视图。如果当前选中的记忆被筛选隐藏了，系统会自动选中可见记忆中最高重要性的那一条；如果没有可见记忆，则关闭详情面板显示空状态。",[10,4094,4095],{},"详情面板里，用户可以编辑标题、正文、类型、标签和重要性。编辑表单使用产品内设计，不弹浏览器原生弹窗。保存后立即更新列表和节点（如果有星河视图的话）。这样用户修改一条记忆时，不必刷新页面或等待后台同步，改动立刻可见。",[10,4097,4098],{},"删除是另一个关键能力。必须让用户能删除被错误标记的记忆，否则错误就成了永久污染源。删除按钮放在详情面板的危险操作区，点击后需确认。删除成功后，该条记忆从表格和星河中消失，列表自动选中下一条（如果有的话）。",[14,4100,4101],{"id":4101},"星河与移动端降级",[10,4103,4104],{},"虽然主界面是表格，但在设计中保留了星河作为可视化补充。桌面端在表格下方或侧边可以显示一个小星图，让用户看到记忆的空间分布——核心记忆聚在中心，临时记忆分散在外围。星河上的节点与表格关联：点击表格行时，星河也高亮对应节点；点击星河节点时，表格定位到对应行。",[10,4106,4107],{},"但星河不是强制项。第一版的实现可能不包含完整星河，而是先确保表格工作台完整可用。星河可以作为后续的增强——用户如果觉得需要可视化辅助，才加上去。",[10,4109,4110],{},"移动端设计上，星河更是不现实（屏幕太小）。所以移动端完全降级为列表视图，点击列表项后通过底部抽屉显示详情和编辑表单。搜索和类型 tabs 放在顶部，保持核心交互可用。这样既避免了表格在手机上的横向溢出，也保证了可用性。",[14,4112,4113],{"id":4113},"节点布局与稳定性",[10,4115,4116,4117,4120],{},"如果星河要实现，一个重要的设计细节是：",[170,4118,4119],{},"节点位置必须稳定","。即便刷新页面或重新打开应用，同一批记忆应该保持相同的位置，这样用户才能形成空间记忆——\"核心记忆总在中心，临时的在右上角\"。",[10,4122,4123,4124,4127],{},"这意味着节点位置不能是随机的实时布局，也不能用物理模拟（那会每次都算不同的位置）。用 ",[275,4125,4126],{},"createdAt"," 字段参与布局计算，保证确定性：同一条记忆的创建时间固定了，它在环形排列中的角度也就固定了。结合 importance 决定距离中心的远近，位置就完全由数据驱动，刷新后毫厘不差。",[14,4129,4130],{"id":4130},"一致性与可维护性",[10,4132,4133],{},"这个设计的关键约束是一致性。如果记忆在表格中是 Amber-500（核心），那在星河、筛选、详情面板的边线上也必须是 Amber-500。任何拆散这个一致性的修改都会破坏用户的心智模型。",[10,4135,4136,4137,4140],{},"所以在设计系统里明确了这一点：",[170,4138,4139],{},"新增或调整记忆类型视觉时，先更新设计文档里的语义色板和 memoryStyles.ts，再在各组件中引用，不允许组件各自复制色值","。这样的约束看似严苛，但它保证了长期的可维护性——下次要改颜色时，只需改一个文件。",[14,4142,4143],{"id":4143},"代价与权衡",[10,4145,4146],{},"这个设计的代价是什么？",[10,4148,4149],{},"第一，视觉冲击力比不上星河优先。表格是务实的设计，不够\"黑科技\"感。如果产品定位是\"AI 记忆系统\"要卖视觉冲击，这个方案会显得保守。",[10,4151,4152],{},"第二，星河的潜力没有完全释放。放弃了复杂的关系推理、自由漫游、力导向布局这些\"高级\"可视化特性，原因就是表格工作台不需要它们，而加上去反而添加复杂度。",[10,4154,4155],{},"第三，设计系统的维护成本提高了。一致性的要求意味着每次改动都要考虑全局影响。但这其实是长期收益——减少了 bug 和不一致的可能。",[10,4157,4158],{},"这些代价对 yun-claude 是可以接受的，因为当前用户还不多，重点是让产品可用，而不是炫技。如果将来用户规模上升，数百条甚至千条记忆的管理场景出现，表格 + 星河的混合界面可能不够，那时再考虑更激进的可视化方案。现在，信息工作台的优先级明确高于视觉体验。",{"title":277,"searchDepth":690,"depth":690,"links":4160},[4161,4162,4163,4164,4165,4166,4167,4168],{"id":3875,"depth":690,"text":3876},{"id":3904,"depth":690,"text":3904},{"id":4029,"depth":690,"text":4029},{"id":4085,"depth":690,"text":4086},{"id":4101,"depth":690,"text":4101},{"id":4113,"depth":690,"text":4113},{"id":4130,"depth":690,"text":4130},{"id":4143,"depth":690,"text":4143},"2026-06-29",{},"\u002F2026-06-29",{"title":3864,"description":3869},"2026-06-29-记忆星系长期记忆可视化工作台","表格优先的记忆管理：用高信息密度工作台承载持久要点，可视化只作辅助，设计令牌贯穿全局。",[4176,4177,4178,4179,4180],"长期记忆","信息工作台","设计决策","语义搜索","设计令牌","gsJKxlNQ-FiADsw2ca6e5WJo-_uyYDFZ55iuiMr4jQE",1785406912256]