| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579 |
- /**
- * Cloud function: authCreditManager
- *
- * 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 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 {
- const action = clean(pickParam(request, 'action'));
- if (action === 'me') return me(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 === '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 自建密码账号。');
- }
- return response.json({ code: 400, success: false, error: `未知 action: ${action || '(empty)'}` });
- } catch (error) {
- 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 me(request, response) {
- const { user } = await requireParseUser(request);
- return response.json({ code: 200, success: true, data: parseUserToAppUser(user) });
- }
- async function balance(request, response) {
- const context = await loadCreditContext(request);
- return response.json({ code: 200, success: true, data: toCreditBalance(context) });
- }
- async function ledger(request, response) {
- 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 context = await loadCreditContext(request);
- const operation = clean(pickParam(request, 'operation')) || 'unknown';
- const title = clean(pickParam(request, 'title')) || operation;
- 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 },
- });
- }
- 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,
- },
- });
- }
- }
- 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 context = await loadCreditContext(request);
- const reservationId = clean(pickParam(request, 'reservationId'));
- 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 context = await loadCreditContext(request);
- const reservationId = clean(pickParam(request, 'reservationId'));
- const reason = clean(pickParam(request, 'reason')) || '任务失败退回积分';
- 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 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 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'));
- if (!authId || !apigId || count <= 0) {
- return response.json({ code: 400, success: false, error: '缺少有效的充值到账参数' });
- }
- 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,
- });
- }
- return response.json({ code: 200, success: true, data: true, raw: saved });
- }
- 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 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 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 ensureVideoWorkflowAuth(userId, sessionToken) {
- const existing = await findVideoWorkflowAuth(userId, sessionToken);
- if (existing) return existing;
- 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 updateApigAuth(authId, patch, sessionToken) {
- return parseRequest('PUT', `/classes/APIGAuth/${encodeURIComponent(authId)}`, patch, sessionToken);
- }
- 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 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 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 parseUserToAppUser(user) {
- const mobile = user.mobilePhoneNumber || user.mobile || '';
- return {
- objectId: user.objectId,
- username: user.username || mobile || user.objectId,
- 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,
- };
- }
- function toCreditBalance(context) {
- return {
- 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 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,
- 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 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 && request.params, request && request.body, request && request.query, request];
- for (const src of sources) {
- if (!src || typeof src !== 'object') continue;
- for (const name of names) {
- const value = src[name];
- if (value !== undefined && value !== null && value !== '') return value;
- }
- }
- return null;
- }
- function headerValue(request, name) {
- const headers = request && 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 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 id = '';
- for (let i = 0; i < (length || 10); i += 1) {
- id += chars.charAt(Math.floor(Math.random() * chars.length));
- }
- return id;
- }
|