| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150 |
- 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<string>();
- /** 已处理过的键(成功或失败都算,避免每轮轮询都打云函数) */
- private readonly done = new Set<string>();
- /** 失败原因(`deliverableId/source` → 文案),供 UI 如实展示 */
- private readonly _errors = signal<ReadonlyMap<string, string>>(new Map());
- readonly errors = this._errors.asReadonly();
- /** 本次会话已入队待搬运的数量 */
- private readonly _queued = signal(0);
- readonly queued = this._queued.asReadonly();
- /**
- * 扫描一批成果,把「有 source、没 url」的附件排进搬运队列。
- * 幂等:同一成果同一附件只处理一次。返回本次**新入队**数量。
- */
- async syncPending(list: Deliverable[]): Promise<number> {
- 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<void> {
- 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<string, { attempts: number; at: number; msg: string }>();
- /** 失败后的重试退避(毫秒),按次数递增 */
- 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}`;
- }
- }
|