89 Commits

Author SHA1 Message Date
erio
9f621713c3 style: 统一代码格式 (prettier) 2026-03-10 18:20:36 +08:00
erio
abff49222b fix: 修复 OrderStatus ref 赋值导致 ESLint react-hooks/refs 报错
将 onStateChangeRef.current 赋值从渲染阶段移入 useEffect
2026-03-10 18:18:58 +08:00
erio
1cb82d8fd7 fix: 消除 buildOrderStatusUrl 重复定义,修复轮询回调引用稳定性
- 将 buildOrderStatusUrl 提取到 status-url.ts(客户端安全模块),
  删除 OrderStatus/PaymentQRCode/result 三处重复定义
- OrderStatus.tsx 轮询 effect 使用 useRef 保存 onStateChange,
  避免非 memoized 回调导致定时器不断重建
- result/page.tsx 增加 accessToken 最小长度校验,
  避免无效参数触发无意义的 API 请求
2026-03-10 14:31:26 +08:00
eriol touwa
d6973256a7 Merge pull request #7 from daguimu/feat/alipay-shortlink-notify-fixes
fix: harden alipay direct pay flow
2026-03-10 14:27:06 +08:00
daguimu
8b10bc3bd5 fix: harden alipay direct pay flow 2026-03-10 11:52:37 +08:00
erio
2492031e13 feat: 全站多语言支持 (i18n),lang=en 显示英文,其余默认中文
新增 src/lib/locale.ts 作为统一多语言入口,覆盖前台支付链路、
管理后台、API/服务层错误文案,共 35 个文件。URL 参数 lang 全链路透传,
包括 Stripe return_url、页面跳转、layout html lang 属性等。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:33:57 +08:00
erio
5cebe85079 fix: 用户端订单列表只显示支付渠道,不显示提供商
管理后台仍保留提供商信息显示
2026-03-09 11:32:50 +08:00
erio
5fb16f0ccf fix: 暗色模式下支付方式选中时文字与背景色冲突不可见 2026-03-09 10:12:05 +08:00
erio
43e116a4f2 Revert "fix: 各支付渠道默认单笔限额从 1000 提升至 100000,每日限额改为不限"
This reverts commit e1788437c9.
2026-03-08 00:06:23 +08:00
erio
e1788437c9 fix: 各支付渠道默认单笔限额从 1000 提升至 100000,每日限额改为不限
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 00:03:01 +08:00
erio
2df040e9b9 docs: 更新 EASY_PAY_RETURN_URL 为 /pay/result
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:01:12 +08:00
erio
8a465ae625 feat: 支付结果页增加 5 秒倒计时自动返回和手动返回按钮
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:55:49 +08:00
erio
1d19fc86ee fix: Dockerfile 构建时注入 dummy 环境变量避免预渲染报错
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 04:31:36 +08:00
erio
f50a180ec4 fix: 微信支付回调验签 PEM 格式自动补全,Stripe webhook 失败重试
- wxpay client: 添加 formatPublicKey() 自动包裹 PEM 头尾,修复裸 base64 公钥导致的 DECODER routines::unsupported 错误
- stripe webhook: 处理失败时返回 500 让 Stripe 重试
- 修正支付宝测试用例与实际代码对齐

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 04:27:38 +08:00
erio
698df1ee47 Merge branch 'worktree-agent-afaec22d' 2026-03-07 04:16:38 +08:00
erio
37096de05c Merge branch 'worktree-agent-a5caa164' 2026-03-07 04:16:26 +08:00
erio
d43b04cb5c fix: 前端暗色模式补全、Unicode 可读化、UI 优化 12 项
- PaymentQRCode 13 处 Unicode 转义替换为可读中文
- RefundDialog 完整暗色模式 + Escape 键关闭
- PayResult 页面添加暗色模式支持
- OrderStatus 使用 dark prop 调整样式
- PaymentForm 选中态暗色对比度修复
- OrderDetail 英文标签改中文 + Escape 键
- Pay 页面错误提示暗色适配
- 倒计时最后 60 秒脉动提醒
- 全局 CSS 添加中文字体栈
- MobileOrderList HTML 实体替换

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 04:16:01 +08:00
erio
ac0772b0f4 fix: API 路由安全加固与架构优化 — 认证、错误处理、Registry 统一
- /api/user 添加 token 认证,防止用户枚举
- Admin token 支持 Authorization header
- /api/orders/my 区分认证失败和服务端错误
- Admin orders userId/date 参数校验
- Decimal 字段统一 Number() 转换
- 抽取 handleApiError/extractHeaders 工具函数
- Webhook 路由改用 Registry 获取 Provider
- PaymentRegistry lazy init 自动初始化

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 04:15:54 +08:00
erio
4b013370b9 fix: 后端资金安全修复 — 金额覆盖、过期订单、退款原子性等 9 项
- confirmPayment 不再覆盖 amount,实付金额写入 payAmount
- EXPIRED 订单增加 5 分钟宽限窗口
- 退款流程先扣余额再退款,失败可回滚
- 支付宝签名过滤 sign_type
- executeRecharge 使用 CAS 更新
- createOrder rechargeCode 事务保护
- EasyPay/Sub2API client 添加 10s 超时
- db.ts 统一从 getEnv() 获取 DATABASE_URL
- 添加 paymentType+paidAt 复合索引

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 04:15:48 +08:00
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
erio
9a90a7ebb9 fix: embedded 模式下也强制 min-h-screen,防止 dark 模式底部白底 2026-03-04 21:55:43 +08:00
erio
f96f89b7bb fix: 移除 body 硬编码背景色,修复 dark 模式底部白底问题 2026-03-04 21:23:48 +08:00
erio
56bf0916e3 perf: 添加 paid_at 索引优化 dashboard 查询性能 2026-03-04 20:08:58 +08:00
eriol touwa
3380b808e2 Merge pull request #3 from dexcoder6/feat/admin-dashboard
Feat/admin dashboard
2026-03-04 20:07:49 +08:00
dexcoder6
96436f617a Merge branch 'touwaeriol:main' into feat/admin-dashboard 2026-03-04 19:06:25 +08:00
eriol touwa
d461880a9e Merge pull request #2 from dexcoder6/fix/stripe-popup-security
fix: Stripe 弹窗安全加固 + 清理未使用依赖
2026-03-04 18:11:14 +08:00
erio
69cf0d00d1 fix: 添加 packageManager 字段修复 CI pnpm 版本检测 2026-03-04 18:10:24 +08:00
miwei
3a9a32e2c2 feat: 管理后台数据看板
新增 /admin/dashboard 页面,提供充值订单统计与分析:
- 汇总统计卡片(今日/累计充值金额、订单数、成功率、平均充值)
- 每日充值趋势折线图(recharts,支持 7/30/90 天切换)
- 充值排行榜(Top 10 用户)
- 支付方式分布(水平条形图)
- 与 /admin 订单管理页面互相导航

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:06:27 +08:00
miwei
d7d91857c7 fix: Stripe 弹窗安全加固 + 清理未使用依赖
安全修复:
- client_secret 和 publishableKey 不再通过 URL 传递,改用 postMessage
  弹窗发送 STRIPE_POPUP_READY 信号,父页面响应 STRIPE_POPUP_INIT 传递敏感数据
  校验 event.origin 防止跨域消息伪造
- confirmAlipayPayment 改为显式调用,移除动态方法查找
- handleStripeSubmit 中 returnUrl 清理残留 query params

依赖清理:
- 移除未使用的 @stripe/react-stripe-js

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-04 15:27:51 +08:00
eriol touwa
84f38f985f Merge pull request #1 from dexcoder6/feat/stripe-embedded-popup
feat: Stripe 改用 PaymentIntent + Payment Element,iframe 嵌入支付宝弹窗支付
2026-03-04 14:43:30 +08:00
miwei
964a2aa6d9 feat: Stripe 改用 PaymentIntent + Payment Element,iframe 嵌入支付宝弹窗支付
Stripe 集成重构:
- 从 Checkout Session 改为 PaymentIntent + Payment Element 模式
- 前端内联渲染 Stripe 支付表单,支持信用卡、支付宝等多种方式
- Webhook 事件改为 payment_intent.succeeded / payment_intent.payment_failed
- provider/test 同步更新

iframe 嵌入模式 (ui_mode=embedded):
- 支付宝等需跳转的方式改为弹出新窗口处理,避免 X-Frame-Options 冲破 iframe
- 信用卡等无跳转方式仍在 iframe 内联完成
- 弹窗使用 confirmAlipayPayment 直接跳转,无需二次操作
- result 页面检测弹窗模式,支付成功后自动关闭窗口

Bug 修复:
- 修复配置加载前支付方式闪烁(初始值改为空数组 + loading)
- 修复桌面端 PaymentForm 缺少 methodLimits prop
- 修复 stripeError 隐藏表单导致无法重试
- 快捷金额增加 1000/2000 选项,过滤低于 minAmount 的选项

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-04 11:11:46 +08:00
erio
5be0616e78 feat: 支付手续费功能
- 支持提供商级别和渠道级别手续费率配置(FEE_RATE_PROVIDER_* / FEE_RATE_*)
- 用户多付手续费,到账金额不变(充值 ¥100 + 1.6% = 实付 ¥101.60)
- 前端显示手续费明细和实付金额
- 退款时按实付金额退款,余额扣减到账金额
2026-03-03 22:00:44 +08:00
erio
1a44e94bb5 docs: 集成说明补充我的订单和订单管理页面链接
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:15:43 +08:00
erio
c326c6edf1 docs: ZPay 超链接 + 明文 URL 方便复制
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 08:19:20 +08:00
erio
5992c06d67 docs: 同步英文 README,ZPay 链接明文显示,添加 release workflow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 08:17:59 +08:00
erio
90ad0e0895 docs: README 补充易支付协议说明、ZPay 推荐及免责声明
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 08:03:02 +08:00
erio
52aa484202 feat: 列表页占满宽度,充值页保持居中卡片,嵌入模式优化
- maxWidth 新增 'lg' 选项(max-w-6xl),'full' 改为无限制
- 充值页 PC 端使用 'lg',管理后台/我的订单使用 'full' 占满宽度
- 嵌入模式:减小外边距、隐藏装饰光斑、取消 min-h-screen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 05:54:46 +08:00
erio
42da18484c feat: 管理后台订单列表展示用户备注,用户信息摊平显示
- 新增 userNotes 字段,创建订单时从 Sub2API 读取用户 notes 保存
- 管理后台订单列表将用户名、邮箱、备注拆分为独立列,节约行高

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 04:37:39 +08:00
erio
f4709b784f fix: 有 src_host 时隐藏订单页「返回充值」按钮
从 iframe 嵌入(带 src_host)时不显示返回充值按钮,避免用户跳出。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 03:55:26 +08:00
erio
880f0211f3 feat: 管理后台统一 PayPageLayout 布局,支持 dark mode
管理后台使用与充值页面相同的 PayPageLayout 组件,OrderTable 和
OrderDetail 组件新增 dark prop,所有样式支持暗色模式切换。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 03:31:20 +08:00
erio
930ce60fcc fix: 审查修复 — 来源字段长度限制、鉴权超时、支付配置启动校验
- src_host max 253, src_url max 2048
- Sub2API 鉴权请求加 5s AbortController 超时
- initPaymentProviders 启动时校验 ENABLED_PAYMENT_TYPES 与已注册 provider 一致性

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 01:56:22 +08:00
erio
8cf78dc295 fix: frame-ancestors 自动从 SUB2API_BASE_URL 推导,无需手动配置
不再依赖 IFRAME_ALLOW_ORIGINS 手动配置 Sub2API 域名,
自动从 SUB2API_BASE_URL 提取 origin 加入 CSP frame-ancestors。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 01:36:22 +08:00
erio
21cc90a71f feat: 管理后台支持 Sub2API 管理员 token 认证
保留原有 ADMIN_TOKEN 认证,同时支持传入 Sub2API 用户 token,
通过 /api/v1/auth/me 验证 role=admin 身份。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 00:41:27 +08:00
erio
c9462f4f14 feat: 管理后台订单列表显示来源域名(srcHost)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 00:19:01 +08:00
erio
d952942627 feat: 订单来源追踪,保存 src_host / src_url 到订单记录
iframe 嵌入充值页面时 URL 自动附带来源参数,写入数据库用于追踪分析。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 20:40:16 +08:00
erio
c083880cbc docs+feat: 完善 README 帮助内容配置说明,支持多行文字
- README (中/英) 修正 NEXT_PUBLIC_PAY_HELP_* → PAY_HELP_*
- 新增 PAYMENT_PROVIDERS 配置说明(两步配置服务商+渠道)
- 说明帮助图片支持外部 URL 或本地 uploads/ 两种方式
- PAY_HELP_TEXT 支持 \n 换行,渲染为多行段落
2026-03-02 04:17:51 +08:00
erio
a9ea9d4862 feat: 帮助图片点击放大(lightbox)
点击支付页右侧帮助区域的联系二维码图片,在屏幕正中以全屏遮罩放大展示;
点击背景或再次点击可关闭。
2026-03-02 03:39:49 +08:00
erio
e170d5451e fix: 帮助内容改为服务端变量经 API 下发,运行时可配无需重新构建 2026-03-02 02:46:51 +08:00
erio
e5424e6c5e feat: 显式 PAYMENT_PROVIDERS 配置服务商,缺密钥启动即报错 2026-03-02 02:04:53 +08:00
erio
310fa1020f fix: loadUserAndOrders 开始时重置 userNotFound,防止状态残留 2026-03-02 01:23:04 +08:00
erio
85239e97f8 fix: 用户不存在时前端提示错误;修正微信支付图标;beta compose 改用 Docker Hub 镜像 2026-03-02 01:05:01 +08:00
erio
c6815fc2a3 feat: 插件化支付渠道限额 — provider 自声明单笔/每日默认限额
- PaymentProvider 接口新增 defaultLimits(单笔 singleMax + 每日 dailyMax)
- EasyPay 默认:支付宝/微信各 单笔 ¥1000、每日 ¥10000
- Stripe 默认:不限额(0 = unlimited)
- getMethodDailyLimit / getMethodSingleLimit 优先读 env var,再回退 provider 默认
- queryMethodLimits 返回 singleMax,PaymentForm 按渠道动态调整最大单笔金额
- MAX_DAILY_AMOUNT_* 改为可选 env var 覆盖(不再有硬编码默认值)
2026-03-01 22:51:09 +08:00
108 changed files with 8713 additions and 1316 deletions

86
.env.example Normal file
View File

@@ -0,0 +1,86 @@
# 数据库
DATABASE_URL="postgresql://sub2apipay:password@localhost:5432/sub2apipay"
# Sub2API
SUB2API_BASE_URL="https://your-sub2api-domain.com"
SUB2API_ADMIN_API_KEY="your-admin-api-key"
# ── 支付服务商(逗号分隔,决定加载哪些服务商) ───────────────────────────────
# 可选值: 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 时必填) ────────────────────────
EASY_PAY_PID="your-pid"
EASY_PAY_PKEY="your-pkey"
EASY_PAY_API_BASE="https://zpayz.cn"
EASY_PAY_NOTIFY_URL="https://pay.example.com/api/easy-pay/notify"
EASY_PAY_RETURN_URL="https://pay.example.com/pay/result"
# 渠道 ID部分易支付平台需要可选
#EASY_PAY_CID_ALIPAY=""
#EASY_PAY_CID_WXPAY=""
# ── Stripe 配置PAYMENT_PROVIDERS 含 stripe 时必填) ────────────────────────
#STRIPE_SECRET_KEY="sk_live_..."
#STRIPE_PUBLISHABLE_KEY="pk_live_..."
#STRIPE_WEBHOOK_SECRET="whsec_..."
# ── 支付宝直连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"
# ── 订单配置 ──────────────────────────────────────────────────────────────────
ORDER_TIMEOUT_MINUTES="5"
MIN_RECHARGE_AMOUNT="1"
MAX_RECHARGE_AMOUNT="10000"
# 每用户每日累计充值上限0 = 不限制
MAX_DAILY_RECHARGE_AMOUNT="0"
# 各渠道全平台每日总限额0 = 不限制(未设置则使用各服务商默认值)
#MAX_DAILY_AMOUNT_ALIPAY="0"
#MAX_DAILY_AMOUNT_WXPAY="0"
#MAX_DAILY_AMOUNT_STRIPE="0"
PRODUCT_NAME="Sub2API 余额充值"
# ── 手续费(百分比,可选) ─────────────────────────────────────────────────────
# 提供商级别(应用于该提供商下所有渠道)
#FEE_RATE_PROVIDER_EASYPAY=1.6
#FEE_RATE_PROVIDER_STRIPE=5.9
# 渠道级别(覆盖提供商级别)
#FEE_RATE_ALIPAY=
#FEE_RATE_WXPAY=
#FEE_RATE_STRIPE=
# ── 管理员 ────────────────────────────────────────────────────────────────────
ADMIN_TOKEN="your-admin-token"
# ── 应用 ──────────────────────────────────────────────────────────────────────
NEXT_PUBLIC_APP_URL="https://pay.example.com"
# iframe 允许嵌入的域名(逗号分隔)
IFRAME_ALLOW_ORIGINS="https://example.com"
# 充值页面底部帮助内容(可选)
#PAY_HELP_IMAGE_URL="https://example.com/qrcode.png"
#PAY_HELP_TEXT="如需帮助请联系客服微信xxxxx"

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

44
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,44 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
release:
name: Create Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog
id: changelog
run: |
# Get previous tag
PREV_TAG=$(git tag --sort=-v:refname | sed -n '2p')
if [ -z "$PREV_TAG" ]; then
COMMITS=$(git log --pretty=format:"- %s (%h)" HEAD)
else
COMMITS=$(git log --pretty=format:"- %s (%h)" "${PREV_TAG}..HEAD")
fi
{
echo 'body<<EOF'
echo "## What's Changed"
echo ""
echo "$COMMITS"
echo ""
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV_TAG:-$(git rev-list --max-parents=0 HEAD | head -1)}...${{ github.ref_name }}"
echo 'EOF'
} >> "$GITHUB_OUTPUT"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
body: ${{ steps.changelog.outputs.body }}
generate_release_notes: false

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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

@@ -11,7 +11,13 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm prisma generate
RUN pnpm build
# 构建时注入 dummy 环境变量,避免 Next.js 预渲染 API 路由时 getEnv() 报错
RUN DATABASE_URL="postgresql://x:x@localhost/x" \
SUB2API_BASE_URL="https://localhost" \
SUB2API_ADMIN_API_KEY="build-dummy" \
ADMIN_TOKEN="build-dummy" \
NEXT_PUBLIC_APP_URL="https://localhost" \
pnpm build
FROM node:22-alpine AS runner
WORKDIR /app

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,68 +85,113 @@ 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.
### Payment Methods
### Payment Providers & Methods
Control which payment methods are enabled via `ENABLED_PAYMENT_TYPES` (comma-separated):
**Step 1**: Declare which payment providers to load via `PAYMENT_PROVIDERS` (comma-separated):
```env
ENABLED_PAYMENT_TYPES=alipay,wxpay,stripe
# EasyPay only
PAYMENT_PROVIDERS=easypay
# Stripe only
PAYMENT_PROVIDERS=stripe
# Both
PAYMENT_PROVIDERS=easypay,stripe
```
**Step 2**: Control which channels are shown to users via `ENABLED_PAYMENT_TYPES`:
```env
# EasyPay supports: alipay, wxpay | Stripe supports: stripe
ENABLED_PAYMENT_TYPES=alipay,wxpay
```
#### EasyPay (Alipay / WeChat Pay)
| Variable | Description |
|----------|-------------|
| `EASY_PAY_PID` | EasyPay merchant ID |
| `EASY_PAY_PKEY` | EasyPay merchant secret key |
| `EASY_PAY_API_BASE` | EasyPay API base URL |
Any payment provider compatible with the **EasyPay protocol** can be used, such as [ZPay](https://z-pay.cn/?uid=23808) (`https://z-pay.cn/?uid=23808`) (this link contains the author's referral code — feel free to remove it).
<details>
<summary>ZPay Registration QR Code</summary>
![ZPay Preview](./docs/zpay-preview.png)
</details>
> **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 |
| `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/result` |
| `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: `checkout.session.completed`, `checkout.session.expired`
> 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)
| Variable | Description |
|----------|-------------|
| `NEXT_PUBLIC_PAY_HELP_IMAGE_URL` | Help image URL (e.g. customer service QR code) |
| `NEXT_PUBLIC_PAY_HELP_TEXT` | Help text displayed on payment page |
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` |
**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
```
- **Local file**: place the image in `./uploads/` and reference it as `/uploads/<filename>`.
The directory must be mounted in `docker-compose.app.yml` (included by default):
```yaml
volumes:
- ./uploads:/app/public/uploads:ro
```
```env
PAY_HELP_IMAGE_URL=/uploads/help-qr.jpg
```
> Clicking the help image opens it full-screen in the center of the screen.
### 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**) |
---
@@ -220,17 +265,21 @@ docker compose exec app npx prisma migrate deploy
## Sub2API Integration
Configure the recharge URL in the Sub2API admin panel:
The following page URLs can be configured in the Sub2API admin panel:
```
https://pay.example.com/pay?user_id={USER_ID}&token={TOKEN}&theme={THEME}
```
| 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 |
| Parameter | Description |
|-----------|-------------|
| `user_id` | Sub2API user ID (required) |
| `token` | User login token (optional — required to view order history) |
| `theme` | `light` (default) or `dark` |
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` |
| `ui_mode` | `standalone` (default) or `embedded` (for iframe) |
---
@@ -239,13 +288,13 @@ https://pay.example.com/pay?user_id={USER_ID}&token={TOKEN}&theme={THEME}
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 |
---
@@ -262,7 +311,7 @@ User submits recharge amount
User completes payment
├─ EasyPay → QR code / H5 redirect
└─ Stripe → Checkout Session
└─ Stripe → Payment Element (PaymentIntent)
Payment callback (signature verified) → Order PAID

173
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,68 +85,113 @@ 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 自动注入,无需手动填写。
### 支付方式
### 支付服务商与支付方式
通过 `ENABLED_PAYMENT_TYPES` 控制开启哪些支付方式(逗号分隔):
**第一步**:通过 `PAYMENT_PROVIDERS` 声明启用哪些支付服务商(逗号分隔):
```env
ENABLED_PAYMENT_TYPES=alipay,wxpay,stripe
# 仅易支付
PAYMENT_PROVIDERS=easypay
# 仅 Stripe
PAYMENT_PROVIDERS=stripe
# 两者都用
PAYMENT_PROVIDERS=easypay,stripe
```
**第二步**:通过 `ENABLED_PAYMENT_TYPES` 控制向用户展示哪些支付渠道:
```env
# 易支付支持: alipay, wxpayStripe 支持: stripe
ENABLED_PAYMENT_TYPES=alipay,wxpay
```
#### EasyPay支付宝 / 微信支付)
| 变量 | 说明 |
|------|------|
| `EASY_PAY_PID` | EasyPay 商户 ID |
| `EASY_PAY_PKEY` | EasyPay 商户密钥 |
| `EASY_PAY_API_BASE` | EasyPay API 地址 |
支付提供商只需兼容**易支付EasyPay协议**即可接入,例如 [ZPay](https://z-pay.cn/?uid=23808)`https://z-pay.cn/?uid=23808`)等平台(链接含本项目作者的邀请码,介意可去掉)。
<details>
<summary>ZPay 申请二维码</summary>
![ZPay 预览](./docs/zpay-preview.png)
</details>
> **注意**:支付渠道的安全性、稳定性及合规性请自行鉴别,本项目不对任何第三方支付服务商做担保或背书。
| 变量 | 说明 |
| --------------------- | ------------------------------------------------------------- |
| `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/result` |
| `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`
> 需订阅事件:`checkout.session.completed`、`checkout.session.expired`
> 需订阅事件:`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 定制(可选)
| 变量 | 说明 |
|------|------|
| `NEXT_PUBLIC_PAY_HELP_IMAGE_URL` | 帮助图片 URL如客服二维码 |
| `NEXT_PUBLIC_PAY_HELP_TEXT` | 帮助说明文字 |
在充值页面右侧可展示客服联系方式、说明图片等帮助内容。
| 变量 | 说明 |
| -------------------- | --------------------------------------------------------------- |
| `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
```
- **本地文件**:将图片放到 `./uploads/` 目录,通过 `/uploads/文件名` 引用。
需在 `docker-compose.app.yml` 中挂载目录(默认已包含):
```yaml
volumes:
- ./uploads:/app/public/uploads:ro
```
```env
PAY_HELP_IMAGE_URL=/uploads/help-qr.jpg
```
> 点击帮助图片可在屏幕中央全屏放大查看。
### Docker Compose 专用
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `APP_PORT` | 宿主机映射端口 | `3001` |
| 变量 | 说明 | 默认值 |
| ------------- | ----------------------------------- | ---------------------------- |
| `APP_PORT` | 宿主机映射端口 | `3001` |
| `DB_PASSWORD` | PostgreSQL 密码(使用自带数据库时) | `password`**生产请修改** |
---
@@ -220,17 +265,21 @@ docker compose exec app npx prisma migrate deploy
## 集成到 Sub2API
在 Sub2API 管理后台将充值链接配置为
在 Sub2API 管理后台可配置以下页面链接
```
https://pay.example.com/pay?user_id={USER_ID}&token={TOKEN}&theme={THEME}
```
| 页面 | 链接 | 说明 |
| -------- | ------------------------------------ | ----------------------- |
| 充值页面 | `https://pay.example.com/pay` | 用户充值入口 |
| 我的订单 | `https://pay.example.com/pay/orders` | 用户查看自己的充值记录 |
| 订单管理 | `https://pay.example.com/admin` | 仅 Sub2API 管理员可访问 |
| 参数 | 说明 |
|------|------|
| `user_id` | Sub2API 用户 ID必填 |
| `token` | 用户登录 Token可选有 token 才能查看订单历史) |
| `theme` | `light`(默认)或 `dark` |
Sub2API **v0.1.88** 及以上版本会自动拼接以下参数,无需手动添加:
| 参数 | 说明 |
| --------- | ------------------------------------------------ |
| `user_id` | Sub2API 用户 ID |
| `token` | 用户登录 Token有 token 才能查看订单历史) |
| `theme` | `light`(默认)或 `dark` |
| `ui_mode` | `standalone`(默认)或 `embedded`iframe 嵌入) |
---
@@ -239,13 +288,13 @@ https://pay.example.com/pay?user_id={USER_ID}&token={TOKEN}&theme={THEME}
访问:`https://pay.example.com/admin?token=YOUR_ADMIN_TOKEN`
| 功能 | 说明 |
|------|------|
| 功能 | 说明 |
| -------- | ------------------------------------------- |
| 订单列表 | 按状态筛选、分页浏览,支持每页 20/50/100 条 |
| 订单详情 | 查看完整字段与操作审计日志 |
| 重试充值 | 对已支付但充值失败的订单重新发起充值 |
| 取消订单 | 强制取消待支付订单 |
| 退款 | 对已完成订单发起退款并扣减 Sub2API 余额 |
| 订单详情 | 查看完整字段与操作审计日志 |
| 重试充值 | 对已支付但充值失败的订单重新发起充值 |
| 取消订单 | 强制取消待支付订单 |
| 退款 | 对已完成订单发起退款并扣减 Sub2API 余额 |
---
@@ -262,7 +311,7 @@ https://pay.example.com/pay?user_id={USER_ID}&token={TOKEN}&theme={THEME}
用户完成支付
├─ EasyPay → 扫码 / H5 跳转
└─ Stripe → Checkout Session
└─ Stripe → Payment Element (PaymentIntent)
支付回调(签名验证)→ 订单 PAID

View File

@@ -12,4 +12,7 @@ services:
ports:
- '${APP_PORT:-3001}:3000'
env_file: .env
volumes:
# 宿主机 uploads 目录挂载到 Next.js public/uploads可通过 /uploads/* 访问
- ./uploads:/app/public/uploads:ro
restart: unless-stopped

View File

@@ -1,6 +1,6 @@
services:
app:
image: sub2apipay:latest
image: touwaeriol/sub2apipay:${IMAGE_TAG:-latest}
container_name: sub2apipay
ports:
- '8087:3000'

BIN
docs/zpay-preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

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

@@ -2,6 +2,7 @@
"name": "sub2apipay",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@10.30.3",
"scripts": {
"dev": "next dev",
"build": "next build",
@@ -16,12 +17,15 @@
"dependencies": {
"@prisma/adapter-pg": "7.4.1",
"@prisma/client": "^7.4.2",
"@stripe/stripe-js": "^8.9.0",
"next": "16.1.6",
"pg": "^8.19.0",
"qrcode": "^1.5.4",
"react": "19.2.3",
"react-dom": "19.2.3",
"recharts": "^3.7.0",
"stripe": "^20.4.0",
"wechatpay-node-v3": "^2.2.1",
"zod": "^4.3.6"
},
"pnpm": {

499
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@prisma/client':
specifier: ^7.4.2
version: 7.4.2(prisma@7.4.1(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
'@stripe/stripe-js':
specifier: ^8.9.0
version: 8.9.0
next:
specifier: 16.1.6
version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -29,9 +32,15 @@ importers:
react-dom:
specifier: 19.2.3
version: 19.2.3(react@19.2.3)
recharts:
specifier: ^3.7.0
version: 3.7.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1)
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
@@ -397,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'}
@@ -653,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'}
@@ -669,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==}
@@ -727,6 +751,17 @@ packages:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
'@reduxjs/toolkit@2.11.2':
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@rolldown/pluginutils@1.0.0-rc.3':
resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==}
@@ -874,6 +909,13 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@stripe/stripe-js@8.9.0':
resolution: {integrity: sha512-OJkXvUI5GAc56QdiSRimQDvWYEqn475J+oj8RzRtFTCPtkJNO2TWW619oDY+nn1ExR+2tCVTQuRQBbR4dRugww==}
engines: {node: '>=12.16'}
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -987,6 +1029,33 @@ packages:
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-shape@3.1.8':
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@@ -1016,6 +1085,9 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@typescript-eslint/eslint-plugin@8.56.1':
resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1273,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'}
@@ -1284,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'}
@@ -1386,6 +1464,10 @@ packages:
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -1393,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==}
@@ -1406,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'}
@@ -1413,6 +1505,50 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-format@3.1.2:
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@@ -1449,6 +1585,9 @@ packages:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -1467,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'}
@@ -1478,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==}
@@ -1548,6 +1694,9 @@ packages:
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
engines: {node: '>= 0.4'}
es-toolkit@1.45.0:
resolution: {integrity: sha512-RArCX+Zea16+R1jg4mH223Z8p/ivbJjIkU3oC6ld2bdUfmDxiCkFYSi9zLOR2anucWJUeH4Djnzgd0im0nD3dw==}
esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
@@ -1684,6 +1833,9 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@@ -1708,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==}
@@ -1751,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}
@@ -1888,6 +2050,12 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
immer@11.1.4:
resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -1900,6 +2068,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@@ -2189,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}
@@ -2301,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'}
@@ -2478,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==}
@@ -2492,6 +2688,18 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react-refresh@0.18.0:
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
engines: {node: '>=0.10.0'}
@@ -2504,6 +2712,22 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
recharts@3.7.0:
resolution: {integrity: sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -2525,6 +2749,9 @@ packages:
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -2727,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'}
@@ -2742,6 +2974,9 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -2773,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'}
@@ -2824,6 +3062,11 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
valibot@1.2.0:
resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==}
peerDependencies:
@@ -2832,6 +3075,9 @@ packages:
typescript:
optional: true
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
vite@7.3.1:
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2906,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'}
@@ -2943,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'}
@@ -3258,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
@@ -3431,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
@@ -3445,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
@@ -3532,6 +3797,18 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1))(react@19.2.3)':
dependencies:
'@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0
immer: 11.1.4
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.1.1
optionalDependencies:
react: 19.2.3
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1)
'@rolldown/pluginutils@1.0.0-rc.3': {}
'@rollup/rollup-android-arm-eabi@4.59.0':
@@ -3613,6 +3890,10 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
'@stripe/stripe-js@8.9.0': {}
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@@ -3717,6 +3998,30 @@ snapshots:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-shape@3.1.8':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {}
@@ -3747,6 +4052,8 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/use-sync-external-store@0.0.6': {}
'@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -4038,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
@@ -4153,12 +4464,20 @@ snapshots:
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
clsx@2.1.1: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
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: {}
@@ -4167,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
@@ -4175,6 +4496,44 @@ snapshots:
csstype@3.2.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-ease@3.0.1: {}
d3-format@3.1.2: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.2
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
damerau-levenshtein@1.0.8: {}
data-view-buffer@1.0.2:
@@ -4205,6 +4564,8 @@ snapshots:
decamelize@1.2.0: {}
decimal.js-light@2.5.1: {}
deep-is@0.1.4: {}
deepmerge-ts@7.1.5: {}
@@ -4223,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:
@@ -4364,6 +4732,8 @@ snapshots:
is-date-object: 1.1.0
is-symbol: 1.1.1
es-toolkit@1.45.0: {}
esbuild@0.27.3:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3
@@ -4606,6 +4976,8 @@ snapshots:
esutils@2.0.3: {}
eventemitter3@5.0.4: {}
expect-type@1.3.0: {}
exsolve@1.0.8: {}
@@ -4628,6 +5000,8 @@ snapshots:
fast-levenshtein@2.0.6: {}
fast-safe-stringify@2.1.1: {}
fastq@1.20.1:
dependencies:
reusify: 1.1.0
@@ -4670,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
@@ -4800,6 +5189,10 @@ snapshots:
ignore@7.0.5: {}
immer@10.2.0: {}
immer@11.1.4: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -4813,6 +5206,8 @@ snapshots:
hasown: 2.0.2
side-channel: 1.1.0
internmap@2.0.3: {}
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.8
@@ -5069,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
@@ -5195,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
@@ -5361,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:
@@ -5375,12 +5788,47 @@ snapshots:
react-is@16.13.1: {}
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.2.3
use-sync-external-store: 1.6.0(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
redux: 5.0.1
react-refresh@0.18.0: {}
react@19.2.3: {}
readdirp@4.1.2: {}
recharts@3.7.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1))(react@19.2.3)
clsx: 2.1.1
decimal.js-light: 2.5.1
es-toolkit: 1.45.0
eventemitter3: 5.0.4
immer: 10.2.0
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
react-is: 16.13.1
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1)
reselect: 5.1.1
tiny-invariant: 1.3.3
use-sync-external-store: 1.6.0(react@19.2.3)
victory-vendor: 37.3.6
transitivePeerDependencies:
- '@types/react'
- redux
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@5.0.1: {}
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.8
@@ -5409,6 +5857,8 @@ snapshots:
require-main-filename@2.0.0: {}
reselect@5.1.1: {}
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
@@ -5684,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
@@ -5694,6 +6159,8 @@ snapshots:
tapable@2.3.0: {}
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
tinyexec@1.0.2: {}
@@ -5722,6 +6189,8 @@ snapshots:
tslib@2.8.1: {}
tweetnacl@1.0.3: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -5815,10 +6284,31 @@ snapshots:
dependencies:
punycode: 2.3.1
use-sync-external-store@1.6.0(react@19.2.3):
dependencies:
react: 19.2.3
valibot@1.2.0(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
victory-vendor@37.3.6:
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.8
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
vite@7.3.1(@types/node@20.19.35)(jiti@2.6.1)(lightningcss@1.31.1):
dependencies:
esbuild: 0.27.3
@@ -5870,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
@@ -5930,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,3 @@
-- AlterTable
ALTER TABLE "orders" ADD COLUMN "src_host" TEXT,
ADD COLUMN "src_url" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "orders" ADD COLUMN "user_notes" TEXT;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "orders" ADD COLUMN "pay_amount" DECIMAL(10,2),
ADD COLUMN "fee_rate" DECIMAL(5,2);

View File

@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "orders_paid_at_idx" ON "orders"("paid_at");

View File

@@ -11,7 +11,10 @@ model Order {
userId Int @map("user_id")
userEmail String? @map("user_email")
userName String? @map("user_name")
userNotes String? @map("user_notes")
amount Decimal @db.Decimal(10, 2)
payAmount Decimal? @db.Decimal(10, 2) @map("pay_amount")
feeRate Decimal? @db.Decimal(5, 2) @map("fee_rate")
rechargeCode String @unique @map("recharge_code")
status OrderStatus @default(PENDING)
paymentType String @map("payment_type")
@@ -34,6 +37,8 @@ model Order {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
clientIp String? @map("client_ip")
srcHost String? @map("src_host")
srcUrl String? @map("src_url")
auditLogs AuditLog[]
@@ -41,6 +46,8 @@ model Order {
@@index([status])
@@index([expiresAt])
@@index([createdAt])
@@index([paidAt])
@@index([paymentType, paidAt])
@@map("orders")
}

View File

@@ -0,0 +1,70 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
const mockFindUnique = vi.fn();
const mockVerifyAdminToken = vi.fn();
vi.mock('@/lib/db', () => ({
prisma: {
order: {
findUnique: (...args: unknown[]) => mockFindUnique(...args),
},
},
}));
vi.mock('@/lib/config', () => ({
getEnv: () => ({
ADMIN_TOKEN: 'test-admin-token',
}),
}));
vi.mock('@/lib/admin-auth', () => ({
verifyAdminToken: (...args: unknown[]) => mockVerifyAdminToken(...args),
}));
import { GET } from '@/app/api/orders/[id]/route';
import { createOrderStatusAccessToken } from '@/lib/order/status-access';
function createRequest(orderId: string, accessToken?: string) {
const url = new URL(`https://pay.example.com/api/orders/${orderId}`);
if (accessToken) {
url.searchParams.set('access_token', accessToken);
}
return new NextRequest(url);
}
describe('GET /api/orders/[id]', () => {
beforeEach(() => {
vi.clearAllMocks();
mockVerifyAdminToken.mockResolvedValue(false);
mockFindUnique.mockResolvedValue({
id: 'order-001',
status: 'PENDING',
expiresAt: new Date('2026-03-10T00:00:00.000Z'),
paidAt: null,
completedAt: null,
});
});
it('rejects requests without access token', async () => {
const response = await GET(createRequest('order-001'), { params: Promise.resolve({ id: 'order-001' }) });
expect(response.status).toBe(401);
});
it('returns order status with valid access token', async () => {
const token = createOrderStatusAccessToken('order-001');
const response = await GET(createRequest('order-001', token), { params: Promise.resolve({ id: 'order-001' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data.id).toBe('order-001');
expect(data.paymentSuccess).toBe(false);
});
it('allows admin-authenticated access as fallback', async () => {
mockVerifyAdminToken.mockResolvedValue(true);
const response = await GET(createRequest('order-001'), { params: Promise.resolve({ id: 'order-001' }) });
expect(response.status).toBe(200);
});
});

View File

@@ -0,0 +1,245 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
import { ORDER_STATUS } from '@/lib/constants';
const mockFindUnique = vi.fn();
const mockBuildAlipayPaymentUrl = vi.fn();
vi.mock('@/lib/db', () => ({
prisma: {
order: {
findUnique: (...args: unknown[]) => mockFindUnique(...args),
},
},
}));
vi.mock('@/lib/config', () => ({
getEnv: () => ({
NEXT_PUBLIC_APP_URL: 'https://pay.example.com',
PRODUCT_NAME: 'Sub2API Balance Recharge',
ALIPAY_NOTIFY_URL: 'https://pay.example.com/api/alipay/notify',
ALIPAY_RETURN_URL: 'https://pay.example.com/pay/result',
ADMIN_TOKEN: 'test-admin-token',
}),
}));
vi.mock('@/lib/alipay/provider', () => ({
buildAlipayPaymentUrl: (...args: unknown[]) => mockBuildAlipayPaymentUrl(...args),
}));
import { GET } from '@/app/pay/[orderId]/route';
import { buildOrderResultUrl } from '@/lib/order/status-access';
function createRequest(userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)') {
return new NextRequest('https://pay.example.com/pay/order-001', {
headers: { 'user-agent': userAgent },
});
}
function createPendingOrder(overrides: Record<string, unknown> = {}) {
return {
id: 'order-001',
amount: 88,
payAmount: 100.5,
paymentType: 'alipay_direct',
status: ORDER_STATUS.PENDING,
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
paidAt: null,
completedAt: null,
...overrides,
};
}
describe('GET /pay/[orderId]', () => {
beforeEach(() => {
vi.clearAllMocks();
mockBuildAlipayPaymentUrl.mockReturnValue('https://openapi.alipay.com/gateway.do?mock=1');
});
it('returns 404 error page when order does not exist', async () => {
mockFindUnique.mockResolvedValue(null);
const response = await GET(createRequest(), {
params: Promise.resolve({ orderId: 'missing-order' }),
});
const html = await response.text();
expect(response.status).toBe(404);
expect(html).toContain('订单不存在');
expect(html).toContain('missing-order');
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
});
it('rejects non-alipay orders', async () => {
mockFindUnique.mockResolvedValue(
createPendingOrder({
paymentType: 'wxpay_direct',
}),
);
const response = await GET(createRequest(), {
params: Promise.resolve({ orderId: 'order-001' }),
});
const html = await response.text();
expect(response.status).toBe(400);
expect(html).toContain('支付方式不匹配');
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
});
it('returns success status page for completed orders', async () => {
mockFindUnique.mockResolvedValue(
createPendingOrder({
status: ORDER_STATUS.COMPLETED,
paidAt: new Date('2026-03-09T10:00:00Z'),
completedAt: new Date('2026-03-09T10:00:03Z'),
}),
);
const response = await GET(createRequest(), {
params: Promise.resolve({ orderId: 'order-001' }),
});
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain('充值成功');
expect(html).toContain('余额已到账');
expect(html).toContain('order_id=order-001');
expect(html).toContain('access_token=');
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
});
it('returns paid-but-recharge-failed status page for failed paid orders', async () => {
mockFindUnique.mockResolvedValue(
createPendingOrder({
status: ORDER_STATUS.FAILED,
paidAt: new Date('2026-03-09T10:00:00Z'),
}),
);
const response = await GET(createRequest(), {
params: Promise.resolve({ orderId: 'order-001' }),
});
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain('支付成功');
expect(html).toContain('余额充值暂未完成');
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
});
it('returns expired status page when order is timed out', async () => {
mockFindUnique.mockResolvedValue(
createPendingOrder({
expiresAt: new Date(Date.now() - 1000),
}),
);
const response = await GET(createRequest(), {
params: Promise.resolve({ orderId: 'order-001' }),
});
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain('订单超时');
expect(html).toContain('订单已超时');
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
});
it('builds desktop redirect page with service-generated alipay url and no manual pay button', async () => {
mockBuildAlipayPaymentUrl.mockReturnValue('https://openapi.alipay.com/gateway.do?desktop=1');
mockFindUnique.mockResolvedValue(createPendingOrder());
const response = await GET(createRequest(), {
params: Promise.resolve({ orderId: 'order-001' }),
});
const html = await response.text();
const expectedReturnUrl = buildOrderResultUrl('https://pay.example.com', 'order-001');
expect(response.status).toBe(200);
expect(html).toContain('正在拉起支付宝');
expect(html).toContain('https://openapi.alipay.com/gateway.do?desktop=1');
expect(html).toContain('http-equiv="refresh"');
expect(html).not.toContain('立即前往支付宝');
expect(html).toContain('查看订单结果');
expect(html).toContain('order_id=order-001');
expect(html).toContain('access_token=');
expect(mockBuildAlipayPaymentUrl).toHaveBeenCalledWith({
orderId: 'order-001',
amount: 100.5,
subject: 'Sub2API Balance Recharge 100.50 CNY',
notifyUrl: 'https://pay.example.com/api/alipay/notify',
returnUrl: expectedReturnUrl,
isMobile: false,
});
});
it('builds mobile redirect page with wap alipay url', async () => {
mockBuildAlipayPaymentUrl.mockReturnValue('https://openapi.alipay.com/gateway.do?mobile=1');
mockFindUnique.mockResolvedValue(
createPendingOrder({
payAmount: null,
amount: 88,
}),
);
const response = await GET(
createRequest('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148'),
{
params: Promise.resolve({ orderId: 'order-001' }),
},
);
const html = await response.text();
const expectedReturnUrl = buildOrderResultUrl('https://pay.example.com', 'order-001');
expect(response.status).toBe(200);
expect(html).toContain('正在拉起支付宝');
expect(html).toContain('https://openapi.alipay.com/gateway.do?mobile=1');
expect(html).not.toContain('立即前往支付宝');
expect(mockBuildAlipayPaymentUrl).toHaveBeenCalledWith({
orderId: 'order-001',
amount: 88,
subject: 'Sub2API Balance Recharge 88.00 CNY',
notifyUrl: 'https://pay.example.com/api/alipay/notify',
returnUrl: expectedReturnUrl,
isMobile: true,
});
});
it('omits returnUrl for Alipay app requests to avoid extra close step', async () => {
mockBuildAlipayPaymentUrl.mockReturnValue('https://openapi.alipay.com/gateway.do?alipayapp=1');
mockFindUnique.mockResolvedValue(createPendingOrder({ payAmount: 66 }));
const response = await GET(
createRequest(
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 AlipayClient/10.5.90',
),
{
params: Promise.resolve({ orderId: 'order-001' }),
},
);
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain('https://openapi.alipay.com/gateway.do?alipayapp=1');
expect(html).toContain('window.location.replace(payUrl)');
expect(html).toContain('<noscript><meta http-equiv="refresh"');
expect(html).not.toContain('立即前往支付宝');
expect(mockBuildAlipayPaymentUrl).toHaveBeenCalledWith({
orderId: 'order-001',
amount: 66,
subject: 'Sub2API Balance Recharge 66.00 CNY',
notifyUrl: 'https://pay.example.com/api/alipay/notify',
returnUrl: null,
isMobile: true,
});
});
});

View File

@@ -0,0 +1,97 @@
import { beforeEach, describe, expect, it, vi } 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',
}),
}));
const { mockGenerateSign } = vi.hoisted(() => ({
mockGenerateSign: vi.fn(() => 'signed-value'),
}));
vi.mock('@/lib/alipay/sign', () => ({
generateSign: mockGenerateSign,
}));
import { execute, pageExecute } from '@/lib/alipay/client';
describe('alipay client helpers', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('pageExecute includes notify_url and return_url by default', () => {
const url = new URL(
pageExecute({ out_trade_no: 'order-001', product_code: 'FAST_INSTANT_TRADE_PAY', total_amount: '10.00' }),
);
expect(url.origin + url.pathname).toBe('https://openapi.alipay.com/gateway.do');
expect(url.searchParams.get('notify_url')).toBe('https://pay.example.com/api/alipay/notify');
expect(url.searchParams.get('return_url')).toBe('https://pay.example.com/pay/result');
expect(url.searchParams.get('method')).toBe('alipay.trade.page.pay');
expect(url.searchParams.get('sign')).toBe('signed-value');
});
it('pageExecute omits return_url when explicitly disabled', () => {
const url = new URL(
pageExecute(
{ out_trade_no: 'order-002', product_code: 'QUICK_WAP_WAY', total_amount: '20.00' },
{ returnUrl: null, method: 'alipay.trade.wap.pay' },
),
);
expect(url.searchParams.get('method')).toBe('alipay.trade.wap.pay');
expect(url.searchParams.get('return_url')).toBeNull();
expect(url.searchParams.get('notify_url')).toBe('https://pay.example.com/api/alipay/notify');
});
it('execute posts form data and returns the named response payload', async () => {
global.fetch = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
alipay_trade_query_response: {
code: '10000',
msg: 'Success',
trade_status: 'TRADE_SUCCESS',
},
sign: 'server-sign',
}),
{ headers: { 'content-type': 'application/json; charset=utf-8' } },
),
) as typeof fetch;
const result = await execute('alipay.trade.query', { out_trade_no: 'order-003' });
expect(result).toEqual({ code: '10000', msg: 'Success', trade_status: 'TRADE_SUCCESS' });
expect(global.fetch).toHaveBeenCalledTimes(1);
const [url, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe('https://openapi.alipay.com/gateway.do');
expect(init.method).toBe('POST');
expect(init.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded' });
expect(String(init.body)).toContain('method=alipay.trade.query');
});
it('execute throws when alipay response code is not successful', async () => {
global.fetch = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
alipay_trade_query_response: {
code: '40004',
msg: 'Business Failed',
sub_code: 'ACQ.TRADE_NOT_EXIST',
sub_msg: 'trade not exist',
},
}),
{ headers: { 'content-type': 'application/json; charset=utf-8' } },
),
) as typeof fetch;
await expect(execute('alipay.trade.query', { out_trade_no: 'order-004' })).rejects.toThrow(
'[ACQ.TRADE_NOT_EXIST] trade not exist',
);
});
});

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { decodeAlipayPayload, parseAlipayNotificationParams } from '@/lib/alipay/codec';
describe('Alipay codec', () => {
it('should normalize plus signs in notify sign parameter', () => {
const params = parseAlipayNotificationParams(Buffer.from('sign=abc+def&trade_no=1'), {
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
});
expect(params.sign).toBe('abc+def');
expect(params.trade_no).toBe('1');
});
it('should decode payload charset from content-type header', () => {
const body = Buffer.from('charset=utf-8&trade_status=TRADE_SUCCESS', 'utf-8');
const decoded = decodeAlipayPayload(body, {
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
});
expect(decoded).toContain('trade_status=TRADE_SUCCESS');
});
it('should fallback to body charset hint when header is missing', () => {
const body = Buffer.from('charset=gbk&trade_no=202603090001', 'utf-8');
const decoded = decodeAlipayPayload(body);
expect(decoded).toContain('trade_no=202603090001');
});
});

View File

@@ -0,0 +1,329 @@
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',
PRODUCT_NAME: 'Sub2API Balance Recharge',
}),
}));
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, buildAlipayEntryUrl } 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 return service short link as desktop qrCode', async () => {
const request: CreatePaymentRequest = {
orderId: 'order-001',
amount: 100,
paymentType: 'alipay_direct',
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.qrCode).toBe('https://pay.example.com/pay/order-001');
expect(result.payUrl).toBe('https://pay.example.com/pay/order-001');
expect(mockExecute).not.toHaveBeenCalled();
expect(mockPageExecute).not.toHaveBeenCalled();
});
it('should build short link from app url', () => {
expect(buildAlipayEntryUrl('order-short-link')).toBe('https://pay.example.com/pay/order-short-link');
});
it('should call pageExecute for mobile and return payUrl', async () => {
mockPageExecute.mockReturnValue('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
const request: CreatePaymentRequest = {
orderId: 'order-002',
amount: 50,
paymentType: 'alipay_direct',
subject: 'Sub2API Balance Recharge 50.00 CNY',
clientIp: '127.0.0.1',
isMobile: true,
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('order-002');
expect(result.payUrl).toBe('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
expect(mockPageExecute).toHaveBeenCalledWith(
{
out_trade_no: 'order-002',
product_code: 'QUICK_WAP_WAY',
total_amount: '50.00',
subject: 'Sub2API Balance Recharge 50.00 CNY',
},
expect.objectContaining({ method: 'alipay.trade.wap.pay' }),
);
expect(mockExecute).not.toHaveBeenCalled();
});
});
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');
});
it('should treat ACQ.TRADE_NOT_EXIST as pending', async () => {
mockExecute.mockRejectedValue(new Error('Alipay API error: [ACQ.TRADE_NOT_EXIST] 交易不存在'));
const result = await provider.queryOrder('order-005');
expect(result.tradeNo).toBe('order-005');
expect(result.status).toBe('pending');
expect(result.amount).toBe(0);
});
});
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',
app_id: '2021000000000000',
}).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: '20.00',
sign: 'test_sign',
sign_type: 'RSA2',
app_id: '2021000000000000',
}).toString();
const result = await provider.verifyNotification(body, {});
expect(result.status).toBe('failed');
});
it('should reject unsupported sign_type', async () => {
const body = new URLSearchParams({
trade_no: '2026030500004',
out_trade_no: 'order-004',
trade_status: 'TRADE_SUCCESS',
total_amount: '20.00',
sign: 'test_sign',
sign_type: 'RSA',
app_id: '2021000000000000',
}).toString();
await expect(provider.verifyNotification(body, {})).rejects.toThrow('Unsupported sign_type');
});
it('should reject invalid signature', async () => {
mockVerifySign.mockReturnValue(false);
const body = new URLSearchParams({
trade_no: '2026030500005',
out_trade_no: 'order-005',
trade_status: 'TRADE_SUCCESS',
total_amount: '20.00',
sign: 'bad_sign',
sign_type: 'RSA2',
app_id: '2021000000000000',
}).toString();
await expect(provider.verifyNotification(body, {})).rejects.toThrow(
'Alipay notification signature verification failed',
);
});
it('should reject app_id mismatch', async () => {
mockVerifySign.mockReturnValue(true);
const body = new URLSearchParams({
trade_no: '2026030500006',
out_trade_no: 'order-006',
trade_status: 'TRADE_SUCCESS',
total_amount: '20.00',
sign: 'test_sign',
sign_type: 'RSA2',
app_id: '2021000000009999',
}).toString();
await expect(provider.verifyNotification(body, {})).rejects.toThrow('Alipay notification app_id mismatch');
});
});
describe('refund', () => {
it('should request refund and map success status', async () => {
mockExecute.mockResolvedValue({
code: '10000',
msg: 'Success',
trade_no: 'refund-trade-no',
fund_change: 'Y',
});
const request: RefundRequest = {
tradeNo: 'trade-no',
orderId: 'order-refund',
amount: 12.34,
reason: 'test refund',
};
const result = await provider.refund(request);
expect(result).toEqual({ refundId: 'refund-trade-no', status: 'success' });
expect(mockExecute).toHaveBeenCalledWith('alipay.trade.refund', {
out_trade_no: 'order-refund',
refund_amount: '12.34',
refund_reason: 'test refund',
out_request_no: 'order-refund-refund',
});
});
});
describe('cancelPayment', () => {
it('should close payment by out_trade_no', async () => {
mockExecute.mockResolvedValue({ code: '10000', msg: 'Success' });
await provider.cancelPayment('order-close');
expect(mockExecute).toHaveBeenCalledWith('alipay.trade.close', {
out_trade_no: 'order-close',
});
});
it('should ignore ACQ.TRADE_NOT_EXIST when closing payment', async () => {
mockExecute.mockRejectedValue(new Error('Alipay API error: [ACQ.TRADE_NOT_EXIST] 交易不存在'));
await expect(provider.cancelPayment('order-close-missing')).resolves.toBeUndefined();
});
});
});

View File

@@ -0,0 +1,145 @@
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');
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 in request signing', () => {
const paramsWithSign = { ...testParams, sign: 'old_sign' };
const sign1 = generateSign(testParams, privateKey);
const sign2 = generateSign(paramsWithSign, privateKey);
expect(sign1).toBe(sign2);
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);
});
it('should work with private key using literal \\n escapes', () => {
const escapedPrivateKey = privateKey.replace(/\n/g, '\\n');
const sign = generateSign(testParams, escapedPrivateKey);
const valid = verifySign(testParams, publicKey, sign);
expect(valid).toBe(true);
});
it('should work with public key using literal \\n escapes', () => {
const escapedPublicKey = publicKey.replace(/\n/g, '\\n');
const sign = generateSign(testParams, privateKey);
const valid = verifySign(testParams, escapedPublicKey, sign);
expect(valid).toBe(true);
});
it('should work with CRLF-formatted PEM keys', () => {
const crlfPrivateKey = privateKey.replace(/\n/g, '\r\n');
const crlfPublicKey = publicKey.replace(/\n/g, '\r\n');
const sign = generateSign(testParams, crlfPrivateKey);
const valid = verifySign(testParams, crlfPublicKey, sign);
expect(valid).toBe(true);
});
it('should work with literal \\r\\n escapes in PEM keys', () => {
const escapedCrlfPrivateKey = privateKey.replace(/\n/g, '\\r\\n');
const escapedCrlfPublicKey = publicKey.replace(/\n/g, '\\r\\n');
const sign = generateSign(testParams, escapedCrlfPrivateKey);
const valid = verifySign(testParams, escapedCrlfPublicKey, sign);
expect(valid).toBe(true);
});
});
});

View File

@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/config', () => ({
getEnv: () => ({
ADMIN_TOKEN: 'test-admin-token',
}),
}));
import {
ORDER_STATUS_ACCESS_QUERY_KEY,
buildOrderResultUrl,
createOrderStatusAccessToken,
verifyOrderStatusAccessToken,
} from '@/lib/order/status-access';
describe('order status access token helpers', () => {
it('creates and verifies a token bound to the order id', () => {
const token = createOrderStatusAccessToken('order-001');
expect(token).toBeTruthy();
expect(verifyOrderStatusAccessToken('order-001', token)).toBe(true);
expect(verifyOrderStatusAccessToken('order-002', token)).toBe(false);
});
it('rejects missing or malformed tokens', () => {
expect(verifyOrderStatusAccessToken('order-001', null)).toBe(false);
expect(verifyOrderStatusAccessToken('order-001', undefined)).toBe(false);
expect(verifyOrderStatusAccessToken('order-001', 'short')).toBe(false);
});
it('builds a result url with order id and access token', () => {
const url = new URL(buildOrderResultUrl('https://pay.example.com', 'order-009'));
expect(url.origin + url.pathname).toBe('https://pay.example.com/pay/result');
expect(url.searchParams.get('order_id')).toBe('order-009');
const token = url.searchParams.get(ORDER_STATUS_ACCESS_QUERY_KEY);
expect(token).toBeTruthy();
expect(verifyOrderStatusAccessToken('order-009', token)).toBe(true);
});
});

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { ORDER_STATUS } from '@/lib/constants';
import { deriveOrderState, getOrderDisplayState } from '@/lib/order/status';
describe('order status helpers', () => {
it('derives paid_pending after successful payment but before recharge completion', () => {
const state = deriveOrderState({
status: ORDER_STATUS.PAID,
paidAt: new Date('2026-03-09T10:00:00Z'),
completedAt: null,
});
expect(state).toEqual({
paymentSuccess: true,
rechargeSuccess: false,
rechargeStatus: 'paid_pending',
});
});
it('maps recharge failure after payment to a payment-success display state', () => {
const display = getOrderDisplayState({
status: ORDER_STATUS.FAILED,
paymentSuccess: true,
rechargeSuccess: false,
rechargeStatus: 'failed',
});
expect(display.label).toBe('支付成功');
expect(display.message).toContain('自动重试');
});
it('maps failed order before payment success to failed display', () => {
const display = getOrderDisplayState({
status: ORDER_STATUS.FAILED,
paymentSuccess: false,
rechargeSuccess: false,
rechargeStatus: 'failed',
});
expect(display.label).toBe('支付失败');
expect(display.message).toContain('重新发起支付');
});
it('maps completed order to success display', () => {
const display = getOrderDisplayState({
status: ORDER_STATUS.COMPLETED,
paymentSuccess: true,
rechargeSuccess: true,
rechargeStatus: 'success',
});
expect(display.label).toBe('充值成功');
expect(display.icon).toBe('✓');
});
it('maps pending order to waiting-for-payment display', () => {
const display = getOrderDisplayState({
status: ORDER_STATUS.PENDING,
paymentSuccess: false,
rechargeSuccess: false,
rechargeStatus: 'not_paid',
});
expect(display.label).toBe('等待支付');
});
});

View File

@@ -12,10 +12,12 @@ import type {
class MockProvider implements PaymentProvider {
readonly name: string;
readonly providerKey: string;
readonly supportedTypes: PaymentType[];
constructor(name: string, types: PaymentType[]) {
this.name = name;
this.providerKey = name;
this.supportedTypes = types;
}

View File

@@ -9,18 +9,18 @@ vi.mock('@/lib/config', () => ({
}),
}));
const mockSessionCreate = vi.fn();
const mockSessionRetrieve = vi.fn();
const mockPaymentIntentCreate = vi.fn();
const mockPaymentIntentRetrieve = vi.fn();
const mockPaymentIntentCancel = vi.fn();
const mockRefundCreate = vi.fn();
const mockWebhooksConstructEvent = vi.fn();
vi.mock('stripe', () => {
const StripeMock = function (this: Record<string, unknown>) {
this.checkout = {
sessions: {
create: mockSessionCreate,
retrieve: mockSessionRetrieve,
},
this.paymentIntents = {
create: mockPaymentIntentCreate,
retrieve: mockPaymentIntentRetrieve,
cancel: mockPaymentIntentCancel,
};
this.refunds = {
create: mockRefundCreate,
@@ -54,10 +54,10 @@ describe('StripeProvider', () => {
});
describe('createPayment', () => {
it('should create a checkout session and return checkoutUrl', async () => {
mockSessionCreate.mockResolvedValue({
id: 'cs_test_abc123',
url: 'https://checkout.stripe.com/pay/cs_test_abc123',
it('should create a PaymentIntent and return clientSecret', async () => {
mockPaymentIntentCreate.mockResolvedValue({
id: 'pi_test_abc123',
client_secret: 'pi_test_abc123_secret_xyz',
});
const request: CreatePaymentRequest = {
@@ -70,34 +70,26 @@ describe('StripeProvider', () => {
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('cs_test_abc123');
expect(result.checkoutUrl).toBe('https://checkout.stripe.com/pay/cs_test_abc123');
expect(mockSessionCreate).toHaveBeenCalledWith(
expect(result.tradeNo).toBe('pi_test_abc123');
expect(result.clientSecret).toBe('pi_test_abc123_secret_xyz');
expect(mockPaymentIntentCreate).toHaveBeenCalledWith(
expect.objectContaining({
mode: 'payment',
payment_method_types: ['card'],
amount: 9999,
currency: 'cny',
automatic_payment_methods: { enabled: true },
metadata: { orderId: 'order-001' },
expires_at: expect.any(Number),
line_items: [
expect.objectContaining({
price_data: expect.objectContaining({
currency: 'cny',
unit_amount: 9999,
}),
quantity: 1,
}),
],
description: 'Sub2API Balance Recharge 99.99 CNY',
}),
expect.objectContaining({
idempotencyKey: 'checkout-order-001',
idempotencyKey: 'pi-order-001',
}),
);
});
it('should handle session with null url', async () => {
mockSessionCreate.mockResolvedValue({
id: 'cs_test_no_url',
url: null,
it('should handle null client_secret', async () => {
mockPaymentIntentCreate.mockResolvedValue({
id: 'pi_test_no_secret',
client_secret: null,
});
const request: CreatePaymentRequest = {
@@ -108,61 +100,58 @@ describe('StripeProvider', () => {
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('cs_test_no_url');
expect(result.checkoutUrl).toBeUndefined();
expect(result.tradeNo).toBe('pi_test_no_secret');
expect(result.clientSecret).toBeUndefined();
});
});
describe('queryOrder', () => {
it('should return paid status for paid session', async () => {
mockSessionRetrieve.mockResolvedValue({
id: 'cs_test_abc123',
payment_status: 'paid',
amount_total: 9999,
it('should return paid status for succeeded PaymentIntent', async () => {
mockPaymentIntentRetrieve.mockResolvedValue({
id: 'pi_test_abc123',
status: 'succeeded',
amount: 9999,
});
const result = await provider.queryOrder('cs_test_abc123');
expect(result.tradeNo).toBe('cs_test_abc123');
const result = await provider.queryOrder('pi_test_abc123');
expect(result.tradeNo).toBe('pi_test_abc123');
expect(result.status).toBe('paid');
expect(result.amount).toBe(99.99);
});
it('should return failed status for expired session', async () => {
mockSessionRetrieve.mockResolvedValue({
id: 'cs_test_expired',
payment_status: 'unpaid',
status: 'expired',
amount_total: 5000,
it('should return failed status for canceled PaymentIntent', async () => {
mockPaymentIntentRetrieve.mockResolvedValue({
id: 'pi_test_canceled',
status: 'canceled',
amount: 5000,
});
const result = await provider.queryOrder('cs_test_expired');
const result = await provider.queryOrder('pi_test_canceled');
expect(result.status).toBe('failed');
expect(result.amount).toBe(50);
});
it('should return pending status for unpaid session', async () => {
mockSessionRetrieve.mockResolvedValue({
id: 'cs_test_pending',
payment_status: 'unpaid',
status: 'open',
amount_total: 1000,
it('should return pending status for requires_payment_method', async () => {
mockPaymentIntentRetrieve.mockResolvedValue({
id: 'pi_test_pending',
status: 'requires_payment_method',
amount: 1000,
});
const result = await provider.queryOrder('cs_test_pending');
const result = await provider.queryOrder('pi_test_pending');
expect(result.status).toBe('pending');
});
});
describe('verifyNotification', () => {
it('should verify and parse checkout.session.completed event', async () => {
it('should verify and parse payment_intent.succeeded event', async () => {
const mockEvent = {
type: 'checkout.session.completed',
type: 'payment_intent.succeeded',
data: {
object: {
id: 'cs_test_abc123',
id: 'pi_test_abc123',
metadata: { orderId: 'order-001' },
amount_total: 9999,
payment_status: 'paid',
amount: 9999,
},
},
};
@@ -172,21 +161,20 @@ describe('StripeProvider', () => {
const result = await provider.verifyNotification('{"raw":"body"}', { 'stripe-signature': 'sig_test_123' });
expect(result).not.toBeNull();
expect(result!.tradeNo).toBe('cs_test_abc123');
expect(result!.tradeNo).toBe('pi_test_abc123');
expect(result!.orderId).toBe('order-001');
expect(result!.amount).toBe(99.99);
expect(result!.status).toBe('success');
});
it('should return failed status for unpaid session', async () => {
it('should return failed status for payment_intent.payment_failed', async () => {
const mockEvent = {
type: 'checkout.session.completed',
type: 'payment_intent.payment_failed',
data: {
object: {
id: 'cs_test_unpaid',
id: 'pi_test_failed',
metadata: { orderId: 'order-002' },
amount_total: 5000,
payment_status: 'unpaid',
amount: 5000,
},
},
};
@@ -210,19 +198,14 @@ describe('StripeProvider', () => {
});
describe('refund', () => {
it('should refund via payment intent from session', async () => {
mockSessionRetrieve.mockResolvedValue({
id: 'cs_test_abc123',
payment_intent: 'pi_test_payment_intent',
});
it('should refund directly using PaymentIntent ID', async () => {
mockRefundCreate.mockResolvedValue({
id: 're_test_refund_001',
status: 'succeeded',
});
const request: RefundRequest = {
tradeNo: 'cs_test_abc123',
tradeNo: 'pi_test_abc123',
orderId: 'order-001',
amount: 50,
reason: 'customer request',
@@ -232,50 +215,34 @@ describe('StripeProvider', () => {
expect(result.refundId).toBe('re_test_refund_001');
expect(result.status).toBe('success');
expect(mockRefundCreate).toHaveBeenCalledWith({
payment_intent: 'pi_test_payment_intent',
payment_intent: 'pi_test_abc123',
amount: 5000,
reason: 'requested_by_customer',
});
});
it('should handle payment intent as object', async () => {
mockSessionRetrieve.mockResolvedValue({
id: 'cs_test_abc123',
payment_intent: { id: 'pi_test_obj_intent', amount: 10000 },
});
it('should handle pending refund status', async () => {
mockRefundCreate.mockResolvedValue({
id: 're_test_refund_002',
status: 'pending',
});
const result = await provider.refund({
tradeNo: 'cs_test_abc123',
tradeNo: 'pi_test_abc123',
orderId: 'order-002',
amount: 100,
});
expect(result.status).toBe('pending');
expect(mockRefundCreate).toHaveBeenCalledWith(
expect.objectContaining({
payment_intent: 'pi_test_obj_intent',
}),
);
});
});
it('should throw if no payment intent found', async () => {
mockSessionRetrieve.mockResolvedValue({
id: 'cs_test_no_pi',
payment_intent: null,
});
describe('cancelPayment', () => {
it('should cancel a PaymentIntent', async () => {
mockPaymentIntentCancel.mockResolvedValue({ id: 'pi_test_abc123', status: 'canceled' });
await expect(
provider.refund({
tradeNo: 'cs_test_no_pi',
orderId: 'order-003',
amount: 20,
}),
).rejects.toThrow('No payment intent found');
await provider.cancelPayment('pi_test_abc123');
expect(mockPaymentIntentCancel).toHaveBeenCalledWith('pi_test_abc123');
});
});
});

View File

@@ -26,7 +26,7 @@ describe('Sub2API Client', () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: mockUser }),
});
}) as typeof fetch;
const user = await getUser(1);
expect(user.username).toBe('testuser');
@@ -37,7 +37,7 @@ describe('Sub2API Client', () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 404,
});
}) as typeof fetch;
await expect(getUser(999)).rejects.toThrow('USER_NOT_FOUND');
});
@@ -57,24 +57,50 @@ describe('Sub2API Client', () => {
used_by: 1,
},
}),
});
}) as typeof fetch;
const result = await createAndRedeem('s2p_test123', 100, 1, 'test notes');
expect(result.code).toBe('s2p_test123');
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(fetchCall[0]).toContain('/redeem-codes/create-and-redeem');
const headers = fetchCall[1].headers;
const headers = fetchCall[1].headers as Record<string, string>;
expect(headers['Idempotency-Key']).toBe('sub2apipay:recharge:s2p_test123');
});
it('createAndRedeem should retry once on timeout', async () => {
const timeoutError = Object.assign(new Error('timed out'), { name: 'TimeoutError' });
global.fetch = vi
.fn()
.mockRejectedValueOnce(timeoutError)
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
redeem_code: {
id: 2,
code: 's2p_retry',
type: 'balance',
value: 88,
status: 'used',
used_by: 1,
},
}),
}) as typeof fetch;
const result = await createAndRedeem('s2p_retry', 88, 1, 'retry notes');
expect(result.code).toBe('s2p_retry');
expect((fetch as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(2);
});
it('subtractBalance should send subtract request', async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) }) as typeof fetch;
await subtractBalance(1, 50, 'refund', 'idempotency-key-1');
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(fetchCall[1].body);
const body = JSON.parse(fetchCall[1].body as string);
expect(body.operation).toBe('subtract');
expect(body.amount).toBe(50);
});

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { getBizDayStartUTC, getNextBizDayStartUTC, toBizDateStr } from '@/lib/time/biz-day';
describe('biz-day helpers', () => {
it('formats business date in Asia/Shanghai timezone', () => {
expect(toBizDateStr(new Date('2026-03-09T15:59:59.000Z'))).toBe('2026-03-09');
expect(toBizDateStr(new Date('2026-03-09T16:00:00.000Z'))).toBe('2026-03-10');
});
it('returns business day start in UTC', () => {
expect(getBizDayStartUTC(new Date('2026-03-09T15:59:59.000Z')).toISOString()).toBe('2026-03-08T16:00:00.000Z');
expect(getBizDayStartUTC(new Date('2026-03-09T16:00:00.000Z')).toISOString()).toBe('2026-03-09T16:00:00.000Z');
});
it('returns next business day start in UTC', () => {
expect(getNextBizDayStartUTC(new Date('2026-03-09T12:00:00.000Z')).toISOString()).toBe('2026-03-09T16:00:00.000Z');
});
});

View File

@@ -0,0 +1,717 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ============================================================
// Mock: EasyPay
// ============================================================
const mockEasyPayCreatePayment = vi.fn();
vi.mock('@/lib/easy-pay/client', () => ({
createPayment: (...args: unknown[]) => mockEasyPayCreatePayment(...args),
queryOrder: vi.fn(),
refund: vi.fn(),
}));
vi.mock('@/lib/easy-pay/sign', () => ({
verifySign: vi.fn(),
generateSign: vi.fn(),
}));
// ============================================================
// Mock: Alipay
// ============================================================
const mockAlipayPageExecute = vi.fn();
vi.mock('@/lib/alipay/client', () => ({
pageExecute: (...args: unknown[]) => mockAlipayPageExecute(...args),
execute: vi.fn(),
}));
vi.mock('@/lib/alipay/sign', () => ({
verifySign: vi.fn(),
generateSign: vi.fn(),
}));
// ============================================================
// Mock: Wxpay
// ============================================================
const mockWxpayCreatePcOrder = vi.fn();
const mockWxpayCreateH5Order = vi.fn();
vi.mock('@/lib/wxpay/client', () => ({
createPcOrder: (...args: unknown[]) => mockWxpayCreatePcOrder(...args),
createH5Order: (...args: unknown[]) => mockWxpayCreateH5Order(...args),
queryOrder: vi.fn(),
closeOrder: vi.fn(),
createRefund: vi.fn(),
decipherNotify: vi.fn(),
verifyNotifySign: vi.fn(),
}));
// ============================================================
// Mock: Config (shared by all providers)
// ============================================================
vi.mock('@/lib/config', () => ({
getEnv: () => ({
// EasyPay
EASY_PAY_PID: 'test-pid',
EASY_PAY_PKEY: 'test-pkey',
EASY_PAY_API_BASE: 'https://easypay.example.com',
EASY_PAY_NOTIFY_URL: 'https://pay.example.com/api/easypay/notify',
EASY_PAY_RETURN_URL: 'https://pay.example.com/pay/result',
// Alipay
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',
// Wxpay
WXPAY_APP_ID: 'wx-test-app-id',
WXPAY_MCH_ID: 'wx-test-mch-id',
WXPAY_PRIVATE_KEY: 'test-private-key',
WXPAY_API_V3_KEY: 'test-api-v3-key',
WXPAY_PUBLIC_KEY: 'test-public-key',
WXPAY_PUBLIC_KEY_ID: 'test-public-key-id',
WXPAY_CERT_SERIAL: 'test-cert-serial',
WXPAY_NOTIFY_URL: 'https://pay.example.com/api/wxpay/notify',
// General
NEXT_PUBLIC_APP_URL: 'https://pay.example.com',
}),
}));
// ============================================================
// Imports (must come after mocks)
// ============================================================
import { EasyPayProvider } from '@/lib/easy-pay/provider';
import { AlipayProvider } from '@/lib/alipay/provider';
import { WxpayProvider } from '@/lib/wxpay/provider';
import { isStripeType } from '@/lib/pay-utils';
import { REDIRECT_PAYMENT_TYPES } from '@/lib/constants';
import type { CreatePaymentRequest } from '@/lib/payment/types';
// ============================================================
// Helper: simulate shouldAutoRedirect logic from PaymentQRCode
// ============================================================
function shouldAutoRedirect(opts: {
expired: boolean;
paymentType?: string;
payUrl?: string | null;
qrCode?: string | null;
isMobile: boolean;
}): boolean {
return !opts.expired && !isStripeType(opts.paymentType) && !!opts.payUrl && (opts.isMobile || !opts.qrCode);
}
// ============================================================
// Tests
// ============================================================
describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ----------------------------------------------------------
// EasyPay Provider
// ----------------------------------------------------------
describe('EasyPayProvider', () => {
let provider: EasyPayProvider;
beforeEach(() => {
provider = new EasyPayProvider();
});
it('PC: createPayment returns both payUrl and qrCode', async () => {
mockEasyPayCreatePayment.mockResolvedValue({
code: 1,
trade_no: 'EP-001',
payurl: 'https://easypay.example.com/pay/EP-001',
qrcode: 'https://qr.alipay.com/fkx12345',
});
const request: CreatePaymentRequest = {
orderId: 'order-ep-001',
amount: 50,
paymentType: 'alipay',
subject: 'Test Recharge',
clientIp: '1.2.3.4',
isMobile: false,
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('EP-001');
expect(result.qrCode).toBe('https://qr.alipay.com/fkx12345');
expect(result.payUrl).toBe('https://easypay.example.com/pay/EP-001');
// PC + has qrCode + has payUrl => shouldAutoRedirect = false (show QR)
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay',
payUrl: result.payUrl,
qrCode: result.qrCode,
isMobile: false,
}),
).toBe(false);
});
it('Mobile: createPayment returns payUrl for redirect', async () => {
mockEasyPayCreatePayment.mockResolvedValue({
code: 1,
trade_no: 'EP-002',
payurl: 'https://easypay.example.com/pay/EP-002',
qrcode: 'https://qr.alipay.com/fkx67890',
});
const request: CreatePaymentRequest = {
orderId: 'order-ep-002',
amount: 100,
paymentType: 'wxpay',
subject: 'Test Recharge',
clientIp: '1.2.3.4',
isMobile: true,
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('EP-002');
expect(result.payUrl).toBeDefined();
// Mobile + has payUrl => shouldAutoRedirect = true (redirect)
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'wxpay',
payUrl: result.payUrl,
qrCode: result.qrCode,
isMobile: true,
}),
).toBe(true);
});
it('EasyPay does not use isMobile flag itself (delegates to frontend)', async () => {
mockEasyPayCreatePayment.mockResolvedValue({
code: 1,
trade_no: 'EP-003',
payurl: 'https://easypay.example.com/pay/EP-003',
qrcode: 'weixin://wxpay/bizpayurl?pr=xxx',
});
const request: CreatePaymentRequest = {
orderId: 'order-ep-003',
amount: 10,
paymentType: 'alipay',
subject: 'Test',
clientIp: '1.2.3.4',
isMobile: true,
};
await provider.createPayment(request);
// EasyPay client is called the same way regardless of isMobile
expect(mockEasyPayCreatePayment).toHaveBeenCalledWith(
expect.objectContaining({
outTradeNo: 'order-ep-003',
paymentType: 'alipay',
}),
);
// No isMobile parameter forwarded to the underlying client
const callArgs = mockEasyPayCreatePayment.mock.calls[0][0];
expect(callArgs).not.toHaveProperty('isMobile');
});
});
// ----------------------------------------------------------
// Alipay Provider
// ----------------------------------------------------------
describe('AlipayProvider', () => {
let provider: AlipayProvider;
beforeEach(() => {
provider = new AlipayProvider();
});
it('PC: returns service short-link payUrl and qrCode', async () => {
const request: CreatePaymentRequest = {
orderId: 'order-ali-001',
amount: 100,
paymentType: 'alipay_direct',
subject: 'Test Recharge',
isMobile: false,
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('order-ali-001');
expect(result.payUrl).toBe('https://pay.example.com/pay/order-ali-001');
expect(result.qrCode).toBe('https://pay.example.com/pay/order-ali-001');
expect(mockAlipayPageExecute).not.toHaveBeenCalled();
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay_direct',
payUrl: result.payUrl,
qrCode: result.qrCode,
isMobile: false,
}),
).toBe(false);
});
it('Mobile: uses alipay.trade.wap.pay, returns payUrl', async () => {
mockAlipayPageExecute.mockReturnValue(
'https://openapi.alipay.com/gateway.do?method=alipay.trade.wap.pay&sign=yyy',
);
const request: CreatePaymentRequest = {
orderId: 'order-ali-002',
amount: 50,
paymentType: 'alipay_direct',
subject: 'Test Recharge',
isMobile: true,
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('order-ali-002');
expect(result.payUrl).toContain('alipay.trade.wap.pay');
expect(result.qrCode).toBeUndefined();
// Verify pageExecute was called with H5 method
expect(mockAlipayPageExecute).toHaveBeenCalledWith(
expect.objectContaining({
product_code: 'QUICK_WAP_WAY',
}),
expect.objectContaining({
method: 'alipay.trade.wap.pay',
}),
);
// Mobile + payUrl => shouldAutoRedirect = true
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay_direct',
payUrl: result.payUrl,
qrCode: result.qrCode,
isMobile: true,
}),
).toBe(true);
});
it('Mobile: surfaces wap.pay creation errors', async () => {
mockAlipayPageExecute.mockImplementationOnce(() => {
throw new Error('WAP pay not available');
});
const request: CreatePaymentRequest = {
orderId: 'order-ali-003',
amount: 30,
paymentType: 'alipay_direct',
subject: 'Test Recharge',
isMobile: true,
};
await expect(provider.createPayment(request)).rejects.toThrow('WAP pay not available');
expect(mockAlipayPageExecute).toHaveBeenCalledTimes(1);
expect(mockAlipayPageExecute).toHaveBeenCalledWith(
expect.objectContaining({ product_code: 'QUICK_WAP_WAY' }),
expect.objectContaining({ method: 'alipay.trade.wap.pay' }),
);
});
it('alipay_direct is in REDIRECT_PAYMENT_TYPES', () => {
expect(REDIRECT_PAYMENT_TYPES.has('alipay_direct')).toBe(true);
});
});
// ----------------------------------------------------------
// Wxpay Provider
// ----------------------------------------------------------
describe('WxpayProvider', () => {
let provider: WxpayProvider;
beforeEach(() => {
provider = new WxpayProvider();
});
it('PC: uses Native order, returns qrCode (no payUrl)', async () => {
mockWxpayCreatePcOrder.mockResolvedValue('weixin://wxpay/bizpayurl?pr=abc123');
const request: CreatePaymentRequest = {
orderId: 'order-wx-001',
amount: 100,
paymentType: 'wxpay_direct',
subject: 'Test Recharge',
clientIp: '1.2.3.4',
isMobile: false,
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('order-wx-001');
expect(result.qrCode).toBe('weixin://wxpay/bizpayurl?pr=abc123');
expect(result.payUrl).toBeUndefined();
// createPcOrder was called, not createH5Order
expect(mockWxpayCreatePcOrder).toHaveBeenCalledTimes(1);
expect(mockWxpayCreateH5Order).not.toHaveBeenCalled();
// PC + qrCode (no payUrl) => shouldAutoRedirect = false (show QR)
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'wxpay_direct',
payUrl: result.payUrl,
qrCode: result.qrCode,
isMobile: false,
}),
).toBe(false);
});
it('Mobile: uses H5 order, returns payUrl (no qrCode)', async () => {
mockWxpayCreateH5Order.mockResolvedValue('https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx123');
const request: CreatePaymentRequest = {
orderId: 'order-wx-002',
amount: 50,
paymentType: 'wxpay_direct',
subject: 'Test Recharge',
clientIp: '2.3.4.5',
isMobile: true,
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('order-wx-002');
expect(result.payUrl).toContain('tenpay.com');
expect(result.qrCode).toBeUndefined();
// createH5Order was called, not createPcOrder
expect(mockWxpayCreateH5Order).toHaveBeenCalledTimes(1);
expect(mockWxpayCreatePcOrder).not.toHaveBeenCalled();
// Mobile + payUrl => shouldAutoRedirect = true
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'wxpay_direct',
payUrl: result.payUrl,
qrCode: result.qrCode,
isMobile: true,
}),
).toBe(true);
});
it('Mobile: falls back to Native qrCode when H5 returns NO_AUTH', async () => {
mockWxpayCreateH5Order.mockRejectedValue(new Error('Wxpay API error: [NO_AUTH] not authorized'));
mockWxpayCreatePcOrder.mockResolvedValue('weixin://wxpay/bizpayurl?pr=fallback123');
const request: CreatePaymentRequest = {
orderId: 'order-wx-003',
amount: 30,
paymentType: 'wxpay_direct',
subject: 'Test Recharge',
clientIp: '3.4.5.6',
isMobile: true,
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('order-wx-003');
expect(result.qrCode).toBe('weixin://wxpay/bizpayurl?pr=fallback123');
expect(result.payUrl).toBeUndefined();
// Both were called: H5 failed, then Native succeeded
expect(mockWxpayCreateH5Order).toHaveBeenCalledTimes(1);
expect(mockWxpayCreatePcOrder).toHaveBeenCalledTimes(1);
// Mobile + qrCode only (no payUrl) => shouldAutoRedirect = false (show QR)
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'wxpay_direct',
payUrl: result.payUrl,
qrCode: result.qrCode,
isMobile: true,
}),
).toBe(false);
});
it('Mobile: re-throws non-NO_AUTH errors from H5', async () => {
mockWxpayCreateH5Order.mockRejectedValue(new Error('Wxpay API error: [SYSTEMERROR] system error'));
const request: CreatePaymentRequest = {
orderId: 'order-wx-004',
amount: 20,
paymentType: 'wxpay_direct',
subject: 'Test Recharge',
clientIp: '4.5.6.7',
isMobile: true,
};
await expect(provider.createPayment(request)).rejects.toThrow('SYSTEMERROR');
// Should not fall back to PC order
expect(mockWxpayCreatePcOrder).not.toHaveBeenCalled();
});
it('Mobile without clientIp: falls back to Native qrCode directly', async () => {
mockWxpayCreatePcOrder.mockResolvedValue('weixin://wxpay/bizpayurl?pr=noip');
const request: CreatePaymentRequest = {
orderId: 'order-wx-005',
amount: 10,
paymentType: 'wxpay_direct',
subject: 'Test Recharge',
// No clientIp
isMobile: true,
};
const result = await provider.createPayment(request);
expect(result.qrCode).toBe('weixin://wxpay/bizpayurl?pr=noip');
expect(result.payUrl).toBeUndefined();
// H5 was never attempted since clientIp is missing
expect(mockWxpayCreateH5Order).not.toHaveBeenCalled();
});
it('uses request.notifyUrl as fallback when WXPAY_NOTIFY_URL is set', async () => {
mockWxpayCreatePcOrder.mockResolvedValue('weixin://wxpay/bizpayurl?pr=withnotify');
const request: CreatePaymentRequest = {
orderId: 'order-wx-006',
amount: 10,
paymentType: 'wxpay_direct',
subject: 'Test',
isMobile: false,
notifyUrl: 'https://pay.example.com/api/wxpay/notify-alt',
};
const result = await provider.createPayment(request);
expect(result.qrCode).toBe('weixin://wxpay/bizpayurl?pr=withnotify');
// WXPAY_NOTIFY_URL from env takes priority over request.notifyUrl
expect(mockWxpayCreatePcOrder).toHaveBeenCalledWith(
expect.objectContaining({
notify_url: 'https://pay.example.com/api/wxpay/notify',
}),
);
});
});
// ----------------------------------------------------------
// shouldAutoRedirect logic (PaymentQRCode component)
// ----------------------------------------------------------
describe('shouldAutoRedirect (PaymentQRCode logic)', () => {
it('PC + qrCode + payUrl => false (show QR code, do not redirect)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay',
payUrl: 'https://example.com/pay',
qrCode: 'https://qr.alipay.com/xxx',
isMobile: false,
}),
).toBe(false);
});
it('PC + payUrl only (no qrCode) => true (redirect)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay_direct',
payUrl: 'https://openapi.alipay.com/gateway.do?...',
qrCode: undefined,
isMobile: false,
}),
).toBe(true);
});
it('PC + payUrl + empty qrCode string => true (redirect)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay_direct',
payUrl: 'https://openapi.alipay.com/gateway.do?...',
qrCode: '',
isMobile: false,
}),
).toBe(true);
});
it('PC + payUrl + null qrCode => true (redirect)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay_direct',
payUrl: 'https://openapi.alipay.com/gateway.do?...',
qrCode: null,
isMobile: false,
}),
).toBe(true);
});
it('Mobile + payUrl => true (redirect)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'wxpay_direct',
payUrl: 'https://wx.tenpay.com/...',
qrCode: undefined,
isMobile: true,
}),
).toBe(true);
});
it('Mobile + payUrl + qrCode => true (redirect, mobile always prefers payUrl)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay',
payUrl: 'https://easypay.example.com/pay/xxx',
qrCode: 'https://qr.alipay.com/xxx',
isMobile: true,
}),
).toBe(true);
});
it('Mobile + qrCode only (no payUrl) => false (show QR code)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'wxpay_direct',
payUrl: undefined,
qrCode: 'weixin://wxpay/bizpayurl?pr=xxx',
isMobile: true,
}),
).toBe(false);
});
it('Stripe => false (never redirect, uses Payment Element)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'stripe',
payUrl: 'https://checkout.stripe.com/xxx',
qrCode: undefined,
isMobile: false,
}),
).toBe(false);
});
it('Stripe on mobile => false (still no redirect)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'stripe',
payUrl: 'https://checkout.stripe.com/xxx',
qrCode: undefined,
isMobile: true,
}),
).toBe(false);
});
it('Expired order => false (never redirect expired orders)', () => {
expect(
shouldAutoRedirect({
expired: true,
paymentType: 'alipay',
payUrl: 'https://example.com/pay',
qrCode: undefined,
isMobile: true,
}),
).toBe(false);
});
it('No payUrl at all => false (nothing to redirect to)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay',
payUrl: undefined,
qrCode: undefined,
isMobile: true,
}),
).toBe(false);
});
it('Empty payUrl string => false (treated as falsy)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay',
payUrl: '',
qrCode: undefined,
isMobile: true,
}),
).toBe(false);
});
it('Null payUrl => false (treated as falsy)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay',
payUrl: null,
qrCode: undefined,
isMobile: true,
}),
).toBe(false);
});
});
// ----------------------------------------------------------
// Utility function tests
// ----------------------------------------------------------
describe('isStripeType', () => {
it('returns true for "stripe"', () => {
expect(isStripeType('stripe')).toBe(true);
});
it('returns true for stripe-prefixed types', () => {
expect(isStripeType('stripe_card')).toBe(true);
});
it('returns false for alipay', () => {
expect(isStripeType('alipay')).toBe(false);
});
it('returns false for wxpay', () => {
expect(isStripeType('wxpay')).toBe(false);
});
it('returns false for undefined', () => {
expect(isStripeType(undefined)).toBe(false);
});
it('returns false for null', () => {
expect(isStripeType(null)).toBe(false);
});
});
describe('REDIRECT_PAYMENT_TYPES', () => {
it('includes alipay_direct', () => {
expect(REDIRECT_PAYMENT_TYPES.has('alipay_direct')).toBe(true);
});
it('does not include alipay (easypay version)', () => {
expect(REDIRECT_PAYMENT_TYPES.has('alipay')).toBe(false);
});
it('does not include wxpay types', () => {
expect(REDIRECT_PAYMENT_TYPES.has('wxpay')).toBe(false);
expect(REDIRECT_PAYMENT_TYPES.has('wxpay_direct')).toBe(false);
});
it('does not include stripe', () => {
expect(REDIRECT_PAYMENT_TYPES.has('stripe')).toBe(false);
});
});
});

View File

@@ -0,0 +1,199 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { useState, useEffect, useCallback, Suspense } from 'react';
import PayPageLayout from '@/components/PayPageLayout';
import DashboardStats from '@/components/admin/DashboardStats';
import DailyChart from '@/components/admin/DailyChart';
import Leaderboard from '@/components/admin/Leaderboard';
import PaymentMethodChart from '@/components/admin/PaymentMethodChart';
import { resolveLocale, type Locale } from '@/lib/locale';
interface DashboardData {
summary: {
today: { amount: number; orderCount: number; paidCount: number };
total: { amount: number; orderCount: number; paidCount: number };
successRate: number;
avgAmount: number;
};
dailySeries: { date: string; amount: number; count: 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 };
}
const DAYS_OPTIONS = [7, 30, 90] as const;
function DashboardContent() {
const searchParams = useSearchParams();
const token = searchParams.get('token');
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
const uiMode = searchParams.get('ui_mode') || 'standalone';
const locale = resolveLocale(searchParams.get('lang'));
const isDark = theme === 'dark';
const isEmbedded = uiMode === 'embedded';
const text =
locale === 'en'
? {
missingToken: 'Missing admin token',
missingTokenHint: 'Please access the admin page from the Sub2API platform.',
invalidToken: 'Invalid admin token',
requestFailed: 'Request failed',
loadFailed: 'Failed to load data',
title: 'Dashboard',
subtitle: 'Recharge order analytics and insights',
daySuffix: 'd',
orders: 'Order Management',
refresh: 'Refresh',
loading: 'Loading...',
}
: {
missingToken: '缺少管理员凭证',
missingTokenHint: '请从 Sub2API 平台正确访问管理页面',
invalidToken: '管理员凭证无效',
requestFailed: '请求失败',
loadFailed: '加载数据失败',
title: '数据概览',
subtitle: '充值订单统计与分析',
daySuffix: '天',
orders: '订单管理',
refresh: '刷新',
loading: '加载中...',
};
const [days, setDays] = useState<number>(30);
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const fetchData = useCallback(async () => {
if (!token) return;
setLoading(true);
setError('');
try {
const res = await fetch(`/api/admin/dashboard?token=${encodeURIComponent(token)}&days=${days}`);
if (!res.ok) {
if (res.status === 401) {
setError(text.invalidToken);
return;
}
throw new Error(text.requestFailed);
}
setData(await res.json());
} catch {
setError(text.loadFailed);
} finally {
setLoading(false);
}
}, [token, days]);
useEffect(() => {
fetchData();
}, [fetchData]);
if (!token) {
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">{text.missingToken}</p>
<p className="mt-2 text-sm text-gray-500">{text.missingTokenHint}</p>
</div>
</div>
);
}
const navParams = new URLSearchParams();
navParams.set('token', token);
if (locale === 'en') navParams.set('lang', 'en');
if (theme === 'dark') navParams.set('theme', 'dark');
if (isEmbedded) navParams.set('ui_mode', 'embedded');
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',
].join(' ');
const btnActive = [
'inline-flex items-center rounded-lg px-3 py-1.5 text-xs font-medium',
isDark ? 'bg-indigo-500/30 text-indigo-200 ring-1 ring-indigo-400/40' : 'bg-blue-600 text-white',
].join(' ');
return (
<PayPageLayout
isDark={isDark}
isEmbedded={isEmbedded}
maxWidth="full"
title={text.title}
subtitle={text.subtitle}
locale={locale}
actions={
<>
{DAYS_OPTIONS.map((d) => (
<button key={d} type="button" onClick={() => setDays(d)} className={days === d ? btnActive : btnBase}>
{d}
{text.daySuffix}
</button>
))}
<a href={`/admin?${navParams}`} className={btnBase}>
{text.orders}
</a>
<button type="button" onClick={fetchData} className={btnBase}>
{text.refresh}
</button>
</>
}
>
{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'}`}
>
{error}
<button onClick={() => setError('')} className="ml-2 opacity-60 hover:opacity-100">
</button>
</div>
)}
{loading ? (
<div className={`py-24 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.loading}</div>
) : data ? (
<div className="space-y-6">
<DashboardStats summary={data.summary} dark={isDark} locale={locale} />
<DailyChart data={data.dailySeries} dark={isDark} locale={locale} />
<div className="grid gap-6 lg:grid-cols-2">
<Leaderboard data={data.leaderboard} dark={isDark} locale={locale} />
<PaymentMethodChart data={data.paymentMethods} dark={isDark} locale={locale} />
</div>
</div>
) : null}
</PayPageLayout>
);
}
function DashboardPageFallback() {
const searchParams = useSearchParams();
const locale = resolveLocale(searchParams.get('lang'));
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-gray-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
</div>
);
}
export default function DashboardPage() {
return (
<Suspense fallback={<DashboardPageFallback />}>
<DashboardContent />
</Suspense>
);
}

View File

@@ -5,12 +5,15 @@ import { useState, useEffect, useCallback, Suspense } from 'react';
import OrderTable from '@/components/admin/OrderTable';
import OrderDetail from '@/components/admin/OrderDetail';
import PaginationBar from '@/components/PaginationBar';
import PayPageLayout from '@/components/PayPageLayout';
import { resolveLocale, type Locale } from '@/lib/locale';
interface AdminOrder {
id: string;
userId: number;
userName: string | null;
userEmail: string | null;
userNotes: string | null;
amount: number;
status: string;
paymentType: string;
@@ -19,6 +22,7 @@ interface AdminOrder {
completedAt: string | null;
failedReason: string | null;
expiresAt: string;
srcHost: string | null;
}
interface AdminOrderDetail extends AdminOrder {
@@ -31,6 +35,8 @@ interface AdminOrderDetail extends AdminOrder {
failedAt: string | null;
updatedAt: string;
clientIp: string | null;
srcHost: string | null;
srcUrl: string | null;
paymentSuccess?: boolean;
rechargeSuccess?: boolean;
rechargeStatus?: string;
@@ -40,6 +46,74 @@ interface AdminOrderDetail extends AdminOrder {
function AdminContent() {
const searchParams = useSearchParams();
const token = searchParams.get('token');
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
const uiMode = searchParams.get('ui_mode') || 'standalone';
const locale = resolveLocale(searchParams.get('lang'));
const isDark = theme === 'dark';
const isEmbedded = uiMode === 'embedded';
const text =
locale === 'en'
? {
missingToken: 'Missing admin token',
missingTokenHint: 'Please access the admin page from the Sub2API platform.',
invalidToken: 'Invalid admin token',
requestFailed: 'Request failed',
loadOrdersFailed: 'Failed to load orders',
retryConfirm: 'Retry recharge for this order?',
retryFailed: 'Retry failed',
retryRequestFailed: 'Retry request failed',
cancelConfirm: 'Cancel this order?',
cancelFailed: 'Cancel failed',
cancelRequestFailed: 'Cancel request failed',
loadDetailFailed: 'Failed to load order details',
title: 'Order Management',
subtitle: 'View and manage all recharge orders',
dashboard: 'Dashboard',
refresh: 'Refresh',
loading: 'Loading...',
statuses: {
'': 'All',
PENDING: 'Pending',
PAID: 'Paid',
RECHARGING: 'Recharging',
COMPLETED: 'Completed',
EXPIRED: 'Expired',
CANCELLED: 'Cancelled',
FAILED: 'Recharge failed',
REFUNDED: 'Refunded',
},
}
: {
missingToken: '缺少管理员凭证',
missingTokenHint: '请从 Sub2API 平台正确访问管理页面',
invalidToken: '管理员凭证无效',
requestFailed: '请求失败',
loadOrdersFailed: '加载订单列表失败',
retryConfirm: '确认重试充值?',
retryFailed: '重试失败',
retryRequestFailed: '重试请求失败',
cancelConfirm: '确认取消该订单?',
cancelFailed: '取消失败',
cancelRequestFailed: '取消请求失败',
loadDetailFailed: '加载订单详情失败',
title: '订单管理',
subtitle: '查看和管理所有充值订单',
dashboard: '数据概览',
refresh: '刷新',
loading: '加载中...',
statuses: {
'': '全部',
PENDING: '待支付',
PAID: '已支付',
RECHARGING: '充值中',
COMPLETED: '已完成',
EXPIRED: '已超时',
CANCELLED: '已取消',
FAILED: '充值失败',
REFUNDED: '已退款',
},
};
const [orders, setOrders] = useState<AdminOrder[]>([]);
const [total, setTotal] = useState(0);
@@ -62,18 +136,18 @@ function AdminContent() {
const res = await fetch(`/api/admin/orders?${params}`);
if (!res.ok) {
if (res.status === 401) {
setError('管理员凭证无效');
setError(text.invalidToken);
return;
}
throw new Error('请求失败');
throw new Error(text.requestFailed);
}
const data = await res.json();
setOrders(data.orders);
setTotal(data.total);
setTotalPages(data.total_pages);
} catch (e) {
setError('加载订单列表失败');
} catch {
setError(text.loadOrdersFailed);
} finally {
setLoading(false);
}
@@ -85,14 +159,17 @@ function AdminContent() {
if (!token) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-red-500"></div>
<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">{text.missingToken}</p>
<p className="mt-2 text-sm text-gray-500">{text.missingTokenHint}</p>
</div>
</div>
);
}
const handleRetry = async (orderId: string) => {
if (!confirm('确认重试充值?')) return;
if (!confirm(text.retryConfirm)) return;
try {
const res = await fetch(`/api/admin/orders/${orderId}/retry?token=${token}`, {
method: 'POST',
@@ -101,15 +178,15 @@ function AdminContent() {
fetchOrders();
} else {
const data = await res.json();
setError(data.error || '重试失败');
setError(data.error || text.retryFailed);
}
} catch {
setError('重试请求失败');
setError(text.retryRequestFailed);
}
};
const handleCancel = async (orderId: string) => {
if (!confirm('确认取消该订单?')) return;
if (!confirm(text.cancelConfirm)) return;
try {
const res = await fetch(`/api/admin/orders/${orderId}/cancel?token=${token}`, {
method: 'POST',
@@ -118,10 +195,10 @@ function AdminContent() {
fetchOrders();
} else {
const data = await res.json();
setError(data.error || '取消失败');
setError(data.error || text.cancelFailed);
}
} catch {
setError('取消请求失败');
setError(text.cancelRequestFailed);
}
};
@@ -133,40 +210,51 @@ function AdminContent() {
setDetailOrder(data);
}
} catch {
setError('加载订单详情失败');
setError(text.loadDetailFailed);
}
};
const statuses = ['', 'PENDING', 'PAID', 'RECHARGING', 'COMPLETED', 'EXPIRED', 'CANCELLED', 'FAILED', 'REFUNDED'];
const statusLabels: Record<string, string> = {
'': '全部',
PENDING: '待支付',
PAID: '已支付',
RECHARGING: '充值中',
COMPLETED: '已完成',
EXPIRED: '已超时',
CANCELLED: '已取消',
FAILED: '充值失败',
REFUNDED: '已退款',
};
const statusLabels: Record<string, string> = text.statuses;
const navParams = new URLSearchParams();
if (token) navParams.set('token', token);
if (locale === 'en') navParams.set('lang', 'en');
if (isDark) navParams.set('theme', 'dark');
if (isEmbedded) navParams.set('ui_mode', 'embedded');
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',
].join(' ');
return (
<div className="mx-auto min-h-screen max-w-6xl p-4">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Sub2ApiPay </h1>
<button
type="button"
onClick={fetchOrders}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-100"
>
</button>
</div>
<PayPageLayout
isDark={isDark}
isEmbedded={isEmbedded}
maxWidth="full"
title={text.title}
subtitle={text.subtitle}
locale={locale}
actions={
<>
<a href={`/admin/dashboard?${navParams}`} className={btnBase}>
{text.dashboard}
</a>
<button type="button" onClick={fetchOrders} className={btnBase}>
{text.refresh}
</button>
</>
}
>
{error && (
<div className="mb-4 rounded-lg bg-red-50 p-3 text-sm 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 text-red-400 hover:text-red-600">
<button onClick={() => setError('')} className="ml-2 opacity-60 hover:opacity-100">
</button>
</div>
@@ -181,9 +269,16 @@ function AdminContent() {
setStatusFilter(s);
setPage(1);
}}
className={`rounded-full px-3 py-1 text-sm transition-colors ${
statusFilter === s ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
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',
].join(' ')}
>
{statusLabels[s]}
</button>
@@ -191,11 +286,23 @@ function AdminContent() {
</div>
{/* Table */}
<div className="rounded-xl bg-white shadow-sm">
<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 text-gray-500">...</div>
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.loading}</div>
) : (
<OrderTable orders={orders} onRetry={handleRetry} onCancel={handleCancel} onViewDetail={handleViewDetail} />
<OrderTable
orders={orders}
onRetry={handleRetry}
onCancel={handleCancel}
onViewDetail={handleViewDetail}
dark={isDark}
locale={locale}
/>
)}
</div>
@@ -206,24 +313,36 @@ function AdminContent() {
pageSize={pageSize}
loading={loading}
onPageChange={(p) => setPage(p)}
onPageSizeChange={(s) => { setPageSize(s); setPage(1); }}
onPageSizeChange={(s) => {
setPageSize(s);
setPage(1);
}}
locale={locale}
isDark={isDark}
/>
{/* Order Detail */}
{detailOrder && <OrderDetail order={detailOrder} onClose={() => setDetailOrder(null)} />}
{detailOrder && (
<OrderDetail order={detailOrder} onClose={() => setDetailOrder(null)} dark={isDark} locale={locale} />
)}
</PayPageLayout>
);
}
function AdminPageFallback() {
const searchParams = useSearchParams();
const locale = resolveLocale(searchParams.get('lang'));
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-gray-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
</div>
);
}
export default function AdminPage() {
return (
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center">
<div className="text-gray-500">...</div>
</div>
}
>
<Suspense fallback={<AdminPageFallback />}>
<AdminContent />
</Suspense>
);

View File

@@ -0,0 +1,144 @@
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';
import { BIZ_TZ_NAME, getBizDayStartUTC, toBizDateStr } from '@/lib/time/biz-day';
export async function GET(request: NextRequest) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
const searchParams = request.nextUrl.searchParams;
const days = Math.min(365, Math.max(1, Number(searchParams.get('days') || '30')));
const now = new Date();
const todayStart = getBizDayStartUTC(now);
const startDate = new Date(todayStart.getTime() - days * 24 * 60 * 60 * 1000);
const paidStatuses: OrderStatus[] = [
OrderStatus.PAID,
OrderStatus.RECHARGING,
OrderStatus.COMPLETED,
OrderStatus.REFUNDING,
OrderStatus.REFUNDED,
OrderStatus.REFUND_FAILED,
];
const [todayStats, totalStats, todayOrders, totalOrders, dailyRaw, leaderboardRaw, paymentMethodStats] =
await Promise.all([
// Today paid aggregate
prisma.order.aggregate({
where: { status: { in: paidStatuses }, paidAt: { gte: todayStart } },
_sum: { amount: true },
_count: { _all: true },
}),
// Total paid aggregate
prisma.order.aggregate({
where: { status: { in: paidStatuses } },
_sum: { amount: true },
_count: { _all: true },
}),
// Today total orders
prisma.order.count({ where: { createdAt: { gte: todayStart } } }),
// Total orders
prisma.order.count(),
// 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 (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 (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
prisma.$queryRaw<
{
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
FROM orders
WHERE status IN ('PAID', 'RECHARGING', 'COMPLETED', 'REFUNDING', 'REFUNDED', 'REFUND_FAILED')
AND paid_at >= ${startDate}
GROUP BY user_id
ORDER BY SUM(amount) DESC
LIMIT 10
`,
// Payment method distribution (within time range)
prisma.order.groupBy({
by: ['paymentType'],
where: { status: { in: paidStatuses }, paidAt: { gte: startDate } },
_sum: { amount: true },
_count: { _all: true },
}),
]);
// Fill missing dates for continuous line chart
const dailyMap = new Map<string, { amount: number; count: number }>();
for (const row of dailyRaw) {
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 = toBizDateStr(cursor);
const entry = dailyMap.get(dateStr);
dailySeries.push({ date: dateStr, amount: entry?.amount ?? 0, count: entry?.count ?? 0 });
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;
const totalPaidAmount = Number(totalStats._sum?.amount || 0);
const totalPaidCount = totalStats._count._all;
const successRate = totalOrders > 0 ? (totalPaidCount / totalOrders) * 100 : 0;
const avgAmount = totalPaidCount > 0 ? totalPaidAmount / totalPaidCount : 0;
// Payment method total for percentage calc
const paymentTotal = paymentMethodStats.reduce((sum, m) => sum + Number(m._sum?.amount || 0), 0);
return NextResponse.json({
summary: {
today: { amount: todayPaidAmount, orderCount: todayOrders, paidCount: todayPaidCount },
total: { amount: totalPaidAmount, orderCount: totalOrders, paidCount: totalPaidCount },
successRate: Math.round(successRate * 10) / 10,
avgAmount: Math.round(avgAmount * 100) / 100,
},
dailySeries: deduped,
leaderboard: leaderboardRaw.map((row) => ({
userId: row.user_id,
userName: row.user_name,
userEmail: row.user_email,
totalAmount: Number(row.total_amount),
orderCount: Number(row.order_count),
})),
paymentMethods: paymentMethodStats.map((m) => {
const amount = Number(m._sum?.amount || 0);
return {
paymentType: m.paymentType,
amount,
count: m._count._all,
percentage: paymentTotal > 0 ? Math.round((amount / paymentTotal) * 1000) / 10 : 0,
};
}),
meta: { days, generatedAt: now.toISOString() },
});
}

View File

@@ -1,22 +1,26 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { adminCancelOrder, OrderError } from '@/lib/order/service';
import { resolveLocale } from '@/lib/locale';
import { adminCancelOrder } from '@/lib/order/service';
import { handleApiError } from '@/lib/utils/api';
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!verifyAdminToken(request)) return unauthorizedResponse();
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
const locale = resolveLocale(request.nextUrl.searchParams.get('lang'));
try {
const { id } = await params;
const outcome = await adminCancelOrder(id);
const outcome = await adminCancelOrder(id, locale);
if (outcome === 'already_paid') {
return NextResponse.json({ success: true, status: 'PAID', message: '订单已支付完成' });
return NextResponse.json({
success: true,
status: 'PAID',
message: locale === 'en' ? 'Order has already been paid' : '订单已支付完成',
});
}
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof OrderError) {
return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
}
console.error('Admin cancel order error:', error);
return NextResponse.json({ error: '取消订单失败' }, { status: 500 });
return handleApiError(error, locale === 'en' ? 'Cancel order failed' : '取消订单失败', request);
}
}

View File

@@ -1,19 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { retryRecharge, OrderError } from '@/lib/order/service';
import { resolveLocale } from '@/lib/locale';
import { retryRecharge } from '@/lib/order/service';
import { handleApiError } from '@/lib/utils/api';
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!verifyAdminToken(request)) return unauthorizedResponse();
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
const locale = resolveLocale(request.nextUrl.searchParams.get('lang'));
try {
const { id } = await params;
await retryRecharge(id);
await retryRecharge(id, locale);
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof OrderError) {
return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
}
console.error('Retry recharge error:', error);
return NextResponse.json({ error: '重试充值失败' }, { status: 500 });
return handleApiError(error, locale === 'en' ? 'Recharge retry failed' : '重试充值失败', request);
}
}

View File

@@ -1,11 +1,13 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { resolveLocale } from '@/lib/locale';
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!verifyAdminToken(request)) return unauthorizedResponse();
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
const { id } = await params;
const locale = resolveLocale(request.nextUrl.searchParams.get('lang'));
const order = await prisma.order.findUnique({
where: { id },
@@ -17,12 +19,14 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
});
if (!order) {
return NextResponse.json({ error: '订单不存在' }, { status: 404 });
return NextResponse.json({ error: locale === 'en' ? 'Order not found' : '订单不存在' }, { status: 404 });
}
return NextResponse.json({
...order,
amount: Number(order.amount),
payAmount: order.payAmount ? Number(order.payAmount) : null,
feeRate: order.feeRate ? Number(order.feeRate) : null,
refundAmount: order.refundAmount ? Number(order.refundAmount) : null,
});
}

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 (!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'));
@@ -16,11 +16,38 @@ export async function GET(request: NextRequest) {
const where: Prisma.OrderWhereInput = {};
if (status && status in OrderStatus) where.status = status as OrderStatus;
if (userId) where.userId = Number(userId);
// userId 校验忽略无效值NaN
if (userId) {
const parsedUserId = Number(userId);
if (Number.isFinite(parsedUserId)) {
where.userId = parsedUserId;
}
}
// 日期校验:忽略无效日期
if (dateFrom || dateTo) {
where.createdAt = {};
if (dateFrom) where.createdAt.gte = new Date(dateFrom);
if (dateTo) where.createdAt.lte = new Date(dateTo);
const createdAt: Prisma.DateTimeFilter = {};
let hasValidDate = false;
if (dateFrom) {
const d = new Date(dateFrom);
if (!isNaN(d.getTime())) {
createdAt.gte = d;
hasValidDate = true;
}
}
if (dateTo) {
const d = new Date(dateTo);
if (!isNaN(d.getTime())) {
createdAt.lte = d;
hasValidDate = true;
}
}
if (hasValidDate) {
where.createdAt = createdAt;
}
}
const [orders, total] = await Promise.all([
@@ -34,6 +61,7 @@ export async function GET(request: NextRequest) {
userId: true,
userName: true,
userEmail: true,
userNotes: true,
amount: true,
status: true,
paymentType: true,
@@ -42,6 +70,7 @@ export async function GET(request: NextRequest) {
completedAt: true,
failedReason: true,
expiresAt: true,
srcHost: true,
},
}),
prisma.order.count({ where }),

View File

@@ -1,7 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { processRefund, OrderError } from '@/lib/order/service';
import { processRefund } from '@/lib/order/service';
import { handleApiError } from '@/lib/utils/api';
import { resolveLocale } from '@/lib/locale';
const refundSchema = z.object({
order_id: z.string().min(1),
@@ -10,28 +12,30 @@ const refundSchema = z.object({
});
export async function POST(request: NextRequest) {
if (!verifyAdminToken(request)) return unauthorizedResponse();
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
const locale = resolveLocale(request.nextUrl.searchParams.get('lang'));
try {
const body = await request.json();
const parsed = refundSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: '参数错误', details: parsed.error.flatten().fieldErrors }, { status: 400 });
return NextResponse.json(
{ error: locale === 'en' ? 'Invalid parameters' : '参数错误', details: parsed.error.flatten().fieldErrors },
{ status: 400 },
);
}
const result = await processRefund({
orderId: parsed.data.order_id,
reason: parsed.data.reason,
force: parsed.data.force,
locale,
});
return NextResponse.json(result);
} catch (error) {
if (error instanceof OrderError) {
return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
}
console.error('Refund error:', error);
return NextResponse.json({ error: '退款失败' }, { status: 500 });
return handleApiError(error, locale === 'en' ? 'Refund failed' : '退款失败', request);
}
}

View File

@@ -0,0 +1,34 @@
import { NextRequest } from 'next/server';
import { handlePaymentNotify } from '@/lib/order/service';
import { paymentRegistry } from '@/lib/payment';
import type { PaymentType } from '@/lib/payment';
import { getEnv } from '@/lib/config';
import { extractHeaders } from '@/lib/utils/api';
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 provider = paymentRegistry.getProvider('alipay_direct' as PaymentType);
const rawBody = await request.text();
const headers = extractHeaders(request);
const notification = await provider.verifyNotification(rawBody, headers);
if (!notification) {
return new Response('success', { headers: { 'Content-Type': 'text/plain' } });
}
const success = await handlePaymentNotify(notification, provider.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,19 +1,21 @@
import { NextRequest } from 'next/server';
import { handlePaymentNotify } from '@/lib/order/service';
import { EasyPayProvider } from '@/lib/easy-pay/provider';
const easyPayProvider = new EasyPayProvider();
import { paymentRegistry } from '@/lib/payment';
import type { PaymentType } from '@/lib/payment';
import { extractHeaders } from '@/lib/utils/api';
export async function GET(request: NextRequest) {
try {
// EasyPay 注册为 'alipay' 和 'wxpay' 类型,任一均可获取同一 provider 实例
const provider = paymentRegistry.getProvider('alipay' as PaymentType);
const rawBody = request.nextUrl.searchParams.toString();
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
const headers = extractHeaders(request);
const notification = await easyPayProvider.verifyNotification(rawBody, headers);
const success = await handlePaymentNotify(notification, easyPayProvider.name);
const notification = await provider.verifyNotification(rawBody, headers);
if (!notification) {
return new Response('success', { headers: { 'Content-Type': 'text/plain' } });
}
const success = await handlePaymentNotify(notification, provider.name);
return new Response(success ? 'success' : 'fail', {
headers: { 'Content-Type': 'text/plain' },
});

View File

@@ -1,6 +1,7 @@
import { NextResponse } from 'next/server';
import { getEnv } from '@/lib/config';
import { queryMethodLimits } from '@/lib/order/limits';
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
import { getNextBizDayStartUTC } from '@/lib/time/biz-day';
/**
* GET /api/limits
@@ -13,19 +14,14 @@ import { queryMethodLimits } from '@/lib/order/limits';
* wxpay: { dailyLimit: 10000, used: 10000, remaining: 0, available: false },
* stripe: { dailyLimit: 0, used: 500, remaining: null, available: true }
* },
* resetAt: "2026-03-02T00:00:00.000Z" // UTC 次日零点(限额重置时间
* resetAt: "2026-03-02T16:00:00.000Z" // 业务时区Asia/Shanghai次日零点对应的 UTC 时间
* }
*/
export async function GET() {
const env = getEnv();
const types = env.ENABLED_PAYMENT_TYPES;
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);
const resetAt = new Date(todayStart);
resetAt.setUTCDate(resetAt.getUTCDate() + 1);
initPaymentProviders();
const types = paymentRegistry.getSupportedTypes();
const methods = await queryMethodLimits(types);
const resetAt = getNextBizDayStartUTC();
return NextResponse.json({ methods, resetAt });
}

View File

@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { cancelOrder, OrderError } from '@/lib/order/service';
import { cancelOrder } from '@/lib/order/service';
import { getCurrentUserByToken } from '@/lib/sub2api/client';
import { handleApiError } from '@/lib/utils/api';
const cancelSchema = z.object({
token: z.string().min(1),
@@ -31,10 +32,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof OrderError) {
return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
}
console.error('Cancel order error:', error);
return NextResponse.json({ error: '取消订单失败' }, { status: 500 });
return handleApiError(error, '取消订单失败');
}
}

View File

@@ -1,9 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { verifyAdminToken } from '@/lib/admin-auth';
import { deriveOrderState } from '@/lib/order/status';
import { ORDER_STATUS_ACCESS_QUERY_KEY, verifyOrderStatusAccessToken } from '@/lib/order/status-access';
// 仅返回订单状态相关字段,不暴露任何用户隐私信息
/**
* 订单状态轮询接口。
*
* 返回最小必要信息供前端判断:
* - 原始订单状态status / expiresAt
* - 支付是否成功paymentSuccess
* - 充值是否成功 / 当前充值阶段rechargeSuccess / rechargeStatus
*/
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const accessToken = request.nextUrl.searchParams.get(ORDER_STATUS_ACCESS_QUERY_KEY);
const isAuthorized = verifyOrderStatusAccessToken(id, accessToken) || (await verifyAdminToken(request));
if (!isAuthorized) {
return NextResponse.json({ error: '未授权访问该订单状态' }, { status: 401 });
}
const order = await prisma.order.findUnique({
where: { id },
@@ -11,6 +27,8 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
id: true,
status: true,
expiresAt: true,
paidAt: true,
completedAt: true,
},
});
@@ -18,9 +36,14 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: '订单不存在' }, { status: 404 });
}
const derived = deriveOrderState(order);
return NextResponse.json({
id: order.id,
status: order.status,
expiresAt: order.expiresAt,
paymentSuccess: derived.paymentSuccess,
rechargeSuccess: derived.rechargeSuccess,
rechargeStatus: derived.rechargeStatus,
});
}

View File

@@ -16,8 +16,16 @@ export async function GET(request: NextRequest) {
const rawPageSize = Number(searchParams.get('page_size') || '20');
const pageSize = VALID_PAGE_SIZES.includes(rawPageSize) ? rawPageSize : 20;
// 单独处理认证,区分认证失败和其他错误
let user;
try {
user = await getCurrentUserByToken(token);
} catch (error) {
console.error('Auth error in /api/orders/my:', error);
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
try {
const user = await getCurrentUserByToken(token);
const where = { userId: user.id };
const [orders, total, statusGroups] = await Promise.all([
@@ -76,6 +84,6 @@ export async function GET(request: NextRequest) {
});
} catch (error) {
console.error('Get my orders error:', error);
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
return NextResponse.json({ error: '获取订单失败' }, { status: 500 });
}
}

View File

@@ -1,12 +1,18 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { createOrder, OrderError } from '@/lib/order/service';
import { createOrder } from '@/lib/order/service';
import { getEnv } from '@/lib/config';
import { paymentRegistry } from '@/lib/payment';
import { getCurrentUserByToken } from '@/lib/sub2api/client';
import { handleApiError } from '@/lib/utils/api';
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) {
@@ -19,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 } = 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) {
@@ -30,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 });
}
@@ -38,20 +53,19 @@ 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,
});
// 不向客户端暴露 userName / userBalance 等隐私字段
const { userName: _u, userBalance: _b, ...safeResult } = result;
return NextResponse.json(safeResult);
} catch (error) {
if (error instanceof OrderError) {
return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
}
console.error('Create order error:', error);
return NextResponse.json({ error: '创建订单失败,请稍后重试' }, { status: 500 });
return handleApiError(error, '创建订单失败,请稍后重试');
}
}

View File

@@ -1,35 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
import { paymentRegistry } from '@/lib/payment';
import type { PaymentType } from '@/lib/payment';
import { handlePaymentNotify } from '@/lib/order/service';
import { extractHeaders } from '@/lib/utils/api';
// Stripe needs raw body - ensure Next.js doesn't parse it
export const dynamic = 'force-dynamic';
export async function POST(request: NextRequest): Promise<NextResponse> {
try {
initPaymentProviders();
const provider = paymentRegistry.getProvider('stripe' as PaymentType);
const rawBody = Buffer.from(await request.arrayBuffer());
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key.toLowerCase()] = value;
});
const headers = extractHeaders(request);
const notification = await provider.verifyNotification(rawBody, headers);
if (!notification) {
// Unknown event type — acknowledge receipt
return NextResponse.json({ received: true });
}
await handlePaymentNotify(notification, provider.name);
const success = await handlePaymentNotify(notification, provider.name);
if (!success) {
// 处理失败(充值未完成等),返回 500 让 Stripe 重试
return NextResponse.json({ error: 'Processing failed, will retry' }, { status: 500 });
}
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

@@ -1,20 +1,73 @@
import { NextRequest, NextResponse } from 'next/server';
import { getUser } from '@/lib/sub2api/client';
import { getUser, getCurrentUserByToken } from '@/lib/sub2api/client';
import { getEnv } from '@/lib/config';
import { queryMethodLimits } from '@/lib/order/limits';
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
import { getPaymentDisplayInfo } from '@/lib/pay-utils';
import { resolveLocale } from '@/lib/locale';
export async function GET(request: NextRequest) {
const locale = resolveLocale(request.nextUrl.searchParams.get('lang'));
const userId = Number(request.nextUrl.searchParams.get('user_id'));
if (!userId || isNaN(userId) || userId <= 0) {
return NextResponse.json({ error: '无效的用户 ID' }, { status: 400 });
return NextResponse.json({ error: locale === 'en' ? 'Invalid user ID' : '无效的用户 ID' }, { status: 400 });
}
const token = request.nextUrl.searchParams.get('token')?.trim();
if (!token) {
return NextResponse.json(
{ error: locale === 'en' ? 'Missing token parameter' : '缺少 token 参数' },
{ status: 401 },
);
}
try {
// 验证 token 并确保请求的 user_id 与 token 对应的用户匹配
let tokenUser;
try {
tokenUser = await getCurrentUserByToken(token);
} catch {
return NextResponse.json({ error: locale === 'en' ? 'Invalid token' : '无效的 token' }, { status: 401 });
}
if (tokenUser.id !== userId) {
return NextResponse.json(
{ error: locale === 'en' ? 'Forbidden to access this user' : '无权访问该用户信息' },
{ status: 403 },
);
}
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 { channel } = getPaymentDisplayInfo(type, locale);
const types = labelCount.get(channel) || [];
types.push(type);
labelCount.set(channel, types);
}
for (const [, types] of labelCount) {
if (types.length > 1) {
for (const type of types) {
const { provider } = getPaymentDisplayInfo(type, locale);
if (provider) sublabelOverrides[type] = 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,19 +75,27 @@ 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:
enabledTypes.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY ? env.STRIPE_PUBLISHABLE_KEY : null,
sublabelOverrides: Object.keys(sublabelOverrides).length > 0 ? sublabelOverrides : null,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message === 'USER_NOT_FOUND') {
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
return NextResponse.json({ error: locale === 'en' ? 'User not found' : '用户不存在' }, { status: 404 });
}
console.error('Get user error:', error);
return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 });
return NextResponse.json(
{ error: locale === 'en' ? 'Failed to fetch user info' : '获取用户信息失败' },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,32 @@
import { NextRequest } from 'next/server';
import { handlePaymentNotify } from '@/lib/order/service';
import { paymentRegistry } from '@/lib/payment';
import type { PaymentType } from '@/lib/payment';
import { getEnv } from '@/lib/config';
import { extractHeaders } from '@/lib/utils/api';
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 provider = paymentRegistry.getProvider('wxpay_direct' as PaymentType);
const rawBody = await request.text();
const headers = extractHeaders(request);
const notification = await provider.verifyNotification(rawBody, headers);
if (!notification) {
return Response.json({ code: 'SUCCESS', message: '成功' });
}
const success = await handlePaymentNotify(notification, provider.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

@@ -8,4 +8,11 @@
body {
background: var(--background);
color: var(--foreground);
font-family:
system-ui,
-apple-system,
'PingFang SC',
'Hiragino Sans GB',
'Microsoft YaHei',
sans-serif;
}

View File

@@ -1,19 +1,26 @@
import type { Metadata } from 'next';
import { headers } from 'next/headers';
import './globals.css';
export const metadata: Metadata = {
title: 'Sub2API 充值',
description: 'Sub2API 余额充值平台',
title: 'Sub2API Recharge',
description: 'Sub2API balance recharge platform',
};
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const headerStore = await headers();
const pathname = headerStore.get('x-pathname') || '';
const search = headerStore.get('x-search') || '';
const locale = new URLSearchParams(search).get('lang')?.trim().toLowerCase() === 'en' ? 'en' : 'zh';
const htmlLang = locale === 'en' ? 'en' : 'zh-CN';
return (
<html lang="zh-CN">
<body className="bg-gray-50 text-gray-900 antialiased">{children}</body>
<html lang={htmlLang} data-pathname={pathname}>
<body className="antialiased">{children}</body>
</html>
);
}

View File

@@ -1,5 +1,11 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/pay');
export default async function Home({
searchParams,
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
const lang = Array.isArray(params?.lang) ? params?.lang[0] : params?.lang;
redirect(lang === 'en' ? '/pay?lang=en' : '/pay');
}

View File

@@ -0,0 +1,302 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { ORDER_STATUS } from '@/lib/constants';
import { getEnv } from '@/lib/config';
import { buildAlipayPaymentUrl } from '@/lib/alipay/provider';
import { deriveOrderState, getOrderDisplayState, type OrderStatusLike } from '@/lib/order/status';
import { buildOrderResultUrl } from '@/lib/order/status-access';
export const dynamic = 'force-dynamic';
const MOBILE_UA_PATTERN = /AlipayClient|Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i;
const ALIPAY_APP_UA_PATTERN = /AlipayClient/i;
type ShortLinkOrderStatus = OrderStatusLike & { id: string };
function getUserAgent(request: NextRequest): string {
return request.headers.get('user-agent') || '';
}
function isMobileRequest(request: NextRequest): boolean {
return MOBILE_UA_PATTERN.test(getUserAgent(request));
}
function isAlipayAppRequest(request: NextRequest): boolean {
return ALIPAY_APP_UA_PATTERN.test(getUserAgent(request));
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function buildAppUrl(pathname = '/'): string {
return new URL(pathname, getEnv().NEXT_PUBLIC_APP_URL).toString();
}
function buildResultUrl(orderId: string): string {
return buildOrderResultUrl(getEnv().NEXT_PUBLIC_APP_URL, orderId);
}
function serializeScriptString(value: string): string {
return JSON.stringify(value).replace(/</g, '\\u003c');
}
function getStatusDisplay(order: OrderStatusLike) {
return getOrderDisplayState({
status: order.status,
...deriveOrderState(order),
});
}
function renderHtml(title: string, body: string, headExtra = ''): string {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="robots" content="noindex,nofollow" />
<title>${escapeHtml(title)}</title>
${headExtra}
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: linear-gradient(180deg, #f5faff 0%, #eef6ff 100%);
color: #0f172a;
}
.card {
width: 100%;
max-width: 420px;
background: #fff;
border-radius: 20px;
padding: 28px 24px;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.12);
text-align: center;
}
.icon {
width: 60px;
height: 60px;
margin: 0 auto 18px;
border-radius: 18px;
background: #1677ff;
color: #fff;
font-size: 30px;
line-height: 60px;
font-weight: 700;
}
h1 {
margin: 0;
font-size: 22px;
line-height: 1.35;
}
p {
margin: 12px 0 0;
font-size: 14px;
line-height: 1.7;
color: #475569;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 46px;
margin-top: 20px;
padding: 12px 16px;
border-radius: 12px;
background: #1677ff;
color: #fff;
font-weight: 600;
text-decoration: none;
}
.button.secondary {
margin-top: 12px;
background: #eff6ff;
color: #1677ff;
}
.spinner {
width: 30px;
height: 30px;
margin: 18px auto 0;
border-radius: 9999px;
border: 3px solid rgba(22, 119, 255, 0.18);
border-top-color: #1677ff;
animation: spin 1s linear infinite;
}
.order {
margin-top: 18px;
padding: 10px 12px;
border-radius: 12px;
background: #f8fafc;
color: #334155;
font-size: 12px;
word-break: break-all;
}
.hint {
margin-top: 16px;
font-size: 13px;
color: #64748b;
}
.text-link {
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 14px;
color: #1677ff;
font-size: 14px;
font-weight: 500;
text-decoration: none;
}
.text-link:hover {
text-decoration: underline;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
${body}
</body>
</html>`;
}
function renderErrorPage(title: string, message: string, orderId?: string, status = 400): NextResponse {
const html = renderHtml(
title,
`<main class="card">
<div class="icon">!</div>
<h1>${escapeHtml(title)}</h1>
<p>${escapeHtml(message)}</p>
${orderId ? `<div class="order">订单号:${escapeHtml(orderId)}</div>` : ''}
<a class="button secondary" href="${escapeHtml(buildAppUrl('/'))}">返回支付首页</a>
</main>`,
);
return new NextResponse(html, {
status,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
},
});
}
function renderStatusPage(order: ShortLinkOrderStatus): NextResponse {
const display = getStatusDisplay(order);
const html = renderHtml(
display.label,
`<main class="card">
<div class="icon">${escapeHtml(display.icon)}</div>
<h1>${escapeHtml(display.label)}</h1>
<p>${escapeHtml(display.message)}</p>
<div class="order">订单号:${escapeHtml(order.id)}</div>
<a class="button secondary" href="${escapeHtml(buildResultUrl(order.id))}">查看订单结果</a>
</main>`,
);
return new NextResponse(html, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
},
});
}
function renderRedirectPage(orderId: string, payUrl: string): NextResponse {
const html = renderHtml(
'正在跳转支付宝',
`<main class="card">
<div class="icon">支</div>
<h1>正在拉起支付宝</h1>
<p>请稍候,系统正在自动跳转到支付宝完成支付。</p>
<div class="spinner"></div>
<div class="order">订单号:${escapeHtml(orderId)}</div>
<p class="hint">如未自动拉起支付宝,请返回原充值页后重新发起支付。</p>
<a class="text-link" href="${escapeHtml(buildResultUrl(orderId))}">已支付?查看订单结果</a>
<script>
const payUrl = ${serializeScriptString(payUrl)};
window.location.replace(payUrl);
setTimeout(() => {
if (document.visibilityState === 'visible') {
window.location.replace(payUrl);
}
}, 800);
</script>
</main>`,
`<noscript><meta http-equiv="refresh" content="0;url=${escapeHtml(payUrl)}" /></noscript>`,
);
return new NextResponse(html, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
},
});
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ orderId: string }> }) {
const { orderId } = await params;
const order = await prisma.order.findUnique({
where: { id: orderId },
select: {
id: true,
amount: true,
payAmount: true,
paymentType: true,
status: true,
expiresAt: true,
paidAt: true,
completedAt: true,
},
});
if (!order) {
return renderErrorPage('订单不存在', '未找到对应订单,请确认二维码是否正确', orderId, 404);
}
if (order.paymentType !== 'alipay_direct') {
return renderErrorPage('支付方式不匹配', '该订单不是支付宝直连订单,无法通过当前链接支付', orderId, 400);
}
if (order.status !== ORDER_STATUS.PENDING) {
return renderStatusPage(order);
}
if (order.expiresAt.getTime() <= Date.now()) {
return renderStatusPage({
id: order.id,
status: ORDER_STATUS.EXPIRED,
paidAt: order.paidAt,
completedAt: order.completedAt,
});
}
const payAmount = Number(order.payAmount ?? order.amount);
if (!Number.isFinite(payAmount) || payAmount <= 0) {
return renderErrorPage('订单金额异常', '订单金额无效,请返回原页面重新发起支付', order.id, 500);
}
const env = getEnv();
const payUrl = buildAlipayPaymentUrl({
orderId: order.id,
amount: payAmount,
subject: `${env.PRODUCT_NAME} ${payAmount.toFixed(2)} CNY`,
notifyUrl: env.ALIPAY_NOTIFY_URL,
returnUrl: isAlipayAppRequest(request) ? null : buildResultUrl(order.id),
isMobile: isMobileRequest(request),
});
return renderRedirectPage(order.id, payUrl);
}

View File

@@ -7,6 +7,7 @@ import OrderFilterBar from '@/components/OrderFilterBar';
import OrderSummaryCards from '@/components/OrderSummaryCards';
import OrderTable from '@/components/OrderTable';
import PaginationBar from '@/components/PaginationBar';
import { applyLocaleToSearchParams, pickLocaleText, resolveLocale } from '@/lib/locale';
import { detectDeviceIsMobile, type UserInfo, type MyOrder, type OrderStatusFilter } from '@/lib/pay-utils';
const PAGE_SIZE_OPTIONS = [20, 50, 100];
@@ -20,12 +21,40 @@ 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';
const srcHost = searchParams.get('src_host') || '';
const locale = resolveLocale(searchParams.get('lang'));
const isDark = theme === 'dark';
const text = {
missingAuth: pickLocaleText(locale, '缺少认证信息', 'Missing authentication information'),
visitOrders: pickLocaleText(
locale,
'请从 Sub2API 平台正确访问订单页面',
'Please open the orders page from Sub2API',
),
sessionExpired: pickLocaleText(
locale,
'登录态已失效,请从 Sub2API 重新进入支付页。',
'Session expired. Please re-enter from Sub2API.',
),
loadFailed: pickLocaleText(locale, '订单加载失败,请稍后重试。', 'Failed to load orders. Please try again later.'),
networkError: pickLocaleText(locale, '网络错误,请稍后重试。', 'Network error. Please try again later.'),
switchingMobileTab: pickLocaleText(locale, '正在切换到移动端订单 Tab...', 'Switching to mobile orders tab...'),
myOrders: pickLocaleText(locale, '我的订单', 'My Orders'),
refresh: pickLocaleText(locale, '刷新', 'Refresh'),
backToPay: pickLocaleText(locale, '返回充值', 'Back to Top Up'),
loading: pickLocaleText(locale, '加载中...', 'Loading...'),
userPrefix: pickLocaleText(locale, '用户', 'User'),
authError: pickLocaleText(
locale,
'缺少认证信息,请从 Sub2API 平台正确访问订单页面',
'Missing authentication information. Please open the orders page from Sub2API.',
),
};
const [isIframeContext, setIsIframeContext] = useState(true);
const [isMobile, setIsMobile] = useState(false);
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
@@ -42,7 +71,6 @@ function OrdersContent() {
const isEmbedded = uiMode === 'embedded' && isIframeContext;
const hasToken = token.length > 0;
const effectiveUserId = resolvedUserId || userId;
useEffect(() => {
if (typeof window === 'undefined') return;
@@ -53,28 +81,21 @@ 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);
params.set('tab', 'orders');
applyLocaleToSearchParams(params, locale);
window.location.replace(`/pay?${params.toString()}`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMobile, isEmbedded]);
}, [isMobile, isEmbedded, token, theme, uiMode, locale]);
const loadOrders = async (targetPage = page, targetPageSize = pageSize) => {
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(text.authError);
return;
}
@@ -85,7 +106,7 @@ function OrdersContent() {
});
const res = await fetch(`/api/orders/my?${params}`);
if (!res.ok) {
setError(res.status === 401 ? '登录态已失效,请从 Sub2API 重新进入支付页。' : '订单加载失败,请稍后重试。');
setError(res.status === 401 ? text.sessionExpired : text.loadFailed);
setOrders([]);
return;
}
@@ -96,11 +117,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}`,
`${text.userPrefix} #${meId}`,
balance: typeof meUser.balance === 'number' ? meUser.balance : 0,
});
@@ -110,7 +131,7 @@ function OrdersContent() {
setTotalPages(data.total_pages ?? 1);
} catch {
setOrders([]);
setError('网络错误,请稍后重试。');
setError(text.networkError);
} finally {
setLoading(false);
}
@@ -119,8 +140,7 @@ function OrdersContent() {
useEffect(() => {
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);
@@ -133,28 +153,31 @@ 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'}`}>
Tab...
<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'}`}
>
{text.switchingMobileTab}
</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="mt-2 text-sm text-gray-500"> Sub2API 访</p>
<p className="text-lg font-medium">{text.missingAuth}</p>
<p className="mt-2 text-sm text-gray-500">{text.visitOrders}</p>
</div>
</div>
);
@@ -162,10 +185,10 @@ 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);
applyLocaleToSearchParams(params, locale);
return `${path}?${params.toString()}`;
};
@@ -173,22 +196,28 @@ function OrdersContent() {
<PayPageLayout
isDark={isDark}
isEmbedded={isEmbedded}
title="我的订单"
subtitle={userInfo?.username || `用户 #${effectiveUserId}`}
title={text.myOrders}
subtitle={userInfo?.username || text.myOrders}
actions={
<>
<button type="button" onClick={() => loadOrders(page, pageSize)} className={btnClass}></button>
<a href={buildScopedUrl('/pay')} className={btnClass}></a>
<button type="button" onClick={() => loadOrders(page, pageSize)} className={btnClass}>
{text.refresh}
</button>
{!srcHost && (
<a href={buildScopedUrl('/pay')} className={btnClass}>
{text.backToPay}
</a>
)}
</>
}
>
<OrderSummaryCards isDark={isDark} summary={summary} />
<OrderSummaryCards isDark={isDark} locale={locale} summary={summary} />
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<OrderFilterBar isDark={isDark} activeFilter={activeFilter} onChange={setActiveFilter} />
<OrderFilterBar isDark={isDark} locale={locale} activeFilter={activeFilter} onChange={setActiveFilter} />
</div>
<OrderTable isDark={isDark} loading={loading} error={error} orders={filteredOrders} />
<OrderTable isDark={isDark} locale={locale} loading={loading} error={error} orders={filteredOrders} />
<PaginationBar
page={page}
@@ -196,6 +225,7 @@ function OrdersContent() {
total={summary.total}
pageSize={pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
locale={locale}
isDark={isDark}
loading={loading}
onPageChange={handlePageChange}
@@ -205,9 +235,20 @@ function OrdersContent() {
);
}
function OrdersPageFallback() {
const searchParams = useSearchParams();
const locale = resolveLocale(searchParams.get('lang'));
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-gray-500">{pickLocaleText(locale, '加载中...', 'Loading...')}</div>
</div>
);
}
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={<OrdersPageFallback />}>
<OrdersContent />
</Suspense>
);

View File

@@ -7,18 +7,22 @@ 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 { resolveLocale, pickLocaleText, applyLocaleToSearchParams } from '@/lib/locale';
import { detectDeviceIsMobile, applySublabelOverrides, type UserInfo, type MyOrder } from '@/lib/pay-utils';
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
import type { MethodLimitInfo } from '@/components/PaymentForm';
interface OrderResult {
orderId: string;
amount: number;
payAmount?: number;
status: string;
paymentType: 'alipay' | 'wxpay' | 'stripe';
paymentType: string;
payUrl?: string | null;
qrCode?: string | null;
checkoutUrl?: string | null;
clientSecret?: string | null;
expiresAt: string;
statusAccessToken: string;
}
interface AppConfig {
@@ -27,15 +31,20 @@ interface AppConfig {
maxAmount: number;
maxDailyAmount: number;
methodLimits?: Record<string, MethodLimitInfo>;
helpImageUrl?: string | null;
helpText?: string | null;
stripePublishableKey?: string | null;
}
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';
const tab = searchParams.get('tab');
const srcHost = searchParams.get('src_host') || undefined;
const srcUrl = searchParams.get('src_url') || undefined;
const locale = resolveLocale(searchParams.get('lang'));
const isDark = theme === 'dark';
const [isIframeContext, setIsIframeContext] = useState(true);
@@ -44,7 +53,7 @@ function PayContent() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [orderResult, setOrderResult] = useState<OrderResult | null>(null);
const [finalStatus, setFinalStatus] = useState('');
const [finalOrderState, setFinalOrderState] = useState<PublicOrderStatusSnapshot | null>(null);
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const [resolvedUserId, setResolvedUserId] = useState<number | null>(null);
const [myOrders, setMyOrders] = useState<MyOrder[]>([]);
@@ -52,20 +61,24 @@ 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: ['alipay', 'wxpay', 'stripe'],
enabledPaymentTypes: [],
minAmount: 1,
maxAmount: 1000,
maxDailyAmount: 0,
});
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 helpImageUrl = (process.env.NEXT_PUBLIC_PAY_HELP_IMAGE_URL || '').trim();
const helpText = (process.env.NEXT_PUBLIC_PAY_HELP_TEXT || '').trim();
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;
@@ -84,11 +97,47 @@ 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}`);
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()) ||
pickLocaleText(locale, `用户 #${meId}`, `User #${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}&token=${encodeURIComponent(token)}`);
if (cfgRes.ok) {
const cfgData = await cfgRes.json();
if (cfgData.config) {
@@ -98,51 +147,16 @@ function PayContent() {
maxAmount: cfgData.config.maxAmount ?? 1000,
maxDailyAmount: cfgData.config.maxDailyAmount ?? 0,
methodLimits: cfgData.config.methodLimits,
helpImageUrl: cfgData.config.helpImageUrl ?? null,
helpText: cfgData.config.helpText ?? null,
stripePublishableKey: cfgData.config.stripePublishableKey ?? null,
});
if (cfgData.config.sublabelOverrides) {
applySublabelOverrides(cfgData.config.sublabelOverrides);
}
}
}
// 有 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);
}
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
}
} catch {}
};
const loadMoreOrders = async () => {
@@ -161,7 +175,6 @@ function PayContent() {
setOrdersHasMore(false);
}
} catch {
// ignore
} finally {
setOrdersLoadingMore(false);
}
@@ -169,15 +182,49 @@ function PayContent() {
useEffect(() => {
loadUserAndOrders();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId, token]);
}, [token, locale]);
if (!effectiveUserId || Number.isNaN(effectiveUserId) || effectiveUserId <= 0) {
useEffect(() => {
if (step !== 'result' || finalOrderState?.status !== 'COMPLETED') return;
loadUserAndOrders();
const timer = setTimeout(() => {
setStep('form');
setOrderResult(null);
setFinalOrderState(null);
setError('');
}, 2200);
return () => clearTimeout(timer);
}, [step, finalOrderState]);
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="mt-2 text-sm text-gray-500"> Sub2API 访</p>
<p className="text-lg font-medium">{pickLocaleText(locale, '缺少认证信息', 'Missing authentication info')}</p>
<p className="mt-2 text-sm text-gray-500">
{pickLocaleText(
locale,
'请从 Sub2API 平台正确访问充值页面',
'Please open the recharge page from the Sub2API platform',
)}
</p>
</div>
</div>
);
}
if (userNotFound) {
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">{pickLocaleText(locale, '用户不存在', 'User not found')}</p>
<p className="mt-2 text-sm text-gray-500">
{pickLocaleText(
locale,
'请检查链接是否正确,或联系管理员',
'Please check whether the link is correct or contact the administrator',
)}
</p>
</div>
</div>
);
@@ -185,11 +232,13 @@ 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);
if (forceOrdersTab) params.set('tab', 'orders');
if (srcHost) params.set('src_host', srcHost);
if (srcUrl) params.set('src_url', srcUrl);
applyLocaleToSearchParams(params, locale);
return `${path}?${params.toString()}`;
};
@@ -198,6 +247,17 @@ function PayContent() {
const ordersUrl = isMobile ? mobileOrdersUrl : pcOrdersUrl;
const handleSubmit = async (amount: number, paymentType: string) => {
if (pendingBlocked) {
setError(
pickLocaleText(
locale,
`您有 ${pendingCount} 个待支付订单,请先完成或取消后再试(最多 ${MAX_PENDING} 个)`,
`You have ${pendingCount} pending orders. Please complete or cancel them first (maximum ${MAX_PENDING}).`,
),
);
return;
}
setLoading(true);
setError('');
@@ -206,9 +266,12 @@ 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,
}),
});
@@ -216,38 +279,59 @@ function PayContent() {
if (!res.ok) {
const codeMessages: Record<string, string> = {
USER_INACTIVE: '账户已被禁用,无法充值,请联系管理员',
TOO_MANY_PENDING: '您有过多待支付订单,请先完成或取消现有订单后再试',
USER_NOT_FOUND: '用户不存在,请检查链接是否正确',
INVALID_TOKEN: pickLocaleText(
locale,
'认证已失效,请重新从平台进入充值页面',
'Authentication expired. Please re-enter the recharge page from the platform',
),
USER_INACTIVE: pickLocaleText(
locale,
'账户已被禁用,无法充值,请联系管理员',
'This account is disabled and cannot be recharged. Please contact the administrator',
),
TOO_MANY_PENDING: pickLocaleText(
locale,
'您有过多待支付订单,请先完成或取消现有订单后再试',
'You have too many pending orders. Please complete or cancel existing orders first',
),
USER_NOT_FOUND: pickLocaleText(
locale,
'用户不存在,请检查链接是否正确',
'User not found. Please check whether the link is correct',
),
DAILY_LIMIT_EXCEEDED: data.error,
METHOD_DAILY_LIMIT_EXCEEDED: data.error,
PAYMENT_GATEWAY_ERROR: data.error,
};
setError(codeMessages[data.code] || data.error || '创建订单失败');
setError(
codeMessages[data.code] || data.error || pickLocaleText(locale, '创建订单失败', 'Failed to create order'),
);
return;
}
setOrderResult({
orderId: data.orderId,
amount: data.amount,
payAmount: data.payAmount,
status: data.status,
paymentType: data.paymentType || paymentType,
payUrl: data.payUrl,
qrCode: data.qrCode,
checkoutUrl: data.checkoutUrl,
clientSecret: data.clientSecret,
expiresAt: data.expiresAt,
statusAccessToken: data.statusAccessToken,
});
setStep('paying');
} catch {
setError('网络错误,请稍后重试');
setError(pickLocaleText(locale, '网络错误,请稍后重试', 'Network error. Please try again later'));
} finally {
setLoading(false);
}
};
const handleStatusChange = (status: string) => {
setFinalStatus(status);
const handleStatusChange = (order: PublicOrderStatusSnapshot) => {
setFinalOrderState(order);
setStep('result');
if (isMobile) {
setActiveMobileTab('orders');
@@ -257,57 +341,55 @@ function PayContent() {
const handleBack = () => {
setStep('form');
setOrderResult(null);
setFinalStatus('');
setFinalOrderState(null);
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}
isEmbedded={isEmbedded}
maxWidth={isMobile ? 'sm' : 'full'}
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}
maxWidth={isMobile ? 'sm' : 'lg'}
title={pickLocaleText(locale, 'Sub2API 余额充值', 'Sub2API Balance Recharge')}
subtitle={pickLocaleText(locale, '安全支付,自动到账', 'Secure payment, automatic crediting')}
locale={locale}
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(' ')}
>
{pickLocaleText(locale, '刷新', 'Refresh')}
</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(' ')}
>
{pickLocaleText(locale, '我的订单', 'My Orders')}
</a>
</>
) : undefined
}
>
{error && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
<div
className={[
'mb-4 rounded-lg border p-3 text-sm',
isDark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600',
].join(' ')}
>
{error}
</div>
)}
@@ -325,13 +407,15 @@ 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(' ')}
>
{pickLocaleText(locale, '充值', 'Recharge')}
</button>
<button
type="button"
@@ -339,23 +423,34 @@ 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(' ')}
>
{pickLocaleText(locale, '我的订单', 'My Orders')}
</button>
</div>
)}
{step === 'form' && (
{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(' ')}>
{pickLocaleText(locale, '加载中...', 'Loading...')}
</span>
</div>
)}
{step === 'form' && config.enabledPaymentTypes.length > 0 && (
<>
{isMobile ? (
activeMobileTab === 'pay' ? (
<PaymentForm
userId={effectiveUserId}
userId={resolvedUserId ?? 0}
userName={userInfo?.username}
userBalance={userInfo?.balance}
enabledPaymentTypes={config.enabledPaymentTypes}
@@ -365,6 +460,9 @@ function PayContent() {
onSubmit={handleSubmit}
loading={loading}
dark={isDark}
pendingBlocked={pendingBlocked}
pendingCount={pendingCount}
locale={locale}
/>
) : (
<MobileOrderList
@@ -375,50 +473,91 @@ function PayContent() {
loadingMore={ordersLoadingMore}
onRefresh={loadUserAndOrders}
onLoadMore={loadMoreOrders}
locale={locale}
/>
)
) : (
<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}
methodLimits={config.methodLimits}
minAmount={config.minAmount}
maxAmount={config.maxAmount}
onSubmit={handleSubmit}
loading={loading}
dark={isDark}
pendingBlocked={pendingBlocked}
pendingCount={pendingCount}
locale={locale}
/>
</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={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}></div>
<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(' ')}>
{pickLocaleText(locale, '支付说明', 'Payment Notes')}
</div>
<ul className={['mt-2 space-y-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
<li></li>
<li>"我的订单"</li>
<li>
{pickLocaleText(
locale,
'订单完成后会自动到账',
'Balance will be credited automatically after the order completes',
)}
</li>
<li>
{pickLocaleText(
locale,
'如需历史记录请查看「我的订单」',
'Check "My Orders" for payment history',
)}
</li>
{config.maxDailyAmount > 0 && (
<li> ¥{config.maxDailyAmount.toFixed(2)}</li>
<li>
{pickLocaleText(locale, '每日最大充值', 'Maximum daily recharge')} ¥
{config.maxDailyAmount.toFixed(2)}
</li>
)}
{!hasToken && <li className={isDark ? 'text-amber-200' : 'text-amber-700'}> token</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={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>Support</div>
<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(' ')}>
{pickLocaleText(locale, '帮助', 'Support')}
</div>
{helpImageUrl && (
<img
src={helpImageUrl}
alt='help'
className='mt-3 max-h-40 w-full rounded-lg object-contain bg-white/70 p-2'
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"
/>
)}
{helpText && (
<p className={['mt-3 text-sm leading-6', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
{helpText}
</p>
<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>
))}
</div>
)}
</div>
)}
@@ -434,32 +573,65 @@ function PayContent() {
token={token || undefined}
payUrl={orderResult.payUrl}
qrCode={orderResult.qrCode}
checkoutUrl={orderResult.checkoutUrl}
clientSecret={orderResult.clientSecret}
stripePublishableKey={config.stripePublishableKey}
paymentType={orderResult.paymentType}
amount={orderResult.amount}
payAmount={orderResult.payAmount}
expiresAt={orderResult.expiresAt}
statusAccessToken={orderResult.statusAccessToken}
onStatusChange={handleStatusChange}
onBack={handleBack}
dark={isDark}
isEmbedded={isEmbedded}
isMobile={isMobile}
locale={locale}
/>
)}
{step === 'result' && (
<OrderStatus status={finalStatus} onBack={handleBack} dark={isDark} />
{step === 'result' && orderResult && finalOrderState && (
<OrderStatus
orderId={orderResult.orderId}
order={finalOrderState}
statusAccessToken={orderResult.statusAccessToken}
onStateChange={setFinalOrderState}
onBack={handleBack}
dark={isDark}
locale={locale}
/>
)}
{helpImageOpen && helpImageUrl && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm"
onClick={() => setHelpImageOpen(false)}
>
<img
src={helpImageUrl}
alt="help"
className="max-h-[90vh] max-w-full rounded-xl object-contain shadow-2xl"
onClick={(e) => e.stopPropagation()}
/>
</div>
)}
</PayPageLayout>
);
}
function PayPageFallback() {
const searchParams = useSearchParams();
const locale = resolveLocale(searchParams.get('lang'));
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-gray-500">{pickLocaleText(locale, '加载中...', 'Loading...')}</div>
</div>
);
}
export default function PayPage() {
return (
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center">
<div className="text-gray-500">...</div>
</div>
}
>
<Suspense fallback={<PayPageFallback />}>
<PayContent />
</Suspense>
);

View File

@@ -2,107 +2,292 @@
import { useSearchParams } from 'next/navigation';
import { useEffect, useState, Suspense } from 'react';
import { applyLocaleToSearchParams, pickLocaleText, resolveLocale, type Locale } from '@/lib/locale';
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
import { buildOrderStatusUrl } from '@/lib/order/status-url';
type WindowWithAlipayBridge = Window & {
AlipayJSBridge?: {
call: (name: string, params?: unknown, callback?: (...args: unknown[]) => void) => void;
};
};
function tryCloseViaAlipayBridge(): boolean {
const bridge = (window as WindowWithAlipayBridge).AlipayJSBridge;
if (!bridge?.call) {
return false;
}
try {
bridge.call('closeWebview');
return true;
} catch {
return false;
}
}
function closeCurrentWindow() {
if (tryCloseViaAlipayBridge()) {
return;
}
let settled = false;
const handleBridgeReady = () => {
if (settled) {
return;
}
settled = true;
document.removeEventListener('AlipayJSBridgeReady', handleBridgeReady);
if (!tryCloseViaAlipayBridge()) {
window.close();
}
};
document.addEventListener('AlipayJSBridgeReady', handleBridgeReady, { once: true });
window.setTimeout(() => {
if (settled) {
return;
}
settled = true;
document.removeEventListener('AlipayJSBridgeReady', handleBridgeReady);
window.close();
}, 250);
}
function getStatusConfig(order: PublicOrderStatusSnapshot | null, locale: Locale, hasAccessToken: boolean) {
if (!order) {
return locale === 'en'
? {
label: 'Payment Error',
color: 'text-red-600',
icon: '✗',
message: hasAccessToken
? 'Unable to load the order status. Please try again later.'
: 'Missing order access token. Please go back to the recharge page.',
}
: {
label: '支付异常',
color: 'text-red-600',
icon: '✗',
message: hasAccessToken ? '未查询到订单状态,请稍后重试。' : '订单访问凭证缺失,请返回原充值页查看订单结果。',
};
}
if (order.rechargeSuccess) {
return locale === 'en'
? {
label: 'Recharge Successful',
color: 'text-green-600',
icon: '✓',
message: 'Your balance has been credited successfully.',
}
: { label: '充值成功', color: 'text-green-600', icon: '✓', message: '余额已成功到账!' };
}
if (order.paymentSuccess) {
if (order.rechargeStatus === 'paid_pending' || order.rechargeStatus === 'recharging') {
return locale === 'en'
? {
label: 'Top-up Processing',
color: 'text-blue-600',
icon: '⟳',
message: 'Payment succeeded, and the balance top-up is being processed.',
}
: { label: '充值处理中', color: 'text-blue-600', icon: '⟳', message: '支付成功,余额正在充值中...' };
}
if (order.rechargeStatus === 'failed') {
return locale === 'en'
? {
label: 'Payment Successful',
color: 'text-amber-600',
icon: '!',
message:
'Payment succeeded, but the balance top-up has not completed yet. Please check again later or contact the administrator.',
}
: {
label: '支付成功',
color: 'text-amber-600',
icon: '!',
message: '支付成功,但余额充值暂未完成,请稍后查看订单结果或联系管理员。',
};
}
}
if (order.status === 'PENDING') {
return locale === 'en'
? { label: 'Awaiting Payment', color: 'text-yellow-600', icon: '⏳', message: 'The order has not been paid yet.' }
: { label: '等待支付', color: 'text-yellow-600', icon: '⏳', message: '订单尚未完成支付。' };
}
if (order.status === 'EXPIRED') {
return locale === 'en'
? {
label: 'Order Expired',
color: 'text-gray-500',
icon: '⏰',
message: 'This order has expired. Please create a new order.',
}
: { label: '订单已超时', color: 'text-gray-500', icon: '⏰', message: '订单已超时,请重新充值。' };
}
if (order.status === 'CANCELLED') {
return locale === 'en'
? { label: 'Order Cancelled', color: 'text-gray-500', icon: '✗', message: 'This order has been cancelled.' }
: { label: '订单已取消', color: 'text-gray-500', icon: '✗', message: '订单已被取消。' };
}
return locale === 'en'
? { label: 'Payment Error', color: 'text-red-600', icon: '✗', message: 'Please contact the administrator.' }
: { label: '支付异常', color: 'text-red-600', icon: '✗', message: '请联系管理员处理。' };
}
function ResultContent() {
const searchParams = useSearchParams();
// Support both ZPAY (out_trade_no) and Stripe (order_id) callback params
const outTradeNo = searchParams.get('out_trade_no') || searchParams.get('order_id');
const tradeStatus = searchParams.get('trade_status') || searchParams.get('status');
const accessToken = searchParams.get('access_token');
const isPopup = searchParams.get('popup') === '1';
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
const locale = resolveLocale(searchParams.get('lang'));
const isDark = theme === 'dark';
const [status, setStatus] = useState<string | null>(null);
const text = {
checking: pickLocaleText(locale, '查询支付结果中...', 'Checking payment result...'),
back: pickLocaleText(locale, '返回', 'Back'),
closeSoon: pickLocaleText(locale, '此窗口将在 3 秒后自动关闭', 'This window will close automatically in 3 seconds'),
closeNow: pickLocaleText(locale, '立即关闭窗口', 'Close now'),
orderId: pickLocaleText(locale, '订单号', 'Order ID'),
unknown: pickLocaleText(locale, '未知', 'Unknown'),
};
const [orderState, setOrderState] = useState<PublicOrderStatusSnapshot | null>(null);
const [loading, setLoading] = useState(true);
const [isInPopup, setIsInPopup] = useState(false);
useEffect(() => {
if (!outTradeNo) {
if (isPopup || window.opener) {
setIsInPopup(true);
}
}, [isPopup]);
useEffect(() => {
if (!outTradeNo || !accessToken || accessToken.length < 10) {
setLoading(false);
return;
}
const checkOrder = async () => {
try {
const res = await fetch(`/api/orders/${outTradeNo}`);
const res = await fetch(buildOrderStatusUrl(outTradeNo, accessToken));
if (res.ok) {
const data = await res.json();
setStatus(data.status);
const data = (await res.json()) as PublicOrderStatusSnapshot;
setOrderState(data);
}
} catch {
// ignore
} finally {
setLoading(false);
}
};
checkOrder();
// Poll a few times in case status hasn't updated yet
const timer = setInterval(checkOrder, 3000);
const timeout = setTimeout(() => clearInterval(timer), 30000);
return () => {
clearInterval(timer);
clearTimeout(timeout);
};
}, [outTradeNo]);
}, [outTradeNo, accessToken]);
const shouldAutoClose = Boolean(orderState?.paymentSuccess);
const goBack = () => {
if (isInPopup) {
closeCurrentWindow();
return;
}
if (window.history.length > 1) {
window.history.back();
return;
}
const params = new URLSearchParams();
params.set('theme', theme);
applyLocaleToSearchParams(params, locale);
window.location.replace(`/pay?${params.toString()}`);
};
useEffect(() => {
if (!isInPopup || !shouldAutoClose) return;
const timer = setTimeout(() => {
closeCurrentWindow();
}, 3000);
return () => clearTimeout(timer);
}, [isInPopup, shouldAutoClose]);
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-gray-500">...</div>
<div className={`flex min-h-screen items-center justify-center ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
<div className={isDark ? 'text-slate-400' : 'text-gray-500'}>{text.checking}</div>
</div>
);
}
const isSuccess = status === 'COMPLETED' || status === 'PAID' || status === 'RECHARGING';
const isPending = status === 'PENDING';
const display = getStatusConfig(orderState, locale, Boolean(accessToken));
return (
<div className="flex min-h-screen items-center justify-center p-4">
<div className="w-full max-w-md rounded-xl bg-white p-8 text-center shadow-lg">
{isSuccess ? (
<>
<div className="text-6xl text-green-500"></div>
<h1 className="mt-4 text-xl font-bold text-green-600">
{status === 'COMPLETED' ? '充值成功' : '充值处理中'}
</h1>
<p className="mt-2 text-gray-500">
{status === 'COMPLETED' ? '余额已成功到账!' : '支付成功,余额正在充值中...'}
</p>
</>
) : isPending ? (
<>
<div className="text-6xl text-yellow-500"></div>
<h1 className="mt-4 text-xl font-bold text-yellow-600"></h1>
<p className="mt-2 text-gray-500"></p>
</>
<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 rounded-xl p-8 text-center shadow-lg',
isDark ? 'bg-slate-900 text-slate-100' : 'bg-white',
].join(' ')}
>
<div className={`text-6xl ${display.color}`}>{display.icon}</div>
<h1 className={`mt-4 text-xl font-bold ${display.color}`}>{display.label}</h1>
<p className={isDark ? 'mt-2 text-slate-400' : 'mt-2 text-gray-500'}>{display.message}</p>
{isInPopup ? (
shouldAutoClose && (
<div className="mt-4 space-y-2">
<p className={isDark ? 'text-sm text-slate-500' : 'text-sm text-gray-400'}>{text.closeSoon}</p>
<button
type="button"
onClick={closeCurrentWindow}
className="text-sm text-blue-600 underline hover:text-blue-700"
>
{text.closeNow}
</button>
</div>
)
) : (
<>
<div className="text-6xl text-red-500"></div>
<h1 className="mt-4 text-xl font-bold text-red-600">
{status === 'EXPIRED' ? '订单已超时' : status === 'CANCELLED' ? '订单已取消' : '支付异常'}
</h1>
<p className="mt-2 text-gray-500">
{status === 'EXPIRED'
? '订单已超时,请重新充值'
: status === 'CANCELLED'
? '订单已被取消'
: '请联系管理员处理'}
</p>
</>
<button type="button" onClick={goBack} className="mt-4 text-sm text-blue-600 underline hover:text-blue-700">
{text.back}
</button>
)}
<p className="mt-4 text-xs text-gray-400">: {outTradeNo || '未知'}</p>
<p className={isDark ? 'mt-4 text-xs text-slate-500' : 'mt-4 text-xs text-gray-400'}>
{text.orderId}: {outTradeNo || text.unknown}
</p>
</div>
</div>
);
}
function ResultPageFallback() {
const searchParams = useSearchParams();
const locale = resolveLocale(searchParams.get('lang'));
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50">
<div className="text-gray-500">{pickLocaleText(locale, '加载中...', 'Loading...')}</div>
</div>
);
}
export default function PayResultPage() {
return (
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center">
<div className="text-gray-500">...</div>
</div>
}
>
<Suspense fallback={<ResultPageFallback />}>
<ResultContent />
</Suspense>
);

View File

@@ -0,0 +1,319 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { useEffect, useState, useCallback, Suspense } from 'react';
import { applyLocaleToSearchParams, pickLocaleText, resolveLocale } from '@/lib/locale';
import { getPaymentMeta } from '@/lib/pay-utils';
function StripePopupContent() {
const searchParams = useSearchParams();
const orderId = searchParams.get('order_id') || '';
const amount = parseFloat(searchParams.get('amount') || '0') || 0;
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
const method = searchParams.get('method') || '';
const accessToken = searchParams.get('access_token');
const locale = resolveLocale(searchParams.get('lang'));
const isDark = theme === 'dark';
const isAlipay = method === 'alipay';
const text = {
init: pickLocaleText(locale, '正在初始化...', 'Initializing...'),
orderId: pickLocaleText(locale, '订单号', 'Order ID'),
loadFailed: pickLocaleText(
locale,
'支付组件加载失败,请关闭窗口重试',
'Failed to load payment component. Please close the window and try again.',
),
payFailed: pickLocaleText(locale, '支付失败,请重试', 'Payment failed. Please try again.'),
closeWindow: pickLocaleText(locale, '关闭窗口', 'Close window'),
redirecting: pickLocaleText(locale, '正在跳转到支付页面...', 'Redirecting to payment page...'),
loadingForm: pickLocaleText(locale, '正在加载支付表单...', 'Loading payment form...'),
successClosing: pickLocaleText(
locale,
'支付成功,窗口即将自动关闭...',
'Payment successful. This window will close automatically...',
),
closeWindowManually: pickLocaleText(locale, '手动关闭窗口', 'Close window manually'),
processing: pickLocaleText(locale, '处理中...', 'Processing...'),
payAmount: pickLocaleText(locale, `支付 ¥${amount.toFixed(2)}`, `Pay ¥${amount.toFixed(2)}`),
};
const [credentials, setCredentials] = useState<{
clientSecret: string;
publishableKey: string;
} | null>(null);
const [stripeLoaded, setStripeLoaded] = useState(false);
const [stripeSubmitting, setStripeSubmitting] = useState(false);
const [stripeError, setStripeError] = useState('');
const [stripeSuccess, setStripeSuccess] = useState(false);
const [stripeLib, setStripeLib] = useState<{
stripe: import('@stripe/stripe-js').Stripe;
elements: import('@stripe/stripe-js').StripeElements;
} | null>(null);
const buildReturnUrl = useCallback(() => {
const returnUrl = new URL(window.location.href);
returnUrl.pathname = '/pay/result';
returnUrl.search = '';
returnUrl.searchParams.set('order_id', orderId);
returnUrl.searchParams.set('status', 'success');
returnUrl.searchParams.set('popup', '1');
returnUrl.searchParams.set('theme', theme);
if (accessToken) {
returnUrl.searchParams.set('access_token', accessToken);
}
applyLocaleToSearchParams(returnUrl.searchParams, locale);
return returnUrl.toString();
}, [orderId, theme, locale, accessToken]);
useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== 'STRIPE_POPUP_INIT') return;
const { clientSecret, publishableKey } = event.data;
if (clientSecret && publishableKey) {
setCredentials({ clientSecret, publishableKey });
}
};
window.addEventListener('message', handler);
if (window.opener) {
window.opener.postMessage({ type: 'STRIPE_POPUP_READY' }, window.location.origin);
}
return () => window.removeEventListener('message', handler);
}, []);
useEffect(() => {
if (!credentials) return;
let cancelled = false;
const { clientSecret, publishableKey } = credentials;
import('@stripe/stripe-js').then(({ loadStripe }) => {
loadStripe(publishableKey).then((stripe) => {
if (cancelled || !stripe) {
if (!cancelled) {
setStripeError(text.loadFailed);
setStripeLoaded(true);
}
return;
}
if (isAlipay) {
stripe
.confirmAlipayPayment(clientSecret, {
return_url: buildReturnUrl(),
})
.then((result) => {
if (cancelled) return;
if (result.error) {
setStripeError(result.error.message || text.payFailed);
setStripeLoaded(true);
}
});
return;
}
const elements = stripe.elements({
clientSecret,
appearance: {
theme: isDark ? 'night' : 'stripe',
variables: { borderRadius: '8px' },
},
});
setStripeLib({ stripe, elements });
setStripeLoaded(true);
});
});
return () => {
cancelled = true;
};
}, [credentials, isDark, isAlipay, buildReturnUrl, text.loadFailed, text.payFailed]);
const stripeContainerRef = useCallback(
(node: HTMLDivElement | null) => {
if (!node || !stripeLib) return;
const existing = stripeLib.elements.getElement('payment');
if (existing) {
existing.mount(node);
} else {
stripeLib.elements.create('payment', { layout: 'tabs' }).mount(node);
}
},
[stripeLib],
);
const handleSubmit = async () => {
if (!stripeLib || stripeSubmitting) return;
setStripeSubmitting(true);
setStripeError('');
const { stripe, elements } = stripeLib;
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: buildReturnUrl(),
},
redirect: 'if_required',
});
if (error) {
setStripeError(error.message || text.payFailed);
setStripeSubmitting(false);
} else {
setStripeSuccess(true);
setStripeSubmitting(false);
}
};
useEffect(() => {
if (!stripeSuccess) return;
const timer = setTimeout(() => {
window.close();
}, 2000);
return () => clearTimeout(timer);
}, [stripeSuccess]);
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="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'}`}>{text.init}</span>
</div>
</div>
</div>
);
}
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="text-center">
<div className="text-3xl font-bold text-blue-600">
{'¥'}
{amount.toFixed(2)}
</div>
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{text.orderId}: {orderId}
</p>
</div>
{stripeError ? (
<div className="space-y-3">
<div
className={`rounded-lg border p-3 text-sm ${isDark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}
>
{stripeError}
</div>
<button
type="button"
onClick={() => window.close()}
className="w-full text-sm text-blue-600 underline hover:text-blue-700"
>
{text.closeWindow}
</button>
</div>
) : (
<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'}`}>{text.redirecting}</span>
</div>
)}
</div>
</div>
);
}
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="text-center">
<div className="text-3xl font-bold text-blue-600">
{'¥'}
{amount.toFixed(2)}
</div>
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{text.orderId}: {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'}`}>{text.loadingForm}</span>
</div>
) : stripeSuccess ? (
<div className="py-6 text-center">
<div className="text-5xl text-green-600">{'✓'}</div>
<p className={`mt-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.successClosing}</p>
<button
type="button"
onClick={() => window.close()}
className="mt-4 text-sm text-blue-600 underline hover:text-blue-700"
>
{text.closeWindowManually}
</button>
</div>
) : (
<>
{stripeError && (
<div
className={`rounded-lg border p-3 text-sm ${isDark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}
>
{stripeError}
</div>
)}
<div
ref={stripeContainerRef}
className={`rounded-lg border p-4 ${isDark ? 'border-slate-700 bg-slate-800' : 'border-gray-200 bg-white'}`}
/>
<button
type="button"
disabled={stripeSubmitting}
onClick={handleSubmit}
className={[
'w-full rounded-lg py-3 font-medium text-white shadow-md transition-colors',
stripeSubmitting ? 'bg-gray-400 cursor-not-allowed' : getPaymentMeta('stripe').buttonClass,
].join(' ')}
>
{stripeSubmitting ? (
<span className="inline-flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
{text.processing}
</span>
) : (
text.payAmount
)}
</button>
</>
)}
</div>
</div>
);
}
function StripePopupFallback() {
const searchParams = useSearchParams();
const locale = resolveLocale(searchParams.get('lang'));
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-gray-500">{pickLocaleText(locale, '加载中...', 'Loading...')}</div>
</div>
);
}
export default function StripePopupPage() {
return (
<Suspense fallback={<StripePopupFallback />}>
<StripePopupContent />
</Suspense>
);
}

View File

@@ -2,10 +2,12 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import OrderFilterBar from '@/components/OrderFilterBar';
import type { Locale } from '@/lib/locale';
import {
formatStatus,
formatCreatedAt,
getStatusBadgeClass,
getPaymentDisplayInfo,
type MyOrder,
type OrderStatusFilter,
} from '@/lib/pay-utils';
@@ -18,6 +20,7 @@ interface MobileOrderListProps {
loadingMore: boolean;
onRefresh: () => void;
onLoadMore: () => void;
locale?: Locale;
}
export default function MobileOrderList({
@@ -28,6 +31,7 @@ export default function MobileOrderList({
loadingMore,
onRefresh,
onLoadMore,
locale = 'zh',
}: MobileOrderListProps) {
const [activeFilter, setActiveFilter] = useState<OrderStatusFilter>('ALL');
const sentinelRef = useRef<HTMLDivElement>(null);
@@ -58,7 +62,7 @@ export default function MobileOrderList({
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className={['text-base font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
{locale === 'en' ? 'My Orders' : '我的订单'}
</h3>
<button
type="button"
@@ -70,11 +74,11 @@ export default function MobileOrderList({
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
].join(' ')}
>
{locale === 'en' ? 'Refresh' : '刷新'}
</button>
</div>
<OrderFilterBar isDark={isDark} activeFilter={activeFilter} onChange={setActiveFilter} />
<OrderFilterBar isDark={isDark} locale={locale} activeFilter={activeFilter} onChange={setActiveFilter} />
{!hasToken ? (
<div
@@ -83,7 +87,9 @@ export default function MobileOrderList({
isDark ? 'border-amber-500/40 text-amber-200' : 'border-amber-300 text-amber-700',
].join(' ')}
>
token&ldquo;&rdquo;
{locale === 'en'
? 'The current link does not include a login token, so "My Orders" is unavailable.'
: '当前链接未携带登录 token无法查询"我的订单"。'}
</div>
) : filteredOrders.length === 0 ? (
<div
@@ -92,7 +98,7 @@ export default function MobileOrderList({
isDark ? 'border-slate-600 text-slate-400' : 'border-slate-300 text-slate-500',
].join(' ')}
>
{locale === 'en' ? 'No matching orders found' : '暂无符合条件的订单记录'}
</div>
) : (
<div className="space-y-2">
@@ -109,28 +115,27 @@ export default function MobileOrderList({
<span
className={['rounded-full px-2 py-0.5 text-xs', getStatusBadgeClass(order.status, isDark)].join(' ')}
>
{formatStatus(order.status)}
{formatStatus(order.status, locale)}
</span>
</div>
<div className={['mt-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
{order.paymentType}
{getPaymentDisplayInfo(order.paymentType, locale).channel}
</div>
<div className={['mt-0.5 text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{formatCreatedAt(order.createdAt)}
{formatCreatedAt(order.createdAt, locale)}
</div>
</div>
))}
{/* 无限滚动哨兵 */}
{hasMore && (
<div ref={sentinelRef} className="py-3 text-center">
{loadingMore ? (
<span className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
...
{locale === 'en' ? 'Loading...' : '加载中...'}
</span>
) : (
<span className={['text-xs', isDark ? 'text-slate-600' : 'text-slate-300'].join(' ')}>
{locale === 'en' ? 'Scroll up to load more' : '上滑加载更多'}
</span>
)}
</div>
@@ -138,7 +143,7 @@ export default function MobileOrderList({
{!hasMore && orders.length > 0 && (
<div className={['py-2 text-center text-xs', isDark ? 'text-slate-600' : 'text-slate-400'].join(' ')}>
{locale === 'en' ? 'All orders loaded' : '已显示全部订单'}
</div>
)}
</div>

View File

@@ -1,15 +1,17 @@
import { FILTER_OPTIONS, type OrderStatusFilter } from '@/lib/pay-utils';
import type { Locale } from '@/lib/locale';
import { getFilterOptions, type OrderStatusFilter } from '@/lib/pay-utils';
interface OrderFilterBarProps {
isDark: boolean;
locale: Locale;
activeFilter: OrderStatusFilter;
onChange: (filter: OrderStatusFilter) => void;
}
export default function OrderFilterBar({ isDark, activeFilter, onChange }: OrderFilterBarProps) {
export default function OrderFilterBar({ isDark, locale, activeFilter, onChange }: OrderFilterBarProps) {
return (
<div className="flex flex-wrap gap-2">
{FILTER_OPTIONS.map((item) => (
{getFilterOptions(locale).map((item) => (
<button
key={item.key}
type="button"

View File

@@ -1,68 +1,178 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import type { Locale } from '@/lib/locale';
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
import { buildOrderStatusUrl } from '@/lib/order/status-url';
interface OrderStatusProps {
status: string;
orderId: string;
order: PublicOrderStatusSnapshot;
statusAccessToken?: string;
onBack: () => void;
onStateChange?: (order: PublicOrderStatusSnapshot) => void;
dark?: boolean;
locale?: Locale;
}
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: string; message: string }> = {
COMPLETED: {
label: '充值成功',
color: 'text-green-600',
icon: '✓',
message: '余额已到账,感谢您的充值!',
},
PAID: {
label: '充值中',
color: 'text-blue-600',
icon: '⟳',
message: '支付成功,正在充值余额中...',
},
RECHARGING: {
label: '充值中',
color: 'text-blue-600',
icon: '⟳',
message: '正在充值余额中,请稍候...',
},
FAILED: {
label: '充值失败',
color: 'text-red-600',
icon: '✗',
message: '充值失败,请联系管理员处理。',
},
EXPIRED: {
label: '订单超时',
color: 'text-gray-500',
icon: '⏰',
message: '订单已超时,请重新创建订单。',
},
CANCELLED: {
label: '已取消',
color: 'text-gray-500',
icon: '✗',
message: '订单已取消。',
},
};
function getStatusConfig(order: PublicOrderStatusSnapshot, locale: Locale) {
if (order.rechargeSuccess) {
return locale === 'en'
? {
label: 'Recharge Successful',
color: 'text-green-600',
icon: '✓',
message: 'Your balance has been credited. Thank you for your payment.',
}
: { label: '充值成功', color: 'text-green-600', icon: '✓', message: '余额已到账,感谢您的充值!' };
}
export default function OrderStatus({ status, onBack, dark = false }: OrderStatusProps) {
const config = STATUS_CONFIG[status] || {
label: status,
color: 'text-gray-600',
icon: '?',
message: '未知状态',
};
if (order.paymentSuccess) {
if (order.rechargeStatus === 'paid_pending' || order.rechargeStatus === 'recharging') {
return locale === 'en'
? {
label: 'Recharging',
color: 'text-blue-600',
icon: '⟳',
message: 'Payment received. Recharging your balance...',
}
: { label: '充值中', color: 'text-blue-600', icon: '⟳', message: '支付成功,正在充值余额中,请稍候...' };
}
if (order.rechargeStatus === 'failed') {
return locale === 'en'
? {
label: 'Payment Successful',
color: 'text-amber-600',
icon: '!',
message:
'Payment completed, but the balance top-up has not finished yet. The system may retry automatically. Please check the order list later or contact the administrator if it remains unresolved.',
}
: {
label: '支付成功',
color: 'text-amber-600',
icon: '!',
message:
'支付已完成,但余额充值暂未完成。系统可能会自动重试,请稍后在订单列表查看;如长时间未到账请联系管理员。',
};
}
}
if (order.status === 'FAILED') {
return locale === 'en'
? {
label: 'Payment Failed',
color: 'text-red-600',
icon: '✗',
message:
'Payment was not completed. Please try again. If funds were deducted but not credited, contact the administrator.',
}
: {
label: '支付失败',
color: 'text-red-600',
icon: '✗',
message: '支付未完成,请重新发起支付;如已扣款未到账,请联系管理员处理。',
};
}
if (order.status === 'PENDING') {
return locale === 'en'
? { label: 'Awaiting Payment', color: 'text-yellow-600', icon: '⏳', message: 'The order has not been paid yet.' }
: { label: '等待支付', color: 'text-yellow-600', icon: '⏳', message: '订单尚未完成支付。' };
}
if (order.status === 'EXPIRED') {
return locale === 'en'
? {
label: 'Order Expired',
color: 'text-gray-500',
icon: '⏰',
message: 'This order has expired. Please create a new one.',
}
: { label: '订单超时', color: 'text-gray-500', icon: '⏰', message: '订单已超时,请重新创建订单。' };
}
if (order.status === 'CANCELLED') {
return locale === 'en'
? { label: 'Cancelled', color: 'text-gray-500', icon: '✗', message: 'The order has been cancelled.' }
: { label: '已取消', color: 'text-gray-500', icon: '✗', message: '订单已取消。' };
}
return locale === 'en'
? {
label: 'Payment Error',
color: 'text-red-600',
icon: '✗',
message: 'Payment status is abnormal. Please contact the administrator.',
}
: { label: '支付异常', color: 'text-red-600', icon: '✗', message: '支付状态异常,请联系管理员处理。' };
}
export default function OrderStatus({
orderId,
order,
statusAccessToken,
onBack,
onStateChange,
dark = false,
locale = 'zh',
}: OrderStatusProps) {
const [currentOrder, setCurrentOrder] = useState(order);
const onStateChangeRef = useRef(onStateChange);
useEffect(() => {
onStateChangeRef.current = onStateChange;
});
useEffect(() => {
setCurrentOrder(order);
}, [order]);
useEffect(() => {
if (!orderId || !currentOrder.paymentSuccess || currentOrder.rechargeSuccess) {
return;
}
let cancelled = false;
const refreshOrder = async () => {
try {
const response = await fetch(buildOrderStatusUrl(orderId, statusAccessToken));
if (!response.ok) return;
const nextOrder = (await response.json()) as PublicOrderStatusSnapshot;
if (cancelled) return;
setCurrentOrder(nextOrder);
onStateChangeRef.current?.(nextOrder);
} catch {}
};
refreshOrder();
const timer = setInterval(refreshOrder, 3000);
const timeout = setTimeout(() => clearInterval(timer), 30000);
return () => {
cancelled = true;
clearInterval(timer);
clearTimeout(timeout);
};
}, [orderId, currentOrder.paymentSuccess, currentOrder.rechargeSuccess, statusAccessToken]);
const config = getStatusConfig(currentOrder, locale);
const doneLabel = locale === 'en' ? 'Done' : '完成';
const backLabel = locale === 'en' ? 'Back to Recharge' : '返回充值';
return (
<div className="flex flex-col items-center space-y-4 py-8">
<div className={`text-6xl ${config.color}`}>{config.icon}</div>
<h2 className={`text-xl font-bold ${config.color}`}>{config.label}</h2>
<p className="text-center text-gray-500">{config.message}</p>
<p className={['text-center', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{config.message}</p>
<button
onClick={onBack}
className="mt-4 w-full rounded-lg bg-blue-600 py-3 font-medium text-white hover:bg-blue-700"
className={[
'mt-4 w-full rounded-lg py-3 font-medium text-white',
dark ? 'bg-blue-600 hover:bg-blue-500' : 'bg-blue-600 hover:bg-blue-700',
].join(' ')}
>
{status === 'COMPLETED' ? '完成' : '返回充值'}
{currentOrder.rechargeSuccess ? doneLabel : backLabel}
</button>
</div>
);

View File

@@ -1,3 +1,5 @@
import type { Locale } from '@/lib/locale';
interface Summary {
total: number;
pending: number;
@@ -7,32 +9,47 @@ interface Summary {
interface OrderSummaryCardsProps {
isDark: boolean;
locale: Locale;
summary: Summary;
}
export default function OrderSummaryCards({ isDark, summary }: OrderSummaryCardsProps) {
export default function OrderSummaryCards({ isDark, locale, summary }: OrderSummaryCardsProps) {
const cardClass = [
'rounded-xl border p-3',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50',
].join(' ');
const labelClass = ['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ');
const labels =
locale === 'en'
? {
total: 'Total Orders',
pending: 'Pending',
completed: 'Completed',
failed: 'Closed/Failed',
}
: {
total: '总订单',
pending: '待支付',
completed: '已完成',
failed: '异常/关闭',
};
return (
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className={cardClass}>
<div className={labelClass}></div>
<div className={labelClass}>{labels.total}</div>
<div className="mt-1 text-xl font-semibold">{summary.total}</div>
</div>
<div className={cardClass}>
<div className={labelClass}></div>
<div className={labelClass}>{labels.pending}</div>
<div className="mt-1 text-xl font-semibold">{summary.pending}</div>
</div>
<div className={cardClass}>
<div className={labelClass}></div>
<div className={labelClass}>{labels.completed}</div>
<div className="mt-1 text-xl font-semibold">{summary.completed}</div>
</div>
<div className={cardClass}>
<div className={labelClass}>/</div>
<div className={labelClass}>{labels.failed}</div>
<div className="mt-1 text-xl font-semibold">{summary.failed}</div>
</div>
</div>

View File

@@ -1,13 +1,40 @@
import { formatStatus, formatCreatedAt, getStatusBadgeClass, type MyOrder } from '@/lib/pay-utils';
import type { Locale } from '@/lib/locale';
import {
formatStatus,
formatCreatedAt,
getStatusBadgeClass,
getPaymentDisplayInfo,
type MyOrder,
} from '@/lib/pay-utils';
interface OrderTableProps {
isDark: boolean;
locale: Locale;
loading: boolean;
error: string;
orders: MyOrder[];
}
export default function OrderTable({ isDark, loading, error, orders }: OrderTableProps) {
export default function OrderTable({ isDark, locale, loading, error, orders }: OrderTableProps) {
const text =
locale === 'en'
? {
empty: 'No matching orders found',
orderId: 'Order ID',
amount: 'Amount',
payment: 'Payment Method',
status: 'Status',
createdAt: 'Created At',
}
: {
empty: '暂无符合条件的订单记录',
orderId: '订单号',
amount: '金额',
payment: '支付方式',
status: '状态',
createdAt: '创建时间',
};
return (
<div
className={[
@@ -40,7 +67,7 @@ export default function OrderTable({ isDark, loading, error, orders }: OrderTabl
isDark ? 'border-slate-600 text-slate-400' : 'border-slate-300 text-slate-500',
].join(' ')}
>
{text.empty}
</div>
) : (
<>
@@ -50,11 +77,11 @@ export default function OrderTable({ isDark, loading, error, orders }: OrderTabl
isDark ? 'text-slate-300' : 'text-slate-600',
].join(' ')}
>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span>{text.orderId}</span>
<span>{text.amount}</span>
<span>{text.payment}</span>
<span>{text.status}</span>
<span>{text.createdAt}</span>
</div>
<div className="space-y-2 md:space-y-0">
{orders.map((order) => (
@@ -67,17 +94,19 @@ 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>{getPaymentDisplayInfo(order.paymentType, locale).channel}</div>
<div>
<span
className={['rounded-full px-2 py-0.5 text-xs', getStatusBadgeClass(order.status, isDark)].join(
' ',
)}
>
{formatStatus(order.status)}
{formatStatus(order.status, locale)}
</span>
</div>
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>{formatCreatedAt(order.createdAt)}</div>
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>
{formatCreatedAt(order.createdAt, locale)}
</div>
</div>
))}
</div>

View File

@@ -1,9 +1,12 @@
import type { Locale } from '@/lib/locale';
interface PaginationBarProps {
page: number;
totalPages: number;
total: number;
pageSize: number;
pageSizeOptions?: number[];
locale?: Locale;
isDark?: boolean;
loading?: boolean;
onPageChange: (newPage: number) => void;
@@ -16,6 +19,7 @@ export default function PaginationBar({
total,
pageSize,
pageSizeOptions = [20, 50, 100],
locale,
isDark = false,
loading = false,
onPageChange,
@@ -30,24 +34,37 @@ export default function PaginationBar({
: 'border-slate-300 text-slate-600 hover:bg-slate-100',
].join(' ');
const text =
locale === 'en'
? {
total: `Total ${total}${totalPages > 1 ? `, Page ${page} / ${totalPages}` : ''}`,
perPage: 'Per page',
previous: 'Previous',
next: 'Next',
}
: {
total: `${total}${totalPages > 1 ? `,第 ${page} / ${totalPages}` : ''}`,
perPage: '每页',
previous: '上一页',
next: '下一页',
};
return (
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-xs">
{/* 左侧:统计 + 每页大小 */}
<div className="flex items-center gap-2">
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>
{total}
{totalPages > 1 && `,第 ${page} / ${totalPages}`}
</span>
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>{text.total}</span>
{onPageSizeChange && (
<>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}></span>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{text.perPage}</span>
{pageSizeOptions.map((s) => (
<button
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
@@ -67,7 +84,6 @@ export default function PaginationBar({
)}
</div>
{/* 右侧:分页导航 */}
{totalPages > 1 && (
<div className="flex items-center gap-1.5">
<button
@@ -84,7 +100,7 @@ export default function PaginationBar({
onClick={() => onPageChange(page - 1)}
className={navBtnClass(page <= 1)}
>
{text.previous}
</button>
<button
type="button"
@@ -92,7 +108,7 @@ export default function PaginationBar({
onClick={() => onPageChange(page + 1)}
className={navBtnClass(page >= totalPages)}
>
{text.next}
</button>
<button
type="button"

View File

@@ -1,13 +1,15 @@
import React from 'react';
import type { Locale } from '@/lib/locale';
interface PayPageLayoutProps {
isDark: boolean;
isEmbedded?: boolean;
maxWidth?: 'sm' | 'full';
maxWidth?: 'sm' | 'lg' | 'full';
title: string;
subtitle: string;
actions?: React.ReactNode;
children: React.ReactNode;
locale?: Locale;
}
export default function PayPageLayout({
@@ -18,31 +20,39 @@ export default function PayPageLayout({
subtitle,
actions,
children,
locale = 'zh',
}: PayPageLayoutProps) {
const maxWidthClass = maxWidth === 'sm' ? 'max-w-lg' : maxWidth === 'lg' ? 'max-w-6xl' : '';
return (
<div
className={[
'relative min-h-screen w-full overflow-hidden p-3 sm:p-4',
'relative w-full overflow-hidden',
isEmbedded ? 'min-h-screen p-2' : 'min-h-screen p-3 sm:p-4',
isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-100 text-slate-900',
].join(' ')}
>
<div
className={[
'pointer-events-none absolute -left-20 -top-20 h-56 w-56 rounded-full blur-3xl',
isDark ? 'bg-indigo-500/25' : 'bg-sky-300/35',
].join(' ')}
/>
<div
className={[
'pointer-events-none absolute -right-24 bottom-0 h-64 w-64 rounded-full blur-3xl',
isDark ? 'bg-cyan-400/20' : 'bg-indigo-200/45',
].join(' ')}
/>
{!isEmbedded && (
<>
<div
className={[
'pointer-events-none absolute -left-20 -top-20 h-56 w-56 rounded-full blur-3xl',
isDark ? 'bg-indigo-500/25' : 'bg-sky-300/35',
].join(' ')}
/>
<div
className={[
'pointer-events-none absolute -right-24 bottom-0 h-64 w-64 rounded-full blur-3xl',
isDark ? 'bg-cyan-400/20' : 'bg-indigo-200/45',
].join(' ')}
/>
</>
)}
<div
className={[
'relative mx-auto w-full rounded-3xl border p-4 sm:p-6',
maxWidth === 'sm' ? 'max-w-lg' : 'max-w-6xl',
maxWidthClass,
isDark
? 'border-slate-700/70 bg-slate-900/85 shadow-2xl shadow-black/35'
: 'border-slate-200/90 bg-white/95 shadow-2xl shadow-slate-300/45',
@@ -57,7 +67,7 @@ export default function PayPageLayout({
isDark ? 'bg-indigo-500/20 text-indigo-200' : 'bg-indigo-50 text-indigo-700',
].join(' ')}
>
Sub2API Secure Pay
{locale === 'en' ? 'Sub2API Secure Pay' : 'Sub2API 安全支付'}
</div>
<h1
className={['text-2xl font-semibold tracking-tight', isDark ? 'text-slate-100' : 'text-slate-900'].join(

View File

@@ -1,11 +1,16 @@
'use client';
import { useState } from 'react';
import { PAYMENT_TYPE_META } from '@/lib/pay-utils';
import type { Locale } from '@/lib/locale';
import { PAYMENT_TYPE_META, getPaymentIconType, getPaymentMeta, getPaymentDisplayInfo } from '@/lib/pay-utils';
export interface MethodLimitInfo {
available: boolean;
remaining: number | null;
/** 单笔限额0 = 使用全局 maxAmount */
singleMax?: number;
/** 手续费率百分比0 = 无手续费 */
feeRate?: number;
}
interface PaymentFormProps {
@@ -19,9 +24,12 @@ interface PaymentFormProps {
onSubmit: (amount: number, paymentType: string) => Promise<void>;
loading?: boolean;
dark?: boolean;
pendingBlocked?: boolean;
pendingCount?: number;
locale?: Locale;
}
const QUICK_AMOUNTS = [10, 20, 50, 100, 200, 500];
const QUICK_AMOUNTS = [10, 20, 50, 100, 200, 500, 1000, 2000];
const AMOUNT_TEXT_PATTERN = /^\d*(\.\d{0,2})?$/;
function hasValidCentPrecision(num: number): boolean {
@@ -39,11 +47,18 @@ export default function PaymentForm({
onSubmit,
loading,
dark = false,
pendingBlocked = false,
pendingCount = 0,
locale = 'zh',
}: PaymentFormProps) {
const [amount, setAmount] = useState<number | ''>('');
const [paymentType, setPaymentType] = useState(enabledPaymentTypes[0] || 'alipay');
const [customAmount, setCustomAmount] = useState('');
const effectivePaymentType = enabledPaymentTypes.includes(paymentType)
? paymentType
: enabledPaymentTypes[0] || 'stripe';
const handleQuickAmount = (val: number) => {
setAmount(val);
setCustomAmount(String(val));
@@ -70,39 +85,45 @@ export default function PaymentForm({
};
const selectedAmount = amount || 0;
const isMethodAvailable = !methodLimits || (methodLimits[paymentType]?.available !== false);
const isValid = selectedAmount >= minAmount && selectedAmount <= maxAmount && 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">
{locale === 'en' ? 'A' : '支'}
</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">
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none">
<path
d="M5 12.5 10.2 17 19 8"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<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" />
</svg>
</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
@@ -125,7 +146,6 @@ export default function PaymentForm({
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* User Info */}
<div
className={[
'rounded-xl border p-4',
@@ -133,25 +153,25 @@ export default function PaymentForm({
].join(' ')}
>
<div className={['text-xs uppercase tracking-wide', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{locale === 'en' ? 'Recharge Account' : '充值账户'}
</div>
<div className={['mt-1 text-base font-medium', dark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
{userName || `用户 #${userId}`}
{userName || (locale === 'en' ? `User #${userId}` : `用户 #${userId}`)}
</div>
{userBalance !== undefined && (
<div className={['mt-1 text-sm', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
: <span className="font-medium text-green-600">{userBalance.toFixed(2)}</span>
{locale === 'en' ? 'Current Balance:' : '当前余额:'}{' '}
<span className="font-medium text-green-600">{userBalance.toFixed(2)}</span>
</div>
)}
</div>
{/* Quick Amount Selection */}
<div>
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
{locale === 'en' ? 'Recharge Amount' : '充值金额'}
</label>
<div className="grid grid-cols-3 gap-2">
{QUICK_AMOUNTS.filter((val) => val <= maxAmount).map((val) => (
{QUICK_AMOUNTS.filter((val) => val >= minAmount && val <= effectiveMax).map((val) => (
<button
key={val}
type="button"
@@ -170,10 +190,9 @@ export default function PaymentForm({
</div>
</div>
{/* Custom Amount */}
<div>
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
{locale === 'en' ? 'Custom Amount' : '自定义金额'}
</label>
<div className="relative">
<span
@@ -188,10 +207,10 @@ export default function PaymentForm({
inputMode="decimal"
step="0.01"
min={minAmount}
max={maxAmount}
max={effectiveMax}
value={customAmount}
onChange={(e) => handleCustomAmountChange(e.target.value)}
placeholder={`${minAmount} - ${maxAmount}`}
placeholder={`${minAmount} - ${effectiveMax}`}
className={[
'w-full rounded-lg border py-3 pl-8 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500',
dark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-gray-300 bg-white text-gray-900',
@@ -200,99 +219,160 @@ 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 > maxAmount) msg = `单笔最高充值 ¥${maxAmount}`;
}
return (
<div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>
{msg}
</div>
);
})()}
{/* Payment Type */}
<div>
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-gray-700'].join(' ')}>
</label>
<div className="flex gap-3">
{enabledPaymentTypes.map((type) => {
const meta = PAYMENT_TYPE_META[type];
const isSelected = paymentType === type;
const limitInfo = methodLimits?.[type];
const isUnavailable = limitInfo !== undefined && !limitInfo.available;
return (
<button
key={type}
type="button"
disabled={isUnavailable}
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',
isUnavailable
? dark
? 'cursor-not-allowed border-slate-700 bg-slate-800/50 opacity-50'
: 'cursor-not-allowed border-gray-200 bg-gray-50 opacity-50'
: isSelected
? `${meta?.selectedBorder || 'border-blue-500'} ${meta?.selectedBg || 'bg-blue-50'} text-slate-900 shadow-sm`
: dark
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
: 'border-gray-300 bg-white text-slate-700 hover:border-gray-400',
].join(' ')}
>
<span className="flex items-center gap-2">
{renderPaymentIcon(type)}
<span className="flex flex-col items-start leading-none">
<span className="text-xl font-semibold tracking-tight">{meta?.label || type}</span>
{isUnavailable ? (
<span className="text-[10px] tracking-wide text-red-400"></span>
) : meta?.sublabel ? (
<span
className={`text-[10px] tracking-wide ${dark && !isSelected ? 'text-slate-400' : 'text-slate-600'}`}
>
{meta.sublabel}
</span>
) : null}
</span>
</span>
</button>
);
})}
</div>
{/* 当前选中渠道额度不足时的提示 */}
{(() => {
const limitInfo = methodLimits?.[paymentType];
if (!limitInfo || limitInfo.available) return null;
return (
<p className={['mt-2 text-xs', dark ? 'text-amber-300' : 'text-amber-600'].join(' ')}>
</p>
);
{customAmount !== '' &&
!isValid &&
(() => {
const num = parseFloat(customAmount);
let msg =
locale === 'en'
? 'Amount must be within range and support up to 2 decimal places'
: '金额需在范围内,且最多支持 2 位小数(精确到分)';
if (!isNaN(num)) {
if (num < minAmount)
msg = locale === 'en' ? `Minimum per transaction: ¥${minAmount}` : `单笔最低充值 ¥${minAmount}`;
else if (num > effectiveMax)
msg = locale === 'en' ? `Maximum per transaction: ¥${effectiveMax}` : `单笔最高充值 ¥${effectiveMax}`;
}
return <div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>{msg}</div>;
})()}
</div>
{/* Submit */}
{enabledPaymentTypes.length > 1 && (
<div>
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-gray-700'].join(' ')}>
{locale === 'en' ? 'Payment Method' : '支付方式'}
</label>
<div className="grid grid-cols-2 gap-3 sm:flex">
{enabledPaymentTypes.map((type) => {
const meta = PAYMENT_TYPE_META[type];
const displayInfo = getPaymentDisplayInfo(type, locale);
const isSelected = effectivePaymentType === type;
const limitInfo = methodLimits?.[type];
const isUnavailable = limitInfo !== undefined && !limitInfo.available;
return (
<button
key={type}
type="button"
disabled={isUnavailable}
onClick={() => !isUnavailable && setPaymentType(type)}
title={
isUnavailable
? locale === 'en'
? 'Daily limit reached, please use another payment method'
: '今日充值额度已满,请使用其他支付方式'
: undefined
}
className={[
'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'
: 'cursor-not-allowed border-gray-200 bg-gray-50 opacity-50'
: isSelected
? `${meta?.selectedBorder || 'border-blue-500'} ${dark ? meta?.selectedBgDark || 'bg-blue-950' : meta?.selectedBg || 'bg-blue-50'} ${dark ? 'text-slate-100' : 'text-slate-900'} shadow-sm`
: dark
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
: 'border-gray-300 bg-white text-slate-700 hover:border-gray-400',
].join(' ')}
>
<span className="flex items-center gap-2">
{renderPaymentIcon(type)}
<span className="flex flex-col items-start leading-none">
<span className="text-xl font-semibold tracking-tight">{displayInfo.channel || type}</span>
{isUnavailable ? (
<span className="text-[10px] tracking-wide text-red-400">
{locale === 'en' ? 'Daily limit reached' : '今日额度已满'}
</span>
) : displayInfo.sublabel ? (
<span
className={`text-[10px] tracking-wide ${dark ? (isSelected ? 'text-slate-300' : 'text-slate-400') : 'text-slate-600'}`}
>
{displayInfo.sublabel}
</span>
) : null}
</span>
</span>
</button>
);
})}
</div>
{(() => {
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(' ')}>
{locale === 'en'
? "The selected payment method has reached today's limit. Please switch to another method."
: '所选支付方式今日额度已满,请切换到其他支付方式'}
</p>
);
})()}
</div>
)}
{feeRate > 0 && selectedAmount > 0 && (
<div
className={[
'rounded-xl border px-4 py-3 text-sm',
dark ? 'border-slate-700 bg-slate-800/60 text-slate-300' : 'border-slate-200 bg-slate-50 text-slate-600',
].join(' ')}
>
<div className="flex items-center justify-between">
<span>{locale === 'en' ? 'Recharge Amount' : '充值金额'}</span>
<span>¥{selectedAmount.toFixed(2)}</span>
</div>
<div className="mt-1 flex items-center justify-between">
<span>{locale === 'en' ? `Fee (${feeRate}%)` : `手续费(${feeRate}%`}</span>
<span>¥{feeAmount.toFixed(2)}</span>
</div>
<div
className={[
'mt-1.5 flex items-center justify-between border-t pt-1.5 font-medium',
dark ? 'border-slate-700 text-slate-100' : 'border-slate-200 text-slate-900',
].join(' ')}
>
<span>{locale === 'en' ? 'Amount to Pay' : '实付金额'}</span>
<span>¥{payAmount.toFixed(2)}</span>
</div>
</div>
)}
{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(' ')}
>
{locale === 'en'
? `You have ${pendingCount} pending orders. Please complete or cancel them before recharging.`
: `您有 ${pendingCount} 个待支付订单,请先完成或取消后再充值`}
</div>
)}
<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 ? '处理中...' : `立即充值 ¥${selectedAmount || 0}`}
{loading
? locale === 'en'
? 'Processing...'
: '处理中...'
: pendingBlocked
? locale === 'en'
? 'Too many pending orders'
: '待支付订单过多'
: locale === 'en'
? `Recharge Now ¥${(feeRate > 0 && selectedAmount > 0 ? payAmount : selectedAmount || 0).toFixed(2)}`
: `立即充值 ¥${(feeRate > 0 && selectedAmount > 0 ? payAmount : selectedAmount || 0).toFixed(2)}`}
</button>
</form>
);

View File

@@ -1,37 +1,35 @@
'use client';
import { useEffect, useMemo, useState, useCallback } from 'react';
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
import QRCode from 'qrcode';
import type { Locale } from '@/lib/locale';
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
import { isStripeType, getPaymentMeta, getPaymentIconSrc, getPaymentChannelLabel } from '@/lib/pay-utils';
import { buildOrderStatusUrl } from '@/lib/order/status-url';
import { TERMINAL_STATUSES } from '@/lib/constants';
interface PaymentQRCodeProps {
orderId: string;
token?: string;
payUrl?: string | null;
qrCode?: string | null;
checkoutUrl?: string | null;
paymentType?: 'alipay' | 'wxpay' | 'stripe';
clientSecret?: string | null;
stripePublishableKey?: string | null;
paymentType?: string;
amount: number;
payAmount?: number;
expiresAt: string;
onStatusChange: (status: string) => void;
statusAccessToken?: string;
onStatusChange: (status: PublicOrderStatusSnapshot) => void;
onBack: () => void;
dark?: boolean;
isEmbedded?: boolean;
isMobile?: boolean;
locale?: Locale;
}
const TEXT_EXPIRED = '\u8BA2\u5355\u5DF2\u8D85\u65F6';
const TEXT_REMAINING = '\u5269\u4F59\u652F\u4ED8\u65F6\u95F4';
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']);
function isSafeCheckoutUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === 'https:' && parsed.hostname.endsWith('.stripe.com');
} catch {
return false;
}
function isVisibleOrderOutcome(data: PublicOrderStatusSnapshot): boolean {
return data.paymentSuccess || TERMINAL_STATUSES.has(data.status);
}
export default function PaymentQRCode({
@@ -39,25 +37,103 @@ export default function PaymentQRCode({
token,
payUrl,
qrCode,
checkoutUrl,
clientSecret,
stripePublishableKey,
paymentType,
amount,
payAmount: payAmountProp,
expiresAt,
statusAccessToken,
onStatusChange,
onBack,
dark = false,
isEmbedded = false,
isMobile = false,
locale = 'zh',
}: PaymentQRCodeProps) {
const displayAmount = payAmountProp ?? amount;
const hasFeeDiff = payAmountProp !== undefined && payAmountProp !== amount;
const [timeLeft, setTimeLeft] = useState('');
const [timeLeftSeconds, setTimeLeftSeconds] = useState(Infinity);
const [expired, setExpired] = useState(false);
const [qrDataUrl, setQrDataUrl] = useState('');
const [imageLoading, setImageLoading] = useState(false);
const [stripeOpened, setStripeOpened] = useState(false);
const [cancelBlocked, setCancelBlocked] = useState(false);
const [redirected, setRedirected] = useState(false);
const [stripeLoaded, setStripeLoaded] = useState(false);
const [stripeSubmitting, setStripeSubmitting] = useState(false);
const [stripeError, setStripeError] = useState('');
const [stripeSuccess, setStripeSuccess] = useState(false);
const [stripeLib, setStripeLib] = useState<{
stripe: import('@stripe/stripe-js').Stripe;
elements: import('@stripe/stripe-js').StripeElements;
} | null>(null);
const [stripePaymentMethod, setStripePaymentMethod] = useState('card');
const [popupBlocked, setPopupBlocked] = useState(false);
const paymentMethodListenerAdded = useRef(false);
const t = {
expired: locale === 'en' ? 'Order Expired' : '订单已超时',
remaining: locale === 'en' ? 'Time Remaining' : '剩余支付时间',
scanPay: locale === 'en' ? 'Please scan with your payment app' : '请使用支付应用扫码支付',
back: locale === 'en' ? 'Back' : '返回',
cancelOrder: locale === 'en' ? 'Cancel Order' : '取消订单',
h5Hint:
locale === 'en'
? 'After payment, please return to this page. The system will confirm automatically.'
: '支付完成后请返回此页面,系统将自动确认',
paid: locale === 'en' ? 'Order Paid' : '订单已支付',
paidCancelBlocked:
locale === 'en'
? 'This order has already been paid and cannot be cancelled. The recharge will be credited automatically.'
: '该订单已支付完成,无法取消。充值将自动到账。',
backToRecharge: locale === 'en' ? 'Back to Recharge' : '返回充值',
credited: locale === 'en' ? 'Credited ¥' : '到账 ¥',
stripeLoadFailed:
locale === 'en'
? 'Failed to load payment component. Please refresh and try again.'
: '支付组件加载失败,请刷新页面重试',
initFailed:
locale === 'en' ? 'Payment initialization failed. Please go back and try again.' : '支付初始化失败,请返回重试',
loadingForm: locale === 'en' ? 'Loading payment form...' : '正在加载支付表单...',
payFailed: locale === 'en' ? 'Payment failed. Please try again.' : '支付失败,请重试',
successProcessing: locale === 'en' ? 'Payment successful, processing your order...' : '支付成功,正在处理订单...',
processing: locale === 'en' ? 'Processing...' : '处理中...',
payNow: locale === 'en' ? 'Pay' : '支付',
popupBlocked:
locale === 'en'
? 'Popup was blocked by your browser. Please allow popups for this site and try again.'
: '弹出窗口被浏览器拦截,请允许本站弹出窗口后重试',
redirectingPrefix: locale === 'en' ? 'Redirecting to ' : '正在跳转到',
redirectingSuffix: locale === 'en' ? '...' : '...',
redirectRetryHint:
locale === 'en'
? 'If the payment app does not open automatically, go back and try again.'
: '如未自动拉起支付应用,请返回上一页后重新发起支付。',
notRedirectedPrefix: locale === 'en' ? 'Not redirected? Open ' : '未跳转?点击前往',
goPaySuffix: locale === 'en' ? '' : '',
gotoPrefix: locale === 'en' ? 'Open ' : '前往',
gotoSuffix: locale === 'en' ? ' to pay' : '支付',
openScanPrefix: locale === 'en' ? 'Open ' : '请打开',
openScanSuffix: locale === 'en' ? ' and scan to complete payment' : '扫一扫完成支付',
};
const shouldAutoRedirect = !expired && !isStripeType(paymentType) && !!payUrl && (isMobile || !qrCode);
useEffect(() => {
if (!shouldAutoRedirect || redirected) return;
setRedirected(true);
if (isEmbedded) {
window.open(payUrl!, '_blank');
} else {
window.location.replace(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;
@@ -93,6 +169,139 @@ export default function PaymentQRCode({
};
}, [qrPayload]);
const isStripe = isStripeType(paymentType);
useEffect(() => {
if (!isStripe || !clientSecret || !stripePublishableKey) return;
let cancelled = false;
import('@stripe/stripe-js').then(({ loadStripe }) => {
loadStripe(stripePublishableKey).then((stripe) => {
if (cancelled) return;
if (!stripe) {
setStripeError(t.stripeLoadFailed);
setStripeLoaded(true);
return;
}
const elements = stripe.elements({
clientSecret,
appearance: {
theme: dark ? 'night' : 'stripe',
variables: {
borderRadius: '8px',
},
},
});
setStripeLib({ stripe, elements });
setStripeLoaded(true);
});
});
return () => {
cancelled = true;
};
}, [isStripe, clientSecret, stripePublishableKey, dark, t.stripeLoadFailed]);
const stripeContainerRef = useCallback(
(node: HTMLDivElement | null) => {
if (!node || !stripeLib) return;
let pe = stripeLib.elements.getElement('payment');
if (pe) {
pe.mount(node);
} else {
pe = stripeLib.elements.create('payment', { layout: 'tabs' });
pe.mount(node);
}
if (!paymentMethodListenerAdded.current) {
paymentMethodListenerAdded.current = true;
pe.on('change', (event: { value?: { type?: string } }) => {
if (event.value?.type) {
setStripePaymentMethod(event.value.type);
}
});
}
},
[stripeLib],
);
const handleStripeSubmit = async () => {
if (!stripeLib || stripeSubmitting) return;
if (isEmbedded && stripePaymentMethod === 'alipay') {
handleOpenPopup();
return;
}
setStripeSubmitting(true);
setStripeError('');
const { stripe, elements } = stripeLib;
const returnUrl = new URL(window.location.href);
returnUrl.pathname = '/pay/result';
returnUrl.search = '';
returnUrl.searchParams.set('order_id', orderId);
returnUrl.searchParams.set('status', 'success');
if (statusAccessToken) {
returnUrl.searchParams.set('access_token', statusAccessToken);
}
if (locale === 'en') {
returnUrl.searchParams.set('lang', 'en');
}
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: returnUrl.toString(),
},
redirect: 'if_required',
});
if (error) {
setStripeError(error.message || t.payFailed);
setStripeSubmitting(false);
} else {
setStripeSuccess(true);
setStripeSubmitting(false);
}
};
const handleOpenPopup = () => {
if (!clientSecret || !stripePublishableKey) return;
setPopupBlocked(false);
const popupUrl = new URL(window.location.href);
popupUrl.pathname = '/pay/stripe-popup';
popupUrl.search = '';
popupUrl.searchParams.set('order_id', orderId);
popupUrl.searchParams.set('amount', String(amount));
popupUrl.searchParams.set('theme', dark ? 'dark' : 'light');
popupUrl.searchParams.set('method', stripePaymentMethod);
if (statusAccessToken) {
popupUrl.searchParams.set('access_token', statusAccessToken);
}
if (locale === 'en') {
popupUrl.searchParams.set('lang', 'en');
}
const popup = window.open(popupUrl.toString(), 'stripe_payment', 'width=500,height=700,scrollbars=yes');
if (!popup || popup.closed) {
setPopupBlocked(true);
return;
}
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,
);
};
window.addEventListener('message', onReady);
};
useEffect(() => {
const updateTimer = () => {
const now = Date.now();
@@ -100,34 +309,35 @@ export default function PaymentQRCode({
const diff = expiry - now;
if (diff <= 0) {
setTimeLeft(TEXT_EXPIRED);
setTimeLeft(t.expired);
setTimeLeftSeconds(0);
setExpired(true);
return;
}
const totalSeconds = Math.floor(diff / 1000);
const minutes = Math.floor(diff / 60000);
const seconds = Math.floor((diff % 60000) / 1000);
setTimeLeft(`${minutes}:${seconds.toString().padStart(2, '0')}`);
setTimeLeftSeconds(totalSeconds);
};
updateTimer();
const timer = setInterval(updateTimer, 1000);
return () => clearInterval(timer);
}, [expiresAt]);
}, [expiresAt, t.expired]);
const pollStatus = useCallback(async () => {
try {
const res = await fetch(`/api/orders/${orderId}`);
const res = await fetch(buildOrderStatusUrl(orderId, statusAccessToken));
if (res.ok) {
const data = await res.json();
if (TERMINAL_STATUSES.has(data.status)) {
onStatusChange(data.status);
const data = (await res.json()) as PublicOrderStatusSnapshot;
if (isVisibleOrderOutcome(data)) {
onStatusChange(data);
}
}
} catch {
// ignore polling errors
}
}, [orderId, onStatusChange]);
} catch {}
}, [orderId, onStatusChange, statusAccessToken]);
useEffect(() => {
if (expired) return;
@@ -139,13 +349,12 @@ export default function PaymentQRCode({
const handleCancel = async () => {
if (!token) return;
try {
// 先检查当前订单状态
const res = await fetch(`/api/orders/${orderId}`);
const res = await fetch(buildOrderStatusUrl(orderId, statusAccessToken));
if (!res.ok) return;
const data = await res.json();
const data = (await res.json()) as PublicOrderStatusSnapshot;
if (TERMINAL_STATUSES.has(data.status)) {
onStatusChange(data.status);
if (data.paymentSuccess || TERMINAL_STATUSES.has(data.status)) {
onStatusChange(data);
return;
}
@@ -160,34 +369,38 @@ export default function PaymentQRCode({
setCancelBlocked(true);
return;
}
onStatusChange('CANCELLED');
onStatusChange({
id: orderId,
status: 'CANCELLED',
expiresAt,
paymentSuccess: false,
rechargeSuccess: false,
rechargeStatus: 'closed',
});
} else {
await pollStatus();
}
} catch {
// ignore
}
} catch {}
};
const isStripe = paymentType === 'stripe';
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', locale);
const iconBgClass = meta.iconBg;
if (cancelBlocked) {
return (
<div className="flex flex-col items-center space-y-4 py-8">
<div className="text-6xl text-green-600">{'\u2713'}</div>
<h2 className="text-xl font-bold text-green-600">{'\u8BA2\u5355\u5DF2\u652F\u4ED8'}</h2>
<div className="text-6xl text-green-600">{''}</div>
<h2 className="text-xl font-bold text-green-600">{t.paid}</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'}
{t.paidCancelBlocked}
</p>
<button
onClick={onBack}
className="mt-4 w-full rounded-lg bg-blue-600 py-3 font-medium text-white hover:bg-blue-700"
>
{'\u8FD4\u56DE\u5145\u503C'}
{t.backToRecharge}
</button>
</div>
);
@@ -196,61 +409,141 @@ 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'}{amount.toFixed(2)}</div>
<div className={`mt-1 text-sm ${expired ? 'text-red-500' : dark ? 'text-slate-400' : 'text-gray-500'}`}>
{expired ? TEXT_EXPIRED : `${TEXT_REMAINING}: ${timeLeft}`}
<div className="text-4xl font-bold text-blue-600">
{'¥'}
{displayAmount.toFixed(2)}
</div>
{hasFeeDiff && (
<div className={['mt-1 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
{t.credited}
{amount.toFixed(2)}
</div>
)}
<div
className={`mt-1 text-sm ${expired ? 'text-red-500' : !expired && timeLeftSeconds <= 60 ? 'text-red-500 animate-pulse' : dark ? 'text-slate-400' : 'text-gray-500'}`}
>
{expired ? t.expired : `${t.remaining}: ${timeLeft}`}
</div>
</div>
{!expired && (
<>
{isStripe ? (
<>
<button
type="button"
disabled={!checkoutUrl || !isSafeCheckoutUrl(checkoutUrl) || stripeOpened}
onClick={() => {
if (checkoutUrl && isSafeCheckoutUrl(checkoutUrl)) {
window.open(checkoutUrl, '_blank', 'noopener,noreferrer');
setStripeOpened(true);
}
}}
className={[
'inline-flex items-center gap-2 rounded-lg px-8 py-3 font-medium text-white shadow-md transition-colors',
!checkoutUrl || !isSafeCheckoutUrl(checkoutUrl) || stripeOpened
? 'bg-gray-400 cursor-not-allowed'
: 'bg-[#635bff] hover:bg-[#5249d9] active:bg-[#4840c4]',
].join(' ')}
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
<line x1="1" y1="10" x2="23" y2="10" />
</svg>
{stripeOpened ? '\u5DF2\u6253\u5F00\u652F\u4ED8\u9875\u9762' : '\u524D\u5F80 Stripe \u652F\u4ED8'}
</button>
{stripeOpened && (
<button
type="button"
onClick={() => {
if (checkoutUrl && isSafeCheckoutUrl(checkoutUrl)) {
window.open(checkoutUrl, '_blank', 'noopener,noreferrer');
}
}}
className={['text-sm underline', dark ? 'text-slate-400 hover:text-slate-300' : 'text-gray-500 hover:text-gray-700'].join(' ')}
<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(' ')}
>
{'\u91CD\u65B0\u6253\u5F00\u652F\u4ED8\u9875\u9762'}
</button>
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{t.initFailed}</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', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
{t.loadingForm}
</span>
</div>
) : stripeError && !stripeLib ? (
<div
className={[
'rounded-lg border p-3 text-sm',
dark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600',
].join(' ')}
>
{stripeError}
</div>
) : (
<>
<div
ref={stripeContainerRef}
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">
{stripeError}
</div>
)}
{stripeSuccess ? (
<div className="text-center">
<div className="text-4xl text-green-600">{'✓'}</div>
<p className={['mt-2 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
{t.successProcessing}
</p>
</div>
) : (
<button
type="button"
disabled={stripeSubmitting}
onClick={handleStripeSubmit}
className={[
'w-full rounded-lg py-3 font-medium text-white shadow-md transition-colors',
stripeSubmitting ? 'cursor-not-allowed bg-gray-400' : meta.buttonClass,
].join(' ')}
>
{stripeSubmitting ? (
<span className="inline-flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
{t.processing}
</span>
) : (
`${t.payNow} ¥${amount.toFixed(2)}`
)}
</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(' ')}
>
{t.popupBlocked}
</div>
)}
</>
)}
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
{!checkoutUrl || !isSafeCheckoutUrl(checkoutUrl)
? '\u652F\u4ED8\u94FE\u63A5\u521B\u5EFA\u5931\u8D25\uFF0C\u8BF7\u8FD4\u56DE\u91CD\u8BD5'
: '\u5728\u65B0\u7A97\u53E3\u5B8C\u6210\u652F\u4ED8\u540E\uFF0C\u6B64\u9875\u9762\u5C06\u81EA\u52A8\u66F4\u65B0'}
</p>
</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(' ')}>
{`${t.redirectingPrefix}${channelLabel}${t.redirectingSuffix}`}
</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
? `${t.notRedirectedPrefix}${channelLabel}`
: `${t.gotoPrefix}${channelLabel}${t.gotoSuffix}`}
</a>
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{t.h5Hint}</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" />
@@ -265,27 +558,21 @@ 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(' ')}>
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{TEXT_SCAN_PAY}</p>
<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(' ')}>{t.scanPay}</p>
</div>
</div>
)}
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
{`\u8BF7\u6253\u5F00${channelLabel}\u626B\u4E00\u626B\u5B8C\u6210\u652F\u4ED8`}
{`${t.openScanPrefix}${channelLabel}${t.openScanSuffix}`}
</p>
</>
)}
@@ -297,17 +584,22 @@ 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}
{t.back}
</button>
{!expired && token && (
<button
onClick={handleCancel}
className="flex-1 rounded-lg border border-red-300 py-2 text-sm text-red-600 hover:bg-red-50"
className={[
'flex-1 rounded-lg border py-2 text-sm',
dark ? 'border-red-700 text-red-400 hover:bg-red-900/30' : 'border-red-300 text-red-600 hover:bg-red-50',
].join(' ')}
>
{TEXT_CANCEL_ORDER}
{t.cancelOrder}
</button>
)}
</div>

View File

@@ -0,0 +1,143 @@
'use client';
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
import type { Locale } from '@/lib/locale';
interface DailyData {
date: string;
amount: number;
count: number;
}
interface DailyChartProps {
data: DailyData[];
dark?: boolean;
locale?: Locale;
}
function formatDate(dateStr: string) {
const [, m, d] = dateStr.split('-');
return `${m}/${d}`;
}
function formatAmount(value: number) {
if (value >= 10000) return `¥${(value / 10000).toFixed(1)}w`;
if (value >= 1000) return `¥${(value / 1000).toFixed(1)}k`;
return `¥${value}`;
}
interface TooltipPayload {
value: number;
dataKey: string;
}
function CustomTooltip({
active,
payload,
label,
dark,
currency,
amountLabel,
countLabel,
}: {
active?: boolean;
payload?: TooltipPayload[];
label?: string;
dark?: boolean;
currency: string;
amountLabel: string;
countLabel: string;
}) {
if (!active || !payload?.length) return null;
return (
<div
className={[
'rounded-lg border px-3 py-2 text-sm shadow-lg',
dark ? 'border-slate-600 bg-slate-800 text-slate-200' : 'border-slate-200 bg-white text-slate-800',
].join(' ')}
>
<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' ? amountLabel : countLabel}:{' '}
{p.dataKey === 'amount' ? `${currency}${p.value.toLocaleString()}` : p.value}
</p>
))}
</div>
);
}
export default function DailyChart({ data, dark, locale = 'zh' }: DailyChartProps) {
const currency = locale === 'en' ? '$' : '¥';
const chartTitle = locale === 'en' ? 'Daily Recharge Trend' : '每日充值趋势';
const emptyText = locale === 'en' ? 'No data' : '暂无数据';
const amountLabel = locale === 'en' ? 'Amount' : '金额';
const countLabel = locale === 'en' ? 'Orders' : '笔数';
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(' ')}>
{chartTitle}
</h3>
<p className={['text-center text-sm py-16', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>
{emptyText}
</p>
</div>
);
}
const axisColor = dark ? '#64748b' : '#94a3b8';
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(' ')}>
{chartTitle}
</h3>
<ResponsiveContainer width="100%" height={320}>
<LineChart data={data} margin={{ top: 5, right: 20, bottom: 5, left: 10 }}>
<CartesianGrid stroke={gridColor} strokeDasharray="3 3" />
<XAxis
dataKey="date"
tickFormatter={formatDate}
tick={{ fill: axisColor, fontSize: 12 }}
axisLine={{ stroke: gridColor }}
tickLine={false}
interval={tickInterval}
/>
<YAxis
tickFormatter={formatAmount}
tick={{ fill: axisColor, fontSize: 12 }}
axisLine={{ stroke: gridColor }}
tickLine={false}
width={60}
/>
<Tooltip
content={
<CustomTooltip dark={dark} currency={currency} amountLabel={amountLabel} countLabel={countLabel} />
}
/>
<Line
type="monotone"
dataKey="amount"
stroke={dark ? '#818cf8' : '#4f46e5'}
strokeWidth={2}
dot={{ r: 3, fill: dark ? '#818cf8' : '#4f46e5' }}
activeDot={{ r: 5 }}
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,63 @@
'use client';
import type { Locale } from '@/lib/locale';
interface Summary {
today: { amount: number; orderCount: number; paidCount: number };
total: { amount: number; orderCount: number; paidCount: number };
successRate: number;
avgAmount: number;
}
interface DashboardStatsProps {
summary: Summary;
dark?: boolean;
locale?: Locale;
}
export default function DashboardStats({ summary, dark, locale = 'zh' }: DashboardStatsProps) {
const currency = locale === 'en' ? '$' : '¥';
const cards = [
{
label: locale === 'en' ? 'Today Recharge' : '今日充值',
value: `${currency}${summary.today.amount.toLocaleString()}`,
accent: true,
},
{
label: locale === 'en' ? 'Today Orders' : '今日订单',
value: `${summary.today.paidCount}/${summary.today.orderCount}`,
},
{
label: locale === 'en' ? 'Total Recharge' : '累计充值',
value: `${currency}${summary.total.amount.toLocaleString()}`,
accent: true,
},
{ label: locale === 'en' ? 'Paid Orders' : '累计订单', value: String(summary.total.paidCount) },
{ label: locale === 'en' ? 'Success Rate' : '成功率', value: `${summary.successRate}%` },
{ label: locale === 'en' ? 'Average Amount' : '平均充值', value: `${currency}${summary.avgAmount.toFixed(2)}` },
];
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
{cards.map((card) => (
<div
key={card.label}
className={[
'rounded-xl border p-4',
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={[
'mt-1 text-xl font-semibold tracking-tight',
card.accent ? (dark ? 'text-indigo-400' : 'text-indigo-600') : dark ? 'text-slate-100' : 'text-slate-900',
].join(' ')}
>
{card.value}
</p>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
import type { Locale } from '@/lib/locale';
interface LeaderboardEntry {
userId: number;
userName: string | null;
userEmail: string | null;
totalAmount: number;
orderCount: number;
}
interface LeaderboardProps {
data: LeaderboardEntry[];
dark?: boolean;
locale?: Locale;
}
const RANK_STYLES: Record<number, { light: string; dark: string }> = {
1: { light: 'bg-amber-100 text-amber-700', dark: 'bg-amber-500/20 text-amber-300' },
2: { light: 'bg-slate-200 text-slate-600', dark: 'bg-slate-500/20 text-slate-300' },
3: { light: 'bg-orange-100 text-orange-700', dark: 'bg-orange-500/20 text-orange-300' },
};
export default function Leaderboard({ data, dark, locale = 'zh' }: LeaderboardProps) {
const title = locale === 'en' ? 'Recharge Leaderboard (Top 10)' : '充值排行榜 (Top 10)';
const emptyText = locale === 'en' ? 'No data' : '暂无数据';
const userLabel = locale === 'en' ? 'User' : '用户';
const amountLabel = locale === 'en' ? 'Total Amount' : '累计金额';
const orderCountLabel = locale === 'en' ? 'Orders' : '订单数';
const currency = locale === 'en' ? '$' : '¥';
const thCls = `px-4 py-3 text-left text-xs font-medium uppercase ${dark ? 'text-slate-400' : 'text-gray-500'}`;
const tdCls = `whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-300' : 'text-slate-700'}`;
const tdMuted = `whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-400' : 'text-gray-500'}`;
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(' ')}>
{title}
</h3>
<p className={['text-center text-sm py-8', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>{emptyText}</p>
</div>
);
}
return (
<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(' ')}>
{title}
</h3>
<div className="overflow-x-auto">
<table className={`min-w-full divide-y ${dark ? 'divide-slate-700' : 'divide-gray-200'}`}>
<thead className={dark ? 'bg-slate-800/50' : 'bg-gray-50'}>
<tr>
<th className={thCls}>#</th>
<th className={thCls}>{userLabel}</th>
<th className={thCls}>{amountLabel}</th>
<th className={thCls}>{orderCountLabel}</th>
</tr>
</thead>
<tbody className={`divide-y ${dark ? 'divide-slate-700/60' : 'divide-gray-200'}`}>
{data.map((entry, i) => {
const rank = i + 1;
const rankStyle = RANK_STYLES[rank];
return (
<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}`}
>
{rank}
</span>
) : (
<span className={dark ? 'text-slate-500' : 'text-gray-400'}>{rank}</span>
)}
</td>
<td className={tdCls}>
<div>{entry.userName || `#${entry.userId}`}</div>
{entry.userEmail && (
<div className={['text-xs', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>
{entry.userEmail}
</div>
)}
</td>
<td
className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : 'text-slate-900'}`}
>
{currency}
{entry.totalAmount.toLocaleString()}
</td>
<td className={tdMuted}>{entry.orderCount}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -1,5 +1,9 @@
'use client';
import { useEffect } from 'react';
import { getPaymentDisplayInfo, formatCreatedAt } from '@/lib/pay-utils';
import type { Locale } from '@/lib/locale';
interface AuditLog {
id: string;
action: string;
@@ -31,91 +35,200 @@ interface OrderDetailProps {
createdAt: string;
updatedAt: string;
clientIp: string | null;
srcHost: string | null;
srcUrl: string | null;
paymentSuccess?: boolean;
rechargeSuccess?: boolean;
rechargeStatus?: string;
auditLogs: AuditLog[];
};
onClose: () => void;
dark?: boolean;
locale?: Locale;
}
export default function OrderDetail({ order, onClose }: OrderDetailProps) {
export default function OrderDetail({ order, onClose, dark, locale = 'zh' }: OrderDetailProps) {
const currency = locale === 'en' ? '$' : '¥';
const text =
locale === 'en'
? {
title: 'Order Details',
auditLogs: 'Audit Logs',
operator: 'Operator',
emptyLogs: 'No logs',
close: 'Close',
yes: 'Yes',
no: 'No',
orderId: 'Order ID',
userId: 'User ID',
userName: 'Username',
email: 'Email',
amount: 'Amount',
status: 'Status',
paymentSuccess: 'Payment Success',
rechargeSuccess: 'Recharge Success',
rechargeStatus: 'Recharge Status',
paymentChannel: 'Payment Channel',
provider: 'Provider',
rechargeCode: 'Recharge Code',
paymentTradeNo: 'Payment Trade No.',
clientIp: 'Client IP',
sourceHost: 'Source Host',
sourcePage: 'Source Page',
createdAt: 'Created At',
expiresAt: 'Expires At',
paidAt: 'Paid At',
completedAt: 'Completed At',
failedAt: 'Failed At',
failedReason: 'Failure Reason',
refundAmount: 'Refund Amount',
refundReason: 'Refund Reason',
refundAt: 'Refunded At',
forceRefund: 'Force Refund',
}
: {
title: '订单详情',
auditLogs: '审计日志',
operator: '操作者',
emptyLogs: '暂无日志',
close: '关闭',
yes: '是',
no: '否',
orderId: '订单号',
userId: '用户ID',
userName: '用户名',
email: '邮箱',
amount: '金额',
status: '状态',
paymentSuccess: '支付成功',
rechargeSuccess: '充值成功',
rechargeStatus: '充值状态',
paymentChannel: '支付渠道',
provider: '提供商',
rechargeCode: '充值码',
paymentTradeNo: '支付单号',
clientIp: '客户端IP',
sourceHost: '来源域名',
sourcePage: '来源页面',
createdAt: '创建时间',
expiresAt: '过期时间',
paidAt: '支付时间',
completedAt: '完成时间',
failedAt: '失败时间',
failedReason: '失败原因',
refundAmount: '退款金额',
refundReason: '退款原因',
refundAt: '退款时间',
forceRefund: '强制退款',
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
const paymentInfo = getPaymentDisplayInfo(order.paymentType, locale);
const fields = [
{ label: '订单号', value: order.id },
{ label: '用户ID', value: order.userId },
{ label: '用户名', value: order.userName || '-' },
{ label: '邮箱', value: order.userEmail || '-' },
{ label: '金额', value: `¥${order.amount.toFixed(2)}` },
{ label: '状态', value: order.status },
{ 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: order.rechargeCode },
{ label: '支付单号', value: order.paymentTradeNo || '-' },
{ label: '客户端IP', value: order.clientIp || '-' },
{ label: '创建时间', value: new Date(order.createdAt).toLocaleString('zh-CN') },
{ label: '过期时间', value: new Date(order.expiresAt).toLocaleString('zh-CN') },
{ label: '支付时间', value: order.paidAt ? new Date(order.paidAt).toLocaleString('zh-CN') : '-' },
{ label: '完成时间', value: order.completedAt ? new Date(order.completedAt).toLocaleString('zh-CN') : '-' },
{ label: '失败时间', value: order.failedAt ? new Date(order.failedAt).toLocaleString('zh-CN') : '-' },
{ label: '失败原因', value: order.failedReason || '-' },
{ label: text.orderId, value: order.id },
{ label: text.userId, value: order.userId },
{ label: text.userName, value: order.userName || '-' },
{ label: text.email, value: order.userEmail || '-' },
{ label: text.amount, value: `${currency}${order.amount.toFixed(2)}` },
{ label: text.status, value: order.status },
{ label: text.paymentSuccess, value: order.paymentSuccess ? text.yes : text.no },
{ label: text.rechargeSuccess, value: order.rechargeSuccess ? text.yes : text.no },
{ label: text.rechargeStatus, value: order.rechargeStatus || '-' },
{ label: text.paymentChannel, value: paymentInfo.channel },
{ label: text.provider, value: paymentInfo.provider || '-' },
{ label: text.rechargeCode, value: order.rechargeCode },
{ label: text.paymentTradeNo, value: order.paymentTradeNo || '-' },
{ label: text.clientIp, value: order.clientIp || '-' },
{ label: text.sourceHost, value: order.srcHost || '-' },
{ label: text.sourcePage, value: order.srcUrl || '-' },
{ label: text.createdAt, value: formatCreatedAt(order.createdAt, locale) },
{ label: text.expiresAt, value: formatCreatedAt(order.expiresAt, locale) },
{ label: text.paidAt, value: order.paidAt ? formatCreatedAt(order.paidAt, locale) : '-' },
{ label: text.completedAt, value: order.completedAt ? formatCreatedAt(order.completedAt, locale) : '-' },
{ label: text.failedAt, value: order.failedAt ? formatCreatedAt(order.failedAt, locale) : '-' },
{ label: text.failedReason, value: order.failedReason || '-' },
];
if (order.refundAmount) {
fields.push(
{ label: '退款金额', value: `¥${order.refundAmount.toFixed(2)}` },
{ label: '退款原因', value: order.refundReason || '-' },
{ label: '退款时间', value: order.refundAt ? new Date(order.refundAt).toLocaleString('zh-CN') : '-' },
{ label: '强制退款', value: order.forceRefund ? '是' : '否' },
{ label: text.refundAmount, value: `${currency}${order.refundAmount.toFixed(2)}` },
{ label: text.refundReason, value: order.refundReason || '-' },
{ label: text.refundAt, value: order.refundAt ? formatCreatedAt(order.refundAt, locale) : '-' },
{ label: text.forceRefund, value: order.forceRefund ? text.yes : text.no },
);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div
className="max-h-[80vh] w-full max-w-2xl overflow-y-auto rounded-xl bg-white p-6 shadow-xl"
className={`max-h-[80vh] w-full max-w-2xl overflow-y-auto rounded-xl p-6 shadow-xl ${dark ? 'bg-slate-800 text-slate-100' : 'bg-white'}`}
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-bold"></h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
<h3 className="text-lg font-bold">{text.title}</h3>
<button
onClick={onClose}
className={dark ? 'text-slate-400 hover:text-slate-200' : 'text-gray-400 hover:text-gray-600'}
>
</button>
</div>
<div className="grid grid-cols-2 gap-3">
{fields.map(({ label, value }) => (
<div key={label} className="rounded-lg bg-gray-50 p-3">
<div className="text-xs text-gray-500">{label}</div>
<div className="mt-1 break-all text-sm font-medium">{value}</div>
<div key={label} className={`rounded-lg p-3 ${dark ? 'bg-slate-700/60' : 'bg-gray-50'}`}>
<div className={`text-xs ${dark ? 'text-slate-400' : 'text-gray-500'}`}>{label}</div>
<div className={`mt-1 break-all text-sm font-medium ${dark ? 'text-slate-200' : ''}`}>{value}</div>
</div>
))}
</div>
{/* Audit Logs */}
<div className="mt-6">
<h4 className="mb-3 font-medium text-gray-900"></h4>
<h4 className={`mb-3 font-medium ${dark ? 'text-slate-100' : 'text-gray-900'}`}>{text.auditLogs}</h4>
<div className="space-y-2">
{order.auditLogs.map((log) => (
<div key={log.id} className="rounded-lg border border-gray-100 bg-gray-50 p-3">
<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 text-gray-400">{new Date(log.createdAt).toLocaleString('zh-CN')}</span>
<span className={`text-xs ${dark ? 'text-slate-500' : 'text-gray-400'}`}>
{formatCreatedAt(log.createdAt, locale)}
</span>
</div>
{log.detail && <div className="mt-1 break-all text-xs text-gray-500">{log.detail}</div>}
{log.operator && <div className="mt-1 text-xs 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'}`}>
{text.operator}: {log.operator}
</div>
)}
</div>
))}
{order.auditLogs.length === 0 && <div className="text-center text-sm text-gray-400"></div>}
{order.auditLogs.length === 0 && (
<div className={`text-center text-sm ${dark ? 'text-slate-500' : 'text-gray-400'}`}>{text.emptyLogs}</div>
)}
</div>
</div>
<button
onClick={onClose}
className="mt-6 w-full rounded-lg border border-gray-300 py-2 text-sm text-gray-600 hover:bg-gray-50"
className={`mt-6 w-full rounded-lg border py-2 text-sm ${dark ? 'border-slate-600 text-slate-300 hover:bg-slate-700' : 'border-gray-300 text-gray-600 hover:bg-gray-50'}`}
>
{text.close}
</button>
</div>
</div>

View File

@@ -1,12 +1,14 @@
'use client';
import { useState } from 'react';
import { getPaymentDisplayInfo, formatStatus, formatCreatedAt } from '@/lib/pay-utils';
import type { Locale } from '@/lib/locale';
interface Order {
id: string;
userId: number;
userName: string | null;
userEmail: string | null;
userNotes: string | null;
amount: number;
status: string;
paymentType: string;
@@ -15,6 +17,7 @@ interface Order {
completedAt: string | null;
failedReason: string | null;
expiresAt: string;
srcHost: string | null;
rechargeRetryable?: boolean;
}
@@ -23,81 +26,157 @@ interface OrderTableProps {
onRetry: (orderId: string) => void;
onCancel: (orderId: string) => void;
onViewDetail: (orderId: string) => void;
dark?: boolean;
locale?: Locale;
}
const STATUS_LABELS: Record<string, { label: string; className: string }> = {
PENDING: { label: '待支付', className: 'bg-yellow-100 text-yellow-800' },
PAID: { label: '已支付', className: 'bg-blue-100 text-blue-800' },
RECHARGING: { label: '充值中', className: 'bg-blue-100 text-blue-800' },
COMPLETED: { label: '已完成', className: 'bg-green-100 text-green-800' },
EXPIRED: { label: '已超时', className: 'bg-gray-100 text-gray-800' },
CANCELLED: { label: '已取消', className: 'bg-gray-100 text-gray-800' },
FAILED: { label: '充值失败', className: 'bg-red-100 text-red-800' },
REFUNDING: { label: '退款中', className: 'bg-orange-100 text-orange-800' },
REFUNDED: { label: '已退款', className: 'bg-purple-100 text-purple-800' },
REFUND_FAILED: { label: '退款失败', className: 'bg-red-100 text-red-800' },
};
export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, dark, locale = 'zh' }: OrderTableProps) {
const currency = locale === 'en' ? '$' : '¥';
const text =
locale === 'en'
? {
orderId: 'Order ID',
userName: 'Username',
email: 'Email',
notes: 'Notes',
amount: 'Amount',
status: 'Status',
paymentMethod: 'Payment',
source: 'Source',
createdAt: 'Created At',
actions: 'Actions',
retry: 'Retry',
cancel: 'Cancel',
empty: 'No orders',
}
: {
orderId: '订单号',
userName: '用户名',
email: '邮箱',
notes: '备注',
amount: '金额',
status: '状态',
paymentMethod: '支付方式',
source: '来源',
createdAt: '创建时间',
actions: '操作',
retry: '重试',
cancel: '取消',
empty: '暂无订单',
};
const thCls = `px-4 py-3 text-left text-xs font-medium uppercase ${dark ? 'text-slate-400' : 'text-gray-500'}`;
const tdMuted = `whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-400' : 'text-gray-500'}`;
export default function OrderTable({ orders, onRetry, onCancel, onViewDetail }: OrderTableProps) {
return (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<table className={`min-w-full divide-y ${dark ? 'divide-slate-700' : 'divide-gray-200'}`}>
<thead className={dark ? 'bg-slate-800/50' : 'bg-gray-50'}>
<tr>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"></th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"></th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"></th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"></th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"></th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"></th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"></th>
<th className={thCls}>{text.orderId}</th>
<th className={thCls}>{text.userName}</th>
<th className={thCls}>{text.email}</th>
<th className={thCls}>{text.notes}</th>
<th className={thCls}>{text.amount}</th>
<th className={thCls}>{text.status}</th>
<th className={thCls}>{text.paymentMethod}</th>
<th className={thCls}>{text.source}</th>
<th className={thCls}>{text.createdAt}</th>
<th className={thCls}>{text.actions}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
<tbody className={`divide-y ${dark ? 'divide-slate-700/60' : 'divide-gray-200 bg-white'}`}>
{orders.map((order) => {
const statusInfo = STATUS_LABELS[order.status] || {
label: order.status,
className: 'bg-gray-100 text-gray-800',
const statusInfo = {
label: formatStatus(order.status, locale),
light:
order.status === 'FAILED' || order.status === 'REFUND_FAILED'
? 'bg-red-100 text-red-800'
: order.status === 'REFUNDED'
? 'bg-purple-100 text-purple-800'
: order.status === 'REFUNDING'
? 'bg-orange-100 text-orange-800'
: order.status === 'COMPLETED'
? 'bg-green-100 text-green-800'
: order.status === 'PAID' || order.status === 'RECHARGING'
? 'bg-blue-100 text-blue-800'
: order.status === 'PENDING'
? 'bg-yellow-100 text-yellow-800'
: 'bg-gray-100 text-gray-800',
dark:
order.status === 'FAILED' || order.status === 'REFUND_FAILED'
? 'bg-red-500/20 text-red-300'
: order.status === 'REFUNDED'
? 'bg-purple-500/20 text-purple-300'
: order.status === 'REFUNDING'
? 'bg-orange-500/20 text-orange-300'
: order.status === 'COMPLETED'
? 'bg-green-500/20 text-green-300'
: order.status === 'PAID' || order.status === 'RECHARGING'
? 'bg-blue-500/20 text-blue-300'
: order.status === 'PENDING'
? 'bg-yellow-500/20 text-yellow-300'
: 'bg-slate-600/30 text-slate-400',
};
return (
<tr key={order.id} className="hover:bg-gray-50">
<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="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>
<td className="whitespace-nowrap px-4 py-3 text-sm">
<div>{order.userName || '-'}</div>
<div className="text-xs text-gray-400">{order.userEmail || `ID: ${order.userId}`}</div>
<td className={`whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-200' : ''}`}>
{order.userName || `#${order.userId}`}
</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' : ''}`}>
{currency}
{order.amount.toFixed(2)}
</td>
<td className="whitespace-nowrap px-4 py-3 text-sm font-medium">¥{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 ${statusInfo.className}`}>
<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="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
{order.paymentType === 'alipay' ? '支付宝' : '微信支付'}
</td>
<td className="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
{new Date(order.createdAt).toLocaleString('zh-CN')}
<td className={tdMuted}>
{(() => {
const { channel, provider } = getPaymentDisplayInfo(order.paymentType, locale);
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}>{formatCreatedAt(order.createdAt, locale)}</td>
<td className="whitespace-nowrap px-4 py-3 text-sm">
<div className="flex gap-1">
{order.rechargeRetryable && (
<button
onClick={() => onRetry(order.id)}
className="rounded bg-blue-100 px-2 py-1 text-xs text-blue-700 hover:bg-blue-200"
className={`rounded px-2 py-1 text-xs ${dark ? 'bg-blue-500/20 text-blue-300 hover:bg-blue-500/30' : 'bg-blue-100 text-blue-700 hover:bg-blue-200'}`}
>
{text.retry}
</button>
)}
{order.status === 'PENDING' && (
<button
onClick={() => onCancel(order.id)}
className="rounded bg-red-100 px-2 py-1 text-xs text-red-700 hover:bg-red-200"
className={`rounded px-2 py-1 text-xs ${dark ? 'bg-red-500/20 text-red-300 hover:bg-red-500/30' : 'bg-red-100 text-red-700 hover:bg-red-200'}`}
>
{text.cancel}
</button>
)}
</div>
@@ -107,7 +186,9 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail }:
})}
</tbody>
</table>
{orders.length === 0 && <div className="py-12 text-center text-gray-500"></div>}
{orders.length === 0 && (
<div className={`py-12 text-center ${dark ? 'text-slate-500' : 'text-gray-500'}`}>{text.empty}</div>
)}
</div>
);
}

View File

@@ -0,0 +1,80 @@
'use client';
import { getPaymentTypeLabel, getPaymentMeta } from '@/lib/pay-utils';
import type { Locale } from '@/lib/locale';
interface PaymentMethod {
paymentType: string;
amount: number;
count: number;
percentage: number;
}
interface PaymentMethodChartProps {
data: PaymentMethod[];
dark?: boolean;
locale?: Locale;
}
export default function PaymentMethodChart({ data, dark, locale = 'zh' }: PaymentMethodChartProps) {
const title = locale === 'en' ? 'Payment Method Distribution' : '支付方式分布';
const emptyText = locale === 'en' ? 'No data' : '暂无数据';
const currency = locale === 'en' ? '$' : '¥';
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(' ')}>
{title}
</h3>
<p className={['text-center text-sm py-8', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>{emptyText}</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(' ')}>{title}</h3>
<div className="space-y-4">
{data.map((method) => {
const meta = getPaymentMeta(method.paymentType);
const label = getPaymentTypeLabel(method.paymentType, locale);
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'}>{label}</span>
<span className={dark ? 'text-slate-400' : 'text-slate-500'}>
{currency}
{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-full rounded-full transition-all',
dark ? meta.chartBar.dark : meta.chartBar.light,
].join(' ')}
style={{ width: `${method.percentage}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -1,6 +1,7 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import type { Locale } from '@/lib/locale';
interface RefundDialogProps {
orderId: string;
@@ -9,6 +10,8 @@ interface RefundDialogProps {
onCancel: () => void;
warning?: string;
requireForce?: boolean;
dark?: boolean;
locale?: Locale;
}
export default function RefundDialog({
@@ -18,11 +21,47 @@ export default function RefundDialog({
onCancel,
warning,
requireForce,
dark = false,
locale = 'zh',
}: RefundDialogProps) {
const [reason, setReason] = useState('');
const [force, setForce] = useState(false);
const [loading, setLoading] = useState(false);
const currency = locale === 'en' ? '$' : '¥';
const text =
locale === 'en'
? {
title: 'Confirm Refund',
orderId: 'Order ID',
amount: 'Refund Amount',
reason: 'Refund Reason',
reasonPlaceholder: 'Enter refund reason (optional)',
forceRefund: 'Force refund (balance may become negative)',
cancel: 'Cancel',
confirm: 'Confirm Refund',
processing: 'Processing...',
}
: {
title: '确认退款',
orderId: '订单号',
amount: '退款金额',
reason: '退款原因',
reasonPlaceholder: '请输入退款原因(可选)',
forceRefund: '强制退款(余额可能扣为负数)',
cancel: '取消',
confirm: '确认退款',
processing: '处理中...',
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onCancel]);
const handleConfirm = async () => {
setLoading(true);
try {
@@ -34,30 +73,50 @@ export default function RefundDialog({
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl" onClick={(e) => e.stopPropagation()}>
<h3 className="text-lg font-bold text-gray-900">退</h3>
<div
className={['w-full max-w-md rounded-xl p-6 shadow-xl', dark ? 'bg-slate-900' : 'bg-white'].join(' ')}
onClick={(e) => e.stopPropagation()}
>
<h3 className={['text-lg font-bold', dark ? 'text-slate-100' : 'text-gray-900'].join(' ')}>{text.title}</h3>
<div className="mt-4 space-y-3">
<div className="rounded-lg bg-gray-50 p-3">
<div className="text-sm text-gray-500"></div>
<div className={['rounded-lg p-3', dark ? 'bg-slate-800' : 'bg-gray-50'].join(' ')}>
<div className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{text.orderId}</div>
<div className="text-sm font-mono">{orderId}</div>
</div>
<div className="rounded-lg bg-gray-50 p-3">
<div className="text-sm text-gray-500">退</div>
<div className="text-lg font-bold text-red-600">¥{amount.toFixed(2)}</div>
<div className={['rounded-lg p-3', dark ? 'bg-slate-800' : 'bg-gray-50'].join(' ')}>
<div className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{text.amount}</div>
<div className="text-lg font-bold text-red-600">
{currency}
{amount.toFixed(2)}
</div>
</div>
{warning && <div className="rounded-lg bg-yellow-50 p-3 text-sm text-yellow-700">{warning}</div>}
{warning && (
<div
className={[
'rounded-lg p-3 text-sm',
dark ? 'bg-yellow-900/30 text-yellow-300' : 'bg-yellow-50 text-yellow-700',
].join(' ')}
>
{warning}
</div>
)}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">退</label>
<label className={['mb-1 block text-sm font-medium', dark ? 'text-slate-300' : 'text-gray-700'].join(' ')}>
{text.reason}
</label>
<input
type="text"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="请输入退款原因(可选)"
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
placeholder={text.reasonPlaceholder}
className={[
'w-full rounded-lg border px-3 py-2 text-sm focus:border-blue-500 focus:outline-none',
dark ? 'border-slate-600 bg-slate-800 text-slate-100' : 'border-gray-300 bg-white text-gray-900',
].join(' ')}
/>
</div>
@@ -67,9 +126,9 @@ export default function RefundDialog({
type="checkbox"
checked={force}
onChange={(e) => setForce(e.target.checked)}
className="rounded border-gray-300"
className={['rounded', dark ? 'border-slate-600' : 'border-gray-300'].join(' ')}
/>
<span className="text-red-600">退</span>
<span className="text-red-600">{text.forceRefund}</span>
</label>
)}
</div>
@@ -77,16 +136,21 @@ export default function RefundDialog({
<div className="mt-6 flex gap-3">
<button
onClick={onCancel}
className="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-600 hover:bg-gray-50"
className={[
'flex-1 rounded-lg border py-2 text-sm',
dark
? 'border-slate-600 text-slate-300 hover:bg-slate-800'
: 'border-gray-300 text-gray-600 hover:bg-gray-50',
].join(' ')}
>
{text.cancel}
</button>
<button
onClick={handleConfirm}
disabled={loading || (requireForce && !force)}
className="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-gray-300"
>
{loading ? '处理中...' : '确认退款'}
{loading ? text.processing : text.confirm}
</button>
</div>
</div>

View File

@@ -1,11 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { getEnv } from '@/lib/config';
import crypto from 'crypto';
import { resolveLocale } from '@/lib/locale';
export function verifyAdminToken(request: NextRequest): boolean {
const token = request.nextUrl.searchParams.get('token');
if (!token) return false;
function isLocalAdminToken(token: string): boolean {
const env = getEnv();
const expected = Buffer.from(env.ADMIN_TOKEN);
const received = Buffer.from(token);
@@ -14,6 +12,52 @@ export function verifyAdminToken(request: NextRequest): boolean {
return crypto.timingSafeEqual(expected, received);
}
export function unauthorizedResponse() {
return NextResponse.json({ error: '未授权' }, { status: 401 });
async function isSub2ApiAdmin(token: string): Promise<boolean> {
try {
const env = getEnv();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/auth/me`, {
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) return false;
const data = await response.json();
return data.data?.role === 'admin';
} catch {
return false;
}
}
export async function verifyAdminToken(request: NextRequest): Promise<boolean> {
// 优先从 Authorization: Bearer <token> header 获取
let token: string | null = null;
const authHeader = request.headers.get('authorization');
if (authHeader?.startsWith('Bearer ')) {
token = authHeader.slice(7).trim();
}
// Fallback: query parameter向后兼容已弃用
if (!token) {
token = request.nextUrl.searchParams.get('token');
if (token) {
console.warn(
'[DEPRECATED] Admin token passed via query parameter. Use "Authorization: Bearer <token>" header instead.',
);
}
}
if (!token) return false;
// 1. 本地 admin token
if (isLocalAdminToken(token)) return true;
// 2. Sub2API 管理员 token
return isSub2ApiAdmin(token);
}
export function unauthorizedResponse(request?: NextRequest) {
const locale = resolveLocale(request?.nextUrl.searchParams.get('lang'));
return NextResponse.json({ error: locale === 'en' ? 'Unauthorized' : '未授权' }, { status: 401 });
}

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

@@ -0,0 +1,107 @@
import { getEnv } from '@/lib/config';
import { generateSign } from './sign';
import type { AlipayResponse } from './types';
import { parseAlipayJsonResponse } from './codec';
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 | null; 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 !== null && (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>,
options?: { notifyUrl?: string; returnUrl?: string },
): Promise<T> {
const env = assertAlipayEnv(getEnv());
const params: Record<string, string> = {
...getCommonParams(env.ALIPAY_APP_ID),
method,
biz_content: JSON.stringify(bizContent),
};
if (options?.notifyUrl) {
params.notify_url = options.notifyUrl;
}
if (options?.returnUrl) {
params.return_url = options.returnUrl;
}
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 parseAlipayJsonResponse<Record<string, unknown>>(response);
// 支付宝响应格式:{ "alipay_trade_query_response": { ... }, "sign": "..." }
const responseKey = method.replace(/\./g, '_') + '_response';
const result = data[responseKey] as T | undefined;
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;
}

104
src/lib/alipay/codec.ts Normal file
View File

@@ -0,0 +1,104 @@
const HEADER_CHARSET_RE = /charset=([^;]+)/i;
const BODY_CHARSET_RE = /(?:^|&)charset=([^&]+)/i;
function normalizeCharset(charset: string | null | undefined): string | null {
if (!charset) return null;
const normalized = charset
.trim()
.replace(/^['"]|['"]$/g, '')
.toLowerCase();
if (!normalized) return null;
switch (normalized) {
case 'utf8':
return 'utf-8';
case 'gb2312':
case 'gb_2312-80':
return 'gbk';
default:
return normalized;
}
}
function detectCharsetFromHeaders(headers: Record<string, string>): string | null {
const contentType = headers['content-type'];
const match = contentType?.match(HEADER_CHARSET_RE);
return normalizeCharset(match?.[1]);
}
function detectCharsetFromBody(rawBody: Buffer): string | null {
const latin1Body = rawBody.toString('latin1');
const match = latin1Body.match(BODY_CHARSET_RE);
if (!match) return null;
try {
return normalizeCharset(decodeURIComponent(match[1].replace(/\+/g, ' ')));
} catch {
return normalizeCharset(match[1]);
}
}
function decodeBuffer(rawBody: Buffer, charset: string): string {
return new TextDecoder(charset).decode(rawBody);
}
export function decodeAlipayPayload(rawBody: string | Buffer, headers: Record<string, string> = {}): string {
if (typeof rawBody === 'string') {
return rawBody;
}
const primaryCharset = detectCharsetFromHeaders(headers) || detectCharsetFromBody(rawBody) || 'utf-8';
const candidates = Array.from(new Set([primaryCharset, 'utf-8', 'gbk', 'gb18030']));
let fallbackDecoded: string | null = null;
let lastError: unknown = null;
for (const charset of candidates) {
try {
const decoded = decodeBuffer(rawBody, charset);
if (!decoded.includes('\uFFFD')) {
return decoded;
}
fallbackDecoded ??= decoded;
} catch (error) {
lastError = error;
}
}
if (fallbackDecoded) {
return fallbackDecoded;
}
throw new Error(`Failed to decode Alipay payload${lastError instanceof Error ? `: ${lastError.message}` : ''}`);
}
export function normalizeAlipaySignature(sign: string): string {
return sign.replace(/ /g, '+').trim();
}
export function parseAlipayNotificationParams(
rawBody: string | Buffer,
headers: Record<string, string> = {},
): Record<string, string> {
const body = decodeAlipayPayload(rawBody, headers);
const searchParams = new URLSearchParams(body);
const params: Record<string, string> = {};
for (const [key, value] of searchParams.entries()) {
params[key] = value;
}
if (params.sign) {
params.sign = normalizeAlipaySignature(params.sign);
}
return params;
}
export async function parseAlipayJsonResponse<T>(response: Response): Promise<T> {
const rawBody = Buffer.from(await response.arrayBuffer());
const contentType = response.headers.get('content-type') || '';
const text = decodeAlipayPayload(rawBody, { 'content-type': contentType });
return JSON.parse(text) as T;
}

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

@@ -0,0 +1,195 @@
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';
import { parseAlipayNotificationParams } from './codec';
export interface BuildAlipayPaymentUrlInput {
orderId: string;
amount: number;
subject: string;
notifyUrl?: string;
returnUrl?: string | null;
isMobile?: boolean;
}
function isTradeNotExistError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
return error.message.includes('[ACQ.TRADE_NOT_EXIST]');
}
function getRequiredParam(params: Record<string, string>, key: string): string {
const value = params[key]?.trim();
if (!value) {
throw new Error(`Alipay notification missing required field: ${key}`);
}
return value;
}
export function buildAlipayPaymentUrl(input: BuildAlipayPaymentUrlInput): string {
const method = input.isMobile ? 'alipay.trade.wap.pay' : 'alipay.trade.page.pay';
const productCode = input.isMobile ? 'QUICK_WAP_WAY' : 'FAST_INSTANT_TRADE_PAY';
return pageExecute(
{
out_trade_no: input.orderId,
product_code: productCode,
total_amount: input.amount.toFixed(2),
subject: input.subject,
},
{
notifyUrl: input.notifyUrl,
returnUrl: input.returnUrl,
method,
},
);
}
export function buildAlipayEntryUrl(orderId: string): string {
const env = getEnv();
return new URL(`/pay/${orderId}`, env.NEXT_PUBLIC_APP_URL).toString();
}
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> {
if (!request.isMobile) {
const entryUrl = buildAlipayEntryUrl(request.orderId);
return {
tradeNo: request.orderId,
payUrl: entryUrl,
qrCode: entryUrl,
};
}
const payUrl = buildAlipayPaymentUrl({
orderId: request.orderId,
amount: request.amount,
subject: request.subject,
notifyUrl: request.notifyUrl,
returnUrl: request.returnUrl,
isMobile: true,
});
return { tradeNo: request.orderId, payUrl };
}
async queryOrder(tradeNo: string): Promise<QueryOrderResponse> {
let result: AlipayTradeQueryResponse;
try {
result = await execute<AlipayTradeQueryResponse>('alipay.trade.query', {
out_trade_no: tradeNo,
});
} catch (error) {
if (isTradeNotExistError(error)) {
return {
tradeNo,
status: 'pending',
amount: 0,
};
}
throw error;
}
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 params = parseAlipayNotificationParams(rawBody, headers);
if (params.sign_type && params.sign_type.toUpperCase() !== 'RSA2') {
throw new Error('Unsupported sign_type, only RSA2 is accepted');
}
const sign = getRequiredParam(params, 'sign');
if (!env.ALIPAY_PUBLIC_KEY || !verifySign(params, env.ALIPAY_PUBLIC_KEY, sign)) {
throw new Error(
'Alipay notification signature verification failed (check ALIPAY_PUBLIC_KEY uses Alipay public key, not app public key, and rebuild/redeploy the latest image)',
);
}
const tradeNo = getRequiredParam(params, 'trade_no');
const orderId = getRequiredParam(params, 'out_trade_no');
const tradeStatus = getRequiredParam(params, 'trade_status');
const appId = getRequiredParam(params, 'app_id');
if (appId !== env.ALIPAY_APP_ID) {
throw new Error('Alipay notification app_id mismatch');
}
const amount = Number.parseFloat(getRequiredParam(params, 'total_amount'));
if (!Number.isFinite(amount) || amount <= 0) {
throw new Error('Alipay notification invalid total_amount');
}
return {
tradeNo,
orderId,
amount: Math.round(amount * 100) / 100,
status: tradeStatus === 'TRADE_SUCCESS' || tradeStatus === '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> {
try {
await execute<AlipayTradeCloseResponse>('alipay.trade.close', {
out_trade_no: tradeNo,
});
} catch (error) {
if (isTradeNotExistError(error)) {
return;
}
throw error;
}
}
}

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

@@ -0,0 +1,80 @@
import crypto from 'crypto';
/** 将裸 base64 按 64 字符/行折行,符合 PEM 标准OpenSSL 3.x 严格模式要求) */
function wrapBase64(b64: string): string {
return b64.replace(/(.{64})/g, '$1\n').trim();
}
function normalizePemLikeValue(key: string): string {
return key
.trim()
.replace(/\r\n/g, '\n')
.replace(/\\r\\n/g, '\n')
.replace(/\\n/g, '\n');
}
function shouldLogVerifyDebug(): boolean {
return process.env.NODE_ENV !== 'production' || process.env.DEBUG_ALIPAY_SIGN === '1';
}
/** 自动补全 PEM 格式PKCS8 */
function formatPrivateKey(key: string): string {
const normalized = normalizePemLikeValue(key);
if (normalized.includes('-----BEGIN')) return normalized;
return `-----BEGIN PRIVATE KEY-----\n${wrapBase64(normalized)}\n-----END PRIVATE KEY-----`;
}
function formatPublicKey(key: string): string {
const normalized = normalizePemLikeValue(key);
if (normalized.includes('-----BEGIN')) return normalized;
return `-----BEGIN PUBLIC KEY-----\n${wrapBase64(normalized)}\n-----END PUBLIC KEY-----`;
}
/** 生成 RSA2 签名(请求签名:仅排除 sign */
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');
}
/** 用支付宝公钥验证签名(回调验签:排除 sign 和 sign_type */
export function verifySign(params: Record<string, string>, alipayPublicKey: string, sign: string): boolean {
const filtered = Object.entries(params)
.filter(
([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null,
)
.sort(([a], [b]) => a.localeCompare(b));
const signStr = filtered.map(([key, value]) => `${key}=${value}`).join('&');
const pem = formatPublicKey(alipayPublicKey);
try {
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(signStr);
const result = verifier.verify(pem, sign, 'base64');
if (!result) {
if (shouldLogVerifyDebug()) {
console.error('[Alipay verifySign] FAILED. signStr:', signStr.substring(0, 200) + '...');
console.error('[Alipay verifySign] sign(first 40):', sign.substring(0, 40));
console.error('[Alipay verifySign] pubKey(first 80):', pem.substring(0, 80));
} else {
console.error('[Alipay verifySign] verification failed');
}
}
return result;
} catch (err) {
if (shouldLogVerifyDebug()) {
console.error('[Alipay verifySign] crypto error:', err);
} else {
const message = err instanceof Error ? err.message : String(err);
console.error('[Alipay verifySign] crypto error:', message);
}
return false;
}
}

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,7 +13,18 @@ const envSchema = z.object({
SUB2API_BASE_URL: z.string().url(),
SUB2API_ADMIN_API_KEY: z.string().min(1),
// ── Easy-Pay (optional when only using Stripe) ──
// ── 支付服务商显式声明启用哪些服务商逗号分隔easypay, alipay, wxpay, stripe ──
PAYMENT_PROVIDERS: z
.string()
.default('')
.transform((v) =>
v
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
),
// ── Easy-PayPAYMENT_PROVIDERS 含 easypay 时必填) ──
EASY_PAY_PID: optionalTrimmedString,
EASY_PAY_PKEY: optionalTrimmedString,
EASY_PAY_API_BASE: optionalTrimmedString,
@@ -22,39 +34,94 @@ 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,
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()),
// 每日每用户最大累计充值额0 = 不限制
MAX_DAILY_RECHARGE_AMOUNT: z.string().default('10000').transform(Number).pipe(z.number().min(0)),
// 每日各渠道全平台总限额0 = 不限制
// 新增渠道按 MAX_DAILY_AMOUNT_{TYPE大写} 命名即可自动生效
MAX_DAILY_AMOUNT_ALIPAY: z.string().default('10000').transform(Number).pipe(z.number().min(0)),
MAX_DAILY_AMOUNT_WXPAY: z.string().default('10000').transform(Number).pipe(z.number().min(0)),
MAX_DAILY_AMOUNT_STRIPE: z.string().default('0').transform(Number).pipe(z.number().min(0)),
// 每日各渠道全平台总限额,可选覆盖(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_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),
NEXT_PUBLIC_APP_URL: z.string().url(),
NEXT_PUBLIC_PAY_HELP_IMAGE_URL: optionalTrimmedString,
NEXT_PUBLIC_PAY_HELP_TEXT: optionalTrimmedString,
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;
@@ -64,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

@@ -1,10 +1,11 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { getEnv } from '@/lib/config';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
function createPrismaClient() {
const connectionString = process.env.DATABASE_URL ?? 'postgresql://localhost:5432/sub2apipay';
const connectionString = getEnv().DATABASE_URL;
const adapter = new PrismaPg({ connectionString });
return new PrismaClient({ adapter });
}

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);
@@ -76,6 +76,7 @@ export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPay
method: 'POST',
body: formData,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
signal: AbortSignal.timeout(10_000),
});
const data = (await response.json()) as EasyPayCreateResponse;
@@ -88,7 +89,9 @@ export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPay
export async function queryOrder(outTradeNo: string): Promise<EasyPayQueryResponse> {
const env = assertEasyPayEnv(getEnv());
const url = `${env.EASY_PAY_API_BASE}/api.php?act=order&pid=${env.EASY_PAY_PID}&key=${env.EASY_PAY_PKEY}&out_trade_no=${outTradeNo}`;
const response = await fetch(url);
const response = await fetch(url, {
signal: AbortSignal.timeout(10_000),
});
const data = (await response.json()) as EasyPayQueryResponse;
if (data.code !== 1) {
throw new Error(`EasyPay query order failed: ${data.msg || 'unknown error'}`);
@@ -109,6 +112,7 @@ export async function refund(tradeNo: string, outTradeNo: string, money: string)
method: 'POST',
body: params,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
signal: AbortSignal.timeout(10_000),
});
const data = (await response.json()) as EasyPayRefundResponse;
if (data.code !== 1) {

View File

@@ -14,7 +14,12 @@ import { getEnv } from '@/lib/config';
export class EasyPayProvider implements PaymentProvider {
readonly name = 'easy-pay';
readonly providerKey = 'easypay';
readonly supportedTypes: PaymentType[] = ['alipay', 'wxpay'];
readonly defaultLimits = {
alipay: { singleMax: 1000, dailyMax: 10000 },
wxpay: { singleMax: 1000, dailyMax: 10000 },
};
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
const result = await createPayment({

20
src/lib/locale.ts Normal file
View File

@@ -0,0 +1,20 @@
export type Locale = 'zh' | 'en';
export function resolveLocale(lang: string | null | undefined): Locale {
return lang?.trim().toLowerCase() === 'en' ? 'en' : 'zh';
}
export function isEnglish(locale: Locale): boolean {
return locale === 'en';
}
export function pickLocaleText<T>(locale: Locale, zh: T, en: T): T {
return locale === 'en' ? en : zh;
}
export function applyLocaleToSearchParams(params: URLSearchParams, locale: Locale): URLSearchParams {
if (locale === 'en') {
params.set('lang', 'en');
}
return params;
}

44
src/lib/order/fee.ts Normal file
View File

@@ -0,0 +1,44 @@
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
import { Prisma } from '@prisma/client';
/**
* 获取指定支付渠道的手续费率(百分比)。
* 优先级FEE_RATE_{TYPE} > FEE_RATE_PROVIDER_{KEY} > 0
*/
export function getMethodFeeRate(paymentType: string): number {
// 渠道级别FEE_RATE_ALIPAY / FEE_RATE_WXPAY / FEE_RATE_STRIPE
const methodRaw = process.env[`FEE_RATE_${paymentType.toUpperCase()}`];
if (methodRaw !== undefined && methodRaw !== '') {
const num = Number(methodRaw);
if (Number.isFinite(num) && num >= 0) return num;
}
// 提供商级别FEE_RATE_PROVIDER_EASYPAY / FEE_RATE_PROVIDER_STRIPE
initPaymentProviders();
const providerKey = paymentRegistry.getProviderKey(paymentType);
if (providerKey) {
const providerRaw = process.env[`FEE_RATE_PROVIDER_${providerKey.toUpperCase()}`];
if (providerRaw !== undefined && providerRaw !== '') {
const num = Number(providerRaw);
if (Number.isFinite(num) && num >= 0) return num;
}
}
return 0;
}
/** decimal.js ROUND_UP = 0远离零方向取整 */
const ROUND_UP = 0;
/**
* 根据到账金额和手续费率计算实付金额(使用 Decimal 精确计算,避免浮点误差)。
* feeAmount = ceil(rechargeAmount * feeRate / 100, 保留2位小数)
* payAmount = rechargeAmount + feeAmount
*/
export function calculatePayAmount(rechargeAmount: number, feeRate: number): number {
if (feeRate <= 0) return rechargeAmount;
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,9 +1,13 @@
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';
import { getBizDayStartUTC } from '@/lib/time/biz-day';
/**
* 获取指定支付渠道的每日全平台限额0 = 不限制)。
* 优先读 configZod 验证),兜底读 process.env适配未来动态注册的新渠道。
* 优先级:环境变量显式配置 > provider 默认值 > process.env 兜底 > 0
*/
export function getMethodDailyLimit(paymentType: string): number {
const env = getEnv();
@@ -11,53 +15,69 @@ export function getMethodDailyLimit(paymentType: string): number {
const val = env[key];
if (typeof val === 'number') return val;
// 兜底:支持动态渠道(未在 schema 中声明的 MAX_DAILY_AMOUNT_* 变量)
initPaymentProviders();
const providerDefault = paymentRegistry.getDefaultLimit(paymentType);
if (providerDefault?.dailyMax !== undefined) return providerDefault.dailyMax;
const raw = process.env[`MAX_DAILY_AMOUNT_${paymentType.toUpperCase()}`];
if (raw !== undefined) {
const num = Number(raw);
return Number.isFinite(num) && num >= 0 ? num : 0;
}
return 0; // 默认不限制
return 0;
}
/**
* 获取指定支付渠道的单笔限额0 = 使用全局 MAX_RECHARGE_AMOUNT
* 优先级process.env MAX_SINGLE_AMOUNT_* > provider 默认值 > 0
*/
export function getMethodSingleLimit(paymentType: string): number {
const raw = process.env[`MAX_SINGLE_AMOUNT_${paymentType.toUpperCase()}`];
if (raw !== undefined) {
const num = Number(raw);
if (Number.isFinite(num) && num >= 0) return num;
}
initPaymentProviders();
const providerDefault = paymentRegistry.getDefaultLimit(paymentType);
if (providerDefault?.singleMax !== undefined) return providerDefault.singleMax;
return 0;
}
export interface MethodLimitStatus {
/** 每日限额0 = 不限 */
dailyLimit: number;
/** 今日已使用金额 */
used: number;
/** 剩余额度null = 不限 */
remaining: number | null;
/** 是否还可使用false = 今日额度已满) */
available: boolean;
singleMax: number;
feeRate: number;
}
/**
* 批量查询多个支付渠道的今日使用情况。
* 一次 DB groupBy 完成,调用方按需传入渠道列表。
*/
export async function queryMethodLimits(
paymentTypes: string[],
): Promise<Record<string, MethodLimitStatus>> {
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);
export async function queryMethodLimits(paymentTypes: string[]): Promise<Record<string, MethodLimitStatus>> {
const todayStart = getBizDayStartUTC();
const usageRows = await prisma.order.groupBy({
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((row) => [row.paymentType, Number(row._sum.amount ?? 0)]));
const result: Record<string, MethodLimitStatus> = {};
for (const type of paymentTypes) {
const dailyLimit = getMethodDailyLimit(type);
const singleMax = getMethodSingleLimit(type);
const feeRate = getMethodFeeRate(type);
const used = usageMap[type] ?? 0;
const remaining = dailyLimit > 0 ? Math.max(0, dailyLimit - used) : null;
result[type] = {
@@ -65,6 +85,8 @@ export async function queryMethodLimits(
used,
remaining,
available: dailyLimit === 0 || used < dailyLimit,
singleMax,
feeRate,
};
}
return result;

View File

@@ -1,58 +1,82 @@
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';
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
import type { PaymentType, PaymentNotification } from '@/lib/payment';
import { getUser, createAndRedeem, subtractBalance } from '@/lib/sub2api/client';
import { getUser, createAndRedeem, subtractBalance, addBalance } from '@/lib/sub2api/client';
import { Prisma } from '@prisma/client';
import { deriveOrderState, isRefundStatus } from './status';
import { pickLocaleText, type Locale } from '@/lib/locale';
import { getBizDayStartUTC } from '@/lib/time/biz-day';
import { buildOrderResultUrl, createOrderStatusAccessToken } from '@/lib/order/status-access';
const MAX_PENDING_ORDERS = 3;
function message(locale: Locale, zh: string, en: string): string {
return pickLocaleText(locale, zh, en);
}
export interface CreateOrderInput {
userId: number;
amount: number;
paymentType: PaymentType;
clientIp: string;
isMobile?: boolean;
srcHost?: string;
srcUrl?: string;
locale?: Locale;
}
export interface CreateOrderResult {
orderId: string;
amount: number;
payAmount: number;
feeRate: number;
status: string;
paymentType: PaymentType;
userName: string;
userBalance: number;
payUrl?: string | null;
qrCode?: string | null;
checkoutUrl?: string | null;
clientSecret?: string | null;
expiresAt: Date;
statusAccessToken: string;
}
export async function createOrder(input: CreateOrderInput): Promise<CreateOrderResult> {
const env = getEnv();
const locale = input.locale ?? 'zh';
const todayStart = getBizDayStartUTC();
const user = await getUser(input.userId);
if (user.status !== 'active') {
throw new OrderError('USER_INACTIVE', 'User account is disabled', 422);
throw new OrderError('USER_INACTIVE', message(locale, '用户账号已被禁用', 'User account is disabled'), 422);
}
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);
throw new OrderError(
'TOO_MANY_PENDING',
message(
locale,
`待支付订单过多(最多 ${MAX_PENDING_ORDERS} 笔)`,
`Too many pending orders (${MAX_PENDING_ORDERS})`,
),
429,
);
}
// 每日累计充值限额校验0 = 不限制)
if (env.MAX_DAILY_RECHARGE_AMOUNT > 0) {
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);
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 },
@@ -62,7 +86,11 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
const remaining = Math.max(0, env.MAX_DAILY_RECHARGE_AMOUNT - alreadyPaid);
throw new OrderError(
'DAILY_LIMIT_EXCEEDED',
`今日累计充值已达上限,剩余可充值 ${remaining.toFixed(2)}`,
message(
locale,
`今日累计充值已达上限,剩余可充值 ${remaining.toFixed(2)}`,
`Daily recharge limit reached. Remaining amount: ${remaining.toFixed(2)} CNY`,
),
429,
);
}
@@ -71,12 +99,10 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
// 渠道每日全平台限额校验0 = 不限)
const methodDailyLimit = getMethodDailyLimit(input.paymentType);
if (methodDailyLimit > 0) {
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);
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 },
@@ -87,45 +113,78 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
throw new OrderError(
'METHOD_DAILY_LIMIT_EXCEEDED',
remaining > 0
? `${input.paymentType} 今日剩余额度 ${remaining.toFixed(2)} 元,请减少充值金额或使用其他支付方式`
: `${input.paymentType} 今日充值额度已满,请使用其他支付方式`,
? message(
locale,
`${input.paymentType} 今日剩余额度 ${remaining.toFixed(2)} 元,请减少充值金额或使用其他支付方式`,
`${input.paymentType} remaining daily quota: ${remaining.toFixed(2)} CNY. Reduce the amount or use another payment method`,
)
: message(
locale,
`${input.paymentType} 今日充值额度已满,请使用其他支付方式`,
`${input.paymentType} daily quota is full. Please use another payment method`,
),
429,
);
}
}
const expiresAt = new Date(Date.now() + env.ORDER_TIMEOUT_MINUTES * 60 * 1000);
const order = await prisma.order.create({
data: {
userId: input.userId,
userEmail: user.email,
userName: user.username,
amount: new Prisma.Decimal(input.amount.toFixed(2)),
rechargeCode: '',
status: 'PENDING',
paymentType: input.paymentType,
expiresAt,
clientIp: input.clientIp,
},
});
const feeRate = getMethodFeeRate(input.paymentType);
const payAmount = calculatePayAmount(input.amount, feeRate);
const rechargeCode = generateRechargeCode(order.id);
await prisma.order.update({
where: { id: order.id },
data: { rechargeCode },
const expiresAt = new Date(Date.now() + env.ORDER_TIMEOUT_MINUTES * 60 * 1000);
const order = await prisma.$transaction(async (tx) => {
const created = await tx.order.create({
data: {
userId: input.userId,
userEmail: user.email,
userName: user.username,
userNotes: user.notes || null,
amount: new Prisma.Decimal(input.amount.toFixed(2)),
payAmount: new Prisma.Decimal(payAmount.toFixed(2)),
feeRate: feeRate > 0 ? new Prisma.Decimal(feeRate.toFixed(2)) : null,
rechargeCode: '',
status: 'PENDING',
paymentType: input.paymentType,
expiresAt,
clientIp: input.clientIp,
srcHost: input.srcHost || null,
srcUrl: input.srcUrl || null,
},
});
const rechargeCode = generateRechargeCode(created.id);
await tx.order.update({
where: { id: created.id },
data: { rechargeCode },
});
return { ...created, rechargeCode };
});
try {
initPaymentProviders();
const provider = paymentRegistry.getProvider(input.paymentType);
const statusAccessToken = createOrderStatusAccessToken(order.id);
const orderResultUrl = buildOrderResultUrl(env.NEXT_PUBLIC_APP_URL, order.id);
// 只有 easypay 从外部传入 notifyUrlreturn_url 统一回到带访问令牌的结果页
let notifyUrl: string | undefined;
let returnUrl: string | undefined = orderResultUrl;
if (provider.providerKey === 'easypay') {
notifyUrl = env.EASY_PAY_NOTIFY_URL || '';
returnUrl = orderResultUrl;
}
const paymentResult = await provider.createPayment({
orderId: order.id,
amount: input.amount,
amount: payAmount,
paymentType: input.paymentType,
subject: `${env.PRODUCT_NAME} ${input.amount.toFixed(2)} CNY`,
notifyUrl: env.EASY_PAY_NOTIFY_URL || '',
returnUrl: env.EASY_PAY_RETURN_URL || '',
subject: `${env.PRODUCT_NAME} ${payAmount.toFixed(2)} CNY`,
notifyUrl,
returnUrl,
clientIp: input.clientIp,
isMobile: input.isMobile,
});
await prisma.order.update({
@@ -149,14 +208,17 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
return {
orderId: order.id,
amount: input.amount,
status: 'PENDING',
payAmount,
feeRate,
status: ORDER_STATUS.PENDING,
paymentType: input.paymentType,
userName: user.username,
userBalance: user.balance,
payUrl: paymentResult.payUrl,
qrCode: paymentResult.qrCode,
checkoutUrl: paymentResult.checkoutUrl,
clientSecret: paymentResult.clientSecret,
expiresAt,
statusAccessToken,
};
} catch (error) {
await prisma.order.delete({ where: { id: order.id } });
@@ -166,10 +228,27 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
// 支付网关配置缺失或调用失败,转成友好错误
const msg = error instanceof Error ? error.message : String(error);
console.error(`Payment gateway error (${input.paymentType}):`, error);
if (msg.includes('environment variables') || msg.includes('not configured') || msg.includes('not found')) {
throw new OrderError('PAYMENT_GATEWAY_ERROR', `支付渠道(${input.paymentType})暂未配置,请联系管理员`, 503);
throw new OrderError(
'PAYMENT_GATEWAY_ERROR',
message(
locale,
`支付渠道(${input.paymentType})暂未配置,请联系管理员`,
`Payment method (${input.paymentType}) is not configured. Please contact the administrator`,
),
503,
);
}
throw new OrderError('PAYMENT_GATEWAY_ERROR', '支付渠道暂时不可用,请稍后重试或更换支付方式', 502);
throw new OrderError(
'PAYMENT_GATEWAY_ERROR',
message(
locale,
'支付渠道暂时不可用,请稍后重试或更换支付方式',
'Payment method is temporarily unavailable. Please try again later or use another payment method',
),
502,
);
}
}
@@ -221,7 +300,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() },
});
@@ -230,7 +309,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,
},
@@ -240,42 +319,44 @@ export async function cancelOrderCore(options: {
return 'cancelled';
}
export async function cancelOrder(orderId: string, userId: number): Promise<CancelOutcome> {
export async function cancelOrder(orderId: string, userId: number, locale: Locale = 'zh'): Promise<CancelOutcome> {
const order = await prisma.order.findUnique({
where: { id: orderId },
select: { id: true, userId: true, status: true, paymentTradeNo: true, paymentType: true },
});
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) throw new OrderError('NOT_FOUND', message(locale, '订单不存在', 'Order not found'), 404);
if (order.userId !== userId) throw new OrderError('FORBIDDEN', message(locale, '无权操作该订单', 'Forbidden'), 403);
if (order.status !== ORDER_STATUS.PENDING)
throw new OrderError('INVALID_STATUS', message(locale, '订单当前状态不可取消', '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',
auditDetail: message(locale, '用户取消订单', 'User cancelled order'),
});
}
export async function adminCancelOrder(orderId: string): Promise<CancelOutcome> {
export async function adminCancelOrder(orderId: string, locale: Locale = 'zh'): Promise<CancelOutcome> {
const order = await prisma.order.findUnique({
where: { id: orderId },
select: { id: true, status: true, paymentTradeNo: true, paymentType: true },
});
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) throw new OrderError('NOT_FOUND', message(locale, '订单不存在', 'Order not found'), 404);
if (order.status !== ORDER_STATUS.PENDING)
throw new OrderError('INVALID_STATUS', message(locale, '订单当前状态不可取消', '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',
auditDetail: message(locale, '管理员取消订单', 'Admin cancelled order'),
});
}
@@ -308,22 +389,47 @@ export async function confirmPayment(input: {
console.error(`${input.providerName} notify: non-positive amount:`, input.paidAmount);
return false;
}
if (!paidAmount.equals(order.amount)) {
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`,
order.amount.toString(),
`${input.providerName} notify: minor amount difference (rounding)`,
expectedAmount.toString(),
paidAmount.toString(),
);
}
// 只接受 PENDING 状态,或过期不超过 5 分钟的 EXPIRED 订单(支付在过期边缘完成的宽限窗口)
const graceDeadline = new Date(Date.now() - 5 * 60 * 1000);
const result = await prisma.order.updateMany({
where: {
id: order.id,
status: { in: ['PENDING', 'EXPIRED'] },
OR: [{ status: ORDER_STATUS.PENDING }, { status: ORDER_STATUS.EXPIRED, updatedAt: { gte: graceDeadline } }],
},
data: {
status: 'PAID',
amount: paidAmount,
status: ORDER_STATUS.PAID,
payAmount: paidAmount,
paymentTradeNo: input.tradeNo,
paidAt: new Date(),
failedAt: null,
@@ -332,6 +438,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;
}
@@ -353,6 +488,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;
@@ -381,16 +517,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,
@@ -399,9 +545,9 @@ export async function executeRecharge(orderId: string): Promise<void> {
`sub2apipay recharge order:${orderId}`,
);
await prisma.order.update({
where: { id: orderId },
data: { status: 'COMPLETED', completedAt: new Date() },
await prisma.order.updateMany({
where: { id: orderId, status: ORDER_STATUS.RECHARGING },
data: { status: ORDER_STATUS.COMPLETED, completedAt: new Date() },
});
await prisma.auditLog.create({
@@ -416,7 +562,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),
},
@@ -435,31 +581,47 @@ export async function executeRecharge(orderId: string): Promise<void> {
}
}
function assertRetryAllowed(order: { status: string; paidAt: Date | null }): void {
function assertRetryAllowed(order: { status: string; paidAt: Date | null }, locale: Locale): void {
if (!order.paidAt) {
throw new OrderError('INVALID_STATUS', 'Order is not paid, retry denied', 400);
throw new OrderError(
'INVALID_STATUS',
message(locale, '订单未支付,不允许重试', 'Order is not paid, retry denied'),
400,
);
}
if (isRefundStatus(order.status)) {
throw new OrderError('INVALID_STATUS', 'Refund-related order cannot retry', 400);
throw new OrderError(
'INVALID_STATUS',
message(locale, '退款相关订单不允许重试', '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') {
throw new OrderError('CONFLICT', 'Order is recharging, retry later', 409);
if (order.status === ORDER_STATUS.RECHARGING) {
throw new OrderError(
'CONFLICT',
message(locale, '订单正在充值中,请稍后重试', 'Order is recharging, retry later'),
409,
);
}
if (order.status === 'COMPLETED') {
throw new OrderError('INVALID_STATUS', 'Order already completed', 400);
if (order.status === ORDER_STATUS.COMPLETED) {
throw new OrderError('INVALID_STATUS', message(locale, '订单已完成', 'Order already completed'), 400);
}
throw new OrderError('INVALID_STATUS', 'Only paid and failed orders can retry', 400);
throw new OrderError(
'INVALID_STATUS',
message(locale, '仅已支付和失败订单允许重试', 'Only paid and failed orders can retry'),
400,
);
}
export async function retryRecharge(orderId: string): Promise<void> {
export async function retryRecharge(orderId: string, locale: Locale = 'zh'): Promise<void> {
const order = await prisma.order.findUnique({
where: { id: orderId },
select: {
@@ -471,18 +633,18 @@ export async function retryRecharge(orderId: string): Promise<void> {
});
if (!order) {
throw new OrderError('NOT_FOUND', 'Order not found', 404);
throw new OrderError('NOT_FOUND', message(locale, '订单不存在', 'Order not found'), 404);
}
assertRetryAllowed(order);
assertRetryAllowed(order, locale);
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) {
@@ -496,30 +658,42 @@ export async function retryRecharge(orderId: string): Promise<void> {
});
if (!latest) {
throw new OrderError('NOT_FOUND', 'Order not found', 404);
throw new OrderError('NOT_FOUND', message(locale, '订单不存在', 'Order not found'), 404);
}
const derived = deriveOrderState(latest);
if (derived.rechargeStatus === 'recharging' || latest.status === 'PAID') {
throw new OrderError('CONFLICT', 'Order is recharging, retry later', 409);
if (derived.rechargeStatus === 'recharging' || latest.status === ORDER_STATUS.PAID) {
throw new OrderError(
'CONFLICT',
message(locale, '订单正在充值中,请稍后重试', 'Order is recharging, retry later'),
409,
);
}
if (derived.rechargeStatus === 'success') {
throw new OrderError('INVALID_STATUS', 'Order already completed', 400);
throw new OrderError('INVALID_STATUS', message(locale, '订单已完成', 'Order already completed'), 400);
}
if (isRefundStatus(latest.status)) {
throw new OrderError('INVALID_STATUS', 'Refund-related order cannot retry', 400);
throw new OrderError(
'INVALID_STATUS',
message(locale, '退款相关订单不允许重试', 'Refund-related order cannot retry'),
400,
);
}
throw new OrderError('CONFLICT', 'Order status changed, refresh and retry', 409);
throw new OrderError(
'CONFLICT',
message(locale, '订单状态已变更,请刷新后重试', 'Order status changed, refresh and retry'),
409,
);
}
await prisma.auditLog.create({
data: {
orderId,
action: 'RECHARGE_RETRY',
detail: 'Admin manual retry recharge',
detail: message(locale, '管理员手动重试充值', 'Admin manual retry recharge'),
operator: 'admin',
},
});
@@ -531,6 +705,7 @@ export interface RefundInput {
orderId: string;
reason?: string;
force?: boolean;
locale?: Locale;
}
export interface RefundResult {
@@ -540,60 +715,108 @@ export interface RefundResult {
}
export async function processRefund(input: RefundInput): Promise<RefundResult> {
const locale = input.locale ?? 'zh';
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') {
throw new OrderError('INVALID_STATUS', 'Only completed orders can be refunded', 400);
if (!order) throw new OrderError('NOT_FOUND', message(locale, '订单不存在', 'Order not found'), 404);
if (order.status !== ORDER_STATUS.COMPLETED) {
throw new OrderError(
'INVALID_STATUS',
message(locale, '仅已完成订单允许退款', 'Only completed orders can be refunded'),
400,
);
}
const amount = Number(order.amount);
const rechargeAmount = Number(order.amount);
const refundAmount = Number(order.payAmount ?? order.amount);
if (!input.force) {
try {
const user = await getUser(order.userId);
if (user.balance < amount) {
if (user.balance < rechargeAmount) {
return {
success: false,
warning: `User balance ${user.balance} is lower than refund ${amount}`,
warning: message(
locale,
`用户余额 ${user.balance} 小于需退款的充值金额 ${rechargeAmount}`,
`User balance ${user.balance} is lower than refund ${rechargeAmount}`,
),
requireForce: true,
};
}
} catch {
return {
success: false,
warning: 'Cannot fetch user balance, use force=true',
warning: message(locale, '无法获取用户余额,请使用 force=true', 'Cannot fetch user balance, use force=true'),
requireForce: true,
};
}
}
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);
throw new OrderError(
'CONFLICT',
message(locale, '订单状态已变更,请刷新后重试', 'Order status changed, refresh and retry'),
409,
);
}
try {
if (order.paymentTradeNo) {
initPaymentProviders();
const provider = paymentRegistry.getProvider(order.paymentType as PaymentType);
await provider.refund({
tradeNo: order.paymentTradeNo,
orderId: order.id,
amount,
reason: input.reason,
});
}
// 1. 先扣减用户余额(安全方向:先扣后退)
await subtractBalance(
order.userId,
rechargeAmount,
`sub2apipay refund order:${order.id}`,
`sub2apipay:refund:${order.id}`,
);
await subtractBalance(order.userId, amount, `sub2apipay refund order:${order.id}`, `sub2apipay:refund:${order.id}`);
// 2. 调用支付网关退款
if (order.paymentTradeNo) {
try {
initPaymentProviders();
const provider = paymentRegistry.getProvider(order.paymentType as PaymentType);
await provider.refund({
tradeNo: order.paymentTradeNo,
orderId: order.id,
amount: refundAmount,
reason: input.reason,
});
} catch (gatewayError) {
// 3. 网关退款失败 — 恢复已扣减的余额
try {
await addBalance(
order.userId,
rechargeAmount,
`sub2apipay refund rollback order:${order.id}`,
`sub2apipay:refund-rollback:${order.id}`,
);
} catch (rollbackError) {
// 余额恢复也失败,记录审计日志,需人工介入
await prisma.auditLog.create({
data: {
orderId: input.orderId,
action: 'REFUND_ROLLBACK_FAILED',
detail: JSON.stringify({
gatewayError: gatewayError instanceof Error ? gatewayError.message : String(gatewayError),
rollbackError: rollbackError instanceof Error ? rollbackError.message : String(rollbackError),
rechargeAmount,
}),
operator: 'admin',
},
});
}
throw gatewayError;
}
}
await prisma.order.update({
where: { id: input.orderId },
data: {
status: 'REFUNDED',
refundAmount: new Prisma.Decimal(amount.toFixed(2)),
status: ORDER_STATUS.REFUNDED,
refundAmount: new Prisma.Decimal(refundAmount.toFixed(2)),
refundReason: input.reason || null,
refundAt: new Date(),
forceRefund: input.force || false,
@@ -604,7 +827,7 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
data: {
orderId: input.orderId,
action: 'REFUND_SUCCESS',
detail: JSON.stringify({ amount, reason: input.reason, force: input.force }),
detail: JSON.stringify({ rechargeAmount, refundAmount, reason: input.reason, force: input.force }),
operator: 'admin',
},
});
@@ -614,7 +837,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

@@ -0,0 +1,37 @@
import crypto from 'crypto';
import { getEnv } from '@/lib/config';
export const ORDER_STATUS_ACCESS_QUERY_KEY = 'access_token';
const ORDER_STATUS_ACCESS_PURPOSE = 'order-status-access:v1';
function buildSignature(orderId: string): string {
return crypto
.createHmac('sha256', getEnv().ADMIN_TOKEN)
.update(`${ORDER_STATUS_ACCESS_PURPOSE}:${orderId}`)
.digest('base64url');
}
export function createOrderStatusAccessToken(orderId: string): string {
return buildSignature(orderId);
}
export function verifyOrderStatusAccessToken(orderId: string, token: string | null | undefined): boolean {
if (!token) return false;
const expected = buildSignature(orderId);
const expectedBuffer = Buffer.from(expected, 'utf8');
const receivedBuffer = Buffer.from(token, 'utf8');
if (expectedBuffer.length !== receivedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
}
export function buildOrderResultUrl(appUrl: string, orderId: string): string {
const url = new URL('/pay/result', appUrl);
url.searchParams.set('order_id', orderId);
url.searchParams.set(ORDER_STATUS_ACCESS_QUERY_KEY, createOrderStatusAccessToken(orderId));
return url.toString();
}

View File

@@ -0,0 +1,15 @@
/**
* Client-safe utility for building order status API URLs.
* This module must NOT import any server-only modules (config, fs, crypto, etc.).
*/
const ACCESS_TOKEN_KEY = 'access_token';
export function buildOrderStatusUrl(orderId: string, accessToken?: string | null): string {
const query = new URLSearchParams();
if (accessToken) {
query.set(ACCESS_TOKEN_KEY, accessToken);
}
const suffix = query.toString();
return suffix ? `/api/orders/${orderId}?${suffix}` : `/api/orders/${orderId}`;
}

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,32 @@ export interface OrderStatusLike {
completedAt?: Date | string | null;
}
const CLOSED_STATUSES = new Set(['EXPIRED', 'CANCELLED', 'REFUNDING', 'REFUNDED', 'REFUND_FAILED']);
export interface DerivedOrderState {
paymentSuccess: boolean;
rechargeSuccess: boolean;
rechargeStatus: RechargeStatus;
}
const REFUND_STATUSES = new Set(['REFUNDING', 'REFUNDED', 'REFUND_FAILED']);
export interface PublicOrderStatusSnapshot extends DerivedOrderState {
id: string;
status: string;
expiresAt: Date | string;
}
export interface OrderDisplayState {
label: string;
color: string;
icon: string;
message: string;
}
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,26 +44,22 @@ 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): {
paymentSuccess: boolean;
rechargeSuccess: boolean;
rechargeStatus: RechargeStatus;
} {
export function deriveOrderState(order: OrderStatusLike): DerivedOrderState {
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' };
}
@@ -52,3 +73,80 @@ export function deriveOrderState(order: OrderStatusLike): {
return { paymentSuccess: false, rechargeSuccess: false, rechargeStatus: 'not_paid' };
}
export function getOrderDisplayState(
order: Pick<PublicOrderStatusSnapshot, 'status' | 'paymentSuccess' | 'rechargeSuccess' | 'rechargeStatus'>,
): OrderDisplayState {
if (order.rechargeSuccess || order.rechargeStatus === 'success') {
return {
label: '充值成功',
color: 'text-green-600',
icon: '✓',
message: '余额已到账,感谢您的充值!',
};
}
if (order.paymentSuccess) {
if (order.rechargeStatus === 'paid_pending' || order.rechargeStatus === 'recharging') {
return {
label: '充值中',
color: 'text-blue-600',
icon: '⟳',
message: '支付成功,正在充值余额中,请稍候...',
};
}
if (order.rechargeStatus === 'failed') {
return {
label: '支付成功',
color: 'text-amber-600',
icon: '!',
message:
'支付已完成,但余额充值暂未完成。系统可能会自动重试,请稍后在订单列表查看;如长时间未到账请联系管理员。',
};
}
}
if (order.status === ORDER_STATUS.FAILED) {
return {
label: '支付失败',
color: 'text-red-600',
icon: '✗',
message: '支付未完成,请重新发起支付;如已扣款未到账,请联系管理员处理。',
};
}
if (order.status === ORDER_STATUS.PENDING) {
return {
label: '等待支付',
color: 'text-yellow-600',
icon: '⏳',
message: '订单尚未完成支付。',
};
}
if (order.status === ORDER_STATUS.EXPIRED) {
return {
label: '订单超时',
color: 'text-gray-500',
icon: '⏰',
message: '订单已超时,请重新创建订单。',
};
}
if (order.status === ORDER_STATUS.CANCELLED) {
return {
label: '已取消',
color: 'text-gray-500',
icon: '✗',
message: '订单已取消。',
};
}
return {
label: '支付异常',
color: 'text-red-600',
icon: '✗',
message: '支付状态异常,请联系管理员处理。',
};
}

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,6 @@
import { ORDER_STATUS, PAYMENT_TYPE, PAYMENT_PREFIX, REDIRECT_PAYMENT_TYPES } from './constants';
import type { Locale } from './locale';
export interface UserInfo {
id?: number;
username: string;
@@ -14,91 +17,256 @@ 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: '退款失败',
const STATUS_TEXT_MAP: Record<Locale, Record<string, string>> = {
zh: {
[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]: '退款失败',
},
en: {
[ORDER_STATUS.PENDING]: 'Pending',
[ORDER_STATUS.PAID]: 'Paid',
[ORDER_STATUS.RECHARGING]: 'Recharging',
[ORDER_STATUS.COMPLETED]: 'Completed',
[ORDER_STATUS.EXPIRED]: 'Expired',
[ORDER_STATUS.CANCELLED]: 'Cancelled',
[ORDER_STATUS.FAILED]: 'Failed',
[ORDER_STATUS.REFUNDING]: 'Refunding',
[ORDER_STATUS.REFUNDED]: 'Refunded',
[ORDER_STATUS.REFUND_FAILED]: 'Refund failed',
},
};
export const FILTER_OPTIONS: { key: OrderStatusFilter; label: string }[] = [
{ key: 'ALL', label: '全部' },
{ key: 'PENDING', label: '待支付' },
{ key: 'COMPLETED', label: '已完成' },
{ key: 'CANCELLED', label: '已取消' },
{ key: 'EXPIRED', label: '已超时' },
];
const FILTER_OPTIONS_MAP: Record<Locale, { key: OrderStatusFilter; label: string }[]> = {
zh: [
{ key: 'ALL', label: '全部' },
{ key: 'PENDING', label: '待支付' },
{ key: 'COMPLETED', label: '已完成' },
{ key: 'CANCELLED', label: '已取消' },
{ key: 'EXPIRED', label: '已超时' },
],
en: [
{ key: 'ALL', label: 'All' },
{ key: 'PENDING', label: 'Pending' },
{ key: 'COMPLETED', label: 'Completed' },
{ key: 'CANCELLED', label: 'Cancelled' },
{ key: 'EXPIRED', label: 'Expired' },
],
};
export function getFilterOptions(locale: Locale = 'zh'): { key: OrderStatusFilter; label: string }[] {
return FILTER_OPTIONS_MAP[locale];
}
export function detectDeviceIsMobile(): boolean {
if (typeof window === 'undefined') return false;
const uad = (navigator as Navigator & { userAgentData?: { mobile: boolean } }).userAgentData;
if (uad !== undefined) return uad.mobile;
const ua = navigator.userAgent || '';
const mobileUA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Windows Phone|Mobile/i.test(ua);
if (mobileUA) return true;
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 {
return STATUS_TEXT_MAP[status] || status;
export function formatStatus(status: string, locale: Locale = 'zh'): string {
return STATUS_TEXT_MAP[locale][status] || status;
}
export function formatCreatedAt(value: string): string {
export function formatCreatedAt(value: string, locale: Locale = 'zh'): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString();
return date.toLocaleString(locale === 'en' ? 'en-US' : 'zh-CN');
}
export interface PaymentTypeMeta {
label: string;
sublabel?: string;
provider: string;
color: string;
selectedBorder: string;
selectedBg: string;
selectedBgDark: string;
iconBg: string;
iconSrc?: string;
chartBar: { light: string; dark: string };
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',
selectedBgDark: 'bg-cyan-950',
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',
selectedBgDark: 'bg-blue-950',
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',
selectedBgDark: 'bg-green-950',
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',
selectedBgDark: 'bg-green-950',
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',
selectedBgDark: 'bg-[#635bff]/20',
iconBg: 'bg-[#635bff]',
chartBar: { light: 'bg-purple-500', dark: 'bg-purple-400' },
buttonClass: 'bg-[#635bff] hover:bg-[#5249d9] active:bg-[#4840c4]',
},
};
const PAYMENT_TEXT_MAP: Record<Locale, Record<string, { label: string; provider: string; sublabel?: string }>> = {
zh: {
[PAYMENT_TYPE.ALIPAY]: { label: '支付宝', provider: '易支付' },
[PAYMENT_TYPE.ALIPAY_DIRECT]: { label: '支付宝', provider: '支付宝' },
[PAYMENT_TYPE.WXPAY]: { label: '微信支付', provider: '易支付' },
[PAYMENT_TYPE.WXPAY_DIRECT]: { label: '微信支付', provider: '微信支付' },
[PAYMENT_TYPE.STRIPE]: { label: 'Stripe', provider: 'Stripe' },
},
en: {
[PAYMENT_TYPE.ALIPAY]: { label: 'Alipay', provider: 'EasyPay' },
[PAYMENT_TYPE.ALIPAY_DIRECT]: { label: 'Alipay', provider: 'Alipay' },
[PAYMENT_TYPE.WXPAY]: { label: 'WeChat Pay', provider: 'EasyPay' },
[PAYMENT_TYPE.WXPAY_DIRECT]: { label: 'WeChat Pay', provider: 'WeChat Pay' },
[PAYMENT_TYPE.STRIPE]: { label: 'Stripe', provider: 'Stripe' },
},
};
function getPaymentText(type: string, locale: Locale = 'zh'): { label: string; provider: string; sublabel?: string } {
const meta = PAYMENT_TYPE_META[type];
if (!meta) return { label: type, provider: '' };
const baseText = PAYMENT_TEXT_MAP[locale][type] || { label: meta.label, provider: meta.provider };
return {
...baseText,
sublabel: meta.sublabel,
};
}
export function getPaymentTypeLabel(type: string, locale: Locale = 'zh'): string {
const meta = getPaymentText(type, locale);
if (!meta) return type;
if (meta.sublabel) {
return locale === 'en' ? `${meta.label} (${meta.sublabel})` : `${meta.label}${meta.sublabel}`;
}
const hasDuplicate = Object.keys(PAYMENT_TYPE_META).some(
(key) => key !== type && getPaymentText(key, locale).label === meta.label,
);
if (!hasDuplicate || !meta.provider) return meta.label;
return locale === 'en' ? `${meta.label} (${meta.provider})` : `${meta.label}${meta.provider}`;
}
export function getPaymentDisplayInfo(
type: string,
locale: Locale = 'zh',
): { channel: string; provider: string; sublabel?: string } {
const meta = getPaymentText(type, locale);
return { channel: meta.label, provider: meta.provider, sublabel: meta.sublabel };
}
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;
}
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 || '';
}
export function getPaymentChannelLabel(type: string, locale: Locale = 'zh'): string {
return getPaymentDisplayInfo(type, locale).channel;
}
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);
}
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

@@ -1,6 +1,9 @@
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';
@@ -19,12 +22,53 @@ let initialized = false;
export function initPaymentProviders(): void {
if (initialized) return;
paymentRegistry.register(new EasyPayProvider());
const env = getEnv();
if (env.STRIPE_SECRET_KEY) {
const providers = env.PAYMENT_PROVIDERS;
if (providers.includes('easypay')) {
if (!env.EASY_PAY_PID || !env.EASY_PAY_PKEY) {
throw new Error('PAYMENT_PROVIDERS 含 easypay但缺少 EASY_PAY_PID 或 EASY_PAY_PKEY');
}
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');
}
paymentRegistry.register(new StripeProvider());
}
initialized = true;
}
// 注入 lazy initRegistry 方法会自动调用 initPaymentProviders()
paymentRegistry.setInitializer(initPaymentProviders);

View File

@@ -1,7 +1,19 @@
import type { PaymentProvider, PaymentType } from './types';
import type { PaymentProvider, PaymentType, MethodDefaultLimits } from './types';
export class PaymentProviderRegistry {
private providers = new Map<PaymentType, PaymentProvider>();
private _ensureInitialized: (() => void) | null = null;
/** 设置 lazy init 回调,由 initPaymentProviders 注入 */
setInitializer(fn: () => void): void {
this._ensureInitialized = fn;
}
private autoInit(): void {
if (this._ensureInitialized) {
this._ensureInitialized();
}
}
register(provider: PaymentProvider): void {
for (const type of provider.supportedTypes) {
@@ -10,6 +22,7 @@ export class PaymentProviderRegistry {
}
getProvider(type: PaymentType): PaymentProvider {
this.autoInit();
const provider = this.providers.get(type);
if (!provider) {
throw new Error(`No payment provider registered for type: ${type}`);
@@ -18,12 +31,28 @@ export class PaymentProviderRegistry {
}
hasProvider(type: PaymentType): boolean {
this.autoInit();
return this.providers.has(type);
}
getSupportedTypes(): PaymentType[] {
this.autoInit();
return Array.from(this.providers.keys());
}
/** 获取指定渠道的提供商默认限额(未注册时返回 undefined */
getDefaultLimit(type: string): MethodDefaultLimits | undefined {
this.autoInit();
const provider = this.providers.get(type as PaymentType);
return provider?.defaultLimits?.[type];
}
/** 获取指定渠道对应的提供商 key如 'easypay'、'stripe' */
getProviderKey(type: string): string | undefined {
this.autoInit();
const provider = this.providers.get(type as PaymentType);
return provider?.providerKey;
}
}
export const paymentRegistry = new PaymentProviderRegistry();

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 */
@@ -17,7 +30,7 @@ export interface CreatePaymentResponse {
tradeNo: string; // third-party transaction ID
payUrl?: string; // H5 payment URL (alipay/wxpay)
qrCode?: string; // QR code content
checkoutUrl?: string; // Stripe Checkout URL
clientSecret?: string; // Stripe PaymentIntent client secret (for embedded Payment Element)
}
/** Response from querying an order's payment status */
@@ -51,10 +64,21 @@ export interface RefundResponse {
status: 'success' | 'pending' | 'failed';
}
/** Per-method default limits declared by the provider */
export interface MethodDefaultLimits {
/** 单笔最大金额0 = 不限(使用全局 MAX_RECHARGE_AMOUNT */
singleMax?: number;
/** 每日全平台最大金额0 = 不限 */
dailyMax?: number;
}
/** Common interface that all payment providers must implement */
export interface PaymentProvider {
readonly name: string;
readonly providerKey: string;
readonly supportedTypes: PaymentType[];
/** 各渠道默认限额key 为 PaymentType如 'alipay'),可被环境变量覆盖 */
readonly defaultLimits?: Record<string, MethodDefaultLimits>;
createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse>;
queryOrder(tradeNo: string): Promise<QueryOrderResponse>;

View File

@@ -14,7 +14,11 @@ import type {
export class StripeProvider implements PaymentProvider {
readonly name = 'stripe';
readonly providerKey = 'stripe';
readonly supportedTypes: PaymentType[] = ['stripe'];
readonly defaultLimits = {
stripe: { singleMax: 0, dailyMax: 0 }, // 0 = unlimited
};
private client: Stripe | null = null;
@@ -28,54 +32,45 @@ export class StripeProvider implements PaymentProvider {
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
const stripe = this.getClient();
const env = getEnv();
const timeoutMinutes = Math.max(30, env.ORDER_TIMEOUT_MINUTES); // Stripe minimum is 30 minutes
const amountInCents = Math.round(new Prisma.Decimal(request.amount).mul(100).toNumber());
const session = await stripe.checkout.sessions.create(
const pi = await stripe.paymentIntents.create(
{
mode: 'payment',
payment_method_types: ['card'],
line_items: [
{
price_data: {
currency: 'cny',
product_data: { name: request.subject },
unit_amount: Math.round(new Prisma.Decimal(request.amount).mul(100).toNumber()),
},
quantity: 1,
},
],
amount: amountInCents,
currency: 'cny',
automatic_payment_methods: { enabled: true },
metadata: { orderId: request.orderId },
expires_at: Math.floor(Date.now() / 1000) + timeoutMinutes * 60,
success_url: `${env.NEXT_PUBLIC_APP_URL}/pay/result?order_id=${request.orderId}&status=success`,
cancel_url: `${env.NEXT_PUBLIC_APP_URL}/pay/result?order_id=${request.orderId}&status=cancelled`,
description: request.subject,
},
{ idempotencyKey: `checkout-${request.orderId}` },
{ idempotencyKey: `pi-${request.orderId}` },
);
return {
tradeNo: session.id,
checkoutUrl: session.url || undefined,
tradeNo: pi.id,
clientSecret: pi.client_secret || undefined,
};
}
async queryOrder(tradeNo: string): Promise<QueryOrderResponse> {
const stripe = this.getClient();
const session = await stripe.checkout.sessions.retrieve(tradeNo);
const pi = await stripe.paymentIntents.retrieve(tradeNo);
let status: QueryOrderResponse['status'] = 'pending';
if (session.payment_status === 'paid') status = 'paid';
else if (session.status === 'expired') status = 'failed';
if (pi.status === 'succeeded') status = 'paid';
else if (pi.status === 'canceled') status = 'failed';
return {
tradeNo: session.id,
tradeNo: pi.id,
status,
amount: new Prisma.Decimal(session.amount_total || 0).div(100).toNumber(),
amount: new Prisma.Decimal(pi.amount).div(100).toNumber(),
};
}
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');
@@ -87,23 +82,23 @@ export class StripeProvider implements PaymentProvider {
env.STRIPE_WEBHOOK_SECRET,
);
if (event.type === 'checkout.session.completed' || event.type === 'checkout.session.async_payment_succeeded') {
const session = event.data.object as Stripe.Checkout.Session;
if (event.type === 'payment_intent.succeeded') {
const pi = event.data.object as Stripe.PaymentIntent;
return {
tradeNo: session.id,
orderId: session.metadata?.orderId || '',
amount: new Prisma.Decimal(session.amount_total || 0).div(100).toNumber(),
status: session.payment_status === 'paid' ? 'success' : 'failed',
tradeNo: pi.id,
orderId: pi.metadata?.orderId || '',
amount: new Prisma.Decimal(pi.amount).div(100).toNumber(),
status: 'success',
rawData: event,
};
}
if (event.type === 'checkout.session.async_payment_failed') {
const session = event.data.object as Stripe.Checkout.Session;
if (event.type === 'payment_intent.payment_failed') {
const pi = event.data.object as Stripe.PaymentIntent;
return {
tradeNo: session.id,
orderId: session.metadata?.orderId || '',
amount: new Prisma.Decimal(session.amount_total || 0).div(100).toNumber(),
tradeNo: pi.id,
orderId: pi.metadata?.orderId || '',
amount: new Prisma.Decimal(pi.amount).div(100).toNumber(),
status: 'failed',
rawData: event,
};
@@ -116,12 +111,9 @@ export class StripeProvider implements PaymentProvider {
async refund(request: RefundRequest): Promise<RefundResponse> {
const stripe = this.getClient();
// Retrieve checkout session to find the payment intent
const session = await stripe.checkout.sessions.retrieve(request.tradeNo);
if (!session.payment_intent) throw new Error('No payment intent found for session');
// tradeNo is now the PaymentIntent ID directly
const refund = await stripe.refunds.create({
payment_intent: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent.id,
payment_intent: request.tradeNo,
amount: Math.round(new Prisma.Decimal(request.amount).mul(100).toNumber()),
reason: 'requested_by_customer',
});
@@ -134,6 +126,6 @@ export class StripeProvider implements PaymentProvider {
async cancelPayment(tradeNo: string): Promise<void> {
const stripe = this.getClient();
await stripe.checkout.sessions.expire(tradeNo);
await stripe.paymentIntents.cancel(tradeNo);
}
}

View File

@@ -1,6 +1,10 @@
import { getEnv } from '@/lib/config';
import type { Sub2ApiUser, Sub2ApiRedeemCode } from './types';
const DEFAULT_TIMEOUT_MS = 10_000;
const RECHARGE_TIMEOUT_MS = 30_000;
const RECHARGE_MAX_ATTEMPTS = 2;
function getHeaders(idempotencyKey?: string): Record<string, string> {
const env = getEnv();
const headers: Record<string, string> = {
@@ -13,12 +17,18 @@ function getHeaders(idempotencyKey?: string): Record<string, string> {
return headers;
}
function isRetryableFetchError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
return error.name === 'TimeoutError' || error.name === 'AbortError' || error.name === 'TypeError';
}
export async function getCurrentUserByToken(token: string): Promise<Sub2ApiUser> {
const env = getEnv();
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/auth/me`, {
headers: {
Authorization: `Bearer ${token}`,
},
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
if (!response.ok) {
@@ -33,6 +43,7 @@ export async function getUser(userId: number): Promise<Sub2ApiUser> {
const env = getEnv();
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}`, {
headers: getHeaders(),
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
if (!response.ok) {
@@ -51,25 +62,43 @@ export async function createAndRedeem(
notes: string,
): Promise<Sub2ApiRedeemCode> {
const env = getEnv();
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/redeem-codes/create-and-redeem`, {
method: 'POST',
headers: getHeaders(`sub2apipay:recharge:${code}`),
body: JSON.stringify({
code,
type: 'balance',
value,
user_id: userId,
notes,
}),
const url = `${env.SUB2API_BASE_URL}/api/v1/admin/redeem-codes/create-and-redeem`;
const body = JSON.stringify({
code,
type: 'balance',
value,
user_id: userId,
notes,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`Recharge failed (${response.status}): ${JSON.stringify(errorData)}`);
let lastError: unknown;
for (let attempt = 1; attempt <= RECHARGE_MAX_ATTEMPTS; attempt += 1) {
try {
const response = await fetch(url, {
method: 'POST',
headers: getHeaders(`sub2apipay:recharge:${code}`),
body,
signal: AbortSignal.timeout(RECHARGE_TIMEOUT_MS),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`Recharge failed (${response.status}): ${JSON.stringify(errorData)}`);
}
const data = await response.json();
return data.redeem_code as Sub2ApiRedeemCode;
} catch (error) {
lastError = error;
if (attempt >= RECHARGE_MAX_ATTEMPTS || !isRetryableFetchError(error)) {
throw error;
}
console.warn(`Sub2API createAndRedeem attempt ${attempt} timed out, retrying...`);
}
}
const data = await response.json();
return data.redeem_code as Sub2ApiRedeemCode;
throw lastError instanceof Error ? lastError : new Error('Recharge failed');
}
export async function subtractBalance(
@@ -87,6 +116,7 @@ export async function subtractBalance(
amount,
notes,
}),
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
if (!response.ok) {
@@ -94,3 +124,22 @@ export async function subtractBalance(
throw new Error(`Subtract balance failed (${response.status}): ${JSON.stringify(errorData)}`);
}
}
export async function addBalance(userId: number, amount: number, notes: string, idempotencyKey: string): Promise<void> {
const env = getEnv();
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}/balance`, {
method: 'POST',
headers: getHeaders(idempotencyKey),
body: JSON.stringify({
operation: 'add',
amount,
notes,
}),
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`Add balance failed (${response.status}): ${JSON.stringify(errorData)}`);
}
}

Some files were not shown because too many files have changed in this diff Show More