| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148 |
- /**
- * 刷新周期与轮询(票 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
- }
|