Просмотр исходного кода

feat: 完善运营辅助服务与测试覆盖

Yi Jiarui 2 месяцев назад
Родитель
Сommit
b415b2594a

+ 3 - 0
src/app/components/recent-task-pill/recent-task-pill.component.ts

@@ -36,6 +36,9 @@ export class RecentTaskPillComponent {
   stepLabel(step: string): string {
     const map: Record<string, string> = {
       'search': '搜索',
+      'prepare': '准备',
+      'submit': '生成',
+      'poll': '生成中',
       'detail': '详情',
       'comments': '评论',
       'download': '下载',

+ 113 - 0
src/app/services/assistant.service.spec.ts

@@ -0,0 +1,113 @@
+import { ApplicationRef, NgZone } from '@angular/core';
+import { AssistantService, Conversation } from './assistant.service';
+import { StorageGovernanceService } from './storage-governance.service';
+
+const STORAGE_KEY = 'tiktok.assistant.conversations.v2';
+const LEGACY_KEY = 'tiktok.assistant.history';
+
+describe('AssistantService', () => {
+  beforeEach(() => {
+    localStorage.clear();
+    sessionStorage.clear();
+    vi.useFakeTimers();
+  });
+
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
+  it('keeps legacy conversations read-only and does not write conversation bodies to localStorage', async () => {
+    const legacy = conversation('legacy-1', '旧会话', 1000);
+    localStorage.setItem(STORAGE_KEY, JSON.stringify([legacy]));
+    const cloud = createCloud();
+    const service = createService(cloud);
+    await Promise.resolve();
+
+    expect(service.conversations$.value[0]?.id).toBe('legacy-1');
+    const id = service.newConversation();
+    vi.advanceTimersByTime(801);
+    await Promise.resolve();
+
+    expect(id).toBeTruthy();
+    expect(JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]')).toEqual([legacy]);
+    expect(localStorage.getItem('tiktok.assistant.activeId.cache.v1')).toContain('__storageGovernance');
+    expect(cloud.upsert).toHaveBeenCalledWith('assistantThread', id, expect.objectContaining({
+      id,
+      messages: [],
+    }));
+  });
+
+  it('upgrades flat legacy history without removing the original key', async () => {
+    const legacyMessages = [
+      { id: 'm-1', role: 'user', content: '帮我看一下账号定位', ts: 1000 },
+      { id: 'm-2', role: 'assistant', content: '先看证据。', ts: 1001 },
+    ];
+    localStorage.setItem(LEGACY_KEY, JSON.stringify(legacyMessages));
+    const cloud = createCloud();
+    const service = createService(cloud);
+    await Promise.resolve();
+
+    expect(service.conversations$.value).toHaveLength(1);
+    expect(service.conversations$.value[0].messages).toHaveLength(2);
+    expect(localStorage.getItem(LEGACY_KEY)).toBe(JSON.stringify(legacyMessages));
+  });
+
+  it('refreshes assistant threads from Parse cloud entities and keeps newer local threads', async () => {
+    const localNewer = conversation('same', '本地较新', 3000);
+    const cloudOlder = conversation('same', '云端较旧', 2000);
+    const cloudOnly = conversation('cloud-only', '云端会话', 4000);
+    const cloud = createCloud([{ data: cloudOlder }, { data: cloudOnly }]);
+    const service = createService(cloud);
+    service.conversations$.next([localNewer]);
+
+    const refreshed = await service.refreshConversationsFromCloud();
+
+    expect(cloud.list).toHaveBeenCalledWith('assistantThread', 'active', 40);
+    expect(refreshed.map((item) => item.id).sort()).toEqual(['cloud-only', 'same']);
+    expect(refreshed.find((item) => item.id === 'same')?.title).toBe('本地较新');
+  });
+
+  it('deletes assistant threads from cloud storage', async () => {
+    const cloud = createCloud();
+    const service = createService(cloud);
+    service.conversations$.next([conversation('c-1', '待删除', 1000)]);
+    service.activeId$.next('c-1');
+
+    service.deleteConversation('c-1');
+    await Promise.resolve();
+
+    expect(service.conversations$.value).toEqual([]);
+    expect(cloud.delete).toHaveBeenCalledWith('assistantThread', 'c-1');
+    expect(localStorage.getItem(STORAGE_KEY)).toBeNull();
+  });
+});
+
+function createService(cloud = createCloud()): AssistantService {
+  return new AssistantService(
+    {} as any,
+    { run: (fn: () => void) => fn() } as NgZone,
+    { tick: vi.fn() } as unknown as ApplicationRef,
+    cloud as any,
+    new StorageGovernanceService(),
+  );
+}
+
+function createCloud(rows: Array<{ data: Conversation }> = []) {
+  return {
+    list: vi.fn().mockResolvedValue(rows),
+    upsert: vi.fn().mockResolvedValue({}),
+    delete: vi.fn().mockResolvedValue(undefined),
+  };
+}
+
+function conversation(id: string, title: string, updated: number): Conversation {
+  return {
+    id,
+    title,
+    messages: [
+      { id: `m-${id}`, role: 'user', content: title, ts: updated },
+    ],
+    created: updated,
+    updated,
+  };
+}

+ 181 - 31
src/app/services/assistant.service.ts

@@ -2,11 +2,28 @@ import { ApplicationRef, Injectable, NgZone } from '@angular/core';
 import { BehaviorSubject, combineLatest, Observable, Subscription } from 'rxjs';
 import { map } from 'rxjs/operators';
 import { LlmService, ChatMessage } from './llm.service';
+import { CloudSessionStorageService } from './cloud-session-storage.service';
+import { StorageGovernanceService } from './storage-governance.service';
 
 export interface AssistantSuggestion {
   tab: string;
   label: string;
   payload?: Record<string, any>;
+  risk?: 'low' | 'medium' | 'blocked';
+  confirmMessage?: string;
+}
+
+export interface IpOperatorAssistantContext {
+  page?: string;
+  pageContext?: string;
+  account?: unknown;
+  snapshot?: unknown;
+  positioning?: unknown;
+  directions?: unknown[];
+  tasks?: unknown[];
+  selectedTopic?: unknown;
+  selectedScript?: unknown;
+  selectedPublishPackage?: unknown;
 }
 
 export interface AssistantMessage {
@@ -29,28 +46,44 @@ export interface Conversation {
 const STORAGE_KEY = 'tiktok.assistant.conversations.v2';
 const LEGACY_KEY = 'tiktok.assistant.history';
 const ACTIVE_KEY = 'tiktok.assistant.activeId';
+const ACTIVE_CACHE_KEY = 'tiktok.assistant.activeId.cache.v1';
+const ASSISTANT_THREAD_ENTITY_TYPE = 'assistantThread';
 const MAX_CONVS = 40;
 const MAX_MSGS_PER_CONV = 80;
 
-const SYSTEM_PROMPT = `你是「Tik-Tok 视频生成系统」的内置创作助手,专注于短视频内容创作
+const SYSTEM_PROMPT = `你是「证据驱动 IP 操盘工作台」的内置运营副驾,重点帮助用户做账号诊断、定位迭代、爆款适配、内容方向、选题、分镜脚本、发布准备和复盘
 
 ## 你的能力范围
-1. 帮用户写视频脚本、镜头描述、中文提示词
-2. 给出风格、节奏、分镜、配乐方面的建议
-3. 解答系统使用问题(系统包含以下生成模式:主题生视频 / 文生视频 / 图生视频 / 数字人合成 / 动作迁移 / 素材合成视频)
-4. 当用户的需求显然适合直接进入某条生成流水线时,在回答**最末尾**用一个独立的 JSON 代码块给出建议:
+1. 基于当前账号、作品、评论、定位版本和任务,解释账号为什么不涨粉、播放不稳定或内容跑偏。
+2. 判断对标爆款能否迁移到当前账号,明确可借鉴点、不可照搬点和改造方式。
+3. 生成或打磨内容方向、选题、详细分镜脚本、内容日历草稿、发布包和复盘建议。
+4. 当用户需要执行低风险运营动作时,在回答**最末尾**用独立 JSON 代码块给出建议:
 
 \`\`\`json
-{ "suggest": [{ "tab": "topic-to-video", "label": "进入主题生视频", "payload": { "topic": "盛夏海边少女回眸微笑" } }] }
+{ "suggest": [{ "tab": "ip-operator", "label": "打开账号运营工作台", "risk": "low", "payload": { "action": "open_workbench" } }] }
 \`\`\`
 
 5. 当你已经帮用户整理出明确选题时,可以建议保存到选题池:
 
 \`\`\`json
-{ "suggest": [{ "tab": "topic-pool", "label": "保存到选题池", "payload": { "saveTopic": true, "title": "盛夏海边少女回眸微笑", "angle": "用电影感回眸制造氛围和情绪记忆点", "tags": ["AI助手", "氛围感"] } }] }
+{ "suggest": [{ "tab": "topic-pool", "label": "保存到选题池", "risk": "low", "payload": { "saveTopic": true, "title": "老板号为什么越发越像广告", "angle": "结合当前账号定位解释信任问题", "tags": ["AI助手", "IP操盘"] } }] }
+\`\`\`
+
+6. 当用户要求你“直接安排下一步”时,可以建议低风险 IP 操盘动作:
+
+\`\`\`json
+{ "suggest": [{ "tab": "ip-operator", "label": "创建今日运营任务", "risk": "low", "payload": { "action": "create_operation_task", "title": "复核高互动作品评论痛点", "reason": "评论问题能反推互动型选题", "type": "pain", "column": "today", "growthImpactScore": 82 } }] }
 \`\`\`
 
-合法的 tab 取值:home / topic-to-video / image-to-video / digital-human / action-transfer / asset-remix / video-generation / search / monitor / topic-pool / analysis-history / tasks / history / results / videos / voice-synthesis。
+常用低风险 payload.action:open_workbench / create_operation_task / draft_calendar / generate_script / organize_comment_pain。
+
+合法的 tab 取值:home / ip-operator / topic-pool / analysis-history / tasks / history / results / videos / topic-to-video / digital-human。
+
+## 动作权限
+- 低风险,可直接执行:生成选题、生成脚本、创建任务、加入日历草稿、整理评论痛点、生成方向建议、打开相关页面。建议项 risk 使用 low。
+- 中风险,必须二次确认:确认定位版本、覆盖已有脚本、批量调整日历、批准发布包、把选题标为本周主推。建议项 risk 使用 medium,并填写 confirmMessage。
+- 高风险,当前版本阻断:自动发布、自动评论、自动私信、Cookie 池、代理池、批量互动。不得提供执行入口;如需提示,建议项 risk 使用 blocked。
+- 不得把推断写成事实;证据不足时明确标注样本不足。
 
 ## 输出格式要求(重要)
 - **使用 Markdown 排版**:合理使用 **加粗** / *斜体* / 二级标题 \`##\` / 列表 / 表格 / 代码块 提升可读性
@@ -93,6 +126,8 @@ export class AssistantService {
    * true 时浮窗 FAB / panel 全部隐藏,避免与主区对话功能重复。
    */
   readonly inlineActive$ = new BehaviorSubject<boolean>(false);
+  private ipOperatorContext: IpOperatorAssistantContext = {};
+  private mirrorTimer: ReturnType<typeof setTimeout> | null = null;
 
   /** 当前会话的消息流(派生 Observable) */
   readonly activeMessages$: Observable<AssistantMessage[]> = combineLatest([
@@ -110,6 +145,8 @@ export class AssistantService {
     private llm: LlmService,
     private zone: NgZone,
     private appRef: ApplicationRef,   // 用于 LLM 响应后强制 CD(绕开 fetch + zone 的兼容性坑)
+    private cloudStorage: CloudSessionStorageService,
+    private storageGovernance: StorageGovernanceService,
   ) {
     this.restore();
   }
@@ -158,6 +195,10 @@ export class AssistantService {
     if (text.trim()) this.send(text.trim());
   }
 
+  setIpOperatorContext(context: IpOperatorAssistantContext): void {
+    this.ipOperatorContext = { ...context };
+  }
+
   /** 新建空会话并切到它 */
   newConversation(): string {
     const id = this.genId('c');
@@ -194,6 +235,7 @@ export class AssistantService {
       this.activeId$.next(fallback);
     }
     this.persist();
+    this.mirrorDelete(id);
   }
 
   /** 重命名会话(暂未在 UI 暴露,预留) */
@@ -247,7 +289,10 @@ export class AssistantService {
       .map((m) => ({ role: m.role, content: m.content }));
 
     const messages: ChatMessage[] = [
-      { role: 'system', content: SYSTEM_PROMPT },
+      {
+        role: 'system',
+        content: `${SYSTEM_PROMPT}\n\n## 当前页面与运营上下文\n${JSON.stringify(this.ipOperatorContext, null, 2)}`,
+      },
       ...history,
     ];
 
@@ -360,6 +405,10 @@ export class AssistantService {
       const arr: AssistantSuggestion[] = Array.isArray(parsed.suggest) ? parsed.suggest : [];
       const cleaned = arr
         .filter((s) => s && typeof s.tab === 'string' && typeof s.label === 'string')
+        .map((s) => ({
+          ...s,
+          risk: ['low', 'medium', 'blocked'].includes(String(s.risk)) ? s.risk : 'low',
+        }))
         .slice(0, 3);
       const stripped = raw.slice(0, lastMatch.index).trim();
       return { text: stripped, suggestions: cleaned.length ? cleaned : undefined };
@@ -370,61 +419,162 @@ export class AssistantService {
 
   // ============== 持久化 ==============
 
+  async refreshConversationsFromCloud(): Promise<Conversation[]> {
+    try {
+      const entities = await this.cloudStorage.list<Conversation>(ASSISTANT_THREAD_ENTITY_TYPE, 'active', MAX_CONVS);
+      const cloudConversations = entities
+        .map((entity) => this.normalizeConversation(entity.data))
+        .filter((item): item is Conversation => !!item);
+      if (!cloudConversations.length) return this.conversations$.value;
+
+      const merged = new Map(this.conversations$.value.map((item) => [item.id, item]));
+      for (const item of cloudConversations) {
+        const local = merged.get(item.id);
+        const keepLocal = local && Number(local.updated || 0) > Number(item.updated || 0);
+        merged.set(item.id, keepLocal ? local : item);
+      }
+      const next = this.sortConversations(Array.from(merged.values())).slice(0, MAX_CONVS);
+      this.conversations$.next(next);
+      const activeId = this.activeId$.value;
+      if (!activeId || !next.some((item) => item.id === activeId)) {
+        this.activeId$.next(next[0]?.id || null);
+      }
+      this.persistActive();
+      return next;
+    } catch (err: any) {
+      console.warn('[assistant] 云端会话刷新失败,继续使用当前内存/旧只读数据:', err?.message || err);
+      return this.conversations$.value;
+    }
+  }
+
   private restore(): void {
     try {
-      // 1. 先尝试新格式
+      const governedActive = this.storageGovernance.readJson<string>(ACTIVE_CACHE_KEY);
+      if (governedActive) this.activeId$.next(governedActive);
+
       const raw = localStorage.getItem(STORAGE_KEY);
       if (raw) {
         const arr = JSON.parse(raw);
         if (Array.isArray(arr)) {
-          this.conversations$.next(arr.slice(0, MAX_CONVS));
-          const savedActive = localStorage.getItem(ACTIVE_KEY);
-          if (savedActive && arr.find((c: Conversation) => c.id === savedActive)) {
+          const conversations = this.sortConversations(
+            arr.map((item) => this.normalizeConversation(item)).filter(Boolean) as Conversation[],
+          ).slice(0, MAX_CONVS);
+          this.conversations$.next(conversations);
+          const savedActive = governedActive || localStorage.getItem(ACTIVE_KEY);
+          if (savedActive && conversations.find((c) => c.id === savedActive)) {
             this.activeId$.next(savedActive);
-          } else if (arr.length > 0) {
-            this.activeId$.next(arr[0].id);
+          } else if (conversations.length > 0) {
+            this.activeId$.next(conversations[0].id);
           }
+          this.refreshConversationsFromCloud();
           return;
         }
       }
-      // 2. 兼容 v1 的扁平消息数组:升级为单一会话
+
       const legacy = localStorage.getItem(LEGACY_KEY);
       if (legacy) {
         const arr = JSON.parse(legacy);
         if (Array.isArray(arr) && arr.length > 0) {
-          const conv: Conversation = {
+          const conv = this.normalizeConversation({
             id: this.genId('c'),
             title: this.summarizeTitle(arr.find((m: any) => m?.role === 'user')?.content || '历史对话'),
             messages: arr,
             created: arr[0]?.ts || Date.now(),
             updated: arr[arr.length - 1]?.ts || Date.now(),
-          };
-          this.conversations$.next([conv]);
-          this.activeId$.next(conv.id);
-          this.persist();
-          localStorage.removeItem(LEGACY_KEY);
+          });
+          if (conv) {
+            this.conversations$.next([conv]);
+            this.activeId$.next(conv.id);
+            this.persist();
+          }
         }
       }
-    } catch {}
+      this.refreshConversationsFromCloud();
+    } catch {
+      this.refreshConversationsFromCloud();
+    }
   }
 
   private persist(): void {
     try {
-      // 过滤 pending 后再存
-      const arr = this.conversations$.value.map((c) => ({
-        ...c,
-        messages: c.messages.filter((m) => !m.pending),
-      }));
-      localStorage.setItem(STORAGE_KEY, JSON.stringify(arr));
+      const arr = this.conversations$.value.map((c) => this.sanitizedConversation(c));
+      this.conversations$.next(this.sortConversations(arr).slice(0, MAX_CONVS));
       this.persistActive();
+      this.scheduleMirror();
     } catch {}
   }
 
   private persistActive(): void {
     try {
       const id = this.activeId$.value;
-      if (id) localStorage.setItem(ACTIVE_KEY, id);
-      else localStorage.removeItem(ACTIVE_KEY);
+      if (id) this.storageGovernance.writeJson(ACTIVE_CACHE_KEY, id, 'uiPreference');
+      else this.storageGovernance.remove(ACTIVE_CACHE_KEY);
     } catch {}
   }
+
+  private scheduleMirror(): void {
+    if (this.mirrorTimer) clearTimeout(this.mirrorTimer);
+    this.mirrorTimer = setTimeout(() => {
+      this.mirrorTimer = null;
+      this.mirrorConversations();
+    }, 800);
+  }
+
+  private mirrorConversations(): void {
+    const conversations = this.conversations$.value.map((item) => this.sanitizedConversation(item));
+    for (const conversation of conversations) {
+      this.cloudStorage.upsert(ASSISTANT_THREAD_ENTITY_TYPE, conversation.id, conversation).catch((err) => {
+        console.warn('[assistant] 云端同步会话失败,当前会话仅保留在内存或临时存储:', err?.message || err);
+      });
+    }
+  }
+
+  private mirrorDelete(id: string): void {
+    this.cloudStorage.delete(ASSISTANT_THREAD_ENTITY_TYPE, id).catch((err) => {
+      console.warn('[assistant] 云端删除会话失败:', err?.message || err);
+    });
+  }
+
+  private normalizeConversation(value: unknown): Conversation | null {
+    const row = value as Partial<Conversation> | null;
+    if (!row || !row.id) return null;
+    const now = Date.now();
+    const messages = Array.isArray(row.messages)
+      ? row.messages
+        .map((message) => this.normalizeMessage(message))
+        .filter((message): message is AssistantMessage => !!message)
+        .slice(-MAX_MSGS_PER_CONV)
+      : [];
+    return {
+      id: String(row.id),
+      title: String(row.title || this.summarizeTitle(messages.find((item) => item.role === 'user')?.content || '历史对话')),
+      messages,
+      created: Number(row.created || messages[0]?.ts || now),
+      updated: Number(row.updated || messages[messages.length - 1]?.ts || now),
+    };
+  }
+
+  private normalizeMessage(value: unknown): AssistantMessage | null {
+    const row = value as Partial<AssistantMessage> | null;
+    if (!row || !row.id || !row.role) return null;
+    return {
+      id: String(row.id),
+      role: row.role === 'assistant' ? 'assistant' : 'user',
+      content: String(row.content || ''),
+      suggestions: Array.isArray(row.suggestions) ? row.suggestions : undefined,
+      ts: Number(row.ts || Date.now()),
+      pending: false,
+    };
+  }
+
+  private sanitizedConversation(conversation: Conversation): Conversation {
+    return {
+      ...conversation,
+      messages: conversation.messages.filter((message) => !message.pending).slice(-MAX_MSGS_PER_CONV),
+    };
+  }
+
+  private sortConversations(conversations: Conversation[]): Conversation[] {
+    return [...conversations].sort((a, b) => Number(b.updated || 0) - Number(a.updated || 0));
+  }
 }

+ 83 - 5
src/app/services/daily-report.service.spec.ts

@@ -1,16 +1,17 @@
 import { DailyReportService } from './daily-report.service';
+import { DailyReport } from '../models/douyin-insight.model';
+import { StorageGovernanceService } from './storage-governance.service';
+
+const LEGACY_STORAGE_KEY = 'videoWorkflow.dailyReports.items';
 
 describe('DailyReportService', () => {
   beforeEach(() => {
     localStorage.clear();
+    sessionStorage.clear();
   });
 
   it('prioritizes coverage across monitored authors before filling by heat', () => {
-    const service = new DailyReportService(
-      { currentUser: { objectId: 'user-1' }, requestLogin: () => undefined } as any,
-      {} as any,
-      { createDailyReport: async () => null } as any,
-    );
+    const service = createService();
 
     const report = service.generateMonitorReport([
       author('author-a', '高热博主', [
@@ -26,8 +27,70 @@ describe('DailyReportService', () => {
     expect(topVideos.slice(0, 3).map((item: any) => item.author).sort()).toEqual(['中等博主', '新博主', '高热博主']);
     expect(report.topTopics.slice(0, 3).flatMap((topic) => topic.sourceAuthorIds).sort()).toEqual(['author-a', 'author-b', 'author-c']);
   });
+
+  it('stores generated reports through Parse cloud entities and governed cache without rewriting legacy key', async () => {
+    const legacy = report('daily-legacy', '2026-06-01T00:00:00.000Z');
+    localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify([legacy]));
+    const cloudStorage = {
+      list: vi.fn().mockResolvedValue([]),
+      upsert: vi.fn().mockResolvedValue({}),
+    };
+    const service = createService({}, cloudStorage);
+
+    const saved = service.generateMonitorReport([
+      author('author-a', '高热博主', [work('a-1', 100000)]),
+    ]);
+    await Promise.resolve();
+
+    expect(cloudStorage.upsert).toHaveBeenCalledWith('dailyReport', saved.id, expect.objectContaining({
+      id: saved.id,
+      userId: 'user-1',
+      status: 'success',
+    }));
+    expect(JSON.parse(localStorage.getItem(LEGACY_STORAGE_KEY) || '[]')).toEqual([legacy]);
+    expect(localStorage.getItem('videoWorkflow.dailyReports.cache.v1')).toContain('__storageGovernance');
+  });
+
+  it('refreshes reports from Parse cloud entities and legacy insight cloud while keeping newer local report', async () => {
+    const localNewer = report('daily-same', '2026-06-05T00:00:00.000Z');
+    const systemOlder = report('daily-same', '2026-06-04T00:00:00.000Z');
+    const insightOnly = report('daily-insight', '2026-06-03T00:00:00.000Z');
+    localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify([localNewer]));
+    const cloudStorage = {
+      list: vi.fn().mockResolvedValue([{ data: systemOlder }]),
+      upsert: vi.fn().mockResolvedValue({}),
+    };
+    const service = createService({ listDailyReports: async () => [insightOnly] }, cloudStorage);
+
+    const refreshed = await service.refreshReportsFromCloud();
+
+    expect(cloudStorage.list).toHaveBeenCalledWith('dailyReport', 'active', 300);
+    expect(refreshed.map((item) => item.id).sort()).toEqual(['daily-insight', 'daily-same']);
+    expect(refreshed.find((item) => item.id === 'daily-same')?.createdAt).toBe(localNewer.createdAt);
+    expect(service.listReports().some((item) => item.id === 'daily-insight')).toBe(true);
+  });
 });
 
+function createService(insightPatch: any = {}, cloudPatch: any = {}): DailyReportService {
+  const insight = {
+    createDailyReport: async () => null,
+    listDailyReports: async () => [],
+    ...insightPatch,
+  };
+  const cloudStorage = {
+    list: vi.fn().mockResolvedValue([]),
+    upsert: vi.fn().mockResolvedValue({}),
+    ...cloudPatch,
+  };
+  return new DailyReportService(
+    { currentUser: { objectId: 'user-1' }, requestLogin: () => undefined } as any,
+    {} as any,
+    insight as any,
+    cloudStorage as any,
+    new StorageGovernanceService(),
+  );
+}
+
 function author(id: string, nickname: string, works: any[]) {
   return { id, uid: id, nickname, works };
 }
@@ -43,3 +106,18 @@ function work(id: string, diggCount: number) {
     },
   };
 }
+
+function report(id: string, createdAt: string): DailyReport {
+  return {
+    id,
+    userId: 'user-1',
+    date: createdAt.slice(0, 10),
+    newWorks: [],
+    topTopics: [],
+    reportMarkdown: `# ${id}`,
+    reportJson: {},
+    status: 'success',
+    createdAt,
+    updatedAt: createdAt,
+  };
+}

+ 97 - 8
src/app/services/daily-report.service.ts

@@ -5,8 +5,12 @@ import { AuthCreditService } from './auth-credit.service';
 import { DouyinApiService } from './douyin-api.service';
 import { DouyinInsightService } from './douyin-insight.service';
 import { DailyReport, InsightConfidence, TopicIdea } from '../models/douyin-insight.model';
+import { CloudSessionStorageService } from './cloud-session-storage.service';
+import { StorageGovernanceService } from './storage-governance.service';
 
-const DAILY_REPORT_STORAGE_KEY = 'videoWorkflow.dailyReports.items';
+const LEGACY_DAILY_REPORT_STORAGE_KEY = 'videoWorkflow.dailyReports.items';
+const DAILY_REPORT_CACHE_KEY = 'videoWorkflow.dailyReports.cache.v1';
+const DAILY_REPORT_ENTITY_TYPE = 'dailyReport';
 
 interface MonitorWorkLike {
   id: string;
@@ -53,10 +57,14 @@ interface CommentSampleLike {
 
 @Injectable({ providedIn: 'root' })
 export class DailyReportService {
+  private reportCache: DailyReport[] | null = null;
+
   constructor(
     private auth: AuthCreditService,
     private douyinApi: DouyinApiService,
     private insight: DouyinInsightService,
+    private cloudStorage: CloudSessionStorageService,
+    private storageGovernance: StorageGovernanceService,
   ) {}
 
   generateMonitorReport(authors: MonitorAuthorLike[]): DailyReport {
@@ -121,11 +129,20 @@ export class DailyReportService {
   async refreshReportsFromCloud(): Promise<DailyReport[]> {
     const userId = this.auth.currentUser?.objectId;
     if (!userId) return [];
-    const cloudItems = await this.insight.listDailyReports();
+    const cloudItems = await this.fetchCloudReports(userId);
     if (!cloudItems.length) return this.listReports();
-    const others = this.readAll().filter((item) => item.userId !== userId);
-    this.writeAll([...others, ...cloudItems]);
-    return cloudItems.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
+    const allLocal = this.readAll();
+    const others = allLocal.filter((item) => item.userId !== userId);
+    const currentLocal = allLocal.filter((item) => item.userId === userId);
+    const merged = new Map(currentLocal.map((item) => [item.id, item]));
+    for (const cloudItem of cloudItems) {
+      const localItem = merged.get(cloudItem.id);
+      const keepLocal = localItem && Date.parse(localItem.updatedAt) > Date.parse(cloudItem.updatedAt);
+      merged.set(cloudItem.id, keepLocal ? localItem : cloudItem);
+    }
+    const current = this.sortReports(Array.from(merged.values()));
+    this.writeAll([...others, ...current]);
+    return current;
   }
 
   private buildMonitorReport(authors: MonitorAuthorLike[], commentsByWorkId: Record<string, string[]> = {}, knownUserId?: string): DailyReport {
@@ -160,6 +177,7 @@ export class DailyReportService {
     const rows = this.readAll();
     rows.unshift(report);
     this.writeAll(rows);
+    this.mirrorSystemUpsert(report);
     this.insight.createDailyReport(report).catch((err) => {
       console.warn('[DailyReportService] 云端保存日报失败,已保留本地数据:', err?.message || err);
     });
@@ -537,16 +555,87 @@ export class DailyReportService {
   }
 
   private readAll(): DailyReport[] {
+    if (this.reportCache) return this.reportCache;
+    const governed = this.storageGovernance.readJson<DailyReport[]>(DAILY_REPORT_CACHE_KEY);
+    if (Array.isArray(governed)) {
+      this.reportCache = this.sortReports(governed.map((item) => this.normalizeReport(item)).filter((item): item is DailyReport => !!item));
+      return this.reportCache;
+    }
     try {
-      const raw = localStorage.getItem(DAILY_REPORT_STORAGE_KEY);
-      return raw ? JSON.parse(raw) as DailyReport[] : [];
+      const raw = localStorage.getItem(LEGACY_DAILY_REPORT_STORAGE_KEY);
+      const parsed = raw ? JSON.parse(raw) as DailyReport[] : [];
+      this.reportCache = Array.isArray(parsed)
+        ? this.sortReports(parsed.map((item) => this.normalizeReport(item)).filter((item): item is DailyReport => !!item))
+        : [];
+      return this.reportCache;
     } catch {
+      this.reportCache = [];
       return [];
     }
   }
 
   private writeAll(items: DailyReport[]): void {
-    localStorage.setItem(DAILY_REPORT_STORAGE_KEY, JSON.stringify(items));
+    this.reportCache = this.sortReports(items);
+    const result = this.storageGovernance.writeJson(DAILY_REPORT_CACHE_KEY, this.reportCache, 'cache');
+    if (!result.stored) {
+      console.warn('[DailyReportService] 日报缓存写入失败,已保留内存快照:', result.reason);
+    }
+  }
+
+  private async fetchCloudReports(userId: string): Promise<DailyReport[]> {
+    const collected: DailyReport[] = [];
+    try {
+      const entities = await this.cloudStorage.list<DailyReport>(DAILY_REPORT_ENTITY_TYPE, 'active', 300);
+      collected.push(...entities
+        .map((entity) => this.normalizeReport({ ...entity.data, userId: entity.data?.userId || userId }))
+        .filter((item): item is DailyReport => !!item));
+    } catch (err: any) {
+      console.warn('[DailyReportService] Parse 云端日报刷新失败,尝试旧洞察云端:', err?.message || err);
+    }
+
+    try {
+      collected.push(...(await this.insight.listDailyReports()));
+    } catch (err: any) {
+      console.warn('[DailyReportService] 洞察云端日报刷新失败:', err?.message || err);
+    }
+
+    const merged = new Map<string, DailyReport>();
+    for (const item of collected) {
+      const existing = merged.get(item.id);
+      const keepExisting = existing && Date.parse(existing.updatedAt) >= Date.parse(item.updatedAt);
+      merged.set(item.id, keepExisting ? existing : item);
+    }
+    return this.sortReports(Array.from(merged.values()));
+  }
+
+  private mirrorSystemUpsert(report: DailyReport): void {
+    this.cloudStorage.upsert(DAILY_REPORT_ENTITY_TYPE, report.id, report).catch((err) => {
+      console.warn('[DailyReportService] Parse 云端同步日报失败,已保留本地缓存:', err?.message || err);
+    });
+  }
+
+  private normalizeReport(value: unknown): DailyReport | null {
+    const row = value as Partial<DailyReport> | null;
+    if (!row || !row.id || !row.userId) return null;
+    const now = new Date().toISOString();
+    return {
+      id: String(row.id),
+      userId: String(row.userId),
+      date: row.date || (row.createdAt || now).slice(0, 10),
+      newWorks: Array.isArray(row.newWorks) ? row.newWorks : [],
+      topTopics: Array.isArray(row.topTopics) ? row.topTopics : [],
+      reportMarkdown: row.reportMarkdown || '',
+      reportJson: row.reportJson || {},
+      calibrationPrompt: row.calibrationPrompt,
+      status: row.status || 'success',
+      errorMessage: row.errorMessage,
+      createdAt: row.createdAt || now,
+      updatedAt: row.updatedAt || row.createdAt || now,
+    };
+  }
+
+  private sortReports(items: DailyReport[]): DailyReport[] {
+    return [...items].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
   }
 
   private createId(prefix: string): string {

+ 30 - 0
src/app/services/llm.service.ts

@@ -40,6 +40,12 @@ export interface ChatCompletionResponse {
   };
 }
 
+export interface ChatCompletionTextResult {
+  content: string;
+  finishReason: string;
+  response: ChatCompletionResponse;
+}
+
 export interface GeminiPart {
   text?: string;
   inlineData?: {
@@ -134,6 +140,30 @@ export class LlmService {
     );
   }
 
+  /**
+   * 带完成原因的系统提示词调用。
+   * 适合需要识别 length/content_filter 等非正常结束状态的长文本分析。
+   */
+  askWithSystemDetailed(
+    systemPrompt: string,
+    userPrompt: string,
+    options: ChatCompletionOptions = {},
+  ): Observable<ChatCompletionTextResult> {
+    return this.chat([
+      { role: 'system', content: systemPrompt },
+      { role: 'user', content: userPrompt }
+    ], options).pipe(
+      map(res => {
+        const choice = res.choices?.[0];
+        return {
+          content: choice?.message?.content || '',
+          finishReason: choice?.finish_reason || '',
+          response: res,
+        };
+      })
+    );
+  }
+
   // ==================== 流式 ChatCompletions ====================
 
   /**

+ 119 - 0
src/app/services/template.service.spec.ts

@@ -0,0 +1,119 @@
+import { GenerationTemplate } from '../models/template.model';
+import { StorageGovernanceService } from './storage-governance.service';
+import { TemplateService } from './template.service';
+
+const LEGACY_STORAGE_KEY = 'videoWorkflow.templates.user.v1';
+
+describe('TemplateService', () => {
+  beforeEach(() => {
+    localStorage.clear();
+    sessionStorage.clear();
+  });
+
+  it('reads legacy templates but writes new user templates through cloud and governed cache', async () => {
+    const legacy = template('tpl-legacy', '旧模板', '2026-06-01T00:00:00.000Z');
+    localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify([legacy]));
+    const cloud = {
+      upsert: vi.fn().mockResolvedValue({}),
+      delete: vi.fn(),
+      list: vi.fn(),
+    };
+    const service = createService(cloud);
+
+    const saved = service.createUserTemplate({
+      pipelineId: 'topic-to-video',
+      name: '新模板',
+      description: '新模板说明',
+      category: '知识科普',
+      tags: ['知识', '知识'],
+      config: { nScenes: 4 },
+    });
+    await Promise.resolve();
+
+    expect(saved.tags).toEqual(['知识']);
+    expect(cloud.upsert).toHaveBeenCalledWith('template', saved.id, expect.objectContaining({
+      id: saved.id,
+      name: '新模板',
+      userId: 'user-1',
+      scope: 'user',
+    }));
+    expect(JSON.parse(localStorage.getItem(LEGACY_STORAGE_KEY) || '[]')).toEqual([legacy]);
+    expect(localStorage.getItem('videoWorkflow.templates.user.cache.v1')).toContain('__storageGovernance');
+    expect(service.listTemplates().some((item) => item.id === 'tpl-legacy')).toBe(true);
+    expect(service.listTemplates().some((item) => item.id === saved.id)).toBe(true);
+  });
+
+  it('refreshes user templates from cloud without deleting newer local cache', async () => {
+    const localNewer = template('tpl-same', '本地较新', '2026-06-03T00:00:00.000Z');
+    const cloudOlder = template('tpl-same', '云端较旧', '2026-06-02T00:00:00.000Z');
+    const cloudOnly = template('tpl-cloud', '云端模板', '2026-06-04T00:00:00.000Z');
+    localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify([localNewer]));
+    const cloud = {
+      upsert: vi.fn(),
+      delete: vi.fn(),
+      list: vi.fn().mockResolvedValue([
+        { data: cloudOlder },
+        { data: cloudOnly },
+      ]),
+    };
+    const service = createService(cloud);
+
+    const refreshed = await service.refreshUserTemplatesFromCloud();
+
+    expect(refreshed.map((item) => item.id).sort()).toEqual(['tpl-cloud', 'tpl-same']);
+    expect(refreshed.find((item) => item.id === 'tpl-same')?.name).toBe('本地较新');
+    expect(service.listTemplates().some((item) => item.id === 'tpl-cloud')).toBe(true);
+  });
+
+  it('syncs update, delete and usage changes to cloud storage', async () => {
+    const existing = template('tpl-edit', '待编辑模板', '2026-06-01T00:00:00.000Z');
+    localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify([existing]));
+    const cloud = {
+      upsert: vi.fn().mockResolvedValue({}),
+      delete: vi.fn().mockResolvedValue({}),
+      list: vi.fn(),
+    };
+    const service = createService(cloud);
+
+    const updated = service.updateUserTemplate('tpl-edit', { name: '编辑后模板' });
+    service.markUsed('tpl-edit');
+    service.deleteUserTemplate('tpl-edit');
+    await Promise.resolve();
+
+    expect(updated.name).toBe('编辑后模板');
+    expect(cloud.upsert).toHaveBeenCalledWith('template', 'tpl-edit', expect.objectContaining({ name: '编辑后模板' }));
+    expect(cloud.upsert).toHaveBeenCalledWith('template', 'tpl-edit', expect.objectContaining({ usageCount: 1 }));
+    expect(cloud.delete).toHaveBeenCalledWith('template', 'tpl-edit');
+    expect(service.listTemplates().some((item) => item.id === 'tpl-edit')).toBe(false);
+  });
+});
+
+function createService(cloud: any): TemplateService {
+  return new TemplateService(
+    {
+      isLoggedIn: true,
+      currentUser: { objectId: 'user-1' },
+      requestLogin: vi.fn(),
+    } as any,
+    cloud,
+    new StorageGovernanceService(),
+  );
+}
+
+function template(id: string, name: string, updatedAt: string): GenerationTemplate {
+  return {
+    id,
+    userId: 'user-1',
+    scope: 'user',
+    pipelineId: 'topic-to-video',
+    name,
+    description: '',
+    category: '知识科普',
+    tags: [],
+    config: { nScenes: 3 },
+    version: 1,
+    usageCount: 0,
+    createdAt: updatedAt,
+    updatedAt,
+  };
+}

+ 90 - 5
src/app/services/template.service.ts

@@ -2,14 +2,23 @@ import { Injectable } from '@angular/core';
 import { AuthCreditService } from './auth-credit.service';
 import { GenerationTemplate, GenerationTemplateInput } from '../models/template.model';
 import { PipelineRoute } from '../pipelines/pipeline-registry';
+import { CloudSessionStorageService } from './cloud-session-storage.service';
+import { StorageGovernanceService } from './storage-governance.service';
 
-const STORAGE_KEY = 'videoWorkflow.templates.user.v1';
+const LEGACY_STORAGE_KEY = 'videoWorkflow.templates.user.v1';
+const CACHE_STORAGE_KEY = 'videoWorkflow.templates.user.cache.v1';
+const TEMPLATE_ENTITY_TYPE = 'template';
 
 @Injectable({ providedIn: 'root' })
 export class TemplateService {
   private readonly systemTemplates: GenerationTemplate[] = this.buildSystemTemplates();
+  private userTemplateCache: GenerationTemplate[] | null = null;
 
-  constructor(private auth: AuthCreditService) {}
+  constructor(
+    private auth: AuthCreditService,
+    private cloudStorage: CloudSessionStorageService,
+    private storageGovernance: StorageGovernanceService,
+  ) {}
 
   listTemplates(): GenerationTemplate[] {
     const userId = this.auth.currentUser?.objectId || '';
@@ -24,6 +33,26 @@ export class TemplateService {
     return this.listTemplates().filter((item) => item.pipelineId === pipelineId);
   }
 
+  async refreshUserTemplatesFromCloud(): Promise<GenerationTemplate[]> {
+    const userId = this.requireUserId();
+    const entities = await this.cloudStorage.list<GenerationTemplate>(TEMPLATE_ENTITY_TYPE, 'active', 500);
+    const cloudTemplates = entities
+      .map((entity) => this.normalizeUserTemplate(entity.data, userId))
+      .filter((item): item is GenerationTemplate => !!item);
+    const localTemplates = this.readUserTemplates().filter((item) => item.userId === userId);
+    const merged = new Map<string, GenerationTemplate>();
+    for (const item of localTemplates) merged.set(item.id, item);
+    for (const item of cloudTemplates) {
+      const local = merged.get(item.id);
+      const keepLocal = local && Date.parse(local.updatedAt || '') > Date.parse(item.updatedAt || '');
+      merged.set(item.id, keepLocal ? local : item);
+    }
+    const others = this.readUserTemplates().filter((item) => item.userId !== userId);
+    const current = Array.from(merged.values());
+    this.writeUserTemplates([...others, ...current]);
+    return this.sortTemplates(current);
+  }
+
   createUserTemplate(input: GenerationTemplateInput): GenerationTemplate {
     const userId = this.requireUserId();
     const now = new Date().toISOString();
@@ -42,6 +71,7 @@ export class TemplateService {
     const items = this.readUserTemplates();
     items.unshift(template);
     this.writeUserTemplates(items);
+    this.mirrorUpsert(template);
     return template;
   }
 
@@ -77,12 +107,14 @@ export class TemplateService {
     };
     items[index] = updated;
     this.writeUserTemplates(items);
+    this.mirrorUpsert(updated);
     return updated;
   }
 
   deleteUserTemplate(id: string): void {
     const userId = this.requireUserId();
     this.writeUserTemplates(this.readUserTemplates().filter((item) => !(item.id === id && item.userId === userId)));
+    this.mirrorDelete(id);
   }
 
   markUsed(id: string): void {
@@ -96,6 +128,7 @@ export class TemplateService {
       updatedAt: new Date().toISOString(),
     };
     this.writeUserTemplates(items);
+    this.mirrorUpsert(items[index]);
   }
 
   private requireUserId(): string {
@@ -108,17 +141,69 @@ export class TemplateService {
   }
 
   private readUserTemplates(): GenerationTemplate[] {
+    if (this.userTemplateCache) return this.userTemplateCache;
+    const governed = this.storageGovernance.readJson<GenerationTemplate[]>(CACHE_STORAGE_KEY);
+    if (Array.isArray(governed)) {
+      this.userTemplateCache = governed.filter((item) => item?.scope === 'user');
+      return this.userTemplateCache;
+    }
     try {
-      const raw = localStorage.getItem(STORAGE_KEY);
+      const raw = localStorage.getItem(LEGACY_STORAGE_KEY);
       const parsed = raw ? JSON.parse(raw) : [];
-      return Array.isArray(parsed) ? parsed : [];
+      this.userTemplateCache = Array.isArray(parsed) ? parsed.filter((item) => item?.scope === 'user') : [];
+      return this.userTemplateCache;
     } catch {
+      this.userTemplateCache = [];
       return [];
     }
   }
 
   private writeUserTemplates(items: GenerationTemplate[]): void {
-    localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
+    this.userTemplateCache = this.sortTemplates(items.filter((item) => item.scope === 'user'));
+    const result = this.storageGovernance.writeJson(CACHE_STORAGE_KEY, this.userTemplateCache, 'cache');
+    if (!result.stored) {
+      console.warn('[TemplateService] 模板缓存写入失败,已保留内存快照:', result.reason);
+    }
+  }
+
+  private mirrorUpsert(template: GenerationTemplate): void {
+    if (!this.auth.isLoggedIn) return;
+    this.cloudStorage.upsert(TEMPLATE_ENTITY_TYPE, template.id, template).catch((err) => {
+      console.warn('[TemplateService] 云端同步模板失败,已保留本地缓存:', err?.message || err);
+    });
+  }
+
+  private mirrorDelete(id: string): void {
+    if (!this.auth.isLoggedIn) return;
+    this.cloudStorage.delete(TEMPLATE_ENTITY_TYPE, id).catch((err) => {
+      console.warn('[TemplateService] 云端删除模板失败,本地缓存已先更新:', err?.message || err);
+    });
+  }
+
+  private normalizeUserTemplate(value: unknown, userId: string): GenerationTemplate | null {
+    const row = value as Partial<GenerationTemplate> | null;
+    if (!row || !row.id || !row.pipelineId || !row.name) return null;
+    const now = new Date().toISOString();
+    return {
+      id: String(row.id),
+      userId: row.userId || userId,
+      scope: 'user',
+      pipelineId: row.pipelineId,
+      name: String(row.name),
+      description: row.description || '',
+      category: row.category || '其他',
+      tags: this.uniqueTags(row.tags || []),
+      config: this.cloneSerializable(row.config || {}),
+      preview: row.preview,
+      version: Number(row.version || 1),
+      usageCount: Number(row.usageCount || 0),
+      createdAt: row.createdAt || now,
+      updatedAt: row.updatedAt || row.createdAt || now,
+    };
+  }
+
+  private sortTemplates<T extends GenerationTemplate>(items: T[]): T[] {
+    return [...items].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
   }
 
   private createId(): string {

+ 72 - 16
src/app/services/topic-pool.service.spec.ts

@@ -1,19 +1,24 @@
 import { TopicIdea } from '../models/douyin-insight.model';
+import { StorageGovernanceService } from './storage-governance.service';
 import { TopicPoolService } from './topic-pool.service';
 
 describe('TopicPoolService', () => {
+  beforeEach(() => {
+    localStorage.clear();
+    sessionStorage.clear();
+  });
+
   it('merges cloud topics without deleting unsynced local directions', async () => {
     const local = topic('local-direction', 2, '2026-06-04T10:00:00.000Z');
     const cloud = topic('cloud-direction', 1, '2026-06-04T09:00:00.000Z');
     localStorage.setItem('videoWorkflow.topicPool.items', JSON.stringify([local]));
-    const service = new TopicPoolService(
-      { currentUser: { objectId: 'user-1' }, isLoggedIn: true } as any,
-      { listTopics: async () => [cloud] } as any,
-    );
+    const service = createService({ listTopics: async () => [cloud] });
 
     const result = await service.refreshFromCloud();
 
     expect(result.map((item) => item.id).sort()).toEqual(['cloud-direction', 'local-direction']);
+    expect(localStorage.getItem('videoWorkflow.topicPool.items')).toBe(JSON.stringify([local]));
+    expect(localStorage.getItem('videoWorkflow.topicPool.cache.v1')).toContain('__storageGovernance');
   });
 
   it('creates the full local topic when cloud update reports Object not found', async () => {
@@ -30,10 +35,7 @@ describe('TopicPoolService', () => {
     };
     const existing = topic('local-direction', 2, '2026-06-04T10:00:00.000Z');
     localStorage.setItem('videoWorkflow.topicPool.items', JSON.stringify([existing]));
-    const service = new TopicPoolService(
-      { currentUser: { objectId: 'user-1' }, isLoggedIn: true } as any,
-      insight as any,
-    );
+    const service = createService(insight);
 
     service.updateTopic(existing.id, { title: '更新后的方向' });
     await Promise.resolve();
@@ -51,10 +53,7 @@ describe('TopicPoolService', () => {
         return topic;
       },
     };
-    const service = new TopicPoolService(
-      { currentUser: { objectId: 'user-1' }, isLoggedIn: true } as any,
-      insight as any,
-    );
+    const service = createService(insight);
 
     const saved = service.saveTopic({
       ...topic('ip-topic', 1, '2026-06-04T10:00:00.000Z'),
@@ -85,10 +84,7 @@ describe('TopicPoolService', () => {
       tags: ['IP操盘'],
     };
     localStorage.setItem('videoWorkflow.topicPool.items', JSON.stringify([existing]));
-    const service = new TopicPoolService(
-      { currentUser: { objectId: 'user-1' }, isLoggedIn: true } as any,
-      insight as any,
-    );
+    const service = createService(insight);
 
     const updated = service.updateTopic(existing.id, { sourceType: 'ip_operator', title: '更新后的 IP 选题' });
     await Promise.resolve();
@@ -99,8 +95,68 @@ describe('TopicPoolService', () => {
     expect(patches[1].sourceType).toBe('manual');
     expect(patches[1].tags).toContain('IP操盘');
   });
+
+  it('syncs saved topics to Parse cloud entities and keeps legacy key read-only', async () => {
+    const legacy = topic('legacy-topic', 9, '2026-06-01T00:00:00.000Z');
+    localStorage.setItem('videoWorkflow.topicPool.items', JSON.stringify([legacy]));
+    const cloudStorage = {
+      list: vi.fn().mockResolvedValue([]),
+      upsert: vi.fn().mockResolvedValue({}),
+    };
+    const service = createService({ createTopic: async () => null }, cloudStorage);
+
+    const saved = service.saveTopic({
+      ...topic('ignored-input-id', 1, '2026-06-04T10:00:00.000Z'),
+      title: '云端同步选题',
+    });
+    await Promise.resolve();
+
+    expect(cloudStorage.upsert).toHaveBeenCalledWith('topic', saved.id, expect.objectContaining({
+      id: saved.id,
+      title: '云端同步选题',
+      userId: 'user-1',
+    }), { status: 'active' });
+    expect(JSON.parse(localStorage.getItem('videoWorkflow.topicPool.items') || '[]')).toEqual([legacy]);
+    expect(localStorage.getItem('videoWorkflow.topicPool.cache.v1')).toContain('__storageGovernance');
+  });
+
+  it('refreshes topics from Parse cloud entities before legacy insight cloud', async () => {
+    const systemTopic = topic('system-topic', 3, '2026-06-05T10:00:00.000Z');
+    const insightTopic = topic('insight-topic', 4, '2026-06-04T10:00:00.000Z');
+    const cloudStorage = {
+      list: vi.fn().mockResolvedValue([{ data: systemTopic }]),
+      upsert: vi.fn(),
+    };
+    const service = createService({ listTopics: async () => [insightTopic] }, cloudStorage);
+
+    const refreshed = await service.refreshFromCloud();
+
+    expect(refreshed.map((item) => item.id)).toContain('system-topic');
+    expect(refreshed.map((item) => item.id)).toContain('insight-topic');
+    expect(cloudStorage.list).toHaveBeenCalledWith('topic', '', 500);
+  });
 });
 
+function createService(insightPatch: any = {}, cloudPatch: any = {}): TopicPoolService {
+  const insight = {
+    listTopics: async () => [],
+    createTopic: async () => null,
+    updateTopic: async () => null,
+    ...insightPatch,
+  };
+  const cloudStorage = {
+    list: vi.fn().mockResolvedValue([]),
+    upsert: vi.fn().mockResolvedValue({}),
+    ...cloudPatch,
+  };
+  return new TopicPoolService(
+    { currentUser: { objectId: 'user-1' }, isLoggedIn: true } as any,
+    insight as any,
+    cloudStorage as any,
+    new StorageGovernanceService(),
+  );
+}
+
 function topic(id: string, variantIndex: number, updatedAt: string): TopicIdea {
   return {
     id,

+ 98 - 5
src/app/services/topic-pool.service.ts

@@ -2,14 +2,22 @@ import { Injectable } from '@angular/core';
 import { AuthCreditService } from './auth-credit.service';
 import { DouyinInsightService } from './douyin-insight.service';
 import { TopicIdea } from '../models/douyin-insight.model';
+import { CloudSessionStorageService } from './cloud-session-storage.service';
+import { StorageGovernanceService } from './storage-governance.service';
 
-const TOPIC_POOL_STORAGE_KEY = 'videoWorkflow.topicPool.items';
+const LEGACY_TOPIC_POOL_STORAGE_KEY = 'videoWorkflow.topicPool.items';
+const TOPIC_POOL_CACHE_KEY = 'videoWorkflow.topicPool.cache.v1';
+const TOPIC_ENTITY_TYPE = 'topic';
 
 @Injectable({ providedIn: 'root' })
 export class TopicPoolService {
+  private topicCache: TopicIdea[] | null = null;
+
   constructor(
     private auth: AuthCreditService,
     private insight: DouyinInsightService,
+    private cloudStorage: CloudSessionStorageService,
+    private storageGovernance: StorageGovernanceService,
   ) {}
 
   listTopics(): TopicIdea[] {
@@ -21,7 +29,7 @@ export class TopicPoolService {
 
   async refreshFromCloud(): Promise<TopicIdea[]> {
     const userId = this.requireUserId();
-    const cloudItems = await this.insight.listTopics();
+    const cloudItems = await this.fetchCloudTopics(userId);
     if (!cloudItems.length) return this.listTopics();
     const allLocal = this.readAll();
     const others = allLocal.filter((item) => item.userId !== userId);
@@ -79,6 +87,7 @@ export class TopicPoolService {
 
   private mirrorCreate(topic: TopicIdea): void {
     if (!this.auth.isLoggedIn) return;
+    this.mirrorSystemUpsert(topic);
     this.insight.createTopic(topic).catch((err) => {
       if (topic.sourceType === 'ip_operator') {
         this.insight.createTopic(this.cloudCompatibleTopic(topic)).catch((fallbackErr) => {
@@ -92,6 +101,7 @@ export class TopicPoolService {
 
   private mirrorUpdate(topic: TopicIdea, patch: Partial<TopicIdea>): void {
     if (!this.auth.isLoggedIn) return;
+    this.mirrorSystemUpsert(topic);
     this.insight.updateTopic(topic.id, patch).catch((err) => {
       if (topic.sourceType === 'ip_operator') {
         this.insight.updateTopic(topic.id, this.cloudCompatiblePatch(patch)).catch((fallbackErr) => {
@@ -151,16 +161,99 @@ export class TopicPoolService {
   }
 
   private readAll(): TopicIdea[] {
+    if (this.topicCache) return this.topicCache;
+    const governed = this.storageGovernance.readJson<TopicIdea[]>(TOPIC_POOL_CACHE_KEY);
+    if (Array.isArray(governed)) {
+      this.topicCache = governed.filter((item) => !!item?.id);
+      return this.topicCache;
+    }
     try {
-      const raw = localStorage.getItem(TOPIC_POOL_STORAGE_KEY);
-      return raw ? JSON.parse(raw) as TopicIdea[] : [];
+      const raw = localStorage.getItem(LEGACY_TOPIC_POOL_STORAGE_KEY);
+      const parsed = raw ? JSON.parse(raw) as TopicIdea[] : [];
+      this.topicCache = Array.isArray(parsed) ? parsed.filter((item) => !!item?.id) : [];
+      return this.topicCache;
     } catch {
+      this.topicCache = [];
       return [];
     }
   }
 
   private writeAll(items: TopicIdea[]): void {
-    localStorage.setItem(TOPIC_POOL_STORAGE_KEY, JSON.stringify(items));
+    this.topicCache = this.sortTopics(items);
+    const result = this.storageGovernance.writeJson(TOPIC_POOL_CACHE_KEY, this.topicCache, 'cache');
+    if (!result.stored) {
+      console.warn('[TopicPoolService] 选题池缓存写入失败,已保留内存快照:', result.reason);
+    }
+  }
+
+  private async fetchCloudTopics(userId: string): Promise<TopicIdea[]> {
+    const collected: TopicIdea[] = [];
+    try {
+      const entities = await this.cloudStorage.list<TopicIdea>(TOPIC_ENTITY_TYPE, '', 500);
+      collected.push(...entities
+        .map((entity) => this.normalizeTopic(entity.data, userId))
+        .filter((item): item is TopicIdea => !!item));
+    } catch (err: any) {
+      console.warn('[TopicPoolService] Parse 云端选题刷新失败,尝试旧洞察云端:', err?.message || err);
+    }
+
+    try {
+      collected.push(...(await this.insight.listTopics()));
+    } catch (err: any) {
+      console.warn('[TopicPoolService] 洞察云端选题刷新失败:', err?.message || err);
+    }
+
+    const merged = new Map<string, TopicIdea>();
+    for (const item of collected) {
+      const existing = merged.get(item.id);
+      const keepExisting = existing && Date.parse(existing.updatedAt) >= Date.parse(item.updatedAt);
+      merged.set(item.id, keepExisting ? existing : item);
+    }
+    return this.sortTopics(Array.from(merged.values()));
+  }
+
+  private mirrorSystemUpsert(topic: TopicIdea): void {
+    this.cloudStorage.upsert(TOPIC_ENTITY_TYPE, topic.id, topic, {
+      status: topic.status === 'archived' ? 'archived' : 'active',
+    }).catch((err) => {
+      console.warn('[TopicPoolService] Parse 云端同步选题失败,已保留本地缓存:', err?.message || err);
+    });
+  }
+
+  private normalizeTopic(value: unknown, userId: string): TopicIdea | null {
+    const row = value as Partial<TopicIdea> | null;
+    if (!row || !row.id || !row.title) return null;
+    const now = new Date().toISOString();
+    return {
+      id: String(row.id),
+      userId: row.userId || userId,
+      title: String(row.title),
+      angle: row.angle || row.title || '',
+      sourceType: row.sourceType || 'manual',
+      sourceVideoIds: Array.isArray(row.sourceVideoIds) ? row.sourceVideoIds : [],
+      sourceAuthorIds: Array.isArray(row.sourceAuthorIds) ? row.sourceAuthorIds : [],
+      tags: Array.isArray(row.tags) ? row.tags : [],
+      audience: row.audience,
+      hook: row.hook,
+      outline: row.outline,
+      shortOutline: row.shortOutline,
+      fullOutline: row.fullOutline,
+      sourceTitle: row.sourceTitle,
+      sourceSummary: row.sourceSummary,
+      sourceEvidence: Array.isArray(row.sourceEvidence) ? row.sourceEvidence : [],
+      variantIndex: row.variantIndex,
+      variantTotal: row.variantTotal,
+      isRecommended: row.isRecommended,
+      status: row.status || 'idea',
+      pipelineTarget: row.pipelineTarget,
+      confidence: row.confidence,
+      createdAt: row.createdAt || now,
+      updatedAt: row.updatedAt || row.createdAt || now,
+    };
+  }
+
+  private sortTopics(items: TopicIdea[]): TopicIdea[] {
+    return [...items].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
   }
 
   private createId(): string {

+ 18 - 0
src/app/services/user-message.util.spec.ts

@@ -0,0 +1,18 @@
+import { userFailureSuggestion, userFriendlyError } from './user-message.util';
+
+describe('user message utilities', () => {
+  it('keeps credit shortage numbers in the public error message', () => {
+    const message = userFriendlyError(
+      new Error('积分不足:当前 0,本次需要 14。请先到用户中心充值后再使用。'),
+      '生成失败',
+    );
+
+    expect(message).toContain('当前 0 积分');
+    expect(message).toContain('本次需要 14 积分');
+    expect(message).toContain('请先充值后再使用');
+  });
+
+  it('suggests recharge or lower settings for insufficient credits', () => {
+    expect(userFailureSuggestion('余额不足:当前 0,本次需要 14')).toContain('请先充值');
+  });
+});

+ 10 - 1
src/app/services/user-message.util.ts

@@ -5,7 +5,10 @@ export function userFriendlyError(error: any, fallback = '操作失败,请稍
   let publicText = fallback;
 
   if (/积分不足|余额不足|insufficient|quota|balance/.test(lower)) {
-    publicText = '当前额度不足,请先充值后再使用。';
+    const shortage = extractCreditShortage(raw);
+    publicText = shortage
+      ? `当前额度不足(当前 ${shortage.current} 积分,本次需要 ${shortage.required} 积分),请先充值后再使用。`
+      : '当前额度不足,请先充值后再使用。';
   } else if (/请先登录|登录已过期|unauthorized|401|403|auth/.test(lower)) {
     publicText = '登录状态已失效,请重新登录后再试。';
   } else if (/failed to fetch|network|err_connection|timeout|timed out|网络|连接|超时/.test(lower)) {
@@ -89,3 +92,9 @@ function readValue(value: any): string {
     return '';
   }
 }
+
+function extractCreditShortage(raw: string): { current: string; required: string } | null {
+  const match = raw.match(/当前\s*([0-9]+(?:\.[0-9]+)?)\s*[,,]\s*本次需要\s*([0-9]+(?:\.[0-9]+)?)/);
+  if (!match) return null;
+  return { current: match[1], required: match[2] };
+}