topic-pool.service.ts 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  1. import { Injectable } from '@angular/core';
  2. import { AuthCreditService } from './auth-credit.service';
  3. import { DouyinInsightService } from './douyin-insight.service';
  4. import { TopicIdea } from '../models/douyin-insight.model';
  5. const TOPIC_POOL_STORAGE_KEY = 'videoWorkflow.topicPool.items';
  6. @Injectable({ providedIn: 'root' })
  7. export class TopicPoolService {
  8. constructor(
  9. private auth: AuthCreditService,
  10. private insight: DouyinInsightService,
  11. ) {}
  12. listTopics(): TopicIdea[] {
  13. const userId = this.requireUserId();
  14. return this.readAll()
  15. .filter((item) => item.userId === userId)
  16. .sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
  17. }
  18. async refreshFromCloud(): Promise<TopicIdea[]> {
  19. const userId = this.requireUserId();
  20. const cloudItems = await this.insight.listTopics();
  21. if (!cloudItems.length) return this.listTopics();
  22. const allLocal = this.readAll();
  23. const others = allLocal.filter((item) => item.userId !== userId);
  24. const currentLocal = allLocal.filter((item) => item.userId === userId);
  25. const merged = new Map(currentLocal.map((item) => [item.id, item]));
  26. for (const cloudItem of cloudItems) {
  27. const localItem = merged.get(cloudItem.id);
  28. const keepLocal = localItem && Date.parse(localItem.updatedAt) > Date.parse(cloudItem.updatedAt);
  29. merged.set(cloudItem.id, keepLocal ? localItem : cloudItem);
  30. }
  31. const current = Array.from(merged.values());
  32. const next = [...others, ...current];
  33. this.writeAll(next);
  34. return current.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
  35. }
  36. saveTopic(input: Omit<TopicIdea, 'id' | 'userId' | 'createdAt' | 'updatedAt'>): TopicIdea {
  37. const userId = this.requireUserId();
  38. const now = new Date().toISOString();
  39. const topic: TopicIdea = {
  40. ...input,
  41. id: this.createId(),
  42. userId,
  43. createdAt: now,
  44. updatedAt: now,
  45. };
  46. const items = this.readAll();
  47. items.unshift(topic);
  48. this.writeAll(items);
  49. this.mirrorCreate(topic);
  50. return topic;
  51. }
  52. updateTopic(id: string, patch: Partial<TopicIdea>): TopicIdea {
  53. const userId = this.requireUserId();
  54. const items = this.readAll();
  55. const index = items.findIndex((item) => item.id === id && item.userId === userId);
  56. if (index < 0) throw new Error('未找到该选题');
  57. const updated: TopicIdea = {
  58. ...items[index],
  59. ...patch,
  60. id: items[index].id,
  61. userId,
  62. updatedAt: new Date().toISOString(),
  63. };
  64. items[index] = updated;
  65. this.writeAll(items);
  66. this.mirrorUpdate(updated, patch);
  67. return updated;
  68. }
  69. archiveTopic(id: string): TopicIdea {
  70. return this.updateTopic(id, { status: 'archived' });
  71. }
  72. private mirrorCreate(topic: TopicIdea): void {
  73. if (!this.auth.isLoggedIn) return;
  74. this.insight.createTopic(topic).catch((err) => {
  75. if (topic.sourceType === 'ip_operator') {
  76. this.insight.createTopic(this.cloudCompatibleTopic(topic)).catch((fallbackErr) => {
  77. console.warn('[TopicPoolService] IP operator topic cloud fallback create failed; local data kept:', fallbackErr?.message || fallbackErr);
  78. });
  79. return;
  80. }
  81. console.warn('[TopicPoolService] 云端保存选题失败,已保留本地数据:', err?.message || err);
  82. });
  83. }
  84. private mirrorUpdate(topic: TopicIdea, patch: Partial<TopicIdea>): void {
  85. if (!this.auth.isLoggedIn) return;
  86. this.insight.updateTopic(topic.id, patch).catch((err) => {
  87. if (topic.sourceType === 'ip_operator') {
  88. this.insight.updateTopic(topic.id, this.cloudCompatiblePatch(patch)).catch((fallbackErr) => {
  89. if (this.isMissingCloudObject(fallbackErr)) {
  90. this.insight.createTopic(this.cloudCompatibleTopic(topic)).catch((createErr) => {
  91. console.warn('[TopicPoolService] IP operator topic cloud fallback create after update miss failed; local data kept:', createErr?.message || createErr);
  92. });
  93. return;
  94. }
  95. console.warn('[TopicPoolService] IP operator topic cloud fallback update failed; local data kept:', fallbackErr?.message || fallbackErr);
  96. });
  97. return;
  98. }
  99. if (this.isMissingCloudObject(err)) {
  100. this.insight.createTopic(topic).catch((createErr) => {
  101. console.warn('[TopicPoolService] 云端补建选题失败,已保留本地数据:', createErr?.message || createErr);
  102. });
  103. return;
  104. }
  105. console.warn('[TopicPoolService] 云端更新选题失败,已保留本地数据:', err?.message || err);
  106. });
  107. }
  108. private cloudCompatibleTopic(topic: TopicIdea): TopicIdea {
  109. if (topic.sourceType !== 'ip_operator') return topic;
  110. return {
  111. ...topic,
  112. sourceType: 'manual',
  113. tags: Array.from(new Set(['IP操盘', ...(topic.tags || [])])),
  114. sourceTitle: topic.sourceTitle || 'IP操盘方案',
  115. sourceSummary: topic.sourceSummary || '由 IP操盘工作台同步,云端兼容保存为手动来源。',
  116. };
  117. }
  118. private cloudCompatiblePatch(patch: Partial<TopicIdea>): Partial<TopicIdea> {
  119. const next: Partial<TopicIdea> = { ...patch };
  120. if (next.sourceType === 'ip_operator') {
  121. next.sourceType = 'manual';
  122. next.tags = Array.from(new Set(['IP操盘', ...(next.tags || [])]));
  123. }
  124. return next;
  125. }
  126. private isMissingCloudObject(error: any): boolean {
  127. const message = [
  128. error?.message,
  129. error?.response?.error,
  130. error?.response?.raw?.message,
  131. error?.detail?.error,
  132. error?.detail?.raw?.message,
  133. ].filter(Boolean).join(' ');
  134. return /object not found|未找到记录|not found/i.test(message);
  135. }
  136. private requireUserId(): string {
  137. return this.auth.currentUser?.objectId || 'local-guest';
  138. }
  139. private readAll(): TopicIdea[] {
  140. try {
  141. const raw = localStorage.getItem(TOPIC_POOL_STORAGE_KEY);
  142. return raw ? JSON.parse(raw) as TopicIdea[] : [];
  143. } catch {
  144. return [];
  145. }
  146. }
  147. private writeAll(items: TopicIdea[]): void {
  148. localStorage.setItem(TOPIC_POOL_STORAGE_KEY, JSON.stringify(items));
  149. }
  150. private createId(): string {
  151. return `topic_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
  152. }
  153. }