import { inject, Injectable, signal } from '@angular/core'; import { FmodeApi } from './api/fmode-api'; import { Deliverable } from './api/models'; /** * 成果文件上云盘 —— App 只负责「报告 + 触发」,签名与搬运都不在浏览器。 * * ## 链路 * * ``` * ① App 发现成果附件只有 source(容器路径)、没有 url * ② App 调云函数 lfYlgU7SkK action:'enqueueArtifact' * (只报:哪个成果、哪个附件、第几个、叫什么) * ③ 云函数用**服务端密钥**签好上传地址,写进 FmodeArtifactJob * ④ 容器轮询 action:'pendingArtifacts' → 读文件 → PUT → action:'ackArtifact' * ⑤ 云函数回写 artifacts[i].url,App 下次刷新即可下载 * ``` * * ## 为什么前端不持有任何密钥(2026-10-21 定稿) * * 曾经的方案是「App 自己签」:云函数把 AK/SK 下发给浏览器。 * 问题是 ① 密钥会出现在 DevTools 里 ② 两个云函数各存一份,轮换要改两处。 * * 现在是:**密钥只存在服务端一处**,浏览器全程不接触。 * 代价只是每个附件多一次云函数调用。 */ @Injectable({ providedIn: 'root' }) export class ArtifactSyncService { private readonly api = inject(FmodeApi); /** 正在处理的键(防并发重复提交) */ private readonly inFlight = new Set(); /** 已处理过的键(成功或失败都算,避免每轮轮询都打云函数) */ private readonly done = new Set(); /** 失败原因(`deliverableId/source` → 文案),供 UI 如实展示 */ private readonly _errors = signal>(new Map()); readonly errors = this._errors.asReadonly(); /** 本次会话已入队待搬运的数量 */ private readonly _queued = signal(0); readonly queued = this._queued.asReadonly(); /** * 扫描一批成果,把「有 source、没 url」的附件排进搬运队列。 * 幂等:同一成果同一附件只处理一次。返回本次**新入队**数量。 */ async syncPending(list: Deliverable[]): Promise { const jobs: { d: Deliverable; source: string; name: string; index: number }[] = []; for (const d of list) { d.artifacts.forEach((a, index) => { if (a.url || !a.source) return; const key = this.keyOf(d.id, a.source); if (this.done.has(key) || this.inFlight.has(key)) return; // 失败过的项按退避重试(2026-11-07) if (!this.canRetry(key)) return; // 下标进队列:同名附件必须落到不同的 key jobs.push({ d, source: a.source, name: a.name, index }); }); } if (jobs.length === 0) return 0; await Promise.all(jobs.map((j) => this.enqueue(j.d, j.source, j.name, j.index))); return jobs.length; } private async enqueue( d: Deliverable, source: string, name: string, index: number, ): Promise { const key = this.keyOf(d.id, source); this.inFlight.add(key); try { await this.api.enqueueArtifactJob({ deliverableId: d.id, agentId: d.agentId, source, filename: name, index, }); this.done.add(key); this.failed.delete(key); // 成功后清掉失败记录,避免残留影响判断 this._errors.update((m) => { if (!m.has(key)) return m; const next = new Map(m); next.delete(key); return next; }); this._queued.update((n) => n + 1); } catch (e) { this.fail(d.id, source, e instanceof Error ? e.message : '上云登记失败'); } finally { this.inFlight.delete(key); } } private fail(deliverableId: string, source: string, msg: string): void { const key = this.keyOf(deliverableId, source); /* * 🔴 失败**不再写入 `done`**(2026-11-07)。 * * 原实现失败也加进 `done`,于是同一次页面生命周期内再也不会重试 —— * 用户只看到「同步失败」而没有任何恢复路径,只能刷新。 * 现在记失败时间与次数,`syncPending` 按退避策略自动重试。 */ const prev = this.failed.get(key); this.failed.set(key, { attempts: (prev?.attempts ?? 0) + 1, at: Date.now(), msg, }); this._errors.update((m) => { const next = new Map(m); next.set(key, msg); return next; }); } /** 失败记录:key → 次数与时间(用于退避重试) */ private readonly failed = new Map(); /** 失败后的重试退避(毫秒),按次数递增 */ private backoffMs(attempts: number): number { return Math.min(30_000 * attempts, 5 * 60_000); } /** 该附件现在是否可以重试(未失败过、或已过退避期) */ private canRetry(key: string): boolean { const f = this.failed.get(key); if (!f) return true; if (f.attempts >= 5) return false; // 放弃,等下次页面加载重置 return Date.now() - f.at >= this.backoffMs(f.attempts); } /** 某附件同步失败原因(没有则 undefined) */ errorOf(deliverableId: string, source: string): string | undefined { return this._errors().get(this.keyOf(deliverableId, source)); } /** 是否已入队(UI 可显示「正在搬运到云盘」) */ isQueued(deliverableId: string, source: string): boolean { return this.done.has(this.keyOf(deliverableId, source)); } private keyOf(deliverableId: string, source: string): string { return `${deliverableId}|${source}`; } }