feat: 集成微信支付直连(Native + H5)及金融级安全修复
- 新增 wxpay provider(wechatpay-node-v3 SDK),支持 Native 扫码和 H5 跳转 - 新增 /api/wxpay/notify 回调路由,AES-256-GCM 解密 + RSA 签名验证 - 修复 confirmPayment count=0 静默成功、充值失败返回 true 等 P0 问题 - 修复 notifyUrl 硬编码 easypay、回调金额覆盖订单金额等 P1 问题 - 手续费计算改用 Prisma.Decimal 精确运算,消除浮点误差 - 支付宝 provider 移除冗余 paramsForVerify,fetch 添加超时 - 补充 .env.example 配置文档和 CLAUDE.md 支付渠道说明
This commit is contained in:
25
.env.example
25
.env.example
@@ -6,10 +6,11 @@ SUB2API_BASE_URL="https://your-sub2api-domain.com"
|
||||
SUB2API_ADMIN_API_KEY="your-admin-api-key"
|
||||
|
||||
# ── 支付服务商(逗号分隔,决定加载哪些服务商) ───────────────────────────────
|
||||
# 可选值: easypay, stripe
|
||||
# 可选值: easypay, alipay, wxpay, stripe
|
||||
# 示例(仅易支付): PAYMENT_PROVIDERS=easypay
|
||||
# 示例(仅 Stripe): PAYMENT_PROVIDERS=stripe
|
||||
# 示例(两者都用): PAYMENT_PROVIDERS=easypay,stripe
|
||||
# 示例(支付宝+微信直连): PAYMENT_PROVIDERS=alipay,wxpay
|
||||
# 示例(全部启用): PAYMENT_PROVIDERS=easypay,alipay,wxpay,stripe
|
||||
PAYMENT_PROVIDERS=easypay
|
||||
|
||||
# ── 易支付配置(PAYMENT_PROVIDERS 含 easypay 时必填) ────────────────────────
|
||||
@@ -27,9 +28,25 @@ EASY_PAY_RETURN_URL="https://pay.example.com/pay/result"
|
||||
#STRIPE_PUBLISHABLE_KEY="pk_live_..."
|
||||
#STRIPE_WEBHOOK_SECRET="whsec_..."
|
||||
|
||||
# ── 支付宝直连(PAYMENT_PROVIDERS 含 alipay 时必填) ────────────────────
|
||||
# ALIPAY_APP_ID=
|
||||
# ALIPAY_PRIVATE_KEY= # PKCS8 格式私钥(不含 -----BEGIN/END----- 头尾)
|
||||
# ALIPAY_PUBLIC_KEY= # 支付宝公钥(非应用公钥,从开放平台获取)
|
||||
# ALIPAY_NOTIFY_URL=https://pay.example.com/api/alipay/notify
|
||||
# ALIPAY_RETURN_URL=https://pay.example.com/pay/result
|
||||
|
||||
# ── 微信支付直连(PAYMENT_PROVIDERS 含 wxpay 时必填) ────────────────────
|
||||
# WXPAY_APP_ID= # 公众号或移动应用 AppID
|
||||
# WXPAY_MCH_ID= # 商户号(10位数字)
|
||||
# WXPAY_PRIVATE_KEY= # 商户 API 私钥 PEM(含 -----BEGIN/END----- 头尾)
|
||||
# WXPAY_CERT_SERIAL= # 商户 API 证书序列号(40位十六进制)
|
||||
# WXPAY_API_V3_KEY= # APIv3 密钥(32位字符串)
|
||||
# WXPAY_PUBLIC_KEY= # 微信支付公钥 PEM(从商户平台下载)
|
||||
# WXPAY_PUBLIC_KEY_ID= # 微信支付公钥 ID
|
||||
# WXPAY_NOTIFY_URL=https://pay.example.com/api/wxpay/notify
|
||||
|
||||
# ── 启用的支付渠道(在已配置服务商支持的渠道中选择) ─────────────────────────
|
||||
# 易支付支持: alipay, wxpay
|
||||
# Stripe 支持: stripe
|
||||
# 可选值: alipay, wxpay, stripe
|
||||
ENABLED_PAYMENT_TYPES="alipay,wxpay"
|
||||
|
||||
# ── 订单配置 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
serverExternalPackages: ['wechatpay-node-v3'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.7.0",
|
||||
"stripe": "^20.4.0",
|
||||
"wechatpay-node-v3": "^2.2.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"pnpm": {
|
||||
|
||||
182
pnpm-lock.yaml
generated
182
pnpm-lock.yaml
generated
@@ -38,6 +38,9 @@ importers:
|
||||
stripe:
|
||||
specifier: ^20.4.0
|
||||
version: 20.4.0(@types/node@20.19.35)
|
||||
wechatpay-node-v3:
|
||||
specifier: ^2.2.1
|
||||
version: 2.2.1
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
@@ -403,6 +406,14 @@ packages:
|
||||
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@fidm/asn1@1.0.4':
|
||||
resolution: {integrity: sha512-esd1jyNvRb2HVaQGq2Gg8Z0kbQPXzV9Tq5Z14KNIov6KfFD6PTaRIO8UpcsYiTNzOqJpmyzWgVTrUwFV3UF4TQ==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
'@fidm/x509@1.2.1':
|
||||
resolution: {integrity: sha512-nwc2iesjyc9hkuzcrMCBXQRn653XuAUKorfWM8PZyJawiy1QzLj4vahwzaI25+pfpwOLvMzbJ0uKpWLDNmo16w==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
'@hono/node-server@1.19.9':
|
||||
resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==}
|
||||
engines: {node: '>=18.14.1'}
|
||||
@@ -659,6 +670,10 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@noble/hashes@1.8.0':
|
||||
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
|
||||
engines: {node: ^14.21.3 || >=16}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -675,6 +690,9 @@ packages:
|
||||
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
|
||||
engines: {node: '>=12.4.0'}
|
||||
|
||||
'@paralleldrive/cuid2@2.3.1':
|
||||
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
|
||||
|
||||
'@prisma/adapter-pg@7.4.1':
|
||||
resolution: {integrity: sha512-AH9XrqvSoBAaStn0Gm/sAnF97pDKz8uLpNmn51j1S9O9dhUva6LIxGdoDiiU9VXRIR89wAJXsvJSy+mK40m2xw==}
|
||||
|
||||
@@ -1327,6 +1345,9 @@ packages:
|
||||
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
asap@2.0.6:
|
||||
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1338,6 +1359,9 @@ packages:
|
||||
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
available-typed-arrays@1.0.7:
|
||||
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1451,6 +1475,13 @@ packages:
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
component-emitter@1.3.1:
|
||||
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
|
||||
|
||||
concat-map@0.0.1:
|
||||
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
|
||||
|
||||
@@ -1464,6 +1495,9 @@ packages:
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
cookiejar@2.1.4:
|
||||
resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1572,6 +1606,10 @@ packages:
|
||||
defu@6.1.4:
|
||||
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
|
||||
|
||||
delayed-stream@1.0.0:
|
||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
|
||||
denque@2.1.0:
|
||||
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
|
||||
engines: {node: '>=0.10'}
|
||||
@@ -1583,6 +1621,9 @@ packages:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
dezalgo@1.0.4:
|
||||
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
|
||||
|
||||
dijkstrajs@1.0.3:
|
||||
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||
|
||||
@@ -1819,6 +1860,9 @@ packages:
|
||||
fast-levenshtein@2.0.6:
|
||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||
|
||||
fast-safe-stringify@2.1.1:
|
||||
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
|
||||
|
||||
fastq@1.20.1:
|
||||
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
|
||||
|
||||
@@ -1862,6 +1906,13 @@ packages:
|
||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
form-data@4.0.5:
|
||||
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
formidable@2.1.5:
|
||||
resolution: {integrity: sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==}
|
||||
|
||||
fsevents@2.3.3:
|
||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
@@ -2310,10 +2361,27 @@ packages:
|
||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
methods@1.1.2:
|
||||
resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
micromatch@4.0.8:
|
||||
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
mime-db@1.52.0:
|
||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime-types@2.1.35:
|
||||
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
mime@2.6.0:
|
||||
resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
hasBin: true
|
||||
|
||||
minimatch@10.2.4:
|
||||
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
@@ -2422,6 +2490,9 @@ packages:
|
||||
ohash@2.0.11:
|
||||
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
||||
|
||||
once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -2599,6 +2670,10 @@ packages:
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
|
||||
qs@6.15.0:
|
||||
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
@@ -2879,6 +2954,11 @@ packages:
|
||||
babel-plugin-macros:
|
||||
optional: true
|
||||
|
||||
superagent@8.0.6:
|
||||
resolution: {integrity: sha512-HqSe6DSIh3hEn6cJvCkaM1BLi466f1LHi4yubR0tpewlMpk4RUFFy35bKz8SsPBwYfIIJy5eclp+3tCYAuX0bw==}
|
||||
engines: {node: '>=6.4.0 <13 || >=14'}
|
||||
deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net
|
||||
|
||||
supports-color@7.2.0:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2928,6 +3008,9 @@ packages:
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
tweetnacl@1.0.3:
|
||||
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -3069,6 +3152,9 @@ packages:
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
wechatpay-node-v3@2.2.1:
|
||||
resolution: {integrity: sha512-z+n8Mrzn0UNoLJPBRrY8ZG6yo9xxNihlGvwvAbV8Nlnm4tTap2UjwIikGkhryC8gOmwrlvJfSUd+x1cK3ks1hA==}
|
||||
|
||||
which-boxed-primitive@1.1.1:
|
||||
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -3106,6 +3192,9 @@ packages:
|
||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
xtend@4.0.2:
|
||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||
engines: {node: '>=0.4'}
|
||||
@@ -3421,6 +3510,13 @@ snapshots:
|
||||
'@eslint/core': 0.17.0
|
||||
levn: 0.4.1
|
||||
|
||||
'@fidm/asn1@1.0.4': {}
|
||||
|
||||
'@fidm/x509@1.2.1':
|
||||
dependencies:
|
||||
'@fidm/asn1': 1.0.4
|
||||
tweetnacl: 1.0.3
|
||||
|
||||
'@hono/node-server@1.19.9(hono@4.11.4)':
|
||||
dependencies:
|
||||
hono: 4.11.4
|
||||
@@ -3594,6 +3690,8 @@ snapshots:
|
||||
'@next/swc-win32-x64-msvc@16.1.6':
|
||||
optional: true
|
||||
|
||||
'@noble/hashes@1.8.0': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -3608,6 +3706,10 @@ snapshots:
|
||||
|
||||
'@nolyfill/is-core-module@1.0.39': {}
|
||||
|
||||
'@paralleldrive/cuid2@2.3.1':
|
||||
dependencies:
|
||||
'@noble/hashes': 1.8.0
|
||||
|
||||
'@prisma/adapter-pg@7.4.1':
|
||||
dependencies:
|
||||
'@prisma/driver-adapter-utils': 7.4.1
|
||||
@@ -4243,12 +4345,16 @@ snapshots:
|
||||
get-intrinsic: 1.3.0
|
||||
is-array-buffer: 3.0.5
|
||||
|
||||
asap@2.0.6: {}
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
ast-types-flow@0.0.8: {}
|
||||
|
||||
async-function@1.0.0: {}
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
available-typed-arrays@1.0.7:
|
||||
dependencies:
|
||||
possible-typed-array-names: 1.1.0
|
||||
@@ -4366,6 +4472,12 @@ snapshots:
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
|
||||
component-emitter@1.3.1: {}
|
||||
|
||||
concat-map@0.0.1: {}
|
||||
|
||||
confbox@0.2.4: {}
|
||||
@@ -4374,6 +4486,8 @@ snapshots:
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
cookiejar@2.1.4: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -4470,12 +4584,19 @@ snapshots:
|
||||
|
||||
defu@6.1.4: {}
|
||||
|
||||
delayed-stream@1.0.0: {}
|
||||
|
||||
denque@2.1.0: {}
|
||||
|
||||
destr@2.0.5: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
dezalgo@1.0.4:
|
||||
dependencies:
|
||||
asap: 2.0.6
|
||||
wrappy: 1.0.2
|
||||
|
||||
dijkstrajs@1.0.3: {}
|
||||
|
||||
doctrine@2.1.0:
|
||||
@@ -4879,6 +5000,8 @@ snapshots:
|
||||
|
||||
fast-levenshtein@2.0.6: {}
|
||||
|
||||
fast-safe-stringify@2.1.1: {}
|
||||
|
||||
fastq@1.20.1:
|
||||
dependencies:
|
||||
reusify: 1.1.0
|
||||
@@ -4921,6 +5044,21 @@ snapshots:
|
||||
cross-spawn: 7.0.6
|
||||
signal-exit: 4.1.0
|
||||
|
||||
form-data@4.0.5:
|
||||
dependencies:
|
||||
asynckit: 0.4.0
|
||||
combined-stream: 1.0.8
|
||||
es-set-tostringtag: 2.1.0
|
||||
hasown: 2.0.2
|
||||
mime-types: 2.1.35
|
||||
|
||||
formidable@2.1.5:
|
||||
dependencies:
|
||||
'@paralleldrive/cuid2': 2.3.1
|
||||
dezalgo: 1.0.4
|
||||
once: 1.4.0
|
||||
qs: 6.15.0
|
||||
|
||||
fsevents@2.3.3:
|
||||
optional: true
|
||||
|
||||
@@ -5326,11 +5464,21 @@ snapshots:
|
||||
|
||||
merge2@1.4.1: {}
|
||||
|
||||
methods@1.1.2: {}
|
||||
|
||||
micromatch@4.0.8:
|
||||
dependencies:
|
||||
braces: 3.0.3
|
||||
picomatch: 2.3.1
|
||||
|
||||
mime-db@1.52.0: {}
|
||||
|
||||
mime-types@2.1.35:
|
||||
dependencies:
|
||||
mime-db: 1.52.0
|
||||
|
||||
mime@2.6.0: {}
|
||||
|
||||
minimatch@10.2.4:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.4
|
||||
@@ -5452,6 +5600,10 @@ snapshots:
|
||||
|
||||
ohash@2.0.11: {}
|
||||
|
||||
once@1.4.0:
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
@@ -5618,6 +5770,10 @@ snapshots:
|
||||
pngjs: 5.0.0
|
||||
yargs: 15.4.1
|
||||
|
||||
qs@6.15.0:
|
||||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
rc9@2.1.2:
|
||||
@@ -5978,6 +6134,21 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@babel/core': 7.29.0
|
||||
|
||||
superagent@8.0.6:
|
||||
dependencies:
|
||||
component-emitter: 1.3.1
|
||||
cookiejar: 2.1.4
|
||||
debug: 4.4.3
|
||||
fast-safe-stringify: 2.1.1
|
||||
form-data: 4.0.5
|
||||
formidable: 2.1.5
|
||||
methods: 1.1.2
|
||||
mime: 2.6.0
|
||||
qs: 6.15.0
|
||||
semver: 7.7.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
@@ -6018,6 +6189,8 @@ snapshots:
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tweetnacl@1.0.3: {}
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -6187,6 +6360,13 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
wechatpay-node-v3@2.2.1:
|
||||
dependencies:
|
||||
'@fidm/x509': 1.2.1
|
||||
superagent: 8.0.6
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
which-boxed-primitive@1.1.1:
|
||||
dependencies:
|
||||
is-bigint: 1.1.0
|
||||
@@ -6247,6 +6427,8 @@ snapshots:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
xtend@4.0.2: {}
|
||||
|
||||
y18n@4.0.3: {}
|
||||
|
||||
31
src/app/api/wxpay/notify/route.ts
Normal file
31
src/app/api/wxpay/notify/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { handlePaymentNotify } from '@/lib/order/service';
|
||||
import { WxpayProvider } from '@/lib/wxpay';
|
||||
|
||||
const wxpayProvider = new WxpayProvider();
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const rawBody = await request.text();
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
|
||||
const notification = await wxpayProvider.verifyNotification(rawBody, headers);
|
||||
if (!notification) {
|
||||
return Response.json({ code: 'SUCCESS', message: '成功' });
|
||||
}
|
||||
const success = await handlePaymentNotify(notification, wxpayProvider.name);
|
||||
return Response.json(
|
||||
success ? { code: 'SUCCESS', message: '成功' } : { code: 'FAIL', message: '处理失败' },
|
||||
{ status: success ? 200 : 500 },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Wxpay notify error:', error);
|
||||
return Response.json(
|
||||
{ code: 'FAIL', message: '处理失败' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,7 @@ export default function PaymentForm({
|
||||
}
|
||||
if (type === 'wxpay') {
|
||||
return (
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-[#2BB741] text-white">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-[#07C160] text-white">
|
||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
|
||||
<path d="M10 3C6.13 3 3 5.58 3 8.75c0 1.7.84 3.23 2.17 4.29l-.5 2.21 2.4-1.32c.61.17 1.25.27 1.93.27.22 0 .43-.01.64-.03C9.41 13.72 9 12.88 9 12c0-3.31 3.13-6 7-6 .26 0 .51.01.76.03C15.96 3.98 13.19 3 10 3z" />
|
||||
<path d="M16 8c-3.31 0-6 2.24-6 5s2.69 5 6 5c.67 0 1.31-.1 1.9-.28l2.1 1.15-.55-2.44C20.77 15.52 22 13.86 22 12c0-2.21-2.69-4-6-4z" />
|
||||
|
||||
@@ -78,6 +78,7 @@ export async function execute<T extends AlipayResponse>(
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(params).toString(),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
@@ -78,14 +78,7 @@ export class AlipayProvider implements PaymentProvider {
|
||||
}
|
||||
|
||||
const sign = params.sign || '';
|
||||
const paramsForVerify: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (key !== 'sign' && key !== 'sign_type' && value !== undefined && value !== null) {
|
||||
paramsForVerify[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!env.ALIPAY_PUBLIC_KEY || !verifySign(paramsForVerify, env.ALIPAY_PUBLIC_KEY, sign)) {
|
||||
if (!env.ALIPAY_PUBLIC_KEY || !verifySign(params, env.ALIPAY_PUBLIC_KEY, sign)) {
|
||||
throw new Error('Alipay notification signature verification failed');
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ const envSchema = z.object({
|
||||
SUB2API_BASE_URL: z.string().url(),
|
||||
SUB2API_ADMIN_API_KEY: z.string().min(1),
|
||||
|
||||
// ── 支付服务商(显式声明启用哪些服务商,逗号分隔:easypay, stripe) ──
|
||||
// ── 支付服务商(显式声明启用哪些服务商,逗号分隔:easypay, alipay, wxpay, stripe) ──
|
||||
PAYMENT_PROVIDERS: z
|
||||
.string()
|
||||
.default('')
|
||||
@@ -40,6 +40,16 @@ const envSchema = z.object({
|
||||
ALIPAY_NOTIFY_URL: optionalTrimmedString,
|
||||
ALIPAY_RETURN_URL: optionalTrimmedString,
|
||||
|
||||
// ── 微信支付直连(PAYMENT_PROVIDERS 含 wxpay 时必填) ──
|
||||
WXPAY_APP_ID: optionalTrimmedString,
|
||||
WXPAY_MCH_ID: optionalTrimmedString,
|
||||
WXPAY_PRIVATE_KEY: optionalTrimmedString,
|
||||
WXPAY_CERT_SERIAL: optionalTrimmedString,
|
||||
WXPAY_API_V3_KEY: optionalTrimmedString,
|
||||
WXPAY_NOTIFY_URL: optionalTrimmedString,
|
||||
WXPAY_PUBLIC_KEY: optionalTrimmedString,
|
||||
WXPAY_PUBLIC_KEY_ID: optionalTrimmedString,
|
||||
|
||||
// ── Stripe(PAYMENT_PROVIDERS 含 stripe 时必填) ──
|
||||
STRIPE_SECRET_KEY: optionalTrimmedString,
|
||||
STRIPE_PUBLISHABLE_KEY: optionalTrimmedString,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* 获取指定支付渠道的手续费率(百分比)。
|
||||
@@ -26,13 +27,18 @@ export function getMethodFeeRate(paymentType: string): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** decimal.js ROUND_UP = 0(远离零方向取整) */
|
||||
const ROUND_UP = 0;
|
||||
|
||||
/**
|
||||
* 根据到账金额和手续费率计算实付金额。
|
||||
* feeAmount = ceil(rechargeAmount * feeRate / 100 * 100) / 100 (进一制到分)
|
||||
* 根据到账金额和手续费率计算实付金额(使用 Decimal 精确计算,避免浮点误差)。
|
||||
* feeAmount = ceil(rechargeAmount * feeRate / 100, 保留2位小数)
|
||||
* payAmount = rechargeAmount + feeAmount
|
||||
*/
|
||||
export function calculatePayAmount(rechargeAmount: number, feeRate: number): number {
|
||||
if (feeRate <= 0) return rechargeAmount;
|
||||
const feeAmount = Math.ceil(((rechargeAmount * feeRate) / 100) * 100) / 100;
|
||||
return Math.round((rechargeAmount + feeAmount) * 100) / 100;
|
||||
const amount = new Prisma.Decimal(rechargeAmount);
|
||||
const rate = new Prisma.Decimal(feeRate.toString());
|
||||
const feeAmount = amount.mul(rate).div(100).toDecimalPlaces(2, ROUND_UP);
|
||||
return amount.plus(feeAmount).toNumber();
|
||||
}
|
||||
|
||||
@@ -127,13 +127,22 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
try {
|
||||
initPaymentProviders();
|
||||
const provider = paymentRegistry.getProvider(input.paymentType);
|
||||
|
||||
// 只有 easypay 从外部传入 notifyUrl/returnUrl,其他 provider 内部读取自己的环境变量
|
||||
let notifyUrl: string | undefined;
|
||||
let returnUrl: string | undefined;
|
||||
if (provider.providerKey === 'easypay') {
|
||||
notifyUrl = env.EASY_PAY_NOTIFY_URL || '';
|
||||
returnUrl = env.EASY_PAY_RETURN_URL || '';
|
||||
}
|
||||
|
||||
const paymentResult = await provider.createPayment({
|
||||
orderId: order.id,
|
||||
amount: payAmount,
|
||||
paymentType: input.paymentType,
|
||||
subject: `${env.PRODUCT_NAME} ${payAmount.toFixed(2)} CNY`,
|
||||
notifyUrl: env.EASY_PAY_NOTIFY_URL || '',
|
||||
returnUrl: env.EASY_PAY_RETURN_URL || '',
|
||||
notifyUrl,
|
||||
returnUrl,
|
||||
clientIp: input.clientIp,
|
||||
});
|
||||
|
||||
@@ -322,8 +331,30 @@ export async function confirmPayment(input: {
|
||||
}
|
||||
const expectedAmount = order.payAmount ?? order.amount;
|
||||
if (!paidAmount.equals(expectedAmount)) {
|
||||
const diff = paidAmount.minus(expectedAmount).abs();
|
||||
if (diff.gt(new Prisma.Decimal('0.01'))) {
|
||||
// 写审计日志
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
orderId: order.id,
|
||||
action: 'PAYMENT_AMOUNT_MISMATCH',
|
||||
detail: JSON.stringify({
|
||||
expected: expectedAmount.toString(),
|
||||
paid: paidAmount.toString(),
|
||||
diff: diff.toString(),
|
||||
tradeNo: input.tradeNo,
|
||||
}),
|
||||
operator: input.providerName,
|
||||
},
|
||||
});
|
||||
console.error(
|
||||
`${input.providerName} notify: amount mismatch beyond threshold`,
|
||||
`expected=${expectedAmount.toString()}, paid=${paidAmount.toString()}, diff=${diff.toString()}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
console.warn(
|
||||
`${input.providerName} notify: amount changed, use paid amount`,
|
||||
`${input.providerName} notify: minor amount difference (rounding)`,
|
||||
expectedAmount.toString(),
|
||||
paidAmount.toString(),
|
||||
);
|
||||
@@ -336,7 +367,6 @@ export async function confirmPayment(input: {
|
||||
},
|
||||
data: {
|
||||
status: 'PAID',
|
||||
amount: paidAmount,
|
||||
paymentTradeNo: input.tradeNo,
|
||||
paidAt: new Date(),
|
||||
failedAt: null,
|
||||
@@ -345,6 +375,35 @@ export async function confirmPayment(input: {
|
||||
});
|
||||
|
||||
if (result.count === 0) {
|
||||
// 重新查询当前状态,区分「已成功」和「需重试」
|
||||
const current = await prisma.order.findUnique({
|
||||
where: { id: order.id },
|
||||
select: { status: true },
|
||||
});
|
||||
if (!current) return true;
|
||||
|
||||
// 已完成或已退款 — 告知支付平台成功
|
||||
if (current.status === 'COMPLETED' || current.status === 'REFUNDED') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// FAILED 状态 — 之前充值失败,利用重试通知自动重试充值
|
||||
if (current.status === 'FAILED') {
|
||||
try {
|
||||
await executeRecharge(order.id);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Recharge retry failed for order:', order.id, err);
|
||||
return false; // 让支付平台继续重试
|
||||
}
|
||||
}
|
||||
|
||||
// PAID / RECHARGING — 正在处理中,让支付平台稍后重试
|
||||
if (current.status === 'PAID' || current.status === 'RECHARGING') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 其他状态(CANCELLED 等)— 不应该出现,返回 true 停止重试
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -366,6 +425,7 @@ export async function confirmPayment(input: {
|
||||
await executeRecharge(order.id);
|
||||
} catch (err) {
|
||||
console.error('Recharge failed for order:', order.id, err);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -404,6 +464,16 @@ export async function executeRecharge(orderId: string): Promise<void> {
|
||||
throw new OrderError('INVALID_STATUS', `Order cannot recharge in status ${order.status}`, 400);
|
||||
}
|
||||
|
||||
// 原子 CAS:将状态从 PAID/FAILED → RECHARGING,防止并发竞态
|
||||
const lockResult = await prisma.order.updateMany({
|
||||
where: { id: orderId, status: { in: ['PAID', 'FAILED'] } },
|
||||
data: { status: 'RECHARGING' },
|
||||
});
|
||||
if (lockResult.count === 0) {
|
||||
// 另一个并发请求已经在处理
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createAndRedeem(
|
||||
order.rechargeCode,
|
||||
|
||||
@@ -83,10 +83,11 @@ export const PAYMENT_TYPE_META: Record<string, PaymentTypeMeta> = {
|
||||
},
|
||||
wxpay: {
|
||||
label: '微信支付',
|
||||
color: '#2BB741',
|
||||
sublabel: 'WECHAT PAY',
|
||||
color: '#07C160',
|
||||
selectedBorder: 'border-green-500',
|
||||
selectedBg: 'bg-green-50',
|
||||
iconBg: 'bg-[#2BB741]',
|
||||
iconBg: 'bg-[#07C160]',
|
||||
},
|
||||
stripe: {
|
||||
label: 'Stripe',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { PaymentType } from './types';
|
||||
import { EasyPayProvider } from '@/lib/easy-pay/provider';
|
||||
import { StripeProvider } from '@/lib/stripe/provider';
|
||||
import { AlipayProvider } from '@/lib/alipay/provider';
|
||||
import { WxpayProvider } from '@/lib/wxpay/provider';
|
||||
import { getEnv } from '@/lib/config';
|
||||
|
||||
export { paymentRegistry } from './registry';
|
||||
@@ -33,12 +34,31 @@ export function initPaymentProviders(): void {
|
||||
}
|
||||
|
||||
if (providers.includes('alipay')) {
|
||||
if (!env.ALIPAY_APP_ID || !env.ALIPAY_PRIVATE_KEY) {
|
||||
throw new Error('PAYMENT_PROVIDERS 含 alipay,但缺少 ALIPAY_APP_ID 或 ALIPAY_PRIVATE_KEY');
|
||||
if (!env.ALIPAY_APP_ID || !env.ALIPAY_PRIVATE_KEY || !env.ALIPAY_NOTIFY_URL) {
|
||||
throw new Error(
|
||||
'PAYMENT_PROVIDERS includes alipay but required env vars are missing: ALIPAY_APP_ID, ALIPAY_PRIVATE_KEY, ALIPAY_NOTIFY_URL',
|
||||
);
|
||||
}
|
||||
paymentRegistry.register(new AlipayProvider());
|
||||
}
|
||||
|
||||
if (providers.includes('wxpay')) {
|
||||
if (
|
||||
!env.WXPAY_APP_ID ||
|
||||
!env.WXPAY_MCH_ID ||
|
||||
!env.WXPAY_PRIVATE_KEY ||
|
||||
!env.WXPAY_API_V3_KEY ||
|
||||
!env.WXPAY_PUBLIC_KEY ||
|
||||
!env.WXPAY_CERT_SERIAL ||
|
||||
!env.WXPAY_NOTIFY_URL
|
||||
) {
|
||||
throw new Error(
|
||||
'PAYMENT_PROVIDERS includes wxpay but required env vars are missing: WXPAY_APP_ID, WXPAY_MCH_ID, WXPAY_PRIVATE_KEY, WXPAY_API_V3_KEY, WXPAY_PUBLIC_KEY, WXPAY_CERT_SERIAL, WXPAY_NOTIFY_URL',
|
||||
);
|
||||
}
|
||||
paymentRegistry.register(new WxpayProvider());
|
||||
}
|
||||
|
||||
if (providers.includes('stripe')) {
|
||||
if (!env.STRIPE_SECRET_KEY) {
|
||||
throw new Error('PAYMENT_PROVIDERS 含 stripe,但缺少 STRIPE_SECRET_KEY');
|
||||
|
||||
154
src/lib/wxpay/client.ts
Normal file
154
src/lib/wxpay/client.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import WxPay from 'wechatpay-node-v3';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import type { WxpayNativeOrderParams, WxpayH5OrderParams, WxpayRefundParams } from './types';
|
||||
|
||||
const BASE_URL = 'https://api.mch.weixin.qq.com';
|
||||
|
||||
function assertWxpayEnv(env: ReturnType<typeof getEnv>) {
|
||||
if (!env.WXPAY_APP_ID || !env.WXPAY_MCH_ID || !env.WXPAY_PRIVATE_KEY || !env.WXPAY_API_V3_KEY) {
|
||||
throw new Error(
|
||||
'Wxpay environment variables (WXPAY_APP_ID, WXPAY_MCH_ID, WXPAY_PRIVATE_KEY, WXPAY_API_V3_KEY) are required',
|
||||
);
|
||||
}
|
||||
return env as typeof env & {
|
||||
WXPAY_APP_ID: string;
|
||||
WXPAY_MCH_ID: string;
|
||||
WXPAY_PRIVATE_KEY: string;
|
||||
WXPAY_API_V3_KEY: string;
|
||||
};
|
||||
}
|
||||
|
||||
let payInstance: WxPay | null = null;
|
||||
|
||||
function getPayInstance(): WxPay {
|
||||
if (payInstance) return payInstance;
|
||||
const env = assertWxpayEnv(getEnv());
|
||||
|
||||
const privateKey = Buffer.from(env.WXPAY_PRIVATE_KEY);
|
||||
const publicKey = env.WXPAY_PUBLIC_KEY ? Buffer.from(env.WXPAY_PUBLIC_KEY) : Buffer.alloc(0);
|
||||
|
||||
payInstance = new WxPay({
|
||||
appid: env.WXPAY_APP_ID,
|
||||
mchid: env.WXPAY_MCH_ID,
|
||||
publicKey,
|
||||
privateKey,
|
||||
key: env.WXPAY_API_V3_KEY,
|
||||
serial_no: env.WXPAY_CERT_SERIAL,
|
||||
});
|
||||
return payInstance;
|
||||
}
|
||||
|
||||
function yuanToFen(yuan: number): number {
|
||||
return Math.round(yuan * 100);
|
||||
}
|
||||
|
||||
async function request<T>(method: string, url: string, body?: Record<string, unknown>): Promise<T> {
|
||||
const pay = getPayInstance();
|
||||
const nonce_str = Math.random().toString(36).substring(2, 15);
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||
|
||||
const signature = pay.getSignature(method, nonce_str, timestamp, url, body ? JSON.stringify(body) : '');
|
||||
const authorization = pay.getAuthorization(nonce_str, timestamp, signature);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: authorization,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'User-Agent': 'Sub2ApiPay/1.0',
|
||||
};
|
||||
|
||||
const res = await fetch(`${BASE_URL}${url}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
if (res.status === 204) return {} as T;
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
const code = (data as Record<string, string>).code || res.status;
|
||||
const message = (data as Record<string, string>).message || res.statusText;
|
||||
throw new Error(`Wxpay API error: [${code}] ${message}`);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export async function createNativeOrder(params: WxpayNativeOrderParams): Promise<string> {
|
||||
const env = assertWxpayEnv(getEnv());
|
||||
const result = await request<{ code_url: string }>('POST', '/v3/pay/transactions/native', {
|
||||
appid: env.WXPAY_APP_ID,
|
||||
mchid: env.WXPAY_MCH_ID,
|
||||
description: params.description,
|
||||
out_trade_no: params.out_trade_no,
|
||||
notify_url: params.notify_url,
|
||||
amount: { total: yuanToFen(params.amount), currency: 'CNY' },
|
||||
});
|
||||
return result.code_url;
|
||||
}
|
||||
|
||||
export async function createH5Order(params: WxpayH5OrderParams): Promise<string> {
|
||||
const env = assertWxpayEnv(getEnv());
|
||||
const result = await request<{ h5_url: string }>('POST', '/v3/pay/transactions/h5', {
|
||||
appid: env.WXPAY_APP_ID,
|
||||
mchid: env.WXPAY_MCH_ID,
|
||||
description: params.description,
|
||||
out_trade_no: params.out_trade_no,
|
||||
notify_url: params.notify_url,
|
||||
amount: { total: yuanToFen(params.amount), currency: 'CNY' },
|
||||
scene_info: {
|
||||
payer_client_ip: params.payer_client_ip,
|
||||
h5_info: { type: 'Wap' },
|
||||
},
|
||||
});
|
||||
return result.h5_url;
|
||||
}
|
||||
|
||||
export async function queryOrder(outTradeNo: string): Promise<Record<string, unknown>> {
|
||||
const env = assertWxpayEnv(getEnv());
|
||||
const url = `/v3/pay/transactions/out-trade-no/${outTradeNo}?mchid=${env.WXPAY_MCH_ID}`;
|
||||
return request<Record<string, unknown>>('GET', url);
|
||||
}
|
||||
|
||||
export async function closeOrder(outTradeNo: string): Promise<void> {
|
||||
const env = assertWxpayEnv(getEnv());
|
||||
const url = `/v3/pay/transactions/out-trade-no/${outTradeNo}/close`;
|
||||
await request('POST', url, { mchid: env.WXPAY_MCH_ID });
|
||||
}
|
||||
|
||||
export async function createRefund(params: WxpayRefundParams): Promise<Record<string, unknown>> {
|
||||
return request<Record<string, unknown>>('POST', '/v3/refund/domestic/refunds', {
|
||||
out_trade_no: params.out_trade_no,
|
||||
out_refund_no: params.out_refund_no,
|
||||
reason: params.reason,
|
||||
amount: {
|
||||
refund: yuanToFen(params.amount),
|
||||
total: yuanToFen(params.total),
|
||||
currency: 'CNY',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function decipherNotify<T>(ciphertext: string, associatedData: string, nonce: string): T {
|
||||
const pay = getPayInstance();
|
||||
return pay.decipher_gcm<T>(ciphertext, associatedData, nonce);
|
||||
}
|
||||
|
||||
export async function verifyNotifySign(params: {
|
||||
timestamp: string;
|
||||
nonce: string;
|
||||
body: string;
|
||||
serial: string;
|
||||
signature: string;
|
||||
}): Promise<boolean> {
|
||||
const pay = getPayInstance();
|
||||
return pay.verifySign({
|
||||
timestamp: params.timestamp,
|
||||
nonce: params.nonce,
|
||||
body: params.body,
|
||||
serial: params.serial,
|
||||
signature: params.signature,
|
||||
});
|
||||
}
|
||||
1
src/lib/wxpay/index.ts
Normal file
1
src/lib/wxpay/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { WxpayProvider } from './provider';
|
||||
160
src/lib/wxpay/provider.ts
Normal file
160
src/lib/wxpay/provider.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type {
|
||||
PaymentProvider,
|
||||
PaymentType,
|
||||
CreatePaymentRequest,
|
||||
CreatePaymentResponse,
|
||||
QueryOrderResponse,
|
||||
PaymentNotification,
|
||||
RefundRequest,
|
||||
RefundResponse,
|
||||
} from '@/lib/payment/types';
|
||||
import {
|
||||
createNativeOrder,
|
||||
createH5Order,
|
||||
queryOrder,
|
||||
closeOrder,
|
||||
createRefund,
|
||||
decipherNotify,
|
||||
verifyNotifySign,
|
||||
} from './client';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import type { WxpayNotifyPayload, WxpayNotifyResource } from './types';
|
||||
|
||||
export class WxpayProvider implements PaymentProvider {
|
||||
readonly name = 'wxpay-direct';
|
||||
readonly providerKey = 'wxpay';
|
||||
readonly supportedTypes: PaymentType[] = ['wxpay'];
|
||||
readonly defaultLimits = {
|
||||
wxpay: { singleMax: 1000, dailyMax: 10000 },
|
||||
};
|
||||
|
||||
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
|
||||
const env = getEnv();
|
||||
const notifyUrl = env.WXPAY_NOTIFY_URL || request.notifyUrl;
|
||||
if (!notifyUrl) {
|
||||
throw new Error('WXPAY_NOTIFY_URL is required');
|
||||
}
|
||||
|
||||
if (request.clientIp) {
|
||||
const h5Url = await createH5Order({
|
||||
out_trade_no: request.orderId,
|
||||
description: request.subject,
|
||||
notify_url: notifyUrl,
|
||||
amount: request.amount,
|
||||
payer_client_ip: request.clientIp,
|
||||
});
|
||||
return { tradeNo: request.orderId, payUrl: h5Url };
|
||||
}
|
||||
|
||||
const codeUrl = await createNativeOrder({
|
||||
out_trade_no: request.orderId,
|
||||
description: request.subject,
|
||||
notify_url: notifyUrl,
|
||||
amount: request.amount,
|
||||
});
|
||||
return { tradeNo: request.orderId, qrCode: codeUrl };
|
||||
}
|
||||
|
||||
async queryOrder(tradeNo: string): Promise<QueryOrderResponse> {
|
||||
const result = await queryOrder(tradeNo);
|
||||
|
||||
let status: 'pending' | 'paid' | 'failed' | 'refunded';
|
||||
switch (result.trade_state) {
|
||||
case 'SUCCESS':
|
||||
status = 'paid';
|
||||
break;
|
||||
case 'REFUND':
|
||||
status = 'refunded';
|
||||
break;
|
||||
case 'CLOSED':
|
||||
case 'PAYERROR':
|
||||
status = 'failed';
|
||||
break;
|
||||
default:
|
||||
status = 'pending';
|
||||
}
|
||||
|
||||
const amount = result.amount as { total?: number } | undefined;
|
||||
const totalFen = amount?.total ?? 0;
|
||||
|
||||
return {
|
||||
tradeNo: (result.transaction_id as string) || tradeNo,
|
||||
status,
|
||||
amount: totalFen / 100,
|
||||
paidAt: result.success_time ? new Date(result.success_time as string) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyNotification(
|
||||
rawBody: string | Buffer,
|
||||
headers: Record<string, string>,
|
||||
): Promise<PaymentNotification | null> {
|
||||
const env = getEnv();
|
||||
if (!env.WXPAY_PUBLIC_KEY) {
|
||||
throw new Error('WXPAY_PUBLIC_KEY is required for notification verification');
|
||||
}
|
||||
|
||||
const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf-8');
|
||||
|
||||
const timestamp = headers['wechatpay-timestamp'] || '';
|
||||
const nonce = headers['wechatpay-nonce'] || '';
|
||||
const signature = headers['wechatpay-signature'] || '';
|
||||
const serial = headers['wechatpay-serial'] || '';
|
||||
|
||||
if (!timestamp || !nonce || !signature || !serial) {
|
||||
throw new Error('Missing required Wechatpay signature headers');
|
||||
}
|
||||
const valid = await verifyNotifySign({ timestamp, nonce, body, serial, signature });
|
||||
if (!valid) {
|
||||
throw new Error('Wxpay notification signature verification failed');
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (Math.abs(now - Number(timestamp)) > 300) {
|
||||
throw new Error('Wechatpay notification timestamp expired');
|
||||
}
|
||||
|
||||
const payload: WxpayNotifyPayload = JSON.parse(body);
|
||||
|
||||
if (payload.event_type !== 'TRANSACTION.SUCCESS') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resource = decipherNotify<WxpayNotifyResource>(
|
||||
payload.resource.ciphertext,
|
||||
payload.resource.associated_data,
|
||||
payload.resource.nonce,
|
||||
);
|
||||
|
||||
return {
|
||||
tradeNo: resource.transaction_id,
|
||||
orderId: resource.out_trade_no,
|
||||
amount: resource.amount.total / 100,
|
||||
status: resource.trade_state === 'SUCCESS' ? 'success' : 'failed',
|
||||
rawData: resource,
|
||||
};
|
||||
}
|
||||
|
||||
async refund(request: RefundRequest): Promise<RefundResponse> {
|
||||
const orderResult = await queryOrder(request.orderId);
|
||||
const amount = orderResult.amount as { total?: number } | undefined;
|
||||
const totalFen = amount?.total ?? 0;
|
||||
|
||||
const result = await createRefund({
|
||||
out_trade_no: request.orderId,
|
||||
out_refund_no: `refund-${request.orderId}`,
|
||||
amount: request.amount,
|
||||
total: totalFen / 100,
|
||||
reason: request.reason,
|
||||
});
|
||||
|
||||
return {
|
||||
refundId: (result.refund_id as string) || `${request.orderId}-refund`,
|
||||
status: result.status === 'SUCCESS' ? 'success' : 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
async cancelPayment(tradeNo: string): Promise<void> {
|
||||
await closeOrder(tradeNo);
|
||||
}
|
||||
}
|
||||
52
src/lib/wxpay/types.ts
Normal file
52
src/lib/wxpay/types.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export interface WxpayNativeOrderParams {
|
||||
out_trade_no: string;
|
||||
description: string;
|
||||
notify_url: string;
|
||||
amount: number; // in yuan, will be converted to fen
|
||||
}
|
||||
|
||||
export interface WxpayH5OrderParams {
|
||||
out_trade_no: string;
|
||||
description: string;
|
||||
notify_url: string;
|
||||
amount: number; // in yuan
|
||||
payer_client_ip: string;
|
||||
}
|
||||
|
||||
export interface WxpayRefundParams {
|
||||
out_trade_no: string;
|
||||
out_refund_no: string;
|
||||
amount: number; // refund amount in yuan
|
||||
total: number; // original total in yuan
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface WxpayNotifyPayload {
|
||||
id: string;
|
||||
create_time: string;
|
||||
event_type: string;
|
||||
resource: {
|
||||
algorithm: string;
|
||||
ciphertext: string;
|
||||
nonce: string;
|
||||
associated_data: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WxpayNotifyResource {
|
||||
appid: string;
|
||||
mchid: string;
|
||||
out_trade_no: string;
|
||||
transaction_id: string;
|
||||
trade_type: string;
|
||||
trade_state: string;
|
||||
trade_state_desc: string;
|
||||
bank_type: string;
|
||||
success_time: string;
|
||||
payer: { openid?: string };
|
||||
amount: {
|
||||
total: number; // in fen
|
||||
payer_total: number;
|
||||
currency: string;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user