# 15: WebSocket 状态更新 **What to build:** 通过统一运行时服务接入设备状态推送。推送只更新受影响的点位和详情字段,断线后可重连,并保留当前选择上下文。 Blocked by: 09, 13 Status: resolved Labels: ready-for-agent - [x] WebSocket 连接、订阅、断线和重连集中在运行时服务中。 - [x] 连接成功后先发 `authenticate` 帧(token 与 REST 同源),再发 `cockpitSubscribe`;authenticate 失败只把状态推送标为不可用,REST 快照照常显示。 - [x] 设备状态增量按服务端更新时间接受,旧消息被忽略。 - [x] 推送更新受影响点位和已打开详情,不重建视图。 - [x] 当前选中对象、面板状态和滚动位置保持不变。 - [x] 连接、重连、过期和失败状态可演示。 - [x] 有增量合并与重连单测。 ## 进度:100%(已关闭) **2026-09-24 真机 DOM 走查完成**(`http://127.0.0.1:8001/#/cockpit`,维护者开启服务后实测): A–F 六组逐项通过,逻辑闸门 **45/45**、19 个逻辑闸门全量回归绿。真机实测抓到并修掉**第二个真 bug** (推送早于分页快照 → 翻页后旧状态复活),详见下文「对抗式自查抓到的真 bug」。 ### 真机走查结果(从 DOM/引擎读,不读 store) - **A 连接状态**:干净启动后顶栏 `data-ws-state=open`、文案「推送 · 状态推送已连接」。 - **B 推送真的改了状态**:`MP-001:OFFLINE`(脚本推的值,**修 bug 前这里停在 NORMAL**)、 `MP-002:ALARM`;**地图点位数 2 → 2 不变**(只改已有实体)。 - **C 旧消息被丢弃**:脚本第 3 条把 MP-001 回退成更早时间戳的 ALARM,界面停在较新的 OFFLINE。 - **D 鉴权失败不牵连数据**:`unauthorized` 时顶栏「推送 · 状态推送不可用(鉴权失败)· 快照数据照常显示」, 经真实服务实测**只发了 `authenticate`**(无 `cockpitSubscribe`、不重连), 同时 **8 个面板仍有数据、2 个点位标记仍在** —— REST 快照照常显示。 - **E 断线重连**:`flaky` 场景状态序列 `connecting → authenticating → subscribing → open → retrying → connecting → … → open`, 工厂建了 **2 个连接**(真重连,不是只改文案),重连后点位数不变。 - **F 无页面级报错**(`pageerror`/`console.error` 全程为空)。 - **不重建视图 + 保持上下文(验收第 4、5 条)**:推送前后**地图实例 id 相同**(`cockpit-map-1`)、 点位数不变、面板 id 列表逐位相同、滚动位置不变。 > 环境备注:本机 `@puppeteer/browsers` 是空目录,`t15-websocket-walkthrough.mjs` 起不来 > (与 t28 同一环境问题),故上述 A–F 用内建浏览器驱动同一批断言完成; > 脚本已保留成可复跑形式。另:内建浏览器窗口被遮挡时 `requestAnimationFrame` 归零, > 涉及相机飞行的走查必须先确认标签页可见(票 14 记过同一坑)。 ## 先定的两处口径(本次会话向维护者确认,不猜) 1. **点位状态联动口径 = 按后端在帧里给的 `monitoringPointCode` 直接改写那个点位**。 不由「设备 ALARM」推导「点位 ALARM」—— CONTEXT.md 第 31-32 行明确「点位状态由后端解析并发布, 前端只渲染、不重算」。后端没给点位码的帧只更新设备维度,**不动任何点位**。 2. **mock 形态 = 服务层注入式假 WebSocket**:脚本化假连接按后端真实时序发帧, `runtimeSocket` 走的是与真实环境**完全同一条**代码路径(演示的不是另一套实现)。 ## 契约事实(2026-09-24 读 `E:\pipe-ner-server` 源码核实,不猜) - 握手:连接 → `{"type":"authenticate","token":...}` → `{"type":"authenticated","success":bool,"msg":...}` (`DeviceStatusWebSocketHandler.handleAuthenticate` 第 220-240 行);**成功才**发 `{"type":"cockpitSubscribe","scope":"all"}`(`handleCockpitSubscribe` 第 344-357 行,scope 只认 `all`)。 - 增量:`DeviceStatusRedisListener.cockpitDeltaMessage`(第 187-202 行)—— **`deviceCode`/`device_code`/`monitoringPointCode` 与 `data.{status,statusTime,serverUpdatedAt,reason}` 平级在顶层**;状态键是 `data.status`(不是 DTO 的 `monitoringState`,两个都认)。 - `cockpitSubscribed` 回帧**只带 `scope` 与 `serverUpdatedAt`,没有初始状态数组** —— 后端注释明确「REST 仍是初始快照,这里只接收后续状态增量」,前端按此实现,不假装它带了初始态。 - 状态词表 `ALARM`/`OFFLINE`/`WORK_ORDER`/`NORMAL`/`EMPTY`(`resolveStatus` 第 231-242 行), 与 `domain/pointStatus.js` 的后端六态吻合;未关联设备的 `monitoringPointCode` 显式为 `null`。 ## 交付物 - `services/runtimeSocket.js`(新):连接/鉴权/订阅/断线/重连的**唯一**所有者。 状态机 `idle → connecting → authenticating → subscribing → open`,断线进 `retrying` (退避 1/2/5/10/15 秒封顶),鉴权失败进 `unauthorized` 且**不重连**(token 不对时重试只是白撞)。 - `domain/statusPush.js`(新):增量合并的**纯函数**层 —— 按 `serverUpdatedAt` **按时刻**(非字符串) 取舍、旧消息原样返回同一引用(调用方据此跳过重渲染)、点位状态**只按后端指名的点位**改写、 就地合并时未命中的元素**保持同一引用**。 - `stores/runtimeStore.js`(新,ADR 0004 要求的 `runtimeStore`):连接状态 + 合并后的状态表 + 把受影响的点位状态推给 `mapStore`/`detailStore`。**不认识 socket、不认识帧类型**。 - `mocks/wsMock.js`(新):脚本化假 socket,三种场景 `success` / `unauthorized` / `flaky`; 默认脚本里**刻意放了一条更旧时间戳的负例**(证明"旧消息被忽略"不是纸面承诺)。 - `stores/mapStore.js`:新增 `applyPointStates()` —— 只替换状态真变了的那几条,未命中的**保持同一引用**。 - `stores/detailStore.js`:新增 `applyPushedStatus()` —— 已打开详情只换状态字段, **不重新取数、不换层级、不动选择**。 - `services/runtimeApi.js`:补 `toDeviceStatusEvent` 的 **`monitoringPointCode`**(原先漏了,点位联动就无从谈起)、 新增 `toAuthenticatedEvent`、补全 `WS_MESSAGE`。 - `CockpitShell.vue`:顶栏状态胶囊(`data-ws-state` 走查断言面)+ 挂载时启动、卸载时停止连接。 - `verify/t15-websocket.mjs`(新,43 项逻辑闸门)、`verify/t15-websocket-walkthrough.mjs`(新,真机走查,**待执行**)。 ## 验收自查(逐条对着验收原句) 1. **连接/订阅/断线/重连集中在运行时服务**:H3(store 里没有 `new WebSocket`/`onmessage`/`send`)、 H1(外壳不认识帧类型与 socket);连接与重连在 B/F 段实测(工厂建了 2 个连接 = 真的重连)。 2. **先 authenticate 再 cockpitSubscribe;鉴权失败只标不可用**:B2 逐帧断言发出的顺序与 `scope`、 B5/B6/B7(失败不订阅、不重连、状态转 `unauthorized`)、H6(源码级:订阅只在 `success===true` 时发生)、 H7(运行时服务不认识任何数据 store —— 鉴权失败在结构上不可能牵连数据分区)。 3. **按服务端更新时间接受,旧消息忽略**:C1–C5 —— 更晚才采纳、更早/相同丢弃、 **跨偏移按时刻比**(`10:00+08:00` 与 `02:00Z` 判为同一时刻)、时间认不出的不采纳、 丢弃时**原样返回同一引用**。 4. **更新受影响点位与已打开详情,不重建视图**:D1–D5(只有命中的对象变、未命中保持同一引用、 坐标/名称/关联设备不动、同值不产生新数组、不在已加载范围的点位不改任何东西)、 E1/E2/E4(点位就地变、已打开详情随之更新、**不触发取数**)、E6(未关联设备只落设备维度、 对象数组引用未变 → 地图不重画)。 5. **选中/面板/滚动不变**:E3 实测(模式、展开面板、选中对象、滚动位置全部原样)。 这条是**结构性**的:推送路径只写 `status` 字段,从不触碰这些状态。 6. **四态可演示**:F1(未配置 → `disabled`,如实说"未配置")、F2(断线 → `retrying` → 重连 → `open`)、 B5(鉴权失败 → `unauthorized`)、D/E 组在走查脚本里对应真机断言。 7. **单测**:t15 逻辑闸门 **45/45** + 三个变异测试确认守卫有牙(见下)。 ## 验证证据 - `node .scratch/cockpit-shell-panel-layout/verify/t15-websocket.mjs` → **45/45**(八段: 协议与时序 / 握手与鉴权失败 / 旧消息丢弃 / 就地更新 / 选中与面板不受影响 / 四态可演示 / 退避与清理 / 静态守卫)。 - **变异测试(守卫确实会变红)**: ① 去掉 `disconnect()` 里的待重连取消 → **G2 红**(本轮抓到的真 bug 之一); ② 去掉时间比较(旧消息也采纳)→ **C4 红**; ③ 把 `success === true` 改成恒真(鉴权失败也订阅)→ **B5/B6/H6 三红**。 - **回归全绿(19 个逻辑闸门)**:t01、t04、t05(227)、t06、t07、t08、t09、t10、t11、t12、t13、 t14、**t15**、t17、t18、t21-terrain、t21-view-modes、t29、t30。 - **真机 DOM 走查**:A–F 六组在上述 `http://127.0.0.1:8001/#/cockpit` 实测通过(见上文)。 - 按项目规则**未跑** `yarn build`(AGENTS.md)。 ### 真机走查脚本(可复跑) node .scratch/cockpit-shell-panel-layout/verify/t15-websocket-walkthrough.mjs A 顶栏 `data-ws-state` 走到 **open**;B 推送后点位状态真的变(MP-001→OFFLINE、MP-002→ALARM) 且**地图点位数不变**;C 旧消息负例被丢弃;D `unauthorized` 时**REST 快照照常显示**; E `flaky` 时状态序列经过 `retrying` 并回到 `open`;F 全程无页面级报错。 (本机 `@puppeteer/browsers` 为空目录时脚本起不来,改用内建浏览器驱动同一批断言。) ## 对抗式自查抓到的真 bug 1. **推送早于分页快照 → 翻页后旧状态复活**(**只有真机才暴露**,逻辑闸门第一版漏了): 真实时序是「快照分页慢慢到,推送先来」,而 `applyPointStates()` 只写**当时已加载**的对象。 于是先被推成 OFFLINE 的 MP-001,在第 1 页到达后又显示快照里的 NORMAL —— 真机上肉眼可见「推送没生效」。已修:`mapStore` 缓存收到的推送状态(`pushedPointStates`), 翻页合并后**补写**到新到的对象上(`withPushedStatus`);同时 `refresh()`(显式换快照) **丢弃**缓存,让这一份快照说了算 —— 否则一条较旧的推送会盖掉刚取回的新状态。 新增 `E7c/E7d` 两条回归守住两个方向。 2. **`disconnect()` 漏取消待触发的重连定时器**(G2 抓红):链路掉了之后 `session` 已经置空, 只剩一个等着重连的模块级定时器,而 `disconnect()` 只清了 `session.timer` —— 于是**离开驾驶舱后连接还会继续重连**。已修:显式取消 `pendingTimer`;顺带在重连触发时 清掉该引用、并在处理断线时显式关闭那个已死的 socket(释放它自己的待发定时器)。 3. **三个既有守卫被本票打红**(t04 的凭据守卫、t05 的 D1/D2、t13 的 I2): 我最初让外壳读 `config.ws.token`、并把 `deviceStatus` 与假 socket 工厂写进页面。 这不是守卫太严,而是**层次被破坏了** —— 已改成外壳只说 `runtimeStore.startWithConfig(config)`, 地址/凭据/mock-real 的连接方式全部下沉到 store,页面因此对凭据与假连接完全无知。 同时把 t15 的 H5 守卫写**精确**:组件零凭据字段,store 只允许把配置里的 token **转交**给服务 (不得自己拼 `Authorization`/`Bearer`)。 ## 边界与移交(本票不追认) - **推送只改状态,不改坐标/名称/行业**:增量帧不带这些字段,凭空补就是编数据。 因此推送**不会**让一个原本不在已加载范围内的点位冒出来:那种情况下状态被记在 `pushedPointStates` 里,等该对象翻到(或出现在后续页)时补写。 - **`monitoringPointCode` 的可用性依赖后端**:票 25 记录该字段当前只在后端**未提交工作树**里, 且真实库点位关联近乎为空。所以真实环境下"点位联动"可能长期只落设备维度 —— 这是**数据/交付缺口**(票 25 的 BT-1/G4),不是本票的实现缺陷;本票按契约实现,并让缺点位码的帧 诚实地只更新设备维度。 - **轮询降级与新鲜度语义不属本票**:决策地图票 07 的 Q3 明确「WebSocket 失败降级轮询与新鲜度语义归票 16」。 本票只提供连接状态与增量,`runtimeStore` 的 `statusState.seenAt` 可供票 16 复用。 - 应用层心跳(`ping`/`pong`)未实现:后端有 `pong` 回应,但验收未要求,且连接活性已由 open/close/error 表达。 ## Answer **状态推送接入完成:连接/鉴权/订阅/断线重连集中在运行时服务,增量按服务端更新时间就地合并, 旧消息丢弃,推送只改受影响点位与已打开详情、不重建视图,选中/面板/滚动保持不变。** - **握手照后端真实协议**(读 `E:\pipe-ner-server` 源码核实):连接 → `authenticate` → `authenticated{success}` → **成功才** `cockpitSubscribe{scope:"all"}` → `cockpitSubscribed`; **鉴权失败只把推送标为不可用**(状态 `unauthorized`、不订阅、不重连), 在结构上不可能牵连数据分区(运行时服务不认识任何数据 store)。 - **旧消息按时刻丢弃**(不是按字符串比):`serverUpdatedAt` 转 epoch 毫秒后比较, 跨偏移写法(`+08:00` vs `Z`)判为同一时刻;时间认不出的帧不采纳;丢弃时**原样返回同一引用**。 - **点位状态只按后端指名的 `monitoringPointCode` 改写**,绝不由设备状态推导 (CONTEXT.md:前端只渲染、不重算);缺点位码的帧只更新设备维度。 - **就地更新**:只替换状态真变了的那几条,未命中的元素保持同一引用;坐标/名称/关联设备不动; 已打开详情只换状态字段(**不重新取数、不换层级**)。 - **推送常早于分页快照**:收到的推送状态被缓存,翻页时**补写**到新到的对象上; 显式 `refresh()` 换新快照时丢弃缓存,让快照说了算。 - **四态可演示**:`disabled`(未配置)/ `connecting…open`(正常)/ `retrying…open`(断线重连, 退避 1/2/5/10/15 秒封顶)/ `unauthorized`(鉴权失败),由脚本化假 socket 三场景驱动, 与真实环境**同一条代码路径**。 **验证**:逻辑闸门 45/45 + 三个变异测试有牙 + 真机 A–F 六组逐项通过 + 19 个逻辑闸门全量回归绿。 真机抓出并修掉两个真 bug(推送早于快照导致翻页后旧状态复活;`disconnect()` 漏取消待重连定时器), 另修正了一处层次破坏(外壳读凭据/认识假连接)。 **解锁**:票 16(新鲜度与过期状态)的 `Blocked by: 15` 已满足,可开始。