Files
admin e6207aa691
build / build-and-scan (push) Waiting to run
chore(仓库对齐): 文档库结构治理 + IM/插件线落地
文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。

IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。

插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。

仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
2026-09-24 07:25:16 +08:00
..

im-connection-gateway —— IM 连接层的参考部署(外部连接层)

本目录是 IM 线「连接层候选 B」的部署侧材料:外部连接层进程的配置样例 + 边缘映射样例 + 平台应用层帧 ↔ 网关协议的逐项映射表。

🔴 本目录里的内容只做部署:平台的连接层抽象在 src/im/connection-backend.ts(中性命名), 两个后端实现在 src/im/backends/{native,gateway}.ts。⛔ 平台内核(hub.ts / ws.ts / store.ts)不认识本目录的任何概念,这是刻意的分层 —— 换掉下面这个组件不需要动内核。

参考部署用的是 Centrifugo v6.9.6(选择依据见 dsh-server-docs/交接单/IM群组-稳定性机制与框架选型.md §十二 执行回报(IM线-第3棒)的 E-2 对照读数)。


🔻 第 15 棒部署实测修正(2026-09-23,⛔ 下方原文不改,只在此标注) —— §3 / §4 / §6 有三处被实测改写:

原文 实测结论 依据
§3 / config:ping_interval: "0s"("⛔ 不启用服务端 WS ping") 🔴 该值会让客户端保活帧被判 bad request —— 没有待回的 ping 时,协议里的 {} 是空命令、不是 pong ⇒ 连接被断开(code 3501)。已改为 25s:网关发协议级 ping,客户端按连接回 {} 47 三向实测:off 80/80 断(3012 no pong)· conn 掉 0 · global 80/80 断(3501 bad request)
§4 "本棒 ⛔ 未执行" ✅ 已在 47 就地执行(二进制 + 配置 + systemd 单元 + 边缘片段落盘 + nginx -t);⛔ 边缘未接进 vhost(不开流量) 见 §7
§6-1 "在线态取数未接线" ✅ 已接线(平台 presence-ingest.ts + POST /api/im/gateway/presence);stats().presenceIngest 从 not-wired 变为 gateway-join-leave 见 §7-3

⚠️ 关于 §3 那句「实测 wsPingRecv = 0」:这条实测本身没错 —— 它数的是 WS 控制帧, 而网关发的是协议级 JSON ping。错的是由它推出的结论("所以网关不管保活")。


1. 拓扑

浏览器 / 实例内客户端
   │  ws(s)://<平台域>/api/im/ws            ← 客户端侧 URL **一个字都不变**
   ▼
边缘(nginx)── location = /api/im/ws ──────▶ 外部连接层(持有 10k+ 连接,做扇出)
                                                     ▲
平台进程 ── POST http://<网关>/api/publish ───────────┘   (每条消息**一次**发布调用)
   │
   └─ 权威态:房间 / 成员 / 消息 / 游标 / 预算 —— 全在平台 `src/im/**`,一条都不外移

平台的 /api/im/ws(进程内自研 WS)不摘: ① 实例内凭据通道(x-dsh-im-instance-token)走它;② 它是一行回滚的落点。


2. 应用层帧 ↔ 网关协议:逐项映射

🔴 映射的判据是「应用层帧不变」:客户端 JSON.parse(payload.frame) 拿到的字节与 native 模式逐字相同(test/im-connection-backend.test.mjs 的 B-18 就是断这条)。

应用层(/api/im/ws 的语义) 网关侧 权威在哪
客户端连 wss://…/api/im/ws 边缘 location = /api/im/ws → 网关 WS 端点 边缘
鉴权(sid cookie / 实例凭据) 网关侧的连接令牌(client.token.hmac_secret_key 签发) 🔴 仍是平台:令牌只回答"你是谁",能不能读房由成员表判
{op:'subscribe', roomId} 客户端 subscribe 频道 im:<roomId> 平台成员表(store.isMember)
{op:'subscribe'} 的应答 {op:'subscribed', cursor} ⛔ 网关不产这个帧 ⇒ 由平台 REST 补(见下「游标」) 平台
{op:'send', roomId, payload} ⛔ 网关不参与:走平台 POST /api/im/rooms/:id/messages 平台(预算 decideWrite + 落库)
{op:'resume', roomId, since} 平台 GET /api/im/rooms/:id/messages?since=(网关 history 只作加速) 平台(游标权威)
服务端 {op:'message', roomId, messages:[…]} 平台 POST /api/publish {channel:'im:<roomId>', data:{frame:'<原字符串>'}} 平台产生、网关投递
{op:'presence', roomId, members:[…]} 网关 presence / join_leave(本轮未接线) ⚠️ 见 §4 未完成项
保活 客户端按 §3 的规则自建 客户端侧垫片

负载信封(src/im/backends/gateway.ts 的 gatewayPayloadOf):

{ "channel": "im:imr_<uuid>", "data": { "frame": "{\"op\":\"message\",\"roomId\":\"imr_…\",\"messages\":[…]}" } }

3. 🔴 硬实现项 ①:必须自建保活(实测必做,⛔ 不是可选优化)

E-2 判别实验(106 /root/im-e2/hb.sh)三条读数,本棒在 E-7 里做了三向复核:

口径 结果
off:客户端完全不发协议 ping 服务端在 26 s 处把连接判死(wsPingRecv = 0:网关不发服务端 WS ping)
global:整进程统一计时 同上(这是 E-2 记录到的适配器缺陷形态)
conn:每条连接认证完成后才起自己的 ping 计时 保活成立(= 本棒的修正形态)

⇒ 规则(src/im/keepalive.ts 是它的可执行形态):

  1. 客户端每 intervalMs 发一次应用层 ping;intervalMs 由一个空窗里发 3 次反推 (实测空窗 26 s ⇒ 8,666 ms;丢一次也不至于被判死)。
  2. 🔴 计时起点 = 该连接自己的认证完成时刻(⛔ 不是进程启动时刻、⛔ 不是 worker 启动时刻)。 用统一计时会一次掉成批连接 —— 本棒的回归用例 B-12 / B-13 把这两种模型结构性区分开。
  3. 平台的 /api/im/stats 在 gateway 模式下会回带 keepalive 与 keepaliveAnchor: 'per-connection-auth-complete'(运维据此核对客户端是否按规则做)。

4. 部署(示例,本棒 ⛔ 未执行)

# 1) 放二进制与配置(版本与校验见 dsh-server-docs 的部署记录)
install -m 0755 centrifugo /opt/dsh-gateway/centrifugo
install -m 0644 config.json /opt/dsh-gateway/config.json       # 凭据先替换 REPLACE_WITH_ROTATED_SECRET

# 2) 起进程(端口示例 18081;v6 的端口用命令行给,配置里已不再有 port 键)
cd /opt/dsh-gateway && ./centrifugo --config=./config.json --http_server.port=18081

# 3) 平台侧(drop-in,⛔ 同名键写 /etc/dshs.env 会被静默压掉)
#    DSH_IM_BACKEND=gateway
#    DSH_IM_GATEWAY_URL=http://127.0.0.1:18081
#    DSH_IM_GATEWAY_KEY=<与 http_api.key 同值>
#    DSH_IM_GATEWAY_CHANNEL_PREFIX=im:

# 4) 边缘:把 nginx-location.conf 的 location 落进站点配置 → nginx -t → reload

# 5) 就绪探测与读数
curl -s -X POST http://127.0.0.1:18081/api/info -H "Authorization: apikey <key>"
#    平台侧:GET /api/im/stats(admin)⇒ backendKind=gateway / backend.degraded 为空

回滚(顺序与上线相反,两步任一即可):

  1. 去掉边缘的 location = /api/im/ws → reload ⇒ 流量立刻回到平台进程内通道;
  2. 平台 drop-in 里 DSH_IM_BACKEND 改回 native(或删掉该行)→ 重启 dshs。

⚠️ 两个方向的都已具备:平台的 /api/im/ws 两种后端都保留(ImConnectionBackend.dispatch), 所以第 1 步是零重启回滚。


5. 容量测算的输入(⛔ 本目录不承诺数值,数值见回报的容量表)

输入 值 来源
边际内存 / 连接 70.1 KiB(自研对照 5.9 KiB,11.9×) E-2 实测(本棒 E-6 复核)
10k 连接稳态 RSS 750.2 MiB(自研对照 116.0 MiB) E-2 实测(本棒 E-6 复核)
客户端观测 P99(1k/5k/10k) 28.9 / 68.1 / 111.9 ms(自研 268.6 / 337.6 / 359.4 ms) E-2(本棒 E-6 复核)
扇出离散中位 17.2 / 57.9 / 97.8 ms(自研 171.8 / 227.6 / 225.5 ms) E-2(本棒 E-6 复核)
平台侧 CPU 每条消息 1 次 HTTP 发布调用;lastPublishMs 见 /api/im/stats 本棒实现口径

⚠️ 内存类读数跨跑方差 ±12 MiB(E-5 教训)⇒ 任何内存判据重复跑 ≥3 次取中位。

6. 未完成项(⛔ 与 §3 的"已做"分开看)

🔻 第 15 棒状态标注(2026-09-23,⛔ 原文不改):① 已接线(平台侧 presence-ingest.ts +回调面 POST /api/im/gateway/presence,读数 gateway-join-leave);② 配置与边缘片段 已落 47 并通过 nginx -t,仍未接进生效 vhost(不开流量);③ 客户端侧垫片仍未接进 E 单 bundle(第 13 棒已修掉帧格式 P0,但"换连接层后客户端要按连接回 {}"这层尚未做)。

  1. 在线态取数未接线:平台的 presence 需要网关的 presence / join_leave 面; 本棒只把现状暴露在 stats().presenceIngest = 'not-wired',⛔ 不假装完成。
  2. 本目录的 nginx / config 未应用(本棒边界:⛔ 不部署到 47 / 106 生产面)。
  3. 客户端侧垫片未接进 E 单 bundle —— 见回报里的 P0 发现(客户端 bundle 与平台 /api/im/ws 的帧格式当前不一致,接线前先要修那一处)。

7. 第 15 棒:47 就地部署实况(2026-09-23)

🔴 本目录只记部署落点;权威读数与判据在 dsh-server-docs/交接单/IM群组-稳定性机制与框架选型.md §15.8。

项 落点 / 值
二进制 /opt/dsh-gateway/centrifugo(v6.9.6,sha256 f4869bf9c0028ed5253b74479b1dc70e3b916dae2ddad5bb03504b1eb07a848c,与 106 测试床同源)
配置 /opt/dsh-gateway/config.json(600,密钥现场 openssl rand -hex 32 生成,⛔ 不入库)
单元 dsh-gateway.service(enabled + active),--http_server.address=127.0.0.1 --http_server.port=18081,LimitNOFILE=200000
边缘片段 /opt/dsh-gateway/nginx-location.conf(已落盘 + 临时配置 nginx -t rc=0;⛔ 未接进 vhost)
切流 DSH_IM_BACKEND / DSH_IM_GATEWAY_URL / DSH_IM_GATEWAY_KEY / DSH_IM_GATEWAY_CHANNEL_PREFIX ⇒ drop-in /etc/systemd/system/dshs.service.d/im-gateway.conf(模板 = /opt/dsh-gateway/dshs-im-gateway.conf.example)
当前状态 native(drop-in 已删,restart dshs 复验 101 ✅)⇒ 线上行为回到改动前

只读取证脚本(本次用的)= /opt/dsh-gateway/im15-drill.mjs:起 N 个客户端订阅一个临时频道, 三组分口径(off / conn / global)计断开数,并读平台 /api/im/stats 与网关 /api/channels。