Ver código fonte

fix: 修复计费查询与生成链路

Yi Jiarui 2 meses atrás
pai
commit
fa64438c7c

+ 470 - 471
cloud-functions/10-authCreditManager.js

@@ -1,507 +1,439 @@
 /**
- * 云函数:authCreditManager(账号、总积分、充值订单、管理员调账)
+ * Cloud function: authCreditManager
  *
- * 部署后把函数 objectId 填入 src/app/services/cloud-functions.ts 的 authCredit。
- * 管理员策略:
- *   1. 第一位注册用户自动为 admin;
- *   2. 也可在 ADMIN_IDENTIFIERS 中配置用户名/邮箱/手机号。
+ * This file is the account and credit gateway for the current video-workflow
+ * project. It intentionally uses the existing Parse user session and APIGAuth
+ * balance instead of creating a second AppUser/UserCreditAccount system.
+ *
+ * Data source of truth:
+ * - Login/session: Parse _User + X-Parse-Session-Token
+ * - Balance: APIGAuth.count for VIDEO_WORKFLOW_APIG_ID
+ * - Consumption marker: APIGAuth.used + APIGAuth.lastBilling
+ * - Recharge: existing APIGOrder/saveRecharge platform endpoints
  */
-const ADMIN_IDENTIFIERS = ['admin'];
-const REGISTER_GIFT_CREDITS = 10;
-const CREDIT_PER_CNY = 10;
-const PARSE_API_HOST = 'https://server.fmode.cn';
-const PARSE_APP_ID = 'ncloudmaster';
+
+const PARSE_API_HOST = env('PARSE_API_HOST', 'https://server.fmode.cn');
+const PARSE_APP_ID = env('PARSE_APP_ID', 'ncloudmaster');
+const VIDEO_WORKFLOW_APIG_ID = env('VIDEO_WORKFLOW_APIG_ID', '6pFf6EAdKT');
+const DEFAULT_APIG_TITLE = env('VIDEO_WORKFLOW_APIG_TITLE', '短视频AI工作流');
+const DEFAULT_UNIT_PRICE_CNY = Number(env('VIDEO_WORKFLOW_APIG_UNIT_PRICE_CNY', '0.1')) || 0.1;
 
 async function handler(request, response) {
   try {
-    await ensureTables();
-    const action = pickParam(request, 'action') || '';
+    const action = clean(pickParam(request, 'action'));
 
-    if (action === 'register') return register(request, response);
-    if (action === 'login') return login(request, response);
     if (action === 'me') return me(request, response);
-    if (action === 'changePassword') return changePassword(request, response);
     if (action === 'balance') return balance(request, response);
     if (action === 'ledger') return ledger(request, response);
+    if (action === 'rechargeContext') return rechargeContext(request, response);
     if (action === 'reserve') return reserve(request, response);
     if (action === 'commitReservation') return commitReservation(request, response);
     if (action === 'refundReservation') return refundReservation(request, response);
     if (action === 'createRechargeOrder') return createRechargeOrder(request, response);
-    if (action === 'adminListUsers') return adminListUsers(request, response);
-    if (action === 'adminCreateUser') return adminCreateUser(request, response);
-    if (action === 'adminAdjustCredit') return adminAdjustCredit(request, response);
+    if (action === 'saveRecharge') return saveRecharge(request, response);
+    if (action === 'adminListUsers') return unsupported(response, '账号列表请继续使用 Parse 用户体系或后续单独建设管理后台。');
+    if (action === 'adminCreateUser') return unsupported(response, '账号创建请继续使用 Parse 用户体系,不再由 authCreditManager 自建账号。');
+    if (action === 'adminAdjustCredit') return unsupported(response, '调额请直接调整 APIGAuth 或后续建设受控管理员云函数。');
+    if (action === 'register' || action === 'login' || action === 'changePassword') {
+      return unsupported(response, '当前项目使用 Parse 手机验证码登录,不再使用 authCreditManager 自建密码账号。');
+    }
 
-    response.json({ code: 400, success: false, error: `未知 action: ${action}` });
+    return response.json({ code: 400, success: false, error: `未知 action: ${action || '(empty)'}` });
   } catch (error) {
-    console.error('authCreditManager failed:', error.message, error.stack);
-    response.json({ code: 500, success: false, error: error.message });
+    const status = Number(error && (error.status || error.statusCode)) || 500;
+    console.error('authCreditManager failed:', error && error.message, error && error.stack);
+    return response.json({
+      code: status,
+      success: false,
+      error: error && error.message ? error.message : '账号积分服务调用失败',
+      detail: error && error.detail ? error.detail : undefined,
+    });
   }
 }
 
-async function ensureTables() {
-  await Psql.query(`
-    CREATE TABLE IF NOT EXISTS "AppUser" (
-      "objectId" VARCHAR(50) PRIMARY KEY,
-      "username" VARCHAR(120) UNIQUE NOT NULL,
-      "email" VARCHAR(180) UNIQUE,
-      "phone" VARCHAR(60) UNIQUE,
-      "displayName" VARCHAR(180) DEFAULT '',
-      "passwordHash" VARCHAR(255) NOT NULL,
-      "passwordSalt" VARCHAR(120) NOT NULL,
-      "role" VARCHAR(30) DEFAULT 'user',
-      "status" VARCHAR(30) DEFAULT 'active',
-      "createdAt" TIMESTAMPTZ DEFAULT NOW(),
-      "updatedAt" TIMESTAMPTZ DEFAULT NOW()
-    )
-  `);
-  await Psql.query(`CREATE INDEX IF NOT EXISTS idx_app_user_email ON "AppUser" ("email")`);
-  await Psql.query(`CREATE INDEX IF NOT EXISTS idx_app_user_phone ON "AppUser" ("phone")`);
-
-  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()
-    )
-  `);
-
-  await Psql.query(`
-    CREATE TABLE IF NOT EXISTS "UserCreditAccount" (
-      "userId" VARCHAR(50) PRIMARY KEY,
-      "balance" NUMERIC(12,2) DEFAULT 0,
-      "gifted" NUMERIC(12,2) DEFAULT 0,
-      "totalRecharged" NUMERIC(12,2) DEFAULT 0,
-      "totalConsumed" NUMERIC(12,2) DEFAULT 0,
-      "createdAt" TIMESTAMPTZ DEFAULT NOW(),
-      "updatedAt" TIMESTAMPTZ DEFAULT NOW()
-    )
-  `);
-
-  await Psql.query(`
-    CREATE TABLE IF NOT EXISTS "CreditLedger" (
-      "objectId" VARCHAR(50) PRIMARY KEY,
-      "userId" VARCHAR(50) NOT NULL,
-      "type" VARCHAR(40) NOT NULL,
-      "amount" NUMERIC(12,2) NOT NULL,
-      "balanceAfter" NUMERIC(12,2) NOT NULL,
-      "title" VARCHAR(255) DEFAULT '',
-      "detail" JSONB DEFAULT '{}',
-      "createdAt" TIMESTAMPTZ DEFAULT NOW()
-    )
-  `);
-  await Psql.query(`CREATE INDEX IF NOT EXISTS idx_credit_ledger_user_time ON "CreditLedger" ("userId", "createdAt" DESC)`);
-
-  await Psql.query(`
-    CREATE TABLE IF NOT EXISTS "CreditReservation" (
-      "objectId" VARCHAR(50) PRIMARY KEY,
-      "userId" VARCHAR(50) NOT NULL,
-      "operation" VARCHAR(120) NOT NULL,
-      "cost" NUMERIC(12,2) NOT NULL,
-      "status" VARCHAR(30) DEFAULT 'reserved',
-      "ledgerId" VARCHAR(50),
-      "detail" JSONB DEFAULT '{}',
-      "createdAt" TIMESTAMPTZ DEFAULT NOW(),
-      "updatedAt" TIMESTAMPTZ DEFAULT NOW()
-    )
-  `);
-
-  await Psql.query(`
-    CREATE TABLE IF NOT EXISTS "RechargeOrder" (
-      "objectId" VARCHAR(50) PRIMARY KEY,
-      "userId" VARCHAR(50) NOT NULL,
-      "amountCny" NUMERIC(12,2) NOT NULL,
-      "creditAmount" NUMERIC(12,2) NOT NULL,
-      "status" VARCHAR(30) DEFAULT 'pending',
-      "detail" JSONB DEFAULT '{}',
-      "createdAt" TIMESTAMPTZ DEFAULT NOW(),
-      "updatedAt" TIMESTAMPTZ DEFAULT NOW()
-    )
-  `);
-}
-
-async function register(request, response) {
-  const username = clean(pickParam(request, 'username'));
-  const email = clean(pickParam(request, 'email'));
-  const phone = clean(pickParam(request, 'phone'));
-  const displayName = clean(pickParam(request, 'displayName')) || username;
-  const password = String(pickParam(request, 'password') || '');
-  if (!username || !password) return response.json({ code: 400, success: false, error: '缺少用户名或密码' });
-
-  const exists = await Psql.query(
-    `SELECT "objectId" FROM "AppUser" WHERE "username"=$1 OR ($2 <> '' AND "email"=$2) OR ($3 <> '' AND "phone"=$3) LIMIT 1`,
-    [username, email, phone]
-  );
-  if (exists.length) return response.json({ code: 409, success: false, error: '账号已存在' });
-
-  const countRows = await Psql.query(`SELECT COUNT(*)::int AS count FROM "AppUser"`);
-  const isFirstUser = Number(countRows[0].count || 0) === 0;
-  const role = isFirstUser || ADMIN_IDENTIFIERS.includes(username) || ADMIN_IDENTIFIERS.includes(email) || ADMIN_IDENTIFIERS.includes(phone)
-    ? 'admin'
-    : 'user';
-  const salt = generateId(16);
-  const userId = generateId();
-  const hash = passwordHash(password, salt);
-
-  await Psql.query(
-    `INSERT INTO "AppUser" ("objectId","username","email","phone","displayName","passwordHash","passwordSalt","role")
-     VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,$6,$7,$8)`,
-    [userId, username, email, phone, displayName, hash, salt, role]
-  );
-  await Psql.query(
-    `INSERT INTO "UserCreditAccount" ("userId","balance","gifted") VALUES ($1,$2,$2)`,
-    [userId, REGISTER_GIFT_CREDITS]
-  );
-  await insertLedger(userId, 'gift', REGISTER_GIFT_CREDITS, REGISTER_GIFT_CREDITS, '注册赠送积分', {});
-  const session = await createSession(userId);
-  response.json({ code: 200, success: true, data: { token: session.token, user: await publicUser(userId) } });
-}
-
-async function login(request, response) {
-  const identifier = clean(pickParam(request, 'identifier'));
-  const password = String(pickParam(request, 'password') || '');
-  const rows = await Psql.query(
-    `SELECT * FROM "AppUser" WHERE "username"=$1 OR "email"=$1 OR "phone"=$1 LIMIT 1`,
-    [identifier]
-  );
-  if (!rows.length) return response.json({ code: 401, success: false, error: '账号或密码不正确' });
-  const row = rows[0];
-  if (row.status !== 'active') return response.json({ code: 403, success: false, error: '账号已停用' });
-  if (passwordHash(password, row.passwordSalt) !== row.passwordHash) {
-    return response.json({ code: 401, success: false, error: '账号或密码不正确' });
-  }
-  const session = await createSession(row.objectId);
-  response.json({ code: 200, success: true, data: { token: session.token, user: rowToUser(row) } });
-}
-
 async function me(request, response) {
-  const session = await requireSession(request);
-  response.json({ code: 200, success: true, data: session.user || await publicUser(session.userId) });
-}
-
-async function changePassword(request, response) {
-  const session = await requireSession(request);
-  const oldPassword = String(pickParam(request, 'oldPassword') || '');
-  const newPassword = String(pickParam(request, 'newPassword') || '');
-  if (!newPassword) return response.json({ code: 400, success: false, error: '缺少新密码' });
-  const rows = await Psql.query(`SELECT * FROM "AppUser" WHERE "objectId"=$1 LIMIT 1`, [session.userId]);
-  const row = rows[0];
-  if (passwordHash(oldPassword, row.passwordSalt) !== row.passwordHash) {
-    return response.json({ code: 400, success: false, error: '原密码不正确' });
-  }
-  const salt = generateId(16);
-  await Psql.query(`UPDATE "AppUser" SET "passwordHash"=$1,"passwordSalt"=$2,"updatedAt"=NOW() WHERE "objectId"=$3`, [
-    passwordHash(newPassword, salt), salt, session.userId
-  ]);
-  response.json({ code: 200, success: true, data: true });
+  const { user } = await requireParseUser(request);
+  return response.json({ code: 200, success: true, data: parseUserToAppUser(user) });
 }
 
 async function balance(request, response) {
-  const session = await requireSession(request);
-  response.json({ code: 200, success: true, data: await getBalance(session.userId) });
+  const context = await loadCreditContext(request);
+  return response.json({ code: 200, success: true, data: toCreditBalance(context) });
 }
 
 async function ledger(request, response) {
-  const session = await requireSession(request);
-  const limit = Math.max(1, Math.min(200, Number(pickParam(request, 'limit') || 50)));
-  const rows = await Psql.query(
-    `SELECT * FROM "CreditLedger" WHERE "userId"=$1 ORDER BY "createdAt" DESC LIMIT ${limit}`,
-    [session.userId]
-  );
-  response.json({ code: 200, success: true, data: rows.map(rowToLedger) });
+  const context = await loadCreditContext(request);
+  const limit = clampNumber(pickParam(request, 'limit'), 1, 200, 50);
+  const items = [];
+  const lastBilling = context.auth && context.auth.lastBilling && typeof context.auth.lastBilling === 'object'
+    ? context.auth.lastBilling
+    : null;
+
+  if (lastBilling) {
+    items.push({
+      objectId: lastBilling.reservationId || lastBilling.idempotencyKey || `lastBilling-${context.auth.objectId}`,
+      userId: context.user.objectId,
+      type: lastBilling.status === 'refunded' ? 'refund' : 'consume',
+      amount: -Math.abs(Number(lastBilling.costCredits || 0)),
+      balanceAfter: Number(lastBilling.balanceAfter || context.balance),
+      title: lastBilling.title || lastBilling.operation || '生成扣费记录',
+      detail: lastBilling,
+      createdAt: lastBilling.updatedAt || context.auth.updatedAt || context.auth.createdAt || new Date().toISOString(),
+    });
+  }
+
+  return response.json({ code: 200, success: true, data: items.slice(0, limit) });
+}
+
+async function rechargeContext(request, response) {
+  const context = await loadCreditContext(request);
+  return response.json({
+    code: 200,
+    success: true,
+    data: {
+      authId: context.auth.objectId,
+      userId: context.user.objectId,
+      payUserId: readCompanyId(context.user) || context.auth.objectId,
+      apig: {
+        objectId: context.apig.objectId,
+        title: context.apig.title,
+        count: context.balance,
+        priceStep: normalizePriceSteps(context.apig.priceStep),
+      },
+    },
+  });
 }
 
 async function reserve(request, response) {
-  const session = await requireSession(request);
+  const context = await loadCreditContext(request);
   const operation = clean(pickParam(request, 'operation')) || 'unknown';
-  const cost = Number(pickParam(request, 'cost') || 0);
   const title = clean(pickParam(request, 'title')) || operation;
-  const detail = pickParam(request, 'detail') || {};
-  if (cost <= 0) return response.json({ code: 400, success: false, error: '积分消耗必须大于 0' });
+  const detail = normalizeObject(pickParam(request, 'detail'));
+  const cost = Math.max(0, Number(pickParam(request, 'cost') || 0));
+  const idempotencyKey = clean(pickParam(request, 'idempotencyKey'))
+    || `${operation}:${context.user.objectId}:${stableJson({ cost, title, detail })}`;
+
+  if (cost <= 0) {
+    return response.json({
+      code: 200,
+      success: true,
+      data: { reservationId: `free-${Date.now()}`, balance: context.balance, cost: 0 },
+    });
+  }
 
-  await Psql.query('BEGIN');
-  try {
-    const accounts = await Psql.query(`SELECT * FROM "UserCreditAccount" WHERE "userId"=$1 FOR UPDATE`, [session.userId]);
-    if (!accounts.length || Number(accounts[0].balance || 0) < cost) {
-      await Psql.query('ROLLBACK');
-      return response.json({ code: 402, success: false, error: '积分不足,请先充值' });
+  const lastBilling = context.auth.lastBilling && typeof context.auth.lastBilling === 'object'
+    ? context.auth.lastBilling
+    : null;
+  if (lastBilling && lastBilling.idempotencyKey === idempotencyKey) {
+    if (lastBilling.status === 'reserved' || lastBilling.status === 'submitted' || lastBilling.status === 'committed') {
+      return response.json({
+        code: 200,
+        success: true,
+        data: {
+          reservationId: lastBilling.reservationId,
+          balance: Number(lastBilling.balanceAfter || context.balance),
+          cost: Number(lastBilling.costCredits || cost),
+          reused: true,
+        },
+      });
     }
-    const newBalance = Number(accounts[0].balance || 0) - cost;
-    await Psql.query(
-      `UPDATE "UserCreditAccount" SET "balance"=$1,"totalConsumed"="totalConsumed"+$2,"updatedAt"=NOW() WHERE "userId"=$3`,
-      [newBalance, cost, session.userId]
-    );
-    const ledgerId = await insertLedger(session.userId, 'consume', -cost, newBalance, title, { operation, ...detail, status: 'reserved' });
-    const reservationId = generateId();
-    await Psql.query(
-      `INSERT INTO "CreditReservation" ("objectId","userId","operation","cost","ledgerId","detail") VALUES ($1,$2,$3,$4,$5,$6)`,
-      [reservationId, session.userId, operation, cost, ledgerId, JSON.stringify(detail || {})]
-    );
-    await Psql.query('COMMIT');
-    response.json({ code: 200, success: true, data: { reservationId, balance: newBalance, cost } });
-  } catch (e) {
-    await Psql.query('ROLLBACK');
-    throw e;
   }
+
+  if (context.balance < cost) {
+    return response.json({
+      code: 402,
+      success: false,
+      error: `余额不足:当前 ${context.balance},本次需要 ${cost}`,
+    });
+  }
+
+  const reservationId = generateId(16);
+  const oldUsed = Number(context.auth.used || 0);
+  const nextBalance = context.balance - cost;
+  const nextUsed = oldUsed + cost;
+  const now = new Date().toISOString();
+  const lastBillingPatch = {
+    kind: 'video_workflow_consume',
+    status: 'reserved',
+    reservationId,
+    idempotencyKey,
+    operation,
+    title,
+    costCredits: cost,
+    unitPriceCny: DEFAULT_UNIT_PRICE_CNY,
+    balanceBefore: context.balance,
+    balanceAfter: nextBalance,
+    usedBefore: oldUsed,
+    usedAfter: nextUsed,
+    detail,
+    updatedAt: now,
+  };
+
+  await updateApigAuth(context.auth.objectId, {
+    count: nextBalance,
+    used: nextUsed,
+    lastBilling: lastBillingPatch,
+  }, context.sessionToken);
+
+  return response.json({
+    code: 200,
+    success: true,
+    data: { reservationId, balance: nextBalance, cost },
+  });
 }
 
 async function commitReservation(request, response) {
-  const session = await requireSession(request);
+  const context = await loadCreditContext(request);
   const reservationId = clean(pickParam(request, 'reservationId'));
-  await Psql.query(
-    `UPDATE "CreditReservation" SET "status"='committed',"detail"=COALESCE("detail",'{}'::jsonb) || $1::jsonb,"updatedAt"=NOW()
-     WHERE "objectId"=$2 AND "userId"=$3 AND "status"='reserved'`,
-    [JSON.stringify(pickParam(request, 'detail') || {}), reservationId, session.userId]
-  );
-  response.json({ code: 200, success: true, data: true });
+  const detail = normalizeObject(pickParam(request, 'detail'));
+  const lastBilling = context.auth.lastBilling && typeof context.auth.lastBilling === 'object'
+    ? context.auth.lastBilling
+    : null;
+
+  if (!lastBilling || lastBilling.reservationId !== reservationId) {
+    return response.json({ code: 200, success: true, data: true, ignored: true });
+  }
+
+  await updateApigAuth(context.auth.objectId, {
+    lastBilling: {
+      ...lastBilling,
+      status: 'committed',
+      commitDetail: detail,
+      updatedAt: new Date().toISOString(),
+    },
+  }, context.sessionToken);
+
+  return response.json({ code: 200, success: true, data: true });
 }
 
 async function refundReservation(request, response) {
-  const session = await requireSession(request);
+  const context = await loadCreditContext(request);
   const reservationId = clean(pickParam(request, 'reservationId'));
   const reason = clean(pickParam(request, 'reason')) || '任务失败退回积分';
-  await refundByReservation(session.userId, reservationId, reason);
-  response.json({ code: 200, success: true, data: true });
+  const lastBilling = context.auth.lastBilling && typeof context.auth.lastBilling === 'object'
+    ? context.auth.lastBilling
+    : null;
+
+  if (!lastBilling || lastBilling.reservationId !== reservationId || lastBilling.status === 'refunded') {
+    return response.json({ code: 200, success: true, data: true, ignored: true });
+  }
+
+  const refundCredits = Math.abs(Number(lastBilling.costCredits || 0));
+  const currentUsed = Number(context.auth.used || 0);
+  const nextBalance = context.balance + refundCredits;
+  const nextUsed = Math.max(0, currentUsed - refundCredits);
+
+  await updateApigAuth(context.auth.objectId, {
+    count: nextBalance,
+    used: nextUsed,
+    lastBilling: {
+      ...lastBilling,
+      status: 'refunded',
+      refundReason: reason,
+      balanceAfter: nextBalance,
+      usedAfter: nextUsed,
+      updatedAt: new Date().toISOString(),
+    },
+  }, context.sessionToken);
+
+  return response.json({ code: 200, success: true, data: true });
 }
 
 async function createRechargeOrder(request, response) {
-  const session = await requireSession(request);
-  const amountCny = Number(pickParam(request, 'amountCny') || 0);
-  if (amountCny <= 0) return response.json({ code: 400, success: false, error: '充值金额必须大于 0' });
-  const order = {
-    objectId: generateId(),
-    amountCny,
-    creditAmount: Math.round(amountCny * CREDIT_PER_CNY),
-    status: 'pending',
-    createdAt: new Date().toISOString()
-  };
-  await Psql.query(
-    `INSERT INTO "RechargeOrder" ("objectId","userId","amountCny","creditAmount","status","detail") VALUES ($1,$2,$3,$4,'pending',$5)`,
-    [order.objectId, session.userId, order.amountCny, order.creditAmount, JSON.stringify({ creditPerCny: CREDIT_PER_CNY })]
-  );
-  response.json({ code: 200, success: true, data: order });
-}
-
-async function adminListUsers(request, response) {
-  const session = await requireSession(request);
-  await requireAdmin(session.userId);
-  const rows = await Psql.query(`
-    SELECT u."objectId",u."username",u."email",u."phone",u."displayName",u."role",u."status",u."createdAt",
-           COALESCE(c."balance",0) AS "creditBalance"
-    FROM "AppUser" u
-    LEFT JOIN "UserCreditAccount" c ON c."userId"=u."objectId"
-    ORDER BY u."createdAt" DESC
-    LIMIT 1000
-  `);
-  response.json({ code: 200, success: true, data: rows });
-}
-
-async function adminCreateUser(request, response) {
-  const session = await requireSession(request);
-  await requireAdmin(session.userId);
-
-  const username = clean(pickParam(request, 'username'));
-  const email = clean(pickParam(request, 'email'));
-  const phone = clean(pickParam(request, 'phone'));
-  const displayName = clean(pickParam(request, 'displayName')) || username;
-  const password = String(pickParam(request, 'password') || '');
-  const roleInput = clean(pickParam(request, 'role')) || 'user';
-  const role = roleInput === 'admin' ? 'admin' : 'user';
-  const initialCredits = Math.max(0, Number(pickParam(request, 'initialCredits') ?? REGISTER_GIFT_CREDITS));
-  if (!username || !password) return response.json({ code: 400, success: false, error: '缺少用户名或初始密码' });
-
-  const exists = await Psql.query(
-    `SELECT "objectId" FROM "AppUser" WHERE "username"=$1 OR ($2 <> '' AND "email"=$2) OR ($3 <> '' AND "phone"=$3) LIMIT 1`,
-    [username, email, phone]
-  );
-  if (exists.length) return response.json({ code: 409, success: false, error: '账号已存在' });
-
-  await Psql.query('BEGIN');
-  try {
-    const salt = generateId(16);
-    const userId = generateId();
-    await Psql.query(
-      `INSERT INTO "AppUser" ("objectId","username","email","phone","displayName","passwordHash","passwordSalt","role")
-       VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,$6,$7,$8)`,
-      [userId, username, email, phone, displayName, passwordHash(password, salt), salt, role]
-    );
-    await Psql.query(
-      `INSERT INTO "UserCreditAccount" ("userId","balance","gifted") VALUES ($1,$2,$2)`,
-      [userId, initialCredits]
-    );
-    if (initialCredits > 0) {
-      await insertLedger(userId, 'admin_adjust', initialCredits, initialCredits, '管理员发放初始积分', { adminUserId: session.userId });
-    }
-    await Psql.query('COMMIT');
-    response.json({ code: 200, success: true, data: await publicUser(userId) });
-  } catch (e) {
-    await Psql.query('ROLLBACK');
-    throw e;
+  const context = await loadCreditContext(request);
+  const count = Math.max(0, Number(pickParam(request, 'count') || 0));
+  const amountCny = Math.max(0, Number(pickParam(request, 'amountCny') || pickParam(request, 'price') || 0));
+  const params = normalizeObject(pickParam(request, 'params'));
+
+  if (count <= 0 || amountCny <= 0) {
+    return response.json({ code: 400, success: false, error: '缺少有效的充值积分或充值金额' });
+  }
+
+  const payUserId = readCompanyId(context.user) || context.auth.objectId;
+  const order = await requestJson(`${apiBase()}/api/apig/created-apigorder`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      user: payUserId,
+      fcompany: payUserId,
+      type: clean(pickParam(request, 'type')) || 'wxpay',
+      authid: context.auth.objectId,
+      params,
+      apigid: context.apig.objectId,
+      oldCount: context.balance,
+      count,
+      amountCny,
+    }),
+  }, 20000);
+
+  if (order && Number(order.code) === 200 && order.data) {
+    return response.json({ code: 200, success: true, data: order.data });
   }
+
+  return response.json({
+    code: 500,
+    success: false,
+    error: order && (order.message || order.error) || '创建 APIGOrder 失败',
+    raw: order,
+  });
 }
 
-async function adminAdjustCredit(request, response) {
-  const session = await requireSession(request);
-  await requireAdmin(session.userId);
-  const userId = clean(pickParam(request, 'userId'));
-  const amount = Number(pickParam(request, 'amount') || 0);
-  const note = clean(pickParam(request, 'note')) || '管理员调整';
-  if (!userId || !amount) return response.json({ code: 400, success: false, error: '缺少用户或调整积分' });
+async function saveRecharge(request, response) {
+  const context = await loadCreditContext(request);
+  const payUserId = clean(pickParam(request, 'payUserId')) || readCompanyId(context.user) || context.auth.objectId;
+  const authId = clean(pickParam(request, 'authId')) || context.auth.objectId;
+  const apigId = clean(pickParam(request, 'apigId')) || context.apig.objectId;
+  const oldCount = Math.max(0, Number(pickParam(request, 'oldCount') || context.balance || 0));
+  const count = Math.max(0, Number(pickParam(request, 'count') || 0));
+  const orderId = clean(pickParam(request, 'orderId'));
 
-  await Psql.query('BEGIN');
-  try {
-    await Psql.query(`INSERT INTO "UserCreditAccount" ("userId","balance") VALUES ($1,0) ON CONFLICT ("userId") DO NOTHING`, [userId]);
-    const rows = await Psql.query(`SELECT * FROM "UserCreditAccount" WHERE "userId"=$1 FOR UPDATE`, [userId]);
-    const next = Math.max(0, Number(rows[0].balance || 0) + amount);
-    await Psql.query(`UPDATE "UserCreditAccount" SET "balance"=$1,"updatedAt"=NOW() WHERE "userId"=$2`, [next, userId]);
-    await insertLedger(userId, 'admin_adjust', amount, next, note, { adminUserId: session.userId });
-    await Psql.query('COMMIT');
-    response.json({ code: 200, success: true, data: true });
-  } catch (e) {
-    await Psql.query('ROLLBACK');
-    throw e;
+  if (!authId || !apigId || count <= 0) {
+    return response.json({ code: 400, success: false, error: '缺少有效的充值到账参数' });
   }
-}
 
-async function refundByReservation(userId, reservationId, reason) {
-  await Psql.query('BEGIN');
-  try {
-    const rows = await Psql.query(
-      `SELECT * FROM "CreditReservation" WHERE "objectId"=$1 AND "userId"=$2 FOR UPDATE`,
-      [reservationId, userId]
-    );
-    if (!rows.length || rows[0].status !== 'reserved') {
-      await Psql.query('COMMIT');
-      return;
-    }
-    const cost = Number(rows[0].cost || 0);
-    const accounts = await Psql.query(`SELECT * FROM "UserCreditAccount" WHERE "userId"=$1 FOR UPDATE`, [userId]);
-    const next = Number(accounts[0].balance || 0) + cost;
-    await Psql.query(
-      `UPDATE "UserCreditAccount" SET "balance"=$1,"totalConsumed"=GREATEST(0,"totalConsumed"-$2),"updatedAt"=NOW() WHERE "userId"=$3`,
-      [next, cost, userId]
-    );
-    await Psql.query(`UPDATE "CreditReservation" SET "status"='refunded',"updatedAt"=NOW() WHERE "objectId"=$1`, [reservationId]);
-    await insertLedger(userId, 'refund', cost, next, reason, { reservationId });
-    await Psql.query('COMMIT');
-  } catch (e) {
-    await Psql.query('ROLLBACK');
-    throw e;
+  const saved = await requestJson(`${apiBase()}/api/apig/saveRecharge`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      user: payUserId,
+      authComp: payUserId,
+      authid: authId,
+      apigid: apigId,
+      oldCount,
+      count,
+      orderid: orderId,
+    }),
+  }, 20000);
+
+  if (saved && saved.code && Number(saved.code) >= 400) {
+    return response.json({
+      code: Number(saved.code),
+      success: false,
+      error: saved.message || saved.error || '充值到账失败',
+      raw: saved,
+    });
   }
-}
 
-async function requireLegacySession(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) throw new Error('登录已过期,请重新登录');
-  return rows[0];
+  return response.json({ code: 200, success: true, data: true, raw: saved });
 }
 
-async function requireAdmin(userId) {
-  const user = await publicUser(userId);
-  if (user.role !== 'admin') throw new Error('没有管理员权限');
+async function loadCreditContext(request) {
+  const { sessionToken, user } = await requireParseUser(request);
+  const auth = await ensureVideoWorkflowAuth(user.objectId, sessionToken);
+  const apigInfo = auth && auth.objectId ? await fetchApigPayInfo(auth.objectId).catch(() => null) : null;
+  const apig = normalizeApig(auth, apigInfo);
+  const balance = Number(apigInfo && apigInfo.count !== undefined ? apigInfo.count : auth.count || 0);
+  const used = Number(apigInfo && apigInfo.used !== undefined ? apigInfo.used : auth.used || 0);
+  return { sessionToken, user, auth: { ...auth, count: balance, used }, apig, balance, used };
 }
 
-async function createSession(userId) {
-  const token = generateId(32);
-  await Psql.query(
-    `INSERT INTO "AppSession" ("token","userId","expiresAt") VALUES ($1,$2,NOW()+INTERVAL '30 days')`,
-    [token, userId]
-  );
-  return { token };
+async function requireParseUser(request) {
+  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 parseRequest('GET', '/users/me?include=company', undefined, sessionToken);
+  if (!user || !user.objectId) {
+    const error = new Error('登录已失效,请重新登录');
+    error.status = 401;
+    throw error;
+  }
+
+  return { sessionToken, user };
 }
 
-async function legacyPublicUser(userId) {
-  const rows = await Psql.query(`SELECT * FROM "AppUser" WHERE "objectId"=$1 LIMIT 1`, [userId]);
-  if (!rows.length) throw new Error('用户不存在');
-  return rowToUser(rows[0]);
+async function findVideoWorkflowAuth(userId, sessionToken) {
+  const where = encodeURIComponent(JSON.stringify({
+    api: { __type: 'Pointer', className: 'APIG', objectId: VIDEO_WORKFLOW_APIG_ID },
+    user: { __type: 'Pointer', className: '_User', objectId: userId },
+  }));
+  const data = await parseRequest('GET', `/classes/APIGAuth?where=${where}&include=api&limit=1`, undefined, sessionToken);
+  return Array.isArray(data.results) ? data.results[0] || null : 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];
+async function ensureVideoWorkflowAuth(userId, sessionToken) {
+  const existing = await findVideoWorkflowAuth(userId, sessionToken);
+  if (existing) return existing;
 
-  const parseUser = await verifyParseSession(token);
-  if (!parseUser?.objectId) throw new Error('登录已过期,请重新登录');
-  await ensureCreditAccount(parseUser.objectId, REGISTER_GIFT_CREDITS);
-  return {
-    token,
-    userId: parseUser.objectId,
-    user: parseUserToPublicUser(parseUser),
-    source: 'parse',
+  const body = {
+    api: { __type: 'Pointer', className: 'APIG', objectId: VIDEO_WORKFLOW_APIG_ID },
+    user: { __type: 'Pointer', className: '_User', objectId: userId },
+    count: 0,
+    used: 0,
   };
+  const created = await parseRequest('POST', '/classes/APIGAuth', body, sessionToken);
+  return { ...body, ...created };
 }
 
-async function publicUser(userId) {
-  const rows = await Psql.query(`SELECT * FROM "AppUser" WHERE "objectId"=$1 LIMIT 1`, [userId]);
-  if (rows.length) return rowToUser(rows[0]);
-  return {
-    objectId: userId,
-    username: userId,
-    email: '',
-    phone: '',
-    displayName: userId,
-    role: ADMIN_IDENTIFIERS.includes(userId) ? 'admin' : 'user'
-  };
+async function updateApigAuth(authId, patch, sessionToken) {
+  return parseRequest('PUT', `/classes/APIGAuth/${encodeURIComponent(authId)}`, patch, sessionToken);
 }
 
-async function getBalance(userId) {
-  await Psql.query(`INSERT INTO "UserCreditAccount" ("userId","balance") VALUES ($1,0) ON CONFLICT ("userId") DO NOTHING`, [userId]);
-  const rows = await Psql.query(`SELECT * FROM "UserCreditAccount" WHERE "userId"=$1 LIMIT 1`, [userId]);
-  const row = rows[0] || {};
-  return {
-    balance: Number(row.balance || 0),
-    gifted: Number(row.gifted || 0),
-    totalRecharged: Number(row.totalRecharged || 0),
-    totalConsumed: Number(row.totalConsumed || 0)
+async function fetchApigPayInfo(authId) {
+  const resp = await requestJson(`${apiBase()}/api/apig/getApig`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ authid: authId }),
+  }, 15000);
+  if (resp && Number(resp.code) === 200 && resp.data) return resp.data;
+  return null;
+}
+
+async function parseRequest(method, path, body, sessionToken) {
+  const url = `${apiBase()}/parse${path}`;
+  const headers = {
+    'Content-Type': 'application/json',
+    'Accept': 'application/json',
+    'X-Parse-Application-Id': PARSE_APP_ID,
   };
+  if (sessionToken) headers['X-Parse-Session-Token'] = sessionToken;
+  const init = { method, headers };
+  if (body !== undefined) init.body = JSON.stringify(body);
+  const { status, ok, rawText, data } = await requestJsonWithMeta(url, init, 15000);
+  if (!ok) {
+    const error = new Error(readErrorMessage(data, rawText) || `Parse ${method} ${path} failed`);
+    error.status = status || 500;
+    error.detail = data || rawText || '';
+    throw error;
+  }
+  return data;
 }
 
-async function insertLedger(userId, type, amount, balanceAfter, title, detail) {
-  const objectId = generateId();
-  await Psql.query(
-    `INSERT INTO "CreditLedger" ("objectId","userId","type","amount","balanceAfter","title","detail") VALUES ($1,$2,$3,$4,$5,$6,$7)`,
-    [objectId, userId, type, amount, balanceAfter, title, JSON.stringify(detail || {})]
-  );
-  return objectId;
+async function requestJson(url, init, timeoutMs) {
+  const result = await requestJsonWithMeta(url, init, timeoutMs);
+  if (!result.ok) {
+    const error = new Error(readErrorMessage(result.data, result.rawText) || `HTTP ${result.status}`);
+    error.status = result.status || 500;
+    error.detail = result.data || result.rawText || '';
+    throw error;
+  }
+  return result.data;
 }
 
-async function verifyParseSession(sessionToken) {
-  if (typeof fetch !== 'function') return null;
-  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(() => ({}));
-  return resp.ok && data.objectId ? data : null;
-}
-
-async function ensureCreditAccount(userId, giftAmount) {
-  const amount = Math.max(0, Number(giftAmount || 0));
-  const rows = await Psql.query(`SELECT * FROM "UserCreditAccount" WHERE "userId"=$1 LIMIT 1`, [userId]);
-  if (rows.length) return;
-  await Psql.query(
-    `INSERT INTO "UserCreditAccount" ("userId","balance","gifted") VALUES ($1,$2,$2)`,
-    [userId, amount]
-  );
-  if (amount > 0) {
-    await insertLedger(userId, 'gift', amount, amount, '初始赠送积分', { source: 'parse_user_login' });
+async function requestJsonWithMeta(url, init, timeoutMs) {
+  const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
+  const timer = controller ? setTimeout(() => controller.abort(), timeoutMs || 15000) : null;
+  try {
+    const resp = await fetch(url, { ...init, signal: controller ? controller.signal : undefined });
+    const rawText = await resp.text();
+    const data = safeJson(rawText);
+    return { status: resp.status, ok: resp.ok, rawText, data };
+  } catch (error) {
+    if (error && error.name === 'AbortError') {
+      const timeoutError = new Error('请求超时,请稍后重试');
+      timeoutError.status = 504;
+      throw timeoutError;
+    }
+    throw error;
+  } finally {
+    if (timer) clearTimeout(timer);
   }
 }
 
-function parseUserToPublicUser(user) {
+function parseUserToAppUser(user) {
   const mobile = user.mobilePhoneNumber || user.mobile || '';
   return {
     objectId: user.objectId,
@@ -509,72 +441,139 @@ function parseUserToPublicUser(user) {
     email: user.email || '',
     phone: mobile,
     displayName: user.nickname || user.name || user.displayName || maskMobile(mobile) || user.username || user.objectId,
+    companyId: readCompanyId(user),
     role: user.role === 'admin' || user.isAdmin === true ? 'admin' : 'user',
-    createdAt: user.createdAt
+    createdAt: user.createdAt,
   };
 }
 
-function maskMobile(mobile) {
-  const value = String(mobile || '');
-  return /^1[3-9]\d{9}$/.test(value) ? value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : value;
-}
-
-function rowToUser(row) {
+function toCreditBalance(context) {
   return {
-    objectId: row.objectId,
-    username: row.username,
-    email: row.email || '',
-    phone: row.phone || '',
-    displayName: row.displayName || row.username,
-    role: row.role || 'user',
-    createdAt: row.createdAt
+    balance: Number(context.balance || 0),
+    gifted: 0,
+    totalRecharged: Number(context.balance || 0) + Number(context.used || 0),
+    totalConsumed: Number(context.used || 0),
+    authId: context.auth.objectId,
+    apigId: context.apig.objectId,
   };
 }
 
-function rowToLedger(row) {
+function normalizeApig(auth, apigInfo) {
+  const source = apigInfo || auth.api || {};
+  const objectId = source.objectId || source.api && source.api.objectId || auth.api && auth.api.objectId || VIDEO_WORKFLOW_APIG_ID;
   return {
-    objectId: row.objectId,
-    type: row.type,
-    amount: Number(row.amount || 0),
-    balanceAfter: Number(row.balanceAfter || 0),
-    title: row.title || '',
-    detail: typeof row.detail === 'string' ? JSON.parse(row.detail || '{}') : (row.detail || {}),
-    createdAt: row.createdAt
+    objectId,
+    title: source.title || source.api && source.api.title || auth.api && auth.api.title || DEFAULT_APIG_TITLE,
+    count: Number(source.count !== undefined ? source.count : auth.count || 0),
+    used: Number(source.used !== undefined ? source.used : auth.used || 0),
+    price: Number(source.price || auth.api && auth.api.price || DEFAULT_UNIT_PRICE_CNY),
+    priceStep: source.priceStep || source.api && source.api.priceStep || [],
   };
 }
 
-function passwordHash(password, salt) {
-  // fmode 云函数环境不保证可用 crypto;这里使用轻量确定性哈希。
-  // 后续若平台支持 crypto,应替换为 PBKDF2/bcrypt。
-  let h = 2166136261;
-  const input = `${salt}:${password}`;
-  for (let i = 0; i < input.length; i++) {
-    h ^= input.charCodeAt(i);
-    h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24);
-  }
-  return (h >>> 0).toString(16);
+function normalizePriceSteps(raw) {
+  const rows = Array.isArray(raw) ? raw : [];
+  return rows
+    .map((item) => {
+      const row = item && typeof item === 'object' ? item : {};
+      return { count: Number(row.count || 0), price: Number(row.price || 0) };
+    })
+    .filter((item) => item.count > 0 && item.price > 0);
+}
+
+function readCompanyId(user) {
+  const company = user && user.company;
+  return company && typeof company === 'object' ? clean(company.objectId) : '';
+}
+
+function unsupported(response, message) {
+  return response.json({ code: 501, success: false, error: message });
 }
 
 function pickParam(request, ...names) {
-  const sources = [request.params, request.body, request];
+  const sources = [request && request.params, request && request.body, request && 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 clean(v) {
-  return String(v || '').trim();
+function headerValue(request, name) {
+  const headers = request && request.headers || {};
+  const target = String(name || '').toLowerCase();
+  for (const key of Object.keys(headers)) {
+    if (key.toLowerCase() === target) return headers[key];
+  }
+  return '';
+}
+
+function env(name, fallback) {
+  if (typeof process !== 'undefined' && process.env && process.env[name] !== undefined) {
+    return process.env[name];
+  }
+  return fallback;
 }
 
-function generateId(len) {
+function apiBase() {
+  return String(PARSE_API_HOST || '').replace(/\/+$/, '').replace(/\/parse$/i, '');
+}
+
+function clean(value) {
+  return String(value || '').trim();
+}
+
+function clampNumber(value, min, max, fallback) {
+  const n = Number(value);
+  if (!Number.isFinite(n)) return fallback;
+  return Math.max(min, Math.min(max, Math.floor(n)));
+}
+
+function normalizeObject(value) {
+  if (!value) return {};
+  if (typeof value === 'object') return value;
+  if (typeof value === 'string') {
+    const parsed = safeJson(value);
+    return parsed && typeof parsed === 'object' ? parsed : { value };
+  }
+  return { value };
+}
+
+function stableJson(value) {
+  if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
+  if (value && typeof value === 'object') {
+    return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
+  }
+  return JSON.stringify(value);
+}
+
+function safeJson(text) {
+  try {
+    return text ? JSON.parse(text) : null;
+  } catch {
+    return null;
+  }
+}
+
+function readErrorMessage(data, fallback) {
+  if (typeof data === 'string') return data;
+  if (!data || typeof data !== 'object') return fallback || '';
+  return data.error || data.message || data.msg || data.data && (data.data.error || data.data.message) || fallback || '';
+}
+
+function maskMobile(mobile) {
+  const value = String(mobile || '');
+  return /^1[3-9]\d{9}$/.test(value) ? value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : value;
+}
+
+function generateId(length) {
   const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
-  let s = '';
-  const n = len || 10;
-  for (let i = 0; i < n; i++) s += chars.charAt(Math.floor(Math.random() * chars.length));
-  return s;
+  let id = '';
+  for (let i = 0; i < (length || 10); i += 1) {
+    id += chars.charAt(Math.floor(Math.random() * chars.length));
+  }
+  return id;
 }

+ 44 - 8
cloud-functions/11-jimengManager.js

@@ -5,7 +5,7 @@
  * function with { action: 'call', endpoint, payload }, and the function injects
  * the server-side token before forwarding to JIMENG_BASE_URL.
  *
- * actions:
+ * 支持 action:
  *   call          -> proxy a whitelisted Jimeng endpoint
  *   getWorkResult -> read Parse ImagineWork by workId/objectId
  *   diagnose      -> non-secret deployment diagnostics
@@ -39,7 +39,7 @@ const PARSE_API_HOST = normalizeParseApiHost(readEnv('PARSE_API_HOST') || readEn
 const PARSE_APP_ID = readEnv('PARSE_APP_ID') || readEnv('PARSE_APPLICATION_ID') || readEnv('X_PARSE_APPLICATION_ID') || 'ncloudmaster';
 const JIMENG_MAX_ATTEMPTS = Math.max(1, Number(readEnv('JIMENG_MAX_ATTEMPTS') || readEnv('JIMENG_LOCAL_MAX_ATTEMPTS') || 3));
 const JIMENG_TOKEN_FALLBACK = 'Bearer r:f0333969e312a40e4703e8fe4ed1c600';
-const VIDEO_WORKFLOW_APIG_ID = '6pF6EAdKT';
+const VIDEO_WORKFLOW_APIG_ID = '6pFf6EAdKT';
 const DEFAULT_APIG_UNIT_PRICE_CNY = 0.1;
 
 const JIMENG_PRICE_CNY = {
@@ -222,10 +222,18 @@ async function requestJimengWithBilling({ endpoint, payload, sessionToken, idemp
 
   const user = await verifyBillingUser(sessionToken);
   const auth = await ensureVideoWorkflowAuth(user.objectId, sessionToken);
-  const unitPriceCny = Number(auth && auth.api && auth.api.price ? auth.api.price : DEFAULT_APIG_UNIT_PRICE_CNY);
+  const apigPayInfo = auth && auth.objectId ? await fetchApigPayInfo(auth.objectId).catch(() => null) : null;
+  const billingAuth = mergeApigPayBalance(auth, apigPayInfo);
+  const unitPriceCny = Number(
+    billingAuth && billingAuth.api && billingAuth.api.price
+      ? billingAuth.api.price
+      : apigPayInfo && apigPayInfo.price
+        ? apigPayInfo.price
+        : DEFAULT_APIG_UNIT_PRICE_CNY
+  );
   const billing = estimateJimengBilling(endpoint, payload, unitPriceCny);
   const stableKey = idempotencyKey || `jimeng:${endpoint}:${user.objectId}:${stableJson(sanitizeBillingSnapshot(payload))}`;
-  const lastBilling = auth && auth.lastBilling && typeof auth.lastBilling === 'object' ? auth.lastBilling : null;
+  const lastBilling = billingAuth && billingAuth.lastBilling && typeof billingAuth.lastBilling === 'object' ? billingAuth.lastBilling : null;
 
   if (lastBilling && lastBilling.idempotencyKey === stableKey) {
     if (lastBilling.workId) {
@@ -249,7 +257,7 @@ async function requestJimengWithBilling({ endpoint, payload, sessionToken, idemp
 
   const reservation = await reserveJimengCredits({
     user,
-    auth,
+    auth: billingAuth,
     billing,
     endpoint,
     payload,
@@ -263,7 +271,7 @@ async function requestJimengWithBilling({ endpoint, payload, sessionToken, idemp
     if (!workId) {
       await refundJimengCredits({
         user,
-        auth,
+        auth: billingAuth,
         reservation,
         reason: 'Jimeng submit returned no workId',
         sessionToken,
@@ -278,7 +286,7 @@ async function requestJimengWithBilling({ endpoint, payload, sessionToken, idemp
       responseSnapshot: sanitizeBillingSnapshot(data),
       updatedAt: new Date().toISOString(),
     };
-    await updateApigAuthBilling(auth, { lastBilling: submittedBilling }, sessionToken);
+    await updateApigAuthBilling(billingAuth, { lastBilling: submittedBilling }, sessionToken);
     return {
       ...data,
       billing: {
@@ -291,7 +299,7 @@ async function requestJimengWithBilling({ endpoint, payload, sessionToken, idemp
   } catch (error) {
     await refundJimengCredits({
       user,
-      auth,
+      auth: billingAuth,
       reservation,
       reason: error && error.message ? error.message : 'Jimeng submit failed before workId',
       sessionToken,
@@ -362,6 +370,34 @@ async function ensureVideoWorkflowAuth(userId, sessionToken) {
   return { ...body, ...created };
 }
 
+async function fetchApigPayInfo(authId) {
+  const base = String(PARSE_API_HOST || '').replace(/\/+$/, '').replace(/\/parse$/i, '');
+  const { ok, data } = await requestJson('POST', `${base}/api/apig/getApig`, {
+    headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
+    body: JSON.stringify({ authid: authId }),
+  });
+  if (!ok || !data || Number(data.code) !== 200 || !data.data) return null;
+  return data.data;
+}
+
+function mergeApigPayBalance(auth, apigPayInfo) {
+  if (!apigPayInfo || typeof apigPayInfo !== 'object') return auth;
+  const count = apigPayInfo.count !== undefined ? Number(apigPayInfo.count) : Number(auth && auth.count || 0);
+  const used = apigPayInfo.used !== undefined ? Number(apigPayInfo.used) : Number(auth && auth.used || 0);
+  const api = {
+    ...(auth && auth.api && typeof auth.api === 'object' ? auth.api : {}),
+    objectId: apigPayInfo.objectId || apigPayInfo.api && apigPayInfo.api.objectId || auth && auth.api && auth.api.objectId || VIDEO_WORKFLOW_APIG_ID,
+    title: apigPayInfo.title || apigPayInfo.api && apigPayInfo.api.title || auth && auth.api && auth.api.title,
+    price: apigPayInfo.price || apigPayInfo.api && apigPayInfo.api.price || auth && auth.api && auth.api.price,
+  };
+  return {
+    ...(auth || {}),
+    count: Number.isFinite(count) ? count : Number(auth && auth.count || 0),
+    used: Number.isFinite(used) ? used : Number(auth && auth.used || 0),
+    api,
+  };
+}
+
 async function updateApigAuthBilling(auth, patch, sessionToken) {
   return parseRequest('PUT', `/classes/APIGAuth/${encodeURIComponent(auth.objectId)}`, patch, sessionToken);
 }

+ 86 - 35
cloud-functions/auth-credit-deploy.md

@@ -1,52 +1,103 @@
-# 账号积分云函数部署说明
+# authCredit 云函数部署说明
 
-## 文件
+## 当前定位
 
-上传 `cloud-functions/10-authCreditManager.js` 到 fmode 云函数平台
+`cloud-functions/10-authCreditManager.js` 现在是 **Parse/APIG 账号积分网关**,不是自建账号系统
 
-## 前端配置
+它不再创建这些旧表:
 
-部署成功后会得到云函数 objectId,把它填入:
+- `AppUser`
+- `AppSession`
+- `UserCreditAccount`
+- `CreditLedger`
+- `CreditReservation`
+- `RechargeOrder`
+
+当前项目真实数据来源是:
+
+- 登录用户:Parse `_User`
+- 登录态:`X-Parse-Session-Token`
+- 视频工作流余额:`APIGAuth.count`
+- 已用额度:`APIGAuth.used`
+- 最近扣费状态:`APIGAuth.lastBilling`
+- 充值订单:现有 `/api/apig/created-apigorder` 和 `/api/apig/saveRecharge`
+
+## 为什么重写
+
+旧版 `10-authCreditManager.js` 从创建开始一直未部署,且已经和当前项目脱节。
+
+如果直接部署旧版,会出现两套账:
+
+1. 用户中心和充值页读取 `APIGAuth` 余额。
+2. 旧云函数扣 `UserCreditAccount` 余额。
+
+这样会导致用户明明在 APIG 里有余额,但生成链路却查到另一套余额为 0。
+
+## 部署步骤
+
+1. 打开 fmode 云函数管理平台。
+2. 新建或更新 `10-authCreditManager.js` 对应云函数。
+3. 粘贴 `cloud-functions/10-authCreditManager.js` 的完整内容。
+4. 配置环境变量。
+5. 保存部署,复制云函数 objectId。
+6. 填入 `src/app/services/cloud-functions.ts`:
 
 ```ts
-// src/app/services/cloud-functions.ts
-authCredit: '你的云函数ID'
+authCredit: '你的云函数 objectId'
 ```
 
-未填写时,前端会进入本地测试模式:账号、积分、流水保存在浏览器 localStorage,只用于看界面和流程。
+7. 执行前端构建和云函数 smoke 检查。
+
+## 环境变量
+
+| 变量 | 必填 | 默认值 | 说明 |
+| --- | --- | --- | --- |
+| `PARSE_API_HOST` | 否 | `https://server.fmode.cn` | Parse/API 主机 |
+| `PARSE_APP_ID` | 否 | `ncloudmaster` | Parse Application ID |
+| `VIDEO_WORKFLOW_APIG_ID` | 否 | `6pFf6EAdKT` | 当前短视频 AI 工作流 APIG ID |
+| `VIDEO_WORKFLOW_APIG_TITLE` | 否 | `短视频AI工作流` | 余额上下文展示标题 |
+| `VIDEO_WORKFLOW_APIG_UNIT_PRICE_CNY` | 否 | `0.1` | 估算单积分价格 |
+
+## 支持 action
+
+| action | 说明 |
+| --- | --- |
+| `me` | 根据 `sessionToken` 读取 Parse 当前用户 |
+| `balance` | 读取或创建当前用户的视频工作流 `APIGAuth`,返回 `count/used` |
+| `ledger` | 返回最近一次 `APIGAuth.lastBilling` 形成的轻量流水 |
+| `rechargeContext` | 返回充值需要的 `authId/userId/payUserId/apig/priceStep` |
+| `reserve` | 在 `APIGAuth.count` 上预扣积分,并写入 `lastBilling` |
+| `commitReservation` | 把最近一次预扣标记为已确认 |
+| `refundReservation` | 把最近一次预扣退回 |
+| `createRechargeOrder` | 代理创建 APIGOrder |
+| `saveRecharge` | 代理保存充值结果,并写回 APIGAuth 余额 |
+
+## 不再支持的 action
 
-## 试运营规则
+这些 action 会返回 `501`:
 
-- 注册赠送 10 积分。
-- 1 元 = 10 积分。
-- 第一位注册用户自动成为管理员。
-- `ADMIN_IDENTIFIERS` 中配置的用户名 / 邮箱 / 手机号也会成为管理员。
-- 前台自助注册已关闭,用户账号由管理员在「账号明细」页发放。
-- 管理员发放账号时可设置用户名、初始密码、邮箱、手机号、角色和初始积分。
-- 充值当前只创建待支付订单,真实微信支付后续再接。
+- `register`
+- `login`
+- `changePassword`
+- `adminListUsers`
+- `adminCreateUser`
+- `adminAdjustCredit`
 
-## 已接入后端预扣的生成链路
+原因:当前项目使用 Parse 手机验证码登录和 APIG 余额体系,不再由该云函数自建账号、密码、积分表。
 
-- 图片生成:3 积分 / 张。
-- 图生视频:
-  - 720p:按 0.28 元/秒换算积分。
-  - 1080p:按 0.63 元/秒换算积分。
-  - Pro:按 1 元/秒换算积分。
-- 标准视频生成 / AI 重塑:同视频规则。
-- 动作迁移:暂按 20 积分 / 次。
+## 前端注意
 
-## 已接入登录拦截但尚未完整后端扣费的入口
+当前 `AuthCreditService` 已经支持云函数优先路径。
 
-- 声音训练。
-- 语音合成。
-- 数字人合成。
-- 标准视频语音配音。
+配置 `src/app/services/cloud-functions.ts` 里的 `authCredit` 后,以下能力会优先走本云函数;未配置时保留本地开发兜底:
 
-## 已接入积分预扣但尚未迁到云函数代理的链路
+- `getVideoWorkflowApigBalance`
+- `getVideoWorkflowRechargeContext`
+- `createApigOrder`
+- `saveApigRecharge`
 
-- 抖音视频发现:1 积分 / 次。
-- 抖音视频详情:1 积分 / 次。
-- 博主资料:1 积分 / 次。
-- 博主作品:1 积分 / 次。
+这样才能彻底做到:
 
-这些链路已经会在后端积分表预扣、成功确认、失败退款;但上游 API 仍由前端发起。下一步需要迁到云函数代理中,才能做到和即梦链路同等级的接口隐藏与强校验。
+- 前端不直接读写 `APIGAuth`
+- 前端不直接创建 `APIGOrder`
+- 余额查询、充值、扣费都由服务端统一校验 `sessionToken`

+ 296 - 0
src/app/pages/account/user-center.component.css

@@ -268,6 +268,269 @@
   margin-bottom: 12px;
 }
 
+.uc-kicker {
+  display: block;
+  margin-bottom: 4px;
+  color: #2563eb;
+  font-size: 12px;
+  font-weight: 800;
+}
+
+.uc-storage-health {
+  margin-bottom: 16px;
+}
+
+.uc-storage-actions {
+  display: flex;
+  gap: 10px;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+}
+
+.uc-storage-actions button:disabled {
+  opacity: .58;
+  cursor: not-allowed;
+}
+
+.uc-danger-lite {
+  border-color: #fecaca !important;
+  color: #b91c1c !important;
+}
+
+.uc-storage-summary {
+  display: grid;
+  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+  gap: 12px;
+  margin: 14px 0;
+}
+
+.uc-storage-summary > div {
+  min-width: 0;
+  border: 1px solid #e2e8f0;
+  border-radius: 8px;
+  padding: 14px;
+  background: #f8fafc;
+}
+
+.uc-storage-summary span,
+.uc-storage-summary small {
+  display: block;
+  color: #64748b;
+  font-size: 12px;
+}
+
+.uc-storage-summary strong {
+  display: block;
+  margin: 6px 0 4px;
+  color: #0f172a;
+  font-size: 22px;
+}
+
+.uc-storage-message,
+.uc-storage-error,
+.uc-storage-empty {
+  border-radius: 8px;
+  padding: 10px 12px;
+  font-size: 13px;
+  font-weight: 700;
+}
+
+.uc-storage-message {
+  margin-bottom: 12px;
+  color: #065f46;
+  background: #ecfdf5;
+  border: 1px solid #a7f3d0;
+}
+
+.uc-storage-error {
+  margin-bottom: 12px;
+  color: #b91c1c;
+  background: #fef2f2;
+  border: 1px solid #fecaca;
+}
+
+.uc-storage-empty {
+  margin: 0;
+  color: #64748b;
+  background: #f8fafc;
+}
+
+.uc-legacy-list {
+  display: grid;
+  gap: 8px;
+}
+
+.uc-cloud-entity-list {
+  display: grid;
+  gap: 8px;
+  margin-bottom: 14px;
+}
+
+.uc-audit-list {
+  display: grid;
+  gap: 8px;
+  margin-bottom: 14px;
+}
+
+.uc-admin-inspection {
+  display: grid;
+  gap: 8px;
+  margin-bottom: 14px;
+}
+
+.uc-subsection-title {
+  display: flex;
+  align-items: baseline;
+  justify-content: space-between;
+  gap: 12px;
+  color: #0f172a;
+}
+
+.uc-subsection-title span {
+  color: #64748b;
+  font-size: 12px;
+}
+
+.uc-cloud-entity-row {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto auto;
+  gap: 12px;
+  align-items: center;
+  border: 1px solid #dbeafe;
+  border-radius: 8px;
+  padding: 10px 12px;
+  background: #f8fbff;
+}
+
+.uc-cloud-entity-row strong {
+  min-width: 0;
+  overflow-wrap: anywhere;
+  color: #0f172a;
+  font-size: 13px;
+}
+
+.uc-cloud-entity-row span,
+.uc-cloud-entity-row small {
+  color: #475569;
+  font-size: 12px;
+  font-weight: 800;
+}
+
+.uc-audit-row {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) minmax(220px, auto);
+  gap: 12px;
+  align-items: center;
+  border: 1px solid #e0f2fe;
+  border-radius: 8px;
+  padding: 10px 12px;
+  background: #f8fafc;
+}
+
+.uc-audit-row div,
+.uc-audit-row strong,
+.uc-audit-row span,
+.uc-audit-row small {
+  min-width: 0;
+}
+
+.uc-audit-row strong,
+.uc-audit-row span {
+  display: block;
+  overflow-wrap: anywhere;
+}
+
+.uc-audit-row strong {
+  color: #0f172a;
+  font-size: 13px;
+}
+
+.uc-audit-row span,
+.uc-audit-row small {
+  color: #64748b;
+  font-size: 12px;
+}
+
+.uc-audit-row small {
+  text-align: right;
+  overflow-wrap: anywhere;
+}
+
+.uc-admin-row {
+  display: grid;
+  grid-template-columns: minmax(160px, 1fr) auto minmax(220px, auto);
+  gap: 12px;
+  align-items: center;
+  border: 1px solid #fde68a;
+  border-radius: 8px;
+  padding: 10px 12px;
+  background: #fffbeb;
+}
+
+.uc-admin-row strong,
+.uc-admin-row span,
+.uc-admin-row small {
+  min-width: 0;
+  overflow-wrap: anywhere;
+}
+
+.uc-admin-row strong {
+  color: #0f172a;
+  font-size: 13px;
+}
+
+.uc-admin-row span,
+.uc-admin-row small {
+  color: #64748b;
+  font-size: 12px;
+  font-weight: 700;
+}
+
+.uc-admin-row small {
+  text-align: right;
+}
+
+.uc-legacy-row {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) auto;
+  gap: 12px;
+  align-items: center;
+  border: 1px solid #e2e8f0;
+  border-radius: 8px;
+  padding: 11px 12px;
+  background: #fff;
+}
+
+.uc-legacy-row.can-clean {
+  border-color: #a7f3d0;
+  background: #f0fdf4;
+}
+
+.uc-legacy-row strong,
+.uc-legacy-row span {
+  display: block;
+  min-width: 0;
+  overflow-wrap: anywhere;
+}
+
+.uc-legacy-row strong {
+  color: #0f172a;
+  font-size: 13px;
+}
+
+.uc-legacy-row span {
+  margin-top: 4px;
+  color: #64748b;
+  font-size: 12px;
+}
+
+.uc-legacy-row em {
+  font-style: normal;
+  color: #475569;
+  font-size: 12px;
+  font-weight: 800;
+}
+
 .uc-message,
 .uc-error {
   margin-bottom: 16px;
@@ -302,12 +565,45 @@
   color: #94a3b8;
 }
 
+.theme-night .uc-storage-summary > div,
+.theme-night .uc-legacy-row,
+.theme-night .uc-storage-empty {
+  background: rgba(15, 23, 42, .72);
+  border-color: rgba(148, 163, 184, .24);
+}
+
+.theme-night .uc-storage-summary strong,
+.theme-night .uc-legacy-row strong,
+.theme-night .uc-subsection-title,
+.theme-night .uc-cloud-entity-row strong,
+.theme-night .uc-audit-row strong,
+.theme-night .uc-admin-row strong {
+  color: #e5e7eb;
+}
+
+.theme-night .uc-cloud-entity-row,
+.theme-night .uc-audit-row,
+.theme-night .uc-admin-row {
+  background: rgba(30, 41, 59, .72);
+  border-color: rgba(96, 165, 250, .26);
+}
+
 @media (max-width: 1100px) {
   .uc-grid { grid-template-columns: 1fr; }
+  .uc-storage-summary { grid-template-columns: 1fr; }
 }
 
 @media (max-width: 720px) {
   .account-dashboard { padding: 18px; }
   .uc-header { align-items: flex-start; flex-direction: column; }
   .uc-payment-content { grid-template-columns: 1fr; }
+  .uc-section-title { align-items: flex-start; flex-direction: column; }
+  .uc-storage-actions { width: 100%; justify-content: stretch; }
+  .uc-storage-actions button { flex: 1 1 150px; }
+  .uc-legacy-row { grid-template-columns: 1fr; }
+  .uc-cloud-entity-row { grid-template-columns: 1fr; }
+  .uc-audit-row { grid-template-columns: 1fr; }
+  .uc-admin-row { grid-template-columns: 1fr; }
+  .uc-audit-row small { text-align: left; }
+  .uc-admin-row small { text-align: left; }
 }

+ 108 - 0
src/app/pages/account/user-center.component.html

@@ -38,6 +38,114 @@
     </article>
   </div>
 
+  <article class="uc-card uc-storage-health" *ngIf="showStorageDiagnostics">
+    <div class="uc-section-title">
+      <div>
+        <span class="uc-kicker">存储治理</span>
+        <h2>浏览器存储健康</h2>
+      </div>
+      <div class="uc-storage-actions">
+        <button type="button" (click)="refreshStorageHealth()" [disabled]="storageHealthLoading || storageCleanupLoading">
+          {{ storageHealthLoading ? '刷新中...' : '刷新状态' }}
+        </button>
+        <button type="button"
+                class="uc-danger-lite"
+                (click)="clearMigratedLegacyStorage()"
+                [disabled]="storageCleanupLoading || cleanableLegacyKeys === 0">
+          {{ storageCleanupLoading ? '清理中...' : '清理已迁移旧缓存' }}
+        </button>
+      </div>
+    </div>
+
+    <div class="uc-storage-summary">
+      <div>
+        <span>localStorage</span>
+        <strong>{{ formatBytes(storageUsage?.localStorageBytes) }}</strong>
+        <small>{{ storageUsage?.localStorageKeys ?? 0 }} 个键</small>
+      </div>
+      <div>
+        <span>sessionStorage</span>
+        <strong>{{ formatBytes(storageUsage?.sessionStorageBytes) }}</strong>
+        <small>{{ storageUsage?.sessionStorageKeys ?? 0 }} 个键</small>
+      </div>
+      <div>
+        <span>旧业务缓存</span>
+        <strong>{{ formatBytes(legacyLocalBytes) }}</strong>
+        <small>{{ cleanableLegacyKeys }} 个可安全清理</small>
+      </div>
+      <div>
+        <span>云端实体</span>
+        <strong>{{ cloudStorageStats?.totalEntities ?? 0 }}</strong>
+        <small>审计 {{ cloudStorageStats?.auditCount ?? 0 }} / 迁移 {{ cloudStorageStats?.migrations?.total ?? 0 }}</small>
+      </div>
+      <div>
+        <span>七牛文件</span>
+        <strong>{{ fileAssetStats?.total ?? 0 }}</strong>
+        <small>{{ formatBytes(fileAssetStats?.totalBytes) }}</small>
+      </div>
+    </div>
+
+    <div class="uc-storage-message" *ngIf="storageMessage">{{ storageMessage }}</div>
+    <div class="uc-storage-error" *ngIf="storageError">{{ storageError }}</div>
+    <div class="uc-storage-error" *ngIf="cloudStorageError">{{ cloudStorageError }}</div>
+    <div class="uc-storage-error" *ngIf="fileAssetError">{{ fileAssetError }}</div>
+
+    <div class="uc-cloud-entity-list" *ngIf="cloudEntityRows.length">
+      <div class="uc-subsection-title">
+        <strong>云端业务实体</strong>
+        <span>按当前账号隔离统计</span>
+      </div>
+      <div class="uc-cloud-entity-row" *ngFor="let item of cloudEntityRows">
+        <strong>{{ item.entityType }}</strong>
+        <span>共 {{ item.total }} 条</span>
+        <small>活跃 {{ item.active }} / 归档 {{ item.archived }} / 删除 {{ item.deleted }}</small>
+      </div>
+    </div>
+
+    <div class="uc-audit-list" *ngIf="recentAuditRows.length">
+      <div class="uc-subsection-title">
+        <strong>最近审计记录</strong>
+        <span>只展示摘要,详细载荷保存在云端审计表</span>
+      </div>
+      <div class="uc-audit-row" *ngFor="let item of recentAuditRows">
+        <div>
+          <strong>{{ item.action }}</strong>
+          <span>{{ item.summary || '无摘要' }}</span>
+        </div>
+        <small>{{ item.entityType || '系统' }} / {{ item.entityId || '未关联实体' }} / {{ item.createdAt || '未知时间' }}</small>
+      </div>
+    </div>
+
+    <div class="uc-admin-inspection" *ngIf="auth.isAdmin">
+      <div class="uc-subsection-title">
+        <strong>管理员存储巡检</strong>
+        <span>{{ adminInspectionLoading ? '加载中...' : '按用户聚合实体、文件和迁移异常' }}</span>
+      </div>
+      <div class="uc-storage-error" *ngIf="adminInspectionError">{{ adminInspectionError }}</div>
+      <div class="uc-admin-row" *ngFor="let item of adminInspectionRows">
+        <strong>{{ item.userId }}</strong>
+        <span>实体 {{ item.totalEntities }} / 文件 {{ item.totalFiles }} / {{ formatBytes(item.totalFileBytes) }}</span>
+        <small>审计 {{ item.auditCount }} / 迁移失败 {{ item.migrationFailedRows }} / {{ item.latestActivityAt || '暂无活动' }}</small>
+      </div>
+      <p class="uc-storage-empty" *ngIf="!adminInspectionLoading && !adminInspectionError && !adminInspectionRows.length">
+        暂无可巡检的云端数据。
+      </p>
+    </div>
+
+    <div class="uc-legacy-list" *ngIf="legacyStorageHealth.length; else noLegacyStorage">
+      <div class="uc-legacy-row" *ngFor="let item of legacyStorageHealth" [class.can-clean]="item.canClean">
+        <div>
+          <strong>{{ item.sourceKey }}</strong>
+          <span>{{ item.entityType }} / {{ item.localRowCount }} 条 / {{ formatBytes(item.localBytes) }}</span>
+        </div>
+        <em>{{ item.migrationStatus === 'completed' ? '已迁移' : (item.migrationStatus === 'missing' ? '未迁移' : item.migrationStatus) }}</em>
+      </div>
+    </div>
+    <ng-template #noLegacyStorage>
+      <p class="uc-storage-empty">暂无需要检查的旧业务缓存。</p>
+    </ng-template>
+  </article>
+
   <div class="uc-message" *ngIf="message">{{ message }}</div>
   <div class="uc-error" *ngIf="error">{{ error }}</div>
 

+ 206 - 0
src/app/pages/account/user-center.component.spec.ts

@@ -0,0 +1,206 @@
+import '@angular/compiler';
+import { ChangeDetectorRef } from '@angular/core';
+import { AuthCreditService } from '../../services/auth-credit.service';
+import { CloudSessionStorageService } from '../../services/cloud-session-storage.service';
+import { FileAssetService } from '../../services/file-asset.service';
+import { StorageGovernanceService } from '../../services/storage-governance.service';
+import { SystemStorageMigrationService } from '../../services/system-storage-migration.service';
+import { UserCenterComponent } from './user-center.component';
+
+describe('UserCenterComponent storage health panel', () => {
+  let component: UserCenterComponent;
+  let storageGovernance: { estimateUsage: ReturnType<typeof vi.fn> };
+  let storageMigration: {
+    inspectLegacyLocalStorage: ReturnType<typeof vi.fn>;
+    clearCompletedLegacyLocalStorage: ReturnType<typeof vi.fn>;
+  };
+  let auth: { isLoggedIn: boolean; isAdmin: boolean; currentUser: any; logout: ReturnType<typeof vi.fn> };
+  let cloudStorage: { stats: ReturnType<typeof vi.fn>; recentAudits: ReturnType<typeof vi.fn>; adminInspect: ReturnType<typeof vi.fn> };
+  let fileAsset: { stats: ReturnType<typeof vi.fn>; adminInspect: ReturnType<typeof vi.fn> };
+
+  beforeEach(() => {
+    storageGovernance = {
+      estimateUsage: vi.fn().mockReturnValue({
+        localStorageBytes: 2048,
+        sessionStorageBytes: 512,
+        localStorageKeys: 4,
+        sessionStorageKeys: 2,
+      }),
+    };
+    storageMigration = {
+      inspectLegacyLocalStorage: vi.fn().mockResolvedValue([
+        {
+          sourceKey: 'videoWorkflow.topicPool.items',
+          entityType: 'topic',
+          localExists: true,
+          localBytes: 1024,
+          localRowCount: 2,
+          migrationStatus: 'completed',
+          migratedCount: 2,
+          failedCount: 0,
+          canClean: true,
+        },
+        {
+          sourceKey: 'videoWorkflow.viralAnalyses.items',
+          entityType: 'viralAnalysis',
+          localExists: true,
+          localBytes: 4096,
+          localRowCount: 1,
+          migrationStatus: 'failed',
+          migratedCount: 0,
+          failedCount: 1,
+          canClean: false,
+        },
+      ]),
+      clearCompletedLegacyLocalStorage: vi.fn().mockResolvedValue({
+        checkedCount: 2,
+        removedCount: 1,
+        removedBytes: 1024,
+        skippedCount: 1,
+        removedKeys: ['videoWorkflow.topicPool.items'],
+        skippedKeys: ['videoWorkflow.viralAnalyses.items'],
+      }),
+    };
+    cloudStorage = {
+      stats: vi.fn().mockResolvedValue({
+        totalEntities: 5,
+        entityTypes: {
+          topic: { total: 2, byStatus: { active: 2 } },
+          'ipOperator.plan': { total: 3, byStatus: { active: 2, archived: 1 } },
+        },
+        auditCount: 7,
+        migrations: { total: 2, byStatus: { completed: 1, failed: 1 } },
+        generatedAt: '2026-06-18T09:20:00.000Z',
+      }),
+      recentAudits: vi.fn().mockResolvedValue(
+        Array.from({ length: 10 }, (_, index) => ({
+          id: `audit-${index + 1}`,
+          entityType: index % 2 ? 'topic' : 'ipOperator.plan',
+          entityId: `entity-${index + 1}`,
+          action: index % 2 ? 'save_topic' : 'save_plan',
+          summary: `审计摘要 ${index + 1}`,
+          createdAt: `2026-06-18T09:${String(30 + index).padStart(2, '0')}:00.000Z`,
+        })),
+      ),
+      adminInspect: vi.fn().mockResolvedValue({
+        adminUserId: 'admin-1',
+        users: [
+          {
+            userId: 'user-1',
+            totalEntities: 12,
+            entityTypes: {},
+            auditCount: 8,
+            migrations: { total: 2, failedRows: 1, byStatus: { completed: 1, failed: 1 } },
+            latestUpdatedAt: '2026-06-18T09:40:00.000Z',
+          },
+        ],
+        generatedAt: '2026-06-18T09:41:00.000Z',
+      }),
+    };
+    fileAsset = {
+      stats: vi.fn().mockResolvedValue({
+        total: 4,
+        totalBytes: 20480,
+        byKind: {
+          image: { total: 3, bytes: 12288, byStatus: { active: 3 } },
+          video: { total: 1, bytes: 8192, byStatus: { active: 1 } },
+        },
+        generatedAt: '2026-06-18T09:25:00.000Z',
+      }),
+      adminInspect: vi.fn().mockResolvedValue({
+        adminUserId: 'admin-1',
+        users: [
+          {
+            userId: 'user-1',
+            totalFiles: 4,
+            totalBytes: 20480,
+            byKind: {},
+            latestCreatedAt: '2026-06-18T09:45:00.000Z',
+          },
+        ],
+        generatedAt: '2026-06-18T09:46:00.000Z',
+      }),
+    };
+    auth = {
+      isLoggedIn: false,
+      isAdmin: false,
+      currentUser: { username: 'tester', displayName: '测试用户' },
+      logout: vi.fn(),
+    };
+
+    component = new UserCenterComponent(
+      auth as unknown as AuthCreditService,
+      { detectChanges: vi.fn() } as unknown as ChangeDetectorRef,
+      storageGovernance as unknown as StorageGovernanceService,
+      storageMigration as unknown as SystemStorageMigrationService,
+      cloudStorage as unknown as CloudSessionStorageService,
+      fileAsset as unknown as FileAssetService,
+    );
+  });
+
+  it('loads browser storage usage and legacy migration health', async () => {
+    await component.refreshStorageHealth();
+
+    expect(storageGovernance.estimateUsage).toHaveBeenCalled();
+    expect(storageMigration.inspectLegacyLocalStorage).toHaveBeenCalled();
+    expect(cloudStorage.stats).toHaveBeenCalled();
+    expect(cloudStorage.recentAudits).toHaveBeenCalledWith(50);
+    expect(fileAsset.stats).toHaveBeenCalled();
+    expect(component.storageUsage?.localStorageBytes).toBe(2048);
+    expect(component.cloudStorageStats?.totalEntities).toBe(5);
+    expect(component.fileAssetStats?.total).toBe(4);
+    expect(component.fileAssetStats?.totalBytes).toBe(20480);
+    expect(component.cloudEntityRows.map((item) => item.entityType)).toEqual(['ipOperator.plan', 'topic']);
+    expect(component.recentAuditRecords).toHaveLength(10);
+    expect(component.recentAuditRows).toHaveLength(8);
+    expect(component.recentAuditRows[0].summary).toBe('审计摘要 1');
+    expect(component.legacyStorageHealth.map((item) => item.sourceKey)).toEqual([
+      'videoWorkflow.topicPool.items',
+      'videoWorkflow.viralAnalyses.items',
+    ]);
+    expect(component.cleanableLegacyKeys).toBe(1);
+    expect(component.legacyLocalBytes).toBe(5120);
+    expect(cloudStorage.adminInspect).not.toHaveBeenCalled();
+    expect(fileAsset.adminInspect).not.toHaveBeenCalled();
+  });
+
+  it('does not load storage diagnostics on user center init', () => {
+    component.ngOnInit();
+
+    expect(component.showStorageDiagnostics).toBe(false);
+    expect(storageGovernance.estimateUsage).not.toHaveBeenCalled();
+    expect(storageMigration.inspectLegacyLocalStorage).not.toHaveBeenCalled();
+    expect(cloudStorage.stats).not.toHaveBeenCalled();
+    expect(fileAsset.stats).not.toHaveBeenCalled();
+  });
+
+  it('loads admin storage inspection for admin users', async () => {
+    auth.isAdmin = true;
+
+    await component.refreshStorageHealth();
+
+    expect(cloudStorage.adminInspect).toHaveBeenCalledWith(100);
+    expect(fileAsset.adminInspect).toHaveBeenCalledWith(100);
+    expect(component.adminInspectionRows).toEqual([
+      expect.objectContaining({
+        userId: 'user-1',
+        totalEntities: 12,
+        totalFiles: 4,
+        totalFileBytes: 20480,
+        auditCount: 8,
+        migrationFailedRows: 1,
+        latestActivityAt: '2026-06-18T09:45:00.000Z',
+      }),
+    ]);
+  });
+
+  it('clears completed migrated legacy keys and refreshes storage usage', async () => {
+    await component.refreshStorageHealth();
+    await component.clearMigratedLegacyStorage();
+
+    expect(storageMigration.clearCompletedLegacyLocalStorage).toHaveBeenCalled();
+    expect(storageGovernance.estimateUsage).toHaveBeenCalledTimes(2);
+    expect(component.storageMessage).toContain('已清理 1 个已迁移旧缓存');
+    expect(component.lastCleanupResult?.removedKeys).toEqual(['videoWorkflow.topicPool.items']);
+  });
+});

+ 201 - 1
src/app/pages/account/user-center.component.ts

@@ -7,6 +7,14 @@ import {
   AuthCreditService,
   CreditBalance,
 } from '../../services/auth-credit.service';
+import { BrowserStorageUsage, StorageGovernanceService } from '../../services/storage-governance.service';
+import {
+  SystemStorageMigrationCleanupResult,
+  SystemStorageMigrationHealthItem,
+  SystemStorageMigrationService,
+} from '../../services/system-storage-migration.service';
+import { CloudAuditRecord, CloudSessionStorageService, CloudStorageStats } from '../../services/cloud-session-storage.service';
+import { FileAssetService, FileAssetStats } from '../../services/file-asset.service';
 
 @Component({
   selector: 'app-user-center',
@@ -31,15 +39,47 @@ export class UserCenterComponent implements OnInit {
   message = '';
   error = '';
   rechargeError = '';
+  storageUsage: BrowserStorageUsage | null = null;
+  legacyStorageHealth: SystemStorageMigrationHealthItem[] = [];
+  storageHealthLoading = false;
+  storageCleanupLoading = false;
+  storageMessage = '';
+  storageError = '';
+  cloudStorageStats: CloudStorageStats | null = null;
+  recentAuditRecords: CloudAuditRecord[] = [];
+  fileAssetStats: FileAssetStats | null = null;
+  cloudStorageError = '';
+  fileAssetError = '';
+  adminInspectionRows: Array<{
+    userId: string;
+    totalEntities: number;
+    totalFiles: number;
+    totalFileBytes: number;
+    auditCount: number;
+    migrationFailedRows: number;
+    latestActivityAt: string;
+  }> = [];
+  adminInspectionLoading = false;
+  adminInspectionError = '';
+  lastCleanupResult: SystemStorageMigrationCleanupResult | null = null;
+  readonly showStorageDiagnostics = false;
   private pollTimer: ReturnType<typeof setInterval> | null = null;
   private checkingPayment = false;
   private destroyed = false;
 
-  constructor(public auth: AuthCreditService, private cdr: ChangeDetectorRef) {}
+  constructor(
+    public auth: AuthCreditService,
+    private cdr: ChangeDetectorRef,
+    private storageGovernance: StorageGovernanceService,
+    private storageMigration: SystemStorageMigrationService,
+    private cloudStorage: CloudSessionStorageService,
+    private fileAsset: FileAssetService,
+  ) {}
 
   ngOnInit(): void {
     this.destroyed = false;
     void this.reload();
+    if (this.showStorageDiagnostics) void this.refreshStorageHealth();
   }
 
   ngOnDestroy(): void {
@@ -141,6 +181,82 @@ export class UserCenterComponent implements OnInit {
     }
   }
 
+  async refreshStorageHealth(): Promise<void> {
+    this.storageHealthLoading = true;
+    this.storageError = '';
+    this.storageMessage = '';
+    this.cloudStorageError = '';
+    this.fileAssetError = '';
+    this.refreshView();
+    try {
+      this.storageUsage = this.storageGovernance.estimateUsage();
+      this.legacyStorageHealth = await this.storageMigration.inspectLegacyLocalStorage();
+      await this.loadCloudStorageHealth();
+      if (this.auth.isAdmin) await this.loadAdminStorageInspection();
+    } catch (e: any) {
+      this.storageError = e?.message || '加载存储健康状态失败';
+    } finally {
+      this.storageHealthLoading = false;
+      this.refreshView();
+    }
+  }
+
+  async clearMigratedLegacyStorage(): Promise<void> {
+    this.storageCleanupLoading = true;
+    this.storageError = '';
+    this.storageMessage = '';
+    this.fileAssetError = '';
+    this.refreshView();
+    try {
+      const result = await this.storageMigration.clearCompletedLegacyLocalStorage();
+      this.lastCleanupResult = result;
+      this.storageMessage = result.removedCount
+        ? `已清理 ${result.removedCount} 个已迁移旧缓存,释放 ${this.formatBytes(result.removedBytes)}。`
+        : '没有可清理的已迁移旧缓存。';
+      this.storageUsage = this.storageGovernance.estimateUsage();
+      this.legacyStorageHealth = await this.storageMigration.inspectLegacyLocalStorage();
+      await this.loadCloudStorageHealth();
+      if (this.auth.isAdmin) await this.loadAdminStorageInspection();
+    } catch (e: any) {
+      this.storageError = e?.message || '清理已迁移旧缓存失败';
+    } finally {
+      this.storageCleanupLoading = false;
+      this.refreshView();
+    }
+  }
+
+  get cleanableLegacyKeys(): number {
+    return this.legacyStorageHealth.filter((item) => item.canClean).length;
+  }
+
+  get legacyLocalBytes(): number {
+    return this.legacyStorageHealth.reduce((total, item) => total + item.localBytes, 0);
+  }
+
+  get cloudEntityRows(): Array<{ entityType: string; total: number; active: number; deleted: number; archived: number }> {
+    const entries = Object.entries(this.cloudStorageStats?.entityTypes || {});
+    return entries
+      .map(([entityType, stats]) => ({
+        entityType,
+        total: stats.total,
+        active: stats.byStatus['active'] || 0,
+        deleted: stats.byStatus['deleted'] || 0,
+        archived: stats.byStatus['archived'] || 0,
+      }))
+      .sort((a, b) => b.total - a.total || a.entityType.localeCompare(b.entityType));
+  }
+
+  get recentAuditRows(): CloudAuditRecord[] {
+    return this.recentAuditRecords.slice(0, 8);
+  }
+
+  formatBytes(bytes: number | undefined | null): string {
+    const value = Math.max(0, Number(bytes || 0));
+    if (value < 1024) return `${value} B`;
+    if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`;
+    return `${(value / 1024 / 1024).toFixed(2)} MB`;
+  }
+
   logout(): void {
     this.stopPolling();
     this.auth.logout();
@@ -190,6 +306,90 @@ export class UserCenterComponent implements OnInit {
     if (reset) this.polling = false;
   }
 
+  private async loadCloudStorageHealth(): Promise<void> {
+    const errors: string[] = [];
+    try {
+      this.cloudStorageStats = await this.cloudStorage.stats();
+    } catch (e: any) {
+      this.cloudStorageStats = null;
+      errors.push(e?.message || '加载云端存储统计失败');
+    }
+    try {
+      this.recentAuditRecords = await this.cloudStorage.recentAudits(50);
+    } catch (e: any) {
+      this.recentAuditRecords = [];
+      errors.push(e?.message || '加载云端审计记录失败');
+    }
+    this.cloudStorageError = errors.join(';');
+    try {
+      this.fileAssetStats = await this.fileAsset.stats();
+      this.fileAssetError = '';
+    } catch (e: any) {
+      this.fileAssetStats = null;
+      this.fileAssetError = e?.message || '加载七牛文件统计失败';
+    }
+  }
+
+  private async loadAdminStorageInspection(): Promise<void> {
+    this.adminInspectionLoading = true;
+    this.adminInspectionError = '';
+    try {
+      const [storage, files] = await Promise.all([
+        this.cloudStorage.adminInspect(100),
+        this.fileAsset.adminInspect(100),
+      ]);
+      const byUser = new Map<string, {
+        userId: string;
+        totalEntities: number;
+        totalFiles: number;
+        totalFileBytes: number;
+        auditCount: number;
+        migrationFailedRows: number;
+        latestActivityAt: string;
+      }>();
+      const ensure = (userId: string) => {
+        const key = userId || 'unknown';
+        if (!byUser.has(key)) {
+          byUser.set(key, {
+            userId: key,
+            totalEntities: 0,
+            totalFiles: 0,
+            totalFileBytes: 0,
+            auditCount: 0,
+            migrationFailedRows: 0,
+            latestActivityAt: '',
+          });
+        }
+        return byUser.get(key)!;
+      };
+      for (const row of storage.users || []) {
+        const item = ensure(row.userId);
+        item.totalEntities = row.totalEntities || 0;
+        item.auditCount = row.auditCount || 0;
+        item.migrationFailedRows = Number(row.migrations?.failedRows || row.migrations?.byStatus?.['failed'] || 0);
+        item.latestActivityAt = this.latestDate(item.latestActivityAt, row.latestUpdatedAt, row.latestAuditAt);
+      }
+      for (const row of files.users || []) {
+        const item = ensure(row.userId);
+        item.totalFiles = row.totalFiles || 0;
+        item.totalFileBytes = row.totalBytes || 0;
+        item.latestActivityAt = this.latestDate(item.latestActivityAt, row.latestCreatedAt);
+      }
+      this.adminInspectionRows = Array.from(byUser.values())
+        .sort((a, b) => (b.totalEntities + b.totalFiles) - (a.totalEntities + a.totalFiles) || b.totalFileBytes - a.totalFileBytes)
+        .slice(0, 20);
+    } catch (e: any) {
+      this.adminInspectionRows = [];
+      this.adminInspectionError = e?.message || '加载管理员存储巡检失败';
+    } finally {
+      this.adminInspectionLoading = false;
+    }
+  }
+
+  private latestDate(...values: Array<string | undefined>): string {
+    return values.filter(Boolean).map(String).sort().pop() || '';
+  }
+
   private refreshView(): void {
     if (this.destroyed) return;
     this.cdr.detectChanges();

+ 5 - 1
src/app/pages/pipelines/action-transfer/action-transfer.component.ts

@@ -190,7 +190,11 @@ export class ActionTransferComponent implements OnInit, OnDestroy {
     this.uploadingSlot = slot;
     this.uploadProgress = 0;
 
-    this.qiniuUpload.uploadFileWithProgress(file, file.name, file.type, slot === 'image' ? 'image' : 'video').subscribe({
+    this.qiniuUpload.uploadFileWithProgress(file, file.name, file.type, slot === 'image' ? 'image' : 'video', {
+      sourceModule: 'action-transfer',
+      bizType: 'reference-asset',
+      bizId: slot,
+    }).subscribe({
       next: (event) => {
         if (event.state === 'progress') {
           this.uploadProgress = event.progress;

+ 5 - 1
src/app/pages/pipelines/asset-remix/asset-remix.component.ts

@@ -299,7 +299,11 @@ export class AssetRemixComponent implements OnInit, OnDestroy {
       const placeholderKey = `${file.name}-${file.size}`;
       this.uploadProgress[placeholderKey] = 0;
 
-      this.qiniuUpload.uploadFileWithProgress(file, file.name, file.type, 'image').subscribe({
+      this.qiniuUpload.uploadFileWithProgress(file, file.name, file.type, 'image', {
+        sourceModule: 'asset-remix',
+        bizType: 'source-image',
+        bizId: placeholderKey,
+      }).subscribe({
         next: (event) => {
           this.zone.run(() => {
             if (event.state === 'progress') {

+ 5 - 1
src/app/pages/pipelines/image-generation/image-generation.component.ts

@@ -223,7 +223,11 @@ export class ImageGenerationComponent implements OnInit, OnDestroy {
 
     this.uploading = true;
     this.uploadError = '';
-    this.qiniu.uploadFile(file, file.name, file.type, 'image').subscribe({
+    this.qiniu.uploadFile(file, file.name, file.type, 'image', {
+      sourceModule: 'image-generation',
+      bizType: 'reference-image',
+      bizId: 'image-generation-reference',
+    }).subscribe({
       next: (url) => {
         const preview = URL.createObjectURL(file);
         this.referenceImages.push({ url, name: file.name, size: file.size, preview });

+ 19 - 0
src/app/pages/pipelines/image-to-video/image-to-video.component.css

@@ -92,3 +92,22 @@
 }
 .app-container.theme-night .i2v-template-card__label { color: #f1f5f9; }
 .app-container.theme-night .i2v-template-card__hint { color: #94a3b8; }
+
+/* —— 参考图生成模式中间首帧 —— */
+.i2v-reference-plan {
+  overflow: hidden;
+}
+
+.i2v-reference-frame {
+  width: 100%;
+  max-height: 260px;
+  object-fit: contain;
+  border-radius: 8px;
+  border: 1px solid rgba(148, 163, 184, 0.25);
+  background: rgba(15, 23, 42, 0.04);
+}
+
+.app-container.theme-night .i2v-reference-frame {
+  border-color: rgba(96, 165, 250, 0.2);
+  background: rgba(255, 255, 255, 0.04);
+}

+ 14 - 5
src/app/pages/pipelines/image-to-video/image-to-video.component.html

@@ -36,15 +36,17 @@
           <button class="dh-segment__item" [class.is-active]="mode === '2'" (click)="setMode('2')">首帧</button>
           <button class="dh-segment__item" [class.is-active]="mode === '3'" (click)="setMode('3')">首尾帧</button>
           <button class="dh-segment__item" [class.is-active]="mode === '4'" (click)="setMode('4')">运镜</button>
+          <button class="dh-segment__item" [class.is-active]="mode === 'reference'" (click)="setMode('reference')">参考图生成</button>
         </div>
         <p class="i2v-mode-hint" *ngIf="mode === '2'">单张图 + 提示词 → 以该图为首帧生成动态视频</p>
         <p class="i2v-mode-hint" *ngIf="mode === '3'">两张图 → 以首帧/尾帧为边界,自动补全中间过渡 <strong>(仅 720P / 1080P)</strong></p>
         <p class="i2v-mode-hint" *ngIf="mode === '4'">一张图 + 运镜模板 → 预设镜头运动生成视频 <strong>(仅 720P)</strong></p>
+        <p class="i2v-mode-hint" *ngIf="mode === 'reference'">上传参考图片并描述创意方向,系统会延续主体与风格生成视频。</p>
       </div>
 
       <!-- 首帧 -->
       <div class="dh-substep">
-        <div class="dh-substep__head"><h4>首帧图片 <em>*</em></h4></div>
+        <div class="dh-substep__head"><h4>{{ mode === 'reference' ? '参考图片' : '首帧图片' }} <em>*</em></h4></div>
         <input type="file" accept="image/*" hidden #firstInput
                (change)="onFilePicked($event, 'first')">
         <div *ngIf="!firstFrame" class="dh-image-card" (click)="triggerFilePick('first', firstInput)">
@@ -56,7 +58,7 @@
             </svg>
           </div>
           <div class="dh-image-card__title">
-            {{ uploadingSlot === 'first' ? '上传中…' : '点击上传首帧图片' }}
+            {{ uploadingSlot === 'first' ? '上传中…' : (mode === 'reference' ? '点击上传参考图片' : '点击上传首帧图片') }}
           </div>
           <div class="dh-image-card__hints">支持 JPG / PNG / WebP,≤10MB</div>
         </div>
@@ -160,7 +162,7 @@
                   [title]="mode === '4' ? '运镜模式仅 720P 可用' : ''">1080P</button>
           <button class="dh-segment__item" [class.is-active]="quality === 'pro'"
                   [disabled]="generating || mode === '3' || mode === '4'" (click)="setQuality('pro')"
-                  [title]="mode !== '2' ? 'Pro 仅支持首帧模式' : ''">Pro</button>
+                  [title]="mode === '3' || mode === '4' ? 'Pro 仅支持首帧和参考图生成模式' : ''">Pro</button>
         </div>
       </div>
 
@@ -193,8 +195,8 @@
         </div>
       </div>
 
-      <!-- 画面比例:仅 Pro 图生视频时需要(720P/1080P i2v 由图片決定) -->
-      <div class="dh-substep" *ngIf="mode === '2' && quality === 'pro'">
+      <!-- 画面比例:Pro 首帧模式用于视频比例;参考图生成模式用于创意画面比例 -->
+      <div class="dh-substep" *ngIf="(mode === '2' && quality === 'pro') || mode === 'reference'">
         <div class="dh-substep__head"><h4>画面比例</h4></div>
         <div class="dh-segment dh-segment--wrap">
           <button class="dh-segment__item" [class.is-active]="aspect === '16:9'"
@@ -207,6 +209,8 @@
                   [disabled]="generating" (click)="aspect = '4:3'">4:3</button>
           <button class="dh-segment__item" [class.is-active]="aspect === '3:4'"
                   [disabled]="generating" (click)="aspect = '3:4'">3:4</button>
+          <button class="dh-segment__item" [class.is-active]="aspect === '21:9'"
+                  [disabled]="generating" (click)="aspect = '21:9'">21:9</button>
         </div>
       </div>
     </div>
@@ -244,6 +248,11 @@
         </div>
       </div>
 
+      <div *ngIf="referenceGeneratedFrameUrl" class="dh-substep i2v-reference-plan">
+        <div class="dh-substep__head"><h4>创意画面预览</h4></div>
+        <img *ngIf="referenceGeneratedFrameUrl" class="i2v-reference-frame" [src]="referenceGeneratedFrameUrl" alt="generated first frame">
+      </div>
+
       <div *ngIf="errorMsg" class="pl-error-card">
         <strong>生成失败:</strong>{{ errorMsg }}
       </div>

+ 136 - 0
src/app/pages/pipelines/image-to-video/image-to-video.component.spec.ts

@@ -0,0 +1,136 @@
+import '@angular/compiler';
+import { ChangeDetectorRef, NgZone } from '@angular/core';
+import { of } from 'rxjs';
+import { ImageToVideoComponent } from './image-to-video.component';
+
+describe('ImageToVideoComponent', () => {
+  it('参考图生成模式先生成新首帧,再复用首帧图生视频链路', () => {
+    const plan = {
+      intentSummary: '雨夜城市橱窗香水广告',
+      referenceKeep: ['保留方形瓶身'],
+      imagePrompt: '以参考图为视觉参考,保留方形瓶身,但不要直接复制原图构图。生成雨夜城市橱窗香水广告静帧。',
+      videoPrompt: '镜头缓慢推进,霓虹反射流动。',
+      negativePrompt: '不要改变香水瓶造型,不要出现水印。',
+      qualityHints: {
+        composition: '产品居中',
+        style: '商业广告',
+        lighting: '霓虹光',
+        aspectRatioSuggestion: '16:9',
+      },
+      riskWarnings: [],
+      imageBrief: {
+        mainSubject: '香水瓶',
+        identityFeatures: ['方形瓶身'],
+        visualStyle: '商业摄影',
+        colorsAndLighting: '冷色霓虹光',
+        composition: '居中产品特写',
+        objects: ['香水瓶'],
+        doNotChange: ['瓶身轮廓'],
+      },
+      source: 'llm' as const,
+    };
+
+    const qiniu = {};
+    const jimeng = {
+      generateImgV4: vi.fn().mockReturnValue(of('https://example.com/generated-first-frame.png')),
+      generateImageToVideo: vi.fn().mockReturnValue(of({
+        videoUrl: 'https://example.com/final.mp4',
+        workId: 'work-1',
+      })),
+    };
+    const cdr = { detectChanges: vi.fn() } as unknown as ChangeDetectorRef;
+    const zone = { run: (fn: () => void) => fn() } as unknown as NgZone;
+    const results = { saveResult: vi.fn().mockReturnValue(of(null)) };
+    const session = {
+      ensureActive: vi.fn(),
+      markRunning: vi.fn(),
+      patch: vi.fn(),
+      addArtifact: vi.fn(),
+      finalize: vi.fn(),
+      fail: vi.fn(),
+      close: vi.fn(),
+      active: vi.fn().mockReturnValue(null),
+    };
+    const generationTasks = {
+      create: vi.fn().mockReturnValue({ id: 'task-1' }),
+      markRunning: vi.fn(),
+      markWaitingExternal: vi.fn(),
+      markStepCompleted: vi.fn(),
+      markCompleted: vi.fn(),
+      markFailed: vi.fn(),
+    };
+    const costEstimator = {
+      estimateImageToVideo: vi.fn().mockReturnValue({
+        operation: 'jimeng.imageToVideo',
+        totalCredits: 14,
+        lines: [],
+      }),
+    };
+    const videoDuration = {
+      framesToSeconds: vi.fn().mockReturnValue(5),
+      secondsToFrames: vi.fn().mockReturnValue(121),
+    };
+    const referencePlanner = {
+      plan: vi.fn().mockReturnValue(of(plan)),
+    };
+
+    const component = new ImageToVideoComponent(
+      qiniu as any,
+      jimeng as any,
+      cdr,
+      zone,
+      results as any,
+      session as any,
+      generationTasks as any,
+      costEstimator as any,
+      videoDuration as any,
+      referencePlanner as any,
+    );
+
+    component.setMode('reference' as any);
+    component.firstFrame = {
+      url: 'https://example.com/ref.png',
+      name: 'ref.png',
+      size: 1024,
+      preview: 'blob:ref',
+    };
+    component.prompt = '参考这张香水瓶,生成雨夜城市橱窗广告,镜头慢慢推进。';
+    component.quality = '720p';
+    component.aspect = '16:9';
+    component.frames = 121;
+    component.durationSeconds = 5;
+
+    component.generate();
+
+    expect(referencePlanner.plan).toHaveBeenCalledWith({
+      referenceImageUrl: 'https://example.com/ref.png',
+      userPrompt: '参考这张香水瓶,生成雨夜城市橱窗广告,镜头慢慢推进。',
+      aspect: '16:9',
+    });
+    expect(jimeng.generateImgV4).toHaveBeenCalledWith(
+      plan.imagePrompt,
+      ['https://example.com/ref.png'],
+      { width: 2048, height: 1152 },
+      expect.any(Function),
+      { scale: 0.55, forceSingle: true },
+    );
+    expect(jimeng.generateImageToVideo).toHaveBeenCalledWith(
+      expect.objectContaining({
+        imageUrls: ['https://example.com/generated-first-frame.png'],
+        prompt: '镜头缓慢推进,霓虹反射流动。',
+        method: '2',
+        quality: '720p',
+        frames: 121,
+        aspectRatio: '16:9',
+      }),
+      expect.any(Function),
+    );
+    expect(session.addArtifact).toHaveBeenCalledWith(expect.objectContaining({
+      type: 'image',
+      url: 'https://example.com/generated-first-frame.png',
+      title: '创意画面',
+    }));
+    expect(component.resultVideoUrl).toBe('https://example.com/final.mp4');
+    expect(component.referenceGeneratedFrameUrl).toBe('https://example.com/generated-first-frame.png');
+  });
+});

+ 196 - 40
src/app/pages/pipelines/image-to-video/image-to-video.component.ts

@@ -2,6 +2,7 @@ import { Component, ChangeDetectorRef, NgZone, OnDestroy, OnInit } from '@angula
 import { CommonModule } from '@angular/common';
 import { FormsModule } from '@angular/forms';
 import { Subscription } from 'rxjs';
+import { map, switchMap } from 'rxjs/operators';
 import { ResultsService } from '../../../services/results.service';
 import {
   JimengService,
@@ -18,6 +19,10 @@ import { userFriendlyError } from '../../../services/user-message.util';
 import { CostEstimatorService } from '../../../services/cost-estimator.service';
 import { GenerationTaskService } from '../../../services/generation-task.service';
 import { VideoDurationService } from '../../../services/video-duration.service';
+import {
+  ReferenceVideoPromptPlan,
+  ReferenceVideoPromptPlannerService,
+} from '../../../services/reference-video-prompt-planner.service';
 
 interface CameraTemplateOption {
   id: JimengCameraTemplateId;
@@ -48,7 +53,16 @@ interface I2vUploadedImage {
 
 type I2vQuality = JimengVideoQuality;
 type I2vAspect = JimengAspectRatio;
-type I2vMode = '2' | '3' | '4'; // 2首帧 3首尾帧 4运镜
+type I2vMode = '2' | '3' | '4' | 'reference'; // 2首帧 3首尾帧 4运镜 reference参考图生成
+
+const I2V_REFERENCE_ASPECT_SIZE: Record<JimengAspectRatio, { width: number; height: number }> = {
+  '1:1': { width: 2048, height: 2048 },
+  '4:3': { width: 2048, height: 1536 },
+  '3:4': { width: 1536, height: 2048 },
+  '16:9': { width: 2048, height: 1152 },
+  '9:16': { width: 1152, height: 2048 },
+  '21:9': { width: 2048, height: 878 },
+};
 
 /** 会话快照:只存可序列化字段(File / ObjectURL 不入快照,但七牛云 url 可保留) */
 interface I2vSnapshot {
@@ -64,6 +78,8 @@ interface I2vSnapshot {
   lastFrame: { url: string; name: string; size: number } | null;
   resultVideoUrl: string;
   resultWorkId: string;
+  referenceGeneratedFrameUrl?: string;
+  referencePlan?: ReferenceVideoPromptPlan | null;
 }
 
 /**
@@ -109,6 +125,9 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
   resultVideoUrl = '';
   resultWorkId = '';
   errorMsg = '';
+  referencePlanning = false;
+  referenceGeneratedFrameUrl = '';
+  referencePlan: ReferenceVideoPromptPlan | null = null;
 
   // ---- 预设提示词 ----
   readonly promptPresets: { label: string; value: string }[] = [
@@ -132,6 +151,7 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
     private generationTasks: GenerationTaskService,
     private costEstimator: CostEstimatorService,
     private videoDuration: VideoDurationService,
+    private referencePlanner: ReferenceVideoPromptPlannerService,
   ) {}
 
   ngOnInit(): void {
@@ -159,6 +179,8 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
       lastFrame: stripPreview(this.lastFrame),
       resultVideoUrl: this.resultVideoUrl,
       resultWorkId: this.resultWorkId,
+      referenceGeneratedFrameUrl: this.referenceGeneratedFrameUrl,
+      referencePlan: this.referencePlan,
     };
   }
 
@@ -177,6 +199,8 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
     this.lastFrame = snap.lastFrame ? { ...snap.lastFrame, preview: snap.lastFrame.url } : null;
     this.resultVideoUrl = snap.resultVideoUrl ?? '';
     this.resultWorkId = snap.resultWorkId ?? '';
+    this.referenceGeneratedFrameUrl = snap.referenceGeneratedFrameUrl ?? '';
+    this.referencePlan = snap.referencePlan ?? null;
     this.errorMsg = '';
     this.statusText = '';
     this.progress = 0;
@@ -186,7 +210,7 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
   private deriveTitle(): string {
     const t = (this.prompt || '').trim();
     if (t) return t.length > 30 ? t.slice(0, 30) + '…' : t;
-    const modeLabel = this.mode === '2' ? '首帧' : this.mode === '3' ? '首尾帧' : '运镜';
+    const modeLabel = this.modeLabel();
     return `图生视频 · ${modeLabel}`;
   }
 
@@ -211,6 +235,9 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
     this.lastFrame = null;
     this.resultVideoUrl = '';
     this.resultWorkId = '';
+    this.referenceGeneratedFrameUrl = '';
+    this.referencePlan = null;
+    this.referencePlanning = false;
     this.errorMsg = '';
     this.statusText = '';
     this.progress = 0;
@@ -254,7 +281,11 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
     this.uploadError = '';
     this.uploadingSlot = slot;
 
-    this.qiniuUpload.uploadFile(file, file.name, file.type, 'image').subscribe({
+    this.qiniuUpload.uploadFile(file, file.name, file.type, 'image', {
+      sourceModule: 'image-to-video',
+      bizType: 'reference-image',
+      bizId: slot,
+    }).subscribe({
       next: (url: string) => {
         if (!url) {
           this.uploadError = '上传失败:未返回 URL';
@@ -317,6 +348,12 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
     this.mode = m;
     if (m === '3' && this.quality === 'pro') this.quality = '1080p';
     if (m === '4') this.quality = '720p';
+    if (m === 'reference') {
+      this.lastFrame = null;
+      this.referenceGeneratedFrameUrl = '';
+      this.referencePlan = null;
+    }
+    this.syncDraft();
   }
 
   setQuality(q: I2vQuality): void {
@@ -351,6 +388,9 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
     this.errorMsg = '';
     this.resultVideoUrl = '';
     this.resultWorkId = '';
+    this.referenceGeneratedFrameUrl = '';
+    this.referencePlan = null;
+    this.referencePlanning = false;
     const estimate = this.costEstimator.estimateImageToVideo(this.quality, this.durationSeconds);
     const task = this.generationTasks.create({
       title: this.deriveTitle(),
@@ -374,8 +414,14 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
     this.session.markRunning();
     this.session.patch({ snapshot: this.toSnapshot(), title: this.deriveTitle() });
 
+    const mode = this.mode;
+    if (mode === 'reference') {
+      this.runReferenceGeneration();
+      return;
+    }
+
     const imageUrls =
-      this.mode === '3'
+      mode === '3'
         ? [this.firstFrame!.url, this.lastFrame!.url]
         : [this.firstFrame!.url];
 
@@ -384,12 +430,12 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
         {
           imageUrls,
           prompt: this.prompt.trim(),
-          method: this.mode,
+          method: mode,
           quality: this.quality,
           frames: this.frames,
           aspectRatio: this.aspect,
-          cameraTemplate: this.mode === '4' ? this.cameraTemplate : undefined,
-          cameraStrength: this.mode === '4' ? this.cameraStrength : undefined,
+          cameraTemplate: mode === '4' ? this.cameraTemplate : undefined,
+          cameraStrength: mode === '4' ? this.cameraStrength : undefined,
         },
         (status: string, progress: number, meta?: Record<string, any>) => {
           this.zone.run(() => {
@@ -411,45 +457,152 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
       )
       .subscribe({
         next: (result) => {
-          this.zone.run(() => {
-            this.resultVideoUrl = result.videoUrl;
-            this.resultWorkId = result.workId;
-            this.progress = 100;
-            this.statusText = '生成完成!';
-            this.generating = false;
-            this.cdr.detectChanges();
-            // 持久化到结果库 + 会话成片
-            const modeLabel = this.mode === '2' ? '首帧' : this.mode === '3' ? '首尾帧' : '运镜';
-            const extras = { workId: result.workId, mode: this.mode, aspect: this.aspect };
-            this.session.patch({ snapshot: this.toSnapshot() });
-            this.session.finalize(result.videoUrl, extras);
-            this.generationTasks.markStepCompleted(this.generationTaskId, 'archive', 100);
-            this.generationTasks.markCompleted(this.generationTaskId, result.videoUrl);
-            this.results.saveResult({
-              type: 'video',
-              url: result.videoUrl,
-              title: `图生视频-${modeLabel}-${this.quality.toUpperCase()}`,
-              quality: this.quality,
-              duration: `${this.durationSeconds}s`,
-              pipelineId: 'image_to_video',
-              extras,
-            }).subscribe();
-          });
+          this.zone.run(() => this.handleGenerationSuccess(result.videoUrl, result.workId));
         },
         error: (err) => {
-          this.zone.run(() => {
-            console.error('[i2v] generate error', err);
-            this.errorMsg = userFriendlyError(err, '视频生成失败,请稍后重试');
-            this.statusText = '';
-            this.generating = false;
-            this.cdr.detectChanges();
-            this.session.fail(this.errorMsg);
-            this.generationTasks.markFailed(this.generationTaskId, err, { retryable: true, recoverable: true });
-          });
+          this.zone.run(() => this.handleGenerationFailure(err));
         },
       });
   }
 
+  private runReferenceGeneration(): void {
+    const referenceUrl = this.firstFrame!.url;
+    const size = I2V_REFERENCE_ASPECT_SIZE[this.aspect] || I2V_REFERENCE_ASPECT_SIZE['16:9'];
+
+    this.referencePlanning = true;
+    this.referenceGeneratedFrameUrl = '';
+    this.referencePlan = null;
+    this.statusText = '正在分析参考图片并准备创意画面...';
+    this.progress = 5;
+
+    this.genSub = this.referencePlanner.plan({
+      referenceImageUrl: referenceUrl,
+      userPrompt: this.prompt.trim(),
+      aspect: this.aspect,
+    }).pipe(
+      switchMap((plan) => {
+        this.referencePlan = plan;
+        this.referencePlanning = false;
+        this.generationTasks.markRunning(this.generationTaskId, 'submit', 20);
+        return this.jimeng.generateImgV4(
+          plan.imagePrompt,
+          [referenceUrl],
+          size,
+          (status, progress) => this.zone.run(() => {
+            this.statusText = this.referenceImageStatus(status, progress);
+            this.progress = Math.min(45, 10 + Math.round(progress * 0.35));
+            this.cdr.detectChanges();
+          }),
+          { scale: 0.55, forceSingle: true },
+        ).pipe(map((generatedFrameUrl) => ({ plan, generatedFrameUrl })));
+      }),
+      switchMap(({ plan, generatedFrameUrl }) => {
+        this.referenceGeneratedFrameUrl = generatedFrameUrl;
+        this.session.addArtifact({
+          type: 'image',
+          url: generatedFrameUrl,
+          title: '创意画面',
+          extras: {
+            kind: 'reference-generated-frame',
+            imagePrompt: plan.imagePrompt,
+            videoPrompt: plan.videoPrompt,
+            referenceImageUrl: referenceUrl,
+            source: plan.source,
+          },
+        });
+        this.session.patch({ snapshot: this.toSnapshot(), thumbnail: generatedFrameUrl });
+        return this.jimeng.generateImageToVideo(
+          {
+            imageUrls: [generatedFrameUrl],
+            prompt: plan.videoPrompt,
+            method: '2',
+            quality: this.quality,
+            frames: this.frames,
+            aspectRatio: this.aspect,
+          },
+          (status: string, progress: number, meta?: Record<string, any>) => {
+            this.zone.run(() => {
+              const mappedProgress = Math.min(98, 45 + Math.round(progress * 0.53));
+              if (meta?.['workId']) {
+                this.generationTasks.markWaitingExternal(
+                  this.generationTaskId,
+                  { workId: String(meta['workId']), routerName: String(meta['routerName'] || '') },
+                  'poll',
+                  mappedProgress,
+                );
+              } else {
+                this.generationTasks.markRunning(this.generationTaskId, 'poll', mappedProgress);
+              }
+              this.statusText = this.referenceVideoStatus(status);
+              this.progress = mappedProgress;
+              this.cdr.detectChanges();
+            });
+          },
+        );
+      }),
+    ).subscribe({
+      next: (result) => this.zone.run(() => this.handleGenerationSuccess(result.videoUrl, result.workId)),
+      error: (err) => this.zone.run(() => this.handleGenerationFailure(err)),
+    });
+  }
+
+  private handleGenerationSuccess(videoUrl: string, workId: string): void {
+    this.resultVideoUrl = videoUrl;
+    this.resultWorkId = workId;
+    this.progress = 100;
+    this.statusText = '生成完成!';
+    this.generating = false;
+    this.referencePlanning = false;
+    this.cdr.detectChanges();
+
+    const modeLabel = this.modeLabel();
+    const extras = {
+      workId,
+      mode: this.mode,
+      aspect: this.aspect,
+      referenceGeneratedFrameUrl: this.referenceGeneratedFrameUrl,
+      referencePlan: this.referencePlan,
+    };
+    this.session.patch({ snapshot: this.toSnapshot() });
+    this.session.finalize(videoUrl, extras);
+    this.generationTasks.markStepCompleted(this.generationTaskId, 'archive', 100);
+    this.generationTasks.markCompleted(this.generationTaskId, videoUrl);
+    this.results.saveResult({
+      type: 'video',
+      url: videoUrl,
+      title: `图生视频-${modeLabel}-${this.quality.toUpperCase()}`,
+      quality: this.quality,
+      duration: `${this.durationSeconds}s`,
+      pipelineId: 'image_to_video',
+      extras,
+    }).subscribe();
+  }
+
+  private handleGenerationFailure(err: unknown): void {
+    console.error('[i2v] generate error', err);
+    this.errorMsg = userFriendlyError(err, '视频生成失败,请稍后重试');
+    this.statusText = '';
+    this.generating = false;
+    this.referencePlanning = false;
+    this.cdr.detectChanges();
+    this.session.fail(this.errorMsg);
+    this.generationTasks.markFailed(this.generationTaskId, err, { retryable: true, recoverable: true });
+  }
+
+  private modeLabel(): string {
+    return this.mode === '2' ? '首帧' : this.mode === '3' ? '首尾帧' : this.mode === '4' ? '运镜' : '参考图生成';
+  }
+
+  private referenceImageStatus(_status: string, progress: number): string {
+    return progress >= 100 ? '创意画面已准备,正在生成视频...' : '正在生成创意画面,请稍候...';
+  }
+
+  private referenceVideoStatus(status: string): string {
+    if (status === 'external-task-id') return '视频已进入生成队列...';
+    if (/提交|任务/i.test(status)) return '视频已进入生成队列,正在生成...';
+    return status || '正在生成视频...';
+  }
+
   cancel(): void {
     this.genSub?.unsubscribe();
     this.generating = false;
@@ -464,6 +617,9 @@ export class ImageToVideoComponent implements OnInit, OnDestroy {
     this.errorMsg = '';
     this.statusText = '';
     this.progress = 0;
+    this.referenceGeneratedFrameUrl = '';
+    this.referencePlan = null;
+    this.referencePlanning = false;
   }
 
   formatSize(bytes: number): string {

+ 5 - 1
src/app/pages/pipelines/topic-to-video/topic-to-video.component.ts

@@ -318,7 +318,11 @@ export class TopicToVideoComponent implements OnInit, OnDestroy {
     this.bibleImageUploading[field] = true;
     this.cdr.detectChanges();
     const filename = `t2v-bible-${field}-${Date.now()}-${file.name}`;
-    this.qiniu.uploadFile(file, filename, file.type || 'image/jpeg', 'image').subscribe({
+    this.qiniu.uploadFile(file, filename, file.type || 'image/jpeg', 'image', {
+      sourceModule: 'topic-to-video',
+      bizType: 'bible-image',
+      bizId: field,
+    }).subscribe({
       next: (url) => {
         this.zone.run(() => {
           this.setBibleImage(field, url);

+ 138 - 0
src/app/services/auth-credit.service.spec.ts

@@ -0,0 +1,138 @@
+import { AuthCreditService } from './auth-credit.service';
+import { CLOUD_FN } from './cloud-functions';
+
+describe('AuthCreditService APIG balance', () => {
+  const originalFetch = globalThis.fetch;
+
+  afterEach(() => {
+    globalThis.fetch = originalFetch;
+    (CLOUD_FN as any).authCredit = '';
+    localStorage.clear();
+    vi.restoreAllMocks();
+  });
+
+  it('queries the real video workflow APIG id from the database', async () => {
+    const calls: string[] = [];
+    globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
+      calls.push(String(input));
+      return jsonResponse({ results: [] });
+    }) as any;
+    const service = new AuthCreditService({} as any);
+
+    await (service as any).findVideoWorkflowApigAuth('user-1', 'token-1');
+
+    const queryUrl = decodeURIComponent(calls[0]);
+    expect(queryUrl).toContain('6pFf6EAdKT');
+    expect(queryUrl).not.toContain('6pF6EAdKT');
+  });
+
+  it('creates APIGAuth records against the real video workflow APIG id', async () => {
+    const bodies: any[] = [];
+    globalThis.fetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
+      if (init?.body) bodies.push(JSON.parse(String(init.body)));
+      return jsonResponse({ objectId: 'auth-1' });
+    }) as any;
+    const service = new AuthCreditService({} as any);
+
+    await (service as any).ensureVideoWorkflowApigAuth('user-1', 'token-1');
+
+    expect(bodies[0]).toMatchObject({
+      api: { __type: 'Pointer', className: 'APIG', objectId: '6pFf6EAdKT' },
+      user: { __type: 'Pointer', className: '_User', objectId: 'user-1' },
+    });
+  });
+
+  it('uses authCredit cloud function for balance when the gateway is configured', async () => {
+    (CLOUD_FN as any).authCredit = 'auth-fn-1';
+    const parse = {
+      call: vi.fn().mockResolvedValue({
+        code: 200,
+        success: true,
+        data: { balance: 2000, gifted: 0, totalRecharged: 2000, totalConsumed: 0 },
+      }),
+    };
+    localStorage.setItem('videoWorkflow.authSession', JSON.stringify({
+      token: 'token-1',
+      user: { objectId: 'user-1', username: 'user-1', role: 'user' },
+    }));
+    globalThis.fetch = vi.fn() as any;
+    const service = new AuthCreditService(parse as any);
+
+    const balance = await service.getBalance();
+
+    expect(balance.balance).toBe(2000);
+    expect(parse.call).toHaveBeenCalledWith('auth-fn-1', {
+      action: 'balance',
+      sessionToken: 'token-1',
+      userId: 'user-1',
+    });
+    expect(globalThis.fetch).not.toHaveBeenCalled();
+  });
+
+  it('detects admin role from the current session', () => {
+    localStorage.setItem('videoWorkflow.authSession', JSON.stringify({
+      token: 'admin-token',
+      user: { objectId: 'admin-1', username: 'admin', role: 'admin' },
+    }));
+    const service = new AuthCreditService({} as any);
+
+    expect(service.isAdmin).toBe(true);
+  });
+
+  it('uses authCredit cloud function for recharge context, order creation and recharge saving', async () => {
+    (CLOUD_FN as any).authCredit = 'auth-fn-1';
+    const parse = {
+      call: vi.fn(async (_id: string, params: any) => {
+        if (params.action === 'rechargeContext') {
+          return {
+            code: 200,
+            success: true,
+            data: {
+              authId: 'auth-1',
+              userId: 'user-1',
+              payUserId: 'company-1',
+              apig: { objectId: '6pFf6EAdKT', title: '短视频AI工作流', count: 2000, priceStep: [{ count: 1000, price: 300 }] },
+            },
+          };
+        }
+        if (params.action === 'createRechargeOrder') {
+          return { code: 200, success: true, data: { objectId: 'order-1' } };
+        }
+        if (params.action === 'saveRecharge') {
+          return { code: 200, success: true, data: true };
+        }
+        return { code: 400, success: false, error: 'unexpected action' };
+      }),
+    };
+    localStorage.setItem('videoWorkflow.authSession', JSON.stringify({
+      token: 'token-1',
+      user: { objectId: 'user-1', username: 'user-1', role: 'user' },
+    }));
+    const service = new AuthCreditService(parse as any);
+
+    const context = await service.getVideoWorkflowRechargeContext();
+    const order = await (service as any).createApigOrder(context, { count: 1000, price: 300 }, { out_trade_no: 'trade-1' });
+    await service.saveApigRecharge({ context, tier: { count: 1000, price: 300 }, orderId: order.objectId } as any);
+
+    expect(parse.call).toHaveBeenCalledWith('auth-fn-1', expect.objectContaining({ action: 'rechargeContext' }));
+    expect(parse.call).toHaveBeenCalledWith('auth-fn-1', expect.objectContaining({
+      action: 'createRechargeOrder',
+      count: 1000,
+      amountCny: 300,
+      price: 300,
+    }));
+    expect(parse.call).toHaveBeenCalledWith('auth-fn-1', expect.objectContaining({
+      action: 'saveRecharge',
+      authId: 'auth-1',
+      apigId: '6pFf6EAdKT',
+      orderId: 'order-1',
+    }));
+  });
+});
+
+function jsonResponse(data: any): Response {
+  return {
+    ok: true,
+    json: async () => data,
+  } as Response;
+}

+ 31 - 6
src/app/services/auth-credit.service.ts

@@ -120,7 +120,7 @@ export class AuthCreditService {
   }
 
   get isAdmin(): boolean {
-    return false;
+    return this.currentUser?.role === 'admin';
   }
 
   requestLogin(featureName = '该功能'): void {
@@ -208,11 +208,11 @@ export class AuthCreditService {
   }
 
   async getBalance(): Promise<CreditBalance> {
+    if (!this.localMode) return this.call<CreditBalance>('balance', this.authPayload());
     if (this.session?.token && this.currentUser?.objectId) {
       return this.getVideoWorkflowApigBalance(this.currentUser.objectId, this.session.token);
     }
-    if (this.localMode) return this.localBalance();
-    return this.call<CreditBalance>('balance', this.authPayload());
+    return this.localBalance();
   }
 
   async getLedger(limit = 50): Promise<CreditLedgerItem[]> {
@@ -355,6 +355,9 @@ export class AuthCreditService {
 
   async getVideoWorkflowRechargeContext(): Promise<ApigRechargeContext> {
     if (!this.session?.token || !this.currentUser?.objectId) throw new Error('请先登录');
+    if (!this.localMode) {
+      return this.call<ApigRechargeContext>('rechargeContext', this.authPayload());
+    }
     await this.ensureVideoWorkflowApigAuth(this.currentUser.objectId, this.session.token);
     const auth = await this.findVideoWorkflowApigAuth(this.currentUser.objectId, this.session.token);
     if (!auth?.objectId) throw new Error('无法获取 APIGAuth 充值账套');
@@ -434,6 +437,18 @@ export class AuthCreditService {
   }
 
   async saveApigRecharge(session: ApigPaymentSession): Promise<void> {
+    if (!this.localMode) {
+      await this.call<void>('saveRecharge', {
+        ...this.authPayload(),
+        authId: session.context.authId,
+        apigId: session.context.apig.objectId,
+        oldCount: session.context.apig.count || 0,
+        count: session.tier.count,
+        orderId: session.orderId,
+        payUserId: session.context.payUserId || session.context.userId || session.context.authId,
+      });
+      return;
+    }
     const payUserId = session.context.payUserId || session.context.userId || session.context.authId;
     const resp = await this.requestJson(`${PARSE_API_HOST}/api/apig/saveRecharge`, {
       method: 'POST',
@@ -490,9 +505,9 @@ export class AuthCreditService {
     }
   }
 
-  private async findVideoWorkflowApigAuth(userId: string, sessionToken: string): Promise<any | null> {
+  private async findVideoWorkflowApigAuth(userId: string, sessionToken: string, apigId = VIDEO_WORKFLOW_APIG_ID): Promise<any | null> {
     const where = JSON.stringify({
-      api: { __type: 'Pointer', className: 'APIG', objectId: VIDEO_WORKFLOW_APIG_ID },
+      api: { __type: 'Pointer', className: 'APIG', objectId: apigId },
       user: { __type: 'Pointer', className: '_User', objectId: userId },
     });
     const query = new URLSearchParams({ where, include: 'api', limit: '1' });
@@ -522,7 +537,7 @@ export class AuthCreditService {
       phone: parseUser.mobilePhoneNumber || mobile || '',
       displayName,
       companyId: parseUser.company?.objectId || '',
-      role: 'user',
+      role: parseUser.role === 'admin' || parseUser.isAdmin === true || parseUser.admin === true ? 'admin' : 'user',
       createdAt: parseUser.createdAt,
     };
   }
@@ -547,6 +562,16 @@ export class AuthCreditService {
   }
 
   private async createApigOrder(context: ApigRechargeContext, tier: ApigPriceStep, params: any): Promise<any> {
+    if (!this.localMode) {
+      return this.call<any>('createRechargeOrder', {
+        ...this.authPayload(),
+        count: tier.count,
+        amountCny: tier.price,
+        price: tier.price,
+        type: 'wxpay',
+        params,
+      });
+    }
     const payUserId = context.payUserId || context.userId || context.authId;
     const resp = await this.requestJson(`${PARSE_API_HOST}/api/apig/created-apigorder`, {
       method: 'POST',

+ 10 - 0
src/app/services/cost-estimator.service.spec.ts

@@ -0,0 +1,10 @@
+import { CostEstimatorService } from './cost-estimator.service';
+
+describe('CostEstimatorService', () => {
+  it('matches backend billing for 720p five-second image-to-video', () => {
+    const estimate = new CostEstimatorService().estimateImageToVideo('720p', 5);
+
+    expect(estimate.totalCredits).toBe(14);
+    expect(estimate.lines[0].credits).toBe(14);
+  });
+});

+ 2 - 1
src/app/services/cost-estimator.service.ts

@@ -141,7 +141,8 @@ export class CostEstimatorService {
   }
 
   private cnyToCredits(cny: number): number {
-    return Math.max(1, Math.ceil(Math.max(0, Number(cny || 0)) / this.creditUnitCny));
+    const costCny = Math.round(Math.max(0, Number(cny || 0)) * 100) / 100;
+    return Math.max(1, Math.ceil(costCny / this.creditUnitCny));
   }
 
   private getVideoUnitPriceCny(quality: EstimateQuality): number {

+ 155 - 0
src/app/services/jimeng.service.spec.ts

@@ -0,0 +1,155 @@
+import { firstValueFrom } from 'rxjs';
+import { JimengService } from './jimeng.service';
+
+describe('JimengService', () => {
+  it('prechecks balance before submitting image-to-video tasks', async () => {
+    const parse = { callOrThrow: vi.fn() };
+    const authCredit = {
+      isLoggedIn: true,
+      getBalance: vi.fn().mockResolvedValue({ balance: 0 }),
+      session: { token: 'token-for-test' },
+      currentUser: { objectId: 'user-for-test' },
+      requestLogin: vi.fn(),
+    };
+    const service = new JimengService(
+      {} as any,
+      {} as any,
+      authCredit as any,
+      parse as any,
+    );
+
+    await expect(firstValueFrom(service.generateImageToVideo({
+      imageUrls: ['https://example.com/cover.png'],
+      prompt: '镜头环绕主体缓慢旋转一周',
+      method: '2',
+      quality: '720p',
+      frames: 121,
+    }))).rejects.toThrow('余额不足:当前 0,本次需要 14');
+
+    expect(authCredit.getBalance).toHaveBeenCalledTimes(1);
+    expect(parse.callOrThrow).not.toHaveBeenCalled();
+  });
+
+  it('does not submit generation when balance lookup fails', async () => {
+    const parse = { callOrThrow: vi.fn() };
+    const authCredit = {
+      isLoggedIn: true,
+      getBalance: vi.fn().mockRejectedValue(new Error('authCredit cloud function unavailable')),
+      session: { token: 'token-for-test' },
+      currentUser: { objectId: 'user-for-test' },
+      requestLogin: vi.fn(),
+    };
+    const service = new JimengService(
+      {} as any,
+      {} as any,
+      authCredit as any,
+      parse as any,
+    );
+
+    await expect(firstValueFrom(service.generateImgV4('生成一张产品图'))).rejects.toThrow('余额查询失败');
+
+    expect(authCredit.getBalance).toHaveBeenCalledTimes(1);
+    expect(parse.callOrThrow).not.toHaveBeenCalled();
+  });
+
+  it('passes the real session token and user id when submitting image-to-video tasks', async () => {
+    const parse = createJimengParseMock({ videoUrl: 'https://example.com/result.mp4' });
+    const authCredit = createAuthCreditMock(100);
+    const service = new JimengService(
+      {} as any,
+      {} as any,
+      authCredit as any,
+      parse as any,
+    );
+
+    const result = await firstValueFrom(service.generateImageToVideo({
+      imageUrls: ['https://example.com/cover.png'],
+      prompt: '镜头环绕主体缓慢旋转一周',
+      method: '2',
+      quality: '720p',
+      frames: 121,
+    }));
+
+    expect(result.videoUrl).toBe('https://example.com/result.mp4');
+    expect(parse.callOrThrow).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
+      action: 'call',
+      endpoint: 'getVideoV3_720p',
+      sessionToken: 'token-for-test',
+      userId: 'user-for-test',
+    }));
+  });
+
+  it('passes the real session token and user id when submitting image generation tasks', async () => {
+    const parse = createJimengParseMock({ imageUrl: 'https://example.com/result.png' });
+    const authCredit = createAuthCreditMock(100);
+    const service = new JimengService(
+      {} as any,
+      {} as any,
+      authCredit as any,
+      parse as any,
+    );
+
+    const result = await firstValueFrom(service.generateImgV4('生成一张产品图'));
+
+    expect(result).toBe('https://example.com/result.png');
+    expect(parse.callOrThrow).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
+      action: 'call',
+      endpoint: 'getImgV4',
+      sessionToken: 'token-for-test',
+      userId: 'user-for-test',
+    }));
+  });
+
+  it('passes the real session token and user id when submitting text-to-video tasks', async () => {
+    const parse = createJimengParseMock({ videoUrl: 'https://example.com/text-video.mp4' });
+    const authCredit = createAuthCreditMock(100);
+    const service = new JimengService(
+      {} as any,
+      {} as any,
+      authCredit as any,
+      parse as any,
+    );
+
+    const result = await firstValueFrom(service.remixVideo('镜头从城市上空推进', {
+      method: '1',
+      quality: '720p',
+      frames: 121,
+    }));
+
+    expect(result.videoUrl).toBe('https://example.com/text-video.mp4');
+    expect(parse.callOrThrow).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
+      action: 'call',
+      endpoint: 'getVideoV3_720p',
+      sessionToken: 'token-for-test',
+      userId: 'user-for-test',
+    }));
+  });
+});
+
+function createAuthCreditMock(balance: number): any {
+  return {
+    isLoggedIn: true,
+    getBalance: vi.fn().mockResolvedValue({ balance }),
+    session: { token: 'token-for-test' },
+    currentUser: { objectId: 'user-for-test' },
+    requestLogin: vi.fn(),
+  };
+}
+
+function createJimengParseMock(result: { videoUrl?: string; imageUrl?: string }): any {
+  return {
+    callOrThrow: vi.fn().mockImplementation(async (_fnId: string, params: any) => {
+      if (params.action === 'call' && params.endpoint === 'getDataByTask02') {
+        return { data: { tip: '已完成', isFinish: true } };
+      }
+      if (params.action === 'getWorkResult') {
+        return {
+          objectId: params.workId,
+          videos: result.videoUrl ? [result.videoUrl] : [],
+          images: result.imageUrl ? [result.imageUrl] : [],
+        };
+      }
+      return { code: 200, success: true, data: { workId: 'work-for-test' } };
+    }),
+  };
+}

+ 28 - 3
src/app/services/jimeng.service.ts

@@ -543,7 +543,11 @@ export class JimengService {
   }
 
   uploadFileToParse(file: Blob, filename: string, contentType?: string): Observable<string> {
-    return this.qiniuUpload.uploadFile(file, filename, contentType).pipe(
+    return this.qiniuUpload.uploadFile(file, filename, contentType, undefined, {
+      sourceModule: 'jimeng',
+      bizType: 'proxy-upload',
+      bizId: filename,
+    }).pipe(
       tap((url: string) => console.log('馃摛 鏁板瓧浜虹礌鏉愪笂浼犳垚鍔?', url)),
       catchError(this.handleError('uploadFileToParse'))
     );
@@ -1272,7 +1276,27 @@ export class JimengService {
       this.authCredit.requestLogin(title || operation);
       return throwError(() => new Error('请先登录后再生成'));
     }
-    return run();
+    if (!this.authCredit.session?.token || !this.authCredit.currentUser?.objectId) {
+      this.authCredit.requestLogin(title || operation);
+      return throwError(() => new Error('登录状态不完整,请重新登录后再生成'));
+    }
+    const requiredCredits = Math.max(0, Math.ceil(Number(cost || 0)));
+    if (!requiredCredits) return run();
+
+    // This is a read-only preflight. The authoritative reservation and refund
+    // are handled in cloud-functions/11-jimengManager.js with the same session.
+    return from(this.authCredit.getBalance().catch((error) => {
+      const message = String(error?.message || error || '');
+      throw new Error(`余额查询失败:无法确认当前积分余额,请刷新登录状态后重试。${message ? `原始错误:${message}` : ''}`);
+    })).pipe(
+      switchMap((balance) => {
+        const available = Math.max(0, Math.floor(Number(balance?.balance || 0)));
+        if (available < requiredCredits) {
+          return throwError(() => new Error(`余额不足:当前 ${available},本次需要 ${requiredCredits}。请先到用户中心充值后再使用。`));
+        }
+        return run();
+      })
+    );
   }
 
   private framesToSeconds(frames: number): number {
@@ -1287,7 +1311,8 @@ export class JimengService {
 
   private videoCreditCost(quality: JimengVideoQuality, seconds: number): number {
     const cnyPerSecond = quality === 'pro' ? 1 : quality === '1080p' ? 0.63 : 0.28;
-    return Math.ceil(cnyPerSecond * seconds * 10);
+    const costCny = Math.round(cnyPerSecond * seconds * 100) / 100;
+    return Math.max(1, Math.ceil(costCny / 0.1));
   }
 
   private handleError(operation: string) {