Files
dsh_shenxian/dsh-server-docs/04-调整方案/76-dsh-univer-office平台适配改造-unix-socket与同源代理.md
T

450 lines
39 KiB
Markdown
Raw Normal View History

# 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`(**不需要会话**)。