26 Commits

Author SHA1 Message Date
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
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
45 changed files with 1481 additions and 443 deletions

65
.env.example Normal file
View File

@@ -0,0 +1,65 @@
# 数据库
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, stripe
# 示例(仅易支付): PAYMENT_PROVIDERS=easypay
# 示例(仅 Stripe: PAYMENT_PROVIDERS=stripe
# 示例(两者都用): PAYMENT_PROVIDERS=easypay,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_..."
# ── 启用的支付渠道(在已配置服务商支持的渠道中选择) ─────────────────────────
# 易支付支持: alipay, wxpay
# Stripe 支持: 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"

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

@@ -94,16 +94,39 @@ See [`.env.example`](./.env.example) for the full template.
> `DATABASE_URL` is automatically injected by Docker Compose when using the bundled database. > `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 ```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) #### EasyPay (Alipay / WeChat Pay)
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 | | Variable | Description |
|----------|-------------| |----------|-------------|
| `EASY_PAY_PID` | EasyPay merchant ID | | `EASY_PAY_PID` | EasyPay merchant ID |
@@ -123,7 +146,7 @@ ENABLED_PAYMENT_TYPES=alipay,wxpay,stripe
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret (`whsec_...`) | | `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret (`whsec_...`) |
> Stripe webhook endpoint: `${NEXT_PUBLIC_APP_URL}/api/stripe/webhook` > 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 ### Business Rules
@@ -137,10 +160,31 @@ ENABLED_PAYMENT_TYPES=alipay,wxpay,stripe
### UI Customization (Optional) ### UI Customization (Optional)
Display a support contact image and description on the right side of the payment page.
| Variable | Description | | Variable | Description |
|----------|-------------| |----------|-------------|
| `NEXT_PUBLIC_PAY_HELP_IMAGE_URL` | Help image URL (e.g. customer service QR code) | | `PAY_HELP_IMAGE_URL` | Help image URL — external URL or local path (see below) |
| `NEXT_PUBLIC_PAY_HELP_TEXT` | Help text displayed on payment page | | `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 ### Docker Compose Variables
@@ -220,16 +264,20 @@ docker compose exec app npx prisma migrate deploy
## Sub2API Integration ## Sub2API Integration
Configure the recharge URL in the Sub2API admin panel: The following page URLs can be configured in the Sub2API admin panel:
``` | Page | URL | Description |
https://pay.example.com/pay?user_id={USER_ID}&token={TOKEN}&theme={THEME} |------|-----|-------------|
``` | Payment | `https://pay.example.com/pay` | User recharge entry |
| My Orders | `https://pay.example.com/pay/orders` | User views their own recharge history |
| Order Management | `https://pay.example.com/admin` | Sub2API admin only |
Sub2API **v0.1.88** and above will automatically append the following parameters — no manual query string needed:
| Parameter | Description | | Parameter | Description |
|-----------|-------------| |-----------|-------------|
| `user_id` | Sub2API user ID (required) | | `user_id` | Sub2API user ID |
| `token` | User login token (optional — required to view order history) | | `token` | User login token (required to view order history) |
| `theme` | `light` (default) or `dark` | | `theme` | `light` (default) or `dark` |
| `ui_mode` | `standalone` (default) or `embedded` (for iframe) | | `ui_mode` | `standalone` (default) or `embedded` (for iframe) |
@@ -262,7 +310,7 @@ User submits recharge amount
User completes payment User completes payment
├─ EasyPay → QR code / H5 redirect ├─ EasyPay → QR code / H5 redirect
└─ Stripe → Checkout Session └─ Stripe → Payment Element (PaymentIntent)
Payment callback (signature verified) → Order PAID Payment callback (signature verified) → Order PAID

View File

@@ -94,16 +94,39 @@ docker compose up -d --build
> `DATABASE_URL` 使用自带数据库时由 Compose 自动注入,无需手动填写。 > `DATABASE_URL` 使用自带数据库时由 Compose 自动注入,无需手动填写。
### 支付方式 ### 支付服务商与支付方式
通过 `ENABLED_PAYMENT_TYPES` 控制开启哪些支付方式(逗号分隔): **第一步**:通过 `PAYMENT_PROVIDERS` 声明启用哪些支付服务商(逗号分隔):
```env ```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支付宝 / 微信支付) #### EasyPay支付宝 / 微信支付)
支付提供商只需兼容**易支付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_PID` | EasyPay 商户 ID |
@@ -123,7 +146,7 @@ ENABLED_PAYMENT_TYPES=alipay,wxpay,stripe
| `STRIPE_WEBHOOK_SECRET` | Stripe Webhook 签名密钥(`whsec_...` | | `STRIPE_WEBHOOK_SECRET` | Stripe Webhook 签名密钥(`whsec_...` |
> Stripe Webhook 端点:`${NEXT_PUBLIC_APP_URL}/api/stripe/webhook` > Stripe Webhook 端点:`${NEXT_PUBLIC_APP_URL}/api/stripe/webhook`
> 需订阅事件:`checkout.session.completed`、`checkout.session.expired` > 需订阅事件:`payment_intent.succeeded`、`payment_intent.payment_failed`
### 业务规则 ### 业务规则
@@ -137,10 +160,31 @@ ENABLED_PAYMENT_TYPES=alipay,wxpay,stripe
### UI 定制(可选) ### UI 定制(可选)
在充值页面右侧可展示客服联系方式、说明图片等帮助内容。
| 变量 | 说明 | | 变量 | 说明 |
|------|------| |------|------|
| `NEXT_PUBLIC_PAY_HELP_IMAGE_URL` | 帮助图片 URL如客服二维码 | | `PAY_HELP_IMAGE_URL` | 帮助图片地址(支持外部 URL 或本地路径,见下方说明 |
| `NEXT_PUBLIC_PAY_HELP_TEXT` | 帮助说明文字 | | `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 专用 ### Docker Compose 专用
@@ -220,16 +264,20 @@ docker compose exec app npx prisma migrate deploy
## 集成到 Sub2API ## 集成到 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 管理员可访问 |
Sub2API **v0.1.88** 及以上版本会自动拼接以下参数,无需手动添加:
| 参数 | 说明 | | 参数 | 说明 |
|------|------| |------|------|
| `user_id` | Sub2API 用户 ID(必填) | | `user_id` | Sub2API 用户 ID |
| `token` | 用户登录 Token可选,有 token 才能查看订单历史) | | `token` | 用户登录 Token有 token 才能查看订单历史) |
| `theme` | `light`(默认)或 `dark` | | `theme` | `light`(默认)或 `dark` |
| `ui_mode` | `standalone`(默认)或 `embedded`iframe 嵌入) | | `ui_mode` | `standalone`(默认)或 `embedded`iframe 嵌入) |
@@ -262,7 +310,7 @@ https://pay.example.com/pay?user_id={USER_ID}&token={TOKEN}&theme={THEME}
用户完成支付 用户完成支付
├─ EasyPay → 扫码 / H5 跳转 ├─ EasyPay → 扫码 / H5 跳转
└─ Stripe → Checkout Session └─ Stripe → Payment Element (PaymentIntent)
支付回调(签名验证)→ 订单 PAID 支付回调(签名验证)→ 订单 PAID

View File

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

View File

@@ -1,6 +1,6 @@
services: services:
app: app:
image: sub2apipay:latest image: touwaeriol/sub2apipay:${IMAGE_TAG:-latest}
container_name: sub2apipay container_name: sub2apipay
ports: ports:
- '8087:3000' - '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 @@
"name": "sub2apipay", "name": "sub2apipay",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"packageManager": "pnpm@10.30.3",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
@@ -16,6 +17,7 @@
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "7.4.1", "@prisma/adapter-pg": "7.4.1",
"@prisma/client": "^7.4.2", "@prisma/client": "^7.4.2",
"@stripe/stripe-js": "^8.9.0",
"next": "16.1.6", "next": "16.1.6",
"pg": "^8.19.0", "pg": "^8.19.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",

9
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@prisma/client': '@prisma/client':
specifier: ^7.4.2 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) 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: next:
specifier: 16.1.6 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) version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -874,6 +877,10 @@ packages:
'@standard-schema/spec@1.1.0': '@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@stripe/stripe-js@8.9.0':
resolution: {integrity: sha512-OJkXvUI5GAc56QdiSRimQDvWYEqn475J+oj8RzRtFTCPtkJNO2TWW619oDY+nn1ExR+2tCVTQuRQBbR4dRugww==}
engines: {node: '>=12.16'}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -3613,6 +3620,8 @@ snapshots:
'@standard-schema/spec@1.1.0': {} '@standard-schema/spec@1.1.0': {}
'@stripe/stripe-js@8.9.0': {}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1

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

@@ -11,7 +11,10 @@ model Order {
userId Int @map("user_id") userId Int @map("user_id")
userEmail String? @map("user_email") userEmail String? @map("user_email")
userName String? @map("user_name") userName String? @map("user_name")
userNotes String? @map("user_notes")
amount Decimal @db.Decimal(10, 2) 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") rechargeCode String @unique @map("recharge_code")
status OrderStatus @default(PENDING) status OrderStatus @default(PENDING)
paymentType String @map("payment_type") paymentType String @map("payment_type")
@@ -34,6 +37,8 @@ model Order {
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
clientIp String? @map("client_ip") clientIp String? @map("client_ip")
srcHost String? @map("src_host")
srcUrl String? @map("src_url")
auditLogs AuditLog[] auditLogs AuditLog[]

View File

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

View File

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

View File

@@ -5,12 +5,14 @@ import { useState, useEffect, useCallback, Suspense } from 'react';
import OrderTable from '@/components/admin/OrderTable'; import OrderTable from '@/components/admin/OrderTable';
import OrderDetail from '@/components/admin/OrderDetail'; import OrderDetail from '@/components/admin/OrderDetail';
import PaginationBar from '@/components/PaginationBar'; import PaginationBar from '@/components/PaginationBar';
import PayPageLayout from '@/components/PayPageLayout';
interface AdminOrder { interface AdminOrder {
id: string; id: string;
userId: number; userId: number;
userName: string | null; userName: string | null;
userEmail: string | null; userEmail: string | null;
userNotes: string | null;
amount: number; amount: number;
status: string; status: string;
paymentType: string; paymentType: string;
@@ -19,6 +21,7 @@ interface AdminOrder {
completedAt: string | null; completedAt: string | null;
failedReason: string | null; failedReason: string | null;
expiresAt: string; expiresAt: string;
srcHost: string | null;
} }
interface AdminOrderDetail extends AdminOrder { interface AdminOrderDetail extends AdminOrder {
@@ -31,6 +34,8 @@ interface AdminOrderDetail extends AdminOrder {
failedAt: string | null; failedAt: string | null;
updatedAt: string; updatedAt: string;
clientIp: string | null; clientIp: string | null;
srcHost: string | null;
srcUrl: string | null;
paymentSuccess?: boolean; paymentSuccess?: boolean;
rechargeSuccess?: boolean; rechargeSuccess?: boolean;
rechargeStatus?: string; rechargeStatus?: string;
@@ -40,6 +45,10 @@ interface AdminOrderDetail extends AdminOrder {
function AdminContent() { function AdminContent() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const token = searchParams.get('token'); const token = searchParams.get('token');
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
const uiMode = searchParams.get('ui_mode') || 'standalone';
const isDark = theme === 'dark';
const isEmbedded = uiMode === 'embedded';
const [orders, setOrders] = useState<AdminOrder[]>([]); const [orders, setOrders] = useState<AdminOrder[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
@@ -85,8 +94,11 @@ function AdminContent() {
if (!token) { if (!token) {
return ( return (
<div className="flex min-h-screen items-center justify-center"> <div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
<div className="text-red-500"></div> <div className="text-center text-red-500">
<p className="text-lg font-medium"></p>
<p className="mt-2 text-sm text-gray-500"> Sub2API 访</p>
</div>
</div> </div>
); );
} }
@@ -151,22 +163,29 @@ function AdminContent() {
}; };
return ( return (
<div className="mx-auto min-h-screen max-w-6xl p-4"> <PayPageLayout
<div className="mb-6 flex items-center justify-between"> isDark={isDark}
<h1 className="text-2xl font-bold text-gray-900">Sub2ApiPay </h1> isEmbedded={isEmbedded}
maxWidth="full"
title="订单管理"
subtitle="查看和管理所有充值订单"
actions={
<button <button
type="button" type="button"
onClick={fetchOrders} 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" 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> </button>
</div> }
>
{error && ( {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} {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> </button>
</div> </div>
@@ -181,9 +200,12 @@ function AdminContent() {
setStatusFilter(s); setStatusFilter(s);
setPage(1); setPage(1);
}} }}
className={`rounded-full px-3 py-1 text-sm transition-colors ${ className={[
statusFilter === s ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200' '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]} {statusLabels[s]}
</button> </button>
@@ -191,11 +213,11 @@ function AdminContent() {
</div> </div>
{/* Table */} {/* 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 ? ( {loading ? (
<div className="py-12 text-center text-gray-500">...</div> <div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>...</div>
) : ( ) : (
<OrderTable orders={orders} onRetry={handleRetry} onCancel={handleCancel} onViewDetail={handleViewDetail} /> <OrderTable orders={orders} onRetry={handleRetry} onCancel={handleCancel} onViewDetail={handleViewDetail} dark={isDark} />
)} )}
</div> </div>
@@ -207,11 +229,12 @@ function AdminContent() {
loading={loading} loading={loading}
onPageChange={(p) => setPage(p)} onPageChange={(p) => setPage(p)}
onPageSizeChange={(s) => { setPageSize(s); setPage(1); }} onPageSizeChange={(s) => { setPageSize(s); setPage(1); }}
isDark={isDark}
/> />
{/* Order Detail */} {/* Order Detail */}
{detailOrder && <OrderDetail order={detailOrder} onClose={() => setDetailOrder(null)} />} {detailOrder && <OrderDetail order={detailOrder} onClose={() => setDetailOrder(null)} dark={isDark} />}
</div> </PayPageLayout>
); );
} }

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { Prisma, OrderStatus } from '@prisma/client'; import { Prisma, OrderStatus } from '@prisma/client';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
if (!verifyAdminToken(request)) return unauthorizedResponse(); if (!await verifyAdminToken(request)) return unauthorizedResponse();
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
const page = Math.max(1, Number(searchParams.get('page') || '1')); const page = Math.max(1, Number(searchParams.get('page') || '1'));
@@ -34,6 +34,7 @@ export async function GET(request: NextRequest) {
userId: true, userId: true,
userName: true, userName: true,
userEmail: true, userEmail: true,
userNotes: true,
amount: true, amount: true,
status: true, status: true,
paymentType: true, paymentType: true,
@@ -42,6 +43,7 @@ export async function GET(request: NextRequest) {
completedAt: true, completedAt: true,
failedReason: true, failedReason: true,
expiresAt: true, expiresAt: true,
srcHost: true,
}, },
}), }),
prisma.order.count({ where }), prisma.order.count({ where }),

View File

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

View File

@@ -7,6 +7,8 @@ const createOrderSchema = z.object({
user_id: z.number().int().positive(), user_id: z.number().int().positive(),
amount: z.number().positive(), amount: z.number().positive(),
payment_type: z.enum(['alipay', 'wxpay', 'stripe']), payment_type: z.enum(['alipay', 'wxpay', 'stripe']),
src_host: z.string().max(253).optional(),
src_url: z.string().max(2048).optional(),
}); });
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -19,7 +21,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '参数错误', details: parsed.error.flatten().fieldErrors }, { status: 400 }); return NextResponse.json({ error: '参数错误', details: parsed.error.flatten().fieldErrors }, { status: 400 });
} }
const { user_id, amount, payment_type } = parsed.data; const { user_id, amount, payment_type, src_host, src_url } = parsed.data;
// Validate amount range // Validate amount range
if (amount < env.MIN_RECHARGE_AMOUNT || amount > env.MAX_RECHARGE_AMOUNT) { if (amount < env.MIN_RECHARGE_AMOUNT || amount > env.MAX_RECHARGE_AMOUNT) {
@@ -42,6 +44,8 @@ export async function POST(request: NextRequest) {
amount, amount,
paymentType: payment_type, paymentType: payment_type,
clientIp, clientIp,
srcHost: src_host,
srcUrl: src_url,
}); });
// 不向客户端暴露 userName / userBalance 等隐私字段 // 不向客户端暴露 userName / userBalance 等隐私字段

View File

@@ -27,6 +27,11 @@ export async function GET(request: NextRequest) {
maxAmount: env.MAX_RECHARGE_AMOUNT, maxAmount: env.MAX_RECHARGE_AMOUNT,
maxDailyAmount: env.MAX_DAILY_RECHARGE_AMOUNT, maxDailyAmount: env.MAX_DAILY_RECHARGE_AMOUNT,
methodLimits, methodLimits,
helpImageUrl: env.PAY_HELP_IMAGE_URL ?? null,
helpText: env.PAY_HELP_TEXT ?? null,
stripePublishableKey: env.ENABLED_PAYMENT_TYPES.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY
? env.STRIPE_PUBLISHABLE_KEY
: null,
}, },
}); });
} catch (error) { } catch (error) {

View File

@@ -24,6 +24,7 @@ function OrdersContent() {
const token = (searchParams.get('token') || '').trim(); const token = (searchParams.get('token') || '').trim();
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light'; const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
const uiMode = searchParams.get('ui_mode') || 'standalone'; const uiMode = searchParams.get('ui_mode') || 'standalone';
const srcHost = searchParams.get('src_host') || '';
const isDark = theme === 'dark'; const isDark = theme === 'dark';
const [isIframeContext, setIsIframeContext] = useState(true); const [isIframeContext, setIsIframeContext] = useState(true);
@@ -178,7 +179,7 @@ function OrdersContent() {
actions={ actions={
<> <>
<button type="button" onClick={() => loadOrders(page, pageSize)} className={btnClass}></button> <button type="button" onClick={() => loadOrders(page, pageSize)} className={btnClass}></button>
<a href={buildScopedUrl('/pay')} className={btnClass}></a> {!srcHost && <a href={buildScopedUrl('/pay')} className={btnClass}></a>}
</> </>
} }
> >

View File

@@ -13,11 +13,12 @@ import type { MethodLimitInfo } from '@/components/PaymentForm';
interface OrderResult { interface OrderResult {
orderId: string; orderId: string;
amount: number; amount: number;
payAmount?: number;
status: string; status: string;
paymentType: 'alipay' | 'wxpay' | 'stripe'; paymentType: 'alipay' | 'wxpay' | 'stripe';
payUrl?: string | null; payUrl?: string | null;
qrCode?: string | null; qrCode?: string | null;
checkoutUrl?: string | null; clientSecret?: string | null;
expiresAt: string; expiresAt: string;
} }
@@ -27,6 +28,9 @@ interface AppConfig {
maxAmount: number; maxAmount: number;
maxDailyAmount: number; maxDailyAmount: number;
methodLimits?: Record<string, MethodLimitInfo>; methodLimits?: Record<string, MethodLimitInfo>;
helpImageUrl?: string | null;
helpText?: string | null;
stripePublishableKey?: string | null;
} }
function PayContent() { function PayContent() {
@@ -36,6 +40,8 @@ function PayContent() {
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light'; const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
const uiMode = searchParams.get('ui_mode') || 'standalone'; const uiMode = searchParams.get('ui_mode') || 'standalone';
const tab = searchParams.get('tab'); const tab = searchParams.get('tab');
const srcHost = searchParams.get('src_host') || undefined;
const srcUrl = searchParams.get('src_url') || undefined;
const isDark = theme === 'dark'; const isDark = theme === 'dark';
const [isIframeContext, setIsIframeContext] = useState(true); const [isIframeContext, setIsIframeContext] = useState(true);
@@ -54,17 +60,19 @@ function PayContent() {
const [activeMobileTab, setActiveMobileTab] = useState<'pay' | 'orders'>('pay'); const [activeMobileTab, setActiveMobileTab] = useState<'pay' | 'orders'>('pay');
const [config, setConfig] = useState<AppConfig>({ const [config, setConfig] = useState<AppConfig>({
enabledPaymentTypes: ['alipay', 'wxpay', 'stripe'], enabledPaymentTypes: [],
minAmount: 1, minAmount: 1,
maxAmount: 1000, maxAmount: 1000,
maxDailyAmount: 0, maxDailyAmount: 0,
}); });
const [userNotFound, setUserNotFound] = useState(false);
const [helpImageOpen, setHelpImageOpen] = useState(false);
const effectiveUserId = resolvedUserId || userId; const effectiveUserId = resolvedUserId || userId;
const isEmbedded = uiMode === 'embedded' && isIframeContext; const isEmbedded = uiMode === 'embedded' && isIframeContext;
const hasToken = token.length > 0; const hasToken = token.length > 0;
const helpImageUrl = (process.env.NEXT_PUBLIC_PAY_HELP_IMAGE_URL || '').trim(); const helpImageUrl = (config.helpImageUrl || '').trim();
const helpText = (process.env.NEXT_PUBLIC_PAY_HELP_TEXT || '').trim(); const helpText = (config.helpText || '').trim();
const hasHelpContent = Boolean(helpImageUrl || helpText); const hasHelpContent = Boolean(helpImageUrl || helpText);
useEffect(() => { useEffect(() => {
@@ -86,6 +94,7 @@ function PayContent() {
const loadUserAndOrders = async () => { const loadUserAndOrders = async () => {
if (!userId || Number.isNaN(userId) || userId <= 0) return; if (!userId || Number.isNaN(userId) || userId <= 0) return;
setUserNotFound(false);
try { try {
// 始终获取服务端配置(不含隐私信息) // 始终获取服务端配置(不含隐私信息)
const cfgRes = await fetch(`/api/user?user_id=${userId}`); const cfgRes = await fetch(`/api/user?user_id=${userId}`);
@@ -98,8 +107,14 @@ function PayContent() {
maxAmount: cfgData.config.maxAmount ?? 1000, maxAmount: cfgData.config.maxAmount ?? 1000,
maxDailyAmount: cfgData.config.maxDailyAmount ?? 0, maxDailyAmount: cfgData.config.maxDailyAmount ?? 0,
methodLimits: cfgData.config.methodLimits, methodLimits: cfgData.config.methodLimits,
helpImageUrl: cfgData.config.helpImageUrl ?? null,
helpText: cfgData.config.helpText ?? null,
stripePublishableKey: cfgData.config.stripePublishableKey ?? null,
}); });
} }
} else if (cfgRes.status === 404) {
setUserNotFound(true);
return;
} }
// 有 token 时才尝试获取用户详情和订单 // 有 token 时才尝试获取用户详情和订单
@@ -183,6 +198,17 @@ function PayContent() {
); );
} }
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"></p>
<p className="mt-2 text-sm text-gray-500"></p>
</div>
</div>
);
}
const buildScopedUrl = (path: string, forceOrdersTab = false) => { const buildScopedUrl = (path: string, forceOrdersTab = false) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (effectiveUserId) params.set('user_id', String(effectiveUserId)); if (effectiveUserId) params.set('user_id', String(effectiveUserId));
@@ -209,6 +235,8 @@ function PayContent() {
user_id: effectiveUserId, user_id: effectiveUserId,
amount, amount,
payment_type: paymentType, payment_type: paymentType,
src_host: srcHost,
src_url: srcUrl,
}), }),
}); });
@@ -230,11 +258,12 @@ function PayContent() {
setOrderResult({ setOrderResult({
orderId: data.orderId, orderId: data.orderId,
amount: data.amount, amount: data.amount,
payAmount: data.payAmount,
status: data.status, status: data.status,
paymentType: data.paymentType || paymentType, paymentType: data.paymentType || paymentType,
payUrl: data.payUrl, payUrl: data.payUrl,
qrCode: data.qrCode, qrCode: data.qrCode,
checkoutUrl: data.checkoutUrl, clientSecret: data.clientSecret,
expiresAt: data.expiresAt, expiresAt: data.expiresAt,
}); });
@@ -279,7 +308,7 @@ function PayContent() {
<PayPageLayout <PayPageLayout
isDark={isDark} isDark={isDark}
isEmbedded={isEmbedded} isEmbedded={isEmbedded}
maxWidth={isMobile ? 'sm' : 'full'} maxWidth={isMobile ? 'sm' : 'lg'}
title="Sub2API 余额充值" title="Sub2API 余额充值"
subtitle="安全支付,自动到账" subtitle="安全支付,自动到账"
actions={!isMobile ? ( actions={!isMobile ? (
@@ -350,7 +379,16 @@ function PayContent() {
</div> </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(' ')}>
...
</span>
</div>
)}
{step === 'form' && config.enabledPaymentTypes.length > 0 && (
<> <>
{isMobile ? ( {isMobile ? (
activeMobileTab === 'pay' ? ( activeMobileTab === 'pay' ? (
@@ -385,6 +423,7 @@ function PayContent() {
userName={userInfo?.username} userName={userInfo?.username}
userBalance={userInfo?.balance} userBalance={userInfo?.balance}
enabledPaymentTypes={config.enabledPaymentTypes} enabledPaymentTypes={config.enabledPaymentTypes}
methodLimits={config.methodLimits}
minAmount={config.minAmount} minAmount={config.minAmount}
maxAmount={config.maxAmount} maxAmount={config.maxAmount}
onSubmit={handleSubmit} onSubmit={handleSubmit}
@@ -412,13 +451,16 @@ function PayContent() {
<img <img
src={helpImageUrl} src={helpImageUrl}
alt='help' alt='help'
className='mt-3 max-h-40 w-full rounded-lg object-contain bg-white/70 p-2' onClick={() => setHelpImageOpen(true)}
className='mt-3 max-h-40 w-full cursor-zoom-in rounded-lg object-contain bg-white/70 p-2'
/> />
)} )}
{helpText && ( {helpText && (
<p className={['mt-3 text-sm leading-6', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}> <div className={['mt-3 space-y-1 text-sm leading-6', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
{helpText} {helpText.split('\\n').map((line, i) => (
</p> <p key={i}>{line}</p>
))}
</div>
)} )}
</div> </div>
)} )}
@@ -434,19 +476,36 @@ function PayContent() {
token={token || undefined} token={token || undefined}
payUrl={orderResult.payUrl} payUrl={orderResult.payUrl}
qrCode={orderResult.qrCode} qrCode={orderResult.qrCode}
checkoutUrl={orderResult.checkoutUrl} clientSecret={orderResult.clientSecret}
stripePublishableKey={config.stripePublishableKey}
paymentType={orderResult.paymentType} paymentType={orderResult.paymentType}
amount={orderResult.amount} amount={orderResult.amount}
payAmount={orderResult.payAmount}
expiresAt={orderResult.expiresAt} expiresAt={orderResult.expiresAt}
onStatusChange={handleStatusChange} onStatusChange={handleStatusChange}
onBack={handleBack} onBack={handleBack}
dark={isDark} dark={isDark}
isEmbedded={isEmbedded}
/> />
)} )}
{step === 'result' && ( {step === 'result' && (
<OrderStatus status={finalStatus} onBack={handleBack} dark={isDark} /> <OrderStatus status={finalStatus} onBack={handleBack} dark={isDark} />
)} )}
{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> </PayPageLayout>
); );
} }

View File

@@ -8,9 +8,18 @@ function ResultContent() {
// Support both ZPAY (out_trade_no) and Stripe (order_id) callback params // Support both ZPAY (out_trade_no) and Stripe (order_id) callback params
const outTradeNo = searchParams.get('out_trade_no') || searchParams.get('order_id'); const outTradeNo = searchParams.get('out_trade_no') || searchParams.get('order_id');
const tradeStatus = searchParams.get('trade_status') || searchParams.get('status'); const tradeStatus = searchParams.get('trade_status') || searchParams.get('status');
const isPopup = searchParams.get('popup') === '1';
const [status, setStatus] = useState<string | null>(null); const [status, setStatus] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [isInPopup, setIsInPopup] = useState(false);
// Detect if opened as a popup window (from stripe-popup or via popup=1 param)
useEffect(() => {
if (isPopup || window.opener) {
setIsInPopup(true);
}
}, [isPopup]);
useEffect(() => { useEffect(() => {
if (!outTradeNo) { if (!outTradeNo) {
@@ -42,6 +51,17 @@ function ResultContent() {
}; };
}, [outTradeNo]); }, [outTradeNo]);
// Auto-close popup window on success
const isSuccess = status === 'COMPLETED' || status === 'PAID' || status === 'RECHARGING';
useEffect(() => {
if (!isInPopup || !isSuccess) return;
const timer = setTimeout(() => {
window.close();
}, 3000);
return () => clearTimeout(timer);
}, [isInPopup, isSuccess]);
if (loading) { if (loading) {
return ( return (
<div className="flex min-h-screen items-center justify-center"> <div className="flex min-h-screen items-center justify-center">
@@ -50,7 +70,6 @@ function ResultContent() {
); );
} }
const isSuccess = status === 'COMPLETED' || status === 'PAID' || status === 'RECHARGING';
const isPending = status === 'PENDING'; const isPending = status === 'PENDING';
return ( return (
@@ -65,12 +84,33 @@ function ResultContent() {
<p className="mt-2 text-gray-500"> <p className="mt-2 text-gray-500">
{status === 'COMPLETED' ? '余额已成功到账!' : '支付成功,余额正在充值中...'} {status === 'COMPLETED' ? '余额已成功到账!' : '支付成功,余额正在充值中...'}
</p> </p>
{isInPopup && (
<div className="mt-4 space-y-2">
<p className="text-sm text-gray-400"> 3 </p>
<button
type="button"
onClick={() => window.close()}
className="text-sm text-blue-600 underline hover:text-blue-700"
>
</button>
</div>
)}
</> </>
) : isPending ? ( ) : isPending ? (
<> <>
<div className="text-6xl text-yellow-500"></div> <div className="text-6xl text-yellow-500"></div>
<h1 className="mt-4 text-xl font-bold text-yellow-600"></h1> <h1 className="mt-4 text-xl font-bold text-yellow-600"></h1>
<p className="mt-2 text-gray-500"></p> <p className="mt-2 text-gray-500"></p>
{isInPopup && (
<button
type="button"
onClick={() => window.close()}
className="mt-4 text-sm text-blue-600 underline hover:text-blue-700"
>
</button>
)}
</> </>
) : ( ) : (
<> <>
@@ -85,6 +125,15 @@ function ResultContent() {
? '订单已被取消' ? '订单已被取消'
: '请联系管理员处理'} : '请联系管理员处理'}
</p> </p>
{isInPopup && (
<button
type="button"
onClick={() => window.close()}
className="mt-4 text-sm text-blue-600 underline hover:text-blue-700"
>
</button>
)}
</> </>
)} )}

View File

@@ -0,0 +1,284 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { useEffect, useState, useCallback, Suspense } from 'react';
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 isDark = theme === 'dark';
const isAlipay = method === 'alipay';
// Sensitive data received via postMessage from parent, NOT from URL
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');
return returnUrl.toString();
}, [orderId]);
// Listen for credentials from parent window via postMessage
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);
// Signal parent that popup is ready to receive data
if (window.opener) {
window.opener.postMessage({ type: 'STRIPE_POPUP_READY' }, window.location.origin);
}
return () => window.removeEventListener('message', handler);
}, []);
// Initialize Stripe once credentials are received
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('支付组件加载失败,请关闭窗口重试');
setStripeLoaded(true);
}
return;
}
if (isAlipay) {
// Alipay: confirm directly and redirect, no Payment Element needed
stripe.confirmAlipayPayment(clientSecret, {
return_url: buildReturnUrl(),
}).then((result) => {
if (cancelled) return;
if (result.error) {
setStripeError(result.error.message || '支付失败,请重试');
setStripeLoaded(true);
}
// If no error, the page has already been redirected
});
return;
}
// Fallback: create Elements for Payment Element flow
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]);
// Mount Payment Element (only for non-alipay methods)
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 || '支付失败,请重试');
setStripeSubmitting(false);
} else {
setStripeSuccess(true);
setStripeSubmitting(false);
}
};
// Auto-close after success
useEffect(() => {
if (!stripeSuccess) return;
const timer = setTimeout(() => {
window.close();
}, 2000);
return () => clearTimeout(timer);
}, [stripeSuccess]);
// Waiting for credentials from parent
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'}`}>
...
</span>
</div>
</div>
</div>
);
}
// Alipay direct confirm: show loading/redirecting state
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">{'\u00A5'}{amount.toFixed(2)}</div>
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
: {orderId}
</p>
</div>
{stripeError ? (
<div className="space-y-3">
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
{stripeError}
</div>
<button
type="button"
onClick={() => window.close()}
className="w-full text-sm text-blue-600 underline hover:text-blue-700"
>
</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'}`}>
...
</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">{'\u00A5'}{amount.toFixed(2)}</div>
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
: {orderId}
</p>
</div>
{!stripeLoaded ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
...
</span>
</div>
) : stripeSuccess ? (
<div className="py-6 text-center">
<div className="text-5xl text-green-600">{'\u2713'}</div>
<p className={`mt-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
...
</p>
<button
type="button"
onClick={() => window.close()}
className="mt-4 text-sm text-blue-600 underline hover:text-blue-700"
>
</button>
</div>
) : (
<>
{stripeError && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
{stripeError}
</div>
)}
<div
ref={stripeContainerRef}
className={`rounded-lg border p-4 ${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'
: 'bg-[#635bff] hover:bg-[#5249d9] active:bg-[#4840c4]',
].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" />
...
</span>
) : (
`支付 ¥${amount.toFixed(2)}`
)}
</button>
</>
)}
</div>
</div>
);
}
export default function StripePopupPage() {
return (
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center">
<div className="text-gray-500">...</div>
</div>
}
>
<StripePopupContent />
</Suspense>
);
}

View File

@@ -3,7 +3,7 @@ import React from 'react';
interface PayPageLayoutProps { interface PayPageLayoutProps {
isDark: boolean; isDark: boolean;
isEmbedded?: boolean; isEmbedded?: boolean;
maxWidth?: 'sm' | 'full'; maxWidth?: 'sm' | 'lg' | 'full';
title: string; title: string;
subtitle: string; subtitle: string;
actions?: React.ReactNode; actions?: React.ReactNode;
@@ -19,30 +19,37 @@ export default function PayPageLayout({
actions, actions,
children, children,
}: PayPageLayoutProps) { }: PayPageLayoutProps) {
const maxWidthClass = maxWidth === 'sm' ? 'max-w-lg' : maxWidth === 'lg' ? 'max-w-6xl' : '';
return ( return (
<div <div
className={[ className={[
'relative min-h-screen w-full overflow-hidden p-3 sm:p-4', 'relative w-full overflow-hidden',
isEmbedded ? 'p-2' : 'min-h-screen p-3 sm:p-4',
isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-100 text-slate-900', isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-100 text-slate-900',
].join(' ')} ].join(' ')}
> >
<div {!isEmbedded && (
className={[ <>
'pointer-events-none absolute -left-20 -top-20 h-56 w-56 rounded-full blur-3xl', <div
isDark ? 'bg-indigo-500/25' : 'bg-sky-300/35', className={[
].join(' ')} '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',
<div ].join(' ')}
className={[ />
'pointer-events-none absolute -right-24 bottom-0 h-64 w-64 rounded-full blur-3xl', <div
isDark ? 'bg-cyan-400/20' : 'bg-indigo-200/45', className={[
].join(' ')} '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 <div
className={[ className={[
'relative mx-auto w-full rounded-3xl border p-4 sm:p-6', 'relative mx-auto w-full rounded-3xl border p-4 sm:p-6',
maxWidth === 'sm' ? 'max-w-lg' : 'max-w-6xl', maxWidthClass,
isDark isDark
? 'border-slate-700/70 bg-slate-900/85 shadow-2xl shadow-black/35' ? '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', : 'border-slate-200/90 bg-white/95 shadow-2xl shadow-slate-300/45',

View File

@@ -1,11 +1,15 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { PAYMENT_TYPE_META } from '@/lib/pay-utils'; import { PAYMENT_TYPE_META } from '@/lib/pay-utils';
export interface MethodLimitInfo { export interface MethodLimitInfo {
available: boolean; available: boolean;
remaining: number | null; remaining: number | null;
/** 单笔限额0 = 使用全局 maxAmount */
singleMax?: number;
/** 手续费率百分比0 = 无手续费 */
feeRate?: number;
} }
interface PaymentFormProps { interface PaymentFormProps {
@@ -21,7 +25,7 @@ interface PaymentFormProps {
dark?: boolean; dark?: boolean;
} }
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})?$/; const AMOUNT_TEXT_PATTERN = /^\d*(\.\d{0,2})?$/;
function hasValidCentPrecision(num: number): boolean { function hasValidCentPrecision(num: number): boolean {
@@ -44,6 +48,13 @@ export default function PaymentForm({
const [paymentType, setPaymentType] = useState(enabledPaymentTypes[0] || 'alipay'); const [paymentType, setPaymentType] = useState(enabledPaymentTypes[0] || 'alipay');
const [customAmount, setCustomAmount] = useState(''); const [customAmount, setCustomAmount] = useState('');
// Reset paymentType when enabledPaymentTypes changes (e.g. after config loads)
useEffect(() => {
if (!enabledPaymentTypes.includes(paymentType)) {
setPaymentType(enabledPaymentTypes[0] || 'stripe');
}
}, [enabledPaymentTypes, paymentType]);
const handleQuickAmount = (val: number) => { const handleQuickAmount = (val: number) => {
setAmount(val); setAmount(val);
setCustomAmount(String(val)); setCustomAmount(String(val));
@@ -71,7 +82,16 @@ export default function PaymentForm({
const selectedAmount = amount || 0; const selectedAmount = amount || 0;
const isMethodAvailable = !methodLimits || (methodLimits[paymentType]?.available !== false); const isMethodAvailable = !methodLimits || (methodLimits[paymentType]?.available !== false);
const isValid = selectedAmount >= minAmount && selectedAmount <= maxAmount && hasValidCentPrecision(selectedAmount) && isMethodAvailable; const methodSingleMax = methodLimits?.[paymentType]?.singleMax;
const effectiveMax = (methodSingleMax !== undefined && methodSingleMax > 0) ? methodSingleMax : maxAmount;
const feeRate = methodLimits?.[paymentType]?.feeRate ?? 0;
const feeAmount = feeRate > 0 && selectedAmount > 0
? Math.ceil(selectedAmount * feeRate / 100 * 100) / 100
: 0;
const payAmount = feeRate > 0 && selectedAmount > 0
? Math.round((selectedAmount + feeAmount) * 100) / 100
: selectedAmount;
const isValid = selectedAmount >= minAmount && selectedAmount <= effectiveMax && hasValidCentPrecision(selectedAmount) && isMethodAvailable;
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -90,14 +110,9 @@ export default function PaymentForm({
if (type === 'wxpay') { if (type === 'wxpay') {
return ( return (
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-[#2BB741] text-white"> <span className="flex h-8 w-8 items-center justify-center rounded-full bg-[#2BB741] text-white">
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none"> <svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
<path <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" />
d="M5 12.5 10.2 17 19 8" <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" />
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg> </svg>
</span> </span>
); );
@@ -151,7 +166,7 @@ export default function PaymentForm({
</label> </label>
<div className="grid grid-cols-3 gap-2"> <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 <button
key={val} key={val}
type="button" type="button"
@@ -188,10 +203,10 @@ export default function PaymentForm({
inputMode="decimal" inputMode="decimal"
step="0.01" step="0.01"
min={minAmount} min={minAmount}
max={maxAmount} max={effectiveMax}
value={customAmount} value={customAmount}
onChange={(e) => handleCustomAmountChange(e.target.value)} onChange={(e) => handleCustomAmountChange(e.target.value)}
placeholder={`${minAmount} - ${maxAmount}`} placeholder={`${minAmount} - ${effectiveMax}`}
className={[ 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', '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', dark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-gray-300 bg-white text-gray-900',
@@ -205,7 +220,7 @@ export default function PaymentForm({
let msg = '金额需在范围内,且最多支持 2 位小数(精确到分)'; let msg = '金额需在范围内,且最多支持 2 位小数(精确到分)';
if (!isNaN(num)) { if (!isNaN(num)) {
if (num < minAmount) msg = `单笔最低充值 ¥${minAmount}`; if (num < minAmount) msg = `单笔最低充值 ¥${minAmount}`;
else if (num > maxAmount) msg = `单笔最高充值 ¥${maxAmount}`; else if (num > effectiveMax) msg = `单笔最高充值 ¥${effectiveMax}`;
} }
return ( return (
<div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}> <div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>
@@ -214,69 +229,97 @@ export default function PaymentForm({
); );
})()} })()}
{/* Payment Type */} {/* Payment Type — only show when multiple types available */}
<div> {enabledPaymentTypes.length > 1 && (
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-gray-700'].join(' ')}> <div>
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-gray-700'].join(' ')}>
</label>
<div className="flex gap-3"> </label>
{enabledPaymentTypes.map((type) => { <div className="flex gap-3">
const meta = PAYMENT_TYPE_META[type]; {enabledPaymentTypes.map((type) => {
const isSelected = paymentType === type; const meta = PAYMENT_TYPE_META[type];
const limitInfo = methodLimits?.[type]; const isSelected = paymentType === type;
const isUnavailable = limitInfo !== undefined && !limitInfo.available; const limitInfo = methodLimits?.[type];
const isUnavailable = limitInfo !== undefined && !limitInfo.available;
return ( return (
<button <button
key={type} key={type}
type="button" type="button"
disabled={isUnavailable} disabled={isUnavailable}
onClick={() => !isUnavailable && setPaymentType(type)} onClick={() => !isUnavailable && setPaymentType(type)}
title={isUnavailable ? '今日充值额度已满,请使用其他支付方式' : undefined} title={isUnavailable ? '今日充值额度已满,请使用其他支付方式' : undefined}
className={[ className={[
'relative flex h-[58px] flex-1 flex-col items-center justify-center rounded-lg border px-3 transition-all', 'relative flex h-[58px] flex-1 flex-col items-center justify-center rounded-lg border px-3 transition-all',
isUnavailable isUnavailable
? dark ? dark
? 'cursor-not-allowed border-slate-700 bg-slate-800/50 opacity-50' ? 'cursor-not-allowed border-slate-700 bg-slate-800/50 opacity-50'
: 'cursor-not-allowed border-gray-200 bg-gray-50 opacity-50' : 'cursor-not-allowed border-gray-200 bg-gray-50 opacity-50'
: isSelected : isSelected
? `${meta?.selectedBorder || 'border-blue-500'} ${meta?.selectedBg || 'bg-blue-50'} text-slate-900 shadow-sm` ? `${meta?.selectedBorder || 'border-blue-500'} ${meta?.selectedBg || 'bg-blue-50'} text-slate-900 shadow-sm`
: dark : dark
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500' ? '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', : 'border-gray-300 bg-white text-slate-700 hover:border-gray-400',
].join(' ')} ].join(' ')}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
{renderPaymentIcon(type)} {renderPaymentIcon(type)}
<span className="flex flex-col items-start leading-none"> <span className="flex flex-col items-start leading-none">
<span className="text-xl font-semibold tracking-tight">{meta?.label || type}</span> <span className="text-xl font-semibold tracking-tight">{meta?.label || type}</span>
{isUnavailable ? ( {isUnavailable ? (
<span className="text-[10px] tracking-wide text-red-400"></span> <span className="text-[10px] tracking-wide text-red-400"></span>
) : meta?.sublabel ? ( ) : meta?.sublabel ? (
<span <span
className={`text-[10px] tracking-wide ${dark && !isSelected ? 'text-slate-400' : 'text-slate-600'}`} className={`text-[10px] tracking-wide ${dark && !isSelected ? 'text-slate-400' : 'text-slate-600'}`}
> >
{meta.sublabel} {meta.sublabel}
</span> </span>
) : null} ) : null}
</span>
</span> </span>
</span> </button>
</button> );
); })}
})} </div>
</div>
{/* 当前选中渠道额度不足时的提示 */} {/* 当前选中渠道额度不足时的提示 */}
{(() => { {(() => {
const limitInfo = methodLimits?.[paymentType]; const limitInfo = methodLimits?.[paymentType];
if (!limitInfo || limitInfo.available) return null; if (!limitInfo || limitInfo.available) return null;
return ( return (
<p className={['mt-2 text-xs', dark ? 'text-amber-300' : 'text-amber-600'].join(' ')}> <p className={['mt-2 text-xs', dark ? 'text-amber-300' : 'text-amber-600'].join(' ')}>
</p> </p>
); );
})()} })()}
</div> </div>
)}
{/* Fee Detail */}
{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></span>
<span>¥{selectedAmount.toFixed(2)}</span>
</div>
<div className="flex items-center justify-between mt-1">
<span>{feeRate}%</span>
<span>¥{feeAmount.toFixed(2)}</span>
</div>
<div className={[
'flex items-center justify-between mt-1.5 pt-1.5 border-t font-medium',
dark ? 'border-slate-700 text-slate-100' : 'border-slate-200 text-slate-900',
].join(' ')}>
<span></span>
<span>¥{payAmount.toFixed(2)}</span>
</div>
</div>
)}
{/* Submit */} {/* Submit */}
<button <button
@@ -292,7 +335,7 @@ export default function PaymentForm({
: 'cursor-not-allowed bg-gray-300' : 'cursor-not-allowed bg-gray-300'
}`} }`}
> >
{loading ? '处理中...' : `立即充值 ¥${selectedAmount || 0}`} {loading ? '处理中...' : `立即充值 ¥${(feeRate > 0 && selectedAmount > 0 ? payAmount : selectedAmount || 0).toFixed(2)}`}
</button> </button>
</form> </form>
); );

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useMemo, useState, useCallback } from 'react'; import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
interface PaymentQRCodeProps { interface PaymentQRCodeProps {
@@ -8,13 +8,16 @@ interface PaymentQRCodeProps {
token?: string; token?: string;
payUrl?: string | null; payUrl?: string | null;
qrCode?: string | null; qrCode?: string | null;
checkoutUrl?: string | null; clientSecret?: string | null;
stripePublishableKey?: string | null;
paymentType?: 'alipay' | 'wxpay' | 'stripe'; paymentType?: 'alipay' | 'wxpay' | 'stripe';
amount: number; amount: number;
payAmount?: number;
expiresAt: string; expiresAt: string;
onStatusChange: (status: string) => void; onStatusChange: (status: string) => void;
onBack: () => void; onBack: () => void;
dark?: boolean; dark?: boolean;
isEmbedded?: boolean;
} }
const TEXT_EXPIRED = '\u8BA2\u5355\u5DF2\u8D85\u65F6'; const TEXT_EXPIRED = '\u8BA2\u5355\u5DF2\u8D85\u65F6';
@@ -25,35 +28,44 @@ const TEXT_BACK = '\u8FD4\u56DE';
const TEXT_CANCEL_ORDER = '\u53D6\u6D88\u8BA2\u5355'; const TEXT_CANCEL_ORDER = '\u53D6\u6D88\u8BA2\u5355';
const TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED', 'CANCELLED', 'EXPIRED', 'REFUNDED', 'REFUND_FAILED']); 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;
}
}
export default function PaymentQRCode({ export default function PaymentQRCode({
orderId, orderId,
token, token,
payUrl, payUrl,
qrCode, qrCode,
checkoutUrl, clientSecret,
stripePublishableKey,
paymentType, paymentType,
amount, amount,
payAmount: payAmountProp,
expiresAt, expiresAt,
onStatusChange, onStatusChange,
onBack, onBack,
dark = false, dark = false,
isEmbedded = false,
}: PaymentQRCodeProps) { }: PaymentQRCodeProps) {
const displayAmount = payAmountProp ?? amount;
const hasFeeDiff = payAmountProp !== undefined && payAmountProp !== amount;
const [timeLeft, setTimeLeft] = useState(''); const [timeLeft, setTimeLeft] = useState('');
const [expired, setExpired] = useState(false); const [expired, setExpired] = useState(false);
const [qrDataUrl, setQrDataUrl] = useState(''); const [qrDataUrl, setQrDataUrl] = useState('');
const [imageLoading, setImageLoading] = useState(false); const [imageLoading, setImageLoading] = useState(false);
const [stripeOpened, setStripeOpened] = useState(false);
const [cancelBlocked, setCancelBlocked] = useState(false); const [cancelBlocked, setCancelBlocked] = useState(false);
// Stripe Payment Element state
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);
// Track selected payment method in Payment Element (for embedded popup decision)
const [stripePaymentMethod, setStripePaymentMethod] = useState('card');
const [popupBlocked, setPopupBlocked] = useState(false);
const paymentMethodListenerAdded = useRef(false);
const qrPayload = useMemo(() => { const qrPayload = useMemo(() => {
const value = (qrCode || payUrl || '').trim(); const value = (qrCode || payUrl || '').trim();
return value; return value;
@@ -93,6 +105,135 @@ export default function PaymentQRCode({
}; };
}, [qrPayload]); }, [qrPayload]);
// Initialize Stripe Payment Element
const isStripe = paymentType === 'stripe';
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('支付组件加载失败,请刷新页面重试');
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]);
// Mount Payment Element when container is available
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;
// In embedded mode, Alipay redirects to a page with X-Frame-Options that breaks iframe
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');
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: returnUrl.toString(),
},
redirect: 'if_required',
});
if (error) {
setStripeError(error.message || '支付失败,请重试');
setStripeSubmitting(false);
} else {
// Payment succeeded (or no redirect needed)
setStripeSuccess(true);
setStripeSubmitting(false);
// Polling will pick up the status change
}
};
const handleOpenPopup = () => {
if (!clientSecret || !stripePublishableKey) return;
setPopupBlocked(false);
// Only pass display params in URL — sensitive data sent via postMessage
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);
const popup = window.open(
popupUrl.toString(),
'stripe_payment',
'width=500,height=700,scrollbars=yes',
);
if (!popup || popup.closed) {
setPopupBlocked(true);
return;
}
// Send sensitive data via postMessage after popup loads
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(() => { useEffect(() => {
const updateTimer = () => { const updateTimer = () => {
const now = Date.now(); const now = Date.now();
@@ -169,7 +310,6 @@ export default function PaymentQRCode({
} }
}; };
const isStripe = paymentType === 'stripe';
const isWx = paymentType === 'wxpay'; const isWx = paymentType === 'wxpay';
const iconSrc = isStripe ? '' : isWx ? '/icons/wxpay.svg' : '/icons/alipay.svg'; const iconSrc = isStripe ? '' : isWx ? '/icons/wxpay.svg' : '/icons/alipay.svg';
const channelLabel = isStripe ? 'Stripe' : isWx ? '\u5FAE\u4FE1' : '\u652F\u4ED8\u5B9D'; const channelLabel = isStripe ? 'Stripe' : isWx ? '\u5FAE\u4FE1' : '\u652F\u4ED8\u5B9D';
@@ -196,7 +336,12 @@ export default function PaymentQRCode({
return ( return (
<div className="flex flex-col items-center space-y-4"> <div className="flex flex-col items-center space-y-4">
<div className="text-center"> <div className="text-center">
<div className="text-4xl font-bold text-blue-600">{'\u00A5'}{amount.toFixed(2)}</div> <div className="text-4xl font-bold text-blue-600">{'\u00A5'}{displayAmount.toFixed(2)}</div>
{hasFeeDiff && (
<div className={['mt-1 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
¥{amount.toFixed(2)}
</div>
)}
<div className={`mt-1 text-sm ${expired ? 'text-red-500' : dark ? 'text-slate-400' : 'text-gray-500'}`}> <div className={`mt-1 text-sm ${expired ? 'text-red-500' : dark ? 'text-slate-400' : 'text-gray-500'}`}>
{expired ? TEXT_EXPIRED : `${TEXT_REMAINING}: ${timeLeft}`} {expired ? TEXT_EXPIRED : `${TEXT_REMAINING}: ${timeLeft}`}
</div> </div>
@@ -205,48 +350,72 @@ export default function PaymentQRCode({
{!expired && ( {!expired && (
<> <>
{isStripe ? ( {isStripe ? (
<> <div className="w-full max-w-md space-y-4">
<button {!clientSecret || !stripePublishableKey ? (
type="button" <div className={['rounded-lg border-2 border-dashed p-8 text-center', dark ? 'border-slate-700' : 'border-gray-300'].join(' ')}>
disabled={!checkoutUrl || !isSafeCheckoutUrl(checkoutUrl) || stripeOpened} <p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
onClick={() => {
if (checkoutUrl && isSafeCheckoutUrl(checkoutUrl)) { </p>
window.open(checkoutUrl, '_blank', 'noopener,noreferrer'); </div>
setStripeOpened(true); ) : !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" />
className={[ <span className={['ml-3 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
'inline-flex items-center gap-2 rounded-lg px-8 py-3 font-medium text-white shadow-md transition-colors', ...
!checkoutUrl || !isSafeCheckoutUrl(checkoutUrl) || stripeOpened </span>
? 'bg-gray-400 cursor-not-allowed' </div>
: 'bg-[#635bff] hover:bg-[#5249d9] active:bg-[#4840c4]', ) : stripeError && !stripeLib ? (
].join(' ')} <div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
> {stripeError}
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> </div>
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" /> ) : (
<line x1="1" y1="10" x2="23" y2="10" /> <>
</svg> <div
{stripeOpened ? '\u5DF2\u6253\u5F00\u652F\u4ED8\u9875\u9762' : '\u524D\u5F80 Stripe \u652F\u4ED8'} ref={stripeContainerRef}
</button> className={['rounded-lg border p-4', dark ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-white'].join(' ')}
{stripeOpened && ( />
<button {stripeError && (
type="button" <div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
onClick={() => { {stripeError}
if (checkoutUrl && isSafeCheckoutUrl(checkoutUrl)) { </div>
window.open(checkoutUrl, '_blank', 'noopener,noreferrer'); )}
} {stripeSuccess ? (
}} <div className="text-center">
className={['text-sm underline', dark ? 'text-slate-400 hover:text-slate-300' : 'text-gray-500 hover:text-gray-700'].join(' ')} <div className="text-4xl text-green-600">{'\u2713'}</div>
> <p className={['mt-2 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
{'\u91CD\u65B0\u6253\u5F00\u652F\u4ED8\u9875\u9762'} ...
</button> </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
? 'bg-gray-400 cursor-not-allowed'
: 'bg-[#635bff] hover:bg-[#5249d9] active:bg-[#4840c4]',
].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" />
...
</span>
) : (
`支付 ¥${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(' ')}>
</div>
)}
</>
)} )}
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}> </div>
{!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>
</>
) : ( ) : (
<> <>
{qrDataUrl && ( {qrDataUrl && (

View File

@@ -31,15 +31,18 @@ interface OrderDetailProps {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
clientIp: string | null; clientIp: string | null;
srcHost: string | null;
srcUrl: string | null;
paymentSuccess?: boolean; paymentSuccess?: boolean;
rechargeSuccess?: boolean; rechargeSuccess?: boolean;
rechargeStatus?: string; rechargeStatus?: string;
auditLogs: AuditLog[]; auditLogs: AuditLog[];
}; };
onClose: () => void; onClose: () => void;
dark?: boolean;
} }
export default function OrderDetail({ order, onClose }: OrderDetailProps) { export default function OrderDetail({ order, onClose, dark }: OrderDetailProps) {
const fields = [ const fields = [
{ label: '订单号', value: order.id }, { label: '订单号', value: order.id },
{ label: '用户ID', value: order.userId }, { label: '用户ID', value: order.userId },
@@ -54,6 +57,8 @@ export default function OrderDetail({ order, onClose }: OrderDetailProps) {
{ label: '充值码', value: order.rechargeCode }, { label: '充值码', value: order.rechargeCode },
{ label: '支付单号', value: order.paymentTradeNo || '-' }, { label: '支付单号', value: order.paymentTradeNo || '-' },
{ label: '客户端IP', value: order.clientIp || '-' }, { label: '客户端IP', value: order.clientIp || '-' },
{ label: '来源域名', value: order.srcHost || '-' },
{ label: '来源页面', value: order.srcUrl || '-' },
{ label: '创建时间', value: new Date(order.createdAt).toLocaleString('zh-CN') }, { label: '创建时间', value: new Date(order.createdAt).toLocaleString('zh-CN') },
{ label: '过期时间', value: new Date(order.expiresAt).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.paidAt ? new Date(order.paidAt).toLocaleString('zh-CN') : '-' },
@@ -74,46 +79,46 @@ export default function OrderDetail({ order, onClose }: OrderDetailProps) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div <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()} onClick={(e) => e.stopPropagation()}
> >
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-bold"></h3> <h3 className="text-lg font-bold"></h3>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600"> <button onClick={onClose} className={dark ? 'text-slate-400 hover:text-slate-200' : 'text-gray-400 hover:text-gray-600'}>
</button> </button>
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
{fields.map(({ label, value }) => ( {fields.map(({ label, value }) => (
<div key={label} className="rounded-lg bg-gray-50 p-3"> <div key={label} className={`rounded-lg p-3 ${dark ? 'bg-slate-700/60' : 'bg-gray-50'}`}>
<div className="text-xs text-gray-500">{label}</div> <div className={`text-xs ${dark ? 'text-slate-400' : 'text-gray-500'}`}>{label}</div>
<div className="mt-1 break-all text-sm font-medium">{value}</div> <div className={`mt-1 break-all text-sm font-medium ${dark ? 'text-slate-200' : ''}`}>{value}</div>
</div> </div>
))} ))}
</div> </div>
{/* Audit Logs */} {/* Audit Logs */}
<div className="mt-6"> <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'}`}></h4>
<div className="space-y-2"> <div className="space-y-2">
{order.auditLogs.map((log) => ( {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"> <div className="flex items-center justify-between">
<span className="text-sm font-medium">{log.action}</span> <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'}`}>{new Date(log.createdAt).toLocaleString('zh-CN')}</span>
</div> </div>
{log.detail && <div className="mt-1 break-all text-xs text-gray-500">{log.detail}</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 text-gray-400">: {log.operator}</div>} {log.operator && <div className={`mt-1 text-xs ${dark ? 'text-slate-500' : 'text-gray-400'}`}>: {log.operator}</div>}
</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'}`}></div>}
</div> </div>
</div> </div>
<button <button
onClick={onClose} 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'}`}
> >
</button> </button>

View File

@@ -1,12 +1,11 @@
'use client'; 'use client';
import { useState } from 'react';
interface Order { interface Order {
id: string; id: string;
userId: number; userId: number;
userName: string | null; userName: string | null;
userEmail: string | null; userEmail: string | null;
userNotes: string | null;
amount: number; amount: number;
status: string; status: string;
paymentType: string; paymentType: string;
@@ -15,6 +14,7 @@ interface Order {
completedAt: string | null; completedAt: string | null;
failedReason: string | null; failedReason: string | null;
expiresAt: string; expiresAt: string;
srcHost: string | null;
rechargeRetryable?: boolean; rechargeRetryable?: boolean;
} }
@@ -23,63 +23,75 @@ interface OrderTableProps {
onRetry: (orderId: string) => void; onRetry: (orderId: string) => void;
onCancel: (orderId: string) => void; onCancel: (orderId: string) => void;
onViewDetail: (orderId: string) => void; onViewDetail: (orderId: string) => void;
dark?: boolean;
} }
const STATUS_LABELS: Record<string, { label: string; className: string }> = { const STATUS_LABELS: Record<string, { label: string; light: string; dark: string }> = {
PENDING: { label: '待支付', className: 'bg-yellow-100 text-yellow-800' }, PENDING: { label: '待支付', light: 'bg-yellow-100 text-yellow-800', dark: 'bg-yellow-500/20 text-yellow-300' },
PAID: { label: '已支付', className: 'bg-blue-100 text-blue-800' }, PAID: { label: '已支付', light: 'bg-blue-100 text-blue-800', dark: 'bg-blue-500/20 text-blue-300' },
RECHARGING: { label: '充值中', className: 'bg-blue-100 text-blue-800' }, RECHARGING: { label: '充值中', light: 'bg-blue-100 text-blue-800', dark: 'bg-blue-500/20 text-blue-300' },
COMPLETED: { label: '已完成', className: 'bg-green-100 text-green-800' }, COMPLETED: { label: '已完成', light: 'bg-green-100 text-green-800', dark: 'bg-green-500/20 text-green-300' },
EXPIRED: { label: '已超时', className: 'bg-gray-100 text-gray-800' }, EXPIRED: { label: '已超时', light: 'bg-gray-100 text-gray-800', dark: 'bg-slate-600/30 text-slate-400' },
CANCELLED: { label: '已取消', className: 'bg-gray-100 text-gray-800' }, CANCELLED: { label: '已取消', light: 'bg-gray-100 text-gray-800', dark: 'bg-slate-600/30 text-slate-400' },
FAILED: { label: '充值失败', className: 'bg-red-100 text-red-800' }, FAILED: { label: '充值失败', light: 'bg-red-100 text-red-800', dark: 'bg-red-500/20 text-red-300' },
REFUNDING: { label: '退款中', className: 'bg-orange-100 text-orange-800' }, REFUNDING: { label: '退款中', light: 'bg-orange-100 text-orange-800', dark: 'bg-orange-500/20 text-orange-300' },
REFUNDED: { label: '已退款', className: 'bg-purple-100 text-purple-800' }, REFUNDED: { label: '已退款', light: 'bg-purple-100 text-purple-800', dark: 'bg-purple-500/20 text-purple-300' },
REFUND_FAILED: { label: '退款失败', className: 'bg-red-100 text-red-800' }, REFUND_FAILED: { label: '退款失败', light: 'bg-red-100 text-red-800', dark: 'bg-red-500/20 text-red-300' },
}; };
export default function OrderTable({ orders, onRetry, onCancel, onViewDetail }: OrderTableProps) { export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, dark }: OrderTableProps) {
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'}`;
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200"> <table className={`min-w-full divide-y ${dark ? 'divide-slate-700' : 'divide-gray-200'}`}>
<thead className="bg-gray-50"> <thead className={dark ? 'bg-slate-800/50' : 'bg-gray-50'}>
<tr> <tr>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"></th> <th className={thCls}></th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"></th> <th className={thCls}></th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"></th> <th className={thCls}></th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"></th> <th className={thCls}></th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"></th> <th className={thCls}></th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"></th> <th className={thCls}></th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase text-gray-500"></th> <th className={thCls}></th>
<th className={thCls}></th>
<th className={thCls}></th>
<th className={thCls}></th>
</tr> </tr>
</thead> </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) => { {orders.map((order) => {
const statusInfo = STATUS_LABELS[order.status] || { const statusInfo = STATUS_LABELS[order.status] || {
label: order.status, label: order.status,
className: 'bg-gray-100 text-gray-800', light: 'bg-gray-100 text-gray-800',
dark: 'bg-slate-600/30 text-slate-400',
}; };
return ( 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"> <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)}... {order.id.slice(0, 12)}...
</button> </button>
</td> </td>
<td className="whitespace-nowrap px-4 py-3 text-sm"> <td className={`whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-200' : ''}`}>
<div>{order.userName || '-'}</div> {order.userName || `#${order.userId}`}
<div className="text-xs text-gray-400">{order.userEmail || `ID: ${order.userId}`}</div>
</td> </td>
<td className="whitespace-nowrap px-4 py-3 text-sm font-medium">¥{order.amount.toFixed(2)}</td> <td className={tdMuted}>{order.userEmail || '-'}</td>
<td className={tdMuted}>{order.userNotes || '-'}</td>
<td className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : ''}`}>¥{order.amount.toFixed(2)}</td>
<td className="whitespace-nowrap px-4 py-3 text-sm"> <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} {statusInfo.label}
</span> </span>
</td> </td>
<td className="whitespace-nowrap px-4 py-3 text-sm text-gray-500"> <td className={tdMuted}>
{order.paymentType === 'alipay' ? '支付宝' : '微信支付'} {order.paymentType === 'alipay' ? '支付宝' : '微信支付'}
</td> </td>
<td className="whitespace-nowrap px-4 py-3 text-sm text-gray-500"> <td className={tdMuted}>
{order.srcHost || '-'}
</td>
<td className={tdMuted}>
{new Date(order.createdAt).toLocaleString('zh-CN')} {new Date(order.createdAt).toLocaleString('zh-CN')}
</td> </td>
<td className="whitespace-nowrap px-4 py-3 text-sm"> <td className="whitespace-nowrap px-4 py-3 text-sm">
@@ -87,7 +99,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail }:
{order.rechargeRetryable && ( {order.rechargeRetryable && (
<button <button
onClick={() => onRetry(order.id)} 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'}`}
> >
</button> </button>
@@ -95,7 +107,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail }:
{order.status === 'PENDING' && ( {order.status === 'PENDING' && (
<button <button
onClick={() => onCancel(order.id)} 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'}`}
> >
</button> </button>
@@ -107,7 +119,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail }:
})} })}
</tbody> </tbody>
</table> </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'}`}></div>}
</div> </div>
); );
} }

View File

@@ -2,10 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { getEnv } from '@/lib/config'; import { getEnv } from '@/lib/config';
import crypto from 'crypto'; import crypto from 'crypto';
export function verifyAdminToken(request: NextRequest): boolean { function isLocalAdminToken(token: string): boolean {
const token = request.nextUrl.searchParams.get('token');
if (!token) return false;
const env = getEnv(); const env = getEnv();
const expected = Buffer.from(env.ADMIN_TOKEN); const expected = Buffer.from(env.ADMIN_TOKEN);
const received = Buffer.from(token); const received = Buffer.from(token);
@@ -14,6 +11,35 @@ export function verifyAdminToken(request: NextRequest): boolean {
return crypto.timingSafeEqual(expected, received); return crypto.timingSafeEqual(expected, received);
} }
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> {
const token = request.nextUrl.searchParams.get('token');
if (!token) return false;
// 1. 本地 admin token
if (isLocalAdminToken(token)) return true;
// 2. Sub2API 管理员 token
return isSub2ApiAdmin(token);
}
export function unauthorizedResponse() { export function unauthorizedResponse() {
return NextResponse.json({ error: '未授权' }, { status: 401 }); return NextResponse.json({ error: '未授权' }, { status: 401 });
} }

View File

@@ -12,7 +12,13 @@ const envSchema = z.object({
SUB2API_BASE_URL: z.string().url(), SUB2API_BASE_URL: z.string().url(),
SUB2API_ADMIN_API_KEY: z.string().min(1), SUB2API_ADMIN_API_KEY: z.string().min(1),
// ── Easy-Pay (optional when only using Stripe) ── // ── 支付服务商显式声明启用哪些服务商逗号分隔easypay, 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_PID: optionalTrimmedString,
EASY_PAY_PKEY: optionalTrimmedString, EASY_PAY_PKEY: optionalTrimmedString,
EASY_PAY_API_BASE: optionalTrimmedString, EASY_PAY_API_BASE: optionalTrimmedString,
@@ -22,10 +28,13 @@ const envSchema = z.object({
EASY_PAY_CID_ALIPAY: optionalTrimmedString, EASY_PAY_CID_ALIPAY: optionalTrimmedString,
EASY_PAY_CID_WXPAY: optionalTrimmedString, EASY_PAY_CID_WXPAY: optionalTrimmedString,
// ── StripePAYMENT_PROVIDERS 含 stripe 时必填) ──
STRIPE_SECRET_KEY: optionalTrimmedString, STRIPE_SECRET_KEY: optionalTrimmedString,
STRIPE_PUBLISHABLE_KEY: optionalTrimmedString, STRIPE_PUBLISHABLE_KEY: optionalTrimmedString,
STRIPE_WEBHOOK_SECRET: optionalTrimmedString, STRIPE_WEBHOOK_SECRET: optionalTrimmedString,
// ── 启用的支付渠道(在已配置服务商支持的渠道中选择) ──
// 易支付支持: alipay, wxpayStripe 支持: stripe
ENABLED_PAYMENT_TYPES: z ENABLED_PAYMENT_TYPES: z
.string() .string()
.default('alipay,wxpay') .default('alipay,wxpay')
@@ -37,18 +46,18 @@ const envSchema = z.object({
// 每日每用户最大累计充值额0 = 不限制 // 每日每用户最大累计充值额0 = 不限制
MAX_DAILY_RECHARGE_AMOUNT: z.string().default('10000').transform(Number).pipe(z.number().min(0)), MAX_DAILY_RECHARGE_AMOUNT: z.string().default('10000').transform(Number).pipe(z.number().min(0)),
// 每日各渠道全平台总限额0 = 不限制 // 每日各渠道全平台总限额,可选覆盖(0 = 不限制)。
// 新增渠道按 MAX_DAILY_AMOUNT_{TYPE大写} 命名即可自动生效 // 未设置时由各 PaymentProvider.defaultLimits 提供默认值。
MAX_DAILY_AMOUNT_ALIPAY: z.string().default('10000').transform(Number).pipe(z.number().min(0)), MAX_DAILY_AMOUNT_ALIPAY: z.string().optional().transform((v) => (v !== undefined ? Number(v) : undefined)).pipe(z.number().min(0).optional()),
MAX_DAILY_AMOUNT_WXPAY: z.string().default('10000').transform(Number).pipe(z.number().min(0)), 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().default('0').transform(Number).pipe(z.number().min(0)), 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'), PRODUCT_NAME: z.string().default('Sub2API Balance Recharge'),
ADMIN_TOKEN: z.string().min(1), ADMIN_TOKEN: z.string().min(1),
NEXT_PUBLIC_APP_URL: z.string().url(), NEXT_PUBLIC_APP_URL: z.string().url(),
NEXT_PUBLIC_PAY_HELP_IMAGE_URL: optionalTrimmedString, PAY_HELP_IMAGE_URL: optionalTrimmedString,
NEXT_PUBLIC_PAY_HELP_TEXT: optionalTrimmedString, PAY_HELP_TEXT: optionalTrimmedString,
}); });
export type Env = z.infer<typeof envSchema>; export type Env = z.infer<typeof envSchema>;

View File

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

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

@@ -0,0 +1,38 @@
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
/**
* 获取指定支付渠道的手续费率(百分比)。
* 优先级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;
}
/**
* 根据到账金额和手续费率计算实付金额。
* feeAmount = ceil(rechargeAmount * feeRate / 100 * 100) / 100 (进一制到分)
* payAmount = rechargeAmount + feeAmount
*/
export function calculatePayAmount(rechargeAmount: number, feeRate: number): number {
if (feeRate <= 0) return rechargeAmount;
const feeAmount = Math.ceil(rechargeAmount * feeRate / 100 * 100) / 100;
return Math.round((rechargeAmount + feeAmount) * 100) / 100;
}

View File

@@ -1,17 +1,24 @@
import { prisma } from '@/lib/db'; import { prisma } from '@/lib/db';
import { getEnv } from '@/lib/config'; import { getEnv } from '@/lib/config';
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
import { getMethodFeeRate } from './fee';
/** /**
* 获取指定支付渠道的每日全平台限额0 = 不限制)。 * 获取指定支付渠道的每日全平台限额0 = 不限制)。
* 优先读 configZod 验证),兜底读 process.env适配未来动态注册的新渠道。 * 优先级:环境变量显式配置 > provider 默认值 > process.env 兜底 > 0
*/ */
export function getMethodDailyLimit(paymentType: string): number { export function getMethodDailyLimit(paymentType: string): number {
const env = getEnv(); const env = getEnv();
const key = `MAX_DAILY_AMOUNT_${paymentType.toUpperCase()}` as keyof typeof env; const key = `MAX_DAILY_AMOUNT_${paymentType.toUpperCase()}` as keyof typeof env;
const val = env[key]; const val = env[key];
if (typeof val === 'number') return val; if (typeof val === 'number') return val; // 明确配置(含 0
// 兜底:支持动态渠道(未在 schema 中声明的 MAX_DAILY_AMOUNT_* 变量) // 尝试从已注册的 provider 取默认值
initPaymentProviders();
const providerDefault = paymentRegistry.getDefaultLimit(paymentType);
if (providerDefault?.dailyMax !== undefined) return providerDefault.dailyMax;
// 兜底process.env支持未在 schema 中声明的动态渠道)
const raw = process.env[`MAX_DAILY_AMOUNT_${paymentType.toUpperCase()}`]; const raw = process.env[`MAX_DAILY_AMOUNT_${paymentType.toUpperCase()}`];
if (raw !== undefined) { if (raw !== undefined) {
const num = Number(raw); const num = Number(raw);
@@ -20,15 +27,37 @@ export function getMethodDailyLimit(paymentType: string): number {
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; // 使用全局 MAX_RECHARGE_AMOUNT
}
export interface MethodLimitStatus { export interface MethodLimitStatus {
/** 每日限额0 = 不限 */ /** 每日限额0 = 不限 */
dailyLimit: number; dailyLimit: number;
/** 今日已使用金额 */ /** 今日已使用金额 */
used: number; used: number;
/** 剩余额度null = 不限 */ /** 剩余每日额度null = 不限 */
remaining: number | null; remaining: number | null;
/** 是否还可使用false = 今日额度已满) */ /** 是否还可使用false = 今日额度已满) */
available: boolean; available: boolean;
/** 单笔限额0 = 使用全局配置 MAX_RECHARGE_AMOUNT */
singleMax: number;
/** 手续费率百分比0 = 无手续费 */
feeRate: number;
} }
/** /**
@@ -58,6 +87,8 @@ export async function queryMethodLimits(
const result: Record<string, MethodLimitStatus> = {}; const result: Record<string, MethodLimitStatus> = {};
for (const type of paymentTypes) { for (const type of paymentTypes) {
const dailyLimit = getMethodDailyLimit(type); const dailyLimit = getMethodDailyLimit(type);
const singleMax = getMethodSingleLimit(type);
const feeRate = getMethodFeeRate(type);
const used = usageMap[type] ?? 0; const used = usageMap[type] ?? 0;
const remaining = dailyLimit > 0 ? Math.max(0, dailyLimit - used) : null; const remaining = dailyLimit > 0 ? Math.max(0, dailyLimit - used) : null;
result[type] = { result[type] = {
@@ -65,6 +96,8 @@ export async function queryMethodLimits(
used, used,
remaining, remaining,
available: dailyLimit === 0 || used < dailyLimit, available: dailyLimit === 0 || used < dailyLimit,
singleMax,
feeRate,
}; };
} }
return result; return result;

View File

@@ -2,6 +2,7 @@ import { prisma } from '@/lib/db';
import { getEnv } from '@/lib/config'; import { getEnv } from '@/lib/config';
import { generateRechargeCode } from './code-gen'; import { generateRechargeCode } from './code-gen';
import { getMethodDailyLimit } from './limits'; import { getMethodDailyLimit } from './limits';
import { getMethodFeeRate, calculatePayAmount } from './fee';
import { initPaymentProviders, paymentRegistry } from '@/lib/payment'; import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
import type { PaymentType, PaymentNotification } from '@/lib/payment'; import type { PaymentType, PaymentNotification } from '@/lib/payment';
import { getUser, createAndRedeem, subtractBalance } from '@/lib/sub2api/client'; import { getUser, createAndRedeem, subtractBalance } from '@/lib/sub2api/client';
@@ -15,18 +16,22 @@ export interface CreateOrderInput {
amount: number; amount: number;
paymentType: PaymentType; paymentType: PaymentType;
clientIp: string; clientIp: string;
srcHost?: string;
srcUrl?: string;
} }
export interface CreateOrderResult { export interface CreateOrderResult {
orderId: string; orderId: string;
amount: number; amount: number;
payAmount: number;
feeRate: number;
status: string; status: string;
paymentType: PaymentType; paymentType: PaymentType;
userName: string; userName: string;
userBalance: number; userBalance: number;
payUrl?: string | null; payUrl?: string | null;
qrCode?: string | null; qrCode?: string | null;
checkoutUrl?: string | null; clientSecret?: string | null;
expiresAt: Date; expiresAt: Date;
} }
@@ -94,18 +99,26 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
} }
} }
const feeRate = getMethodFeeRate(input.paymentType);
const payAmount = calculatePayAmount(input.amount, feeRate);
const expiresAt = new Date(Date.now() + env.ORDER_TIMEOUT_MINUTES * 60 * 1000); const expiresAt = new Date(Date.now() + env.ORDER_TIMEOUT_MINUTES * 60 * 1000);
const order = await prisma.order.create({ const order = await prisma.order.create({
data: { data: {
userId: input.userId, userId: input.userId,
userEmail: user.email, userEmail: user.email,
userName: user.username, userName: user.username,
userNotes: user.notes || null,
amount: new Prisma.Decimal(input.amount.toFixed(2)), 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: '', rechargeCode: '',
status: 'PENDING', status: 'PENDING',
paymentType: input.paymentType, paymentType: input.paymentType,
expiresAt, expiresAt,
clientIp: input.clientIp, clientIp: input.clientIp,
srcHost: input.srcHost || null,
srcUrl: input.srcUrl || null,
}, },
}); });
@@ -120,9 +133,9 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
const provider = paymentRegistry.getProvider(input.paymentType); const provider = paymentRegistry.getProvider(input.paymentType);
const paymentResult = await provider.createPayment({ const paymentResult = await provider.createPayment({
orderId: order.id, orderId: order.id,
amount: input.amount, amount: payAmount,
paymentType: input.paymentType, paymentType: input.paymentType,
subject: `${env.PRODUCT_NAME} ${input.amount.toFixed(2)} CNY`, subject: `${env.PRODUCT_NAME} ${payAmount.toFixed(2)} CNY`,
notifyUrl: env.EASY_PAY_NOTIFY_URL || '', notifyUrl: env.EASY_PAY_NOTIFY_URL || '',
returnUrl: env.EASY_PAY_RETURN_URL || '', returnUrl: env.EASY_PAY_RETURN_URL || '',
clientIp: input.clientIp, clientIp: input.clientIp,
@@ -149,13 +162,15 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
return { return {
orderId: order.id, orderId: order.id,
amount: input.amount, amount: input.amount,
payAmount,
feeRate,
status: 'PENDING', status: 'PENDING',
paymentType: input.paymentType, paymentType: input.paymentType,
userName: user.username, userName: user.username,
userBalance: user.balance, userBalance: user.balance,
payUrl: paymentResult.payUrl, payUrl: paymentResult.payUrl,
qrCode: paymentResult.qrCode, qrCode: paymentResult.qrCode,
checkoutUrl: paymentResult.checkoutUrl, clientSecret: paymentResult.clientSecret,
expiresAt, expiresAt,
}; };
} catch (error) { } catch (error) {
@@ -166,6 +181,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
// 支付网关配置缺失或调用失败,转成友好错误 // 支付网关配置缺失或调用失败,转成友好错误
const msg = error instanceof Error ? error.message : String(error); 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')) { 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', `支付渠道(${input.paymentType})暂未配置,请联系管理员`, 503);
} }
@@ -308,10 +324,11 @@ export async function confirmPayment(input: {
console.error(`${input.providerName} notify: non-positive amount:`, input.paidAmount); console.error(`${input.providerName} notify: non-positive amount:`, input.paidAmount);
return false; return false;
} }
if (!paidAmount.equals(order.amount)) { const expectedAmount = order.payAmount ?? order.amount;
if (!paidAmount.equals(expectedAmount)) {
console.warn( console.warn(
`${input.providerName} notify: amount changed, use paid amount`, `${input.providerName} notify: amount changed, use paid amount`,
order.amount.toString(), expectedAmount.toString(),
paidAmount.toString(), paidAmount.toString(),
); );
} }
@@ -546,15 +563,16 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
throw new OrderError('INVALID_STATUS', 'Only completed orders can be refunded', 400); throw new OrderError('INVALID_STATUS', '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) { if (!input.force) {
try { try {
const user = await getUser(order.userId); const user = await getUser(order.userId);
if (user.balance < amount) { if (user.balance < rechargeAmount) {
return { return {
success: false, success: false,
warning: `User balance ${user.balance} is lower than refund ${amount}`, warning: `User balance ${user.balance} is lower than refund ${rechargeAmount}`,
requireForce: true, requireForce: true,
}; };
} }
@@ -582,18 +600,18 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
await provider.refund({ await provider.refund({
tradeNo: order.paymentTradeNo, tradeNo: order.paymentTradeNo,
orderId: order.id, orderId: order.id,
amount, amount: refundAmount,
reason: input.reason, reason: input.reason,
}); });
} }
await subtractBalance(order.userId, amount, `sub2apipay refund order:${order.id}`, `sub2apipay:refund:${order.id}`); await subtractBalance(order.userId, rechargeAmount, `sub2apipay refund order:${order.id}`, `sub2apipay:refund:${order.id}`);
await prisma.order.update({ await prisma.order.update({
where: { id: input.orderId }, where: { id: input.orderId },
data: { data: {
status: 'REFUNDED', status: 'REFUNDED',
refundAmount: new Prisma.Decimal(amount.toFixed(2)), refundAmount: new Prisma.Decimal(refundAmount.toFixed(2)),
refundReason: input.reason || null, refundReason: input.reason || null,
refundAt: new Date(), refundAt: new Date(),
forceRefund: input.force || false, forceRefund: input.force || false,
@@ -604,7 +622,7 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
data: { data: {
orderId: input.orderId, orderId: input.orderId,
action: 'REFUND_SUCCESS', 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', operator: 'admin',
}, },
}); });

View File

@@ -1,4 +1,5 @@
import { paymentRegistry } from './registry'; import { paymentRegistry } from './registry';
import type { PaymentType } from './types';
import { EasyPayProvider } from '@/lib/easy-pay/provider'; import { EasyPayProvider } from '@/lib/easy-pay/provider';
import { StripeProvider } from '@/lib/stripe/provider'; import { StripeProvider } from '@/lib/stripe/provider';
import { getEnv } from '@/lib/config'; import { getEnv } from '@/lib/config';
@@ -19,12 +20,32 @@ let initialized = false;
export function initPaymentProviders(): void { export function initPaymentProviders(): void {
if (initialized) return; if (initialized) return;
paymentRegistry.register(new EasyPayProvider());
const env = getEnv(); 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('stripe')) {
if (!env.STRIPE_SECRET_KEY) {
throw new Error('PAYMENT_PROVIDERS 含 stripe但缺少 STRIPE_SECRET_KEY');
}
paymentRegistry.register(new StripeProvider()); paymentRegistry.register(new StripeProvider());
} }
// 校验 ENABLED_PAYMENT_TYPES 的每个渠道都有对应 provider 已注册
const unsupported = env.ENABLED_PAYMENT_TYPES.filter((t) => !paymentRegistry.hasProvider(t as PaymentType));
if (unsupported.length > 0) {
throw new Error(
`ENABLED_PAYMENT_TYPES 含 [${unsupported.join(', ')}],但没有对应的 PAYMENT_PROVIDERS 注册。` +
`请检查 PAYMENT_PROVIDERS 配置`,
);
}
initialized = true; initialized = true;
} }

View File

@@ -1,4 +1,4 @@
import type { PaymentProvider, PaymentType } from './types'; import type { PaymentProvider, PaymentType, MethodDefaultLimits } from './types';
export class PaymentProviderRegistry { export class PaymentProviderRegistry {
private providers = new Map<PaymentType, PaymentProvider>(); private providers = new Map<PaymentType, PaymentProvider>();
@@ -24,6 +24,18 @@ export class PaymentProviderRegistry {
getSupportedTypes(): PaymentType[] { getSupportedTypes(): PaymentType[] {
return Array.from(this.providers.keys()); return Array.from(this.providers.keys());
} }
/** 获取指定渠道的提供商默认限额(未注册时返回 undefined */
getDefaultLimit(type: string): MethodDefaultLimits | undefined {
const provider = this.providers.get(type as PaymentType);
return provider?.defaultLimits?.[type];
}
/** 获取指定渠道对应的提供商 key如 'easypay'、'stripe' */
getProviderKey(type: string): string | undefined {
const provider = this.providers.get(type as PaymentType);
return provider?.providerKey;
}
} }
export const paymentRegistry = new PaymentProviderRegistry(); export const paymentRegistry = new PaymentProviderRegistry();

View File

@@ -17,7 +17,7 @@ export interface CreatePaymentResponse {
tradeNo: string; // third-party transaction ID tradeNo: string; // third-party transaction ID
payUrl?: string; // H5 payment URL (alipay/wxpay) payUrl?: string; // H5 payment URL (alipay/wxpay)
qrCode?: string; // QR code content 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 */ /** Response from querying an order's payment status */
@@ -51,10 +51,21 @@ export interface RefundResponse {
status: 'success' | 'pending' | 'failed'; 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 */ /** Common interface that all payment providers must implement */
export interface PaymentProvider { export interface PaymentProvider {
readonly name: string; readonly name: string;
readonly providerKey: string;
readonly supportedTypes: PaymentType[]; readonly supportedTypes: PaymentType[];
/** 各渠道默认限额key 为 PaymentType如 'alipay'),可被环境变量覆盖 */
readonly defaultLimits?: Record<string, MethodDefaultLimits>;
createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse>; createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse>;
queryOrder(tradeNo: string): Promise<QueryOrderResponse>; queryOrder(tradeNo: string): Promise<QueryOrderResponse>;

View File

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

View File

@@ -4,6 +4,7 @@ export interface Sub2ApiUser {
email: string; email: string;
status: string; // "active", "banned", etc. status: string; // "active", "banned", etc.
balance: number; balance: number;
notes?: string;
} }
export interface Sub2ApiRedeemCode { export interface Sub2ApiRedeemCode {

View File

@@ -4,16 +4,27 @@ import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) { export function middleware(request: NextRequest) {
const response = NextResponse.next(); const response = NextResponse.next();
// IFRAME_ALLOW_ORIGINS: 允许嵌入 iframe 的外部域名(逗号分隔) // 自动从 SUB2API_BASE_URL 提取 origin允许 Sub2API 主站 iframe 嵌入
const allowOrigins = process.env.IFRAME_ALLOW_ORIGINS || ''; const sub2apiUrl = process.env.SUB2API_BASE_URL || '';
const extraOrigins = process.env.IFRAME_ALLOW_ORIGINS || '';
const origins = allowOrigins const origins = new Set<string>();
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (origins.length > 0) { if (sub2apiUrl) {
response.headers.set('Content-Security-Policy', `frame-ancestors 'self' ${origins.join(' ')}`); try {
origins.add(new URL(sub2apiUrl).origin);
} catch {
// ignore invalid URL
}
}
for (const s of extraOrigins.split(',')) {
const trimmed = s.trim();
if (trimmed) origins.add(trimmed);
}
if (origins.size > 0) {
response.headers.set('Content-Security-Policy', `frame-ancestors 'self' ${[...origins].join(' ')}`);
} }
return response; return response;