38 Commits

Author SHA1 Message Date
erio
a5e07edda6 fix: PC 端易支付优先显示二维码;订单页移除 user_id 依赖改用 token
- PaymentQRCode: PC 端有 qrCode 时不跳转,优先展示二维码
- /pay/orders: 移除 user_id 参数,统一通过 token 认证

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:59:43 +08:00
erio
7627066549 fix: 官方支付未配置时 notify 路由静默返回成功,避免错误日志
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:07:56 +08:00
erio
387bc96fc9 feat: 创建订单必须通过 token 认证,移除 user_id 参数
- POST /api/orders 改为通过 token 解析用户身份,移除 user_id
- 前端不再从 URL 读取 user_id,完全依赖 token
- 前端提交前检查 pending 订单数量,超过 3 个禁止提交并提示
- 后端 createOrder 保留 MAX_PENDING_ORDERS=3 的服务端校验
- PaymentForm 增加 pendingBlocked 状态提示和按钮禁用

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:05:12 +08:00
erio
e846cc1cce feat: sublabel 智能显示 — 仅同名渠道冲突时自动标记
- 默认去掉所有渠道的 sublabel
- 当多个启用渠道有相同显示名(如支付宝+支付宝)时,
  自动用 provider 名标记区分
- 用户手动配置的 PAYMENT_SUBLABEL_* 优先级最高
- 管理后台 getPaymentTypeLabel 自动检测同名并区分

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:58:10 +08:00
erio
bdf2577f28 fix: 支付安全审核修复(支付宝+微信)
支付宝:
- 回调增加 app_id 校验,防止跨商户通知
- 回调增加 sign_type 过滤,仅接受 RSA2
- 退款增加 out_request_no 保证幂等
- 金额解析增加精度保护
- timestamp 改用 CST 时区

微信:
- 自行实现 AES-GCM 解密替代库的 decipher_gcm(修复 AuthTag 未验证)
- WXPAY_PUBLIC_KEY_ID 改为必填
- serial 匹配检查改为强制
- 时间戳校验移到签名验证之前
- nonce 改用 crypto.randomBytes
- publicKey 不允许空 Buffer fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:57:55 +08:00
erio
5253bc8d35 fix: 支付宝 H5 下单失败时 fallback 到 PC 页面支付
与微信支付保持一致的 fallback 策略。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:40:09 +08:00
erio
e2a6895bb7 fix: H5 fallback 仅在 NO_AUTH 时触发,其他错误正常抛出
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:35:23 +08:00
erio
0c6c6e0ea6 fix: 微信 H5 支付未开通时 fallback 到 Native 扫码
移动端尝试 H5 下单失败时自动降级为 Native 二维码模式,
避免因 H5 权限未开通导致支付不可用。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:34:57 +08:00
erio
918750047a Revert "fix: 微信支付暂时统一使用 Native 扫码模式"
This reverts commit ef1078279a.
2026-03-06 22:32:53 +08:00
erio
ef1078279a fix: 微信支付暂时统一使用 Native 扫码模式
H5 支付需要在微信商户平台单独开通权限,当前未开通会报
NO_AUTH 错误。暂时移动端也走 Native 二维码,待 H5 权限
开通后再恢复。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:32:13 +08:00
erio
225f2e0c5a fix: 微信支付回调验签改用公钥直接验证
wechatpay-node-v3 的 verifySign 会尝试拉取平台证书,
但我们使用的是微信支付公钥模式,不需要平台证书。
改用 crypto.createVerify 直接用公钥做 RSA-SHA256 验签。
同时增加 serial 与 WXPAY_PUBLIC_KEY_ID 的匹配校验。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:25:58 +08:00
erio
96962ec38e fix: 微信支付密钥支持文件路径自动解析
在 getEnv() 中对 WXPAY_PRIVATE_KEY 和 WXPAY_PUBLIC_KEY 调用
resolveKeyValue(),与支付宝保持一致,支持通过文件路径配置密钥。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:10:50 +08:00
erio
137a780269 refactor: 简化支付展示逻辑 - 有 payUrl 直接跳转,有 qrCode 显示二维码
移除 isRedirectPayment / mobileRedirectUrl 等支付类型判断,
前端只根据后端返回的字段决定行为:
- payUrl → 自动跳转,无需确认
- qrCode → 展示二维码 + 中心图标
- clientSecret → Stripe 嵌入式表单

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:13:06 +08:00
erio
7be0614c7d fix: wxpay_direct supportedTypes 修正,避免与易支付 wxpay 冲突
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:05:21 +08:00
erio
7cab333213 merge: 合并 wxpay_direct 微信支付直连分支
解决冲突:保留 main 的常量化/provider 字段/ENABLED_PAYMENT_TYPES 移除,
合并 worktree 的微信支付直连实现、notifyUrl/returnUrl 传递、签名验证优化。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:00:16 +08:00
erio
e72325140b style: 移动端支付方式选择器改为两列网格布局
避免三个选项挤在一行,移动端使用 grid-cols-2,PC 端保持 flex 等宽一行。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:54:28 +08:00
erio
d46793f072 feat: 支付宝直连 H5 端使用 wap.pay 唤起支付宝 APP
前端传递 is_mobile 参数,AlipayProvider 根据设备类型选择:
- PC: alipay.trade.page.pay (FAST_INSTANT_TRADE_PAY)
- H5: alipay.trade.wap.pay (QUICK_WAP_WAY)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:04:11 +08:00
erio
94d25ddc31 refactor: 移除 ENABLED_PAYMENT_TYPES,支付类型由 PAYMENT_PROVIDERS 自动推导
PAYMENT_PROVIDERS 配置提供商后,各 provider 的 supportedTypes 自动注册为可用支付类型,
无需再手动配置 ENABLED_PAYMENT_TYPES。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:53:47 +08:00
erio
254ead1908 refactor: 常量化订单状态 + 支付渠道/提供商分离显示 + H5自动跳转
- 新增 src/lib/constants.ts,集中管理 ORDER_STATUS / PAYMENT_TYPE / PAYMENT_PREFIX 等常量
- 后端 service/status/timeout/limits 全量替换魔法字符串为 ORDER_STATUS.*
- PaymentTypeMeta 新增 provider 字段,分离 sublabel(选择器展示)与 provider(提供商名称)
- getPaymentDisplayInfo() 返回 { channel, provider } 用于用户端/管理端展示
- 支持通过 PAYMENT_SUBLABEL_* 环境变量覆盖默认 sublabel
- PaymentQRCode: H5 支付自动跳转(含易支付微信 weixin:// scheme 兜底)
- 订单列表/详情页:显示可读的渠道名+提供商,不再暴露内部标识符

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:34:42 +08:00
erio
3829d0e52e refactor: 将支付类型硬编码抽取到 pay-utils 统一管理
- PaymentTypeMeta 新增 iconSrc、chartBar、buttonClass 字段
- 新增工具函数: getPaymentMeta、getPaymentIconSrc、
  getPaymentChannelLabel、isStripeType、isRedirectPayment 等
- PaymentQRCode: 用 meta/工具函数替换散落的颜色和类型判断
- PaymentForm: 提交按钮颜色改用 meta.buttonClass
- PaymentMethodChart: 删除重复的 TYPE_CONFIG,改用 getPaymentMeta
- stripe-popup: 按钮颜色改用 meta.buttonClass

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:46:36 +08:00
erio
cee24c3afb fix: 修复 isAlipayDirect 变量声明顺序导致构建失败
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:34:01 +08:00
erio
dc78a41912 fix: alipay_direct 桌面端跳转到支付宝收银台而非显示二维码
alipay.trade.page.pay 返回的 payUrl 是跳转链接,不应作为
二维码内容展示。改为显示"前往支付宝收银台"按钮,在新标签页
打开支付宝收银台页面。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:32:50 +08:00
erio
ad6b63dd9e fix: 修复支付宝签名时错误排除 sign_type 导致 invalid-signature
支付宝验签字符串包含 sign_type 字段,但 generateSign 错误地
将 sign_type 与 sign 一起排除。只需排除 sign,保留 sign_type。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:14:52 +08:00
erio
0763d72a89 fix: 各支付 provider 自行获取 notifyUrl/returnUrl
service.ts 不再硬编码传 EASY_PAY 的 notify/return URL,
避免官方支付宝错误使用 EasyPay 的回调地址导致签名失败。
2026-03-06 15:49:54 +08:00
erio
f53aa9e14c feat: 支持官方支付宝与易支付支付宝同时展示
- PaymentType 改为 string 类型,支持复合 key(如 alipay_direct)
- 官方支付宝注册为 alipay_direct,易支付保持 alipay/wxpay
- 前端按 PAYMENT_TYPE_META 展示标签区分(官方直连/易支付)
- 管理后台显示统一改为 getPaymentTypeLabel 通用映射
- 修复 admin/OrderTable 中 wechat 拼写错误
2026-03-06 15:33:22 +08:00
erio
01d5a0b3c4 feat: 支付宝密钥支持文件路径配置
ALIPAY_PRIVATE_KEY / ALIPAY_PUBLIC_KEY 可直接填密钥内容,
也可填文件路径,代码自动检测并读取文件。
2026-03-06 15:11:47 +08:00
erio
cba8acdd60 refactor: 微信支付命名从 Native 改为 PC扫码,统一术语
- WxpayNativeOrderParams → WxpayPcOrderParams
- createNativeOrder → createPcOrder
- 文档注释统一为「PC扫码 / H5」
2026-03-06 14:31:43 +08:00
erio
b0f1daf469 fix: 修复微信支付 Native/H5 判断逻辑,改为前端设备检测驱动
- 修复 clientIp 始终存在导致永远走 H5 的 bug,改用 isMobile 判断
- 前端通过 detectDeviceIsMobile() 传 is_mobile 给后端
- ENABLED_PAYMENT_TYPES 默认改为空,必须显式配置才启用
- 补充 .env.example 配置说明
2026-03-06 14:04:51 +08:00
erio
937f54dec2 feat: 集成微信支付直连(Native + H5)及金融级安全修复
- 新增 wxpay provider(wechatpay-node-v3 SDK),支持 Native 扫码和 H5 跳转
- 新增 /api/wxpay/notify 回调路由,AES-256-GCM 解密 + RSA 签名验证
- 修复 confirmPayment count=0 静默成功、充值失败返回 true 等 P0 问题
- 修复 notifyUrl 硬编码 easypay、回调金额覆盖订单金额等 P1 问题
- 手续费计算改用 Prisma.Decimal 精确运算,消除浮点误差
- 支付宝 provider 移除冗余 paramsForVerify,fetch 添加超时
- 补充 .env.example 配置文档和 CLAUDE.md 支付渠道说明
2026-03-06 13:57:52 +08:00
erio
e9e164babc chore: 添加 .gitattributes 强制 LF 行尾 2026-03-05 23:12:01 +08:00
erio
0a35ba9002 style: 全量 prettier 格式化 2026-03-05 23:10:44 +08:00
erio
ab961e669a fix: 修复 lint errors(hooks 条件调用、未转义引号、effect 内 setState) 2026-03-05 23:08:48 +08:00
eriol touwa
93a417b312 Merge pull request #4 from dexcoder6/feat/admin-dashboard
fix: 数据看板时区统一为 Asia/Shanghai + 订单列表支付方式显示修复
2026-03-05 23:03:24 +08:00
erio
ba1ce6b696 fix: CI 各 job 添加 prisma generate 步骤
Prisma 7 需要先 generate 才能生成 @prisma/client 类型,
缺少此步骤导致 typecheck/lint/test 全部失败。
2026-03-05 23:02:27 +08:00
erio
448d36fe2b feat: 移动端 H5 支付跳转 + 改进移动端检测
- PaymentQRCode: 移动端有 payUrl 时直接跳转支付,iframe 中新窗口打开
- detectDeviceIsMobile: 优先使用 navigator.userAgentData.mobile API
2026-03-05 16:21:12 +08:00
miwei
f1e3fd35ef fix: 数据看板时区统一为 Asia/Shanghai + 订单列表支付方式显示修复
- dashboard API 日期计算和 SQL GROUP BY 统一使用东八区,避免 UTC 服务器上"今日"统计偏移
- OrderTable 支付方式显示支持 stripe/wechat/alipay 三种,修复 stripe 误显为微信支付

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:13:57 +08:00
erio
8746f474d1 refactor: 支付宝 providerKey 改为 alipay 2026-03-05 01:52:59 +08:00
erio
55756744a1 feat: 集成支付宝电脑网站支付(alipay direct)
- 新增 src/lib/alipay/ 模块:RSA2 签名、网关客户端、AlipayProvider
- 新增 /api/alipay/notify 异步通知回调路由
- config.ts 添加 ALIPAY_* 环境变量
- payment/index.ts 注册 alipaydirect 提供商
- 27 个单元测试全部通过
2026-03-05 01:48:10 +08:00
60 changed files with 2696 additions and 635 deletions

View File

@@ -6,10 +6,11 @@ SUB2API_BASE_URL="https://your-sub2api-domain.com"
SUB2API_ADMIN_API_KEY="your-admin-api-key"
# ── 支付服务商(逗号分隔,决定加载哪些服务商) ───────────────────────────────
# 可选值: easypay, stripe
# 示例(仅易支付): 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"
# ── 订单配置 ──────────────────────────────────────────────────────────────────

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

View File

@@ -24,6 +24,7 @@ jobs:
node-version-file: .node-version
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm prisma generate
- run: pnpm typecheck
lint:
@@ -39,6 +40,7 @@ jobs:
node-version-file: .node-version
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm prisma generate
- run: pnpm lint
format:
@@ -54,6 +56,7 @@ jobs:
node-version-file: .node-version
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm prisma generate
- run: pnpm format:check
test:
@@ -69,4 +72,5 @@ jobs:
node-version-file: .node-version
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm prisma generate
- run: pnpm test

1
.idea/vcs.xml generated
View File

@@ -2,6 +2,5 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/third-party/sub2api" vcs="Git" />
</component>
</project>

View File

@@ -34,15 +34,15 @@ Sub2ApiPay is a self-hosted recharge payment gateway built for the [Sub2API](htt
## Tech Stack
| Category | Technology |
|----------|------------|
| Framework | Next.js 16 (App Router) |
| Language | TypeScript 5 + React 19 |
| Styling | TailwindCSS 4 |
| ORM | Prisma 7 (adapter-pg mode) |
| Database | PostgreSQL 16 |
| Container | Docker + Docker Compose |
| Package Manager | pnpm |
| Category | Technology |
| --------------- | -------------------------- |
| Framework | Next.js 16 (App Router) |
| Language | TypeScript 5 + React 19 |
| Styling | TailwindCSS 4 |
| ORM | Prisma 7 (adapter-pg mode) |
| Database | PostgreSQL 16 |
| Container | Docker + Docker Compose |
| Package Manager | pnpm |
---
@@ -85,12 +85,12 @@ See [`.env.example`](./.env.example) for the full template.
### Core (Required)
| Variable | Description |
|----------|-------------|
| `SUB2API_BASE_URL` | Sub2API service URL, e.g. `https://sub2api.com` |
| `SUB2API_ADMIN_API_KEY` | Sub2API admin API key |
| `ADMIN_TOKEN` | Admin panel access token (use a strong random string) |
| `NEXT_PUBLIC_APP_URL` | Public URL of this service, e.g. `https://pay.example.com` |
| Variable | Description |
| ----------------------- | ---------------------------------------------------------- |
| `SUB2API_BASE_URL` | Sub2API service URL, e.g. `https://sub2api.com` |
| `SUB2API_ADMIN_API_KEY` | Sub2API admin API key |
| `ADMIN_TOKEN` | Admin panel access token (use a strong random string) |
| `NEXT_PUBLIC_APP_URL` | Public URL of this service, e.g. `https://pay.example.com` |
> `DATABASE_URL` is automatically injected by Docker Compose when using the bundled database.
@@ -127,49 +127,50 @@ Any payment provider compatible with the **EasyPay protocol** can be used, such
> **Disclaimer**: Please evaluate the security, reliability, and compliance of any third-party payment provider on your own. This project does not endorse or guarantee any specific provider.
| Variable | Description |
|----------|-------------|
| `EASY_PAY_PID` | EasyPay merchant ID |
| `EASY_PAY_PKEY` | EasyPay merchant secret key |
| `EASY_PAY_API_BASE` | EasyPay API base URL |
| Variable | Description |
| --------------------- | ---------------------------------------------------------------- |
| `EASY_PAY_PID` | EasyPay merchant ID |
| `EASY_PAY_PKEY` | EasyPay merchant secret key |
| `EASY_PAY_API_BASE` | EasyPay API base URL |
| `EASY_PAY_NOTIFY_URL` | Async callback URL: `${NEXT_PUBLIC_APP_URL}/api/easy-pay/notify` |
| `EASY_PAY_RETURN_URL` | Redirect URL after payment: `${NEXT_PUBLIC_APP_URL}/pay` |
| `EASY_PAY_CID_ALIPAY` | Alipay channel ID (optional) |
| `EASY_PAY_CID_WXPAY` | WeChat Pay channel ID (optional) |
| `EASY_PAY_RETURN_URL` | Redirect URL after payment: `${NEXT_PUBLIC_APP_URL}/pay` |
| `EASY_PAY_CID_ALIPAY` | Alipay channel ID (optional) |
| `EASY_PAY_CID_WXPAY` | WeChat Pay channel ID (optional) |
#### Stripe
| Variable | Description |
|----------|-------------|
| `STRIPE_SECRET_KEY` | Stripe secret key (`sk_live_...`) |
| `STRIPE_PUBLISHABLE_KEY` | Stripe publishable key (`pk_live_...`) |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret (`whsec_...`) |
| Variable | Description |
| ------------------------ | ------------------------------------------- |
| `STRIPE_SECRET_KEY` | Stripe secret key (`sk_live_...`) |
| `STRIPE_PUBLISHABLE_KEY` | Stripe publishable key (`pk_live_...`) |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret (`whsec_...`) |
> Stripe webhook endpoint: `${NEXT_PUBLIC_APP_URL}/api/stripe/webhook`
> Subscribe to: `payment_intent.succeeded`, `payment_intent.payment_failed`
### Business Rules
| Variable | Description | Default |
|----------|-------------|---------|
| `MIN_RECHARGE_AMOUNT` | Minimum amount per transaction (CNY) | `1` |
| `MAX_RECHARGE_AMOUNT` | Maximum amount per transaction (CNY) | `1000` |
| `MAX_DAILY_RECHARGE_AMOUNT` | Daily cumulative max per user (`0` = unlimited) | `10000` |
| `ORDER_TIMEOUT_MINUTES` | Order expiry in minutes | `5` |
| `PRODUCT_NAME` | Product name shown on payment page | `Sub2API Balance Recharge` |
| Variable | Description | Default |
| --------------------------- | ----------------------------------------------- | -------------------------- |
| `MIN_RECHARGE_AMOUNT` | Minimum amount per transaction (CNY) | `1` |
| `MAX_RECHARGE_AMOUNT` | Maximum amount per transaction (CNY) | `1000` |
| `MAX_DAILY_RECHARGE_AMOUNT` | Daily cumulative max per user (`0` = unlimited) | `10000` |
| `ORDER_TIMEOUT_MINUTES` | Order expiry in minutes | `5` |
| `PRODUCT_NAME` | Product name shown on payment page | `Sub2API Balance Recharge` |
### UI Customization (Optional)
Display a support contact image and description on the right side of the payment page.
| Variable | Description |
|----------|-------------|
| `PAY_HELP_IMAGE_URL` | Help image URL — external URL or local path (see below) |
| `PAY_HELP_TEXT` | Help text; use `\n` for line breaks, e.g. `Scan to add WeChat\nMonFri 9am6pm` |
| Variable | Description |
| -------------------- | ------------------------------------------------------------------------------- |
| `PAY_HELP_IMAGE_URL` | Help image URL — external URL or local path (see below) |
| `PAY_HELP_TEXT` | Help text; use `\n` for line breaks, e.g. `Scan to add WeChat\nMonFri 9am6pm` |
**Two ways to provide the image:**
- **External URL** (recommended — no Compose changes needed): any publicly accessible image link (CDN, OSS, image hosting).
```env
PAY_HELP_IMAGE_URL=https://cdn.example.com/help-qr.jpg
```
@@ -188,9 +189,9 @@ Display a support contact image and description on the right side of the payment
### Docker Compose Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `APP_PORT` | Host port mapping | `3001` |
| Variable | Description | Default |
| ------------- | -------------------------------- | ------------------------------------- |
| `APP_PORT` | Host port mapping | `3001` |
| `DB_PASSWORD` | PostgreSQL password (bundled DB) | `password` (**change in production**) |
---
@@ -266,19 +267,19 @@ docker compose exec app npx prisma migrate deploy
The following page URLs can be configured in the Sub2API admin panel:
| Page | URL | Description |
|------|-----|-------------|
| Payment | `https://pay.example.com/pay` | User recharge entry |
| My Orders | `https://pay.example.com/pay/orders` | User views their own recharge history |
| Order Management | `https://pay.example.com/admin` | Sub2API admin only |
| Page | URL | Description |
| ---------------- | ------------------------------------ | ------------------------------------- |
| Payment | `https://pay.example.com/pay` | User recharge entry |
| My Orders | `https://pay.example.com/pay/orders` | User views their own recharge history |
| Order Management | `https://pay.example.com/admin` | Sub2API admin only |
Sub2API **v0.1.88** and above will automatically append the following parameters — no manual query string needed:
| Parameter | Description |
|-----------|-------------|
| `user_id` | Sub2API user ID |
| `token` | User login token (required to view order history) |
| `theme` | `light` (default) or `dark` |
| Parameter | Description |
| --------- | ------------------------------------------------- |
| `user_id` | Sub2API user ID |
| `token` | User login token (required to view order history) |
| `theme` | `light` (default) or `dark` |
| `ui_mode` | `standalone` (default) or `embedded` (for iframe) |
---
@@ -287,13 +288,13 @@ Sub2API **v0.1.88** and above will automatically append the following parameters
Access: `https://pay.example.com/admin?token=YOUR_ADMIN_TOKEN`
| Feature | Description |
|---------|-------------|
| Order List | Filter by status, paginate, choose 20/50/100 per page |
| Order Detail | View all fields and audit log timeline |
| Retry Recharge | Re-trigger recharge for paid-but-failed orders |
| Cancel Order | Force-cancel pending orders |
| Refund | Issue refund and deduct Sub2API balance |
| Feature | Description |
| -------------- | ----------------------------------------------------- |
| Order List | Filter by status, paginate, choose 20/50/100 per page |
| Order Detail | View all fields and audit log timeline |
| Retry Recharge | Re-trigger recharge for paid-but-failed orders |
| Cancel Order | Force-cancel pending orders |
| Refund | Issue refund and deduct Sub2API balance |
---

117
README.md
View File

@@ -34,15 +34,15 @@ Sub2ApiPay 是为 [Sub2API](https://sub2api.com) 平台构建的自托管充值
## 技术栈
| 类别 | 技术 |
|------|------|
| 框架 | Next.js 16 (App Router) |
| 语言 | TypeScript 5 + React 19 |
| 样式 | TailwindCSS 4 |
| ORM | Prisma 7adapter-pg 模式) |
| 数据库 | PostgreSQL 16 |
| 容器 | Docker + Docker Compose |
| 包管理 | pnpm |
| 类别 | 技术 |
| ------ | --------------------------- |
| 框架 | Next.js 16 (App Router) |
| 语言 | TypeScript 5 + React 19 |
| 样式 | TailwindCSS 4 |
| ORM | Prisma 7adapter-pg 模式) |
| 数据库 | PostgreSQL 16 |
| 容器 | Docker + Docker Compose |
| 包管理 | pnpm |
---
@@ -85,12 +85,12 @@ docker compose up -d --build
### 核心(必填)
| 变量 | 说明 |
|------|------|
| `SUB2API_BASE_URL` | Sub2API 服务地址,如 `https://sub2api.com` |
| `SUB2API_ADMIN_API_KEY` | Sub2API 管理 API 密钥 |
| `ADMIN_TOKEN` | 管理后台访问令牌(自定义强密码) |
| `NEXT_PUBLIC_APP_URL` | 本服务的公网地址,如 `https://pay.example.com` |
| 变量 | 说明 |
| ----------------------- | ---------------------------------------------- |
| `SUB2API_BASE_URL` | Sub2API 服务地址,如 `https://sub2api.com` |
| `SUB2API_ADMIN_API_KEY` | Sub2API 管理 API 密钥 |
| `ADMIN_TOKEN` | 管理后台访问令牌(自定义强密码) |
| `NEXT_PUBLIC_APP_URL` | 本服务的公网地址,如 `https://pay.example.com` |
> `DATABASE_URL` 使用自带数据库时由 Compose 自动注入,无需手动填写。
@@ -127,49 +127,50 @@ ENABLED_PAYMENT_TYPES=alipay,wxpay
> **注意**:支付渠道的安全性、稳定性及合规性请自行鉴别,本项目不对任何第三方支付服务商做担保或背书。
| 变量 | 说明 |
|------|------|
| `EASY_PAY_PID` | EasyPay 商户 ID |
| `EASY_PAY_PKEY` | EasyPay 商户密钥 |
| `EASY_PAY_API_BASE` | EasyPay API 地址 |
| 变量 | 说明 |
| --------------------- | ------------------------------------------------------------- |
| `EASY_PAY_PID` | EasyPay 商户 ID |
| `EASY_PAY_PKEY` | EasyPay 商户密钥 |
| `EASY_PAY_API_BASE` | EasyPay API 地址 |
| `EASY_PAY_NOTIFY_URL` | 异步回调地址,填 `${NEXT_PUBLIC_APP_URL}/api/easy-pay/notify` |
| `EASY_PAY_RETURN_URL` | 支付完成跳转地址,填 `${NEXT_PUBLIC_APP_URL}/pay` |
| `EASY_PAY_CID_ALIPAY` | 支付宝通道 ID可选 |
| `EASY_PAY_CID_WXPAY` | 微信支付通道 ID可选 |
| `EASY_PAY_RETURN_URL` | 支付完成跳转地址,填 `${NEXT_PUBLIC_APP_URL}/pay` |
| `EASY_PAY_CID_ALIPAY` | 支付宝通道 ID可选 |
| `EASY_PAY_CID_WXPAY` | 微信支付通道 ID可选 |
#### Stripe
| 变量 | 说明 |
|------|------|
| `STRIPE_SECRET_KEY` | Stripe 密钥(`sk_live_...` |
| `STRIPE_PUBLISHABLE_KEY` | Stripe 可公开密钥(`pk_live_...` |
| `STRIPE_WEBHOOK_SECRET` | Stripe Webhook 签名密钥(`whsec_...` |
| 变量 | 说明 |
| ------------------------ | -------------------------------------- |
| `STRIPE_SECRET_KEY` | Stripe 密钥(`sk_live_...` |
| `STRIPE_PUBLISHABLE_KEY` | Stripe 可公开密钥(`pk_live_...` |
| `STRIPE_WEBHOOK_SECRET` | Stripe Webhook 签名密钥(`whsec_...` |
> Stripe Webhook 端点:`${NEXT_PUBLIC_APP_URL}/api/stripe/webhook`
> 需订阅事件:`payment_intent.succeeded`、`payment_intent.payment_failed`
### 业务规则
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `MIN_RECHARGE_AMOUNT` | 单笔最低充值金额(元) | `1` |
| `MAX_RECHARGE_AMOUNT` | 单笔最高充值金额(元) | `1000` |
| `MAX_DAILY_RECHARGE_AMOUNT` | 每日累计最高充值(元,`0` = 不限) | `10000` |
| `ORDER_TIMEOUT_MINUTES` | 订单超时分钟数 | `5` |
| `PRODUCT_NAME` | 充值商品名称(显示在支付页) | `Sub2API Balance Recharge` |
| 变量 | 说明 | 默认值 |
| --------------------------- | ---------------------------------- | -------------------------- |
| `MIN_RECHARGE_AMOUNT` | 单笔最低充值金额(元) | `1` |
| `MAX_RECHARGE_AMOUNT` | 单笔最高充值金额(元) | `1000` |
| `MAX_DAILY_RECHARGE_AMOUNT` | 每日累计最高充值(元,`0` = 不限) | `10000` |
| `ORDER_TIMEOUT_MINUTES` | 订单超时分钟数 | `5` |
| `PRODUCT_NAME` | 充值商品名称(显示在支付页) | `Sub2API Balance Recharge` |
### UI 定制(可选)
在充值页面右侧可展示客服联系方式、说明图片等帮助内容。
| 变量 | 说明 |
|------|------|
| `PAY_HELP_IMAGE_URL` | 帮助图片地址(支持外部 URL 或本地路径,见下方说明) |
| `PAY_HELP_TEXT` | 帮助说明文字,用 `\n` 换行,如 `扫码加微信\n工作日 9-18 点在线` |
| 变量 | 说明 |
| -------------------- | --------------------------------------------------------------- |
| `PAY_HELP_IMAGE_URL` | 帮助图片地址(支持外部 URL 或本地路径,见下方说明) |
| `PAY_HELP_TEXT` | 帮助说明文字,用 `\n` 换行,如 `扫码加微信\n工作日 9-18 点在线` |
**图片地址两种方式:**
- **外部 URL**(推荐,无需改 Compose 配置):直接填图片的公网地址,如 OSS / CDN / 图床链接。
```env
PAY_HELP_IMAGE_URL=https://cdn.example.com/help-qr.jpg
```
@@ -188,9 +189,9 @@ ENABLED_PAYMENT_TYPES=alipay,wxpay
### Docker Compose 专用
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `APP_PORT` | 宿主机映射端口 | `3001` |
| 变量 | 说明 | 默认值 |
| ------------- | ----------------------------------- | ---------------------------- |
| `APP_PORT` | 宿主机映射端口 | `3001` |
| `DB_PASSWORD` | PostgreSQL 密码(使用自带数据库时) | `password`**生产请修改** |
---
@@ -266,19 +267,19 @@ docker compose exec app npx prisma migrate deploy
在 Sub2API 管理后台可配置以下页面链接:
| 页面 | 链接 | 说明 |
|------|------|------|
| 充值页面 | `https://pay.example.com/pay` | 用户充值入口 |
| 我的订单 | `https://pay.example.com/pay/orders` | 用户查看自己的充值记录 |
| 订单管理 | `https://pay.example.com/admin` | 仅 Sub2API 管理员可访问 |
| 页面 | 链接 | 说明 |
| -------- | ------------------------------------ | ----------------------- |
| 充值页面 | `https://pay.example.com/pay` | 用户充值入口 |
| 我的订单 | `https://pay.example.com/pay/orders` | 用户查看自己的充值记录 |
| 订单管理 | `https://pay.example.com/admin` | 仅 Sub2API 管理员可访问 |
Sub2API **v0.1.88** 及以上版本会自动拼接以下参数,无需手动添加:
| 参数 | 说明 |
|------|------|
| `user_id` | Sub2API 用户 ID |
| `token` | 用户登录 Token有 token 才能查看订单历史) |
| `theme` | `light`(默认)或 `dark` |
| 参数 | 说明 |
| --------- | ------------------------------------------------ |
| `user_id` | Sub2API 用户 ID |
| `token` | 用户登录 Token有 token 才能查看订单历史) |
| `theme` | `light`(默认)或 `dark` |
| `ui_mode` | `standalone`(默认)或 `embedded`iframe 嵌入) |
---
@@ -287,13 +288,13 @@ Sub2API **v0.1.88** 及以上版本会自动拼接以下参数,无需手动添
访问:`https://pay.example.com/admin?token=YOUR_ADMIN_TOKEN`
| 功能 | 说明 |
|------|------|
| 功能 | 说明 |
| -------- | ------------------------------------------- |
| 订单列表 | 按状态筛选、分页浏览,支持每页 20/50/100 条 |
| 订单详情 | 查看完整字段与操作审计日志 |
| 重试充值 | 对已支付但充值失败的订单重新发起充值 |
| 取消订单 | 强制取消待支付订单 |
| 退款 | 对已完成订单发起退款并扣减 Sub2API 余额 |
| 订单详情 | 查看完整字段与操作审计日志 |
| 重试充值 | 对已支付但充值失败的订单重新发起充值 |
| 取消订单 | 强制取消待支付订单 |
| 退款 | 对已完成订单发起退款并扣减 Sub2API 余额 |
---

View File

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

View File

@@ -25,6 +25,7 @@
"react-dom": "19.2.3",
"recharts": "^3.7.0",
"stripe": "^20.4.0",
"wechatpay-node-v3": "^2.2.1",
"zod": "^4.3.6"
},
"pnpm": {

182
pnpm-lock.yaml generated
View File

@@ -38,6 +38,9 @@ importers:
stripe:
specifier: ^20.4.0
version: 20.4.0(@types/node@20.19.35)
wechatpay-node-v3:
specifier: ^2.2.1
version: 2.2.1
zod:
specifier: ^4.3.6
version: 4.3.6
@@ -403,6 +406,14 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@fidm/asn1@1.0.4':
resolution: {integrity: sha512-esd1jyNvRb2HVaQGq2Gg8Z0kbQPXzV9Tq5Z14KNIov6KfFD6PTaRIO8UpcsYiTNzOqJpmyzWgVTrUwFV3UF4TQ==}
engines: {node: '>= 8'}
'@fidm/x509@1.2.1':
resolution: {integrity: sha512-nwc2iesjyc9hkuzcrMCBXQRn653XuAUKorfWM8PZyJawiy1QzLj4vahwzaI25+pfpwOLvMzbJ0uKpWLDNmo16w==}
engines: {node: '>= 8'}
'@hono/node-server@1.19.9':
resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==}
engines: {node: '>=18.14.1'}
@@ -659,6 +670,10 @@ packages:
cpu: [x64]
os: [win32]
'@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -675,6 +690,9 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
'@prisma/adapter-pg@7.4.1':
resolution: {integrity: sha512-AH9XrqvSoBAaStn0Gm/sAnF97pDKz8uLpNmn51j1S9O9dhUva6LIxGdoDiiU9VXRIR89wAJXsvJSy+mK40m2xw==}
@@ -1327,6 +1345,9 @@ packages:
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
engines: {node: '>= 0.4'}
asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@@ -1338,6 +1359,9 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
@@ -1451,6 +1475,13 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
component-emitter@1.3.1:
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -1464,6 +1495,9 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cookiejar@2.1.4:
resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -1572,6 +1606,10 @@ packages:
defu@6.1.4:
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
denque@2.1.0:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'}
@@ -1583,6 +1621,9 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
dezalgo@1.0.4:
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
@@ -1819,6 +1860,9 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
@@ -1862,6 +1906,13 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
formidable@2.1.5:
resolution: {integrity: sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2310,10 +2361,27 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
methods@1.1.2:
resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
engines: {node: '>= 0.6'}
micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
mime@2.6.0:
resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
engines: {node: '>=4.0.0'}
hasBin: true
minimatch@10.2.4:
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
engines: {node: 18 || 20 || >=22}
@@ -2422,6 +2490,9 @@ packages:
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -2599,6 +2670,10 @@ packages:
engines: {node: '>=10.13.0'}
hasBin: true
qs@6.15.0:
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
engines: {node: '>=0.6'}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -2879,6 +2954,11 @@ packages:
babel-plugin-macros:
optional: true
superagent@8.0.6:
resolution: {integrity: sha512-HqSe6DSIh3hEn6cJvCkaM1BLi466f1LHi4yubR0tpewlMpk4RUFFy35bKz8SsPBwYfIIJy5eclp+3tCYAuX0bw==}
engines: {node: '>=6.4.0 <13 || >=14'}
deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -2928,6 +3008,9 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tweetnacl@1.0.3:
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -3069,6 +3152,9 @@ packages:
jsdom:
optional: true
wechatpay-node-v3@2.2.1:
resolution: {integrity: sha512-z+n8Mrzn0UNoLJPBRrY8ZG6yo9xxNihlGvwvAbV8Nlnm4tTap2UjwIikGkhryC8gOmwrlvJfSUd+x1cK3ks1hA==}
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -3106,6 +3192,9 @@ packages:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
@@ -3421,6 +3510,13 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
'@fidm/asn1@1.0.4': {}
'@fidm/x509@1.2.1':
dependencies:
'@fidm/asn1': 1.0.4
tweetnacl: 1.0.3
'@hono/node-server@1.19.9(hono@4.11.4)':
dependencies:
hono: 4.11.4
@@ -3594,6 +3690,8 @@ snapshots:
'@next/swc-win32-x64-msvc@16.1.6':
optional: true
'@noble/hashes@1.8.0': {}
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -3608,6 +3706,10 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@paralleldrive/cuid2@2.3.1':
dependencies:
'@noble/hashes': 1.8.0
'@prisma/adapter-pg@7.4.1':
dependencies:
'@prisma/driver-adapter-utils': 7.4.1
@@ -4243,12 +4345,16 @@ snapshots:
get-intrinsic: 1.3.0
is-array-buffer: 3.0.5
asap@2.0.6: {}
assertion-error@2.0.1: {}
ast-types-flow@0.0.8: {}
async-function@1.0.0: {}
asynckit@0.4.0: {}
available-typed-arrays@1.0.7:
dependencies:
possible-typed-array-names: 1.1.0
@@ -4366,6 +4472,12 @@ snapshots:
color-name@1.1.4: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
component-emitter@1.3.1: {}
concat-map@0.0.1: {}
confbox@0.2.4: {}
@@ -4374,6 +4486,8 @@ snapshots:
convert-source-map@2.0.0: {}
cookiejar@2.1.4: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -4470,12 +4584,19 @@ snapshots:
defu@6.1.4: {}
delayed-stream@1.0.0: {}
denque@2.1.0: {}
destr@2.0.5: {}
detect-libc@2.1.2: {}
dezalgo@1.0.4:
dependencies:
asap: 2.0.6
wrappy: 1.0.2
dijkstrajs@1.0.3: {}
doctrine@2.1.0:
@@ -4879,6 +5000,8 @@ snapshots:
fast-levenshtein@2.0.6: {}
fast-safe-stringify@2.1.1: {}
fastq@1.20.1:
dependencies:
reusify: 1.1.0
@@ -4921,6 +5044,21 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
formidable@2.1.5:
dependencies:
'@paralleldrive/cuid2': 2.3.1
dezalgo: 1.0.4
once: 1.4.0
qs: 6.15.0
fsevents@2.3.3:
optional: true
@@ -5326,11 +5464,21 @@ snapshots:
merge2@1.4.1: {}
methods@1.1.2: {}
micromatch@4.0.8:
dependencies:
braces: 3.0.3
picomatch: 2.3.1
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
mime@2.6.0: {}
minimatch@10.2.4:
dependencies:
brace-expansion: 5.0.4
@@ -5452,6 +5600,10 @@ snapshots:
ohash@2.0.11: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -5618,6 +5770,10 @@ snapshots:
pngjs: 5.0.0
yargs: 15.4.1
qs@6.15.0:
dependencies:
side-channel: 1.1.0
queue-microtask@1.2.3: {}
rc9@2.1.2:
@@ -5978,6 +6134,21 @@ snapshots:
optionalDependencies:
'@babel/core': 7.29.0
superagent@8.0.6:
dependencies:
component-emitter: 1.3.1
cookiejar: 2.1.4
debug: 4.4.3
fast-safe-stringify: 2.1.1
form-data: 4.0.5
formidable: 2.1.5
methods: 1.1.2
mime: 2.6.0
qs: 6.15.0
semver: 7.7.4
transitivePeerDependencies:
- supports-color
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
@@ -6018,6 +6189,8 @@ snapshots:
tslib@2.8.1: {}
tweetnacl@1.0.3: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -6187,6 +6360,13 @@ snapshots:
- tsx
- yaml
wechatpay-node-v3@2.2.1:
dependencies:
'@fidm/x509': 1.2.1
superagent: 8.0.6
transitivePeerDependencies:
- supports-color
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
@@ -6247,6 +6427,8 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
wrappy@1.0.2: {}
xtend@4.0.2: {}
y18n@4.0.3: {}

View File

@@ -0,0 +1,274 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/lib/config', () => ({
getEnv: () => ({
ALIPAY_APP_ID: '2021000000000000',
ALIPAY_PRIVATE_KEY: 'test-private-key',
ALIPAY_PUBLIC_KEY: 'test-public-key',
ALIPAY_NOTIFY_URL: 'https://pay.example.com/api/alipay/notify',
ALIPAY_RETURN_URL: 'https://pay.example.com/pay/result',
NEXT_PUBLIC_APP_URL: 'https://pay.example.com',
}),
}));
const mockPageExecute = vi.fn();
const mockExecute = vi.fn();
vi.mock('@/lib/alipay/client', () => ({
pageExecute: (...args: unknown[]) => mockPageExecute(...args),
execute: (...args: unknown[]) => mockExecute(...args),
}));
const mockVerifySign = vi.fn();
vi.mock('@/lib/alipay/sign', () => ({
verifySign: (...args: unknown[]) => mockVerifySign(...args),
}));
import { AlipayProvider } from '@/lib/alipay/provider';
import type { CreatePaymentRequest, RefundRequest } from '@/lib/payment/types';
describe('AlipayProvider', () => {
let provider: AlipayProvider;
beforeEach(() => {
vi.clearAllMocks();
provider = new AlipayProvider();
});
describe('metadata', () => {
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_direct" payment type', () => {
expect(provider.supportedTypes).toEqual(['alipay_direct']);
});
it('should have default limits', () => {
expect(provider.defaultLimits).toEqual({
alipay_direct: { singleMax: 1000, dailyMax: 10000 },
});
});
});
describe('createPayment', () => {
it('should call pageExecute and return payUrl', async () => {
mockPageExecute.mockReturnValue('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
const request: CreatePaymentRequest = {
orderId: 'order-001',
amount: 100,
paymentType: 'alipay',
subject: 'Sub2API Balance Recharge 100.00 CNY',
clientIp: '127.0.0.1',
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('order-001');
expect(result.payUrl).toBe('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
expect(mockPageExecute).toHaveBeenCalledWith(
{
out_trade_no: 'order-001',
product_code: 'FAST_INSTANT_TRADE_PAY',
total_amount: '100.00',
subject: 'Sub2API Balance Recharge 100.00 CNY',
},
expect.objectContaining({}),
);
});
});
describe('queryOrder', () => {
it('should return paid status for TRADE_SUCCESS', async () => {
mockExecute.mockResolvedValue({
code: '10000',
msg: 'Success',
trade_no: '2026030500001',
trade_status: 'TRADE_SUCCESS',
total_amount: '100.00',
send_pay_date: '2026-03-05 12:00:00',
});
const result = await provider.queryOrder('order-001');
expect(result.tradeNo).toBe('2026030500001');
expect(result.status).toBe('paid');
expect(result.amount).toBe(100);
expect(result.paidAt).toBeInstanceOf(Date);
});
it('should return paid status for TRADE_FINISHED', async () => {
mockExecute.mockResolvedValue({
code: '10000',
msg: 'Success',
trade_no: '2026030500002',
trade_status: 'TRADE_FINISHED',
total_amount: '50.00',
});
const result = await provider.queryOrder('order-002');
expect(result.status).toBe('paid');
});
it('should return pending status for WAIT_BUYER_PAY', async () => {
mockExecute.mockResolvedValue({
code: '10000',
msg: 'Success',
trade_no: '2026030500003',
trade_status: 'WAIT_BUYER_PAY',
total_amount: '30.00',
});
const result = await provider.queryOrder('order-003');
expect(result.status).toBe('pending');
});
it('should return failed status for TRADE_CLOSED', async () => {
mockExecute.mockResolvedValue({
code: '10000',
msg: 'Success',
trade_no: '2026030500004',
trade_status: 'TRADE_CLOSED',
total_amount: '20.00',
});
const result = await provider.queryOrder('order-004');
expect(result.status).toBe('failed');
});
});
describe('verifyNotification', () => {
it('should verify and parse successful payment notification', async () => {
mockVerifySign.mockReturnValue(true);
const body = new URLSearchParams({
trade_no: '2026030500001',
out_trade_no: 'order-001',
trade_status: 'TRADE_SUCCESS',
total_amount: '100.00',
sign: 'test_sign',
sign_type: 'RSA2',
app_id: '2021000000000000',
}).toString();
const result = await provider.verifyNotification(body, {});
expect(result.tradeNo).toBe('2026030500001');
expect(result.orderId).toBe('order-001');
expect(result.amount).toBe(100);
expect(result.status).toBe('success');
});
it('should parse TRADE_FINISHED as success', async () => {
mockVerifySign.mockReturnValue(true);
const body = new URLSearchParams({
trade_no: '2026030500002',
out_trade_no: 'order-002',
trade_status: 'TRADE_FINISHED',
total_amount: '50.00',
sign: 'test_sign',
sign_type: 'RSA2',
}).toString();
const result = await provider.verifyNotification(body, {});
expect(result.status).toBe('success');
});
it('should parse TRADE_CLOSED as failed', async () => {
mockVerifySign.mockReturnValue(true);
const body = new URLSearchParams({
trade_no: '2026030500003',
out_trade_no: 'order-003',
trade_status: 'TRADE_CLOSED',
total_amount: '30.00',
sign: 'test_sign',
sign_type: 'RSA2',
}).toString();
const result = await provider.verifyNotification(body, {});
expect(result.status).toBe('failed');
});
it('should throw on invalid signature', async () => {
mockVerifySign.mockReturnValue(false);
const body = new URLSearchParams({
trade_no: '2026030500004',
out_trade_no: 'order-004',
trade_status: 'TRADE_SUCCESS',
total_amount: '20.00',
sign: 'bad_sign',
sign_type: 'RSA2',
}).toString();
await expect(provider.verifyNotification(body, {})).rejects.toThrow(
'Alipay notification signature verification failed',
);
});
});
describe('refund', () => {
it('should call alipay.trade.refund and return success', async () => {
mockExecute.mockResolvedValue({
code: '10000',
msg: 'Success',
trade_no: '2026030500001',
fund_change: 'Y',
});
const request: RefundRequest = {
tradeNo: '2026030500001',
orderId: 'order-001',
amount: 100,
reason: 'customer request',
};
const result = await provider.refund(request);
expect(result.refundId).toBe('2026030500001');
expect(result.status).toBe('success');
expect(mockExecute).toHaveBeenCalledWith('alipay.trade.refund', {
out_trade_no: 'order-001',
refund_amount: '100.00',
refund_reason: 'customer request',
});
});
it('should return pending when fund_change is N', async () => {
mockExecute.mockResolvedValue({
code: '10000',
msg: 'Success',
trade_no: '2026030500002',
fund_change: 'N',
});
const result = await provider.refund({
tradeNo: '2026030500002',
orderId: 'order-002',
amount: 50,
});
expect(result.status).toBe('pending');
});
});
describe('cancelPayment', () => {
it('should call alipay.trade.close', async () => {
mockExecute.mockResolvedValue({
code: '10000',
msg: 'Success',
trade_no: '2026030500001',
});
await provider.cancelPayment('order-001');
expect(mockExecute).toHaveBeenCalledWith('alipay.trade.close', {
out_trade_no: 'order-001',
});
});
});
});

View File

@@ -0,0 +1,117 @@
import { describe, it, expect } from 'vitest';
import crypto from 'crypto';
import { generateSign, verifySign } from '@/lib/alipay/sign';
// 生成测试用 RSA 密钥对
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
// 提取裸 base64去掉 PEM 头尾)
const barePrivateKey = privateKey
.replace(/-----BEGIN PRIVATE KEY-----/, '')
.replace(/-----END PRIVATE KEY-----/, '')
.replace(/\n/g, '');
const barePublicKey = publicKey
.replace(/-----BEGIN PUBLIC KEY-----/, '')
.replace(/-----END PUBLIC KEY-----/, '')
.replace(/\n/g, '');
describe('Alipay RSA2 Sign', () => {
const testParams: Record<string, string> = {
app_id: '2021000000000000',
method: 'alipay.trade.page.pay',
charset: 'utf-8',
timestamp: '2026-03-05 12:00:00',
version: '1.0',
biz_content: '{"out_trade_no":"order-001","total_amount":"100.00"}',
};
describe('generateSign', () => {
it('should generate a valid RSA2 signature', () => {
const sign = generateSign(testParams, privateKey);
expect(sign).toBeTruthy();
expect(typeof sign).toBe('string');
// base64 格式
expect(() => Buffer.from(sign, 'base64')).not.toThrow();
});
it('should produce consistent signatures for same input', () => {
const sign1 = generateSign(testParams, privateKey);
const sign2 = generateSign(testParams, privateKey);
expect(sign1).toBe(sign2);
});
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', () => {
const paramsWithEmpty = { ...testParams, empty_field: '' };
const sign1 = generateSign(testParams, privateKey);
const sign2 = generateSign(paramsWithEmpty, privateKey);
expect(sign1).toBe(sign2);
});
it('should sort parameters alphabetically', () => {
const reversed: Record<string, string> = {};
const keys = Object.keys(testParams).reverse();
for (const key of keys) {
reversed[key] = testParams[key];
}
const sign1 = generateSign(testParams, privateKey);
const sign2 = generateSign(reversed, privateKey);
expect(sign1).toBe(sign2);
});
});
describe('verifySign', () => {
it('should verify a valid signature', () => {
const sign = generateSign(testParams, privateKey);
const valid = verifySign(testParams, publicKey, sign);
expect(valid).toBe(true);
});
it('should reject an invalid signature', () => {
const valid = verifySign(testParams, publicKey, 'invalid_base64_signature');
expect(valid).toBe(false);
});
it('should reject tampered params', () => {
const sign = generateSign(testParams, privateKey);
const tampered = { ...testParams, total_amount: '999.99' };
const valid = verifySign(tampered, publicKey, sign);
expect(valid).toBe(false);
});
});
describe('PEM auto-formatting', () => {
it('should work with bare base64 private key (no PEM headers)', () => {
const sign = generateSign(testParams, barePrivateKey);
const valid = verifySign(testParams, publicKey, sign);
expect(valid).toBe(true);
});
it('should work with bare base64 public key (no PEM headers)', () => {
const sign = generateSign(testParams, privateKey);
const valid = verifySign(testParams, barePublicKey, sign);
expect(valid).toBe(true);
});
it('should work with both bare keys', () => {
const sign = generateSign(testParams, barePrivateKey);
const valid = verifySign(testParams, barePublicKey, sign);
expect(valid).toBe(true);
});
});
});

View File

@@ -16,7 +16,13 @@ interface DashboardData {
avgAmount: number;
};
dailySeries: { date: string; amount: number; count: number }[];
leaderboard: { userId: number; userName: string | null; userEmail: string | null; totalAmount: number; orderCount: number }[];
leaderboard: {
userId: number;
userName: string | null;
userEmail: string | null;
totalAmount: number;
orderCount: number;
}[];
paymentMethods: { paymentType: string; amount: number; count: number; percentage: number }[];
meta: { days: number; generatedAt: string };
}
@@ -79,7 +85,9 @@ function DashboardContent() {
const btnBase = [
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
isDark
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
].join(' ');
const btnActive = [
@@ -97,12 +105,7 @@ function DashboardContent() {
actions={
<>
{DAYS_OPTIONS.map((d) => (
<button
key={d}
type="button"
onClick={() => setDays(d)}
className={days === d ? btnActive : btnBase}
>
<button key={d} type="button" onClick={() => setDays(d)} className={days === d ? btnActive : btnBase}>
{d}
</button>
))}
@@ -116,7 +119,9 @@ function DashboardContent() {
}
>
{error && (
<div className={`mb-4 rounded-lg border p-3 text-sm ${isDark ? 'border-red-800 bg-red-950/50 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}>
<div
className={`mb-4 rounded-lg border p-3 text-sm ${isDark ? 'border-red-800 bg-red-950/50 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}
>
{error}
<button onClick={() => setError('')} className="ml-2 opacity-60 hover:opacity-100">

View File

@@ -169,7 +169,9 @@ function AdminContent() {
const btnBase = [
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
isDark
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
].join(' ');
return (
@@ -191,7 +193,9 @@ function AdminContent() {
}
>
{error && (
<div className={`mb-4 rounded-lg border p-3 text-sm ${isDark ? 'border-red-800 bg-red-950/50 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}>
<div
className={`mb-4 rounded-lg border p-3 text-sm ${isDark ? 'border-red-800 bg-red-950/50 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}
>
{error}
<button onClick={() => setError('')} className="ml-2 opacity-60 hover:opacity-100">
@@ -211,8 +215,12 @@ function AdminContent() {
className={[
'rounded-full px-3 py-1 text-sm transition-colors',
statusFilter === s
? (isDark ? 'bg-indigo-500/30 text-indigo-200 ring-1 ring-indigo-400/40' : 'bg-blue-600 text-white')
: (isDark ? 'bg-slate-800 text-slate-400 hover:bg-slate-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'),
? isDark
? 'bg-indigo-500/30 text-indigo-200 ring-1 ring-indigo-400/40'
: 'bg-blue-600 text-white'
: isDark
? 'bg-slate-800 text-slate-400 hover:bg-slate-700'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200',
].join(' ')}
>
{statusLabels[s]}
@@ -221,11 +229,22 @@ function AdminContent() {
</div>
{/* Table */}
<div className={['rounded-xl border', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white shadow-sm'].join(' ')}>
<div
className={[
'rounded-xl border',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white shadow-sm',
].join(' ')}
>
{loading ? (
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>...</div>
) : (
<OrderTable orders={orders} onRetry={handleRetry} onCancel={handleCancel} onViewDetail={handleViewDetail} dark={isDark} />
<OrderTable
orders={orders}
onRetry={handleRetry}
onCancel={handleCancel}
onViewDetail={handleViewDetail}
dark={isDark}
/>
)}
</div>
@@ -236,7 +255,10 @@ function AdminContent() {
pageSize={pageSize}
loading={loading}
onPageChange={(p) => setPage(p)}
onPageSizeChange={(s) => { setPageSize(s); setPage(1); }}
onPageSizeChange={(s) => {
setPageSize(s);
setPage(1);
}}
isDark={isDark}
/>

View File

@@ -1,14 +1,24 @@
import { NextRequest, NextResponse } from 'next/server';
import { Prisma } from '@prisma/client';
import { prisma } from '@/lib/db';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { OrderStatus } from '@prisma/client';
/** 格式化 Date 为 YYYY-MM-DD使用本地时区与 PostgreSQL DATE() 一致 */
function toDateStr(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
/** 业务时区偏移(东八区,+8 小时 */
const BIZ_TZ_OFFSET_MS = 8 * 60 * 60 * 1000;
const BIZ_TZ_NAME = 'Asia/Shanghai';
/** 获取业务时区下的 YYYY-MM-DD */
function toBizDateStr(d: Date): string {
const local = new Date(d.getTime() + BIZ_TZ_OFFSET_MS);
return local.toISOString().split('T')[0];
}
/** 获取业务时区下"今天 00:00"对应的 UTC 时间 */
function getBizDayStartUTC(d: Date): Date {
const bizDateStr = toBizDateStr(d);
// bizDateStr 00:00 在业务时区 = bizDateStr 00:00 - offset 在 UTC
return new Date(`${bizDateStr}T00:00:00+08:00`);
}
export async function GET(request: NextRequest) {
@@ -18,12 +28,8 @@ export async function GET(request: NextRequest) {
const days = Math.min(365, Math.max(1, Number(searchParams.get('days') || '30')));
const now = new Date();
const startDate = new Date(now);
startDate.setDate(startDate.getDate() - days);
startDate.setHours(0, 0, 0, 0);
const todayStart = new Date(now);
todayStart.setHours(0, 0, 0, 0);
const todayStart = getBizDayStartUTC(now);
const startDate = new Date(todayStart.getTime() - days * 24 * 60 * 60 * 1000);
const paidStatuses: OrderStatus[] = [
OrderStatus.PAID,
@@ -52,18 +58,26 @@ export async function GET(request: NextRequest) {
prisma.order.count({ where: { createdAt: { gte: todayStart } } }),
// Total orders
prisma.order.count(),
// Daily series (raw query for DATE truncation)
// Daily series: use AT TIME ZONE to group by business timezone date
// Prisma.raw() inlines the timezone name to avoid parameterization mismatch between SELECT and GROUP BY
prisma.$queryRaw<{ date: string; amount: string; count: bigint }[]>`
SELECT DATE(paid_at) as date, SUM(amount)::text as amount, COUNT(*) as count
SELECT (paid_at AT TIME ZONE 'UTC' AT TIME ZONE ${Prisma.raw(`'${BIZ_TZ_NAME}'`)})::date::text as date,
SUM(amount)::text as amount, COUNT(*) as count
FROM orders
WHERE status IN ('PAID', 'RECHARGING', 'COMPLETED', 'REFUNDING', 'REFUNDED', 'REFUND_FAILED')
AND paid_at >= ${startDate}
GROUP BY DATE(paid_at)
GROUP BY (paid_at AT TIME ZONE 'UTC' AT TIME ZONE ${Prisma.raw(`'${BIZ_TZ_NAME}'`)})::date
ORDER BY date
`,
// Leaderboard: GROUP BY user_id only, MAX() for name/email to avoid splitting rows on name changes
// Leaderboard: GROUP BY user_id only, MAX() for name/email
prisma.$queryRaw<
{ user_id: number; user_name: string | null; user_email: string | null; total_amount: string; order_count: bigint }[]
{
user_id: number;
user_name: string | null;
user_email: string | null;
total_amount: string;
order_count: bigint;
}[]
>`
SELECT user_id, MAX(user_name) as user_name, MAX(user_email) as user_email,
SUM(amount)::text as total_amount, COUNT(*) as order_count
@@ -83,22 +97,29 @@ export async function GET(request: NextRequest) {
}),
]);
// Fill missing dates for continuous line chart (use local timezone consistently)
// Fill missing dates for continuous line chart
const dailyMap = new Map<string, { amount: number; count: number }>();
for (const row of dailyRaw) {
const dateStr = typeof row.date === 'string' ? row.date : toDateStr(new Date(row.date));
dailyMap.set(dateStr, { amount: Number(row.amount), count: Number(row.count) });
dailyMap.set(row.date, { amount: Number(row.amount), count: Number(row.count) });
}
const dailySeries: { date: string; amount: number; count: number }[] = [];
const cursor = new Date(startDate);
while (cursor <= now) {
const dateStr = toDateStr(cursor);
const dateStr = toBizDateStr(cursor);
const entry = dailyMap.get(dateStr);
dailySeries.push({ date: dateStr, amount: entry?.amount ?? 0, count: entry?.count ?? 0 });
cursor.setDate(cursor.getDate() + 1);
cursor.setTime(cursor.getTime() + 24 * 60 * 60 * 1000);
}
// Deduplicate: toBizDateStr on consecutive UTC days near midnight can produce the same biz date
const seen = new Set<string>();
const deduped = dailySeries.filter((d) => {
if (seen.has(d.date)) return false;
seen.add(d.date);
return true;
});
// Calculate summary
const todayPaidAmount = Number(todayStats._sum?.amount || 0);
const todayPaidCount = todayStats._count._all;
@@ -117,7 +138,7 @@ export async function GET(request: NextRequest) {
successRate: Math.round(successRate * 10) / 10,
avgAmount: Math.round(avgAmount * 100) / 100,
},
dailySeries,
dailySeries: deduped,
leaderboard: leaderboardRaw.map((row) => ({
userId: row.user_id,
userName: row.user_name,

View File

@@ -3,7 +3,7 @@ import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { adminCancelOrder, OrderError } from '@/lib/order/service';
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!await verifyAdminToken(request)) return unauthorizedResponse();
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
try {
const { id } = await params;

View File

@@ -3,7 +3,7 @@ import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { retryRecharge, OrderError } from '@/lib/order/service';
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!await verifyAdminToken(request)) return unauthorizedResponse();
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
try {
const { id } = await params;

View File

@@ -3,7 +3,7 @@ import { prisma } from '@/lib/db';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!await verifyAdminToken(request)) return unauthorizedResponse();
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
const { id } = await params;

View File

@@ -4,7 +4,7 @@ import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { Prisma, OrderStatus } from '@prisma/client';
export async function GET(request: NextRequest) {
if (!await verifyAdminToken(request)) return unauthorizedResponse();
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
const searchParams = request.nextUrl.searchParams;
const page = Math.max(1, Number(searchParams.get('page') || '1'));

View File

@@ -10,7 +10,7 @@ const refundSchema = z.object({
});
export async function POST(request: NextRequest) {
if (!await verifyAdminToken(request)) return unauthorizedResponse();
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
try {
const body = await request.json();

View File

@@ -0,0 +1,33 @@
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) => {
headers[key] = value;
});
const notification = await alipayProvider.verifyNotification(rawBody, headers);
const success = await handlePaymentNotify(notification, alipayProvider.name);
return new Response(success ? 'success' : 'fail', {
headers: { 'Content-Type': 'text/plain' },
});
} catch (error) {
console.error('Alipay notify error:', error);
return new Response('fail', {
headers: { 'Content-Type': 'text/plain' },
});
}
}

View File

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

View File

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

View File

@@ -27,9 +27,6 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
return NextResponse.json({ received: true });
} catch (error) {
console.error('Stripe webhook error:', error);
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 400 },
);
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 400 });
}
}

View File

@@ -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,10 +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 冲突:多个启用渠道有相同的显示名,自动标记默认 sublabelprovider 名)
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: {
@@ -22,16 +51,18 @@ 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,
methodLimits,
helpImageUrl: env.PAY_HELP_IMAGE_URL ?? null,
helpText: env.PAY_HELP_TEXT ?? null,
stripePublishableKey: env.ENABLED_PAYMENT_TYPES.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY
? env.STRIPE_PUBLISHABLE_KEY
: null,
stripePublishableKey:
enabledTypes.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY
? env.STRIPE_PUBLISHABLE_KEY
: null,
sublabelOverrides: Object.keys(sublabelOverrides).length > 0 ? sublabelOverrides : null,
},
});
} catch (error) {

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

View File

@@ -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);
@@ -134,27 +125,30 @@ function OrdersContent() {
loadOrders(1, newSize);
};
const filteredOrders =
activeFilter === 'ALL' ? orders : orders.filter((o) => o.status === activeFilter);
const filteredOrders = activeFilter === 'ALL' ? orders : orders.filter((o) => o.status === activeFilter);
const btnClass = [
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
isDark
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
].join(' ');
if (isMobile) {
return (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-50 text-slate-900'}`}>
<div
className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-50 text-slate-900'}`}
>
Tab...
</div>
);
}
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>
@@ -163,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);
@@ -175,11 +168,17 @@ function OrdersContent() {
isDark={isDark}
isEmbedded={isEmbedded}
title="我的订单"
subtitle={userInfo?.username || `用户 #${effectiveUserId}`}
subtitle={userInfo?.username || '我的订单'}
actions={
<>
<button type="button" onClick={() => loadOrders(page, pageSize)} className={btnClass}></button>
{!srcHost && <a href={buildScopedUrl('/pay')} className={btnClass}></a>}
<button type="button" onClick={() => loadOrders(page, pageSize)} className={btnClass}>
</button>
{!srcHost && (
<a href={buildScopedUrl('/pay')} className={btnClass}>
</a>
)}
</>
}
>
@@ -208,7 +207,13 @@ function OrdersContent() {
export default function OrdersPage() {
return (
<Suspense fallback={<div className="flex min-h-screen items-center justify-center"><div className="text-gray-500">...</div></div>}>
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center">
<div className="text-gray-500">...</div>
</div>
}
>
<OrdersContent />
</Suspense>
);

View File

@@ -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,13 +184,27 @@ function PayContent() {
useEffect(() => {
loadUserAndOrders();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId, token]);
}, [token]);
if (!effectiveUserId || Number.isNaN(effectiveUserId) || effectiveUserId <= 0) {
useEffect(() => {
if (step !== 'result' || finalStatus !== 'COMPLETED') return;
// 立即在后台刷新余额2.2s 显示结果页后再切回表单(届时余额已更新)
loadUserAndOrders();
const timer = setTimeout(() => {
setStep('form');
setOrderResult(null);
setFinalStatus('');
setError('');
}, 2200);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step, finalStatus]);
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>
@@ -211,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);
@@ -224,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('');
@@ -232,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,
}),
@@ -244,6 +262,7 @@ function PayContent() {
if (!res.ok) {
const codeMessages: Record<string, string> = {
INVALID_TOKEN: '认证已失效,请重新从平台进入充值页面',
USER_INACTIVE: '账户已被禁用,无法充值,请联系管理员',
TOO_MANY_PENDING: '您有过多待支付订单,请先完成或取消现有订单后再试',
USER_NOT_FOUND: '用户不存在,请检查链接是否正确',
@@ -290,20 +309,6 @@ function PayContent() {
setError('');
};
useEffect(() => {
if (step !== 'result' || finalStatus !== 'COMPLETED') return;
// 立即在后台刷新余额2.2s 显示结果页后再切回表单(届时余额已更新)
loadUserAndOrders();
const timer = setTimeout(() => {
setStep('form');
setOrderResult(null);
setFinalStatus('');
setError('');
}, 2200);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step, finalStatus]);
return (
<PayPageLayout
isDark={isDark}
@@ -311,35 +316,37 @@ function PayContent() {
maxWidth={isMobile ? 'sm' : 'lg'}
title="Sub2API 余额充值"
subtitle="安全支付,自动到账"
actions={!isMobile ? (
<>
<button
type="button"
onClick={loadUserAndOrders}
className={[
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
].join(' ')}
>
</button>
<a
href={ordersUrl}
className={[
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
].join(' ')}
>
</a>
</>
) : undefined}
actions={
!isMobile ? (
<>
<button
type="button"
onClick={loadUserAndOrders}
className={[
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
isDark
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
].join(' ')}
>
</button>
<a
href={ordersUrl}
className={[
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
isDark
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
].join(' ')}
>
</a>
</>
) : undefined
}
>
{error && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
{error}
</div>
)}
{error && <div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">{error}</div>}
{step === 'form' && isMobile && (
<div
@@ -354,10 +361,12 @@ function PayContent() {
className={[
'rounded-lg px-3 py-2 text-sm font-semibold transition-all duration-200',
activeMobileTab === 'pay'
? (isDark
? isDark
? 'bg-indigo-500/30 text-indigo-100 ring-1 ring-indigo-300/35 shadow-sm'
: 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-md shadow-slate-300/50')
: (isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700'),
: 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-md shadow-slate-300/50'
: isDark
? 'text-slate-400 hover:text-slate-200'
: 'text-slate-500 hover:text-slate-700',
].join(' ')}
>
@@ -368,10 +377,12 @@ function PayContent() {
className={[
'rounded-lg px-3 py-2 text-sm font-semibold transition-all duration-200',
activeMobileTab === 'orders'
? (isDark
? isDark
? 'bg-indigo-500/30 text-indigo-100 ring-1 ring-indigo-300/35 shadow-sm'
: 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-md shadow-slate-300/50')
: (isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700'),
: 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-md shadow-slate-300/50'
: isDark
? 'text-slate-400 hover:text-slate-200'
: 'text-slate-500 hover:text-slate-700',
].join(' ')}
>
@@ -382,9 +393,7 @@ function PayContent() {
{step === 'form' && config.enabledPaymentTypes.length === 0 && (
<div className="flex items-center justify-center py-12">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
<span className={['ml-3 text-sm', isDark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
...
</span>
<span className={['ml-3 text-sm', isDark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>...</span>
</div>
)}
@@ -393,7 +402,7 @@ function PayContent() {
{isMobile ? (
activeMobileTab === 'pay' ? (
<PaymentForm
userId={effectiveUserId}
userId={resolvedUserId ?? 0}
userName={userInfo?.username}
userBalance={userInfo?.balance}
enabledPaymentTypes={config.enabledPaymentTypes}
@@ -403,6 +412,8 @@ function PayContent() {
onSubmit={handleSubmit}
loading={loading}
dark={isDark}
pendingBlocked={pendingBlocked}
pendingCount={pendingCount}
/>
) : (
<MobileOrderList
@@ -419,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}
@@ -429,34 +440,48 @@ function PayContent() {
onSubmit={handleSubmit}
loading={loading}
dark={isDark}
pendingBlocked={pendingBlocked}
pendingCount={pendingCount}
/>
</div>
<div className="space-y-4">
<div className={['rounded-2xl border p-4', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50'].join(' ')}>
<div
className={[
'rounded-2xl border p-4',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50',
].join(' ')}
>
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}></div>
<ul className={['mt-2 space-y-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
<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>}
<li></li>
{config.maxDailyAmount > 0 && <li> ¥{config.maxDailyAmount.toFixed(2)}</li>}
</ul>
</div>
{hasHelpContent && (
<div className={['rounded-2xl border p-4', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50'].join(' ')}>
<div
className={[
'rounded-2xl border p-4',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50',
].join(' ')}
>
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>Support</div>
{helpImageUrl && (
<img
src={helpImageUrl}
alt='help'
alt="help"
onClick={() => setHelpImageOpen(true)}
className='mt-3 max-h-40 w-full cursor-zoom-in rounded-lg object-contain bg-white/70 p-2'
className="mt-3 max-h-40 w-full cursor-zoom-in rounded-lg object-contain bg-white/70 p-2"
/>
)}
{helpText && (
<div className={['mt-3 space-y-1 text-sm leading-6', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
<div
className={[
'mt-3 space-y-1 text-sm leading-6',
isDark ? 'text-slate-300' : 'text-slate-600',
].join(' ')}
>
{helpText.split('\\n').map((line, i) => (
<p key={i}>{line}</p>
))}
@@ -486,12 +511,11 @@ function PayContent() {
onBack={handleBack}
dark={isDark}
isEmbedded={isEmbedded}
isMobile={isMobile}
/>
)}
{step === 'result' && (
<OrderStatus status={finalStatus} onBack={handleBack} dark={isDark} />
)}
{step === 'result' && <OrderStatus status={finalStatus} onBack={handleBack} dark={isDark} />}
{helpImageOpen && helpImageUrl && (
<div
@@ -500,8 +524,8 @@ function PayContent() {
>
<img
src={helpImageUrl}
alt='help'
className='max-h-[90vh] max-w-full rounded-xl object-contain shadow-2xl'
alt="help"
className="max-h-[90vh] max-w-full rounded-xl object-contain shadow-2xl"
onClick={(e) => e.stopPropagation()}
/>
</div>

View File

@@ -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();
@@ -72,16 +73,18 @@ function StripePopupContent() {
if (isAlipay) {
// Alipay: confirm directly and redirect, no Payment Element needed
stripe.confirmAlipayPayment(clientSecret, {
return_url: buildReturnUrl(),
}).then((result) => {
if (cancelled) return;
if (result.error) {
setStripeError(result.error.message || '支付失败,请重试');
setStripeLoaded(true);
}
// If no error, the page has already been redirected
});
stripe
.confirmAlipayPayment(clientSecret, {
return_url: buildReturnUrl(),
})
.then((result) => {
if (cancelled) return;
if (result.error) {
setStripeError(result.error.message || '支付失败,请重试');
setStripeLoaded(true);
}
// If no error, the page has already been redirected
});
return;
}
@@ -97,7 +100,9 @@ function StripePopupContent() {
setStripeLoaded(true);
});
});
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [credentials, isDark, isAlipay, buildReturnUrl]);
// Mount Payment Element (only for non-alipay methods)
@@ -151,12 +156,12 @@ function StripePopupContent() {
if (!credentials) {
return (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
<div className={`w-full max-w-md space-y-4 rounded-2xl border p-6 ${isDark ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-white'} shadow-lg`}>
<div
className={`w-full max-w-md space-y-4 rounded-2xl border p-6 ${isDark ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-white'} shadow-lg`}
>
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
...
</span>
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>...</span>
</div>
</div>
</div>
@@ -167,18 +172,19 @@ function StripePopupContent() {
if (isAlipay) {
return (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
<div className={`w-full max-w-md space-y-4 rounded-2xl border p-6 ${isDark ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-white'} shadow-lg`}>
<div
className={`w-full max-w-md space-y-4 rounded-2xl border p-6 ${isDark ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-white'} shadow-lg`}
>
<div className="text-center">
<div className="text-3xl font-bold text-blue-600">{'\u00A5'}{amount.toFixed(2)}</div>
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
: {orderId}
</p>
<div className="text-3xl font-bold text-blue-600">
{'\u00A5'}
{amount.toFixed(2)}
</div>
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>: {orderId}</p>
</div>
{stripeError ? (
<div className="space-y-3">
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
{stripeError}
</div>
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">{stripeError}</div>
<button
type="button"
onClick={() => window.close()}
@@ -202,20 +208,21 @@ function StripePopupContent() {
return (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
<div className={`w-full max-w-md space-y-4 rounded-2xl border p-6 ${isDark ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-white'} shadow-lg`}>
<div
className={`w-full max-w-md space-y-4 rounded-2xl border p-6 ${isDark ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-white'} shadow-lg`}
>
<div className="text-center">
<div className="text-3xl font-bold text-blue-600">{'\u00A5'}{amount.toFixed(2)}</div>
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
: {orderId}
</p>
<div className="text-3xl font-bold text-blue-600">
{'\u00A5'}
{amount.toFixed(2)}
</div>
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>: {orderId}</p>
</div>
{!stripeLoaded ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
...
</span>
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>...</span>
</div>
) : stripeSuccess ? (
<div className="py-6 text-center">
@@ -234,9 +241,7 @@ function StripePopupContent() {
) : (
<>
{stripeError && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
{stripeError}
</div>
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">{stripeError}</div>
)}
<div
ref={stripeContainerRef}
@@ -250,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 ? (

View File

@@ -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)}
@@ -125,9 +129,7 @@ export default function MobileOrderList({
{hasMore && (
<div ref={sentinelRef} className="py-3 text-center">
{loadingMore ? (
<span className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
...
</span>
<span className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>...</span>
) : (
<span className={['text-xs', isDark ? 'text-slate-600' : 'text-slate-300'].join(' ')}>

View File

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

View File

@@ -35,8 +35,7 @@ export default function PaginationBar({
{/* 左侧:统计 + 每页大小 */}
<div className="flex items-center gap-2">
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>
{total}
{totalPages > 1 && `,第 ${page} / ${totalPages}`}
{total} {totalPages > 1 && `,第 ${page} / ${totalPages}`}
</span>
{onPageSizeChange && (
@@ -47,7 +46,9 @@ export default function PaginationBar({
key={s}
type="button"
disabled={loading}
onClick={() => { onPageSizeChange(s); }}
onClick={() => {
onPageSizeChange(s);
}}
className={[
'rounded border px-2 py-1 font-medium transition-colors',
pageSize === s

View File

@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { PAYMENT_TYPE_META } from '@/lib/pay-utils';
import { useState } from 'react';
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,17 +45,17 @@ 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');
const [customAmount, setCustomAmount] = useState('');
// Reset paymentType when enabledPaymentTypes changes (e.g. after config loads)
useEffect(() => {
if (!enabledPaymentTypes.includes(paymentType)) {
setPaymentType(enabledPaymentTypes[0] || 'stripe');
}
}, [enabledPaymentTypes, paymentType]);
const effectivePaymentType = enabledPaymentTypes.includes(paymentType)
? paymentType
: enabledPaymentTypes[0] || 'stripe';
const handleQuickAmount = (val: number) => {
setAmount(val);
@@ -81,35 +83,37 @@ export default function PaymentForm({
};
const selectedAmount = amount || 0;
const isMethodAvailable = !methodLimits || (methodLimits[paymentType]?.available !== false);
const methodSingleMax = methodLimits?.[paymentType]?.singleMax;
const effectiveMax = (methodSingleMax !== undefined && methodSingleMax > 0) ? methodSingleMax : maxAmount;
const feeRate = methodLimits?.[paymentType]?.feeRate ?? 0;
const feeAmount = feeRate > 0 && selectedAmount > 0
? Math.ceil(selectedAmount * feeRate / 100 * 100) / 100
: 0;
const payAmount = feeRate > 0 && selectedAmount > 0
? Math.round((selectedAmount + feeAmount) * 100) / 100
: selectedAmount;
const isValid = selectedAmount >= minAmount && selectedAmount <= effectiveMax && hasValidCentPrecision(selectedAmount) && isMethodAvailable;
const isMethodAvailable = !methodLimits || methodLimits[effectivePaymentType]?.available !== false;
const methodSingleMax = methodLimits?.[effectivePaymentType]?.singleMax;
const effectiveMax = methodSingleMax !== undefined && methodSingleMax > 0 ? methodSingleMax : maxAmount;
const feeRate = methodLimits?.[effectivePaymentType]?.feeRate ?? 0;
const feeAmount = feeRate > 0 && selectedAmount > 0 ? Math.ceil(((selectedAmount * feeRate) / 100) * 100) / 100 : 0;
const payAmount =
feeRate > 0 && selectedAmount > 0 ? Math.round((selectedAmount + feeAmount) * 100) / 100 : selectedAmount;
const isValid =
selectedAmount >= minAmount &&
selectedAmount <= effectiveMax &&
hasValidCentPrecision(selectedAmount) &&
isMethodAvailable;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!isValid || loading) return;
await onSubmit(selectedAmount, paymentType);
await onSubmit(selectedAmount, effectivePaymentType);
};
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" />
@@ -117,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
@@ -215,19 +219,17 @@ export default function PaymentForm({
</div>
</div>
{customAmount !== '' && !isValid && (() => {
const num = parseFloat(customAmount);
let msg = '金额需在范围内,且最多支持 2 位小数(精确到分)';
if (!isNaN(num)) {
if (num < minAmount) msg = `单笔最低充值 ¥${minAmount}`;
else if (num > effectiveMax) msg = `单笔最高充值 ¥${effectiveMax}`;
}
return (
<div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>
{msg}
</div>
);
})()}
{customAmount !== '' &&
!isValid &&
(() => {
const num = parseFloat(customAmount);
let msg = '金额需在范围内,且最多支持 2 位小数(精确到分)';
if (!isNaN(num)) {
if (num < minAmount) msg = `单笔最低充值 ¥${minAmount}`;
else if (num > effectiveMax) msg = `单笔最高充值 ¥${effectiveMax}`;
}
return <div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>{msg}</div>;
})()}
{/* Payment Type — only show when multiple types available */}
{enabledPaymentTypes.length > 1 && (
@@ -235,10 +237,10 @@ 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 = paymentType === type;
const isSelected = effectivePaymentType === type;
const limitInfo = methodLimits?.[type];
const isUnavailable = limitInfo !== undefined && !limitInfo.available;
@@ -250,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'
@@ -284,7 +286,7 @@ export default function PaymentForm({
{/* 当前选中渠道额度不足时的提示 */}
{(() => {
const limitInfo = methodLimits?.[paymentType];
const limitInfo = methodLimits?.[effectivePaymentType];
if (!limitInfo || limitInfo.available) return null;
return (
<p className={['mt-2 text-xs', dark ? 'text-amber-300' : 'text-amber-600'].join(' ')}>
@@ -311,31 +313,49 @@ export default function PaymentForm({
<span>{feeRate}%</span>
<span>¥{feeAmount.toFixed(2)}</span>
</div>
<div className={[
'flex items-center justify-between mt-1.5 pt-1.5 border-t font-medium',
dark ? 'border-slate-700 text-slate-100' : 'border-slate-200 text-slate-900',
].join(' ')}>
<div
className={[
'flex items-center justify-between mt-1.5 pt-1.5 border-t font-medium',
dark ? 'border-slate-700 text-slate-100' : 'border-slate-200 text-slate-900',
].join(' ')}
>
<span></span>
<span>¥{payAmount.toFixed(2)}</span>
</div>
</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
? paymentType === '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'
}`}
>
{loading ? '处理中...' : `立即充值 ¥${(feeRate > 0 && selectedAmount > 0 ? payAmount : selectedAmount || 0).toFixed(2)}`}
{loading
? '处理中...'
: pendingBlocked
? '待支付订单过多'
: `立即充值 ¥${(feeRate > 0 && selectedAmount > 0 ? payAmount : selectedAmount || 0).toFixed(2)}`}
</button>
</form>
);

View File

@@ -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;
@@ -18,6 +25,7 @@ interface PaymentQRCodeProps {
onBack: () => void;
dark?: boolean;
isEmbedded?: boolean;
isMobile?: boolean;
}
const TEXT_EXPIRED = '\u8BA2\u5355\u5DF2\u8D85\u65F6';
@@ -26,7 +34,8 @@ const TEXT_GO_PAY = '\u70B9\u51FB\u524D\u5F80\u652F\u4ED8';
const TEXT_SCAN_PAY = '\u8BF7\u4F7F\u7528\u652F\u4ED8\u5E94\u7528\u626B\u7801\u652F\u4ED8';
const TEXT_BACK = '\u8FD4\u56DE';
const TEXT_CANCEL_ORDER = '\u53D6\u6D88\u8BA2\u5355';
const TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED', 'CANCELLED', 'EXPIRED', 'REFUNDED', 'REFUND_FAILED']);
const TEXT_H5_HINT =
'\u652F\u4ED8\u5B8C\u6210\u540E\u8BF7\u8FD4\u56DE\u6B64\u9875\u9762\uFF0C\u7CFB\u7EDF\u5C06\u81EA\u52A8\u786E\u8BA4';
export default function PaymentQRCode({
orderId,
@@ -43,6 +52,7 @@ export default function PaymentQRCode({
onBack,
dark = false,
isEmbedded = false,
isMobile = false,
}: PaymentQRCodeProps) {
const displayAmount = payAmountProp ?? amount;
const hasFeeDiff = payAmountProp !== undefined && payAmountProp !== amount;
@@ -51,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);
@@ -66,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;
@@ -106,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;
@@ -212,11 +235,7 @@ export default function PaymentQRCode({
popupUrl.searchParams.set('theme', dark ? 'dark' : 'light');
popupUrl.searchParams.set('method', stripePaymentMethod);
const popup = window.open(
popupUrl.toString(),
'stripe_payment',
'width=500,height=700,scrollbars=yes',
);
const popup = window.open(popupUrl.toString(), 'stripe_payment', 'width=500,height=700,scrollbars=yes');
if (!popup || popup.closed) {
setPopupBlocked(true);
return;
@@ -225,11 +244,14 @@ export default function PaymentQRCode({
const onReady = (event: MessageEvent) => {
if (event.source !== popup || event.data?.type !== 'STRIPE_POPUP_READY') return;
window.removeEventListener('message', onReady);
popup.postMessage({
type: 'STRIPE_POPUP_INIT',
clientSecret,
publishableKey: stripePublishableKey,
}, window.location.origin);
popup.postMessage(
{
type: 'STRIPE_POPUP_INIT',
clientSecret,
publishableKey: stripePublishableKey,
},
window.location.origin,
);
};
window.addEventListener('message', onReady);
};
@@ -310,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 (
@@ -321,7 +343,9 @@ export default function PaymentQRCode({
<div className="text-6xl text-green-600">{'\u2713'}</div>
<h2 className="text-xl font-bold text-green-600">{'\u8BA2\u5355\u5DF2\u652F\u4ED8'}</h2>
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
{'\u8BE5\u8BA2\u5355\u5DF2\u652F\u4ED8\u5B8C\u6210\uFF0C\u65E0\u6CD5\u53D6\u6D88\u3002\u5145\u503C\u5C06\u81EA\u52A8\u5230\u8D26\u3002'}
{
'\u8BE5\u8BA2\u5355\u5DF2\u652F\u4ED8\u5B8C\u6210\uFF0C\u65E0\u6CD5\u53D6\u6D88\u3002\u5145\u503C\u5C06\u81EA\u52A8\u5230\u8D26\u3002'
}
</p>
<button
onClick={onBack}
@@ -336,7 +360,10 @@ export default function PaymentQRCode({
return (
<div className="flex flex-col items-center space-y-4">
<div className="text-center">
<div className="text-4xl font-bold text-blue-600">{'\u00A5'}{displayAmount.toFixed(2)}</div>
<div className="text-4xl font-bold text-blue-600">
{'\u00A5'}
{displayAmount.toFixed(2)}
</div>
{hasFeeDiff && (
<div className={['mt-1 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
¥{amount.toFixed(2)}
@@ -352,7 +379,12 @@ export default function PaymentQRCode({
{isStripe ? (
<div className="w-full max-w-md space-y-4">
{!clientSecret || !stripePublishableKey ? (
<div className={['rounded-lg border-2 border-dashed p-8 text-center', dark ? 'border-slate-700' : 'border-gray-300'].join(' ')}>
<div
className={[
'rounded-lg border-2 border-dashed p-8 text-center',
dark ? 'border-slate-700' : 'border-gray-300',
].join(' ')}
>
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
</p>
@@ -365,14 +397,15 @@ export default function PaymentQRCode({
</span>
</div>
) : stripeError && !stripeLib ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
{stripeError}
</div>
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">{stripeError}</div>
) : (
<>
<div
ref={stripeContainerRef}
className={['rounded-lg border p-4', dark ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-white'].join(' ')}
className={[
'rounded-lg border p-4',
dark ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-white',
].join(' ')}
/>
{stripeError && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
@@ -395,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 ? (
@@ -409,17 +442,50 @@ export default function PaymentQRCode({
</button>
)}
{popupBlocked && (
<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(' ')}>
<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(' ')}
>
</div>
)}
</>
)}
</div>
) : 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!}
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 ${meta.buttonClass}`}
>
{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}
</p>
</>
) : (
<>
{qrDataUrl && (
<div className={['relative rounded-lg border p-4', dark ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-white'].join(' ')}>
<div
className={[
'relative rounded-lg border p-4',
dark ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-white',
].join(' ')}
>
{imageLoading && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-black/10">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
@@ -434,20 +500,14 @@ 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={['rounded-lg border-2 border-dashed p-8', dark ? 'border-slate-700' : 'border-gray-300'].join(' ')}>
<div
className={[
'rounded-lg border-2 border-dashed p-8',
dark ? 'border-slate-700' : 'border-gray-300',
].join(' ')}
>
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{TEXT_SCAN_PAY}</p>
</div>
</div>
@@ -466,7 +526,9 @@ export default function PaymentQRCode({
onClick={onBack}
className={[
'flex-1 rounded-lg border py-2 text-sm',
dark ? 'border-slate-700 text-slate-300 hover:bg-slate-800' : 'border-gray-300 text-gray-600 hover:bg-gray-50',
dark
? 'border-slate-700 text-slate-300 hover:bg-slate-800'
: 'border-gray-300 text-gray-600 hover:bg-gray-50',
].join(' ')}
>
{TEXT_BACK}

View File

@@ -51,7 +51,8 @@ function CustomTooltip({
<p className={['mb-1 text-xs', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>{label}</p>
{payload.map((p) => (
<p key={p.dataKey}>
{p.dataKey === 'amount' ? '金额' : '笔数'}: {p.dataKey === 'amount' ? `¥${p.value.toLocaleString()}` : p.value}
{p.dataKey === 'amount' ? '金额' : '笔数'}:{' '}
{p.dataKey === 'amount' ? `¥${p.value.toLocaleString()}` : p.value}
</p>
))}
</div>
@@ -63,8 +64,15 @@ export default function DailyChart({ data, dark }: DailyChartProps) {
const tickInterval = data.length > 30 ? Math.ceil(data.length / 12) - 1 : 0;
if (data.length === 0) {
return (
<div className={['rounded-xl border p-6', dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm'].join(' ')}>
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}></h3>
<div
className={[
'rounded-xl border p-6',
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
].join(' ')}
>
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
</h3>
<p className={['text-center text-sm py-16', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}></p>
</div>
);
@@ -74,8 +82,15 @@ export default function DailyChart({ data, dark }: DailyChartProps) {
const gridColor = dark ? '#334155' : '#e2e8f0';
return (
<div className={['rounded-xl border p-6', dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm'].join(' ')}>
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}></h3>
<div
className={[
'rounded-xl border p-6',
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
].join(' ')}
>
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
</h3>
<ResponsiveContainer width="100%" height={320}>
<LineChart data={data} margin={{ top: 5, right: 20, bottom: 5, left: 10 }}>
<CartesianGrid stroke={gridColor} strokeDasharray="3 3" />

View File

@@ -29,24 +29,14 @@ export default function DashboardStats({ summary, dark }: DashboardStatsProps) {
key={card.label}
className={[
'rounded-xl border p-4',
dark
? 'border-slate-700 bg-slate-800/60'
: 'border-slate-200 bg-white shadow-sm',
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
].join(' ')}
>
<p className={['text-xs font-medium', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{card.label}
</p>
<p className={['text-xs font-medium', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>{card.label}</p>
<p
className={[
'mt-1 text-xl font-semibold tracking-tight',
card.accent
? dark
? 'text-indigo-400'
: 'text-indigo-600'
: dark
? 'text-slate-100'
: 'text-slate-900',
card.accent ? (dark ? 'text-indigo-400' : 'text-indigo-600') : dark ? 'text-slate-100' : 'text-slate-900',
].join(' ')}
>
{card.value}

View File

@@ -26,15 +26,27 @@ export default function Leaderboard({ data, dark }: LeaderboardProps) {
if (data.length === 0) {
return (
<div className={['rounded-xl border p-6', dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm'].join(' ')}>
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}> (Top 10)</h3>
<div
className={[
'rounded-xl border p-6',
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
].join(' ')}
>
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
(Top 10)
</h3>
<p className={['text-center text-sm py-8', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}></p>
</div>
);
}
return (
<div className={['rounded-xl border', dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm'].join(' ')}>
<div
className={[
'rounded-xl border',
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
].join(' ')}
>
<h3 className={['px-6 pt-5 pb-2 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
(Top 10)
</h3>
@@ -56,7 +68,9 @@ export default function Leaderboard({ data, dark }: LeaderboardProps) {
<tr key={entry.userId} className={dark ? 'hover:bg-slate-700/40' : 'hover:bg-gray-50'}>
<td className="whitespace-nowrap px-4 py-3 text-sm">
{rankStyle ? (
<span className={`inline-flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold ${dark ? rankStyle.dark : rankStyle.light}`}>
<span
className={`inline-flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold ${dark ? rankStyle.dark : rankStyle.light}`}
>
{rank}
</span>
) : (
@@ -71,7 +85,9 @@ export default function Leaderboard({ data, dark }: LeaderboardProps) {
</div>
)}
</td>
<td className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : 'text-slate-900'}`}>
<td
className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : 'text-slate-900'}`}
>
¥{entry.totalAmount.toLocaleString()}
</td>
<td className={tdMuted}>{entry.orderCount}</td>

View File

@@ -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 || '-' },
@@ -84,7 +87,10 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps)
>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-bold"></h3>
<button onClick={onClose} className={dark ? 'text-slate-400 hover:text-slate-200' : 'text-gray-400 hover:text-gray-600'}>
<button
onClick={onClose}
className={dark ? 'text-slate-400 hover:text-slate-200' : 'text-gray-400 hover:text-gray-600'}
>
</button>
</div>
@@ -103,16 +109,31 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps)
<h4 className={`mb-3 font-medium ${dark ? 'text-slate-100' : 'text-gray-900'}`}></h4>
<div className="space-y-2">
{order.auditLogs.map((log) => (
<div key={log.id} className={`rounded-lg border p-3 ${dark ? 'border-slate-600 bg-slate-700/60' : 'border-gray-100 bg-gray-50'}`}>
<div
key={log.id}
className={`rounded-lg border p-3 ${dark ? 'border-slate-600 bg-slate-700/60' : 'border-gray-100 bg-gray-50'}`}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{log.action}</span>
<span className={`text-xs ${dark ? 'text-slate-500' : 'text-gray-400'}`}>{new Date(log.createdAt).toLocaleString('zh-CN')}</span>
<span className={`text-xs ${dark ? 'text-slate-500' : 'text-gray-400'}`}>
{new Date(log.createdAt).toLocaleString('zh-CN')}
</span>
</div>
{log.detail && <div className={`mt-1 break-all text-xs ${dark ? 'text-slate-400' : 'text-gray-500'}`}>{log.detail}</div>}
{log.operator && <div className={`mt-1 text-xs ${dark ? 'text-slate-500' : 'text-gray-400'}`}>: {log.operator}</div>}
{log.detail && (
<div className={`mt-1 break-all text-xs ${dark ? 'text-slate-400' : 'text-gray-500'}`}>
{log.detail}
</div>
)}
{log.operator && (
<div className={`mt-1 text-xs ${dark ? 'text-slate-500' : 'text-gray-400'}`}>
: {log.operator}
</div>
)}
</div>
))}
{order.auditLogs.length === 0 && <div className={`text-center text-sm ${dark ? 'text-slate-500' : 'text-gray-400'}`}></div>}
{order.auditLogs.length === 0 && (
<div className={`text-center text-sm ${dark ? 'text-slate-500' : 'text-gray-400'}`}></div>
)}
</div>
</div>

View File

@@ -1,5 +1,7 @@
'use client';
import { getPaymentDisplayInfo } from '@/lib/pay-utils';
interface Order {
id: string;
userId: number;
@@ -70,7 +72,10 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
return (
<tr key={order.id} className={dark ? 'hover:bg-slate-700/40' : 'hover:bg-gray-50'}>
<td className="whitespace-nowrap px-4 py-3 text-sm">
<button onClick={() => onViewDetail(order.id)} className={dark ? 'text-indigo-400 hover:underline' : 'text-blue-600 hover:underline'}>
<button
onClick={() => onViewDetail(order.id)}
className={dark ? 'text-indigo-400 hover:underline' : 'text-blue-600 hover:underline'}
>
{order.id.slice(0, 12)}...
</button>
</td>
@@ -79,21 +84,33 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
</td>
<td className={tdMuted}>{order.userEmail || '-'}</td>
<td className={tdMuted}>{order.userNotes || '-'}</td>
<td className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : ''}`}>¥{order.amount.toFixed(2)}</td>
<td className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : ''}`}>
¥{order.amount.toFixed(2)}
</td>
<td className="whitespace-nowrap px-4 py-3 text-sm">
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${dark ? statusInfo.dark : statusInfo.light}`}>
<span
className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${dark ? statusInfo.dark : statusInfo.light}`}
>
{statusInfo.label}
</span>
</td>
<td className={tdMuted}>
{order.paymentType === 'alipay' ? '支付宝' : '微信支付'}
</td>
<td className={tdMuted}>
{order.srcHost || '-'}
</td>
<td className={tdMuted}>
{new Date(order.createdAt).toLocaleString('zh-CN')}
{(() => {
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>
<td className="whitespace-nowrap px-4 py-3 text-sm">
<div className="flex gap-1">
{order.rechargeRetryable && (
@@ -119,7 +136,9 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
})}
</tbody>
</table>
{orders.length === 0 && <div className={`py-12 text-center ${dark ? 'text-slate-500' : 'text-gray-500'}`}></div>}
{orders.length === 0 && (
<div className={`py-12 text-center ${dark ? 'text-slate-500' : 'text-gray-500'}`}></div>
)}
</div>
);
}

View File

@@ -1,5 +1,7 @@
'use client';
import { getPaymentTypeLabel, getPaymentMeta } from '@/lib/pay-utils';
interface PaymentMethod {
paymentType: string;
amount: number;
@@ -12,43 +14,52 @@ 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 (
<div className={['rounded-xl border p-6', dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm'].join(' ')}>
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}></h3>
<div
className={[
'rounded-xl border p-6',
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
].join(' ')}
>
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
</h3>
<p className={['text-center text-sm py-8', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}></p>
</div>
);
}
return (
<div className={['rounded-xl border p-6', dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm'].join(' ')}>
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}></h3>
<div
className={[
'rounded-xl border p-6',
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
].join(' ')}
>
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
</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>
</div>
<div className={['h-3 w-full overflow-hidden rounded-full', dark ? 'bg-slate-700' : 'bg-slate-100'].join(' ')}>
<div
className={['h-3 w-full overflow-hidden rounded-full', dark ? 'bg-slate-700' : 'bg-slate-100'].join(
' ',
)}
>
<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>

98
src/lib/alipay/client.ts Normal file
View File

@@ -0,0 +1,98 @@
import { getEnv } from '@/lib/config';
import { generateSign } from './sign';
import type { AlipayResponse } from './types';
const GATEWAY = 'https://openapi.alipay.com/gateway.do';
function getCommonParams(appId: string): Record<string, string> {
return {
app_id: appId,
format: 'JSON',
charset: 'utf-8',
sign_type: 'RSA2',
timestamp: new Date().toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai' }).replace('T', ' '),
version: '1.0',
};
}
function assertAlipayEnv(env: ReturnType<typeof getEnv>) {
if (!env.ALIPAY_APP_ID || !env.ALIPAY_PRIVATE_KEY || !env.ALIPAY_PUBLIC_KEY) {
throw new Error('Alipay environment variables (ALIPAY_APP_ID, ALIPAY_PRIVATE_KEY, ALIPAY_PUBLIC_KEY) are required');
}
return env as typeof env & {
ALIPAY_APP_ID: string;
ALIPAY_PRIVATE_KEY: string;
ALIPAY_PUBLIC_KEY: string;
};
}
/**
* 生成支付宝网站/H5支付的跳转 URLGET 方式)
* PC: alipay.trade.page.pay H5: alipay.trade.wap.pay
*/
export function pageExecute(
bizContent: Record<string, unknown>,
options?: { notifyUrl?: string; returnUrl?: string; method?: string },
): string {
const env = assertAlipayEnv(getEnv());
const params: Record<string, string> = {
...getCommonParams(env.ALIPAY_APP_ID),
method: options?.method || 'alipay.trade.page.pay',
biz_content: JSON.stringify(bizContent),
};
if (options?.notifyUrl || env.ALIPAY_NOTIFY_URL) {
params.notify_url = (options?.notifyUrl || env.ALIPAY_NOTIFY_URL)!;
}
if (options?.returnUrl || env.ALIPAY_RETURN_URL) {
params.return_url = (options?.returnUrl || env.ALIPAY_RETURN_URL)!;
}
params.sign = generateSign(params, env.ALIPAY_PRIVATE_KEY);
const query = new URLSearchParams(params).toString();
return `${GATEWAY}?${query}`;
}
/**
* 调用支付宝服务端 APIPOST 方式)
* 用于 alipay.trade.query、alipay.trade.refund、alipay.trade.close
*/
export async function execute<T extends AlipayResponse>(
method: string,
bizContent: Record<string, unknown>,
): Promise<T> {
const env = assertAlipayEnv(getEnv());
const params: Record<string, string> = {
...getCommonParams(env.ALIPAY_APP_ID),
method,
biz_content: JSON.stringify(bizContent),
};
params.sign = generateSign(params, env.ALIPAY_PRIVATE_KEY);
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();
// 支付宝响应格式:{ "alipay_trade_query_response": { ... }, "sign": "..." }
const responseKey = method.replace(/\./g, '_') + '_response';
const result = data[responseKey] as T;
if (!result) {
throw new Error(`Alipay API error: unexpected response format for ${method}`);
}
if (result.code !== '10000') {
throw new Error(`Alipay API error: [${result.sub_code || result.code}] ${result.sub_msg || result.msg}`);
}
return result;
}

137
src/lib/alipay/provider.ts Normal file
View File

@@ -0,0 +1,137 @@
import type {
PaymentProvider,
PaymentType,
CreatePaymentRequest,
CreatePaymentResponse,
QueryOrderResponse,
PaymentNotification,
RefundRequest,
RefundResponse,
} from '@/lib/payment/types';
import { pageExecute, execute } from './client';
import { verifySign } from './sign';
import { getEnv } from '@/lib/config';
import type { AlipayTradeQueryResponse, AlipayTradeRefundResponse, AlipayTradeCloseResponse } from './types';
export class AlipayProvider implements PaymentProvider {
readonly name = 'alipay-direct';
readonly providerKey = 'alipay';
readonly supportedTypes: PaymentType[] = ['alipay_direct'];
readonly defaultLimits = {
alipay_direct: { singleMax: 1000, dailyMax: 10000 },
};
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
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> {
const result = await execute<AlipayTradeQueryResponse>('alipay.trade.query', {
out_trade_no: tradeNo,
});
let status: 'pending' | 'paid' | 'failed' | 'refunded';
switch (result.trade_status) {
case 'TRADE_SUCCESS':
case 'TRADE_FINISHED':
status = 'paid';
break;
case 'TRADE_CLOSED':
status = 'failed';
break;
default:
status = 'pending';
}
return {
tradeNo: result.trade_no || tradeNo,
status,
amount: Math.round(parseFloat(result.total_amount || '0') * 100) / 100,
paidAt: result.send_pay_date ? new Date(result.send_pay_date) : undefined,
};
}
async verifyNotification(rawBody: string | Buffer, _headers: Record<string, string>): Promise<PaymentNotification> {
const env = getEnv();
const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf-8');
const searchParams = new URLSearchParams(body);
const params: Record<string, string> = {};
for (const [key, value] of searchParams.entries()) {
params[key] = value;
}
// sign_type 过滤:仅接受 RSA2
if (params.sign_type && params.sign_type !== 'RSA2') {
throw new Error('Unsupported sign_type, only RSA2 is accepted');
}
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: Math.round(parseFloat(params.total_amount || '0') * 100) / 100,
status:
params.trade_status === 'TRADE_SUCCESS' || params.trade_status === 'TRADE_FINISHED' ? 'success' : 'failed',
rawData: params,
};
}
async refund(request: RefundRequest): Promise<RefundResponse> {
const result = await execute<AlipayTradeRefundResponse>('alipay.trade.refund', {
out_trade_no: request.orderId,
refund_amount: request.amount.toFixed(2),
refund_reason: request.reason || '',
out_request_no: request.orderId + '-refund',
});
return {
refundId: result.trade_no || `${request.orderId}-refund`,
status: result.fund_change === 'Y' ? 'success' : 'pending',
};
}
async cancelPayment(tradeNo: string): Promise<void> {
await execute<AlipayTradeCloseResponse>('alipay.trade.close', {
out_trade_no: tradeNo,
});
}
}

38
src/lib/alipay/sign.ts Normal file
View File

@@ -0,0 +1,38 @@
import crypto from 'crypto';
/** 自动补全 PEM 格式PKCS8 */
function formatPrivateKey(key: string): string {
if (key.includes('-----BEGIN')) return key;
return `-----BEGIN PRIVATE KEY-----\n${key}\n-----END PRIVATE KEY-----`;
}
function formatPublicKey(key: string): string {
if (key.includes('-----BEGIN')) return key;
return `-----BEGIN PUBLIC KEY-----\n${key}\n-----END PUBLIC KEY-----`;
}
/** 生成 RSA2 签名 */
export function generateSign(params: Record<string, string>, privateKey: string): string {
const filtered = Object.entries(params)
.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('&');
const signer = crypto.createSign('RSA-SHA256');
signer.update(signStr);
return signer.sign(formatPrivateKey(privateKey), 'base64');
}
/** 用支付宝公钥验证签名 */
export function verifySign(params: Record<string, string>, alipayPublicKey: string, sign: string): boolean {
const filtered = Object.entries(params)
.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('&');
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(signStr);
return verifier.verify(formatPublicKey(alipayPublicKey), sign, 'base64');
}

59
src/lib/alipay/types.ts Normal file
View File

@@ -0,0 +1,59 @@
/** 支付宝电脑网站支付 bizContent */
export interface AlipayTradePagePayBizContent {
out_trade_no: string;
product_code: 'FAST_INSTANT_TRADE_PAY';
total_amount: string;
subject: string;
body?: string;
}
/** 支付宝统一响应结构 */
export interface AlipayResponse {
code: string;
msg: string;
sub_code?: string;
sub_msg?: string;
}
/** alipay.trade.query 响应 */
export interface AlipayTradeQueryResponse extends AlipayResponse {
trade_no?: string;
out_trade_no?: string;
trade_status?: string; // WAIT_BUYER_PAY, TRADE_CLOSED, TRADE_SUCCESS, TRADE_FINISHED
total_amount?: string;
send_pay_date?: string;
}
/** alipay.trade.refund 响应 */
export interface AlipayTradeRefundResponse extends AlipayResponse {
trade_no?: string;
out_trade_no?: string;
refund_fee?: string;
fund_change?: string; // Y/N
}
/** alipay.trade.close 响应 */
export interface AlipayTradeCloseResponse extends AlipayResponse {
trade_no?: string;
out_trade_no?: string;
}
/** 异步通知参数 */
export interface AlipayNotifyParams {
notify_time: string;
notify_type: string;
notify_id: string;
app_id: string;
charset: string;
version: string;
sign_type: string;
sign: string;
trade_no: string;
out_trade_no: string;
trade_status: string;
total_amount: string;
receipt_amount?: string;
buyer_pay_amount?: string;
gmt_payment?: string;
[key: string]: string | undefined;
}

View File

@@ -1,4 +1,5 @@
import { z } from 'zod';
import fs from 'fs';
const optionalTrimmedString = z.preprocess((value) => {
if (typeof value !== 'string') return value;
@@ -12,11 +13,16 @@ 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('')
.transform((v) => v.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)),
.transform((v) =>
v
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
),
// ── Easy-PayPAYMENT_PROVIDERS 含 easypay 时必填) ──
EASY_PAY_PID: optionalTrimmedString,
@@ -28,18 +34,29 @@ const envSchema = z.object({
EASY_PAY_CID_ALIPAY: optionalTrimmedString,
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,
// ── StripePAYMENT_PROVIDERS 含 stripe 时必填) ──
STRIPE_SECRET_KEY: optionalTrimmedString,
STRIPE_PUBLISHABLE_KEY: optionalTrimmedString,
STRIPE_WEBHOOK_SECRET: optionalTrimmedString,
// ── 启用的支付渠道(在已配置服务商支持的渠道中选择) ──
// 易支付支持: alipay, wxpayStripe 支持: 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()),
@@ -48,9 +65,26 @@ const envSchema = z.object({
// 每日各渠道全平台总限额可选覆盖0 = 不限制)。
// 未设置时由各 PaymentProvider.defaultLimits 提供默认值。
MAX_DAILY_AMOUNT_ALIPAY: z.string().optional().transform((v) => (v !== undefined ? Number(v) : undefined)).pipe(z.number().min(0).optional()),
MAX_DAILY_AMOUNT_WXPAY: z.string().optional().transform((v) => (v !== undefined ? Number(v) : undefined)).pipe(z.number().min(0).optional()),
MAX_DAILY_AMOUNT_STRIPE: z.string().optional().transform((v) => (v !== undefined ? Number(v) : undefined)).pipe(z.number().min(0).optional()),
MAX_DAILY_AMOUNT_ALIPAY: z
.string()
.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()
.transform((v) => (v !== undefined ? Number(v) : undefined))
.pipe(z.number().min(0).optional()),
MAX_DAILY_AMOUNT_STRIPE: z
.string()
.optional()
.transform((v) => (v !== undefined ? Number(v) : undefined))
.pipe(z.number().min(0).optional()),
PRODUCT_NAME: z.string().default('Sub2API Balance Recharge'),
ADMIN_TOKEN: z.string().min(1),
@@ -58,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;
@@ -73,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
View 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]);

View File

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

View File

@@ -18,7 +18,7 @@ export class EasyPayProvider implements PaymentProvider {
readonly supportedTypes: PaymentType[] = ['alipay', 'wxpay'];
readonly defaultLimits = {
alipay: { singleMax: 1000, dailyMax: 10000 },
wxpay: { singleMax: 1000, dailyMax: 10000 },
wxpay: { singleMax: 1000, dailyMax: 10000 },
};
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {

View File

@@ -1,4 +1,5 @@
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
import { Prisma } from '@prisma/client';
/**
* 获取指定支付渠道的手续费率(百分比)。
@@ -26,13 +27,18 @@ export function getMethodFeeRate(paymentType: string): number {
return 0;
}
/** decimal.js ROUND_UP = 0远离零方向取整 */
const ROUND_UP = 0;
/**
* 根据到账金额和手续费率计算实付金额。
* feeAmount = ceil(rechargeAmount * feeRate / 100 * 100) / 100 (进一制到分)
* 根据到账金额和手续费率计算实付金额(使用 Decimal 精确计算,避免浮点误差)
* feeAmount = ceil(rechargeAmount * feeRate / 100, 保留2位小数)
* payAmount = rechargeAmount + feeAmount
*/
export function calculatePayAmount(rechargeAmount: number, feeRate: number): number {
if (feeRate <= 0) return rechargeAmount;
const feeAmount = Math.ceil(rechargeAmount * feeRate / 100 * 100) / 100;
return Math.round((rechargeAmount + feeAmount) * 100) / 100;
const amount = new Prisma.Decimal(rechargeAmount);
const rate = new Prisma.Decimal(feeRate.toString());
const feeAmount = amount.mul(rate).div(100).toDecimalPlaces(2, ROUND_UP);
return amount.plus(feeAmount).toNumber();
}

View File

@@ -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';
@@ -64,9 +65,7 @@ export interface MethodLimitStatus {
* 批量查询多个支付渠道的今日使用情况。
* 一次 DB groupBy 完成,调用方按需传入渠道列表。
*/
export async function queryMethodLimits(
paymentTypes: string[],
): Promise<Record<string, MethodLimitStatus>> {
export async function queryMethodLimits(paymentTypes: string[]): Promise<Record<string, MethodLimitStatus>> {
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);
@@ -74,15 +73,13 @@ export async function queryMethodLimits(
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 },
});
const usageMap = Object.fromEntries(
usageRows.map((r) => [r.paymentType, Number(r._sum.amount ?? 0)]),
);
const usageMap = Object.fromEntries(usageRows.map((r) => [r.paymentType, Number(r._sum.amount ?? 0)]));
const result: Record<string, MethodLimitStatus> = {};
for (const type of paymentTypes) {

View File

@@ -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 },
@@ -65,11 +67,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
const alreadyPaid = Number(dailyAgg._sum.amount ?? 0);
if (alreadyPaid + input.amount > env.MAX_DAILY_RECHARGE_AMOUNT) {
const remaining = Math.max(0, env.MAX_DAILY_RECHARGE_AMOUNT - alreadyPaid);
throw new OrderError(
'DAILY_LIMIT_EXCEEDED',
`今日累计充值已达上限,剩余可充值 ${remaining.toFixed(2)}`,
429,
);
throw new OrderError('DAILY_LIMIT_EXCEEDED', `今日累计充值已达上限,剩余可充值 ${remaining.toFixed(2)}`, 429);
}
}
@@ -81,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 },
@@ -131,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({
@@ -164,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,
@@ -237,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() },
});
@@ -246,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,
},
@@ -264,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',
});
@@ -283,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',
});
@@ -326,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(),
);
@@ -336,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(),
@@ -349,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;
}
@@ -370,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;
@@ -398,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,
@@ -418,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({
@@ -433,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),
},
@@ -461,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);
}
@@ -496,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) {
@@ -517,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);
}
@@ -559,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);
}
@@ -586,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);
@@ -605,12 +675,17 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
});
}
await subtractBalance(order.userId, rechargeAmount, `sub2apipay refund order:${order.id}`, `sub2apipay:refund:${order.id}`);
await subtractBalance(
order.userId,
rechargeAmount,
`sub2apipay refund order:${order.id}`,
`sub2apipay:refund:${order.id}`,
);
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(),
@@ -632,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),
},

View File

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

View File

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

View File

@@ -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 }[] = [
@@ -38,12 +45,19 @@ export const FILTER_OPTIONS: { key: OrderStatusFilter; label: string }[] = [
export function detectDeviceIsMobile(): boolean {
if (typeof window === 'undefined') return false;
// 1. 现代 APIChromium 系浏览器,最准确)
const uad = (navigator as Navigator & { userAgentData?: { mobile: boolean } }).userAgentData;
if (uad !== undefined) return uad.mobile;
// 2. UA 正则兜底Safari / Firefox 等)
const ua = navigator.userAgent || '';
const mobileUA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Windows Phone|Mobile/i.test(ua);
if (mobileUA) return true;
// 3. 触控 + 小屏兜底(新版 iPad UA 伪装成 Mac 的情况)
const smallPhysicalScreen = Math.min(window.screen.width, window.screen.height) <= 768;
const touchCapable = navigator.maxTouchPoints > 1;
return mobileUA || (touchCapable && smallPhysicalScreen);
return touchCapable && smallPhysicalScreen;
}
export function formatStatus(status: string): string {
@@ -57,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';

View File

@@ -2,6 +2,8 @@ import { paymentRegistry } from './registry';
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';
@@ -31,6 +33,33 @@ export function initPaymentProviders(): void {
paymentRegistry.register(new EasyPayProvider());
}
if (providers.includes('alipay')) {
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()); // 注册 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')) {
if (!env.STRIPE_SECRET_KEY) {
throw new Error('PAYMENT_PROVIDERS 含 stripe但缺少 STRIPE_SECRET_KEY');
@@ -38,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;
}

View File

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

View File

@@ -67,7 +67,10 @@ export class StripeProvider implements PaymentProvider {
};
}
async verifyNotification(rawBody: string | Buffer, headers: Record<string, string>): Promise<PaymentNotification | null> {
async verifyNotification(
rawBody: string | Buffer,
headers: Record<string, string>,
): Promise<PaymentNotification | null> {
const stripe = this.getClient();
const env = getEnv();
if (!env.STRIPE_WEBHOOK_SECRET) throw new Error('STRIPE_WEBHOOK_SECRET not configured');

170
src/lib/wxpay/client.ts Normal file
View 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
View File

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

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