live.service.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. import { computed, inject, Injectable, signal } from '@angular/core';
  2. import { FmodeApi } from './api/fmode-api';
  3. import { Agent, LoadState } from './api/models';
  4. /** 轮询节拍(《数据访问层设计.md》§五.1:收敛为 1 个轮询器) */
  5. const POLL_MS = 15_000;
  6. /**
  7. * 实况服务 —— **全项目唯一的轮询器**
  8. *
  9. * 依据 SPEC FR-14 AC-14.1/14.3 与《数据访问层设计.md》§五.1。
  10. *
  11. * 存量有 **4 个重叠轮询器**(15s + 10s + 桌面组件自建 10s + 移动组件自建 10s),
  12. * 且 `ConversationsService.stopPolling()` 是**空实现**(停不掉)。v2 收敛为这一个,
  13. * 且 `stop()` 必须**真实停止**(有单测覆盖)。
  14. *
  15. * 约束:
  16. * · 视图层**禁止**自建 `setInterval` 取数(AC-14.2)
  17. * · 其它服务**消费**本服务,不得各自轮询
  18. */
  19. @Injectable({ providedIn: 'root' })
  20. export class LiveService {
  21. private readonly api = inject(FmodeApi);
  22. private readonly _agents = signal<Agent[]>([]);
  23. private readonly _state = signal<LoadState>('idle');
  24. private readonly _error = signal<string | null>(null);
  25. private readonly _lastUpdated = signal<string | null>(null);
  26. /** 轮询轮次(数据未变也自增,供下游做快照对比) */
  27. private readonly _tick = signal(0);
  28. /**
  29. * 最近一次拉取成功的时刻(epoch ms,0 = 还没拉过)。
  30. *
  31. * 🔴 为什么需要(2026-11-11):云函数直出的 `heartbeatAgeSec` 是
  32. * **响应那一刻**的年龄。本服务每 15 秒轮询一次,两次拉取之间
  33. * `heartbeatAgeSec` 是冻结的 —— 直接拿它判「是否超过 80 分钟」,
  34. * 真实年龄会被低估最多 15 秒。
  35. *
  36. * 判定在线时应使用 `heartbeatAgeSec + (now - fetchedAt)`,
  37. * 见 `agent-liveness.ts` 中 `livenessOf` 的 `now` 参数说明。
  38. */
  39. private readonly _fetchedAt = signal(0);
  40. readonly fetchedAt = this._fetchedAt.asReadonly();
  41. readonly tick = this._tick.asReadonly();
  42. readonly agents = this._agents.asReadonly();
  43. readonly state = this._state.asReadonly();
  44. readonly error = this._error.asReadonly();
  45. readonly lastUpdated = this._lastUpdated.asReadonly();
  46. readonly hasData = computed(() => this._agents().length > 0);
  47. readonly blockedAgents = computed(() => this._agents().filter((a) => a.status === 'error'));
  48. /** 当前是否在轮询(用于界面提示与单测断言) */
  49. readonly polling = signal(false);
  50. private timer: ReturnType<typeof setInterval> | null = null;
  51. /** 递增序号:切上下文/并发请求时丢弃过期回包(存量 seq 模式) */
  52. private seq = 0;
  53. /** 进行中的首次加载(并发调用共享,避免"加载中"被误判为"不存在") */
  54. private _inflight: Promise<void> | null = null;
  55. /**
  56. * 就地更新单个容器(2026-11-04)。
  57. *
  58. * 用于「重命名」这类**小范围**变更:整表重拉会让列表闪烁、
  59. * 也会打断正在进行的操作。下一次轮询仍以服务端为准。
  60. */
  61. patchAgent(next: Agent): void {
  62. this._agents.update((list) => list.map((a) => (a.id === next.id ? { ...a, ...next } : a)));
  63. }
  64. /** 首次加载(若已有数据则不重复请求) */
  65. async ensureLoaded(): Promise<void> {
  66. /*
  67. * 🔴 `empty` 也算「已加载过」。
  68. * `load()` 在列表为空时设的是 `state='empty'`,只判 'ready' 会导致
  69. * 每次调用都重新请求(列表为空时尤为浪费)。
  70. */
  71. if (this._state() === 'ready' || this._state() === 'empty') return;
  72. /*
  73. * 🔴 `loading` 时必须**等待**进行中的那次加载,不能直接 return。
  74. *
  75. * 原实现 `if (ready || loading) return;` —— 并发调用方(如聊天页
  76. * `onRouteChange` 与 shell 的启动加载同时触发)里,后到者立刻拿到 undefined,
  77. * 上层据此判「数字生命不存在」→ 刷新页面时报错(实测)。
  78. *
  79. * 改为复用同一个 in-flight Promise:所有人等到同一份数据。
  80. */
  81. if (this._inflight) return this._inflight;
  82. this._inflight = this.load().finally(() => {
  83. this._inflight = null;
  84. });
  85. return this._inflight;
  86. }
  87. /**
  88. * 加载实况数据。
  89. *
  90. * @param silent `true` = **静默更新**(轮询触发的常规刷新):
  91. * ① **不置 `loading`** —— 否则列表每 15s 闪一次骨架屏(实测反馈)
  92. * ② 数据**未变化时不换数组引用** —— 否则 `@for` 重算、滚动位置与选中态抖动
  93. *
  94. * 口径:
  95. * · 首次加载 / 手动「刷新」→ 非静默(用户需要看到加载反馈)
  96. * · 定时轮询 → 静默
  97. */
  98. async load(silent = false): Promise<void> {
  99. const my = ++this.seq;
  100. if (!silent) {
  101. this._state.set('loading');
  102. }
  103. this._error.set(null);
  104. try {
  105. const agents = await this.api.fetchAgents();
  106. if (my !== this.seq) return; // 过期回包丢弃
  107. // 数据未变 → 不换引用(避免整列重渲染导致闪烁/滚动跳动)
  108. if (!sameAgents(this._agents(), agents)) {
  109. this._agents.set(agents);
  110. } else {
  111. /*
  112. * 🔴 数据没变也必须让下游「知道又到一轮了」。
  113. *
  114. * `TaskPulseService` 靠每次轮询建立快照对比来发现状态翻转;
  115. * 只在引用变化时才通知的话,「running → idle 但其它字段都没动」
  116. * 这种最常见的翻转会被吞掉。故用独立的轻量信号承载轮次。
  117. */
  118. this._tick.update((n) => n + 1);
  119. }
  120. // 区分「空」与「就绪」:空列表是合法但需显示空态的状态(AC-03.4 禁止用空态掩盖失败)
  121. this._state.set(agents.length === 0 ? 'empty' : 'ready');
  122. this._lastUpdated.set(new Date().toISOString());
  123. /* 记录拉取时刻:心跳年龄判定要以此为基准向后推算(见 _fetchedAt 注释) */
  124. this._fetchedAt.set(Date.now());
  125. } catch (e) {
  126. if (my !== this.seq) return;
  127. this._state.set('error');
  128. this._error.set(e instanceof Error ? e.message : '实况数据加载失败');
  129. }
  130. }
  131. /** 手动刷新(工作台「刷新实况」按钮) */
  132. async refresh(): Promise<void> {
  133. await this.load();
  134. }
  135. /** 启动轮询;重复调用不会产生第二个定时器 */
  136. start(): void {
  137. if (this.timer) return;
  138. this.polling.set(true);
  139. void this.load(); // 首屏:非静默,让骨架屏生效
  140. this.timer = setInterval(() => void this.load(true), POLL_MS); // 轮询:静默
  141. }
  142. /**
  143. * 停止轮询 —— **真实停止**(AC-14.3:不得空实现)
  144. * 单测断言:调用后定时器不再触发。
  145. */
  146. stop(): void {
  147. if (this.timer) {
  148. clearInterval(this.timer);
  149. this.timer = null;
  150. }
  151. this.polling.set(false);
  152. }
  153. /** 复位(退出登录时调用) */
  154. reset(): void {
  155. this.stop();
  156. this.seq++;
  157. this._agents.set([]);
  158. this._state.set('idle');
  159. this._error.set(null);
  160. this._lastUpdated.set(null);
  161. }
  162. /** 单条查询(详情页;不发新请求,从已加载列表取,避免重复取数) */
  163. agentById(id: string): Agent | undefined {
  164. return this._agents().find((a) => a.id === id);
  165. }
  166. }
  167. /**
  168. * 两个 agent 列表是否**实质相同**(决定要不要换数组引用)。
  169. *
  170. * 比较 id 与所有可能影响界面的字段;顺序敏感(列表顺序变化属可见变化)。
  171. * 目的:轮询周期内数据没变时**不触发重渲染**,避免骨架屏/滚动位置抖动。
  172. */
  173. function sameAgents(a: Agent[], b: Agent[]): boolean {
  174. if (a === b) return true;
  175. if (a.length !== b.length) return false;
  176. for (let i = 0; i < a.length; i++) {
  177. if (!sameAgent(a[i], b[i])) return false;
  178. }
  179. return true;
  180. }
  181. function sameAgent(x: Agent, y: Agent): boolean {
  182. return (
  183. x.id === y.id &&
  184. x.name === y.name &&
  185. x.avatar === y.avatar &&
  186. x.status === y.status &&
  187. x.statusText === y.statusText &&
  188. x.currentTask === y.currentTask &&
  189. x.currentAction === y.currentAction &&
  190. x.progress === y.progress &&
  191. x.heartbeatAt === y.heartbeatAt &&
  192. x.updatedAt === y.updatedAt &&
  193. x.matrixReady === y.matrixReady &&
  194. x.unread === y.unread &&
  195. x.companyId === y.companyId &&
  196. // 嵌套对象按关键字段比(避免深比较开销)
  197. x.host?.cpuPercent === y.host?.cpuPercent &&
  198. x.host?.memPercent === y.host?.memPercent &&
  199. x.host?.diskPercent === y.host?.diskPercent &&
  200. x.balance?.remainCny === y.balance?.remainCny &&
  201. x.channels?.matrix === y.channels?.matrix &&
  202. x.channels?.matrixConfig?.userId === y.channels?.matrixConfig?.userId
  203. );
  204. }