فهرست منبع

feat: 增加云端存储与抖音数据采集能力

Yi Jiarui 2 ماه پیش
والد
کامیت
f649c524fe
32فایلهای تغییر یافته به همراه4721 افزوده شده و 268 حذف شده
  1. 28 9
      cloud-functions/09-uploadManager.js
  2. 309 138
      cloud-functions/13-douyinInsightManager.js
  3. 108 0
      cloud-functions/13-douyinInsightManager.spec.js
  4. 449 0
      cloud-functions/14-systemStorageManager.js
  5. 297 0
      cloud-functions/15-fileAssetManager.js
  6. 248 0
      cloud-functions/_parseClassStore.js
  7. 126 0
      cloud-functions/_session.js
  8. 42 0
      cloud-functions/_session.spec.js
  9. 119 0
      scripts/build-cloud-functions.mjs
  10. 144 54
      scripts/smoke-cloud-functions.mjs
  11. 161 0
      scripts/validation/cloud-functions-readiness.mjs
  12. 213 0
      scripts/validation/parse-storage-postdeploy.mjs
  13. 208 0
      scripts/validation/storage-governance-postdeploy.mjs
  14. 86 0
      scripts/validation/storage-sensitive-policy.mjs
  15. 257 0
      src/app/services/cloud-session-storage.service.spec.ts
  16. 328 0
      src/app/services/cloud-session-storage.service.ts
  17. 66 0
      src/app/services/douyin-api.service.spec.ts
  18. 98 3
      src/app/services/douyin-api.service.ts
  19. 75 0
      src/app/services/douyin-insight.service.spec.ts
  20. 31 10
      src/app/services/douyin-insight.service.ts
  21. 106 0
      src/app/services/file-asset.service.spec.ts
  22. 115 0
      src/app/services/file-asset.service.ts
  23. 54 0
      src/app/services/parse.service.spec.ts
  24. 77 41
      src/app/services/parse.service.ts
  25. 157 0
      src/app/services/qiniu-upload.service.spec.ts
  26. 87 11
      src/app/services/qiniu-upload.service.ts
  27. 69 0
      src/app/services/storage-governance.service.spec.ts
  28. 242 0
      src/app/services/storage-governance.service.ts
  29. 168 0
      src/app/services/system-storage-migration.service.spec.ts
  30. 220 0
      src/app/services/system-storage-migration.service.ts
  31. 25 0
      src/app/services/voice-api.service.spec.ts
  32. 8 2
      src/app/services/voice-api.service.ts

+ 28 - 9
cloud-functions/09-uploadManager.js

@@ -1,11 +1,13 @@
 /**
- * Cloud function: uploadManager
- * Signs Qiniu direct-upload tokens. File bytes are uploaded from the browser
- * directly to Qiniu, so the cloud function never handles large payloads.
+ * 云函数:uploadManager
+ * 签发七牛直传 token。文件本体由浏览器直接上传到七牛,
+ * 云函数只负责生成受账号隔离约束的上传凭证,不接收大文件内容。
  *
- * actions:
+ * 支持 action:
  *   createUploadToken -> { uploadUrl, token, key, url, mimeType, kind, expiresAt }
  */
+const { requireSession, assertRequestedUserMatchesSession } = require('./_session');
+
 async function handler(request, response) {
   try {
     const action = pickParam(request, 'action') || 'createUploadToken';
@@ -14,12 +16,16 @@ async function handler(request, response) {
       return response.json({ code: 400, success: false, error: `Unknown action: ${action}` });
     }
 
+    const session = await requireSession(request, Psql);
+    assertRequestedUserMatchesSession(request, session);
+    const userId = session.userId;
     const filename = String(pickParam(request, 'filename', 'fileName') || `asset-${Date.now()}`);
     const mimeType = String(pickParam(request, 'mimeType', 'contentType') || 'application/octet-stream');
     const requestedKind = String(pickParam(request, 'kind') || '').trim();
     const size = Number(pickParam(request, 'size') || 0);
+    const bizId = String(pickParam(request, 'bizId') || '').trim();
     const kind = normalizeKind(requestedKind, mimeType);
-    const key = buildDigitalHumanAssetKey(filename, kind);
+    const key = buildUserAssetKey(filename, kind, userId, bizId);
     const { token, deadline } = await buildQiniuUploadToken(key);
     const url = `${QINIU_CDN_DOMAIN.replace(/\/$/, '')}/${key}`;
 
@@ -33,6 +39,7 @@ async function handler(request, response) {
         url,
         mimeType,
         kind,
+        bucket: QINIU_BUCKET,
         size,
         expiresAt: deadline * 1000,
       },
@@ -73,7 +80,7 @@ async function buildQiniuUploadToken(key) {
   };
 }
 
-function buildDigitalHumanAssetKey(fileName, kind) {
+function buildUserAssetKey(fileName, kind, userId, bizId) {
   const rawName = String(fileName || `asset-${Date.now()}`);
   const extMatch = rawName.match(/(\.[a-z0-9]+)$/i);
   const ext = extMatch ? extMatch[1].toLowerCase() : '';
@@ -88,18 +95,30 @@ function buildDigitalHumanAssetKey(fileName, kind) {
   const yyyy = date.getFullYear();
   const mm = String(date.getMonth() + 1).padStart(2, '0');
   const dd = String(date.getDate()).padStart(2, '0');
-  const timestamp = `${yyyy}${mm}${dd}-${Date.now()}`;
+  const uuid = generateId();
+  const partition = safePathSegment(bizId) || 'free';
 
-  return `${QINIU_CDN_PREFIX}/digital-human/${kind}/${yyyy}${mm}${dd}/${timestamp}-${baseName}${safeExt}`;
+  return `users/${safePathSegment(userId)}/${kind}/${yyyy}/${mm}/${partition}/${yyyy}${mm}${dd}-${Date.now()}-${uuid}-${baseName}${safeExt}`;
 }
 
 function normalizeKind(kind, mimeType) {
-  if (kind === 'audio' || kind === 'video' || kind === 'image') return kind;
+  if (kind === 'audio' || kind === 'video' || kind === 'image' || kind === 'export' || kind === 'snapshot') return kind;
   if (mimeType.startsWith('audio/')) return 'audio';
   if (mimeType.startsWith('video/')) return 'video';
   return 'image';
 }
 
+function safePathSegment(value) {
+  return String(value || '').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80);
+}
+
+function generateId() {
+  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
+  let value = '';
+  for (let i = 0; i < 8; i += 1) value += chars.charAt(Math.floor(Math.random() * chars.length));
+  return value;
+}
+
 function toBase64Url(input) {
   return base64UrlEncode(bytesFromString(input));
 }

+ 309 - 138
cloud-functions/13-douyinInsightManager.js

@@ -1,6 +1,6 @@
 /**
  * 云函数:douyinInsightManager(二阶段:爆款分析、选题池、日报)
- * actions:
+ * 支持 action:
  *   analysisCreate | analysisList | analysisGet | analysisUpdate
  *   topicCreate | topicList | topicUpdate | topicArchive
  *   dailyReportCreate | dailyReportList | dailyReportGet
@@ -17,7 +17,12 @@ const VOC_SOCIAL_TOKEN = readEnv('DOUYIN_API_TOKEN') || readEnv('VOC_TOKEN') ||
 const TIKHUB_TOKEN = readEnv('TIKHUB_TOKEN') || 'gqsZHfMWgAiMwV+ITbmZy0qALADWBZVS7QnV7kKJe9CwzgWgJG+7bwK+GQ==';
 const DOUYIN_API_TOKEN = IS_TIKHUB_DIRECT ? (readEnv('DOUYIN_API_TOKEN') || TIKHUB_TOKEN) : VOC_SOCIAL_TOKEN;
 const TRANSCRIPTION_GATEWAY = (readEnv('IFLYTEK_GATEWAY_BASE_URL') || 'https://server.fmode.cn/api/apig/transcription').replace(/\/+$/, '');
-const VOC_TOKEN = readEnv('VOC_TOKEN') || readEnv('TRANSCRIPTION_VOC_TOKEN') || readEnv('VOICE_TOKEN') || readEnv('OPENCLAW_VOC_TOKEN') || readEnv('VOC_SOCIAL_TOKEN') || LOCAL_VOC_TOKEN_FALLBACK;
+const TRANSCRIPTION_GATEWAY_TOKEN = readEnv('TRANSCRIPTION_VOC_TOKEN') || readEnv('VOICE_TOKEN') || readEnv('OPENCLAW_VOC_TOKEN') || readEnv('VOC_TOKEN') || readEnv('VOC_SOCIAL_TOKEN');
+const {
+  requireSession: requireParseSession,
+  optionalSession,
+  assertRequestedUserMatchesSession,
+} = require('./_session');
 
 async function handler(request, response) {
   try {
@@ -28,7 +33,8 @@ async function handler(request, response) {
 
     await ensureTables();
 
-    const session = await requireSession(request);
+    const session = await requireParseSession(request, Psql);
+    assertRequestedUserMatchesSession(request, session);
     const requestedUserId = pickParam(request, 'userId') || '';
     if (requestedUserId && requestedUserId !== session.userId) {
       return response.json({ code: 403, success: false, error: '没有访问该账号数据的权限' });
@@ -37,20 +43,20 @@ async function handler(request, response) {
 
     if (action === 'analysisCreate') return createRow(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'analysis', 'data') || {});
     if (action === 'analysisList') return listRows(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'limit') || 100);
-    if (action === 'analysisGet') return getRow(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'id'));
-    if (action === 'analysisUpdate') return updateRow(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'id'), pickParam(request, 'patch', 'analysis', 'data') || {});
+    if (action === 'analysisGet') return getRow(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'recordId', 'analysisId', 'id'));
+    if (action === 'analysisUpdate') return updateRow(response, 'VideoflowViralAnalysis', userId, pickParam(request, 'recordId', 'analysisId', 'id'), pickParam(request, 'patch', 'analysis', 'data') || {});
 
     if (action === 'topicCreate') return createRow(response, 'VideoflowTopicIdea', userId, pickParam(request, 'topic', 'data') || {});
     if (action === 'topicList') return listRows(response, 'VideoflowTopicIdea', userId, pickParam(request, 'limit') || 500);
-    if (action === 'topicUpdate') return updateRow(response, 'VideoflowTopicIdea', userId, pickParam(request, 'id'), pickParam(request, 'patch', 'topic', 'data') || {});
-    if (action === 'topicArchive') return updateRow(response, 'VideoflowTopicIdea', userId, pickParam(request, 'id'), { status: 'archived' });
+    if (action === 'topicUpdate') return updateRow(response, 'VideoflowTopicIdea', userId, pickParam(request, 'topicId', 'recordId', 'id'), pickParam(request, 'patch', 'topic', 'data') || {});
+    if (action === 'topicArchive') return updateRow(response, 'VideoflowTopicIdea', userId, pickParam(request, 'topicId', 'recordId', 'id'), { status: 'archived' });
 
     if (action === 'dailyReportCreate') return createRow(response, 'VideoflowDailyReport', userId, pickParam(request, 'report', 'data') || {});
     if (action === 'dailyReportList') return listRows(response, 'VideoflowDailyReport', userId, pickParam(request, 'limit') || 100);
-    if (action === 'dailyReportGet') return getRow(response, 'VideoflowDailyReport', userId, pickParam(request, 'id'));
+    if (action === 'dailyReportGet') return getRow(response, 'VideoflowDailyReport', userId, pickParam(request, 'reportId', 'recordId', 'id'));
 
-    if (action === 'transcriptStart') return startTranscript(request, response, userId);
-    if (action === 'transcriptGet') return getTranscript(request, response, userId);
+    if (action === 'transcriptStart') return startTranscript(request, response, session);
+    if (action === 'transcriptGet') return getTranscript(request, response, session);
 
     response.json({ code: 400, success: false, error: `未知 action: ${action}` });
   } catch (error) {
@@ -76,14 +82,6 @@ async function ensureTables() {
     await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_${table.toLowerCase()}_user_biz ON "${table}" ("userId", "bizId")`);
     await Psql.query(`CREATE INDEX IF NOT EXISTS idx_${table.toLowerCase()}_user ON "${table}" ("userId")`);
   }
-  await Psql.query(`
-    CREATE TABLE IF NOT EXISTS "AppSession" (
-      "token" VARCHAR(120) PRIMARY KEY,
-      "userId" VARCHAR(50) NOT NULL,
-      "expiresAt" TIMESTAMPTZ NOT NULL,
-      "createdAt" TIMESTAMPTZ DEFAULT NOW()
-    )
-  `);
 }
 
 async function createRow(response, table, userId, data) {
@@ -145,7 +143,9 @@ async function updateRow(response, table, userId, id, patch) {
   response.json({ code: 200, success: true, data: merged });
 }
 
-async function startTranscript(request, response, userId) {
+async function startTranscript(request, response, session) {
+  const userId = session.userId;
+  const transcriptionToken = transcriptionGatewayToken(session);
   const awemeId = clean(pickParam(request, 'awemeId'));
   const analysisId = clean(pickParam(request, 'analysisId'));
   if (!awemeId) return response.json({ code: 400, success: false, error: '缺少 awemeId' });
@@ -162,16 +162,17 @@ async function startTranscript(request, response, userId) {
     updatedAt: now,
   };
 
-  if (!VOC_TOKEN) {
+  if (!transcriptionToken) {
     job.status = 'needs_provider_config';
-    job.warnings.push('云函数未配置 VOC_TOKEN、TRANSCRIPTION_VOC_TOKEN 或 VOICE_TOKEN,无法调用转写网关。');
+    job.warnings.push('当前请求缺少可用于转写网关的登录态 token,请重新登录后再试。');
     await upsertTranscriptJob(userId, job);
     return response.json({ code: 200, success: true, data: job });
   }
 
   try {
-    const detail = await fetchDouyinDetail(awemeId);
-    const media = selectAudioCandidate(detail);
+    const detailResponse = await fetchDouyinDetail(awemeId);
+    const detail = findAwemeDetail(detailResponse) || detailResponse;
+    const media = selectMediaCandidate(detail, 'audio');
     if (!media.url) {
       job.status = 'needs_media';
       job.warnings.push('视频详情中未找到可直接提交转写的音频地址。');
@@ -179,7 +180,7 @@ async function startTranscript(request, response, userId) {
       return response.json({ code: 200, success: true, data: job });
     }
 
-    const durationMs = media.durationMs || extractDurationMs(detail);
+    const durationMs = media.durationMs || durationFromDetail(detail) || extractDurationMs(detail);
     if (!durationMs) {
       job.status = 'needs_media';
       job.warnings.push('未能确认音频时长,转写网关需要 durationMs。');
@@ -187,12 +188,18 @@ async function startTranscript(request, response, userId) {
       return response.json({ code: 200, success: true, data: job });
     }
 
-    const uploaded = await uploadGatewayAudio(media.url, durationMs);
+    const uploaded = await uploadGatewayAudio(media.url, durationMs, transcriptionToken, {
+      sourceKind: media.kind,
+      keyPath: media.keyPath,
+    });
     job.orderId = uploaded.orderId;
     job.estimateTime = uploaded.estimateTime || 0;
     job.mediaUrl = media.url;
+    job.sourceKind = media.kind === 'audio' ? 'douyin_audio' : 'douyin_video';
+    job.mediaKeyPath = media.keyPath;
     job.durationMs = durationMs;
     job.warnings.push(`已提交转写任务:${uploaded.orderId}`);
+    if (media.keyPath) job.warnings.push(`媒体来源:${media.kind || 'media'} ${media.keyPath}`);
     await upsertTranscriptJob(userId, job);
     response.json({ code: 200, success: true, data: job });
   } catch (error) {
@@ -204,8 +211,10 @@ async function startTranscript(request, response, userId) {
   }
 }
 
-async function getTranscript(request, response, userId) {
-  const id = clean(pickParam(request, 'id'));
+async function getTranscript(request, response, session) {
+  const userId = session.userId;
+  const transcriptionToken = transcriptionGatewayToken(session);
+  const id = clean(pickParam(request, 'jobId', 'recordId', 'id'));
   if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
   const rows = await Psql.query(
     `SELECT * FROM "VideoflowTranscriptJob" WHERE "bizId"=$1 AND "userId"=$2 LIMIT 1`,
@@ -218,15 +227,15 @@ async function getTranscript(request, response, userId) {
     return response.json({ code: 200, success: true, data: job });
   }
 
-  if (!VOC_TOKEN) {
+  if (!transcriptionToken) {
     job.status = 'needs_provider_config';
-    job.warnings = [...(job.warnings || []), '云函数未配置 VOC_TOKEN、TRANSCRIPTION_VOC_TOKEN 或 VOICE_TOKEN,无法查询转写网关。'];
+    job.warnings = [...(job.warnings || []), '当前请求缺少可用于转写网关的登录态 token,请重新登录后再试。'];
     await upsertTranscriptJob(userId, job);
     return response.json({ code: 200, success: true, data: job });
   }
 
   try {
-    const data = await queryGateway(job.orderId);
+    const data = await queryGateway(job.orderId, transcriptionToken);
     const status = String(gatewayValue(data, 'status') || '').toLowerCase();
     const text = clean(gatewayValue(data, 'text'));
     const segments = normalizeGatewaySegments(gatewayValue(data, 'segments'));
@@ -236,7 +245,7 @@ async function getTranscript(request, response, userId) {
       job.segments = segments;
     } else if (status === 'failed' || status === 'error') {
       job.status = 'failed';
-      job.errorMessage = gatewayValue(data, 'error') || gatewayValue(data, 'message') || '转写失败';
+      job.errorMessage = readGatewayError(data) || '转写失败';
       job.warnings = [...(job.warnings || []), job.errorMessage];
     } else {
       job.status = 'pending';
@@ -314,8 +323,9 @@ async function diagnose(request, response) {
       isTikhubDirect: IS_TIKHUB_DIRECT,
       douyinTokenConfigured: !!DOUYIN_API_TOKEN,
       douyinTokenSource: douyinTokenSource(),
-      transcriptTokenConfigured: !!VOC_TOKEN,
-      transcriptTokenSource: transcriptTokenSource(),
+      transcriptTokenConfigured: !!TRANSCRIPTION_GATEWAY_TOKEN,
+      transcriptTokenSource: transcriptTokenSource(TRANSCRIPTION_GATEWAY_TOKEN),
+      transcriptUsesCurrentSession: true,
       transcriptionGateway: maskBaseUrl(TRANSCRIPTION_GATEWAY),
       probe: null,
     }
@@ -354,11 +364,11 @@ async function diagnose(request, response) {
   const sessionToken = clean(pickParam(request, 'sessionToken'));
   if (sessionToken) {
     try {
-      const user = await verifyParseSessionFromDb(sessionToken) || await verifyParseSession(sessionToken);
+      const session = await optionalSession({ body: { sessionToken } }, Psql);
       result.data.session = {
-        success: !!user?.objectId,
-        userId: user?.objectId || '',
-        source: user?.source || '',
+        success: !!session?.userId,
+        userId: session?.userId || '',
+        source: 'parse-helper',
       };
     } catch (error) {
       result.data.session = {
@@ -378,32 +388,156 @@ function normalizeDouyinPath(path) {
   return path;
 }
 
-function selectAudioCandidate(detail) {
-  const candidates = [];
-  collectMediaUrls(detail, candidates, []);
-  const audio = candidates.find(item => item.kind === 'audio') || candidates.find(item => /audio|mp4a|music|sound/i.test(item.url));
-  return audio || { url: '', durationMs: extractDurationMs(detail) };
+function findAwemeDetail(node, depth = 0) {
+  if (!node || depth > 8) return null;
+  if (Array.isArray(node)) {
+    for (const item of node) {
+      const found = findAwemeDetail(item, depth + 1);
+      if (found) return found;
+    }
+    return null;
+  }
+  if (typeof node !== 'object') return null;
+  if (node.aweme_detail) return findAwemeDetail(node.aweme_detail, depth + 1) || node.aweme_detail;
+  if (node.aweme_info) return findAwemeDetail(node.aweme_info, depth + 1) || node.aweme_info;
+  if (node.aweme_id && node.video) return node;
+  for (const value of Object.values(node)) {
+    const found = findAwemeDetail(value, depth + 1);
+    if (found) return found;
+  }
+  return null;
+}
+
+function inferUrlKind(pathParts, url) {
+  const joined = pathParts.join('.').toLowerCase();
+  if (/cover|poster|image|thumb|avatar|sticker/.test(joined)) return 'image';
+  if (/audio|mp4a|music|sound/.test(joined) || /media-audio|audio/.test(url)) return 'audio';
+  if (/play_addr|download_addr|bit_rate|video|media-video/.test(joined) || isLikelyMediaUrl(url)) return 'video';
+  return 'unknown';
 }
 
-function collectMediaUrls(value, out, path) {
-  if (!value) return;
-  if (Array.isArray(value)) {
-    value.forEach((item, index) => collectMediaUrls(item, out, [...path, String(index)]));
-    return;
+function collectMediaCandidates(node, pathParts = [], out = []) {
+  if (!node) return out;
+  if (Array.isArray(node)) {
+    node.forEach((item, index) => collectMediaCandidates(item, [...pathParts, String(index)], out));
+    return out;
   }
-  if (typeof value !== 'object') return;
-  for (const [key, raw] of Object.entries(value)) {
-    const keyPath = [...path, key].join('.');
-    if (typeof raw === 'string' && /^https?:\/\//i.test(raw)) {
-      const joined = keyPath.toLowerCase();
-      const kind = /audio|music|sound|mp4a/.test(joined) ? 'audio' : /video|play|download|media/.test(joined) ? 'video' : '';
-      if (kind) out.push({ url: raw, kind, keyPath, durationMs: extractDurationMs(value) });
-    } else if (raw && typeof raw === 'object') {
-      collectMediaUrls(raw, out, [...path, key]);
+  if (typeof node !== 'object') return out;
+
+  Object.entries(node).forEach(([key, value]) => {
+    const nextPath = [...pathParts, key];
+    if (key === 'url_list' && Array.isArray(value)) {
+      value.forEach((item, index) => {
+        const url = decodeMaybeBase64Url(item);
+        const kind = inferUrlKind(nextPath, url);
+        if (url && kind !== 'image') {
+          out.push({
+            url,
+            kind,
+            keyPath: nextPath.join('.'),
+            index,
+            durationMs: durationFromDetail(node),
+            dataSize: Number(node.data_size || node.size || 0),
+            bitRate: Number(node.bit_rate || node.bitrate || node.real_bitrate || node.avg_bitrate || 0),
+          });
+        }
+      });
+    } else if (['main_url', 'backup_url', 'backup_url_1', 'url'].includes(key) && typeof value === 'string') {
+      const url = decodeMaybeBase64Url(value);
+      const kind = inferUrlKind(nextPath, url);
+      if (url && kind !== 'image') {
+        out.push({
+          url,
+          kind,
+          keyPath: nextPath.join('.'),
+          durationMs: durationFromDetail(node),
+          dataSize: Number(node.data_size || node.size || 0),
+          bitRate: Number(node.bit_rate || node.bitrate || node.real_bitrate || node.avg_bitrate || 0),
+        });
+      }
     }
+    collectMediaCandidates(value, nextPath, out);
+  });
+  return out;
+}
+
+function selectMediaCandidate(detail, preferred = 'audio') {
+  const candidates = collectMediaCandidates(detail)
+    .filter(item => ['audio', 'video'].includes(item.kind))
+    .filter(item => isLikelyMediaUrl(item.url));
+  const seen = new Set();
+  const unique = candidates.filter(item => {
+    if (seen.has(item.url)) return false;
+    seen.add(item.url);
+    return true;
+  });
+  unique.sort((a, b) => {
+    const aPreferred = a.kind === preferred ? 0 : 1;
+    const bPreferred = b.kind === preferred ? 0 : 1;
+    if (aPreferred !== bPreferred) return aPreferred - bPreferred;
+    const rank = item => {
+      const keyPath = String(item.keyPath || '').toLowerCase();
+      if (preferred === 'audio' && item.kind === 'audio') {
+        if (keyPath.includes('video.dynamic_audio_list') || keyPath.includes('video.bit_rate_audio')) return 0;
+        if (keyPath.includes('video.')) return 1;
+        if (keyPath.includes('music.')) return 2;
+      }
+      if (item.kind === preferred) return 3;
+      return 4;
+    };
+    const aRank = rank(a);
+    const bRank = rank(b);
+    if (aRank !== bRank) return aRank - bRank;
+    const aSize = a.dataSize || Number.MAX_SAFE_INTEGER;
+    const bSize = b.dataSize || Number.MAX_SAFE_INTEGER;
+    if (aSize !== bSize) return aSize - bSize;
+    return (a.bitRate || 0) - (b.bitRate || 0);
+  });
+  return unique[0] || { url: '', kind: '', keyPath: '', durationMs: durationFromDetail(detail) };
+}
+
+function isLikelyMediaUrl(value) {
+  if (!/^https?:\/\//i.test(String(value || ''))) return false;
+  try {
+    const parsed = new URL(String(value));
+    const pathName = parsed.pathname.toLowerCase();
+    const host = parsed.hostname.toLowerCase();
+    if (/\.(wav|mp3|m4a|aac|flac|ogg|oga|opus|pcm|mp4|mov|m4v|webm|mkv)$/i.test(pathName)) return true;
+    if (host.includes('douyinvod.com')) return true;
+    if (host.includes('amemv.com') && pathName.includes('/aweme/v1/play')) return true;
+    const mime = parsed.searchParams.get('mime_type') || '';
+    return /audio|video_mp4|video/.test(mime);
+  } catch {
+    return false;
   }
 }
 
+function decodeMaybeBase64Url(value) {
+  const text = clean(value);
+  if (!text) return '';
+  if (/^https?:\/\//i.test(text)) return text;
+  if (!/^[A-Za-z0-9+/=_-]+$/.test(text) || text.length < 24) return '';
+  try {
+    const decoded = atobCompatible(text.replace(/-/g, '+').replace(/_/g, '/'));
+    return /^https?:\/\//i.test(decoded) ? decoded : '';
+  } catch {
+    return '';
+  }
+}
+
+function atobCompatible(value) {
+  if (typeof atob === 'function') return atob(value);
+  if (typeof Buffer !== 'undefined') return Buffer.from(value, 'base64').toString('utf8');
+  return '';
+}
+
+function durationFromDetail(detail) {
+  const raw = detail?.video?.duration || detail?.duration || detail?.video_duration || detail?.durationMs;
+  const numeric = Number(raw || 0);
+  if (!Number.isFinite(numeric) || numeric <= 0) return 0;
+  return numeric > 10000 ? Math.round(numeric) : Math.round(numeric * 1000);
+}
+
 function extractDurationMs(value) {
   const found = findFirstNumber(value, ['duration', 'duration_ms', 'durationMs', 'video_duration']);
   if (!found) return 0;
@@ -431,57 +565,74 @@ function findFirstNumber(value, keys, depth = 0) {
   return 0;
 }
 
-async function uploadGatewayAudio(mediaUrl, durationMs) {
+async function uploadGatewayAudio(mediaUrl, durationMs, token, context = {}) {
   let mediaResp;
   try {
-    mediaResp = await fetch(mediaUrl);
+    mediaResp = await fetchWithTimeout(mediaUrl, {
+      method: 'GET',
+      headers: {
+        Accept: '*/*',
+        'User-Agent': 'Mozilla/5.0',
+        Referer: 'https://www.douyin.com/',
+        Origin: 'https://www.douyin.com',
+      },
+    }, 60000, '下载音频');
   } catch (error) {
     throw new Error(`音频下载网络失败:${formatFetchError(error)}`);
   }
   if (!mediaResp.ok) throw new Error(`音频下载失败 HTTP ${mediaResp.status}`);
-  const blob = await mediaResp.blob();
+  const contentType = mediaResp.headers?.get?.('content-type') || '';
+  const arrayBuffer = await mediaResp.arrayBuffer();
+  const mimeType = normalizeAudioMime(contentType, mediaUrl);
+  const filename = `douyin-audio-${Date.now()}${extensionFromUrl(mediaUrl, mimeType)}`;
+  const blob = new Blob([arrayBuffer], { type: mimeType });
   const form = new FormData();
-  form.append('audio', blob, `douyin-audio-${Date.now()}.m4a`);
+  form.append('audio', blob, filename);
   form.append('durationMs', String(durationMs));
-  form.append('roleType', '1');
-  form.append('roleNum', '0');
+  form.append('roleType', readEnv('IFLYTEK_IST_ROLE_TYPE') || '1');
+  form.append('roleNum', readEnv('IFLYTEK_IST_ROLE_NUM') || '0');
+  if (readEnv('IFLYTEK_IST_LANGUAGE')) form.append('language', readEnv('IFLYTEK_IST_LANGUAGE'));
+  if (readEnv('IFLYTEK_IST_PD')) form.append('pd', readEnv('IFLYTEK_IST_PD'));
   let resp;
   try {
-    resp = await fetch(`${TRANSCRIPTION_GATEWAY}/upload`, {
+    resp = await fetchWithTimeout(`${TRANSCRIPTION_GATEWAY}/upload`, {
       method: 'POST',
       headers: {
-        Authorization: bearerAuth(VOC_TOKEN),
+        Authorization: bearerAuth(token),
         Accept: 'application/json',
       },
       body: form,
-    });
+    }, 300000, '转写网关上传');
   } catch (error) {
-    throw new Error(`转写网关上传网络失败:${formatFetchError(error)}。gateway=${maskBaseUrl(TRANSCRIPTION_GATEWAY)}`);
+    const mediaSource = context.keyPath ? `,media=${context.sourceKind || 'media'}:${context.keyPath}` : '';
+    throw new Error(`转写网关上传网络失败:${formatFetchError(error)}。gateway=${maskBaseUrl(TRANSCRIPTION_GATEWAY)},tokenSource=${transcriptTokenSource(token)},file=${filename},size=${arrayBuffer.byteLength}${mediaSource}`);
   }
-  const data = await resp.json().catch(() => ({}));
-  if (!resp.ok || data.success === false) throw new Error(data.error?.message || data.error || data.message || `转写上传失败 HTTP ${resp.status}`);
+  const text = await resp.text();
+  const data = parseJson(text) || { rawText: text };
+  if (!resp.ok || data.success === false) throw new Error(readGatewayError(data) || `转写上传失败 HTTP ${resp.status}`);
   const orderId = data.orderId || data.content?.orderId || data.data?.orderId || data.result?.orderId;
   if (!orderId) throw new Error('转写网关未返回 orderId');
   return { orderId, estimateTime: Number(data.estimateTime || data.content?.estimateTime || data.data?.estimateTime || 0) };
 }
 
-async function queryGateway(orderId) {
+async function queryGateway(orderId, token) {
   let resp;
   try {
-    resp = await fetch(`${TRANSCRIPTION_GATEWAY}/result`, {
+    resp = await fetchWithTimeout(`${TRANSCRIPTION_GATEWAY}/result`, {
       method: 'POST',
       headers: {
-        Authorization: bearerAuth(VOC_TOKEN),
+        Authorization: bearerAuth(token),
         Accept: 'application/json',
         'Content-Type': 'application/json',
       },
       body: JSON.stringify({ orderId }),
-    });
+    }, 120000, '转写网关查询');
   } catch (error) {
-    throw new Error(`转写网关查询网络失败:${formatFetchError(error)}。gateway=${maskBaseUrl(TRANSCRIPTION_GATEWAY)}`);
+    throw new Error(`转写网关查询网络失败:${formatFetchError(error)}。gateway=${maskBaseUrl(TRANSCRIPTION_GATEWAY)},tokenSource=${transcriptTokenSource(token)}`);
   }
-  const data = await resp.json().catch(() => ({}));
-  if (!resp.ok) throw new Error(data.error?.message || data.error || data.message || `转写查询失败 HTTP ${resp.status}`);
+  const text = await resp.text();
+  const data = parseJson(text) || { rawText: text };
+  if (!resp.ok) throw new Error(readGatewayError(data) || `转写查询失败 HTTP ${resp.status}`);
   return data;
 }
 
@@ -492,7 +643,16 @@ function gatewayValue(data, key) {
 function readGatewayError(data) {
   const detail = data?.detail;
   if (detail === 'Not Found') return '抖音数据接口地址未找到,请检查 DOUYIN_API_BASE_URL 是否配置为 https://server.fmode.cn/api/voc-social';
-  const message = data?.mess || data?.message || data?.msg || data?.error?.message || data?.error || detail || '';
+  const type = clean(data?.type || data?.error?.type);
+  const raw = data?.mess || data?.message || data?.msg || data?.error?.message || data?.error || detail || data?.rawText || '';
+  const message = typeof raw === 'string'
+    ? raw
+    : raw && typeof raw === 'object'
+      ? JSON.stringify(raw)
+      : String(raw || '');
+  if (/unauthorized/i.test(type) || /unauthorized/i.test(message)) {
+    return '转写网关鉴权失败:请确认当前登录用户的 VOC/APIG 权限、余额和 session token 是否有效。';
+  }
   if (/company或用户信息不存在/.test(String(message))) {
     return '当前抖音数据网关 token 未绑定有效用户或公司,请在云函数配置 DOUYIN_API_TOKEN/VOC_TOKEN/VOC_SOCIAL_TOKEN,不能使用 TikHub token。';
   }
@@ -519,6 +679,49 @@ function bearerAuth(token) {
   return /^Bearer\s+/i.test(value) ? value : `Bearer ${value}`;
 }
 
+async function fetchWithTimeout(url, init, timeoutMs, label) {
+  const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
+  const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
+  try {
+    return await fetch(url, {
+      ...init,
+      signal: controller ? controller.signal : init?.signal,
+    });
+  } catch (error) {
+    if (error && error.name === 'AbortError') {
+      throw new Error(`${label || '请求'}超时(${timeoutMs}ms)`);
+    }
+    throw error;
+  } finally {
+    if (timer) clearTimeout(timer);
+  }
+}
+
+function normalizeAudioMime(contentType, url) {
+  const value = clean(contentType).split(';')[0].toLowerCase();
+  if (/^audio\//.test(value) || value === 'video/mp4') return value;
+  const pathname = (() => {
+    try { return new URL(url).pathname.toLowerCase(); } catch { return String(url || '').toLowerCase(); }
+  })();
+  if (pathname.endsWith('.mp3')) return 'audio/mpeg';
+  if (pathname.endsWith('.wav')) return 'audio/wav';
+  if (pathname.endsWith('.aac')) return 'audio/aac';
+  if (pathname.endsWith('.m4a')) return 'audio/mp4';
+  return 'audio/mp4';
+}
+
+function extensionFromUrl(url, mimeType) {
+  try {
+    const ext = new URL(url).pathname.match(/\.(mp3|m4a|aac|wav|mp4|mov|webm)$/i)?.[0];
+    if (ext) return ext.toLowerCase();
+  } catch {}
+  const mime = clean(mimeType).toLowerCase();
+  if (mime.includes('mpeg')) return '.mp3';
+  if (mime.includes('wav')) return '.wav';
+  if (mime.includes('aac')) return '.aac';
+  return '.m4a';
+}
+
 function douyinTokenSource() {
   if (readEnv('DOUYIN_API_TOKEN')) return 'DOUYIN_API_TOKEN';
   if (readEnv('VOC_TOKEN')) return 'VOC_TOKEN';
@@ -531,16 +734,29 @@ function douyinTokenSource() {
   return IS_TIKHUB_DIRECT ? 'TIKHUB_TOKEN_FALLBACK' : '';
 }
 
-function transcriptTokenSource() {
-  if (readEnv('VOC_TOKEN')) return 'VOC_TOKEN';
+function transcriptionGatewayToken(session) {
+  return clean(session?.sessionToken) || clean(TRANSCRIPTION_GATEWAY_TOKEN);
+}
+
+function transcriptTokenSource(token) {
+  if (token && clean(token).startsWith('r:')) return 'current_user_sessionToken';
   if (readEnv('TRANSCRIPTION_VOC_TOKEN')) return 'TRANSCRIPTION_VOC_TOKEN';
   if (readEnv('VOICE_TOKEN')) return 'VOICE_TOKEN';
   if (readEnv('OPENCLAW_VOC_TOKEN')) return 'OPENCLAW_VOC_TOKEN';
+  if (readEnv('VOC_TOKEN')) return 'VOC_TOKEN';
   if (readEnv('VOC_SOCIAL_TOKEN')) return 'VOC_SOCIAL_TOKEN';
-  if (LOCAL_VOC_TOKEN_FALLBACK) return 'LOCAL_VOC_TOKEN_FALLBACK';
+  if (clean(token)) return 'TRANSCRIPTION_GATEWAY_TOKEN';
   return '';
 }
 
+function parseJson(text) {
+  try {
+    return text ? JSON.parse(text) : null;
+  } catch {
+    return null;
+  }
+}
+
 function formatFetchError(error) {
   const message = error && error.message ? error.message : String(error || 'fetch failed');
   const cause = error && error.cause ? `;cause=${error.cause.code || error.cause.message || error.cause}` : '';
@@ -563,65 +779,6 @@ function pickParam(request, ...names) {
   return null;
 }
 
-async function requireSession(request) {
-  const token = clean(pickParam(request, 'sessionToken'));
-  if (!token) throw new Error('请先登录');
-  const rows = await Psql.query(
-    `SELECT * FROM "AppSession" WHERE "token"=$1 AND "expiresAt" > NOW() LIMIT 1`,
-    [token]
-  );
-  if (rows.length) return rows[0];
-
-  const parseUser = await verifyParseSessionFromDb(token) || await verifyParseSession(token);
-  if (parseUser?.objectId) return { token, userId: parseUser.objectId, source: 'parse' };
-  throw new Error('登录已过期,请重新登录');
-}
-
-async function verifyParseSessionFromDb(sessionToken) {
-  try {
-    const rows = await Psql.query(
-      `SELECT s."objectId" AS "sessionObjectId",
-              s."sessionToken" AS "sessionToken",
-              s."expiresAt" AS "expiresAt",
-              s."_p_user" AS "userPointer",
-              u."objectId" AS "objectId",
-              u."username" AS "username"
-         FROM "_Session" s
-         LEFT JOIN "_User" u ON s."_p_user" = CONCAT('_User$', u."objectId")
-        WHERE s."sessionToken"=$1
-          AND (s."expiresAt" IS NULL OR s."expiresAt" > NOW())
-        LIMIT 1`,
-      [sessionToken]
-    );
-    if (!rows.length) return null;
-    const row = rows[0];
-    const pointerUserId = String(row.userPointer || '').replace(/^_User\$/, '');
-    const objectId = row.objectId || pointerUserId;
-    return objectId ? { objectId, username: row.username || '', source: 'parse-db' } : null;
-  } catch (error) {
-    console.warn('verifyParseSessionFromDb skipped:', error.message);
-    return null;
-  }
-}
-
-async function verifyParseSession(sessionToken) {
-  if (typeof fetch !== 'function') return null;
-  try {
-    const resp = await fetch(`${PARSE_API_HOST}/parse/users/me?include=company`, {
-      method: 'GET',
-      headers: {
-        'X-Parse-Application-Id': PARSE_APP_ID,
-        'X-Parse-Session-Token': sessionToken,
-      },
-    });
-    const data = await resp.json().catch(() => ({}));
-    if (!resp.ok) throw new Error(data.error || data.message || `HTTP ${resp.status}`);
-    return data.objectId ? { ...data, source: 'parse-rest' } : null;
-  } catch (error) {
-    throw new Error(`Parse 会话网络校验失败:${formatFetchError(error)}`);
-  }
-}
-
 function clean(value) {
   return String(value || '').trim();
 }
@@ -639,3 +796,17 @@ function readEnv(name) {
   }
   return '';
 }
+
+if (typeof module !== 'undefined' && module.exports && readEnv('NODE_ENV') === 'test') {
+  module.exports = {
+    __test: {
+      findAwemeDetail,
+      selectMediaCandidate,
+      decodeMaybeBase64Url,
+      durationFromDetail,
+      transcriptionGatewayToken,
+      transcriptTokenSource,
+      readGatewayError,
+    },
+  };
+}

+ 108 - 0
cloud-functions/13-douyinInsightManager.spec.js

@@ -0,0 +1,108 @@
+const modulePath = './13-douyinInsightManager';
+const transcriptionEnvNames = [
+  'TRANSCRIPTION_VOC_TOKEN',
+  'VOICE_TOKEN',
+  'OPENCLAW_VOC_TOKEN',
+  'VOC_TOKEN',
+  'VOC_SOCIAL_TOKEN',
+];
+const originalNodeEnv = process.env.NODE_ENV;
+const originalTranscriptionEnv = Object.fromEntries(transcriptionEnvNames.map(name => [name, process.env[name]]));
+
+function loadHelpers(env = {}) {
+  process.env.NODE_ENV = 'test';
+  transcriptionEnvNames.forEach(name => {
+    delete process.env[name];
+  });
+  Object.entries(env).forEach(([key, value]) => {
+    process.env[key] = value;
+  });
+  delete require.cache[require.resolve(modulePath)];
+  return require(modulePath).__test;
+}
+
+describe('douyinInsightManager transcript helpers', () => {
+  afterAll(() => {
+    process.env.NODE_ENV = originalNodeEnv;
+    transcriptionEnvNames.forEach(name => {
+      if (originalTranscriptionEnv[name] === undefined) {
+        delete process.env[name];
+      } else {
+        process.env[name] = originalTranscriptionEnv[name];
+      }
+    });
+    delete require.cache[require.resolve(modulePath)];
+  });
+
+  it('prefers Douyin video audio candidates before music fallback', () => {
+    const helpers = loadHelpers();
+    const payload = {
+      data: {
+        aweme_detail: {
+          aweme_id: '7636915997076770089',
+          video: {
+            duration: 380,
+            dynamic_audio_list: [
+              {
+                url_list: ['https://sf6-cdn-tos.douyinstatic.com/obj/ies-music/dynamic-audio.m4a'],
+                data_size: 1200,
+                bit_rate: 64000,
+              },
+            ],
+            play_addr: {
+              url_list: ['https://v3-web.douyinvod.com/aweme/v1/play/video.mp4?mime_type=video_mp4'],
+            },
+          },
+          music: {
+            play_url: {
+              url_list: ['https://sf6-cdn-tos.douyinstatic.com/obj/ies-music/music-fallback.mp3'],
+            },
+          },
+        },
+      },
+    };
+
+    const detail = helpers.findAwemeDetail(payload);
+    const selected = helpers.selectMediaCandidate(detail, 'audio');
+
+    expect(selected.url).toContain('dynamic-audio.m4a');
+    expect(selected.kind).toBe('audio');
+    expect(selected.keyPath).toBe('video.dynamic_audio_list.0.url_list');
+    expect(selected.durationMs).toBe(0);
+    expect(helpers.durationFromDetail(detail)).toBe(380000);
+  });
+
+  it('decodes base64 encoded media urls from Douyin payloads', () => {
+    const helpers = loadHelpers();
+    const encoded = Buffer.from('https://v3-web.douyinvod.com/aweme/v1/play/video.mp4?mime_type=video_mp4').toString('base64');
+
+    expect(helpers.decodeMaybeBase64Url(encoded)).toContain('/aweme/v1/play/video.mp4');
+  });
+
+  it('uses current Parse session token for transcription gateway auth', () => {
+    const helpers = loadHelpers({ TRANSCRIPTION_VOC_TOKEN: 'global-token' });
+
+    expect(helpers.transcriptionGatewayToken({ sessionToken: 'r:current-user-session' })).toBe('r:current-user-session');
+    expect(helpers.transcriptTokenSource('r:current-user-session')).toBe('current_user_sessionToken');
+  });
+
+  it('can fall back to a configured transcription gateway token for diagnostics or legacy callers', () => {
+    const helpers = loadHelpers({ TRANSCRIPTION_VOC_TOKEN: 'gateway-token' });
+
+    expect(helpers.transcriptionGatewayToken()).toBe('gateway-token');
+    expect(helpers.transcriptTokenSource('gateway-token')).toBe('TRANSCRIPTION_VOC_TOKEN');
+  });
+
+  it('reports no gateway token when neither session nor env token is available', () => {
+    const helpers = loadHelpers();
+
+    expect(helpers.transcriptionGatewayToken()).toBe('');
+    expect(helpers.transcriptTokenSource('')).toBe('');
+  });
+
+  it('formats gateway unauthorized responses as operator-readable token errors', () => {
+    const helpers = loadHelpers();
+
+    expect(helpers.readGatewayError({ type: 'unauthorized' })).toContain('转写网关鉴权失败');
+  });
+});

+ 449 - 0
cloud-functions/14-systemStorageManager.js

@@ -0,0 +1,449 @@
+/**
+ * 云函数:systemStorageManager
+ *
+ * 通用业务实体、审计和迁移状态的云端入口。
+ * 数据主存储为 Parse 项目命名空间 Class:
+ * - VideoWorkflowEntity
+ * - VideoWorkflowAudit
+ * - VideoWorkflowMigration
+ *
+ * 所有数据归属都通过 _session.js 从 Parse sessionToken 解析得到。
+ */
+const { requireSession, requireAdmin, assertRequestedUserMatchesSession } = require('./_session');
+const {
+  VIDEO_WORKFLOW_CLASSES,
+  createParseClassStore,
+  parseUserFields,
+  parseUserWhere,
+} = require('./_parseClassStore');
+
+async function handler(request, response) {
+  try {
+    const action = pickParam(request, 'action') || 'list';
+    if (action === 'adminInspect') {
+      const adminSession = await requireAdmin(request, Psql);
+      const store = createParseClassStore({ sessionToken: adminSession.sessionToken });
+      return adminInspect(request, response, adminSession, store);
+    }
+
+    const session = await requireSession(request, Psql);
+    assertRequestedUserMatchesSession(request, session);
+    const store = createParseClassStore({ sessionToken: session.sessionToken });
+    const userId = session.userId;
+
+    if (action === 'list') return listEntities(request, response, userId, store);
+    if (action === 'get') return getEntity(request, response, userId, store);
+    if (action === 'upsert') return upsertEntity(request, response, userId, store);
+    if (action === 'patch') return patchEntity(request, response, userId, store);
+    if (action === 'delete') return deleteEntity(request, response, userId, store);
+    if (action === 'purge') return purgeEntity(request, response, userId, store);
+    if (action === 'audit') return writeAudit(request, response, userId, store);
+    if (action === 'auditList') return listAudits(request, response, userId, store);
+    if (action === 'stats') return getStats(request, response, userId, store);
+    if (action === 'migrationGet') return getMigration(request, response, userId, store);
+    if (action === 'migrationSet') return setMigration(request, response, userId, store);
+
+    return response.json({ code: 400, success: false, error: `未知 action: ${action}` });
+  } catch (error) {
+    console.error('systemStorageManager failed:', error && error.message);
+    return response.json({ code: error.status || 500, success: false, error: error.message });
+  }
+}
+
+async function listEntities(request, response, userId, store) {
+  const entityType = requiredText(request, 'entityType', 'type');
+  const status = pickParam(request, 'status') || 'active';
+  const limit = safeLimit(pickParam(request, 'limit'), 100, 1000);
+  const where = parseUserWhere(userId, {
+    entityType,
+    ...(status ? { status } : {}),
+  });
+  const rows = await store.list(VIDEO_WORKFLOW_CLASSES.entity, where, { order: '-updatedAt', limit });
+  return response.json({ code: 200, success: true, data: rows.map(rowToEntity) });
+}
+
+async function getEntity(request, response, userId, store) {
+  const entityType = requiredText(request, 'entityType', 'type');
+  const entityId = requiredText(request, 'entityId', 'id', 'bizId');
+  const row = await findEntity(store, userId, entityType, entityId);
+  return response.json({ code: 200, success: true, data: row ? rowToEntity(row) : null });
+}
+
+async function upsertEntity(request, response, userId, store) {
+  const entityType = requiredText(request, 'entityType', 'type');
+  const entityId = requiredText(request, 'entityId', 'id', 'bizId');
+  const data = normalizeObject(pickParam(request, 'data', 'entity') || {});
+  // 行状态只表达存储生命周期,业务状态继续放在 data.status,避免 completed/failed 等业务态污染刷新查询。
+  const status = pickParam(request, 'status') || 'active';
+  const schemaVersion = Number(pickParam(request, 'schemaVersion') || data.schemaVersion || 1);
+  const spaceId = pickParam(request, 'spaceId') || data.spaceId || '';
+  const row = await saveEntity(store, { userId, entityType, entityId, data, status, schemaVersion, spaceId });
+  return response.json({ code: 200, success: true, data: rowToEntity(row) });
+}
+
+async function patchEntity(request, response, userId, store) {
+  const entityType = requiredText(request, 'entityType', 'type');
+  const entityId = requiredText(request, 'entityId', 'id', 'bizId');
+  const patch = normalizeObject(pickParam(request, 'patch', 'data') || {});
+  const previousRow = await findEntity(store, userId, entityType, entityId);
+  const previous = previousRow ? normalizeObject(previousRow.data) : {};
+  const status = pickParam(request, 'status') || previousRow?.status || 'active';
+  const row = await saveEntity(store, {
+    userId,
+    entityType,
+    entityId,
+    data: { ...previous, ...patch, id: entityId },
+    status,
+    schemaVersion: Number(patch.schemaVersion || previousRow?.schemaVersion || 1),
+    spaceId: patch.spaceId || previousRow?.spaceId || '',
+  });
+  return response.json({ code: 200, success: true, data: rowToEntity(row) });
+}
+
+async function deleteEntity(request, response, userId, store) {
+  const entityType = requiredText(request, 'entityType', 'type');
+  const entityId = requiredText(request, 'entityId', 'id', 'bizId');
+  const row = await findEntity(store, userId, entityType, entityId);
+  if (row?.objectId) {
+    await store.update(VIDEO_WORKFLOW_CLASSES.entity, row.objectId, { status: 'deleted' });
+  }
+  return response.json({ code: 200, success: true, data: { entityType, entityId, deleted: true } });
+}
+
+async function purgeEntity(request, response, userId, store) {
+  const entityType = requiredText(request, 'entityType', 'type');
+  const entityId = requiredText(request, 'entityId', 'id', 'bizId');
+  const reason = String(pickParam(request, 'reason') || '').trim();
+  if (!reason) {
+    return response.json({ code: 400, success: false, error: '清理必须提供 reason' });
+  }
+  const row = await findEntity(store, userId, entityType, entityId);
+  const purgedAt = new Date().toISOString();
+  const data = { purged: true, purgedAt, reason, id: entityId, userId };
+  let purged = false;
+  if (row?.objectId) {
+    await store.update(VIDEO_WORKFLOW_CLASSES.entity, row.objectId, {
+      status: 'purged',
+      data,
+    });
+    purged = true;
+  }
+  await createAudit(store, userId, {
+    entityType,
+    entityId,
+    action: 'purge',
+    summary: `清理 ${entityType}:${entityId}`,
+    detail: { reason, purged },
+  });
+  return response.json({
+    code: 200,
+    success: true,
+    data: { entityType, entityId, purged },
+  });
+}
+
+async function writeAudit(request, response, userId, store) {
+  const action = requiredText(request, 'auditAction', 'eventAction', 'name');
+  const entityType = pickParam(request, 'entityType') || '';
+  const entityId = pickParam(request, 'entityId', 'id') || '';
+  const summary = String(pickParam(request, 'summary') || '').slice(0, 500);
+  const detail = normalizeObject(pickParam(request, 'detail') || {});
+  const row = await createAudit(store, userId, { entityType, entityId, action, summary, detail });
+  return response.json({ code: 200, success: true, data: rowToAudit(row) });
+}
+
+async function listAudits(request, response, userId, store) {
+  const limit = safeLimit(pickParam(request, 'limit'), 50, 200);
+  const entityType = String(pickParam(request, 'entityType') || '').trim();
+  const where = parseUserWhere(userId, entityType ? { entityType } : {});
+  const rows = await store.list(VIDEO_WORKFLOW_CLASSES.audit, where, { order: '-createdAt', limit });
+  return response.json({ code: 200, success: true, data: rows.map(rowToAudit) });
+}
+
+async function getStats(request, response, userId, store) {
+  const [entityRows, auditRows, migrationRows] = await Promise.all([
+    listAll(store, VIDEO_WORKFLOW_CLASSES.entity, parseUserWhere(userId, {}), 5000),
+    listAll(store, VIDEO_WORKFLOW_CLASSES.audit, parseUserWhere(userId, {}), 1000),
+    listAll(store, VIDEO_WORKFLOW_CLASSES.migration, parseUserWhere(userId, {}), 1000),
+  ]);
+  return response.json({
+    code: 200,
+    success: true,
+    data: buildStats(entityRows, auditRows, migrationRows),
+  });
+}
+
+async function adminInspect(request, response, adminSession, store) {
+  const targetUserId = String(pickParam(request, 'targetUserId', 'inspectUserId') || '').trim();
+  const limit = safeLimit(pickParam(request, 'limit'), 100, 500);
+  const userWhere = targetUserId ? { ownerId: targetUserId } : {};
+  const [entityRows, auditRows, migrationRows] = await Promise.all([
+    listAll(store, VIDEO_WORKFLOW_CLASSES.entity, parseUserWhereForAdmin(userWhere), limit * 20),
+    listAll(store, VIDEO_WORKFLOW_CLASSES.audit, parseUserWhereForAdmin(userWhere), limit * 20),
+    listAll(store, VIDEO_WORKFLOW_CLASSES.migration, parseUserWhereForAdmin(userWhere), limit * 20),
+  ]);
+  const users = {};
+  const ensureUser = (userId) => {
+    if (!users[userId]) {
+      users[userId] = {
+        userId,
+        totalEntities: 0,
+        entityTypes: {},
+        auditCount: 0,
+        latestAuditAt: '',
+        migrations: { total: 0, failedRows: 0, byStatus: {} },
+        latestUpdatedAt: '',
+      };
+    }
+    return users[userId];
+  };
+
+  for (const row of entityRows) {
+    const user = ensureUser(row.ownerId || '');
+    const entityType = row.entityType || '';
+    const status = row.status || 'active';
+    if (!entityType || !user.userId) continue;
+    if (!user.entityTypes[entityType]) user.entityTypes[entityType] = { total: 0, byStatus: {} };
+    user.entityTypes[entityType].total += 1;
+    user.entityTypes[entityType].byStatus[status] = (user.entityTypes[entityType].byStatus[status] || 0) + 1;
+    user.totalEntities += 1;
+    const latest = row.updatedAt || '';
+    if (latest && String(latest) > String(user.latestUpdatedAt || '')) user.latestUpdatedAt = latest;
+  }
+  for (const row of auditRows) {
+    const user = ensureUser(row.ownerId || '');
+    if (!user.userId) continue;
+    user.auditCount += 1;
+    const latest = row.createdAt || '';
+    if (latest && String(latest) > String(user.latestAuditAt || '')) user.latestAuditAt = latest;
+  }
+  for (const row of migrationRows) {
+    const user = ensureUser(row.ownerId || '');
+    if (!user.userId) continue;
+    const status = row.status || 'unknown';
+    user.migrations.total += 1;
+    user.migrations.byStatus[status] = (user.migrations.byStatus[status] || 0) + 1;
+    user.migrations.failedRows += Number(row.failedCount || 0);
+  }
+
+  const rows = Object.values(users)
+    .sort((a, b) => b.totalEntities - a.totalEntities || String(b.latestUpdatedAt || '').localeCompare(String(a.latestUpdatedAt || '')))
+    .slice(0, limit);
+  return response.json({
+    code: 200,
+    success: true,
+    data: {
+      adminUserId: adminSession.userId,
+      targetUserId,
+      users: rows,
+      generatedAt: new Date().toISOString(),
+    },
+  });
+}
+
+async function getMigration(request, response, userId, store) {
+  const migrationKey = requiredText(request, 'migrationKey');
+  const sourceKey = requiredText(request, 'sourceKey');
+  const row = await findMigration(store, userId, migrationKey, sourceKey);
+  return response.json({ code: 200, success: true, data: row ? rowToMigration(row) : null });
+}
+
+async function setMigration(request, response, userId, store) {
+  const migrationKey = requiredText(request, 'migrationKey');
+  const sourceKey = requiredText(request, 'sourceKey');
+  const status = pickParam(request, 'status') || 'completed';
+  const migratedCount = Number(pickParam(request, 'migratedCount') || 0);
+  const failedCount = Number(pickParam(request, 'failedCount') || 0);
+  const detail = normalizeObject(pickParam(request, 'detail') || {});
+  const where = parseUserWhere(userId, { migrationKey, sourceKey });
+  const payload = {
+    ...parseUserFields(userId),
+    migrationKey,
+    sourceKey,
+    status,
+    migratedCount,
+    failedCount,
+    detail,
+  };
+  const row = await upsertMigrationWithDetailFallback(store, where, payload);
+  return response.json({ code: 200, success: true, data: rowToMigration(row) });
+}
+
+async function upsertMigrationWithDetailFallback(store, where, payload) {
+  try {
+    return await store.upsertByQuery(VIDEO_WORKFLOW_CLASSES.migration, where, payload);
+  } catch (error) {
+    if (!isParseFieldTypeMismatch(error) || typeof payload.detail === 'string') {
+      throw error;
+    }
+    return store.upsertByQuery(VIDEO_WORKFLOW_CLASSES.migration, where, {
+      ...payload,
+      detail: safeStringify(payload.detail),
+    });
+  }
+}
+
+async function saveEntity(store, { userId, entityType, entityId, data, status, schemaVersion, spaceId }) {
+  const rawData = normalizeObject(data);
+  const payload = { ...rawData, id: rawData.id || entityId, userId };
+  const where = parseUserWhere(userId, { entityType, entityId });
+  return store.upsertByQuery(VIDEO_WORKFLOW_CLASSES.entity, where, {
+    ...parseUserFields(userId),
+    entityType,
+    entityId,
+    status: status || 'active',
+    schemaVersion: schemaVersion || 1,
+    spaceId: spaceId || '',
+    data: payload,
+  });
+}
+
+async function findEntity(store, userId, entityType, entityId) {
+  return store.findFirst(VIDEO_WORKFLOW_CLASSES.entity, parseUserWhere(userId, { entityType, entityId }), { limit: 1 });
+}
+
+async function findMigration(store, userId, migrationKey, sourceKey) {
+  return store.findFirst(VIDEO_WORKFLOW_CLASSES.migration, parseUserWhere(userId, { migrationKey, sourceKey }), { limit: 1 });
+}
+
+async function createAudit(store, userId, { entityType, entityId, action, summary, detail }) {
+  return store.create(VIDEO_WORKFLOW_CLASSES.audit, {
+    ...parseUserFields(userId),
+    entityType: entityType || '',
+    entityId: entityId || '',
+    action,
+    summary: String(summary || '').slice(0, 500),
+    detail: safeStringify(detail || {}),
+  });
+}
+
+async function listAll(store, className, where, maxRows) {
+  const all = [];
+  const pageSize = 1000;
+  for (let skip = 0; skip < maxRows; skip += pageSize) {
+    const rows = await store.list(className, where, { order: '-updatedAt', limit: Math.min(pageSize, maxRows - skip), skip });
+    all.push(...rows);
+    if (rows.length < pageSize || all.length >= maxRows) break;
+  }
+  return all.slice(0, maxRows);
+}
+
+function buildStats(entityRows, auditRows, migrationRows) {
+  const entityTypes = {};
+  let totalEntities = 0;
+  for (const row of entityRows) {
+    const entityType = row.entityType || '';
+    const status = row.status || 'active';
+    if (!entityType) continue;
+    if (!entityTypes[entityType]) entityTypes[entityType] = { total: 0, byStatus: {} };
+    entityTypes[entityType].total += 1;
+    entityTypes[entityType].byStatus[status] = (entityTypes[entityType].byStatus[status] || 0) + 1;
+    totalEntities += 1;
+  }
+  const migrations = { total: 0, byStatus: {} };
+  for (const row of migrationRows) {
+    const status = row.status || 'unknown';
+    migrations.total += 1;
+    migrations.byStatus[status] = (migrations.byStatus[status] || 0) + 1;
+  }
+  return {
+    totalEntities,
+    entityTypes,
+    auditCount: auditRows.length,
+    migrations,
+    generatedAt: new Date().toISOString(),
+  };
+}
+
+function parseUserWhereForAdmin(extra = {}) {
+  return { projectKey: 'video-workflow', ...extra };
+}
+
+function rowToEntity(row) {
+  const data = normalizeObject(row.data);
+  return {
+    id: row.entityId,
+    entityId: row.entityId,
+    type: row.entityType,
+    entityType: row.entityType,
+    status: row.status || 'active',
+    schemaVersion: row.schemaVersion,
+    spaceId: row.spaceId || '',
+    data,
+    createdAt: row.createdAt,
+    updatedAt: row.updatedAt,
+  };
+}
+
+function rowToMigration(row) {
+  return {
+    id: row.objectId,
+    migrationKey: row.migrationKey,
+    sourceKey: row.sourceKey,
+    status: row.status,
+    migratedCount: Number(row.migratedCount || 0),
+    failedCount: Number(row.failedCount || 0),
+    detail: normalizeObject(row.detail),
+    createdAt: row.createdAt,
+    updatedAt: row.updatedAt,
+  };
+}
+
+function rowToAudit(row) {
+  return {
+    id: row.objectId,
+    entityType: row.entityType || '',
+    entityId: row.entityId || '',
+    action: row.action,
+    summary: row.summary || '',
+    detail: normalizeObject(row.detail),
+    createdAt: row.createdAt,
+  };
+}
+
+function requiredText(request, ...names) {
+  const value = pickParam(request, ...names);
+  const text = String(value || '').trim();
+  if (!text) {
+    const error = new Error(`缺少 ${names[0]}`);
+    error.status = 400;
+    throw error;
+  }
+  return text;
+}
+
+function pickParam(request, ...names) {
+  const sources = [request.params, request.body, request.query, request];
+  for (const src of sources) {
+    if (!src || typeof src !== 'object') continue;
+    for (const name of names) {
+      const value = src[name];
+      if (value !== undefined && value !== null && value !== '') return value;
+    }
+  }
+  return null;
+}
+
+function normalizeObject(value) {
+  if (typeof value === 'string') {
+    try { return JSON.parse(value); } catch { return {}; }
+  }
+  if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
+  return value;
+}
+
+function safeLimit(value, fallback, max) {
+  const parsed = parseInt(value || fallback, 10);
+  return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), max) : fallback;
+}
+
+function safeStringify(value) {
+  const text = JSON.stringify(normalizeObject(value));
+  return text.length > 8000 ? `${text.slice(0, 7997)}...` : text;
+}
+
+function isParseFieldTypeMismatch(error) {
+  const message = String(error?.message || error?.detail?.error || '').toLowerCase();
+  return Number(error?.status || 0) === 400
+    && (message.includes('schema') || message.includes('type') || message.includes('expected'));
+}

+ 297 - 0
cloud-functions/15-fileAssetManager.js

@@ -0,0 +1,297 @@
+/**
+ * 云函数:fileAssetManager
+ *
+ * 记录七牛文件的归属和生命周期元数据。文件本体仍存放在七牛,
+ * 元数据主存储为 Parse 项目命名空间 Class:VideoWorkflowFileAsset。
+ */
+const { requireSession, requireAdmin, assertRequestedUserMatchesSession } = require('./_session');
+const {
+  VIDEO_WORKFLOW_CLASSES,
+  createParseClassStore,
+  parseUserFields,
+  parseUserWhere,
+} = require('./_parseClassStore');
+
+async function handler(request, response) {
+  try {
+    const action = pickParam(request, 'action') || 'list';
+    if (action === 'adminInspect') {
+      const adminSession = await requireAdmin(request, Psql);
+      const store = createParseClassStore({ sessionToken: adminSession.sessionToken });
+      return adminInspect(request, response, adminSession, store);
+    }
+
+    const session = await requireSession(request, Psql);
+    assertRequestedUserMatchesSession(request, session);
+    const store = createParseClassStore({ sessionToken: session.sessionToken });
+    const userId = session.userId;
+
+    if (action === 'register') return registerAsset(request, response, userId, store);
+    if (action === 'get') return getAsset(request, response, userId, store);
+    if (action === 'list') return listAssets(request, response, userId, store);
+    if (action === 'stats') return getStats(request, response, userId, store);
+    if (action === 'bind') return bindAsset(request, response, userId, store);
+    if (action === 'delete') return deleteAsset(request, response, userId, store);
+    if (action === 'purge') return purgeAsset(request, response, userId, store);
+
+    return response.json({ code: 400, success: false, error: `Unknown action: ${action}` });
+  } catch (error) {
+    console.error('fileAssetManager failed:', error && error.message);
+    return response.json({ code: error.status || 500, success: false, error: error.message || 'fileAssetManager failed' });
+  }
+}
+
+async function registerAsset(request, response, userId, store) {
+  const qiniuKey = requiredText(request, 'qiniuKey', 'key');
+  assertUserScopedKey(qiniuKey, userId);
+  const data = readAssetInput(request);
+  const where = parseUserWhere(userId, { qiniuKey });
+  const row = await store.upsertByQuery(VIDEO_WORKFLOW_CLASSES.fileAsset, where, {
+    ...parseUserFields(userId),
+    spaceId: data.spaceId,
+    qiniuKey,
+    url: data.url,
+    bucket: data.bucket,
+    mimeType: data.mimeType,
+    kind: data.kind,
+    sizeBytes: data.sizeBytes,
+    sha256: data.sha256,
+    sourceModule: data.sourceModule,
+    bizType: data.bizType,
+    bizId: data.bizId,
+    status: data.status,
+    metadata: data.metadata,
+  });
+  return response.json({ code: 200, success: true, data: rowToAsset(row) });
+}
+
+async function getAsset(request, response, userId, store) {
+  const assetId = requiredText(request, 'assetId', 'id');
+  const row = await store.findFirst(VIDEO_WORKFLOW_CLASSES.fileAsset, parseUserWhere(userId, { objectId: assetId }), { limit: 1 });
+  return response.json({ code: 200, success: true, data: row ? rowToAsset(row) : null });
+}
+
+async function listAssets(request, response, userId, store) {
+  const kind = String(pickParam(request, 'kind') || '').trim();
+  const sourceModule = String(pickParam(request, 'sourceModule') || '').trim();
+  const bizType = String(pickParam(request, 'bizType') || '').trim();
+  const bizId = String(pickParam(request, 'bizId') || '').trim();
+  const status = String(pickParam(request, 'status') || 'active').trim();
+  const limit = safeLimit(pickParam(request, 'limit'), 50, 200);
+  const where = parseUserWhere(userId, {
+    ...(kind ? { kind } : {}),
+    ...(sourceModule ? { sourceModule } : {}),
+    ...(bizType ? { bizType } : {}),
+    ...(bizId ? { bizId } : {}),
+    ...(status ? { status } : {}),
+  });
+  const rows = await store.list(VIDEO_WORKFLOW_CLASSES.fileAsset, where, { order: '-createdAt', limit });
+  return response.json({ code: 200, success: true, data: rows.map(rowToAsset) });
+}
+
+async function getStats(request, response, userId, store) {
+  const rows = await listAll(store, VIDEO_WORKFLOW_CLASSES.fileAsset, parseUserWhere(userId, {}), 5000);
+  return response.json({ code: 200, success: true, data: buildStats(rows) });
+}
+
+async function adminInspect(request, response, adminSession, store) {
+  const targetUserId = String(pickParam(request, 'targetUserId', 'inspectUserId') || '').trim();
+  const limit = safeLimit(pickParam(request, 'limit'), 100, 500);
+  const where = targetUserId
+    ? { projectKey: 'video-workflow', ownerId: targetUserId }
+    : { projectKey: 'video-workflow' };
+  const rows = await listAll(store, VIDEO_WORKFLOW_CLASSES.fileAsset, where, limit * 20);
+  const users = {};
+  for (const row of rows) {
+    const userId = row.ownerId || '';
+    if (!userId) continue;
+    if (!users[userId]) {
+      users[userId] = {
+        userId,
+        totalFiles: 0,
+        totalBytes: 0,
+        byKind: {},
+        latestCreatedAt: '',
+      };
+    }
+    addAssetStats(users[userId], row);
+  }
+  return response.json({
+    code: 200,
+    success: true,
+    data: {
+      adminUserId: adminSession.userId,
+      targetUserId,
+      users: Object.values(users)
+        .sort((a, b) => b.totalBytes - a.totalBytes || b.totalFiles - a.totalFiles)
+        .slice(0, limit),
+      generatedAt: new Date().toISOString(),
+    },
+  });
+}
+
+async function bindAsset(request, response, userId, store) {
+  const assetId = requiredText(request, 'assetId', 'id');
+  const row = await store.findFirst(VIDEO_WORKFLOW_CLASSES.fileAsset, parseUserWhere(userId, { objectId: assetId }), { limit: 1 });
+  if (!row?.objectId) return response.json({ code: 200, success: true, data: null });
+  const bizType = String(pickParam(request, 'bizType') || '').trim();
+  const bizId = String(pickParam(request, 'bizId') || '').trim();
+  const sourceModule = String(pickParam(request, 'sourceModule') || '').trim();
+  const patch = {};
+  if (bizType) patch.bizType = bizType;
+  if (bizId) patch.bizId = bizId;
+  if (sourceModule) patch.sourceModule = sourceModule;
+  const updated = Object.keys(patch).length
+    ? await store.update(VIDEO_WORKFLOW_CLASSES.fileAsset, row.objectId, patch)
+    : row;
+  return response.json({ code: 200, success: true, data: rowToAsset(updated) });
+}
+
+async function deleteAsset(request, response, userId, store) {
+  const assetId = requiredText(request, 'assetId', 'id');
+  const row = await store.findFirst(VIDEO_WORKFLOW_CLASSES.fileAsset, parseUserWhere(userId, { objectId: assetId }), { limit: 1 });
+  if (!row?.objectId) return response.json({ code: 200, success: true, data: null });
+  const updated = await store.update(VIDEO_WORKFLOW_CLASSES.fileAsset, row.objectId, { status: 'deleted' });
+  return response.json({ code: 200, success: true, data: rowToAsset(updated) });
+}
+
+async function purgeAsset(request, response, userId, store) {
+  const assetId = requiredText(request, 'assetId', 'id');
+  const reason = String(pickParam(request, 'reason') || '').trim();
+  if (!reason) return response.json({ code: 400, success: false, error: '清理文件资产必须提供 reason' });
+  const row = await store.findFirst(VIDEO_WORKFLOW_CLASSES.fileAsset, parseUserWhere(userId, { objectId: assetId }), { limit: 1 });
+  if (!row?.objectId) return response.json({ code: 200, success: true, data: null });
+  const updated = await store.update(VIDEO_WORKFLOW_CLASSES.fileAsset, row.objectId, {
+    status: 'purged',
+    metadata: { ...normalizeObject(row.metadata), purged: true, purgedAt: new Date().toISOString(), reason },
+  });
+  return response.json({ code: 200, success: true, data: rowToAsset(updated) });
+}
+
+function readAssetInput(request) {
+  return {
+    spaceId: String(pickParam(request, 'spaceId') || ''),
+    url: String(pickParam(request, 'url') || ''),
+    bucket: String(pickParam(request, 'bucket') || ''),
+    mimeType: String(pickParam(request, 'mimeType', 'contentType') || ''),
+    kind: normalizeKind(String(pickParam(request, 'kind') || '')),
+    sizeBytes: Math.max(0, Number(pickParam(request, 'sizeBytes', 'size') || 0)),
+    sha256: String(pickParam(request, 'sha256') || '').slice(0, 128),
+    sourceModule: String(pickParam(request, 'sourceModule') || ''),
+    bizType: String(pickParam(request, 'bizType') || ''),
+    bizId: String(pickParam(request, 'bizId') || ''),
+    status: String(pickParam(request, 'status') || 'active'),
+    metadata: normalizeObject(pickParam(request, 'metadata') || {}),
+  };
+}
+
+function rowToAsset(row) {
+  return {
+    assetId: row.objectId,
+    id: row.objectId,
+    userId: row.ownerId || '',
+    spaceId: row.spaceId || '',
+    qiniuKey: row.qiniuKey,
+    url: row.url || '',
+    bucket: row.bucket || '',
+    mimeType: row.mimeType || '',
+    kind: row.kind || '',
+    sizeBytes: Number(row.sizeBytes || 0),
+    sha256: row.sha256 || '',
+    sourceModule: row.sourceModule || '',
+    bizType: row.bizType || '',
+    bizId: row.bizId || '',
+    status: row.status || 'active',
+    metadata: normalizeObject(row.metadata),
+    createdAt: row.createdAt,
+    updatedAt: row.updatedAt,
+  };
+}
+
+async function listAll(store, className, where, maxRows) {
+  const all = [];
+  const pageSize = 1000;
+  for (let skip = 0; skip < maxRows; skip += pageSize) {
+    const rows = await store.list(className, where, { order: '-createdAt', limit: Math.min(pageSize, maxRows - skip), skip });
+    all.push(...rows);
+    if (rows.length < pageSize || all.length >= maxRows) break;
+  }
+  return all.slice(0, maxRows);
+}
+
+function buildStats(rows) {
+  const byKind = {};
+  let total = 0;
+  let totalBytes = 0;
+  for (const row of rows) {
+    const aggregate = { totalFiles: 0, totalBytes: 0, byKind, latestCreatedAt: '' };
+    addAssetStats(aggregate, row);
+    total += 1;
+    totalBytes += Number(row.sizeBytes || 0);
+  }
+  return { total, totalBytes, byKind, generatedAt: new Date().toISOString() };
+}
+
+function addAssetStats(target, row) {
+  const kind = row.kind || 'unknown';
+  const status = row.status || 'active';
+  const bytes = Number(row.sizeBytes || 0);
+  if (!target.byKind[kind]) target.byKind[kind] = { total: 0, bytes: 0, byStatus: {} };
+  target.byKind[kind].total += 1;
+  target.byKind[kind].bytes += bytes;
+  target.byKind[kind].byStatus[status] = (target.byKind[kind].byStatus[status] || 0) + 1;
+  target.totalFiles += 1;
+  target.totalBytes += bytes;
+  const latest = row.createdAt || '';
+  if (latest && String(latest) > String(target.latestCreatedAt || '')) target.latestCreatedAt = latest;
+}
+
+function assertUserScopedKey(qiniuKey, userId) {
+  const expected = `users/${userId}/`;
+  if (!String(qiniuKey || '').startsWith(expected)) {
+    const error = new Error('File key does not belong to current user partition');
+    error.status = 403;
+    throw error;
+  }
+}
+
+function normalizeKind(kind) {
+  if (['image', 'video', 'audio', 'export', 'snapshot'].includes(kind)) return kind;
+  return 'image';
+}
+
+function requiredText(request, ...names) {
+  const value = pickParam(request, ...names);
+  const text = String(value || '').trim();
+  if (!text) {
+    const error = new Error(`Missing ${names[0]}`);
+    error.status = 400;
+    throw error;
+  }
+  return text;
+}
+
+function pickParam(request, ...names) {
+  const sources = [request.params, request.body, request.query, request];
+  for (const src of sources) {
+    if (!src || typeof src !== 'object') continue;
+    for (const name of names) {
+      const value = src[name];
+      if (value !== undefined && value !== null && value !== '') return value;
+    }
+  }
+  return null;
+}
+
+function normalizeObject(value) {
+  if (typeof value === 'string') {
+    try { return JSON.parse(value); } catch { return {}; }
+  }
+  if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
+  return value;
+}
+
+function safeLimit(value, fallback, max) {
+  const parsed = parseInt(value || fallback, 10);
+  return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), max) : fallback;
+}

+ 248 - 0
cloud-functions/_parseClassStore.js

@@ -0,0 +1,248 @@
+/**
+ * 云函数共享的 Parse Class 存储工具。
+ *
+ * 本项目的结构化业务主数据写入带 VideoWorkflow 命名空间的 Parse Class。
+ * 这里封装 REST 调用、项目命名空间字段和 owner Pointer,避免业务云函数
+ * 继续直接依赖 PSQL。
+ */
+
+const PARSE_CLASS_PROJECT_KEY = 'video-workflow';
+
+const VIDEO_WORKFLOW_CLASSES = {
+  entity: 'VideoWorkflowEntity',
+  audit: 'VideoWorkflowAudit',
+  migration: 'VideoWorkflowMigration',
+  fileAsset: 'VideoWorkflowFileAsset',
+};
+
+function createParseClassStore(options = {}) {
+  const parseApiHost = normalizeParseApiHost(options.parseApiHost || parseStoreEnv('PARSE_API_HOST', parseStoreEnv('PARSE_BASE_URL', 'https://server.fmode.cn')));
+  const appId = options.appId || parseStoreEnv('PARSE_APP_ID', parseStoreEnv('PARSE_APPLICATION_ID', 'ncloudmaster'));
+  const sessionToken = options.sessionToken || '';
+
+  return {
+    findFirst: (className, where, opts = {}) => parseFindFirst({ parseApiHost, appId, sessionToken, className, where, opts }),
+    list: (className, where, opts = {}) => parseList({ parseApiHost, appId, sessionToken, className, where, opts }),
+    create: (className, data) => parseCreate({ parseApiHost, appId, sessionToken, className, data }),
+    update: (className, objectId, patch) => parseUpdate({ parseApiHost, appId, sessionToken, className, objectId, patch }),
+    deleteRecord: (className, objectId) => parseDelete({ parseApiHost, appId, sessionToken, className, objectId }),
+    upsertByQuery: (className, where, data, opts = {}) => parseUpsertByQuery({ parseApiHost, appId, sessionToken, className, where, data, opts }),
+    userFields: (userId) => parseUserFields(userId),
+    projectKey: PARSE_CLASS_PROJECT_KEY,
+  };
+}
+
+function parseUserFields(userId) {
+  return {
+    projectKey: PARSE_CLASS_PROJECT_KEY,
+    owner: { __type: 'Pointer', className: '_User', objectId: String(userId || '') },
+    ownerId: String(userId || ''),
+  };
+}
+
+function parseUserWhere(userId, extra = {}) {
+  return {
+    projectKey: PARSE_CLASS_PROJECT_KEY,
+    owner: { __type: 'Pointer', className: '_User', objectId: String(userId || '') },
+    ...extra,
+  };
+}
+
+async function parseFindFirst(ctx) {
+  const rows = await parseList({ ...ctx, opts: { ...ctx.opts, limit: 1 } });
+  return rows[0] || null;
+}
+
+async function parseList({ parseApiHost, appId, sessionToken, className, where, opts = {} }) {
+  const query = new URLSearchParams();
+  query.set('where', JSON.stringify(where || {}));
+  query.set('limit', String(safeParseLimit(opts.limit, 100, 1000)));
+  if (opts.skip !== undefined) query.set('skip', String(Math.max(0, parseInt(opts.skip || 0, 10) || 0)));
+  if (opts.order) query.set('order', String(opts.order));
+  if (opts.include) query.set('include', String(opts.include));
+  if (opts.keys) query.set('keys', String(opts.keys));
+  const data = await parseRequest({
+    parseApiHost,
+    appId,
+    sessionToken,
+    method: 'GET',
+    path: `/classes/${encodeURIComponent(className)}?${query.toString()}`,
+  });
+  return Array.isArray(data.results) ? data.results : [];
+}
+
+async function parseCreate({ parseApiHost, appId, sessionToken, className, data }) {
+  const created = await parseRequest({
+    parseApiHost,
+    appId,
+    sessionToken,
+    method: 'POST',
+    path: `/classes/${encodeURIComponent(className)}`,
+    body: sanitizeParseWrite(data),
+  });
+  return { ...sanitizeParseWrite(data), ...created };
+}
+
+async function parseUpdate({ parseApiHost, appId, sessionToken, className, objectId, patch }) {
+  await parseRequest({
+    parseApiHost,
+    appId,
+    sessionToken,
+    method: 'PUT',
+    path: `/classes/${encodeURIComponent(className)}/${encodeURIComponent(objectId)}`,
+    body: sanitizeParseWrite(patch),
+  });
+  return parseFindFirst({
+    parseApiHost,
+    appId,
+    sessionToken,
+    className,
+    where: { objectId },
+    opts: { limit: 1 },
+  });
+}
+
+async function parseDelete({ parseApiHost, appId, sessionToken, className, objectId }) {
+  return parseRequest({
+    parseApiHost,
+    appId,
+    sessionToken,
+    method: 'DELETE',
+    path: `/classes/${encodeURIComponent(className)}/${encodeURIComponent(objectId)}`,
+  });
+}
+
+async function parseUpsertByQuery({ parseApiHost, appId, sessionToken, className, where, data, opts = {} }) {
+  const findExisting = () => parseFindFirst({
+    parseApiHost,
+    appId,
+    sessionToken,
+    className,
+    where,
+    opts: { order: opts.order || '-updatedAt', limit: 1 },
+  });
+  const existing = await findExisting();
+  if (existing && existing.objectId) {
+    return parseUpdate({
+      parseApiHost,
+      appId,
+      sessionToken,
+      className,
+      objectId: existing.objectId,
+      patch: data,
+    });
+  }
+  try {
+    return await parseCreate({ parseApiHost, appId, sessionToken, className, data: { ...where, ...data } });
+  } catch (error) {
+    if (!isRetryableParseError(error)) throw error;
+    await parseStoreSleep(500);
+    const created = await findExisting();
+    if (created && created.objectId) {
+      return parseUpdate({
+        parseApiHost,
+        appId,
+        sessionToken,
+        className,
+        objectId: created.objectId,
+        patch: data,
+      });
+    }
+    return parseCreate({ parseApiHost, appId, sessionToken, className, data: { ...where, ...data } });
+  }
+}
+
+async function parseRequest({ parseApiHost, appId, sessionToken, method, path, body }) {
+  const headers = {
+    'Accept': 'application/json',
+    'X-Parse-Application-Id': appId,
+  };
+  if (sessionToken) headers['X-Parse-Session-Token'] = sessionToken;
+  if (body !== undefined) headers['Content-Type'] = 'application/json';
+
+  const maxAttempts = isSafeParseRetryMethod(method) ? 3 : 1;
+  let lastError = null;
+  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
+    try {
+      const resp = await fetch(`${parseApiHost}/parse${path}`, {
+        method,
+        headers,
+        body: body !== undefined ? JSON.stringify(body) : undefined,
+      });
+      const data = await resp.json().catch(() => ({}));
+      if (!resp.ok || data.error) {
+        const error = new Error(data.error || data.message || `Parse ${method} ${path} failed`);
+        error.status = resp.status || 500;
+        error.detail = data;
+        if (attempt < maxAttempts && isRetryableParseError(error)) {
+          await parseStoreSleep(350 * attempt);
+          continue;
+        }
+        throw error;
+      }
+      return data;
+    } catch (error) {
+      lastError = error;
+      if (attempt < maxAttempts && isRetryableParseError(error)) {
+        await parseStoreSleep(350 * attempt);
+        continue;
+      }
+      throw error;
+    }
+  }
+  throw lastError || new Error(`Parse ${method} ${path} failed`);
+}
+
+function sanitizeParseWrite(value) {
+  const source = normalizeParseObject(value);
+  const next = {};
+  for (const [key, item] of Object.entries(source)) {
+    if (['objectId', 'createdAt', 'updatedAt'].includes(key)) continue;
+    if (item === undefined) continue;
+    next[key] = item;
+  }
+  return next;
+}
+
+function normalizeParseObject(value) {
+  if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
+  return value;
+}
+
+function normalizeParseApiHost(value) {
+  return String(value || 'https://server.fmode.cn').replace(/\/+$/, '').replace(/\/parse$/i, '');
+}
+
+function safeParseLimit(value, fallback, max) {
+  const parsed = parseInt(value || fallback, 10);
+  return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), max) : fallback;
+}
+
+function parseStoreEnv(name, fallback = '') {
+  if (typeof process !== 'undefined' && process.env && process.env[name] !== undefined) {
+    return process.env[name];
+  }
+  return fallback;
+}
+
+function isSafeParseRetryMethod(method) {
+  return ['GET', 'PUT', 'DELETE'].includes(String(method || '').toUpperCase());
+}
+
+function isRetryableParseError(error) {
+  const status = Number(error?.status || 0);
+  const message = `${error?.message || ''} ${error?.detail?.error || ''} ${error?.detail?.message || ''}`;
+  return status >= 500 || /fetch failed|Failed to fetch|NetworkError|Load failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(message);
+}
+
+function parseStoreSleep(ms) {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+module.exports = {
+  PARSE_CLASS_PROJECT_KEY,
+  VIDEO_WORKFLOW_CLASSES,
+  createParseClassStore,
+  parseUserFields,
+  parseUserWhere,
+};

+ 126 - 0
cloud-functions/_session.js

@@ -0,0 +1,126 @@
+/**
+ * 云函数共享的 Parse 登录态工具。
+ *
+ * 浏览器侧为了兼容旧接口仍可能传入 userId,但用户私有数据归属必须由
+ * Parse sessionToken 在服务端解析得到。不要信任前端传入的 request.userId。
+ */
+
+const DEFAULT_PARSE_API_HOST = env('PARSE_API_HOST', env('PARSE_BASE_URL', 'https://server.fmode.cn'));
+const DEFAULT_PARSE_APP_ID = env('PARSE_APP_ID', env('PARSE_APPLICATION_ID', 'ncloudmaster'));
+
+async function requireSession(request, _psql) {
+  const sessionToken = clean(pickParam(request, 'sessionToken') || headerValue(request, 'x-parse-session-token'));
+  if (!sessionToken) {
+    const error = new Error('请先登录');
+    error.status = 401;
+    throw error;
+  }
+
+  const user = await parseMe(sessionToken);
+  if (!user || !user.objectId) {
+    const error = new Error('登录已失效,请重新登录');
+    error.status = 401;
+    throw error;
+  }
+
+  return {
+    sessionToken,
+    user,
+    userId: user.objectId,
+    role: normalizeRole(user),
+  };
+}
+
+async function optionalSession(request, psql) {
+  try {
+    return await requireSession(request, psql);
+  } catch {
+    return null;
+  }
+}
+
+async function requireAdmin(request, psql) {
+  const session = await requireSession(request, psql);
+  if (session.role !== 'admin') {
+    const error = new Error('需要管理员权限');
+    error.status = 403;
+    throw error;
+  }
+  return session;
+}
+
+function assertRequestedUserMatchesSession(request, session) {
+  const requestedUserId = clean(pickParam(request, 'userId'));
+  if (requestedUserId && requestedUserId !== session.userId) {
+    const error = new Error('没有访问该账号数据的权限');
+    error.status = 403;
+    throw error;
+  }
+}
+
+async function parseMe(sessionToken) {
+  const base = String(DEFAULT_PARSE_API_HOST || '').replace(/\/+$/, '').replace(/\/parse$/i, '');
+  const resp = await fetch(`${base}/parse/users/me?include=company`, {
+    method: 'GET',
+    headers: {
+      'Accept': 'application/json',
+      'X-Parse-Application-Id': DEFAULT_PARSE_APP_ID,
+      'X-Parse-Session-Token': sessionToken,
+    },
+  });
+  const data = await resp.json().catch(() => ({}));
+  if (!resp.ok) {
+    const error = new Error(data.error || data.message || '登录已失效,请重新登录');
+    error.status = resp.status || 401;
+    error.detail = data;
+    throw error;
+  }
+  return data;
+}
+
+function normalizeRole(user) {
+  const raw = String(user?.role || user?.userRole || user?.type || '').toLowerCase();
+  if (raw === 'admin' || user?.isAdmin === true || user?.admin === true) return 'admin';
+  return 'user';
+}
+
+function pickParam(request, ...names) {
+  const sources = [request?.params, request?.body, request?.query, request];
+  for (const src of sources) {
+    if (!src || typeof src !== 'object') continue;
+    for (const name of names) {
+      const value = src[name];
+      if (value !== undefined && value !== null && value !== '') return value;
+    }
+  }
+  return null;
+}
+
+function headerValue(request, name) {
+  const headers = request?.headers || {};
+  const target = String(name || '').toLowerCase();
+  for (const key of Object.keys(headers)) {
+    if (key.toLowerCase() === target) return headers[key];
+  }
+  return '';
+}
+
+function clean(value) {
+  return String(value || '').trim();
+}
+
+function env(name, fallback = '') {
+  if (typeof process !== 'undefined' && process.env && process.env[name] !== undefined) {
+    return process.env[name];
+  }
+  return fallback;
+}
+
+module.exports = {
+  requireSession,
+  optionalSession,
+  requireAdmin,
+  assertRequestedUserMatchesSession,
+  pickParam,
+  clean,
+};

+ 42 - 0
cloud-functions/_session.spec.js

@@ -0,0 +1,42 @@
+const {
+  assertRequestedUserMatchesSession,
+  requireAdmin,
+  requireSession,
+} = require('./_session');
+
+describe('cloud function session helper', () => {
+  const originalFetch = global.fetch;
+
+  afterEach(() => {
+    global.fetch = originalFetch;
+  });
+
+  it('resolves user id from Parse session token', async () => {
+    global.fetch = vi.fn().mockResolvedValue({
+      ok: true,
+      json: async () => ({ objectId: 'user-a', username: 'alice' }),
+    });
+
+    const session = await requireSession({ body: { sessionToken: 'token-a', userId: 'user-a' } });
+
+    expect(session.userId).toBe('user-a');
+    expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('/parse/users/me?include=company'), expect.objectContaining({
+      headers: expect.objectContaining({ 'X-Parse-Session-Token': 'token-a' }),
+    }));
+  });
+
+  it('rejects forged request user id', async () => {
+    const session = { userId: 'user-a' };
+
+    expect(() => assertRequestedUserMatchesSession({ body: { userId: 'user-b' } }, session)).toThrow('没有访问该账号数据的权限');
+  });
+
+  it('requires admin role for admin actions', async () => {
+    global.fetch = vi.fn().mockResolvedValue({
+      ok: true,
+      json: async () => ({ objectId: 'user-a', role: 'user' }),
+    });
+
+    await expect(requireAdmin({ body: { sessionToken: 'token-a' } })).rejects.toMatchObject({ status: 403 });
+  });
+});

+ 119 - 0
scripts/build-cloud-functions.mjs

@@ -0,0 +1,119 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { execFileSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const rootDir = path.resolve(__dirname, '..');
+const cloudDir = path.join(rootDir, 'cloud-functions');
+const outDir = path.join(cloudDir, 'deployable');
+const sessionSourcePath = path.join(cloudDir, '_session.js');
+const parseClassStoreSourcePath = path.join(cloudDir, '_parseClassStore.js');
+
+const targetFiles = [
+  '01-manifestManager.js',
+  '02-taskManager.js',
+  '03-historyManager.js',
+  '04-resultManager.js',
+  '05-remixManager.js',
+  '06-voiceManager.js',
+  '09-uploadManager.js',
+  '13-douyinInsightManager.js',
+  '14-systemStorageManager.js',
+  '15-fileAssetManager.js',
+];
+
+const sessionSource = stripCommonJsExport(read(sessionSourcePath)).trim();
+const parseClassStoreSource = stripCommonJsExport(read(parseClassStoreSourcePath)).trim();
+fs.mkdirSync(outDir, { recursive: true });
+
+for (const file of targetFiles) {
+  const sourcePath = path.join(cloudDir, file);
+  const source = read(sourcePath);
+  const transformed = inlineLocalRequires(source, file);
+  const helperSources = [sessionSource];
+  if (transformed.usesParseClassStore) {
+    helperSources.push(parseClassStoreSource);
+  }
+  const banner = [
+    '/**',
+    ` * ${file} 的 fmode 单文件部署版。`,
+    ' * 本文件由 scripts/build-cloud-functions.mjs 生成。',
+    ' * 请不要手动修改本文件;如需改动,请修改 cloud-functions 源码后重新生成。',
+    ' */',
+    '',
+  ].join('\n');
+  const output = `${banner}${helperSources.join('\n\n')}\n\n${transformed.source}`;
+  const outputPath = path.join(outDir, file);
+  fs.writeFileSync(outputPath, output, 'utf8');
+  checkSyntax(outputPath);
+  console.log(`built ${path.relative(rootDir, outputPath)}`);
+}
+
+console.log('云函数单文件部署包已生成');
+
+function inlineLocalRequires(source, file) {
+  const sessionResult = inlineRequire(
+    source,
+    './_session',
+    '上方已内联 _session.js 的登录态和权限校验逻辑,用于 fmode 单文件部署。',
+  );
+  if (!sessionResult.replaced) {
+    throw new Error(`${file} does not import ./_session`);
+  }
+  const parseStoreResult = inlineRequire(
+    sessionResult.source,
+    './_parseClassStore',
+    '上方已内联 _parseClassStore.js 的 Parse Class 存储工具,用于 fmode 单文件部署。',
+  );
+  const withoutRequire = parseStoreResult.source;
+  if (withoutRequire.includes("require('./_session')") || withoutRequire.includes('require("./_session")')) {
+    throw new Error(`${file} still contains ./_session require after transform`);
+  }
+  if (
+    withoutRequire.includes("require('./_parseClassStore')") ||
+    withoutRequire.includes('require("./_parseClassStore")')
+  ) {
+    throw new Error(`${file} still contains ./_parseClassStore require after transform`);
+  }
+  return {
+    source: withoutRequire.trim(),
+    usesParseClassStore: parseStoreResult.replaced,
+  };
+}
+
+function inlineRequire(source, requirePath, comment) {
+  let replaced = false;
+  const escapedPath = requirePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+  const pattern = new RegExp(`const\\s*\\{([\\s\\S]*?)\\}\\s*=\\s*require\\(['"]${escapedPath}['"]\\);?`, 'm');
+  const next = source.replace(pattern, (_match, imports) => {
+    replaced = true;
+    return buildAliases(imports, comment);
+  });
+  return { source: next, replaced };
+}
+
+function buildAliases(imports, comment) {
+  const lines = [];
+  for (const raw of imports.split(',')) {
+    const item = raw.trim();
+    if (!item) continue;
+    const aliasMatch = item.match(/^([A-Za-z_$][\w$]*)\s*:\s*([A-Za-z_$][\w$]*)$/);
+    if (aliasMatch) {
+      lines.push(`const ${aliasMatch[2]} = ${aliasMatch[1]};`);
+    }
+  }
+  return lines.length ? `// ${comment}\n${lines.join('\n')}` : `// ${comment}`;
+}
+
+function stripCommonJsExport(source) {
+  return source.replace(/module\.exports\s*=\s*\{[\s\S]*?\};?\s*$/m, '');
+}
+
+function read(filePath) {
+  return fs.readFileSync(filePath, 'utf8');
+}
+
+function checkSyntax(filePath) {
+  execFileSync(process.execPath, ['--check', filePath], { stdio: 'pipe' });
+}

+ 144 - 54
scripts/smoke-cloud-functions.mjs

@@ -1,73 +1,97 @@
-const FN_URL = 'https://server.fmode.cn/api/functions';
-const APP_ID = 'ncloudmaster';
-
-const fn = {
-  manifest: 'VWejNK8E5z',
-  task: 'yL6opHxC4D',
-  history: 'cegZcOyOH0',
-  result: 'XPv6Sym2I6',
-  remix: 'LjcaC94Kno',
-  voice: 'LHBMMcDP4n',
-  quickly: '',
-  proxy: 'wzwnEuAhwb',
-  jimeng: 'WUkf74lVE5',
-  douyin: 'HArHym0smi',
-  douyinInsight: 'pzBTvs6UcE',
-  upload: 'VHS0noRP7Q',
-  authCredit: '',
-};
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const FN_URL = process.env.SMOKE_FN_URL || 'https://server.fmode.cn/api/functions';
+const APP_ID = process.env.SMOKE_PARSE_APP_ID || 'ncloudmaster';
+const SESSION_TOKEN = process.env.SMOKE_SESSION_TOKEN || '';
+const STRICT_PROTECTED = process.env.SMOKE_STRICT_PROTECTED === '1';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const rootDir = path.resolve(__dirname, '..');
+const cloudFunctionsPath = path.join(rootDir, 'src', 'app', 'services', 'cloud-functions.ts');
+const fn = readCloudFunctionIds(cloudFunctionsPath);
 
 const checks = [
-  ['manifest.list', fn.manifest, { action: 'list' }, true],
-  ['task.list', fn.task, { action: 'list' }, true],
-  ['history.list', fn.history, { action: 'list' }, true],
-  ['result.list', fn.result, { action: 'list' }, true],
-  ['remix.listAll', fn.remix, { action: 'listAll' }, true],
-  ['voice.listProfiles', fn.voice, { action: 'listProfiles' }, true],
-  ['upload.token.probe', fn.upload, { action: 'createUploadToken', filename: 'probe.txt', mimeType: 'text/plain', kind: 'probe' }, true],
-  ['jimeng.unknownAction', fn.jimeng, { action: 'unknown' }, false],
-  ['douyin.unknownRoute', fn.douyin, { action: 'call', route: 'unknown' }, false],
-  ['douyinInsight.authGuard', fn.douyinInsight, { action: 'listTopics' }, false],
-  ['proxy.unknownAction', fn.proxy, { action: 'foo' }, false],
-  ['quickly.configured', fn.quickly, { action: 'query', taskId: 'probe' }, null],
-  ['authCredit.configured', fn.authCredit, { action: 'me' }, null],
+  protectedCheck('manifest.list', 'manifest', { action: 'list' }, validateArrayData),
+  protectedCheck('task.list', 'task', { action: 'list' }, validateArrayData),
+  protectedCheck('history.list', 'history', { action: 'list' }, validateArrayData),
+  protectedCheck('result.list', 'result', { action: 'list' }, validateArrayData),
+  protectedCheck('remix.listAll', 'remix', { action: 'listAll' }, validateRemixListAllData),
+  protectedCheck('voice.listProfiles', 'voice', { action: 'listProfiles' }, validateArrayData),
+  check('jimeng.unknownAction', 'jimeng', { action: 'unknown' }, 'failure'),
+  check('douyin.unknownRoute', 'douyin', { action: 'call', route: 'unknown' }, 'failure'),
+  check('douyinInsight.authGuard', 'douyinInsight', { action: 'listTopics' }, 'failure'),
+  check('proxy.unknownAction', 'proxy', { action: 'foo' }, 'failure'),
+  check('quickly.configured', 'quickly', { action: 'query', taskId: 'probe' }, 'any'),
+  protectedCheck(
+    'upload.token',
+    'upload',
+    { action: 'createUploadToken', filename: 'probe.txt', mimeType: 'text/plain', kind: 'probe', bizId: 'smoke' },
+    validateUploadToken
+  ),
+  protectedCheck('authCredit.balance', 'authCredit', { action: 'balance' }, validateAuthCreditBalance),
+  protectedCheck('systemStorage.stats', 'systemStorage', { action: 'stats' }, validateSystemStorageStats),
+  protectedCheck('fileAsset.stats', 'fileAsset', { action: 'stats' }, validateFileAssetStats),
 ];
 
+function check(name, key, body, expect) {
+  return { name, key, body, expect, validate: null };
+}
+
+function protectedCheck(name, key, body, validate) {
+  const expect = SESSION_TOKEN ? 'success' : STRICT_PROTECTED ? 'failure' : 'any';
+  return { name, key, body, expect, validate };
+}
+
 async function call(id, body) {
-  const response = await fetch(FN_URL, {
-    method: 'POST',
-    headers: {
-      'Content-Type': 'application/json',
-      'X-Parse-Application-Id': APP_ID,
-    },
-    body: JSON.stringify({ id, _ApplicationId: APP_ID, ...body }),
-  });
-  const text = await response.text();
-  try {
-    return JSON.parse(text);
-  } catch {
-    return { code: response.status, success: false, error: text };
+  const payload = { id, _ApplicationId: APP_ID, ...body };
+  if (SESSION_TOKEN) payload.sessionToken = SESSION_TOKEN;
+  let lastError = null;
+  for (let attempt = 1; attempt <= 3; attempt += 1) {
+    try {
+      const response = await fetch(FN_URL, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'X-Parse-Application-Id': APP_ID,
+        },
+        body: JSON.stringify(payload),
+      });
+      const text = await response.text();
+      try {
+        const data = JSON.parse(text);
+        if (isRetryableGatewayResult(data) && attempt < 3) {
+          await sleep(500 * attempt);
+          continue;
+        }
+        return data;
+      } catch {
+        return { code: response.status, success: false, error: text };
+      }
+    } catch (error) {
+      lastError = error;
+      await sleep(500 * attempt);
+    }
   }
+  throw lastError || new Error('fetch failed');
 }
 
 let failed = 0;
-for (const [name, id, body, expectSuccess] of checks) {
+for (const item of checks) {
+  const id = fn[item.key] || '';
   if (!id) {
-    console.log(`SKIP ${name}: function id is empty`);
+    console.log(`SKIP ${item.name}: ${item.key} function id is empty`);
     continue;
   }
   try {
-    const result = await call(id, body);
-    const ok = expectSuccess === null
-      ? true
-      : expectSuccess
-        ? result?.code === 200 && result?.success !== false
-        : result?.code >= 400 || result?.success === false;
+    const result = await call(id, item.body);
+    const ok = matchesExpectation(result, item.expect) && runValidator(item, result);
     if (!ok) failed += 1;
-    console.log(`${ok ? 'OK' : 'FAIL'} ${name}: ${JSON.stringify(result).slice(0, 500)}`);
+    console.log(`${ok ? 'OK' : 'FAIL'} ${item.name}: ${summarize(result)}`);
   } catch (error) {
     failed += 1;
-    console.log(`FAIL ${name}: ${error?.message || error}`);
+    console.log(`FAIL ${item.name}: ${error?.message || error}`);
   }
 }
 
@@ -77,3 +101,69 @@ if (failed > 0) {
 }
 
 console.log('Cloud function smoke passed');
+
+function matchesExpectation(result, expect) {
+  if (expect === 'any') return true;
+  const code = Number(result?.code || 0);
+  const success = result?.success !== false && code === 200;
+  if (expect === 'success') return success;
+  return code >= 400 || result?.success === false;
+}
+
+function runValidator(item, result) {
+  if (item.expect !== 'success' || !item.validate) return true;
+  try {
+    return item.validate(result);
+  } catch {
+    return false;
+  }
+}
+
+function validateUploadToken(result) {
+  const key = String(result?.data?.key || '');
+  return !!result?.data?.token && key.startsWith('users/');
+}
+
+function validateArrayData(result) {
+  return Array.isArray(result?.data);
+}
+
+function validateRemixListAllData(result) {
+  return Array.isArray(result?.data) || (result?.data && typeof result.data === 'object' && !Array.isArray(result.data));
+}
+
+function validateAuthCreditBalance(result) {
+  return Number.isFinite(Number(result?.data?.balance));
+}
+
+function validateSystemStorageStats(result) {
+  return Number.isFinite(Number(result?.data?.entities?.total ?? result?.data?.totalEntities ?? 0));
+}
+
+function validateFileAssetStats(result) {
+  return Number.isFinite(Number(result?.data?.total ?? 0));
+}
+
+function summarize(result) {
+  return JSON.stringify(result).slice(0, 700);
+}
+
+function readCloudFunctionIds(filePath) {
+  const text = fs.readFileSync(filePath, 'utf8');
+  const ids = {};
+  const pattern = /^\s*([a-zA-Z][a-zA-Z0-9_]*)\s*:\s*'([^']*)'/gm;
+  let match;
+  while ((match = pattern.exec(text))) {
+    ids[match[1]] = match[2];
+  }
+  return ids;
+}
+
+function isRetryableGatewayResult(result) {
+  const message = `${result?.error || ''} ${result?.message || ''}`;
+  return /必须提供\s*id|fetch failed|Failed to fetch|NetworkError|Load failed/i.test(message);
+}
+
+function sleep(ms) {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}

+ 161 - 0
scripts/validation/cloud-functions-readiness.mjs

@@ -0,0 +1,161 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { execFileSync } from 'node:child_process';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const rootDir = path.resolve(__dirname, '..', '..');
+const cloudDir = path.join(rootDir, 'cloud-functions');
+const deployableDir = path.join(cloudDir, 'deployable');
+const cloudFunctionsTs = path.join(rootDir, 'src', 'app', 'services', 'cloud-functions.ts');
+const deployDoc = path.join(cloudDir, 'DEPLOY.md');
+
+const expectedFiles = [
+  '_session.js',
+  '_parseClassStore.js',
+  '09-uploadManager.js',
+  '10-authCreditManager.js',
+  '11-jimengManager.js',
+  '14-systemStorageManager.js',
+  '15-fileAssetManager.js',
+];
+
+const sessionUsers = [
+  '09-uploadManager.js',
+  '14-systemStorageManager.js',
+  '15-fileAssetManager.js',
+];
+
+const deployableSessionUsers = [
+  '01-manifestManager.js',
+  '02-taskManager.js',
+  '03-historyManager.js',
+  '04-resultManager.js',
+  '05-remixManager.js',
+  '06-voiceManager.js',
+  '09-uploadManager.js',
+  '13-douyinInsightManager.js',
+  '14-systemStorageManager.js',
+  '15-fileAssetManager.js',
+];
+
+const deployKeys = [
+  'authCredit',
+  'systemStorage',
+  'fileAsset',
+  'upload',
+];
+
+const errors = [];
+const warnings = [];
+
+for (const file of expectedFiles) {
+  assertFile(path.join(cloudDir, file), `Missing cloud function source: ${file}`);
+}
+
+for (const file of expectedFiles) {
+  const fullPath = path.join(cloudDir, file);
+  if (fs.existsSync(fullPath)) checkNodeSyntax(fullPath);
+}
+
+for (const file of deployableSessionUsers) {
+  const fullPath = path.join(deployableDir, file);
+  assertFile(fullPath, `Missing deployable single-file cloud function: ${file}. Run npm run build:cloud-functions`);
+  if (fs.existsSync(fullPath)) {
+    checkNodeSyntax(fullPath);
+    const text = read(fullPath);
+    if (text.includes("require('./_session')") || text.includes('require("./_session")')) {
+      errors.push(`${file} deployable build must inline _session and must not require it`);
+    }
+    if (text.includes("require('./_parseClassStore')") || text.includes('require("./_parseClassStore")')) {
+      errors.push(`${file} deployable build must inline _parseClassStore and must not require it`);
+    }
+    if (!text.includes('本文件由 scripts/build-cloud-functions.mjs 生成')) {
+      warnings.push(`${file} deployable build is missing generated-file banner`);
+    }
+  }
+}
+
+for (const file of sessionUsers) {
+  const text = read(path.join(cloudDir, file));
+  if (!text.includes("require('./_session')") && !text.includes('require("./_session")')) {
+    errors.push(`${file} must use shared _session.js for session ownership checks`);
+  }
+}
+
+const cloudConfig = read(cloudFunctionsTs);
+for (const key of deployKeys) {
+  if (!new RegExp(`\\b${key}\\s*:`).test(cloudConfig)) {
+    errors.push(`CLOUD_FN.${key} is missing in src/app/services/cloud-functions.ts`);
+  }
+}
+
+const allCloudSource = expectedFiles.map((file) => read(path.join(cloudDir, file))).join('\n');
+if (allCloudSource.includes('6pF6EAdKT')) {
+  errors.push('Wrong video workflow APIG id 6pF6EAdKT is still present; expected 6pFf6EAdKT');
+}
+if (!allCloudSource.includes('6pFf6EAdKT')) {
+  errors.push('Expected video workflow APIG id 6pFf6EAdKT was not found in cloud function sources');
+}
+
+const deployText = read(deployDoc);
+for (const file of expectedFiles) {
+  if (!deployText.includes(file)) {
+    warnings.push(`DEPLOY.md does not mention ${file}`);
+  }
+}
+if (!deployText.includes('fmode') || !deployText.includes('authCredit') || !deployText.includes('systemStorage') || !deployText.includes('fileAsset')) {
+  warnings.push('DEPLOY.md should explicitly state new functions use the existing fmode cloud function mechanism');
+}
+if (!deployText.includes('SMOKE_SESSION_TOKEN')) {
+  warnings.push('DEPLOY.md should document SMOKE_SESSION_TOKEN deployment validation');
+}
+if (!deployText.includes('cloud-functions/deployable')) {
+  warnings.push('DEPLOY.md should tell users to deploy cloud-functions/deployable single-file builds on fmode');
+}
+
+for (const file of ['14-systemStorageManager.js', '15-fileAssetManager.js']) {
+  const text = read(path.join(cloudDir, file));
+  if (!text.includes('adminInspect')) {
+    errors.push(`${file} must expose adminInspect for storage governance inspection`);
+  }
+  if (/\bPsql\.query\b|\bSystemEntity\b|\bSystemAudit\b|\bDataMigration\b|\bFileAsset\b/.test(text)) {
+    errors.push(`${file} must use VideoWorkflow* Parse Class storage and must not reference old PSQL table names`);
+  }
+  const deployableText = read(path.join(deployableDir, file));
+  if (/\bPsql\.query\b|\bSystemEntity\b|\bSystemAudit\b|\bDataMigration\b|\bFileAsset\b/.test(deployableText)) {
+    errors.push(`${file} deployable must not reference old PSQL table names`);
+  }
+}
+
+if (warnings.length) {
+  for (const warning of warnings) console.warn(`WARN ${warning}`);
+}
+
+if (errors.length) {
+  for (const error of errors) console.error(`FAIL ${error}`);
+  process.exit(1);
+}
+
+console.log('Cloud function readiness passed');
+
+function assertFile(filePath, message) {
+  if (!fs.existsSync(filePath)) errors.push(message);
+}
+
+function read(filePath) {
+  try {
+    return fs.readFileSync(filePath, 'utf8');
+  } catch {
+    return '';
+  }
+}
+
+function checkNodeSyntax(filePath) {
+  try {
+    execFileSync(process.execPath, ['--check', filePath], { stdio: 'pipe' });
+  } catch (error) {
+    const output = `${error.stdout || ''}${error.stderr || ''}`.trim();
+    errors.push(`${path.relative(rootDir, filePath)} syntax check failed${output ? `: ${output}` : ''}`);
+  }
+}

+ 213 - 0
scripts/validation/parse-storage-postdeploy.mjs

@@ -0,0 +1,213 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const FN_URL = process.env.SMOKE_FN_URL || 'https://server.fmode.cn/api/functions';
+const APP_ID = process.env.SMOKE_PARSE_APP_ID || 'ncloudmaster';
+const SESSION_TOKEN = process.env.SMOKE_SESSION_TOKEN || '';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const rootDir = path.resolve(__dirname, '..', '..');
+const cloudFunctionsPath = path.join(rootDir, 'src', 'app', 'services', 'cloud-functions.ts');
+const fn = readCloudFunctionIds(cloudFunctionsPath);
+
+const probeType = 'governanceProbe';
+const probeId = `parse-storage-${Date.now()}`;
+const errors = [];
+let probeAssetId = '';
+
+if (!SESSION_TOKEN) {
+  fail('Missing SMOKE_SESSION_TOKEN. Provide one test user Parse session token.');
+}
+for (const key of ['systemStorage', 'upload', 'fileAsset', 'authCredit']) {
+  if (!fn[key]) fail(`CLOUD_FN.${key} is empty. Deploy and fill src/app/services/cloud-functions.ts before running validation.`);
+}
+if (errors.length) exitWithErrors();
+
+try {
+  await run('authCredit.balance keeps APIG reachable', async () => {
+    const result = await call(fn.authCredit, { action: 'balance' });
+    assertOk(result, 'authCredit.balance should succeed');
+    assert(result?.data?.apigId === '6pFf6EAdKT', `APIG id must stay 6pFf6EAdKT: ${summarize(result)}`);
+    assert(Number.isFinite(Number(result?.data?.balance)), 'authCredit.balance must return numeric balance');
+  });
+
+  await run('systemStorage writes VideoWorkflowEntity', async () => {
+    const upsert = await call(fn.systemStorage, {
+      action: 'upsert',
+      entityType: probeType,
+      entityId: probeId,
+      status: 'active',
+      data: {
+        marker: probeId,
+        source: 'parse-storage-postdeploy',
+      },
+    });
+    assertOk(upsert, 'systemStorage.upsert should succeed');
+    assert(upsert?.data?.data?.marker === probeId, `systemStorage.upsert should return probe payload: ${summarize(upsert)}`);
+  });
+
+  await run('systemStorage reads VideoWorkflowEntity', async () => {
+    const get = await call(fn.systemStorage, { action: 'get', entityType: probeType, entityId: probeId });
+    assertOk(get, 'systemStorage.get should succeed');
+    assert(get?.data?.data?.marker === probeId, `systemStorage.get should return the probe payload: ${summarize(get)}`);
+  });
+
+  await run('systemStorage writes VideoWorkflowAudit detail string', async () => {
+    const audit = await call(fn.systemStorage, {
+      action: 'audit',
+      auditAction: 'parse-storage-postdeploy',
+      entityType: probeType,
+      entityId: probeId,
+      summary: 'Parse storage postdeploy probe',
+      detail: { marker: probeId },
+    });
+    assertOk(audit, 'systemStorage.audit should succeed');
+    assert(audit?.data?.detail?.marker === probeId, `systemStorage.audit should return parsed detail: ${summarize(audit)}`);
+  });
+
+  await run('upload issues user-partition qiniu key', async () => {
+    const upload = await call(fn.upload, {
+      action: 'createUploadToken',
+      filename: 'parse-storage-postdeploy-probe.txt',
+      mimeType: 'text/plain',
+      kind: 'probe',
+      bizId: probeId,
+    });
+    assertOk(upload, 'upload.createUploadToken should succeed');
+    const qiniuKey = String(upload?.data?.key || '');
+    assert(qiniuKey.startsWith('users/'), `upload key must start with users/: ${summarize(upload)}`);
+
+    const registered = await call(fn.fileAsset, {
+      action: 'register',
+      qiniuKey,
+      url: upload?.data?.url || '',
+      bucket: upload?.data?.bucket || 'nova-repos',
+      mimeType: 'text/plain',
+      kind: 'export',
+      sizeBytes: 0,
+      sourceModule: 'parse-storage-postdeploy',
+      bizType: probeType,
+      bizId: probeId,
+      metadata: { marker: probeId },
+    });
+    assertOk(registered, 'fileAsset.register should succeed');
+    probeAssetId = String(registered?.data?.assetId || registered?.data?.id || '');
+    assert(probeAssetId, `fileAsset.register must return assetId: ${summarize(registered)}`);
+    assert(registered?.data?.qiniuKey === qiniuKey, `fileAsset.register must preserve qiniuKey: ${summarize(registered)}`);
+  });
+
+  await run('fileAsset reads VideoWorkflowFileAsset', async () => {
+    const get = await call(fn.fileAsset, { action: 'get', assetId: probeAssetId });
+    assertOk(get, 'fileAsset.get should succeed');
+    assert(get?.data?.metadata?.marker === probeId, `fileAsset.get should return probe metadata: ${summarize(get)}`);
+  });
+} finally {
+  if (probeAssetId) {
+    await run('cleanup VideoWorkflowFileAsset', async () => {
+      const deleted = await call(fn.fileAsset, { action: 'delete', assetId: probeAssetId });
+      assertOk(deleted, 'fileAsset.delete cleanup should succeed');
+    });
+  }
+  await run('cleanup VideoWorkflowEntity', async () => {
+    const deleted = await call(fn.systemStorage, { action: 'delete', entityType: probeType, entityId: probeId });
+    assertOk(deleted, 'systemStorage.delete cleanup should succeed');
+  });
+}
+
+if (errors.length) exitWithErrors();
+console.log('Parse storage postdeploy validation passed');
+
+async function run(name, task) {
+  try {
+    await task();
+    console.log(`OK ${name}`);
+  } catch (error) {
+    errors.push(`${name}: ${error?.message || error}`);
+    console.error(`FAIL ${name}: ${error?.message || error}`);
+  }
+}
+
+async function call(id, body) {
+  let lastResult = null;
+  let lastError = null;
+  for (let attempt = 1; attempt <= 4; attempt += 1) {
+    try {
+      const response = await fetch(FN_URL, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'X-Parse-Application-Id': APP_ID,
+        },
+        body: JSON.stringify({ id, _ApplicationId: APP_ID, sessionToken: SESSION_TOKEN, ...body }),
+      });
+      const text = await response.text();
+      let result;
+      try {
+        result = JSON.parse(text);
+      } catch {
+        result = { code: response.status, success: false, error: text };
+      }
+      lastResult = result;
+      if (attempt < 4 && isRetryableResult(result)) {
+        await sleep(600 * attempt);
+        continue;
+      }
+      return result;
+    } catch (error) {
+      lastError = error;
+      if (attempt < 4 && isRetryableMessage(error?.message || '')) {
+        await sleep(600 * attempt);
+        continue;
+      }
+      throw error;
+    }
+  }
+  if (lastResult) return lastResult;
+  throw lastError || new Error('fetch failed');
+}
+
+function assertOk(result, message) {
+  assert(Number(result?.code || 0) === 200 && result?.success !== false, `${message}: ${summarize(result)}`);
+}
+
+function assert(condition, message) {
+  if (!condition) throw new Error(message);
+}
+
+function fail(message) {
+  errors.push(message);
+}
+
+function exitWithErrors() {
+  for (const error of errors) console.error(`FAIL ${error}`);
+  process.exit(1);
+}
+
+function summarize(result) {
+  return JSON.stringify(result, (key, value) => key === 'token' ? '<redacted>' : value).slice(0, 700);
+}
+
+function isRetryableResult(result) {
+  const message = `${result?.error || ''} ${result?.message || ''}`;
+  return Number(result?.code || 0) >= 500 || isRetryableMessage(message);
+}
+
+function isRetryableMessage(message) {
+  return /fetch failed|Failed to fetch|NetworkError|Load failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(message || '');
+}
+
+function sleep(ms) {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function readCloudFunctionIds(filePath) {
+  const text = fs.readFileSync(filePath, 'utf8');
+  const ids = {};
+  const pattern = /^\s*([a-zA-Z][a-zA-Z0-9_]*)\s*:\s*'([^']*)'/gm;
+  let match;
+  while ((match = pattern.exec(text))) {
+    ids[match[1]] = match[2];
+  }
+  return ids;
+}

+ 208 - 0
scripts/validation/storage-governance-postdeploy.mjs

@@ -0,0 +1,208 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const FN_URL = process.env.SMOKE_FN_URL || 'https://server.fmode.cn/api/functions';
+const APP_ID = process.env.SMOKE_PARSE_APP_ID || 'ncloudmaster';
+const TOKEN_A = process.env.STORAGE_GOVERNANCE_SESSION_A || process.env.SMOKE_SESSION_TOKEN_A || '';
+const TOKEN_B = process.env.STORAGE_GOVERNANCE_SESSION_B || process.env.SMOKE_SESSION_TOKEN_B || '';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const rootDir = path.resolve(__dirname, '..', '..');
+const cloudFunctionsPath = path.join(rootDir, 'src', 'app', 'services', 'cloud-functions.ts');
+const fn = readCloudFunctionIds(cloudFunctionsPath);
+
+const probeType = 'governanceProbe';
+const probeId = `postdeploy-${Date.now()}`;
+const errors = [];
+let probeQiniuKey = '';
+let probeAssetId = '';
+
+if (!TOKEN_A || !TOKEN_B) {
+  fail('Missing STORAGE_GOVERNANCE_SESSION_A and STORAGE_GOVERNANCE_SESSION_B. Provide two different test user Parse session tokens.');
+}
+for (const key of ['systemStorage', 'upload', 'authCredit', 'fileAsset']) {
+  if (!fn[key]) fail(`CLOUD_FN.${key} is empty. Deploy and fill src/app/services/cloud-functions.ts before running postdeploy validation.`);
+}
+
+if (errors.length) exitWithErrors();
+
+await run('authCredit.balance user A', async () => {
+  const result = await call(fn.authCredit, { action: 'balance' }, TOKEN_A);
+  assertOk(result, 'authCredit.balance should succeed for user A');
+  assert(Number.isFinite(Number(result?.data?.balance)), 'authCredit.balance must return numeric balance');
+});
+
+await run('systemStorage user A write/read/delete', async () => {
+  const upsert = await call(fn.systemStorage, {
+    action: 'upsert',
+    entityType: probeType,
+    entityId: probeId,
+    data: { marker: probeId, owner: 'A', createdBy: 'storage-governance-postdeploy' },
+    status: 'active',
+  }, TOKEN_A);
+  assertOk(upsert, 'systemStorage.upsert should succeed for user A');
+
+  const get = await call(fn.systemStorage, { action: 'get', entityType: probeType, entityId: probeId }, TOKEN_A);
+  assertOk(get, 'systemStorage.get should return user A probe');
+  assert(get?.data?.data?.marker === probeId || get?.data?.marker === probeId, 'systemStorage.get should return the probe payload');
+});
+
+await run('systemStorage cross-account isolation', async () => {
+  const result = await call(fn.systemStorage, { action: 'get', entityType: probeType, entityId: probeId }, TOKEN_B);
+  const data = result?.data;
+  const hasAProbe = data?.data?.marker === probeId || data?.marker === probeId;
+  assert(!hasAProbe, 'user B must not read user A governance probe');
+});
+
+await run('upload token user partition', async () => {
+  const result = await call(fn.upload, {
+    action: 'createUploadToken',
+    filename: 'postdeploy-probe.txt',
+    mimeType: 'text/plain',
+    kind: 'probe',
+    bizId: probeId,
+  }, TOKEN_A);
+  assertOk(result, 'upload token should succeed for user A');
+  const key = String(result?.data?.key || '');
+  assert(key.startsWith('users/'), `upload key must start with users/, got: ${key || '(empty)'}`);
+  probeQiniuKey = key;
+});
+
+await run('fileAsset register/read/delete user A', async () => {
+  assert(probeQiniuKey, 'upload token key is required before registering file asset metadata');
+  const register = await call(fn.fileAsset, {
+    action: 'register',
+    qiniuKey: probeQiniuKey,
+    url: '',
+    bucket: 'postdeploy-probe',
+    mimeType: 'text/plain',
+    kind: 'export',
+    sizeBytes: 0,
+    sourceModule: 'storage-governance-postdeploy',
+    bizType: probeType,
+    bizId: probeId,
+    metadata: { marker: probeId },
+  }, TOKEN_A);
+  assertOk(register, 'fileAsset.register should create VideoWorkflowFileAsset metadata');
+  probeAssetId = String(register?.data?.assetId || register?.data?.id || '');
+  assert(probeAssetId, `fileAsset.register must return assetId: ${JSON.stringify(register).slice(0, 500)}`);
+  assert(register?.data?.qiniuKey === probeQiniuKey, 'fileAsset.register must preserve qiniuKey');
+
+  const get = await call(fn.fileAsset, { action: 'get', assetId: probeAssetId }, TOKEN_A);
+  assertOk(get, 'fileAsset.get should return registered metadata');
+  assert(get?.data?.qiniuKey === probeQiniuKey, 'fileAsset.get must return the registered qiniuKey');
+
+  const deleted = await call(fn.fileAsset, { action: 'delete', assetId: probeAssetId }, TOKEN_A);
+  assertOk(deleted, 'fileAsset.delete should soft-delete temporary metadata');
+});
+
+await run('fileAsset stats user A', async () => {
+  const result = await call(fn.fileAsset, { action: 'stats' }, TOKEN_A);
+  assertOk(result, 'fileAsset.stats should succeed for user A');
+  assert(Number.isFinite(Number(result?.data?.total ?? 0)), 'fileAsset.stats must return numeric total');
+});
+
+await run('cleanup user A probe', async () => {
+  const result = await call(fn.systemStorage, { action: 'delete', entityType: probeType, entityId: probeId }, TOKEN_A);
+  assertOk(result, 'systemStorage.delete should clean up user A probe');
+});
+
+if (errors.length) exitWithErrors();
+console.log('Storage governance postdeploy validation passed');
+
+async function run(name, task) {
+  try {
+    await task();
+    console.log(`OK ${name}`);
+  } catch (error) {
+    errors.push(`${name}: ${error?.message || error}`);
+    console.error(`FAIL ${name}: ${error?.message || error}`);
+    if (name !== 'cleanup user A probe') {
+      if (probeAssetId) {
+        await call(fn.fileAsset, { action: 'delete', assetId: probeAssetId }, TOKEN_A).catch(() => null);
+      }
+      await call(fn.systemStorage, { action: 'delete', entityType: probeType, entityId: probeId }, TOKEN_A).catch(() => null);
+    }
+  }
+}
+
+async function call(id, body, sessionToken) {
+  let lastResult = null;
+  let lastError = null;
+  for (let attempt = 1; attempt <= 4; attempt += 1) {
+    try {
+      const response = await fetch(FN_URL, {
+        method: 'POST',
+        headers: {
+          'Content-Type': 'application/json',
+          'X-Parse-Application-Id': APP_ID,
+        },
+        body: JSON.stringify({ id, _ApplicationId: APP_ID, sessionToken, ...body }),
+      });
+      const text = await response.text();
+      let result;
+      try {
+        result = JSON.parse(text);
+      } catch {
+        result = { code: response.status, success: false, error: text };
+      }
+      lastResult = result;
+      if (attempt < 4 && isRetryableResult(result)) {
+        await sleep(600 * attempt);
+        continue;
+      }
+      return result;
+    } catch (error) {
+      lastError = error;
+      if (attempt < 4 && isRetryableMessage(error?.message || '')) {
+        await sleep(600 * attempt);
+        continue;
+      }
+      throw error;
+    }
+  }
+  if (lastResult) return lastResult;
+  throw lastError || new Error('fetch failed');
+}
+
+function assertOk(result, message) {
+  assert(Number(result?.code || 0) === 200 && result?.success !== false, `${message}: ${JSON.stringify(result).slice(0, 500)}`);
+}
+
+function assert(condition, message) {
+  if (!condition) throw new Error(message);
+}
+
+function fail(message) {
+  errors.push(message);
+}
+
+function exitWithErrors() {
+  for (const error of errors) console.error(`FAIL ${error}`);
+  process.exit(1);
+}
+
+function readCloudFunctionIds(filePath) {
+  const text = fs.readFileSync(filePath, 'utf8');
+  const ids = {};
+  const pattern = /^\s*([a-zA-Z][a-zA-Z0-9_]*)\s*:\s*'([^']*)'/gm;
+  let match;
+  while ((match = pattern.exec(text))) {
+    ids[match[1]] = match[2];
+  }
+  return ids;
+}
+
+function isRetryableResult(result) {
+  const message = `${result?.error || ''} ${result?.message || ''}`;
+  return Number(result?.code || 0) >= 500 || isRetryableMessage(message);
+}
+
+function isRetryableMessage(message) {
+  return /fetch failed|Failed to fetch|NetworkError|Load failed|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(message || '');
+}
+
+function sleep(ms) {
+  return new Promise((resolve) => setTimeout(resolve, ms));
+}

+ 86 - 0
scripts/validation/storage-sensitive-policy.mjs

@@ -0,0 +1,86 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const rootDir = path.resolve(__dirname, '..', '..');
+
+const boundaryDoc = path.join(
+  rootDir,
+  'docs',
+  'superpowers',
+  '计划',
+  '全系统存储治理',
+  '2026-06-17-全系统存储边界矩阵.md',
+);
+
+const routeDoc = path.join(
+  rootDir,
+  'docs',
+  'superpowers',
+  '计划',
+  '全系统存储治理',
+  '2026-06-17-全系统存储执行路线图.md',
+);
+
+const sourceFiles = {
+  viral: path.join(rootDir, 'src', 'app', 'services', 'viral-analysis.service.ts'),
+  ipOperator: path.join(rootDir, 'src', 'app', 'services', 'ip-operator.service.ts'),
+  assistant: path.join(rootDir, 'src', 'app', 'services', 'assistant.service.ts'),
+};
+
+const errors = [];
+
+const boundaryText = read(boundaryDoc);
+const routeText = read(routeDoc);
+
+assertIncludes(boundaryText, '敏感数据与大对象归档决策', 'Boundary matrix must define sensitive/big-object archival decisions');
+assertIncludes(boundaryText, 'viralAnalysis.comment', 'Boundary matrix must cover viral comment child entities');
+assertIncludes(boundaryText, 'viralAnalysis.reply', 'Boundary matrix must cover viral reply child entities');
+assertIncludes(boundaryText, 'viralAnalysis.transcript', 'Boundary matrix must cover viral transcript child entities');
+assertIncludes(boundaryText, 'ipOperator.accountSnapshotWork', 'Boundary matrix must cover account snapshot work child entities');
+assertIncludes(boundaryText, 'ipOperator.accountSnapshotComment', 'Boundary matrix must cover account snapshot comment child entities');
+assertIncludes(boundaryText, 'ipOperator.planScriptBody', 'Boundary matrix must cover IP operator script body child entities');
+assertIncludes(boundaryText, 'ipOperator.planGenerationPreview', 'Boundary matrix must cover IP operator raw preview child entities');
+assertIncludes(boundaryText, 'assistantThread', 'Boundary matrix must cover assistant thread storage');
+assertIncludes(boundaryText, 'localStorage', 'Boundary matrix must state browser long-term cache boundaries');
+assertIncludes(boundaryText, '七牛', 'Boundary matrix must define Qiniu archival placement');
+assertIncludes(boundaryText, '决策红线', 'Boundary matrix must include red lines for sensitive data handling');
+
+assertIncludes(routeText, '敏感数据与大对象归档策略复核', 'Route map must track sensitive/big-object policy review as a completed checkpoint');
+assertIncludes(routeText, '历史存量数据归档执行', 'Route map must keep real historical data archival execution as a separate remaining checkpoint');
+
+const viralText = read(sourceFiles.viral);
+assertIncludes(viralText, 'viralAnalysis.comment', 'Viral analysis service must externalize comment samples');
+assertIncludes(viralText, 'viralAnalysis.reply', 'Viral analysis service must externalize reply samples');
+assertIncludes(viralText, 'viralAnalysis.transcript', 'Viral analysis service must externalize transcripts');
+assertIncludes(viralText, 'compactAnalysisForStorage', 'Viral analysis service must compact the main analysis entity');
+
+const ipOperatorText = read(sourceFiles.ipOperator);
+assertIncludes(ipOperatorText, 'ipOperator.accountSnapshotWork', 'IP operator service must externalize account works');
+assertIncludes(ipOperatorText, 'ipOperator.accountSnapshotComment', 'IP operator service must externalize account comments');
+assertIncludes(ipOperatorText, 'ipOperator.planScriptBody', 'IP operator service must externalize full script bodies');
+assertIncludes(ipOperatorText, 'ipOperator.planGenerationPreview', 'IP operator service must externalize raw generation previews');
+
+const assistantText = read(sourceFiles.assistant);
+assertIncludes(assistantText, 'assistantThread', 'Assistant service must use a cloud entity for assistant threads');
+
+if (errors.length) {
+  for (const error of errors) console.error(`FAIL ${error}`);
+  process.exit(1);
+}
+
+console.log('Sensitive storage policy validation passed');
+
+function read(filePath) {
+  try {
+    return fs.readFileSync(filePath, 'utf8');
+  } catch (error) {
+    errors.push(`Cannot read ${path.relative(rootDir, filePath)}: ${error.message}`);
+    return '';
+  }
+}
+
+function assertIncludes(text, needle, message) {
+  if (!text.includes(needle)) errors.push(message);
+}

+ 257 - 0
src/app/services/cloud-session-storage.service.spec.ts

@@ -0,0 +1,257 @@
+import { CLOUD_FN } from './cloud-functions';
+import { CloudSessionStorageService } from './cloud-session-storage.service';
+
+describe('CloudSessionStorageService', () => {
+  const originalSystemStorage = CLOUD_FN.systemStorage;
+
+  beforeEach(() => {
+    localStorage.clear();
+    sessionStorage.clear();
+    (CLOUD_FN as any).systemStorage = 'system-storage-fn';
+  });
+
+  afterEach(() => {
+    (CLOUD_FN as any).systemStorage = originalSystemStorage;
+    vi.restoreAllMocks();
+  });
+
+  it('stores logged-in entities through cloud storage instead of localStorage', async () => {
+    const parse = {
+      call: vi.fn().mockResolvedValue({
+        code: 200,
+        success: true,
+        data: {
+          id: 'topic-1',
+          entityId: 'topic-1',
+          type: 'topic',
+          entityType: 'topic',
+          status: 'active',
+          data: { title: 'A' },
+        },
+      }),
+    };
+    const auth = {
+      session: { token: 'session-token', user: { objectId: 'user-1' } },
+      currentUser: { objectId: 'user-1' },
+      requestLogin: vi.fn(),
+    };
+    const originalSetItem = Storage.prototype.setItem;
+    const localWrites: string[] = [];
+    Storage.prototype.setItem = function (this: Storage, key: string, value: string) {
+      if (this === localStorage) localWrites.push(key);
+      return originalSetItem.call(this, key, value);
+    };
+
+    try {
+      const service = new CloudSessionStorageService(parse as any, auth as any);
+
+      await service.upsert('topic', 'topic-1', { title: 'A' });
+
+      expect(parse.call).toHaveBeenCalledWith('system-storage-fn', expect.objectContaining({
+        action: 'upsert',
+        sessionToken: 'session-token',
+        userId: 'user-1',
+        entityType: 'topic',
+        entityId: 'topic-1',
+        data: { title: 'A' },
+      }));
+      expect(localWrites).toEqual([]);
+    } finally {
+      Storage.prototype.setItem = originalSetItem;
+    }
+  });
+
+  it('keeps guest trial entities in sessionStorage only', async () => {
+    const parse = { call: vi.fn() };
+    const auth = {
+      session: null,
+      currentUser: null,
+      requestLogin: vi.fn(),
+    };
+    const service = new CloudSessionStorageService(parse as any, auth as any);
+
+    const saved = await service.upsert('topic', 'draft-1', { title: '临时选题' });
+    const listed = await service.list<{ title: string }>('topic');
+
+    expect(saved.data.title).toBe('临时选题');
+    expect(listed[0].entityId).toBe('draft-1');
+    expect(parse.call).not.toHaveBeenCalled();
+    expect(Object.keys(localStorage)).toEqual([]);
+    expect(Object.keys(sessionStorage).some((key) => key.includes('videoWorkflow.trialSystemEntity.topic'))).toBe(true);
+  });
+
+  it('reads cloud storage stats through the system storage function when logged in', async () => {
+    const parse = {
+      call: vi.fn().mockResolvedValue({
+        code: 200,
+        success: true,
+        data: {
+          totalEntities: 3,
+          entityTypes: { topic: { total: 3, byStatus: { active: 2, archived: 1 } } },
+          auditCount: 4,
+          migrations: { total: 1, byStatus: { completed: 1 } },
+          generatedAt: '2026-06-18T09:20:00.000Z',
+        },
+      }),
+    };
+    const service = new CloudSessionStorageService(parse as any, {
+      session: { token: 'session-token', user: { objectId: 'user-1' } },
+      currentUser: { objectId: 'user-1' },
+      requestLogin: vi.fn(),
+    } as any);
+
+    const stats = await service.stats();
+
+    expect(stats.totalEntities).toBe(3);
+    expect(stats.auditCount).toBe(4);
+    expect(parse.call).toHaveBeenCalledWith('system-storage-fn', expect.objectContaining({
+      action: 'stats',
+      sessionToken: 'session-token',
+      userId: 'user-1',
+    }));
+  });
+
+  it('reads recent audit records through the system storage function when logged in', async () => {
+    const parse = {
+      call: vi.fn().mockResolvedValue({
+        code: 200,
+        success: true,
+        data: [
+          {
+            id: 'audit-1',
+            entityType: 'ipOperator.plan',
+            entityId: 'plan-1',
+            action: 'save_plan',
+            summary: '保存方案',
+            createdAt: '2026-06-18T09:30:00.000Z',
+          },
+        ],
+      }),
+    };
+    const service = new CloudSessionStorageService(parse as any, {
+      session: { token: 'session-token', user: { objectId: 'user-1' } },
+      currentUser: { objectId: 'user-1' },
+      requestLogin: vi.fn(),
+    } as any);
+
+    const rows = await service.recentAudits(20, 'ipOperator.plan');
+
+    expect(rows[0].id).toBe('audit-1');
+    expect(parse.call).toHaveBeenCalledWith('system-storage-fn', expect.objectContaining({
+      action: 'auditList',
+      sessionToken: 'session-token',
+      userId: 'user-1',
+      limit: 20,
+      entityType: 'ipOperator.plan',
+    }));
+  });
+
+  it('calls systemStorage purge with a reason for logged in users', async () => {
+    const parse = {
+      call: vi.fn().mockResolvedValue({
+        code: 200,
+        success: true,
+        data: { entityType: 'ipOperator.accountSnapshot', entityId: 'snapshot-1', purged: true },
+      }),
+    };
+    const service = new CloudSessionStorageService(parse as any, {
+      session: { token: 'session-token', user: { objectId: 'user-1' } },
+      currentUser: { objectId: 'user-1' },
+      requestLogin: vi.fn(),
+    } as any);
+
+    await service.purge('ipOperator.accountSnapshot', 'snapshot-1', { reason: 'delete IP account account-1' });
+
+    expect(parse.call).toHaveBeenCalledWith('system-storage-fn', expect.objectContaining({
+      action: 'purge',
+      entityType: 'ipOperator.accountSnapshot',
+      entityId: 'snapshot-1',
+      reason: 'delete IP account account-1',
+    }));
+  });
+
+  it('reads admin storage inspection through the system storage function', async () => {
+    const parse = {
+      call: vi.fn().mockResolvedValue({
+        code: 200,
+        success: true,
+        data: {
+          adminUserId: 'admin-1',
+          users: [{ userId: 'user-1', totalEntities: 2, entityTypes: {}, auditCount: 1, migrations: { total: 0, byStatus: {} } }],
+          generatedAt: '2026-06-18T09:50:00.000Z',
+        },
+      }),
+    };
+    const service = new CloudSessionStorageService(parse as any, {
+      session: { token: 'admin-token', user: { objectId: 'admin-1' } },
+      currentUser: { objectId: 'admin-1', role: 'admin' },
+      requestLogin: vi.fn(),
+    } as any);
+
+    const inspection = await service.adminInspect(20, 'user-1');
+
+    expect(inspection.users[0].userId).toBe('user-1');
+    expect(parse.call).toHaveBeenCalledWith('system-storage-fn', expect.objectContaining({
+      action: 'adminInspect',
+      sessionToken: 'admin-token',
+      userId: 'admin-1',
+      limit: 20,
+      targetUserId: 'user-1',
+    }));
+  });
+
+  it('does not persist audit lists for guest users', async () => {
+    const parse = { call: vi.fn() };
+    const service = new CloudSessionStorageService(parse as any, {
+      session: null,
+      currentUser: null,
+      requestLogin: vi.fn(),
+    } as any);
+
+    const rows = await service.recentAudits();
+
+    expect(rows).toEqual([]);
+    expect(parse.call).not.toHaveBeenCalled();
+    expect(Object.keys(localStorage)).toEqual([]);
+    expect(Object.keys(sessionStorage)).toEqual([]);
+  });
+
+  it('builds trial storage stats from sessionStorage for guest users', async () => {
+    const parse = { call: vi.fn() };
+    const service = new CloudSessionStorageService(parse as any, {
+      session: null,
+      currentUser: null,
+      requestLogin: vi.fn(),
+    } as any);
+
+    await service.upsert('topic', 'topic-1', { title: 'A' });
+    await service.upsert('topic', 'topic-2', { title: 'B' }, { status: 'archived' });
+    await service.upsert('template', 'tpl-1', { title: 'T' });
+    await service.setMigrationState({
+      migrationKey: 'localStorage-to-systemEntity-v1',
+      sourceKey: 'videoWorkflow.topicPool.items',
+      status: 'completed',
+      migratedCount: 2,
+      failedCount: 0,
+    });
+
+    const stats = await service.stats();
+
+    expect(stats.totalEntities).toBe(3);
+    expect(stats.entityTypes['topic']).toEqual({ total: 2, byStatus: { active: 1, archived: 1 } });
+    expect(stats.entityTypes['template']).toEqual({ total: 1, byStatus: { active: 1 } });
+    expect(stats.migrations).toEqual({ total: 1, byStatus: { completed: 1 } });
+    expect(parse.call).not.toHaveBeenCalled();
+  });
+
+  it('fails clearly when logged in but cloud function id is not configured', async () => {
+    (CLOUD_FN as any).systemStorage = '';
+    const service = new CloudSessionStorageService({ call: vi.fn() } as any, {
+      session: { token: 'session-token', user: { objectId: 'user-1' } },
+      currentUser: { objectId: 'user-1' },
+      requestLogin: vi.fn(),
+    } as any);
+
+    await expect(service.get('topic', 'topic-1')).rejects.toThrow('通用云端存储云函数尚未配置');
+  });
+});

+ 328 - 0
src/app/services/cloud-session-storage.service.ts

@@ -0,0 +1,328 @@
+import { Injectable } from '@angular/core';
+import { AuthCreditService } from './auth-credit.service';
+import { CLOUD_FN } from './cloud-functions';
+import { CloudFnResponse, ParseService } from './parse.service';
+
+export type CloudEntityStatus = 'active' | 'deleted' | 'archived' | string;
+
+export interface CloudEntity<T = unknown> {
+  id: string;
+  entityId: string;
+  type: string;
+  entityType: string;
+  data: T;
+  status: CloudEntityStatus;
+  schemaVersion?: number;
+  spaceId?: string;
+  createdAt?: string;
+  updatedAt?: string;
+}
+
+export interface CloudMigrationState {
+  id?: string;
+  migrationKey: string;
+  sourceKey: string;
+  status: 'pending' | 'completed' | 'failed' | string;
+  migratedCount: number;
+  failedCount: number;
+  detail?: Record<string, unknown>;
+  createdAt?: string;
+  updatedAt?: string;
+}
+
+export interface UpsertEntityOptions {
+  status?: CloudEntityStatus;
+  schemaVersion?: number;
+  spaceId?: string;
+}
+
+export interface CloudAuditEvent {
+  auditAction: string;
+  entityType?: string;
+  entityId?: string;
+  summary?: string;
+  detail?: Record<string, unknown>;
+}
+
+export interface CloudAuditRecord {
+  id: string;
+  entityType: string;
+  entityId: string;
+  action: string;
+  summary: string;
+  detail?: Record<string, unknown>;
+  createdAt?: string;
+}
+
+export interface CloudEntityTypeStats {
+  total: number;
+  byStatus: Record<string, number>;
+}
+
+export interface CloudMigrationStats {
+  total: number;
+  byStatus: Record<string, number>;
+}
+
+export interface CloudStorageStats {
+  totalEntities: number;
+  entityTypes: Record<string, CloudEntityTypeStats>;
+  auditCount: number;
+  migrations: CloudMigrationStats;
+  generatedAt: string;
+}
+
+export interface CloudAdminStorageUserInspection {
+  userId: string;
+  totalEntities: number;
+  entityTypes: Record<string, CloudEntityTypeStats>;
+  auditCount: number;
+  latestAuditAt?: string;
+  migrations: CloudMigrationStats & { failedRows?: number };
+  latestUpdatedAt?: string;
+}
+
+export interface CloudAdminStorageInspection {
+  adminUserId: string;
+  targetUserId?: string;
+  users: CloudAdminStorageUserInspection[];
+  generatedAt: string;
+}
+
+const TRIAL_STORAGE_PREFIX = 'videoWorkflow.trialSystemEntity.';
+const TRIAL_MIGRATION_PREFIX = 'videoWorkflow.trialMigration.';
+
+@Injectable({ providedIn: 'root' })
+export class CloudSessionStorageService {
+  constructor(
+    private parse: ParseService,
+    private auth: AuthCreditService,
+  ) {}
+
+  async list<T = unknown>(entityType: string, status = 'active', limit = 100): Promise<Array<CloudEntity<T>>> {
+    if (!this.hasLogin()) return this.listTrial<T>(entityType, status, limit);
+    return this.call<Array<CloudEntity<T>>>('list', { entityType, status, limit });
+  }
+
+  async get<T = unknown>(entityType: string, entityId: string): Promise<CloudEntity<T> | null> {
+    if (!this.hasLogin()) return this.getTrial<T>(entityType, entityId);
+    return this.call<CloudEntity<T> | null>('get', { entityType, entityId });
+  }
+
+  async upsert<T = unknown>(
+    entityType: string,
+    entityId: string,
+    data: T,
+    options: UpsertEntityOptions = {},
+  ): Promise<CloudEntity<T>> {
+    if (!this.hasLogin()) return this.upsertTrial(entityType, entityId, data, options);
+    return this.call<CloudEntity<T>>('upsert', { entityType, entityId, data, ...options });
+  }
+
+  async patch<T = unknown>(
+    entityType: string,
+    entityId: string,
+    patch: Partial<T> | Record<string, unknown>,
+  ): Promise<CloudEntity<T>> {
+    if (!this.hasLogin()) {
+      const current = await this.getTrial<T>(entityType, entityId);
+      const data = { ...((current?.data || {}) as object), ...(patch as object) } as T;
+      return this.upsertTrial(entityType, entityId, data, { status: current?.status || 'active' });
+    }
+    return this.call<CloudEntity<T>>('patch', { entityType, entityId, patch });
+  }
+
+  async delete(entityType: string, entityId: string): Promise<void> {
+    if (!this.hasLogin()) {
+      const current = await this.getTrial(entityType, entityId);
+      if (current) await this.upsertTrial(entityType, entityId, current.data, { status: 'deleted' });
+      return;
+    }
+    await this.call('delete', { entityType, entityId });
+  }
+
+  async purge(entityType: string, entityId: string, confirmation: { reason: string }): Promise<void> {
+    const reason = confirmation.reason.trim();
+    if (!reason) throw new Error('物理删除必须提供原因');
+    if (!this.hasLogin()) {
+      const bucket = this.readTrialBucket(entityType).filter((item) => item.entityId !== entityId);
+      sessionStorage.setItem(this.trialKey(entityType), JSON.stringify(bucket));
+      return;
+    }
+    await this.call('purge', { entityType, entityId, reason });
+  }
+
+  async audit(event: CloudAuditEvent): Promise<void> {
+    if (!this.hasLogin()) return;
+    await this.call('audit', event);
+  }
+
+  async recentAudits(limit = 50, entityType = ''): Promise<CloudAuditRecord[]> {
+    if (!this.hasLogin()) return [];
+    return this.call<CloudAuditRecord[]>('auditList', { limit, entityType });
+  }
+
+  async stats(): Promise<CloudStorageStats> {
+    if (!this.hasLogin()) return this.trialStats();
+    return this.call<CloudStorageStats>('stats', {});
+  }
+
+  async adminInspect(limit = 100, targetUserId = ''): Promise<CloudAdminStorageInspection> {
+    return this.call<CloudAdminStorageInspection>('adminInspect', { limit, targetUserId });
+  }
+
+  async getMigrationState(migrationKey: string, sourceKey: string): Promise<CloudMigrationState | null> {
+    if (!this.hasLogin()) return this.getTrialMigration(migrationKey, sourceKey);
+    return this.call<CloudMigrationState | null>('migrationGet', { migrationKey, sourceKey });
+  }
+
+  async setMigrationState(state: CloudMigrationState): Promise<CloudMigrationState> {
+    if (!this.hasLogin()) return this.setTrialMigration(state);
+    return this.call<CloudMigrationState>('migrationSet', state);
+  }
+
+  private async call<T = unknown>(action: string, params: object): Promise<T> {
+    const session = this.auth.session;
+    const userId = this.auth.currentUser?.objectId || session?.user?.objectId || '';
+    const sessionToken = session?.token || '';
+    if (!sessionToken || !userId) {
+      this.auth.requestLogin('云端存储');
+      throw new Error('请先登录后再同步云端数据');
+    }
+    if (!CLOUD_FN.systemStorage) {
+      throw new Error('通用云端存储云函数尚未配置');
+    }
+
+    const res: CloudFnResponse<T> = await this.parse.call<T>(CLOUD_FN.systemStorage, {
+      action,
+      sessionToken,
+      userId,
+      ...(params as Record<string, unknown>),
+    });
+    if (res.code !== 200 || !res.success) {
+      throw new Error(res.error || '云端存储调用失败');
+    }
+    return res.data as T;
+  }
+
+  private hasLogin(): boolean {
+    return Boolean(this.auth.session?.token && this.auth.currentUser?.objectId);
+  }
+
+  private listTrial<T>(entityType: string, status: string, limit: number): Array<CloudEntity<T>> {
+    return this.readTrialBucket<T>(entityType)
+      .filter((item) => !status || item.status === status)
+      .sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')))
+      .slice(0, Math.max(1, limit));
+  }
+
+  private getTrial<T>(entityType: string, entityId: string): CloudEntity<T> | null {
+    return this.readTrialBucket<T>(entityType).find((item) => item.entityId === entityId) || null;
+  }
+
+  private upsertTrial<T>(
+    entityType: string,
+    entityId: string,
+    data: T,
+    options: UpsertEntityOptions = {},
+  ): CloudEntity<T> {
+    const now = new Date().toISOString();
+    const bucket = this.readTrialBucket<T>(entityType);
+    const previous = bucket.find((item) => item.entityId === entityId);
+    const entity: CloudEntity<T> = {
+      id: entityId,
+      entityId,
+      type: entityType,
+      entityType,
+      data,
+      status: options.status || previous?.status || 'active',
+      schemaVersion: options.schemaVersion || previous?.schemaVersion || 1,
+      spaceId: options.spaceId || previous?.spaceId || '',
+      createdAt: previous?.createdAt || now,
+      updatedAt: now,
+    };
+    const next = [entity, ...bucket.filter((item) => item.entityId !== entityId)];
+    sessionStorage.setItem(this.trialKey(entityType), JSON.stringify(next));
+    return entity;
+  }
+
+  private readTrialBucket<T>(entityType: string): Array<CloudEntity<T>> {
+    try {
+      const raw = sessionStorage.getItem(this.trialKey(entityType));
+      const rows = raw ? JSON.parse(raw) : [];
+      return Array.isArray(rows) ? rows : [];
+    } catch {
+      return [];
+    }
+  }
+
+  private getTrialMigration(migrationKey: string, sourceKey: string): CloudMigrationState | null {
+    try {
+      const raw = sessionStorage.getItem(this.trialMigrationKey(migrationKey, sourceKey));
+      return raw ? JSON.parse(raw) : null;
+    } catch {
+      return null;
+    }
+  }
+
+  private setTrialMigration(state: CloudMigrationState): CloudMigrationState {
+    const next: CloudMigrationState = {
+      ...state,
+      migratedCount: Number(state.migratedCount || 0),
+      failedCount: Number(state.failedCount || 0),
+      updatedAt: new Date().toISOString(),
+    };
+    sessionStorage.setItem(this.trialMigrationKey(state.migrationKey, state.sourceKey), JSON.stringify(next));
+    return next;
+  }
+
+  private trialStats(): CloudStorageStats {
+    const entityTypes: Record<string, CloudEntityTypeStats> = {};
+    let totalEntities = 0;
+    for (const key of this.sessionKeys()) {
+      if (!key.startsWith(TRIAL_STORAGE_PREFIX)) continue;
+      const entityType = key.slice(TRIAL_STORAGE_PREFIX.length);
+      const rows = this.readTrialBucket(entityType);
+      const stats: CloudEntityTypeStats = { total: rows.length, byStatus: {} };
+      for (const row of rows) {
+        const status = row.status || 'active';
+        stats.byStatus[status] = (stats.byStatus[status] || 0) + 1;
+      }
+      entityTypes[entityType] = stats;
+      totalEntities += rows.length;
+    }
+    const migrations: CloudMigrationStats = { total: 0, byStatus: {} };
+    for (const key of this.sessionKeys()) {
+      if (!key.startsWith(TRIAL_MIGRATION_PREFIX)) continue;
+      const raw = sessionStorage.getItem(key);
+      if (!raw) continue;
+      try {
+        const state = JSON.parse(raw) as CloudMigrationState;
+        const status = state.status || 'unknown';
+        migrations.total += 1;
+        migrations.byStatus[status] = (migrations.byStatus[status] || 0) + 1;
+      } catch {
+        // Ignore malformed trial migration records.
+      }
+    }
+    return {
+      totalEntities,
+      entityTypes,
+      auditCount: 0,
+      migrations,
+      generatedAt: new Date().toISOString(),
+    };
+  }
+
+  private trialKey(entityType: string): string {
+    return `${TRIAL_STORAGE_PREFIX}${entityType}`;
+  }
+
+  private trialMigrationKey(migrationKey: string, sourceKey: string): string {
+    return `${TRIAL_MIGRATION_PREFIX}${migrationKey}.${sourceKey}`;
+  }
+
+  private sessionKeys(): string[] {
+    return Array.from({ length: sessionStorage.length }, (_, index) => sessionStorage.key(index) || '').filter(Boolean);
+  }
+}

+ 66 - 0
src/app/services/douyin-api.service.spec.ts

@@ -0,0 +1,66 @@
+import { firstValueFrom, of, throwError } from 'rxjs';
+import { environment } from '../../environments/environment';
+import { DouyinApiService } from './douyin-api.service';
+
+describe('DouyinApiService', () => {
+  const originalProduction = environment.production;
+
+  afterEach(() => {
+    environment.production = originalProduction;
+    vi.restoreAllMocks();
+  });
+
+  it('uses the douyin cloud function instead of /backend in production', async () => {
+    environment.production = true;
+    const http = {
+      post: vi.fn(() => throwError(() => ({ status: 405, message: 'Method Not Allowed' }))),
+    };
+    const parse = {
+      call: vi.fn(async () => ({
+        code: 200,
+        success: true,
+        data: { user_info: { nickname: '测试博主', unique_id: 'tester' } },
+      })),
+    };
+    const auth = {
+      session: { token: 'session-token' },
+      currentUser: { objectId: 'user-1' },
+    };
+    const service = new DouyinApiService(http as any, parse as any, auth as any);
+
+    const result = await firstValueFrom(service.getUserProfileByUniqueId('tester'));
+
+    expect(result.user_info.nickname).toBe('测试博主');
+    expect(http.post).not.toHaveBeenCalled();
+    expect(parse.call).toHaveBeenCalledWith('HArHym0smi', expect.objectContaining({
+      action: 'call',
+      route: 'userProfileWeb',
+      params: { unique_id: 'tester' },
+      sessionToken: 'session-token',
+      userId: 'user-1',
+    }));
+  });
+
+  it('keeps local Express as the first path in development', async () => {
+    environment.production = false;
+    const http = {
+      post: vi.fn(() => of({ success: true, data: { aweme_list: [] } })),
+    };
+    const parse = {
+      call: vi.fn(),
+    };
+    const auth = {
+      session: { token: 'session-token' },
+      currentUser: { objectId: 'user-1' },
+    };
+    const service = new DouyinApiService(http as any, parse as any, auth as any);
+
+    const result = await firstValueFrom(service.getUserPosts({ sec_user_id: 'sec-1' }));
+
+    expect(result.aweme_list).toEqual([]);
+    expect(http.post).toHaveBeenCalledWith('/backend/api/douyin/call', expect.objectContaining({
+      route: 'userPosts',
+    }));
+    expect(parse.call).not.toHaveBeenCalled();
+  });
+});

+ 98 - 3
src/app/services/douyin-api.service.ts

@@ -3,6 +3,9 @@ import { HttpClient } from '@angular/common/http';
 import { Observable, firstValueFrom, from, throwError } from 'rxjs';
 import { catchError, retry } from 'rxjs/operators';
 import { environment } from '../../environments/environment';
+import { AuthCreditService } from './auth-credit.service';
+import { CLOUD_FN } from './cloud-functions';
+import { ParseService } from './parse.service';
 
 // 抖音API接口定义
 interface DouyinSearchParams {
@@ -25,13 +28,26 @@ interface DouyinCommentsParams {
   count?: number | string;
 }
 
+interface DouyinUserPostsParams {
+  sec_user_id: string;
+  cursor?: number | string;
+  max_cursor?: number | string;
+  count?: number | string;
+  sort_type?: number | string;
+  filter_type?: number | string;
+}
+
 @Injectable({
   providedIn: 'root'
 })
 export class DouyinApiService {
   private readonly MAX_RETRIES = environment.douyinApi.maxRetries;
 
-  constructor(private http: HttpClient) {}
+  constructor(
+    private http: HttpClient,
+    private parse: ParseService,
+    private auth: AuthCreditService,
+  ) {}
 
   /**
    * 抖音综合搜索
@@ -127,11 +143,58 @@ export class DouyinApiService {
     );
   }
 
+  /**
+   * 根据抖音号获取用户主页信息
+   * @param uniqueId 抖音号 / unique_id
+   * @returns 用户主页信息
+   */
+  getUserProfileByUniqueId(uniqueId: string): Observable<any> {
+    return this.callDouyin('userProfileWeb', { params: { unique_id: uniqueId } }).pipe(
+      retry(this.MAX_RETRIES),
+      catchError(this.handleError)
+    );
+  }
+
+  /**
+   * 获取用户最近作品
+   * @param params 用户作品参数
+   * @returns 作品列表
+   */
+  getUserPosts(params: DouyinUserPostsParams): Observable<any> {
+    const requestParams = {
+      sec_user_id: params.sec_user_id,
+      max_cursor: params.max_cursor ?? params.cursor ?? 0,
+      count: params.count || 10,
+      sort_type: params.sort_type ?? 0,
+      filter_type: params.filter_type ?? 0,
+    };
+
+    return this.callDouyin('userPosts', { params: requestParams }).pipe(
+      retry(this.MAX_RETRIES),
+      catchError(this.handleError)
+    );
+  }
+
   private callDouyin(route: string, params: { payload?: Record<string, any>; params?: Record<string, any>; optional?: boolean }): Observable<any> {
-    return from(this.callDouyinLocalFirst(route, params));
+    return from(this.callDouyinByRuntime(route, params));
+  }
+
+  private async callDouyinByRuntime(route: string, params: { payload?: Record<string, any>; params?: Record<string, any>; optional?: boolean }): Promise<any> {
+    if (environment.production && CLOUD_FN.douyin) {
+      return this.callDouyinCloud(route, params);
+    }
+
+    try {
+      return await this.callDouyinLocal(route, params);
+    } catch (localError) {
+      if (!environment.production && CLOUD_FN.douyin && this.shouldFallbackToCloud(localError)) {
+        return this.callDouyinCloud(route, params);
+      }
+      throw localError;
+    }
   }
 
-  private async callDouyinLocalFirst(route: string, params: { payload?: Record<string, any>; params?: Record<string, any>; optional?: boolean }): Promise<any> {
+  private async callDouyinLocal(route: string, params: { payload?: Record<string, any>; params?: Record<string, any>; optional?: boolean }): Promise<any> {
     try {
       const local = await firstValueFrom(this.http.post<{ success?: boolean; optional?: boolean; data?: any; error?: string }>('/backend/api/douyin/call', {
         route,
@@ -150,10 +213,42 @@ export class DouyinApiService {
     }
   }
 
+  private async callDouyinCloud(route: string, params: { payload?: Record<string, any>; params?: Record<string, any>; optional?: boolean }): Promise<any> {
+    if (!CLOUD_FN.douyin) {
+      throw new Error('抖音数据云函数尚未配置');
+    }
+    const res = await this.parse.call<any>(CLOUD_FN.douyin, {
+      action: 'call',
+      route,
+      payload: params.payload || {},
+      params: params.params || {},
+      sessionToken: this.auth.session?.token || '',
+      userId: this.auth.currentUser?.objectId || '',
+    });
+    if (res.code === 200 && res.success) return res.data;
+    if (params.optional && this.isOptionalDouyinFailure(res)) return null;
+    throw new Error(`抖音数据云函数请求失败:${this.readCloudError(res)}`);
+  }
+
   private readLocalProxyError(error: any): string {
     return error?.error?.error || error?.error?.message || error?.message || '本地抖音数据代理不可用';
   }
 
+  private readCloudError(error: any): string {
+    return error?.data?.error || error?.raw?.error || error?.error || error?.message || '未知错误';
+  }
+
+  private shouldFallbackToCloud(error: any): boolean {
+    const status = Number(error?.status || 0);
+    const message = this.readLocalProxyError(error);
+    return status === 0 || status === 404 || status === 405 || /Cannot POST|Method Not Allowed|本地抖音数据代理不可用/i.test(message);
+  }
+
+  private isOptionalDouyinFailure(result: any): boolean {
+    const code = Number(result?.code || 0);
+    return code === 404 || code === 429 || code >= 500;
+  }
+
   private handleOptionalError(error: any): Observable<never> {
     return throwError(() => error);
   }

+ 75 - 0
src/app/services/douyin-insight.service.spec.ts

@@ -0,0 +1,75 @@
+import { describe, expect, it, vi } from 'vitest';
+import { CLOUD_FN } from './cloud-functions';
+import { DouyinInsightService } from './douyin-insight.service';
+
+describe('DouyinInsightService', () => {
+  it('queries transcript jobs without overriding the fmode function id', async () => {
+    const { service, parse } = createService({ id: 'job-1', status: 'completed' });
+
+    await service.getTranscript('job-1');
+
+    expect(parse.callOrThrow).toHaveBeenCalledWith(CLOUD_FN.douyinInsight, {
+      action: 'transcriptGet',
+      jobId: 'job-1',
+      userId: 'user-1',
+      sessionToken: 'session-1',
+    });
+    expect(parse.callOrThrow.mock.calls[0][1].id).toBeUndefined();
+  });
+
+  it('updates insight rows with scoped identifiers instead of top-level id', async () => {
+    const { service, parse } = createService({ id: 'analysis-1', status: 'ready' });
+
+    await service.updateAnalysis('analysis-1', { status: 'ready' } as any);
+    await service.updateTopic('topic-1', { status: 'selected' } as any);
+    await service.archiveTopic('topic-2');
+
+    expect(parse.callOrThrow.mock.calls[0][1]).toMatchObject({
+      action: 'analysisUpdate',
+      recordId: 'analysis-1',
+    });
+    expect(parse.callOrThrow.mock.calls[1][1]).toMatchObject({
+      action: 'topicUpdate',
+      topicId: 'topic-1',
+    });
+    expect(parse.callOrThrow.mock.calls[2][1]).toMatchObject({
+      action: 'topicArchive',
+      topicId: 'topic-2',
+    });
+    for (const [, params] of parse.callOrThrow.mock.calls) {
+      expect(params.id).toBeUndefined();
+    }
+  });
+
+  it('clears stale login state when Parse rejects the session token', async () => {
+    const { service, parse, auth } = createService(null);
+    const error = new Error('Invalid session token');
+    (error as any).response = { code: 400, success: false, error: 'Invalid session token' };
+    parse.callOrThrow.mockRejectedValue(error);
+
+    await expect(service.startTranscript('aweme-1')).rejects.toThrow('登录已失效,请重新登录后再分析视频');
+
+    expect(auth.logout).toHaveBeenCalled();
+    expect(auth.requestLogin).toHaveBeenCalledWith('视频分析');
+  });
+});
+
+function createService(result: unknown) {
+  const parse = {
+    callOrThrow: vi.fn().mockResolvedValue(result),
+  };
+  const auth = {
+    session: {
+      token: 'session-1',
+      user: { objectId: 'user-1' },
+    },
+    logout: vi.fn(),
+    requestLogin: vi.fn(),
+  };
+
+  return {
+    auth,
+    parse,
+    service: new DouyinInsightService(parse as any, auth as any),
+  };
+}

+ 31 - 10
src/app/services/douyin-insight.service.ts

@@ -37,7 +37,7 @@ export class DouyinInsightService {
 
   async updateAnalysis(id: string, patch: Partial<ViralAnalysis>): Promise<ViralAnalysis | null> {
     if (!this.available) return null;
-    return this.call<ViralAnalysis>('analysisUpdate', { id, patch });
+    return this.call<ViralAnalysis>('analysisUpdate', { recordId: id, patch });
   }
 
   async listAnalyses(limit = 100): Promise<ViralAnalysis[]> {
@@ -52,12 +52,12 @@ export class DouyinInsightService {
 
   async updateTopic(id: string, patch: Partial<TopicIdea>): Promise<TopicIdea | null> {
     if (!this.available) return null;
-    return this.call<TopicIdea>('topicUpdate', { id, patch });
+    return this.call<TopicIdea>('topicUpdate', { topicId: id, patch });
   }
 
   async archiveTopic(id: string): Promise<TopicIdea | null> {
     if (!this.available) return null;
-    return this.call<TopicIdea>('topicArchive', { id });
+    return this.call<TopicIdea>('topicArchive', { topicId: id });
   }
 
   async listTopics(limit = 500): Promise<TopicIdea[]> {
@@ -82,17 +82,38 @@ export class DouyinInsightService {
 
   async getTranscript(jobId: string): Promise<DouyinTranscriptJob | null> {
     if (!this.available) return null;
-    return this.call<DouyinTranscriptJob>('transcriptGet', { id: jobId });
+    return this.call<DouyinTranscriptJob>('transcriptGet', { jobId });
   }
 
   private async call<T>(action: InsightAction, params: Record<string, any>): Promise<T> {
     const session = this.auth.session;
     if (!session) throw new Error('请先登录');
-    return this.parse.callOrThrow<T>(CLOUD_FN.douyinInsight, {
-      action,
-      ...params,
-      userId: session.user.objectId,
-      sessionToken: session.token,
-    });
+    try {
+      return await this.parse.callOrThrow<T>(CLOUD_FN.douyinInsight, {
+        action,
+        ...params,
+        userId: session.user.objectId,
+        sessionToken: session.token,
+      });
+    } catch (error) {
+      if (this.isInvalidSessionError(error)) {
+        this.auth.logout();
+        this.auth.requestLogin('视频分析');
+        throw new Error('登录已失效,请重新登录后再分析视频');
+      }
+      throw error;
+    }
+  }
+
+  private isInvalidSessionError(error: unknown): boolean {
+    const value = error as any;
+    const text = [
+      value?.message,
+      value?.response?.error,
+      value?.detail?.error,
+      value?.response?.message,
+      value?.detail?.message,
+    ].filter(Boolean).join(' ');
+    return /invalid session token|登录已失效|请重新登录/i.test(text);
   }
 }

+ 106 - 0
src/app/services/file-asset.service.spec.ts

@@ -0,0 +1,106 @@
+import { CLOUD_FN } from './cloud-functions';
+import { FileAssetService } from './file-asset.service';
+
+describe('FileAssetService', () => {
+  const originalFileAsset = CLOUD_FN.fileAsset;
+
+  beforeEach(() => {
+    localStorage.clear();
+    sessionStorage.clear();
+    (CLOUD_FN as any).fileAsset = 'file-asset-fn';
+  });
+
+  afterEach(() => {
+    (CLOUD_FN as any).fileAsset = originalFileAsset;
+    vi.restoreAllMocks();
+  });
+
+  it('registers uploaded assets through the file asset cloud function', async () => {
+    const parse = {
+      call: vi.fn().mockResolvedValue({
+        code: 200,
+        success: true,
+        data: {
+          assetId: 'asset-1',
+          qiniuKey: 'users/user-1/image/2026/06/free/key.png',
+          url: 'https://cdn.example/key.png',
+          kind: 'image',
+          sizeBytes: 123,
+          mimeType: 'image/png',
+        },
+      }),
+    };
+    const auth = {
+      session: { token: 'session-token', user: { objectId: 'user-1' } },
+      currentUser: { objectId: 'user-1' },
+      requestLogin: vi.fn(),
+    };
+    const service = new FileAssetService(parse as any, auth as any);
+
+    const asset = await service.registerUploadedAsset({
+      qiniuKey: 'users/user-1/image/2026/06/free/key.png',
+      url: 'https://cdn.example/key.png',
+      kind: 'image',
+      mimeType: 'image/png',
+      sizeBytes: 123,
+      sourceModule: 'image-generation',
+    });
+
+    expect(asset.assetId).toBe('asset-1');
+    expect(parse.call).toHaveBeenCalledWith('file-asset-fn', expect.objectContaining({
+      action: 'register',
+      sessionToken: 'session-token',
+      userId: 'user-1',
+      qiniuKey: 'users/user-1/image/2026/06/free/key.png',
+      sourceModule: 'image-generation',
+    }));
+  });
+
+  it('does not use browser storage when the user is not logged in', async () => {
+    const parse = { call: vi.fn() };
+    const auth = {
+      session: null,
+      currentUser: null,
+      requestLogin: vi.fn(),
+    };
+    const service = new FileAssetService(parse as any, auth as any);
+
+    await expect(service.stats()).rejects.toThrow('请先登录后再登记文件资产');
+
+    expect(auth.requestLogin).toHaveBeenCalledWith('七牛文件资产');
+    expect(parse.call).not.toHaveBeenCalled();
+    expect(Object.keys(localStorage)).toEqual([]);
+    expect(Object.keys(sessionStorage)).toEqual([]);
+  });
+
+  it('reads admin file inspection through the file asset cloud function', async () => {
+    const parse = {
+      call: vi.fn().mockResolvedValue({
+        code: 200,
+        success: true,
+        data: {
+          adminUserId: 'admin-1',
+          users: [{ userId: 'user-1', totalFiles: 3, totalBytes: 4096, byKind: {} }],
+          generatedAt: '2026-06-18T09:55:00.000Z',
+        },
+      }),
+    };
+    const auth = {
+      session: { token: 'admin-token', user: { objectId: 'admin-1' } },
+      currentUser: { objectId: 'admin-1', role: 'admin' },
+      requestLogin: vi.fn(),
+    };
+    const service = new FileAssetService(parse as any, auth as any);
+
+    const inspection = await service.adminInspect(20, 'user-1');
+
+    expect(inspection.users[0].totalFiles).toBe(3);
+    expect(parse.call).toHaveBeenCalledWith('file-asset-fn', expect.objectContaining({
+      action: 'adminInspect',
+      sessionToken: 'admin-token',
+      userId: 'admin-1',
+      limit: 20,
+      targetUserId: 'user-1',
+    }));
+  });
+});

+ 115 - 0
src/app/services/file-asset.service.ts

@@ -0,0 +1,115 @@
+import { Injectable } from '@angular/core';
+import { AuthCreditService } from './auth-credit.service';
+import { CLOUD_FN } from './cloud-functions';
+import { CloudFnResponse, ParseService } from './parse.service';
+
+export type FileAssetKind = 'image' | 'video' | 'audio' | 'export' | 'snapshot';
+
+export interface UploadedAsset {
+  assetId: string;
+  id?: string;
+  url: string;
+  qiniuKey: string;
+  bucket?: string;
+  kind: FileAssetKind;
+  sizeBytes: number;
+  mimeType: string;
+  sha256?: string;
+  sourceModule?: string;
+  bizType?: string;
+  bizId?: string;
+  status?: string;
+  metadata?: Record<string, unknown>;
+  createdAt?: string;
+  updatedAt?: string;
+}
+
+export interface RegisterUploadedAssetInput {
+  qiniuKey: string;
+  url: string;
+  bucket?: string;
+  mimeType: string;
+  kind: FileAssetKind;
+  sizeBytes: number;
+  sha256?: string;
+  sourceModule?: string;
+  bizType?: string;
+  bizId?: string;
+  metadata?: Record<string, unknown>;
+}
+
+export interface FileAssetKindStats {
+  total: number;
+  bytes: number;
+  byStatus: Record<string, number>;
+}
+
+export interface FileAssetStats {
+  total: number;
+  totalBytes: number;
+  byKind: Record<string, FileAssetKindStats>;
+  generatedAt: string;
+}
+
+export interface FileAssetAdminUserInspection {
+  userId: string;
+  totalFiles: number;
+  totalBytes: number;
+  byKind: Record<string, FileAssetKindStats>;
+  latestCreatedAt?: string;
+}
+
+export interface FileAssetAdminInspection {
+  adminUserId: string;
+  targetUserId?: string;
+  users: FileAssetAdminUserInspection[];
+  generatedAt: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class FileAssetService {
+  constructor(
+    private parse: ParseService,
+    private auth: AuthCreditService,
+  ) {}
+
+  async registerUploadedAsset(input: RegisterUploadedAssetInput): Promise<UploadedAsset> {
+    return this.call<UploadedAsset>('register', input as unknown as Record<string, unknown>);
+  }
+
+  async stats(): Promise<FileAssetStats> {
+    return this.call<FileAssetStats>('stats', {});
+  }
+
+  async list(kind = '', limit = 50): Promise<UploadedAsset[]> {
+    return this.call<UploadedAsset[]>('list', { kind, limit });
+  }
+
+  async adminInspect(limit = 100, targetUserId = ''): Promise<FileAssetAdminInspection> {
+    return this.call<FileAssetAdminInspection>('adminInspect', { limit, targetUserId });
+  }
+
+  private async call<T>(action: string, params: Record<string, unknown>): Promise<T> {
+    const session = this.auth.session;
+    const userId = this.auth.currentUser?.objectId || session?.user?.objectId || '';
+    const sessionToken = session?.token || '';
+    if (!sessionToken || !userId) {
+      this.auth.requestLogin('七牛文件资产');
+      throw new Error('请先登录后再登记文件资产');
+    }
+    if (!CLOUD_FN.fileAsset) {
+      throw new Error('七牛文件资产云函数尚未配置');
+    }
+
+    const res: CloudFnResponse<T> = await this.parse.call<T>(CLOUD_FN.fileAsset, {
+      action,
+      sessionToken,
+      userId,
+      ...params,
+    });
+    if (res.code !== 200 || !res.success) {
+      throw new Error(res.error || '七牛文件资产服务调用失败');
+    }
+    return res.data as T;
+  }
+}

+ 54 - 0
src/app/services/parse.service.spec.ts

@@ -0,0 +1,54 @@
+import { ParseService } from './parse.service';
+
+describe('ParseService cloud function calls', () => {
+  const originalFetch = globalThis.fetch;
+
+  afterEach(() => {
+    globalThis.fetch = originalFetch;
+    vi.restoreAllMocks();
+  });
+
+  it('retries transient fmode gateway body parsing errors before returning success', async () => {
+    const fetchMock = vi.fn()
+      .mockResolvedValueOnce(textResponse({
+        error: '参数错误',
+        message: '必须提供 id、(pid + path) 或 path 其中一种方式定位云函数',
+      }))
+      .mockResolvedValueOnce(textResponse({
+        code: 200,
+        success: true,
+        data: { balance: 2000 },
+      }));
+    globalThis.fetch = fetchMock as any;
+    const service = new ParseService();
+
+    const result = await service.call('auth-fn', { action: 'balance', sessionToken: 'session-token' });
+
+    expect(fetchMock).toHaveBeenCalledTimes(2);
+    expect(result).toEqual({ code: 200, success: true, data: { balance: 2000 } });
+  });
+
+  it('does not retry normal business failures', async () => {
+    const fetchMock = vi.fn().mockResolvedValue(textResponse({
+      code: 401,
+      success: false,
+      error: '请先登录',
+    }));
+    globalThis.fetch = fetchMock as any;
+    const service = new ParseService();
+
+    const result = await service.call('auth-fn', { action: 'balance' });
+
+    expect(fetchMock).toHaveBeenCalledTimes(1);
+    expect(result.code).toBe(401);
+    expect(result.error).toBe('请先登录');
+  });
+});
+
+function textResponse(data: unknown): Response {
+  return {
+    ok: true,
+    status: 200,
+    text: async () => JSON.stringify(data),
+  } as Response;
+}

+ 77 - 41
src/app/services/parse.service.ts

@@ -9,6 +9,9 @@ export interface CloudFnResponse<T = any> {
   [key: string]: any;
 }
 
+const FUNCTION_CALL_MAX_ATTEMPTS = 3;
+const FUNCTION_CALL_RETRY_BASE_MS = 350;
+
 @Injectable({ providedIn: 'root' })
 export class ParseService {
   private Parse: any;
@@ -24,50 +27,43 @@ export class ParseService {
   }
 
   async call<T = any>(id: string, params: Record<string, any> = {}): Promise<CloudFnResponse<T>> {
-    try {
-      const response = await fetch(this.functionsURL, {
-        method: 'POST',
-        headers: {
-          'Content-Type': 'application/json',
-          'X-Parse-Application-Id': this.appId,
-        },
-        body: JSON.stringify({
-          id,
-          ...params,
-          _ApplicationId: this.appId,
-        }),
-      });
-
-      const text = await response.text();
-      const data = this.parseJson(text);
-
-      if (!response.ok) {
-        return {
-          code: response.status,
-          success: false,
-          error: this.readError(data, text) || `HTTP error! status: ${response.status}`,
-          raw: data,
-          upstream: data?.upstream,
-        };
-      }
+    const payload = {
+      id,
+      ...params,
+      _ApplicationId: this.appId,
+    };
 
-      if (data && typeof data === 'object' && 'code' in data && 'success' in data) {
-        return data as CloudFnResponse<T>;
-      }
+    let lastError = '';
+    for (let attempt = 1; attempt <= FUNCTION_CALL_MAX_ATTEMPTS; attempt += 1) {
+      try {
+        const response = await fetch(this.functionsURL, {
+          method: 'POST',
+          headers: {
+            'Content-Type': 'application/json',
+            'X-Parse-Application-Id': this.appId,
+          },
+          body: JSON.stringify(payload),
+        });
 
-      if (data?.error) {
-        return {
-          code: 500,
-          success: false,
-          error: this.readError(data, text) || 'Cloud function error',
-        };
+        const text = await response.text();
+        const data = this.parseJson(text);
+        const result = this.normalizeResponse<T>(response, data, text);
+        if (this.isRetryableFunctionGatewayError(result) && attempt < FUNCTION_CALL_MAX_ATTEMPTS) {
+          await this.sleep(FUNCTION_CALL_RETRY_BASE_MS * attempt);
+          continue;
+        }
+        return result;
+      } catch (e: any) {
+        lastError = e?.message || 'Cloud function call failed';
+        if (attempt < FUNCTION_CALL_MAX_ATTEMPTS && this.isRetryableFetchError(lastError)) {
+          await this.sleep(FUNCTION_CALL_RETRY_BASE_MS * attempt);
+          continue;
+        }
+        console.error(`[ParseService] ${id}:`, e);
+        return { code: 500, success: false, error: lastError };
       }
-
-      return { code: 200, success: true, data: (data?.result ?? data) as T };
-    } catch (e: any) {
-      console.error(`[ParseService] ${id}:`, e);
-      return { code: 500, success: false, error: e?.message || 'Cloud function call failed' };
     }
+    return { code: 500, success: false, error: lastError || 'Cloud function call failed' };
   }
 
   async callOrThrow<T = any>(id: string, params: Record<string, any> = {}): Promise<T> {
@@ -90,8 +86,48 @@ export class ParseService {
     }
   }
 
+  private normalizeResponse<T>(response: Response, data: any, text: string): CloudFnResponse<T> {
+    if (!response.ok) {
+      return {
+        code: response.status,
+        success: false,
+        error: this.readError(data, text) || `HTTP error! status: ${response.status}`,
+        raw: data,
+        upstream: data?.upstream,
+      };
+    }
+
+    if (data && typeof data === 'object' && 'code' in data && 'success' in data) {
+      return data as CloudFnResponse<T>;
+    }
+
+    if (data?.error) {
+      return {
+        code: 500,
+        success: false,
+        error: this.readError(data, text) || 'Cloud function error',
+        raw: data,
+      };
+    }
+
+    return { code: 200, success: true, data: (data?.result ?? data) as T };
+  }
+
+  private isRetryableFunctionGatewayError(result: CloudFnResponse): boolean {
+    const message = `${result.error || ''} ${(result as any).raw?.message || ''}`;
+    return /必须提供\s*id|fetch failed|Failed to fetch|NetworkError|Load failed/i.test(message);
+  }
+
+  private isRetryableFetchError(message: string): boolean {
+    return /fetch failed|Failed to fetch|NetworkError|Load failed/i.test(message || '');
+  }
+
+  private sleep(ms: number): Promise<void> {
+    return new Promise((resolve) => setTimeout(resolve, ms));
+  }
+
   private readError(data: any, fallback: string): string {
-    const detail = data?.data?.error || data?.error;
+    const detail = data?.data?.error || data?.error || data?.message;
     if (typeof detail === 'string') return detail;
     if (detail && typeof detail === 'object') {
       return [

+ 157 - 0
src/app/services/qiniu-upload.service.spec.ts

@@ -0,0 +1,157 @@
+import { NgZone } from '@angular/core';
+import { filter, lastValueFrom } from 'rxjs';
+import { CLOUD_FN } from './cloud-functions';
+import { QiniuUploadService } from './qiniu-upload.service';
+
+class MockXMLHttpRequest {
+  static instances: MockXMLHttpRequest[] = [];
+  upload: { onprogress: ((event: ProgressEvent) => void) | null } = { onprogress: null };
+  onload: (() => void) | null = null;
+  onerror: (() => void) | null = null;
+  readyState = 0;
+  status = 200;
+  responseText = '{}';
+  method = '';
+  url = '';
+
+  constructor() {
+    MockXMLHttpRequest.instances.push(this);
+  }
+
+  open(method: string, url: string): void {
+    this.method = method;
+    this.url = url;
+  }
+
+  send(): void {
+    this.readyState = XMLHttpRequest.DONE;
+    setTimeout(() => this.onload?.(), 0);
+  }
+
+  abort(): void {
+    this.readyState = XMLHttpRequest.DONE;
+  }
+}
+
+describe('QiniuUploadService', () => {
+  const originalUpload = CLOUD_FN.upload;
+  const originalXHR = globalThis.XMLHttpRequest;
+
+  beforeEach(() => {
+    (CLOUD_FN as any).upload = 'upload-fn';
+    MockXMLHttpRequest.instances = [];
+    (globalThis as any).XMLHttpRequest = MockXMLHttpRequest;
+  });
+
+  afterEach(() => {
+    (CLOUD_FN as any).upload = originalUpload;
+    (globalThis as any).XMLHttpRequest = originalXHR;
+    vi.restoreAllMocks();
+  });
+
+  it('requests a user-scoped token and registers file metadata after upload', async () => {
+    const parse = {
+      call: vi.fn().mockResolvedValue({
+        code: 200,
+        success: true,
+        data: {
+          uploadUrl: 'https://upload.example',
+          token: 'qiniu-token',
+          key: 'users/user-1/image/2026/06/brief-1/key.png',
+          url: 'https://cdn.example/key.png',
+          mimeType: 'image/png',
+          kind: 'image',
+          bucket: 'bucket-a',
+          size: 12,
+        },
+      }),
+    };
+    const auth = {
+      session: { token: 'session-token', user: { objectId: 'user-1' } },
+      currentUser: { objectId: 'user-1' },
+      requestLogin: vi.fn(),
+    };
+    const fileAsset = {
+      registerUploadedAsset: vi.fn().mockResolvedValue({
+        assetId: 'asset-1',
+        qiniuKey: 'users/user-1/image/2026/06/brief-1/key.png',
+        url: 'https://cdn.example/key.png',
+        kind: 'image',
+        mimeType: 'image/png',
+        sizeBytes: 12,
+      }),
+    };
+    const zone = { run: (fn: Function) => fn() } as unknown as NgZone;
+    const service = new QiniuUploadService(parse as any, zone, auth as any, fileAsset as any);
+    const file = new File(['hello'], 'demo.png', { type: 'image/png' });
+
+    const done = await lastValueFrom(service.uploadFileWithProgress(file, 'demo.png', 'image/png', 'image', {
+      sourceModule: 'image-generation',
+      bizType: 'brief',
+      bizId: 'brief-1',
+    }).pipe(filter((event) => event.state === 'done')));
+
+    expect(parse.call).toHaveBeenCalledWith('upload-fn', expect.objectContaining({
+      action: 'createUploadToken',
+      sessionToken: 'session-token',
+      userId: 'user-1',
+      filename: 'demo.png',
+      mimeType: 'image/png',
+      size: file.size,
+      kind: 'image',
+      sourceModule: 'image-generation',
+      bizType: 'brief',
+      bizId: 'brief-1',
+    }));
+    expect(fileAsset.registerUploadedAsset).toHaveBeenCalledWith(expect.objectContaining({
+      qiniuKey: 'users/user-1/image/2026/06/brief-1/key.png',
+      url: 'https://cdn.example/key.png',
+      bucket: 'bucket-a',
+      sourceModule: 'image-generation',
+      bizType: 'brief',
+      bizId: 'brief-1',
+      metadata: { filename: 'demo.png' },
+    }));
+    expect(done.url).toBe('https://cdn.example/key.png');
+    expect(done.assetId).toBe('asset-1');
+    expect(done.metadataStatus).toBe('registered');
+  });
+
+  it('keeps the uploaded URL available when metadata registration is not configured', async () => {
+    const parse = {
+      call: vi.fn().mockResolvedValue({
+        code: 200,
+        success: true,
+        data: {
+          uploadUrl: 'https://upload.example',
+          token: 'qiniu-token',
+          key: 'users/user-1/image/2026/06/free/key.png',
+          url: 'https://cdn.example/key.png',
+          mimeType: 'image/png',
+          kind: 'image',
+        },
+      }),
+    };
+    const auth = {
+      session: { token: 'session-token', user: { objectId: 'user-1' } },
+      currentUser: { objectId: 'user-1' },
+      requestLogin: vi.fn(),
+    };
+    const fileAsset = {
+      registerUploadedAsset: vi.fn().mockRejectedValue(new Error('七牛文件资产云函数尚未配置')),
+    };
+    const zone = { run: (fn: Function) => fn() } as unknown as NgZone;
+    const service = new QiniuUploadService(parse as any, zone, auth as any, fileAsset as any);
+
+    const done = await lastValueFrom(service.uploadFileWithProgress(
+      new File(['hello'], 'demo.png', { type: 'image/png' }),
+      'demo.png',
+      'image/png',
+      'image',
+    ).pipe(filter((event) => event.state === 'done')));
+
+    expect(done.url).toBe('https://cdn.example/key.png');
+    expect(done.metadataStatus).toBe('failed');
+    expect(done.metadataError).toBe('七牛文件资产云函数尚未配置');
+  });
+});

+ 87 - 11
src/app/services/qiniu-upload.service.ts

@@ -1,7 +1,9 @@
 import { Injectable, NgZone } from '@angular/core';
 import { Observable, from } from 'rxjs';
 import { filter, map, switchMap } from 'rxjs/operators';
+import { AuthCreditService } from './auth-credit.service';
 import { CLOUD_FN } from './cloud-functions';
+import { FileAssetKind, FileAssetService, UploadedAsset } from './file-asset.service';
 import { ParseService } from './parse.service';
 
 export interface QiniuUploadToken {
@@ -10,7 +12,9 @@ export interface QiniuUploadToken {
   key: string;
   url: string;
   mimeType: string;
-  kind: 'image' | 'video' | 'audio';
+  kind: FileAssetKind;
+  bucket?: string;
+  size?: number;
   expiresAt?: number;
 }
 
@@ -19,6 +23,19 @@ export interface QiniuUploadProgress {
   progress: number;
   url?: string;
   key?: string;
+  asset?: UploadedAsset;
+  assetId?: string;
+  metadataStatus?: 'registered' | 'failed' | 'skipped';
+  metadataError?: string;
+}
+
+export interface QiniuUploadOptions {
+  kind?: FileAssetKind;
+  sourceModule?: string;
+  bizType?: string;
+  bizId?: string;
+  metadata?: Record<string, unknown>;
+  requireMetadata?: boolean;
 }
 
 @Injectable({ providedIn: 'root' })
@@ -26,10 +43,12 @@ export class QiniuUploadService {
   constructor(
     private parse: ParseService,
     private zone: NgZone,
+    private auth: AuthCreditService,
+    private fileAsset: FileAssetService,
   ) {}
 
-  uploadFile(file: Blob, filename: string, contentType?: string, kind?: 'image' | 'video' | 'audio'): Observable<string> {
-    return this.uploadFileWithProgress(file, filename, contentType, kind).pipe(
+  uploadFile(file: Blob, filename: string, contentType?: string, kind?: FileAssetKind, options: QiniuUploadOptions = {}): Observable<string> {
+    return this.uploadFileWithProgress(file, filename, contentType, kind, options).pipe(
       filter((event) => event.state === 'done' && !!event.url),
       map((event) => event.url as string),
     );
@@ -39,7 +58,8 @@ export class QiniuUploadService {
     file: Blob,
     filename: string,
     contentType?: string,
-    kind?: 'image' | 'video' | 'audio',
+    kind?: FileAssetKind,
+    options: QiniuUploadOptions = {},
   ): Observable<QiniuUploadProgress> {
     const normalizedFilename = String(filename || `asset-${Date.now()}`);
     const mimeType = contentType || file.type || 'application/octet-stream';
@@ -47,8 +67,10 @@ export class QiniuUploadService {
       ? file
       : new File([file], normalizedFilename, { type: mimeType });
 
-    return from(this.createUploadToken(normalizedFilename, mimeType, typedFile.size, kind)).pipe(
-      switchMap((token) => this.uploadToQiniu(typedFile, token)),
+    const uploadOptions = { ...options, kind: options.kind || kind };
+
+    return from(this.createUploadToken(normalizedFilename, mimeType, typedFile.size, uploadOptions)).pipe(
+      switchMap((token) => this.uploadToQiniu(typedFile, token, uploadOptions)),
     );
   }
 
@@ -56,20 +78,32 @@ export class QiniuUploadService {
     filename: string,
     mimeType: string,
     size: number,
-    kind?: 'image' | 'video' | 'audio',
+    options: QiniuUploadOptions,
   ): Promise<QiniuUploadToken> {
     if (!CLOUD_FN.upload) {
       throw new Error('上传云函数 ID 未配置');
     }
+    const session = this.auth.session;
+    const userId = this.auth.currentUser?.objectId || session?.user?.objectId || '';
+    const sessionToken = session?.token || '';
+    if (!sessionToken || !userId) {
+      this.auth.requestLogin('七牛上传');
+      throw new Error('请先登录后再上传文件');
+    }
 
     let lastError = '获取七牛上传凭证失败';
     for (let attempt = 1; attempt <= 3; attempt += 1) {
       const res = await this.parse.call<QiniuUploadToken>(CLOUD_FN.upload, {
         action: 'createUploadToken',
+        sessionToken,
+        userId,
         filename,
         mimeType,
         size,
-        kind,
+        kind: options.kind,
+        sourceModule: options.sourceModule,
+        bizType: options.bizType,
+        bizId: options.bizId,
       });
 
       if (res.code === 200 && res.success && res.data?.token && res.data?.url) {
@@ -87,7 +121,7 @@ export class QiniuUploadService {
     throw new Error(lastError);
   }
 
-  private uploadToQiniu(file: File, token: QiniuUploadToken): Observable<QiniuUploadProgress> {
+  private uploadToQiniu(file: File, token: QiniuUploadToken, options: QiniuUploadOptions): Observable<QiniuUploadProgress> {
     return new Observable<QiniuUploadProgress>((observer) => {
       let xhr: XMLHttpRequest | null = null;
       let cancelled = false;
@@ -109,7 +143,7 @@ export class QiniuUploadService {
         };
 
         xhr.onload = () => {
-          this.zone.run(() => {
+          this.zone.run(async () => {
             if (!xhr) return;
             if (xhr.status < 200 || xhr.status >= 300) {
               const message = this.readQiniuError(xhr);
@@ -120,7 +154,31 @@ export class QiniuUploadService {
               observer.error(new Error(message));
               return;
             }
-            observer.next({ state: 'done', progress: 100, url: token.url, key: token.key });
+            try {
+              const asset = await this.registerUploadedAsset(file, token, options);
+              observer.next({
+                state: 'done',
+                progress: 100,
+                url: token.url,
+                key: token.key,
+                asset,
+                assetId: asset.assetId,
+                metadataStatus: 'registered',
+              });
+            } catch (e: any) {
+              if (options.requireMetadata) {
+                observer.error(e);
+                return;
+              }
+              observer.next({
+                state: 'done',
+                progress: 100,
+                url: token.url,
+                key: token.key,
+                metadataStatus: 'failed',
+                metadataError: e?.message || '文件元数据登记失败',
+              });
+            }
             observer.complete();
           });
         };
@@ -158,6 +216,24 @@ export class QiniuUploadService {
     return new Promise((resolve) => setTimeout(resolve, ms));
   }
 
+  private async registerUploadedAsset(file: File, token: QiniuUploadToken, options: QiniuUploadOptions): Promise<UploadedAsset> {
+    return this.fileAsset.registerUploadedAsset({
+      qiniuKey: token.key,
+      url: token.url,
+      bucket: token.bucket,
+      mimeType: token.mimeType || file.type || 'application/octet-stream',
+      kind: token.kind,
+      sizeBytes: file.size || token.size || 0,
+      sourceModule: options.sourceModule,
+      bizType: options.bizType,
+      bizId: options.bizId,
+      metadata: {
+        filename: file.name,
+        ...(options.metadata || {}),
+      },
+    });
+  }
+
   private readQiniuError(xhr: XMLHttpRequest): string {
     try {
       const payload = JSON.parse(xhr.responseText || '{}');

+ 69 - 0
src/app/services/storage-governance.service.spec.ts

@@ -0,0 +1,69 @@
+import { StorageGovernanceService } from './storage-governance.service';
+
+describe('StorageGovernanceService', () => {
+  let service: StorageGovernanceService;
+
+  beforeEach(() => {
+    localStorage.clear();
+    sessionStorage.clear();
+    service = new StorageGovernanceService();
+  });
+
+  it('rejects growing business data from browser storage', () => {
+    const result = service.writeJson('ip.comments.raw', { comments: ['用户痛点全文'] }, 'forbiddenBusinessData');
+
+    expect(result.stored).toBe(false);
+    expect(result.storage).toBe('none');
+    expect(result.reason).toContain('业务主数据');
+    expect(Object.keys(localStorage)).toEqual([]);
+    expect(Object.keys(sessionStorage)).toEqual([]);
+  });
+
+  it('falls back to sessionStorage when localStorage cache write fails', () => {
+    const originalSetItem = Storage.prototype.setItem;
+    Storage.prototype.setItem = function (this: Storage, key: string, value: string) {
+      if (this === localStorage) throw new DOMException('Quota exceeded', 'QuotaExceededError');
+      return originalSetItem.call(this, key, value);
+    };
+
+    try {
+      const result = service.writeJson('cache.account.summary', { title: '缓存摘要' }, 'cache');
+
+      expect(result.stored).toBe(true);
+      expect(result.storage).toBe('sessionStorage');
+      expect(localStorage.getItem('cache.account.summary')).toBeNull();
+      expect(service.readJson<any>('cache.account.summary')?.title).toBe('缓存摘要');
+    } finally {
+      Storage.prototype.setItem = originalSetItem;
+    }
+  });
+
+  it('stores ui preferences in localStorage with a governed envelope', () => {
+    const result = service.writeJson('ui.sidebar.collapsed', { collapsed: true }, 'uiPreference');
+    const raw = localStorage.getItem('ui.sidebar.collapsed') || '';
+
+    expect(result.stored).toBe(true);
+    expect(result.storage).toBe('localStorage');
+    expect(raw).toContain('__storageGovernance');
+    expect(service.readJson<any>('ui.sidebar.collapsed')?.collapsed).toBe(true);
+  });
+
+  it('clears only expired governed entries', () => {
+    localStorage.setItem('old.cache', JSON.stringify({
+      __storageGovernance: true,
+      storageClass: 'cache',
+      createdAt: '2026-01-01T00:00:00.000Z',
+      expiresAt: '2026-01-02T00:00:00.000Z',
+      data: { value: 1 },
+    }));
+    localStorage.setItem('plain.login', 'keep');
+    service.writeJson('new.cache', { value: 2 }, 'cache');
+
+    const removed = service.clearExpired();
+
+    expect(removed).toBe(1);
+    expect(localStorage.getItem('old.cache')).toBeNull();
+    expect(localStorage.getItem('plain.login')).toBe('keep');
+    expect(service.readJson<any>('new.cache')?.value).toBe(2);
+  });
+});

+ 242 - 0
src/app/services/storage-governance.service.ts

@@ -0,0 +1,242 @@
+import { Injectable } from '@angular/core';
+
+export type BrowserStorageClass =
+  | 'session'
+  | 'uiPreference'
+  | 'shortDraft'
+  | 'cache'
+  | 'forbiddenBusinessData';
+
+export interface BrowserStoragePolicy {
+  storageClass: BrowserStorageClass;
+  maxBytes: number;
+  ttlMs: number;
+  allowLocalStorage: boolean;
+  description: string;
+}
+
+export interface GovernedStorageWriteResult {
+  stored: boolean;
+  storage: 'localStorage' | 'sessionStorage' | 'none';
+  key: string;
+  bytes: number;
+  reason?: string;
+}
+
+export interface BrowserStorageUsage {
+  localStorageBytes: number;
+  sessionStorageBytes: number;
+  localStorageKeys: number;
+  sessionStorageKeys: number;
+}
+
+interface StoredEnvelope<T = unknown> {
+  __storageGovernance: true;
+  storageClass: BrowserStorageClass;
+  createdAt: string;
+  expiresAt: string;
+  data: T;
+}
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+export const BROWSER_STORAGE_POLICIES: Record<BrowserStorageClass, BrowserStoragePolicy> = {
+  session: {
+    storageClass: 'session',
+    maxBytes: 120 * 1024,
+    ttlMs: DAY_MS,
+    allowLocalStorage: false,
+    description: '登录态以外的会话级临时状态,只能写 sessionStorage。',
+  },
+  uiPreference: {
+    storageClass: 'uiPreference',
+    maxBytes: 40 * 1024,
+    ttlMs: 180 * DAY_MS,
+    allowLocalStorage: true,
+    description: '主题、折叠状态、排序方式等可长期保留的轻量偏好。',
+  },
+  shortDraft: {
+    storageClass: 'shortDraft',
+    maxBytes: 300 * 1024,
+    ttlMs: 7 * DAY_MS,
+    allowLocalStorage: false,
+    description: '未登录或未提交前的短期草稿,只能写 sessionStorage。',
+  },
+  cache: {
+    storageClass: 'cache',
+    maxBytes: 600 * 1024,
+    ttlMs: 3 * DAY_MS,
+    allowLocalStorage: true,
+    description: '可清理、可重建的轻缓存,不允许作为业务唯一数据源。',
+  },
+  forbiddenBusinessData: {
+    storageClass: 'forbiddenBusinessData',
+    maxBytes: 0,
+    ttlMs: 0,
+    allowLocalStorage: false,
+    description: '评论样本、作品列表、诊断报告、AI 会话全文等增长型业务主数据。',
+  },
+};
+
+@Injectable({ providedIn: 'root' })
+export class StorageGovernanceService {
+  getPolicy(storageClass: BrowserStorageClass): BrowserStoragePolicy {
+    return BROWSER_STORAGE_POLICIES[storageClass];
+  }
+
+  canUseLocalStorage(storageClass: BrowserStorageClass): boolean {
+    return this.getPolicy(storageClass).allowLocalStorage;
+  }
+
+  writeJson<T>(key: string, value: T, storageClass: BrowserStorageClass): GovernedStorageWriteResult {
+    const policy = this.getPolicy(storageClass);
+    if (storageClass === 'forbiddenBusinessData') {
+      return {
+        stored: false,
+        storage: 'none',
+        key,
+        bytes: 0,
+        reason: '增长型业务主数据必须写云端或 IndexedDB 草稿,不能写浏览器长期存储。',
+      };
+    }
+
+    const now = new Date();
+    const envelope: StoredEnvelope<T> = {
+      __storageGovernance: true,
+      storageClass,
+      createdAt: now.toISOString(),
+      expiresAt: new Date(now.getTime() + policy.ttlMs).toISOString(),
+      data: value,
+    };
+    const payload = JSON.stringify(envelope);
+    const bytes = this.measureBytes(payload);
+    if (bytes > policy.maxBytes) {
+      return { stored: false, storage: 'none', key, bytes, reason: `数据超过 ${policy.maxBytes} 字节上限` };
+    }
+
+    if (policy.allowLocalStorage) {
+      const localResult = this.trySet(localStorage, 'localStorage', key, payload, bytes);
+      if (localResult.stored) return localResult;
+    }
+
+    return this.trySet(sessionStorage, 'sessionStorage', key, payload, bytes);
+  }
+
+  readJson<T>(key: string): T | null {
+    const local = this.readEnvelope<T>(localStorage, key);
+    if (local.found) return local.value;
+    const session = this.readEnvelope<T>(sessionStorage, key);
+    return session.found ? session.value : null;
+  }
+
+  remove(key: string): void {
+    localStorage.removeItem(key);
+    sessionStorage.removeItem(key);
+  }
+
+  estimateUsage(): BrowserStorageUsage {
+    return {
+      localStorageBytes: this.measureStorage(localStorage),
+      sessionStorageBytes: this.measureStorage(sessionStorage),
+      localStorageKeys: localStorage.length,
+      sessionStorageKeys: sessionStorage.length,
+    };
+  }
+
+  clearExpired(prefix = ''): number {
+    return this.clearExpiredFrom(localStorage, prefix) + this.clearExpiredFrom(sessionStorage, prefix);
+  }
+
+  clearGovernedCache(): number {
+    return this.clearByClass(localStorage, ['cache']) + this.clearByClass(sessionStorage, ['cache', 'shortDraft', 'session']);
+  }
+
+  private trySet(
+    storage: Storage,
+    storageName: 'localStorage' | 'sessionStorage',
+    key: string,
+    payload: string,
+    bytes: number,
+  ): GovernedStorageWriteResult {
+    try {
+      storage.setItem(key, payload);
+      return { stored: true, storage: storageName, key, bytes };
+    } catch (error: any) {
+      return {
+        stored: false,
+        storage: 'none',
+        key,
+        bytes,
+        reason: error?.message || `${storageName} 写入失败`,
+      };
+    }
+  }
+
+  private readEnvelope<T>(storage: Storage, key: string): { found: boolean; value: T | null } {
+    const raw = storage.getItem(key);
+    if (!raw) return { found: false, value: null };
+    try {
+      const parsed = JSON.parse(raw) as StoredEnvelope<T>;
+      if (!parsed?.__storageGovernance) return { found: true, value: parsed as T };
+      if (Date.parse(parsed.expiresAt) < Date.now()) {
+        storage.removeItem(key);
+        return { found: false, value: null };
+      }
+      return { found: true, value: parsed.data };
+    } catch {
+      return { found: false, value: null };
+    }
+  }
+
+  private clearExpiredFrom(storage: Storage, prefix: string): number {
+    let removed = 0;
+    for (const key of this.keys(storage)) {
+      if (prefix && !key.startsWith(prefix)) continue;
+      const raw = storage.getItem(key);
+      if (!raw) continue;
+      try {
+        const parsed = JSON.parse(raw) as StoredEnvelope;
+        if (parsed?.__storageGovernance && Date.parse(parsed.expiresAt) < Date.now()) {
+          storage.removeItem(key);
+          removed += 1;
+        }
+      } catch {
+        // 非治理服务写入的数据不在这里清理。
+      }
+    }
+    return removed;
+  }
+
+  private clearByClass(storage: Storage, classes: BrowserStorageClass[]): number {
+    let removed = 0;
+    for (const key of this.keys(storage)) {
+      const raw = storage.getItem(key);
+      if (!raw) continue;
+      try {
+        const parsed = JSON.parse(raw) as StoredEnvelope;
+        if (parsed?.__storageGovernance && classes.includes(parsed.storageClass)) {
+          storage.removeItem(key);
+          removed += 1;
+        }
+      } catch {
+        // 跳过非治理数据,避免误删登录态或第三方缓存。
+      }
+    }
+    return removed;
+  }
+
+  private measureStorage(storage: Storage): number {
+    return this.keys(storage).reduce((total, key) => {
+      const value = storage.getItem(key) || '';
+      return total + this.measureBytes(key) + this.measureBytes(value);
+    }, 0);
+  }
+
+  private keys(storage: Storage): string[] {
+    return Array.from({ length: storage.length }, (_, index) => storage.key(index) || '').filter(Boolean);
+  }
+
+  private measureBytes(value: string): number {
+    return new Blob([value]).size;
+  }
+}

+ 168 - 0
src/app/services/system-storage-migration.service.spec.ts

@@ -0,0 +1,168 @@
+import { SYSTEM_ENTITY_TYPES, SystemStorageMigrationService } from './system-storage-migration.service';
+
+describe('SystemStorageMigrationService', () => {
+  beforeEach(() => {
+    localStorage.clear();
+  });
+
+  it('migrates local topic pool rows into Parse cloud entities', async () => {
+    localStorage.setItem('videoWorkflow.topicPool.items', JSON.stringify([
+      { id: 'topic-1', title: '选题 1' },
+      { id: 'topic-2', title: '选题 2' },
+    ]));
+    const cloud = {
+      getMigrationState: vi.fn().mockResolvedValue(null),
+      upsert: vi.fn().mockResolvedValue({}),
+      setMigrationState: vi.fn().mockResolvedValue({}),
+    };
+    const service = new SystemStorageMigrationService(cloud as any);
+
+    const [result] = await service.migrateAll([
+      { sourceKey: 'videoWorkflow.topicPool.items', entityType: SYSTEM_ENTITY_TYPES.topic },
+    ]);
+
+    expect(result.status).toBe('completed');
+    expect(result.migratedCount).toBe(2);
+    expect(cloud.upsert).toHaveBeenCalledWith('topic', 'topic-1', { id: 'topic-1', title: '选题 1' });
+    expect(cloud.upsert).toHaveBeenCalledWith('topic', 'topic-2', { id: 'topic-2', title: '选题 2' });
+    expect(cloud.setMigrationState).toHaveBeenCalledWith(expect.objectContaining({
+      migrationKey: 'localStorage-to-systemEntity-v1',
+      sourceKey: 'videoWorkflow.topicPool.items',
+      status: 'completed',
+      migratedCount: 2,
+      failedCount: 0,
+    }));
+  });
+
+  it('does not migrate the same localStorage key twice after migration record exists', async () => {
+    localStorage.setItem('videoWorkflow.templates.user.v1', JSON.stringify([{ id: 'tpl-1' }]));
+    const cloud = {
+      getMigrationState: vi.fn().mockResolvedValue({
+        status: 'completed',
+        migratedCount: 1,
+        failedCount: 0,
+      }),
+      upsert: vi.fn(),
+      setMigrationState: vi.fn(),
+    };
+    const service = new SystemStorageMigrationService(cloud as any);
+
+    const [result] = await service.migrateAll([
+      { sourceKey: 'videoWorkflow.templates.user.v1', entityType: SYSTEM_ENTITY_TYPES.template },
+    ]);
+
+    expect(result.status).toBe('skipped');
+    expect(cloud.upsert).not.toHaveBeenCalled();
+    expect(cloud.setMigrationState).not.toHaveBeenCalled();
+  });
+
+  it('records failed rows in migration state', async () => {
+    localStorage.setItem('videoWorkflow.viralAnalyses.items', JSON.stringify([
+      { id: 'analysis-ok', title: '成功' },
+      { id: 'analysis-fail', title: '失败' },
+    ]));
+    const cloud = {
+      getMigrationState: vi.fn().mockResolvedValue(null),
+      upsert: vi.fn().mockImplementation((_type: string, id: string) => {
+        if (id === 'analysis-fail') return Promise.reject(new Error('cloud failed'));
+        return Promise.resolve({});
+      }),
+      setMigrationState: vi.fn().mockResolvedValue({}),
+    };
+    const service = new SystemStorageMigrationService(cloud as any);
+
+    const [result] = await service.migrateAll([
+      { sourceKey: 'videoWorkflow.viralAnalyses.items', entityType: SYSTEM_ENTITY_TYPES.viralAnalysis },
+    ]);
+
+    expect(result.status).toBe('failed');
+    expect(result.migratedCount).toBe(1);
+    expect(result.failedCount).toBe(1);
+    expect(cloud.setMigrationState).toHaveBeenCalledWith(expect.objectContaining({
+      status: 'failed',
+      migratedCount: 1,
+      failedCount: 1,
+      detail: expect.objectContaining({
+        failedIds: expect.arrayContaining([expect.stringContaining('analysis-fail')]),
+      }),
+    }));
+  });
+  it('inspects legacy localStorage keys with migration status before cleanup', async () => {
+    localStorage.setItem('videoWorkflow.topicPool.items', JSON.stringify([{ id: 'topic-1' }]));
+    localStorage.setItem('videoWorkflow.templates.user.v1', JSON.stringify([{ id: 'tpl-1' }]));
+    const cloud = {
+      getMigrationState: vi.fn().mockImplementation((_migrationKey: string, sourceKey: string) => {
+        if (sourceKey === 'videoWorkflow.topicPool.items') {
+          return Promise.resolve({ status: 'completed', migratedCount: 1, failedCount: 0 });
+        }
+        return Promise.resolve(null);
+      }),
+      upsert: vi.fn(),
+      setMigrationState: vi.fn(),
+    };
+    const service = new SystemStorageMigrationService(cloud as any);
+
+    const health = await service.inspectLegacyLocalStorage([
+      { sourceKey: 'videoWorkflow.topicPool.items', entityType: SYSTEM_ENTITY_TYPES.topic },
+      { sourceKey: 'videoWorkflow.templates.user.v1', entityType: SYSTEM_ENTITY_TYPES.template },
+    ]);
+
+    expect(health).toEqual([
+      expect.objectContaining({
+        sourceKey: 'videoWorkflow.topicPool.items',
+        localExists: true,
+        localRowCount: 1,
+        migrationStatus: 'completed',
+        migratedCount: 1,
+        failedCount: 0,
+        canClean: true,
+      }),
+      expect.objectContaining({
+        sourceKey: 'videoWorkflow.templates.user.v1',
+        localExists: true,
+        localRowCount: 1,
+        migrationStatus: 'missing',
+        migratedCount: 0,
+        failedCount: 0,
+        canClean: false,
+      }),
+    ]);
+    expect(health[0].localBytes).toBeGreaterThan(0);
+  });
+
+  it('clears only completed legacy localStorage keys and keeps unfinished migration data', async () => {
+    localStorage.setItem('videoWorkflow.topicPool.items', JSON.stringify([{ id: 'topic-1' }]));
+    localStorage.setItem('videoWorkflow.viralAnalyses.items', JSON.stringify([{ id: 'analysis-1' }]));
+    localStorage.setItem('videoWorkflow.templates.user.v1', JSON.stringify([{ id: 'tpl-1' }]));
+    const cloud = {
+      getMigrationState: vi.fn().mockImplementation((_migrationKey: string, sourceKey: string) => {
+        if (sourceKey === 'videoWorkflow.topicPool.items') {
+          return Promise.resolve({ status: 'completed', migratedCount: 1, failedCount: 0 });
+        }
+        if (sourceKey === 'videoWorkflow.viralAnalyses.items') {
+          return Promise.resolve({ status: 'failed', migratedCount: 0, failedCount: 1 });
+        }
+        return Promise.resolve(null);
+      }),
+      upsert: vi.fn(),
+      setMigrationState: vi.fn(),
+    };
+    const service = new SystemStorageMigrationService(cloud as any);
+
+    const result = await service.clearCompletedLegacyLocalStorage([
+      { sourceKey: 'videoWorkflow.topicPool.items', entityType: SYSTEM_ENTITY_TYPES.topic },
+      { sourceKey: 'videoWorkflow.viralAnalyses.items', entityType: SYSTEM_ENTITY_TYPES.viralAnalysis },
+      { sourceKey: 'videoWorkflow.templates.user.v1', entityType: SYSTEM_ENTITY_TYPES.template },
+    ]);
+
+    expect(result.checkedCount).toBe(3);
+    expect(result.removedCount).toBe(1);
+    expect(result.skippedCount).toBe(2);
+    expect(result.removedBytes).toBeGreaterThan(0);
+    expect(result.removedKeys).toEqual(['videoWorkflow.topicPool.items']);
+    expect(result.skippedKeys).toEqual(['videoWorkflow.viralAnalyses.items', 'videoWorkflow.templates.user.v1']);
+    expect(localStorage.getItem('videoWorkflow.topicPool.items')).toBeNull();
+    expect(localStorage.getItem('videoWorkflow.viralAnalyses.items')).toContain('analysis-1');
+    expect(localStorage.getItem('videoWorkflow.templates.user.v1')).toContain('tpl-1');
+  });
+});

+ 220 - 0
src/app/services/system-storage-migration.service.ts

@@ -0,0 +1,220 @@
+import { Injectable } from '@angular/core';
+import { CloudMigrationState, CloudSessionStorageService } from './cloud-session-storage.service';
+
+export const SYSTEM_ENTITY_TYPES = {
+  topic: 'topic',
+  template: 'template',
+  viralAnalysis: 'viralAnalysis',
+  dailyReport: 'dailyReport',
+  retrospective: 'retrospective',
+  assistantThread: 'assistantThread',
+} as const;
+
+export type SystemEntityType = typeof SYSTEM_ENTITY_TYPES[keyof typeof SYSTEM_ENTITY_TYPES];
+
+export interface LocalStorageMigrationMapping {
+  sourceKey: string;
+  entityType: SystemEntityType;
+}
+
+export interface SystemStorageMigrationResult {
+  sourceKey: string;
+  entityType: SystemEntityType;
+  status: 'skipped' | 'completed' | 'failed';
+  migratedCount: number;
+  failedCount: number;
+  message?: string;
+}
+
+export interface SystemStorageMigrationHealthItem {
+  sourceKey: string;
+  entityType: SystemEntityType;
+  localExists: boolean;
+  localBytes: number;
+  localRowCount: number;
+  migrationStatus: CloudMigrationState['status'] | 'missing';
+  migratedCount: number;
+  failedCount: number;
+  canClean: boolean;
+}
+
+export interface SystemStorageMigrationCleanupResult {
+  checkedCount: number;
+  removedCount: number;
+  removedBytes: number;
+  skippedCount: number;
+  removedKeys: string[];
+  skippedKeys: string[];
+}
+
+const MIGRATION_KEY = 'localStorage-to-systemEntity-v1';
+
+export const SYSTEM_STORAGE_MIGRATIONS: LocalStorageMigrationMapping[] = [
+  { sourceKey: 'videoWorkflow.topicPool.items', entityType: SYSTEM_ENTITY_TYPES.topic },
+  { sourceKey: 'videoWorkflow.templates.user.v1', entityType: SYSTEM_ENTITY_TYPES.template },
+  { sourceKey: 'videoWorkflow.viralAnalyses.items', entityType: SYSTEM_ENTITY_TYPES.viralAnalysis },
+  { sourceKey: 'videoWorkflow.dailyReports.items', entityType: SYSTEM_ENTITY_TYPES.dailyReport },
+  { sourceKey: 'videoWorkflow.publicationRecords.v1', entityType: SYSTEM_ENTITY_TYPES.retrospective },
+  { sourceKey: 'tiktok.assistant.conversations.v2', entityType: SYSTEM_ENTITY_TYPES.assistantThread },
+];
+
+@Injectable({ providedIn: 'root' })
+export class SystemStorageMigrationService {
+  constructor(private cloudStorage: CloudSessionStorageService) {}
+
+  async migrateAll(mappings = SYSTEM_STORAGE_MIGRATIONS): Promise<SystemStorageMigrationResult[]> {
+    const results: SystemStorageMigrationResult[] = [];
+    for (const mapping of mappings) {
+      results.push(await this.migrateMapping(mapping));
+    }
+    return results;
+  }
+
+  async inspectLegacyLocalStorage(mappings = SYSTEM_STORAGE_MIGRATIONS): Promise<SystemStorageMigrationHealthItem[]> {
+    const items: SystemStorageMigrationHealthItem[] = [];
+    for (const mapping of mappings) {
+      const raw = localStorage.getItem(mapping.sourceKey);
+      const state = await this.cloudStorage.getMigrationState(MIGRATION_KEY, mapping.sourceKey);
+      items.push({
+        sourceKey: mapping.sourceKey,
+        entityType: mapping.entityType,
+        localExists: raw !== null,
+        localBytes: raw ? this.measureBytes(mapping.sourceKey) + this.measureBytes(raw) : 0,
+        localRowCount: raw ? this.readLocalArray(mapping.sourceKey).length : 0,
+        migrationStatus: state?.status || 'missing',
+        migratedCount: state?.migratedCount || 0,
+        failedCount: state?.failedCount || 0,
+        canClean: raw !== null && state?.status === 'completed',
+      });
+    }
+    return items;
+  }
+
+  async clearCompletedLegacyLocalStorage(mappings = SYSTEM_STORAGE_MIGRATIONS): Promise<SystemStorageMigrationCleanupResult> {
+    const health = await this.inspectLegacyLocalStorage(mappings);
+    const result: SystemStorageMigrationCleanupResult = {
+      checkedCount: health.length,
+      removedCount: 0,
+      removedBytes: 0,
+      skippedCount: 0,
+      removedKeys: [],
+      skippedKeys: [],
+    };
+
+    for (const item of health) {
+      if (!item.localExists) continue;
+      if (!item.canClean) {
+        result.skippedCount += 1;
+        result.skippedKeys.push(item.sourceKey);
+        continue;
+      }
+      localStorage.removeItem(item.sourceKey);
+      result.removedCount += 1;
+      result.removedBytes += item.localBytes;
+      result.removedKeys.push(item.sourceKey);
+    }
+    return result;
+  }
+
+  async migrateMapping(mapping: LocalStorageMigrationMapping): Promise<SystemStorageMigrationResult> {
+    const existing = await this.cloudStorage.getMigrationState(MIGRATION_KEY, mapping.sourceKey);
+    if (existing?.status === 'completed') {
+      return {
+        sourceKey: mapping.sourceKey,
+        entityType: mapping.entityType,
+        status: 'skipped',
+        migratedCount: existing.migratedCount || 0,
+        failedCount: existing.failedCount || 0,
+        message: '迁移记录已完成,跳过重复迁移。',
+      };
+    }
+
+    const rows = this.readLocalArray(mapping.sourceKey);
+    if (!rows.length) {
+      await this.writeMigrationState(mapping, 'completed', 0, 0, { reason: 'empty_source' });
+      return {
+        sourceKey: mapping.sourceKey,
+        entityType: mapping.entityType,
+        status: 'completed',
+        migratedCount: 0,
+        failedCount: 0,
+        message: '旧浏览器数据为空。',
+      };
+    }
+
+    let migratedCount = 0;
+    let failedCount = 0;
+    const failedIds: string[] = [];
+    for (const row of rows) {
+      const entityId = this.resolveEntityId(row, mapping);
+      try {
+        await this.cloudStorage.upsert(mapping.entityType, entityId, row);
+        migratedCount += 1;
+      } catch (error: any) {
+        failedCount += 1;
+        failedIds.push(`${entityId}: ${error?.message || 'unknown'}`.slice(0, 180));
+      }
+    }
+
+    const status = failedCount > 0 ? 'failed' : 'completed';
+    await this.writeMigrationState(mapping, status, migratedCount, failedCount, { failedIds });
+    return {
+      sourceKey: mapping.sourceKey,
+      entityType: mapping.entityType,
+      status,
+      migratedCount,
+      failedCount,
+      message: failedCount > 0 ? '部分数据迁移失败。' : '迁移完成。',
+    };
+  }
+
+  private async writeMigrationState(
+    mapping: LocalStorageMigrationMapping,
+    status: CloudMigrationState['status'],
+    migratedCount: number,
+    failedCount: number,
+    detail: Record<string, unknown>,
+  ): Promise<void> {
+    await this.cloudStorage.setMigrationState({
+      migrationKey: MIGRATION_KEY,
+      sourceKey: mapping.sourceKey,
+      status,
+      migratedCount,
+      failedCount,
+      detail: {
+        entityType: mapping.entityType,
+        ...detail,
+      },
+    });
+  }
+
+  private readLocalArray(sourceKey: string): unknown[] {
+    try {
+      const raw = localStorage.getItem(sourceKey);
+      const parsed = raw ? JSON.parse(raw) : [];
+      return Array.isArray(parsed) ? parsed : [];
+    } catch {
+      return [];
+    }
+  }
+
+  private resolveEntityId(row: any, mapping: LocalStorageMigrationMapping): string {
+    const explicit = String(row?.id || row?.objectId || row?.awemeId || '').trim();
+    if (explicit) return explicit;
+    const fingerprint = JSON.stringify(row || {}).slice(0, 80);
+    return `${mapping.entityType}_${this.hash(`${mapping.sourceKey}:${fingerprint}`)}`;
+  }
+
+  private hash(value: string): string {
+    let hash = 0;
+    for (let i = 0; i < value.length; i += 1) {
+      hash = ((hash << 5) - hash) + value.charCodeAt(i);
+      hash |= 0;
+    }
+    return Math.abs(hash).toString(36);
+  }
+
+  private measureBytes(value: string): number {
+    return new Blob([value]).size;
+  }
+}

+ 25 - 0
src/app/services/voice-api.service.spec.ts

@@ -0,0 +1,25 @@
+import { firstValueFrom } from 'rxjs';
+import { CLOUD_FN } from './cloud-functions';
+import { VoiceApiService } from './voice-api.service';
+
+describe('VoiceApiService', () => {
+  it('passes session token and user id to voice cloud functions', async () => {
+    const parse = {
+      callOrThrow: vi.fn().mockResolvedValue({ ok: true }),
+    };
+    const auth = {
+      session: { token: 'token-for-test' },
+      currentUser: { objectId: 'user-for-test' },
+    };
+    const service = new VoiceApiService(parse as any, auth as any);
+
+    await firstValueFrom(service.synthesize({ text: '测试语音' }));
+
+    expect(parse.callOrThrow).toHaveBeenCalledWith(CLOUD_FN.voice, expect.objectContaining({
+      action: 'synthesize',
+      sessionToken: 'token-for-test',
+      userId: 'user-for-test',
+      payload: { text: '测试语音' },
+    }));
+  });
+});

+ 8 - 2
src/app/services/voice-api.service.ts

@@ -2,6 +2,7 @@ import { Injectable } from '@angular/core';
 import { from, Observable } from 'rxjs';
 import { CLOUD_FN } from './cloud-functions';
 import { ParseService } from './parse.service';
+import { AuthCreditService } from './auth-credit.service';
 
 export interface VoiceSynthesizeRequest {
   text: string;
@@ -26,7 +27,7 @@ export interface VoiceCloneRequest {
 
 @Injectable({ providedIn: 'root' })
 export class VoiceApiService {
-  constructor(private parse: ParseService) {}
+  constructor(private parse: ParseService, private auth: AuthCreditService) {}
 
   synthesize(payload: VoiceSynthesizeRequest): Observable<any> {
     return from(this.call('synthesize', { payload: this.stripEmpty(payload) }));
@@ -48,7 +49,12 @@ export class VoiceApiService {
     if (!CLOUD_FN.voice) {
       throw new Error('声音服务暂未配置,请先部署语音云函数。');
     }
-    return this.parse.callOrThrow<T>(CLOUD_FN.voice, { action, ...params });
+    return this.parse.callOrThrow<T>(CLOUD_FN.voice, {
+      action,
+      sessionToken: this.auth.session?.token || '',
+      userId: this.auth.currentUser?.objectId || '',
+      ...params,
+    });
   }
 
   private stripEmpty<T extends Record<string, any>>(value: T): T {