10-authCreditManager.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579
  1. /**
  2. * Cloud function: authCreditManager
  3. *
  4. * This file is the account and credit gateway for the current video-workflow
  5. * project. It intentionally uses the existing Parse user session and APIGAuth
  6. * balance instead of creating a second AppUser/UserCreditAccount system.
  7. *
  8. * Data source of truth:
  9. * - Login/session: Parse _User + X-Parse-Session-Token
  10. * - Balance: APIGAuth.count for VIDEO_WORKFLOW_APIG_ID
  11. * - Consumption marker: APIGAuth.used + APIGAuth.lastBilling
  12. * - Recharge: existing APIGOrder/saveRecharge platform endpoints
  13. */
  14. const PARSE_API_HOST = env('PARSE_API_HOST', 'https://server.fmode.cn');
  15. const PARSE_APP_ID = env('PARSE_APP_ID', 'ncloudmaster');
  16. const VIDEO_WORKFLOW_APIG_ID = env('VIDEO_WORKFLOW_APIG_ID', '6pFf6EAdKT');
  17. const DEFAULT_APIG_TITLE = env('VIDEO_WORKFLOW_APIG_TITLE', '短视频AI工作流');
  18. const DEFAULT_UNIT_PRICE_CNY = Number(env('VIDEO_WORKFLOW_APIG_UNIT_PRICE_CNY', '0.1')) || 0.1;
  19. async function handler(request, response) {
  20. try {
  21. const action = clean(pickParam(request, 'action'));
  22. if (action === 'me') return me(request, response);
  23. if (action === 'balance') return balance(request, response);
  24. if (action === 'ledger') return ledger(request, response);
  25. if (action === 'rechargeContext') return rechargeContext(request, response);
  26. if (action === 'reserve') return reserve(request, response);
  27. if (action === 'commitReservation') return commitReservation(request, response);
  28. if (action === 'refundReservation') return refundReservation(request, response);
  29. if (action === 'createRechargeOrder') return createRechargeOrder(request, response);
  30. if (action === 'saveRecharge') return saveRecharge(request, response);
  31. if (action === 'adminListUsers') return unsupported(response, '账号列表请继续使用 Parse 用户体系或后续单独建设管理后台。');
  32. if (action === 'adminCreateUser') return unsupported(response, '账号创建请继续使用 Parse 用户体系,不再由 authCreditManager 自建账号。');
  33. if (action === 'adminAdjustCredit') return unsupported(response, '调额请直接调整 APIGAuth 或后续建设受控管理员云函数。');
  34. if (action === 'register' || action === 'login' || action === 'changePassword') {
  35. return unsupported(response, '当前项目使用 Parse 手机验证码登录,不再使用 authCreditManager 自建密码账号。');
  36. }
  37. return response.json({ code: 400, success: false, error: `未知 action: ${action || '(empty)'}` });
  38. } catch (error) {
  39. const status = Number(error && (error.status || error.statusCode)) || 500;
  40. console.error('authCreditManager failed:', error && error.message, error && error.stack);
  41. return response.json({
  42. code: status,
  43. success: false,
  44. error: error && error.message ? error.message : '账号积分服务调用失败',
  45. detail: error && error.detail ? error.detail : undefined,
  46. });
  47. }
  48. }
  49. async function me(request, response) {
  50. const { user } = await requireParseUser(request);
  51. return response.json({ code: 200, success: true, data: parseUserToAppUser(user) });
  52. }
  53. async function balance(request, response) {
  54. const context = await loadCreditContext(request);
  55. return response.json({ code: 200, success: true, data: toCreditBalance(context) });
  56. }
  57. async function ledger(request, response) {
  58. const context = await loadCreditContext(request);
  59. const limit = clampNumber(pickParam(request, 'limit'), 1, 200, 50);
  60. const items = [];
  61. const lastBilling = context.auth && context.auth.lastBilling && typeof context.auth.lastBilling === 'object'
  62. ? context.auth.lastBilling
  63. : null;
  64. if (lastBilling) {
  65. items.push({
  66. objectId: lastBilling.reservationId || lastBilling.idempotencyKey || `lastBilling-${context.auth.objectId}`,
  67. userId: context.user.objectId,
  68. type: lastBilling.status === 'refunded' ? 'refund' : 'consume',
  69. amount: -Math.abs(Number(lastBilling.costCredits || 0)),
  70. balanceAfter: Number(lastBilling.balanceAfter || context.balance),
  71. title: lastBilling.title || lastBilling.operation || '生成扣费记录',
  72. detail: lastBilling,
  73. createdAt: lastBilling.updatedAt || context.auth.updatedAt || context.auth.createdAt || new Date().toISOString(),
  74. });
  75. }
  76. return response.json({ code: 200, success: true, data: items.slice(0, limit) });
  77. }
  78. async function rechargeContext(request, response) {
  79. const context = await loadCreditContext(request);
  80. return response.json({
  81. code: 200,
  82. success: true,
  83. data: {
  84. authId: context.auth.objectId,
  85. userId: context.user.objectId,
  86. payUserId: readCompanyId(context.user) || context.auth.objectId,
  87. apig: {
  88. objectId: context.apig.objectId,
  89. title: context.apig.title,
  90. count: context.balance,
  91. priceStep: normalizePriceSteps(context.apig.priceStep),
  92. },
  93. },
  94. });
  95. }
  96. async function reserve(request, response) {
  97. const context = await loadCreditContext(request);
  98. const operation = clean(pickParam(request, 'operation')) || 'unknown';
  99. const title = clean(pickParam(request, 'title')) || operation;
  100. const detail = normalizeObject(pickParam(request, 'detail'));
  101. const cost = Math.max(0, Number(pickParam(request, 'cost') || 0));
  102. const idempotencyKey = clean(pickParam(request, 'idempotencyKey'))
  103. || `${operation}:${context.user.objectId}:${stableJson({ cost, title, detail })}`;
  104. if (cost <= 0) {
  105. return response.json({
  106. code: 200,
  107. success: true,
  108. data: { reservationId: `free-${Date.now()}`, balance: context.balance, cost: 0 },
  109. });
  110. }
  111. const lastBilling = context.auth.lastBilling && typeof context.auth.lastBilling === 'object'
  112. ? context.auth.lastBilling
  113. : null;
  114. if (lastBilling && lastBilling.idempotencyKey === idempotencyKey) {
  115. if (lastBilling.status === 'reserved' || lastBilling.status === 'submitted' || lastBilling.status === 'committed') {
  116. return response.json({
  117. code: 200,
  118. success: true,
  119. data: {
  120. reservationId: lastBilling.reservationId,
  121. balance: Number(lastBilling.balanceAfter || context.balance),
  122. cost: Number(lastBilling.costCredits || cost),
  123. reused: true,
  124. },
  125. });
  126. }
  127. }
  128. if (context.balance < cost) {
  129. return response.json({
  130. code: 402,
  131. success: false,
  132. error: `余额不足:当前 ${context.balance},本次需要 ${cost}`,
  133. });
  134. }
  135. const reservationId = generateId(16);
  136. const oldUsed = Number(context.auth.used || 0);
  137. const nextBalance = context.balance - cost;
  138. const nextUsed = oldUsed + cost;
  139. const now = new Date().toISOString();
  140. const lastBillingPatch = {
  141. kind: 'video_workflow_consume',
  142. status: 'reserved',
  143. reservationId,
  144. idempotencyKey,
  145. operation,
  146. title,
  147. costCredits: cost,
  148. unitPriceCny: DEFAULT_UNIT_PRICE_CNY,
  149. balanceBefore: context.balance,
  150. balanceAfter: nextBalance,
  151. usedBefore: oldUsed,
  152. usedAfter: nextUsed,
  153. detail,
  154. updatedAt: now,
  155. };
  156. await updateApigAuth(context.auth.objectId, {
  157. count: nextBalance,
  158. used: nextUsed,
  159. lastBilling: lastBillingPatch,
  160. }, context.sessionToken);
  161. return response.json({
  162. code: 200,
  163. success: true,
  164. data: { reservationId, balance: nextBalance, cost },
  165. });
  166. }
  167. async function commitReservation(request, response) {
  168. const context = await loadCreditContext(request);
  169. const reservationId = clean(pickParam(request, 'reservationId'));
  170. const detail = normalizeObject(pickParam(request, 'detail'));
  171. const lastBilling = context.auth.lastBilling && typeof context.auth.lastBilling === 'object'
  172. ? context.auth.lastBilling
  173. : null;
  174. if (!lastBilling || lastBilling.reservationId !== reservationId) {
  175. return response.json({ code: 200, success: true, data: true, ignored: true });
  176. }
  177. await updateApigAuth(context.auth.objectId, {
  178. lastBilling: {
  179. ...lastBilling,
  180. status: 'committed',
  181. commitDetail: detail,
  182. updatedAt: new Date().toISOString(),
  183. },
  184. }, context.sessionToken);
  185. return response.json({ code: 200, success: true, data: true });
  186. }
  187. async function refundReservation(request, response) {
  188. const context = await loadCreditContext(request);
  189. const reservationId = clean(pickParam(request, 'reservationId'));
  190. const reason = clean(pickParam(request, 'reason')) || '任务失败退回积分';
  191. const lastBilling = context.auth.lastBilling && typeof context.auth.lastBilling === 'object'
  192. ? context.auth.lastBilling
  193. : null;
  194. if (!lastBilling || lastBilling.reservationId !== reservationId || lastBilling.status === 'refunded') {
  195. return response.json({ code: 200, success: true, data: true, ignored: true });
  196. }
  197. const refundCredits = Math.abs(Number(lastBilling.costCredits || 0));
  198. const currentUsed = Number(context.auth.used || 0);
  199. const nextBalance = context.balance + refundCredits;
  200. const nextUsed = Math.max(0, currentUsed - refundCredits);
  201. await updateApigAuth(context.auth.objectId, {
  202. count: nextBalance,
  203. used: nextUsed,
  204. lastBilling: {
  205. ...lastBilling,
  206. status: 'refunded',
  207. refundReason: reason,
  208. balanceAfter: nextBalance,
  209. usedAfter: nextUsed,
  210. updatedAt: new Date().toISOString(),
  211. },
  212. }, context.sessionToken);
  213. return response.json({ code: 200, success: true, data: true });
  214. }
  215. async function createRechargeOrder(request, response) {
  216. const context = await loadCreditContext(request);
  217. const count = Math.max(0, Number(pickParam(request, 'count') || 0));
  218. const amountCny = Math.max(0, Number(pickParam(request, 'amountCny') || pickParam(request, 'price') || 0));
  219. const params = normalizeObject(pickParam(request, 'params'));
  220. if (count <= 0 || amountCny <= 0) {
  221. return response.json({ code: 400, success: false, error: '缺少有效的充值积分或充值金额' });
  222. }
  223. const payUserId = readCompanyId(context.user) || context.auth.objectId;
  224. const order = await requestJson(`${apiBase()}/api/apig/created-apigorder`, {
  225. method: 'POST',
  226. headers: { 'Content-Type': 'application/json' },
  227. body: JSON.stringify({
  228. user: payUserId,
  229. fcompany: payUserId,
  230. type: clean(pickParam(request, 'type')) || 'wxpay',
  231. authid: context.auth.objectId,
  232. params,
  233. apigid: context.apig.objectId,
  234. oldCount: context.balance,
  235. count,
  236. amountCny,
  237. }),
  238. }, 20000);
  239. if (order && Number(order.code) === 200 && order.data) {
  240. return response.json({ code: 200, success: true, data: order.data });
  241. }
  242. return response.json({
  243. code: 500,
  244. success: false,
  245. error: order && (order.message || order.error) || '创建 APIGOrder 失败',
  246. raw: order,
  247. });
  248. }
  249. async function saveRecharge(request, response) {
  250. const context = await loadCreditContext(request);
  251. const payUserId = clean(pickParam(request, 'payUserId')) || readCompanyId(context.user) || context.auth.objectId;
  252. const authId = clean(pickParam(request, 'authId')) || context.auth.objectId;
  253. const apigId = clean(pickParam(request, 'apigId')) || context.apig.objectId;
  254. const oldCount = Math.max(0, Number(pickParam(request, 'oldCount') || context.balance || 0));
  255. const count = Math.max(0, Number(pickParam(request, 'count') || 0));
  256. const orderId = clean(pickParam(request, 'orderId'));
  257. if (!authId || !apigId || count <= 0) {
  258. return response.json({ code: 400, success: false, error: '缺少有效的充值到账参数' });
  259. }
  260. const saved = await requestJson(`${apiBase()}/api/apig/saveRecharge`, {
  261. method: 'POST',
  262. headers: { 'Content-Type': 'application/json' },
  263. body: JSON.stringify({
  264. user: payUserId,
  265. authComp: payUserId,
  266. authid: authId,
  267. apigid: apigId,
  268. oldCount,
  269. count,
  270. orderid: orderId,
  271. }),
  272. }, 20000);
  273. if (saved && saved.code && Number(saved.code) >= 400) {
  274. return response.json({
  275. code: Number(saved.code),
  276. success: false,
  277. error: saved.message || saved.error || '充值到账失败',
  278. raw: saved,
  279. });
  280. }
  281. return response.json({ code: 200, success: true, data: true, raw: saved });
  282. }
  283. async function loadCreditContext(request) {
  284. const { sessionToken, user } = await requireParseUser(request);
  285. const auth = await ensureVideoWorkflowAuth(user.objectId, sessionToken);
  286. const apigInfo = auth && auth.objectId ? await fetchApigPayInfo(auth.objectId).catch(() => null) : null;
  287. const apig = normalizeApig(auth, apigInfo);
  288. const balance = Number(apigInfo && apigInfo.count !== undefined ? apigInfo.count : auth.count || 0);
  289. const used = Number(apigInfo && apigInfo.used !== undefined ? apigInfo.used : auth.used || 0);
  290. return { sessionToken, user, auth: { ...auth, count: balance, used }, apig, balance, used };
  291. }
  292. async function requireParseUser(request) {
  293. const sessionToken = clean(pickParam(request, 'sessionToken') || headerValue(request, 'x-parse-session-token'));
  294. if (!sessionToken) {
  295. const error = new Error('请先登录');
  296. error.status = 401;
  297. throw error;
  298. }
  299. const user = await parseRequest('GET', '/users/me?include=company', undefined, sessionToken);
  300. if (!user || !user.objectId) {
  301. const error = new Error('登录已失效,请重新登录');
  302. error.status = 401;
  303. throw error;
  304. }
  305. return { sessionToken, user };
  306. }
  307. async function findVideoWorkflowAuth(userId, sessionToken) {
  308. const where = encodeURIComponent(JSON.stringify({
  309. api: { __type: 'Pointer', className: 'APIG', objectId: VIDEO_WORKFLOW_APIG_ID },
  310. user: { __type: 'Pointer', className: '_User', objectId: userId },
  311. }));
  312. const data = await parseRequest('GET', `/classes/APIGAuth?where=${where}&include=api&limit=1`, undefined, sessionToken);
  313. return Array.isArray(data.results) ? data.results[0] || null : null;
  314. }
  315. async function ensureVideoWorkflowAuth(userId, sessionToken) {
  316. const existing = await findVideoWorkflowAuth(userId, sessionToken);
  317. if (existing) return existing;
  318. const body = {
  319. api: { __type: 'Pointer', className: 'APIG', objectId: VIDEO_WORKFLOW_APIG_ID },
  320. user: { __type: 'Pointer', className: '_User', objectId: userId },
  321. count: 0,
  322. used: 0,
  323. };
  324. const created = await parseRequest('POST', '/classes/APIGAuth', body, sessionToken);
  325. return { ...body, ...created };
  326. }
  327. async function updateApigAuth(authId, patch, sessionToken) {
  328. return parseRequest('PUT', `/classes/APIGAuth/${encodeURIComponent(authId)}`, patch, sessionToken);
  329. }
  330. async function fetchApigPayInfo(authId) {
  331. const resp = await requestJson(`${apiBase()}/api/apig/getApig`, {
  332. method: 'POST',
  333. headers: { 'Content-Type': 'application/json' },
  334. body: JSON.stringify({ authid: authId }),
  335. }, 15000);
  336. if (resp && Number(resp.code) === 200 && resp.data) return resp.data;
  337. return null;
  338. }
  339. async function parseRequest(method, path, body, sessionToken) {
  340. const url = `${apiBase()}/parse${path}`;
  341. const headers = {
  342. 'Content-Type': 'application/json',
  343. 'Accept': 'application/json',
  344. 'X-Parse-Application-Id': PARSE_APP_ID,
  345. };
  346. if (sessionToken) headers['X-Parse-Session-Token'] = sessionToken;
  347. const init = { method, headers };
  348. if (body !== undefined) init.body = JSON.stringify(body);
  349. const { status, ok, rawText, data } = await requestJsonWithMeta(url, init, 15000);
  350. if (!ok) {
  351. const error = new Error(readErrorMessage(data, rawText) || `Parse ${method} ${path} failed`);
  352. error.status = status || 500;
  353. error.detail = data || rawText || '';
  354. throw error;
  355. }
  356. return data;
  357. }
  358. async function requestJson(url, init, timeoutMs) {
  359. const result = await requestJsonWithMeta(url, init, timeoutMs);
  360. if (!result.ok) {
  361. const error = new Error(readErrorMessage(result.data, result.rawText) || `HTTP ${result.status}`);
  362. error.status = result.status || 500;
  363. error.detail = result.data || result.rawText || '';
  364. throw error;
  365. }
  366. return result.data;
  367. }
  368. async function requestJsonWithMeta(url, init, timeoutMs) {
  369. const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
  370. const timer = controller ? setTimeout(() => controller.abort(), timeoutMs || 15000) : null;
  371. try {
  372. const resp = await fetch(url, { ...init, signal: controller ? controller.signal : undefined });
  373. const rawText = await resp.text();
  374. const data = safeJson(rawText);
  375. return { status: resp.status, ok: resp.ok, rawText, data };
  376. } catch (error) {
  377. if (error && error.name === 'AbortError') {
  378. const timeoutError = new Error('请求超时,请稍后重试');
  379. timeoutError.status = 504;
  380. throw timeoutError;
  381. }
  382. throw error;
  383. } finally {
  384. if (timer) clearTimeout(timer);
  385. }
  386. }
  387. function parseUserToAppUser(user) {
  388. const mobile = user.mobilePhoneNumber || user.mobile || '';
  389. return {
  390. objectId: user.objectId,
  391. username: user.username || mobile || user.objectId,
  392. email: user.email || '',
  393. phone: mobile,
  394. displayName: user.nickname || user.name || user.displayName || maskMobile(mobile) || user.username || user.objectId,
  395. companyId: readCompanyId(user),
  396. role: user.role === 'admin' || user.isAdmin === true ? 'admin' : 'user',
  397. createdAt: user.createdAt,
  398. };
  399. }
  400. function toCreditBalance(context) {
  401. return {
  402. balance: Number(context.balance || 0),
  403. gifted: 0,
  404. totalRecharged: Number(context.balance || 0) + Number(context.used || 0),
  405. totalConsumed: Number(context.used || 0),
  406. authId: context.auth.objectId,
  407. apigId: context.apig.objectId,
  408. };
  409. }
  410. function normalizeApig(auth, apigInfo) {
  411. const source = apigInfo || auth.api || {};
  412. const objectId = source.objectId || source.api && source.api.objectId || auth.api && auth.api.objectId || VIDEO_WORKFLOW_APIG_ID;
  413. return {
  414. objectId,
  415. title: source.title || source.api && source.api.title || auth.api && auth.api.title || DEFAULT_APIG_TITLE,
  416. count: Number(source.count !== undefined ? source.count : auth.count || 0),
  417. used: Number(source.used !== undefined ? source.used : auth.used || 0),
  418. price: Number(source.price || auth.api && auth.api.price || DEFAULT_UNIT_PRICE_CNY),
  419. priceStep: source.priceStep || source.api && source.api.priceStep || [],
  420. };
  421. }
  422. function normalizePriceSteps(raw) {
  423. const rows = Array.isArray(raw) ? raw : [];
  424. return rows
  425. .map((item) => {
  426. const row = item && typeof item === 'object' ? item : {};
  427. return { count: Number(row.count || 0), price: Number(row.price || 0) };
  428. })
  429. .filter((item) => item.count > 0 && item.price > 0);
  430. }
  431. function readCompanyId(user) {
  432. const company = user && user.company;
  433. return company && typeof company === 'object' ? clean(company.objectId) : '';
  434. }
  435. function unsupported(response, message) {
  436. return response.json({ code: 501, success: false, error: message });
  437. }
  438. function pickParam(request, ...names) {
  439. const sources = [request && request.params, request && request.body, request && request.query, request];
  440. for (const src of sources) {
  441. if (!src || typeof src !== 'object') continue;
  442. for (const name of names) {
  443. const value = src[name];
  444. if (value !== undefined && value !== null && value !== '') return value;
  445. }
  446. }
  447. return null;
  448. }
  449. function headerValue(request, name) {
  450. const headers = request && request.headers || {};
  451. const target = String(name || '').toLowerCase();
  452. for (const key of Object.keys(headers)) {
  453. if (key.toLowerCase() === target) return headers[key];
  454. }
  455. return '';
  456. }
  457. function env(name, fallback) {
  458. if (typeof process !== 'undefined' && process.env && process.env[name] !== undefined) {
  459. return process.env[name];
  460. }
  461. return fallback;
  462. }
  463. function apiBase() {
  464. return String(PARSE_API_HOST || '').replace(/\/+$/, '').replace(/\/parse$/i, '');
  465. }
  466. function clean(value) {
  467. return String(value || '').trim();
  468. }
  469. function clampNumber(value, min, max, fallback) {
  470. const n = Number(value);
  471. if (!Number.isFinite(n)) return fallback;
  472. return Math.max(min, Math.min(max, Math.floor(n)));
  473. }
  474. function normalizeObject(value) {
  475. if (!value) return {};
  476. if (typeof value === 'object') return value;
  477. if (typeof value === 'string') {
  478. const parsed = safeJson(value);
  479. return parsed && typeof parsed === 'object' ? parsed : { value };
  480. }
  481. return { value };
  482. }
  483. function stableJson(value) {
  484. if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
  485. if (value && typeof value === 'object') {
  486. return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(',')}}`;
  487. }
  488. return JSON.stringify(value);
  489. }
  490. function safeJson(text) {
  491. try {
  492. return text ? JSON.parse(text) : null;
  493. } catch {
  494. return null;
  495. }
  496. }
  497. function readErrorMessage(data, fallback) {
  498. if (typeof data === 'string') return data;
  499. if (!data || typeof data !== 'object') return fallback || '';
  500. return data.error || data.message || data.msg || data.data && (data.data.error || data.data.message) || fallback || '';
  501. }
  502. function maskMobile(mobile) {
  503. const value = String(mobile || '');
  504. return /^1[3-9]\d{9}$/.test(value) ? value.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') : value;
  505. }
  506. function generateId(length) {
  507. const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  508. let id = '';
  509. for (let i = 0; i < (length || 10); i += 1) {
  510. id += chars.charAt(Math.floor(Math.random() * chars.length));
  511. }
  512. return id;
  513. }