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

39 KiB
Raw Blame 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 ✅:

{"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(不需要会话)。