deliverables.service.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  1. import { ArtifactSyncService } from './artifact-sync.service';
  2. import { environment } from '../../environments/environment';
  3. import { computed, inject, Injectable, signal } from '@angular/core';
  4. import { FmodeApi } from './api/fmode-api';
  5. import {
  6. Agent,
  7. Deliverable,
  8. DeliverableGroup,
  9. LoadState,
  10. Project,
  11. SessionFile,
  12. } from './api/models';
  13. import { AlertService } from './alert.service';
  14. import { LiveService } from './live.service';
  15. import { isUserUploadUrl } from './s3-link';
  16. /**
  17. * 交付成果服务 —— 成果域的**唯一 owner**
  18. *
  19. * 依据《数据访问层设计.md》§五。约束:
  20. * · AC-08.3:成果数据统一走本服务,**禁止组件自行请求**
  21. * · 视图只消费,不自建定时器(FR-14 AC-14.2)
  22. */
  23. @Injectable({ providedIn: 'root' })
  24. export class DeliverablesService {
  25. private readonly api = inject(FmodeApi);
  26. /** 成果附件搬运到云盘(2026-11-07:下沉到 load 末尾,全端生效) */
  27. private readonly artifactSync = inject(ArtifactSyncService);
  28. /** 上云失败要弹全局提示(2026-11-10) */
  29. private readonly alerts = inject(AlertService);
  30. private readonly _items = signal<Deliverable[]>([]);
  31. private readonly _groups = signal<DeliverableGroup[]>([]);
  32. private readonly _state = signal<LoadState>('idle');
  33. private readonly _error = signal<string | null>(null);
  34. /** 视图切换:按项目 / 按时间线(AC-08.1) */
  35. readonly view = signal<'project' | 'timeline'>('project');
  36. /** 搜索关键字 */
  37. readonly keyword = signal('');
  38. readonly state = this._state.asReadonly();
  39. readonly error = this._error.asReadonly();
  40. /** 按项目分组的成果,同样遵循当前搜索关键字过滤。 */
  41. readonly groups = computed(() => {
  42. const q = this.keyword().trim().toLowerCase();
  43. const groups = this._groups();
  44. if (!q) return groups;
  45. return groups
  46. .map((group) => ({
  47. ...group,
  48. items: group.items.filter((d) => this.matches(d, q)),
  49. }))
  50. .map((group) => ({ ...group, count: group.items.length }))
  51. .filter((group) => group.items.length > 0);
  52. });
  53. readonly all = this._items.asReadonly();
  54. /** 搜索过滤后的条目(按时间线视图用) */
  55. readonly filtered = computed(() => {
  56. const q = this.keyword().trim().toLowerCase();
  57. const items = this._items();
  58. if (!q) return items;
  59. return items.filter((d) => this.matches(d, q));
  60. });
  61. readonly filteredEmpty = computed(
  62. () =>
  63. (this._state() === 'ready' || this._state() === 'empty') &&
  64. this.keyword().trim().length > 0 &&
  65. this.filtered().length === 0,
  66. );
  67. /** 按天/月分组(时间线视图;`dayKey` 由调用方传入格式化结果) */
  68. readonly monthGroups = computed(() => {
  69. const map = new Map<string, Deliverable[]>();
  70. for (const d of this.filtered()) {
  71. const key = d.deliveredAt.slice(0, 7); // YYYY-MM
  72. if (!map.has(key)) map.set(key, []);
  73. map.get(key)!.push(d);
  74. }
  75. return [...map.entries()]
  76. .sort((a, b) => b[0].localeCompare(a[0]))
  77. .map(([key, items]) => ({ key, items }));
  78. });
  79. /**
  80. * 是否「加载完成但确实没有成果」。
  81. *
  82. * 🔴 必须同时认 `empty` 与 `ready` 两种状态:
  83. * `load()` 在列表为空时设的是 `state='empty'`,
  84. * 只判断 `'ready'` 会让这个信号恒为 false,UI 显示不出「暂无成果」。
  85. * 也不能把 `error` 算进来 —— 失败必须显示错误态,不能伪装成空(AC-08.4)。
  86. */
  87. readonly empty = computed(
  88. () =>
  89. (this._state() === 'ready' || this._state() === 'empty') && this._items().length === 0,
  90. );
  91. async ensureLoaded(): Promise<void> {
  92. // empty 同样是「已加载过」,否则空列表时会反复请求
  93. if (this._state() === 'ready' || this._state() === 'empty' || this._state() === 'loading') return;
  94. await this.load();
  95. }
  96. /**
  97. * 强制重新拉取(手动「刷新」按钮用)。
  98. *
  99. * 与 `ensureLoaded` 的区别:**跳过缓存直接加载** —— 用户点刷新就是要拿最新数据。
  100. * 事件页的刷新依赖本方法(事件派生自成果)。
  101. */
  102. async refresh(): Promise<void> {
  103. await this.load();
  104. }
  105. async load(): Promise<void> {
  106. this._state.set('loading');
  107. this._error.set(null);
  108. try {
  109. const [items, groups] = await Promise.all([
  110. this.api.fetchDeliverables({ limit: 200 }),
  111. this.api.fetchDeliverablesByProject(200),
  112. ]);
  113. this._items.set(items);
  114. this._groups.set(groups);
  115. this._state.set(items.length === 0 ? 'empty' : 'ready');
  116. /*
  117. * 🔴 成果上云盘的触发点(2026-11-07)。
  118. *
  119. * 原来只在桌面单聊页的 effect 里调 `syncPending` ——
  120. * 用户只在移动端、或在群聊/成果页时,容器产出的文件**永远不会入队**,
  121. * 表现就是「有文件名但点不开、没有云盘地址」。
  122. * 下沉到加载完成的末尾:所有调用方(移动端、群聊、成果页)自动生效。
  123. * `syncPending` 内部幂等,桌面那处 effect 可以保留。
  124. */
  125. void this.artifactSync.syncPending(items).then((queued) => {
  126. /*
  127. * 上云失败要让人知道(2026-11-10)。
  128. *
  129. * 原先这里 `void` 掉结果:失败项进 `errors` signal 但没人读,
  130. * 用户只看到成果表里一条「点不开的文件名」,完全不知道发生了什么。
  131. * 现在有失败就弹全局提示(需手动关闭),并附上第一个失败原因。
  132. */
  133. const failed = [...this.artifactSync.errors().values()];
  134. if (failed.length > 0) {
  135. this.alerts.show(`${failed.length} 个成果未能上云`, failed[0]);
  136. } else if (queued > 0 && !environment.production) {
  137. // 成功入队不打扰用户,只留痕便于排查(**仅开发态**,线上不打印)
  138. console.info(`[成果上云] 已入队 ${queued} 个附件`);
  139. }
  140. });
  141. } catch (e) {
  142. // AC-08.4:接口失败必须显示错误态,禁止伪装成「暂无成果」
  143. this._state.set('error');
  144. this._error.set(e instanceof Error ? e.message : '成果加载失败');
  145. }
  146. }
  147. switchView(v: 'project' | 'timeline'): void {
  148. this.view.set(v);
  149. }
  150. /** 按 agent 取成果(详情页交付记录用) */
  151. byAgent(agentId: string): Deliverable[] {
  152. return this._items().filter((d) => d.agentId === agentId);
  153. }
  154. /**
  155. * 按**会话**取成果(2026-11-09 用户裁决)。
  156. *
  157. * 用户原话:「所有成果都是和它们的 Session 绑定的,不是这个容器里所有成果」。
  158. *
  159. * ## 数据来源
  160. *
  161. * `deliverableUrls` 里的链接分两类,**只有第一类算成果**:
  162. * ① 容器产出的成果 → 能在 `deliverables` 表里匹配到,有标题/版本等富信息
  163. * ② 历史数据里混入的用户上传链接(`/user/<uid>/chat/…`)→ **一律丢弃**,
  164. * 它们由消息流(`sender === 'me'`)在「文件」页签渲染
  165. *
  166. * 🔴 **宁缺勿错**:没有 `urls` 就返回空,绝不回退到「整个容器的成果」。
  167. *
  168. * @param urls 会话记录上的 `deliverableUrls`
  169. */
  170. /**
  171. * 按会话从**数据库**拉文件(2026-11-10,右侧栏主路径)。
  172. *
  173. * 用户裁决:每次直接按会话从数据库加载,不再依赖
  174. * 「先加载聊天历史」或 `FmodeChatThread.deliverableUrls`。
  175. *
  176. * 返回的 `SessionFile` 直接映射成 `Deliverable` 视图,复用现有渲染层
  177. * (右栏组件本来就吃 `Deliverable[]`,不用改模板)。
  178. *
  179. * 历史数据没有 `threadRoot`,查不到属预期 —— 用户明确「只保证新数据」。
  180. */
  181. async loadForSession(threadRoot: string): Promise<Deliverable[]> {
  182. if (!threadRoot) return [];
  183. const files = await this.api.fetchFilesBySession(threadRoot);
  184. return files.map(toDeliverable);
  185. }
  186. /**
  187. * 按会话拉台账并**按来源分流**(2026-11-11 用户裁决「严格互斥」)。
  188. *
  189. * ## 为什么必须分流
  190. *
  191. * 用户上传的文件**也会登记进台账**(`uploadedBy:'user'`,见
  192. * `chat.service.ts` 的 `registerFile` 调用)。而台账这一路
  193. * (`loadForSession` → 右栏「成果」的 `fromDb`)此前不区分来源,
  194. * 于是「我发的 test-20MB.bin」同时出现在「文件」和「成果」两个页签。
  195. *
  196. * 只补 `fromAgentFiles` 的 URL 过滤修不掉这条 —— 台账那条路压根不看 URL。
  197. *
  198. * ## 判据:`uploadedBy` 为主,URL 形态兜底
  199. *
  200. * `uploadedBy` 是**服务端写入时的权威标记**,云盘前缀变了也不受影响;
  201. * URL 形态(`/user/<id>/chat/`)只用于兜住 `uploadedBy` 缺失的老记录。
  202. */
  203. async loadSplitForSession(
  204. threadRoot: string,
  205. ): Promise<{ userFiles: Deliverable[]; agentFiles: Deliverable[] }> {
  206. if (!threadRoot) return { userFiles: [], agentFiles: [] };
  207. const files = await this.api.fetchFilesBySession(threadRoot);
  208. /*
  209. * 防御:只认本会话的记录。
  210. * 服务端 `listBySession` 已按 `user` + `threadRoot` 双条件查
  211. * (`fmode-file-hub.js` 的 `opListBySession`),这里是 App 侧兜底 ——
  212. * 万一回包混入其它会话的数据,也不能让它串进当前右栏。
  213. */
  214. const mine = files.filter((f) => !f.threadRoot || f.threadRoot === threadRoot);
  215. const { userFiles, agentFiles } = partitionSessionFiles(mine);
  216. return { userFiles: userFiles.map(toDeliverable), agentFiles: agentFiles.map(toDeliverable) };
  217. }
  218. /**
  219. * 按**群**拉台账并分流(2026-11-11,与单聊 `loadSplitForSession` 同口径)。
  220. *
  221. * 伪群聊下文件有两种来源,服务端 `listByGroup` 用
  222. * `groupId == g OR threadRoot == g` 一并覆盖,这里只管分流。
  223. */
  224. async loadSplitForGroup(
  225. groupId: string,
  226. ): Promise<{ userFiles: Deliverable[]; agentFiles: Deliverable[] }> {
  227. if (!groupId) return { userFiles: [], agentFiles: [] };
  228. const files = await this.api.fetchFilesByGroup(groupId);
  229. /* 防御:只认本群的记录(服务端已按 user + 群过滤,这是 App 侧兜底) */
  230. const mine = files.filter((f) => !f.groupId || f.groupId === groupId);
  231. const { userFiles, agentFiles } = partitionSessionFiles(mine);
  232. return { userFiles: userFiles.map(toDeliverable), agentFiles: agentFiles.map(toDeliverable) };
  233. }
  234. /**
  235. * 把「有把握属于本群」的容器产出补归并到群(2026-11-11)。
  236. *
  237. * 只在**确定**时才调用:`ids` 由调用方按「本群成员的 DM thread +
  238. * 时间落在派发窗口内」筛过。服务端还会再校验一次(本人 + 容器产出 +
  239. * 当前未归群),失败静默跳过 —— 宁可漏,不归错。
  240. *
  241. * 🔴 服务端会把 `threadRoot` 一并改写为群 id,所以搬进群之后
  242. * 单聊就查不到这条了 —— 这是「一条文件只属于一个页签」的保证。
  243. */
  244. async claimToGroup(groupId: string, ids: string[]): Promise<number> {
  245. if (!groupId || ids.length === 0) return 0;
  246. try {
  247. const r = await this.api.claimFilesToGroup(groupId, ids);
  248. return r.claimed;
  249. } catch {
  250. /* 补归并失败不影响展示:这些文件仍按原归属显示 */
  251. return 0;
  252. }
  253. }
  254. bySessionUrls(urls: readonly string[] | undefined): Deliverable[] {
  255. if (!urls || urls.length === 0) return [];
  256. const out: Deliverable[] = [];
  257. for (const url of urls) {
  258. /*
  259. * 🔴 用户上传的文件不得计入成果(2026-11-10 修复)。
  260. *
  261. * 线上存量数据里混着用户自己上传的链接(历史版本无差别写入),
  262. * 不在这里挡一道,右栏「成果」会继续显示用户自己发的图。
  263. * 主修在写入端(`chat.service.ts` 只认 sender='agent'),此处是兜底。
  264. */
  265. if (isUserUploadUrl(url)) continue;
  266. // ① 先在成果表里找完整记录
  267. const hit = this._items().find((d) => d.artifacts.some((a) => a.url === url));
  268. if (hit) {
  269. // 只保留本会话引用的那个附件,避免把同一成果的其他附件也带出来
  270. const only = hit.artifacts.filter((a) => a.url === url);
  271. out.push({ ...hit, artifacts: only.length ? only : hit.artifacts });
  272. continue;
  273. }
  274. // ② 成果表里没有 → 按 URL 造一条最小记录
  275. const name = fileNameOfUrl(url);
  276. out.push({
  277. id: `url:${url}`,
  278. agentId: '',
  279. title: name,
  280. /* filename/size 也让右栏能显示「有附件」而不是「暂无附件」 */
  281. filename: name,
  282. artifacts: [{ name, url, type: '' }],
  283. tags: [],
  284. deliveredAt: '',
  285. state: 'delivered',
  286. });
  287. }
  288. return out;
  289. }
  290. /**
  291. * 从**消息流**里取容器产出的成果(2026-11-09)。
  292. *
  293. * 为什么除了 `bySessionUrls` 还要这个:
  294. * 会话记录的 `deliverableUrls` 要等落库+回读才生效,中间有个时间窗;
  295. * 而且历史上部分链接没被写进去(实测:最新会话 `urls=[]`)。
  296. * 消息流是**实时的、一定有的**,用它兜底能让成果立刻可见。
  297. *
  298. * @param files 本会话里 `sender === 'agent'` 的附件
  299. */
  300. fromAgentFiles(files: readonly { name: string; url: string }[]): Deliverable[] {
  301. return files
  302. /*
  303. * 🔴 用户自己上传的文件不得计入成果(2026-11-11 修复)。
  304. *
  305. * 与 `bySessionUrls` 同口径:那一处早就挡了,这里漏了 ——
  306. * 结果我上传的文件在「文件」和「成果」两个页签各出现一次。
  307. *
  308. * 走这条路的场景:容器回复里贴了用户文件的 URL,
  309. * 或 Matrix 把用户附件以容器身份回显 —— 两种都会被判成 `sender='agent'`。
  310. */
  311. .filter((f) => !isUserUploadUrl(f.url))
  312. .map((f) => ({
  313. id: `msg:${f.url}`,
  314. agentId: '',
  315. title: f.name,
  316. filename: f.name,
  317. artifacts: [{ name: f.name, url: f.url, type: '' }],
  318. tags: [],
  319. deliveredAt: '',
  320. state: 'delivered' as const,
  321. }));
  322. }
  323. byId(id: string): Deliverable | undefined {
  324. return this._items().find((d) => d.id === id);
  325. }
  326. /**
  327. * 合并两组成果并去重(2026-11-10)。
  328. *
  329. * 去重键用**附件 URL** 而不是 `id`:
  330. * 同一条成果可能一边来自 `deliverableUrls` 匹配(有完整 id),
  331. * 一边来自 `fromAgentFiles`(`id = msg:<url>`),id 对不上但其实是同一个文件。
  332. * 优先保留前者(信息更全,有标题/版本)。
  333. */
  334. merge(...groups: readonly Deliverable[][]): Deliverable[] {
  335. const out: Deliverable[] = [];
  336. const seen = new Set<string>();
  337. for (const g of groups) {
  338. for (const d of g) {
  339. const key = d.artifacts.map((a) => a.url).join('|') || d.id;
  340. if (seen.has(key)) continue;
  341. seen.add(key);
  342. out.push(d);
  343. }
  344. }
  345. return out;
  346. }
  347. byProject(projectId: string): Deliverable[] {
  348. return this._items().filter((d) => d.projectId === projectId);
  349. }
  350. private matches(d: Deliverable, q: string): boolean {
  351. return [d.title, d.projectName, d.filename, d.subtitle, d.fileType]
  352. .filter((value): value is string => !!value)
  353. .some((value) => value.toLowerCase().includes(q));
  354. }
  355. }
  356. /**
  357. * 台账记录按来源分流(2026-11-11 用户裁决「文件 / 成果严格互斥」)。
  358. *
  359. * - `userFiles` → 右栏「文件」页签(我发给容器的)
  360. * - `agentFiles` → 右栏「成果」页签(容器产出的)
  361. *
  362. * 判据:`uploadedBy` 为**权威**(服务端写入时打的标,不随云盘前缀变化),
  363. * `isUserUploadUrl` 只兜 `uploadedBy` 缺失/异常的老记录。
  364. *
  365. * 纯函数、不依赖 Angular,便于单测覆盖分流边界。
  366. */
  367. export function partitionSessionFiles(files: readonly SessionFile[]): {
  368. userFiles: SessionFile[];
  369. agentFiles: SessionFile[];
  370. } {
  371. const userFiles: SessionFile[] = [];
  372. const agentFiles: SessionFile[] = [];
  373. for (const f of files) {
  374. const isUser = f.uploadedBy === 'user' || isUserUploadUrl(f.url);
  375. (isUser ? userFiles : agentFiles).push(f);
  376. }
  377. return { userFiles, agentFiles };
  378. }
  379. /**
  380. * 文件台账记录 → 右栏可渲染的 `Deliverable`。
  381. *
  382. * 右栏组件吃的是 `Deliverable[]`,这里做一次投影,
  383. * 模板层一行都不用改(用户要求「直接更换为新的一套逻辑」,不是重写 UI)。
  384. */
  385. function toDeliverable(f: SessionFile): Deliverable {
  386. const at = f.deliveredAt ? new Date(f.deliveredAt).toISOString() : '';
  387. return {
  388. id: f.id,
  389. agentId: f.agentId,
  390. title: f.name,
  391. filename: f.name,
  392. /* 原始字节数,供右栏显示与两路去重(2026-11-11);未知时缺省,不写 0 */
  393. ...(f.size > 0 ? { sizeBytes: f.size } : {}),
  394. summary: f.uploadedBy === 'user' ? '用户上传' : '容器产出',
  395. artifacts: [
  396. {
  397. name: f.name,
  398. url: f.url,
  399. type: f.mimeType,
  400. ...(f.storageKey ? { source: f.storageKey } : {}),
  401. },
  402. ],
  403. tags: [f.uploadedBy],
  404. deliveredAt: at,
  405. state: 'delivered',
  406. };
  407. }
  408. /**
  409. * 从云盘 URL 里取文件名(用于「只有 URL、成果表里没有」的条目)。
  410. *
  411. * 例:`https://s3.fmode.cn/user/x/chat/202611/x-1793-e2e.txt` → `x-1793-e2e.txt`
  412. * 取不到时回落到「未命名文件」,不抛错。
  413. */
  414. function fileNameOfUrl(url: string): string {
  415. try {
  416. const path = url.split('?')[0];
  417. const seg = path.split('/').filter(Boolean).pop() ?? '';
  418. return decodeURIComponent(seg) || '未命名文件';
  419. } catch {
  420. return '未命名文件';
  421. }
  422. }