Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5e07edda6 | ||
|
|
7627066549 | ||
|
|
387bc96fc9 | ||
|
|
e846cc1cce | ||
|
|
bdf2577f28 | ||
|
|
5253bc8d35 | ||
|
|
e2a6895bb7 | ||
|
|
0c6c6e0ea6 | ||
|
|
918750047a | ||
|
|
ef1078279a | ||
|
|
225f2e0c5a | ||
|
|
96962ec38e | ||
|
|
137a780269 | ||
|
|
7be0614c7d | ||
|
|
7cab333213 | ||
|
|
e72325140b | ||
|
|
d46793f072 | ||
|
|
94d25ddc31 | ||
|
|
254ead1908 | ||
|
|
3829d0e52e | ||
|
|
cee24c3afb | ||
|
|
dc78a41912 | ||
|
|
ad6b63dd9e | ||
|
|
0763d72a89 | ||
|
|
f53aa9e14c | ||
|
|
01d5a0b3c4 | ||
|
|
cba8acdd60 | ||
|
|
b0f1daf469 | ||
|
|
937f54dec2 |
35
.env.example
35
.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,29 @@ EASY_PAY_RETURN_URL="https://pay.example.com/pay/result"
|
||||
#STRIPE_PUBLISHABLE_KEY="pk_live_..."
|
||||
#STRIPE_WEBHOOK_SECRET="whsec_..."
|
||||
|
||||
# ── 启用的支付渠道(在已配置服务商支持的渠道中选择) ─────────────────────────
|
||||
# 易支付支持: alipay, wxpay
|
||||
# Stripe 支持: stripe
|
||||
# ── 支付宝直连(PAYMENT_PROVIDERS 含 alipay 时必填) ────────────────────
|
||||
# 不在 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 时必填) ────────────────────
|
||||
# 前端自动检测设备类型:PC 端扫码支付,移动端 H5 跳转微信 APP 支付
|
||||
# 不在 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
|
||||
# 默认值为空 = 不启用任何渠道,必须手动配置
|
||||
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: {}
|
||||
|
||||
@@ -37,21 +37,21 @@ describe('AlipayProvider', () => {
|
||||
});
|
||||
|
||||
describe('metadata', () => {
|
||||
it('should have name "alipay"', () => {
|
||||
expect(provider.name).toBe('alipay');
|
||||
it('should have name "alipay-direct"', () => {
|
||||
expect(provider.name).toBe('alipay-direct');
|
||||
});
|
||||
|
||||
it('should have providerKey "alipay"', () => {
|
||||
expect(provider.providerKey).toBe('alipay');
|
||||
});
|
||||
|
||||
it('should support "alipay" payment type', () => {
|
||||
expect(provider.supportedTypes).toEqual(['alipay']);
|
||||
it('should support "alipay_direct" payment type', () => {
|
||||
expect(provider.supportedTypes).toEqual(['alipay_direct']);
|
||||
});
|
||||
|
||||
it('should have default limits', () => {
|
||||
expect(provider.defaultLimits).toEqual({
|
||||
alipay: { singleMax: 1000, dailyMax: 10000 },
|
||||
alipay_direct: { singleMax: 1000, dailyMax: 10000 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,11 +44,16 @@ describe('Alipay RSA2 Sign', () => {
|
||||
expect(sign1).toBe(sign2);
|
||||
});
|
||||
|
||||
it('should filter out sign and sign_type fields', () => {
|
||||
const paramsWithSign = { ...testParams, sign: 'old_sign', sign_type: 'RSA2' };
|
||||
it('should filter out sign field but keep sign_type', () => {
|
||||
const paramsWithSign = { ...testParams, sign: 'old_sign' };
|
||||
const sign1 = generateSign(testParams, privateKey);
|
||||
const sign2 = generateSign(paramsWithSign, privateKey);
|
||||
expect(sign1).toBe(sign2);
|
||||
|
||||
// sign_type should be included in signing
|
||||
const paramsWithSignType = { ...testParams, sign_type: 'RSA2' };
|
||||
const sign3 = generateSign(paramsWithSignType, privateKey);
|
||||
expect(sign3).not.toBe(sign1);
|
||||
});
|
||||
|
||||
it('should filter out empty values', () => {
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { handlePaymentNotify } from '@/lib/order/service';
|
||||
import { AlipayProvider } from '@/lib/alipay/provider';
|
||||
import { getEnv } from '@/lib/config';
|
||||
|
||||
const alipayProvider = new AlipayProvider();
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 官方支付宝未配置时,直接返回成功(避免旧回调重试产生错误日志)
|
||||
const env = getEnv();
|
||||
if (!env.ALIPAY_APP_ID || !env.ALIPAY_PRIVATE_KEY) {
|
||||
return new Response('success', { headers: { 'Content-Type': 'text/plain' } });
|
||||
}
|
||||
|
||||
const rawBody = await request.text();
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import { queryMethodLimits } from '@/lib/order/limits';
|
||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
|
||||
/**
|
||||
* GET /api/limits
|
||||
@@ -17,8 +17,8 @@ import { queryMethodLimits } from '@/lib/order/limits';
|
||||
* }
|
||||
*/
|
||||
export async function GET() {
|
||||
const env = getEnv();
|
||||
const types = env.ENABLED_PAYMENT_TYPES;
|
||||
initPaymentProviders();
|
||||
const types = paymentRegistry.getSupportedTypes();
|
||||
|
||||
const todayStart = new Date();
|
||||
todayStart.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
@@ -2,18 +2,22 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { createOrder, OrderError } from '@/lib/order/service';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
import { getCurrentUserByToken } from '@/lib/sub2api/client';
|
||||
|
||||
const createOrderSchema = z.object({
|
||||
user_id: z.number().int().positive(),
|
||||
token: z.string().min(1),
|
||||
amount: z.number().positive(),
|
||||
payment_type: z.enum(['alipay', 'wxpay', 'stripe']),
|
||||
payment_type: z.string().min(1),
|
||||
src_host: z.string().max(253).optional(),
|
||||
src_url: z.string().max(2048).optional(),
|
||||
is_mobile: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const env = getEnv();
|
||||
initPaymentProviders();
|
||||
const body = await request.json();
|
||||
const parsed = createOrderSchema.safeParse(body);
|
||||
|
||||
@@ -21,7 +25,16 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: '参数错误', details: parsed.error.flatten().fieldErrors }, { status: 400 });
|
||||
}
|
||||
|
||||
const { user_id, amount, payment_type, src_host, src_url } = parsed.data;
|
||||
const { token, amount, payment_type, src_host, src_url, is_mobile } = parsed.data;
|
||||
|
||||
// 通过 token 解析用户身份
|
||||
let userId: number;
|
||||
try {
|
||||
const user = await getCurrentUserByToken(token);
|
||||
userId = user.id;
|
||||
} catch {
|
||||
return NextResponse.json({ error: '无效的 token,请重新登录', code: 'INVALID_TOKEN' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Validate amount range
|
||||
if (amount < env.MIN_RECHARGE_AMOUNT || amount > env.MAX_RECHARGE_AMOUNT) {
|
||||
@@ -32,7 +45,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Validate payment type is enabled
|
||||
if (!env.ENABLED_PAYMENT_TYPES.includes(payment_type)) {
|
||||
if (!paymentRegistry.getSupportedTypes().includes(payment_type)) {
|
||||
return NextResponse.json({ error: `不支持的支付方式: ${payment_type}` }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -40,10 +53,11 @@ export async function POST(request: NextRequest) {
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.headers.get('x-real-ip') || '127.0.0.1';
|
||||
|
||||
const result = await createOrder({
|
||||
userId: user_id,
|
||||
userId,
|
||||
amount,
|
||||
paymentType: payment_type,
|
||||
clientIp,
|
||||
isMobile: is_mobile,
|
||||
srcHost: src_host,
|
||||
srcUrl: src_url,
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getUser } from '@/lib/sub2api/client';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import { queryMethodLimits } from '@/lib/order/limits';
|
||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
import { PAYMENT_TYPE_META } from '@/lib/pay-utils';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const userId = Number(request.nextUrl.searchParams.get('user_id'));
|
||||
@@ -11,7 +13,37 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const env = getEnv();
|
||||
const [user, methodLimits] = await Promise.all([getUser(userId), queryMethodLimits(env.ENABLED_PAYMENT_TYPES)]);
|
||||
initPaymentProviders();
|
||||
const enabledTypes = paymentRegistry.getSupportedTypes();
|
||||
const [user, methodLimits] = await Promise.all([getUser(userId), queryMethodLimits(enabledTypes)]);
|
||||
|
||||
// 收集 sublabel 覆盖
|
||||
const sublabelOverrides: Record<string, string> = {};
|
||||
|
||||
// 1. 检测同 label 冲突:多个启用渠道有相同的显示名,自动标记默认 sublabel(provider 名)
|
||||
const labelCount = new Map<string, string[]>();
|
||||
for (const type of enabledTypes) {
|
||||
const meta = PAYMENT_TYPE_META[type];
|
||||
if (!meta) continue;
|
||||
const types = labelCount.get(meta.label) || [];
|
||||
types.push(type);
|
||||
labelCount.set(meta.label, types);
|
||||
}
|
||||
for (const [, types] of labelCount) {
|
||||
if (types.length > 1) {
|
||||
for (const type of types) {
|
||||
const meta = PAYMENT_TYPE_META[type];
|
||||
if (meta) sublabelOverrides[type] = meta.provider;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 用户手动配置的 PAYMENT_SUBLABEL_* 优先级最高,覆盖自动生成的
|
||||
if (env.PAYMENT_SUBLABEL_ALIPAY) sublabelOverrides.alipay = env.PAYMENT_SUBLABEL_ALIPAY;
|
||||
if (env.PAYMENT_SUBLABEL_ALIPAY_DIRECT) sublabelOverrides.alipay_direct = env.PAYMENT_SUBLABEL_ALIPAY_DIRECT;
|
||||
if (env.PAYMENT_SUBLABEL_WXPAY) sublabelOverrides.wxpay = env.PAYMENT_SUBLABEL_WXPAY;
|
||||
if (env.PAYMENT_SUBLABEL_WXPAY_DIRECT) sublabelOverrides.wxpay_direct = env.PAYMENT_SUBLABEL_WXPAY_DIRECT;
|
||||
if (env.PAYMENT_SUBLABEL_STRIPE) sublabelOverrides.stripe = env.PAYMENT_SUBLABEL_STRIPE;
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
@@ -19,7 +51,7 @@ export async function GET(request: NextRequest) {
|
||||
status: user.status,
|
||||
},
|
||||
config: {
|
||||
enabledPaymentTypes: env.ENABLED_PAYMENT_TYPES,
|
||||
enabledPaymentTypes: enabledTypes,
|
||||
minAmount: env.MIN_RECHARGE_AMOUNT,
|
||||
maxAmount: env.MAX_RECHARGE_AMOUNT,
|
||||
maxDailyAmount: env.MAX_DAILY_RECHARGE_AMOUNT,
|
||||
@@ -27,9 +59,10 @@ export async function GET(request: NextRequest) {
|
||||
helpImageUrl: env.PAY_HELP_IMAGE_URL ?? null,
|
||||
helpText: env.PAY_HELP_TEXT ?? null,
|
||||
stripePublishableKey:
|
||||
env.ENABLED_PAYMENT_TYPES.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY
|
||||
enabledTypes.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY
|
||||
? env.STRIPE_PUBLISHABLE_KEY
|
||||
: null,
|
||||
sublabelOverrides: Object.keys(sublabelOverrides).length > 0 ? sublabelOverrides : null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
38
src/app/api/wxpay/notify/route.ts
Normal file
38
src/app/api/wxpay/notify/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { handlePaymentNotify } from '@/lib/order/service';
|
||||
import { WxpayProvider } from '@/lib/wxpay';
|
||||
import { getEnv } from '@/lib/config';
|
||||
|
||||
const wxpayProvider = new WxpayProvider();
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 微信支付未配置时,直接返回成功(避免旧回调重试产生错误日志)
|
||||
const env = getEnv();
|
||||
if (!env.WXPAY_PUBLIC_KEY || !env.WXPAY_MCH_ID) {
|
||||
return Response.json({ code: 'SUCCESS', message: '成功' });
|
||||
}
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ interface Summary {
|
||||
|
||||
function OrdersContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const userId = Number(searchParams.get('user_id'));
|
||||
const token = (searchParams.get('token') || '').trim();
|
||||
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
||||
const uiMode = searchParams.get('ui_mode') || 'standalone';
|
||||
@@ -43,7 +42,6 @@ function OrdersContent() {
|
||||
|
||||
const isEmbedded = uiMode === 'embedded' && isIframeContext;
|
||||
const hasToken = token.length > 0;
|
||||
const effectiveUserId = resolvedUserId || userId;
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
@@ -54,7 +52,6 @@ function OrdersContent() {
|
||||
useEffect(() => {
|
||||
if (!isMobile || isEmbedded || typeof window === 'undefined') return;
|
||||
const params = new URLSearchParams();
|
||||
if (userId && !Number.isNaN(userId)) params.set('user_id', String(userId));
|
||||
if (token) params.set('token', token);
|
||||
params.set('theme', theme);
|
||||
params.set('ui_mode', uiMode);
|
||||
@@ -67,15 +64,9 @@ function OrdersContent() {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
if (!userId || Number.isNaN(userId) || userId <= 0) {
|
||||
setError('无效的用户 ID');
|
||||
setOrders([]);
|
||||
return;
|
||||
}
|
||||
if (!hasToken) {
|
||||
setUserInfo({ id: userId, username: `用户 #${userId}` });
|
||||
setOrders([]);
|
||||
setError('当前链接未携带登录 token,无法查询"我的订单"。');
|
||||
setError('缺少认证信息,请从 Sub2API 平台正确访问订单页面。');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -97,11 +88,11 @@ function OrdersContent() {
|
||||
if (Number.isInteger(meId) && meId > 0) setResolvedUserId(meId);
|
||||
|
||||
setUserInfo({
|
||||
id: Number.isInteger(meId) && meId > 0 ? meId : userId,
|
||||
id: Number.isInteger(meId) && meId > 0 ? meId : undefined,
|
||||
username:
|
||||
(typeof meUser.displayName === 'string' && meUser.displayName.trim()) ||
|
||||
(typeof meUser.username === 'string' && meUser.username.trim()) ||
|
||||
`用户 #${userId}`,
|
||||
`用户 #${meId}`,
|
||||
balance: typeof meUser.balance === 'number' ? meUser.balance : 0,
|
||||
});
|
||||
|
||||
@@ -121,7 +112,7 @@ function OrdersContent() {
|
||||
if (isMobile && !isEmbedded) return;
|
||||
loadOrders(1, pageSize);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId, token, isMobile, isEmbedded]);
|
||||
}, [token, isMobile, isEmbedded]);
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage);
|
||||
@@ -153,11 +144,11 @@ function OrdersContent() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!effectiveUserId || Number.isNaN(effectiveUserId) || effectiveUserId <= 0) {
|
||||
if (!hasToken && !resolvedUserId) {
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">无效的用户 ID</p>
|
||||
<p className="text-lg font-medium">缺少认证信息</p>
|
||||
<p className="mt-2 text-sm text-gray-500">请从 Sub2API 平台正确访问订单页面</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -166,7 +157,6 @@ function OrdersContent() {
|
||||
|
||||
const buildScopedUrl = (path: string) => {
|
||||
const params = new URLSearchParams();
|
||||
if (effectiveUserId) params.set('user_id', String(effectiveUserId));
|
||||
if (token) params.set('token', token);
|
||||
params.set('theme', theme);
|
||||
params.set('ui_mode', uiMode);
|
||||
@@ -178,7 +168,7 @@ function OrdersContent() {
|
||||
isDark={isDark}
|
||||
isEmbedded={isEmbedded}
|
||||
title="我的订单"
|
||||
subtitle={userInfo?.username || `用户 #${effectiveUserId}`}
|
||||
subtitle={userInfo?.username || '我的订单'}
|
||||
actions={
|
||||
<>
|
||||
<button type="button" onClick={() => loadOrders(page, pageSize)} className={btnClass}>
|
||||
|
||||
@@ -7,7 +7,7 @@ import PaymentQRCode from '@/components/PaymentQRCode';
|
||||
import OrderStatus from '@/components/OrderStatus';
|
||||
import PayPageLayout from '@/components/PayPageLayout';
|
||||
import MobileOrderList from '@/components/MobileOrderList';
|
||||
import { detectDeviceIsMobile, type UserInfo, type MyOrder } from '@/lib/pay-utils';
|
||||
import { detectDeviceIsMobile, applySublabelOverrides, type UserInfo, type MyOrder } from '@/lib/pay-utils';
|
||||
import type { MethodLimitInfo } from '@/components/PaymentForm';
|
||||
|
||||
interface OrderResult {
|
||||
@@ -15,7 +15,7 @@ interface OrderResult {
|
||||
amount: number;
|
||||
payAmount?: number;
|
||||
status: string;
|
||||
paymentType: 'alipay' | 'wxpay' | 'stripe';
|
||||
paymentType: string;
|
||||
payUrl?: string | null;
|
||||
qrCode?: string | null;
|
||||
clientSecret?: string | null;
|
||||
@@ -35,7 +35,6 @@ interface AppConfig {
|
||||
|
||||
function PayContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const userId = Number(searchParams.get('user_id'));
|
||||
const token = (searchParams.get('token') || '').trim();
|
||||
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
||||
const uiMode = searchParams.get('ui_mode') || 'standalone';
|
||||
@@ -58,6 +57,7 @@ function PayContent() {
|
||||
const [ordersHasMore, setOrdersHasMore] = useState(false);
|
||||
const [ordersLoadingMore, setOrdersLoadingMore] = useState(false);
|
||||
const [activeMobileTab, setActiveMobileTab] = useState<'pay' | 'orders'>('pay');
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
|
||||
const [config, setConfig] = useState<AppConfig>({
|
||||
enabledPaymentTypes: [],
|
||||
@@ -68,12 +68,13 @@ function PayContent() {
|
||||
const [userNotFound, setUserNotFound] = useState(false);
|
||||
const [helpImageOpen, setHelpImageOpen] = useState(false);
|
||||
|
||||
const effectiveUserId = resolvedUserId || userId;
|
||||
const isEmbedded = uiMode === 'embedded' && isIframeContext;
|
||||
const hasToken = token.length > 0;
|
||||
const isEmbedded = uiMode === 'embedded' && isIframeContext;
|
||||
const helpImageUrl = (config.helpImageUrl || '').trim();
|
||||
const helpText = (config.helpText || '').trim();
|
||||
const hasHelpContent = Boolean(helpImageUrl || helpText);
|
||||
const MAX_PENDING = 3;
|
||||
const pendingBlocked = pendingCount >= MAX_PENDING;
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
@@ -92,12 +93,49 @@ function PayContent() {
|
||||
}, [isMobile, step, tab]);
|
||||
|
||||
const loadUserAndOrders = async () => {
|
||||
if (!userId || Number.isNaN(userId) || userId <= 0) return;
|
||||
if (!token) return;
|
||||
|
||||
setUserNotFound(false);
|
||||
try {
|
||||
// 始终获取服务端配置(不含隐私信息)
|
||||
const cfgRes = await fetch(`/api/user?user_id=${userId}`);
|
||||
// 通过 token 获取用户详情和订单
|
||||
const meRes = await fetch(`/api/orders/my?token=${encodeURIComponent(token)}`);
|
||||
if (!meRes.ok) {
|
||||
setUserNotFound(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const meData = await meRes.json();
|
||||
const meUser = meData.user || {};
|
||||
const meId = Number(meUser.id);
|
||||
if (!Number.isInteger(meId) || meId <= 0) {
|
||||
setUserNotFound(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setResolvedUserId(meId);
|
||||
setPendingCount(meData.summary?.pending ?? 0);
|
||||
|
||||
setUserInfo({
|
||||
id: meId,
|
||||
username:
|
||||
(typeof meUser.displayName === 'string' && meUser.displayName.trim()) ||
|
||||
(typeof meUser.username === 'string' && meUser.username.trim()) ||
|
||||
`用户 #${meId}`,
|
||||
balance: typeof meUser.balance === 'number' ? meUser.balance : undefined,
|
||||
});
|
||||
|
||||
if (Array.isArray(meData.orders)) {
|
||||
setMyOrders(meData.orders);
|
||||
setOrdersPage(1);
|
||||
setOrdersHasMore((meData.total_pages ?? 1) > 1);
|
||||
} else {
|
||||
setMyOrders([]);
|
||||
setOrdersPage(1);
|
||||
setOrdersHasMore(false);
|
||||
}
|
||||
|
||||
// 获取服务端支付配置
|
||||
const cfgRes = await fetch(`/api/user?user_id=${meId}`);
|
||||
if (cfgRes.ok) {
|
||||
const cfgData = await cfgRes.json();
|
||||
if (cfgData.config) {
|
||||
@@ -111,50 +149,11 @@ function PayContent() {
|
||||
helpText: cfgData.config.helpText ?? null,
|
||||
stripePublishableKey: cfgData.config.stripePublishableKey ?? null,
|
||||
});
|
||||
}
|
||||
} else if (cfgRes.status === 404) {
|
||||
setUserNotFound(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 有 token 时才尝试获取用户详情和订单
|
||||
if (token) {
|
||||
const meRes = await fetch(`/api/orders/my?token=${encodeURIComponent(token)}`);
|
||||
if (meRes.ok) {
|
||||
const meData = await meRes.json();
|
||||
const meUser = meData.user || {};
|
||||
const meId = Number(meUser.id);
|
||||
if (Number.isInteger(meId) && meId > 0) {
|
||||
setResolvedUserId(meId);
|
||||
if (cfgData.config.sublabelOverrides) {
|
||||
applySublabelOverrides(cfgData.config.sublabelOverrides);
|
||||
}
|
||||
|
||||
setUserInfo({
|
||||
id: Number.isInteger(meId) && meId > 0 ? meId : userId,
|
||||
username:
|
||||
(typeof meUser.displayName === 'string' && meUser.displayName.trim()) ||
|
||||
(typeof meUser.username === 'string' && meUser.username.trim()) ||
|
||||
`用户 #${userId}`,
|
||||
balance: typeof meUser.balance === 'number' ? meUser.balance : undefined,
|
||||
});
|
||||
|
||||
if (Array.isArray(meData.orders)) {
|
||||
setMyOrders(meData.orders);
|
||||
setOrdersPage(1);
|
||||
setOrdersHasMore((meData.total_pages ?? 1) > 1);
|
||||
} else {
|
||||
setMyOrders([]);
|
||||
setOrdersPage(1);
|
||||
setOrdersHasMore(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 无 token 或 token 失效:只显示用户 ID,不展示隐私信息(不显示余额)
|
||||
setUserInfo({ id: userId, username: `用户 #${userId}` });
|
||||
setMyOrders([]);
|
||||
setOrdersPage(1);
|
||||
setOrdersHasMore(false);
|
||||
} catch {
|
||||
// ignore and keep page usable
|
||||
}
|
||||
@@ -185,7 +184,7 @@ function PayContent() {
|
||||
useEffect(() => {
|
||||
loadUserAndOrders();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId, token]);
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 'result' || finalStatus !== 'COMPLETED') return;
|
||||
@@ -201,11 +200,11 @@ function PayContent() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [step, finalStatus]);
|
||||
|
||||
if (!effectiveUserId || Number.isNaN(effectiveUserId) || effectiveUserId <= 0) {
|
||||
if (!hasToken) {
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">无效的用户 ID</p>
|
||||
<p className="text-lg font-medium">缺少认证信息</p>
|
||||
<p className="mt-2 text-sm text-gray-500">请从 Sub2API 平台正确访问充值页面</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -225,7 +224,6 @@ function PayContent() {
|
||||
|
||||
const buildScopedUrl = (path: string, forceOrdersTab = false) => {
|
||||
const params = new URLSearchParams();
|
||||
if (effectiveUserId) params.set('user_id', String(effectiveUserId));
|
||||
if (token) params.set('token', token);
|
||||
params.set('theme', theme);
|
||||
params.set('ui_mode', uiMode);
|
||||
@@ -238,6 +236,11 @@ function PayContent() {
|
||||
const ordersUrl = isMobile ? mobileOrdersUrl : pcOrdersUrl;
|
||||
|
||||
const handleSubmit = async (amount: number, paymentType: string) => {
|
||||
if (pendingBlocked) {
|
||||
setError(`您有 ${pendingCount} 个待支付订单,请先完成或取消后再试(最多 ${MAX_PENDING} 个)`);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
@@ -246,9 +249,10 @@ function PayContent() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
user_id: effectiveUserId,
|
||||
token,
|
||||
amount,
|
||||
payment_type: paymentType,
|
||||
is_mobile: isMobile,
|
||||
src_host: srcHost,
|
||||
src_url: srcUrl,
|
||||
}),
|
||||
@@ -258,6 +262,7 @@ function PayContent() {
|
||||
|
||||
if (!res.ok) {
|
||||
const codeMessages: Record<string, string> = {
|
||||
INVALID_TOKEN: '认证已失效,请重新从平台进入充值页面',
|
||||
USER_INACTIVE: '账户已被禁用,无法充值,请联系管理员',
|
||||
TOO_MANY_PENDING: '您有过多待支付订单,请先完成或取消现有订单后再试',
|
||||
USER_NOT_FOUND: '用户不存在,请检查链接是否正确',
|
||||
@@ -397,7 +402,7 @@ function PayContent() {
|
||||
{isMobile ? (
|
||||
activeMobileTab === 'pay' ? (
|
||||
<PaymentForm
|
||||
userId={effectiveUserId}
|
||||
userId={resolvedUserId ?? 0}
|
||||
userName={userInfo?.username}
|
||||
userBalance={userInfo?.balance}
|
||||
enabledPaymentTypes={config.enabledPaymentTypes}
|
||||
@@ -407,6 +412,8 @@ function PayContent() {
|
||||
onSubmit={handleSubmit}
|
||||
loading={loading}
|
||||
dark={isDark}
|
||||
pendingBlocked={pendingBlocked}
|
||||
pendingCount={pendingCount}
|
||||
/>
|
||||
) : (
|
||||
<MobileOrderList
|
||||
@@ -423,7 +430,7 @@ function PayContent() {
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.45fr)_minmax(300px,0.8fr)]">
|
||||
<div className="min-w-0">
|
||||
<PaymentForm
|
||||
userId={effectiveUserId}
|
||||
userId={resolvedUserId ?? 0}
|
||||
userName={userInfo?.username}
|
||||
userBalance={userInfo?.balance}
|
||||
enabledPaymentTypes={config.enabledPaymentTypes}
|
||||
@@ -433,6 +440,8 @@ function PayContent() {
|
||||
onSubmit={handleSubmit}
|
||||
loading={loading}
|
||||
dark={isDark}
|
||||
pendingBlocked={pendingBlocked}
|
||||
pendingCount={pendingCount}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
@@ -447,9 +456,6 @@ function PayContent() {
|
||||
<li>订单完成后会自动到账</li>
|
||||
<li>如需历史记录请查看「我的订单」</li>
|
||||
{config.maxDailyAmount > 0 && <li>每日最大充值 ¥{config.maxDailyAmount.toFixed(2)}</li>}
|
||||
{!hasToken && (
|
||||
<li className={isDark ? 'text-amber-200' : 'text-amber-700'}>当前链接无 token,订单查询受限</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState, useCallback, Suspense } from 'react';
|
||||
import { getPaymentMeta } from '@/lib/pay-utils';
|
||||
|
||||
function StripePopupContent() {
|
||||
const searchParams = useSearchParams();
|
||||
@@ -254,7 +255,7 @@ function StripePopupContent() {
|
||||
'w-full rounded-lg py-3 font-medium text-white shadow-md transition-colors',
|
||||
stripeSubmitting
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-[#635bff] hover:bg-[#5249d9] active:bg-[#4840c4]',
|
||||
: getPaymentMeta('stripe').buttonClass,
|
||||
].join(' ')}
|
||||
>
|
||||
{stripeSubmitting ? (
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
formatStatus,
|
||||
formatCreatedAt,
|
||||
getStatusBadgeClass,
|
||||
getPaymentDisplayInfo,
|
||||
type MyOrder,
|
||||
type OrderStatusFilter,
|
||||
} from '@/lib/pay-utils';
|
||||
@@ -113,7 +114,10 @@ export default function MobileOrderList({
|
||||
</span>
|
||||
</div>
|
||||
<div className={['mt-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
{order.paymentType}
|
||||
{(() => {
|
||||
const { channel, provider } = getPaymentDisplayInfo(order.paymentType);
|
||||
return provider ? `${channel} · ${provider}` : channel;
|
||||
})()}
|
||||
</div>
|
||||
<div className={['mt-0.5 text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{formatCreatedAt(order.createdAt)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatStatus, formatCreatedAt, getStatusBadgeClass, type MyOrder } from '@/lib/pay-utils';
|
||||
import { formatStatus, formatCreatedAt, getStatusBadgeClass, getPaymentDisplayInfo, type MyOrder } from '@/lib/pay-utils';
|
||||
|
||||
interface OrderTableProps {
|
||||
isDark: boolean;
|
||||
@@ -67,7 +67,21 @@ export default function OrderTable({ isDark, loading, error, orders }: OrderTabl
|
||||
>
|
||||
<div className="font-medium">#{order.id.slice(0, 12)}</div>
|
||||
<div className="font-semibold">¥{order.amount.toFixed(2)}</div>
|
||||
<div>{order.paymentType}</div>
|
||||
<div>
|
||||
{(() => {
|
||||
const { channel, provider } = getPaymentDisplayInfo(order.paymentType);
|
||||
return (
|
||||
<>
|
||||
<span>{channel}</span>
|
||||
{provider && (
|
||||
<span className={['ml-1 text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{provider}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
className={['rounded-full px-2 py-0.5 text-xs', getStatusBadgeClass(order.status, isDark)].join(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { PAYMENT_TYPE_META } from '@/lib/pay-utils';
|
||||
import { PAYMENT_TYPE_META, getPaymentIconType, getPaymentMeta } from '@/lib/pay-utils';
|
||||
|
||||
export interface MethodLimitInfo {
|
||||
available: boolean;
|
||||
@@ -23,6 +23,8 @@ interface PaymentFormProps {
|
||||
onSubmit: (amount: number, paymentType: string) => Promise<void>;
|
||||
loading?: boolean;
|
||||
dark?: boolean;
|
||||
pendingBlocked?: boolean;
|
||||
pendingCount?: number;
|
||||
}
|
||||
|
||||
const QUICK_AMOUNTS = [10, 20, 50, 100, 200, 500, 1000, 2000];
|
||||
@@ -43,6 +45,8 @@ export default function PaymentForm({
|
||||
onSubmit,
|
||||
loading,
|
||||
dark = false,
|
||||
pendingBlocked = false,
|
||||
pendingCount = 0,
|
||||
}: PaymentFormProps) {
|
||||
const [amount, setAmount] = useState<number | ''>('');
|
||||
const [paymentType, setPaymentType] = useState(enabledPaymentTypes[0] || 'alipay');
|
||||
@@ -99,16 +103,17 @@ export default function PaymentForm({
|
||||
};
|
||||
|
||||
const renderPaymentIcon = (type: string) => {
|
||||
if (type === 'alipay') {
|
||||
const iconType = getPaymentIconType(type);
|
||||
if (iconType === 'alipay') {
|
||||
return (
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-[#00AEEF] text-xl font-bold leading-none text-white">
|
||||
支
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (type === 'wxpay') {
|
||||
if (iconType === '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" />
|
||||
@@ -116,7 +121,7 @@ export default function PaymentForm({
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (type === 'stripe') {
|
||||
if (iconType === 'stripe') {
|
||||
return (
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-[#635bff] text-white">
|
||||
<svg
|
||||
@@ -232,7 +237,7 @@ export default function PaymentForm({
|
||||
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-gray-700'].join(' ')}>
|
||||
支付方式
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<div className="grid grid-cols-2 gap-3 sm:flex">
|
||||
{enabledPaymentTypes.map((type) => {
|
||||
const meta = PAYMENT_TYPE_META[type];
|
||||
const isSelected = effectivePaymentType === type;
|
||||
@@ -247,7 +252,7 @@ export default function PaymentForm({
|
||||
onClick={() => !isUnavailable && setPaymentType(type)}
|
||||
title={isUnavailable ? '今日充值额度已满,请使用其他支付方式' : undefined}
|
||||
className={[
|
||||
'relative flex h-[58px] flex-1 flex-col items-center justify-center rounded-lg border px-3 transition-all',
|
||||
'relative flex h-[58px] flex-col items-center justify-center rounded-lg border px-3 transition-all sm:flex-1',
|
||||
isUnavailable
|
||||
? dark
|
||||
? 'cursor-not-allowed border-slate-700 bg-slate-800/50 opacity-50'
|
||||
@@ -320,15 +325,27 @@ export default function PaymentForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending order limit warning */}
|
||||
{pendingBlocked && (
|
||||
<div
|
||||
className={[
|
||||
'rounded-lg border p-3 text-sm',
|
||||
dark
|
||||
? 'border-amber-700 bg-amber-900/30 text-amber-300'
|
||||
: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
].join(' ')}
|
||||
>
|
||||
您有 {pendingCount} 个待支付订单,请先完成或取消后再充值
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid || loading}
|
||||
disabled={!isValid || loading || pendingBlocked}
|
||||
className={`w-full rounded-lg py-3 text-center font-medium text-white transition-colors ${
|
||||
isValid && !loading
|
||||
? effectivePaymentType === 'stripe'
|
||||
? 'bg-[#635bff] hover:bg-[#5851db] active:bg-[#4b44c7]'
|
||||
: 'bg-blue-600 hover:bg-blue-700 active:bg-blue-800'
|
||||
isValid && !loading && !pendingBlocked
|
||||
? getPaymentMeta(effectivePaymentType).buttonClass
|
||||
: dark
|
||||
? 'cursor-not-allowed bg-slate-700 text-slate-300'
|
||||
: 'cursor-not-allowed bg-gray-300'
|
||||
@@ -336,7 +353,9 @@ export default function PaymentForm({
|
||||
>
|
||||
{loading
|
||||
? '处理中...'
|
||||
: `立即充值 ¥${(feeRate > 0 && selectedAmount > 0 ? payAmount : selectedAmount || 0).toFixed(2)}`}
|
||||
: pendingBlocked
|
||||
? '待支付订单过多'
|
||||
: `立即充值 ¥${(feeRate > 0 && selectedAmount > 0 ? payAmount : selectedAmount || 0).toFixed(2)}`}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import {
|
||||
isStripeType,
|
||||
getPaymentMeta,
|
||||
getPaymentIconSrc,
|
||||
getPaymentChannelLabel,
|
||||
} from '@/lib/pay-utils';
|
||||
import { TERMINAL_STATUSES } from '@/lib/constants';
|
||||
|
||||
interface PaymentQRCodeProps {
|
||||
orderId: string;
|
||||
@@ -10,7 +17,7 @@ interface PaymentQRCodeProps {
|
||||
qrCode?: string | null;
|
||||
clientSecret?: string | null;
|
||||
stripePublishableKey?: string | null;
|
||||
paymentType?: 'alipay' | 'wxpay' | 'stripe';
|
||||
paymentType?: string;
|
||||
amount: number;
|
||||
payAmount?: number;
|
||||
expiresAt: string;
|
||||
@@ -29,7 +36,6 @@ const TEXT_BACK = '\u8FD4\u56DE';
|
||||
const TEXT_CANCEL_ORDER = '\u53D6\u6D88\u8BA2\u5355';
|
||||
const TEXT_H5_HINT =
|
||||
'\u652F\u4ED8\u5B8C\u6210\u540E\u8BF7\u8FD4\u56DE\u6B64\u9875\u9762\uFF0C\u7CFB\u7EDF\u5C06\u81EA\u52A8\u786E\u8BA4';
|
||||
const TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED', 'CANCELLED', 'EXPIRED', 'REFUNDED', 'REFUND_FAILED']);
|
||||
|
||||
export default function PaymentQRCode({
|
||||
orderId,
|
||||
@@ -55,6 +61,7 @@ export default function PaymentQRCode({
|
||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||
const [imageLoading, setImageLoading] = useState(false);
|
||||
const [cancelBlocked, setCancelBlocked] = useState(false);
|
||||
const [redirected, setRedirected] = useState(false);
|
||||
|
||||
// Stripe Payment Element state
|
||||
const [stripeLoaded, setStripeLoaded] = useState(false);
|
||||
@@ -70,10 +77,22 @@ export default function PaymentQRCode({
|
||||
const [popupBlocked, setPopupBlocked] = useState(false);
|
||||
const paymentMethodListenerAdded = useRef(false);
|
||||
|
||||
// PC 端有二维码时优先展示二维码;仅移动端或无二维码时才跳转
|
||||
const shouldAutoRedirect = !expired && !isStripeType(paymentType) && !!payUrl && (isMobile || !qrCode);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAutoRedirect || redirected) return;
|
||||
setRedirected(true);
|
||||
if (isEmbedded) {
|
||||
window.open(payUrl!, '_blank');
|
||||
} else {
|
||||
window.location.href = payUrl!;
|
||||
}
|
||||
}, [shouldAutoRedirect, redirected, payUrl, isEmbedded]);
|
||||
|
||||
const qrPayload = useMemo(() => {
|
||||
const value = (qrCode || payUrl || '').trim();
|
||||
return value;
|
||||
}, [qrCode, payUrl]);
|
||||
return (qrCode || '').trim();
|
||||
}, [qrCode]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -110,7 +129,7 @@ export default function PaymentQRCode({
|
||||
}, [qrPayload]);
|
||||
|
||||
// Initialize Stripe Payment Element
|
||||
const isStripe = paymentType === 'stripe';
|
||||
const isStripe = isStripeType(paymentType);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStripe || !clientSecret || !stripePublishableKey) return;
|
||||
@@ -313,10 +332,10 @@ export default function PaymentQRCode({
|
||||
}
|
||||
};
|
||||
|
||||
const isWx = paymentType === 'wxpay';
|
||||
const iconSrc = isStripe ? '' : isWx ? '/icons/wxpay.svg' : '/icons/alipay.svg';
|
||||
const channelLabel = isStripe ? 'Stripe' : isWx ? '\u5FAE\u4FE1' : '\u652F\u4ED8\u5B9D';
|
||||
const iconBgClass = isStripe ? 'bg-[#635bff]' : isWx ? 'bg-[#07C160]' : 'bg-[#1677FF]';
|
||||
const meta = getPaymentMeta(paymentType || 'alipay');
|
||||
const iconSrc = getPaymentIconSrc(paymentType || 'alipay');
|
||||
const channelLabel = getPaymentChannelLabel(paymentType || 'alipay');
|
||||
const iconBgClass = meta.iconBg;
|
||||
|
||||
if (cancelBlocked) {
|
||||
return (
|
||||
@@ -409,7 +428,7 @@ export default function PaymentQRCode({
|
||||
'w-full rounded-lg py-3 font-medium text-white shadow-md transition-colors',
|
||||
stripeSubmitting
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-[#635bff] hover:bg-[#5249d9] active:bg-[#4840c4]',
|
||||
: meta.buttonClass,
|
||||
].join(' ')}
|
||||
>
|
||||
{stripeSubmitting ? (
|
||||
@@ -437,16 +456,22 @@ export default function PaymentQRCode({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : isMobile && payUrl ? (
|
||||
) : shouldAutoRedirect ? (
|
||||
<>
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<div className={`h-8 w-8 animate-spin rounded-full border-2 border-t-transparent`} style={{ borderColor: meta.color, borderTopColor: 'transparent' }} />
|
||||
<span className={['ml-3 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
正在跳转到{channelLabel}...
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href={payUrl}
|
||||
href={payUrl!}
|
||||
target={isEmbedded ? '_blank' : '_self'}
|
||||
rel="noopener noreferrer"
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-lg py-3 font-medium text-white shadow-md ${iconBgClass}`}
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-lg py-3 font-medium text-white shadow-md ${meta.buttonClass}`}
|
||||
>
|
||||
<img src={iconSrc} alt={channelLabel} className="h-5 w-5 brightness-0 invert" />
|
||||
{`打开${channelLabel}支付`}
|
||||
{iconSrc && <img src={iconSrc} alt={channelLabel} className="h-5 w-5 brightness-0 invert" />}
|
||||
{redirected ? `未跳转?点击前往${channelLabel}` : `前往${channelLabel}支付`}
|
||||
</a>
|
||||
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{TEXT_H5_HINT}
|
||||
@@ -475,18 +500,7 @@ export default function PaymentQRCode({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!qrDataUrl && payUrl && (
|
||||
<a
|
||||
href={payUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-lg bg-blue-600 px-8 py-3 font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
{TEXT_GO_PAY}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{!qrDataUrl && !payUrl && (
|
||||
{!qrDataUrl && (
|
||||
<div className="text-center">
|
||||
<div
|
||||
className={[
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { getPaymentDisplayInfo } from '@/lib/pay-utils';
|
||||
|
||||
interface AuditLog {
|
||||
id: string;
|
||||
action: string;
|
||||
@@ -53,7 +55,8 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps)
|
||||
{ label: 'Payment OK', value: order.paymentSuccess ? 'yes' : 'no' },
|
||||
{ label: 'Recharge OK', value: order.rechargeSuccess ? 'yes' : 'no' },
|
||||
{ label: 'Recharge Status', value: order.rechargeStatus || '-' },
|
||||
{ label: '支付方式', value: order.paymentType === 'alipay' ? '支付宝' : '微信支付' },
|
||||
{ label: '支付渠道', value: getPaymentDisplayInfo(order.paymentType).channel },
|
||||
{ label: '提供商', value: getPaymentDisplayInfo(order.paymentType).provider || '-' },
|
||||
{ label: '充值码', value: order.rechargeCode },
|
||||
{ label: '支付单号', value: order.paymentTradeNo || '-' },
|
||||
{ label: '客户端IP', value: order.clientIp || '-' },
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { getPaymentDisplayInfo } from '@/lib/pay-utils';
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
userId: number;
|
||||
@@ -93,13 +95,19 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
</span>
|
||||
</td>
|
||||
<td className={tdMuted}>
|
||||
{order.paymentType === 'alipay'
|
||||
? '支付宝'
|
||||
: order.paymentType === 'wechat'
|
||||
? '微信支付'
|
||||
: order.paymentType === 'stripe'
|
||||
? 'Stripe'
|
||||
: order.paymentType}
|
||||
{(() => {
|
||||
const { channel, provider } = getPaymentDisplayInfo(order.paymentType);
|
||||
return (
|
||||
<>
|
||||
{channel}
|
||||
{provider && (
|
||||
<span className={dark ? 'ml-1 text-xs text-slate-500' : 'ml-1 text-xs text-slate-400'}>
|
||||
{provider}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className={tdMuted}>{order.srcHost || '-'}</td>
|
||||
<td className={tdMuted}>{new Date(order.createdAt).toLocaleString('zh-CN')}</td>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { getPaymentTypeLabel, getPaymentMeta } from '@/lib/pay-utils';
|
||||
|
||||
interface PaymentMethod {
|
||||
paymentType: string;
|
||||
amount: number;
|
||||
@@ -12,12 +14,6 @@ interface PaymentMethodChartProps {
|
||||
dark?: boolean;
|
||||
}
|
||||
|
||||
const TYPE_CONFIG: Record<string, { label: string; light: string; dark: string }> = {
|
||||
alipay: { label: '支付宝', light: 'bg-blue-500', dark: 'bg-blue-400' },
|
||||
wechat: { label: '微信支付', light: 'bg-green-500', dark: 'bg-green-400' },
|
||||
stripe: { label: 'Stripe', light: 'bg-purple-500', dark: 'bg-purple-400' },
|
||||
};
|
||||
|
||||
export default function PaymentMethodChart({ data, dark }: PaymentMethodChartProps) {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
@@ -47,15 +43,12 @@ export default function PaymentMethodChart({ data, dark }: PaymentMethodChartPro
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{data.map((method) => {
|
||||
const config = TYPE_CONFIG[method.paymentType] || {
|
||||
label: method.paymentType,
|
||||
light: 'bg-gray-500',
|
||||
dark: 'bg-gray-400',
|
||||
};
|
||||
const meta = getPaymentMeta(method.paymentType);
|
||||
const label = getPaymentTypeLabel(method.paymentType);
|
||||
return (
|
||||
<div key={method.paymentType}>
|
||||
<div className="mb-1.5 flex items-center justify-between text-sm">
|
||||
<span className={dark ? 'text-slate-300' : 'text-slate-700'}>{config.label}</span>
|
||||
<span className={dark ? 'text-slate-300' : 'text-slate-700'}>{label}</span>
|
||||
<span className={dark ? 'text-slate-400' : 'text-slate-500'}>
|
||||
¥{method.amount.toLocaleString()} · {method.percentage}%
|
||||
</span>
|
||||
@@ -66,7 +59,7 @@ export default function PaymentMethodChart({ data, dark }: PaymentMethodChartPro
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={['h-full rounded-full transition-all', dark ? config.dark : config.light].join(' ')}
|
||||
className={['h-full rounded-full transition-all', dark ? meta.chartBar.dark : meta.chartBar.light].join(' ')}
|
||||
style={{ width: `${method.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ function getCommonParams(appId: string): Record<string, string> {
|
||||
format: 'JSON',
|
||||
charset: 'utf-8',
|
||||
sign_type: 'RSA2',
|
||||
timestamp: new Date().toISOString().replace('T', ' ').substring(0, 19),
|
||||
timestamp: new Date().toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai' }).replace('T', ' '),
|
||||
version: '1.0',
|
||||
};
|
||||
}
|
||||
@@ -27,18 +27,18 @@ function assertAlipayEnv(env: ReturnType<typeof getEnv>) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成电脑网站支付的跳转 URL(GET 方式)
|
||||
* 用于 alipay.trade.page.pay
|
||||
* 生成支付宝网站/H5支付的跳转 URL(GET 方式)
|
||||
* PC: alipay.trade.page.pay H5: alipay.trade.wap.pay
|
||||
*/
|
||||
export function pageExecute(
|
||||
bizContent: Record<string, unknown>,
|
||||
options?: { notifyUrl?: string; returnUrl?: string },
|
||||
options?: { notifyUrl?: string; returnUrl?: string; method?: string },
|
||||
): string {
|
||||
const env = assertAlipayEnv(getEnv());
|
||||
|
||||
const params: Record<string, string> = {
|
||||
...getCommonParams(env.ALIPAY_APP_ID),
|
||||
method: 'alipay.trade.page.pay',
|
||||
method: options?.method || 'alipay.trade.page.pay',
|
||||
biz_content: JSON.stringify(bizContent),
|
||||
};
|
||||
|
||||
@@ -51,7 +51,7 @@ export function pageExecute(
|
||||
|
||||
params.sign = generateSign(params, env.ALIPAY_PRIVATE_KEY);
|
||||
|
||||
const query = new URLSearchParams({ ...params, sign_type: 'RSA2' }).toString();
|
||||
const query = new URLSearchParams(params).toString();
|
||||
return `${GATEWAY}?${query}`;
|
||||
}
|
||||
|
||||
@@ -72,12 +72,12 @@ export async function execute<T extends AlipayResponse>(
|
||||
};
|
||||
|
||||
params.sign = generateSign(params, env.ALIPAY_PRIVATE_KEY);
|
||||
params.sign_type = 'RSA2';
|
||||
|
||||
const response = await fetch(GATEWAY, {
|
||||
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();
|
||||
|
||||
@@ -14,31 +14,44 @@ import { getEnv } from '@/lib/config';
|
||||
import type { AlipayTradeQueryResponse, AlipayTradeRefundResponse, AlipayTradeCloseResponse } from './types';
|
||||
|
||||
export class AlipayProvider implements PaymentProvider {
|
||||
readonly name = 'alipay';
|
||||
readonly name = 'alipay-direct';
|
||||
readonly providerKey = 'alipay';
|
||||
readonly supportedTypes: PaymentType[] = ['alipay'];
|
||||
readonly supportedTypes: PaymentType[] = ['alipay_direct'];
|
||||
readonly defaultLimits = {
|
||||
alipay: { singleMax: 1000, dailyMax: 10000 },
|
||||
alipay_direct: { singleMax: 1000, dailyMax: 10000 },
|
||||
};
|
||||
|
||||
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
|
||||
const url = pageExecute(
|
||||
{
|
||||
out_trade_no: request.orderId,
|
||||
product_code: 'FAST_INSTANT_TRADE_PAY',
|
||||
total_amount: request.amount.toFixed(2),
|
||||
subject: request.subject,
|
||||
},
|
||||
{
|
||||
notifyUrl: request.notifyUrl,
|
||||
returnUrl: request.returnUrl,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
tradeNo: request.orderId,
|
||||
payUrl: url,
|
||||
const buildPayUrl = (mobile: boolean) => {
|
||||
const method = mobile ? 'alipay.trade.wap.pay' : 'alipay.trade.page.pay';
|
||||
const productCode = mobile ? 'QUICK_WAP_WAY' : 'FAST_INSTANT_TRADE_PAY';
|
||||
return pageExecute(
|
||||
{
|
||||
out_trade_no: request.orderId,
|
||||
product_code: productCode,
|
||||
total_amount: request.amount.toFixed(2),
|
||||
subject: request.subject,
|
||||
},
|
||||
{
|
||||
notifyUrl: request.notifyUrl,
|
||||
returnUrl: request.returnUrl,
|
||||
method,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
let url: string;
|
||||
if (request.isMobile) {
|
||||
try {
|
||||
url = buildPayUrl(true);
|
||||
} catch {
|
||||
url = buildPayUrl(false);
|
||||
}
|
||||
} else {
|
||||
url = buildPayUrl(false);
|
||||
}
|
||||
|
||||
return { tradeNo: request.orderId, payUrl: url };
|
||||
}
|
||||
|
||||
async queryOrder(tradeNo: string): Promise<QueryOrderResponse> {
|
||||
@@ -62,7 +75,7 @@ export class AlipayProvider implements PaymentProvider {
|
||||
return {
|
||||
tradeNo: result.trade_no || tradeNo,
|
||||
status,
|
||||
amount: parseFloat(result.total_amount || '0'),
|
||||
amount: Math.round(parseFloat(result.total_amount || '0') * 100) / 100,
|
||||
paidAt: result.send_pay_date ? new Date(result.send_pay_date) : undefined,
|
||||
};
|
||||
}
|
||||
@@ -77,22 +90,25 @@ export class AlipayProvider implements PaymentProvider {
|
||||
params[key] = value;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
// sign_type 过滤:仅接受 RSA2
|
||||
if (params.sign_type && params.sign_type !== 'RSA2') {
|
||||
throw new Error('Unsupported sign_type, only RSA2 is accepted');
|
||||
}
|
||||
|
||||
if (!env.ALIPAY_PUBLIC_KEY || !verifySign(paramsForVerify, env.ALIPAY_PUBLIC_KEY, sign)) {
|
||||
const sign = params.sign || '';
|
||||
if (!env.ALIPAY_PUBLIC_KEY || !verifySign(params, env.ALIPAY_PUBLIC_KEY, sign)) {
|
||||
throw new Error('Alipay notification signature verification failed');
|
||||
}
|
||||
|
||||
// app_id 校验
|
||||
if (params.app_id !== env.ALIPAY_APP_ID) {
|
||||
throw new Error('Alipay notification app_id mismatch');
|
||||
}
|
||||
|
||||
return {
|
||||
tradeNo: params.trade_no || '',
|
||||
orderId: params.out_trade_no || '',
|
||||
amount: parseFloat(params.total_amount || '0'),
|
||||
amount: Math.round(parseFloat(params.total_amount || '0') * 100) / 100,
|
||||
status:
|
||||
params.trade_status === 'TRADE_SUCCESS' || params.trade_status === 'TRADE_FINISHED' ? 'success' : 'failed',
|
||||
rawData: params,
|
||||
@@ -104,6 +120,7 @@ export class AlipayProvider implements PaymentProvider {
|
||||
out_trade_no: request.orderId,
|
||||
refund_amount: request.amount.toFixed(2),
|
||||
refund_reason: request.reason || '',
|
||||
out_request_no: request.orderId + '-refund',
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -14,9 +14,7 @@ function formatPublicKey(key: string): string {
|
||||
/** 生成 RSA2 签名 */
|
||||
export function generateSign(params: Record<string, string>, privateKey: string): string {
|
||||
const filtered = Object.entries(params)
|
||||
.filter(
|
||||
([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null,
|
||||
)
|
||||
.filter(([key, value]) => key !== 'sign' && value !== '' && value !== undefined && value !== null)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
const signStr = filtered.map(([key, value]) => `${key}=${value}`).join('&');
|
||||
@@ -29,9 +27,7 @@ export function generateSign(params: Record<string, string>, privateKey: string)
|
||||
/** 用支付宝公钥验证签名 */
|
||||
export function verifySign(params: Record<string, string>, alipayPublicKey: string, sign: string): boolean {
|
||||
const filtered = Object.entries(params)
|
||||
.filter(
|
||||
([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null,
|
||||
)
|
||||
.filter(([key, value]) => key !== 'sign' && value !== '' && value !== undefined && value !== null)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
const signStr = filtered.map(([key, value]) => `${key}=${value}`).join('&');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import fs from 'fs';
|
||||
|
||||
const optionalTrimmedString = z.preprocess((value) => {
|
||||
if (typeof value !== 'string') return value;
|
||||
@@ -12,7 +13,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('')
|
||||
@@ -34,24 +35,28 @@ const envSchema = z.object({
|
||||
EASY_PAY_CID_WXPAY: optionalTrimmedString,
|
||||
|
||||
// ── 支付宝直连(PAYMENT_PROVIDERS 含 alipay 时必填) ──
|
||||
// 支持直接传密钥内容,也支持传文件路径(自动读取)
|
||||
ALIPAY_APP_ID: optionalTrimmedString,
|
||||
ALIPAY_PRIVATE_KEY: optionalTrimmedString,
|
||||
ALIPAY_PUBLIC_KEY: optionalTrimmedString,
|
||||
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,
|
||||
STRIPE_WEBHOOK_SECRET: optionalTrimmedString,
|
||||
|
||||
// ── 启用的支付渠道(在已配置服务商支持的渠道中选择) ──
|
||||
// 易支付支持: alipay, wxpay;Stripe 支持: stripe
|
||||
ENABLED_PAYMENT_TYPES: z
|
||||
.string()
|
||||
.default('alipay,wxpay')
|
||||
.transform((v) => v.split(',').map((s) => s.trim())),
|
||||
|
||||
ORDER_TIMEOUT_MINUTES: z.string().default('5').transform(Number).pipe(z.number().int().positive()),
|
||||
MIN_RECHARGE_AMOUNT: z.string().default('1').transform(Number).pipe(z.number().positive()),
|
||||
MAX_RECHARGE_AMOUNT: z.string().default('1000').transform(Number).pipe(z.number().positive()),
|
||||
@@ -65,6 +70,11 @@ const envSchema = z.object({
|
||||
.optional()
|
||||
.transform((v) => (v !== undefined ? Number(v) : undefined))
|
||||
.pipe(z.number().min(0).optional()),
|
||||
MAX_DAILY_AMOUNT_ALIPAY_DIRECT: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => (v !== undefined ? Number(v) : undefined))
|
||||
.pipe(z.number().min(0).optional()),
|
||||
MAX_DAILY_AMOUNT_WXPAY: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -82,12 +92,36 @@ const envSchema = z.object({
|
||||
NEXT_PUBLIC_APP_URL: z.string().url(),
|
||||
PAY_HELP_IMAGE_URL: optionalTrimmedString,
|
||||
PAY_HELP_TEXT: optionalTrimmedString,
|
||||
|
||||
// ── 支付方式前端描述(sublabel)覆盖,不设置则使用默认值 ──
|
||||
PAYMENT_SUBLABEL_ALIPAY: optionalTrimmedString,
|
||||
PAYMENT_SUBLABEL_ALIPAY_DIRECT: optionalTrimmedString,
|
||||
PAYMENT_SUBLABEL_WXPAY: optionalTrimmedString,
|
||||
PAYMENT_SUBLABEL_WXPAY_DIRECT: optionalTrimmedString,
|
||||
PAYMENT_SUBLABEL_STRIPE: optionalTrimmedString,
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
let cachedEnv: Env | null = null;
|
||||
|
||||
/**
|
||||
* 如果值看起来是文件路径且文件存在,则读取文件内容作为实际值;
|
||||
* 否则直接返回原值。
|
||||
*/
|
||||
function resolveKeyValue(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
// 密钥内容不会以 / 或盘符开头,文件路径才会
|
||||
if ((value.startsWith('/') || /^[A-Za-z]:[/\\]/.test(value)) && fs.existsSync(value)) {
|
||||
try {
|
||||
return fs.readFileSync(value, 'utf-8').trim();
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to read key file ${value}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getEnv(): Env {
|
||||
if (cachedEnv) return cachedEnv;
|
||||
|
||||
@@ -97,6 +131,16 @@ export function getEnv(): Env {
|
||||
throw new Error('Invalid environment variables');
|
||||
}
|
||||
|
||||
cachedEnv = parsed.data;
|
||||
const env = parsed.data;
|
||||
|
||||
// 支付宝密钥:支持直接传内容或传文件路径
|
||||
env.ALIPAY_PRIVATE_KEY = resolveKeyValue(env.ALIPAY_PRIVATE_KEY);
|
||||
env.ALIPAY_PUBLIC_KEY = resolveKeyValue(env.ALIPAY_PUBLIC_KEY);
|
||||
|
||||
// 微信支付密钥:支持直接传内容或传文件路径
|
||||
env.WXPAY_PRIVATE_KEY = resolveKeyValue(env.WXPAY_PRIVATE_KEY);
|
||||
env.WXPAY_PUBLIC_KEY = resolveKeyValue(env.WXPAY_PUBLIC_KEY);
|
||||
|
||||
cachedEnv = env;
|
||||
return cachedEnv;
|
||||
}
|
||||
|
||||
51
src/lib/constants.ts
Normal file
51
src/lib/constants.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/** 订单状态 */
|
||||
export const ORDER_STATUS = {
|
||||
PENDING: 'PENDING',
|
||||
PAID: 'PAID',
|
||||
RECHARGING: 'RECHARGING',
|
||||
COMPLETED: 'COMPLETED',
|
||||
EXPIRED: 'EXPIRED',
|
||||
CANCELLED: 'CANCELLED',
|
||||
FAILED: 'FAILED',
|
||||
REFUNDING: 'REFUNDING',
|
||||
REFUNDED: 'REFUNDED',
|
||||
REFUND_FAILED: 'REFUND_FAILED',
|
||||
} as const;
|
||||
|
||||
export type OrderStatus = (typeof ORDER_STATUS)[keyof typeof ORDER_STATUS];
|
||||
|
||||
/** 终态状态集合(不再轮询) */
|
||||
export const TERMINAL_STATUSES = new Set<string>([
|
||||
ORDER_STATUS.COMPLETED,
|
||||
ORDER_STATUS.FAILED,
|
||||
ORDER_STATUS.CANCELLED,
|
||||
ORDER_STATUS.EXPIRED,
|
||||
ORDER_STATUS.REFUNDED,
|
||||
ORDER_STATUS.REFUND_FAILED,
|
||||
]);
|
||||
|
||||
/** 退款相关状态 */
|
||||
export const REFUND_STATUSES = new Set<string>([
|
||||
ORDER_STATUS.REFUNDING,
|
||||
ORDER_STATUS.REFUNDED,
|
||||
ORDER_STATUS.REFUND_FAILED,
|
||||
]);
|
||||
|
||||
/** 支付方式标识 */
|
||||
export const PAYMENT_TYPE = {
|
||||
ALIPAY: 'alipay',
|
||||
ALIPAY_DIRECT: 'alipay_direct',
|
||||
WXPAY: 'wxpay',
|
||||
WXPAY_DIRECT: 'wxpay_direct',
|
||||
STRIPE: 'stripe',
|
||||
} as const;
|
||||
|
||||
/** 支付方式前缀(用于 startsWith 判断) */
|
||||
export const PAYMENT_PREFIX = {
|
||||
ALIPAY: 'alipay',
|
||||
WXPAY: 'wxpay',
|
||||
STRIPE: 'stripe',
|
||||
} as const;
|
||||
|
||||
/** 需要页面跳转(而非二维码)的支付方式 */
|
||||
export const REDIRECT_PAYMENT_TYPES = new Set<string>([PAYMENT_TYPE.ALIPAY_DIRECT]);
|
||||
@@ -5,7 +5,7 @@ import type { EasyPayCreateResponse, EasyPayQueryResponse, EasyPayRefundResponse
|
||||
export interface CreatePaymentOptions {
|
||||
outTradeNo: string;
|
||||
amount: string;
|
||||
paymentType: 'alipay' | 'wxpay';
|
||||
paymentType: string;
|
||||
clientIp: string;
|
||||
productName: string;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ function normalizeCidList(cid?: string): string | undefined {
|
||||
return normalized || undefined;
|
||||
}
|
||||
|
||||
function resolveCid(paymentType: 'alipay' | 'wxpay'): string | undefined {
|
||||
function resolveCid(paymentType: string): string | undefined {
|
||||
const env = getEnv();
|
||||
if (paymentType === 'alipay') {
|
||||
return normalizeCidList(env.EASY_PAY_CID_ALIPAY) || normalizeCidList(env.EASY_PAY_CID);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import { ORDER_STATUS } from '@/lib/constants';
|
||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
import { getMethodFeeRate } from './fee';
|
||||
|
||||
@@ -72,7 +73,7 @@ export async function queryMethodLimits(paymentTypes: string[]): Promise<Record<
|
||||
by: ['paymentType'],
|
||||
where: {
|
||||
paymentType: { in: paymentTypes },
|
||||
status: { in: ['PAID', 'RECHARGING', 'COMPLETED'] },
|
||||
status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.RECHARGING, ORDER_STATUS.COMPLETED] },
|
||||
paidAt: { gte: todayStart },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import { ORDER_STATUS } from '@/lib/constants';
|
||||
import { generateRechargeCode } from './code-gen';
|
||||
import { getMethodDailyLimit } from './limits';
|
||||
import { getMethodFeeRate, calculatePayAmount } from './fee';
|
||||
@@ -16,6 +17,7 @@ export interface CreateOrderInput {
|
||||
amount: number;
|
||||
paymentType: PaymentType;
|
||||
clientIp: string;
|
||||
isMobile?: boolean;
|
||||
srcHost?: string;
|
||||
srcUrl?: string;
|
||||
}
|
||||
@@ -44,7 +46,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
}
|
||||
|
||||
const pendingCount = await prisma.order.count({
|
||||
where: { userId: input.userId, status: 'PENDING' },
|
||||
where: { userId: input.userId, status: ORDER_STATUS.PENDING },
|
||||
});
|
||||
if (pendingCount >= MAX_PENDING_ORDERS) {
|
||||
throw new OrderError('TOO_MANY_PENDING', `Too many pending orders (${MAX_PENDING_ORDERS})`, 429);
|
||||
@@ -57,7 +59,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
const dailyAgg = await prisma.order.aggregate({
|
||||
where: {
|
||||
userId: input.userId,
|
||||
status: { in: ['PAID', 'RECHARGING', 'COMPLETED'] },
|
||||
status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.RECHARGING, ORDER_STATUS.COMPLETED] },
|
||||
paidAt: { gte: todayStart },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
@@ -77,7 +79,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
const methodAgg = await prisma.order.aggregate({
|
||||
where: {
|
||||
paymentType: input.paymentType,
|
||||
status: { in: ['PAID', 'RECHARGING', 'COMPLETED'] },
|
||||
status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.RECHARGING, ORDER_STATUS.COMPLETED] },
|
||||
paidAt: { gte: todayStart },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
@@ -127,14 +129,24 @@ 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,
|
||||
isMobile: input.isMobile,
|
||||
});
|
||||
|
||||
await prisma.order.update({
|
||||
@@ -160,7 +172,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
amount: input.amount,
|
||||
payAmount,
|
||||
feeRate,
|
||||
status: 'PENDING',
|
||||
status: ORDER_STATUS.PENDING,
|
||||
paymentType: input.paymentType,
|
||||
userName: user.username,
|
||||
userBalance: user.balance,
|
||||
@@ -233,7 +245,7 @@ export async function cancelOrderCore(options: {
|
||||
|
||||
// 2. DB 更新 (WHERE status='PENDING' 保证幂等)
|
||||
const result = await prisma.order.updateMany({
|
||||
where: { id: orderId, status: 'PENDING' },
|
||||
where: { id: orderId, status: ORDER_STATUS.PENDING },
|
||||
data: { status: finalStatus, updatedAt: new Date() },
|
||||
});
|
||||
|
||||
@@ -242,7 +254,7 @@ export async function cancelOrderCore(options: {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
orderId,
|
||||
action: finalStatus === 'EXPIRED' ? 'ORDER_EXPIRED' : 'ORDER_CANCELLED',
|
||||
action: finalStatus === ORDER_STATUS.EXPIRED ? 'ORDER_EXPIRED' : 'ORDER_CANCELLED',
|
||||
detail: auditDetail,
|
||||
operator,
|
||||
},
|
||||
@@ -260,13 +272,13 @@ export async function cancelOrder(orderId: string, userId: number): Promise<Canc
|
||||
|
||||
if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404);
|
||||
if (order.userId !== userId) throw new OrderError('FORBIDDEN', 'Forbidden', 403);
|
||||
if (order.status !== 'PENDING') throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400);
|
||||
if (order.status !== ORDER_STATUS.PENDING) throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400);
|
||||
|
||||
return cancelOrderCore({
|
||||
orderId: order.id,
|
||||
paymentTradeNo: order.paymentTradeNo,
|
||||
paymentType: order.paymentType,
|
||||
finalStatus: 'CANCELLED',
|
||||
finalStatus: ORDER_STATUS.CANCELLED,
|
||||
operator: `user:${userId}`,
|
||||
auditDetail: 'User cancelled order',
|
||||
});
|
||||
@@ -279,13 +291,13 @@ export async function adminCancelOrder(orderId: string): Promise<CancelOutcome>
|
||||
});
|
||||
|
||||
if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404);
|
||||
if (order.status !== 'PENDING') throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400);
|
||||
if (order.status !== ORDER_STATUS.PENDING) throw new OrderError('INVALID_STATUS', 'Order cannot be cancelled', 400);
|
||||
|
||||
return cancelOrderCore({
|
||||
orderId: order.id,
|
||||
paymentTradeNo: order.paymentTradeNo,
|
||||
paymentType: order.paymentType,
|
||||
finalStatus: 'CANCELLED',
|
||||
finalStatus: ORDER_STATUS.CANCELLED,
|
||||
operator: 'admin',
|
||||
auditDetail: 'Admin cancelled order',
|
||||
});
|
||||
@@ -322,8 +334,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(),
|
||||
);
|
||||
@@ -332,10 +366,10 @@ export async function confirmPayment(input: {
|
||||
const result = await prisma.order.updateMany({
|
||||
where: {
|
||||
id: order.id,
|
||||
status: { in: ['PENDING', 'EXPIRED'] },
|
||||
status: { in: [ORDER_STATUS.PENDING, ORDER_STATUS.EXPIRED] },
|
||||
},
|
||||
data: {
|
||||
status: 'PAID',
|
||||
status: ORDER_STATUS.PAID,
|
||||
amount: paidAmount,
|
||||
paymentTradeNo: input.tradeNo,
|
||||
paidAt: new Date(),
|
||||
@@ -345,6 +379,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 === ORDER_STATUS.COMPLETED || current.status === ORDER_STATUS.REFUNDED) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// FAILED 状态 — 之前充值失败,利用重试通知自动重试充值
|
||||
if (current.status === ORDER_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 === ORDER_STATUS.PAID || current.status === ORDER_STATUS.RECHARGING) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 其他状态(CANCELLED 等)— 不应该出现,返回 true 停止重试
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -366,6 +429,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;
|
||||
@@ -394,16 +458,26 @@ export async function executeRecharge(orderId: string): Promise<void> {
|
||||
if (!order) {
|
||||
throw new OrderError('NOT_FOUND', 'Order not found', 404);
|
||||
}
|
||||
if (order.status === 'COMPLETED') {
|
||||
if (order.status === ORDER_STATUS.COMPLETED) {
|
||||
return;
|
||||
}
|
||||
if (isRefundStatus(order.status)) {
|
||||
throw new OrderError('INVALID_STATUS', 'Refund-related order cannot recharge', 400);
|
||||
}
|
||||
if (order.status !== 'PAID' && order.status !== 'FAILED') {
|
||||
if (order.status !== ORDER_STATUS.PAID && order.status !== ORDER_STATUS.FAILED) {
|
||||
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: [ORDER_STATUS.PAID, ORDER_STATUS.FAILED] } },
|
||||
data: { status: ORDER_STATUS.RECHARGING },
|
||||
});
|
||||
if (lockResult.count === 0) {
|
||||
// 另一个并发请求已经在处理
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await createAndRedeem(
|
||||
order.rechargeCode,
|
||||
@@ -414,7 +488,7 @@ export async function executeRecharge(orderId: string): Promise<void> {
|
||||
|
||||
await prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: { status: 'COMPLETED', completedAt: new Date() },
|
||||
data: { status: ORDER_STATUS.COMPLETED, completedAt: new Date() },
|
||||
});
|
||||
|
||||
await prisma.auditLog.create({
|
||||
@@ -429,7 +503,7 @@ export async function executeRecharge(orderId: string): Promise<void> {
|
||||
await prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
status: ORDER_STATUS.FAILED,
|
||||
failedAt: new Date(),
|
||||
failedReason: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
@@ -457,15 +531,15 @@ function assertRetryAllowed(order: { status: string; paidAt: Date | null }): voi
|
||||
throw new OrderError('INVALID_STATUS', 'Refund-related order cannot retry', 400);
|
||||
}
|
||||
|
||||
if (order.status === 'FAILED' || order.status === 'PAID') {
|
||||
if (order.status === ORDER_STATUS.FAILED || order.status === ORDER_STATUS.PAID) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (order.status === 'RECHARGING') {
|
||||
if (order.status === ORDER_STATUS.RECHARGING) {
|
||||
throw new OrderError('CONFLICT', 'Order is recharging, retry later', 409);
|
||||
}
|
||||
|
||||
if (order.status === 'COMPLETED') {
|
||||
if (order.status === ORDER_STATUS.COMPLETED) {
|
||||
throw new OrderError('INVALID_STATUS', 'Order already completed', 400);
|
||||
}
|
||||
|
||||
@@ -492,10 +566,10 @@ export async function retryRecharge(orderId: string): Promise<void> {
|
||||
const result = await prisma.order.updateMany({
|
||||
where: {
|
||||
id: orderId,
|
||||
status: { in: ['FAILED', 'PAID'] },
|
||||
status: { in: [ORDER_STATUS.FAILED, ORDER_STATUS.PAID] },
|
||||
paidAt: { not: null },
|
||||
},
|
||||
data: { status: 'PAID', failedAt: null, failedReason: null },
|
||||
data: { status: ORDER_STATUS.PAID, failedAt: null, failedReason: null },
|
||||
});
|
||||
|
||||
if (result.count === 0) {
|
||||
@@ -513,7 +587,7 @@ export async function retryRecharge(orderId: string): Promise<void> {
|
||||
}
|
||||
|
||||
const derived = deriveOrderState(latest);
|
||||
if (derived.rechargeStatus === 'recharging' || latest.status === 'PAID') {
|
||||
if (derived.rechargeStatus === 'recharging' || latest.status === ORDER_STATUS.PAID) {
|
||||
throw new OrderError('CONFLICT', 'Order is recharging, retry later', 409);
|
||||
}
|
||||
|
||||
@@ -555,7 +629,7 @@ export interface RefundResult {
|
||||
export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
const order = await prisma.order.findUnique({ where: { id: input.orderId } });
|
||||
if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404);
|
||||
if (order.status !== 'COMPLETED') {
|
||||
if (order.status !== ORDER_STATUS.COMPLETED) {
|
||||
throw new OrderError('INVALID_STATUS', 'Only completed orders can be refunded', 400);
|
||||
}
|
||||
|
||||
@@ -582,8 +656,8 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
}
|
||||
|
||||
const lockResult = await prisma.order.updateMany({
|
||||
where: { id: input.orderId, status: 'COMPLETED' },
|
||||
data: { status: 'REFUNDING' },
|
||||
where: { id: input.orderId, status: ORDER_STATUS.COMPLETED },
|
||||
data: { status: ORDER_STATUS.REFUNDING },
|
||||
});
|
||||
if (lockResult.count === 0) {
|
||||
throw new OrderError('CONFLICT', 'Order status changed, refresh and retry', 409);
|
||||
@@ -611,7 +685,7 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
await prisma.order.update({
|
||||
where: { id: input.orderId },
|
||||
data: {
|
||||
status: 'REFUNDED',
|
||||
status: ORDER_STATUS.REFUNDED,
|
||||
refundAmount: new Prisma.Decimal(refundAmount.toFixed(2)),
|
||||
refundReason: input.reason || null,
|
||||
refundAt: new Date(),
|
||||
@@ -633,7 +707,7 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
await prisma.order.update({
|
||||
where: { id: input.orderId },
|
||||
data: {
|
||||
status: 'REFUND_FAILED',
|
||||
status: ORDER_STATUS.REFUND_FAILED,
|
||||
failedAt: new Date(),
|
||||
failedReason: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ORDER_STATUS, REFUND_STATUSES } from '@/lib/constants';
|
||||
|
||||
export type RechargeStatus = 'not_paid' | 'paid_pending' | 'recharging' | 'success' | 'failed' | 'closed';
|
||||
|
||||
export interface OrderStatusLike {
|
||||
@@ -6,9 +8,13 @@ export interface OrderStatusLike {
|
||||
completedAt?: Date | string | null;
|
||||
}
|
||||
|
||||
const CLOSED_STATUSES = new Set(['EXPIRED', 'CANCELLED', 'REFUNDING', 'REFUNDED', 'REFUND_FAILED']);
|
||||
|
||||
const REFUND_STATUSES = new Set(['REFUNDING', 'REFUNDED', 'REFUND_FAILED']);
|
||||
const CLOSED_STATUSES = new Set<string>([
|
||||
ORDER_STATUS.EXPIRED,
|
||||
ORDER_STATUS.CANCELLED,
|
||||
ORDER_STATUS.REFUNDING,
|
||||
ORDER_STATUS.REFUNDED,
|
||||
ORDER_STATUS.REFUND_FAILED,
|
||||
]);
|
||||
|
||||
function hasDate(value: Date | string | null | undefined): boolean {
|
||||
return Boolean(value);
|
||||
@@ -19,7 +25,7 @@ export function isRefundStatus(status: string): boolean {
|
||||
}
|
||||
|
||||
export function isRechargeRetryable(order: OrderStatusLike): boolean {
|
||||
return hasDate(order.paidAt) && order.status === 'FAILED' && !isRefundStatus(order.status);
|
||||
return hasDate(order.paidAt) && order.status === ORDER_STATUS.FAILED && !isRefundStatus(order.status);
|
||||
}
|
||||
|
||||
export function deriveOrderState(order: OrderStatusLike): {
|
||||
@@ -28,17 +34,17 @@ export function deriveOrderState(order: OrderStatusLike): {
|
||||
rechargeStatus: RechargeStatus;
|
||||
} {
|
||||
const paymentSuccess = hasDate(order.paidAt);
|
||||
const rechargeSuccess = hasDate(order.completedAt) || order.status === 'COMPLETED';
|
||||
const rechargeSuccess = hasDate(order.completedAt) || order.status === ORDER_STATUS.COMPLETED;
|
||||
|
||||
if (rechargeSuccess) {
|
||||
return { paymentSuccess, rechargeSuccess: true, rechargeStatus: 'success' };
|
||||
}
|
||||
|
||||
if (order.status === 'RECHARGING') {
|
||||
if (order.status === ORDER_STATUS.RECHARGING) {
|
||||
return { paymentSuccess, rechargeSuccess: false, rechargeStatus: 'recharging' };
|
||||
}
|
||||
|
||||
if (order.status === 'FAILED') {
|
||||
if (order.status === ORDER_STATUS.FAILED) {
|
||||
return { paymentSuccess, rechargeSuccess: false, rechargeStatus: 'failed' };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { prisma } from '@/lib/db';
|
||||
import { ORDER_STATUS } from '@/lib/constants';
|
||||
import { cancelOrderCore } from './service';
|
||||
|
||||
const INTERVAL_MS = 30_000; // 30 seconds
|
||||
@@ -7,7 +8,7 @@ let timer: ReturnType<typeof setInterval> | null = null;
|
||||
export async function expireOrders(): Promise<number> {
|
||||
const orders = await prisma.order.findMany({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
status: ORDER_STATUS.PENDING,
|
||||
expiresAt: { lt: new Date() },
|
||||
},
|
||||
select: {
|
||||
@@ -27,7 +28,7 @@ export async function expireOrders(): Promise<number> {
|
||||
orderId: order.id,
|
||||
paymentTradeNo: order.paymentTradeNo,
|
||||
paymentType: order.paymentType,
|
||||
finalStatus: 'EXPIRED',
|
||||
finalStatus: ORDER_STATUS.EXPIRED,
|
||||
operator: 'timeout',
|
||||
auditDetail: 'Order expired',
|
||||
});
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
ORDER_STATUS,
|
||||
PAYMENT_TYPE,
|
||||
PAYMENT_PREFIX,
|
||||
REDIRECT_PAYMENT_TYPES,
|
||||
} from './constants';
|
||||
|
||||
export interface UserInfo {
|
||||
id?: number;
|
||||
username: string;
|
||||
@@ -15,16 +22,16 @@ export interface MyOrder {
|
||||
export type OrderStatusFilter = 'ALL' | 'PENDING' | 'PAID' | 'COMPLETED' | 'CANCELLED' | 'EXPIRED' | 'FAILED';
|
||||
|
||||
export const STATUS_TEXT_MAP: Record<string, string> = {
|
||||
PENDING: '待支付',
|
||||
PAID: '已支付',
|
||||
RECHARGING: '充值中',
|
||||
COMPLETED: '已完成',
|
||||
EXPIRED: '已超时',
|
||||
CANCELLED: '已取消',
|
||||
FAILED: '失败',
|
||||
REFUNDING: '退款中',
|
||||
REFUNDED: '已退款',
|
||||
REFUND_FAILED: '退款失败',
|
||||
[ORDER_STATUS.PENDING]: '待支付',
|
||||
[ORDER_STATUS.PAID]: '已支付',
|
||||
[ORDER_STATUS.RECHARGING]: '充值中',
|
||||
[ORDER_STATUS.COMPLETED]: '已完成',
|
||||
[ORDER_STATUS.EXPIRED]: '已超时',
|
||||
[ORDER_STATUS.CANCELLED]: '已取消',
|
||||
[ORDER_STATUS.FAILED]: '失败',
|
||||
[ORDER_STATUS.REFUNDING]: '退款中',
|
||||
[ORDER_STATUS.REFUNDED]: '已退款',
|
||||
[ORDER_STATUS.REFUND_FAILED]: '退款失败',
|
||||
};
|
||||
|
||||
export const FILTER_OPTIONS: { key: OrderStatusFilter; label: string }[] = [
|
||||
@@ -64,48 +71,160 @@ export function formatCreatedAt(value: string): string {
|
||||
}
|
||||
|
||||
export interface PaymentTypeMeta {
|
||||
/** 支付渠道名(用户看到的:支付宝 / 微信支付 / Stripe) */
|
||||
label: string;
|
||||
/** 选择器中的辅助说明(易支付 / 官方 / 信用卡 / 借记卡) */
|
||||
sublabel?: string;
|
||||
/** 提供商名称(易支付 / 支付宝 / 微信支付 / Stripe) */
|
||||
provider: string;
|
||||
color: string;
|
||||
selectedBorder: string;
|
||||
selectedBg: string;
|
||||
iconBg: string;
|
||||
/** 图标路径(Stripe 不使用外部图标) */
|
||||
iconSrc?: string;
|
||||
/** 图表条形颜色 class */
|
||||
chartBar: { light: string; dark: string };
|
||||
/** 按钮颜色 class(含 hover/active 状态) */
|
||||
buttonClass: string;
|
||||
}
|
||||
|
||||
export const PAYMENT_TYPE_META: Record<string, PaymentTypeMeta> = {
|
||||
alipay: {
|
||||
[PAYMENT_TYPE.ALIPAY]: {
|
||||
label: '支付宝',
|
||||
sublabel: 'ALIPAY',
|
||||
provider: '易支付',
|
||||
color: '#00AEEF',
|
||||
selectedBorder: 'border-cyan-400',
|
||||
selectedBg: 'bg-cyan-50',
|
||||
iconBg: 'bg-[#00AEEF]',
|
||||
iconSrc: '/icons/alipay.svg',
|
||||
chartBar: { light: 'bg-cyan-500', dark: 'bg-cyan-400' },
|
||||
buttonClass: 'bg-[#00AEEF] hover:bg-[#009dd6] active:bg-[#008cbe]',
|
||||
},
|
||||
wxpay: {
|
||||
[PAYMENT_TYPE.ALIPAY_DIRECT]: {
|
||||
label: '支付宝',
|
||||
provider: '支付宝',
|
||||
color: '#1677FF',
|
||||
selectedBorder: 'border-blue-500',
|
||||
selectedBg: 'bg-blue-50',
|
||||
iconBg: 'bg-[#1677FF]',
|
||||
iconSrc: '/icons/alipay.svg',
|
||||
chartBar: { light: 'bg-blue-500', dark: 'bg-blue-400' },
|
||||
buttonClass: 'bg-[#1677FF] hover:bg-[#0958d9] active:bg-[#003eb3]',
|
||||
},
|
||||
[PAYMENT_TYPE.WXPAY]: {
|
||||
label: '微信支付',
|
||||
provider: '易支付',
|
||||
color: '#2BB741',
|
||||
selectedBorder: 'border-green-500',
|
||||
selectedBg: 'bg-green-50',
|
||||
iconBg: 'bg-[#2BB741]',
|
||||
iconSrc: '/icons/wxpay.svg',
|
||||
chartBar: { light: 'bg-green-500', dark: 'bg-green-400' },
|
||||
buttonClass: 'bg-[#2BB741] hover:bg-[#24a038] active:bg-[#1d8a2f]',
|
||||
},
|
||||
stripe: {
|
||||
[PAYMENT_TYPE.WXPAY_DIRECT]: {
|
||||
label: '微信支付',
|
||||
provider: '微信支付',
|
||||
color: '#07C160',
|
||||
selectedBorder: 'border-green-600',
|
||||
selectedBg: 'bg-green-50',
|
||||
iconBg: 'bg-[#07C160]',
|
||||
iconSrc: '/icons/wxpay.svg',
|
||||
chartBar: { light: 'bg-emerald-500', dark: 'bg-emerald-400' },
|
||||
buttonClass: 'bg-[#07C160] hover:bg-[#06ad56] active:bg-[#05994c]',
|
||||
},
|
||||
[PAYMENT_TYPE.STRIPE]: {
|
||||
label: 'Stripe',
|
||||
sublabel: '信用卡 / 借记卡',
|
||||
provider: 'Stripe',
|
||||
color: '#635bff',
|
||||
selectedBorder: 'border-[#635bff]',
|
||||
selectedBg: 'bg-[#635bff]/10',
|
||||
iconBg: 'bg-[#635bff]',
|
||||
chartBar: { light: 'bg-purple-500', dark: 'bg-purple-400' },
|
||||
buttonClass: 'bg-[#635bff] hover:bg-[#5249d9] active:bg-[#4840c4]',
|
||||
},
|
||||
};
|
||||
|
||||
/** 获取支付方式的显示名称(如 '支付宝(易支付)'),用于管理后台等需要区分的场景 */
|
||||
export function getPaymentTypeLabel(type: string): string {
|
||||
const meta = PAYMENT_TYPE_META[type];
|
||||
if (!meta) return type;
|
||||
if (meta.sublabel) return `${meta.label}(${meta.sublabel})`;
|
||||
// 无 sublabel 时,检查是否有同名渠道需要用 provider 区分
|
||||
const hasDuplicate = Object.entries(PAYMENT_TYPE_META).some(
|
||||
([k, m]) => k !== type && m.label === meta.label,
|
||||
);
|
||||
return hasDuplicate ? `${meta.label}(${meta.provider})` : meta.label;
|
||||
}
|
||||
|
||||
/** 获取支付渠道和提供商的结构化信息 */
|
||||
export function getPaymentDisplayInfo(type: string): { channel: string; provider: string } {
|
||||
const meta = PAYMENT_TYPE_META[type];
|
||||
if (!meta) return { channel: type, provider: '' };
|
||||
return { channel: meta.label, provider: meta.provider };
|
||||
}
|
||||
|
||||
/** 获取基础支付方式图标类型(alipay_direct → alipay) */
|
||||
export function getPaymentIconType(type: string): string {
|
||||
if (type.startsWith(PAYMENT_PREFIX.ALIPAY)) return PAYMENT_PREFIX.ALIPAY;
|
||||
if (type.startsWith(PAYMENT_PREFIX.WXPAY)) return PAYMENT_PREFIX.WXPAY;
|
||||
if (type.startsWith(PAYMENT_PREFIX.STRIPE)) return PAYMENT_PREFIX.STRIPE;
|
||||
return type;
|
||||
}
|
||||
|
||||
/** 获取支付方式的元数据,带合理的 fallback */
|
||||
export function getPaymentMeta(type: string): PaymentTypeMeta {
|
||||
const base = getPaymentIconType(type);
|
||||
return PAYMENT_TYPE_META[type] || PAYMENT_TYPE_META[base] || PAYMENT_TYPE_META[PAYMENT_TYPE.ALIPAY];
|
||||
}
|
||||
|
||||
/** 获取支付方式图标路径 */
|
||||
export function getPaymentIconSrc(type: string): string {
|
||||
return getPaymentMeta(type).iconSrc || '';
|
||||
}
|
||||
|
||||
/** 获取支付方式简短标签(如 '支付宝'、'微信'、'Stripe') */
|
||||
export function getPaymentChannelLabel(type: string): string {
|
||||
return getPaymentMeta(type).label;
|
||||
}
|
||||
|
||||
/** 支付类型谓词函数 */
|
||||
export function isStripeType(type: string | undefined | null): boolean {
|
||||
return !!type?.startsWith(PAYMENT_PREFIX.STRIPE);
|
||||
}
|
||||
|
||||
export function isWxpayType(type: string | undefined | null): boolean {
|
||||
return !!type?.startsWith(PAYMENT_PREFIX.WXPAY);
|
||||
}
|
||||
|
||||
export function isAlipayType(type: string | undefined | null): boolean {
|
||||
return !!type?.startsWith(PAYMENT_PREFIX.ALIPAY);
|
||||
}
|
||||
|
||||
/** 该支付方式需要页面跳转(而非二维码) */
|
||||
export function isRedirectPayment(type: string | undefined | null): boolean {
|
||||
return !!type && REDIRECT_PAYMENT_TYPES.has(type);
|
||||
}
|
||||
|
||||
/** 用自定义 sublabel 覆盖默认值 */
|
||||
export function applySublabelOverrides(overrides: Record<string, string>): void {
|
||||
for (const [type, sublabel] of Object.entries(overrides)) {
|
||||
if (PAYMENT_TYPE_META[type]) {
|
||||
PAYMENT_TYPE_META[type] = { ...PAYMENT_TYPE_META[type], sublabel };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getStatusBadgeClass(status: string, isDark: boolean): string {
|
||||
if (['COMPLETED', 'PAID'].includes(status)) {
|
||||
if (status === ORDER_STATUS.COMPLETED || status === ORDER_STATUS.PAID) {
|
||||
return isDark ? 'bg-emerald-500/20 text-emerald-200' : 'bg-emerald-100 text-emerald-700';
|
||||
}
|
||||
if (status === 'PENDING') {
|
||||
if (status === ORDER_STATUS.PENDING) {
|
||||
return isDark ? 'bg-blue-500/20 text-blue-200' : 'bg-blue-100 text-blue-700';
|
||||
}
|
||||
if (['CANCELLED', 'EXPIRED', 'FAILED'].includes(status)) {
|
||||
const GREY_STATUSES = new Set<string>([ORDER_STATUS.CANCELLED, ORDER_STATUS.EXPIRED, ORDER_STATUS.FAILED]);
|
||||
if (GREY_STATUSES.has(status)) {
|
||||
return isDark ? 'bg-slate-600 text-slate-200' : 'bg-slate-100 text-slate-700';
|
||||
}
|
||||
return isDark ? 'bg-slate-700 text-slate-200' : 'bg-slate-100 text-slate-700';
|
||||
|
||||
@@ -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,10 +34,30 @@ 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());
|
||||
paymentRegistry.register(new AlipayProvider()); // 注册 alipay_direct
|
||||
}
|
||||
|
||||
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_PUBLIC_KEY_ID ||
|
||||
!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_PUBLIC_KEY_ID, WXPAY_CERT_SERIAL, WXPAY_NOTIFY_URL',
|
||||
);
|
||||
}
|
||||
paymentRegistry.register(new WxpayProvider());
|
||||
}
|
||||
|
||||
if (providers.includes('stripe')) {
|
||||
@@ -46,14 +67,5 @@ export function initPaymentProviders(): void {
|
||||
paymentRegistry.register(new StripeProvider());
|
||||
}
|
||||
|
||||
// 校验 ENABLED_PAYMENT_TYPES 的每个渠道都有对应 provider 已注册
|
||||
const unsupported = env.ENABLED_PAYMENT_TYPES.filter((t) => !paymentRegistry.hasProvider(t as PaymentType));
|
||||
if (unsupported.length > 0) {
|
||||
throw new Error(
|
||||
`ENABLED_PAYMENT_TYPES 含 [${unsupported.join(', ')}],但没有对应的 PAYMENT_PROVIDERS 注册。` +
|
||||
`请检查 PAYMENT_PROVIDERS 配置`,
|
||||
);
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
/** Unified payment method types across all providers */
|
||||
export type PaymentType = 'alipay' | 'wxpay' | 'stripe';
|
||||
export type PaymentType = string;
|
||||
|
||||
/**
|
||||
* 从复合 key 中提取基础支付方式(如 'alipay_direct' → 'alipay')
|
||||
* 用于传给第三方 API 时映射回标准名称
|
||||
*/
|
||||
export function getBasePaymentType(type: string): string {
|
||||
if (type.startsWith('alipay')) return 'alipay';
|
||||
if (type.startsWith('wxpay')) return 'wxpay';
|
||||
if (type.startsWith('stripe')) return 'stripe';
|
||||
return type;
|
||||
}
|
||||
|
||||
/** Request to create a payment with any provider */
|
||||
export interface CreatePaymentRequest {
|
||||
@@ -10,6 +21,8 @@ export interface CreatePaymentRequest {
|
||||
notifyUrl?: string;
|
||||
returnUrl?: string;
|
||||
clientIp?: string;
|
||||
/** 是否来自移动端(影响支付宝选择 PC 页面支付 / H5 手机网站支付) */
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
/** Response from creating a payment */
|
||||
|
||||
170
src/lib/wxpay/client.ts
Normal file
170
src/lib/wxpay/client.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import WxPay from 'wechatpay-node-v3';
|
||||
import crypto from 'crypto';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import type { WxpayPcOrderParams, 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);
|
||||
if (!env.WXPAY_PUBLIC_KEY) {
|
||||
throw new Error('WXPAY_PUBLIC_KEY is required');
|
||||
}
|
||||
const publicKey = Buffer.from(env.WXPAY_PUBLIC_KEY);
|
||||
|
||||
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 = crypto.randomBytes(16).toString('hex');
|
||||
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;
|
||||
}
|
||||
|
||||
/** PC 扫码支付(微信官方 API: /v3/pay/transactions/native) */
|
||||
export async function createPcOrder(params: WxpayPcOrderParams): 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 env = assertWxpayEnv(getEnv());
|
||||
const key = env.WXPAY_API_V3_KEY;
|
||||
const ciphertextBuf = Buffer.from(ciphertext, 'base64');
|
||||
// AES-GCM 最后 16 字节是 AuthTag
|
||||
const authTag = ciphertextBuf.subarray(ciphertextBuf.length - 16);
|
||||
const data = ciphertextBuf.subarray(0, ciphertextBuf.length - 16);
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
|
||||
decipher.setAuthTag(authTag);
|
||||
decipher.setAAD(Buffer.from(associatedData));
|
||||
const decoded = Buffer.concat([decipher.update(data), decipher.final()]);
|
||||
return JSON.parse(decoded.toString('utf-8')) as T;
|
||||
}
|
||||
|
||||
export async function verifyNotifySign(params: {
|
||||
timestamp: string;
|
||||
nonce: string;
|
||||
body: string;
|
||||
serial: string;
|
||||
signature: string;
|
||||
}): Promise<boolean> {
|
||||
const env = getEnv();
|
||||
if (!env.WXPAY_PUBLIC_KEY) {
|
||||
throw new Error('WXPAY_PUBLIC_KEY is required for signature verification');
|
||||
}
|
||||
|
||||
// 微信支付公钥模式:直接用公钥验签,不拉取平台证书
|
||||
const message = `${params.timestamp}\n${params.nonce}\n${params.body}\n`;
|
||||
const verify = crypto.createVerify('RSA-SHA256');
|
||||
verify.update(message);
|
||||
return verify.verify(env.WXPAY_PUBLIC_KEY, params.signature, 'base64');
|
||||
}
|
||||
1
src/lib/wxpay/index.ts
Normal file
1
src/lib/wxpay/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { WxpayProvider } from './provider';
|
||||
172
src/lib/wxpay/provider.ts
Normal file
172
src/lib/wxpay/provider.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import type {
|
||||
PaymentProvider,
|
||||
PaymentType,
|
||||
CreatePaymentRequest,
|
||||
CreatePaymentResponse,
|
||||
QueryOrderResponse,
|
||||
PaymentNotification,
|
||||
RefundRequest,
|
||||
RefundResponse,
|
||||
} from '@/lib/payment/types';
|
||||
import {
|
||||
createPcOrder,
|
||||
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_direct'];
|
||||
readonly defaultLimits = {
|
||||
wxpay_direct: { 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.isMobile && request.clientIp) {
|
||||
try {
|
||||
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 };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '';
|
||||
if (!msg.includes('NO_AUTH')) throw err;
|
||||
// H5 未开通,fallback 到 Native 扫码
|
||||
}
|
||||
}
|
||||
|
||||
const codeUrl = await createPcOrder({
|
||||
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');
|
||||
}
|
||||
|
||||
// 验证 serial 匹配我们配置的公钥 ID
|
||||
if (serial !== env.WXPAY_PUBLIC_KEY_ID) {
|
||||
throw new Error(`Wxpay serial mismatch: expected ${env.WXPAY_PUBLIC_KEY_ID}, got ${serial}`);
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (Math.abs(now - Number(timestamp)) > 300) {
|
||||
throw new Error('Wechatpay notification timestamp expired');
|
||||
}
|
||||
|
||||
const valid = await verifyNotifySign({ timestamp, nonce, body, serial, signature });
|
||||
if (!valid) {
|
||||
throw new Error('Wxpay notification signature verification failed');
|
||||
}
|
||||
|
||||
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 WxpayPcOrderParams {
|
||||
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