Files
dsh_shenxian/dsh-server-docs/04-调整方案/76-dsh-univer-office平台适配改造-unix-socket与同源代理.md
T
admin 5ad755116e chore(docs): 文档库并入代码仓(R4 选 a)+ 索引/台账跟进
1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
   保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
   工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
   必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
   + ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
   ⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
   验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
2026-09-15 18:47:13 +08:00

451 lines
39 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 76 · dsh-univer-office 平台适配改造:unix socket 传输 + 同源 Viewer 代理(2026-09-12)
- 日期:2026-09-12
- 状态:✅ **代码改造 + 组件级验证 + 打包完成**;⏳ **端到端验收待拍板(R8)**
- 触发:`档案 75` 立「托管友好性」评估维度后,用户要求「直接修复插件就好了」→「开整」
- 产物:`[email protected]`(fork 版,41 MB,`/d/tmp/dsh-univer-office-0.2.15.tgz`)|源码 `D:\github\dsh-univer-office-src`(浅克隆上游 + 本地改造,**未 commit**)
- 关系:**`档案 75 §四 改造规范 R-a~R-e` 的首个完整落地样本**(入口唯一 / IPC 代替 TCP loopback / 不新增端口 / 保留懒加载空间 / 相对地址)
---
## 一、为什么必须改造(两层独立阻碍,缺一都打不开)
| 阻碍 | 机理 | 实测证据 |
|---|---|---|
| **Host → Gateway** | gateway 由 Host spawn,**继承同一 uid**(100002);平台 nft 对 `skuid 100000-199999 → 127.0.0.0/8` 是 `reject with tcp reset` ⇒ **Host 连不上自己拉起的 gateway** | `univer_new` 恒报 `bundled Gateway did not become ready within 10000ms` |
| **Browser → Viewer** | 源码**硬编码** `gateway = http://127.0.0.1:${port}`、`viewerUrl = ${gateway}/?file=…`,client 用它作 **iframe src** ⇒ 浏览器去**用户自己电脑**的 127.0.0.1 找 Viewer | `grep -o "viewerUrl: [^,]*" lib/index.js`;`http://127\.0\.0\.1:${String(port)}` |
**⇒ 解封 loopback 也没用**(第二条拦在浏览器侧)。**只能改插件**。
**为什么不能「换成服务器 IP」**(用户曾提):nft 封禁名单**含服务器自身 IP**(`daddr { 47.77.182.89, 127.0.0.0/8, 172.17.0.1, 172.18.16.212 }`);且多租户同主机绑端口会**跨租户泄露**、HTTPS 页面嵌 HTTP iframe 被**浏览器硬拦**。**正解是换成「一条路径」而非「一个地址」。**
---
## 二、改造清单
### 新增(4 个模块)
| 文件 | 作用 |
|---|---|
| `src/shared/gateway-socket.ts` | `UNIVER_DSH_GATEWAY_SOCKET` 解析(值或 `auto`)+ `unix:<path>` 端点标识(`unixOrigin` / `parseUnixOrigin`) |
| `src/shared/unix-http.ts` | HTTP over unix socket 最小客户端(`ok`/`status`/`headers.get`/`text`/`json`/`arrayBuffer`/`signal`),**零新依赖** |
| `src/shared/gateway-request.ts` | **统一传输入口** `requestGateway(endpoint, path, init)` —— 按 endpoint 前缀自动选 socket / TCP |
| `src/host/webServer/viewer-proxy.ts` | **同源反向代理**:HTTP(流式、剥离 hop-by-hop 头)+ **WebSocket upgrade 转发** + `viewerSocketPaths(fileKey)` |
| `src/shared/viewer-paths.ts` | 浏览器侧路径常量(独立于 webServer,避免 service 反向依赖) |
### 改动(7 个文件)
| 文件 | 改动 |
|---|---|
| `gateway-app/server.ts` | `LOOPBACK_HOST` 常量 → `socketPath` / `host` 选项;`listen()` 支持 unix socket(**TCP 默认行为不变**)|
| `gateway-app/main.ts` / `gateway-entry.ts` | 入口支持 socket(真入口读 `UNIVER_COLLAB_GATEWAY_SOCKET`)|
| `host/processes/gateway/launcher.ts` | 把 socket 路径经 env 传给 gateway 子进程 |
| `host/processes/gateway/gateway-process.ts` | 端点按传输打标:`http://127.0.0.1:<port>` 或 `unix:<path>` |
| `host/processes/gateway/protocol.ts` | 健康检查与 `probeGateway` 支持 socket(**Viewer 身份判据在两种传输上都保留**)|
| `host/adapters/gateway/client.ts` | 改用 `requestGateway`(一处覆盖绝大部分调用点)|
| `host/webServer/{router,plugin}.ts` | viewer 代理分支 + `/uf` 数据面注册 + **按文件懒注册 WS 路由** |
| `host/provider/gateway-univer-service.ts` | `viewerUrl` 与 worktree 的 `base`(派生出 `openUrl`/`worktreeUrl`/`mergeUrl`)→ **相对路径** |
| `host/provider/render-source-operations.ts` | 截图资产取图改用 `requestGateway` |
---
## 三、关键设计决策(三条,都踩过)
### 1. socket 是**可选传输**,不是替换 TCP
由 `UNIVER_DSH_GATEWAY_SOCKET` 开启(设路径,或设 `auto` 取私有 temp 下的进程级路径);**不设则完全保持上游原行为**。这样对上游友好——**默认行为不变,可以安心合并**。
### 2. 代理规则只有两条(因 gateway 的 API 全在 `/uf` 前缀下)
`main.ts` 自带的端点清单揭示:所有数据面请求都带 `/uf/<base64url(univerfile)>`,WS 也在其中。故:
```
/uf/** → 转发 gateway(HTTP + WS upgrade)
/univer-api/viewer/** → 转发 gateway 的 / 与 /assets/**(Viewer 页面 + 静态)
```
**关键**:**Viewer 加载后用绝对路径请求 `/uf/*`**,所以代理**必须接管 `/uf/*`**,否则页面打开但数据拿不到。
### 3. ⚠️ 两条容易踩的机制(写下来避免重犯)
- **dsh webServer 前缀匹配无「最长优先」** ⇒ `/univer-api` 会吃掉 `/univer-api/viewer/...` ⇒ **viewer 代理必须放进现有 router 的 dispatch 内**,单独注册会被 404 截胡;`/uf` 不冲突可独立注册。
- **`registerUpgrade` 是精确路径**,而 gateway 的 WS 路径含动态段(`/uf/<enc>[/worktrees/<id>]/…`)⇒ **只能按文件懒注册**(打开文件时种子化 + Map 去重 + `ctx.effect` 统一 dispose)。
- 另:`register` 的路由**不存在「catch-all」**,`kind: 'prefix'` 已是可用的最宽形式。
---
## 四、验证(组件级全绿)
| 项 | 结果 |
|---|---|
| 依赖安装 | ✅ 150+ 依赖(含私有 registry `insider-npm-registry.univer.work` 的 insiders 快照;该 registry **公开可读**)|
| 构建 | ✅ 5 个产物;增量构建 ~5-16 s |
| **回归测试** | ✅ `test/integration-smoke.mjs` **OK**(18 项:new/status/Unit/import/export/screenshot/print-pdf/resources/worktree lifecycle…),**改动未破坏原功能** |
| **Linux socket 验证** | ✅ 服务器隔离目录内:`listening on unix:…gw.sock`;`GET /` → **200**,`<title>Univer</title>` + `<div id="app"></div>` 均为 true;`GET /uf/AAAA` → 400(路由可达);**TCP 对照同样通过** |
| 打包 | ✅ `dsh-univer-office-0.2.15.tgz`(41 MB)|
**验证方法(不碰生产)**:构建产物传到服务器 `/tmp/uv-verify`,原生依赖用 **guest 实例已装的 node_modules 只读符号链接**;跑完即清理。
⚠️ **Windows 上无法验证 socket**:本机 AF_UNIX 返回 **EACCES**(用纯 `node:net` 同样复现)⇒ **平台限制,非代码问题**。
---
## 五、已知限制
| 限制 | 说明 |
|---|---|
| **worker 侧仍走 TCP** | `unit-content-worker`(协同/快照类)内部用标准 `fetch` 拼 12 个 URL,改造面大且属边缘功能。**导出 xlsx 走 Host 的 `GatewayClient`,不受影响** |
| worktree 场景的 WS | 只注册了文件级路径(`/uf/<enc>/…`);`/worktrees/<id>` 段未种子化 |
| fork 维护 | 上游更新需合并(见 §六)|
---
## 六、回滚与维护
- **回滚**:不设 `UNIVER_DSH_GATEWAY_SOCKET` 即回到**原 TCP 行为**(无需改代码);候选池回退到上游 `0.2.14` 即可整体退回。
- **维护承诺(待定,需用户拍板)**:fork 放哪 / 谁维护 / 上游更新怎么合 —— 这是长期成本,**不解决就只是把问题上移**。
- **更优路径**:把本改造作为 **issue + patch 提给上游**(`dream-num/dsh-univer-office`)——论证是「你的架构在容器化/托管环境可以更简单更安全」,而非「请支持我们的平台」。
---
## 七、待办
| # | 项 | 前置 |
|---|---|---|
| 1 | **端到端验收**(部署到 guest 实例,真的打开一次表格)| **需用户拍板**(R8:启停 = 重启实例、断在线)|
| 2 | 落档后的四件套校验与(可选)scp | 本档 |
| 3 | worker 侧 socket 支持 | 视验收结果 |
| 4 | 定 fork 维护策略 / 提上游 issue | 需用户决策 |
---
## 修正(2026-09-13 07:1x 只读复核)
> 按「档案只增不改」追加。**上文头部「状态」与 §七 待办 ① 的"待拍板"表述,已被本段取代。**
- **部署已发生**(09-12 23:2x 用户「确认执行」):候选池 `business-plugins/dsh-univer-office.tgz` = **0.2.15**(42.4 MB,09-12 23:19);**guest(uid 100002)`profile/web` 的 `bundles` 已含 `dsh-univer-office`**、`node_modules` 已装 ⇒ 改造版**已在生产用户环境启用**(非"待部署")。
- **端到端仍未闭环**:09-12 23:24 用户浏览器请求 `/univer-api/state` → **400(8.2 s)**,当时实例无 gateway 子进程、`/tmp` 无 `.sock`;根因未定(400 属校验类错误码,非 500)。⇒ §七 待办 ① **仍开放**,但前置已从"部署待拍板"变成"**复测 + 定位 400**"。
- **兜底仍在**:不设 `UNIVER_DSH_GATEWAY_SOCKET` 即回退原 TCP 行为;候选池可整体回退上游 `0.2.14`。
- 复核手段(只读):`tar -xzOf … | grep version`、`cat …/profiles/web/package.json`、`ls node_modules`(`bt-server` 只读 ssh)。
---
## 追加(2026-09-13 07:3x · 生产实测):`/univer-api/state` 持续 **400**
**现象**:2026-09-13 07:35–07:36,guest 用户在约 1 秒内被**连续 ~10 次** `GET /univer-api/state?file=<绝对路径>.univer&sessionId=session-eda867a0-…`,平台侧日志**全部 `statusCode: 400`**(`host: guest.alotbuy.com`,`remoteAddress: 125.85.124.119`)。08:02 重启后**无该调用**(用户已离开页面),故暂未复现。
**已查明的事实(只读排查,未改任何东西)**:
| # | 事实 | 证据 |
|---|---|---|
| 1 | 400 的**来源映射**在插件 host 侧 | fork `src/host/webServer/router.ts`:`INVALID_REQUEST` / `INVALID_FILE_PATH` / `SESSION_SCOPE_UNAVAILABLE` → **400**;`FILE_PERMISSION_DENIED` / `SESSION_SCOPE_DENIED` → 403 |
| 2 | 寻址校验的三条 400 触发点 | `src/gateway-app/univerfile-manager.ts`:`:142` key 缺失、`:146` key 不可解码、`:154` **必须是 `.univer` 路径**、`:217-222` **超出 `allowedRoot`** |
| 3 | **请求的那个 `.univer` 文件不存在** | `find /var/lib/…/users/4092b965…/ws -name '*.univer'` → **0 个命中**;而 `ws/MCN短视频创作/` 目录**存在** |
| 4 | 插件已就位且是 fork 版 | guest `bundles` 含 `dsh-univer-office`,`node_modules` 里版本 **0.2.15** |
| 5 | 插件侧**无日志可查** | 实例 scope 的 journal 只有 2 行(插件 stdout 不落 journald);平台日志只记状态码,**拿不到响应体 `{ok:false,code,message}`** |
**两个候选根因(未定论)**:
1. **寻址类 400**(`UniverfileError`)—— 路径不在 `allowedRoot` 内 / 形态不符;
2. **`SESSION_SCOPE_UNAVAILABLE`** —— 传入的 dsh `sessionId` 在插件侧不可用。⚠️ 07:36 时实例刚经历 00:15 的重启,**旧会话可能已失效**,这条与"用户反复重试"的现象很吻合。
**下一步(取到确凿根因的最小代价)**:让该页面**带浏览器网络面板再打开一次**,直接看 `/univer-api/state` 的响应体(`code` / `message` 会直接指明上面哪一条);或用临时会话 + 真实 `sessionId` 复现。
**另需查的线索**:**`.univer` 文件为何不存在** —— 是 MCN 侧生成后又被清理(注意 04:10 的 `ws-cleanup`),还是前端打开的是历史路径?这属于「MCN 生成 → Univer 打开」链路,不在本档改造范围。
---
## 追加(2026-09-13 17:0x–17:2x · **真因定位 + 修复 + 已投放并验收**)
> 触发:用户「看看 guest 的最新会话,univer 这个插件有问题需要处理」。
> guest 会话 `session-21ba73b0`(16:12–16:23,「检查Univer是否可用」)实测:`univer_new/status/worktree/unit/api` 正常,
> 但 **`univer_execute` / `univer_inspect` 全线 `COLLABORATION_UNAVAILABLE`**(连带 export / lint / pdf / compile-svg)。
### 一、两个**互相独立**的根因(修掉第一个才会暴露第二个)
| # | 层 | 根因 | 证据 |
|---|---|---|---|
| **1** | **插件侧(真因)** | worker 的三个 socket 垫片**全部失效**:`entry.ts` 原顺序是「顶层 `await main()` 在前、模块级常量在后」,打包器把 `const SOCKET_ORIGIN` 降级为**函数作用域 `var`** 并整体提升 ⇒ 顶层 await 处 `main()` 已在跑、而垫片读到的常量仍是 `undefined` ⇒ `'http://unix/…'.startsWith(undefined)` **恒 false** ⇒ 静默把请求交回真 `fetch` ⇒ 拨号主机名 `unix` ⇒ 只报 `COLLABORATION_UNAVAILABLE`。**0.2.16–0.2.23 全部受影响**(垫片逻辑本身是对的,是**打包顺序**把它废掉了) | 临时探针(在 bundle 里插日志):`EARLY-RETURN target=http://unix/uf/… \| origin=undefined`;对照实验——同样入参、仅把 origin 换成字面量,则 `fetch-result status=200` + `ws-construct ws://unix/…` 全部打通 |
| **2** | **环境侧** | `@univerjs-pro/engine-formula-rust-binding` 的 linux-x64-gnu 产物按 **glibc ≥ 2.35** 链接;本机 = Alibaba Cloud Linux 3 / **glibc 2.32** ⇒ `require()` 抛 `version 'GLIBC_2.35' not found` ⇒ **建内容投影时整进程 uncaught 崩**(不是可降级的错误) | `ldd --version` → 2.32;单独 `require(<binding>.node)` → 同样报错;栈顶 `RustSameRuntimeProjectionController._initialize → _loadProjection → loadNativeBinding` |
**上游对 #2 本就有闸门**:`IUniverRustFormulaEngineConfig.useRustEngine: false` ⇒ 不注册 `RustEngineSyncController` /
`RustSameRuntimeProjectionController`,公式改由 `@univerjs-pro/engine-formula` 的 **JS 引擎**算(功能等价,大表慢些)。
但 `@univerjs-cli/headless-univer` 的 `createStandardHeadlessUniverFactory()` **不接受该配置、也不暴露注册点**。
### 二、修复(本仓 `dsh-univer-office-src`,均未 commit)
| 文件 | 改动 |
|---|---|
| `src/workers/unit-content/entry.ts` | ① 三处 `installUnixGateway*Shim()` 提到**顶层 `await main()` 之前**;② 垫片内 origin 一律用**函数内字面量**(新增 `gatewaySocketPath()` 统一读 env)——双保险,防再被重排顺序废掉;③ `http.request` 垫片补认 **URL 对象 / `(url, options, cb)`** 两种形态(原先只认字符串)+ 从 URL 抠 path;④ WS 取不到 `ws` 包时**出声报错**(原先静默吞,掩盖了这个失败) |
| `src/workers/unit-content/rust-formula-engine-host-compat.ts`(新增) | 同名导出上游一切,仅在**绑定确实装不上**时强制 `useRustEngine:false`。**绑定可用则原样转交上游** ⇒ 宿主 glibc 升级后**自动恢复 Rust,无需改代码** |
| `scripts/build.mjs` | 新增 `rustFormulaEngineHostCompat()`(esbuild 虚拟模块 + `resolveDir` 指向 pnpm 提升目录),**只作用于 worker 构建**:host(lib)不含该插件,gateway 只读快照、不建公式投影 |
### 三、投放与验收
- 产物 **`[email protected]`**(`dsh-univer-office-0.2.24.tgz`,md5 `c1a12688ca70c51a321d75ad7a8aefbd`)。
- 候选池按**平台既有 admin 接口**重新登记:`POST /api/plugins/business`(临时 admin 会话)⇒ `replaced:true`、**`compat.level = "ok"`(未用 trust override)**、版本刷成 0.2.24。
- 换包用 `POST /api/plugins/mine/apply`(临时 guest 会话)⇒ `pnpm add file:<池内 tgz>` + bundles 重算 + **重启实例并探活**。
⚠️ **该接口对「已启用」插件是 `noop`**(`toEnable` 只算「新选中且原先不在 bundles 里」的项)⇒ **更新已启用插件必须「先停用、再启用」两步**(平台侧缺口,见遗留 ③)。
- 验收(**实装产物**、**tenant uid 100002**、**unix socket**、对真实 `.univer`):
| 项 | 结果 |
|---|---|
| 实装 worker md5 == 构建产物 md5 | ✅ `b74584db5ab047ba2c987c6ff76ebfb6`(逐字节一致) |
| `univer_execute`(doc 单元) | ✅ `{"ok":true,…}` |
| `univer_execute`(sheet 单元,真读工作表) | ✅ `{"ok":true,…"value":{"sheet":"Sheet1"}}` |
| 垫片降级可见性 | ✅ stderr 明确打印 `[uvcompat] Rust formula engine binding unavailable -> JS formula engine fallback: …GLIBC_2.35…` |
| 平台/实例状态 | ✅ `dshs` active、实例 scope active(重启+探活由平台自身完成) |
### 四、遗留(本次**未**处理,按需另开)
1. **`univer_screenshot` 仍缺 Chromium**(实例 `/usr/bin` 无 chromium/chrome)——独立缺口。
2. **`ws` 只是上游的 devDependency**:生产里靠 profile 的 `.pnpm/node_modules` 提升才解析得到;已把「取不到」改成出声报错,但**依赖未被声明**这件事仍是长期脆弱点。
3. **平台 apply 接口不重装已启用插件**(更新插件需两步停用→启用)——建议后续补「已启用项也纳入 toEnable」的能力。
4. 宿主 glibc ≥ 2.35 后:垫片自动恢复 Rust 引擎,**本档改动无需回退**(回退只针对 #1 的垫片与顺序修正)。
### 五、回滚
- 换包回退:池内已留 `dsh-univer-office-0.2.23.tgz.bak`(改名成 `.tgz` 后 admin 重新登记 → 停用→启用);整体退回上游 `0.2.14` 亦可。
- 不设 `UNIVER_DSH_GATEWAY_SOCKET` 即回退 TCP 模式(原口径不变)。
### 六、【复现配方】不碰生产也能定位「worker 侧」故障(本次就是这么定位的)
> 适用于任何「网关在跑、但内容操作报错」的疑似链路问题。全部在 `/tmp/<工作名>/` 里做,**不碰实例、不碰用户 `ws`**,跑完 `rm -rf`。
1. **起一个自己的 gateway**(socket 模式,用**实装**产物):
`env -i HOME=<tmp> PATH=/usr/local/bin:/usr/bin:/bin UNIVER_COLLAB_GATEWAY_PORT=19292 UNIVER_COLLAB_GATEWAY_SOCKET=<tmp>/gw.sock UNIVER_VIEW_ASSETS_ROOT=<pkg>/artifacts/viewer UNIVER_ALLOWED_ROOT=<tmp>/ws NODE_PATH=<pkg>/node_modules setsid nohup node <pkg>/artifacts/gateway.cjs > <tmp>/gw.log 2>&1 &`
—— 起得来(`listening on unix:…`)+ `curl --unix-socket … http://localhost/` 返回 `200 <title>Univer</title>` = 网关侧没问题。
2. **手工构造 worker 请求**(JSON 一行,含 `gatewayOrigin:"http://unix"` + `gatewaySocket:<sock>` + `fileKey`(**`base64url(绝对路径)`,去 `=`**)),
以**租户 uid** 跑:`printf '%s' "$REQ" | env -i HOME=<tmp> … UNIVER_DSH_GATEWAY_SOCKET=<sock> UNIVER_DSH_GATEWAY_DEBUG=1 setpriv --reuid=<uid> --regid=<uid> --clear-groups node <pkg>/artifacts/unit-content-worker.mjs`
3. **看 `[uvshim]` / `[uvcompat]` 轨迹**:`fetch-called → fetch-result status=… / ws-construct / http-request host=…` 每一步都该出现;
**只出现 `fetch-called` 后面就断** ⇒ 垫片内部提前 return(本次即 `SOCKET_ORIGIN=undefined`)。
4. **兜底判据**:把 gateway 换成一个「什么都回 200 + 打印请求」的假 socket 服务器 —— 若假服务器**一个请求都没收到**而 worker 仍报错,
⇒ 问题在 worker 侧(请求根本没发出去),不在网关侧。
5. ⚠️ **临时目录里的 worker 解析不到 `ws`**(`createRequire` 沿路径上溯)⇒ 复现时 `NODE_PATH` 要补 `…/node_modules/.pnpm/node_modules`。
6. ⚠️ **两处易踩**:`systemctl stop dsh-<uid>-*.scope` 后,随便发一个带 `Host: <user>.alotbuy.com` 的请求就会把实例拉起来(无需登录,401 也算);
临时会话用 `node /opt/dshs/mksess{,-guest}.cjs`,**用完必须 `DELETE FROM sessions WHERE user_agent='poc-curl2'`**。
7. ⚠️ **跑 harness 时 `NODE_PATH` 必须带 `…/node_modules/.pnpm/node_modules`**(worker 与 gateway 都如此):否则外部依赖(`libsql` / `ws` 等)解析不到,
会被误判成「插件坏了」。同理垫片的绑定探针在缺这个路径时会报 `Cannot find module`(结论恰好仍正确,但理由是假的)。
---
## 追加(2026-09-13 17:5x · **第二轮**:`gateway` 侧同一根因 ⇒ 「写入假错误」;已投放 **0.2.25**)
> 触发:用户「再看看 guest 的最新会话记录」→ guest **自己在同一会话复测**(17:34–17:37):内容读写层**已恢复** ✅
> (Sheet 值/公式/重算、Doc、Base、Board、Slide 读取全通过,且数据真落盘),但暴露两件事。
### 一、**写入报假错误**(真 bug,已修)
| 项 | 内容 |
|---|---|
| 现象 | 写操作返回 `Collaboration HTTP request failed`,但 `univer_inspect` 读回**数据正确落盘** ⇒ 有「报错就重试 → 重复写入」的风险 |
| 复现 | 组件级 harness(租户 uid + socket + 实装产物)跑一次**写入型** `execute`:日志尾部 `socket-request FAILED: read ECONNRESET` → 之后 `connect ECONNREFUSED`;**网关日志里是 `Failed to load native formula engine binding`(GLIBC 2.35)** |
| 真因 | **gateway 在提交 changeset 时也要建 workbook 投影** ⇒ 撞同一个 glibc 崩溃 ⇒ **网关整进程死**;客户端读到连接被重置 ⇒ 假错误;数据在崩前已落盘(所以「报错但数据在」)。⛔ **本档 §三 之前"网关只读快照、不建投影、不用打补丁"的判断是错的**,已纠正 |
| 修(两处) | ① `scripts/build.mjs`:把 `rustFormulaEngineHostCompat()` **同时挂到 gateway 构建**;② 修跨产物差异 —— **CJS 产物里 `import.meta` 被 esbuild 降级成空对象** `var import_meta = {}` ⇒ `createRequire(import.meta.url)` 抛 `ERR_INVALID_ARG_VALUE`(**网关启动即崩**)⇒ (a) 垫片内改用 `typeof __filename === 'string' ? __filename : import.meta.url`;(b) 插件在 **CJS 产物**下把垫片自身的 import 显式指向该包的 **CJS 入口**(`lib/cjs/index.cjs`;ESM 入口靠 `import.meta.url` 定位原生绑定,CJS 里必崩) |
| 验收 | 实装产物 + 租户 uid 100002 + socket:写入返回 `{"ok":true,…,"committed":true,"revision":4}`;`socket-request FAILED` **0**、网关**存活**、崩溃计数 **0**、`uv_probe4` **在文件里** ✅ |
| 产物 | **`[email protected]`**(`dsh-univer-office-0.2.25.tgz`,md5 `01f0f3828a09b3fc4fa4269e519378ba`) |
### 二、**导入导出仍不可用**(环境级,非本档可修)
`@univerjs-pro/exchange-node` 的 loader **只尝试** `@univerjs-pro/exchange-node-binding`(源码级已确认:无 JS 回退、**无 env 覆盖**,与公式引擎不同)
⇒ xlsx / csv / docx / pptx 的导入导出在本机(**glibc 2.32**)**必然失败**。渲染类(`compile_svg` / `lint` / `screenshot` / `print_pdf`)
则是**缺 Chromium**(实例内无浏览器,`/usr` 只读,`UNIVER_RENDER_BROWSER` 由 DSH 主进程 env 决定)。
⇒ 两者都**超出插件侧能修的范围**,属宿主/镜像决策(换 glibc ≥ 2.35 的基底、或装 Chromium)。
### 三、备查
- 平台 `POST /api/plugins/mine/apply` 的任务**终态是 `success`**(不是 `done`;另有 `failed`)—— 轮询别只判 `done`,否则会空转(本次踩到)。
- 换包流程仍是**「停用 → 启用」两步**(见 §四 遗留 3)。
---
## 追加(2026-09-13 18:0x–18:2x · **第三轮**:「AI 生成 Office 文件」做成实例内能力 → **0.2.26**)
> 触发:用户把真实用法说清了 —— **「和 AI 对话 → AI 生成 md/doc/excel 等文件 → Univer 在线看 → 下载的就是 AI 生成的那个文件」**,
> 并对「补上这条能力」答复**「可以加入」**。
### 一、先纠正两个容易误判的点(本次最重要的认知)
| # | 纠偏 | 依据 |
|---|---|---|
| 1 | **「AI 生成文件 + 下载」这条路根本不碰 Exchange 原生绑定** —— 文件由 AI 自己生成、落在工作区,用户直接下载。受 glibc 2.32 卡住的**只有两件事**:**外部 xlsx/docx 导入 Univer** 与 **`.univer` 导出成 Office** | `exportSnapshotToFile` = `exportSnapshotToBuffer()`(→ `loadNativeBinding().exchangeExportSnapshot`)+ `writeFile()`:**写文件只有一行,转换才是原生库** |
| 2 | **「在线看」这一环不需要导入** —— 让 AI 直接用 `univer_new`/`univer_unit`/`univer_execute` 写 `.univer`(§追加 第一轮已修好)即可在线看/编辑 | guest 会话 17:34–17:37 复测:写 Sheet/Doc/Base/Board 全通过且真落盘 |
另:实例 Python **只有** `requests/urllib3/idna/certifi/charset_normalizer`(**无 openpyxl/python-docx/python-pptx**),Node 侧 `xlsx`(SheetJS) 是**业务插件顺带带的依赖**(不可当基础能力)。
⇒ **「没有库」≠「不能生成」**:OOXML 就是 zip + XML,Python 标准库足够。
### 二、自裁过程(提问被闸门拦下 ⇒ 按 8 条顺序自答)
| 候选 | 裁决 | 依据 |
|---|---|---|
| 换宿主 glibc ≥ 2.35 | ❌ 不做 | 影响基础设施;只在「必须保真 Office 双向交付」时才值得 ⇒ 留给用户决定(A 类) |
| 把 `xlsx` 提成平台 profile 基础依赖 | ❌ 不做 | 影响面扩到**全体用户**,代价不对称;且真正需要它的场景本就由业务插件带 |
| **随包加一个零依赖生成技能** | ✅ 采纳 | **既有机制可复用**(插件 `skills/` 随包投放 + 实例 AI 真能加载,guest 会话 `skill{name:'univer-sheet'}` 成功过)、**只改一层**、**可回滚**、失败代价对称 |
### 三、实现(随 univer 插件投放)
- 新增 `skills/office-file-generation/`:
- `SKILL.md` —— 触发词含 docx/xlsx/pptx/Word/Excel/PPT/报告/周报/榜单/汇报;并**显式写明「不要用它处理 Univer 导入导出」**(避免误导)。
- `scripts/office_gen.py` —— **零依赖**(stdlib `zipfile` 手写 OOXML);入口 `docx` / `xlsx` / `pptx` / `csv2xlsx`。
- 能力:标题层级、段落、项目符号、**表格**、多 sheet、列宽、加粗表头、**真公式**(`=SUM()`)、多页幻灯片;
缺:图片、图表、页眉页脚、目录、自动编号(用 `• ` 前缀代替)、公式重算(写入公式,打开时才计算)。
- ⚠️⚠️ **易踩点(后人必看)**:该插件的技能是**显式清单**注册的 —— `src/host/skills/plugin.ts` 的 `DEFINITIONS`,
**不是扫目录** ⇒ 只把技能目录丢进包里**不会生效**,必须同时改清单并 `node scripts/build.mjs lib`。
- 修掉的真缺陷(严格解析器抓的):`w:tbl` 缺**必需**子元素 **`w:tblGrid`**(`python-docx` 直接抛 `InvalidXmlError`)。
### 四、三层验证
| 层 | 手段 | 结果 |
|---|---|---|
| 结构 | `zipfile.testzip()` + 全 XML 可解析 | ✅ |
| **严格解析器** | 隔离 venv 装 `python-docx` / `openpyxl` / `python-pptx` **读回** | ✅ docx 段落+样式+3×3 表格;xlsx `=SUM(C2:C3)`/表头加粗/列宽;pptx 多页文本 |
| 平台侧 | 服务器 python3.12 + **租户 uid 100002** 用**实装脚本**生成三格式 | ✅ 全部成功 |
| 额外 | 用户在本机用 **Tencent Docs 预览打开了 `样例.docx`** | ✅ 真编辑器可打开(比库解析更强的证据) |
### 五、投放
`[email protected]`(`dsh-univer-office-0.2.26.tgz`,md5 `4bfdf89bed52dc87b420a520caa7176a`)
→ admin `POST /api/plugins/business` 重登记 → `mine/apply` **停用 → 启用**(终态 `success`)
→ 实装确认:版本 `0.2.26`,技能已入包 `skills/office-file-generation/{SKILL.md,scripts/office_gen.py}`。
### 六、仍未做(**环境级**,需宿主决策)
1. **外部 xlsx/docx → Univer 导入** / **.univer → Office 导出**:`@univerjs-pro/exchange-node` 只试原生绑定(**无 JS 回退、无 env 覆盖**)⇒ 需宿主 glibc ≥ 2.35。
2. **渲染类**(`compile_svg` / `lint` / `screenshot` / `print_pdf`):缺 Chromium(实例内无浏览器、`/usr` 只读、`UNIVER_RENDER_BROWSER` 由 DSH 主进程 env 决定)。
---
## 追加(2026-09-13 19:5x–20:3x · **第四轮**:**客户端半边静默挂死** ⇒ 浏览器里什么都没有;已修 **0.2.27**)
> 触发:用户问「**为什么不能在浏览器预览文件**」(起因是 guest 会话里 AI 的排查)。
### 一、guest AI 的结论 vs 复核
| 它的说法 | 复核结果 |
|---|---|
| 「我的文件」面板没有预览功能 | ✅ 属实(平台侧只有浏览+下载,浏览器服务清单里无预览服务) |
| univer 的浏览器半边「**没挂载**」 | ⚠️ **不准确** —— 半边**在下发列表里**(页面 46 个客户端插件含 `dsh-univer-office/client.js`),只是**永不 `apply()`** |
### 二、真因:**一条永远不可满足的 `inject`**(静默)
- 插件 `package.json` 的 `dsh.client.inject` 里有 **`@deepseek-ai/dsh-client-ui-settings-plugins`**;
- 该包**被平台自己的角色补丁禁用**(`ensure-role-profile-patch.cjs`,档案 15「普通用户隐藏『插件』分区」);
- dsh 客户端加载器源码注释写明 `fiber lifecycle, **inject waiting**` 由 cordis 管 ⇒ **依赖永不就绪 ⇒ 客户端 fiber 永久挂起 ⇒ `apply()` 永不执行** ⇒ 预览 UI 全无,且**无报错、无日志**。
**证据链**:① 页面 `__DSH_BOOT__` 里 univer 行列出 7 个 inject,6 个在页面里、**唯一缺的就是被禁那个**;② **A/B 对照**:guest 页面缺 `ui-settings-plugins`/`ui-settings-models`/`ui-cordis`(全是被禁的),**admin 页面全都有**(admin profile 无该补丁)。
### 三、修与验证
- **0.2.27**:从 `dsh.client.inject` **移除** `@deepseek-ai/dsh-client-ui-settings-plugins`(它只服务「插件设置卡片」,而该分区对本角色本来就隐藏)→ admin 重登记 → 停用→启用(平台重启探活通过)。
- **验证**:实装 `0.2.27`;页面 boot manifest 里该行 inject 变成 **6 个、缺集为空**;`rev` 已变(浏览器取新包)。
- **guest AI 复测(20:2x)**:客户端半边**已 apply** —— 探针在 `conversation.input.dock` 看到 `{"id":"univer-dock","active":false}` ⇒ **修复生效**。
⚠️ **`active:false` 属预期**:该 dock 只在有 draft worktree / 实时内容时显示(`univer-dock.tsx:172`:`if (operation.worktreeId === null) return null`);同槽的 todo/goal/queue 恒有内容故为 `true`。
### 四、使用者预期管理(**这个插件不注册 `tool.*.toolview`**)
所以**工具卡片不会变样**;能看内容的地方只有三处:
1. **会话「轮尾」卡片**(`conversation.chat.turnTail`,`select: selectUniverTurn` ⇒ **只挂在含 `univer_*` 工具调用的那一轮**);
2. **输入框上方 dock**(`conversation.input.dock`);
3. **全屏审阅 / 浮动预览窗**(浮层)。
⇒ 想看到它:**往上翻到跑过 univer 工具的那几轮**,或**让 AI 现在再动一次 `.univer`**;页面需**硬刷新**一次(客户端包按 `rev` 缓存)。
另两条边界:**「我的文件」面板只有浏览+下载**(预览是平台能力缺项);**Office 格式(xlsx/docx)仍不能在线预览**(必须走原生导入 = glibc 挡着)。
### 五、⚠️ 沉淀为铁律(已写入 `MEMORY.md`)
1. **第三方客户端插件不得把「官方 UI 包」写进 `dsh.client.inject`**,除非确认该包在**目标角色**里必然存在 ——
平台角色补丁会禁:`dsh-client-ui-settings-models` / `-settings-plugins` / `-settings-plugin-inventory` / `-cordis` / `dsh-client-hmr` / `dsh-host-directory-picker-auto`。
2. **排查手法(通用)**:取实例页面(平台代理 + 临时 `mksess` 会话)→ 解析 `__DSH_BOOT__` → 把每行 `inject` 与**页面实际下发的客户端插件集合**求**差集**;**非空 = 该客户端 fiber 永久挂起**。
3. **同类案例**:`dsh-plugin-mcn-suite` 的 inject 含 **`@deepseek-ai/dsh-client-runtime`** —— 该包在当前 dsh 版本**根本不存在**(官方只有 `dsh-client-ui-sidebar`、`dsh-code-runtime`)⇒ 它的浏览器半边**同样静默挂死**(已告知 T03 侧,未代改)。
### 六、**端到端确认**(2026-09-13 20:4x · 浏览器侧)
- **决定性证据**:用户硬刷新后复探**同一槽位**,`conversation.input.dock` 里的 univer 项 **`active:false` → `active:true`** ⇒ 插件**浏览器半边已挂载并激活**。
(此前我这边只能验到"依赖缺口补平 + 半边已 apply"这一层,剩下靠这条闭环。)
- **佐证**:`conversation.chat.turnTail` 有 2 个 `registrant:H5` 占位者(priority **-10** 与 0);**priority -10 的就是本插件的 `PreviewCard`**(`src/client/index.tsx` 正是以 `priority: -10` 注册的)—— 该槽的探针输出**不带名称字段**,所以"无法按名字确认"是**探针本身的局限**,不等于未注册。
- **旧回合不会回溯渲染**:卡片由 conversation turn 定义(`kind: univerTurn`,匹配含 `univer_*` 工具调用的轮)驱动 ⇒ 客户端半边激活**之前**产生的旧回合**不会补卡片**;要看就得**新跑一轮**含 univer 调用的回合。
- **顺带记录一个新现象(平台级,非本插件缺陷)**:**硬刷新页面会取消在途的 `platform: client` 工具查询**(这类查询由浏览器页面回答,页面上下文销毁即 cancelled);**Host 侧调用(bash / `univer_*`)不受影响**。⇒ 用户报"怎么断开了"即此;刷新后等一两秒再跑客户端探针即可,排查时务必区分 **client-platform** 与 **host** 两类工具。
---
## 追加(2026-09-14 08:0x · **§七 P1 结清**:`/univer-api/state` 的 400 已复现并定性)
> 档案头部 §一 与 `BRIEF`/`MEMORY` 里挂了一整天的 **P1「`/univer-api/state` 生产 400(待取响应体)」** —— **本条结清**。
**按客户端真实形状复测(`encodeURIComponent` 编码 + 有效 sessionId + 文件存在)** ⇒ **200 ✅**:
```json
{"ok":true,"file":"…/ws/MCN短视频创作/DSH插件精选清单.univer",
"gateway":"unix:/tmp/dsh-univer-gateway-2.sock","gatewayRunning":true,
"viewerUrl":"/univer-api/viewer/?file=…",
"worktrees":[{"worktreeId":"wt-mu0h2qv0-pa2aq3","name":"插件清单导入","status":"ready",
"units":[{"unitId":"u-mu0h2zbd-bue11a","name":"DSH插件精选清单","type":"sheet","kind":"added"}]}]}
```
`/univer-api/viewer/?file=<fileKey>` 同期 **200**(37 KB viewer HTML)。⇒ **服务端预览链路(state → viewerUrl → viewer → 网关)当天是健康的**。
**三类 400 的分辨(排查时别误判 —— 本次我差点栽在第 3 类)**:
| 形状 | 来源 | 触发 |
|---|---|---|
| `{"ok":false,"code":"INVALID_REQUEST","message":"sessionId is required"}` | **插件**(`router.ts` 的 catch 信封) | 缺 `sessionId` |
| `{"ok":false,"code":"INVALID_FILE_PATH" / "SESSION_SCOPE_UNAVAILABLE", …}` | **插件** | 文件不存在 / 旧会话已失效(**2026-09-13 07:3x 那批 400 最可能是这两类**:当时文件确实不存在,且实例刚重启过) |
| `{"error":"Bad Request","message":"Client Error","statusCode":400}` | **代理层**(Fastify 默认形状,**不是**插件) | ⚠️ **请求 URL 未编码**(中文路径裸发)—— 我用 curl 手工复现时踩到,一度误判成"插件恒 400"。**浏览器永远会编码,故线上不会出现这一种** |
⇒ 结论:**该 P1 关闭**(不是恒 400;是"文件/会话"时序问题 + 我自己的测试姿态问题)。**下次排查铁律**:手工 curl 带中文/空格的路径**必须** `-G --data-urlencode`;见到 Fastify 默认错误形状时,先怀疑"请求没到插件"。
---
## 追加(2026-09-14 08:2x–08:5x · **§八 原生 Office 导入导出已启用**:给两个进程挂自带 glibc 2.35 → **0.2.28**)
> 起因:用户问「**为什么不升级 glibc**」→ 我给出「换基底 / 原地升 / 容器 / 数据级替代」的代价对比后,用户说「**按推荐方案执行**」。
> 我先做**零风险干跑验证**(只取一份 glibc 2.35 rootfs,不启 dockerd、不碰防火墙),结果**比原方案都便宜**。
### 一、实验链(全部实测)
| # | 动作 | 结果 |
|---|---|---|
| 1 | 宿主现状 | Alibaba Cloud Linux 3 / **glibc 2.32** / kernel 5.10;`docker` **已装但守护没跑**、无镜像;平台 supervisor 里有 `k8s-spawner.js`(容器形态有口子) |
| 2 | `objdump -T` 看绑定需求 | 两个绑定都要 **GLIBC_2.33/2.34/2.35 的版本化符号**;`libsql` 只要 2.18(所以它一直好用) |
| 3 | 取 glibc | Ubuntu 22.04 `libc6`(⚠️ deb 内是 **`data.tar.zst`**,Python 3.12 的 tarfile 不认 ⇒ 用宿上 `zstd` 解) |
| 4 | ❌ 只给 `libc/libm` + 宿主 `/lib64` | **`GLIBC_PRIVATE` 冲突**:`libpthread.so.0: undefined symbol __libc_siglongjmp` ⇒ **必须整套 20 个 so 一起给**(2.35 已把 libpthread 并入 libc) |
| 5 | ✅ 给全后 | `node -v` 正常;`require` 两个绑定**均成功**;`glibcVersionRuntime=2.35` |
| 6 | ✅ **端到端** | gateway + worker 跑在 2.35 上 ⇒ **`univer_import` 导入 xlsx 成功**(列宽 44/176/88、文本、`=SUM(C2:C3)` 公式全进来了)← 正是 09-14 早上 guest AI 失败的那一步 |
### 二、落地(平台侧**零改动**)
- **运行时**:`/usr/local/dsh-runtime/glibc-2.35/`(**5.0 MB**:20 个 so + `ld-linux-x86-64.so.2` + `COPYRIGHT-libc6.txt`)。
放 `/usr/local` 是**刻意的** —— bwrap 沙箱把 `/usr` 只读挂进去 ⇒ **实例内可见**。**删目录即完全回退**。
- **插件侧(0.2.28)**:
- 新增 `src/host/processes/glibc-runtime.ts`:`nodeLaunch(node, entry)` —— 有运行时 ⇒
`ld.so --library-path <rt>/lib:/usr/lib64:/lib64 <node> <entry>`;否则 ⇒ 原样 `<node> <entry>`。
- **两处 spawn 接线**:`src/host/adapters/unit-content/worker.ts`(worker)、`src/host/processes/gateway/launcher.ts`(gateway)。
- 目录**自探测**(默认路径 + `UNIVER_DSH_GLIBC_RUNTIME_DIR` 可覆盖,置空即关闭)⇒ **不改平台 env、不重启 dshs**。
- **不满足条件时行为与改动前逐字一致**(零回归)。
- **附带收益**:**公式引擎自动切回 Rust**(引擎侧垫片本就写成"绑定能用就用 Rust")。
### 三、验证(真机证据)
| 项 | 证据 |
|---|---|
| 网关确实经 ld.so 启动 | `POST /univer-api/gateway/start` → `{"ok":true,…,"reused":false}`;`/proc/<pid>/cmdline` = `/usr/local/dsh-runtime/glibc-2.35/ld-linux-x86-64.so.2 --library-path …/glibc-2.35/lib:/usr/lib64:/lib64 /usr/local/bin/node …/gateway.cjs` |
| 加载的 libc 确实来自 2.35 | `/proc/<pid>/maps` 里 `libc.so.6` / `libdl.so.2` / `libm.so.6` / `libpthread.so.0` **全部**指向 `/usr/local/dsh-runtime/glibc-2.35/lib/` |
| 服务态 | `/univer-api/status` → `{"gateway":{"phase":"running",…},"unitContent":"bundled"}`;实装 `0.2.28`;实例 active |
| worker 侧 | 走**同一个** `nodeLaunch`;其命令形态已在第 6 步手动实验中端到端验证过 import |
### 四、遗留与注意
- ⛔ **截图 / PDF / `compile_svg` 仍不可用** —— 那是**缺浏览器 + 内存不够**(headless Chromium +200~400 MB vs 实例配额),与 glibc 无关。
- ⚠️ **对外开源导出不要打包这份运行时**(LGPL + 只在服务器上)。
- ⚠️ **`/univer-api/state` 需要「活会话」**:实例重启后 SessionStore 是内存态、未水化 ⇒ 会回
`{"ok":false,"code":"SESSION_SCOPE_UNAVAILABLE"}`(**这解释了 09-13 那批 400 的第二种可能**,与"文件不存在"并列)。
要拉起网关做自检请用 `POST /univer-api/gateway/start`(**不需要会话**)。