/** * 运行链路领域接口(票 05 只交 REST 侧的分区读取;消费方是票 15/16)。 * * ADR 0005 的 `runtimeApi`:WebSocket 是运行链路,前端消费的是**版本化领域事件**, * 不把 `/ws/deviceStatus` 的裸 JSON 当依赖。本文件先交 REST 侧能落地的部分 * (新鲜度、设备状态增量帧 → 领域事件的**转换**),连接与重连归票 15。 * * 增量帧的字段名有个坑(调研 §1 已核实):WS 线上帧的状态键是 `data.status`, * 而后端 DTO 命名为 `monitoringState`。转换层两个键都认,避免接错一边就静默丢状态。 */ import { toFreshness } from '../domain/freshness.js' import { fetchSection } from './domainRequest.js' import { SECTION_STATE, section } from '../domain/sectionState.js' /** WS 消息类型(后端 `DeviceStatusWebSocketHandler`)。 */ export const WS_MESSAGE = { SUBSCRIBE: 'cockpitSubscribe', SUBSCRIBED: 'cockpitSubscribed', DELTA: 'cockpitDeviceStatusDelta', AUTHENTICATE: 'authenticate', } /** * 设备状态增量帧 → 领域事件。 * 只做字段归一,不做取舍:是否采纳(旧消息要丢弃)由票 15 按 `serverUpdatedAt` 判。 * @param {object} frame WS 线上帧 * @returns {object|null} 认不出的帧回 null(调用方据此忽略,不猜) */ export function toDeviceStatusEvent(frame) { const raw = frame && typeof frame === 'object' ? frame : null if (!raw || raw.type !== WS_MESSAGE.DELTA) return null const payload = raw.data && typeof raw.data === 'object' ? raw.data : {} const deviceCode = raw.deviceCode || raw.device_code || payload.deviceCode || '' if (!deviceCode) return null return { deviceCode, // 线上帧用 status,DTO 用 monitoringState:两个都认。 status: payload.status || payload.monitoringState || '', statusTime: payload.statusTime || '', serverUpdatedAt: payload.serverUpdatedAt || raw.serverUpdatedAt || '', reason: payload.reason || '', // 增量帧不带坐标与行业(只能靠 deviceCode 回联 REST 快照)——票 15 据此设计合并。 sampledAt: payload.sampledAt || '', } } /** 订阅确认帧 → 领域事件。 */ export function toSubscribedEvent(frame) { const raw = frame && typeof frame === 'object' ? frame : null if (!raw || raw.type !== WS_MESSAGE.SUBSCRIBED) return null return { scope: raw.scope || '', serverUpdatedAt: raw.serverUpdatedAt || '' } } /** * 设备新鲜度分区(票 16 的统一新鲜度模型)。 * @param {string} deviceCode 设备编码 * @param {object} [options] `{ calibrationPeriodSeconds, config, fetchImpl }` */ export async function fetchFreshnessSection(deviceCode, options = {}) { if (!deviceCode) { return section(SECTION_STATE.FAILED, { reason: '缺少设备编码' }) } const result = await fetchSection(`/equipments/${encodeURIComponent(deviceCode)}/freshness`, { params: { calibrationPeriodSeconds: options.calibrationPeriodSeconds }, toData: toFreshness, config: options.config, fetchImpl: options.fetchImpl, }) if (result.state !== SECTION_STATE.SUCCESS) return result const freshness = result.data || {} // 后端四态 → 分区状态:EXPIRED/STALE 归「过期但保留旧值」,EMPTY 归「确实没有」。 if (freshness.freshness === 'EMPTY') { return section(SECTION_STATE.EMPTY, { mode: result.mode, data: freshness, freshness: 'EMPTY' }) } if (freshness.freshness === 'EXPIRED' || freshness.freshness === 'STALE') { return section(SECTION_STATE.STALE, { mode: result.mode, reason: freshness.staleReason || '', data: freshness, freshness: freshness.freshness, retained: freshness.retained === true, }) } return section(SECTION_STATE.SUCCESS, { mode: result.mode, data: freshness, freshness: freshness.freshness || 'CURRENT' }) }