q-koa 13.8.30 → 13.8.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -24,6 +24,64 @@ const { getCachedLoginData } = require('../../utils')
24
24
 
25
25
  const AllinPay = require('../../services/allinpay')
26
26
 
27
+ /**
28
+ * 微信支付 V2 回调验签(MD5 / HMAC-SHA256)
29
+ * @param {Object} xmlObj 解析后的 xmlBody.xml 字段
30
+ * @param {string} partner_key 商户 API 密钥(32 位)
31
+ * @returns {boolean}
32
+ */
33
+ const validateWxPayV2Sign = (xmlObj, partner_key) => {
34
+ if (!xmlObj || !xmlObj.sign) return false
35
+ const signType = (xmlObj.sign_type || 'MD5').toUpperCase()
36
+ const params = { ...xmlObj }
37
+ delete params.sign
38
+ // 按微信规则:过滤空值/undefined/key;ASCII 字典序;k=v&k=v&key=partner_key
39
+ const querystring =
40
+ Object.keys(params)
41
+ .filter(
42
+ (k) =>
43
+ params[k] !== undefined &&
44
+ params[k] !== '' &&
45
+ k !== 'key' &&
46
+ k !== 'pfx' &&
47
+ k !== 'partner_key'
48
+ )
49
+ .sort()
50
+ .map((k) => `${k}=${params[k]}`)
51
+ .join('&') + `&key=${partner_key}`
52
+ let expected
53
+ if (signType === 'HMAC-SHA256') {
54
+ expected = crypto
55
+ .createHmac('sha256', partner_key)
56
+ .update(querystring, 'utf8')
57
+ .digest('hex')
58
+ .toUpperCase()
59
+ } else {
60
+ expected = crypto
61
+ .createHash('md5')
62
+ .update(querystring, 'utf8')
63
+ .digest('hex')
64
+ .toUpperCase()
65
+ }
66
+ return expected === String(xmlObj.sign).toUpperCase()
67
+ }
68
+
69
+ /**
70
+ * 发送微信 V2 标准的 FAIL XML 响应(供回调异常/验签失败使用)
71
+ */
72
+ const sendWxFail = (ctx, msg = 'FAIL') => {
73
+ ctx.status = 200
74
+ ctx.res.setHeader('Content-Type', 'application/xml')
75
+ ctx.res.end(
76
+ jsonToXml({
77
+ xml: {
78
+ return_code: 'FAIL',
79
+ return_msg: msg,
80
+ },
81
+ })
82
+ )
83
+ }
84
+
27
85
  const check = ({ timestamp, nonce, signature, token }) => {
28
86
  const tmp = [token, timestamp, nonce].sort().join('')
29
87
  const currSign = crypto.createHash('sha1').update(tmp).digest('hex')
@@ -1490,6 +1548,54 @@ exports.union_notify = async (ctx) => {
1490
1548
  exports.notify = async (ctx) => {
1491
1549
  const { app } = getAppByCtx(ctx)
1492
1550
  const result = ctx.request.xmlBody
1551
+ // TODO: 临时观察日志,上线后删除
1552
+ app.service.log.push({
1553
+ app,
1554
+ message: `微信支付回调notify报文: ${JSON.stringify(result || {})}`,
1555
+ })
1556
+ if (!result || !result.xml) return sendWxFail(ctx, 'INVALID_BODY')
1557
+
1558
+ // ===== 微信 V2 回调验签 + mch_id 匹配 =====
1559
+ // 未识别到 xml.sign / return_code 时一律拒绝,防止未签名/异常报文进入业务
1560
+ if (result.xml.return_code === 'SUCCESS' && !result.xml.sign) {
1561
+ return sendWxFail(ctx, 'SIGN_MISSING')
1562
+ }
1563
+ const appConfig = getConfig(app)
1564
+ const pay_config = 'weixin_pay'
1565
+ const {
1566
+ key,
1567
+ partner_key,
1568
+ mch_id: cfg_mch_id,
1569
+ mchId,
1570
+ } = await appConfig.getObject(pay_config)
1571
+ const app_key = key && key.length > 10 ? key : partner_key
1572
+ if (!validateWxPayV2Sign(result.xml, app_key)) {
1573
+ app.service.log.push({
1574
+ app,
1575
+ message: `微信支付回调验签失败: ${JSON.stringify(result.xml)}`,
1576
+ })
1577
+ return sendWxFail(ctx, 'SIGN_ERROR')
1578
+ }
1579
+ // 商户号绑定校验:防止 A 商户的回调被伪装成 B 商户
1580
+ const boundMchId = cfg_mch_id || mchId
1581
+ if (boundMchId && result.xml.mch_id && boundMchId !== result.xml.mch_id) {
1582
+ app.service.log.push({
1583
+ app,
1584
+ message: `微信支付回调商户号不匹配: cfg=${boundMchId} got=${result.xml.mch_id}`,
1585
+ })
1586
+ return sendWxFail(ctx, 'MCH_MISMATCH')
1587
+ }
1588
+ // return_code !== SUCCESS 一般是微信端通知失败/上游异常,不处理业务但仍应答 SUCCESS 让微信不再重放
1589
+ if (result.xml.return_code !== 'SUCCESS') {
1590
+ ctx.status = 200
1591
+ ctx.res.setHeader('Content-Type', 'application/xml')
1592
+ ctx.res.end(
1593
+ jsonToXml({
1594
+ xml: { return_code: 'SUCCESS' },
1595
+ })
1596
+ )
1597
+ return
1598
+ }
1493
1599
 
1494
1600
  let prefix = ''
1495
1601
  let order_id = result.xml.out_trade_no.split('_')[1]
@@ -1510,7 +1616,6 @@ exports.notify = async (ctx) => {
1510
1616
  transactionid = result.xml.transaction_id
1511
1617
  } catch (e) {}
1512
1618
 
1513
- // console.log(order_id, '微信支付回调-----', model, order_price, transactionid)
1514
1619
  if (app.service[model] && app.service[model].notify) {
1515
1620
  await app.service[model].notify({
1516
1621
  ctx,
@@ -1722,15 +1827,57 @@ const getRefundResultJson = async ({ req_info, app_key }) => {
1722
1827
  exports.refund_notify = async (ctx) => {
1723
1828
  const { app } = getAppByCtx(ctx)
1724
1829
  const result = ctx.request.xmlBody
1725
- const pay_config = ctx.params.sub || 'weixin_pay'
1830
+ // TODO: 临时观察日志,上线后删除
1831
+ app.service.log.push({
1832
+ app,
1833
+ message: `微信退款回调refund_notify报文: ${JSON.stringify(result || {})}`,
1834
+ })
1835
+ if (!result || !result.xml) return sendWxFail(ctx, 'INVALID_BODY')
1726
1836
 
1837
+ const pay_config = ctx.params.sub || 'weixin_pay'
1727
1838
  const appConfig = getConfig(app)
1728
- const { key, partner_key } = await appConfig.getObject(pay_config)
1839
+ const {
1840
+ key,
1841
+ partner_key,
1842
+ mch_id: cfg_mch_id,
1843
+ mchId,
1844
+ } = await appConfig.getObject(pay_config)
1845
+ const app_key = key && key.length > 10 ? key : partner_key
1846
+
1847
+ // ===== 退款回调外层 XML 先验签(req_info 是加密块,但外层 XML 必须签名防伪造) =====
1848
+ if (result.xml.return_code === 'SUCCESS' && !result.xml.sign) {
1849
+ return sendWxFail(ctx, 'SIGN_MISSING')
1850
+ }
1851
+ if (!validateWxPayV2Sign(result.xml, app_key)) {
1852
+ app.service.log.push({
1853
+ app,
1854
+ message: `微信退款回调验签失败: ${JSON.stringify(result.xml)}`,
1855
+ })
1856
+ return sendWxFail(ctx, 'SIGN_ERROR')
1857
+ }
1858
+ const boundMchId = cfg_mch_id || mchId
1859
+ if (boundMchId && result.xml.mch_id && boundMchId !== result.xml.mch_id) {
1860
+ app.service.log.push({
1861
+ app,
1862
+ message: `微信退款回调商户号不匹配: cfg=${boundMchId} got=${result.xml.mch_id}`,
1863
+ })
1864
+ return sendWxFail(ctx, 'MCH_MISMATCH')
1865
+ }
1866
+ if (result.xml.return_code !== 'SUCCESS') {
1867
+ ctx.status = 200
1868
+ ctx.res.setHeader('Content-Type', 'application/xml')
1869
+ ctx.res.end(
1870
+ jsonToXml({
1871
+ xml: { return_code: 'SUCCESS' },
1872
+ })
1873
+ )
1874
+ return
1875
+ }
1729
1876
 
1730
1877
  const { req_info } = result.xml
1731
1878
  const refundResult = await getRefundResultJson({
1732
1879
  req_info,
1733
- app_key: key.length > 10 ? key : partner_key,
1880
+ app_key,
1734
1881
  })
1735
1882
 
1736
1883
  const {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "q-koa",
3
- "version": "13.8.30",
3
+ "version": "13.8.32",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {