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 一律写「远程服务器」。
39 KiB
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} |
两个候选根因(未定论):
- 寻址类 400(
UniverfileError)—— 路径不在allowedRoot内 / 形态不符; SESSION_SCOPE_UNAVAILABLE—— 传入的 dshsessionId在插件侧不可用。⚠️ 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,md5c1a12688ca70c51a321d75ad7a8aefbd)。 - 候选池按平台既有 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(重启+探活由平台自身完成) |
四、遗留(本次未处理,按需另开)
univer_screenshot仍缺 Chromium(实例/usr/bin无 chromium/chrome)——独立缺口。ws只是上游的 devDependency:生产里靠 profile 的.pnpm/node_modules提升才解析得到;已把「取不到」改成出声报错,但依赖未被声明这件事仍是长期脆弱点。- 平台 apply 接口不重装已启用插件(更新插件需两步停用→启用)——建议后续补「已启用项也纳入 toEnable」的能力。
- 宿主 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。
- 起一个自己的 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>= 网关侧没问题。 - 手工构造 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 - 看
[uvshim]/[uvcompat]轨迹:fetch-called → fetch-result status=… / ws-construct / http-request host=…每一步都该出现; 只出现fetch-called后面就断 ⇒ 垫片内部提前 return(本次即SOCKET_ORIGIN=undefined)。 - 兜底判据:把 gateway 换成一个「什么都回 200 + 打印请求」的假 socket 服务器 —— 若假服务器一个请求都没收到而 worker 仍报错, ⇒ 问题在 worker 侧(请求根本没发出去),不在网关侧。
- ⚠️ 临时目录里的 worker 解析不到
ws(createRequire沿路径上溯)⇒ 复现时NODE_PATH要补…/node_modules/.pnpm/node_modules。 - ⚠️ 两处易踩:
systemctl stop dsh-<uid>-*.scope后,随便发一个带Host: <user>.alotbuy.com的请求就会把实例拉起来(无需登录,401 也算); 临时会话用node /opt/dshs/mksess{,-guest}.cjs,用完必须DELETE FROM sessions WHERE user_agent='poc-curl2'。 - ⚠️ 跑 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—— 零依赖(stdlibzipfile手写 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}。
六、仍未做(环境级,需宿主决策)
- 外部 xlsx/docx → Univer 导入 / .univer → Office 导出:
@univerjs-pro/exchange-node只试原生绑定(无 JS 回退、无 env 覆盖)⇒ 需宿主 glibc ≥ 2.35。 - 渲染类(
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)
所以工具卡片不会变样;能看内容的地方只有三处:
- 会话「轮尾」卡片(
conversation.chat.turnTail,select: selectUniverTurn⇒ 只挂在含univer_*工具调用的那一轮); - 输入框上方 dock(
conversation.input.dock); - 全屏审阅 / 浮动预览窗(浮层)。
⇒ 想看到它:往上翻到跑过 univer 工具的那几轮,或让 AI 现在再动一次
.univer;页面需硬刷新一次(客户端包按rev缓存)。
另两条边界:「我的文件」面板只有浏览+下载(预览是平台能力缺项);Office 格式(xlsx/docx)仍不能在线预览(必须走原生导入 = glibc 挡着)。
五、⚠️ 沉淀为铁律(已写入 MEMORY.md)
- 第三方客户端插件不得把「官方 UI 包」写进
dsh.client.inject,除非确认该包在目标角色里必然存在 —— 平台角色补丁会禁:dsh-client-ui-settings-models/-settings-plugins/-settings-plugin-inventory/-cordis/dsh-client-hmr/dsh-host-directory-picker-auto。 - 排查手法(通用):取实例页面(平台代理 + 临时
mksess会话)→ 解析__DSH_BOOT__→ 把每行inject与页面实际下发的客户端插件集合求差集;非空 = 该客户端 fiber 永久挂起。 - 同类案例:
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(不需要会话)。