wechatServicePay.test.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. import test from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import fs from 'node:fs';
  4. import vm from 'node:vm';
  5. import config from '../src/config/index.js';
  6. import {
  7. fulfillWechatServiceOrder,
  8. MiaoguoWechatServicePayLogin500,
  9. MiaoguoWechatServicePayNotify500,
  10. MiaoguoWechatServicePayOrderStatus500
  11. } from '../src/api/wechatServicePay/wechatServicePayController.js';
  12. import { buildV2Sign, buildV2Xml, parseV2Xml } from '../src/api/wechatServicePay/wechatPayV2.js';
  13. function signed(fields) {
  14. const result = { ...fields };
  15. result.sign = buildV2Sign(result, config.wx.payapisecret);
  16. return result;
  17. }
  18. function createConnection({ order, serviceUser, miaoguoUsers = [] }) {
  19. const state = {
  20. began: 0,
  21. committed: 0,
  22. rolledBack: 0,
  23. released: 0,
  24. queries: []
  25. };
  26. const connection = {
  27. async beginTransaction() { state.began += 1; },
  28. async commit() { state.committed += 1; },
  29. async rollback() { state.rolledBack += 1; },
  30. release() { state.released += 1; },
  31. async query(sql, params) {
  32. state.queries.push({ sql, params });
  33. if (sql.includes('FROM ProductPayInfo')) return [[order]];
  34. if (sql.includes('FROM WechatServiceWXUsers')) return [[serviceUser]];
  35. if (sql.includes('FROM MiaoguoWXUsers')) return [miaoguoUsers];
  36. return [{ affectedRows: 1 }];
  37. }
  38. };
  39. return { connection, state };
  40. }
  41. function pendingOrder(overrides = {}) {
  42. return {
  43. ID: 501,
  44. TradeNo: 'WSP178784640012345678',
  45. ProductID: 167,
  46. PayType: 8,
  47. BuyType: 131,
  48. OpenID: 'service-openid',
  49. Money: 100,
  50. Status: 0,
  51. ...overrides
  52. };
  53. }
  54. const serviceUser = {
  55. UserID: 88,
  56. OpenID: 'service-openid',
  57. UnionID: 'union-test'
  58. };
  59. const paidResult = {
  60. openid: 'service-openid',
  61. totalFee: 100,
  62. transactionId: '420000000020260828000000001',
  63. timeEnd: '20260828120000',
  64. raw: { source: 'test' }
  65. };
  66. test('服务号支付完成在一个事务中写完整订单并发放 16 天试用', async () => {
  67. const { connection, state } = createConnection({
  68. order: pendingOrder(),
  69. serviceUser,
  70. miaoguoUsers: [{ UserID: 1, UnionID: 'union-test' }]
  71. });
  72. const postActions = [];
  73. const result = await fulfillWechatServiceOrder(pendingOrder().TradeNo, paidResult, {
  74. getConnection: async () => connection,
  75. schedulePostCommit: callback => callback(),
  76. runPostPayActions: async payload => { postActions.push(payload); }
  77. });
  78. await new Promise(resolve => setImmediate(resolve));
  79. assert.equal(result.alreadyFulfilled, false);
  80. assert.equal(result.serviceUserID, 88);
  81. assert.deepEqual(result.miaoguoUserIDs, [1]);
  82. assert.equal(result.trialEnd, '2026-09-13 12:00:00');
  83. assert.equal(state.committed, 1);
  84. assert.equal(state.rolledBack, 0);
  85. assert.equal(state.released, 1);
  86. assert.ok(state.queries.some(item => item.sql.includes('UPDATE WechatServiceWXUsers SET IsProbation=1')));
  87. assert.ok(state.queries.some(item => item.sql.includes('UPDATE MiaoguoWXUsers SET ProductServiceTime=')));
  88. const payUpdate = state.queries.find(item => item.sql.includes('UPDATE ProductPayInfo'));
  89. assert.ok(payUpdate);
  90. assert.match(payUpdate.sql, /Status=1/);
  91. assert.equal(payUpdate.params[2], '2026-09-13 12:00:00');
  92. assert.equal(payUpdate.params[3], 88);
  93. assert.equal(payUpdate.params[4], 501);
  94. assert.equal(postActions.length, 1);
  95. });
  96. test('重复支付通知不会重复延长试用权益', async () => {
  97. const { connection, state } = createConnection({
  98. order: pendingOrder({ Status: 1 }),
  99. serviceUser
  100. });
  101. const result = await fulfillWechatServiceOrder(pendingOrder().TradeNo, paidResult, {
  102. getConnection: async () => connection,
  103. runPostPayActions: async () => assert.fail('重复通知不应触发支付后动作')
  104. });
  105. assert.equal(result.alreadyFulfilled, true);
  106. assert.equal(state.committed, 1);
  107. assert.equal(state.queries.some(item => item.sql.includes('UPDATE ')), false);
  108. });
  109. test('微信实付金额不等于 100 分时回滚且不发权益', async () => {
  110. const { connection, state } = createConnection({ order: pendingOrder(), serviceUser });
  111. await assert.rejects(
  112. fulfillWechatServiceOrder(pendingOrder().TradeNo, { ...paidResult, totalFee: 1 }, {
  113. getConnection: async () => connection
  114. }),
  115. /金额与本地订单不一致/
  116. );
  117. assert.equal(state.committed, 0);
  118. assert.equal(state.rolledBack, 1);
  119. assert.equal(state.queries.some(item => item.sql.includes('UPDATE ')), false);
  120. });
  121. test('下单忽略客户端 money,统一下单与数据库都固定为 100 分', async () => {
  122. const databaseCalls = [];
  123. const httpClient = {
  124. async get() {
  125. return { data: { openid: 'service-openid' } };
  126. },
  127. async post(_url, xml) {
  128. const request = parseV2Xml(xml);
  129. assert.equal(request.total_fee, '100');
  130. assert.equal(request.trade_type, 'JSAPI');
  131. assert.equal(request.openid, 'service-openid');
  132. return {
  133. data: buildV2Xml(signed({
  134. return_code: 'SUCCESS',
  135. result_code: 'SUCCESS',
  136. appid: String(config.wx.wechatservice_appid),
  137. mch_id: String(config.wx.mch_id),
  138. nonce_str: 'wechat-response-nonce',
  139. trade_type: 'JSAPI',
  140. prepay_id: 'wx-prepay-test'
  141. }))
  142. };
  143. }
  144. };
  145. const ctx = {
  146. req: { socket: { remoteAddress: '127.0.0.1' } },
  147. request: {
  148. headers: { 'x-forwarded-for': '203.0.113.8' },
  149. body: { code: 'oauth-code', money: '0.01' }
  150. }
  151. };
  152. await MiaoguoWechatServicePayLogin500(ctx, {
  153. httpClient,
  154. query: async (sql, params) => {
  155. databaseCalls.push({ sql, params });
  156. if (sql.includes('FROM WechatServiceWXUsers'))
  157. return [{ UserID: 88, OpenID: 'service-openid', Subscribe: 1, IsProbation: 0 }];
  158. return { insertId: 501 };
  159. }
  160. });
  161. assert.equal(ctx.body.errcode, 10000);
  162. assert.equal(ctx.body.result.package, 'prepay_id=wx-prepay-test');
  163. assert.match(ctx.body.result.timeStamp, /^\d{10}$/);
  164. const insert = databaseCalls.find(item => item.sql.includes('INSERT INTO ProductPayInfo'));
  165. assert.ok(insert);
  166. assert.equal(insert.params[1], 8);
  167. assert.equal(insert.params[2], 131);
  168. assert.equal(insert.params[7], 100);
  169. assert.equal(insert.params[9], 167);
  170. });
  171. test('合法签名的微信通知进入统一完成函数并返回 SUCCESS', async () => {
  172. const calls = [];
  173. const fields = signed({
  174. return_code: 'SUCCESS',
  175. result_code: 'SUCCESS',
  176. appid: String(config.wx.wechatservice_appid),
  177. mch_id: String(config.wx.mch_id),
  178. nonce_str: 'notify-nonce',
  179. out_trade_no: pendingOrder().TradeNo,
  180. openid: 'service-openid',
  181. total_fee: '100',
  182. fee_type: 'CNY',
  183. trade_type: 'JSAPI',
  184. transaction_id: paidResult.transactionId,
  185. time_end: paidResult.timeEnd
  186. });
  187. const ctx = { request: { body: { xml: {}, rawBody: buildV2Xml(fields) } } };
  188. await MiaoguoWechatServicePayNotify500(ctx, {
  189. fulfillOrder: async (...args) => { calls.push(args); return { alreadyFulfilled: false }; }
  190. });
  191. assert.equal(calls.length, 1);
  192. assert.equal(calls[0][0], pendingOrder().TradeNo);
  193. assert.equal(calls[0][1].totalFee, 100);
  194. assert.equal(parseV2Xml(ctx.body).return_code, 'SUCCESS');
  195. });
  196. test('伪造或被篡改的微信通知不会发放权益', async () => {
  197. let fulfilled = false;
  198. const fields = signed({
  199. return_code: 'SUCCESS',
  200. result_code: 'SUCCESS',
  201. appid: String(config.wx.wechatservice_appid),
  202. mch_id: String(config.wx.mch_id),
  203. nonce_str: 'notify-nonce',
  204. out_trade_no: pendingOrder().TradeNo,
  205. openid: 'service-openid',
  206. total_fee: '100',
  207. transaction_id: paidResult.transactionId
  208. });
  209. fields.total_fee = '1';
  210. const ctx = { request: { body: { xml: fields } } };
  211. await MiaoguoWechatServicePayNotify500(ctx, {
  212. fulfillOrder: async () => { fulfilled = true; },
  213. logger: { error() {} }
  214. });
  215. assert.equal(fulfilled, false);
  216. assert.equal(parseV2Xml(ctx.body).return_code, 'FAIL');
  217. });
  218. test('主动查单确认 SUCCESS 后补走统一完成函数', async () => {
  219. const order = pendingOrder();
  220. const fulfillCalls = [];
  221. const response = signed({
  222. return_code: 'SUCCESS',
  223. result_code: 'SUCCESS',
  224. appid: String(config.wx.wechatservice_appid),
  225. mch_id: String(config.wx.mch_id),
  226. nonce_str: 'query-response-nonce',
  227. out_trade_no: order.TradeNo,
  228. openid: order.OpenID,
  229. total_fee: '100',
  230. trade_type: 'JSAPI',
  231. trade_state: 'SUCCESS',
  232. transaction_id: paidResult.transactionId,
  233. time_end: paidResult.timeEnd
  234. });
  235. const ctx = { request: { body: { TradeNo: order.TradeNo } } };
  236. await MiaoguoWechatServicePayOrderStatus500(ctx, {
  237. query: async () => [order],
  238. httpClient: { async post() { return { data: buildV2Xml(response) }; } },
  239. fulfillOrder: async (...args) => {
  240. fulfillCalls.push(args);
  241. return { alreadyFulfilled: false, trialEnd: '2026-09-13 12:00:00' };
  242. }
  243. });
  244. assert.equal(ctx.body.errcode, 10000);
  245. assert.equal(ctx.body.result.Status, 1);
  246. assert.equal(fulfillCalls.length, 1);
  247. assert.equal(fulfillCalls[0][1].totalFee, 100);
  248. });
  249. test('网页不再提交客户端金额,并在前端回调后主动查单', () => {
  250. const html = fs.readFileSync(new URL('../public/wcs/pay.html', import.meta.url), 'utf8');
  251. const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].map(match => match[1]);
  252. assert.equal(html.includes('jquery-1.6.4'), false);
  253. assert.equal(html.includes('param.money'), false);
  254. assert.equal(html.includes('http://miaguo-1253256735'), false);
  255. assert.match(html, /const createPayEndpoint = '\[支付链接\]'/);
  256. assert.match(html, /const orderStatusEndpoint = '\[查询链接\]'/);
  257. assert.match(html, /waitForPaidOrder\(payParameters\.TradeNo/);
  258. assert.match(html, /getBrandWCPayRequest/);
  259. for (const script of scripts) new vm.Script(script);
  260. });