From 937f54dec2b82eb45e38bc0b2c9dcf212d168c68 Mon Sep 17 00:00:00 2001 From: erio Date: Fri, 6 Mar 2026 13:57:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9B=86=E6=88=90=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E7=9B=B4=E8=BF=9E=EF=BC=88Native=20+=20H5?= =?UTF-8?q?=EF=BC=89=E5=8F=8A=E9=87=91=E8=9E=8D=E7=BA=A7=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 支付渠道说明 --- .env.example | 29 ++++- next.config.ts | 1 + package.json | 1 + pnpm-lock.yaml | 182 ++++++++++++++++++++++++++++++ src/app/api/wxpay/notify/route.ts | 31 +++++ src/components/PaymentForm.tsx | 2 +- src/lib/alipay/client.ts | 1 + src/lib/alipay/provider.ts | 9 +- src/lib/config.ts | 12 +- src/lib/order/fee.ts | 14 ++- src/lib/order/service.ts | 78 ++++++++++++- src/lib/pay-utils.ts | 5 +- src/lib/payment/index.ts | 24 +++- src/lib/wxpay/client.ts | 154 +++++++++++++++++++++++++ src/lib/wxpay/index.ts | 1 + src/lib/wxpay/provider.ts | 160 ++++++++++++++++++++++++++ src/lib/wxpay/types.ts | 52 +++++++++ 17 files changed, 728 insertions(+), 28 deletions(-) create mode 100644 src/app/api/wxpay/notify/route.ts create mode 100644 src/lib/wxpay/client.ts create mode 100644 src/lib/wxpay/index.ts create mode 100644 src/lib/wxpay/provider.ts create mode 100644 src/lib/wxpay/types.ts diff --git a/.env.example b/.env.example index a208a4a..df6aa18 100644 --- a/.env.example +++ b/.env.example @@ -6,10 +6,11 @@ SUB2API_BASE_URL="https://your-sub2api-domain.com" SUB2API_ADMIN_API_KEY="your-admin-api-key" # ── 支付服务商(逗号分隔,决定加载哪些服务商) ─────────────────────────────── -# 可选值: easypay, stripe -# 示例(仅易支付): PAYMENT_PROVIDERS=easypay -# 示例(仅 Stripe): PAYMENT_PROVIDERS=stripe -# 示例(两者都用): PAYMENT_PROVIDERS=easypay,stripe +# 可选值: easypay, alipay, wxpay, stripe +# 示例(仅易支付): PAYMENT_PROVIDERS=easypay +# 示例(仅 Stripe): PAYMENT_PROVIDERS=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" # ── 订单配置 ────────────────────────────────────────────────────────────────── diff --git a/next.config.ts b/next.config.ts index 94647ad..7e80199 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,7 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { output: 'standalone', + serverExternalPackages: ['wechatpay-node-v3'], }; export default nextConfig; diff --git a/package.json b/package.json index 28d1b35..daccec3 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ccbe71..e2be27b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/src/app/api/wxpay/notify/route.ts b/src/app/api/wxpay/notify/route.ts new file mode 100644 index 0000000..6836e5b --- /dev/null +++ b/src/app/api/wxpay/notify/route.ts @@ -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 = {}; + 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 }, + ); + } +} diff --git a/src/components/PaymentForm.tsx b/src/components/PaymentForm.tsx index 03915d5..da5c5ab 100644 --- a/src/components/PaymentForm.tsx +++ b/src/components/PaymentForm.tsx @@ -108,7 +108,7 @@ export default function PaymentForm({ } if (type === 'wxpay') { return ( - + diff --git a/src/lib/alipay/client.ts b/src/lib/alipay/client.ts index acc1aee..6c2d321 100644 --- a/src/lib/alipay/client.ts +++ b/src/lib/alipay/client.ts @@ -78,6 +78,7 @@ export async function execute( 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(); diff --git a/src/lib/alipay/provider.ts b/src/lib/alipay/provider.ts index 59e5a0e..db73cda 100644 --- a/src/lib/alipay/provider.ts +++ b/src/lib/alipay/provider.ts @@ -78,14 +78,7 @@ export class AlipayProvider implements PaymentProvider { } const sign = params.sign || ''; - const paramsForVerify: Record = {}; - 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'); } diff --git a/src/lib/config.ts b/src/lib/config.ts index 948c0fc..8731240 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -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, diff --git a/src/lib/order/fee.ts b/src/lib/order/fee.ts index b1733f6..28b5c72 100644 --- a/src/lib/order/fee.ts +++ b/src/lib/order/fee.ts @@ -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(); } diff --git a/src/lib/order/service.ts b/src/lib/order/service.ts index a6594b8..2c8fd8c 100644 --- a/src/lib/order/service.ts +++ b/src/lib/order/service.ts @@ -127,13 +127,22 @@ export async function createOrder(input: CreateOrderInput): Promise { 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, diff --git a/src/lib/pay-utils.ts b/src/lib/pay-utils.ts index c019fe3..a36c43d 100644 --- a/src/lib/pay-utils.ts +++ b/src/lib/pay-utils.ts @@ -83,10 +83,11 @@ export const PAYMENT_TYPE_META: Record = { }, 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', diff --git a/src/lib/payment/index.ts b/src/lib/payment/index.ts index 16a85a7..8a9b31d 100644 --- a/src/lib/payment/index.ts +++ b/src/lib/payment/index.ts @@ -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'); diff --git a/src/lib/wxpay/client.ts b/src/lib/wxpay/client.ts new file mode 100644 index 0000000..3d47670 --- /dev/null +++ b/src/lib/wxpay/client.ts @@ -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) { + 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(method: string, url: string, body?: Record): Promise { + 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 = { + 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).code || res.status; + const message = (data as Record).message || res.statusText; + throw new Error(`Wxpay API error: [${code}] ${message}`); + } + + return data as T; +} + +export async function createNativeOrder(params: WxpayNativeOrderParams): Promise { + 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 { + 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> { + const env = assertWxpayEnv(getEnv()); + const url = `/v3/pay/transactions/out-trade-no/${outTradeNo}?mchid=${env.WXPAY_MCH_ID}`; + return request>('GET', url); +} + +export async function closeOrder(outTradeNo: string): Promise { + 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> { + return request>('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(ciphertext: string, associatedData: string, nonce: string): T { + const pay = getPayInstance(); + return pay.decipher_gcm(ciphertext, associatedData, nonce); +} + +export async function verifyNotifySign(params: { + timestamp: string; + nonce: string; + body: string; + serial: string; + signature: string; +}): Promise { + const pay = getPayInstance(); + return pay.verifySign({ + timestamp: params.timestamp, + nonce: params.nonce, + body: params.body, + serial: params.serial, + signature: params.signature, + }); +} diff --git a/src/lib/wxpay/index.ts b/src/lib/wxpay/index.ts new file mode 100644 index 0000000..9d23363 --- /dev/null +++ b/src/lib/wxpay/index.ts @@ -0,0 +1 @@ +export { WxpayProvider } from './provider'; diff --git a/src/lib/wxpay/provider.ts b/src/lib/wxpay/provider.ts new file mode 100644 index 0000000..c5b4339 --- /dev/null +++ b/src/lib/wxpay/provider.ts @@ -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 { + 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 { + 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, + ): Promise { + 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( + 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 { + 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 { + await closeOrder(tradeNo); + } +} diff --git a/src/lib/wxpay/types.ts b/src/lib/wxpay/types.ts new file mode 100644 index 0000000..ea3d85d --- /dev/null +++ b/src/lib/wxpay/types.ts @@ -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; + }; +}