15-websocket-status-updates.md 15 KB

15: WebSocket 状态更新

What to build: 通过统一运行时服务接入设备状态推送。推送只更新受影响的点位和详情字段,断线后可重连,并保留当前选择上下文。

Blocked by: 09, 13

Status: resolved Labels: ready-for-agent

  • WebSocket 连接、订阅、断线和重连集中在运行时服务中。
  • 连接成功后先发 authenticate 帧(token 与 REST 同源),再发 cockpitSubscribe;authenticate 失败只把状态推送标为不可用,REST 快照照常显示。
  • 设备状态增量按服务端更新时间接受,旧消息被忽略。
  • 推送更新受影响点位和已打开详情,不重建视图。
  • 当前选中对象、面板状态和滚动位置保持不变。
  • 连接、重连、过期和失败状态可演示。
  • 有增量合并与重连单测。

进度: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 已满足,可开始。