/** * 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; }