/** * 刷新周期与轮询(票 16):把「一个配置周期」落成可注入、可单测的定时器。 * * 决策地图票 07 的 Q3 把「WebSocket 失败降级轮询与新鲜度语义」判给了本票, * 维护者 2026-09-24 进一步定了口径:**前端轮询 + WS 断线也算失败周期**。 * 所以本服务每个周期问两件事: * 1. 这一轮取数成功了吗?(由注入的 `tick` 回调回答 —— 它去刷新地图摘要/面板) * 2. WebSocket 这一轮可用吗?(维护者口径:WS 断线也算这一周期失败) * 两个都好的周期才算**成功**;任一不好就算失败 —— 连续两个失败即过期(见 `domain/freshnessModel.js`)。 * * 三条纪律: * · **时钟与定时器全注入**:单测用假定时器推进"两个周期",不真的等 60 秒; * 走查也能用假时钟演示 CURRENT→STALE→UNAVAILABLE; * · **不自动起定时器**(`start()` 才起):没人调用就什么都不发生 —— 避免"导入即轮询" * 这种在单测与静态环境里闯祸的写法; * · **`stop()` 一定清干净**:离开驾驶舱后定时器必须停(票 15 刚在这一点上栽过 —— * `disconnect()` 漏取消重连定时器,离开后还在重连)。 */ import { emptyFreshness, markFailure, markSuccess } from '../domain/freshnessModel.js' /** 默认周期(秒);配置 `runtime.refreshSeconds` 覆盖它。 */ export const DEFAULT_REFRESH_SECONDS = 30 let timer = null let options = null let state = emptyFreshness() function now() { return options && typeof options.now === 'function' ? options.now() : Date.now() } /** 当前新鲜度现态(响应式由 store 负责;本层只持有事实)。 */ export function getFreshnessState() { return state } /** 仅测试/切换配置用:重置现态与定时器。 */ export function resetFreshness() { stopPolling() state = emptyFreshness() options = null } /** * 开始轮询(幂等:重复调用不会叠加定时器)。 * * @param {object} config * @param {number} [config.refreshSeconds] 周期长度(秒) * @param {Function} config.tick 每周期执行一次:返回 `true` 表示这一轮**取数成功**, * `false`/抛错表示失败(失败原因可回字符串或 Error) * @param {Function} [config.isPushLive] 返回 WS 是否可用(维护者口径:断线也算失败周期) * @param {Function} [config.now] 注入时钟 * @param {Function} [config.setTimeout] 注入定时器 * @param {Function} [config.clearTimeout] * @param {Function} [config.onState] 每次状态变化回调(store 用它同步响应式) * @returns {object} 当前现态 */ export function startPolling(config = {}) { stopPolling() const seconds = Number(config.refreshSeconds) const period = Number.isFinite(seconds) && seconds > 0 ? seconds : DEFAULT_REFRESH_SECONDS options = { periodMs: period * 1000, tick: config.tick, isPushLive: config.isPushLive, now: config.now, setTimeout: config.setTimeout || ((fn, ms) => setTimeout(fn, ms)), clearTimeout: config.clearTimeout || ((id) => clearTimeout(id)), onState: config.onState, } // 立刻跑一轮:等一个周期才第一次取数,会让首屏在 30 秒里都是"尚未取数"。 runCycle() schedule() return state } /** 停止轮询并清掉定时器(离开驾驶舱/切换配置用)。 */ export function stopPolling() { if (timer && options && typeof options.clearTimeout === 'function') options.clearTimeout(timer) timer = null } export function isPolling() { return timer !== null } function schedule() { if (!options) return timer = options.setTimeout(() => { timer = null runCycle() schedule() }, options.periodMs) } /** * 跑一个周期:取数结果 + WS 可用性 → 成功/失败 → 更新现态。 * * **两个条件都要好才算成功**(维护者口径)。只取数成功但 WS 断着,说明这条链路上 * "实时性"已经没了 —— 那正是票 16 要如实告诉值班人员的事,不能因为 REST 还能取就报新鲜。 */ export function runCycle() { if (!options) return state const at = now() let ok = false let reason = '' // WS 断线:这一周期直接算失败(但仍然照常尝试取数 —— REST 快照本身仍要更新)。 const pushLive = typeof options.isPushLive === 'function' ? options.isPushLive() : true if (!pushLive) reason = '状态推送未连接' try { const result = typeof options.tick === 'function' ? options.tick() : true if (result === false) { ok = false reason = reason || '取数失败' } else if (result && typeof result === 'object' && result.ok === false) { ok = false reason = reason || result.reason || '取数失败' } else { ok = !reason } } catch (error) { ok = false reason = reason || String((error && error.message) || error) } state = ok ? markSuccess(state, at) : markFailure(state, at, reason) if (options.onState) options.onState(state) return state } /** * 外部驱动的"成功/失败"记账(票 15 的推送到达时用)。 * * 推送是**事件驱动**的,不在轮询节拍上:收到推送说明链路活着, * 应当把连续失败清零(否则一个安静的推送周期会被误判成过期)。 * @param {boolean} ok * @param {string} [reason] * @param {number} [at] */ export function recordExternal(ok, reason = '', at = now()) { state = ok ? markSuccess(state, at) : markFailure(state, at, reason) if (options && options.onState) options.onState(state) return state }