artifact-sync.service.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. import { inject, Injectable, signal } from '@angular/core';
  2. import { FmodeApi } from './api/fmode-api';
  3. import { Deliverable } from './api/models';
  4. /**
  5. * 成果文件上云盘 —— App 只负责「报告 + 触发」,签名与搬运都不在浏览器。
  6. *
  7. * ## 链路
  8. *
  9. * ```
  10. * ① App 发现成果附件只有 source(容器路径)、没有 url
  11. * ② App 调云函数 lfYlgU7SkK action:'enqueueArtifact'
  12. * (只报:哪个成果、哪个附件、第几个、叫什么)
  13. * ③ 云函数用**服务端密钥**签好上传地址,写进 FmodeArtifactJob
  14. * ④ 容器轮询 action:'pendingArtifacts' → 读文件 → PUT → action:'ackArtifact'
  15. * ⑤ 云函数回写 artifacts[i].url,App 下次刷新即可下载
  16. * ```
  17. *
  18. * ## 为什么前端不持有任何密钥(2026-10-21 定稿)
  19. *
  20. * 曾经的方案是「App 自己签」:云函数把 AK/SK 下发给浏览器。
  21. * 问题是 ① 密钥会出现在 DevTools 里 ② 两个云函数各存一份,轮换要改两处。
  22. *
  23. * 现在是:**密钥只存在服务端一处**,浏览器全程不接触。
  24. * 代价只是每个附件多一次云函数调用。
  25. */
  26. @Injectable({ providedIn: 'root' })
  27. export class ArtifactSyncService {
  28. private readonly api = inject(FmodeApi);
  29. /** 正在处理的键(防并发重复提交) */
  30. private readonly inFlight = new Set<string>();
  31. /** 已处理过的键(成功或失败都算,避免每轮轮询都打云函数) */
  32. private readonly done = new Set<string>();
  33. /** 失败原因(`deliverableId/source` → 文案),供 UI 如实展示 */
  34. private readonly _errors = signal<ReadonlyMap<string, string>>(new Map());
  35. readonly errors = this._errors.asReadonly();
  36. /** 本次会话已入队待搬运的数量 */
  37. private readonly _queued = signal(0);
  38. readonly queued = this._queued.asReadonly();
  39. /**
  40. * 扫描一批成果,把「有 source、没 url」的附件排进搬运队列。
  41. * 幂等:同一成果同一附件只处理一次。返回本次**新入队**数量。
  42. */
  43. async syncPending(list: Deliverable[]): Promise<number> {
  44. const jobs: { d: Deliverable; source: string; name: string; index: number }[] = [];
  45. for (const d of list) {
  46. d.artifacts.forEach((a, index) => {
  47. if (a.url || !a.source) return;
  48. const key = this.keyOf(d.id, a.source);
  49. if (this.done.has(key) || this.inFlight.has(key)) return;
  50. // 失败过的项按退避重试(2026-11-07)
  51. if (!this.canRetry(key)) return;
  52. // 下标进队列:同名附件必须落到不同的 key
  53. jobs.push({ d, source: a.source, name: a.name, index });
  54. });
  55. }
  56. if (jobs.length === 0) return 0;
  57. await Promise.all(jobs.map((j) => this.enqueue(j.d, j.source, j.name, j.index)));
  58. return jobs.length;
  59. }
  60. private async enqueue(
  61. d: Deliverable,
  62. source: string,
  63. name: string,
  64. index: number,
  65. ): Promise<void> {
  66. const key = this.keyOf(d.id, source);
  67. this.inFlight.add(key);
  68. try {
  69. await this.api.enqueueArtifactJob({
  70. deliverableId: d.id,
  71. agentId: d.agentId,
  72. source,
  73. filename: name,
  74. index,
  75. });
  76. this.done.add(key);
  77. this.failed.delete(key); // 成功后清掉失败记录,避免残留影响判断
  78. this._errors.update((m) => {
  79. if (!m.has(key)) return m;
  80. const next = new Map(m);
  81. next.delete(key);
  82. return next;
  83. });
  84. this._queued.update((n) => n + 1);
  85. } catch (e) {
  86. this.fail(d.id, source, e instanceof Error ? e.message : '上云登记失败');
  87. } finally {
  88. this.inFlight.delete(key);
  89. }
  90. }
  91. private fail(deliverableId: string, source: string, msg: string): void {
  92. const key = this.keyOf(deliverableId, source);
  93. /*
  94. * 🔴 失败**不再写入 `done`**(2026-11-07)。
  95. *
  96. * 原实现失败也加进 `done`,于是同一次页面生命周期内再也不会重试 ——
  97. * 用户只看到「同步失败」而没有任何恢复路径,只能刷新。
  98. * 现在记失败时间与次数,`syncPending` 按退避策略自动重试。
  99. */
  100. const prev = this.failed.get(key);
  101. this.failed.set(key, {
  102. attempts: (prev?.attempts ?? 0) + 1,
  103. at: Date.now(),
  104. msg,
  105. });
  106. this._errors.update((m) => {
  107. const next = new Map(m);
  108. next.set(key, msg);
  109. return next;
  110. });
  111. }
  112. /** 失败记录:key → 次数与时间(用于退避重试) */
  113. private readonly failed = new Map<string, { attempts: number; at: number; msg: string }>();
  114. /** 失败后的重试退避(毫秒),按次数递增 */
  115. private backoffMs(attempts: number): number {
  116. return Math.min(30_000 * attempts, 5 * 60_000);
  117. }
  118. /** 该附件现在是否可以重试(未失败过、或已过退避期) */
  119. private canRetry(key: string): boolean {
  120. const f = this.failed.get(key);
  121. if (!f) return true;
  122. if (f.attempts >= 5) return false; // 放弃,等下次页面加载重置
  123. return Date.now() - f.at >= this.backoffMs(f.attempts);
  124. }
  125. /** 某附件同步失败原因(没有则 undefined) */
  126. errorOf(deliverableId: string, source: string): string | undefined {
  127. return this._errors().get(this.keyOf(deliverableId, source));
  128. }
  129. /** 是否已入队(UI 可显示「正在搬运到云盘」) */
  130. isQueued(deliverableId: string, source: string): boolean {
  131. return this.done.has(this.keyOf(deliverableId, source));
  132. }
  133. private keyOf(deliverableId: string, source: string): string {
  134. return `${deliverableId}|${source}`;
  135. }
  136. }