# T-WEB-016 契约提升:websocket-keypoints 0.2.0(保活 / 去重 / 状态机) - **状态**: done - **依赖**: T-WEB-003(本项目为契约源) - **文档**: v1.6 §6.5 / §7.2 - **触发**: Android 端 T-AND-011「连接状态异常、频繁重连」要求**两端一起改** ## 背景 按文档 §7.2,任何跨端契约变更必须**先在本项目修改 `contracts/` 并提升版本**, 再向另两端建迁移任务。本次 Android 端排查出"频繁重连"的根因在协议层: 接收端有静默超时,但**契约里没有声明超时值,也没有定义任何应用层保活消息**。 ## 变更内容(均为**向后兼容**:子协议名与二进制帧 `version` 均不变) | 章节 | 新增 | 为什么必须写进契约 | |--|--|--| | §1.1 保活 | 接收端必须在 `welcome`(及需要变更时的 `config`)中声明 `silentTimeoutMs` 与 `heartbeatIntervalMs`;且**心跳必须小于静默超时的一半** | 客户端原先只能用 OkHttp 的**协议层** ping,而它由底层库自动应答、**不暴露给应用层**,接收端根本看不到,无法续命 | | §3.1 | `hello.deviceId`(该安装实例的稳定标识,**不使用任何硬件序列号**) | 没有它,一次闪断重连会被接收端显示成两台设备 | | §3.2 / §3.3 | `welcome` / `config` 携带上述保活字段 | 让客户端按服务端节奏保活,而不是各写一套魔数 | | §3.6 | 应用层 `ping` / `pong`(回显 `sentAtMs`,`pong` 另带 `serverAtMs` 供算 RTT) | 协议层 ping 对应用层不可见,必须自定义 | | §5 | 设备去重规则:同一 `deviceId` 只保留最新连接,旧连接以 `1000` 关闭 | 用 `1000` 是刻意的:对端据 §7 不会重连,否则会形成"挤掉-重连-再挤掉"的循环 | | §7 | 关闭码与重连策略:`1000` / `1001` / `1002` 与主动停止**不重连**;其余重连 | 原实现"只要能重连就重连",服务端重启或协议不匹配时也会疯狂重连 | | §7.1 | 退避参数冻结:`min(500ms * 2^(n-1), 15000ms)` + **±20% 抖动** | 避免同网段多台设备同时重连打尖峰 | | §8 | 连接状态机 `idle → resolving → connecting → handshaking → streaming → degraded → reconnecting → stopped` | 两端同名,日志可直接对照 | ## 兼容性 * 子协议 `opendance.pose.v1` 与二进制帧 `version = 0x01` **均不变**; * 只**新增可选**控制字段与一种新控制类型; * 仅实现 v0.1.0 的一端仍可正常互通(它不发 `ping`、不读 `silentTimeoutMs`, 但每帧关键点 + 每秒 `stats` 已足够保活)。 ## 改动清单 | 文件 | 改动 | |--|--| | `contracts/websocket-keypoints.md` | 上文全部章节 | | `contracts/VERSION` | `websocket-keypoints: 0.1.0` → `0.2.0` | | `web-server/src/config/contract-versions.ts` | 编译期常量同步为 0.2.0(`check-contracts.mjs` 强制) | ## 验收 | 验收项 | 结果 | |--|--| | `npm run contracts:check` 通过 | ✅ 六项契约版本一致,35 条路由与契约一致 | | 三端快照逐字节一致 | ✅ windows / android 的 `contracts/` md5 与 web 完全相同 | | 另两端的迁移任务已建立 | ✅ Windows `T-WIN-015`、Android `T-AND-011` 均已落地并引用本契约 |