Explorar o código

fix: 调整云函数管理器与接口调用

Yi Jiarui hai 2 meses
pai
achega
340168a54b

+ 17 - 18
cloud-functions/01-manifestManager.js

@@ -1,10 +1,17 @@
 /**
  * 云函数:manifestManager(视频清单)
  * 替代:GET/POST/PUT/DELETE /api/manifest[/:videoId]
- * actions: list | get | create | update | delete
+ * 支持 action:list | get | create | update | delete
  */
+const { requireSession, assertRequestedUserMatchesSession } = require('./_session');
+
 async function handler(request, response) {
   try {
+    const action = pickParam(request, 'action') || 'list';
+    const session = await requireSession(request, Psql);
+    assertRequestedUserMatchesSession(request, session);
+    const userId = session.userId;
+
     await Psql.query(`
       CREATE TABLE IF NOT EXISTS "Manifest" (
         "objectId"  VARCHAR(50) PRIMARY KEY,
@@ -17,12 +24,10 @@ async function handler(request, response) {
         "updatedAt" TIMESTAMPTZ  DEFAULT NOW()
       )
     `);
-    await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_manifest_biz ON "Manifest" ("bizId")`);
+    await Psql.query(`DROP INDEX IF EXISTS idx_manifest_biz`);
+    await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_manifest_user_biz ON "Manifest" ("userId", "bizId")`);
     await Psql.query(`CREATE INDEX IF NOT EXISTS idx_manifest_user ON "Manifest" ("userId")`);
 
-    const action = pickParam(request, 'action') || 'list';
-    const userId = pickParam(request, 'userId') || '';
-
     if (action === 'list') {
       const limit = Math.min(parseInt(pickParam(request, 'limit') || '200'), 500);
       const conds = [], params = [];
@@ -42,9 +47,7 @@ async function handler(request, response) {
     if (action === 'get') {
       const bizId = pickParam(request, 'videoId', 'id', 'bizId');
       if (!bizId) return response.json({ code: 400, success: false, error: '缺少 videoId' });
-      const rows = userId
-        ? await Psql.query(`SELECT * FROM "Manifest" WHERE "bizId" = $1 AND "userId" = $2 LIMIT 1`, [bizId, userId])
-        : await Psql.query(`SELECT * FROM "Manifest" WHERE "bizId" = $1 LIMIT 1`, [bizId]);
+      const rows = await Psql.query(`SELECT * FROM "Manifest" WHERE "bizId" = $1 AND "userId" = $2 LIMIT 1`, [bizId, userId]);
       if (!rows.length) return response.json({ code: 404, success: false, error: '未找到视频' });
       response.json({ code: 200, success: true, data: rowToVideo(rows[0]) });
       return;
@@ -56,7 +59,7 @@ async function handler(request, response) {
       if (!bizId || !body.filename) {
         return response.json({ code: 400, success: false, error: '缺少 id 或 filename' });
       }
-      const exists = await Psql.query(`SELECT "objectId" FROM "Manifest" WHERE "bizId" = $1`, [bizId]);
+      const exists = await Psql.query(`SELECT "objectId" FROM "Manifest" WHERE "bizId" = $1 AND "userId" = $2`, [bizId, userId]);
       if (exists.length) return response.json({ code: 409, success: false, error: `视频 ${bizId} 已存在` });
 
       const objectId = generateId();
@@ -73,14 +76,12 @@ async function handler(request, response) {
       const bizId = pickParam(request, 'videoId', 'id', 'bizId');
       if (!bizId) return response.json({ code: 400, success: false, error: '缺少 videoId' });
       const patch = pickParam(request, 'video', 'data', 'patch') || request.body || {};
-      const cur = userId
-        ? await Psql.query(`SELECT * FROM "Manifest" WHERE "bizId" = $1 AND "userId" = $2 LIMIT 1`, [bizId, userId])
-        : await Psql.query(`SELECT * FROM "Manifest" WHERE "bizId" = $1 LIMIT 1`, [bizId]);
+      const cur = await Psql.query(`SELECT * FROM "Manifest" WHERE "bizId" = $1 AND "userId" = $2 LIMIT 1`, [bizId, userId]);
       if (!cur.length) return response.json({ code: 404, success: false, error: '未找到视频' });
       const merged = { ...rowToVideo(cur[0]), ...patch };
       await Psql.query(
-        userId ? `UPDATE "Manifest" SET "data"=$1, "category"=$2, "source"=$3, "updatedAt"=NOW() WHERE "bizId"=$4 AND "userId"=$5` : `UPDATE "Manifest" SET "data"=$1, "category"=$2, "source"=$3, "updatedAt"=NOW() WHERE "bizId"=$4`,
-        userId ? [JSON.stringify(merged), merged.category || '', merged.source || '', bizId, userId] : [JSON.stringify(merged), merged.category || '', merged.source || '', bizId]
+        `UPDATE "Manifest" SET "data"=$1, "category"=$2, "source"=$3, "updatedAt"=NOW() WHERE "bizId"=$4 AND "userId"=$5`,
+        [JSON.stringify(merged), merged.category || '', merged.source || '', bizId, userId]
       );
       response.json({ code: 200, success: true, data: merged });
       return;
@@ -89,9 +90,7 @@ async function handler(request, response) {
     if (action === 'delete') {
       const bizId = pickParam(request, 'videoId', 'id', 'bizId');
       if (!bizId) return response.json({ code: 400, success: false, error: '缺少 videoId' });
-      const result = userId
-        ? await Psql.query(`DELETE FROM "Manifest" WHERE "bizId" = $1 AND "userId" = $2 RETURNING "bizId"`, [bizId, userId])
-        : await Psql.query(`DELETE FROM "Manifest" WHERE "bizId" = $1 RETURNING "bizId"`, [bizId]);
+      const result = await Psql.query(`DELETE FROM "Manifest" WHERE "bizId" = $1 AND "userId" = $2 RETURNING "bizId"`, [bizId, userId]);
       response.json({ code: 200, success: true, data: { videoId: bizId, deleted: result.length > 0 } });
       return;
     }
@@ -99,7 +98,7 @@ async function handler(request, response) {
     response.json({ code: 400, success: false, error: `未知 action: ${action}` });
   } catch (error) {
     console.error('❌ manifestManager 失败:', error.message);
-    response.json({ code: 500, success: false, error: error.message });
+    response.json({ code: error.status || 500, success: false, error: error.message });
   }
 }
 

+ 109 - 120
cloud-functions/02-taskManager.js

@@ -1,131 +1,115 @@
 /**
  * 云函数:taskManager(任务管理)
  * 替代:GET/POST/PUT/DELETE /api/tasks[/:id]
- * actions: list | get | create | update | delete
+ * 支持 action:list | get | create | update | delete
  */
+const { requireSession, assertRequestedUserMatchesSession } = require('./_session');
+
 async function handler(request, response) {
   try {
-    await Psql.query(`
-      CREATE TABLE IF NOT EXISTS "VideoflowTask" (
-        "objectId"  VARCHAR(50) PRIMARY KEY,
-        "bizId"     VARCHAR(255),
-        "data"      JSONB NOT NULL DEFAULT '{}',
-        "userId"    VARCHAR(255) DEFAULT '',
-        "type"      VARCHAR(50)  DEFAULT '',
-        "status"    VARCHAR(50)  DEFAULT '',
-        "createdAt" TIMESTAMPTZ  DEFAULT NOW(),
-        "updatedAt" TIMESTAMPTZ  DEFAULT NOW()
-      )
-    `);
-    await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_videoflow_task_biz ON "VideoflowTask" ("bizId")`);
-    await Psql.query(`CREATE INDEX IF NOT EXISTS idx_videoflow_task_user ON "VideoflowTask" ("userId")`);
-    await Psql.query(`CREATE INDEX IF NOT EXISTS idx_videoflow_task_status ON "VideoflowTask" ("status")`);
-
     const action = pickParam(request, 'action') || 'list';
-    const userId = pickParam(request, 'userId') || '';
+    const session = await requireSession(request, Psql);
+    assertRequestedUserMatchesSession(request, session);
+    const userId = session.userId;
 
-    if (action === 'list') {
-      const conds = [], params = [];
-      if (userId) { params.push(userId); conds.push(`"userId" = $${params.length}`); }
-      const status = pickParam(request, 'status');
-      if (status) { params.push(status); conds.push(`"status" = $${params.length}`); }
-      const type = pickParam(request, 'type');
-      if (type) { params.push(type); conds.push(`"type" = $${params.length}`); }
-      const limit = Math.min(parseInt(pickParam(request, 'limit') || '500'), 1000);
-      const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
-      params.push(limit);
-      const rows = await Psql.query(
-        `SELECT * FROM "VideoflowTask" ${where} ORDER BY "createdAt" DESC LIMIT $${params.length}`,
-        params
-      );
-      response.json({ code: 200, success: true, data: rows.map(rowToObj) });
-      return;
-    }
+    await ensureTable();
 
-    if (action === 'get') {
-      const id = pickParam(request, 'id', 'taskId', 'bizId');
-      if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
-      const rows = await Psql.query(`SELECT * FROM "VideoflowTask" WHERE "bizId" = $1 LIMIT 1`, [id]);
-      if (!rows.length) return response.json({ code: 404, success: false, error: '未找到任务' });
-      response.json({ code: 200, success: true, data: rowToObj(rows[0]) });
-      return;
-    }
+    if (action === 'list') return listTasks(request, response, userId);
+    if (action === 'get') return getTask(request, response, userId);
+    if (action === 'create') return upsertTask(request, response, userId, 'create');
+    if (action === 'update') return upsertTask(request, response, userId, 'update');
+    if (action === 'delete') return deleteTask(request, response, userId);
 
-    if (action === 'create') {
-      const body = pickParam(request, 'task', 'data') || request.body || {};
-      const bizId = body.id || body.bizId || generateId();
-      const merged = { ...body, id: bizId, created_at: body.created_at || new Date().toISOString(), updated_at: new Date().toISOString() };
-      const objectId = generateId();
-      await Psql.query(
-        `INSERT INTO "VideoflowTask" ("objectId","bizId","data","userId","type","status")
-         VALUES ($1,$2,$3,$4,$5,$6)
-         ON CONFLICT ("bizId") DO UPDATE SET "data"=$3, "type"=$5, "status"=$6, "updatedAt"=NOW()`,
-        [objectId, bizId, JSON.stringify(merged), userId, merged.type || '', merged.status || '']
-      );
-      response.json({ code: 200, success: true, data: merged });
-      return;
-    }
+    return response.json({ code: 400, success: false, error: `未知 action: ${action}` });
+  } catch (error) {
+    console.error('taskManager failed:', error && error.message);
+    return response.json({ code: error.status || 500, success: false, error: error.message });
+  }
+}
 
-    if (action === 'update') {
-      const id = pickParam(request, 'id', 'taskId', 'bizId');
-      if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
-      const patch = pickParam(request, 'task', 'data', 'patch') || request.body || {};
-      const cur = await Psql.query(`SELECT * FROM "VideoflowTask" WHERE "bizId" = $1 LIMIT 1`, [id]);
-      const now = new Date().toISOString();
-      const previous = cur.length ? rowToObj(cur[0]) : {};
-      const merged = {
-        ...previous,
-        ...patch,
-        id,
-        bizId: patch.bizId || patch.id || id,
-        created_at: previous.created_at || patch.created_at || now,
-        updated_at: now,
-      };
-      if (cur.length) {
-        await Psql.query(
-          `UPDATE "VideoflowTask" SET "data"=$1, "type"=$2, "status"=$3, "updatedAt"=NOW() WHERE "bizId"=$4`,
-          [JSON.stringify(merged), merged.type || '', merged.status || '', id]
-        );
-      } else {
-        await Psql.query(
-          `INSERT INTO "VideoflowTask" ("objectId","bizId","data","userId","type","status")
-           VALUES ($1,$2,$3,$4,$5,$6)
-           ON CONFLICT ("bizId") DO UPDATE SET "data"=$3, "type"=$5, "status"=$6, "updatedAt"=NOW()`,
-          [generateId(), id, JSON.stringify(merged), userId || merged.userId || '', merged.type || '', merged.status || '']
-        );
-      }
-      response.json({ code: 200, success: true, data: merged });
-      return;
-    }
+async function ensureTable() {
+  await Psql.query(`
+    CREATE TABLE IF NOT EXISTS "VideoflowTask" (
+      "objectId"  VARCHAR(50) PRIMARY KEY,
+      "bizId"     VARCHAR(255),
+      "data"      JSONB NOT NULL DEFAULT '{}',
+      "userId"    VARCHAR(255) DEFAULT '',
+      "type"      VARCHAR(50)  DEFAULT '',
+      "status"    VARCHAR(50)  DEFAULT '',
+      "createdAt" TIMESTAMPTZ  DEFAULT NOW(),
+      "updatedAt" TIMESTAMPTZ  DEFAULT NOW()
+    )
+  `);
+  await Psql.query(`DROP INDEX IF EXISTS idx_videoflow_task_biz`);
+  await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_videoflow_task_user_biz ON "VideoflowTask" ("userId", "bizId")`);
+  await Psql.query(`CREATE INDEX IF NOT EXISTS idx_videoflow_task_user ON "VideoflowTask" ("userId")`);
+  await Psql.query(`CREATE INDEX IF NOT EXISTS idx_videoflow_task_status ON "VideoflowTask" ("status")`);
+}
 
-    if (action === 'update') {
-      const id = pickParam(request, 'id', 'taskId', 'bizId');
-      if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
-      const patch = pickParam(request, 'task', 'data', 'patch') || request.body || {};
-      const cur = await Psql.query(`SELECT * FROM "VideoflowTask" WHERE "bizId" = $1 LIMIT 1`, [id]);
-      if (!cur.length) return response.json({ code: 404, success: false, error: '未找到任务' });
-      const merged = { ...rowToObj(cur[0]), ...patch, updated_at: new Date().toISOString() };
-      await Psql.query(
-        `UPDATE "VideoflowTask" SET "data"=$1, "type"=$2, "status"=$3, "updatedAt"=NOW() WHERE "bizId"=$4`,
-        [JSON.stringify(merged), merged.type || '', merged.status || '', id]
-      );
-      response.json({ code: 200, success: true, data: merged });
-      return;
-    }
+async function listTasks(request, response, userId) {
+  const conds = ['"userId" = $1'];
+  const params = [userId];
+  const status = pickParam(request, 'status');
+  if (status) {
+    params.push(status);
+    conds.push(`"status" = $${params.length}`);
+  }
+  const type = pickParam(request, 'type');
+  if (type) {
+    params.push(type);
+    conds.push(`"type" = $${params.length}`);
+  }
+  const limit = safeLimit(pickParam(request, 'limit'), 500, 1000);
+  params.push(limit);
+  const rows = await Psql.query(
+    `SELECT * FROM "VideoflowTask" WHERE ${conds.join(' AND ')} ORDER BY "createdAt" DESC LIMIT $${params.length}`,
+    params
+  );
+  return response.json({ code: 200, success: true, data: rows.map(rowToObj) });
+}
 
-    if (action === 'delete') {
-      const id = pickParam(request, 'id', 'taskId', 'bizId');
-      if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
-      const r = await Psql.query(`DELETE FROM "VideoflowTask" WHERE "bizId" = $1 RETURNING "bizId"`, [id]);
-      response.json({ code: 200, success: true, data: { id, deleted: r.length > 0 } });
-      return;
-    }
+async function getTask(request, response, userId) {
+  const id = pickParam(request, 'id', 'taskId', 'bizId');
+  if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
+  const rows = await Psql.query(`SELECT * FROM "VideoflowTask" WHERE "bizId" = $1 AND "userId" = $2 LIMIT 1`, [id, userId]);
+  if (!rows.length) return response.json({ code: 404, success: false, error: '未找到任务' });
+  return response.json({ code: 200, success: true, data: rowToObj(rows[0]) });
+}
 
-    response.json({ code: 400, success: false, error: `未知 action: ${action}` });
-  } catch (error) {
-    console.error('❌ taskManager 失败:', error.message);
-    response.json({ code: 500, success: false, error: error.message });
+async function upsertTask(request, response, userId, mode) {
+  const body = pickParam(request, 'task', 'data', 'patch') || request.body || {};
+  const requestedId = pickParam(request, 'id', 'taskId', 'bizId');
+  const bizId = body.id || body.bizId || requestedId || generateId();
+  const now = new Date().toISOString();
+  const existing = await Psql.query(`SELECT * FROM "VideoflowTask" WHERE "bizId" = $1 AND "userId" = $2 LIMIT 1`, [bizId, userId]);
+  const previous = existing.length ? rowToObj(existing[0]) : {};
+  if (mode === 'update' && requestedId && requestedId !== bizId && existing.length) {
+    return response.json({ code: 400, success: false, error: '任务 id 不一致' });
   }
+  const merged = {
+    ...previous,
+    ...body,
+    id: bizId,
+    bizId,
+    userId,
+    created_at: previous.created_at || body.created_at || now,
+    updated_at: now,
+  };
+
+  await Psql.query(
+    `INSERT INTO "VideoflowTask" ("objectId","bizId","data","userId","type","status")
+     VALUES ($1,$2,$3,$4,$5,$6)
+     ON CONFLICT ("userId","bizId") DO UPDATE SET "data"=$3, "type"=$5, "status"=$6, "updatedAt"=NOW()`,
+    [existing[0]?.objectId || generateId(), bizId, JSON.stringify(merged), userId, merged.type || '', merged.status || '']
+  );
+  return response.json({ code: 200, success: true, data: merged });
+}
+
+async function deleteTask(request, response, userId) {
+  const id = pickParam(request, 'id', 'taskId', 'bizId');
+  if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
+  const result = await Psql.query(`DELETE FROM "VideoflowTask" WHERE "bizId" = $1 AND "userId" = $2 RETURNING "bizId"`, [id, userId]);
+  return response.json({ code: 200, success: true, data: { id, deleted: result.length > 0 } });
 }
 
 function rowToObj(row) {
@@ -134,20 +118,25 @@ function rowToObj(row) {
 }
 
 function pickParam(request, ...names) {
-  const sources = [request.params, request.body, request];
+  const sources = [request.params, request.body, request.query, request];
   for (const src of sources) {
     if (!src || typeof src !== 'object') continue;
-    for (const n of names) {
-      const v = src[n];
-      if (v !== undefined && v !== null && v !== '') return v;
+    for (const name of names) {
+      const value = src[name];
+      if (value !== undefined && value !== null && value !== '') return value;
     }
   }
   return null;
 }
 
+function safeLimit(value, fallback, max) {
+  const parsed = parseInt(value || fallback, 10);
+  return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), max) : fallback;
+}
+
 function generateId() {
   const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
-  let s = '';
-  for (let i = 0; i < 10; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
-  return s;
+  let value = '';
+  for (let i = 0; i < 10; i += 1) value += chars.charAt(Math.floor(Math.random() * chars.length));
+  return value;
 }

+ 78 - 69
cloud-functions/03-historyManager.js

@@ -1,78 +1,82 @@
 /**
  * 云函数:historyManager(历史记录)
  * 替代:GET/POST/DELETE /api/history[/:id]
- * actions: list | create | delete | clearAll
+ * 支持 action:list | create | delete | clearAll
  */
+const { requireSession, assertRequestedUserMatchesSession } = require('./_session');
+
 async function handler(request, response) {
   try {
-    await Psql.query(`
-      CREATE TABLE IF NOT EXISTS "History" (
-        "objectId"  VARCHAR(50) PRIMARY KEY,
-        "bizId"     VARCHAR(255),
-        "data"      JSONB NOT NULL DEFAULT '{}',
-        "userId"    VARCHAR(255) DEFAULT '',
-        "action"    VARCHAR(100) DEFAULT '',
-        "createdAt" TIMESTAMPTZ  DEFAULT NOW()
-      )
-    `);
-    await Psql.query(`CREATE INDEX IF NOT EXISTS idx_history_user ON "History" ("userId")`);
-    await Psql.query(`CREATE INDEX IF NOT EXISTS idx_history_created ON "History" ("createdAt" DESC)`);
+    await ensureTable();
 
     const action = pickParam(request, 'action') || 'list';
-    const userId = pickParam(request, 'userId') || '';
+    const session = await requireSession(request, Psql);
+    assertRequestedUserMatchesSession(request, session);
+    const userId = session.userId;
 
-    if (action === 'list') {
-      const conds = [], params = [];
-      if (userId) { params.push(userId); conds.push(`"userId" = $${params.length}`); }
-      const limit = Math.min(parseInt(pickParam(request, 'limit') || '500'), 2000);
-      const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
-      params.push(limit);
-      const rows = await Psql.query(
-        `SELECT * FROM "History" ${where} ORDER BY "createdAt" DESC LIMIT $${params.length}`,
-        params
-      );
-      response.json({ code: 200, success: true, data: rows.map(rowToObj) });
-      return;
-    }
+    if (action === 'list') return listHistory(request, response, userId);
+    if (action === 'create') return createHistory(request, response, userId);
+    if (action === 'delete') return deleteHistory(request, response, userId);
+    if (action === 'clearAll') return clearHistory(response, userId);
 
-    if (action === 'create') {
-      const body = pickParam(request, 'entry', 'data') || request.body || {};
-      const bizId = body.id || body.bizId || generateId();
-      const merged = { ...body, id: bizId, created_at: body.created_at || new Date().toISOString() };
-      const objectId = generateId();
-      await Psql.query(
-        `INSERT INTO "History" ("objectId","bizId","data","userId","action") VALUES ($1,$2,$3,$4,$5)`,
-        [objectId, bizId, JSON.stringify(merged), userId, body.action || body.type || '']
-      );
-      response.json({ code: 200, success: true, data: merged });
-      return;
-    }
+    return response.json({ code: 400, success: false, error: `未知 action: ${action}` });
+  } catch (error) {
+    console.error('historyManager failed:', error && error.message);
+    return response.json({ code: error.status || 500, success: false, error: error.message });
+  }
+}
 
-    if (action === 'delete') {
-      const id = pickParam(request, 'id', 'bizId');
-      if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
-      const r = userId
-        ? await Psql.query(`DELETE FROM "History" WHERE "bizId" = $1 AND "userId" = $2 RETURNING "bizId"`, [id, userId])
-        : await Psql.query(`DELETE FROM "History" WHERE "bizId" = $1 RETURNING "bizId"`, [id]);
-      response.json({ code: 200, success: true, data: { id, deleted: r.length > 0 } });
-      return;
-    }
+async function ensureTable() {
+  await Psql.query(`
+    CREATE TABLE IF NOT EXISTS "History" (
+      "objectId"  VARCHAR(50) PRIMARY KEY,
+      "bizId"     VARCHAR(255),
+      "data"      JSONB NOT NULL DEFAULT '{}',
+      "userId"    VARCHAR(255) DEFAULT '',
+      "action"    VARCHAR(100) DEFAULT '',
+      "createdAt" TIMESTAMPTZ  DEFAULT NOW()
+    )
+  `);
+  await Psql.query(`CREATE INDEX IF NOT EXISTS idx_history_user ON "History" ("userId")`);
+  await Psql.query(`CREATE INDEX IF NOT EXISTS idx_history_created ON "History" ("createdAt" DESC)`);
+}
 
-    if (action === 'clearAll') {
-      if (userId) {
-        await Psql.query(`DELETE FROM "History" WHERE "userId" = $1`, [userId]);
-      } else {
-        await Psql.query(`DELETE FROM "History"`);
-      }
-      response.json({ code: 200, success: true, message: '已清空历史' });
-      return;
-    }
+async function listHistory(request, response, userId) {
+  const limit = safeLimit(pickParam(request, 'limit'), 500, 2000);
+  const rows = await Psql.query(
+    `SELECT * FROM "History" WHERE "userId" = $1 ORDER BY "createdAt" DESC LIMIT $2`,
+    [userId, limit]
+  );
+  return response.json({ code: 200, success: true, data: rows.map(rowToObj) });
+}
 
-    response.json({ code: 400, success: false, error: `未知 action: ${action}` });
-  } catch (error) {
-    console.error('❌ historyManager 失败:', error.message);
-    response.json({ code: 500, success: false, error: error.message });
-  }
+async function createHistory(request, response, userId) {
+  const body = pickParam(request, 'entry', 'data') || request.body || {};
+  const bizId = body.id || body.bizId || generateId();
+  const merged = {
+    ...body,
+    id: bizId,
+    bizId,
+    userId,
+    created_at: body.created_at || new Date().toISOString(),
+  };
+  await Psql.query(
+    `INSERT INTO "History" ("objectId","bizId","data","userId","action") VALUES ($1,$2,$3,$4,$5)`,
+    [generateId(), bizId, JSON.stringify(merged), userId, body.action || body.type || '']
+  );
+  return response.json({ code: 200, success: true, data: merged });
+}
+
+async function deleteHistory(request, response, userId) {
+  const id = pickParam(request, 'id', 'bizId');
+  if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
+  const result = await Psql.query(`DELETE FROM "History" WHERE "bizId" = $1 AND "userId" = $2 RETURNING "bizId"`, [id, userId]);
+  return response.json({ code: 200, success: true, data: { id, deleted: result.length > 0 } });
+}
+
+async function clearHistory(response, userId) {
+  await Psql.query(`DELETE FROM "History" WHERE "userId" = $1`, [userId]);
+  return response.json({ code: 200, success: true, message: '已清空当前账号历史' });
 }
 
 function rowToObj(row) {
@@ -81,20 +85,25 @@ function rowToObj(row) {
 }
 
 function pickParam(request, ...names) {
-  const sources = [request.params, request.body, request];
+  const sources = [request.params, request.body, request.query, request];
   for (const src of sources) {
     if (!src || typeof src !== 'object') continue;
-    for (const n of names) {
-      const v = src[n];
-      if (v !== undefined && v !== null && v !== '') return v;
+    for (const name of names) {
+      const value = src[name];
+      if (value !== undefined && value !== null && value !== '') return value;
     }
   }
   return null;
 }
 
+function safeLimit(value, fallback, max) {
+  const parsed = parseInt(value || fallback, 10);
+  return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), max) : fallback;
+}
+
 function generateId() {
   const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
-  let s = '';
-  for (let i = 0; i < 10; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
-  return s;
+  let value = '';
+  for (let i = 0; i < 10; i += 1) value += chars.charAt(Math.floor(Math.random() * chars.length));
+  return value;
 }

+ 109 - 94
cloud-functions/04-resultManager.js

@@ -1,105 +1,115 @@
 /**
  * 云函数:resultManager(结果库)
  * 替代:GET/POST/PUT/DELETE /api/results[/:id]
- * actions: list | get | create | update | delete
+ * 支持 action:list | get | create | update | delete
  */
+const { requireSession, assertRequestedUserMatchesSession } = require('./_session');
+
 async function handler(request, response) {
   try {
-    await Psql.query(`
-      CREATE TABLE IF NOT EXISTS "VideoflowResult" (
-        "objectId"  VARCHAR(50) PRIMARY KEY,
-        "bizId"     VARCHAR(255),
-        "data"      JSONB NOT NULL DEFAULT '{}',
-        "userId"    VARCHAR(255) DEFAULT '',
-        "type"      VARCHAR(50)  DEFAULT '',
-        "status"    VARCHAR(50)  DEFAULT '',
-        "createdAt" TIMESTAMPTZ  DEFAULT NOW(),
-        "updatedAt" TIMESTAMPTZ  DEFAULT NOW()
-      )
-    `);
-    await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_videoflow_result_biz ON "VideoflowResult" ("bizId")`);
-    await Psql.query(`CREATE INDEX IF NOT EXISTS idx_videoflow_result_user ON "VideoflowResult" ("userId")`);
-    await Psql.query(`CREATE INDEX IF NOT EXISTS idx_videoflow_result_type ON "VideoflowResult" ("type")`);
+    await ensureTable();
 
     const action = pickParam(request, 'action') || 'list';
-    const userId = pickParam(request, 'userId') || '';
+    const session = await requireSession(request, Psql);
+    assertRequestedUserMatchesSession(request, session);
+    const userId = session.userId;
 
-    if (action === 'list') {
-      const conds = [], params = [];
-      if (userId) { params.push(userId); conds.push(`"userId" = $${params.length}`); }
-      const type = pickParam(request, 'type');
-      if (type) { params.push(type); conds.push(`"type" = $${params.length}`); }
-      const status = pickParam(request, 'status');
-      if (status) { params.push(status); conds.push(`"status" = $${params.length}`); }
-      const limit = Math.min(parseInt(pickParam(request, 'limit') || '500'), 2000);
-      const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
-      params.push(limit);
-      const rows = await Psql.query(
-        `SELECT * FROM "VideoflowResult" ${where} ORDER BY "createdAt" DESC LIMIT $${params.length}`,
-        params
-      );
-      response.json({ code: 200, success: true, data: rows.map(rowToObj) });
-      return;
-    }
+    if (action === 'list') return listResults(request, response, userId);
+    if (action === 'get') return getResult(request, response, userId);
+    if (action === 'create') return upsertResult(request, response, userId, 'create');
+    if (action === 'update') return upsertResult(request, response, userId, 'update');
+    if (action === 'delete') return deleteResult(request, response, userId);
 
-    if (action === 'get') {
-      const id = pickParam(request, 'id', 'bizId');
-      if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
-      const rows = userId
-        ? await Psql.query(`SELECT * FROM "VideoflowResult" WHERE "bizId" = $1 AND "userId" = $2 LIMIT 1`, [id, userId])
-        : await Psql.query(`SELECT * FROM "VideoflowResult" WHERE "bizId" = $1 LIMIT 1`, [id]);
-      if (!rows.length) return response.json({ code: 404, success: false, error: '未找到结果' });
-      response.json({ code: 200, success: true, data: rowToObj(rows[0]) });
-      return;
-    }
+    return response.json({ code: 400, success: false, error: `未知 action: ${action}` });
+  } catch (error) {
+    console.error('resultManager failed:', error && error.message);
+    return response.json({ code: error.status || 500, success: false, error: error.message });
+  }
+}
 
-    if (action === 'create') {
-      const body = pickParam(request, 'result', 'data') || request.body || {};
-      const bizId = body.id || body.bizId || generateId();
-      const merged = { ...body, id: bizId, created_at: body.created_at || new Date().toISOString(), updated_at: new Date().toISOString() };
-      const objectId = generateId();
-      await Psql.query(
-        `INSERT INTO "VideoflowResult" ("objectId","bizId","data","userId","type","status")
-         VALUES ($1,$2,$3,$4,$5,$6)
-         ON CONFLICT ("bizId") DO UPDATE SET "data"=$3, "type"=$5, "status"=$6, "updatedAt"=NOW()`,
-        [objectId, bizId, JSON.stringify(merged), userId, merged.type || '', merged.status || '']
-      );
-      response.json({ code: 200, success: true, data: merged });
-      return;
-    }
+async function ensureTable() {
+  await Psql.query(`
+    CREATE TABLE IF NOT EXISTS "VideoflowResult" (
+      "objectId"  VARCHAR(50) PRIMARY KEY,
+      "bizId"     VARCHAR(255),
+      "data"      JSONB NOT NULL DEFAULT '{}',
+      "userId"    VARCHAR(255) DEFAULT '',
+      "type"      VARCHAR(50)  DEFAULT '',
+      "status"    VARCHAR(50)  DEFAULT '',
+      "createdAt" TIMESTAMPTZ  DEFAULT NOW(),
+      "updatedAt" TIMESTAMPTZ  DEFAULT NOW()
+    )
+  `);
+  await Psql.query(`DROP INDEX IF EXISTS idx_videoflow_result_biz`);
+  await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_videoflow_result_user_biz ON "VideoflowResult" ("userId", "bizId")`);
+  await Psql.query(`CREATE INDEX IF NOT EXISTS idx_videoflow_result_user ON "VideoflowResult" ("userId")`);
+  await Psql.query(`CREATE INDEX IF NOT EXISTS idx_videoflow_result_type ON "VideoflowResult" ("type")`);
+}
 
-    if (action === 'update') {
-      const id = pickParam(request, 'id', 'bizId');
-      if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
-      const patch = pickParam(request, 'result', 'data', 'patch') || request.body || {};
-      const cur = userId
-        ? await Psql.query(`SELECT * FROM "VideoflowResult" WHERE "bizId" = $1 AND "userId" = $2 LIMIT 1`, [id, userId])
-        : await Psql.query(`SELECT * FROM "VideoflowResult" WHERE "bizId" = $1 LIMIT 1`, [id]);
-      if (!cur.length) return response.json({ code: 404, success: false, error: '未找到结果' });
-      const merged = { ...rowToObj(cur[0]), ...patch, updated_at: new Date().toISOString() };
-      await Psql.query(
-        userId ? `UPDATE "VideoflowResult" SET "data"=$1, "type"=$2, "status"=$3, "updatedAt"=NOW() WHERE "bizId"=$4 AND "userId"=$5` : `UPDATE "VideoflowResult" SET "data"=$1, "type"=$2, "status"=$3, "updatedAt"=NOW() WHERE "bizId"=$4`,
-        userId ? [JSON.stringify(merged), merged.type || '', merged.status || '', id, userId] : [JSON.stringify(merged), merged.type || '', merged.status || '', id]
-      );
-      response.json({ code: 200, success: true, data: merged });
-      return;
-    }
+async function listResults(request, response, userId) {
+  const conds = ['"userId" = $1'];
+  const params = [userId];
+  const type = pickParam(request, 'type');
+  if (type) {
+    params.push(type);
+    conds.push(`"type" = $${params.length}`);
+  }
+  const status = pickParam(request, 'status');
+  if (status) {
+    params.push(status);
+    conds.push(`"status" = $${params.length}`);
+  }
+  const limit = safeLimit(pickParam(request, 'limit'), 500, 2000);
+  params.push(limit);
+  const rows = await Psql.query(
+    `SELECT * FROM "VideoflowResult" WHERE ${conds.join(' AND ')} ORDER BY "createdAt" DESC LIMIT $${params.length}`,
+    params
+  );
+  return response.json({ code: 200, success: true, data: rows.map(rowToObj) });
+}
 
-    if (action === 'delete') {
-      const id = pickParam(request, 'id', 'bizId');
-      if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
-      const r = userId
-        ? await Psql.query(`DELETE FROM "VideoflowResult" WHERE "bizId" = $1 AND "userId" = $2 RETURNING "bizId"`, [id, userId])
-        : await Psql.query(`DELETE FROM "VideoflowResult" WHERE "bizId" = $1 RETURNING "bizId"`, [id]);
-      response.json({ code: 200, success: true, data: { id, deleted: r.length > 0 } });
-      return;
-    }
+async function getResult(request, response, userId) {
+  const id = pickParam(request, 'id', 'bizId');
+  if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
+  const rows = await Psql.query(`SELECT * FROM "VideoflowResult" WHERE "bizId" = $1 AND "userId" = $2 LIMIT 1`, [id, userId]);
+  if (!rows.length) return response.json({ code: 404, success: false, error: '未找到结果' });
+  return response.json({ code: 200, success: true, data: rowToObj(rows[0]) });
+}
 
-    response.json({ code: 400, success: false, error: `未知 action: ${action}` });
-  } catch (error) {
-    console.error('❌ resultManager 失败:', error.message);
-    response.json({ code: 500, success: false, error: error.message });
+async function upsertResult(request, response, userId, mode) {
+  const body = pickParam(request, 'result', 'data', 'patch') || request.body || {};
+  const requestedId = pickParam(request, 'id', 'bizId');
+  const bizId = body.id || body.bizId || requestedId || generateId();
+  const now = new Date().toISOString();
+  const existing = await Psql.query(`SELECT * FROM "VideoflowResult" WHERE "bizId" = $1 AND "userId" = $2 LIMIT 1`, [bizId, userId]);
+  const previous = existing.length ? rowToObj(existing[0]) : {};
+  if (mode === 'update' && requestedId && requestedId !== bizId && existing.length) {
+    return response.json({ code: 400, success: false, error: '结果 id 不一致' });
   }
+  const merged = {
+    ...previous,
+    ...body,
+    id: bizId,
+    bizId,
+    userId,
+    created_at: previous.created_at || body.created_at || now,
+    updated_at: now,
+  };
+
+  await Psql.query(
+    `INSERT INTO "VideoflowResult" ("objectId","bizId","data","userId","type","status")
+     VALUES ($1,$2,$3,$4,$5,$6)
+     ON CONFLICT ("userId","bizId") DO UPDATE SET "data"=$3, "type"=$5, "status"=$6, "updatedAt"=NOW()`,
+    [existing[0]?.objectId || generateId(), bizId, JSON.stringify(merged), userId, merged.type || '', merged.status || '']
+  );
+  return response.json({ code: 200, success: true, data: merged });
+}
+
+async function deleteResult(request, response, userId) {
+  const id = pickParam(request, 'id', 'bizId');
+  if (!id) return response.json({ code: 400, success: false, error: '缺少 id' });
+  const result = await Psql.query(`DELETE FROM "VideoflowResult" WHERE "bizId" = $1 AND "userId" = $2 RETURNING "bizId"`, [id, userId]);
+  return response.json({ code: 200, success: true, data: { id, deleted: result.length > 0 } });
 }
 
 function rowToObj(row) {
@@ -108,20 +118,25 @@ function rowToObj(row) {
 }
 
 function pickParam(request, ...names) {
-  const sources = [request.params, request.body, request];
+  const sources = [request.params, request.body, request.query, request];
   for (const src of sources) {
     if (!src || typeof src !== 'object') continue;
-    for (const n of names) {
-      const v = src[n];
-      if (v !== undefined && v !== null && v !== '') return v;
+    for (const name of names) {
+      const value = src[name];
+      if (value !== undefined && value !== null && value !== '') return value;
     }
   }
   return null;
 }
 
+function safeLimit(value, fallback, max) {
+  const parsed = parseInt(value || fallback, 10);
+  return Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), max) : fallback;
+}
+
 function generateId() {
   const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
-  let s = '';
-  for (let i = 0; i < 10; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
-  return s;
+  let value = '';
+  for (let i = 0; i < 10; i += 1) value += chars.charAt(Math.floor(Math.random() * chars.length));
+  return value;
 }

+ 110 - 97
cloud-functions/05-remixManager.js

@@ -1,107 +1,120 @@
 /**
  * 云函数:remixManager(视频重塑记录)
  * 替代:GET/POST/DELETE /api/remixes[/:videoId[/:remixId]]
- * actions:
- *   listAll       → 所有视频的重塑汇总 { [videoId]: Remix[] }
- *   listByVideo   → 某视频的所有重塑
- *   upsert        → 创建或更新一条
- *   delete        → 删除一条
+ * 支持 action:
+ *   listAll
+ *   listByVideo
+ *   upsert
+ *   delete
  */
+const { requireSession, assertRequestedUserMatchesSession } = require('./_session');
+
 async function handler(request, response) {
   try {
-    await Psql.query(`
-      CREATE TABLE IF NOT EXISTS "Remix" (
-        "objectId"  VARCHAR(50) PRIMARY KEY,
-        "videoId"   VARCHAR(255) NOT NULL,
-        "remixId"   VARCHAR(255) NOT NULL,
-        "data"      JSONB NOT NULL DEFAULT '{}',
-        "userId"    VARCHAR(255) DEFAULT '',
-        "createdAt" TIMESTAMPTZ  DEFAULT NOW(),
-        "updatedAt" TIMESTAMPTZ  DEFAULT NOW()
-      )
-    `);
-    await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_remix_pair ON "Remix" ("videoId","remixId")`);
-    await Psql.query(`CREATE INDEX IF NOT EXISTS idx_remix_video ON "Remix" ("videoId")`);
-    await Psql.query(`CREATE INDEX IF NOT EXISTS idx_remix_user ON "Remix" ("userId")`);
+    await ensureTable();
 
     const action = pickParam(request, 'action') || 'listAll';
-    const userId = pickParam(request, 'userId') || '';
+    const session = await requireSession(request, Psql);
+    assertRequestedUserMatchesSession(request, session);
+    const userId = session.userId;
 
-    if (action === 'listAll') {
-      const conds = [], params = [];
-      if (userId) { params.push(userId); conds.push(`"userId" = $${params.length}`); }
-      const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
-      const rows = await Psql.query(
-        `SELECT * FROM "Remix" ${where} ORDER BY "createdAt" DESC LIMIT 5000`,
-        params
-      );
-      const grouped = {};
-      for (const r of rows) {
-        if (!grouped[r.videoId]) grouped[r.videoId] = [];
-        grouped[r.videoId].push(rowToObj(r));
-      }
-      response.json({ code: 200, success: true, data: grouped });
-      return;
-    }
+    if (action === 'listAll') return listAll(response, userId);
+    if (action === 'listByVideo') return listByVideo(request, response, userId);
+    if (action === 'upsert') return upsertRemix(request, response, userId);
+    if (action === 'delete') return deleteRemix(request, response, userId);
 
-    if (action === 'listByVideo') {
-      const videoId = pickParam(request, 'videoId');
-      if (!videoId) return response.json({ code: 400, success: false, error: '缺少 videoId' });
-      const rows = userId
-        ? await Psql.query(`SELECT * FROM "Remix" WHERE "videoId" = $1 AND "userId" = $2 ORDER BY "createdAt" DESC`, [videoId, userId])
-        : await Psql.query(`SELECT * FROM "Remix" WHERE "videoId" = $1 ORDER BY "createdAt" DESC`, [videoId]);
-      response.json({ code: 200, success: true, data: rows.map(rowToObj) });
-      return;
-    }
+    return response.json({ code: 400, success: false, error: `未知 action: ${action}` });
+  } catch (error) {
+    console.error('remixManager failed:', error && error.message);
+    return response.json({ code: error.status || 500, success: false, error: error.message });
+  }
+}
 
-    if (action === 'upsert') {
-      const videoId = pickParam(request, 'videoId');
-      const body = pickParam(request, 'remix', 'data') || request.body || {};
-      const remixId = body.remixId || pickParam(request, 'remixId');
-      if (!videoId || !remixId) {
-        return response.json({ code: 400, success: false, error: '缺少 videoId 或 remixId' });
-      }
-      const now = new Date().toISOString();
-      const merged = { ...body, remixId, videoId, updated_at: now };
-      const exists = userId
-        ? await Psql.query(`SELECT "objectId" FROM "Remix" WHERE "videoId"=$1 AND "remixId"=$2 AND "userId"=$3`, [videoId, remixId, userId])
-        : await Psql.query(`SELECT "objectId" FROM "Remix" WHERE "videoId"=$1 AND "remixId"=$2`, [videoId, remixId]);
-      if (exists.length) {
-        await Psql.query(
-          userId ? `UPDATE "Remix" SET "data"=$1, "updatedAt"=NOW() WHERE "videoId"=$2 AND "remixId"=$3 AND "userId"=$4` : `UPDATE "Remix" SET "data"=$1, "updatedAt"=NOW() WHERE "videoId"=$2 AND "remixId"=$3`,
-          userId ? [JSON.stringify(merged), videoId, remixId, userId] : [JSON.stringify(merged), videoId, remixId]
-        );
-      } else {
-        merged.created_at = now;
-        const objectId = generateId();
-        await Psql.query(
-          `INSERT INTO "Remix" ("objectId","videoId","remixId","data","userId") VALUES ($1,$2,$3,$4,$5)`,
-          [objectId, videoId, remixId, JSON.stringify(merged), userId]
-        );
-      }
-      response.json({ code: 200, success: true, data: merged });
-      return;
-    }
+async function ensureTable() {
+  await Psql.query(`
+    CREATE TABLE IF NOT EXISTS "Remix" (
+      "objectId"  VARCHAR(50) PRIMARY KEY,
+      "videoId"   VARCHAR(255) NOT NULL,
+      "remixId"   VARCHAR(255) NOT NULL,
+      "data"      JSONB NOT NULL DEFAULT '{}',
+      "userId"    VARCHAR(255) DEFAULT '',
+      "createdAt" TIMESTAMPTZ  DEFAULT NOW(),
+      "updatedAt" TIMESTAMPTZ  DEFAULT NOW()
+    )
+  `);
+  await Psql.query(`DROP INDEX IF EXISTS idx_remix_pair`);
+  await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_remix_user_pair ON "Remix" ("userId", "videoId", "remixId")`);
+  await Psql.query(`CREATE INDEX IF NOT EXISTS idx_remix_video ON "Remix" ("videoId")`);
+  await Psql.query(`CREATE INDEX IF NOT EXISTS idx_remix_user ON "Remix" ("userId")`);
+}
 
-    if (action === 'delete') {
-      const videoId = pickParam(request, 'videoId');
-      const remixId = pickParam(request, 'remixId');
-      if (!videoId || !remixId) {
-        return response.json({ code: 400, success: false, error: '缺少 videoId 或 remixId' });
-      }
-      const r = await Psql.query(
-        userId ? `DELETE FROM "Remix" WHERE "videoId"=$1 AND "remixId"=$2 AND "userId"=$3 RETURNING "objectId"` : `DELETE FROM "Remix" WHERE "videoId"=$1 AND "remixId"=$2 RETURNING "objectId"`,
-        userId ? [videoId, remixId, userId] : [videoId, remixId]
-      );
-      response.json({ code: 200, success: true, data: { videoId, remixId, deleted: r.length > 0 } });
-      return;
-    }
+async function listAll(response, userId) {
+  const rows = await Psql.query(
+    `SELECT * FROM "Remix" WHERE "userId" = $1 ORDER BY "createdAt" DESC LIMIT 5000`,
+    [userId]
+  );
+  const grouped = {};
+  for (const row of rows) {
+    if (!grouped[row.videoId]) grouped[row.videoId] = [];
+    grouped[row.videoId].push(rowToObj(row));
+  }
+  return response.json({ code: 200, success: true, data: grouped });
+}
 
-    response.json({ code: 400, success: false, error: `未知 action: ${action}` });
-  } catch (error) {
-    console.error('❌ remixManager 失败:', error.message);
-    response.json({ code: 500, success: false, error: error.message });
+async function listByVideo(request, response, userId) {
+  const videoId = pickParam(request, 'videoId');
+  if (!videoId) return response.json({ code: 400, success: false, error: '缺少 videoId' });
+  const rows = await Psql.query(
+    `SELECT * FROM "Remix" WHERE "videoId" = $1 AND "userId" = $2 ORDER BY "createdAt" DESC`,
+    [videoId, userId]
+  );
+  return response.json({ code: 200, success: true, data: rows.map(rowToObj) });
+}
+
+async function upsertRemix(request, response, userId) {
+  const videoId = pickParam(request, 'videoId');
+  const body = pickParam(request, 'remix', 'data') || request.body || {};
+  const remixId = body.remixId || pickParam(request, 'remixId');
+  if (!videoId || !remixId) {
+    return response.json({ code: 400, success: false, error: '缺少 videoId 或 remixId' });
+  }
+
+  const now = new Date().toISOString();
+  const existing = await Psql.query(
+    `SELECT * FROM "Remix" WHERE "videoId"=$1 AND "remixId"=$2 AND "userId"=$3 LIMIT 1`,
+    [videoId, remixId, userId]
+  );
+  const previous = existing.length ? rowToObj(existing[0]) : {};
+  const merged = {
+    ...previous,
+    ...body,
+    remixId,
+    videoId,
+    userId,
+    created_at: previous.created_at || body.created_at || now,
+    updated_at: now,
+  };
+
+  await Psql.query(
+    `INSERT INTO "Remix" ("objectId","videoId","remixId","data","userId")
+     VALUES ($1,$2,$3,$4,$5)
+     ON CONFLICT ("userId","videoId","remixId") DO UPDATE SET "data"=$4, "updatedAt"=NOW()`,
+    [existing[0]?.objectId || generateId(), videoId, remixId, JSON.stringify(merged), userId]
+  );
+  return response.json({ code: 200, success: true, data: merged });
+}
+
+async function deleteRemix(request, response, userId) {
+  const videoId = pickParam(request, 'videoId');
+  const remixId = pickParam(request, 'remixId');
+  if (!videoId || !remixId) {
+    return response.json({ code: 400, success: false, error: '缺少 videoId 或 remixId' });
   }
+  const result = await Psql.query(
+    `DELETE FROM "Remix" WHERE "videoId"=$1 AND "remixId"=$2 AND "userId"=$3 RETURNING "objectId"`,
+    [videoId, remixId, userId]
+  );
+  return response.json({ code: 200, success: true, data: { videoId, remixId, deleted: result.length > 0 } });
 }
 
 function rowToObj(row) {
@@ -110,12 +123,12 @@ function rowToObj(row) {
 }
 
 function pickParam(request, ...names) {
-  const sources = [request.params, request.body, request];
+  const sources = [request.params, request.body, request.query, request];
   for (const src of sources) {
     if (!src || typeof src !== 'object') continue;
-    for (const n of names) {
-      const v = src[n];
-      if (v !== undefined && v !== null && v !== '') return v;
+    for (const name of names) {
+      const value = src[name];
+      if (value !== undefined && value !== null && value !== '') return value;
     }
   }
   return null;
@@ -123,7 +136,7 @@ function pickParam(request, ...names) {
 
 function generateId() {
   const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
-  let s = '';
-  for (let i = 0; i < 10; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
-  return s;
+  let value = '';
+  for (let i = 0; i < 10; i += 1) value += chars.charAt(Math.floor(Math.random() * chars.length));
+  return value;
 }

+ 19 - 11
cloud-functions/06-voiceManager.js

@@ -14,6 +14,11 @@
 const VOICE_TOKEN = readEnv('VOICE_TOKEN') || 'Bearer r:f0333969e312a40e4703e8fe4ed1c600';
 const VOICE_TTS_BASE_URL = readEnv('VOICE_TTS_BASE_URL') || 'https://server.fmode.cn/api/volcengine/tts';
 const VOICE_MAX_ATTEMPTS = Math.max(1, Number(readEnv('VOICE_MAX_ATTEMPTS') || 3));
+const {
+  requireSession,
+  requireAdmin,
+  assertRequestedUserMatchesSession,
+} = require('./_session');
 
 async function handler(request, response) {
   try {
@@ -30,7 +35,8 @@ async function handler(request, response) {
         "updatedAt"  TIMESTAMPTZ DEFAULT NOW()
       )
     `);
-    await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_voice_timbre ON "VoiceProfile" ("timbreId") WHERE "timbreId" IS NOT NULL AND "timbreId" <> ''`);
+    await Psql.query(`DROP INDEX IF EXISTS idx_voice_timbre`);
+    await Psql.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_voice_user_timbre ON "VoiceProfile" ("userId", "timbreId") WHERE "timbreId" IS NOT NULL AND "timbreId" <> ''`);
     await Psql.query(`CREATE INDEX IF NOT EXISTS idx_voice_speaker ON "VoiceProfile" ("speakerId")`);
 
     await Psql.query(`
@@ -42,7 +48,11 @@ async function handler(request, response) {
     `);
 
     const action = pickParam(request, 'action') || 'listProfiles';
-    const userId = pickParam(request, 'userId') || '';
+    const session = action === 'initPool'
+      ? await requireAdmin(request, Psql)
+      : await requireSession(request, Psql);
+    assertRequestedUserMatchesSession(request, session);
+    const userId = session.userId;
 
     if (action === 'autoSpeakerId') {
       const used = await Psql.query(`
@@ -71,10 +81,10 @@ async function handler(request, response) {
 
       let existing = [];
       if (timbreId) {
-        existing = await Psql.query(`SELECT * FROM "VoiceProfile" WHERE "timbreId" = $1 LIMIT 1`, [timbreId]);
+        existing = await Psql.query(`SELECT * FROM "VoiceProfile" WHERE "timbreId" = $1 AND "userId" = $2 LIMIT 1`, [timbreId, userId]);
       }
       if (!existing.length && speakerId) {
-        existing = await Psql.query(`SELECT * FROM "VoiceProfile" WHERE "speakerId" = $1 LIMIT 1`, [speakerId]);
+        existing = await Psql.query(`SELECT * FROM "VoiceProfile" WHERE "speakerId" = $1 AND "userId" = $2 LIMIT 1`, [speakerId, userId]);
       }
 
       if (existing.length) {
@@ -99,11 +109,9 @@ async function handler(request, response) {
     }
 
     if (action === 'listProfiles') {
-      const conds = [], params = [];
-      if (userId) { params.push(userId); conds.push(`"userId" = $${params.length}`); }
-      const where = conds.length ? `WHERE ${conds.join(' AND ')}` : '';
+      const params = [userId];
       const rows = await Psql.query(
-        `SELECT * FROM "VoiceProfile" ${where} ORDER BY "createdAt" DESC LIMIT 1000`,
+        `SELECT * FROM "VoiceProfile" WHERE "userId" = $1 ORDER BY "createdAt" DESC LIMIT 1000`,
         params
       );
       response.json({ code: 200, success: true, data: rows.map(rowToObj) });
@@ -160,8 +168,8 @@ async function handler(request, response) {
         return response.json({ code: 400, success: false, error: '缺少 objectId 或 timbreId' });
       }
       const r = objectId
-        ? await Psql.query(`DELETE FROM "VoiceProfile" WHERE "objectId" = $1 RETURNING "objectId"`, [objectId])
-        : await Psql.query(`DELETE FROM "VoiceProfile" WHERE "timbreId" = $1 RETURNING "objectId"`, [timbreId]);
+        ? await Psql.query(`DELETE FROM "VoiceProfile" WHERE "objectId" = $1 AND "userId" = $2 RETURNING "objectId"`, [objectId, userId])
+        : await Psql.query(`DELETE FROM "VoiceProfile" WHERE "timbreId" = $1 AND "userId" = $2 RETURNING "objectId"`, [timbreId, userId]);
       response.json({ code: 200, success: true, data: { deleted: r.length > 0 } });
       return;
     }
@@ -169,7 +177,7 @@ async function handler(request, response) {
     response.json({ code: 400, success: false, error: `未知 action: ${action}` });
   } catch (error) {
     console.error('❌ voiceManager 失败:', error.message);
-    response.json({ code: 500, success: false, error: error.message });
+    response.json({ code: error.status || 500, success: false, error: error.message });
   }
 }
 

+ 5 - 1
src/app/services/browser-ffmpeg.service.ts

@@ -317,7 +317,11 @@ export class BrowserFfmpegService {
         try {
           url = await new Promise<string>((resolve, reject) => {
             this.qiniu
-              .uploadFileWithProgress(blob, filename, 'video/mp4', 'video')
+              .uploadFileWithProgress(blob, filename, 'video/mp4', 'video', {
+                sourceModule: 'browser-ffmpeg',
+                bizType: 'composited-video',
+                bizId: filename,
+              })
               .subscribe({
                 next: (ev) => {
                   if (ev.state === 'progress') {

+ 2 - 2
src/app/services/cloud-api.interceptor.ts

@@ -129,10 +129,10 @@ export const cloudApiInterceptor: HttpInterceptorFn = (req, next) => {
 
   // ==================== voice ====================
   if (resource === 'voice' && p1 === 'auto-speaker-id' && method === 'POST') {
-    return call(CLOUD_FN.voice, { action: 'autoSpeakerId' }, data => ({ speaker_id: data?.speakerId || '' }));
+    return call(CLOUD_FN.voice, scoped({ action: 'autoSpeakerId' }), data => ({ speaker_id: data?.speakerId || '' }));
   }
   if (resource === 'voice-profiles' && p1 === 'sync' && method === 'POST') {
-    return call(CLOUD_FN.voice, { action: 'syncProfile', profile: body }, data => ({ success: true, profile: data }));
+    return call(CLOUD_FN.voice, scoped({ action: 'syncProfile', profile: body }), data => ({ success: true, profile: data }));
   }
 
   // ==================== jimeng ====================

+ 5 - 1
src/app/services/cloud-functions.ts

@@ -36,7 +36,11 @@ export const CLOUD_FN = {
   /** 七牛云直传凭证 */
   upload: 'VHS0noRP7Q',
   /** 账号、积分、充值与管理员 */
-  authCredit: '',
+  authCredit: '6u6qzjMjGS',
+  /** 通用业务云端主存储:VideoWorkflowEntity/VideoWorkflowAudit/VideoWorkflowMigration */
+  systemStorage: '7aPh6JyEDM',
+  /** 七牛云文件资产元数据与归属治理:VideoWorkflowFileAsset */
+  fileAsset: 'rBou7pybuU',
 } as const;
 
 export type CloudFnKey = keyof typeof CLOUD_FN;

+ 50 - 3
src/app/services/douyin.service.ts

@@ -5,12 +5,18 @@ import { catchError, map, retry, switchMap } from 'rxjs/operators';
 import { DouyinVideo, VideoDownloadTask } from '../models/types';
 import { environment } from '../../environments/environment';
 import { AuthCreditService } from './auth-credit.service';
+import { CLOUD_FN } from './cloud-functions';
+import { ParseService } from './parse.service';
 
 @Injectable({ providedIn: 'root' })
 export class DouyinService {
   private readonly MAX_RETRIES = environment.douyinApi.maxRetries;
 
-  constructor(private http: HttpClient, private authCredit: AuthCreditService) {}
+  constructor(
+    private http: HttpClient,
+    private authCredit: AuthCreditService,
+    private parse: ParseService,
+  ) {}
 
   // 抖音话题搜索 V2
   searchVideos(
@@ -395,10 +401,25 @@ export class DouyinService {
   }
 
   private callDouyin(route: string, params: { payload?: Record<string, any>; params?: Record<string, any> }): Observable<any> {
-    return from(this.callDouyinLocalFirst(route, params));
+    return from(this.callDouyinByRuntime(route, params));
   }
 
-  private async callDouyinLocalFirst(route: string, params: { payload?: Record<string, any>; params?: Record<string, any> }): Promise<any> {
+  private async callDouyinByRuntime(route: string, params: { payload?: Record<string, any>; params?: Record<string, any> }): 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 callDouyinLocal(route: string, params: { payload?: Record<string, any>; params?: Record<string, any> }): Promise<any> {
     try {
       const local = await firstValueFrom(this.http.post<{ success?: boolean; data?: any; error?: string }>('/backend/api/douyin/call', {
         route,
@@ -416,10 +437,36 @@ export class DouyinService {
     }
   }
 
+  private async callDouyinCloud(route: string, params: { payload?: Record<string, any>; params?: Record<string, any> }): 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.authCredit.session?.token || '',
+      userId: this.authCredit.currentUser?.objectId || '',
+    });
+    if (res.code === 200 && res.success) return res.data;
+    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 withCreditGate<T>(
     operation: string,
     cost: number,