runtimeApi.js 3.9 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
  1. /**
  2. * 运行链路领域接口(票 05 只交 REST 侧的分区读取;消费方是票 15/16)。
  3. *
  4. * ADR 0005 的 `runtimeApi`:WebSocket 是运行链路,前端消费的是**版本化领域事件**,
  5. * 不把 `/ws/deviceStatus` 的裸 JSON 当依赖。本文件先交 REST 侧能落地的部分
  6. * (新鲜度、设备状态增量帧 → 领域事件的**转换**),连接与重连归票 15。
  7. *
  8. * 增量帧的字段名有个坑(调研 §1 已核实):WS 线上帧的状态键是 `data.status`,
  9. * 而后端 DTO 命名为 `monitoringState`。转换层两个键都认,避免接错一边就静默丢状态。
  10. */
  11. import { toFreshness } from '../domain/freshness.js'
  12. import { fetchSection } from './domainRequest.js'
  13. import { SECTION_STATE, section } from '../domain/sectionState.js'
  14. /** WS 消息类型(后端 `DeviceStatusWebSocketHandler`)。 */
  15. export const WS_MESSAGE = {
  16. SUBSCRIBE: 'cockpitSubscribe',
  17. SUBSCRIBED: 'cockpitSubscribed',
  18. DELTA: 'cockpitDeviceStatusDelta',
  19. AUTHENTICATE: 'authenticate',
  20. }
  21. /**
  22. * 设备状态增量帧 → 领域事件。
  23. * 只做字段归一,不做取舍:是否采纳(旧消息要丢弃)由票 15 按 `serverUpdatedAt` 判。
  24. * @param {object} frame WS 线上帧
  25. * @returns {object|null} 认不出的帧回 null(调用方据此忽略,不猜)
  26. */
  27. export function toDeviceStatusEvent(frame) {
  28. const raw = frame && typeof frame === 'object' ? frame : null
  29. if (!raw || raw.type !== WS_MESSAGE.DELTA) return null
  30. const payload = raw.data && typeof raw.data === 'object' ? raw.data : {}
  31. const deviceCode = raw.deviceCode || raw.device_code || payload.deviceCode || ''
  32. if (!deviceCode) return null
  33. return {
  34. deviceCode,
  35. // 线上帧用 status,DTO 用 monitoringState:两个都认。
  36. status: payload.status || payload.monitoringState || '',
  37. statusTime: payload.statusTime || '',
  38. serverUpdatedAt: payload.serverUpdatedAt || raw.serverUpdatedAt || '',
  39. reason: payload.reason || '',
  40. // 增量帧不带坐标与行业(只能靠 deviceCode 回联 REST 快照)——票 15 据此设计合并。
  41. sampledAt: payload.sampledAt || '',
  42. }
  43. }
  44. /** 订阅确认帧 → 领域事件。 */
  45. export function toSubscribedEvent(frame) {
  46. const raw = frame && typeof frame === 'object' ? frame : null
  47. if (!raw || raw.type !== WS_MESSAGE.SUBSCRIBED) return null
  48. return { scope: raw.scope || '', serverUpdatedAt: raw.serverUpdatedAt || '' }
  49. }
  50. /**
  51. * 设备新鲜度分区(票 16 的统一新鲜度模型)。
  52. * @param {string} deviceCode 设备编码
  53. * @param {object} [options] `{ calibrationPeriodSeconds, config, fetchImpl }`
  54. */
  55. export async function fetchFreshnessSection(deviceCode, options = {}) {
  56. if (!deviceCode) {
  57. return section(SECTION_STATE.FAILED, { reason: '缺少设备编码' })
  58. }
  59. const result = await fetchSection(`/equipments/${encodeURIComponent(deviceCode)}/freshness`, {
  60. params: { calibrationPeriodSeconds: options.calibrationPeriodSeconds },
  61. toData: toFreshness,
  62. config: options.config,
  63. fetchImpl: options.fetchImpl,
  64. })
  65. if (result.state !== SECTION_STATE.SUCCESS) return result
  66. const freshness = result.data || {}
  67. // 后端四态 → 分区状态:EXPIRED/STALE 归「过期但保留旧值」,EMPTY 归「确实没有」。
  68. if (freshness.freshness === 'EMPTY') {
  69. return section(SECTION_STATE.EMPTY, { mode: result.mode, data: freshness, freshness: 'EMPTY' })
  70. }
  71. if (freshness.freshness === 'EXPIRED' || freshness.freshness === 'STALE') {
  72. return section(SECTION_STATE.STALE, {
  73. mode: result.mode,
  74. reason: freshness.staleReason || '',
  75. data: freshness,
  76. freshness: freshness.freshness,
  77. retained: freshness.retained === true,
  78. })
  79. }
  80. return section(SECTION_STATE.SUCCESS, { mode: result.mode, data: freshness, freshness: freshness.freshness || 'CURRENT' })
  81. }