recharge-client.js 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. 'use strict';
  2. const { randomBytes } = require('crypto');
  3. const DEFAULT_PARSE_FUNCTIONS_URL = 'https://server.fmode.cn/parse/functions';
  4. const DEFAULT_RECHARGE_ORDER_URL = 'https://server.fmode.cn/api/fmode/recharge/order';
  5. const DEFAULT_PAY_COMPANY = '1AiWpTEDH9';
  6. const DEFAULT_NEW_API_CALLBACK = 'WbyPrzMI3A';
  7. function createTradeNo(prefix = 'VOC') {
  8. const stamp = new Date().toISOString().replace(/[-:.TZ]/g, '');
  9. return `${prefix}${stamp}${randomBytes(4).toString('hex')}`;
  10. }
  11. function buildRechargePaymentRequest({ amount, title = 'VOC 数据调用充值', tradeNo, sessionToken } = {}) {
  12. const numericAmount = Number(amount);
  13. if (!Number.isFinite(numericAmount) || numericAmount <= 0) {
  14. throw new Error('充值金额必须大于 0');
  15. }
  16. const outTradeNo = tradeNo || createTradeNo();
  17. const parseFunctionsUrl = String(process.env.FMODE_PARSE_FUNCTIONS_URL || DEFAULT_PARSE_FUNCTIONS_URL).replace(/\/$/, '');
  18. const body = {
  19. _ApplicationId: 'ncloudmaster',
  20. company: process.env.FMODE_PAY_COMPANY || DEFAULT_PAY_COMPANY,
  21. out_trade_no: outTradeNo,
  22. total_fee: Number(numericAmount.toFixed(2)),
  23. body: String(title).slice(0, 80),
  24. fun_id: process.env.FMODE_NEWAPI_RECHARGE_CALLBACK_FUN_ID || DEFAULT_NEW_API_CALLBACK
  25. };
  26. return {
  27. endpoint: `${parseFunctionsUrl}/pay_code2`,
  28. method: 'POST',
  29. headers: sessionToken ? {
  30. 'X-Parse-Application-Id': 'ncloudmaster',
  31. 'X-Parse-Session-Token': sessionToken
  32. } : { 'X-Parse-Application-Id': 'ncloudmaster' },
  33. body,
  34. tradeNo: outTradeNo,
  35. amountCny: body.total_fee,
  36. polling: {
  37. endpoint: `${parseFunctionsUrl}/order_status2`,
  38. method: 'POST',
  39. body: {
  40. _ApplicationId: 'ncloudmaster',
  41. out_trade_no: outTradeNo,
  42. company: body.company
  43. }
  44. }
  45. };
  46. }
  47. function buildRechargeOrderRequest({ amount, tradeNo, sessionToken, title = 'VOC 数据调用充值' } = {}) {
  48. const numericAmount = Number(amount);
  49. if (!Number.isFinite(numericAmount) || numericAmount <= 0) {
  50. throw new Error('充值金额必须大于 0');
  51. }
  52. if (!sessionToken) throw new Error('创建充值订单需要 Session Token');
  53. const outTradeNo = tradeNo || createTradeNo();
  54. const endpoint = String(process.env.FMODE_RECHARGE_ORDER_URL || DEFAULT_RECHARGE_ORDER_URL).replace(/\/$/, '');
  55. return {
  56. endpoint,
  57. method: 'POST',
  58. headers: {
  59. Accept: 'application/json',
  60. 'Content-Type': 'application/json'
  61. },
  62. body: {
  63. sessionToken,
  64. amount: Number(numericAmount.toFixed(2)),
  65. tradeNo: outTradeNo,
  66. detail: { product: 'voc-newapi-balance', source: 'voc-skill', title: String(title).slice(0, 80) }
  67. },
  68. tradeNo: outTradeNo,
  69. amountCny: Number(numericAmount.toFixed(2))
  70. };
  71. }
  72. async function requestPaymentQr({ amount, title, sessionToken, tradeNo, fetchImpl = fetch } = {}) {
  73. let request = buildRechargePaymentRequest({ amount, title, sessionToken, tradeNo });
  74. if (sessionToken) {
  75. const orderRequest = buildRechargeOrderRequest({ amount, title, sessionToken, tradeNo: request.tradeNo });
  76. const orderResponse = await fetchImpl(orderRequest.endpoint, {
  77. method: orderRequest.method,
  78. headers: orderRequest.headers,
  79. body: JSON.stringify(orderRequest.body)
  80. });
  81. const orderPayload = await orderResponse.json().catch(() => ({}));
  82. if (!orderResponse.ok || Number(orderPayload?.code) !== 200 || !orderPayload?.data?.tradeNo) {
  83. const error = new Error(orderPayload?.mess || orderPayload?.message || `recharge/order HTTP ${orderResponse.status}`);
  84. error.httpStatus = orderResponse.status;
  85. throw error;
  86. }
  87. request = buildRechargePaymentRequest({
  88. amount,
  89. title,
  90. sessionToken,
  91. tradeNo: String(orderPayload.data.tradeNo)
  92. });
  93. }
  94. const response = await fetchImpl(request.endpoint, {
  95. method: request.method,
  96. headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...request.headers },
  97. body: JSON.stringify(request.body)
  98. });
  99. const payload = await response.json().catch(() => ({}));
  100. // Parse Cloud functions wrap successful results in `result`; older proxies
  101. // returned the same fields at the top level. Accept both response shapes.
  102. const paymentPayload = payload?.result || payload?.data || payload || {};
  103. const rawCodeUrl = paymentPayload.code_url ?? payload?.code_url;
  104. const codeUrl = Array.isArray(rawCodeUrl) ? rawCodeUrl[0] : rawCodeUrl;
  105. if (!response.ok || !codeUrl) {
  106. const error = new Error(payload?.error || payload?.message || `pay_code2 HTTP ${response.status}`);
  107. error.httpStatus = response.status;
  108. throw error;
  109. }
  110. const nonceStr = paymentPayload.nonce_str || payload?.nonce_str || '';
  111. return {
  112. ...request,
  113. paymentMode: 'qr',
  114. qrCodeUrl: String(codeUrl),
  115. nonceStr,
  116. polling: {
  117. ...request.polling,
  118. body: {
  119. ...request.polling.body,
  120. ...(nonceStr ? { nonce_str: nonceStr } : {})
  121. }
  122. }
  123. };
  124. }
  125. module.exports = {
  126. DEFAULT_PARSE_FUNCTIONS_URL,
  127. DEFAULT_RECHARGE_ORDER_URL,
  128. createTradeNo,
  129. buildRechargeOrderRequest,
  130. buildRechargePaymentRequest,
  131. requestPaymentQr
  132. };