11 Commits

Author SHA1 Message Date
erio
e9e164babc chore: 添加 .gitattributes 强制 LF 行尾 2026-03-05 23:12:01 +08:00
erio
0a35ba9002 style: 全量 prettier 格式化 2026-03-05 23:10:44 +08:00
erio
ab961e669a fix: 修复 lint errors(hooks 条件调用、未转义引号、effect 内 setState) 2026-03-05 23:08:48 +08:00
eriol touwa
93a417b312 Merge pull request #4 from dexcoder6/feat/admin-dashboard
fix: 数据看板时区统一为 Asia/Shanghai + 订单列表支付方式显示修复
2026-03-05 23:03:24 +08:00
erio
ba1ce6b696 fix: CI 各 job 添加 prisma generate 步骤
Prisma 7 需要先 generate 才能生成 @prisma/client 类型,
缺少此步骤导致 typecheck/lint/test 全部失败。
2026-03-05 23:02:27 +08:00
erio
448d36fe2b feat: 移动端 H5 支付跳转 + 改进移动端检测
- PaymentQRCode: 移动端有 payUrl 时直接跳转支付,iframe 中新窗口打开
- detectDeviceIsMobile: 优先使用 navigator.userAgentData.mobile API
2026-03-05 16:21:12 +08:00
miwei
f1e3fd35ef fix: 数据看板时区统一为 Asia/Shanghai + 订单列表支付方式显示修复
- dashboard API 日期计算和 SQL GROUP BY 统一使用东八区,避免 UTC 服务器上"今日"统计偏移
- OrderTable 支付方式显示支持 stripe/wechat/alipay 三种,修复 stripe 误显为微信支付

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:13:57 +08:00
erio
8746f474d1 refactor: 支付宝 providerKey 改为 alipay 2026-03-05 01:52:59 +08:00
erio
55756744a1 feat: 集成支付宝电脑网站支付(alipay direct)
- 新增 src/lib/alipay/ 模块:RSA2 签名、网关客户端、AlipayProvider
- 新增 /api/alipay/notify 异步通知回调路由
- config.ts 添加 ALIPAY_* 环境变量
- payment/index.ts 注册 alipaydirect 提供商
- 27 个单元测试全部通过
2026-03-05 01:48:10 +08:00
erio
9a90a7ebb9 fix: embedded 模式下也强制 min-h-screen,防止 dark 模式底部白底 2026-03-04 21:55:43 +08:00
erio
f96f89b7bb fix: 移除 body 硬编码背景色,修复 dark 模式底部白底问题 2026-03-04 21:23:48 +08:00
46 changed files with 1389 additions and 417 deletions

1
.gitattributes vendored Normal file
View File

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

View File

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

1
.idea/vcs.xml generated
View File

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

View File

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

117
README.md
View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,9 +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,
stripePublishableKey:
env.ENABLED_PAYMENT_TYPES.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY
? env.STRIPE_PUBLISHABLE_KEY
: null,
},
});
} catch (error) {

View File

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

View File

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

View File

@@ -187,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'}`}>
@@ -290,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}
@@ -311,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
@@ -354,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(' ')}
>
@@ -368,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(' ')}
>
@@ -382,9 +388,7 @@ function PayContent() {
{step === 'form' && config.enabledPaymentTypes.length === 0 && (
<div className="flex items-center justify-center py-12">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
<span className={['ml-3 text-sm', isDark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
...
</span>
<span className={['ml-3 text-sm', isDark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>...</span>
</div>
)}
@@ -432,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>
))}
@@ -486,12 +505,11 @@ function PayContent() {
onBack={handleBack}
dark={isDark}
isEmbedded={isEmbedded}
isMobile={isMobile}
/>
)}
{step === 'result' && (
<OrderStatus status={finalStatus} onBack={handleBack} dark={isDark} />
)}
{step === 'result' && <OrderStatus status={finalStatus} onBack={handleBack} dark={isDark} />}
{helpImageOpen && helpImageUrl && (
<div
@@ -500,8 +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>

View File

@@ -64,7 +64,7 @@ function ResultContent() {
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>
);
@@ -73,7 +73,7 @@ function ResultContent() {
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 ? (
<>
@@ -147,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>
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { PAYMENT_TYPE_META } from '@/lib/pay-utils';
export interface MethodLimitInfo {
@@ -49,11 +49,9 @@ export default function PaymentForm({
const [customAmount, setCustomAmount] = useState('');
// Reset paymentType when enabledPaymentTypes changes (e.g. after config loads)
useEffect(() => {
if (!enabledPaymentTypes.includes(paymentType)) {
setPaymentType(enabledPaymentTypes[0] || 'stripe');
}
}, [enabledPaymentTypes, paymentType]);
const effectivePaymentType = enabledPaymentTypes.includes(paymentType)
? paymentType
: enabledPaymentTypes[0] || 'stripe';
const handleQuickAmount = (val: number) => {
setAmount(val);
@@ -81,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) => {
@@ -215,19 +214,17 @@ export default function PaymentForm({
</div>
</div>
{customAmount !== '' && !isValid && (() => {
const num = parseFloat(customAmount);
let msg = '金额需在范围内,且最多支持 2 位小数(精确到分)';
if (!isNaN(num)) {
if (num < minAmount) msg = `单笔最低充值 ¥${minAmount}`;
else if (num > effectiveMax) msg = `单笔最高充值 ¥${effectiveMax}`;
}
return (
<div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>
{msg}
</div>
);
})()}
{customAmount !== '' &&
!isValid &&
(() => {
const num = parseFloat(customAmount);
let msg = '金额需在范围内,且最多支持 2 位小数(精确到分)';
if (!isNaN(num)) {
if (num < minAmount) msg = `单笔最低充值 ¥${minAmount}`;
else if (num > effectiveMax) msg = `单笔最高充值 ¥${effectiveMax}`;
}
return <div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>{msg}</div>;
})()}
{/* Payment Type — only show when multiple types available */}
{enabledPaymentTypes.length > 1 && (
@@ -238,7 +235,7 @@ export default function PaymentForm({
<div className="flex gap-3">
{enabledPaymentTypes.map((type) => {
const meta = PAYMENT_TYPE_META[type];
const isSelected = paymentType === type;
const isSelected = effectivePaymentType === type;
const limitInfo = methodLimits?.[type];
const isUnavailable = limitInfo !== undefined && !limitInfo.available;
@@ -284,7 +281,7 @@ export default function PaymentForm({
{/* 当前选中渠道额度不足时的提示 */}
{(() => {
const limitInfo = methodLimits?.[paymentType];
const limitInfo = methodLimits?.[effectivePaymentType];
if (!limitInfo || limitInfo.available) return null;
return (
<p className={['mt-2 text-xs', dark ? 'text-amber-300' : 'text-amber-600'].join(' ')}>
@@ -311,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>
@@ -327,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
@@ -335,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>
);

View File

@@ -18,6 +18,7 @@ interface PaymentQRCodeProps {
onBack: () => void;
dark?: boolean;
isEmbedded?: boolean;
isMobile?: boolean;
}
const TEXT_EXPIRED = '\u8BA2\u5355\u5DF2\u8D85\u65F6';
@@ -26,6 +27,8 @@ const TEXT_GO_PAY = '\u70B9\u51FB\u524D\u5F80\u652F\u4ED8';
const TEXT_SCAN_PAY = '\u8BF7\u4F7F\u7528\u652F\u4ED8\u5E94\u7528\u626B\u7801\u652F\u4ED8';
const TEXT_BACK = '\u8FD4\u56DE';
const TEXT_CANCEL_ORDER = '\u53D6\u6D88\u8BA2\u5355';
const TEXT_H5_HINT =
'\u652F\u4ED8\u5B8C\u6210\u540E\u8BF7\u8FD4\u56DE\u6B64\u9875\u9762\uFF0C\u7CFB\u7EDF\u5C06\u81EA\u52A8\u786E\u8BA4';
const TERMINAL_STATUSES = new Set(['COMPLETED', 'FAILED', 'CANCELLED', 'EXPIRED', 'REFUNDED', 'REFUND_FAILED']);
export default function PaymentQRCode({
@@ -43,6 +46,7 @@ export default function PaymentQRCode({
onBack,
dark = false,
isEmbedded = false,
isMobile = false,
}: PaymentQRCodeProps) {
const displayAmount = payAmountProp ?? amount;
const hasFeeDiff = payAmountProp !== undefined && payAmountProp !== amount;
@@ -212,11 +216,7 @@ export default function PaymentQRCode({
popupUrl.searchParams.set('theme', dark ? 'dark' : 'light');
popupUrl.searchParams.set('method', stripePaymentMethod);
const popup = window.open(
popupUrl.toString(),
'stripe_payment',
'width=500,height=700,scrollbars=yes',
);
const popup = window.open(popupUrl.toString(), 'stripe_payment', 'width=500,height=700,scrollbars=yes');
if (!popup || popup.closed) {
setPopupBlocked(true);
return;
@@ -225,11 +225,14 @@ export default function PaymentQRCode({
const onReady = (event: MessageEvent) => {
if (event.source !== popup || event.data?.type !== 'STRIPE_POPUP_READY') return;
window.removeEventListener('message', onReady);
popup.postMessage({
type: 'STRIPE_POPUP_INIT',
clientSecret,
publishableKey: stripePublishableKey,
}, window.location.origin);
popup.postMessage(
{
type: 'STRIPE_POPUP_INIT',
clientSecret,
publishableKey: stripePublishableKey,
},
window.location.origin,
);
};
window.addEventListener('message', onReady);
};
@@ -321,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}
@@ -336,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)}
@@ -352,7 +360,12 @@ export default function PaymentQRCode({
{isStripe ? (
<div className="w-full max-w-md space-y-4">
{!clientSecret || !stripePublishableKey ? (
<div className={['rounded-lg border-2 border-dashed p-8 text-center', dark ? 'border-slate-700' : 'border-gray-300'].join(' ')}>
<div
className={[
'rounded-lg border-2 border-dashed p-8 text-center',
dark ? 'border-slate-700' : 'border-gray-300',
].join(' ')}
>
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
</p>
@@ -365,14 +378,15 @@ export default function PaymentQRCode({
</span>
</div>
) : stripeError && !stripeLib ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
{stripeError}
</div>
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">{stripeError}</div>
) : (
<>
<div
ref={stripeContainerRef}
className={['rounded-lg border p-4', dark ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-white'].join(' ')}
className={[
'rounded-lg border p-4',
dark ? 'border-slate-700 bg-slate-900' : 'border-gray-200 bg-white',
].join(' ')}
/>
{stripeError && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
@@ -409,17 +423,44 @@ export default function PaymentQRCode({
</button>
)}
{popupBlocked && (
<div className={['rounded-lg border p-3 text-sm', dark ? 'border-amber-700 bg-amber-900/30 text-amber-300' : 'border-amber-200 bg-amber-50 text-amber-700'].join(' ')}>
<div
className={[
'rounded-lg border p-3 text-sm',
dark
? 'border-amber-700 bg-amber-900/30 text-amber-300'
: 'border-amber-200 bg-amber-50 text-amber-700',
].join(' ')}
>
</div>
)}
</>
)}
</div>
) : 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(' ')}>
{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" />
@@ -447,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>
@@ -466,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}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,16 +21,30 @@ const TYPE_CONFIG: Record<string, { label: string; light: string; dark: string }
export default function PaymentMethodChart({ data, dark }: PaymentMethodChartProps) {
if (data.length === 0) {
return (
<div className={['rounded-xl border p-6', dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm'].join(' ')}>
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}></h3>
<div
className={[
'rounded-xl border p-6',
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
].join(' ')}
>
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
</h3>
<p className={['text-center text-sm py-8', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}></p>
</div>
);
}
return (
<div className={['rounded-xl border p-6', dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm'].join(' ')}>
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}></h3>
<div
className={[
'rounded-xl border p-6',
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
].join(' ')}
>
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
</h3>
<div className="space-y-4">
{data.map((method) => {
const config = TYPE_CONFIG[method.paymentType] || {
@@ -46,7 +60,11 @@ export default function PaymentMethodChart({ data, dark }: PaymentMethodChartPro
¥{method.amount.toLocaleString()} · {method.percentage}%
</span>
</div>
<div className={['h-3 w-full overflow-hidden rounded-full', dark ? 'bg-slate-700' : 'bg-slate-100'].join(' ')}>
<div
className={['h-3 w-full overflow-hidden rounded-full', dark ? 'bg-slate-700' : 'bg-slate-100'].join(
' ',
)}
>
<div
className={['h-full rounded-full transition-all', dark ? config.dark : config.light].join(' ')}
style={{ width: `${method.percentage}%` }}

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

@@ -0,0 +1,98 @@
import { getEnv } from '@/lib/config';
import { generateSign } from './sign';
import type { AlipayResponse } from './types';
const GATEWAY = 'https://openapi.alipay.com/gateway.do';
function getCommonParams(appId: string): Record<string, string> {
return {
app_id: appId,
format: 'JSON',
charset: 'utf-8',
sign_type: 'RSA2',
timestamp: new Date().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;
};
}
/**
* 生成电脑网站支付的跳转 URLGET 方式)
* 用于 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}`;
}
/**
* 调用支付宝服务端 APIPOST 方式)
* 用于 alipay.trade.query、alipay.trade.refund、alipay.trade.close
*/
export async function execute<T extends AlipayResponse>(
method: string,
bizContent: Record<string, unknown>,
): Promise<T> {
const env = assertAlipayEnv(getEnv());
const params: Record<string, string> = {
...getCommonParams(env.ALIPAY_APP_ID),
method,
biz_content: JSON.stringify(bizContent),
};
params.sign = generateSign(params, env.ALIPAY_PRIVATE_KEY);
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
View 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
View 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
View File

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

View File

@@ -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-PayPAYMENT_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,
// ── StripePAYMENT_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),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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