Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9e164babc | ||
|
|
0a35ba9002 | ||
|
|
ab961e669a | ||
|
|
93a417b312 | ||
|
|
ba1ce6b696 | ||
|
|
448d36fe2b | ||
|
|
f1e3fd35ef | ||
|
|
8746f474d1 | ||
|
|
55756744a1 | ||
|
|
9a90a7ebb9 | ||
|
|
f96f89b7bb | ||
|
|
56bf0916e3 | ||
|
|
3380b808e2 | ||
|
|
96436f617a | ||
|
|
d461880a9e | ||
|
|
69cf0d00d1 | ||
|
|
3a9a32e2c2 | ||
|
|
d7d91857c7 | ||
|
|
84f38f985f | ||
|
|
964a2aa6d9 |
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -24,6 +24,7 @@ jobs:
|
||||
node-version-file: .node-version
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm prisma generate
|
||||
- run: pnpm typecheck
|
||||
|
||||
lint:
|
||||
@@ -39,6 +40,7 @@ jobs:
|
||||
node-version-file: .node-version
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm prisma generate
|
||||
- run: pnpm lint
|
||||
|
||||
format:
|
||||
@@ -54,6 +56,7 @@ jobs:
|
||||
node-version-file: .node-version
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm prisma generate
|
||||
- run: pnpm format:check
|
||||
|
||||
test:
|
||||
@@ -69,4 +72,5 @@ jobs:
|
||||
node-version-file: .node-version
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm prisma generate
|
||||
- run: pnpm test
|
||||
|
||||
1
.idea/vcs.xml
generated
1
.idea/vcs.xml
generated
@@ -2,6 +2,5 @@
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/third-party/sub2api" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
123
README.en.md
123
README.en.md
@@ -34,15 +34,15 @@ Sub2ApiPay is a self-hosted recharge payment gateway built for the [Sub2API](htt
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Category | Technology |
|
||||
|----------|------------|
|
||||
| Framework | Next.js 16 (App Router) |
|
||||
| Language | TypeScript 5 + React 19 |
|
||||
| Styling | TailwindCSS 4 |
|
||||
| ORM | Prisma 7 (adapter-pg mode) |
|
||||
| Database | PostgreSQL 16 |
|
||||
| Container | Docker + Docker Compose |
|
||||
| Package Manager | pnpm |
|
||||
| Category | Technology |
|
||||
| --------------- | -------------------------- |
|
||||
| Framework | Next.js 16 (App Router) |
|
||||
| Language | TypeScript 5 + React 19 |
|
||||
| Styling | TailwindCSS 4 |
|
||||
| ORM | Prisma 7 (adapter-pg mode) |
|
||||
| Database | PostgreSQL 16 |
|
||||
| Container | Docker + Docker Compose |
|
||||
| Package Manager | pnpm |
|
||||
|
||||
---
|
||||
|
||||
@@ -85,12 +85,12 @@ See [`.env.example`](./.env.example) for the full template.
|
||||
|
||||
### Core (Required)
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SUB2API_BASE_URL` | Sub2API service URL, e.g. `https://sub2api.com` |
|
||||
| `SUB2API_ADMIN_API_KEY` | Sub2API admin API key |
|
||||
| `ADMIN_TOKEN` | Admin panel access token (use a strong random string) |
|
||||
| `NEXT_PUBLIC_APP_URL` | Public URL of this service, e.g. `https://pay.example.com` |
|
||||
| Variable | Description |
|
||||
| ----------------------- | ---------------------------------------------------------- |
|
||||
| `SUB2API_BASE_URL` | Sub2API service URL, e.g. `https://sub2api.com` |
|
||||
| `SUB2API_ADMIN_API_KEY` | Sub2API admin API key |
|
||||
| `ADMIN_TOKEN` | Admin panel access token (use a strong random string) |
|
||||
| `NEXT_PUBLIC_APP_URL` | Public URL of this service, e.g. `https://pay.example.com` |
|
||||
|
||||
> `DATABASE_URL` is automatically injected by Docker Compose when using the bundled database.
|
||||
|
||||
@@ -127,49 +127,50 @@ Any payment provider compatible with the **EasyPay protocol** can be used, such
|
||||
|
||||
> **Disclaimer**: Please evaluate the security, reliability, and compliance of any third-party payment provider on your own. This project does not endorse or guarantee any specific provider.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `EASY_PAY_PID` | EasyPay merchant ID |
|
||||
| `EASY_PAY_PKEY` | EasyPay merchant secret key |
|
||||
| `EASY_PAY_API_BASE` | EasyPay API base URL |
|
||||
| Variable | Description |
|
||||
| --------------------- | ---------------------------------------------------------------- |
|
||||
| `EASY_PAY_PID` | EasyPay merchant ID |
|
||||
| `EASY_PAY_PKEY` | EasyPay merchant secret key |
|
||||
| `EASY_PAY_API_BASE` | EasyPay API base URL |
|
||||
| `EASY_PAY_NOTIFY_URL` | Async callback URL: `${NEXT_PUBLIC_APP_URL}/api/easy-pay/notify` |
|
||||
| `EASY_PAY_RETURN_URL` | Redirect URL after payment: `${NEXT_PUBLIC_APP_URL}/pay` |
|
||||
| `EASY_PAY_CID_ALIPAY` | Alipay channel ID (optional) |
|
||||
| `EASY_PAY_CID_WXPAY` | WeChat Pay channel ID (optional) |
|
||||
| `EASY_PAY_RETURN_URL` | Redirect URL after payment: `${NEXT_PUBLIC_APP_URL}/pay` |
|
||||
| `EASY_PAY_CID_ALIPAY` | Alipay channel ID (optional) |
|
||||
| `EASY_PAY_CID_WXPAY` | WeChat Pay channel ID (optional) |
|
||||
|
||||
#### Stripe
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `STRIPE_SECRET_KEY` | Stripe secret key (`sk_live_...`) |
|
||||
| `STRIPE_PUBLISHABLE_KEY` | Stripe publishable key (`pk_live_...`) |
|
||||
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret (`whsec_...`) |
|
||||
| Variable | Description |
|
||||
| ------------------------ | ------------------------------------------- |
|
||||
| `STRIPE_SECRET_KEY` | Stripe secret key (`sk_live_...`) |
|
||||
| `STRIPE_PUBLISHABLE_KEY` | Stripe publishable key (`pk_live_...`) |
|
||||
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret (`whsec_...`) |
|
||||
|
||||
> Stripe webhook endpoint: `${NEXT_PUBLIC_APP_URL}/api/stripe/webhook`
|
||||
> Subscribe to: `checkout.session.completed`, `checkout.session.expired`
|
||||
> Subscribe to: `payment_intent.succeeded`, `payment_intent.payment_failed`
|
||||
|
||||
### Business Rules
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `MIN_RECHARGE_AMOUNT` | Minimum amount per transaction (CNY) | `1` |
|
||||
| `MAX_RECHARGE_AMOUNT` | Maximum amount per transaction (CNY) | `1000` |
|
||||
| `MAX_DAILY_RECHARGE_AMOUNT` | Daily cumulative max per user (`0` = unlimited) | `10000` |
|
||||
| `ORDER_TIMEOUT_MINUTES` | Order expiry in minutes | `5` |
|
||||
| `PRODUCT_NAME` | Product name shown on payment page | `Sub2API Balance Recharge` |
|
||||
| Variable | Description | Default |
|
||||
| --------------------------- | ----------------------------------------------- | -------------------------- |
|
||||
| `MIN_RECHARGE_AMOUNT` | Minimum amount per transaction (CNY) | `1` |
|
||||
| `MAX_RECHARGE_AMOUNT` | Maximum amount per transaction (CNY) | `1000` |
|
||||
| `MAX_DAILY_RECHARGE_AMOUNT` | Daily cumulative max per user (`0` = unlimited) | `10000` |
|
||||
| `ORDER_TIMEOUT_MINUTES` | Order expiry in minutes | `5` |
|
||||
| `PRODUCT_NAME` | Product name shown on payment page | `Sub2API Balance Recharge` |
|
||||
|
||||
### UI Customization (Optional)
|
||||
|
||||
Display a support contact image and description on the right side of the payment page.
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `PAY_HELP_IMAGE_URL` | Help image URL — external URL or local path (see below) |
|
||||
| `PAY_HELP_TEXT` | Help text; use `\n` for line breaks, e.g. `Scan to add WeChat\nMon–Fri 9am–6pm` |
|
||||
| Variable | Description |
|
||||
| -------------------- | ------------------------------------------------------------------------------- |
|
||||
| `PAY_HELP_IMAGE_URL` | Help image URL — external URL or local path (see below) |
|
||||
| `PAY_HELP_TEXT` | Help text; use `\n` for line breaks, e.g. `Scan to add WeChat\nMon–Fri 9am–6pm` |
|
||||
|
||||
**Two ways to provide the image:**
|
||||
|
||||
- **External URL** (recommended — no Compose changes needed): any publicly accessible image link (CDN, OSS, image hosting).
|
||||
|
||||
```env
|
||||
PAY_HELP_IMAGE_URL=https://cdn.example.com/help-qr.jpg
|
||||
```
|
||||
@@ -188,9 +189,9 @@ Display a support contact image and description on the right side of the payment
|
||||
|
||||
### Docker Compose Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `APP_PORT` | Host port mapping | `3001` |
|
||||
| Variable | Description | Default |
|
||||
| ------------- | -------------------------------- | ------------------------------------- |
|
||||
| `APP_PORT` | Host port mapping | `3001` |
|
||||
| `DB_PASSWORD` | PostgreSQL password (bundled DB) | `password` (**change in production**) |
|
||||
|
||||
---
|
||||
@@ -266,19 +267,19 @@ docker compose exec app npx prisma migrate deploy
|
||||
|
||||
The following page URLs can be configured in the Sub2API admin panel:
|
||||
|
||||
| Page | URL | Description |
|
||||
|------|-----|-------------|
|
||||
| Payment | `https://pay.example.com/pay` | User recharge entry |
|
||||
| My Orders | `https://pay.example.com/pay/orders` | User views their own recharge history |
|
||||
| Order Management | `https://pay.example.com/admin` | Sub2API admin only |
|
||||
| Page | URL | Description |
|
||||
| ---------------- | ------------------------------------ | ------------------------------------- |
|
||||
| Payment | `https://pay.example.com/pay` | User recharge entry |
|
||||
| My Orders | `https://pay.example.com/pay/orders` | User views their own recharge history |
|
||||
| Order Management | `https://pay.example.com/admin` | Sub2API admin only |
|
||||
|
||||
Sub2API **v0.1.88** and above will automatically append the following parameters — no manual query string needed:
|
||||
|
||||
| Parameter | Description |
|
||||
|-----------|-------------|
|
||||
| `user_id` | Sub2API user ID |
|
||||
| `token` | User login token (required to view order history) |
|
||||
| `theme` | `light` (default) or `dark` |
|
||||
| Parameter | Description |
|
||||
| --------- | ------------------------------------------------- |
|
||||
| `user_id` | Sub2API user ID |
|
||||
| `token` | User login token (required to view order history) |
|
||||
| `theme` | `light` (default) or `dark` |
|
||||
| `ui_mode` | `standalone` (default) or `embedded` (for iframe) |
|
||||
|
||||
---
|
||||
@@ -287,13 +288,13 @@ Sub2API **v0.1.88** and above will automatically append the following parameters
|
||||
|
||||
Access: `https://pay.example.com/admin?token=YOUR_ADMIN_TOKEN`
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| Order List | Filter by status, paginate, choose 20/50/100 per page |
|
||||
| Order Detail | View all fields and audit log timeline |
|
||||
| Retry Recharge | Re-trigger recharge for paid-but-failed orders |
|
||||
| Cancel Order | Force-cancel pending orders |
|
||||
| Refund | Issue refund and deduct Sub2API balance |
|
||||
| Feature | Description |
|
||||
| -------------- | ----------------------------------------------------- |
|
||||
| Order List | Filter by status, paginate, choose 20/50/100 per page |
|
||||
| Order Detail | View all fields and audit log timeline |
|
||||
| Retry Recharge | Re-trigger recharge for paid-but-failed orders |
|
||||
| Cancel Order | Force-cancel pending orders |
|
||||
| Refund | Issue refund and deduct Sub2API balance |
|
||||
|
||||
---
|
||||
|
||||
@@ -310,7 +311,7 @@ User submits recharge amount
|
||||
▼
|
||||
User completes payment
|
||||
├─ EasyPay → QR code / H5 redirect
|
||||
└─ Stripe → Checkout Session
|
||||
└─ Stripe → Payment Element (PaymentIntent)
|
||||
│
|
||||
▼
|
||||
Payment callback (signature verified) → Order PAID
|
||||
|
||||
121
README.md
121
README.md
@@ -34,15 +34,15 @@ Sub2ApiPay 是为 [Sub2API](https://sub2api.com) 平台构建的自托管充值
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 |
|
||||
|------|------|
|
||||
| 框架 | Next.js 16 (App Router) |
|
||||
| 语言 | TypeScript 5 + React 19 |
|
||||
| 样式 | TailwindCSS 4 |
|
||||
| ORM | Prisma 7(adapter-pg 模式) |
|
||||
| 数据库 | PostgreSQL 16 |
|
||||
| 容器 | Docker + Docker Compose |
|
||||
| 包管理 | pnpm |
|
||||
| 类别 | 技术 |
|
||||
| ------ | --------------------------- |
|
||||
| 框架 | Next.js 16 (App Router) |
|
||||
| 语言 | TypeScript 5 + React 19 |
|
||||
| 样式 | TailwindCSS 4 |
|
||||
| ORM | Prisma 7(adapter-pg 模式) |
|
||||
| 数据库 | PostgreSQL 16 |
|
||||
| 容器 | Docker + Docker Compose |
|
||||
| 包管理 | pnpm |
|
||||
|
||||
---
|
||||
|
||||
@@ -85,12 +85,12 @@ docker compose up -d --build
|
||||
|
||||
### 核心(必填)
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `SUB2API_BASE_URL` | Sub2API 服务地址,如 `https://sub2api.com` |
|
||||
| `SUB2API_ADMIN_API_KEY` | Sub2API 管理 API 密钥 |
|
||||
| `ADMIN_TOKEN` | 管理后台访问令牌(自定义强密码) |
|
||||
| `NEXT_PUBLIC_APP_URL` | 本服务的公网地址,如 `https://pay.example.com` |
|
||||
| 变量 | 说明 |
|
||||
| ----------------------- | ---------------------------------------------- |
|
||||
| `SUB2API_BASE_URL` | Sub2API 服务地址,如 `https://sub2api.com` |
|
||||
| `SUB2API_ADMIN_API_KEY` | Sub2API 管理 API 密钥 |
|
||||
| `ADMIN_TOKEN` | 管理后台访问令牌(自定义强密码) |
|
||||
| `NEXT_PUBLIC_APP_URL` | 本服务的公网地址,如 `https://pay.example.com` |
|
||||
|
||||
> `DATABASE_URL` 使用自带数据库时由 Compose 自动注入,无需手动填写。
|
||||
|
||||
@@ -127,49 +127,50 @@ ENABLED_PAYMENT_TYPES=alipay,wxpay
|
||||
|
||||
> **注意**:支付渠道的安全性、稳定性及合规性请自行鉴别,本项目不对任何第三方支付服务商做担保或背书。
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `EASY_PAY_PID` | EasyPay 商户 ID |
|
||||
| `EASY_PAY_PKEY` | EasyPay 商户密钥 |
|
||||
| `EASY_PAY_API_BASE` | EasyPay API 地址 |
|
||||
| 变量 | 说明 |
|
||||
| --------------------- | ------------------------------------------------------------- |
|
||||
| `EASY_PAY_PID` | EasyPay 商户 ID |
|
||||
| `EASY_PAY_PKEY` | EasyPay 商户密钥 |
|
||||
| `EASY_PAY_API_BASE` | EasyPay API 地址 |
|
||||
| `EASY_PAY_NOTIFY_URL` | 异步回调地址,填 `${NEXT_PUBLIC_APP_URL}/api/easy-pay/notify` |
|
||||
| `EASY_PAY_RETURN_URL` | 支付完成跳转地址,填 `${NEXT_PUBLIC_APP_URL}/pay` |
|
||||
| `EASY_PAY_CID_ALIPAY` | 支付宝通道 ID(可选) |
|
||||
| `EASY_PAY_CID_WXPAY` | 微信支付通道 ID(可选) |
|
||||
| `EASY_PAY_RETURN_URL` | 支付完成跳转地址,填 `${NEXT_PUBLIC_APP_URL}/pay` |
|
||||
| `EASY_PAY_CID_ALIPAY` | 支付宝通道 ID(可选) |
|
||||
| `EASY_PAY_CID_WXPAY` | 微信支付通道 ID(可选) |
|
||||
|
||||
#### Stripe
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `STRIPE_SECRET_KEY` | Stripe 密钥(`sk_live_...`) |
|
||||
| `STRIPE_PUBLISHABLE_KEY` | Stripe 可公开密钥(`pk_live_...`) |
|
||||
| `STRIPE_WEBHOOK_SECRET` | Stripe Webhook 签名密钥(`whsec_...`) |
|
||||
| 变量 | 说明 |
|
||||
| ------------------------ | -------------------------------------- |
|
||||
| `STRIPE_SECRET_KEY` | Stripe 密钥(`sk_live_...`) |
|
||||
| `STRIPE_PUBLISHABLE_KEY` | Stripe 可公开密钥(`pk_live_...`) |
|
||||
| `STRIPE_WEBHOOK_SECRET` | Stripe Webhook 签名密钥(`whsec_...`) |
|
||||
|
||||
> Stripe Webhook 端点:`${NEXT_PUBLIC_APP_URL}/api/stripe/webhook`
|
||||
> 需订阅事件:`checkout.session.completed`、`checkout.session.expired`
|
||||
> 需订阅事件:`payment_intent.succeeded`、`payment_intent.payment_failed`
|
||||
|
||||
### 业务规则
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `MIN_RECHARGE_AMOUNT` | 单笔最低充值金额(元) | `1` |
|
||||
| `MAX_RECHARGE_AMOUNT` | 单笔最高充值金额(元) | `1000` |
|
||||
| `MAX_DAILY_RECHARGE_AMOUNT` | 每日累计最高充值(元,`0` = 不限) | `10000` |
|
||||
| `ORDER_TIMEOUT_MINUTES` | 订单超时分钟数 | `5` |
|
||||
| `PRODUCT_NAME` | 充值商品名称(显示在支付页) | `Sub2API Balance Recharge` |
|
||||
| 变量 | 说明 | 默认值 |
|
||||
| --------------------------- | ---------------------------------- | -------------------------- |
|
||||
| `MIN_RECHARGE_AMOUNT` | 单笔最低充值金额(元) | `1` |
|
||||
| `MAX_RECHARGE_AMOUNT` | 单笔最高充值金额(元) | `1000` |
|
||||
| `MAX_DAILY_RECHARGE_AMOUNT` | 每日累计最高充值(元,`0` = 不限) | `10000` |
|
||||
| `ORDER_TIMEOUT_MINUTES` | 订单超时分钟数 | `5` |
|
||||
| `PRODUCT_NAME` | 充值商品名称(显示在支付页) | `Sub2API Balance Recharge` |
|
||||
|
||||
### UI 定制(可选)
|
||||
|
||||
在充值页面右侧可展示客服联系方式、说明图片等帮助内容。
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `PAY_HELP_IMAGE_URL` | 帮助图片地址(支持外部 URL 或本地路径,见下方说明) |
|
||||
| `PAY_HELP_TEXT` | 帮助说明文字,用 `\n` 换行,如 `扫码加微信\n工作日 9-18 点在线` |
|
||||
| 变量 | 说明 |
|
||||
| -------------------- | --------------------------------------------------------------- |
|
||||
| `PAY_HELP_IMAGE_URL` | 帮助图片地址(支持外部 URL 或本地路径,见下方说明) |
|
||||
| `PAY_HELP_TEXT` | 帮助说明文字,用 `\n` 换行,如 `扫码加微信\n工作日 9-18 点在线` |
|
||||
|
||||
**图片地址两种方式:**
|
||||
|
||||
- **外部 URL**(推荐,无需改 Compose 配置):直接填图片的公网地址,如 OSS / CDN / 图床链接。
|
||||
|
||||
```env
|
||||
PAY_HELP_IMAGE_URL=https://cdn.example.com/help-qr.jpg
|
||||
```
|
||||
@@ -188,9 +189,9 @@ ENABLED_PAYMENT_TYPES=alipay,wxpay
|
||||
|
||||
### Docker Compose 专用
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `APP_PORT` | 宿主机映射端口 | `3001` |
|
||||
| 变量 | 说明 | 默认值 |
|
||||
| ------------- | ----------------------------------- | ---------------------------- |
|
||||
| `APP_PORT` | 宿主机映射端口 | `3001` |
|
||||
| `DB_PASSWORD` | PostgreSQL 密码(使用自带数据库时) | `password`(**生产请修改**) |
|
||||
|
||||
---
|
||||
@@ -266,19 +267,19 @@ docker compose exec app npx prisma migrate deploy
|
||||
|
||||
在 Sub2API 管理后台可配置以下页面链接:
|
||||
|
||||
| 页面 | 链接 | 说明 |
|
||||
|------|------|------|
|
||||
| 充值页面 | `https://pay.example.com/pay` | 用户充值入口 |
|
||||
| 我的订单 | `https://pay.example.com/pay/orders` | 用户查看自己的充值记录 |
|
||||
| 订单管理 | `https://pay.example.com/admin` | 仅 Sub2API 管理员可访问 |
|
||||
| 页面 | 链接 | 说明 |
|
||||
| -------- | ------------------------------------ | ----------------------- |
|
||||
| 充值页面 | `https://pay.example.com/pay` | 用户充值入口 |
|
||||
| 我的订单 | `https://pay.example.com/pay/orders` | 用户查看自己的充值记录 |
|
||||
| 订单管理 | `https://pay.example.com/admin` | 仅 Sub2API 管理员可访问 |
|
||||
|
||||
Sub2API **v0.1.88** 及以上版本会自动拼接以下参数,无需手动添加:
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `user_id` | Sub2API 用户 ID |
|
||||
| `token` | 用户登录 Token(有 token 才能查看订单历史) |
|
||||
| `theme` | `light`(默认)或 `dark` |
|
||||
| 参数 | 说明 |
|
||||
| --------- | ------------------------------------------------ |
|
||||
| `user_id` | Sub2API 用户 ID |
|
||||
| `token` | 用户登录 Token(有 token 才能查看订单历史) |
|
||||
| `theme` | `light`(默认)或 `dark` |
|
||||
| `ui_mode` | `standalone`(默认)或 `embedded`(iframe 嵌入) |
|
||||
|
||||
---
|
||||
@@ -287,13 +288,13 @@ Sub2API **v0.1.88** 及以上版本会自动拼接以下参数,无需手动添
|
||||
|
||||
访问:`https://pay.example.com/admin?token=YOUR_ADMIN_TOKEN`
|
||||
|
||||
| 功能 | 说明 |
|
||||
|------|------|
|
||||
| 功能 | 说明 |
|
||||
| -------- | ------------------------------------------- |
|
||||
| 订单列表 | 按状态筛选、分页浏览,支持每页 20/50/100 条 |
|
||||
| 订单详情 | 查看完整字段与操作审计日志 |
|
||||
| 重试充值 | 对已支付但充值失败的订单重新发起充值 |
|
||||
| 取消订单 | 强制取消待支付订单 |
|
||||
| 退款 | 对已完成订单发起退款并扣减 Sub2API 余额 |
|
||||
| 订单详情 | 查看完整字段与操作审计日志 |
|
||||
| 重试充值 | 对已支付但充值失败的订单重新发起充值 |
|
||||
| 取消订单 | 强制取消待支付订单 |
|
||||
| 退款 | 对已完成订单发起退款并扣减 Sub2API 余额 |
|
||||
|
||||
---
|
||||
|
||||
@@ -310,7 +311,7 @@ Sub2API **v0.1.88** 及以上版本会自动拼接以下参数,无需手动添
|
||||
▼
|
||||
用户完成支付
|
||||
├─ EasyPay → 扫码 / H5 跳转
|
||||
└─ Stripe → Checkout Session
|
||||
└─ Stripe → Payment Element (PaymentIntent)
|
||||
│
|
||||
▼
|
||||
支付回调(签名验证)→ 订单 PAID
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "sub2apipay",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.30.3",
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
@@ -16,11 +17,13 @@
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "7.4.1",
|
||||
"@prisma/client": "^7.4.2",
|
||||
"@stripe/stripe-js": "^8.9.0",
|
||||
"next": "16.1.6",
|
||||
"pg": "^8.19.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.7.0",
|
||||
"stripe": "^20.4.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
|
||||
317
pnpm-lock.yaml
generated
317
pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
||||
'@prisma/client':
|
||||
specifier: ^7.4.2
|
||||
version: 7.4.2(prisma@7.4.1(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
|
||||
'@stripe/stripe-js':
|
||||
specifier: ^8.9.0
|
||||
version: 8.9.0
|
||||
next:
|
||||
specifier: 16.1.6
|
||||
version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
@@ -29,6 +32,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: 19.2.3
|
||||
version: 19.2.3(react@19.2.3)
|
||||
recharts:
|
||||
specifier: ^3.7.0
|
||||
version: 3.7.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1)
|
||||
stripe:
|
||||
specifier: ^20.4.0
|
||||
version: 20.4.0(@types/node@20.19.35)
|
||||
@@ -727,6 +733,17 @@ packages:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
|
||||
'@reduxjs/toolkit@2.11.2':
|
||||
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
|
||||
peerDependencies:
|
||||
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
|
||||
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-redux:
|
||||
optional: true
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.3':
|
||||
resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==}
|
||||
|
||||
@@ -874,6 +891,13 @@ packages:
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@standard-schema/utils@0.3.0':
|
||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||
|
||||
'@stripe/stripe-js@8.9.0':
|
||||
resolution: {integrity: sha512-OJkXvUI5GAc56QdiSRimQDvWYEqn475J+oj8RzRtFTCPtkJNO2TWW619oDY+nn1ExR+2tCVTQuRQBbR4dRugww==}
|
||||
engines: {node: '>=12.16'}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
|
||||
@@ -987,6 +1011,33 @@ packages:
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/d3-array@3.2.2':
|
||||
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
|
||||
|
||||
'@types/d3-color@3.1.3':
|
||||
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
|
||||
|
||||
'@types/d3-ease@3.0.2':
|
||||
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
|
||||
|
||||
'@types/d3-path@3.1.1':
|
||||
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
|
||||
|
||||
'@types/d3-scale@4.0.9':
|
||||
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
|
||||
|
||||
'@types/d3-shape@3.1.8':
|
||||
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
|
||||
|
||||
'@types/d3-time@3.0.4':
|
||||
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
|
||||
|
||||
'@types/d3-timer@3.0.2':
|
||||
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
|
||||
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
@@ -1016,6 +1067,9 @@ packages:
|
||||
'@types/react@19.2.14':
|
||||
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
||||
|
||||
'@types/use-sync-external-store@0.0.6':
|
||||
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.56.1':
|
||||
resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
@@ -1386,6 +1440,10 @@ packages:
|
||||
cliui@6.0.0:
|
||||
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@@ -1413,6 +1471,50 @@ packages:
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
d3-array@3.2.4:
|
||||
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-color@3.1.0:
|
||||
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-ease@3.0.1:
|
||||
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-format@3.1.2:
|
||||
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-path@3.1.0:
|
||||
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-scale@4.0.2:
|
||||
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-shape@3.2.0:
|
||||
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-time-format@4.1.0:
|
||||
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-time@3.1.0:
|
||||
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
d3-timer@3.0.1:
|
||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
damerau-levenshtein@1.0.8:
|
||||
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
|
||||
|
||||
@@ -1449,6 +1551,9 @@ packages:
|
||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
decimal.js-light@2.5.1:
|
||||
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||
|
||||
deep-is@0.1.4:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
|
||||
@@ -1548,6 +1653,9 @@ packages:
|
||||
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-toolkit@1.45.0:
|
||||
resolution: {integrity: sha512-RArCX+Zea16+R1jg4mH223Z8p/ivbJjIkU3oC6ld2bdUfmDxiCkFYSi9zLOR2anucWJUeH4Djnzgd0im0nD3dw==}
|
||||
|
||||
esbuild@0.27.3:
|
||||
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -1684,6 +1792,9 @@ packages:
|
||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
eventemitter3@5.0.4:
|
||||
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
|
||||
|
||||
expect-type@1.3.0:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -1888,6 +1999,12 @@ packages:
|
||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
immer@10.2.0:
|
||||
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
|
||||
|
||||
immer@11.1.4:
|
||||
resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1900,6 +2017,10 @@ packages:
|
||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
internmap@2.0.3:
|
||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
is-array-buffer@3.0.5:
|
||||
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2492,6 +2613,18 @@ packages:
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
react-redux@9.2.0:
|
||||
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
|
||||
peerDependencies:
|
||||
'@types/react': ^18.2.25 || ^19
|
||||
react: ^18.0 || ^19
|
||||
redux: ^5.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
redux:
|
||||
optional: true
|
||||
|
||||
react-refresh@0.18.0:
|
||||
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2504,6 +2637,22 @@ packages:
|
||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||
engines: {node: '>= 14.18.0'}
|
||||
|
||||
recharts@3.7.0:
|
||||
resolution: {integrity: sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
redux-thunk@3.1.0:
|
||||
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
|
||||
peerDependencies:
|
||||
redux: ^5.0.0
|
||||
|
||||
redux@5.0.1:
|
||||
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
|
||||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2525,6 +2674,9 @@ packages:
|
||||
require-main-filename@2.0.0:
|
||||
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||
|
||||
reselect@5.1.1:
|
||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||
|
||||
resolve-from@4.0.0:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -2742,6 +2894,9 @@ packages:
|
||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tiny-invariant@1.3.3:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
@@ -2824,6 +2979,11 @@ packages:
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
use-sync-external-store@1.6.0:
|
||||
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
valibot@1.2.0:
|
||||
resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==}
|
||||
peerDependencies:
|
||||
@@ -2832,6 +2992,9 @@ packages:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
|
||||
|
||||
vite@7.3.1:
|
||||
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -3532,6 +3695,18 @@ snapshots:
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
|
||||
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@standard-schema/utils': 0.3.0
|
||||
immer: 11.1.4
|
||||
redux: 5.0.1
|
||||
redux-thunk: 3.1.0(redux@5.0.1)
|
||||
reselect: 5.1.1
|
||||
optionalDependencies:
|
||||
react: 19.2.3
|
||||
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1)
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.3': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.59.0':
|
||||
@@ -3613,6 +3788,10 @@ snapshots:
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@standard-schema/utils@0.3.0': {}
|
||||
|
||||
'@stripe/stripe-js@8.9.0': {}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -3717,6 +3896,30 @@ snapshots:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/d3-array@3.2.2': {}
|
||||
|
||||
'@types/d3-color@3.1.3': {}
|
||||
|
||||
'@types/d3-ease@3.0.2': {}
|
||||
|
||||
'@types/d3-interpolate@3.0.4':
|
||||
dependencies:
|
||||
'@types/d3-color': 3.1.3
|
||||
|
||||
'@types/d3-path@3.1.1': {}
|
||||
|
||||
'@types/d3-scale@4.0.9':
|
||||
dependencies:
|
||||
'@types/d3-time': 3.0.4
|
||||
|
||||
'@types/d3-shape@3.1.8':
|
||||
dependencies:
|
||||
'@types/d3-path': 3.1.1
|
||||
|
||||
'@types/d3-time@3.0.4': {}
|
||||
|
||||
'@types/d3-timer@3.0.2': {}
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
@@ -3747,6 +3950,8 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/use-sync-external-store@0.0.6': {}
|
||||
|
||||
'@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)':
|
||||
dependencies:
|
||||
'@eslint-community/regexpp': 4.12.2
|
||||
@@ -4153,6 +4358,8 @@ snapshots:
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 6.2.0
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@@ -4175,6 +4382,44 @@ snapshots:
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
d3-array@3.2.4:
|
||||
dependencies:
|
||||
internmap: 2.0.3
|
||||
|
||||
d3-color@3.1.0: {}
|
||||
|
||||
d3-ease@3.0.1: {}
|
||||
|
||||
d3-format@3.1.2: {}
|
||||
|
||||
d3-interpolate@3.0.1:
|
||||
dependencies:
|
||||
d3-color: 3.1.0
|
||||
|
||||
d3-path@3.1.0: {}
|
||||
|
||||
d3-scale@4.0.2:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
d3-format: 3.1.2
|
||||
d3-interpolate: 3.0.1
|
||||
d3-time: 3.1.0
|
||||
d3-time-format: 4.1.0
|
||||
|
||||
d3-shape@3.2.0:
|
||||
dependencies:
|
||||
d3-path: 3.1.0
|
||||
|
||||
d3-time-format@4.1.0:
|
||||
dependencies:
|
||||
d3-time: 3.1.0
|
||||
|
||||
d3-time@3.1.0:
|
||||
dependencies:
|
||||
d3-array: 3.2.4
|
||||
|
||||
d3-timer@3.0.1: {}
|
||||
|
||||
damerau-levenshtein@1.0.8: {}
|
||||
|
||||
data-view-buffer@1.0.2:
|
||||
@@ -4205,6 +4450,8 @@ snapshots:
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
decimal.js-light@2.5.1: {}
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
deepmerge-ts@7.1.5: {}
|
||||
@@ -4364,6 +4611,8 @@ snapshots:
|
||||
is-date-object: 1.1.0
|
||||
is-symbol: 1.1.1
|
||||
|
||||
es-toolkit@1.45.0: {}
|
||||
|
||||
esbuild@0.27.3:
|
||||
optionalDependencies:
|
||||
'@esbuild/aix-ppc64': 0.27.3
|
||||
@@ -4606,6 +4855,8 @@ snapshots:
|
||||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
eventemitter3@5.0.4: {}
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
exsolve@1.0.8: {}
|
||||
@@ -4800,6 +5051,10 @@ snapshots:
|
||||
|
||||
ignore@7.0.5: {}
|
||||
|
||||
immer@10.2.0: {}
|
||||
|
||||
immer@11.1.4: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
dependencies:
|
||||
parent-module: 1.0.1
|
||||
@@ -4813,6 +5068,8 @@ snapshots:
|
||||
hasown: 2.0.2
|
||||
side-channel: 1.1.0
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
is-array-buffer@3.0.5:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
@@ -5375,12 +5632,47 @@ snapshots:
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@types/use-sync-external-store': 0.0.6
|
||||
react: 19.2.3
|
||||
use-sync-external-store: 1.6.0(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
redux: 5.0.1
|
||||
|
||||
react-refresh@0.18.0: {}
|
||||
|
||||
react@19.2.3: {}
|
||||
|
||||
readdirp@4.1.2: {}
|
||||
|
||||
recharts@3.7.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1):
|
||||
dependencies:
|
||||
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1))(react@19.2.3)
|
||||
clsx: 2.1.1
|
||||
decimal.js-light: 2.5.1
|
||||
es-toolkit: 1.45.0
|
||||
eventemitter3: 5.0.4
|
||||
immer: 10.2.0
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
react-is: 16.13.1
|
||||
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1)
|
||||
reselect: 5.1.1
|
||||
tiny-invariant: 1.3.3
|
||||
use-sync-external-store: 1.6.0(react@19.2.3)
|
||||
victory-vendor: 37.3.6
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- redux
|
||||
|
||||
redux-thunk@3.1.0(redux@5.0.1):
|
||||
dependencies:
|
||||
redux: 5.0.1
|
||||
|
||||
redux@5.0.1: {}
|
||||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
@@ -5409,6 +5701,8 @@ snapshots:
|
||||
|
||||
require-main-filename@2.0.0: {}
|
||||
|
||||
reselect@5.1.1: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
@@ -5694,6 +5988,8 @@ snapshots:
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@1.0.2: {}
|
||||
@@ -5815,10 +6111,31 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
use-sync-external-store@1.6.0(react@19.2.3):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
|
||||
valibot@1.2.0(typescript@5.9.3):
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
victory-vendor@37.3.6:
|
||||
dependencies:
|
||||
'@types/d3-array': 3.2.2
|
||||
'@types/d3-ease': 3.0.2
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-scale': 4.0.9
|
||||
'@types/d3-shape': 3.1.8
|
||||
'@types/d3-time': 3.0.4
|
||||
'@types/d3-timer': 3.0.2
|
||||
d3-array: 3.2.4
|
||||
d3-ease: 3.0.1
|
||||
d3-interpolate: 3.0.1
|
||||
d3-scale: 4.0.2
|
||||
d3-shape: 3.2.0
|
||||
d3-time: 3.1.0
|
||||
d3-timer: 3.0.1
|
||||
|
||||
vite@7.3.1(@types/node@20.19.35)(jiti@2.6.1)(lightningcss@1.31.1):
|
||||
dependencies:
|
||||
esbuild: 0.27.3
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- CreateIndex
|
||||
CREATE INDEX "orders_paid_at_idx" ON "orders"("paid_at");
|
||||
@@ -46,6 +46,7 @@ model Order {
|
||||
@@index([status])
|
||||
@@index([expiresAt])
|
||||
@@index([createdAt])
|
||||
@@index([paidAt])
|
||||
@@map("orders")
|
||||
}
|
||||
|
||||
|
||||
274
src/__tests__/lib/alipay/provider.test.ts
Normal file
274
src/__tests__/lib/alipay/provider.test.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getEnv: () => ({
|
||||
ALIPAY_APP_ID: '2021000000000000',
|
||||
ALIPAY_PRIVATE_KEY: 'test-private-key',
|
||||
ALIPAY_PUBLIC_KEY: 'test-public-key',
|
||||
ALIPAY_NOTIFY_URL: 'https://pay.example.com/api/alipay/notify',
|
||||
ALIPAY_RETURN_URL: 'https://pay.example.com/pay/result',
|
||||
NEXT_PUBLIC_APP_URL: 'https://pay.example.com',
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockPageExecute = vi.fn();
|
||||
const mockExecute = vi.fn();
|
||||
|
||||
vi.mock('@/lib/alipay/client', () => ({
|
||||
pageExecute: (...args: unknown[]) => mockPageExecute(...args),
|
||||
execute: (...args: unknown[]) => mockExecute(...args),
|
||||
}));
|
||||
|
||||
const mockVerifySign = vi.fn();
|
||||
|
||||
vi.mock('@/lib/alipay/sign', () => ({
|
||||
verifySign: (...args: unknown[]) => mockVerifySign(...args),
|
||||
}));
|
||||
|
||||
import { AlipayProvider } from '@/lib/alipay/provider';
|
||||
import type { CreatePaymentRequest, RefundRequest } from '@/lib/payment/types';
|
||||
|
||||
describe('AlipayProvider', () => {
|
||||
let provider: AlipayProvider;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
provider = new AlipayProvider();
|
||||
});
|
||||
|
||||
describe('metadata', () => {
|
||||
it('should have name "alipay"', () => {
|
||||
expect(provider.name).toBe('alipay');
|
||||
});
|
||||
|
||||
it('should have providerKey "alipay"', () => {
|
||||
expect(provider.providerKey).toBe('alipay');
|
||||
});
|
||||
|
||||
it('should support "alipay" payment type', () => {
|
||||
expect(provider.supportedTypes).toEqual(['alipay']);
|
||||
});
|
||||
|
||||
it('should have default limits', () => {
|
||||
expect(provider.defaultLimits).toEqual({
|
||||
alipay: { singleMax: 1000, dailyMax: 10000 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPayment', () => {
|
||||
it('should call pageExecute and return payUrl', async () => {
|
||||
mockPageExecute.mockReturnValue('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
|
||||
|
||||
const request: CreatePaymentRequest = {
|
||||
orderId: 'order-001',
|
||||
amount: 100,
|
||||
paymentType: 'alipay',
|
||||
subject: 'Sub2API Balance Recharge 100.00 CNY',
|
||||
clientIp: '127.0.0.1',
|
||||
};
|
||||
|
||||
const result = await provider.createPayment(request);
|
||||
|
||||
expect(result.tradeNo).toBe('order-001');
|
||||
expect(result.payUrl).toBe('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
|
||||
expect(mockPageExecute).toHaveBeenCalledWith(
|
||||
{
|
||||
out_trade_no: 'order-001',
|
||||
product_code: 'FAST_INSTANT_TRADE_PAY',
|
||||
total_amount: '100.00',
|
||||
subject: 'Sub2API Balance Recharge 100.00 CNY',
|
||||
},
|
||||
expect.objectContaining({}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryOrder', () => {
|
||||
it('should return paid status for TRADE_SUCCESS', async () => {
|
||||
mockExecute.mockResolvedValue({
|
||||
code: '10000',
|
||||
msg: 'Success',
|
||||
trade_no: '2026030500001',
|
||||
trade_status: 'TRADE_SUCCESS',
|
||||
total_amount: '100.00',
|
||||
send_pay_date: '2026-03-05 12:00:00',
|
||||
});
|
||||
|
||||
const result = await provider.queryOrder('order-001');
|
||||
expect(result.tradeNo).toBe('2026030500001');
|
||||
expect(result.status).toBe('paid');
|
||||
expect(result.amount).toBe(100);
|
||||
expect(result.paidAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('should return paid status for TRADE_FINISHED', async () => {
|
||||
mockExecute.mockResolvedValue({
|
||||
code: '10000',
|
||||
msg: 'Success',
|
||||
trade_no: '2026030500002',
|
||||
trade_status: 'TRADE_FINISHED',
|
||||
total_amount: '50.00',
|
||||
});
|
||||
|
||||
const result = await provider.queryOrder('order-002');
|
||||
expect(result.status).toBe('paid');
|
||||
});
|
||||
|
||||
it('should return pending status for WAIT_BUYER_PAY', async () => {
|
||||
mockExecute.mockResolvedValue({
|
||||
code: '10000',
|
||||
msg: 'Success',
|
||||
trade_no: '2026030500003',
|
||||
trade_status: 'WAIT_BUYER_PAY',
|
||||
total_amount: '30.00',
|
||||
});
|
||||
|
||||
const result = await provider.queryOrder('order-003');
|
||||
expect(result.status).toBe('pending');
|
||||
});
|
||||
|
||||
it('should return failed status for TRADE_CLOSED', async () => {
|
||||
mockExecute.mockResolvedValue({
|
||||
code: '10000',
|
||||
msg: 'Success',
|
||||
trade_no: '2026030500004',
|
||||
trade_status: 'TRADE_CLOSED',
|
||||
total_amount: '20.00',
|
||||
});
|
||||
|
||||
const result = await provider.queryOrder('order-004');
|
||||
expect(result.status).toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyNotification', () => {
|
||||
it('should verify and parse successful payment notification', async () => {
|
||||
mockVerifySign.mockReturnValue(true);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
trade_no: '2026030500001',
|
||||
out_trade_no: 'order-001',
|
||||
trade_status: 'TRADE_SUCCESS',
|
||||
total_amount: '100.00',
|
||||
sign: 'test_sign',
|
||||
sign_type: 'RSA2',
|
||||
app_id: '2021000000000000',
|
||||
}).toString();
|
||||
|
||||
const result = await provider.verifyNotification(body, {});
|
||||
|
||||
expect(result.tradeNo).toBe('2026030500001');
|
||||
expect(result.orderId).toBe('order-001');
|
||||
expect(result.amount).toBe(100);
|
||||
expect(result.status).toBe('success');
|
||||
});
|
||||
|
||||
it('should parse TRADE_FINISHED as success', async () => {
|
||||
mockVerifySign.mockReturnValue(true);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
trade_no: '2026030500002',
|
||||
out_trade_no: 'order-002',
|
||||
trade_status: 'TRADE_FINISHED',
|
||||
total_amount: '50.00',
|
||||
sign: 'test_sign',
|
||||
sign_type: 'RSA2',
|
||||
}).toString();
|
||||
|
||||
const result = await provider.verifyNotification(body, {});
|
||||
expect(result.status).toBe('success');
|
||||
});
|
||||
|
||||
it('should parse TRADE_CLOSED as failed', async () => {
|
||||
mockVerifySign.mockReturnValue(true);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
trade_no: '2026030500003',
|
||||
out_trade_no: 'order-003',
|
||||
trade_status: 'TRADE_CLOSED',
|
||||
total_amount: '30.00',
|
||||
sign: 'test_sign',
|
||||
sign_type: 'RSA2',
|
||||
}).toString();
|
||||
|
||||
const result = await provider.verifyNotification(body, {});
|
||||
expect(result.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('should throw on invalid signature', async () => {
|
||||
mockVerifySign.mockReturnValue(false);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
trade_no: '2026030500004',
|
||||
out_trade_no: 'order-004',
|
||||
trade_status: 'TRADE_SUCCESS',
|
||||
total_amount: '20.00',
|
||||
sign: 'bad_sign',
|
||||
sign_type: 'RSA2',
|
||||
}).toString();
|
||||
|
||||
await expect(provider.verifyNotification(body, {})).rejects.toThrow(
|
||||
'Alipay notification signature verification failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refund', () => {
|
||||
it('should call alipay.trade.refund and return success', async () => {
|
||||
mockExecute.mockResolvedValue({
|
||||
code: '10000',
|
||||
msg: 'Success',
|
||||
trade_no: '2026030500001',
|
||||
fund_change: 'Y',
|
||||
});
|
||||
|
||||
const request: RefundRequest = {
|
||||
tradeNo: '2026030500001',
|
||||
orderId: 'order-001',
|
||||
amount: 100,
|
||||
reason: 'customer request',
|
||||
};
|
||||
|
||||
const result = await provider.refund(request);
|
||||
expect(result.refundId).toBe('2026030500001');
|
||||
expect(result.status).toBe('success');
|
||||
expect(mockExecute).toHaveBeenCalledWith('alipay.trade.refund', {
|
||||
out_trade_no: 'order-001',
|
||||
refund_amount: '100.00',
|
||||
refund_reason: 'customer request',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return pending when fund_change is N', async () => {
|
||||
mockExecute.mockResolvedValue({
|
||||
code: '10000',
|
||||
msg: 'Success',
|
||||
trade_no: '2026030500002',
|
||||
fund_change: 'N',
|
||||
});
|
||||
|
||||
const result = await provider.refund({
|
||||
tradeNo: '2026030500002',
|
||||
orderId: 'order-002',
|
||||
amount: 50,
|
||||
});
|
||||
expect(result.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelPayment', () => {
|
||||
it('should call alipay.trade.close', async () => {
|
||||
mockExecute.mockResolvedValue({
|
||||
code: '10000',
|
||||
msg: 'Success',
|
||||
trade_no: '2026030500001',
|
||||
});
|
||||
|
||||
await provider.cancelPayment('order-001');
|
||||
expect(mockExecute).toHaveBeenCalledWith('alipay.trade.close', {
|
||||
out_trade_no: 'order-001',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
112
src/__tests__/lib/alipay/sign.test.ts
Normal file
112
src/__tests__/lib/alipay/sign.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import crypto from 'crypto';
|
||||
import { generateSign, verifySign } from '@/lib/alipay/sign';
|
||||
|
||||
// 生成测试用 RSA 密钥对
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
});
|
||||
|
||||
// 提取裸 base64(去掉 PEM 头尾)
|
||||
const barePrivateKey = privateKey
|
||||
.replace(/-----BEGIN PRIVATE KEY-----/, '')
|
||||
.replace(/-----END PRIVATE KEY-----/, '')
|
||||
.replace(/\n/g, '');
|
||||
const barePublicKey = publicKey
|
||||
.replace(/-----BEGIN PUBLIC KEY-----/, '')
|
||||
.replace(/-----END PUBLIC KEY-----/, '')
|
||||
.replace(/\n/g, '');
|
||||
|
||||
describe('Alipay RSA2 Sign', () => {
|
||||
const testParams: Record<string, string> = {
|
||||
app_id: '2021000000000000',
|
||||
method: 'alipay.trade.page.pay',
|
||||
charset: 'utf-8',
|
||||
timestamp: '2026-03-05 12:00:00',
|
||||
version: '1.0',
|
||||
biz_content: '{"out_trade_no":"order-001","total_amount":"100.00"}',
|
||||
};
|
||||
|
||||
describe('generateSign', () => {
|
||||
it('should generate a valid RSA2 signature', () => {
|
||||
const sign = generateSign(testParams, privateKey);
|
||||
expect(sign).toBeTruthy();
|
||||
expect(typeof sign).toBe('string');
|
||||
// base64 格式
|
||||
expect(() => Buffer.from(sign, 'base64')).not.toThrow();
|
||||
});
|
||||
|
||||
it('should produce consistent signatures for same input', () => {
|
||||
const sign1 = generateSign(testParams, privateKey);
|
||||
const sign2 = generateSign(testParams, privateKey);
|
||||
expect(sign1).toBe(sign2);
|
||||
});
|
||||
|
||||
it('should filter out sign and sign_type fields', () => {
|
||||
const paramsWithSign = { ...testParams, sign: 'old_sign', sign_type: 'RSA2' };
|
||||
const sign1 = generateSign(testParams, privateKey);
|
||||
const sign2 = generateSign(paramsWithSign, privateKey);
|
||||
expect(sign1).toBe(sign2);
|
||||
});
|
||||
|
||||
it('should filter out empty values', () => {
|
||||
const paramsWithEmpty = { ...testParams, empty_field: '' };
|
||||
const sign1 = generateSign(testParams, privateKey);
|
||||
const sign2 = generateSign(paramsWithEmpty, privateKey);
|
||||
expect(sign1).toBe(sign2);
|
||||
});
|
||||
|
||||
it('should sort parameters alphabetically', () => {
|
||||
const reversed: Record<string, string> = {};
|
||||
const keys = Object.keys(testParams).reverse();
|
||||
for (const key of keys) {
|
||||
reversed[key] = testParams[key];
|
||||
}
|
||||
const sign1 = generateSign(testParams, privateKey);
|
||||
const sign2 = generateSign(reversed, privateKey);
|
||||
expect(sign1).toBe(sign2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifySign', () => {
|
||||
it('should verify a valid signature', () => {
|
||||
const sign = generateSign(testParams, privateKey);
|
||||
const valid = verifySign(testParams, publicKey, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject an invalid signature', () => {
|
||||
const valid = verifySign(testParams, publicKey, 'invalid_base64_signature');
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject tampered params', () => {
|
||||
const sign = generateSign(testParams, privateKey);
|
||||
const tampered = { ...testParams, total_amount: '999.99' };
|
||||
const valid = verifySign(tampered, publicKey, sign);
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PEM auto-formatting', () => {
|
||||
it('should work with bare base64 private key (no PEM headers)', () => {
|
||||
const sign = generateSign(testParams, barePrivateKey);
|
||||
const valid = verifySign(testParams, publicKey, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with bare base64 public key (no PEM headers)', () => {
|
||||
const sign = generateSign(testParams, privateKey);
|
||||
const valid = verifySign(testParams, barePublicKey, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with both bare keys', () => {
|
||||
const sign = generateSign(testParams, barePrivateKey);
|
||||
const valid = verifySign(testParams, barePublicKey, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,18 +9,18 @@ vi.mock('@/lib/config', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockSessionCreate = vi.fn();
|
||||
const mockSessionRetrieve = vi.fn();
|
||||
const mockPaymentIntentCreate = vi.fn();
|
||||
const mockPaymentIntentRetrieve = vi.fn();
|
||||
const mockPaymentIntentCancel = vi.fn();
|
||||
const mockRefundCreate = vi.fn();
|
||||
const mockWebhooksConstructEvent = vi.fn();
|
||||
|
||||
vi.mock('stripe', () => {
|
||||
const StripeMock = function (this: Record<string, unknown>) {
|
||||
this.checkout = {
|
||||
sessions: {
|
||||
create: mockSessionCreate,
|
||||
retrieve: mockSessionRetrieve,
|
||||
},
|
||||
this.paymentIntents = {
|
||||
create: mockPaymentIntentCreate,
|
||||
retrieve: mockPaymentIntentRetrieve,
|
||||
cancel: mockPaymentIntentCancel,
|
||||
};
|
||||
this.refunds = {
|
||||
create: mockRefundCreate,
|
||||
@@ -54,10 +54,10 @@ describe('StripeProvider', () => {
|
||||
});
|
||||
|
||||
describe('createPayment', () => {
|
||||
it('should create a checkout session and return checkoutUrl', async () => {
|
||||
mockSessionCreate.mockResolvedValue({
|
||||
id: 'cs_test_abc123',
|
||||
url: 'https://checkout.stripe.com/pay/cs_test_abc123',
|
||||
it('should create a PaymentIntent and return clientSecret', async () => {
|
||||
mockPaymentIntentCreate.mockResolvedValue({
|
||||
id: 'pi_test_abc123',
|
||||
client_secret: 'pi_test_abc123_secret_xyz',
|
||||
});
|
||||
|
||||
const request: CreatePaymentRequest = {
|
||||
@@ -70,34 +70,26 @@ describe('StripeProvider', () => {
|
||||
|
||||
const result = await provider.createPayment(request);
|
||||
|
||||
expect(result.tradeNo).toBe('cs_test_abc123');
|
||||
expect(result.checkoutUrl).toBe('https://checkout.stripe.com/pay/cs_test_abc123');
|
||||
expect(mockSessionCreate).toHaveBeenCalledWith(
|
||||
expect(result.tradeNo).toBe('pi_test_abc123');
|
||||
expect(result.clientSecret).toBe('pi_test_abc123_secret_xyz');
|
||||
expect(mockPaymentIntentCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
mode: 'payment',
|
||||
payment_method_types: ['card'],
|
||||
amount: 9999,
|
||||
currency: 'cny',
|
||||
automatic_payment_methods: { enabled: true },
|
||||
metadata: { orderId: 'order-001' },
|
||||
expires_at: expect.any(Number),
|
||||
line_items: [
|
||||
expect.objectContaining({
|
||||
price_data: expect.objectContaining({
|
||||
currency: 'cny',
|
||||
unit_amount: 9999,
|
||||
}),
|
||||
quantity: 1,
|
||||
}),
|
||||
],
|
||||
description: 'Sub2API Balance Recharge 99.99 CNY',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
idempotencyKey: 'checkout-order-001',
|
||||
idempotencyKey: 'pi-order-001',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle session with null url', async () => {
|
||||
mockSessionCreate.mockResolvedValue({
|
||||
id: 'cs_test_no_url',
|
||||
url: null,
|
||||
it('should handle null client_secret', async () => {
|
||||
mockPaymentIntentCreate.mockResolvedValue({
|
||||
id: 'pi_test_no_secret',
|
||||
client_secret: null,
|
||||
});
|
||||
|
||||
const request: CreatePaymentRequest = {
|
||||
@@ -108,61 +100,58 @@ describe('StripeProvider', () => {
|
||||
};
|
||||
|
||||
const result = await provider.createPayment(request);
|
||||
expect(result.tradeNo).toBe('cs_test_no_url');
|
||||
expect(result.checkoutUrl).toBeUndefined();
|
||||
expect(result.tradeNo).toBe('pi_test_no_secret');
|
||||
expect(result.clientSecret).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryOrder', () => {
|
||||
it('should return paid status for paid session', async () => {
|
||||
mockSessionRetrieve.mockResolvedValue({
|
||||
id: 'cs_test_abc123',
|
||||
payment_status: 'paid',
|
||||
amount_total: 9999,
|
||||
it('should return paid status for succeeded PaymentIntent', async () => {
|
||||
mockPaymentIntentRetrieve.mockResolvedValue({
|
||||
id: 'pi_test_abc123',
|
||||
status: 'succeeded',
|
||||
amount: 9999,
|
||||
});
|
||||
|
||||
const result = await provider.queryOrder('cs_test_abc123');
|
||||
expect(result.tradeNo).toBe('cs_test_abc123');
|
||||
const result = await provider.queryOrder('pi_test_abc123');
|
||||
expect(result.tradeNo).toBe('pi_test_abc123');
|
||||
expect(result.status).toBe('paid');
|
||||
expect(result.amount).toBe(99.99);
|
||||
});
|
||||
|
||||
it('should return failed status for expired session', async () => {
|
||||
mockSessionRetrieve.mockResolvedValue({
|
||||
id: 'cs_test_expired',
|
||||
payment_status: 'unpaid',
|
||||
status: 'expired',
|
||||
amount_total: 5000,
|
||||
it('should return failed status for canceled PaymentIntent', async () => {
|
||||
mockPaymentIntentRetrieve.mockResolvedValue({
|
||||
id: 'pi_test_canceled',
|
||||
status: 'canceled',
|
||||
amount: 5000,
|
||||
});
|
||||
|
||||
const result = await provider.queryOrder('cs_test_expired');
|
||||
const result = await provider.queryOrder('pi_test_canceled');
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.amount).toBe(50);
|
||||
});
|
||||
|
||||
it('should return pending status for unpaid session', async () => {
|
||||
mockSessionRetrieve.mockResolvedValue({
|
||||
id: 'cs_test_pending',
|
||||
payment_status: 'unpaid',
|
||||
status: 'open',
|
||||
amount_total: 1000,
|
||||
it('should return pending status for requires_payment_method', async () => {
|
||||
mockPaymentIntentRetrieve.mockResolvedValue({
|
||||
id: 'pi_test_pending',
|
||||
status: 'requires_payment_method',
|
||||
amount: 1000,
|
||||
});
|
||||
|
||||
const result = await provider.queryOrder('cs_test_pending');
|
||||
const result = await provider.queryOrder('pi_test_pending');
|
||||
expect(result.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyNotification', () => {
|
||||
it('should verify and parse checkout.session.completed event', async () => {
|
||||
it('should verify and parse payment_intent.succeeded event', async () => {
|
||||
const mockEvent = {
|
||||
type: 'checkout.session.completed',
|
||||
type: 'payment_intent.succeeded',
|
||||
data: {
|
||||
object: {
|
||||
id: 'cs_test_abc123',
|
||||
id: 'pi_test_abc123',
|
||||
metadata: { orderId: 'order-001' },
|
||||
amount_total: 9999,
|
||||
payment_status: 'paid',
|
||||
amount: 9999,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -172,21 +161,20 @@ describe('StripeProvider', () => {
|
||||
const result = await provider.verifyNotification('{"raw":"body"}', { 'stripe-signature': 'sig_test_123' });
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.tradeNo).toBe('cs_test_abc123');
|
||||
expect(result!.tradeNo).toBe('pi_test_abc123');
|
||||
expect(result!.orderId).toBe('order-001');
|
||||
expect(result!.amount).toBe(99.99);
|
||||
expect(result!.status).toBe('success');
|
||||
});
|
||||
|
||||
it('should return failed status for unpaid session', async () => {
|
||||
it('should return failed status for payment_intent.payment_failed', async () => {
|
||||
const mockEvent = {
|
||||
type: 'checkout.session.completed',
|
||||
type: 'payment_intent.payment_failed',
|
||||
data: {
|
||||
object: {
|
||||
id: 'cs_test_unpaid',
|
||||
id: 'pi_test_failed',
|
||||
metadata: { orderId: 'order-002' },
|
||||
amount_total: 5000,
|
||||
payment_status: 'unpaid',
|
||||
amount: 5000,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -210,19 +198,14 @@ describe('StripeProvider', () => {
|
||||
});
|
||||
|
||||
describe('refund', () => {
|
||||
it('should refund via payment intent from session', async () => {
|
||||
mockSessionRetrieve.mockResolvedValue({
|
||||
id: 'cs_test_abc123',
|
||||
payment_intent: 'pi_test_payment_intent',
|
||||
});
|
||||
|
||||
it('should refund directly using PaymentIntent ID', async () => {
|
||||
mockRefundCreate.mockResolvedValue({
|
||||
id: 're_test_refund_001',
|
||||
status: 'succeeded',
|
||||
});
|
||||
|
||||
const request: RefundRequest = {
|
||||
tradeNo: 'cs_test_abc123',
|
||||
tradeNo: 'pi_test_abc123',
|
||||
orderId: 'order-001',
|
||||
amount: 50,
|
||||
reason: 'customer request',
|
||||
@@ -232,50 +215,34 @@ describe('StripeProvider', () => {
|
||||
expect(result.refundId).toBe('re_test_refund_001');
|
||||
expect(result.status).toBe('success');
|
||||
expect(mockRefundCreate).toHaveBeenCalledWith({
|
||||
payment_intent: 'pi_test_payment_intent',
|
||||
payment_intent: 'pi_test_abc123',
|
||||
amount: 5000,
|
||||
reason: 'requested_by_customer',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle payment intent as object', async () => {
|
||||
mockSessionRetrieve.mockResolvedValue({
|
||||
id: 'cs_test_abc123',
|
||||
payment_intent: { id: 'pi_test_obj_intent', amount: 10000 },
|
||||
});
|
||||
|
||||
it('should handle pending refund status', async () => {
|
||||
mockRefundCreate.mockResolvedValue({
|
||||
id: 're_test_refund_002',
|
||||
status: 'pending',
|
||||
});
|
||||
|
||||
const result = await provider.refund({
|
||||
tradeNo: 'cs_test_abc123',
|
||||
tradeNo: 'pi_test_abc123',
|
||||
orderId: 'order-002',
|
||||
amount: 100,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('pending');
|
||||
expect(mockRefundCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payment_intent: 'pi_test_obj_intent',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw if no payment intent found', async () => {
|
||||
mockSessionRetrieve.mockResolvedValue({
|
||||
id: 'cs_test_no_pi',
|
||||
payment_intent: null,
|
||||
});
|
||||
describe('cancelPayment', () => {
|
||||
it('should cancel a PaymentIntent', async () => {
|
||||
mockPaymentIntentCancel.mockResolvedValue({ id: 'pi_test_abc123', status: 'canceled' });
|
||||
|
||||
await expect(
|
||||
provider.refund({
|
||||
tradeNo: 'cs_test_no_pi',
|
||||
orderId: 'order-003',
|
||||
amount: 20,
|
||||
}),
|
||||
).rejects.toThrow('No payment intent found');
|
||||
await provider.cancelPayment('pi_test_abc123');
|
||||
expect(mockPaymentIntentCancel).toHaveBeenCalledWith('pi_test_abc123');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
160
src/app/admin/dashboard/page.tsx
Normal file
160
src/app/admin/dashboard/page.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useState, useEffect, useCallback, Suspense } from 'react';
|
||||
import PayPageLayout from '@/components/PayPageLayout';
|
||||
import DashboardStats from '@/components/admin/DashboardStats';
|
||||
import DailyChart from '@/components/admin/DailyChart';
|
||||
import Leaderboard from '@/components/admin/Leaderboard';
|
||||
import PaymentMethodChart from '@/components/admin/PaymentMethodChart';
|
||||
|
||||
interface DashboardData {
|
||||
summary: {
|
||||
today: { amount: number; orderCount: number; paidCount: number };
|
||||
total: { amount: number; orderCount: number; paidCount: number };
|
||||
successRate: number;
|
||||
avgAmount: number;
|
||||
};
|
||||
dailySeries: { date: string; amount: number; count: number }[];
|
||||
leaderboard: {
|
||||
userId: number;
|
||||
userName: string | null;
|
||||
userEmail: string | null;
|
||||
totalAmount: number;
|
||||
orderCount: number;
|
||||
}[];
|
||||
paymentMethods: { paymentType: string; amount: number; count: number; percentage: number }[];
|
||||
meta: { days: number; generatedAt: string };
|
||||
}
|
||||
|
||||
const DAYS_OPTIONS = [7, 30, 90] as const;
|
||||
|
||||
function DashboardContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get('token');
|
||||
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
||||
const uiMode = searchParams.get('ui_mode') || 'standalone';
|
||||
const isDark = theme === 'dark';
|
||||
const isEmbedded = uiMode === 'embedded';
|
||||
|
||||
const [days, setDays] = useState<number>(30);
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`/api/admin/dashboard?token=${encodeURIComponent(token)}&days=${days}`);
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
setError('管理员凭证无效');
|
||||
return;
|
||||
}
|
||||
throw new Error('请求失败');
|
||||
}
|
||||
setData(await res.json());
|
||||
} catch {
|
||||
setError('加载数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, days]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">缺少管理员凭证</p>
|
||||
<p className="mt-2 text-sm text-gray-500">请从 Sub2API 平台正确访问管理页面</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const navParams = new URLSearchParams();
|
||||
navParams.set('token', token);
|
||||
if (theme === 'dark') navParams.set('theme', 'dark');
|
||||
if (isEmbedded) navParams.set('ui_mode', 'embedded');
|
||||
|
||||
const btnBase = [
|
||||
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
isDark
|
||||
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
|
||||
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
].join(' ');
|
||||
|
||||
const btnActive = [
|
||||
'inline-flex items-center rounded-lg px-3 py-1.5 text-xs font-medium',
|
||||
isDark ? 'bg-indigo-500/30 text-indigo-200 ring-1 ring-indigo-400/40' : 'bg-blue-600 text-white',
|
||||
].join(' ');
|
||||
|
||||
return (
|
||||
<PayPageLayout
|
||||
isDark={isDark}
|
||||
isEmbedded={isEmbedded}
|
||||
maxWidth="full"
|
||||
title="数据概览"
|
||||
subtitle="充值订单统计与分析"
|
||||
actions={
|
||||
<>
|
||||
{DAYS_OPTIONS.map((d) => (
|
||||
<button key={d} type="button" onClick={() => setDays(d)} className={days === d ? btnActive : btnBase}>
|
||||
{d}天
|
||||
</button>
|
||||
))}
|
||||
<a href={`/admin?${navParams}`} className={btnBase}>
|
||||
订单管理
|
||||
</a>
|
||||
<button type="button" onClick={fetchData} className={btnBase}>
|
||||
刷新
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
<div
|
||||
className={`mb-4 rounded-lg border p-3 text-sm ${isDark ? 'border-red-800 bg-red-950/50 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}
|
||||
>
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 opacity-60 hover:opacity-100">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className={`py-24 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>加载中...</div>
|
||||
) : data ? (
|
||||
<div className="space-y-6">
|
||||
<DashboardStats summary={data.summary} dark={isDark} />
|
||||
<DailyChart data={data.dailySeries} dark={isDark} />
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Leaderboard data={data.leaderboard} dark={isDark} />
|
||||
<PaymentMethodChart data={data.paymentMethods} dark={isDark} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</PayPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DashboardContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -162,6 +162,18 @@ function AdminContent() {
|
||||
REFUNDED: '已退款',
|
||||
};
|
||||
|
||||
const navParams = new URLSearchParams();
|
||||
if (token) navParams.set('token', token);
|
||||
if (isDark) navParams.set('theme', 'dark');
|
||||
if (isEmbedded) navParams.set('ui_mode', 'embedded');
|
||||
|
||||
const btnBase = [
|
||||
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
isDark
|
||||
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
|
||||
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
].join(' ');
|
||||
|
||||
return (
|
||||
<PayPageLayout
|
||||
isDark={isDark}
|
||||
@@ -170,20 +182,20 @@ function AdminContent() {
|
||||
title="订单管理"
|
||||
subtitle="查看和管理所有充值订单"
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchOrders}
|
||||
className={[
|
||||
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
].join(' ')}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<>
|
||||
<a href={`/admin/dashboard?${navParams}`} className={btnBase}>
|
||||
数据概览
|
||||
</a>
|
||||
<button type="button" onClick={fetchOrders} className={btnBase}>
|
||||
刷新
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
<div className={`mb-4 rounded-lg border p-3 text-sm ${isDark ? 'border-red-800 bg-red-950/50 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}>
|
||||
<div
|
||||
className={`mb-4 rounded-lg border p-3 text-sm ${isDark ? 'border-red-800 bg-red-950/50 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}
|
||||
>
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 opacity-60 hover:opacity-100">
|
||||
✕
|
||||
@@ -203,8 +215,12 @@ function AdminContent() {
|
||||
className={[
|
||||
'rounded-full px-3 py-1 text-sm transition-colors',
|
||||
statusFilter === s
|
||||
? (isDark ? 'bg-indigo-500/30 text-indigo-200 ring-1 ring-indigo-400/40' : 'bg-blue-600 text-white')
|
||||
: (isDark ? 'bg-slate-800 text-slate-400 hover:bg-slate-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'),
|
||||
? isDark
|
||||
? 'bg-indigo-500/30 text-indigo-200 ring-1 ring-indigo-400/40'
|
||||
: 'bg-blue-600 text-white'
|
||||
: isDark
|
||||
? 'bg-slate-800 text-slate-400 hover:bg-slate-700'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200',
|
||||
].join(' ')}
|
||||
>
|
||||
{statusLabels[s]}
|
||||
@@ -213,11 +229,22 @@ function AdminContent() {
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className={['rounded-xl border', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white shadow-sm'].join(' ')}>
|
||||
<div
|
||||
className={[
|
||||
'rounded-xl border',
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white shadow-sm',
|
||||
].join(' ')}
|
||||
>
|
||||
{loading ? (
|
||||
<div className={`py-12 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>加载中...</div>
|
||||
) : (
|
||||
<OrderTable orders={orders} onRetry={handleRetry} onCancel={handleCancel} onViewDetail={handleViewDetail} dark={isDark} />
|
||||
<OrderTable
|
||||
orders={orders}
|
||||
onRetry={handleRetry}
|
||||
onCancel={handleCancel}
|
||||
onViewDetail={handleViewDetail}
|
||||
dark={isDark}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -228,7 +255,10 @@ function AdminContent() {
|
||||
pageSize={pageSize}
|
||||
loading={loading}
|
||||
onPageChange={(p) => setPage(p)}
|
||||
onPageSizeChange={(s) => { setPageSize(s); setPage(1); }}
|
||||
onPageSizeChange={(s) => {
|
||||
setPageSize(s);
|
||||
setPage(1);
|
||||
}}
|
||||
isDark={isDark}
|
||||
/>
|
||||
|
||||
|
||||
160
src/app/api/admin/dashboard/route.ts
Normal file
160
src/app/api/admin/dashboard/route.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { OrderStatus } from '@prisma/client';
|
||||
|
||||
/** 业务时区偏移(东八区,+8 小时) */
|
||||
const BIZ_TZ_OFFSET_MS = 8 * 60 * 60 * 1000;
|
||||
const BIZ_TZ_NAME = 'Asia/Shanghai';
|
||||
|
||||
/** 获取业务时区下的 YYYY-MM-DD */
|
||||
function toBizDateStr(d: Date): string {
|
||||
const local = new Date(d.getTime() + BIZ_TZ_OFFSET_MS);
|
||||
return local.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/** 获取业务时区下"今天 00:00"对应的 UTC 时间 */
|
||||
function getBizDayStartUTC(d: Date): Date {
|
||||
const bizDateStr = toBizDateStr(d);
|
||||
// bizDateStr 00:00 在业务时区 = bizDateStr 00:00 - offset 在 UTC
|
||||
return new Date(`${bizDateStr}T00:00:00+08:00`);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const days = Math.min(365, Math.max(1, Number(searchParams.get('days') || '30')));
|
||||
|
||||
const now = new Date();
|
||||
const todayStart = getBizDayStartUTC(now);
|
||||
const startDate = new Date(todayStart.getTime() - days * 24 * 60 * 60 * 1000);
|
||||
|
||||
const paidStatuses: OrderStatus[] = [
|
||||
OrderStatus.PAID,
|
||||
OrderStatus.RECHARGING,
|
||||
OrderStatus.COMPLETED,
|
||||
OrderStatus.REFUNDING,
|
||||
OrderStatus.REFUNDED,
|
||||
OrderStatus.REFUND_FAILED,
|
||||
];
|
||||
|
||||
const [todayStats, totalStats, todayOrders, totalOrders, dailyRaw, leaderboardRaw, paymentMethodStats] =
|
||||
await Promise.all([
|
||||
// Today paid aggregate
|
||||
prisma.order.aggregate({
|
||||
where: { status: { in: paidStatuses }, paidAt: { gte: todayStart } },
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
// Total paid aggregate
|
||||
prisma.order.aggregate({
|
||||
where: { status: { in: paidStatuses } },
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
// Today total orders
|
||||
prisma.order.count({ where: { createdAt: { gte: todayStart } } }),
|
||||
// Total orders
|
||||
prisma.order.count(),
|
||||
// Daily series: use AT TIME ZONE to group by business timezone date
|
||||
// Prisma.raw() inlines the timezone name to avoid parameterization mismatch between SELECT and GROUP BY
|
||||
prisma.$queryRaw<{ date: string; amount: string; count: bigint }[]>`
|
||||
SELECT (paid_at AT TIME ZONE 'UTC' AT TIME ZONE ${Prisma.raw(`'${BIZ_TZ_NAME}'`)})::date::text as date,
|
||||
SUM(amount)::text as amount, COUNT(*) as count
|
||||
FROM orders
|
||||
WHERE status IN ('PAID', 'RECHARGING', 'COMPLETED', 'REFUNDING', 'REFUNDED', 'REFUND_FAILED')
|
||||
AND paid_at >= ${startDate}
|
||||
GROUP BY (paid_at AT TIME ZONE 'UTC' AT TIME ZONE ${Prisma.raw(`'${BIZ_TZ_NAME}'`)})::date
|
||||
ORDER BY date
|
||||
`,
|
||||
// Leaderboard: GROUP BY user_id only, MAX() for name/email
|
||||
prisma.$queryRaw<
|
||||
{
|
||||
user_id: number;
|
||||
user_name: string | null;
|
||||
user_email: string | null;
|
||||
total_amount: string;
|
||||
order_count: bigint;
|
||||
}[]
|
||||
>`
|
||||
SELECT user_id, MAX(user_name) as user_name, MAX(user_email) as user_email,
|
||||
SUM(amount)::text as total_amount, COUNT(*) as order_count
|
||||
FROM orders
|
||||
WHERE status IN ('PAID', 'RECHARGING', 'COMPLETED', 'REFUNDING', 'REFUNDED', 'REFUND_FAILED')
|
||||
AND paid_at >= ${startDate}
|
||||
GROUP BY user_id
|
||||
ORDER BY SUM(amount) DESC
|
||||
LIMIT 10
|
||||
`,
|
||||
// Payment method distribution (within time range)
|
||||
prisma.order.groupBy({
|
||||
by: ['paymentType'],
|
||||
where: { status: { in: paidStatuses }, paidAt: { gte: startDate } },
|
||||
_sum: { amount: true },
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Fill missing dates for continuous line chart
|
||||
const dailyMap = new Map<string, { amount: number; count: number }>();
|
||||
for (const row of dailyRaw) {
|
||||
dailyMap.set(row.date, { amount: Number(row.amount), count: Number(row.count) });
|
||||
}
|
||||
|
||||
const dailySeries: { date: string; amount: number; count: number }[] = [];
|
||||
const cursor = new Date(startDate);
|
||||
while (cursor <= now) {
|
||||
const dateStr = toBizDateStr(cursor);
|
||||
const entry = dailyMap.get(dateStr);
|
||||
dailySeries.push({ date: dateStr, amount: entry?.amount ?? 0, count: entry?.count ?? 0 });
|
||||
cursor.setTime(cursor.getTime() + 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
// Deduplicate: toBizDateStr on consecutive UTC days near midnight can produce the same biz date
|
||||
const seen = new Set<string>();
|
||||
const deduped = dailySeries.filter((d) => {
|
||||
if (seen.has(d.date)) return false;
|
||||
seen.add(d.date);
|
||||
return true;
|
||||
});
|
||||
|
||||
// Calculate summary
|
||||
const todayPaidAmount = Number(todayStats._sum?.amount || 0);
|
||||
const todayPaidCount = todayStats._count._all;
|
||||
const totalPaidAmount = Number(totalStats._sum?.amount || 0);
|
||||
const totalPaidCount = totalStats._count._all;
|
||||
const successRate = totalOrders > 0 ? (totalPaidCount / totalOrders) * 100 : 0;
|
||||
const avgAmount = totalPaidCount > 0 ? totalPaidAmount / totalPaidCount : 0;
|
||||
|
||||
// Payment method total for percentage calc
|
||||
const paymentTotal = paymentMethodStats.reduce((sum, m) => sum + Number(m._sum?.amount || 0), 0);
|
||||
|
||||
return NextResponse.json({
|
||||
summary: {
|
||||
today: { amount: todayPaidAmount, orderCount: todayOrders, paidCount: todayPaidCount },
|
||||
total: { amount: totalPaidAmount, orderCount: totalOrders, paidCount: totalPaidCount },
|
||||
successRate: Math.round(successRate * 10) / 10,
|
||||
avgAmount: Math.round(avgAmount * 100) / 100,
|
||||
},
|
||||
dailySeries: deduped,
|
||||
leaderboard: leaderboardRaw.map((row) => ({
|
||||
userId: row.user_id,
|
||||
userName: row.user_name,
|
||||
userEmail: row.user_email,
|
||||
totalAmount: Number(row.total_amount),
|
||||
orderCount: Number(row.order_count),
|
||||
})),
|
||||
paymentMethods: paymentMethodStats.map((m) => {
|
||||
const amount = Number(m._sum?.amount || 0);
|
||||
return {
|
||||
paymentType: m.paymentType,
|
||||
amount,
|
||||
count: m._count._all,
|
||||
percentage: paymentTotal > 0 ? Math.round((amount / paymentTotal) * 1000) / 10 : 0,
|
||||
};
|
||||
}),
|
||||
meta: { days, generatedAt: now.toISOString() },
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { adminCancelOrder, OrderError } from '@/lib/order/service';
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!await verifyAdminToken(request)) return unauthorizedResponse();
|
||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { retryRecharge, OrderError } from '@/lib/order/service';
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!await verifyAdminToken(request)) return unauthorizedResponse();
|
||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { prisma } from '@/lib/db';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!await verifyAdminToken(request)) return unauthorizedResponse();
|
||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { Prisma, OrderStatus } from '@prisma/client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!await verifyAdminToken(request)) return unauthorizedResponse();
|
||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const page = Math.max(1, Number(searchParams.get('page') || '1'));
|
||||
|
||||
@@ -10,7 +10,7 @@ const refundSchema = z.object({
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!await verifyAdminToken(request)) return unauthorizedResponse();
|
||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
26
src/app/api/alipay/notify/route.ts
Normal file
26
src/app/api/alipay/notify/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { handlePaymentNotify } from '@/lib/order/service';
|
||||
import { AlipayProvider } from '@/lib/alipay/provider';
|
||||
|
||||
const alipayProvider = new AlipayProvider();
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const rawBody = await request.text();
|
||||
const headers: Record<string, string> = {};
|
||||
request.headers.forEach((value, key) => {
|
||||
headers[key] = value;
|
||||
});
|
||||
|
||||
const notification = await alipayProvider.verifyNotification(rawBody, headers);
|
||||
const success = await handlePaymentNotify(notification, alipayProvider.name);
|
||||
return new Response(success ? 'success' : 'fail', {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Alipay notify error:', error);
|
||||
return new Response('fail', {
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,6 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
return NextResponse.json({ received: true });
|
||||
} catch (error) {
|
||||
console.error('Stripe webhook error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Webhook processing failed' },
|
||||
{ status: 400 },
|
||||
);
|
||||
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
try {
|
||||
const env = getEnv();
|
||||
const [user, methodLimits] = await Promise.all([
|
||||
getUser(userId),
|
||||
queryMethodLimits(env.ENABLED_PAYMENT_TYPES),
|
||||
]);
|
||||
const [user, methodLimits] = await Promise.all([getUser(userId), queryMethodLimits(env.ENABLED_PAYMENT_TYPES)]);
|
||||
|
||||
return NextResponse.json({
|
||||
user: {
|
||||
@@ -29,6 +26,10 @@ export async function GET(request: NextRequest) {
|
||||
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) {
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body className="bg-gray-50 text-gray-900 antialiased">{children}</body>
|
||||
<body className="antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,17 +134,20 @@ function OrdersContent() {
|
||||
loadOrders(1, newSize);
|
||||
};
|
||||
|
||||
const filteredOrders =
|
||||
activeFilter === 'ALL' ? orders : orders.filter((o) => o.status === activeFilter);
|
||||
const filteredOrders = activeFilter === 'ALL' ? orders : orders.filter((o) => o.status === activeFilter);
|
||||
|
||||
const btnClass = [
|
||||
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
isDark
|
||||
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
|
||||
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
].join(' ');
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-50 text-slate-900'}`}>
|
||||
<div
|
||||
className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-50 text-slate-900'}`}
|
||||
>
|
||||
正在切换到移动端订单 Tab...
|
||||
</div>
|
||||
);
|
||||
@@ -178,8 +181,14 @@ function OrdersContent() {
|
||||
subtitle={userInfo?.username || `用户 #${effectiveUserId}`}
|
||||
actions={
|
||||
<>
|
||||
<button type="button" onClick={() => loadOrders(page, pageSize)} className={btnClass}>刷新</button>
|
||||
{!srcHost && <a href={buildScopedUrl('/pay')} className={btnClass}>返回充值</a>}
|
||||
<button type="button" onClick={() => loadOrders(page, pageSize)} className={btnClass}>
|
||||
刷新
|
||||
</button>
|
||||
{!srcHost && (
|
||||
<a href={buildScopedUrl('/pay')} className={btnClass}>
|
||||
返回充值
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
@@ -208,7 +217,13 @@ function OrdersContent() {
|
||||
|
||||
export default function OrdersPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex min-h-screen items-center justify-center"><div className="text-gray-500">加载中...</div></div>}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<OrdersContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@ interface OrderResult {
|
||||
paymentType: 'alipay' | 'wxpay' | 'stripe';
|
||||
payUrl?: string | null;
|
||||
qrCode?: string | null;
|
||||
checkoutUrl?: string | null;
|
||||
clientSecret?: string | null;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ interface AppConfig {
|
||||
methodLimits?: Record<string, MethodLimitInfo>;
|
||||
helpImageUrl?: string | null;
|
||||
helpText?: string | null;
|
||||
stripePublishableKey?: string | null;
|
||||
}
|
||||
|
||||
function PayContent() {
|
||||
@@ -59,7 +60,7 @@ function PayContent() {
|
||||
const [activeMobileTab, setActiveMobileTab] = useState<'pay' | 'orders'>('pay');
|
||||
|
||||
const [config, setConfig] = useState<AppConfig>({
|
||||
enabledPaymentTypes: ['alipay', 'wxpay', 'stripe'],
|
||||
enabledPaymentTypes: [],
|
||||
minAmount: 1,
|
||||
maxAmount: 1000,
|
||||
maxDailyAmount: 0,
|
||||
@@ -108,6 +109,7 @@ function PayContent() {
|
||||
methodLimits: cfgData.config.methodLimits,
|
||||
helpImageUrl: cfgData.config.helpImageUrl ?? null,
|
||||
helpText: cfgData.config.helpText ?? null,
|
||||
stripePublishableKey: cfgData.config.stripePublishableKey ?? null,
|
||||
});
|
||||
}
|
||||
} else if (cfgRes.status === 404) {
|
||||
@@ -185,6 +187,20 @@ function PayContent() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 'result' || finalStatus !== 'COMPLETED') return;
|
||||
// 立即在后台刷新余额,2.2s 显示结果页后再切回表单(届时余额已更新)
|
||||
loadUserAndOrders();
|
||||
const timer = setTimeout(() => {
|
||||
setStep('form');
|
||||
setOrderResult(null);
|
||||
setFinalStatus('');
|
||||
setError('');
|
||||
}, 2200);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [step, finalStatus]);
|
||||
|
||||
if (!effectiveUserId || Number.isNaN(effectiveUserId) || effectiveUserId <= 0) {
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
@@ -261,7 +277,7 @@ function PayContent() {
|
||||
paymentType: data.paymentType || paymentType,
|
||||
payUrl: data.payUrl,
|
||||
qrCode: data.qrCode,
|
||||
checkoutUrl: data.checkoutUrl,
|
||||
clientSecret: data.clientSecret,
|
||||
expiresAt: data.expiresAt,
|
||||
});
|
||||
|
||||
@@ -288,20 +304,6 @@ function PayContent() {
|
||||
setError('');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 'result' || finalStatus !== 'COMPLETED') return;
|
||||
// 立即在后台刷新余额,2.2s 显示结果页后再切回表单(届时余额已更新)
|
||||
loadUserAndOrders();
|
||||
const timer = setTimeout(() => {
|
||||
setStep('form');
|
||||
setOrderResult(null);
|
||||
setFinalStatus('');
|
||||
setError('');
|
||||
}, 2200);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [step, finalStatus]);
|
||||
|
||||
return (
|
||||
<PayPageLayout
|
||||
isDark={isDark}
|
||||
@@ -309,35 +311,37 @@ function PayContent() {
|
||||
maxWidth={isMobile ? 'sm' : 'lg'}
|
||||
title="Sub2API 余额充值"
|
||||
subtitle="安全支付,自动到账"
|
||||
actions={!isMobile ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadUserAndOrders}
|
||||
className={[
|
||||
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
].join(' ')}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<a
|
||||
href={ordersUrl}
|
||||
className={[
|
||||
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
].join(' ')}
|
||||
>
|
||||
我的订单
|
||||
</a>
|
||||
</>
|
||||
) : undefined}
|
||||
actions={
|
||||
!isMobile ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadUserAndOrders}
|
||||
className={[
|
||||
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
isDark
|
||||
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
|
||||
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
].join(' ')}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<a
|
||||
href={ordersUrl}
|
||||
className={[
|
||||
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
isDark
|
||||
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
|
||||
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
].join(' ')}
|
||||
>
|
||||
我的订单
|
||||
</a>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">{error}</div>}
|
||||
|
||||
{step === 'form' && isMobile && (
|
||||
<div
|
||||
@@ -352,10 +356,12 @@ function PayContent() {
|
||||
className={[
|
||||
'rounded-lg px-3 py-2 text-sm font-semibold transition-all duration-200',
|
||||
activeMobileTab === 'pay'
|
||||
? (isDark
|
||||
? isDark
|
||||
? 'bg-indigo-500/30 text-indigo-100 ring-1 ring-indigo-300/35 shadow-sm'
|
||||
: 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-md shadow-slate-300/50')
|
||||
: (isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700'),
|
||||
: 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-md shadow-slate-300/50'
|
||||
: isDark
|
||||
? 'text-slate-400 hover:text-slate-200'
|
||||
: 'text-slate-500 hover:text-slate-700',
|
||||
].join(' ')}
|
||||
>
|
||||
充值
|
||||
@@ -366,10 +372,12 @@ function PayContent() {
|
||||
className={[
|
||||
'rounded-lg px-3 py-2 text-sm font-semibold transition-all duration-200',
|
||||
activeMobileTab === 'orders'
|
||||
? (isDark
|
||||
? isDark
|
||||
? 'bg-indigo-500/30 text-indigo-100 ring-1 ring-indigo-300/35 shadow-sm'
|
||||
: 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-md shadow-slate-300/50')
|
||||
: (isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700'),
|
||||
: 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-md shadow-slate-300/50'
|
||||
: isDark
|
||||
? 'text-slate-400 hover:text-slate-200'
|
||||
: 'text-slate-500 hover:text-slate-700',
|
||||
].join(' ')}
|
||||
>
|
||||
我的订单
|
||||
@@ -377,7 +385,14 @@ function PayContent() {
|
||||
</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 ? (
|
||||
activeMobileTab === 'pay' ? (
|
||||
@@ -421,31 +436,46 @@ function PayContent() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className={['rounded-2xl border p-4', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50'].join(' ')}>
|
||||
<div
|
||||
className={[
|
||||
'rounded-2xl border p-4',
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>支付说明</div>
|
||||
<ul className={['mt-2 space-y-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
<li>订单完成后会自动到账</li>
|
||||
<li>如需历史记录请查看"我的订单"</li>
|
||||
{config.maxDailyAmount > 0 && (
|
||||
<li>每日最大充值 ¥{config.maxDailyAmount.toFixed(2)}</li>
|
||||
<li>如需历史记录请查看「我的订单」</li>
|
||||
{config.maxDailyAmount > 0 && <li>每日最大充值 ¥{config.maxDailyAmount.toFixed(2)}</li>}
|
||||
{!hasToken && (
|
||||
<li className={isDark ? 'text-amber-200' : 'text-amber-700'}>当前链接无 token,订单查询受限</li>
|
||||
)}
|
||||
{!hasToken && <li className={isDark ? 'text-amber-200' : 'text-amber-700'}>当前链接无 token,订单查询受限</li>}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{hasHelpContent && (
|
||||
<div className={['rounded-2xl border p-4', isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50'].join(' ')}>
|
||||
<div
|
||||
className={[
|
||||
'rounded-2xl border p-4',
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>Support</div>
|
||||
{helpImageUrl && (
|
||||
<img
|
||||
src={helpImageUrl}
|
||||
alt='help'
|
||||
alt="help"
|
||||
onClick={() => setHelpImageOpen(true)}
|
||||
className='mt-3 max-h-40 w-full cursor-zoom-in rounded-lg object-contain bg-white/70 p-2'
|
||||
className="mt-3 max-h-40 w-full cursor-zoom-in rounded-lg object-contain bg-white/70 p-2"
|
||||
/>
|
||||
)}
|
||||
{helpText && (
|
||||
<div className={['mt-3 space-y-1 text-sm leading-6', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
<div
|
||||
className={[
|
||||
'mt-3 space-y-1 text-sm leading-6',
|
||||
isDark ? 'text-slate-300' : 'text-slate-600',
|
||||
].join(' ')}
|
||||
>
|
||||
{helpText.split('\\n').map((line, i) => (
|
||||
<p key={i}>{line}</p>
|
||||
))}
|
||||
@@ -465,7 +495,8 @@ function PayContent() {
|
||||
token={token || undefined}
|
||||
payUrl={orderResult.payUrl}
|
||||
qrCode={orderResult.qrCode}
|
||||
checkoutUrl={orderResult.checkoutUrl}
|
||||
clientSecret={orderResult.clientSecret}
|
||||
stripePublishableKey={config.stripePublishableKey}
|
||||
paymentType={orderResult.paymentType}
|
||||
amount={orderResult.amount}
|
||||
payAmount={orderResult.payAmount}
|
||||
@@ -473,12 +504,12 @@ function PayContent() {
|
||||
onStatusChange={handleStatusChange}
|
||||
onBack={handleBack}
|
||||
dark={isDark}
|
||||
isEmbedded={isEmbedded}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === 'result' && (
|
||||
<OrderStatus status={finalStatus} onBack={handleBack} dark={isDark} />
|
||||
)}
|
||||
{step === 'result' && <OrderStatus status={finalStatus} onBack={handleBack} dark={isDark} />}
|
||||
|
||||
{helpImageOpen && helpImageUrl && (
|
||||
<div
|
||||
@@ -487,8 +518,8 @@ function PayContent() {
|
||||
>
|
||||
<img
|
||||
src={helpImageUrl}
|
||||
alt='help'
|
||||
className='max-h-[90vh] max-w-full rounded-xl object-contain shadow-2xl'
|
||||
alt="help"
|
||||
className="max-h-[90vh] max-w-full rounded-xl object-contain shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -8,9 +8,18 @@ function ResultContent() {
|
||||
// Support both ZPAY (out_trade_no) and Stripe (order_id) callback params
|
||||
const outTradeNo = searchParams.get('out_trade_no') || searchParams.get('order_id');
|
||||
const tradeStatus = searchParams.get('trade_status') || searchParams.get('status');
|
||||
const isPopup = searchParams.get('popup') === '1';
|
||||
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
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(() => {
|
||||
if (!outTradeNo) {
|
||||
@@ -42,19 +51,29 @@ function ResultContent() {
|
||||
};
|
||||
}, [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) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50">
|
||||
<div className="text-gray-500">查询支付结果中...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isSuccess = status === 'COMPLETED' || status === 'PAID' || status === 'RECHARGING';
|
||||
const isPending = status === 'PENDING';
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-4">
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50 p-4">
|
||||
<div className="w-full max-w-md rounded-xl bg-white p-8 text-center shadow-lg">
|
||||
{isSuccess ? (
|
||||
<>
|
||||
@@ -65,12 +84,33 @@ function ResultContent() {
|
||||
<p className="mt-2 text-gray-500">
|
||||
{status === 'COMPLETED' ? '余额已成功到账!' : '支付成功,余额正在充值中...'}
|
||||
</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 ? (
|
||||
<>
|
||||
<div className="text-6xl text-yellow-500">⏳</div>
|
||||
<h1 className="mt-4 text-xl font-bold text-yellow-600">等待支付</h1>
|
||||
<p className="mt-2 text-gray-500">订单尚未完成支付</p>
|
||||
{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>
|
||||
{isInPopup && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.close()}
|
||||
className="mt-4 text-sm text-blue-600 underline hover:text-blue-700"
|
||||
>
|
||||
关闭窗口
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -98,7 +147,7 @@ export default function PayResultPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50">
|
||||
<div className="text-gray-500">加载中...</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
288
src/app/pay/stripe-popup/page.tsx
Normal file
288
src/app/pay/stripe-popup/page.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -125,9 +125,7 @@ export default function MobileOrderList({
|
||||
{hasMore && (
|
||||
<div ref={sentinelRef} className="py-3 text-center">
|
||||
{loadingMore ? (
|
||||
<span className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
加载中...
|
||||
</span>
|
||||
<span className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>加载中...</span>
|
||||
) : (
|
||||
<span className={['text-xs', isDark ? 'text-slate-600' : 'text-slate-300'].join(' ')}>
|
||||
上滑加载更多
|
||||
|
||||
@@ -35,8 +35,7 @@ export default function PaginationBar({
|
||||
{/* 左侧:统计 + 每页大小 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>
|
||||
共 {total} 条
|
||||
{totalPages > 1 && `,第 ${page} / ${totalPages} 页`}
|
||||
共 {total} 条{totalPages > 1 && `,第 ${page} / ${totalPages} 页`}
|
||||
</span>
|
||||
|
||||
{onPageSizeChange && (
|
||||
@@ -47,7 +46,9 @@ export default function PaginationBar({
|
||||
key={s}
|
||||
type="button"
|
||||
disabled={loading}
|
||||
onClick={() => { onPageSizeChange(s); }}
|
||||
onClick={() => {
|
||||
onPageSizeChange(s);
|
||||
}}
|
||||
className={[
|
||||
'rounded border px-2 py-1 font-medium transition-colors',
|
||||
pageSize === s
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function PayPageLayout({
|
||||
<div
|
||||
className={[
|
||||
'relative w-full overflow-hidden',
|
||||
isEmbedded ? 'p-2' : 'min-h-screen p-3 sm:p-4',
|
||||
isEmbedded ? 'min-h-screen p-2' : 'min-h-screen p-3 sm:p-4',
|
||||
isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-100 text-slate-900',
|
||||
].join(' ')}
|
||||
>
|
||||
|
||||
@@ -25,7 +25,7 @@ interface PaymentFormProps {
|
||||
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})?$/;
|
||||
|
||||
function hasValidCentPrecision(num: number): boolean {
|
||||
@@ -48,6 +48,11 @@ export default function PaymentForm({
|
||||
const [paymentType, setPaymentType] = useState(enabledPaymentTypes[0] || 'alipay');
|
||||
const [customAmount, setCustomAmount] = useState('');
|
||||
|
||||
// Reset paymentType when enabledPaymentTypes changes (e.g. after config loads)
|
||||
const effectivePaymentType = enabledPaymentTypes.includes(paymentType)
|
||||
? paymentType
|
||||
: enabledPaymentTypes[0] || 'stripe';
|
||||
|
||||
const handleQuickAmount = (val: number) => {
|
||||
setAmount(val);
|
||||
setCustomAmount(String(val));
|
||||
@@ -74,22 +79,23 @@ export default function PaymentForm({
|
||||
};
|
||||
|
||||
const selectedAmount = amount || 0;
|
||||
const isMethodAvailable = !methodLimits || (methodLimits[paymentType]?.available !== false);
|
||||
const methodSingleMax = methodLimits?.[paymentType]?.singleMax;
|
||||
const effectiveMax = (methodSingleMax !== undefined && methodSingleMax > 0) ? methodSingleMax : maxAmount;
|
||||
const feeRate = methodLimits?.[paymentType]?.feeRate ?? 0;
|
||||
const feeAmount = feeRate > 0 && selectedAmount > 0
|
||||
? Math.ceil(selectedAmount * feeRate / 100 * 100) / 100
|
||||
: 0;
|
||||
const payAmount = feeRate > 0 && selectedAmount > 0
|
||||
? Math.round((selectedAmount + feeAmount) * 100) / 100
|
||||
: selectedAmount;
|
||||
const isValid = selectedAmount >= minAmount && selectedAmount <= effectiveMax && hasValidCentPrecision(selectedAmount) && isMethodAvailable;
|
||||
const isMethodAvailable = !methodLimits || methodLimits[effectivePaymentType]?.available !== false;
|
||||
const methodSingleMax = methodLimits?.[effectivePaymentType]?.singleMax;
|
||||
const effectiveMax = methodSingleMax !== undefined && methodSingleMax > 0 ? methodSingleMax : maxAmount;
|
||||
const feeRate = methodLimits?.[effectivePaymentType]?.feeRate ?? 0;
|
||||
const feeAmount = feeRate > 0 && selectedAmount > 0 ? Math.ceil(((selectedAmount * feeRate) / 100) * 100) / 100 : 0;
|
||||
const payAmount =
|
||||
feeRate > 0 && selectedAmount > 0 ? Math.round((selectedAmount + feeAmount) * 100) / 100 : selectedAmount;
|
||||
const isValid =
|
||||
selectedAmount >= minAmount &&
|
||||
selectedAmount <= effectiveMax &&
|
||||
hasValidCentPrecision(selectedAmount) &&
|
||||
isMethodAvailable;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!isValid || loading) return;
|
||||
await onSubmit(selectedAmount, paymentType);
|
||||
await onSubmit(selectedAmount, effectivePaymentType);
|
||||
};
|
||||
|
||||
const renderPaymentIcon = (type: string) => {
|
||||
@@ -159,7 +165,7 @@ export default function PaymentForm({
|
||||
充值金额
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{QUICK_AMOUNTS.filter((val) => val <= effectiveMax).map((val) => (
|
||||
{QUICK_AMOUNTS.filter((val) => val >= minAmount && val <= effectiveMax).map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
type="button"
|
||||
@@ -208,83 +214,83 @@ export default function PaymentForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{customAmount !== '' && !isValid && (() => {
|
||||
const num = parseFloat(customAmount);
|
||||
let msg = '金额需在范围内,且最多支持 2 位小数(精确到分)';
|
||||
if (!isNaN(num)) {
|
||||
if (num < minAmount) msg = `单笔最低充值 ¥${minAmount}`;
|
||||
else if (num > effectiveMax) msg = `单笔最高充值 ¥${effectiveMax}`;
|
||||
}
|
||||
return (
|
||||
<div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>
|
||||
{msg}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Payment Type */}
|
||||
<div>
|
||||
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-gray-700'].join(' ')}>
|
||||
支付方式
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
{enabledPaymentTypes.map((type) => {
|
||||
const meta = PAYMENT_TYPE_META[type];
|
||||
const isSelected = paymentType === type;
|
||||
const limitInfo = methodLimits?.[type];
|
||||
const isUnavailable = limitInfo !== undefined && !limitInfo.available;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
disabled={isUnavailable}
|
||||
onClick={() => !isUnavailable && setPaymentType(type)}
|
||||
title={isUnavailable ? '今日充值额度已满,请使用其他支付方式' : undefined}
|
||||
className={[
|
||||
'relative flex h-[58px] flex-1 flex-col items-center justify-center rounded-lg border px-3 transition-all',
|
||||
isUnavailable
|
||||
? dark
|
||||
? 'cursor-not-allowed border-slate-700 bg-slate-800/50 opacity-50'
|
||||
: 'cursor-not-allowed border-gray-200 bg-gray-50 opacity-50'
|
||||
: isSelected
|
||||
? `${meta?.selectedBorder || 'border-blue-500'} ${meta?.selectedBg || 'bg-blue-50'} text-slate-900 shadow-sm`
|
||||
: dark
|
||||
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
|
||||
: 'border-gray-300 bg-white text-slate-700 hover:border-gray-400',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{renderPaymentIcon(type)}
|
||||
<span className="flex flex-col items-start leading-none">
|
||||
<span className="text-xl font-semibold tracking-tight">{meta?.label || type}</span>
|
||||
{isUnavailable ? (
|
||||
<span className="text-[10px] tracking-wide text-red-400">今日额度已满</span>
|
||||
) : meta?.sublabel ? (
|
||||
<span
|
||||
className={`text-[10px] tracking-wide ${dark && !isSelected ? 'text-slate-400' : 'text-slate-600'}`}
|
||||
>
|
||||
{meta.sublabel}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 当前选中渠道额度不足时的提示 */}
|
||||
{(() => {
|
||||
const limitInfo = methodLimits?.[paymentType];
|
||||
if (!limitInfo || limitInfo.available) return null;
|
||||
return (
|
||||
<p className={['mt-2 text-xs', dark ? 'text-amber-300' : 'text-amber-600'].join(' ')}>
|
||||
所选支付方式今日额度已满,请切换到其他支付方式
|
||||
</p>
|
||||
);
|
||||
{customAmount !== '' &&
|
||||
!isValid &&
|
||||
(() => {
|
||||
const num = parseFloat(customAmount);
|
||||
let msg = '金额需在范围内,且最多支持 2 位小数(精确到分)';
|
||||
if (!isNaN(num)) {
|
||||
if (num < minAmount) msg = `单笔最低充值 ¥${minAmount}`;
|
||||
else if (num > effectiveMax) msg = `单笔最高充值 ¥${effectiveMax}`;
|
||||
}
|
||||
return <div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>{msg}</div>;
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Payment Type — only show when multiple types available */}
|
||||
{enabledPaymentTypes.length > 1 && (
|
||||
<div>
|
||||
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-gray-700'].join(' ')}>
|
||||
支付方式
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
{enabledPaymentTypes.map((type) => {
|
||||
const meta = PAYMENT_TYPE_META[type];
|
||||
const isSelected = effectivePaymentType === type;
|
||||
const limitInfo = methodLimits?.[type];
|
||||
const isUnavailable = limitInfo !== undefined && !limitInfo.available;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
disabled={isUnavailable}
|
||||
onClick={() => !isUnavailable && setPaymentType(type)}
|
||||
title={isUnavailable ? '今日充值额度已满,请使用其他支付方式' : undefined}
|
||||
className={[
|
||||
'relative flex h-[58px] flex-1 flex-col items-center justify-center rounded-lg border px-3 transition-all',
|
||||
isUnavailable
|
||||
? dark
|
||||
? 'cursor-not-allowed border-slate-700 bg-slate-800/50 opacity-50'
|
||||
: 'cursor-not-allowed border-gray-200 bg-gray-50 opacity-50'
|
||||
: isSelected
|
||||
? `${meta?.selectedBorder || 'border-blue-500'} ${meta?.selectedBg || 'bg-blue-50'} text-slate-900 shadow-sm`
|
||||
: dark
|
||||
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
|
||||
: 'border-gray-300 bg-white text-slate-700 hover:border-gray-400',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{renderPaymentIcon(type)}
|
||||
<span className="flex flex-col items-start leading-none">
|
||||
<span className="text-xl font-semibold tracking-tight">{meta?.label || type}</span>
|
||||
{isUnavailable ? (
|
||||
<span className="text-[10px] tracking-wide text-red-400">今日额度已满</span>
|
||||
) : meta?.sublabel ? (
|
||||
<span
|
||||
className={`text-[10px] tracking-wide ${dark && !isSelected ? 'text-slate-400' : 'text-slate-600'}`}
|
||||
>
|
||||
{meta.sublabel}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 当前选中渠道额度不足时的提示 */}
|
||||
{(() => {
|
||||
const limitInfo = methodLimits?.[effectivePaymentType];
|
||||
if (!limitInfo || limitInfo.available) return null;
|
||||
return (
|
||||
<p className={['mt-2 text-xs', dark ? 'text-amber-300' : 'text-amber-600'].join(' ')}>
|
||||
所选支付方式今日额度已满,请切换到其他支付方式
|
||||
</p>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fee Detail */}
|
||||
{feeRate > 0 && selectedAmount > 0 && (
|
||||
@@ -302,10 +308,12 @@ export default function PaymentForm({
|
||||
<span>手续费({feeRate}%)</span>
|
||||
<span>¥{feeAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className={[
|
||||
'flex items-center justify-between mt-1.5 pt-1.5 border-t font-medium',
|
||||
dark ? 'border-slate-700 text-slate-100' : 'border-slate-200 text-slate-900',
|
||||
].join(' ')}>
|
||||
<div
|
||||
className={[
|
||||
'flex items-center justify-between mt-1.5 pt-1.5 border-t font-medium',
|
||||
dark ? 'border-slate-700 text-slate-100' : 'border-slate-200 text-slate-900',
|
||||
].join(' ')}
|
||||
>
|
||||
<span>实付金额</span>
|
||||
<span>¥{payAmount.toFixed(2)}</span>
|
||||
</div>
|
||||
@@ -318,7 +326,7 @@ export default function PaymentForm({
|
||||
disabled={!isValid || loading}
|
||||
className={`w-full rounded-lg py-3 text-center font-medium text-white transition-colors ${
|
||||
isValid && !loading
|
||||
? paymentType === 'stripe'
|
||||
? effectivePaymentType === 'stripe'
|
||||
? 'bg-[#635bff] hover:bg-[#5851db] active:bg-[#4b44c7]'
|
||||
: 'bg-blue-600 hover:bg-blue-700 active:bg-blue-800'
|
||||
: dark
|
||||
@@ -326,7 +334,9 @@ export default function PaymentForm({
|
||||
: 'cursor-not-allowed bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
{loading ? '处理中...' : `立即充值 ¥${(feeRate > 0 && selectedAmount > 0 ? payAmount : selectedAmount || 0).toFixed(2)}`}
|
||||
{loading
|
||||
? '处理中...'
|
||||
: `立即充值 ¥${(feeRate > 0 && selectedAmount > 0 ? payAmount : selectedAmount || 0).toFixed(2)}`}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
interface PaymentQRCodeProps {
|
||||
@@ -8,7 +8,8 @@ interface PaymentQRCodeProps {
|
||||
token?: string;
|
||||
payUrl?: string | null;
|
||||
qrCode?: string | null;
|
||||
checkoutUrl?: string | null;
|
||||
clientSecret?: string | null;
|
||||
stripePublishableKey?: string | null;
|
||||
paymentType?: 'alipay' | 'wxpay' | 'stripe';
|
||||
amount: number;
|
||||
payAmount?: number;
|
||||
@@ -16,6 +17,8 @@ interface PaymentQRCodeProps {
|
||||
onStatusChange: (status: string) => void;
|
||||
onBack: () => void;
|
||||
dark?: boolean;
|
||||
isEmbedded?: boolean;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
const TEXT_EXPIRED = '\u8BA2\u5355\u5DF2\u8D85\u65F6';
|
||||
@@ -24,23 +27,17 @@ const TEXT_GO_PAY = '\u70B9\u51FB\u524D\u5F80\u652F\u4ED8';
|
||||
const TEXT_SCAN_PAY = '\u8BF7\u4F7F\u7528\u652F\u4ED8\u5E94\u7528\u626B\u7801\u652F\u4ED8';
|
||||
const TEXT_BACK = '\u8FD4\u56DE';
|
||||
const TEXT_CANCEL_ORDER = '\u53D6\u6D88\u8BA2\u5355';
|
||||
const TEXT_H5_HINT =
|
||||
'\u652F\u4ED8\u5B8C\u6210\u540E\u8BF7\u8FD4\u56DE\u6B64\u9875\u9762\uFF0C\u7CFB\u7EDF\u5C06\u81EA\u52A8\u786E\u8BA4';
|
||||
const TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED', 'CANCELLED', 'EXPIRED', 'REFUNDED', 'REFUND_FAILED']);
|
||||
|
||||
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({
|
||||
orderId,
|
||||
token,
|
||||
payUrl,
|
||||
qrCode,
|
||||
checkoutUrl,
|
||||
clientSecret,
|
||||
stripePublishableKey,
|
||||
paymentType,
|
||||
amount,
|
||||
payAmount: payAmountProp,
|
||||
@@ -48,6 +45,8 @@ export default function PaymentQRCode({
|
||||
onStatusChange,
|
||||
onBack,
|
||||
dark = false,
|
||||
isEmbedded = false,
|
||||
isMobile = false,
|
||||
}: PaymentQRCodeProps) {
|
||||
const displayAmount = payAmountProp ?? amount;
|
||||
const hasFeeDiff = payAmountProp !== undefined && payAmountProp !== amount;
|
||||
@@ -55,9 +54,22 @@ export default function PaymentQRCode({
|
||||
const [expired, setExpired] = useState(false);
|
||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||
const [imageLoading, setImageLoading] = useState(false);
|
||||
const [stripeOpened, setStripeOpened] = 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 value = (qrCode || payUrl || '').trim();
|
||||
return value;
|
||||
@@ -97,6 +109,134 @@ export default function PaymentQRCode({
|
||||
};
|
||||
}, [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(() => {
|
||||
const updateTimer = () => {
|
||||
const now = Date.now();
|
||||
@@ -173,7 +313,6 @@ export default function PaymentQRCode({
|
||||
}
|
||||
};
|
||||
|
||||
const isStripe = paymentType === 'stripe';
|
||||
const isWx = paymentType === 'wxpay';
|
||||
const iconSrc = isStripe ? '' : isWx ? '/icons/wxpay.svg' : '/icons/alipay.svg';
|
||||
const channelLabel = isStripe ? 'Stripe' : isWx ? '\u5FAE\u4FE1' : '\u652F\u4ED8\u5B9D';
|
||||
@@ -185,7 +324,9 @@ export default function PaymentQRCode({
|
||||
<div className="text-6xl text-green-600">{'\u2713'}</div>
|
||||
<h2 className="text-xl font-bold text-green-600">{'\u8BA2\u5355\u5DF2\u652F\u4ED8'}</h2>
|
||||
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{'\u8BE5\u8BA2\u5355\u5DF2\u652F\u4ED8\u5B8C\u6210\uFF0C\u65E0\u6CD5\u53D6\u6D88\u3002\u5145\u503C\u5C06\u81EA\u52A8\u5230\u8D26\u3002'}
|
||||
{
|
||||
'\u8BE5\u8BA2\u5355\u5DF2\u652F\u4ED8\u5B8C\u6210\uFF0C\u65E0\u6CD5\u53D6\u6D88\u3002\u5145\u503C\u5C06\u81EA\u52A8\u5230\u8D26\u3002'
|
||||
}
|
||||
</p>
|
||||
<button
|
||||
onClick={onBack}
|
||||
@@ -200,7 +341,10 @@ export default function PaymentQRCode({
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-bold text-blue-600">{'\u00A5'}{displayAmount.toFixed(2)}</div>
|
||||
<div className="text-4xl font-bold text-blue-600">
|
||||
{'\u00A5'}
|
||||
{displayAmount.toFixed(2)}
|
||||
</div>
|
||||
{hasFeeDiff && (
|
||||
<div className={['mt-1 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
到账 ¥{amount.toFixed(2)}
|
||||
@@ -214,52 +358,109 @@ export default function PaymentQRCode({
|
||||
{!expired && (
|
||||
<>
|
||||
{isStripe ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!checkoutUrl || !isSafeCheckoutUrl(checkoutUrl) || stripeOpened}
|
||||
onClick={() => {
|
||||
if (checkoutUrl && isSafeCheckoutUrl(checkoutUrl)) {
|
||||
window.open(checkoutUrl, '_blank', 'noopener,noreferrer');
|
||||
setStripeOpened(true);
|
||||
}
|
||||
}}
|
||||
className={[
|
||||
'inline-flex items-center gap-2 rounded-lg px-8 py-3 font-medium text-white shadow-md transition-colors',
|
||||
!checkoutUrl || !isSafeCheckoutUrl(checkoutUrl) || stripeOpened
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-[#635bff] hover:bg-[#5249d9] active:bg-[#4840c4]',
|
||||
].join(' ')}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" ry="2" />
|
||||
<line x1="1" y1="10" x2="23" y2="10" />
|
||||
</svg>
|
||||
{stripeOpened ? '\u5DF2\u6253\u5F00\u652F\u4ED8\u9875\u9762' : '\u524D\u5F80 Stripe \u652F\u4ED8'}
|
||||
</button>
|
||||
{stripeOpened && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (checkoutUrl && isSafeCheckoutUrl(checkoutUrl)) {
|
||||
window.open(checkoutUrl, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
}}
|
||||
className={['text-sm underline', dark ? 'text-slate-400 hover:text-slate-300' : 'text-gray-500 hover:text-gray-700'].join(' ')}
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
{!clientSecret || !stripePublishableKey ? (
|
||||
<div
|
||||
className={[
|
||||
'rounded-lg border-2 border-dashed p-8 text-center',
|
||||
dark ? 'border-slate-700' : 'border-gray-300',
|
||||
].join(' ')}
|
||||
>
|
||||
{'\u91CD\u65B0\u6253\u5F00\u652F\u4ED8\u9875\u9762'}
|
||||
</button>
|
||||
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
支付初始化失败,请返回重试
|
||||
</p>
|
||||
</div>
|
||||
) : !stripeLoaded ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
|
||||
<span className={['ml-3 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
正在加载支付表单...
|
||||
</span>
|
||||
</div>
|
||||
) : stripeError && !stripeLib ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">{stripeError}</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
ref={stripeContainerRef}
|
||||
className={[
|
||||
'rounded-lg border p-4',
|
||||
dark ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-white',
|
||||
].join(' ')}
|
||||
/>
|
||||
{stripeError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
|
||||
{stripeError}
|
||||
</div>
|
||||
)}
|
||||
{stripeSuccess ? (
|
||||
<div className="text-center">
|
||||
<div className="text-4xl text-green-600">{'\u2713'}</div>
|
||||
<p className={['mt-2 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
支付成功,正在处理订单...
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : isMobile && payUrl ? (
|
||||
<>
|
||||
<a
|
||||
href={payUrl}
|
||||
target={isEmbedded ? '_blank' : '_self'}
|
||||
rel="noopener noreferrer"
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-lg py-3 font-medium text-white shadow-md ${iconBgClass}`}
|
||||
>
|
||||
<img src={iconSrc} alt={channelLabel} className="h-5 w-5 brightness-0 invert" />
|
||||
{`打开${channelLabel}支付`}
|
||||
</a>
|
||||
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{!checkoutUrl || !isSafeCheckoutUrl(checkoutUrl)
|
||||
? '\u652F\u4ED8\u94FE\u63A5\u521B\u5EFA\u5931\u8D25\uFF0C\u8BF7\u8FD4\u56DE\u91CD\u8BD5'
|
||||
: '\u5728\u65B0\u7A97\u53E3\u5B8C\u6210\u652F\u4ED8\u540E\uFF0C\u6B64\u9875\u9762\u5C06\u81EA\u52A8\u66F4\u65B0'}
|
||||
{TEXT_H5_HINT}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{qrDataUrl && (
|
||||
<div className={['relative rounded-lg border p-4', dark ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-white'].join(' ')}>
|
||||
<div
|
||||
className={[
|
||||
'relative rounded-lg border p-4',
|
||||
dark ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-white',
|
||||
].join(' ')}
|
||||
>
|
||||
{imageLoading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-black/10">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
@@ -287,7 +488,12 @@ export default function PaymentQRCode({
|
||||
|
||||
{!qrDataUrl && !payUrl && (
|
||||
<div className="text-center">
|
||||
<div className={['rounded-lg border-2 border-dashed p-8', dark ? 'border-slate-700' : 'border-gray-300'].join(' ')}>
|
||||
<div
|
||||
className={[
|
||||
'rounded-lg border-2 border-dashed p-8',
|
||||
dark ? 'border-slate-700' : 'border-gray-300',
|
||||
].join(' ')}
|
||||
>
|
||||
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{TEXT_SCAN_PAY}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -306,7 +512,9 @@ export default function PaymentQRCode({
|
||||
onClick={onBack}
|
||||
className={[
|
||||
'flex-1 rounded-lg border py-2 text-sm',
|
||||
dark ? 'border-slate-700 text-slate-300 hover:bg-slate-800' : 'border-gray-300 text-gray-600 hover:bg-gray-50',
|
||||
dark
|
||||
? 'border-slate-700 text-slate-300 hover:bg-slate-800'
|
||||
: 'border-gray-300 text-gray-600 hover:bg-gray-50',
|
||||
].join(' ')}
|
||||
>
|
||||
{TEXT_BACK}
|
||||
|
||||
125
src/components/admin/DailyChart.tsx
Normal file
125
src/components/admin/DailyChart.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
|
||||
|
||||
interface DailyData {
|
||||
date: string;
|
||||
amount: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface DailyChartProps {
|
||||
data: DailyData[];
|
||||
dark?: boolean;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
const [, m, d] = dateStr.split('-');
|
||||
return `${m}/${d}`;
|
||||
}
|
||||
|
||||
function formatAmount(value: number) {
|
||||
if (value >= 10000) return `¥${(value / 10000).toFixed(1)}w`;
|
||||
if (value >= 1000) return `¥${(value / 1000).toFixed(1)}k`;
|
||||
return `¥${value}`;
|
||||
}
|
||||
|
||||
interface TooltipPayload {
|
||||
value: number;
|
||||
dataKey: string;
|
||||
}
|
||||
|
||||
function CustomTooltip({
|
||||
active,
|
||||
payload,
|
||||
label,
|
||||
dark,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: TooltipPayload[];
|
||||
label?: string;
|
||||
dark?: boolean;
|
||||
}) {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'rounded-lg border px-3 py-2 text-sm shadow-lg',
|
||||
dark ? 'border-slate-600 bg-slate-800 text-slate-200' : 'border-slate-200 bg-white text-slate-800',
|
||||
].join(' ')}
|
||||
>
|
||||
<p className={['mb-1 text-xs', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>{label}</p>
|
||||
{payload.map((p) => (
|
||||
<p key={p.dataKey}>
|
||||
{p.dataKey === 'amount' ? '金额' : '笔数'}:{' '}
|
||||
{p.dataKey === 'amount' ? `¥${p.value.toLocaleString()}` : p.value}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DailyChart({ data, dark }: DailyChartProps) {
|
||||
// Auto-calculate tick interval: show ~10-15 labels max
|
||||
const tickInterval = data.length > 30 ? Math.ceil(data.length / 12) - 1 : 0;
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'rounded-xl border p-6',
|
||||
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
每日充值趋势
|
||||
</h3>
|
||||
<p className={['text-center text-sm py-16', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>暂无数据</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const axisColor = dark ? '#64748b' : '#94a3b8';
|
||||
const gridColor = dark ? '#334155' : '#e2e8f0';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'rounded-xl border p-6',
|
||||
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
每日充值趋势
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<LineChart data={data} margin={{ top: 5, right: 20, bottom: 5, left: 10 }}>
|
||||
<CartesianGrid stroke={gridColor} strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={formatDate}
|
||||
tick={{ fill: axisColor, fontSize: 12 }}
|
||||
axisLine={{ stroke: gridColor }}
|
||||
tickLine={false}
|
||||
interval={tickInterval}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={formatAmount}
|
||||
tick={{ fill: axisColor, fontSize: 12 }}
|
||||
axisLine={{ stroke: gridColor }}
|
||||
tickLine={false}
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip dark={dark} />} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="amount"
|
||||
stroke={dark ? '#818cf8' : '#4f46e5'}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 3, fill: dark ? '#818cf8' : '#4f46e5' }}
|
||||
activeDot={{ r: 5 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
src/components/admin/DashboardStats.tsx
Normal file
48
src/components/admin/DashboardStats.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
interface Summary {
|
||||
today: { amount: number; orderCount: number; paidCount: number };
|
||||
total: { amount: number; orderCount: number; paidCount: number };
|
||||
successRate: number;
|
||||
avgAmount: number;
|
||||
}
|
||||
|
||||
interface DashboardStatsProps {
|
||||
summary: Summary;
|
||||
dark?: boolean;
|
||||
}
|
||||
|
||||
export default function DashboardStats({ summary, dark }: DashboardStatsProps) {
|
||||
const cards = [
|
||||
{ label: '今日充值', value: `¥${summary.today.amount.toLocaleString()}`, accent: true },
|
||||
{ label: '今日订单', value: `${summary.today.paidCount}/${summary.today.orderCount}` },
|
||||
{ label: '累计充值', value: `¥${summary.total.amount.toLocaleString()}`, accent: true },
|
||||
{ label: '累计订单', value: String(summary.total.paidCount) },
|
||||
{ label: '成功率', value: `${summary.successRate}%` },
|
||||
{ label: '平均充值', value: `¥${summary.avgAmount.toFixed(2)}` },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{cards.map((card) => (
|
||||
<div
|
||||
key={card.label}
|
||||
className={[
|
||||
'rounded-xl border p-4',
|
||||
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
|
||||
].join(' ')}
|
||||
>
|
||||
<p className={['text-xs font-medium', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>{card.label}</p>
|
||||
<p
|
||||
className={[
|
||||
'mt-1 text-xl font-semibold tracking-tight',
|
||||
card.accent ? (dark ? 'text-indigo-400' : 'text-indigo-600') : dark ? 'text-slate-100' : 'text-slate-900',
|
||||
].join(' ')}
|
||||
>
|
||||
{card.value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
102
src/components/admin/Leaderboard.tsx
Normal file
102
src/components/admin/Leaderboard.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
interface LeaderboardEntry {
|
||||
userId: number;
|
||||
userName: string | null;
|
||||
userEmail: string | null;
|
||||
totalAmount: number;
|
||||
orderCount: number;
|
||||
}
|
||||
|
||||
interface LeaderboardProps {
|
||||
data: LeaderboardEntry[];
|
||||
dark?: boolean;
|
||||
}
|
||||
|
||||
const RANK_STYLES: Record<number, { light: string; dark: string }> = {
|
||||
1: { light: 'bg-amber-100 text-amber-700', dark: 'bg-amber-500/20 text-amber-300' },
|
||||
2: { light: 'bg-slate-200 text-slate-600', dark: 'bg-slate-500/20 text-slate-300' },
|
||||
3: { light: 'bg-orange-100 text-orange-700', dark: 'bg-orange-500/20 text-orange-300' },
|
||||
};
|
||||
|
||||
export default function Leaderboard({ data, dark }: LeaderboardProps) {
|
||||
const thCls = `px-4 py-3 text-left text-xs font-medium uppercase ${dark ? 'text-slate-400' : 'text-gray-500'}`;
|
||||
const tdCls = `whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-300' : 'text-slate-700'}`;
|
||||
const tdMuted = `whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-400' : 'text-gray-500'}`;
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'rounded-xl border p-6',
|
||||
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
充值排行榜 (Top 10)
|
||||
</h3>
|
||||
<p className={['text-center text-sm py-8', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>暂无数据</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'rounded-xl border',
|
||||
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['px-6 pt-5 pb-2 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
充值排行榜 (Top 10)
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className={`min-w-full divide-y ${dark ? 'divide-slate-700' : 'divide-gray-200'}`}>
|
||||
<thead className={dark ? 'bg-slate-800/50' : 'bg-gray-50'}>
|
||||
<tr>
|
||||
<th className={thCls}>#</th>
|
||||
<th className={thCls}>用户</th>
|
||||
<th className={thCls}>累计金额</th>
|
||||
<th className={thCls}>订单数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={`divide-y ${dark ? 'divide-slate-700/60' : 'divide-gray-200'}`}>
|
||||
{data.map((entry, i) => {
|
||||
const rank = i + 1;
|
||||
const rankStyle = RANK_STYLES[rank];
|
||||
return (
|
||||
<tr key={entry.userId} className={dark ? 'hover:bg-slate-700/40' : 'hover:bg-gray-50'}>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||
{rankStyle ? (
|
||||
<span
|
||||
className={`inline-flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold ${dark ? rankStyle.dark : rankStyle.light}`}
|
||||
>
|
||||
{rank}
|
||||
</span>
|
||||
) : (
|
||||
<span className={dark ? 'text-slate-500' : 'text-gray-400'}>{rank}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={tdCls}>
|
||||
<div>{entry.userName || `#${entry.userId}`}</div>
|
||||
{entry.userEmail && (
|
||||
<div className={['text-xs', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>
|
||||
{entry.userEmail}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : 'text-slate-900'}`}
|
||||
>
|
||||
¥{entry.totalAmount.toLocaleString()}
|
||||
</td>
|
||||
<td className={tdMuted}>{entry.orderCount}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -84,7 +84,10 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps)
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold">订单详情</h3>
|
||||
<button onClick={onClose} className={dark ? 'text-slate-400 hover:text-slate-200' : 'text-gray-400 hover:text-gray-600'}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={dark ? 'text-slate-400 hover:text-slate-200' : 'text-gray-400 hover:text-gray-600'}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
@@ -103,16 +106,31 @@ export default function OrderDetail({ order, onClose, dark }: OrderDetailProps)
|
||||
<h4 className={`mb-3 font-medium ${dark ? 'text-slate-100' : 'text-gray-900'}`}>审计日志</h4>
|
||||
<div className="space-y-2">
|
||||
{order.auditLogs.map((log) => (
|
||||
<div key={log.id} className={`rounded-lg border p-3 ${dark ? 'border-slate-600 bg-slate-700/60' : 'border-gray-100 bg-gray-50'}`}>
|
||||
<div
|
||||
key={log.id}
|
||||
className={`rounded-lg border p-3 ${dark ? 'border-slate-600 bg-slate-700/60' : 'border-gray-100 bg-gray-50'}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{log.action}</span>
|
||||
<span className={`text-xs ${dark ? 'text-slate-500' : 'text-gray-400'}`}>{new Date(log.createdAt).toLocaleString('zh-CN')}</span>
|
||||
<span className={`text-xs ${dark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
{new Date(log.createdAt).toLocaleString('zh-CN')}
|
||||
</span>
|
||||
</div>
|
||||
{log.detail && <div className={`mt-1 break-all text-xs ${dark ? 'text-slate-400' : 'text-gray-500'}`}>{log.detail}</div>}
|
||||
{log.operator && <div className={`mt-1 text-xs ${dark ? 'text-slate-500' : 'text-gray-400'}`}>操作者: {log.operator}</div>}
|
||||
{log.detail && (
|
||||
<div className={`mt-1 break-all text-xs ${dark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{log.detail}
|
||||
</div>
|
||||
)}
|
||||
{log.operator && (
|
||||
<div className={`mt-1 text-xs ${dark ? 'text-slate-500' : 'text-gray-400'}`}>
|
||||
操作者: {log.operator}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{order.auditLogs.length === 0 && <div className={`text-center text-sm ${dark ? 'text-slate-500' : 'text-gray-400'}`}>暂无日志</div>}
|
||||
{order.auditLogs.length === 0 && (
|
||||
<div className={`text-center text-sm ${dark ? 'text-slate-500' : 'text-gray-400'}`}>暂无日志</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -70,7 +70,10 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
return (
|
||||
<tr key={order.id} className={dark ? 'hover:bg-slate-700/40' : 'hover:bg-gray-50'}>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<button onClick={() => onViewDetail(order.id)} className={dark ? 'text-indigo-400 hover:underline' : 'text-blue-600 hover:underline'}>
|
||||
<button
|
||||
onClick={() => onViewDetail(order.id)}
|
||||
className={dark ? 'text-indigo-400 hover:underline' : 'text-blue-600 hover:underline'}
|
||||
>
|
||||
{order.id.slice(0, 12)}...
|
||||
</button>
|
||||
</td>
|
||||
@@ -79,21 +82,27 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
</td>
|
||||
<td className={tdMuted}>{order.userEmail || '-'}</td>
|
||||
<td className={tdMuted}>{order.userNotes || '-'}</td>
|
||||
<td className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : ''}`}>¥{order.amount.toFixed(2)}</td>
|
||||
<td className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : ''}`}>
|
||||
¥{order.amount.toFixed(2)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<span className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${dark ? statusInfo.dark : statusInfo.light}`}>
|
||||
<span
|
||||
className={`inline-flex rounded-full px-2 py-1 text-xs font-semibold ${dark ? statusInfo.dark : statusInfo.light}`}
|
||||
>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className={tdMuted}>
|
||||
{order.paymentType === 'alipay' ? '支付宝' : '微信支付'}
|
||||
</td>
|
||||
<td className={tdMuted}>
|
||||
{order.srcHost || '-'}
|
||||
</td>
|
||||
<td className={tdMuted}>
|
||||
{new Date(order.createdAt).toLocaleString('zh-CN')}
|
||||
{order.paymentType === 'alipay'
|
||||
? '支付宝'
|
||||
: order.paymentType === 'wechat'
|
||||
? '微信支付'
|
||||
: order.paymentType === 'stripe'
|
||||
? 'Stripe'
|
||||
: order.paymentType}
|
||||
</td>
|
||||
<td className={tdMuted}>{order.srcHost || '-'}</td>
|
||||
<td className={tdMuted}>{new Date(order.createdAt).toLocaleString('zh-CN')}</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<div className="flex gap-1">
|
||||
{order.rechargeRetryable && (
|
||||
@@ -119,7 +128,9 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{orders.length === 0 && <div className={`py-12 text-center ${dark ? 'text-slate-500' : 'text-gray-500'}`}>暂无订单</div>}
|
||||
{orders.length === 0 && (
|
||||
<div className={`py-12 text-center ${dark ? 'text-slate-500' : 'text-gray-500'}`}>暂无订单</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
79
src/components/admin/PaymentMethodChart.tsx
Normal file
79
src/components/admin/PaymentMethodChart.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
interface PaymentMethod {
|
||||
paymentType: string;
|
||||
amount: number;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
interface PaymentMethodChartProps {
|
||||
data: PaymentMethod[];
|
||||
dark?: boolean;
|
||||
}
|
||||
|
||||
const TYPE_CONFIG: Record<string, { label: string; light: string; dark: string }> = {
|
||||
alipay: { label: '支付宝', light: 'bg-blue-500', dark: 'bg-blue-400' },
|
||||
wechat: { label: '微信支付', light: 'bg-green-500', dark: 'bg-green-400' },
|
||||
stripe: { label: 'Stripe', light: 'bg-purple-500', dark: 'bg-purple-400' },
|
||||
};
|
||||
|
||||
export default function PaymentMethodChart({ data, dark }: PaymentMethodChartProps) {
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'rounded-xl border p-6',
|
||||
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
支付方式分布
|
||||
</h3>
|
||||
<p className={['text-center text-sm py-8', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>暂无数据</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'rounded-xl border p-6',
|
||||
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
支付方式分布
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{data.map((method) => {
|
||||
const config = TYPE_CONFIG[method.paymentType] || {
|
||||
label: method.paymentType,
|
||||
light: 'bg-gray-500',
|
||||
dark: 'bg-gray-400',
|
||||
};
|
||||
return (
|
||||
<div key={method.paymentType}>
|
||||
<div className="mb-1.5 flex items-center justify-between text-sm">
|
||||
<span className={dark ? 'text-slate-300' : 'text-slate-700'}>{config.label}</span>
|
||||
<span className={dark ? 'text-slate-400' : 'text-slate-500'}>
|
||||
¥{method.amount.toLocaleString()} · {method.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={['h-3 w-full overflow-hidden rounded-full', dark ? 'bg-slate-700' : 'bg-slate-100'].join(
|
||||
' ',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={['h-full rounded-full transition-all', dark ? config.dark : config.light].join(' ')}
|
||||
style={{ width: `${method.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
src/lib/alipay/client.ts
Normal file
98
src/lib/alipay/client.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { getEnv } from '@/lib/config';
|
||||
import { generateSign } from './sign';
|
||||
import type { AlipayResponse } from './types';
|
||||
|
||||
const GATEWAY = 'https://openapi.alipay.com/gateway.do';
|
||||
|
||||
function getCommonParams(appId: string): Record<string, string> {
|
||||
return {
|
||||
app_id: appId,
|
||||
format: 'JSON',
|
||||
charset: 'utf-8',
|
||||
sign_type: 'RSA2',
|
||||
timestamp: new Date().toISOString().replace('T', ' ').substring(0, 19),
|
||||
version: '1.0',
|
||||
};
|
||||
}
|
||||
|
||||
function assertAlipayEnv(env: ReturnType<typeof getEnv>) {
|
||||
if (!env.ALIPAY_APP_ID || !env.ALIPAY_PRIVATE_KEY || !env.ALIPAY_PUBLIC_KEY) {
|
||||
throw new Error('Alipay environment variables (ALIPAY_APP_ID, ALIPAY_PRIVATE_KEY, ALIPAY_PUBLIC_KEY) are required');
|
||||
}
|
||||
return env as typeof env & {
|
||||
ALIPAY_APP_ID: string;
|
||||
ALIPAY_PRIVATE_KEY: string;
|
||||
ALIPAY_PUBLIC_KEY: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成电脑网站支付的跳转 URL(GET 方式)
|
||||
* 用于 alipay.trade.page.pay
|
||||
*/
|
||||
export function pageExecute(
|
||||
bizContent: Record<string, unknown>,
|
||||
options?: { notifyUrl?: string; returnUrl?: string },
|
||||
): string {
|
||||
const env = assertAlipayEnv(getEnv());
|
||||
|
||||
const params: Record<string, string> = {
|
||||
...getCommonParams(env.ALIPAY_APP_ID),
|
||||
method: 'alipay.trade.page.pay',
|
||||
biz_content: JSON.stringify(bizContent),
|
||||
};
|
||||
|
||||
if (options?.notifyUrl || env.ALIPAY_NOTIFY_URL) {
|
||||
params.notify_url = (options?.notifyUrl || env.ALIPAY_NOTIFY_URL)!;
|
||||
}
|
||||
if (options?.returnUrl || env.ALIPAY_RETURN_URL) {
|
||||
params.return_url = (options?.returnUrl || env.ALIPAY_RETURN_URL)!;
|
||||
}
|
||||
|
||||
params.sign = generateSign(params, env.ALIPAY_PRIVATE_KEY);
|
||||
|
||||
const query = new URLSearchParams({ ...params, sign_type: 'RSA2' }).toString();
|
||||
return `${GATEWAY}?${query}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用支付宝服务端 API(POST 方式)
|
||||
* 用于 alipay.trade.query、alipay.trade.refund、alipay.trade.close
|
||||
*/
|
||||
export async function execute<T extends AlipayResponse>(
|
||||
method: string,
|
||||
bizContent: Record<string, unknown>,
|
||||
): Promise<T> {
|
||||
const env = assertAlipayEnv(getEnv());
|
||||
|
||||
const params: Record<string, string> = {
|
||||
...getCommonParams(env.ALIPAY_APP_ID),
|
||||
method,
|
||||
biz_content: JSON.stringify(bizContent),
|
||||
};
|
||||
|
||||
params.sign = generateSign(params, env.ALIPAY_PRIVATE_KEY);
|
||||
params.sign_type = 'RSA2';
|
||||
|
||||
const response = await fetch(GATEWAY, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(params).toString(),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 支付宝响应格式:{ "alipay_trade_query_response": { ... }, "sign": "..." }
|
||||
const responseKey = method.replace(/\./g, '_') + '_response';
|
||||
const result = data[responseKey] as T;
|
||||
|
||||
if (!result) {
|
||||
throw new Error(`Alipay API error: unexpected response format for ${method}`);
|
||||
}
|
||||
|
||||
if (result.code !== '10000') {
|
||||
throw new Error(`Alipay API error: [${result.sub_code || result.code}] ${result.sub_msg || result.msg}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
120
src/lib/alipay/provider.ts
Normal file
120
src/lib/alipay/provider.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type {
|
||||
PaymentProvider,
|
||||
PaymentType,
|
||||
CreatePaymentRequest,
|
||||
CreatePaymentResponse,
|
||||
QueryOrderResponse,
|
||||
PaymentNotification,
|
||||
RefundRequest,
|
||||
RefundResponse,
|
||||
} from '@/lib/payment/types';
|
||||
import { pageExecute, execute } from './client';
|
||||
import { verifySign } from './sign';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import type { AlipayTradeQueryResponse, AlipayTradeRefundResponse, AlipayTradeCloseResponse } from './types';
|
||||
|
||||
export class AlipayProvider implements PaymentProvider {
|
||||
readonly name = 'alipay';
|
||||
readonly providerKey = 'alipay';
|
||||
readonly supportedTypes: PaymentType[] = ['alipay'];
|
||||
readonly defaultLimits = {
|
||||
alipay: { singleMax: 1000, dailyMax: 10000 },
|
||||
};
|
||||
|
||||
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
|
||||
const url = pageExecute(
|
||||
{
|
||||
out_trade_no: request.orderId,
|
||||
product_code: 'FAST_INSTANT_TRADE_PAY',
|
||||
total_amount: request.amount.toFixed(2),
|
||||
subject: request.subject,
|
||||
},
|
||||
{
|
||||
notifyUrl: request.notifyUrl,
|
||||
returnUrl: request.returnUrl,
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
tradeNo: request.orderId,
|
||||
payUrl: url,
|
||||
};
|
||||
}
|
||||
|
||||
async queryOrder(tradeNo: string): Promise<QueryOrderResponse> {
|
||||
const result = await execute<AlipayTradeQueryResponse>('alipay.trade.query', {
|
||||
out_trade_no: tradeNo,
|
||||
});
|
||||
|
||||
let status: 'pending' | 'paid' | 'failed' | 'refunded';
|
||||
switch (result.trade_status) {
|
||||
case 'TRADE_SUCCESS':
|
||||
case 'TRADE_FINISHED':
|
||||
status = 'paid';
|
||||
break;
|
||||
case 'TRADE_CLOSED':
|
||||
status = 'failed';
|
||||
break;
|
||||
default:
|
||||
status = 'pending';
|
||||
}
|
||||
|
||||
return {
|
||||
tradeNo: result.trade_no || tradeNo,
|
||||
status,
|
||||
amount: parseFloat(result.total_amount || '0'),
|
||||
paidAt: result.send_pay_date ? new Date(result.send_pay_date) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyNotification(rawBody: string | Buffer, _headers: Record<string, string>): Promise<PaymentNotification> {
|
||||
const env = getEnv();
|
||||
const body = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf-8');
|
||||
const searchParams = new URLSearchParams(body);
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
params[key] = value;
|
||||
}
|
||||
|
||||
const sign = params.sign || '';
|
||||
const paramsForVerify: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (key !== 'sign' && key !== 'sign_type' && value !== undefined && value !== null) {
|
||||
paramsForVerify[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!env.ALIPAY_PUBLIC_KEY || !verifySign(paramsForVerify, env.ALIPAY_PUBLIC_KEY, sign)) {
|
||||
throw new Error('Alipay notification signature verification failed');
|
||||
}
|
||||
|
||||
return {
|
||||
tradeNo: params.trade_no || '',
|
||||
orderId: params.out_trade_no || '',
|
||||
amount: parseFloat(params.total_amount || '0'),
|
||||
status:
|
||||
params.trade_status === 'TRADE_SUCCESS' || params.trade_status === 'TRADE_FINISHED' ? 'success' : 'failed',
|
||||
rawData: params,
|
||||
};
|
||||
}
|
||||
|
||||
async refund(request: RefundRequest): Promise<RefundResponse> {
|
||||
const result = await execute<AlipayTradeRefundResponse>('alipay.trade.refund', {
|
||||
out_trade_no: request.orderId,
|
||||
refund_amount: request.amount.toFixed(2),
|
||||
refund_reason: request.reason || '',
|
||||
});
|
||||
|
||||
return {
|
||||
refundId: result.trade_no || `${request.orderId}-refund`,
|
||||
status: result.fund_change === 'Y' ? 'success' : 'pending',
|
||||
};
|
||||
}
|
||||
|
||||
async cancelPayment(tradeNo: string): Promise<void> {
|
||||
await execute<AlipayTradeCloseResponse>('alipay.trade.close', {
|
||||
out_trade_no: tradeNo,
|
||||
});
|
||||
}
|
||||
}
|
||||
42
src/lib/alipay/sign.ts
Normal file
42
src/lib/alipay/sign.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/** 自动补全 PEM 格式(PKCS8) */
|
||||
function formatPrivateKey(key: string): string {
|
||||
if (key.includes('-----BEGIN')) return key;
|
||||
return `-----BEGIN PRIVATE KEY-----\n${key}\n-----END PRIVATE KEY-----`;
|
||||
}
|
||||
|
||||
function formatPublicKey(key: string): string {
|
||||
if (key.includes('-----BEGIN')) return key;
|
||||
return `-----BEGIN PUBLIC KEY-----\n${key}\n-----END PUBLIC KEY-----`;
|
||||
}
|
||||
|
||||
/** 生成 RSA2 签名 */
|
||||
export function generateSign(params: Record<string, string>, privateKey: string): string {
|
||||
const filtered = Object.entries(params)
|
||||
.filter(
|
||||
([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null,
|
||||
)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
const signStr = filtered.map(([key, value]) => `${key}=${value}`).join('&');
|
||||
|
||||
const signer = crypto.createSign('RSA-SHA256');
|
||||
signer.update(signStr);
|
||||
return signer.sign(formatPrivateKey(privateKey), 'base64');
|
||||
}
|
||||
|
||||
/** 用支付宝公钥验证签名 */
|
||||
export function verifySign(params: Record<string, string>, alipayPublicKey: string, sign: string): boolean {
|
||||
const filtered = Object.entries(params)
|
||||
.filter(
|
||||
([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null,
|
||||
)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
const signStr = filtered.map(([key, value]) => `${key}=${value}`).join('&');
|
||||
|
||||
const verifier = crypto.createVerify('RSA-SHA256');
|
||||
verifier.update(signStr);
|
||||
return verifier.verify(formatPublicKey(alipayPublicKey), sign, 'base64');
|
||||
}
|
||||
59
src/lib/alipay/types.ts
Normal file
59
src/lib/alipay/types.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/** 支付宝电脑网站支付 bizContent */
|
||||
export interface AlipayTradePagePayBizContent {
|
||||
out_trade_no: string;
|
||||
product_code: 'FAST_INSTANT_TRADE_PAY';
|
||||
total_amount: string;
|
||||
subject: string;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
/** 支付宝统一响应结构 */
|
||||
export interface AlipayResponse {
|
||||
code: string;
|
||||
msg: string;
|
||||
sub_code?: string;
|
||||
sub_msg?: string;
|
||||
}
|
||||
|
||||
/** alipay.trade.query 响应 */
|
||||
export interface AlipayTradeQueryResponse extends AlipayResponse {
|
||||
trade_no?: string;
|
||||
out_trade_no?: string;
|
||||
trade_status?: string; // WAIT_BUYER_PAY, TRADE_CLOSED, TRADE_SUCCESS, TRADE_FINISHED
|
||||
total_amount?: string;
|
||||
send_pay_date?: string;
|
||||
}
|
||||
|
||||
/** alipay.trade.refund 响应 */
|
||||
export interface AlipayTradeRefundResponse extends AlipayResponse {
|
||||
trade_no?: string;
|
||||
out_trade_no?: string;
|
||||
refund_fee?: string;
|
||||
fund_change?: string; // Y/N
|
||||
}
|
||||
|
||||
/** alipay.trade.close 响应 */
|
||||
export interface AlipayTradeCloseResponse extends AlipayResponse {
|
||||
trade_no?: string;
|
||||
out_trade_no?: string;
|
||||
}
|
||||
|
||||
/** 异步通知参数 */
|
||||
export interface AlipayNotifyParams {
|
||||
notify_time: string;
|
||||
notify_type: string;
|
||||
notify_id: string;
|
||||
app_id: string;
|
||||
charset: string;
|
||||
version: string;
|
||||
sign_type: string;
|
||||
sign: string;
|
||||
trade_no: string;
|
||||
out_trade_no: string;
|
||||
trade_status: string;
|
||||
total_amount: string;
|
||||
receipt_amount?: string;
|
||||
buyer_pay_amount?: string;
|
||||
gmt_payment?: string;
|
||||
[key: string]: string | undefined;
|
||||
}
|
||||
@@ -16,7 +16,12 @@ const envSchema = z.object({
|
||||
PAYMENT_PROVIDERS: z
|
||||
.string()
|
||||
.default('')
|
||||
.transform((v) => v.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)),
|
||||
.transform((v) =>
|
||||
v
|
||||
.split(',')
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
),
|
||||
|
||||
// ── Easy-Pay(PAYMENT_PROVIDERS 含 easypay 时必填) ──
|
||||
EASY_PAY_PID: optionalTrimmedString,
|
||||
@@ -28,6 +33,13 @@ const envSchema = z.object({
|
||||
EASY_PAY_CID_ALIPAY: optionalTrimmedString,
|
||||
EASY_PAY_CID_WXPAY: optionalTrimmedString,
|
||||
|
||||
// ── 支付宝直连(PAYMENT_PROVIDERS 含 alipay 时必填) ──
|
||||
ALIPAY_APP_ID: optionalTrimmedString,
|
||||
ALIPAY_PRIVATE_KEY: optionalTrimmedString,
|
||||
ALIPAY_PUBLIC_KEY: optionalTrimmedString,
|
||||
ALIPAY_NOTIFY_URL: optionalTrimmedString,
|
||||
ALIPAY_RETURN_URL: optionalTrimmedString,
|
||||
|
||||
// ── Stripe(PAYMENT_PROVIDERS 含 stripe 时必填) ──
|
||||
STRIPE_SECRET_KEY: optionalTrimmedString,
|
||||
STRIPE_PUBLISHABLE_KEY: optionalTrimmedString,
|
||||
@@ -48,9 +60,21 @@ const envSchema = z.object({
|
||||
|
||||
// 每日各渠道全平台总限额,可选覆盖(0 = 不限制)。
|
||||
// 未设置时由各 PaymentProvider.defaultLimits 提供默认值。
|
||||
MAX_DAILY_AMOUNT_ALIPAY: z.string().optional().transform((v) => (v !== undefined ? Number(v) : undefined)).pipe(z.number().min(0).optional()),
|
||||
MAX_DAILY_AMOUNT_WXPAY: z.string().optional().transform((v) => (v !== undefined ? Number(v) : undefined)).pipe(z.number().min(0).optional()),
|
||||
MAX_DAILY_AMOUNT_STRIPE: z.string().optional().transform((v) => (v !== undefined ? Number(v) : undefined)).pipe(z.number().min(0).optional()),
|
||||
MAX_DAILY_AMOUNT_ALIPAY: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => (v !== undefined ? Number(v) : undefined))
|
||||
.pipe(z.number().min(0).optional()),
|
||||
MAX_DAILY_AMOUNT_WXPAY: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => (v !== undefined ? Number(v) : undefined))
|
||||
.pipe(z.number().min(0).optional()),
|
||||
MAX_DAILY_AMOUNT_STRIPE: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => (v !== undefined ? Number(v) : undefined))
|
||||
.pipe(z.number().min(0).optional()),
|
||||
PRODUCT_NAME: z.string().default('Sub2API Balance Recharge'),
|
||||
|
||||
ADMIN_TOKEN: z.string().min(1),
|
||||
|
||||
@@ -18,7 +18,7 @@ export class EasyPayProvider implements PaymentProvider {
|
||||
readonly supportedTypes: PaymentType[] = ['alipay', 'wxpay'];
|
||||
readonly defaultLimits = {
|
||||
alipay: { singleMax: 1000, dailyMax: 10000 },
|
||||
wxpay: { singleMax: 1000, dailyMax: 10000 },
|
||||
wxpay: { singleMax: 1000, dailyMax: 10000 },
|
||||
};
|
||||
|
||||
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
|
||||
|
||||
@@ -33,6 +33,6 @@ export function getMethodFeeRate(paymentType: string): number {
|
||||
*/
|
||||
export function calculatePayAmount(rechargeAmount: number, feeRate: number): number {
|
||||
if (feeRate <= 0) return rechargeAmount;
|
||||
const feeAmount = Math.ceil(rechargeAmount * feeRate / 100 * 100) / 100;
|
||||
const feeAmount = Math.ceil(((rechargeAmount * feeRate) / 100) * 100) / 100;
|
||||
return Math.round((rechargeAmount + feeAmount) * 100) / 100;
|
||||
}
|
||||
|
||||
@@ -64,9 +64,7 @@ export interface MethodLimitStatus {
|
||||
* 批量查询多个支付渠道的今日使用情况。
|
||||
* 一次 DB groupBy 完成,调用方按需传入渠道列表。
|
||||
*/
|
||||
export async function queryMethodLimits(
|
||||
paymentTypes: string[],
|
||||
): Promise<Record<string, MethodLimitStatus>> {
|
||||
export async function queryMethodLimits(paymentTypes: string[]): Promise<Record<string, MethodLimitStatus>> {
|
||||
const todayStart = new Date();
|
||||
todayStart.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
@@ -80,9 +78,7 @@ export async function queryMethodLimits(
|
||||
_sum: { amount: true },
|
||||
});
|
||||
|
||||
const usageMap = Object.fromEntries(
|
||||
usageRows.map((r) => [r.paymentType, Number(r._sum.amount ?? 0)]),
|
||||
);
|
||||
const usageMap = Object.fromEntries(usageRows.map((r) => [r.paymentType, Number(r._sum.amount ?? 0)]));
|
||||
|
||||
const result: Record<string, MethodLimitStatus> = {};
|
||||
for (const type of paymentTypes) {
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface CreateOrderResult {
|
||||
userBalance: number;
|
||||
payUrl?: string | null;
|
||||
qrCode?: string | null;
|
||||
checkoutUrl?: string | null;
|
||||
clientSecret?: string | null;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
@@ -65,11 +65,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
const alreadyPaid = Number(dailyAgg._sum.amount ?? 0);
|
||||
if (alreadyPaid + input.amount > env.MAX_DAILY_RECHARGE_AMOUNT) {
|
||||
const remaining = Math.max(0, env.MAX_DAILY_RECHARGE_AMOUNT - alreadyPaid);
|
||||
throw new OrderError(
|
||||
'DAILY_LIMIT_EXCEEDED',
|
||||
`今日累计充值已达上限,剩余可充值 ${remaining.toFixed(2)} 元`,
|
||||
429,
|
||||
);
|
||||
throw new OrderError('DAILY_LIMIT_EXCEEDED', `今日累计充值已达上限,剩余可充值 ${remaining.toFixed(2)} 元`, 429);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +166,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
userBalance: user.balance,
|
||||
payUrl: paymentResult.payUrl,
|
||||
qrCode: paymentResult.qrCode,
|
||||
checkoutUrl: paymentResult.checkoutUrl,
|
||||
clientSecret: paymentResult.clientSecret,
|
||||
expiresAt,
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -181,6 +177,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
|
||||
|
||||
// 支付网关配置缺失或调用失败,转成友好错误
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
console.error(`Payment gateway error (${input.paymentType}):`, error);
|
||||
if (msg.includes('environment variables') || msg.includes('not configured') || msg.includes('not found')) {
|
||||
throw new OrderError('PAYMENT_GATEWAY_ERROR', `支付渠道(${input.paymentType})暂未配置,请联系管理员`, 503);
|
||||
}
|
||||
@@ -604,7 +601,12 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
|
||||
});
|
||||
}
|
||||
|
||||
await subtractBalance(order.userId, rechargeAmount, `sub2apipay refund order:${order.id}`, `sub2apipay:refund:${order.id}`);
|
||||
await subtractBalance(
|
||||
order.userId,
|
||||
rechargeAmount,
|
||||
`sub2apipay refund order:${order.id}`,
|
||||
`sub2apipay:refund:${order.id}`,
|
||||
);
|
||||
|
||||
await prisma.order.update({
|
||||
where: { id: input.orderId },
|
||||
|
||||
@@ -38,12 +38,19 @@ export const FILTER_OPTIONS: { key: OrderStatusFilter; label: string }[] = [
|
||||
export function detectDeviceIsMobile(): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
// 1. 现代 API(Chromium 系浏览器,最准确)
|
||||
const uad = (navigator as Navigator & { userAgentData?: { mobile: boolean } }).userAgentData;
|
||||
if (uad !== undefined) return uad.mobile;
|
||||
|
||||
// 2. UA 正则兜底(Safari / Firefox 等)
|
||||
const ua = navigator.userAgent || '';
|
||||
const mobileUA = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Windows Phone|Mobile/i.test(ua);
|
||||
if (mobileUA) return true;
|
||||
|
||||
// 3. 触控 + 小屏兜底(新版 iPad UA 伪装成 Mac 的情况)
|
||||
const smallPhysicalScreen = Math.min(window.screen.width, window.screen.height) <= 768;
|
||||
const touchCapable = navigator.maxTouchPoints > 1;
|
||||
|
||||
return mobileUA || (touchCapable && smallPhysicalScreen);
|
||||
return touchCapable && smallPhysicalScreen;
|
||||
}
|
||||
|
||||
export function formatStatus(status: string): string {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { paymentRegistry } from './registry';
|
||||
import type { PaymentType } from './types';
|
||||
import { EasyPayProvider } from '@/lib/easy-pay/provider';
|
||||
import { StripeProvider } from '@/lib/stripe/provider';
|
||||
import { AlipayProvider } from '@/lib/alipay/provider';
|
||||
import { getEnv } from '@/lib/config';
|
||||
|
||||
export { paymentRegistry } from './registry';
|
||||
@@ -31,6 +32,13 @@ export function initPaymentProviders(): void {
|
||||
paymentRegistry.register(new EasyPayProvider());
|
||||
}
|
||||
|
||||
if (providers.includes('alipay')) {
|
||||
if (!env.ALIPAY_APP_ID || !env.ALIPAY_PRIVATE_KEY) {
|
||||
throw new Error('PAYMENT_PROVIDERS 含 alipay,但缺少 ALIPAY_APP_ID 或 ALIPAY_PRIVATE_KEY');
|
||||
}
|
||||
paymentRegistry.register(new AlipayProvider());
|
||||
}
|
||||
|
||||
if (providers.includes('stripe')) {
|
||||
if (!env.STRIPE_SECRET_KEY) {
|
||||
throw new Error('PAYMENT_PROVIDERS 含 stripe,但缺少 STRIPE_SECRET_KEY');
|
||||
@@ -43,7 +51,7 @@ export function initPaymentProviders(): void {
|
||||
if (unsupported.length > 0) {
|
||||
throw new Error(
|
||||
`ENABLED_PAYMENT_TYPES 含 [${unsupported.join(', ')}],但没有对应的 PAYMENT_PROVIDERS 注册。` +
|
||||
`请检查 PAYMENT_PROVIDERS 配置`,
|
||||
`请检查 PAYMENT_PROVIDERS 配置`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface CreatePaymentResponse {
|
||||
tradeNo: string; // third-party transaction ID
|
||||
payUrl?: string; // H5 payment URL (alipay/wxpay)
|
||||
qrCode?: string; // QR code content
|
||||
checkoutUrl?: string; // Stripe Checkout URL
|
||||
clientSecret?: string; // Stripe PaymentIntent client secret (for embedded Payment Element)
|
||||
}
|
||||
|
||||
/** Response from querying an order's payment status */
|
||||
|
||||
@@ -32,54 +32,45 @@ export class StripeProvider implements PaymentProvider {
|
||||
|
||||
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
|
||||
const stripe = this.getClient();
|
||||
const env = getEnv();
|
||||
|
||||
const timeoutMinutes = Math.max(30, env.ORDER_TIMEOUT_MINUTES); // Stripe minimum is 30 minutes
|
||||
const amountInCents = Math.round(new Prisma.Decimal(request.amount).mul(100).toNumber());
|
||||
|
||||
const session = await stripe.checkout.sessions.create(
|
||||
const pi = await stripe.paymentIntents.create(
|
||||
{
|
||||
mode: 'payment',
|
||||
payment_method_types: ['card'],
|
||||
line_items: [
|
||||
{
|
||||
price_data: {
|
||||
currency: 'cny',
|
||||
product_data: { name: request.subject },
|
||||
unit_amount: Math.round(new Prisma.Decimal(request.amount).mul(100).toNumber()),
|
||||
},
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
amount: amountInCents,
|
||||
currency: 'cny',
|
||||
automatic_payment_methods: { enabled: true },
|
||||
metadata: { orderId: request.orderId },
|
||||
expires_at: Math.floor(Date.now() / 1000) + timeoutMinutes * 60,
|
||||
success_url: `${env.NEXT_PUBLIC_APP_URL}/pay/result?order_id=${request.orderId}&status=success`,
|
||||
cancel_url: `${env.NEXT_PUBLIC_APP_URL}/pay/result?order_id=${request.orderId}&status=cancelled`,
|
||||
description: request.subject,
|
||||
},
|
||||
{ idempotencyKey: `checkout-${request.orderId}` },
|
||||
{ idempotencyKey: `pi-${request.orderId}` },
|
||||
);
|
||||
|
||||
return {
|
||||
tradeNo: session.id,
|
||||
checkoutUrl: session.url || undefined,
|
||||
tradeNo: pi.id,
|
||||
clientSecret: pi.client_secret || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async queryOrder(tradeNo: string): Promise<QueryOrderResponse> {
|
||||
const stripe = this.getClient();
|
||||
const session = await stripe.checkout.sessions.retrieve(tradeNo);
|
||||
const pi = await stripe.paymentIntents.retrieve(tradeNo);
|
||||
|
||||
let status: QueryOrderResponse['status'] = 'pending';
|
||||
if (session.payment_status === 'paid') status = 'paid';
|
||||
else if (session.status === 'expired') status = 'failed';
|
||||
if (pi.status === 'succeeded') status = 'paid';
|
||||
else if (pi.status === 'canceled') status = 'failed';
|
||||
|
||||
return {
|
||||
tradeNo: session.id,
|
||||
tradeNo: pi.id,
|
||||
status,
|
||||
amount: new Prisma.Decimal(session.amount_total || 0).div(100).toNumber(),
|
||||
amount: new Prisma.Decimal(pi.amount).div(100).toNumber(),
|
||||
};
|
||||
}
|
||||
|
||||
async verifyNotification(rawBody: string | Buffer, headers: Record<string, string>): Promise<PaymentNotification | null> {
|
||||
async verifyNotification(
|
||||
rawBody: string | Buffer,
|
||||
headers: Record<string, string>,
|
||||
): Promise<PaymentNotification | null> {
|
||||
const stripe = this.getClient();
|
||||
const env = getEnv();
|
||||
if (!env.STRIPE_WEBHOOK_SECRET) throw new Error('STRIPE_WEBHOOK_SECRET not configured');
|
||||
@@ -91,23 +82,23 @@ export class StripeProvider implements PaymentProvider {
|
||||
env.STRIPE_WEBHOOK_SECRET,
|
||||
);
|
||||
|
||||
if (event.type === 'checkout.session.completed' || event.type === 'checkout.session.async_payment_succeeded') {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
if (event.type === 'payment_intent.succeeded') {
|
||||
const pi = event.data.object as Stripe.PaymentIntent;
|
||||
return {
|
||||
tradeNo: session.id,
|
||||
orderId: session.metadata?.orderId || '',
|
||||
amount: new Prisma.Decimal(session.amount_total || 0).div(100).toNumber(),
|
||||
status: session.payment_status === 'paid' ? 'success' : 'failed',
|
||||
tradeNo: pi.id,
|
||||
orderId: pi.metadata?.orderId || '',
|
||||
amount: new Prisma.Decimal(pi.amount).div(100).toNumber(),
|
||||
status: 'success',
|
||||
rawData: event,
|
||||
};
|
||||
}
|
||||
|
||||
if (event.type === 'checkout.session.async_payment_failed') {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
if (event.type === 'payment_intent.payment_failed') {
|
||||
const pi = event.data.object as Stripe.PaymentIntent;
|
||||
return {
|
||||
tradeNo: session.id,
|
||||
orderId: session.metadata?.orderId || '',
|
||||
amount: new Prisma.Decimal(session.amount_total || 0).div(100).toNumber(),
|
||||
tradeNo: pi.id,
|
||||
orderId: pi.metadata?.orderId || '',
|
||||
amount: new Prisma.Decimal(pi.amount).div(100).toNumber(),
|
||||
status: 'failed',
|
||||
rawData: event,
|
||||
};
|
||||
@@ -120,12 +111,9 @@ export class StripeProvider implements PaymentProvider {
|
||||
async refund(request: RefundRequest): Promise<RefundResponse> {
|
||||
const stripe = this.getClient();
|
||||
|
||||
// Retrieve checkout session to find the payment intent
|
||||
const session = await stripe.checkout.sessions.retrieve(request.tradeNo);
|
||||
if (!session.payment_intent) throw new Error('No payment intent found for session');
|
||||
|
||||
// tradeNo is now the PaymentIntent ID directly
|
||||
const refund = await stripe.refunds.create({
|
||||
payment_intent: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent.id,
|
||||
payment_intent: request.tradeNo,
|
||||
amount: Math.round(new Prisma.Decimal(request.amount).mul(100).toNumber()),
|
||||
reason: 'requested_by_customer',
|
||||
});
|
||||
@@ -138,6 +126,6 @@ export class StripeProvider implements PaymentProvider {
|
||||
|
||||
async cancelPayment(tradeNo: string): Promise<void> {
|
||||
const stripe = this.getClient();
|
||||
await stripe.checkout.sessions.expire(tradeNo);
|
||||
await stripe.paymentIntents.cancel(tradeNo);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user