freshnessService.js 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. /**
  2. * 刷新周期与轮询(票 16):把「一个配置周期」落成可注入、可单测的定时器。
  3. *
  4. * 决策地图票 07 的 Q3 把「WebSocket 失败降级轮询与新鲜度语义」判给了本票,
  5. * 维护者 2026-09-24 进一步定了口径:**前端轮询 + WS 断线也算失败周期**。
  6. * 所以本服务每个周期问两件事:
  7. * 1. 这一轮取数成功了吗?(由注入的 `tick` 回调回答 —— 它去刷新地图摘要/面板)
  8. * 2. WebSocket 这一轮可用吗?(维护者口径:WS 断线也算这一周期失败)
  9. * 两个都好的周期才算**成功**;任一不好就算失败 —— 连续两个失败即过期(见 `domain/freshnessModel.js`)。
  10. *
  11. * 三条纪律:
  12. * · **时钟与定时器全注入**:单测用假定时器推进"两个周期",不真的等 60 秒;
  13. * 走查也能用假时钟演示 CURRENT→STALE→UNAVAILABLE;
  14. * · **不自动起定时器**(`start()` 才起):没人调用就什么都不发生 —— 避免"导入即轮询"
  15. * 这种在单测与静态环境里闯祸的写法;
  16. * · **`stop()` 一定清干净**:离开驾驶舱后定时器必须停(票 15 刚在这一点上栽过 ——
  17. * `disconnect()` 漏取消重连定时器,离开后还在重连)。
  18. */
  19. import { emptyFreshness, markFailure, markSuccess } from '../domain/freshnessModel.js'
  20. /** 默认周期(秒);配置 `runtime.refreshSeconds` 覆盖它。 */
  21. export const DEFAULT_REFRESH_SECONDS = 30
  22. let timer = null
  23. let options = null
  24. let state = emptyFreshness()
  25. function now() {
  26. return options && typeof options.now === 'function' ? options.now() : Date.now()
  27. }
  28. /** 当前新鲜度现态(响应式由 store 负责;本层只持有事实)。 */
  29. export function getFreshnessState() {
  30. return state
  31. }
  32. /** 仅测试/切换配置用:重置现态与定时器。 */
  33. export function resetFreshness() {
  34. stopPolling()
  35. state = emptyFreshness()
  36. options = null
  37. }
  38. /**
  39. * 开始轮询(幂等:重复调用不会叠加定时器)。
  40. *
  41. * @param {object} config
  42. * @param {number} [config.refreshSeconds] 周期长度(秒)
  43. * @param {Function} config.tick 每周期执行一次:返回 `true` 表示这一轮**取数成功**,
  44. * `false`/抛错表示失败(失败原因可回字符串或 Error)
  45. * @param {Function} [config.isPushLive] 返回 WS 是否可用(维护者口径:断线也算失败周期)
  46. * @param {Function} [config.now] 注入时钟
  47. * @param {Function} [config.setTimeout] 注入定时器
  48. * @param {Function} [config.clearTimeout]
  49. * @param {Function} [config.onState] 每次状态变化回调(store 用它同步响应式)
  50. * @returns {object} 当前现态
  51. */
  52. export function startPolling(config = {}) {
  53. stopPolling()
  54. const seconds = Number(config.refreshSeconds)
  55. const period = Number.isFinite(seconds) && seconds > 0 ? seconds : DEFAULT_REFRESH_SECONDS
  56. options = {
  57. periodMs: period * 1000,
  58. tick: config.tick,
  59. isPushLive: config.isPushLive,
  60. now: config.now,
  61. setTimeout: config.setTimeout || ((fn, ms) => setTimeout(fn, ms)),
  62. clearTimeout: config.clearTimeout || ((id) => clearTimeout(id)),
  63. onState: config.onState,
  64. }
  65. // 立刻跑一轮:等一个周期才第一次取数,会让首屏在 30 秒里都是"尚未取数"。
  66. runCycle()
  67. schedule()
  68. return state
  69. }
  70. /** 停止轮询并清掉定时器(离开驾驶舱/切换配置用)。 */
  71. export function stopPolling() {
  72. if (timer && options && typeof options.clearTimeout === 'function') options.clearTimeout(timer)
  73. timer = null
  74. }
  75. export function isPolling() {
  76. return timer !== null
  77. }
  78. function schedule() {
  79. if (!options) return
  80. timer = options.setTimeout(() => {
  81. timer = null
  82. runCycle()
  83. schedule()
  84. }, options.periodMs)
  85. }
  86. /**
  87. * 跑一个周期:取数结果 + WS 可用性 → 成功/失败 → 更新现态。
  88. *
  89. * **两个条件都要好才算成功**(维护者口径)。只取数成功但 WS 断着,说明这条链路上
  90. * "实时性"已经没了 —— 那正是票 16 要如实告诉值班人员的事,不能因为 REST 还能取就报新鲜。
  91. */
  92. export function runCycle() {
  93. if (!options) return state
  94. const at = now()
  95. let ok = false
  96. let reason = ''
  97. // WS 断线:这一周期直接算失败(但仍然照常尝试取数 —— REST 快照本身仍要更新)。
  98. const pushLive = typeof options.isPushLive === 'function' ? options.isPushLive() : true
  99. if (!pushLive) reason = '状态推送未连接'
  100. try {
  101. const result = typeof options.tick === 'function' ? options.tick() : true
  102. if (result === false) {
  103. ok = false
  104. reason = reason || '取数失败'
  105. } else if (result && typeof result === 'object' && result.ok === false) {
  106. ok = false
  107. reason = reason || result.reason || '取数失败'
  108. } else {
  109. ok = !reason
  110. }
  111. } catch (error) {
  112. ok = false
  113. reason = reason || String((error && error.message) || error)
  114. }
  115. state = ok ? markSuccess(state, at) : markFailure(state, at, reason)
  116. if (options.onState) options.onState(state)
  117. return state
  118. }
  119. /**
  120. * 外部驱动的"成功/失败"记账(票 15 的推送到达时用)。
  121. *
  122. * 推送是**事件驱动**的,不在轮询节拍上:收到推送说明链路活着,
  123. * 应当把连续失败清零(否则一个安静的推送周期会被误判成过期)。
  124. * @param {boolean} ok
  125. * @param {string} [reason]
  126. * @param {number} [at]
  127. */
  128. export function recordExternal(ok, reason = '', at = now()) {
  129. state = ok ? markSuccess(state, at) : markFailure(state, at, reason)
  130. if (options && options.onState) options.onState(state)
  131. return state
  132. }