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:
erio
2026-03-06 13:57:52 +08:00
parent e9e164babc
commit 937f54dec2
17 changed files with 728 additions and 28 deletions

View File

@@ -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"
# ── 订单配置 ──────────────────────────────────────────────────────────────────

View File

@@ -2,6 +2,7 @@ import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
serverExternalPackages: ['wechatpay-node-v3'],
};
export default nextConfig;

View File

@@ -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
View File

@@ -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: {}

View 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 },
);
}
}

View File

@@ -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" />

View File

@@ -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();

View File

@@ -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');
}

View File

@@ -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,
// ── StripePAYMENT_PROVIDERS 含 stripe 时必填) ──
STRIPE_SECRET_KEY: optionalTrimmedString,
STRIPE_PUBLISHABLE_KEY: optionalTrimmedString,

View File

@@ -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();
}

View File

@@ -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,

View File

@@ -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',

View File

@@ -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
View 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
View File

@@ -0,0 +1 @@
export { WxpayProvider } from './provider';

160
src/lib/wxpay/provider.ts Normal file
View 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
View 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;
};
}