Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
af9820a2ee | ||
|
|
a3f3fa83f1 | ||
|
|
2590145a2c | ||
|
|
e2018cbcf9 | ||
|
|
a1d3f3b639 | ||
|
|
58d4c7efbf | ||
|
|
a7089936a4 | ||
|
|
6bca9853b3 | ||
|
|
33e4a811f3 | ||
|
|
0a94cecad8 | ||
|
|
b3730b567e | ||
|
|
9af7133d93 | ||
|
|
1f2d0499ed | ||
|
|
ae3aa2e0e4 | ||
|
|
48244f270b | ||
|
|
6dd7583b6c | ||
|
|
bd1db1efd8 | ||
|
|
ef4241b82f | ||
|
|
4ce3484179 | ||
|
|
34ad876626 | ||
|
|
e98f01f472 | ||
|
|
8f0ec3d9de | ||
|
|
b44a8db9ef | ||
|
|
ac70635879 | ||
|
|
87a6237a8f | ||
|
|
6d01fcb822 | ||
|
|
886389939e | ||
|
|
78ecd206de | ||
|
|
d30c663f29 | ||
|
|
48e94c205a | ||
|
|
3b5a3ba5df | ||
|
|
8a5f8662d0 | ||
|
|
5050544d4b | ||
|
|
45713aeb57 | ||
|
|
8dd0d1144b | ||
|
|
5ce7ba3cb8 | ||
|
|
d8078eb38c | ||
|
|
14ec33fc69 | ||
|
|
aeaa09d2c1 | ||
|
|
10e9189bcb | ||
|
|
4427c17417 | ||
|
|
6e0fe54720 | ||
|
|
1218b31461 | ||
|
|
10e3e445ed | ||
|
|
ce223f09d8 | ||
|
|
405fab8c8d | ||
|
|
6c61c3f877 | ||
|
|
1bb11ee32b | ||
|
|
ca03a501f2 | ||
|
|
38156bd4ef | ||
|
|
bc9ae8370c | ||
|
|
b1c90d4b04 | ||
|
|
41f7059585 | ||
|
|
1399cd277b | ||
|
|
687336cfd8 | ||
|
|
9096271307 | ||
|
|
eafb7e49fa | ||
|
|
9f621713c3 | ||
|
|
abff49222b | ||
|
|
1cb82d8fd7 | ||
|
|
d6973256a7 | ||
|
|
8b10bc3bd5 |
429
.claude/plan.md
Normal file
429
.claude/plan.md
Normal file
@@ -0,0 +1,429 @@
|
||||
# Sub2ApiPay 改造方案
|
||||
|
||||
## 一、概述
|
||||
|
||||
基于 Pincc 参考界面,改造 Sub2ApiPay 项目,新增:
|
||||
|
||||
- **用户页面**:双 Tab(按量付费 / 包月套餐),渠道卡片展示,充值弹窗,订阅购买流程
|
||||
- **管理员界面**:渠道管理、订阅套餐管理、系统配置
|
||||
- **数据库存储配置**:支付渠道等配置从环境变量迁移至数据库,支持运行时修改
|
||||
|
||||
---
|
||||
|
||||
## 二、数据库 Schema 变更
|
||||
|
||||
### 2.1 新增模型
|
||||
|
||||
```prisma
|
||||
// 渠道展示配置(管理员配置,对应 Sub2API 的 group)
|
||||
model Channel {
|
||||
id String @id @default(cuid())
|
||||
groupId Int @unique @map("group_id") // Sub2API group ID
|
||||
name String // 显示名称
|
||||
platform String @default("claude") // 分类: claude/openai/gemini/codex
|
||||
rateMultiplier Decimal @db.Decimal(10, 4) @map("rate_multiplier") // 倍率
|
||||
description String? @db.Text // 描述
|
||||
models String? @db.Text // JSON数组: 支持的模型列表
|
||||
features String? @db.Text // JSON数组: 功能特性列表
|
||||
sortOrder Int @default(0) @map("sort_order") // 排序
|
||||
enabled Boolean @default(true) // 是否启用
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([sortOrder])
|
||||
@@map("channels")
|
||||
}
|
||||
|
||||
// 订阅套餐配置(管理员配置价格后才可售卖)
|
||||
model SubscriptionPlan {
|
||||
id String @id @default(cuid())
|
||||
groupId Int @unique @map("group_id") // Sub2API group ID
|
||||
name String // 套餐名称
|
||||
description String? @db.Text // 描述
|
||||
price Decimal @db.Decimal(10, 2) // CNY 价格
|
||||
originalPrice Decimal? @db.Decimal(10, 2) @map("original_price") // 原价(划线价)
|
||||
validityDays Int @default(30) @map("validity_days") // 有效期天数
|
||||
features String? @db.Text // JSON数组: 特性描述
|
||||
forSale Boolean @default(false) @map("for_sale") // 是否启用售卖
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
orders Order[]
|
||||
|
||||
@@index([forSale, sortOrder])
|
||||
@@map("subscription_plans")
|
||||
}
|
||||
|
||||
// 系统配置(键值对,支持运行时修改)
|
||||
model SystemConfig {
|
||||
key String @id
|
||||
value String @db.Text
|
||||
group String @default("general") // general / payment / limits / display
|
||||
label String? // 配置项显示名称
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([group])
|
||||
@@map("system_configs")
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 扩展 Order 模型
|
||||
|
||||
在现有 Order 模型上新增字段,复用整套支付流程:
|
||||
|
||||
```prisma
|
||||
model Order {
|
||||
// ... 现有字段不变 ...
|
||||
|
||||
// 新增:订单类型
|
||||
orderType String @default("balance") @map("order_type") // "balance" | "subscription"
|
||||
|
||||
// 新增:订阅相关(orderType="subscription" 时有值)
|
||||
planId String? @map("plan_id")
|
||||
plan SubscriptionPlan? @relation(fields: [planId], references: [id])
|
||||
subscriptionGroupId Int? @map("subscription_group_id") // Sub2API group ID
|
||||
subscriptionDays Int? @map("subscription_days") // 购买时的有效天数
|
||||
|
||||
// 新增索引
|
||||
@@index([orderType])
|
||||
}
|
||||
```
|
||||
|
||||
**设计理由**:订阅订单和余额充值订单共享同一套支付流程(创建→支付→回调→履约),仅在最终「履约」步骤不同:
|
||||
|
||||
- `balance`:调用 `createAndRedeem()` 充值余额
|
||||
- `subscription`:调用 Sub2API `POST /admin/subscriptions/assign` 分配订阅
|
||||
|
||||
---
|
||||
|
||||
## 三、Sub2API Client 扩展
|
||||
|
||||
在 `src/lib/sub2api/client.ts` 新增方法:
|
||||
|
||||
```typescript
|
||||
// 获取所有分组(管理员)
|
||||
async function getAllGroups(): Promise<Sub2ApiGroup[]>;
|
||||
// GET /api/v1/admin/groups/all
|
||||
|
||||
// 获取单个分组
|
||||
async function getGroup(groupId: number): Promise<Sub2ApiGroup | null>;
|
||||
// GET /api/v1/admin/groups/:id
|
||||
|
||||
// 分配订阅(支付成功后调用)
|
||||
async function assignSubscription(
|
||||
userId: number,
|
||||
groupId: number,
|
||||
validityDays: number,
|
||||
notes?: string,
|
||||
): Promise<Sub2ApiSubscription>;
|
||||
// POST /api/v1/admin/subscriptions/assign
|
||||
|
||||
// 获取用户的订阅列表
|
||||
async function getUserSubscriptions(userId: number): Promise<Sub2ApiSubscription[]>;
|
||||
// GET /api/v1/admin/users/:id/subscriptions
|
||||
|
||||
// 延长订阅(续费)
|
||||
async function extendSubscription(subscriptionId: number, days: number): Promise<void>;
|
||||
// POST /api/v1/admin/subscriptions/:id/extend
|
||||
```
|
||||
|
||||
类型定义:
|
||||
|
||||
```typescript
|
||||
interface Sub2ApiGroup {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
platform: string;
|
||||
status: string;
|
||||
rate_multiplier: number;
|
||||
subscription_type: string;
|
||||
daily_limit_usd: number | null;
|
||||
weekly_limit_usd: number | null;
|
||||
monthly_limit_usd: number | null;
|
||||
default_validity_days: number;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
interface Sub2ApiSubscription {
|
||||
id: number;
|
||||
user_id: number;
|
||||
group_id: number;
|
||||
starts_at: string;
|
||||
expires_at: string;
|
||||
status: string;
|
||||
daily_usage_usd: number;
|
||||
weekly_usage_usd: number;
|
||||
monthly_usage_usd: number;
|
||||
notes: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、API 路由新增
|
||||
|
||||
### 4.1 用户 API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ---- | ------------------------- | --------------------------------------------------------------- |
|
||||
| GET | `/api/channels` | 获取已启用的渠道列表(用户token验证 + 校验Sub2API分组是否存在) |
|
||||
| GET | `/api/subscription-plans` | 获取可售卖的订阅套餐列表(同上校验) |
|
||||
| POST | `/api/orders` | **扩展**:支持 `order_type: "subscription"` + `plan_id` |
|
||||
| GET | `/api/subscriptions/my` | 获取当前用户的活跃订阅列表 |
|
||||
|
||||
### 4.2 管理员 API
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ------ | ------------------------------------ | --------------------------------- |
|
||||
| GET | `/api/admin/channels` | 渠道列表(含Sub2API分组同步状态) |
|
||||
| POST | `/api/admin/channels` | 创建/更新渠道 |
|
||||
| PUT | `/api/admin/channels/[id]` | 更新渠道 |
|
||||
| DELETE | `/api/admin/channels/[id]` | 删除渠道 |
|
||||
| GET | `/api/admin/sub2api/groups` | 从Sub2API拉取所有分组(供选择) |
|
||||
| GET | `/api/admin/subscription-plans` | 订阅套餐列表 |
|
||||
| POST | `/api/admin/subscription-plans` | 创建套餐 |
|
||||
| PUT | `/api/admin/subscription-plans/[id]` | 更新套餐 |
|
||||
| DELETE | `/api/admin/subscription-plans/[id]` | 删除套餐 |
|
||||
| GET | `/api/admin/subscriptions` | 所有用户的订阅列表 |
|
||||
| GET | `/api/admin/config` | 获取系统配置 |
|
||||
| PUT | `/api/admin/config` | 批量更新系统配置 |
|
||||
|
||||
---
|
||||
|
||||
## 五、订单服务改造
|
||||
|
||||
### 5.1 订单创建(扩展 `createOrder`)
|
||||
|
||||
```typescript
|
||||
interface CreateOrderInput {
|
||||
// 现有字段...
|
||||
orderType?: 'balance' | 'subscription'; // 新增
|
||||
planId?: string; // 新增(订阅时必填)
|
||||
}
|
||||
```
|
||||
|
||||
订阅订单创建时的校验逻辑:
|
||||
|
||||
1. 验证 `planId` 对应的 SubscriptionPlan 存在且 `forSale=true`
|
||||
2. 调用 Sub2API 验证 `groupId` 对应的分组仍然存在且 status=active
|
||||
3. 金额使用 plan.price(不允许用户自定义)
|
||||
4. 其余流程(支付方式选择、限额检查等)与余额订单一致
|
||||
|
||||
### 5.2 订单履约(修改 `executeRecharge` → `executeFulfillment`)
|
||||
|
||||
```
|
||||
if (order.orderType === 'subscription') {
|
||||
// 1. 再次验证 Sub2API 分组存在
|
||||
const group = await getGroup(order.subscriptionGroupId)
|
||||
if (!group || group.status !== 'active') {
|
||||
// 标记 FAILED,reason = "订阅分组已不存在"
|
||||
// 前端展示常驻错误提示
|
||||
return
|
||||
}
|
||||
// 2. 调用 Sub2API 分配订阅
|
||||
await assignSubscription(order.userId, order.subscriptionGroupId, order.subscriptionDays)
|
||||
// 3. 标记 COMPLETED
|
||||
} else {
|
||||
// 原有余额充值逻辑不变
|
||||
await createAndRedeem(...)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 订阅退款
|
||||
|
||||
订阅订单的退款需要额外步骤:撤销 Sub2API 中的订阅(`DELETE /admin/subscriptions/:id`)。
|
||||
如果撤销失败,标记为 REFUND_FAILED 并记录审计日志,需人工介入。
|
||||
|
||||
---
|
||||
|
||||
## 六、用户页面改造
|
||||
|
||||
### 6.1 页面结构(参考 Pincc 的 top-up-main.png)
|
||||
|
||||
```
|
||||
/pay 页面
|
||||
├── 顶部标题区:"选择适合你的 订阅套餐"
|
||||
├── 双 Tab 切换:[ 按量付费 | 包月套餐 ]
|
||||
│
|
||||
├── Tab 1: 按量付费
|
||||
│ ├── Banner: 按量付费模式说明(倍率换算、余额通用等)
|
||||
│ ├── 渠道卡片网格(3列,从 /api/channels 获取)
|
||||
│ │ └── 每张卡片:平台标签 + 名称 + 倍率 + 余额换算 + 描述 + 模型标签 + 功能标签 + "立即充值" 按钮
|
||||
│ └── 点击"立即充值" → 弹出充值金额选择弹窗(参考 top-up.png)
|
||||
│ └── 金额网格(管理员可配置档位)→ "确认充值" → 支付方式选择 → 支付流程
|
||||
│
|
||||
├── Tab 2: 包月套餐
|
||||
│ ├── 订阅套餐卡片(从 /api/subscription-plans 获取)
|
||||
│ │ └── 每张卡片:套餐名 + 价格/月 + 划线原价 + 限额特性列表 + "立即开通" 按钮
|
||||
│ └── 点击"立即开通" → 确认订单页(参考 subscribe.png)
|
||||
│ └── 套餐详情 + 价格 + 选择支付方式 + "立即购买"
|
||||
│ (注:我们的用户已通过 token 认证,不需要 Pincc 的邮箱/密码输入框)
|
||||
│
|
||||
├── 用户已有订阅展示区
|
||||
│ └── 活跃订阅列表 + 到期提醒 + "续费" 按钮
|
||||
│
|
||||
└── 底部:购买流程说明 + 温馨提示
|
||||
```
|
||||
|
||||
**条件逻辑**:
|
||||
|
||||
- 如果管理员 **没有配置渠道**(Channel 表为空)→ 直接显示现有的充值界面(PaymentForm),不显示卡片
|
||||
- 如果管理员 **配置了渠道** → 显示渠道卡片网格,点击"立即充值"弹出金额选择弹窗
|
||||
- 如果管理员 **没有配置订阅套餐**(SubscriptionPlan 无 forSale=true)→ 隐藏"包月套餐" Tab
|
||||
|
||||
### 6.2 新增组件
|
||||
|
||||
| 组件 | 说明 |
|
||||
| -------------------------- | -------------------------------------- |
|
||||
| `ChannelCard.tsx` | 渠道卡片(平台标签、倍率、模型标签等) |
|
||||
| `ChannelGrid.tsx` | 渠道卡片网格容器 |
|
||||
| `TopUpModal.tsx` | 充值金额选择弹窗 |
|
||||
| `SubscriptionPlanCard.tsx` | 订阅套餐卡片 |
|
||||
| `SubscriptionConfirm.tsx` | 订阅确认订单页 |
|
||||
| `UserSubscriptions.tsx` | 用户已有订阅展示 |
|
||||
| `MainTabs.tsx` | 按量付费/包月套餐 Tab 切换 |
|
||||
| `PurchaseFlow.tsx` | 购买流程说明(4步骤图标) |
|
||||
|
||||
### 6.3 异常处理
|
||||
|
||||
- 支付成功但订阅分组不存在:前端显示**常驻红色告警框**,包含:
|
||||
- 错误说明:"您已成功支付,但订阅分组已下架,无法自动开通"
|
||||
- 订单信息(订单号、金额、支付时间)
|
||||
- 引导:"请联系客服处理,提供订单号 xxx"
|
||||
|
||||
---
|
||||
|
||||
## 七、管理员页面新增
|
||||
|
||||
### 7.1 页面路由
|
||||
|
||||
| 路由 | 说明 |
|
||||
| ---------------------- | -------------------------------------------------- |
|
||||
| `/admin/channels` | 渠道管理(列表 + 编辑弹窗,参考 channel-conf.png) |
|
||||
| `/admin/subscriptions` | 订阅套餐管理 + 已有订阅列表 |
|
||||
| `/admin/settings` | 系统配置(支付渠道配置、业务参数等) |
|
||||
|
||||
### 7.2 渠道管理页(/admin/channels)
|
||||
|
||||
- 顶部操作栏:[从 Sub2API 同步分组] [新建渠道]
|
||||
- 渠道列表表格:名称 | 分类 | 倍率 | Sub2API状态 | 排序 | 启用 | 操作
|
||||
- 编辑弹窗(参考 channel-conf.png):
|
||||
- 渠道名称、分类(下拉)、倍率、描述
|
||||
- 支持模型(textarea,每行一个)
|
||||
- 功能特性(textarea,每行一个)
|
||||
- 排序、启用开关
|
||||
- "从 Sub2API 同步":拉取所有分组 → 显示差异 → 可选批量导入
|
||||
|
||||
### 7.3 订阅套餐管理页(/admin/subscriptions)
|
||||
|
||||
两个区域:
|
||||
|
||||
1. **套餐配置**:
|
||||
- 列表:套餐名 | 关联分组 | 价格 | 有效天数 | 启用售卖 | Sub2API状态 | 操作
|
||||
- 新建/编辑表单:选择 Sub2API 分组 → 配置名称、价格、原价、有效天数、特性描述、启用售卖
|
||||
|
||||
2. **已有订阅**:
|
||||
- 从 Sub2API 查询所有订阅记录
|
||||
- 表格:用户 | 分组 | 开始时间 | 到期时间 | 状态 | 用量
|
||||
|
||||
### 7.4 系统配置页(/admin/settings)
|
||||
|
||||
分组展示:
|
||||
|
||||
- **支付渠道配置**:PAYMENT_PROVIDERS、各支付商的 Key/密钥等(敏感字段脱敏显示)
|
||||
- **业务参数**:ORDER_TIMEOUT_MINUTES、MIN/MAX_RECHARGE_AMOUNT、MAX_DAILY_RECHARGE_AMOUNT 等
|
||||
- **充值档位配置**:自定义充值金额选项(如 50/100/500/1000)
|
||||
- **显示配置**:PAY*HELP_IMAGE_URL、PAY_HELP_TEXT、PAYMENT_SUBLABEL*\* 等
|
||||
- **前端定制**:站点名称、联系客服信息等
|
||||
|
||||
配置优先级:**数据库配置 > 环境变量**(环境变量作为默认值/回退值)
|
||||
|
||||
---
|
||||
|
||||
## 八、配置系统改造
|
||||
|
||||
### 8.1 `getConfig()` 函数改造
|
||||
|
||||
```typescript
|
||||
// 新的配置读取优先级:
|
||||
// 1. 数据库 SystemConfig 表(运行时可修改)
|
||||
// 2. 环境变量(作为回退/初始值)
|
||||
|
||||
async function getConfig(key: string): Promise<string | undefined> {
|
||||
const dbConfig = await prisma.systemConfig.findUnique({ where: { key } })
|
||||
if (dbConfig) return dbConfig.value
|
||||
return process.env[key]
|
||||
}
|
||||
|
||||
// 批量获取(带缓存,避免频繁查DB)
|
||||
async function getConfigs(keys: string[]): Promise<Record<string, string>> { ... }
|
||||
```
|
||||
|
||||
### 8.2 缓存策略
|
||||
|
||||
- 使用内存缓存(Map + TTL 30秒),避免每次请求都查数据库
|
||||
- 管理员更新配置时清除缓存
|
||||
- 支付商密钥等敏感配置仍可通过环境变量传入(数据库中存储 `__FROM_ENV__` 标记表示使用环境变量值)
|
||||
|
||||
---
|
||||
|
||||
## 九、管理员入口
|
||||
|
||||
管理员通过以下方式进入:
|
||||
|
||||
1. Sub2API 管理面板中跳转(携带 admin token)
|
||||
2. 直接访问 `/admin?token=xxx`(现有机制)
|
||||
|
||||
管理员页面新增导航侧边栏:
|
||||
|
||||
- 订单管理(现有)
|
||||
- 数据概览(现有)
|
||||
- **渠道管理**(新增)
|
||||
- **订阅管理**(新增)
|
||||
- **系统配置**(新增)
|
||||
|
||||
---
|
||||
|
||||
## 十、实施顺序
|
||||
|
||||
### Phase 1:数据库 & 基础设施(预估 2-3 步)
|
||||
|
||||
1. Prisma schema 变更 + migration
|
||||
2. SystemConfig 服务层(CRUD + 缓存)
|
||||
3. Sub2API client 扩展(分组/订阅 API)
|
||||
|
||||
### Phase 2:管理员 API & 页面(预估 4-5 步)
|
||||
|
||||
4. 渠道管理 API + 页面
|
||||
5. 订阅套餐管理 API + 页面
|
||||
6. 系统配置 API + 页面
|
||||
7. 管理员导航侧边栏
|
||||
|
||||
### Phase 3:订单服务改造(预估 2 步)
|
||||
|
||||
8. Order 模型扩展 + 订阅订单创建逻辑
|
||||
9. 订阅履约逻辑(executeSubscriptionFulfillment)
|
||||
|
||||
### Phase 4:用户页面改造(预估 3-4 步)
|
||||
|
||||
10. 用户 API(channels、subscription-plans、subscriptions/my)
|
||||
11. 按量付费 Tab(ChannelGrid + TopUpModal)
|
||||
12. 包月套餐 Tab(SubscriptionPlanCard + SubscriptionConfirm)
|
||||
13. 用户订阅展示 + 续费 + 异常处理
|
||||
|
||||
### Phase 5:配置迁移 & 收尾(预估 1-2 步)
|
||||
|
||||
14. getEnv() 改造(数据库优先 + 环境变量回退)
|
||||
15. 测试 + 端到端验证
|
||||
|
||||
---
|
||||
|
||||
## 十一、安全考虑
|
||||
|
||||
1. **订阅分组校验**:每次展示和下单都实时校验 Sub2API 分组是否存在且 active
|
||||
2. **价格篡改防护**:订阅订单金额从服务端 SubscriptionPlan.price 读取,不信任客户端传值
|
||||
3. **支付后分组消失**:订单标记 FAILED + 常驻错误提示 + 审计日志,不自动退款(需人工确认)
|
||||
4. **敏感配置**:支付密钥在 API 响应中脱敏,前端仅展示 `****...最后4位`
|
||||
5. **幂等性**:订阅分配使用 `orderId` 作为幂等 key,防止重复分配
|
||||
35
.github/workflows/release.yml
vendored
35
.github/workflows/release.yml
vendored
@@ -42,3 +42,38 @@ jobs:
|
||||
with:
|
||||
body: ${{ steps.changelog.outputs.body }}
|
||||
generate_release_notes: false
|
||||
|
||||
docker:
|
||||
name: Build & Push Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: touwaeriol/sub2apipay
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -42,3 +42,6 @@ next-env.d.ts
|
||||
|
||||
# third-party source code (local reference only)
|
||||
/third-party
|
||||
|
||||
# Claude Code project instructions (contains sensitive deployment info)
|
||||
CLAUDE.md
|
||||
|
||||
@@ -15,7 +15,7 @@ RUN pnpm prisma generate
|
||||
RUN DATABASE_URL="postgresql://x:x@localhost/x" \
|
||||
SUB2API_BASE_URL="https://localhost" \
|
||||
SUB2API_ADMIN_API_KEY="build-dummy" \
|
||||
ADMIN_TOKEN="build-dummy" \
|
||||
ADMIN_TOKEN="build-dummy-placeholder-key" \
|
||||
NEXT_PUBLIC_APP_URL="https://localhost" \
|
||||
pnpm build
|
||||
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-present touwaeriol
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
195
README.en.md
195
README.en.md
@@ -2,7 +2,7 @@
|
||||
|
||||
**Language**: [中文](./README.md) | English (current)
|
||||
|
||||
Sub2ApiPay is a self-hosted recharge payment gateway built for the [Sub2API](https://sub2api.com) platform. It supports Alipay, WeChat Pay (via EasyPay aggregator), and Stripe. Once a payment is confirmed, the system automatically calls the Sub2API management API to credit the user's balance — no manual intervention required.
|
||||
Sub2ApiPay is a self-hosted payment gateway built for the [Sub2API](https://sub2api.com) platform. It supports four payment channels — **EasyPay** (aggregated Alipay/WeChat Pay), **Alipay** (official), **WeChat Pay** (official), and **Stripe** — with both pay-as-you-go balance top-up and subscription plans. Once a payment is confirmed, the system automatically calls the Sub2API management API to credit the user's balance or activate the subscription — no manual intervention required.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,21 +14,32 @@ Sub2ApiPay is a self-hosted recharge payment gateway built for the [Sub2API](htt
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Deployment](#deployment)
|
||||
- [Sub2API Integration](#sub2api-integration)
|
||||
- [Admin Panel](#admin-panel)
|
||||
- [Payment Flow](#payment-flow)
|
||||
- [API Endpoints](#api-endpoints)
|
||||
- [Development](#development)
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Multiple Payment Methods** — Alipay, WeChat Pay (EasyPay), Stripe credit card
|
||||
- **Four Payment Channels** — EasyPay aggregation, Alipay (official), WeChat Pay (official), Stripe
|
||||
- **Dual Billing Modes** — Pay-as-you-go balance top-up + subscription plans
|
||||
- **Auto Balance Credit** — Automatically calls Sub2API after payment verification, fully hands-free
|
||||
- **Full Order Lifecycle** — Auto-expiry, user cancellation, admin cancellation, refunds
|
||||
- **Limit Controls** — Configurable per-transaction cap and daily cumulative cap per user
|
||||
- **Security** — Token auth, MD5/Webhook signature verification, timing-safe comparison, full audit log
|
||||
- **Responsive UI** — PC + mobile adaptive layout, dark mode support, iframe embed support
|
||||
- **Admin Panel** — Order list (pagination/filtering), order details, retry recharge, refunds
|
||||
- **Limit Controls** — Per-transaction cap, daily per-user cap, daily per-channel global cap
|
||||
- **Security** — Token auth, RSA2/MD5/Webhook signature verification, timing-safe comparison, full audit log
|
||||
- **Responsive UI** — PC + mobile adaptive layout, dark/light theme, iframe embed support
|
||||
- **Bilingual** — Automatic Chinese/English interface adaptation
|
||||
- **Admin Panel** — Dashboard, order management (pagination/filtering/retry/refund), channel & subscription management
|
||||
|
||||
> **EasyPay Recommendation**: We personally recommend [ZPay](https://z-pay.cn/?uid=23808) as an EasyPay provider (referral link — feel free to remove). ZPay supports **individual users** (no business license) with a daily limit of ¥10,000; business license holders have no limit. 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.
|
||||
|
||||
<details>
|
||||
<summary>ZPay Registration QR Code</summary>
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@@ -99,33 +110,18 @@ See [`.env.example`](./.env.example) for the full template.
|
||||
**Step 1**: Declare which payment providers to load via `PAYMENT_PROVIDERS` (comma-separated):
|
||||
|
||||
```env
|
||||
# EasyPay only
|
||||
# Available: easypay, alipay, wxpay, stripe
|
||||
# Example: EasyPay only
|
||||
PAYMENT_PROVIDERS=easypay
|
||||
# Stripe only
|
||||
PAYMENT_PROVIDERS=stripe
|
||||
# Both
|
||||
PAYMENT_PROVIDERS=easypay,stripe
|
||||
# Example: Alipay + WeChat Pay + Stripe (official channels)
|
||||
PAYMENT_PROVIDERS=alipay,wxpay,stripe
|
||||
```
|
||||
|
||||
**Step 2**: Control which channels are shown to users via `ENABLED_PAYMENT_TYPES`:
|
||||
> **Alipay / WeChat Pay (official)** and **EasyPay** can coexist. Official channels connect directly to Alipay/WeChat Pay APIs with funds going straight to your merchant account and lower fees; EasyPay proxies payments through a third-party platform that forwards to official channels, with a lower barrier to entry. When using EasyPay, choose providers where funds are forwarded through official channels directly to your own account, rather than collected by a third party.
|
||||
|
||||
```env
|
||||
# EasyPay supports: alipay, wxpay | Stripe supports: stripe
|
||||
ENABLED_PAYMENT_TYPES=alipay,wxpay
|
||||
```
|
||||
#### EasyPay (Alipay / WeChat Pay Aggregation)
|
||||
|
||||
#### EasyPay (Alipay / WeChat Pay)
|
||||
|
||||
Any payment provider compatible with the **EasyPay protocol** can be used, such as [ZPay](https://z-pay.cn/?uid=23808) (`https://z-pay.cn/?uid=23808`) (this link contains the author's referral code — feel free to remove it).
|
||||
|
||||
<details>
|
||||
<summary>ZPay Registration QR Code</summary>
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
> **Disclaimer**: Please evaluate the security, reliability, and compliance of any third-party payment provider on your own. This project does not endorse or guarantee any specific provider.
|
||||
Any payment provider compatible with the **EasyPay protocol** can be used.
|
||||
|
||||
| Variable | Description |
|
||||
| --------------------- | ---------------------------------------------------------------- |
|
||||
@@ -137,6 +133,33 @@ Any payment provider compatible with the **EasyPay protocol** can be used, such
|
||||
| `EASY_PAY_CID_ALIPAY` | Alipay channel ID (optional) |
|
||||
| `EASY_PAY_CID_WXPAY` | WeChat Pay channel ID (optional) |
|
||||
|
||||
#### Alipay (Official)
|
||||
|
||||
Direct integration with the Alipay Open Platform. Supports PC page payment (`alipay.trade.page.pay`) and mobile web payment (`alipay.trade.wap.pay`), automatically switching based on device type.
|
||||
|
||||
| Variable | Description |
|
||||
| -------------------- | ---------------------------------------------- |
|
||||
| `ALIPAY_APP_ID` | Alipay application AppID |
|
||||
| `ALIPAY_PRIVATE_KEY` | Application private key (content or file path) |
|
||||
| `ALIPAY_PUBLIC_KEY` | Alipay public key (content or file path) |
|
||||
| `ALIPAY_NOTIFY_URL` | Async callback URL |
|
||||
| `ALIPAY_RETURN_URL` | Sync redirect URL (optional) |
|
||||
|
||||
#### WeChat Pay (Official)
|
||||
|
||||
Direct integration with WeChat Pay APIv3. Supports Native QR code payment and H5 payment, with mobile devices preferring H5 and auto-fallback to QR code.
|
||||
|
||||
| Variable | Description |
|
||||
| --------------------- | ----------------------------------------------- |
|
||||
| `WXPAY_APP_ID` | WeChat Pay AppID |
|
||||
| `WXPAY_MCH_ID` | Merchant ID |
|
||||
| `WXPAY_PRIVATE_KEY` | Merchant API private key (content or file path) |
|
||||
| `WXPAY_CERT_SERIAL` | Merchant certificate serial number |
|
||||
| `WXPAY_API_V3_KEY` | APIv3 key |
|
||||
| `WXPAY_PUBLIC_KEY` | WeChat Pay public key (content or file path) |
|
||||
| `WXPAY_PUBLIC_KEY_ID` | WeChat Pay public key ID |
|
||||
| `WXPAY_NOTIFY_URL` | Async callback URL |
|
||||
|
||||
#### Stripe
|
||||
|
||||
| Variable | Description |
|
||||
@@ -151,10 +174,14 @@ Any payment provider compatible with the **EasyPay protocol** can be used, such
|
||||
### 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` |
|
||||
| `MAX_DAILY_AMOUNT_ALIPAY` | EasyPay Alipay channel daily global limit (opt.) | Provider default |
|
||||
| `MAX_DAILY_AMOUNT_ALIPAY_DIRECT` | Alipay official channel daily global limit (opt.) | Provider default |
|
||||
| `MAX_DAILY_AMOUNT_WXPAY` | WeChat Pay channel daily global limit (opt.) | Provider default |
|
||||
| `MAX_DAILY_AMOUNT_STRIPE` | Stripe channel daily global limit (opt.) | Provider default |
|
||||
| `ORDER_TIMEOUT_MINUTES` | Order expiry in minutes | `5` |
|
||||
| `PRODUCT_NAME` | Product name shown on payment page | `Sub2API Balance Recharge` |
|
||||
|
||||
@@ -265,13 +292,16 @@ docker compose exec app npx prisma migrate deploy
|
||||
|
||||
## Sub2API Integration
|
||||
|
||||
The following page URLs can be configured in the Sub2API admin panel:
|
||||
Assuming this service is deployed at `https://pay.example.com`.
|
||||
|
||||
| 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 |
|
||||
### User-Facing Pages
|
||||
|
||||
Configure the following URLs in the Sub2API admin panel under **Recharge Settings**, so users can navigate from Sub2API to the payment and order pages:
|
||||
|
||||
| Setting | URL | Description |
|
||||
| --------- | ------------------------------------ | -------------------------------------------------- |
|
||||
| Payment | `https://pay.example.com/pay` | User top-up & subscription purchase page |
|
||||
| My Orders | `https://pay.example.com/pay/orders` | User views their own recharge/subscription history |
|
||||
|
||||
Sub2API **v0.1.88** and above will automatically append the following parameters — no manual query string needed:
|
||||
|
||||
@@ -280,50 +310,113 @@ Sub2API **v0.1.88** and above will automatically append the following parameters
|
||||
| `user_id` | Sub2API user ID |
|
||||
| `token` | User login token (required to view order history) |
|
||||
| `theme` | `light` (default) or `dark` |
|
||||
| `lang` | Interface language, `zh` (default) or `en` |
|
||||
| `ui_mode` | `standalone` (default) or `embedded` (for iframe) |
|
||||
|
||||
---
|
||||
### Admin Panel
|
||||
|
||||
## Admin Panel
|
||||
The admin panel is authenticated via the `token` URL parameter (set to the `ADMIN_TOKEN` environment variable). When integrating with Sub2API, just configure the paths — **no query parameters needed** — Sub2API will automatically append `token` and other parameters:
|
||||
|
||||
Access: `https://pay.example.com/admin?token=YOUR_ADMIN_TOKEN`
|
||||
| Page | URL | Description |
|
||||
| ------------- | --------------------------------------------- | ------------------------------------------------------------- |
|
||||
| Overview | `https://pay.example.com/admin` | Aggregated entry with card-style navigation |
|
||||
| Orders | `https://pay.example.com/admin/orders` | Filter by status, paginate, view details, retry/cancel/refund |
|
||||
| Dashboard | `https://pay.example.com/admin/dashboard` | Revenue stats, order trends, payment method breakdown |
|
||||
| Channels | `https://pay.example.com/admin/channels` | Configure API channels & rates, sync from Sub2API |
|
||||
| Subscriptions | `https://pay.example.com/admin/subscriptions` | Manage subscription plans & user subscriptions |
|
||||
|
||||
| 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 |
|
||||
> **Tip**: When accessing directly (not via Sub2API), you need to manually append `?token=YOUR_ADMIN_TOKEN` to the URL. All admin pages share the same token — once you enter any page, you can navigate between modules via the sidebar.
|
||||
|
||||
---
|
||||
|
||||
## Payment Flow
|
||||
|
||||
```
|
||||
User submits recharge amount
|
||||
User selects top-up / subscription plan
|
||||
│
|
||||
▼
|
||||
Create Order (PENDING)
|
||||
├─ Validate user status / pending order count / daily limit
|
||||
├─ Validate user status / pending orders / daily limit / channel limit
|
||||
└─ Call payment provider to get payment link
|
||||
│
|
||||
▼
|
||||
User completes payment
|
||||
├─ EasyPay → QR code / H5 redirect
|
||||
├─ Alipay (official) → PC page payment / H5 mobile web payment
|
||||
├─ WeChat Pay (official) → Native QR code / H5 payment
|
||||
└─ Stripe → Payment Element (PaymentIntent)
|
||||
│
|
||||
▼
|
||||
Payment callback (signature verified) → Order PAID
|
||||
Payment callback (RSA2 / MD5 / Webhook signature verified) → Order PAID
|
||||
│
|
||||
▼
|
||||
Auto-call Sub2API recharge API
|
||||
├─ Success → COMPLETED, balance credited automatically
|
||||
Auto-call Sub2API recharge / subscription API
|
||||
├─ Success → COMPLETED, balance credited / subscription activated
|
||||
└─ Failure → FAILED (admin can retry)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
All API paths are prefixed with `/api`.
|
||||
|
||||
### Public API
|
||||
|
||||
User-facing endpoints, authenticated via `user_id` + `token` URL parameters.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ------------------------- | --------------------------------------------------- |
|
||||
| `GET` | `/api/user` | Get current user info |
|
||||
| `GET` | `/api/users/:id` | Get specific user info |
|
||||
| `POST` | `/api/orders` | Create recharge / subscription order |
|
||||
| `GET` | `/api/orders/:id` | Query order details |
|
||||
| `POST` | `/api/orders/:id/cancel` | User cancels pending order |
|
||||
| `GET` | `/api/orders/my` | List current user's orders |
|
||||
| `GET` | `/api/channels` | Get channel list (for frontend display) |
|
||||
| `GET` | `/api/subscription-plans` | Get available subscription plans |
|
||||
| `GET` | `/api/subscriptions/my` | Query current user's subscriptions |
|
||||
| `GET` | `/api/limits` | Query recharge limits & payment method availability |
|
||||
|
||||
### Payment Callbacks
|
||||
|
||||
Called asynchronously by payment providers; signature verified before triggering credit flow.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---------------------- | ------------------------------ |
|
||||
| `GET` | `/api/easy-pay/notify` | EasyPay async callback |
|
||||
| `POST` | `/api/alipay/notify` | Alipay (official) callback |
|
||||
| `POST` | `/api/wxpay/notify` | WeChat Pay (official) callback |
|
||||
| `POST` | `/api/stripe/webhook` | Stripe webhook callback |
|
||||
|
||||
### Admin API
|
||||
|
||||
Authenticated via `token` parameter set to `ADMIN_TOKEN`.
|
||||
|
||||
| Method | Path | Description |
|
||||
| -------- | ----------------------------------- | ------------------------------------ |
|
||||
| `GET` | `/api/admin/orders` | Order list (paginated, filterable) |
|
||||
| `GET` | `/api/admin/orders/:id` | Order details (with audit log) |
|
||||
| `POST` | `/api/admin/orders/:id/cancel` | Admin cancels order |
|
||||
| `POST` | `/api/admin/orders/:id/retry` | Retry failed recharge / subscription |
|
||||
| `POST` | `/api/admin/refund` | Issue refund |
|
||||
| `GET` | `/api/admin/dashboard` | Dashboard (revenue stats, trends) |
|
||||
| `GET` | `/api/admin/channels` | Channel list |
|
||||
| `POST` | `/api/admin/channels` | Create channel |
|
||||
| `PUT` | `/api/admin/channels/:id` | Update channel |
|
||||
| `DELETE` | `/api/admin/channels/:id` | Delete channel |
|
||||
| `GET` | `/api/admin/subscription-plans` | Subscription plan list |
|
||||
| `POST` | `/api/admin/subscription-plans` | Create subscription plan |
|
||||
| `PUT` | `/api/admin/subscription-plans/:id` | Update subscription plan |
|
||||
| `DELETE` | `/api/admin/subscription-plans/:id` | Delete subscription plan |
|
||||
| `GET` | `/api/admin/subscriptions` | User subscription records |
|
||||
| `GET` | `/api/admin/config` | Get system configuration |
|
||||
| `PUT` | `/api/admin/config` | Update system configuration |
|
||||
| `GET` | `/api/admin/sub2api/groups` | Sync channel groups from Sub2API |
|
||||
| `GET` | `/api/admin/sub2api/search-users` | Search Sub2API users |
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
### Requirements
|
||||
|
||||
199
README.md
199
README.md
@@ -2,7 +2,7 @@
|
||||
|
||||
**语言 / Language**: 中文(当前)| [English](./README.en.md)
|
||||
|
||||
Sub2ApiPay 是为 [Sub2API](https://sub2api.com) 平台构建的自托管充值支付网关。支持支付宝、微信支付(通过 EasyPay 聚合)和 Stripe,订单支付成功后自动调用 Sub2API 管理接口完成余额到账,无需人工干预。
|
||||
Sub2ApiPay 是为 [Sub2API](https://sub2api.com) 平台构建的自托管支付网关。支持 **EasyPay 易支付聚合**、**支付宝官方**、**微信官方**和 **Stripe** 四种支付渠道,提供按量充值与套餐订阅两种计费模式,支付成功后自动调用 Sub2API 管理接口完成到账,无需人工干预。
|
||||
|
||||
---
|
||||
|
||||
@@ -14,21 +14,32 @@ Sub2ApiPay 是为 [Sub2API](https://sub2api.com) 平台构建的自托管充值
|
||||
- [环境变量](#环境变量)
|
||||
- [部署指南](#部署指南)
|
||||
- [集成到 Sub2API](#集成到-sub2api)
|
||||
- [管理后台](#管理后台)
|
||||
- [支付流程](#支付流程)
|
||||
- [API 端点](#api-端点)
|
||||
- [开发指南](#开发指南)
|
||||
|
||||
---
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **多支付方式** — 支付宝、微信支付(EasyPay 聚合)、Stripe 信用卡
|
||||
- **自动到账** — 支付回调验签后自动调用 Sub2API 充值接口,全程无需人工
|
||||
- **四渠道支付** — EasyPay 易支付聚合、支付宝官方、微信官方、Stripe
|
||||
- **双计费模式** — 按量余额充值 + 套餐订阅,灵活适配不同业务场景
|
||||
- **自动到账** — 支付回调验签后自动调用 Sub2API 充值 / 订阅接口,全程无需人工
|
||||
- **订单全生命周期** — 超时自动取消、用户主动取消、管理员取消、退款
|
||||
- **限额控制** — 可配置单笔上限与每日累计上限,按用户维度统计
|
||||
- **安全设计** — Token 鉴权、MD5/Webhook 签名验证、时序安全对比、完整审计日志
|
||||
- **响应式 UI** — PC + 移动端自适应,支持深色模式,支持 iframe 嵌入
|
||||
- **管理后台** — 订单列表(分页/筛选)、订单详情、重试充值、退款
|
||||
- **限额控制** — 单笔上限、每日用户累计上限、每日渠道全局限额,多维度风控
|
||||
- **安全设计** — Token 鉴权、RSA2 / MD5 / Webhook 签名验证、时序安全对比、完整审计日志
|
||||
- **响应式 UI** — PC + 移动端自适应,暗色 / 亮色主题,支持 iframe 嵌入
|
||||
- **中英双语** — 支付页面自动适配中英文
|
||||
- **管理后台** — 数据概览、订单管理(分页/筛选/重试/退款)、渠道管理、订阅管理
|
||||
|
||||
> **EasyPay 推荐**:个人推荐 [ZPay](https://z-pay.cn/?uid=23808)(`https://z-pay.cn/?uid=23808`)作为 EasyPay 服务商(链接含作者邀请码,介意可去掉)。ZPay 支持**个人用户**(无营业执照)每日 1 万元以内交易;拥有营业执照则无限额。支付渠道的安全性、稳定性及合规性请自行鉴别,本项目不对任何第三方支付服务商做担保或背书。
|
||||
|
||||
<details>
|
||||
<summary>ZPay 申请二维码</summary>
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
@@ -99,33 +110,18 @@ docker compose up -d --build
|
||||
**第一步**:通过 `PAYMENT_PROVIDERS` 声明启用哪些支付服务商(逗号分隔):
|
||||
|
||||
```env
|
||||
# 仅易支付
|
||||
# 可选值: easypay, alipay, wxpay, stripe
|
||||
# 示例:仅使用 EasyPay 易支付聚合
|
||||
PAYMENT_PROVIDERS=easypay
|
||||
# 仅 Stripe
|
||||
PAYMENT_PROVIDERS=stripe
|
||||
# 两者都用
|
||||
PAYMENT_PROVIDERS=easypay,stripe
|
||||
# 示例:同时启用支付宝官方 + 微信官方 + Stripe
|
||||
PAYMENT_PROVIDERS=alipay,wxpay,stripe
|
||||
```
|
||||
|
||||
**第二步**:通过 `ENABLED_PAYMENT_TYPES` 控制向用户展示哪些支付渠道:
|
||||
> **支付宝官方 / 微信官方**与 **EasyPay** 可以共存。官方渠道直接对接支付宝/微信 API,资金直达商户账户,手续费更低;EasyPay 通过第三方平台代收/转发官方,接入门槛更低。使用 EasyPay 时请尽量选择资金直接走转发官方直达自己账户的形式,而非第三方代收的服务商。
|
||||
|
||||
```env
|
||||
# 易支付支持: alipay, wxpay;Stripe 支持: stripe
|
||||
ENABLED_PAYMENT_TYPES=alipay,wxpay
|
||||
```
|
||||
#### EasyPay(支付宝 / 微信支付聚合)
|
||||
|
||||
#### EasyPay(支付宝 / 微信支付)
|
||||
|
||||
支付提供商只需兼容**易支付(EasyPay)协议**即可接入,例如 [ZPay](https://z-pay.cn/?uid=23808)(`https://z-pay.cn/?uid=23808`)等平台(链接含本项目作者的邀请码,介意可去掉)。
|
||||
|
||||
<details>
|
||||
<summary>ZPay 申请二维码</summary>
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
> **注意**:支付渠道的安全性、稳定性及合规性请自行鉴别,本项目不对任何第三方支付服务商做担保或背书。
|
||||
任何兼容**易支付(EasyPay)协议**的支付服务商均可接入。
|
||||
|
||||
| 变量 | 说明 |
|
||||
| --------------------- | ------------------------------------------------------------- |
|
||||
@@ -137,6 +133,33 @@ ENABLED_PAYMENT_TYPES=alipay,wxpay
|
||||
| `EASY_PAY_CID_ALIPAY` | 支付宝通道 ID(可选) |
|
||||
| `EASY_PAY_CID_WXPAY` | 微信支付通道 ID(可选) |
|
||||
|
||||
#### 支付宝官方
|
||||
|
||||
直接对接支付宝开放平台,支持 PC 页面支付(`alipay.trade.page.pay`)和手机网站支付(`alipay.trade.wap.pay`),自动根据终端类型切换。
|
||||
|
||||
| 变量 | 说明 |
|
||||
| -------------------- | ---------------------------- |
|
||||
| `ALIPAY_APP_ID` | 支付宝应用 AppID |
|
||||
| `ALIPAY_PRIVATE_KEY` | 应用私钥(内容或文件路径) |
|
||||
| `ALIPAY_PUBLIC_KEY` | 支付宝公钥(内容或文件路径) |
|
||||
| `ALIPAY_NOTIFY_URL` | 异步回调地址 |
|
||||
| `ALIPAY_RETURN_URL` | 同步跳转地址(可选) |
|
||||
|
||||
#### 微信官方
|
||||
|
||||
直接对接微信支付 APIv3,支持 Native 扫码支付和 H5 支付,移动端优先尝试 H5,自动 fallback 到扫码。
|
||||
|
||||
| 变量 | 说明 |
|
||||
| --------------------- | ------------------------------- |
|
||||
| `WXPAY_APP_ID` | 微信支付 AppID |
|
||||
| `WXPAY_MCH_ID` | 商户号 |
|
||||
| `WXPAY_PRIVATE_KEY` | 商户 API 私钥(内容或文件路径) |
|
||||
| `WXPAY_CERT_SERIAL` | 商户证书序列号 |
|
||||
| `WXPAY_API_V3_KEY` | APIv3 密钥 |
|
||||
| `WXPAY_PUBLIC_KEY` | 微信支付公钥(内容或文件路径) |
|
||||
| `WXPAY_PUBLIC_KEY_ID` | 微信支付公钥 ID |
|
||||
| `WXPAY_NOTIFY_URL` | 异步回调地址 |
|
||||
|
||||
#### Stripe
|
||||
|
||||
| 变量 | 说明 |
|
||||
@@ -151,10 +174,14 @@ ENABLED_PAYMENT_TYPES=alipay,wxpay
|
||||
### 业务规则
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
| --------------------------- | ---------------------------------- | -------------------------- |
|
||||
| -------------------------------- | ---------------------------------------- | -------------------------- |
|
||||
| `MIN_RECHARGE_AMOUNT` | 单笔最低充值金额(元) | `1` |
|
||||
| `MAX_RECHARGE_AMOUNT` | 单笔最高充值金额(元) | `1000` |
|
||||
| `MAX_DAILY_RECHARGE_AMOUNT` | 每日累计最高充值(元,`0` = 不限) | `10000` |
|
||||
| `MAX_DAILY_RECHARGE_AMOUNT` | 每日每用户累计最高充值(元,`0` = 不限) | `10000` |
|
||||
| `MAX_DAILY_AMOUNT_ALIPAY` | 易支付支付宝渠道每日全局限额(可选) | 由提供商默认 |
|
||||
| `MAX_DAILY_AMOUNT_ALIPAY_DIRECT` | 支付宝官方渠道每日全局限额(可选) | 由提供商默认 |
|
||||
| `MAX_DAILY_AMOUNT_WXPAY` | 微信支付渠道每日全局限额(可选) | 由提供商默认 |
|
||||
| `MAX_DAILY_AMOUNT_STRIPE` | Stripe 渠道每日全局限额(可选) | 由提供商默认 |
|
||||
| `ORDER_TIMEOUT_MINUTES` | 订单超时分钟数 | `5` |
|
||||
| `PRODUCT_NAME` | 充值商品名称(显示在支付页) | `Sub2API Balance Recharge` |
|
||||
|
||||
@@ -265,13 +292,16 @@ docker compose exec app npx prisma migrate deploy
|
||||
|
||||
## 集成到 Sub2API
|
||||
|
||||
在 Sub2API 管理后台可配置以下页面链接:
|
||||
假设本服务部署在 `https://pay.example.com`。
|
||||
|
||||
| 页面 | 链接 | 说明 |
|
||||
| -------- | ------------------------------------ | ----------------------- |
|
||||
| 充值页面 | `https://pay.example.com/pay` | 用户充值入口 |
|
||||
| 我的订单 | `https://pay.example.com/pay/orders` | 用户查看自己的充值记录 |
|
||||
| 订单管理 | `https://pay.example.com/admin` | 仅 Sub2API 管理员可访问 |
|
||||
### 用户端页面
|
||||
|
||||
在 Sub2API 管理后台的**充值设置**中配置以下链接,用户即可从 Sub2API 平台跳转到充值和订单页面:
|
||||
|
||||
| 配置项 | URL | 说明 |
|
||||
| -------- | ------------------------------------ | --------------------------- |
|
||||
| 充值页面 | `https://pay.example.com/pay` | 用户充值、购买订阅套餐入口 |
|
||||
| 我的订单 | `https://pay.example.com/pay/orders` | 用户查看自己的充值/订阅记录 |
|
||||
|
||||
Sub2API **v0.1.88** 及以上版本会自动拼接以下参数,无需手动添加:
|
||||
|
||||
@@ -280,50 +310,113 @@ Sub2API **v0.1.88** 及以上版本会自动拼接以下参数,无需手动添
|
||||
| `user_id` | Sub2API 用户 ID |
|
||||
| `token` | 用户登录 Token(有 token 才能查看订单历史) |
|
||||
| `theme` | `light`(默认)或 `dark` |
|
||||
| `lang` | 界面语言,`zh`(默认)或 `en` |
|
||||
| `ui_mode` | `standalone`(默认)或 `embedded`(iframe 嵌入) |
|
||||
|
||||
---
|
||||
### 管理后台
|
||||
|
||||
## 管理后台
|
||||
管理后台通过 URL 参数 `token` 鉴权(值为环境变量 `ADMIN_TOKEN`)。在 Sub2API 中集成时只需配置路径,**无需附加任何查询参数**——Sub2API 会自动拼接 `token` 等参数:
|
||||
|
||||
访问:`https://pay.example.com/admin?token=YOUR_ADMIN_TOKEN`
|
||||
| 页面 | URL | 说明 |
|
||||
| -------- | --------------------------------------------- | ---------------------------------------------- |
|
||||
| 管理总览 | `https://pay.example.com/admin` | 聚合入口,卡片式导航到各管理模块 |
|
||||
| 订单管理 | `https://pay.example.com/admin/orders` | 按状态筛选、分页浏览、订单详情、重试/取消/退款 |
|
||||
| 数据概览 | `https://pay.example.com/admin/dashboard` | 收入统计、订单趋势、支付方式分布 |
|
||||
| 渠道管理 | `https://pay.example.com/admin/channels` | 配置 API 渠道与倍率,支持从 Sub2API 同步 |
|
||||
| 订阅管理 | `https://pay.example.com/admin/subscriptions` | 管理订阅套餐与用户订阅 |
|
||||
|
||||
| 功能 | 说明 |
|
||||
| -------- | ------------------------------------------- |
|
||||
| 订单列表 | 按状态筛选、分页浏览,支持每页 20/50/100 条 |
|
||||
| 订单详情 | 查看完整字段与操作审计日志 |
|
||||
| 重试充值 | 对已支付但充值失败的订单重新发起充值 |
|
||||
| 取消订单 | 强制取消待支付订单 |
|
||||
| 退款 | 对已完成订单发起退款并扣减 Sub2API 余额 |
|
||||
> **提示**:若独立访问(不通过 Sub2API 跳转),需手动在 URL 后添加 `?token=YOUR_ADMIN_TOKEN`。管理后台所有页面间共享同一个 token,进入任一页面后可通过侧边导航切换。
|
||||
|
||||
---
|
||||
|
||||
## 支付流程
|
||||
|
||||
```
|
||||
用户提交充值金额
|
||||
用户选择充值 / 订阅套餐
|
||||
│
|
||||
▼
|
||||
创建订单 (PENDING)
|
||||
├─ 校验用户状态 / 待支付订单数 / 每日限额
|
||||
├─ 校验用户状态 / 待支付订单数 / 每日限额 / 渠道限额
|
||||
└─ 调用支付提供商获取支付链接
|
||||
│
|
||||
▼
|
||||
用户完成支付
|
||||
├─ EasyPay → 扫码 / H5 跳转
|
||||
├─ 支付宝官方 → PC 页面支付 / H5 手机网站支付
|
||||
├─ 微信官方 → Native 扫码 / H5 支付
|
||||
└─ Stripe → Payment Element (PaymentIntent)
|
||||
│
|
||||
▼
|
||||
支付回调(签名验证)→ 订单 PAID
|
||||
支付回调(RSA2 / MD5 / Webhook 签名验证)→ 订单 PAID
|
||||
│
|
||||
▼
|
||||
自动调用 Sub2API 充值接口
|
||||
├─ 成功 → COMPLETED,余额自动到账
|
||||
自动调用 Sub2API 充值 / 订阅接口
|
||||
├─ 成功 → COMPLETED,余额到账 / 订阅生效
|
||||
└─ 失败 → FAILED(管理员可重试)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 端点
|
||||
|
||||
所有 API 路径前缀为 `/api`。
|
||||
|
||||
### 公开 API
|
||||
|
||||
用户侧接口,通过 URL 参数 `user_id` + `token` 鉴权。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ------ | ------------------------- | ------------------------------ |
|
||||
| `GET` | `/api/user` | 获取当前用户信息 |
|
||||
| `GET` | `/api/users/:id` | 获取指定用户信息 |
|
||||
| `POST` | `/api/orders` | 创建充值 / 订阅订单 |
|
||||
| `GET` | `/api/orders/:id` | 查询订单详情 |
|
||||
| `POST` | `/api/orders/:id/cancel` | 用户取消待支付订单 |
|
||||
| `GET` | `/api/orders/my` | 查询当前用户的订单列表 |
|
||||
| `GET` | `/api/channels` | 获取渠道列表(前端展示用) |
|
||||
| `GET` | `/api/subscription-plans` | 获取在售订阅套餐列表 |
|
||||
| `GET` | `/api/subscriptions/my` | 查询当前用户的订阅状态 |
|
||||
| `GET` | `/api/limits` | 查询充值限额与支付方式可用状态 |
|
||||
|
||||
### 支付回调
|
||||
|
||||
由支付服务商异步调用,签名验证后触发到账流程。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| ------ | ---------------------- | ----------------------- |
|
||||
| `GET` | `/api/easy-pay/notify` | EasyPay 异步回调(GET) |
|
||||
| `POST` | `/api/alipay/notify` | 支付宝官方异步回调 |
|
||||
| `POST` | `/api/wxpay/notify` | 微信官方异步回调 |
|
||||
| `POST` | `/api/stripe/webhook` | Stripe Webhook 回调 |
|
||||
|
||||
### 管理 API
|
||||
|
||||
需通过 `token` 参数传递 `ADMIN_TOKEN` 鉴权。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| -------- | ----------------------------------- | -------------------------- |
|
||||
| `GET` | `/api/admin/orders` | 订单列表(分页、状态筛选) |
|
||||
| `GET` | `/api/admin/orders/:id` | 订单详情(含审计日志) |
|
||||
| `POST` | `/api/admin/orders/:id/cancel` | 管理员取消订单 |
|
||||
| `POST` | `/api/admin/orders/:id/retry` | 重试失败的充值 / 订阅 |
|
||||
| `POST` | `/api/admin/refund` | 发起退款 |
|
||||
| `GET` | `/api/admin/dashboard` | 数据概览(收入统计、趋势) |
|
||||
| `GET` | `/api/admin/channels` | 渠道列表 |
|
||||
| `POST` | `/api/admin/channels` | 创建渠道 |
|
||||
| `PUT` | `/api/admin/channels/:id` | 更新渠道 |
|
||||
| `DELETE` | `/api/admin/channels/:id` | 删除渠道 |
|
||||
| `GET` | `/api/admin/subscription-plans` | 订阅套餐列表 |
|
||||
| `POST` | `/api/admin/subscription-plans` | 创建订阅套餐 |
|
||||
| `PUT` | `/api/admin/subscription-plans/:id` | 更新订阅套餐 |
|
||||
| `DELETE` | `/api/admin/subscription-plans/:id` | 删除订阅套餐 |
|
||||
| `GET` | `/api/admin/subscriptions` | 用户订阅记录列表 |
|
||||
| `GET` | `/api/admin/config` | 获取系统配置 |
|
||||
| `PUT` | `/api/admin/config` | 更新系统配置 |
|
||||
| `GET` | `/api/admin/sub2api/groups` | 从 Sub2API 同步渠道分组 |
|
||||
| `GET` | `/api/admin/sub2api/search-users` | 搜索 Sub2API 用户 |
|
||||
|
||||
---
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 环境要求
|
||||
|
||||
129
docs/payment-alipay.md
Normal file
129
docs/payment-alipay.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# 支付宝直连支付接入指南
|
||||
|
||||
## 概述
|
||||
|
||||
本项目通过直接对接 **支付宝开放平台** 实现收款,不依赖任何三方聚合支付平台。支持以下产品:
|
||||
|
||||
| 产品 | API 方法 | 场景 |
|
||||
| ------------ | ----------------------- | -------------------- |
|
||||
| 电脑网站支付 | `alipay.trade.page.pay` | PC 浏览器扫码 |
|
||||
| 手机网站支付 | `alipay.trade.wap.pay` | 移动端 H5 拉起支付宝 |
|
||||
|
||||
签名算法:**RSA2 (SHA256withRSA)**,密钥格式 **PKCS8**。
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. 注册 [支付宝开放平台](https://open.alipay.com/) 企业/个人账号
|
||||
2. 创建网页/移动应用,获取 **APPID**
|
||||
3. 在应用中签约 **电脑网站支付** 和 **手机网站支付** 产品
|
||||
4. 配置 **接口加签方式** → 选择 **公钥模式 (RSA2)**,生成密钥对
|
||||
|
||||
## 密钥说明
|
||||
|
||||
支付宝公钥模式涉及 **三把密钥**,务必区分:
|
||||
|
||||
| 密钥 | 来源 | 用途 | 对应环境变量 |
|
||||
| -------------- | -------------------------- | ---------------- | -------------------- |
|
||||
| **应用私钥** | 你自己生成 | 对请求参数签名 | `ALIPAY_PRIVATE_KEY` |
|
||||
| **支付宝公钥** | 上传应用公钥后,支付宝返回 | 验证回调通知签名 | `ALIPAY_PUBLIC_KEY` |
|
||||
| 应用公钥 | 你自己生成 | 上传到支付宝后台 | (不配置到项目中) |
|
||||
|
||||
> **常见错误**:把「应用公钥」填到 `ALIPAY_PUBLIC_KEY`。必须使用「支付宝公钥」,否则回调验签永远失败。
|
||||
|
||||
## 环境变量
|
||||
|
||||
```env
|
||||
# ── 必需 ──
|
||||
ALIPAY_APP_ID=2021000000000000 # 支付宝开放平台 APPID
|
||||
ALIPAY_PRIVATE_KEY=MIIEvQIBADANB... # 应用私钥(PKCS8 格式,Base64 / PEM 均可)
|
||||
ALIPAY_PUBLIC_KEY=MIIBIjANBgkqh... # 支付宝公钥(非应用公钥!)
|
||||
ALIPAY_NOTIFY_URL=https://pay.example.com/api/alipay/notify # 异步通知地址
|
||||
|
||||
# ── 可选 ──
|
||||
ALIPAY_RETURN_URL=https://pay.example.com/pay/result # 同步跳转地址(默认自动生成)
|
||||
|
||||
# ── 启用渠道 ──
|
||||
PAYMENT_PROVIDERS=alipay # 逗号分隔,可同时含 easypay,alipay,wxpay,stripe
|
||||
ENABLED_PAYMENT_TYPES=alipay # 前端展示哪些支付方式
|
||||
```
|
||||
|
||||
### 密钥格式
|
||||
|
||||
`ALIPAY_PRIVATE_KEY` 和 `ALIPAY_PUBLIC_KEY` 支持两种写法:
|
||||
|
||||
```env
|
||||
# 方式 1:裸 Base64(推荐,适合 Docker 环境)
|
||||
ALIPAY_PRIVATE_KEY=MIIEvQIBADANBgkqh...一行到底...
|
||||
|
||||
# 方式 2:完整 PEM(换行用 \n)
|
||||
ALIPAY_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIIEvQIBADA...\n-----END PRIVATE KEY-----
|
||||
```
|
||||
|
||||
项目会自动补全 PEM header/footer 并按 64 字符折行(兼容 OpenSSL 3.x 严格模式)。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
用户浏览器
|
||||
│
|
||||
├── PC:扫码页面 (/pay/{orderId}) → 生成支付宝跳转 URL → 扫码/登录付款
|
||||
│ ↓
|
||||
│ alipay.trade.page.pay (GET 跳转)
|
||||
│
|
||||
└── Mobile:直接拉起 → alipay.trade.wap.pay (GET 跳转)
|
||||
|
||||
支付宝服务器
|
||||
│
|
||||
└── POST /api/alipay/notify ← 异步通知(trade_status=TRADE_SUCCESS)
|
||||
│
|
||||
├── 验签(RSA2 + 支付宝公钥)
|
||||
├── 校验 app_id 一致
|
||||
├── 确认订单金额匹配
|
||||
└── 调用 handlePaymentNotify() → 订单状态流转 → 充值/订阅履约
|
||||
```
|
||||
|
||||
### PC 支付流程(短链中转)
|
||||
|
||||
PC 端不直接返回支付宝 URL,而是生成一个 **项目内部短链** `/pay/{orderId}`:
|
||||
|
||||
1. 用户扫描短链二维码
|
||||
2. 服务端根据 User-Agent 判断设备类型
|
||||
3. 如果在支付宝客户端内打开 → 直接跳转 `alipay.trade.wap.pay`
|
||||
4. 如果在普通浏览器打开 → 跳转 `alipay.trade.page.pay`
|
||||
5. 订单已支付/已过期 → 显示状态页
|
||||
|
||||
这种设计避免了支付宝 URL 过长无法生成二维码的问题。
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/lib/alipay/
|
||||
├── provider.ts # AlipayProvider 实现 PaymentProvider 接口
|
||||
├── client.ts # pageExecute (跳转URL) + execute (服务端API调用)
|
||||
├── sign.ts # RSA2 签名生成 + 验签
|
||||
├── codec.ts # 编码处理(GBK/UTF-8 自动检测、回调参数解析)
|
||||
└── types.ts # TypeScript 类型定义
|
||||
|
||||
src/app/api/alipay/
|
||||
└── notify/route.ts # 异步通知接收端点
|
||||
|
||||
src/app/pay/
|
||||
└── [orderId]/route.ts # PC 扫码中转页(短链)
|
||||
```
|
||||
|
||||
## 支持的 API 能力
|
||||
|
||||
| 能力 | API | 说明 |
|
||||
| -------- | ----------------------------------- | ---------------- |
|
||||
| 创建支付 | `alipay.trade.page.pay` / `wap.pay` | GET 跳转方式 |
|
||||
| 查询订单 | `alipay.trade.query` | 主动查询交易状态 |
|
||||
| 关闭订单 | `alipay.trade.close` | 超时关单 |
|
||||
| 退款 | `alipay.trade.refund` | 全额退款 |
|
||||
| 异步通知 | POST 回调 | RSA2 验签 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **异步通知编码**:支付宝可能使用 GBK 编码发送通知。`codec.ts` 自动检测 Content-Type 和 body 中的 charset 参数,按 `UTF-8 → GBK → GB18030` 优先级尝试解码。
|
||||
- **签名空格问题**:支付宝通知中的 `sign` 参数可能包含空格(URL 解码 `+` 导致),`codec.ts` 会自动将空格还原为 `+`。
|
||||
- **默认限额**:单笔 ¥1000,单日 ¥10000(可通过环境变量 `MAX_DAILY_AMOUNT_ALIPAY_DIRECT` 调整)。
|
||||
- **验签调试**:非生产环境自动输出验签失败的详细信息;生产环境可设置 `DEBUG_ALIPAY_SIGN=1` 开启。
|
||||
187
docs/payment-wxpay.md
Normal file
187
docs/payment-wxpay.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# 微信支付直连接入指南
|
||||
|
||||
## 概述
|
||||
|
||||
本项目通过直接对接 **微信支付 APIv3** 实现收款。使用 **公钥模式** 验签(非平台证书模式),支持以下产品:
|
||||
|
||||
| 产品 | API | 场景 |
|
||||
| ----------- | ----------------------------- | -------------------------------------- |
|
||||
| Native 支付 | `/v3/pay/transactions/native` | PC 扫码支付(生成 `weixin://` 二维码) |
|
||||
| H5 支付 | `/v3/pay/transactions/h5` | 移动端浏览器拉起微信 |
|
||||
|
||||
> H5 支付需要在微信支付商户后台单独签约开通。如果未开通,移动端会自动降级到 Native 扫码。
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. 注册 [微信支付商户平台](https://pay.weixin.qq.com/),获取 **商户号 (mchid)**
|
||||
2. 在 [微信开放平台](https://open.weixin.qq.com/) 创建应用,获取 **APPID**
|
||||
3. 在商户后台 → API 安全 → 配置以下内容:
|
||||
- **APIv3 密钥**(32 字节随机字符串)
|
||||
- **商户 API 私钥**(RSA 2048,下载 PEM 文件)
|
||||
- **微信支付公钥**(用于验签通知,注意区别于平台证书)
|
||||
- **微信支付公钥 ID**(与公钥配套的 serial/key ID)
|
||||
- **商户证书序列号**(用于签名请求的 Authorization header)
|
||||
|
||||
## 密钥说明
|
||||
|
||||
微信支付 APIv3 公钥模式涉及 **多组密钥**:
|
||||
|
||||
| 密钥 | 来源 | 用途 | 对应环境变量 |
|
||||
| ------------------- | ----------------- | ------------------------- | --------------------- |
|
||||
| **商户 API 私钥** | 商户后台生成/下载 | 对 API 请求签名 | `WXPAY_PRIVATE_KEY` |
|
||||
| **微信支付公钥** | 商户后台获取 | 验证异步通知签名 | `WXPAY_PUBLIC_KEY` |
|
||||
| **微信支付公钥 ID** | 与公钥配套 | 匹配通知中的 serial | `WXPAY_PUBLIC_KEY_ID` |
|
||||
| **商户证书序列号** | 商户后台查看 | 放入 Authorization header | `WXPAY_CERT_SERIAL` |
|
||||
| **APIv3 密钥** | 商户后台设置 | AES-GCM 解密通知内容 | `WXPAY_API_V3_KEY` |
|
||||
|
||||
> **公钥模式 vs 平台证书模式**:本项目使用公钥模式,直接用微信支付公钥验签,不需要定期拉取/更新平台证书,部署更简单。
|
||||
|
||||
## 环境变量
|
||||
|
||||
```env
|
||||
# ── 必需 ──
|
||||
WXPAY_APP_ID=wx1234567890abcdef # 微信开放平台 APPID
|
||||
WXPAY_MCH_ID=1234567890 # 微信支付商户号
|
||||
WXPAY_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\n... # 商户 API 私钥 (RSA PEM)
|
||||
WXPAY_API_V3_KEY=your32bytesrandomstring # APIv3 密钥 (32字节)
|
||||
WXPAY_PUBLIC_KEY=-----BEGIN PUBLIC KEY-----\n... # 微信支付公钥 (PEM)
|
||||
WXPAY_PUBLIC_KEY_ID=PUB_KEY_ID_xxxxxx # 微信支付公钥 ID
|
||||
WXPAY_CERT_SERIAL=SERIAL_NUMBER_xxxxxx # 商户证书序列号
|
||||
WXPAY_NOTIFY_URL=https://pay.example.com/api/wxpay/notify # 异步通知地址
|
||||
|
||||
# ── 启用渠道 ──
|
||||
PAYMENT_PROVIDERS=wxpay # 逗号分隔,可同时含 easypay,alipay,wxpay,stripe
|
||||
ENABLED_PAYMENT_TYPES=wxpay # 前端展示哪些支付方式
|
||||
```
|
||||
|
||||
### 私钥格式
|
||||
|
||||
`WXPAY_PRIVATE_KEY` 需要完整的 PEM 格式。在 Docker Compose 中推荐使用 `|-` 多行写法:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
app:
|
||||
environment:
|
||||
WXPAY_PRIVATE_KEY: |-
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASC...
|
||||
...
|
||||
-----END PRIVATE KEY-----
|
||||
```
|
||||
|
||||
或者在 `.env` 中用 `\n` 表示换行:
|
||||
|
||||
```env
|
||||
WXPAY_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nMIIEvQI...\n-----END PRIVATE KEY-----
|
||||
```
|
||||
|
||||
`WXPAY_PUBLIC_KEY` 同理,支持裸 Base64 或完整 PEM(裸 Base64 会自动补全 header/footer)。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
用户浏览器
|
||||
│
|
||||
├── PC:前端显示 code_url 二维码 → 用户微信扫码 → 完成付款
|
||||
│ ↓
|
||||
│ POST /v3/pay/transactions/native → 返回 code_url (weixin://wxpay/...)
|
||||
│
|
||||
└── Mobile:跳转 h5_url → 拉起微信客户端付款
|
||||
↓
|
||||
POST /v3/pay/transactions/h5 → 返回 h5_url
|
||||
(如果 H5 未签约,自动 fallback 到 Native)
|
||||
|
||||
微信支付服务器
|
||||
│
|
||||
└── POST /api/wxpay/notify ← 异步通知(event_type=TRANSACTION.SUCCESS)
|
||||
│
|
||||
├── 验签(RSA-SHA256 + 微信支付公钥)
|
||||
├── 校验 serial 匹配 WXPAY_PUBLIC_KEY_ID
|
||||
├── 校验 timestamp 不超过 5 分钟
|
||||
├── AES-256-GCM 解密 resource(使用 APIv3 密钥)
|
||||
└── 调用 handlePaymentNotify() → 订单状态流转 → 充值/订阅履约
|
||||
```
|
||||
|
||||
### 签名机制
|
||||
|
||||
**请求签名** (Authorization header):
|
||||
|
||||
```
|
||||
签名串 = HTTP方法\n请求URL\n时间戳\n随机串\n请求体\n
|
||||
签名 = RSA-SHA256(签名串, 商户私钥)
|
||||
Authorization: WECHATPAY2-SHA256-RSA2048 mchid="...",serial_no="...",nonce_str="...",timestamp="...",signature="..."
|
||||
```
|
||||
|
||||
**通知验签**:
|
||||
|
||||
```
|
||||
验签串 = 时间戳\n随机串\nJSON body\n
|
||||
验证 = RSA-SHA256.verify(验签串, 微信支付公钥, Wechatpay-Signature header)
|
||||
```
|
||||
|
||||
**通知解密**:
|
||||
|
||||
```
|
||||
明文 = AES-256-GCM.decrypt(
|
||||
ciphertext,
|
||||
key = APIv3密钥,
|
||||
nonce = resource.nonce,
|
||||
aad = resource.associated_data
|
||||
)
|
||||
```
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/lib/wxpay/
|
||||
├── provider.ts # WxpayProvider 实现 PaymentProvider 接口
|
||||
├── client.ts # Native/H5 下单、查询、关闭、退款、解密通知、验签
|
||||
├── types.ts # TypeScript 类型定义
|
||||
└── index.ts # 导出入口
|
||||
|
||||
src/app/api/wxpay/
|
||||
└── notify/route.ts # 异步通知接收端点
|
||||
```
|
||||
|
||||
## 支持的 API 能力
|
||||
|
||||
| 能力 | API | 说明 |
|
||||
| ----------- | -------------------------------------------- | ------------------------------ |
|
||||
| Native 下单 | `POST /v3/pay/transactions/native` | 返回 `code_url` 用于生成二维码 |
|
||||
| H5 下单 | `POST /v3/pay/transactions/h5` | 返回 `h5_url` 拉起微信 |
|
||||
| 查询订单 | `GET /v3/pay/transactions/out-trade-no/{id}` | 主动查询交易状态 |
|
||||
| 关闭订单 | `POST /v3/pay/.../close` | 超时关单 |
|
||||
| 退款 | `POST /v3/refund/domestic/refunds` | 原路退款 |
|
||||
| 异步通知 | POST 回调 | RSA-SHA256 验签 + AES-GCM 解密 |
|
||||
|
||||
## 与 wechatpay-node-v3 的关系
|
||||
|
||||
项目使用 [`wechatpay-node-v3`](https://www.npmjs.com/package/wechatpay-node-v3) 库来生成请求签名 (`getSignature`) 和构建 Authorization header (`getAuthorization`)。实际的 HTTP 请求和通知验签/解密逻辑由项目自己实现(使用原生 `fetch` 和 Node.js `crypto`)。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **H5 支付降级**:如果 H5 支付返回 `NO_AUTH` 错误(未签约),自动 fallback 到 Native 扫码模式。
|
||||
- **金额单位**:微信 API 使用 **分** 为单位,项目内部使用 **元**。`client.ts` 中 `yuanToFen()` 自动转换。
|
||||
- **通知时效**:通知中的 `timestamp` 与服务器时间差超过 5 分钟将被拒绝。
|
||||
- **默认限额**:单笔 ¥1000,单日 ¥10000(可通过环境变量 `MAX_DAILY_AMOUNT_WXPAY_DIRECT` 调整)。
|
||||
- **WxPay 实例缓存**:`getPayInstance()` 使用模块级单例,避免重复解析密钥。
|
||||
- **通知响应格式**:微信要求成功返回 `{"code":"SUCCESS","message":"成功"}`,失败返回 `{"code":"FAIL","message":"处理失败"}`。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 通知验签失败
|
||||
|
||||
检查以下几点:
|
||||
|
||||
1. `WXPAY_PUBLIC_KEY` 是否是 **微信支付公钥**(不是商户公钥或平台证书)
|
||||
2. `WXPAY_PUBLIC_KEY_ID` 是否与通知 header 中的 `Wechatpay-Serial` 匹配
|
||||
3. 服务器时间是否准确(NTP 同步)
|
||||
|
||||
### Q: H5 支付报 NO_AUTH
|
||||
|
||||
需要在微信支付商户后台 → 产品中心 → H5 支付 → 申请开通,并配置 H5 支付域名。未开通前项目会自动降级为 Native 扫码。
|
||||
|
||||
### Q: 如何获取微信支付公钥?
|
||||
|
||||
微信支付商户后台 → API 安全 → 微信支付公钥。注意这是 2024 年后推出的公钥模式,区别于之前的平台证书模式。如果你的商户号不支持公钥模式,需要联系微信支付升级。
|
||||
BIN
docs/refrence/channel-conf.png
Normal file
BIN
docs/refrence/channel-conf.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
BIN
docs/refrence/subscribe-main.png
Normal file
BIN
docs/refrence/subscribe-main.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
BIN
docs/refrence/subscribe.png
Normal file
BIN
docs/refrence/subscribe.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
BIN
docs/refrence/top-up-main.png
Normal file
BIN
docs/refrence/top-up-main.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 331 KiB |
BIN
docs/refrence/top-up.png
Normal file
BIN
docs/refrence/top-up.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 244 KiB |
@@ -0,0 +1,66 @@
|
||||
-- CreateTable: channels
|
||||
CREATE TABLE "channels" (
|
||||
"id" TEXT NOT NULL,
|
||||
"group_id" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"platform" TEXT NOT NULL DEFAULT 'claude',
|
||||
"rate_multiplier" DECIMAL(10,4) NOT NULL,
|
||||
"description" TEXT,
|
||||
"models" TEXT,
|
||||
"features" TEXT,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "channels_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable: subscription_plans
|
||||
CREATE TABLE "subscription_plans" (
|
||||
"id" TEXT NOT NULL,
|
||||
"group_id" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"price" DECIMAL(10,2) NOT NULL,
|
||||
"original_price" DECIMAL(10,2),
|
||||
"validity_days" INTEGER NOT NULL DEFAULT 30,
|
||||
"features" TEXT,
|
||||
"for_sale" BOOLEAN NOT NULL DEFAULT false,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "subscription_plans_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable: system_configs
|
||||
CREATE TABLE "system_configs" (
|
||||
"key" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"group" TEXT NOT NULL DEFAULT 'general',
|
||||
"label" TEXT,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "system_configs_pkey" PRIMARY KEY ("key")
|
||||
);
|
||||
|
||||
-- AlterTable: orders - add subscription fields
|
||||
ALTER TABLE "orders" ADD COLUMN "order_type" TEXT NOT NULL DEFAULT 'balance';
|
||||
ALTER TABLE "orders" ADD COLUMN "plan_id" TEXT;
|
||||
ALTER TABLE "orders" ADD COLUMN "subscription_group_id" INTEGER;
|
||||
ALTER TABLE "orders" ADD COLUMN "subscription_days" INTEGER;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "channels_group_id_key" ON "channels"("group_id");
|
||||
CREATE INDEX "channels_sort_order_idx" ON "channels"("sort_order");
|
||||
|
||||
CREATE UNIQUE INDEX "subscription_plans_group_id_key" ON "subscription_plans"("group_id");
|
||||
CREATE INDEX "subscription_plans_for_sale_sort_order_idx" ON "subscription_plans"("for_sale", "sort_order");
|
||||
|
||||
CREATE INDEX "system_configs_group_idx" ON "system_configs"("group");
|
||||
|
||||
CREATE INDEX "orders_order_type_idx" ON "orders"("order_type");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "orders" ADD CONSTRAINT "orders_plan_id_fkey" FOREIGN KEY ("plan_id") REFERENCES "subscription_plans"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "subscription_plans" ADD COLUMN "validity_unit" TEXT NOT NULL DEFAULT 'day';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable: increase fee_rate precision from Decimal(5,2) to Decimal(5,4)
|
||||
ALTER TABLE "orders" ALTER COLUMN "fee_rate" TYPE DECIMAL(5,4);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "subscription_plans" ADD COLUMN "product_name" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable: make group_id nullable on subscription_plans
|
||||
ALTER TABLE "subscription_plans" ALTER COLUMN "group_id" DROP NOT NULL;
|
||||
@@ -14,7 +14,7 @@ model Order {
|
||||
userNotes String? @map("user_notes")
|
||||
amount Decimal @db.Decimal(10, 2)
|
||||
payAmount Decimal? @db.Decimal(10, 2) @map("pay_amount")
|
||||
feeRate Decimal? @db.Decimal(5, 2) @map("fee_rate")
|
||||
feeRate Decimal? @db.Decimal(5, 4) @map("fee_rate")
|
||||
rechargeCode String @unique @map("recharge_code")
|
||||
status OrderStatus @default(PENDING)
|
||||
paymentType String @map("payment_type")
|
||||
@@ -40,6 +40,13 @@ model Order {
|
||||
srcHost String? @map("src_host")
|
||||
srcUrl String? @map("src_url")
|
||||
|
||||
// ── 订单类型 & 订阅相关 ──
|
||||
orderType String @default("balance") @map("order_type")
|
||||
planId String? @map("plan_id")
|
||||
plan SubscriptionPlan? @relation(fields: [planId], references: [id])
|
||||
subscriptionGroupId Int? @map("subscription_group_id")
|
||||
subscriptionDays Int? @map("subscription_days")
|
||||
|
||||
auditLogs AuditLog[]
|
||||
|
||||
@@index([userId])
|
||||
@@ -48,6 +55,7 @@ model Order {
|
||||
@@index([createdAt])
|
||||
@@index([paidAt])
|
||||
@@index([paymentType, paidAt])
|
||||
@@index([orderType])
|
||||
@@map("orders")
|
||||
}
|
||||
|
||||
@@ -77,3 +85,57 @@ model AuditLog {
|
||||
@@index([createdAt])
|
||||
@@map("audit_logs")
|
||||
}
|
||||
|
||||
// ── 渠道展示配置 ──
|
||||
model Channel {
|
||||
id String @id @default(cuid())
|
||||
groupId Int @unique @map("group_id")
|
||||
name String
|
||||
platform String @default("claude")
|
||||
rateMultiplier Decimal @db.Decimal(10, 4) @map("rate_multiplier")
|
||||
description String? @db.Text
|
||||
models String? @db.Text
|
||||
features String? @db.Text
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
enabled Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([sortOrder])
|
||||
@@map("channels")
|
||||
}
|
||||
|
||||
// ── 订阅套餐配置 ──
|
||||
model SubscriptionPlan {
|
||||
id String @id @default(cuid())
|
||||
groupId Int? @unique @map("group_id")
|
||||
name String
|
||||
description String? @db.Text
|
||||
price Decimal @db.Decimal(10, 2)
|
||||
originalPrice Decimal? @db.Decimal(10, 2) @map("original_price")
|
||||
validityDays Int @default(30) @map("validity_days")
|
||||
validityUnit String @default("day") @map("validity_unit") // day | week | month
|
||||
features String? @db.Text
|
||||
productName String? @map("product_name")
|
||||
forSale Boolean @default(false) @map("for_sale")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
orders Order[]
|
||||
|
||||
@@index([forSale, sortOrder])
|
||||
@@map("subscription_plans")
|
||||
}
|
||||
|
||||
// ── 系统配置 ──
|
||||
model SystemConfig {
|
||||
key String @id
|
||||
value String @db.Text
|
||||
group String @default("general")
|
||||
label String?
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([group])
|
||||
@@map("system_configs")
|
||||
}
|
||||
|
||||
70
src/__tests__/app/api/order-status-route.test.ts
Normal file
70
src/__tests__/app/api/order-status-route.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
const mockFindUnique = vi.fn();
|
||||
const mockVerifyAdminToken = vi.fn();
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: {
|
||||
order: {
|
||||
findUnique: (...args: unknown[]) => mockFindUnique(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getEnv: () => ({
|
||||
ADMIN_TOKEN: 'test-admin-token',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/admin-auth', () => ({
|
||||
verifyAdminToken: (...args: unknown[]) => mockVerifyAdminToken(...args),
|
||||
}));
|
||||
|
||||
import { GET } from '@/app/api/orders/[id]/route';
|
||||
import { createOrderStatusAccessToken } from '@/lib/order/status-access';
|
||||
|
||||
function createRequest(orderId: string, accessToken?: string) {
|
||||
const url = new URL(`https://pay.example.com/api/orders/${orderId}`);
|
||||
if (accessToken) {
|
||||
url.searchParams.set('access_token', accessToken);
|
||||
}
|
||||
return new NextRequest(url);
|
||||
}
|
||||
|
||||
describe('GET /api/orders/[id]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockVerifyAdminToken.mockResolvedValue(false);
|
||||
mockFindUnique.mockResolvedValue({
|
||||
id: 'order-001',
|
||||
status: 'PENDING',
|
||||
expiresAt: new Date('2026-03-10T00:00:00.000Z'),
|
||||
paidAt: null,
|
||||
completedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects requests without access token', async () => {
|
||||
const response = await GET(createRequest('order-001'), { params: Promise.resolve({ id: 'order-001' }) });
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns order status with valid access token', async () => {
|
||||
const token = createOrderStatusAccessToken('order-001');
|
||||
const response = await GET(createRequest('order-001', token), { params: Promise.resolve({ id: 'order-001' }) });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.id).toBe('order-001');
|
||||
expect(data.paymentSuccess).toBe(false);
|
||||
});
|
||||
|
||||
it('allows admin-authenticated access as fallback', async () => {
|
||||
mockVerifyAdminToken.mockResolvedValue(true);
|
||||
const response = await GET(createRequest('order-001'), { params: Promise.resolve({ id: 'order-001' }) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
114
src/__tests__/app/api/user-route.test.ts
Normal file
114
src/__tests__/app/api/user-route.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
const mockGetCurrentUserByToken = vi.fn();
|
||||
const mockGetUser = vi.fn();
|
||||
const mockGetSystemConfig = vi.fn();
|
||||
const mockQueryMethodLimits = vi.fn();
|
||||
const mockGetSupportedTypes = vi.fn();
|
||||
|
||||
vi.mock('@/lib/sub2api/client', () => ({
|
||||
getCurrentUserByToken: (...args: unknown[]) => mockGetCurrentUserByToken(...args),
|
||||
getUser: (...args: unknown[]) => mockGetUser(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getEnv: () => ({
|
||||
MIN_RECHARGE_AMOUNT: 1,
|
||||
MAX_RECHARGE_AMOUNT: 1000,
|
||||
MAX_DAILY_RECHARGE_AMOUNT: 10000,
|
||||
PAY_HELP_IMAGE_URL: undefined,
|
||||
PAY_HELP_TEXT: undefined,
|
||||
STRIPE_PUBLISHABLE_KEY: 'pk_test',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/order/limits', () => ({
|
||||
queryMethodLimits: (...args: unknown[]) => mockQueryMethodLimits(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/payment', () => ({
|
||||
initPaymentProviders: vi.fn(),
|
||||
paymentRegistry: {
|
||||
getSupportedTypes: (...args: unknown[]) => mockGetSupportedTypes(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/pay-utils', () => ({
|
||||
getPaymentDisplayInfo: (type: string) => ({
|
||||
channel: type === 'alipay_direct' ? 'alipay' : type,
|
||||
provider: type,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/locale', () => ({
|
||||
resolveLocale: () => 'zh',
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/system-config', () => ({
|
||||
getSystemConfig: (...args: unknown[]) => mockGetSystemConfig(...args),
|
||||
}));
|
||||
|
||||
import { GET } from '@/app/api/user/route';
|
||||
|
||||
function createRequest() {
|
||||
return new NextRequest('https://pay.example.com/api/user?user_id=1&token=test-token');
|
||||
}
|
||||
|
||||
describe('GET /api/user', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetCurrentUserByToken.mockResolvedValue({ id: 1 });
|
||||
mockGetUser.mockResolvedValue({ id: 1, status: 'active' });
|
||||
mockGetSupportedTypes.mockReturnValue(['alipay', 'wxpay', 'stripe']);
|
||||
mockQueryMethodLimits.mockResolvedValue({
|
||||
alipay: { maxDailyAmount: 1000 },
|
||||
wxpay: { maxDailyAmount: 1000 },
|
||||
stripe: { maxDailyAmount: 1000 },
|
||||
});
|
||||
mockGetSystemConfig.mockImplementation(async (key: string) => {
|
||||
if (key === 'ENABLED_PAYMENT_TYPES') return undefined;
|
||||
if (key === 'BALANCE_PAYMENT_DISABLED') return 'false';
|
||||
return undefined;
|
||||
});
|
||||
});
|
||||
|
||||
it('filters enabled payment types by ENABLED_PAYMENT_TYPES config', async () => {
|
||||
mockGetSystemConfig.mockImplementation(async (key: string) => {
|
||||
if (key === 'ENABLED_PAYMENT_TYPES') return 'alipay,wxpay';
|
||||
if (key === 'BALANCE_PAYMENT_DISABLED') return 'false';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const response = await GET(createRequest());
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.config.enabledPaymentTypes).toEqual(['alipay', 'wxpay']);
|
||||
expect(mockQueryMethodLimits).toHaveBeenCalledWith(['alipay', 'wxpay']);
|
||||
});
|
||||
|
||||
it('falls back to supported payment types when ENABLED_PAYMENT_TYPES is empty', async () => {
|
||||
mockGetSystemConfig.mockImplementation(async (key: string) => {
|
||||
if (key === 'ENABLED_PAYMENT_TYPES') return ' ';
|
||||
if (key === 'BALANCE_PAYMENT_DISABLED') return 'false';
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const response = await GET(createRequest());
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.config.enabledPaymentTypes).toEqual(['alipay', 'wxpay', 'stripe']);
|
||||
expect(mockQueryMethodLimits).toHaveBeenCalledWith(['alipay', 'wxpay', 'stripe']);
|
||||
});
|
||||
|
||||
it('falls back to supported payment types when ENABLED_PAYMENT_TYPES is undefined', async () => {
|
||||
const response = await GET(createRequest());
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.config.enabledPaymentTypes).toEqual(['alipay', 'wxpay', 'stripe']);
|
||||
expect(mockQueryMethodLimits).toHaveBeenCalledWith(['alipay', 'wxpay', 'stripe']);
|
||||
});
|
||||
});
|
||||
250
src/__tests__/app/pay/alipay-short-link-route.test.ts
Normal file
250
src/__tests__/app/pay/alipay-short-link-route.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { ORDER_STATUS } from '@/lib/constants';
|
||||
|
||||
const mockFindUnique = vi.fn();
|
||||
const mockBuildAlipayPaymentUrl = vi.fn();
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: {
|
||||
order: {
|
||||
findUnique: (...args: unknown[]) => mockFindUnique(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getEnv: () => ({
|
||||
NEXT_PUBLIC_APP_URL: 'https://pay.example.com',
|
||||
PRODUCT_NAME: 'Sub2API Balance Recharge',
|
||||
ALIPAY_NOTIFY_URL: 'https://pay.example.com/api/alipay/notify',
|
||||
ALIPAY_RETURN_URL: 'https://pay.example.com/pay/result',
|
||||
ADMIN_TOKEN: 'test-admin-token',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/alipay/provider', () => ({
|
||||
buildAlipayPaymentUrl: (...args: unknown[]) => mockBuildAlipayPaymentUrl(...args),
|
||||
}));
|
||||
|
||||
import { GET } from '@/app/pay/[orderId]/route';
|
||||
import { buildOrderResultUrl } from '@/lib/order/status-access';
|
||||
|
||||
function createRequest(userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)') {
|
||||
return new NextRequest('https://pay.example.com/pay/order-001', {
|
||||
headers: { 'user-agent': userAgent },
|
||||
});
|
||||
}
|
||||
|
||||
function createPendingOrder(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'order-001',
|
||||
amount: 88,
|
||||
payAmount: 100.5,
|
||||
paymentType: 'alipay_direct',
|
||||
status: ORDER_STATUS.PENDING,
|
||||
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
||||
paidAt: null,
|
||||
completedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('GET /pay/[orderId]', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ now: new Date('2026-03-14T12:00:00Z') });
|
||||
vi.clearAllMocks();
|
||||
mockBuildAlipayPaymentUrl.mockReturnValue('https://openapi.alipay.com/gateway.do?mock=1');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns 404 error page when order does not exist', async () => {
|
||||
mockFindUnique.mockResolvedValue(null);
|
||||
|
||||
const response = await GET(createRequest(), {
|
||||
params: Promise.resolve({ orderId: 'missing-order' }),
|
||||
});
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(html).toContain('订单不存在');
|
||||
expect(html).toContain('missing-order');
|
||||
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects non-alipay orders', async () => {
|
||||
mockFindUnique.mockResolvedValue(
|
||||
createPendingOrder({
|
||||
paymentType: 'wxpay_direct',
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await GET(createRequest(), {
|
||||
params: Promise.resolve({ orderId: 'order-001' }),
|
||||
});
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(html).toContain('支付方式不匹配');
|
||||
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns success status page for completed orders', async () => {
|
||||
mockFindUnique.mockResolvedValue(
|
||||
createPendingOrder({
|
||||
status: ORDER_STATUS.COMPLETED,
|
||||
paidAt: new Date('2026-03-09T10:00:00Z'),
|
||||
completedAt: new Date('2026-03-09T10:00:03Z'),
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await GET(createRequest(), {
|
||||
params: Promise.resolve({ orderId: 'order-001' }),
|
||||
});
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(html).toContain('充值成功');
|
||||
expect(html).toContain('余额已到账');
|
||||
expect(html).toContain('order_id=order-001');
|
||||
expect(html).toContain('access_token=');
|
||||
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns paid-but-recharge-failed status page for failed paid orders', async () => {
|
||||
mockFindUnique.mockResolvedValue(
|
||||
createPendingOrder({
|
||||
status: ORDER_STATUS.FAILED,
|
||||
paidAt: new Date('2026-03-09T10:00:00Z'),
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await GET(createRequest(), {
|
||||
params: Promise.resolve({ orderId: 'order-001' }),
|
||||
});
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(html).toContain('支付成功');
|
||||
expect(html).toContain('余额充值暂未完成');
|
||||
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns expired status page when order is timed out', async () => {
|
||||
mockFindUnique.mockResolvedValue(
|
||||
createPendingOrder({
|
||||
expiresAt: new Date(Date.now() - 1000),
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await GET(createRequest(), {
|
||||
params: Promise.resolve({ orderId: 'order-001' }),
|
||||
});
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(html).toContain('订单超时');
|
||||
expect(html).toContain('订单已超时');
|
||||
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('builds desktop redirect page with service-generated alipay url and no manual pay button', async () => {
|
||||
mockBuildAlipayPaymentUrl.mockReturnValue('https://openapi.alipay.com/gateway.do?desktop=1');
|
||||
mockFindUnique.mockResolvedValue(createPendingOrder());
|
||||
|
||||
const response = await GET(createRequest(), {
|
||||
params: Promise.resolve({ orderId: 'order-001' }),
|
||||
});
|
||||
|
||||
const html = await response.text();
|
||||
const expectedReturnUrl = buildOrderResultUrl('https://pay.example.com', 'order-001');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(html).toContain('正在拉起支付宝');
|
||||
expect(html).toContain('https://openapi.alipay.com/gateway.do?desktop=1');
|
||||
expect(html).toContain('http-equiv="refresh"');
|
||||
expect(html).not.toContain('立即前往支付宝');
|
||||
expect(html).toContain('查看订单结果');
|
||||
expect(html).toContain('order_id=order-001');
|
||||
expect(html).toContain('access_token=');
|
||||
expect(mockBuildAlipayPaymentUrl).toHaveBeenCalledWith({
|
||||
orderId: 'order-001',
|
||||
amount: 100.5,
|
||||
subject: 'Sub2API Balance Recharge 100.50 CNY',
|
||||
notifyUrl: 'https://pay.example.com/api/alipay/notify',
|
||||
returnUrl: expectedReturnUrl,
|
||||
isMobile: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds mobile redirect page with wap alipay url', async () => {
|
||||
mockBuildAlipayPaymentUrl.mockReturnValue('https://openapi.alipay.com/gateway.do?mobile=1');
|
||||
mockFindUnique.mockResolvedValue(
|
||||
createPendingOrder({
|
||||
payAmount: null,
|
||||
amount: 88,
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await GET(
|
||||
createRequest('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148'),
|
||||
{
|
||||
params: Promise.resolve({ orderId: 'order-001' }),
|
||||
},
|
||||
);
|
||||
|
||||
const html = await response.text();
|
||||
const expectedReturnUrl = buildOrderResultUrl('https://pay.example.com', 'order-001');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(html).toContain('正在拉起支付宝');
|
||||
expect(html).toContain('https://openapi.alipay.com/gateway.do?mobile=1');
|
||||
expect(html).not.toContain('立即前往支付宝');
|
||||
expect(mockBuildAlipayPaymentUrl).toHaveBeenCalledWith({
|
||||
orderId: 'order-001',
|
||||
amount: 88,
|
||||
subject: 'Sub2API Balance Recharge 88.00 CNY',
|
||||
notifyUrl: 'https://pay.example.com/api/alipay/notify',
|
||||
returnUrl: expectedReturnUrl,
|
||||
isMobile: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('omits returnUrl for Alipay app requests to avoid extra close step', async () => {
|
||||
mockBuildAlipayPaymentUrl.mockReturnValue('https://openapi.alipay.com/gateway.do?alipayapp=1');
|
||||
mockFindUnique.mockResolvedValue(createPendingOrder({ payAmount: 66 }));
|
||||
|
||||
const response = await GET(
|
||||
createRequest(
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 AlipayClient/10.5.90',
|
||||
),
|
||||
{
|
||||
params: Promise.resolve({ orderId: 'order-001' }),
|
||||
},
|
||||
);
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(html).toContain('https://openapi.alipay.com/gateway.do?alipayapp=1');
|
||||
expect(html).toContain('window.location.replace(payUrl)');
|
||||
expect(html).toContain('<noscript><meta http-equiv="refresh"');
|
||||
expect(html).not.toContain('立即前往支付宝');
|
||||
expect(mockBuildAlipayPaymentUrl).toHaveBeenCalledWith({
|
||||
orderId: 'order-001',
|
||||
amount: 66,
|
||||
subject: 'Sub2API Balance Recharge 66.00 CNY',
|
||||
notifyUrl: 'https://pay.example.com/api/alipay/notify',
|
||||
returnUrl: null,
|
||||
isMobile: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
98
src/__tests__/lib/alipay/client.test.ts
Normal file
98
src/__tests__/lib/alipay/client.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getEnv: () => ({
|
||||
ALIPAY_APP_ID: '2021000000000000',
|
||||
ALIPAY_PRIVATE_KEY: 'test-private-key',
|
||||
ALIPAY_PUBLIC_KEY: 'test-public-key',
|
||||
ALIPAY_NOTIFY_URL: 'https://pay.example.com/api/alipay/notify',
|
||||
ALIPAY_RETURN_URL: 'https://pay.example.com/pay/result',
|
||||
}),
|
||||
}));
|
||||
|
||||
const { mockGenerateSign } = vi.hoisted(() => ({
|
||||
mockGenerateSign: vi.fn(() => 'signed-value'),
|
||||
}));
|
||||
vi.mock('@/lib/alipay/sign', () => ({
|
||||
generateSign: mockGenerateSign,
|
||||
verifyResponseSign: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
import { execute, pageExecute } from '@/lib/alipay/client';
|
||||
|
||||
describe('alipay client helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('pageExecute includes notify_url and return_url by default', () => {
|
||||
const url = new URL(
|
||||
pageExecute({ out_trade_no: 'order-001', product_code: 'FAST_INSTANT_TRADE_PAY', total_amount: '10.00' }),
|
||||
);
|
||||
|
||||
expect(url.origin + url.pathname).toBe('https://openapi.alipay.com/gateway.do');
|
||||
expect(url.searchParams.get('notify_url')).toBe('https://pay.example.com/api/alipay/notify');
|
||||
expect(url.searchParams.get('return_url')).toBe('https://pay.example.com/pay/result');
|
||||
expect(url.searchParams.get('method')).toBe('alipay.trade.page.pay');
|
||||
expect(url.searchParams.get('sign')).toBe('signed-value');
|
||||
});
|
||||
|
||||
it('pageExecute omits return_url when explicitly disabled', () => {
|
||||
const url = new URL(
|
||||
pageExecute(
|
||||
{ out_trade_no: 'order-002', product_code: 'QUICK_WAP_WAY', total_amount: '20.00' },
|
||||
{ returnUrl: null, method: 'alipay.trade.wap.pay' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(url.searchParams.get('method')).toBe('alipay.trade.wap.pay');
|
||||
expect(url.searchParams.get('return_url')).toBeNull();
|
||||
expect(url.searchParams.get('notify_url')).toBe('https://pay.example.com/api/alipay/notify');
|
||||
});
|
||||
|
||||
it('execute posts form data and returns the named response payload', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
alipay_trade_query_response: {
|
||||
code: '10000',
|
||||
msg: 'Success',
|
||||
trade_status: 'TRADE_SUCCESS',
|
||||
},
|
||||
sign: 'server-sign',
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json; charset=utf-8' } },
|
||||
),
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await execute('alipay.trade.query', { out_trade_no: 'order-003' });
|
||||
|
||||
expect(result).toEqual({ code: '10000', msg: 'Success', trade_status: 'TRADE_SUCCESS' });
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe('https://openapi.alipay.com/gateway.do');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded' });
|
||||
expect(String(init.body)).toContain('method=alipay.trade.query');
|
||||
});
|
||||
|
||||
it('execute throws when alipay response code is not successful', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
alipay_trade_query_response: {
|
||||
code: '40004',
|
||||
msg: 'Business Failed',
|
||||
sub_code: 'ACQ.TRADE_NOT_EXIST',
|
||||
sub_msg: 'trade not exist',
|
||||
},
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json; charset=utf-8' } },
|
||||
),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(execute('alipay.trade.query', { out_trade_no: 'order-004' })).rejects.toThrow(
|
||||
'[ACQ.TRADE_NOT_EXIST] trade not exist',
|
||||
);
|
||||
});
|
||||
});
|
||||
31
src/__tests__/lib/alipay/codec.test.ts
Normal file
31
src/__tests__/lib/alipay/codec.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { decodeAlipayPayload, parseAlipayNotificationParams } from '@/lib/alipay/codec';
|
||||
|
||||
describe('Alipay codec', () => {
|
||||
it('should normalize plus signs in notify sign parameter', () => {
|
||||
const params = parseAlipayNotificationParams(Buffer.from('sign=abc+def&trade_no=1'), {
|
||||
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
|
||||
});
|
||||
|
||||
expect(params.sign).toBe('abc+def');
|
||||
expect(params.trade_no).toBe('1');
|
||||
});
|
||||
|
||||
it('should decode payload charset from content-type header', () => {
|
||||
const body = Buffer.from('charset=utf-8&trade_status=TRADE_SUCCESS', 'utf-8');
|
||||
|
||||
const decoded = decodeAlipayPayload(body, {
|
||||
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
|
||||
});
|
||||
|
||||
expect(decoded).toContain('trade_status=TRADE_SUCCESS');
|
||||
});
|
||||
|
||||
it('should fallback to body charset hint when header is missing', () => {
|
||||
const body = Buffer.from('charset=gbk&trade_no=202603090001', 'utf-8');
|
||||
|
||||
const decoded = decodeAlipayPayload(body);
|
||||
|
||||
expect(decoded).toContain('trade_no=202603090001');
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ vi.mock('@/lib/config', () => ({
|
||||
ALIPAY_NOTIFY_URL: 'https://pay.example.com/api/alipay/notify',
|
||||
ALIPAY_RETURN_URL: 'https://pay.example.com/pay/result',
|
||||
NEXT_PUBLIC_APP_URL: 'https://pay.example.com',
|
||||
PRODUCT_NAME: 'Sub2API Balance Recharge',
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -25,7 +26,7 @@ vi.mock('@/lib/alipay/sign', () => ({
|
||||
verifySign: (...args: unknown[]) => mockVerifySign(...args),
|
||||
}));
|
||||
|
||||
import { AlipayProvider } from '@/lib/alipay/provider';
|
||||
import { AlipayProvider, buildAlipayEntryUrl } from '@/lib/alipay/provider';
|
||||
import type { CreatePaymentRequest, RefundRequest } from '@/lib/payment/types';
|
||||
|
||||
describe('AlipayProvider', () => {
|
||||
@@ -57,13 +58,11 @@ describe('AlipayProvider', () => {
|
||||
});
|
||||
|
||||
describe('createPayment', () => {
|
||||
it('should call pageExecute and return payUrl', async () => {
|
||||
mockPageExecute.mockReturnValue('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
|
||||
|
||||
it('should return service short link as desktop qrCode', async () => {
|
||||
const request: CreatePaymentRequest = {
|
||||
orderId: 'order-001',
|
||||
amount: 100,
|
||||
paymentType: 'alipay',
|
||||
paymentType: 'alipay_direct',
|
||||
subject: 'Sub2API Balance Recharge 100.00 CNY',
|
||||
clientIp: '127.0.0.1',
|
||||
};
|
||||
@@ -71,16 +70,42 @@ describe('AlipayProvider', () => {
|
||||
const result = await provider.createPayment(request);
|
||||
|
||||
expect(result.tradeNo).toBe('order-001');
|
||||
expect(result.qrCode).toBe('https://pay.example.com/pay/order-001');
|
||||
expect(result.payUrl).toBe('https://pay.example.com/pay/order-001');
|
||||
expect(mockExecute).not.toHaveBeenCalled();
|
||||
expect(mockPageExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should build short link from app url', () => {
|
||||
expect(buildAlipayEntryUrl('order-short-link')).toBe('https://pay.example.com/pay/order-short-link');
|
||||
});
|
||||
|
||||
it('should call pageExecute for mobile and return payUrl', async () => {
|
||||
mockPageExecute.mockReturnValue('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
|
||||
|
||||
const request: CreatePaymentRequest = {
|
||||
orderId: 'order-002',
|
||||
amount: 50,
|
||||
paymentType: 'alipay_direct',
|
||||
subject: 'Sub2API Balance Recharge 50.00 CNY',
|
||||
clientIp: '127.0.0.1',
|
||||
isMobile: true,
|
||||
};
|
||||
|
||||
const result = await provider.createPayment(request);
|
||||
|
||||
expect(result.tradeNo).toBe('order-002');
|
||||
expect(result.payUrl).toBe('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
|
||||
expect(mockPageExecute).toHaveBeenCalledWith(
|
||||
{
|
||||
out_trade_no: 'order-001',
|
||||
product_code: 'FAST_INSTANT_TRADE_PAY',
|
||||
total_amount: '100.00',
|
||||
subject: 'Sub2API Balance Recharge 100.00 CNY',
|
||||
out_trade_no: 'order-002',
|
||||
product_code: 'QUICK_WAP_WAY',
|
||||
total_amount: '50.00',
|
||||
subject: 'Sub2API Balance Recharge 50.00 CNY',
|
||||
},
|
||||
expect.objectContaining({}),
|
||||
expect.objectContaining({ method: 'alipay.trade.wap.pay' }),
|
||||
);
|
||||
expect(mockExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,6 +165,15 @@ describe('AlipayProvider', () => {
|
||||
const result = await provider.queryOrder('order-004');
|
||||
expect(result.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('should treat ACQ.TRADE_NOT_EXIST as pending', async () => {
|
||||
mockExecute.mockRejectedValue(new Error('Alipay API error: [ACQ.TRADE_NOT_EXIST] 交易不存在'));
|
||||
|
||||
const result = await provider.queryOrder('order-005');
|
||||
expect(result.tradeNo).toBe('order-005');
|
||||
expect(result.status).toBe('pending');
|
||||
expect(result.amount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyNotification', () => {
|
||||
@@ -188,7 +222,7 @@ describe('AlipayProvider', () => {
|
||||
trade_no: '2026030500003',
|
||||
out_trade_no: 'order-003',
|
||||
trade_status: 'TRADE_CLOSED',
|
||||
total_amount: '30.00',
|
||||
total_amount: '20.00',
|
||||
sign: 'test_sign',
|
||||
sign_type: 'RSA2',
|
||||
app_id: '2021000000000000',
|
||||
@@ -198,80 +232,98 @@ describe('AlipayProvider', () => {
|
||||
expect(result.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('should throw on invalid signature', async () => {
|
||||
mockVerifySign.mockReturnValue(false);
|
||||
|
||||
it('should reject unsupported sign_type', async () => {
|
||||
const body = new URLSearchParams({
|
||||
trade_no: '2026030500004',
|
||||
out_trade_no: 'order-004',
|
||||
trade_status: 'TRADE_SUCCESS',
|
||||
total_amount: '20.00',
|
||||
sign: 'test_sign',
|
||||
sign_type: 'RSA',
|
||||
app_id: '2021000000000000',
|
||||
}).toString();
|
||||
|
||||
await expect(provider.verifyNotification(body, {})).rejects.toThrow('Unsupported sign_type');
|
||||
});
|
||||
|
||||
it('should reject invalid signature', async () => {
|
||||
mockVerifySign.mockReturnValue(false);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
trade_no: '2026030500005',
|
||||
out_trade_no: 'order-005',
|
||||
trade_status: 'TRADE_SUCCESS',
|
||||
total_amount: '20.00',
|
||||
sign: 'bad_sign',
|
||||
sign_type: 'RSA2',
|
||||
app_id: '2021000000000000',
|
||||
}).toString();
|
||||
|
||||
await expect(provider.verifyNotification(body, {})).rejects.toThrow(
|
||||
'Alipay notification signature verification failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject app_id mismatch', async () => {
|
||||
mockVerifySign.mockReturnValue(true);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
trade_no: '2026030500006',
|
||||
out_trade_no: 'order-006',
|
||||
trade_status: 'TRADE_SUCCESS',
|
||||
total_amount: '20.00',
|
||||
sign: 'test_sign',
|
||||
sign_type: 'RSA2',
|
||||
app_id: '2021000000009999',
|
||||
}).toString();
|
||||
|
||||
await expect(provider.verifyNotification(body, {})).rejects.toThrow('Alipay notification app_id mismatch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refund', () => {
|
||||
it('should call alipay.trade.refund and return success', async () => {
|
||||
it('should request refund and map success status', async () => {
|
||||
mockExecute.mockResolvedValue({
|
||||
code: '10000',
|
||||
msg: 'Success',
|
||||
trade_no: '2026030500001',
|
||||
trade_no: 'refund-trade-no',
|
||||
fund_change: 'Y',
|
||||
});
|
||||
|
||||
const request: RefundRequest = {
|
||||
tradeNo: '2026030500001',
|
||||
orderId: 'order-001',
|
||||
amount: 100,
|
||||
reason: 'customer request',
|
||||
tradeNo: 'trade-no',
|
||||
orderId: 'order-refund',
|
||||
amount: 12.34,
|
||||
reason: 'test refund',
|
||||
};
|
||||
|
||||
const result = await provider.refund(request);
|
||||
expect(result.refundId).toBe('2026030500001');
|
||||
expect(result.status).toBe('success');
|
||||
|
||||
expect(result).toEqual({ refundId: 'refund-trade-no', status: 'success' });
|
||||
expect(mockExecute).toHaveBeenCalledWith('alipay.trade.refund', {
|
||||
out_trade_no: 'order-001',
|
||||
refund_amount: '100.00',
|
||||
refund_reason: 'customer request',
|
||||
out_request_no: 'order-001-refund',
|
||||
out_trade_no: 'order-refund',
|
||||
refund_amount: '12.34',
|
||||
refund_reason: 'test refund',
|
||||
out_request_no: 'order-refund-refund',
|
||||
});
|
||||
});
|
||||
|
||||
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',
|
||||
it('should close payment by out_trade_no', async () => {
|
||||
mockExecute.mockResolvedValue({ code: '10000', msg: 'Success' });
|
||||
|
||||
await provider.cancelPayment('order-close');
|
||||
|
||||
expect(mockExecute).toHaveBeenCalledWith('alipay.trade.close', {
|
||||
out_trade_no: 'order-close',
|
||||
});
|
||||
});
|
||||
|
||||
await provider.cancelPayment('order-001');
|
||||
expect(mockExecute).toHaveBeenCalledWith('alipay.trade.close', {
|
||||
out_trade_no: 'order-001',
|
||||
});
|
||||
it('should ignore ACQ.TRADE_NOT_EXIST when closing payment', async () => {
|
||||
mockExecute.mockRejectedValue(new Error('Alipay API error: [ACQ.TRADE_NOT_EXIST] 交易不存在'));
|
||||
|
||||
await expect(provider.cancelPayment('order-close-missing')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,6 @@ describe('Alipay RSA2 Sign', () => {
|
||||
const sign = generateSign(testParams, privateKey);
|
||||
expect(sign).toBeTruthy();
|
||||
expect(typeof sign).toBe('string');
|
||||
// base64 格式
|
||||
expect(() => Buffer.from(sign, 'base64')).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -44,16 +43,15 @@ describe('Alipay RSA2 Sign', () => {
|
||||
expect(sign1).toBe(sign2);
|
||||
});
|
||||
|
||||
it('should filter out sign and sign_type fields', () => {
|
||||
it('should filter out sign field but keep sign_type in request signing', () => {
|
||||
const paramsWithSign = { ...testParams, sign: 'old_sign' };
|
||||
const sign1 = generateSign(testParams, privateKey);
|
||||
const sign2 = generateSign(paramsWithSign, privateKey);
|
||||
expect(sign1).toBe(sign2);
|
||||
|
||||
// sign_type should also be excluded from signing (per Alipay spec)
|
||||
const paramsWithSignType = { ...testParams, sign_type: 'RSA2' };
|
||||
const sign3 = generateSign(paramsWithSignType, privateKey);
|
||||
expect(sign3).toBe(sign1);
|
||||
expect(sign3).not.toBe(sign1);
|
||||
});
|
||||
|
||||
it('should filter out empty values', () => {
|
||||
@@ -113,5 +111,35 @@ describe('Alipay RSA2 Sign', () => {
|
||||
const valid = verifySign(testParams, barePublicKey, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with private key using literal \\n escapes', () => {
|
||||
const escapedPrivateKey = privateKey.replace(/\n/g, '\\n');
|
||||
const sign = generateSign(testParams, escapedPrivateKey);
|
||||
const valid = verifySign(testParams, publicKey, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with public key using literal \\n escapes', () => {
|
||||
const escapedPublicKey = publicKey.replace(/\n/g, '\\n');
|
||||
const sign = generateSign(testParams, privateKey);
|
||||
const valid = verifySign(testParams, escapedPublicKey, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with CRLF-formatted PEM keys', () => {
|
||||
const crlfPrivateKey = privateKey.replace(/\n/g, '\r\n');
|
||||
const crlfPublicKey = publicKey.replace(/\n/g, '\r\n');
|
||||
const sign = generateSign(testParams, crlfPrivateKey);
|
||||
const valid = verifySign(testParams, crlfPublicKey, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with literal \\r\\n escapes in PEM keys', () => {
|
||||
const escapedCrlfPrivateKey = privateKey.replace(/\n/g, '\\r\\n');
|
||||
const escapedCrlfPublicKey = publicKey.replace(/\n/g, '\\r\\n');
|
||||
const sign = generateSign(testParams, escapedCrlfPrivateKey);
|
||||
const valid = verifySign(testParams, escapedCrlfPublicKey, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
345
src/__tests__/lib/easy-pay/client.test.ts
Normal file
345
src/__tests__/lib/easy-pay/client.test.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { mockGetEnv } = vi.hoisted(() => ({
|
||||
mockGetEnv: vi.fn(() => ({
|
||||
EASY_PAY_PID: '1001',
|
||||
EASY_PAY_PKEY: 'test-merchant-secret-key',
|
||||
EASY_PAY_API_BASE: 'https://pay.example.com',
|
||||
EASY_PAY_NOTIFY_URL: 'https://pay.example.com/api/easy-pay/notify',
|
||||
EASY_PAY_RETURN_URL: 'https://pay.example.com/pay/result',
|
||||
EASY_PAY_CID: undefined as string | undefined,
|
||||
EASY_PAY_CID_ALIPAY: undefined as string | undefined,
|
||||
EASY_PAY_CID_WXPAY: undefined as string | undefined,
|
||||
})),
|
||||
}));
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getEnv: mockGetEnv,
|
||||
}));
|
||||
|
||||
const { mockGenerateSign, signCallSnapshots } = vi.hoisted(() => {
|
||||
const snapshots: Record<string, string>[][] = [];
|
||||
return {
|
||||
signCallSnapshots: snapshots,
|
||||
mockGenerateSign: vi.fn((...args: unknown[]) => {
|
||||
// Snapshot params at call time (before caller mutates the object)
|
||||
snapshots.push(args.map((a) => (typeof a === 'object' && a ? { ...a } : a)) as Record<string, string>[]);
|
||||
return 'mocked-sign-value';
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('@/lib/easy-pay/sign', () => ({
|
||||
generateSign: mockGenerateSign,
|
||||
}));
|
||||
|
||||
import { createPayment, queryOrder } from '@/lib/easy-pay/client';
|
||||
|
||||
describe('EasyPay client', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
signCallSnapshots.length = 0;
|
||||
});
|
||||
|
||||
describe('createPayment', () => {
|
||||
it('should build correct params and POST to mapi.php', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
code: 1,
|
||||
trade_no: 'EP20260313000001',
|
||||
payurl: 'https://pay.example.com/pay/EP20260313000001',
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json' } },
|
||||
),
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await createPayment({
|
||||
outTradeNo: 'order-001',
|
||||
amount: '10.00',
|
||||
paymentType: 'alipay',
|
||||
clientIp: '127.0.0.1',
|
||||
productName: 'Test Product',
|
||||
});
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.trade_no).toBe('EP20260313000001');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe('https://pay.example.com/mapi.php');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded' });
|
||||
|
||||
const body = new URLSearchParams(init.body as string);
|
||||
expect(body.get('pid')).toBe('1001');
|
||||
expect(body.get('type')).toBe('alipay');
|
||||
expect(body.get('out_trade_no')).toBe('order-001');
|
||||
expect(body.get('money')).toBe('10.00');
|
||||
expect(body.get('name')).toBe('Test Product');
|
||||
expect(body.get('clientip')).toBe('127.0.0.1');
|
||||
expect(body.get('notify_url')).toBe('https://pay.example.com/api/easy-pay/notify');
|
||||
expect(body.get('return_url')).toBe('https://pay.example.com/pay/result');
|
||||
expect(body.get('sign')).toBe('mocked-sign-value');
|
||||
expect(body.get('sign_type')).toBe('MD5');
|
||||
});
|
||||
|
||||
it('should call generateSign with correct params (without sign/sign_type)', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 1, trade_no: 'EP001' }), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await createPayment({
|
||||
outTradeNo: 'order-002',
|
||||
amount: '20.00',
|
||||
paymentType: 'wxpay',
|
||||
clientIp: '10.0.0.1',
|
||||
productName: 'Another Product',
|
||||
});
|
||||
|
||||
expect(mockGenerateSign).toHaveBeenCalledTimes(1);
|
||||
const [signParams, pkey] = signCallSnapshots[signCallSnapshots.length - 1] as [Record<string, string>, string];
|
||||
expect(pkey).toBe('test-merchant-secret-key');
|
||||
// sign and sign_type should not be in the params passed to generateSign
|
||||
expect(signParams).not.toHaveProperty('sign');
|
||||
expect(signParams).not.toHaveProperty('sign_type');
|
||||
expect(signParams.type).toBe('wxpay');
|
||||
});
|
||||
|
||||
it('should throw when API returns code !== 1', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: -1, msg: 'Invalid parameter' }), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(
|
||||
createPayment({
|
||||
outTradeNo: 'order-003',
|
||||
amount: '10.00',
|
||||
paymentType: 'alipay',
|
||||
clientIp: '127.0.0.1',
|
||||
productName: 'Product',
|
||||
}),
|
||||
).rejects.toThrow('EasyPay create payment failed: Invalid parameter');
|
||||
});
|
||||
|
||||
it('should throw with "unknown error" when msg is absent', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 0 }), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(
|
||||
createPayment({
|
||||
outTradeNo: 'order-004',
|
||||
amount: '10.00',
|
||||
paymentType: 'alipay',
|
||||
clientIp: '127.0.0.1',
|
||||
productName: 'Product',
|
||||
}),
|
||||
).rejects.toThrow('EasyPay create payment failed: unknown error');
|
||||
});
|
||||
|
||||
it('should not include cid when no CID env vars are set', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 1, trade_no: 'EP001' }), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await createPayment({
|
||||
outTradeNo: 'order-005',
|
||||
amount: '10.00',
|
||||
paymentType: 'alipay',
|
||||
clientIp: '127.0.0.1',
|
||||
productName: 'Product',
|
||||
});
|
||||
|
||||
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const body = new URLSearchParams(init.body as string);
|
||||
expect(body.has('cid')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPayment CID routing', () => {
|
||||
it('should use EASY_PAY_CID_ALIPAY for alipay payment type', async () => {
|
||||
mockGetEnv.mockReturnValue({
|
||||
EASY_PAY_PID: '1001',
|
||||
EASY_PAY_PKEY: 'test-merchant-secret-key',
|
||||
EASY_PAY_API_BASE: 'https://pay.example.com',
|
||||
EASY_PAY_NOTIFY_URL: 'https://pay.example.com/api/easy-pay/notify',
|
||||
EASY_PAY_RETURN_URL: 'https://pay.example.com/pay/result',
|
||||
EASY_PAY_CID: '100',
|
||||
EASY_PAY_CID_ALIPAY: '200',
|
||||
EASY_PAY_CID_WXPAY: '300',
|
||||
});
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 1, trade_no: 'EP001' }), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await createPayment({
|
||||
outTradeNo: 'order-cid-1',
|
||||
amount: '10.00',
|
||||
paymentType: 'alipay',
|
||||
clientIp: '127.0.0.1',
|
||||
productName: 'Product',
|
||||
});
|
||||
|
||||
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const body = new URLSearchParams(init.body as string);
|
||||
expect(body.get('cid')).toBe('200');
|
||||
});
|
||||
|
||||
it('should use EASY_PAY_CID_WXPAY for wxpay payment type', async () => {
|
||||
mockGetEnv.mockReturnValue({
|
||||
EASY_PAY_PID: '1001',
|
||||
EASY_PAY_PKEY: 'test-merchant-secret-key',
|
||||
EASY_PAY_API_BASE: 'https://pay.example.com',
|
||||
EASY_PAY_NOTIFY_URL: 'https://pay.example.com/api/easy-pay/notify',
|
||||
EASY_PAY_RETURN_URL: 'https://pay.example.com/pay/result',
|
||||
EASY_PAY_CID: '100',
|
||||
EASY_PAY_CID_ALIPAY: '200',
|
||||
EASY_PAY_CID_WXPAY: '300',
|
||||
});
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 1, trade_no: 'EP001' }), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await createPayment({
|
||||
outTradeNo: 'order-cid-2',
|
||||
amount: '10.00',
|
||||
paymentType: 'wxpay',
|
||||
clientIp: '127.0.0.1',
|
||||
productName: 'Product',
|
||||
});
|
||||
|
||||
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const body = new URLSearchParams(init.body as string);
|
||||
expect(body.get('cid')).toBe('300');
|
||||
});
|
||||
|
||||
it('should fall back to EASY_PAY_CID when channel-specific CID is not set', async () => {
|
||||
mockGetEnv.mockReturnValue({
|
||||
EASY_PAY_PID: '1001',
|
||||
EASY_PAY_PKEY: 'test-merchant-secret-key',
|
||||
EASY_PAY_API_BASE: 'https://pay.example.com',
|
||||
EASY_PAY_NOTIFY_URL: 'https://pay.example.com/api/easy-pay/notify',
|
||||
EASY_PAY_RETURN_URL: 'https://pay.example.com/pay/result',
|
||||
EASY_PAY_CID: '100',
|
||||
EASY_PAY_CID_ALIPAY: undefined,
|
||||
EASY_PAY_CID_WXPAY: undefined,
|
||||
});
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 1, trade_no: 'EP001' }), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await createPayment({
|
||||
outTradeNo: 'order-cid-3',
|
||||
amount: '10.00',
|
||||
paymentType: 'alipay',
|
||||
clientIp: '127.0.0.1',
|
||||
productName: 'Product',
|
||||
});
|
||||
|
||||
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const body = new URLSearchParams(init.body as string);
|
||||
expect(body.get('cid')).toBe('100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryOrder', () => {
|
||||
it('should call POST api.php with correct body parameters', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
code: 1,
|
||||
trade_no: 'EP20260313000001',
|
||||
out_trade_no: 'order-001',
|
||||
type: 'alipay',
|
||||
pid: '1001',
|
||||
addtime: '2026-03-13 10:00:00',
|
||||
endtime: '2026-03-13 10:01:00',
|
||||
name: 'Test Product',
|
||||
money: '10.00',
|
||||
status: 1,
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json' } },
|
||||
),
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await queryOrder('order-001');
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.trade_no).toBe('EP20260313000001');
|
||||
expect(result.status).toBe(1);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe('https://pay.example.com/api.php');
|
||||
expect(init.method).toBe('POST');
|
||||
const body = new URLSearchParams(init.body as string);
|
||||
expect(body.get('act')).toBe('order');
|
||||
expect(body.get('pid')).toBe('1001');
|
||||
expect(body.get('key')).toBe('test-merchant-secret-key');
|
||||
expect(body.get('out_trade_no')).toBe('order-001');
|
||||
});
|
||||
|
||||
it('should throw when API returns code !== 1', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: -1, msg: 'Order not found' }), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(queryOrder('nonexistent-order')).rejects.toThrow('EasyPay query order failed: Order not found');
|
||||
});
|
||||
|
||||
it('should throw with "unknown error" when msg is absent', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 0 }), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(queryOrder('order-err')).rejects.toThrow('EasyPay query order failed: unknown error');
|
||||
});
|
||||
|
||||
it('should parse all response fields correctly', async () => {
|
||||
const mockResponse = {
|
||||
code: 1,
|
||||
trade_no: 'EP20260313000002',
|
||||
out_trade_no: 'order-010',
|
||||
type: 'wxpay',
|
||||
pid: '1001',
|
||||
addtime: '2026-03-13 12:00:00',
|
||||
endtime: '2026-03-13 12:05:00',
|
||||
name: 'Premium Plan',
|
||||
money: '99.00',
|
||||
status: 1,
|
||||
param: 'custom-param',
|
||||
buyer: 'buyer@example.com',
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(mockResponse), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await queryOrder('order-010');
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
131
src/__tests__/lib/easy-pay/sign.test.ts
Normal file
131
src/__tests__/lib/easy-pay/sign.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import crypto from 'crypto';
|
||||
import { generateSign, verifySign } from '@/lib/easy-pay/sign';
|
||||
|
||||
const TEST_PKEY = 'test-merchant-secret-key';
|
||||
|
||||
describe('EasyPay MD5 Sign', () => {
|
||||
const testParams: Record<string, string> = {
|
||||
pid: '1001',
|
||||
type: 'alipay',
|
||||
out_trade_no: 'order-001',
|
||||
notify_url: 'https://pay.example.com/api/easy-pay/notify',
|
||||
return_url: 'https://pay.example.com/pay/result',
|
||||
name: 'Test Product',
|
||||
money: '10.00',
|
||||
clientip: '127.0.0.1',
|
||||
};
|
||||
|
||||
describe('generateSign', () => {
|
||||
it('should generate a valid MD5 hex string', () => {
|
||||
const sign = generateSign(testParams, TEST_PKEY);
|
||||
expect(sign).toBeTruthy();
|
||||
expect(sign).toMatch(/^[0-9a-f]{32}$/);
|
||||
});
|
||||
|
||||
it('should produce consistent signatures for same input', () => {
|
||||
const sign1 = generateSign(testParams, TEST_PKEY);
|
||||
const sign2 = generateSign(testParams, TEST_PKEY);
|
||||
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, TEST_PKEY);
|
||||
const sign2 = generateSign(reversed, TEST_PKEY);
|
||||
expect(sign1).toBe(sign2);
|
||||
});
|
||||
|
||||
it('should filter out empty values', () => {
|
||||
const paramsWithEmpty = { ...testParams, empty_field: '' };
|
||||
const sign1 = generateSign(testParams, TEST_PKEY);
|
||||
const sign2 = generateSign(paramsWithEmpty, TEST_PKEY);
|
||||
expect(sign1).toBe(sign2);
|
||||
});
|
||||
|
||||
it('should exclude sign field from signing', () => {
|
||||
const paramsWithSign = { ...testParams, sign: 'old_sign' };
|
||||
const sign1 = generateSign(testParams, TEST_PKEY);
|
||||
const sign2 = generateSign(paramsWithSign, TEST_PKEY);
|
||||
expect(sign1).toBe(sign2);
|
||||
});
|
||||
|
||||
it('should exclude sign_type field from signing', () => {
|
||||
const paramsWithSignType = { ...testParams, sign_type: 'MD5' };
|
||||
const sign1 = generateSign(testParams, TEST_PKEY);
|
||||
const sign2 = generateSign(paramsWithSignType, TEST_PKEY);
|
||||
expect(sign1).toBe(sign2);
|
||||
});
|
||||
|
||||
it('should produce correct MD5 hash for known input', () => {
|
||||
// Manually compute expected: sorted keys → query string → append pkey → MD5
|
||||
const sorted = Object.entries(testParams)
|
||||
.filter(([, v]) => v !== '')
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
const queryString = sorted.map(([k, v]) => `${k}=${v}`).join('&');
|
||||
const expected = crypto
|
||||
.createHash('md5')
|
||||
.update(queryString + TEST_PKEY)
|
||||
.digest('hex');
|
||||
|
||||
const sign = generateSign(testParams, TEST_PKEY);
|
||||
expect(sign).toBe(expected);
|
||||
});
|
||||
|
||||
it('should produce different signatures for different pkeys', () => {
|
||||
const sign1 = generateSign(testParams, TEST_PKEY);
|
||||
const sign2 = generateSign(testParams, 'different-key');
|
||||
expect(sign1).not.toBe(sign2);
|
||||
});
|
||||
|
||||
it('should produce different signatures for different params', () => {
|
||||
const sign1 = generateSign(testParams, TEST_PKEY);
|
||||
const modified = { ...testParams, money: '99.99' };
|
||||
const sign2 = generateSign(modified, TEST_PKEY);
|
||||
expect(sign1).not.toBe(sign2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifySign', () => {
|
||||
it('should return true for a valid signature', () => {
|
||||
const sign = generateSign(testParams, TEST_PKEY);
|
||||
const valid = verifySign(testParams, TEST_PKEY, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for an invalid signature', () => {
|
||||
const valid = verifySign(testParams, TEST_PKEY, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for tampered params', () => {
|
||||
const sign = generateSign(testParams, TEST_PKEY);
|
||||
const tampered = { ...testParams, money: '999.99' };
|
||||
const valid = verifySign(tampered, TEST_PKEY, sign);
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for wrong pkey', () => {
|
||||
const sign = generateSign(testParams, TEST_PKEY);
|
||||
const valid = verifySign(testParams, 'wrong-key', sign);
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when sign length differs (timing-safe guard)', () => {
|
||||
const valid = verifySign(testParams, TEST_PKEY, 'short');
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should use timing-safe comparison (same length, different content)', () => {
|
||||
const sign = generateSign(testParams, TEST_PKEY);
|
||||
// Flip the first character to create a same-length but different sign
|
||||
const flipped = (sign[0] === 'a' ? 'b' : 'a') + sign.slice(1);
|
||||
const valid = verifySign(testParams, TEST_PKEY, flipped);
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,9 +2,12 @@ import { describe, it, expect } from 'vitest';
|
||||
import { generateRechargeCode } from '@/lib/order/code-gen';
|
||||
|
||||
describe('generateRechargeCode', () => {
|
||||
it('should generate code with s2p_ prefix', () => {
|
||||
it('should generate code with s2p_ prefix and random suffix', () => {
|
||||
const code = generateRechargeCode('cm1234567890');
|
||||
expect(code).toBe('s2p_cm1234567890');
|
||||
expect(code.startsWith('s2p_')).toBe(true);
|
||||
expect(code.length).toBeLessThanOrEqual(32);
|
||||
// 包含 orderId 部分和 8 字符随机后缀
|
||||
expect(code.length).toBeGreaterThan(12);
|
||||
});
|
||||
|
||||
it('should truncate long order IDs to fit 32 chars', () => {
|
||||
@@ -14,8 +17,15 @@ describe('generateRechargeCode', () => {
|
||||
expect(code.startsWith('s2p_')).toBe(true);
|
||||
});
|
||||
|
||||
it('should generate different codes for same orderId (randomness)', () => {
|
||||
const code1 = generateRechargeCode('order-001');
|
||||
const code2 = generateRechargeCode('order-001');
|
||||
expect(code1).not.toBe(code2);
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
const code = generateRechargeCode('');
|
||||
expect(code).toBe('s2p_');
|
||||
expect(code.startsWith('s2p_')).toBe(true);
|
||||
expect(code.length).toBeLessThanOrEqual(32);
|
||||
});
|
||||
});
|
||||
|
||||
74
src/__tests__/lib/order/fee.test.ts
Normal file
74
src/__tests__/lib/order/fee.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { calculatePayAmount } from '@/lib/order/fee';
|
||||
|
||||
describe('calculatePayAmount', () => {
|
||||
it.each([
|
||||
{ rechargeAmount: 100, feeRate: 0, expected: '100.00', desc: 'feeRate=0 返回原金额' },
|
||||
{ rechargeAmount: 100, feeRate: -1, expected: '100.00', desc: 'feeRate<0 返回原金额' },
|
||||
{ rechargeAmount: 100, feeRate: 3, expected: '103.00', desc: '100 * 3% = 3.00' },
|
||||
{ rechargeAmount: 100, feeRate: 2.5, expected: '102.50', desc: '100 * 2.5% = 2.50' },
|
||||
{
|
||||
rechargeAmount: 99.99,
|
||||
feeRate: 1,
|
||||
expected: '100.99',
|
||||
desc: '99.99 * 1% = 0.9999 → ROUND_UP → 1.00, total 100.99',
|
||||
},
|
||||
{ rechargeAmount: 10, feeRate: 3, expected: '10.30', desc: '10 * 3% = 0.30' },
|
||||
{ rechargeAmount: 1, feeRate: 1, expected: '1.01', desc: '1 * 1% = 0.01' },
|
||||
])('$desc (amount=$rechargeAmount, rate=$feeRate)', ({ rechargeAmount, feeRate, expected }) => {
|
||||
expect(calculatePayAmount(rechargeAmount, feeRate)).toBe(expected);
|
||||
});
|
||||
|
||||
describe('ROUND_UP 向上取整', () => {
|
||||
it('小数第三位非零时进位', () => {
|
||||
// 33 * 1% = 0.33, 整除无进位
|
||||
expect(calculatePayAmount(33, 1)).toBe('33.33');
|
||||
});
|
||||
|
||||
it('产生无限小数时向上进位', () => {
|
||||
// 10 * 3.3% = 0.33, 精确
|
||||
expect(calculatePayAmount(10, 3.3)).toBe('10.33');
|
||||
// 7 * 3% = 0.21, 精确
|
||||
expect(calculatePayAmount(7, 3)).toBe('7.21');
|
||||
// 1 * 0.7% = 0.007 → ROUND_UP → 0.01
|
||||
expect(calculatePayAmount(1, 0.7)).toBe('1.01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('极小金额', () => {
|
||||
it('0.01 元 + 1% 手续费', () => {
|
||||
// 0.01 * 1% = 0.0001 → ROUND_UP → 0.01
|
||||
expect(calculatePayAmount(0.01, 1)).toBe('0.02');
|
||||
});
|
||||
|
||||
it('0.01 元 + 0 手续费', () => {
|
||||
expect(calculatePayAmount(0.01, 0)).toBe('0.01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('大金额', () => {
|
||||
it('10000 元 + 2.5%', () => {
|
||||
// 10000 * 2.5% = 250.00
|
||||
expect(calculatePayAmount(10000, 2.5)).toBe('10250.00');
|
||||
});
|
||||
|
||||
it('99999.99 元 + 5%', () => {
|
||||
// 99999.99 * 5% = 4999.9995 → ROUND_UP → 5000.00
|
||||
// 但 rechargeAmount 传入为 number 99999.99,Decimal(99999.99) 可能有浮点
|
||||
// 实际: 99999.99 + 5000.00 = 104999.99
|
||||
expect(calculatePayAmount(99999.99, 5)).toBe('104999.99');
|
||||
});
|
||||
});
|
||||
|
||||
describe('精度', () => {
|
||||
it('输出始终为 2 位小数', () => {
|
||||
const result = calculatePayAmount(100, 0);
|
||||
expect(result).toMatch(/^\d+\.\d{2}$/);
|
||||
});
|
||||
|
||||
it('有手续费时输出也为 2 位小数', () => {
|
||||
const result = calculatePayAmount(77, 3.33);
|
||||
expect(result).toMatch(/^\d+\.\d{2}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
142
src/__tests__/lib/order/limits.test.ts
Normal file
142
src/__tests__/lib/order/limits.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||
import type { MethodDefaultLimits } from '@/lib/payment/types';
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: {
|
||||
order: { groupBy: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getEnv: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/payment', () => ({
|
||||
initPaymentProviders: vi.fn(),
|
||||
paymentRegistry: {
|
||||
getDefaultLimit: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { getEnv } from '@/lib/config';
|
||||
import { paymentRegistry } from '@/lib/payment';
|
||||
import { getMethodDailyLimit, getMethodSingleLimit } from '@/lib/order/limits';
|
||||
|
||||
const mockedGetEnv = vi.mocked(getEnv);
|
||||
const mockedGetDefaultLimit = vi.mocked(paymentRegistry.getDefaultLimit);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// 默认:getEnv 返回无渠道限额字段,provider 无默认值
|
||||
mockedGetEnv.mockReturnValue({} as ReturnType<typeof getEnv>);
|
||||
mockedGetDefaultLimit.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
describe('getMethodDailyLimit', () => {
|
||||
it('无环境变量且无 provider 默认值时返回 0', () => {
|
||||
expect(getMethodDailyLimit('alipay')).toBe(0);
|
||||
});
|
||||
|
||||
it('从 getEnv 读取渠道每日限额', () => {
|
||||
mockedGetEnv.mockReturnValue({
|
||||
MAX_DAILY_AMOUNT_ALIPAY: 5000,
|
||||
} as unknown as ReturnType<typeof getEnv>);
|
||||
expect(getMethodDailyLimit('alipay')).toBe(5000);
|
||||
});
|
||||
|
||||
it('环境变量 0 表示不限制', () => {
|
||||
mockedGetEnv.mockReturnValue({
|
||||
MAX_DAILY_AMOUNT_WXPAY: 0,
|
||||
} as unknown as ReturnType<typeof getEnv>);
|
||||
expect(getMethodDailyLimit('wxpay')).toBe(0);
|
||||
});
|
||||
|
||||
it('getEnv 未设置时回退到 provider 默认值', () => {
|
||||
mockedGetEnv.mockReturnValue({} as ReturnType<typeof getEnv>);
|
||||
mockedGetDefaultLimit.mockReturnValue({ dailyMax: 3000 } as MethodDefaultLimits);
|
||||
expect(getMethodDailyLimit('stripe')).toBe(3000);
|
||||
});
|
||||
|
||||
it('getEnv 设置时覆盖 provider 默认值', () => {
|
||||
mockedGetEnv.mockReturnValue({
|
||||
MAX_DAILY_AMOUNT_STRIPE: 8000,
|
||||
} as unknown as ReturnType<typeof getEnv>);
|
||||
mockedGetDefaultLimit.mockReturnValue({ dailyMax: 3000 } as MethodDefaultLimits);
|
||||
expect(getMethodDailyLimit('stripe')).toBe(8000);
|
||||
});
|
||||
|
||||
it('paymentType 大小写不敏感(key 构造用 toUpperCase)', () => {
|
||||
mockedGetEnv.mockReturnValue({
|
||||
MAX_DAILY_AMOUNT_ALIPAY: 2000,
|
||||
} as unknown as ReturnType<typeof getEnv>);
|
||||
expect(getMethodDailyLimit('alipay')).toBe(2000);
|
||||
});
|
||||
|
||||
it('未知支付类型返回 0', () => {
|
||||
expect(getMethodDailyLimit('unknown_type')).toBe(0);
|
||||
});
|
||||
|
||||
it('getEnv 无值且 provider 默认值也无 dailyMax 时回退 process.env', () => {
|
||||
mockedGetEnv.mockReturnValue({} as ReturnType<typeof getEnv>);
|
||||
mockedGetDefaultLimit.mockReturnValue({} as MethodDefaultLimits); // no dailyMax
|
||||
process.env['MAX_DAILY_AMOUNT_ALIPAY'] = '7777';
|
||||
try {
|
||||
expect(getMethodDailyLimit('alipay')).toBe(7777);
|
||||
} finally {
|
||||
delete process.env['MAX_DAILY_AMOUNT_ALIPAY'];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMethodSingleLimit', () => {
|
||||
it('无环境变量且无 provider 默认值时返回 0', () => {
|
||||
expect(getMethodSingleLimit('alipay')).toBe(0);
|
||||
});
|
||||
|
||||
it('从 process.env 读取单笔限额', () => {
|
||||
process.env['MAX_SINGLE_AMOUNT_WXPAY'] = '500';
|
||||
try {
|
||||
expect(getMethodSingleLimit('wxpay')).toBe(500);
|
||||
} finally {
|
||||
delete process.env['MAX_SINGLE_AMOUNT_WXPAY'];
|
||||
}
|
||||
});
|
||||
|
||||
it('process.env 设置 0 表示使用全局限额', () => {
|
||||
process.env['MAX_SINGLE_AMOUNT_STRIPE'] = '0';
|
||||
try {
|
||||
expect(getMethodSingleLimit('stripe')).toBe(0);
|
||||
} finally {
|
||||
delete process.env['MAX_SINGLE_AMOUNT_STRIPE'];
|
||||
}
|
||||
});
|
||||
|
||||
it('process.env 未设置时回退到 provider 默认值', () => {
|
||||
mockedGetDefaultLimit.mockReturnValue({ singleMax: 200 } as MethodDefaultLimits);
|
||||
expect(getMethodSingleLimit('alipay')).toBe(200);
|
||||
});
|
||||
|
||||
it('process.env 设置时覆盖 provider 默认值', () => {
|
||||
process.env['MAX_SINGLE_AMOUNT_ALIPAY'] = '999';
|
||||
mockedGetDefaultLimit.mockReturnValue({ singleMax: 200 } as MethodDefaultLimits);
|
||||
try {
|
||||
expect(getMethodSingleLimit('alipay')).toBe(999);
|
||||
} finally {
|
||||
delete process.env['MAX_SINGLE_AMOUNT_ALIPAY'];
|
||||
}
|
||||
});
|
||||
|
||||
it('无效 process.env 值回退到 provider 默认值', () => {
|
||||
process.env['MAX_SINGLE_AMOUNT_ALIPAY'] = 'abc';
|
||||
mockedGetDefaultLimit.mockReturnValue({ singleMax: 150 } as MethodDefaultLimits);
|
||||
try {
|
||||
expect(getMethodSingleLimit('alipay')).toBe(150);
|
||||
} finally {
|
||||
delete process.env['MAX_SINGLE_AMOUNT_ALIPAY'];
|
||||
}
|
||||
});
|
||||
|
||||
it('未知支付类型返回 0', () => {
|
||||
expect(getMethodSingleLimit('unknown_type')).toBe(0);
|
||||
});
|
||||
});
|
||||
38
src/__tests__/lib/order/status-access.test.ts
Normal file
38
src/__tests__/lib/order/status-access.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getEnv: () => ({
|
||||
ADMIN_TOKEN: 'test-admin-token',
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
ORDER_STATUS_ACCESS_QUERY_KEY,
|
||||
buildOrderResultUrl,
|
||||
createOrderStatusAccessToken,
|
||||
verifyOrderStatusAccessToken,
|
||||
} from '@/lib/order/status-access';
|
||||
|
||||
describe('order status access token helpers', () => {
|
||||
it('creates and verifies a token bound to the order id', () => {
|
||||
const token = createOrderStatusAccessToken('order-001');
|
||||
expect(token).toBeTruthy();
|
||||
expect(verifyOrderStatusAccessToken('order-001', token)).toBe(true);
|
||||
expect(verifyOrderStatusAccessToken('order-002', token)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing or malformed tokens', () => {
|
||||
expect(verifyOrderStatusAccessToken('order-001', null)).toBe(false);
|
||||
expect(verifyOrderStatusAccessToken('order-001', undefined)).toBe(false);
|
||||
expect(verifyOrderStatusAccessToken('order-001', 'short')).toBe(false);
|
||||
});
|
||||
|
||||
it('builds a result url with order id and access token', () => {
|
||||
const url = new URL(buildOrderResultUrl('https://pay.example.com', 'order-009'));
|
||||
expect(url.origin + url.pathname).toBe('https://pay.example.com/pay/result');
|
||||
expect(url.searchParams.get('order_id')).toBe('order-009');
|
||||
const token = url.searchParams.get(ORDER_STATUS_ACCESS_QUERY_KEY);
|
||||
expect(token).toBeTruthy();
|
||||
expect(verifyOrderStatusAccessToken('order-009', token)).toBe(true);
|
||||
});
|
||||
});
|
||||
66
src/__tests__/lib/order/status.test.ts
Normal file
66
src/__tests__/lib/order/status.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ORDER_STATUS } from '@/lib/constants';
|
||||
import { deriveOrderState, getOrderDisplayState } from '@/lib/order/status';
|
||||
|
||||
describe('order status helpers', () => {
|
||||
it('derives paid_pending after successful payment but before recharge completion', () => {
|
||||
const state = deriveOrderState({
|
||||
status: ORDER_STATUS.PAID,
|
||||
paidAt: new Date('2026-03-09T10:00:00Z'),
|
||||
completedAt: null,
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
paymentSuccess: true,
|
||||
rechargeSuccess: false,
|
||||
rechargeStatus: 'paid_pending',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps recharge failure after payment to a payment-success display state', () => {
|
||||
const display = getOrderDisplayState({
|
||||
status: ORDER_STATUS.FAILED,
|
||||
paymentSuccess: true,
|
||||
rechargeSuccess: false,
|
||||
rechargeStatus: 'failed',
|
||||
});
|
||||
|
||||
expect(display.label).toBe('支付成功');
|
||||
expect(display.message).toContain('自动重试');
|
||||
});
|
||||
|
||||
it('maps failed order before payment success to failed display', () => {
|
||||
const display = getOrderDisplayState({
|
||||
status: ORDER_STATUS.FAILED,
|
||||
paymentSuccess: false,
|
||||
rechargeSuccess: false,
|
||||
rechargeStatus: 'failed',
|
||||
});
|
||||
|
||||
expect(display.label).toBe('支付失败');
|
||||
expect(display.message).toContain('重新发起支付');
|
||||
});
|
||||
|
||||
it('maps completed order to success display', () => {
|
||||
const display = getOrderDisplayState({
|
||||
status: ORDER_STATUS.COMPLETED,
|
||||
paymentSuccess: true,
|
||||
rechargeSuccess: true,
|
||||
rechargeStatus: 'success',
|
||||
});
|
||||
|
||||
expect(display.label).toBe('充值成功');
|
||||
expect(display.icon).toBe('✓');
|
||||
});
|
||||
|
||||
it('maps pending order to waiting-for-payment display', () => {
|
||||
const display = getOrderDisplayState({
|
||||
status: ORDER_STATUS.PENDING,
|
||||
paymentSuccess: false,
|
||||
rechargeSuccess: false,
|
||||
rechargeStatus: 'not_paid',
|
||||
});
|
||||
|
||||
expect(display.label).toBe('等待支付');
|
||||
});
|
||||
});
|
||||
70
src/__tests__/lib/platform-style.test.ts
Normal file
70
src/__tests__/lib/platform-style.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
|
||||
import { getPlatformStyle, PlatformBadge, PlatformIcon } from '@/lib/platform-style';
|
||||
|
||||
describe('getPlatformStyle', () => {
|
||||
const knownPlatforms = ['claude', 'anthropic', 'openai', 'codex', 'gemini', 'google', 'sora', 'antigravity'];
|
||||
|
||||
it.each(knownPlatforms)('should return correct label and non-empty icon for "%s"', (platform) => {
|
||||
const style = getPlatformStyle(platform);
|
||||
// label should be the capitalised form, not empty
|
||||
expect(style.label).toBeTruthy();
|
||||
expect(style.icon).toBeTruthy();
|
||||
});
|
||||
|
||||
it('anthropic and claude should share the same badge style', () => {
|
||||
const claude = getPlatformStyle('claude');
|
||||
const anthropic = getPlatformStyle('anthropic');
|
||||
expect(claude.badge).toBe(anthropic.badge);
|
||||
});
|
||||
|
||||
it('openai and codex should share the same badge style', () => {
|
||||
const openai = getPlatformStyle('openai');
|
||||
const codex = getPlatformStyle('codex');
|
||||
expect(openai.badge).toBe(codex.badge);
|
||||
});
|
||||
|
||||
it('gemini and google should share the same badge style', () => {
|
||||
const gemini = getPlatformStyle('gemini');
|
||||
const google = getPlatformStyle('google');
|
||||
expect(gemini.badge).toBe(google.badge);
|
||||
});
|
||||
|
||||
it('should be case-insensitive ("OpenAI" and "openai" return same result)', () => {
|
||||
const upper = getPlatformStyle('OpenAI');
|
||||
const lower = getPlatformStyle('openai');
|
||||
expect(upper).toEqual(lower);
|
||||
});
|
||||
|
||||
it('should return fallback grey style for unknown platform', () => {
|
||||
const style = getPlatformStyle('unknownService');
|
||||
expect(style.badge).toContain('slate');
|
||||
expect(style.label).toBe('unknownService');
|
||||
expect(style.icon).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlatformBadge', () => {
|
||||
it('should render output containing the correct label text', () => {
|
||||
const html = renderToStaticMarkup(PlatformBadge({ platform: 'claude' }));
|
||||
expect(html).toContain('Claude');
|
||||
});
|
||||
|
||||
it('should render fallback label for unknown platform', () => {
|
||||
const html = renderToStaticMarkup(PlatformBadge({ platform: 'myPlatform' }));
|
||||
expect(html).toContain('myPlatform');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PlatformIcon', () => {
|
||||
it('should return non-null for known platforms', () => {
|
||||
const icon = PlatformIcon({ platform: 'openai' });
|
||||
expect(icon).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for unknown platform (empty icon)', () => {
|
||||
const icon = PlatformIcon({ platform: 'unknownPlatform' });
|
||||
expect(icon).toBeNull();
|
||||
});
|
||||
});
|
||||
77
src/__tests__/lib/sub2api/client-listSubscriptions.test.ts
Normal file
77
src/__tests__/lib/sub2api/client-listSubscriptions.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getEnv: () => ({
|
||||
SUB2API_BASE_URL: 'https://test.sub2api.com',
|
||||
SUB2API_ADMIN_API_KEY: 'admin-testkey123',
|
||||
}),
|
||||
}));
|
||||
|
||||
import { listSubscriptions } from '@/lib/sub2api/client';
|
||||
|
||||
describe('listSubscriptions', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should call correct URL with no query params when no params provided', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: { items: [], total: 0, page: 1, page_size: 50 } }),
|
||||
}) as typeof fetch;
|
||||
|
||||
await listSubscriptions();
|
||||
|
||||
const [url] = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
// URL should end with "subscriptions?" and have no params after the ?
|
||||
expect(url).toBe('https://test.sub2api.com/api/v1/admin/subscriptions?');
|
||||
});
|
||||
|
||||
it('should build correct query params when all params provided', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: { items: [], total: 0, page: 2, page_size: 10 } }),
|
||||
}) as typeof fetch;
|
||||
|
||||
await listSubscriptions({
|
||||
user_id: 42,
|
||||
group_id: 5,
|
||||
status: 'active',
|
||||
page: 2,
|
||||
page_size: 10,
|
||||
});
|
||||
|
||||
const [url] = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const parsedUrl = new URL(url);
|
||||
expect(parsedUrl.searchParams.get('user_id')).toBe('42');
|
||||
expect(parsedUrl.searchParams.get('group_id')).toBe('5');
|
||||
expect(parsedUrl.searchParams.get('status')).toBe('active');
|
||||
expect(parsedUrl.searchParams.get('page')).toBe('2');
|
||||
expect(parsedUrl.searchParams.get('page_size')).toBe('10');
|
||||
});
|
||||
|
||||
it('should parse normal response correctly', async () => {
|
||||
const mockSubs = [{ id: 1, user_id: 42, group_id: 5, status: 'active', expires_at: '2026-12-31' }];
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: { items: mockSubs, total: 1, page: 1, page_size: 50 } }),
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await listSubscriptions({ user_id: 42 });
|
||||
|
||||
expect(result.subscriptions).toEqual(mockSubs);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.page_size).toBe(50);
|
||||
});
|
||||
|
||||
it('should throw on HTTP error', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
}) as typeof fetch;
|
||||
|
||||
await expect(listSubscriptions()).rejects.toThrow('Failed to list subscriptions: 500');
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,7 @@ describe('Sub2API Client', () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: mockUser }),
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const user = await getUser(1);
|
||||
expect(user.username).toBe('testuser');
|
||||
@@ -37,7 +37,7 @@ describe('Sub2API Client', () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
await expect(getUser(999)).rejects.toThrow('USER_NOT_FOUND');
|
||||
});
|
||||
@@ -57,24 +57,50 @@ describe('Sub2API Client', () => {
|
||||
used_by: 1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await createAndRedeem('s2p_test123', 100, 1, 'test notes');
|
||||
expect(result.code).toBe('s2p_test123');
|
||||
|
||||
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(fetchCall[0]).toContain('/redeem-codes/create-and-redeem');
|
||||
const headers = fetchCall[1].headers;
|
||||
const headers = fetchCall[1].headers as Record<string, string>;
|
||||
expect(headers['Idempotency-Key']).toBe('sub2apipay:recharge:s2p_test123');
|
||||
});
|
||||
|
||||
it('createAndRedeem should retry once on timeout', async () => {
|
||||
const timeoutError = Object.assign(new Error('timed out'), { name: 'TimeoutError' });
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(timeoutError)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
redeem_code: {
|
||||
id: 2,
|
||||
code: 's2p_retry',
|
||||
type: 'balance',
|
||||
value: 88,
|
||||
status: 'used',
|
||||
used_by: 1,
|
||||
},
|
||||
}),
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await createAndRedeem('s2p_retry', 88, 1, 'retry notes');
|
||||
|
||||
expect(result.code).toBe('s2p_retry');
|
||||
expect((fetch as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('subtractBalance should send subtract request', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) }) as typeof fetch;
|
||||
|
||||
await subtractBalance(1, 50, 'refund', 'idempotency-key-1');
|
||||
|
||||
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const body = JSON.parse(fetchCall[1].body);
|
||||
const body = JSON.parse(fetchCall[1].body as string);
|
||||
expect(body.operation).toBe('subtract');
|
||||
expect(body.amount).toBe(50);
|
||||
});
|
||||
|
||||
18
src/__tests__/lib/time/biz-day.test.ts
Normal file
18
src/__tests__/lib/time/biz-day.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getBizDayStartUTC, getNextBizDayStartUTC, toBizDateStr } from '@/lib/time/biz-day';
|
||||
|
||||
describe('biz-day helpers', () => {
|
||||
it('formats business date in Asia/Shanghai timezone', () => {
|
||||
expect(toBizDateStr(new Date('2026-03-09T15:59:59.000Z'))).toBe('2026-03-09');
|
||||
expect(toBizDateStr(new Date('2026-03-09T16:00:00.000Z'))).toBe('2026-03-10');
|
||||
});
|
||||
|
||||
it('returns business day start in UTC', () => {
|
||||
expect(getBizDayStartUTC(new Date('2026-03-09T15:59:59.000Z')).toISOString()).toBe('2026-03-08T16:00:00.000Z');
|
||||
expect(getBizDayStartUTC(new Date('2026-03-09T16:00:00.000Z')).toISOString()).toBe('2026-03-09T16:00:00.000Z');
|
||||
});
|
||||
|
||||
it('returns next business day start in UTC', () => {
|
||||
expect(getNextBizDayStartUTC(new Date('2026-03-09T12:00:00.000Z')).toISOString()).toBe('2026-03-09T16:00:00.000Z');
|
||||
});
|
||||
});
|
||||
@@ -101,12 +101,7 @@ function shouldAutoRedirect(opts: {
|
||||
qrCode?: string | null;
|
||||
isMobile: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
!opts.expired &&
|
||||
!isStripeType(opts.paymentType) &&
|
||||
!!opts.payUrl &&
|
||||
(opts.isMobile || !opts.qrCode)
|
||||
);
|
||||
return !opts.expired && !isStripeType(opts.paymentType) && !!opts.payUrl && (opts.isMobile || !opts.qrCode);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -198,7 +193,7 @@ describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('EasyPay does not use isMobile flag itself (delegates to frontend)', async () => {
|
||||
it('EasyPay forwards isMobile to client for device=jump on mobile', async () => {
|
||||
mockEasyPayCreatePayment.mockResolvedValue({
|
||||
code: 1,
|
||||
trade_no: 'EP-003',
|
||||
@@ -217,16 +212,14 @@ describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
|
||||
|
||||
await provider.createPayment(request);
|
||||
|
||||
// EasyPay client is called the same way regardless of isMobile
|
||||
// EasyPay client receives isMobile so it can set device=jump
|
||||
expect(mockEasyPayCreatePayment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
outTradeNo: 'order-ep-003',
|
||||
paymentType: 'alipay',
|
||||
isMobile: true,
|
||||
}),
|
||||
);
|
||||
// No isMobile parameter forwarded to the underlying client
|
||||
const callArgs = mockEasyPayCreatePayment.mock.calls[0][0];
|
||||
expect(callArgs).not.toHaveProperty('isMobile');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -241,11 +234,7 @@ describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
|
||||
provider = new AlipayProvider();
|
||||
});
|
||||
|
||||
it('PC: uses alipay.trade.page.pay, returns payUrl only (no qrCode)', async () => {
|
||||
mockAlipayPageExecute.mockReturnValue(
|
||||
'https://openapi.alipay.com/gateway.do?method=alipay.trade.page.pay&sign=xxx',
|
||||
);
|
||||
|
||||
it('PC: returns service short-link payUrl and qrCode', async () => {
|
||||
const request: CreatePaymentRequest = {
|
||||
orderId: 'order-ali-001',
|
||||
amount: 100,
|
||||
@@ -257,20 +246,10 @@ describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
|
||||
const result = await provider.createPayment(request);
|
||||
|
||||
expect(result.tradeNo).toBe('order-ali-001');
|
||||
expect(result.payUrl).toContain('alipay.trade.page.pay');
|
||||
expect(result.qrCode).toBeUndefined();
|
||||
expect(result.payUrl).toBe('https://pay.example.com/pay/order-ali-001');
|
||||
expect(result.qrCode).toBe('https://pay.example.com/pay/order-ali-001');
|
||||
expect(mockAlipayPageExecute).not.toHaveBeenCalled();
|
||||
|
||||
// Verify pageExecute was called with PC method
|
||||
expect(mockAlipayPageExecute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
product_code: 'FAST_INSTANT_TRADE_PAY',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
method: 'alipay.trade.page.pay',
|
||||
}),
|
||||
);
|
||||
|
||||
// PC + payUrl only (no qrCode) => shouldAutoRedirect = true (redirect to Alipay cashier page)
|
||||
expect(
|
||||
shouldAutoRedirect({
|
||||
expired: false,
|
||||
@@ -279,7 +258,7 @@ describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
|
||||
qrCode: result.qrCode,
|
||||
isMobile: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('Mobile: uses alipay.trade.wap.pay, returns payUrl', async () => {
|
||||
@@ -323,15 +302,10 @@ describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('Mobile: falls back to PC page.pay when wap.pay throws', async () => {
|
||||
// First call (wap.pay) throws, second call (page.pay) succeeds
|
||||
mockAlipayPageExecute
|
||||
.mockImplementationOnce(() => {
|
||||
it('Mobile: surfaces wap.pay creation errors', async () => {
|
||||
mockAlipayPageExecute.mockImplementationOnce(() => {
|
||||
throw new Error('WAP pay not available');
|
||||
})
|
||||
.mockReturnValueOnce(
|
||||
'https://openapi.alipay.com/gateway.do?method=alipay.trade.page.pay&sign=fallback',
|
||||
);
|
||||
});
|
||||
|
||||
const request: CreatePaymentRequest = {
|
||||
orderId: 'order-ali-003',
|
||||
@@ -341,21 +315,12 @@ describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
|
||||
isMobile: true,
|
||||
};
|
||||
|
||||
const result = await provider.createPayment(request);
|
||||
|
||||
expect(result.payUrl).toContain('alipay.trade.page.pay');
|
||||
// pageExecute was called twice: first wap.pay (failed), then page.pay
|
||||
expect(mockAlipayPageExecute).toHaveBeenCalledTimes(2);
|
||||
expect(mockAlipayPageExecute).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
await expect(provider.createPayment(request)).rejects.toThrow('WAP pay not available');
|
||||
expect(mockAlipayPageExecute).toHaveBeenCalledTimes(1);
|
||||
expect(mockAlipayPageExecute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ product_code: 'QUICK_WAP_WAY' }),
|
||||
expect.objectContaining({ method: 'alipay.trade.wap.pay' }),
|
||||
);
|
||||
expect(mockAlipayPageExecute).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ product_code: 'FAST_INSTANT_TRADE_PAY' }),
|
||||
expect.objectContaining({ method: 'alipay.trade.page.pay' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('alipay_direct is in REDIRECT_PAYMENT_TYPES', () => {
|
||||
@@ -409,9 +374,7 @@ describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
|
||||
});
|
||||
|
||||
it('Mobile: uses H5 order, returns payUrl (no qrCode)', async () => {
|
||||
mockWxpayCreateH5Order.mockResolvedValue(
|
||||
'https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx123',
|
||||
);
|
||||
mockWxpayCreateH5Order.mockResolvedValue('https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx123');
|
||||
|
||||
const request: CreatePaymentRequest = {
|
||||
orderId: 'order-wx-002',
|
||||
|
||||
1105
src/app/admin/channels/page.tsx
Normal file
1105
src/app/admin/channels/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,7 @@ import DashboardStats from '@/components/admin/DashboardStats';
|
||||
import DailyChart from '@/components/admin/DailyChart';
|
||||
import Leaderboard from '@/components/admin/Leaderboard';
|
||||
import PaymentMethodChart from '@/components/admin/PaymentMethodChart';
|
||||
import { resolveLocale, type Locale } from '@/lib/locale';
|
||||
import { resolveLocale } from '@/lib/locale';
|
||||
|
||||
interface DashboardData {
|
||||
summary: {
|
||||
@@ -39,7 +39,8 @@ function DashboardContent() {
|
||||
const isDark = theme === 'dark';
|
||||
const isEmbedded = uiMode === 'embedded';
|
||||
|
||||
const text = locale === 'en'
|
||||
const text =
|
||||
locale === 'en'
|
||||
? {
|
||||
missingToken: 'Missing admin token',
|
||||
missingTokenHint: 'Please access the admin page from the Sub2API platform.',
|
||||
@@ -102,7 +103,7 @@ function DashboardContent() {
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">{text.missingToken}</p>
|
||||
<p className="mt-2 text-sm text-gray-500">{text.missingTokenHint}</p>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>{text.missingTokenHint}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -138,10 +139,11 @@ function DashboardContent() {
|
||||
<>
|
||||
{DAYS_OPTIONS.map((d) => (
|
||||
<button key={d} type="button" onClick={() => setDays(d)} className={days === d ? btnActive : btnBase}>
|
||||
{d}{text.daySuffix}
|
||||
{d}
|
||||
{text.daySuffix}
|
||||
</button>
|
||||
))}
|
||||
<a href={`/admin?${navParams}`} className={btnBase}>
|
||||
<a href={`/admin/orders?${navParams}`} className={btnBase}>
|
||||
{text.orders}
|
||||
</a>
|
||||
<button type="button" onClick={fetchData} className={btnBase}>
|
||||
@@ -183,16 +185,14 @@ function DashboardPageFallback() {
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
||||
<div className="text-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={<DashboardPageFallback />}
|
||||
>
|
||||
<Suspense fallback={<DashboardPageFallback />}>
|
||||
<DashboardContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
77
src/app/admin/layout.tsx
Normal file
77
src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams, usePathname } from 'next/navigation';
|
||||
import { Suspense } from 'react';
|
||||
import { resolveLocale } from '@/lib/locale';
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ path: '/admin', label: { zh: '数据概览', en: 'Dashboard' } },
|
||||
{ path: '/admin/orders', label: { zh: '订单管理', en: 'Orders' } },
|
||||
{ path: '/admin/channels', label: { zh: '渠道管理', en: 'Channels' } },
|
||||
{ path: '/admin/subscriptions', label: { zh: '订阅管理', en: 'Subscriptions' } },
|
||||
];
|
||||
|
||||
function AdminLayoutInner({ children }: { children: React.ReactNode }) {
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const token = searchParams.get('token') || '';
|
||||
const theme = searchParams.get('theme') || 'light';
|
||||
const uiMode = searchParams.get('ui_mode') || 'standalone';
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const buildUrl = (path: string) => {
|
||||
const params = new URLSearchParams();
|
||||
if (token) params.set('token', token);
|
||||
params.set('theme', theme);
|
||||
params.set('ui_mode', uiMode);
|
||||
if (locale !== 'zh') params.set('lang', locale);
|
||||
return `${path}?${params.toString()}`;
|
||||
};
|
||||
|
||||
const isActive = (navPath: string) => {
|
||||
if (navPath === '/admin') return pathname === '/admin' || pathname === '/admin/dashboard';
|
||||
return pathname.startsWith(navPath);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-theme={theme} className={['min-h-screen', isDark ? 'bg-slate-950' : 'bg-slate-100'].join(' ')}>
|
||||
<div className="px-2 pt-2 sm:px-3 sm:pt-3">
|
||||
<nav
|
||||
className={[
|
||||
'mb-1 flex flex-wrap gap-1 rounded-xl border p-1',
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-100/90',
|
||||
].join(' ')}
|
||||
>
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<a
|
||||
key={item.path}
|
||||
href={buildUrl(item.path)}
|
||||
className={[
|
||||
'rounded-lg px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
isActive(item.path)
|
||||
? isDark
|
||||
? 'bg-indigo-500/30 text-indigo-100 ring-1 ring-indigo-300/35'
|
||||
: 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-sm'
|
||||
: isDark
|
||||
? 'text-slate-400 hover:text-slate-200 hover:bg-slate-700/50'
|
||||
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-200/70',
|
||||
].join(' ')}
|
||||
>
|
||||
{item.label[locale]}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Suspense>
|
||||
<AdminLayoutInner>{children}</AdminLayoutInner>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
349
src/app/admin/orders/page.tsx
Normal file
349
src/app/admin/orders/page.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useState, useEffect, useCallback, Suspense } from 'react';
|
||||
import OrderTable from '@/components/admin/OrderTable';
|
||||
import OrderDetail from '@/components/admin/OrderDetail';
|
||||
import PaginationBar from '@/components/PaginationBar';
|
||||
import PayPageLayout from '@/components/PayPageLayout';
|
||||
import { resolveLocale } from '@/lib/locale';
|
||||
|
||||
interface AdminOrder {
|
||||
id: string;
|
||||
userId: number;
|
||||
userName: string | null;
|
||||
userEmail: string | null;
|
||||
userNotes: string | null;
|
||||
amount: number;
|
||||
status: string;
|
||||
paymentType: string;
|
||||
createdAt: string;
|
||||
paidAt: string | null;
|
||||
completedAt: string | null;
|
||||
failedReason: string | null;
|
||||
expiresAt: string;
|
||||
srcHost: string | null;
|
||||
}
|
||||
|
||||
interface AdminOrderDetail extends AdminOrder {
|
||||
rechargeCode: string;
|
||||
paymentTradeNo: string | null;
|
||||
refundAmount: number | null;
|
||||
refundReason: string | null;
|
||||
refundAt: string | null;
|
||||
forceRefund: boolean;
|
||||
failedAt: string | null;
|
||||
updatedAt: string;
|
||||
clientIp: string | null;
|
||||
srcHost: string | null;
|
||||
srcUrl: string | null;
|
||||
paymentSuccess?: boolean;
|
||||
rechargeSuccess?: boolean;
|
||||
rechargeStatus?: string;
|
||||
auditLogs: { id: string; action: string; detail: string | null; operator: string | null; createdAt: string }[];
|
||||
}
|
||||
|
||||
function AdminContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get('token');
|
||||
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
||||
const uiMode = searchParams.get('ui_mode') || 'standalone';
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
const isDark = theme === 'dark';
|
||||
const isEmbedded = uiMode === 'embedded';
|
||||
|
||||
const text =
|
||||
locale === 'en'
|
||||
? {
|
||||
missingToken: 'Missing admin token',
|
||||
missingTokenHint: 'Please access the admin page from the Sub2API platform.',
|
||||
invalidToken: 'Invalid admin token',
|
||||
requestFailed: 'Request failed',
|
||||
loadOrdersFailed: 'Failed to load orders',
|
||||
retryConfirm: 'Retry recharge for this order?',
|
||||
retryFailed: 'Retry failed',
|
||||
retryRequestFailed: 'Retry request failed',
|
||||
cancelConfirm: 'Cancel this order?',
|
||||
cancelFailed: 'Cancel failed',
|
||||
cancelRequestFailed: 'Cancel request failed',
|
||||
loadDetailFailed: 'Failed to load order details',
|
||||
title: 'Order Management',
|
||||
subtitle: 'View and manage all recharge orders',
|
||||
dashboard: 'Dashboard',
|
||||
refresh: 'Refresh',
|
||||
loading: 'Loading...',
|
||||
statuses: {
|
||||
'': 'All',
|
||||
PENDING: 'Pending',
|
||||
PAID: 'Paid',
|
||||
RECHARGING: 'Recharging',
|
||||
COMPLETED: 'Completed',
|
||||
EXPIRED: 'Expired',
|
||||
CANCELLED: 'Cancelled',
|
||||
FAILED: 'Recharge failed',
|
||||
REFUNDED: 'Refunded',
|
||||
},
|
||||
}
|
||||
: {
|
||||
missingToken: '缺少管理员凭证',
|
||||
missingTokenHint: '请从 Sub2API 平台正确访问管理页面',
|
||||
invalidToken: '管理员凭证无效',
|
||||
requestFailed: '请求失败',
|
||||
loadOrdersFailed: '加载订单列表失败',
|
||||
retryConfirm: '确认重试充值?',
|
||||
retryFailed: '重试失败',
|
||||
retryRequestFailed: '重试请求失败',
|
||||
cancelConfirm: '确认取消该订单?',
|
||||
cancelFailed: '取消失败',
|
||||
cancelRequestFailed: '取消请求失败',
|
||||
loadDetailFailed: '加载订单详情失败',
|
||||
title: '订单管理',
|
||||
subtitle: '查看和管理所有充值订单',
|
||||
dashboard: '数据概览',
|
||||
refresh: '刷新',
|
||||
loading: '加载中...',
|
||||
statuses: {
|
||||
'': '全部',
|
||||
PENDING: '待支付',
|
||||
PAID: '已支付',
|
||||
RECHARGING: '充值中',
|
||||
COMPLETED: '已完成',
|
||||
EXPIRED: '已超时',
|
||||
CANCELLED: '已取消',
|
||||
FAILED: '充值失败',
|
||||
REFUNDED: '已退款',
|
||||
},
|
||||
};
|
||||
|
||||
const [orders, setOrders] = useState<AdminOrder[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [detailOrder, setDetailOrder] = useState<AdminOrderDetail | null>(null);
|
||||
|
||||
const fetchOrders = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams({ token, page: String(page), page_size: String(pageSize) });
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
|
||||
const res = await fetch(`/api/admin/orders?${params}`);
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
setError(text.invalidToken);
|
||||
return;
|
||||
}
|
||||
throw new Error(text.requestFailed);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setOrders(data.orders);
|
||||
setTotal(data.total);
|
||||
setTotalPages(data.total_pages);
|
||||
} catch {
|
||||
setError(text.loadOrdersFailed);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, page, pageSize, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders();
|
||||
}, [fetchOrders]);
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">{text.missingToken}</p>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>{text.missingTokenHint}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleRetry = async (orderId: string) => {
|
||||
if (!confirm(text.retryConfirm)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/orders/${orderId}/retry?token=${token}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (res.ok) {
|
||||
fetchOrders();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || text.retryFailed);
|
||||
}
|
||||
} catch {
|
||||
setError(text.retryRequestFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (orderId: string) => {
|
||||
if (!confirm(text.cancelConfirm)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/orders/${orderId}/cancel?token=${token}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (res.ok) {
|
||||
fetchOrders();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || text.cancelFailed);
|
||||
}
|
||||
} catch {
|
||||
setError(text.cancelRequestFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetail = async (orderId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/orders/${orderId}?token=${token}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setDetailOrder(data);
|
||||
}
|
||||
} catch {
|
||||
setError(text.loadDetailFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const statuses = ['', 'PENDING', 'PAID', 'RECHARGING', 'COMPLETED', 'EXPIRED', 'CANCELLED', 'FAILED', 'REFUNDED'];
|
||||
const statusLabels: Record<string, string> = text.statuses;
|
||||
|
||||
const navParams = new URLSearchParams();
|
||||
if (token) navParams.set('token', token);
|
||||
if (locale === 'en') navParams.set('lang', 'en');
|
||||
if (isDark) navParams.set('theme', 'dark');
|
||||
if (isEmbedded) navParams.set('ui_mode', 'embedded');
|
||||
|
||||
const btnBase = [
|
||||
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
isDark
|
||||
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
|
||||
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
].join(' ');
|
||||
|
||||
return (
|
||||
<PayPageLayout
|
||||
isDark={isDark}
|
||||
isEmbedded={isEmbedded}
|
||||
maxWidth="full"
|
||||
title={text.title}
|
||||
subtitle={text.subtitle}
|
||||
locale={locale}
|
||||
actions={
|
||||
<>
|
||||
<a href={`/admin/dashboard?${navParams}`} className={btnBase}>
|
||||
{text.dashboard}
|
||||
</a>
|
||||
<button type="button" onClick={fetchOrders} className={btnBase}>
|
||||
{text.refresh}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{error && (
|
||||
<div
|
||||
className={`mb-4 rounded-lg border p-3 text-sm ${isDark ? 'border-red-800 bg-red-950/50 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}
|
||||
>
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 opacity-60 hover:opacity-100">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{statuses.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => {
|
||||
setStatusFilter(s);
|
||||
setPage(1);
|
||||
}}
|
||||
className={[
|
||||
'rounded-full px-3 py-1 text-sm transition-colors',
|
||||
statusFilter === s
|
||||
? isDark
|
||||
? 'bg-indigo-500/30 text-indigo-200 ring-1 ring-indigo-400/40'
|
||||
: 'bg-blue-600 text-white'
|
||||
: isDark
|
||||
? 'bg-slate-800 text-slate-400 hover:bg-slate-700'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200',
|
||||
].join(' ')}
|
||||
>
|
||||
{statusLabels[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<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'}`}>{text.loading}</div>
|
||||
) : (
|
||||
<OrderTable
|
||||
orders={orders}
|
||||
onRetry={handleRetry}
|
||||
onCancel={handleCancel}
|
||||
onViewDetail={handleViewDetail}
|
||||
dark={isDark}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PaginationBar
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
loading={loading}
|
||||
onPageChange={(p) => setPage(p)}
|
||||
onPageSizeChange={(s) => {
|
||||
setPageSize(s);
|
||||
setPage(1);
|
||||
}}
|
||||
locale={locale}
|
||||
isDark={isDark}
|
||||
/>
|
||||
|
||||
{/* Order Detail */}
|
||||
{detailOrder && (
|
||||
<OrderDetail order={detailOrder} onClose={() => setDetailOrder(null)} dark={isDark} locale={locale} />
|
||||
)}
|
||||
</PayPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminPageFallback() {
|
||||
const searchParams = useSearchParams();
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
return (
|
||||
<Suspense fallback={<AdminPageFallback />}>
|
||||
<AdminContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -2,48 +2,35 @@
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useState, useEffect, useCallback, Suspense } from 'react';
|
||||
import OrderTable from '@/components/admin/OrderTable';
|
||||
import OrderDetail from '@/components/admin/OrderDetail';
|
||||
import PaginationBar from '@/components/PaginationBar';
|
||||
import PayPageLayout from '@/components/PayPageLayout';
|
||||
import { resolveLocale, type Locale } from '@/lib/locale';
|
||||
import DashboardStats from '@/components/admin/DashboardStats';
|
||||
import DailyChart from '@/components/admin/DailyChart';
|
||||
import Leaderboard from '@/components/admin/Leaderboard';
|
||||
import PaymentMethodChart from '@/components/admin/PaymentMethodChart';
|
||||
import { resolveLocale } from '@/lib/locale';
|
||||
|
||||
interface AdminOrder {
|
||||
id: string;
|
||||
interface DashboardData {
|
||||
summary: {
|
||||
today: { amount: number; orderCount: number; paidCount: number };
|
||||
total: { amount: number; orderCount: number; paidCount: number };
|
||||
successRate: number;
|
||||
avgAmount: number;
|
||||
};
|
||||
dailySeries: { date: string; amount: number; count: number }[];
|
||||
leaderboard: {
|
||||
userId: number;
|
||||
userName: string | null;
|
||||
userEmail: string | null;
|
||||
userNotes: string | null;
|
||||
amount: number;
|
||||
status: string;
|
||||
paymentType: string;
|
||||
createdAt: string;
|
||||
paidAt: string | null;
|
||||
completedAt: string | null;
|
||||
failedReason: string | null;
|
||||
expiresAt: string;
|
||||
srcHost: string | null;
|
||||
totalAmount: number;
|
||||
orderCount: number;
|
||||
}[];
|
||||
paymentMethods: { paymentType: string; amount: number; count: number; percentage: number }[];
|
||||
meta: { days: number; generatedAt: string };
|
||||
}
|
||||
|
||||
interface AdminOrderDetail extends AdminOrder {
|
||||
rechargeCode: string;
|
||||
paymentTradeNo: string | null;
|
||||
refundAmount: number | null;
|
||||
refundReason: string | null;
|
||||
refundAt: string | null;
|
||||
forceRefund: boolean;
|
||||
failedAt: string | null;
|
||||
updatedAt: string;
|
||||
clientIp: string | null;
|
||||
srcHost: string | null;
|
||||
srcUrl: string | null;
|
||||
paymentSuccess?: boolean;
|
||||
rechargeSuccess?: boolean;
|
||||
rechargeStatus?: string;
|
||||
auditLogs: { id: string; action: string; detail: string | null; operator: string | null; createdAt: string }[];
|
||||
}
|
||||
const DAYS_OPTIONS = [7, 30, 90] as const;
|
||||
|
||||
function AdminContent() {
|
||||
function DashboardContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get('token');
|
||||
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
||||
@@ -52,87 +39,46 @@ function AdminContent() {
|
||||
const isDark = theme === 'dark';
|
||||
const isEmbedded = uiMode === 'embedded';
|
||||
|
||||
const text = locale === 'en'
|
||||
const text =
|
||||
locale === 'en'
|
||||
? {
|
||||
missingToken: 'Missing admin token',
|
||||
missingTokenHint: 'Please access the admin page from the Sub2API platform.',
|
||||
invalidToken: 'Invalid admin token',
|
||||
requestFailed: 'Request failed',
|
||||
loadOrdersFailed: 'Failed to load orders',
|
||||
retryConfirm: 'Retry recharge for this order?',
|
||||
retryFailed: 'Retry failed',
|
||||
retryRequestFailed: 'Retry request failed',
|
||||
cancelConfirm: 'Cancel this order?',
|
||||
cancelFailed: 'Cancel failed',
|
||||
cancelRequestFailed: 'Cancel request failed',
|
||||
loadDetailFailed: 'Failed to load order details',
|
||||
title: 'Order Management',
|
||||
subtitle: 'View and manage all recharge orders',
|
||||
dashboard: 'Dashboard',
|
||||
loadFailed: 'Failed to load data',
|
||||
title: 'Dashboard',
|
||||
subtitle: 'Recharge order analytics and insights',
|
||||
daySuffix: 'd',
|
||||
orders: 'Order Management',
|
||||
refresh: 'Refresh',
|
||||
loading: 'Loading...',
|
||||
statuses: {
|
||||
'': 'All',
|
||||
PENDING: 'Pending',
|
||||
PAID: 'Paid',
|
||||
RECHARGING: 'Recharging',
|
||||
COMPLETED: 'Completed',
|
||||
EXPIRED: 'Expired',
|
||||
CANCELLED: 'Cancelled',
|
||||
FAILED: 'Recharge failed',
|
||||
REFUNDED: 'Refunded',
|
||||
},
|
||||
}
|
||||
: {
|
||||
missingToken: '缺少管理员凭证',
|
||||
missingTokenHint: '请从 Sub2API 平台正确访问管理页面',
|
||||
invalidToken: '管理员凭证无效',
|
||||
requestFailed: '请求失败',
|
||||
loadOrdersFailed: '加载订单列表失败',
|
||||
retryConfirm: '确认重试充值?',
|
||||
retryFailed: '重试失败',
|
||||
retryRequestFailed: '重试请求失败',
|
||||
cancelConfirm: '确认取消该订单?',
|
||||
cancelFailed: '取消失败',
|
||||
cancelRequestFailed: '取消请求失败',
|
||||
loadDetailFailed: '加载订单详情失败',
|
||||
title: '订单管理',
|
||||
subtitle: '查看和管理所有充值订单',
|
||||
dashboard: '数据概览',
|
||||
loadFailed: '加载数据失败',
|
||||
title: '数据概览',
|
||||
subtitle: '充值订单统计与分析',
|
||||
daySuffix: '天',
|
||||
orders: '订单管理',
|
||||
refresh: '刷新',
|
||||
loading: '加载中...',
|
||||
statuses: {
|
||||
'': '全部',
|
||||
PENDING: '待支付',
|
||||
PAID: '已支付',
|
||||
RECHARGING: '充值中',
|
||||
COMPLETED: '已完成',
|
||||
EXPIRED: '已超时',
|
||||
CANCELLED: '已取消',
|
||||
FAILED: '充值失败',
|
||||
REFUNDED: '已退款',
|
||||
},
|
||||
};
|
||||
|
||||
const [orders, setOrders] = useState<AdminOrder[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [days, setDays] = useState<number>(30);
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [detailOrder, setDetailOrder] = useState<AdminOrderDetail | null>(null);
|
||||
|
||||
const fetchOrders = useCallback(async () => {
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const params = new URLSearchParams({ token, page: String(page), page_size: String(pageSize) });
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
|
||||
const res = await fetch(`/api/admin/orders?${params}`);
|
||||
const res = await fetch(`/api/admin/dashboard?token=${encodeURIComponent(token)}&days=${days}`);
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
setError(text.invalidToken);
|
||||
@@ -140,86 +86,33 @@ function AdminContent() {
|
||||
}
|
||||
throw new Error(text.requestFailed);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setOrders(data.orders);
|
||||
setTotal(data.total);
|
||||
setTotalPages(data.total_pages);
|
||||
setData(await res.json());
|
||||
} catch {
|
||||
setError(text.loadOrdersFailed);
|
||||
setError(text.loadFailed);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token, page, pageSize, statusFilter]);
|
||||
}, [token, days]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders();
|
||||
}, [fetchOrders]);
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">{text.missingToken}</p>
|
||||
<p className="mt-2 text-sm text-gray-500">{text.missingTokenHint}</p>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>{text.missingTokenHint}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleRetry = async (orderId: string) => {
|
||||
if (!confirm(text.retryConfirm)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/orders/${orderId}/retry?token=${token}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (res.ok) {
|
||||
fetchOrders();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || text.retryFailed);
|
||||
}
|
||||
} catch {
|
||||
setError(text.retryRequestFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (orderId: string) => {
|
||||
if (!confirm(text.cancelConfirm)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/orders/${orderId}/cancel?token=${token}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (res.ok) {
|
||||
fetchOrders();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
setError(data.error || text.cancelFailed);
|
||||
}
|
||||
} catch {
|
||||
setError(text.cancelRequestFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetail = async (orderId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/orders/${orderId}?token=${token}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setDetailOrder(data);
|
||||
}
|
||||
} catch {
|
||||
setError(text.loadDetailFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const statuses = ['', 'PENDING', 'PAID', 'RECHARGING', 'COMPLETED', 'EXPIRED', 'CANCELLED', 'FAILED', 'REFUNDED'];
|
||||
const statusLabels: Record<string, string> = text.statuses;
|
||||
|
||||
const navParams = new URLSearchParams();
|
||||
if (token) navParams.set('token', token);
|
||||
navParams.set('token', token);
|
||||
if (locale === 'en') navParams.set('lang', 'en');
|
||||
if (isDark) navParams.set('theme', 'dark');
|
||||
if (theme === 'dark') navParams.set('theme', 'dark');
|
||||
if (isEmbedded) navParams.set('ui_mode', 'embedded');
|
||||
|
||||
const btnBase = [
|
||||
@@ -229,6 +122,11 @@ function AdminContent() {
|
||||
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
|
||||
].join(' ');
|
||||
|
||||
const btnActive = [
|
||||
'inline-flex items-center rounded-lg px-3 py-1.5 text-xs font-medium',
|
||||
isDark ? 'bg-indigo-500/30 text-indigo-200 ring-1 ring-indigo-400/40' : 'bg-blue-600 text-white',
|
||||
].join(' ');
|
||||
|
||||
return (
|
||||
<PayPageLayout
|
||||
isDark={isDark}
|
||||
@@ -239,10 +137,16 @@ function AdminContent() {
|
||||
locale={locale}
|
||||
actions={
|
||||
<>
|
||||
<a href={`/admin/dashboard?${navParams}`} className={btnBase}>
|
||||
{text.dashboard}
|
||||
{DAYS_OPTIONS.map((d) => (
|
||||
<button key={d} type="button" onClick={() => setDays(d)} className={days === d ? btnActive : btnBase}>
|
||||
{d}
|
||||
{text.daySuffix}
|
||||
</button>
|
||||
))}
|
||||
<a href={`/admin/orders?${navParams}`} className={btnBase}>
|
||||
{text.orders}
|
||||
</a>
|
||||
<button type="button" onClick={fetchOrders} className={btnBase}>
|
||||
<button type="button" onClick={fetchData} className={btnBase}>
|
||||
{text.refresh}
|
||||
</button>
|
||||
</>
|
||||
@@ -259,90 +163,37 @@ function AdminContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{statuses.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => {
|
||||
setStatusFilter(s);
|
||||
setPage(1);
|
||||
}}
|
||||
className={[
|
||||
'rounded-full px-3 py-1 text-sm transition-colors',
|
||||
statusFilter === s
|
||||
? isDark
|
||||
? 'bg-indigo-500/30 text-indigo-200 ring-1 ring-indigo-400/40'
|
||||
: 'bg-blue-600 text-white'
|
||||
: isDark
|
||||
? 'bg-slate-800 text-slate-400 hover:bg-slate-700'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200',
|
||||
].join(' ')}
|
||||
>
|
||||
{statusLabels[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<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'}`}>{text.loading}</div>
|
||||
) : (
|
||||
<OrderTable
|
||||
orders={orders}
|
||||
onRetry={handleRetry}
|
||||
onCancel={handleCancel}
|
||||
onViewDetail={handleViewDetail}
|
||||
dark={isDark}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
<div className={`py-24 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.loading}</div>
|
||||
) : data ? (
|
||||
<div className="space-y-6">
|
||||
<DashboardStats summary={data.summary} dark={isDark} locale={locale} />
|
||||
<DailyChart data={data.dailySeries} dark={isDark} locale={locale} />
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<Leaderboard data={data.leaderboard} dark={isDark} locale={locale} />
|
||||
<PaymentMethodChart data={data.paymentMethods} dark={isDark} locale={locale} />
|
||||
</div>
|
||||
|
||||
<PaginationBar
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
loading={loading}
|
||||
onPageChange={(p) => setPage(p)}
|
||||
onPageSizeChange={(s) => {
|
||||
setPageSize(s);
|
||||
setPage(1);
|
||||
}}
|
||||
locale={locale}
|
||||
isDark={isDark}
|
||||
/>
|
||||
|
||||
{/* Order Detail */}
|
||||
{detailOrder && <OrderDetail order={detailOrder} onClose={() => setDetailOrder(null)} dark={isDark} locale={locale} />}
|
||||
</div>
|
||||
) : null}
|
||||
</PayPageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminPageFallback() {
|
||||
function DashboardPageFallback() {
|
||||
const searchParams = useSearchParams();
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
||||
<div className="text-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPage() {
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={<AdminPageFallback />}
|
||||
>
|
||||
<AdminContent />
|
||||
<Suspense fallback={<DashboardPageFallback />}>
|
||||
<DashboardContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
1518
src/app/admin/subscriptions/page.tsx
Normal file
1518
src/app/admin/subscriptions/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
92
src/app/api/admin/channels/[id]/route.ts
Normal file
92
src/app/api/admin/channels/[id]/route.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
|
||||
const updateChannelSchema = z.object({
|
||||
group_id: z.number().int().positive().optional(),
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
platform: z.string().min(1).max(50).optional(),
|
||||
rate_multiplier: z.number().positive().optional(),
|
||||
description: z.string().max(500).nullable().optional(),
|
||||
models: z.union([z.array(z.string()), z.string()]).nullable().optional(),
|
||||
features: z.union([z.record(z.string(), z.unknown()), z.string()]).nullable().optional(),
|
||||
sort_order: z.number().int().min(0).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
const rawBody = await request.json();
|
||||
const parsed = updateChannelSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: '参数校验失败' }, { status: 400 });
|
||||
}
|
||||
const body = parsed.data;
|
||||
|
||||
const existing = await prisma.channel.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: '渠道不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 如果更新了 group_id,检查唯一性
|
||||
if (body.group_id !== undefined && Number(body.group_id) !== existing.groupId) {
|
||||
const conflict = await prisma.channel.findUnique({
|
||||
where: { groupId: Number(body.group_id) },
|
||||
});
|
||||
if (conflict) {
|
||||
return NextResponse.json(
|
||||
{ error: `分组 ID ${body.group_id} 已被渠道「${conflict.name}」使用` },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (body.group_id !== undefined) data.groupId = body.group_id;
|
||||
if (body.name !== undefined) data.name = body.name;
|
||||
if (body.platform !== undefined) data.platform = body.platform;
|
||||
if (body.rate_multiplier !== undefined) data.rateMultiplier = body.rate_multiplier;
|
||||
if (body.description !== undefined) data.description = body.description;
|
||||
if (body.models !== undefined) data.models = body.models;
|
||||
if (body.features !== undefined) data.features = body.features;
|
||||
if (body.sort_order !== undefined) data.sortOrder = body.sort_order;
|
||||
if (body.enabled !== undefined) data.enabled = body.enabled;
|
||||
|
||||
const channel = await prisma.channel.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
...channel,
|
||||
rateMultiplier: Number(channel.rateMultiplier),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update channel:', error);
|
||||
return NextResponse.json({ error: '更新渠道失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await prisma.channel.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: '渠道不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.channel.delete({ where: { id } });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete channel:', error);
|
||||
return NextResponse.json({ error: '删除渠道失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
94
src/app/api/admin/channels/route.ts
Normal file
94
src/app/api/admin/channels/route.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getGroup } from '@/lib/sub2api/client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
|
||||
|
||||
try {
|
||||
const channels = await prisma.channel.findMany({
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
// 并发检查每个渠道对应的 Sub2API 分组是否仍然存在
|
||||
const results = await Promise.all(
|
||||
channels.map(async (channel) => {
|
||||
let groupExists = false;
|
||||
try {
|
||||
const group = await getGroup(channel.groupId);
|
||||
groupExists = group !== null;
|
||||
} catch {
|
||||
groupExists = false;
|
||||
}
|
||||
return {
|
||||
...channel,
|
||||
rateMultiplier: Number(channel.rateMultiplier),
|
||||
groupExists,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return NextResponse.json({ channels: results });
|
||||
} catch (error) {
|
||||
console.error('Failed to list channels:', error);
|
||||
return NextResponse.json({ error: '获取渠道列表失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { group_id, name, platform, rate_multiplier, description, models, features, sort_order, enabled } = body;
|
||||
|
||||
if (!group_id || !name || !platform || rate_multiplier === undefined) {
|
||||
return NextResponse.json({ error: '缺少必填字段: group_id, name, platform, rate_multiplier' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (typeof name !== 'string' || name.trim() === '') {
|
||||
return NextResponse.json({ error: 'name 必须非空' }, { status: 400 });
|
||||
}
|
||||
if (typeof rate_multiplier !== 'number' || rate_multiplier <= 0) {
|
||||
return NextResponse.json({ error: 'rate_multiplier 必须是正数' }, { status: 400 });
|
||||
}
|
||||
if (sort_order !== undefined && (!Number.isInteger(sort_order) || sort_order < 0)) {
|
||||
return NextResponse.json({ error: 'sort_order 必须是非负整数' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 验证 group_id 唯一性
|
||||
const existing = await prisma.channel.findUnique({
|
||||
where: { groupId: Number(group_id) },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: `分组 ID ${group_id} 已被渠道「${existing.name}」使用` }, { status: 409 });
|
||||
}
|
||||
|
||||
const channel = await prisma.channel.create({
|
||||
data: {
|
||||
groupId: Number(group_id),
|
||||
name,
|
||||
platform,
|
||||
rateMultiplier: rate_multiplier,
|
||||
description: description ?? null,
|
||||
models: models ?? null,
|
||||
features: features ?? null,
|
||||
sortOrder: sort_order ?? 0,
|
||||
enabled: enabled ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
...channel,
|
||||
rateMultiplier: Number(channel.rateMultiplier),
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to create channel:', error);
|
||||
return NextResponse.json({ error: '创建渠道失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
80
src/app/api/admin/config/route.ts
Normal file
80
src/app/api/admin/config/route.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { getAllSystemConfigs, setSystemConfigs } from '@/lib/system-config';
|
||||
|
||||
const SENSITIVE_PATTERNS = ['KEY', 'SECRET', 'PASSWORD', 'PRIVATE'];
|
||||
|
||||
function maskSensitiveValue(key: string, value: string): string {
|
||||
const isSensitive = SENSITIVE_PATTERNS.some((pattern) => key.toUpperCase().includes(pattern));
|
||||
if (!isSensitive) return value;
|
||||
if (value.length <= 4) return '****';
|
||||
return '*'.repeat(value.length - 4) + value.slice(-4);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
|
||||
|
||||
try {
|
||||
const configs = await getAllSystemConfigs();
|
||||
|
||||
const masked = configs.map((config) => ({
|
||||
...config,
|
||||
value: maskSensitiveValue(config.key, config.value),
|
||||
}));
|
||||
|
||||
return NextResponse.json({ configs: masked });
|
||||
} catch (error) {
|
||||
console.error('Failed to get system configs:', error instanceof Error ? error.message : String(error));
|
||||
return NextResponse.json({ error: '获取系统配置失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { configs } = body;
|
||||
|
||||
if (!Array.isArray(configs) || configs.length === 0) {
|
||||
return NextResponse.json({ error: '缺少必填字段: configs 数组' }, { status: 400 });
|
||||
}
|
||||
|
||||
const ALLOWED_CONFIG_KEYS = new Set([
|
||||
'PRODUCT_NAME',
|
||||
'ENABLED_PAYMENT_TYPES',
|
||||
'RECHARGE_MIN_AMOUNT',
|
||||
'RECHARGE_MAX_AMOUNT',
|
||||
'DAILY_RECHARGE_LIMIT',
|
||||
'ORDER_TIMEOUT_MINUTES',
|
||||
'IFRAME_ALLOW_ORIGINS',
|
||||
'PRODUCT_NAME_PREFIX',
|
||||
'PRODUCT_NAME_SUFFIX',
|
||||
'BALANCE_PAYMENT_DISABLED',
|
||||
]);
|
||||
|
||||
// 校验每条配置
|
||||
for (const config of configs) {
|
||||
if (!config.key || config.value === undefined) {
|
||||
return NextResponse.json({ error: '每条配置必须包含 key 和 value' }, { status: 400 });
|
||||
}
|
||||
if (!ALLOWED_CONFIG_KEYS.has(config.key)) {
|
||||
return NextResponse.json({ error: `不允许修改配置项: ${config.key}` }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
await setSystemConfigs(
|
||||
configs.map((c: { key: string; value: string; group?: string; label?: string }) => ({
|
||||
key: c.key,
|
||||
value: c.value,
|
||||
group: c.group,
|
||||
label: c.label,
|
||||
})),
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true, updated: configs.length });
|
||||
} catch (error) {
|
||||
console.error('Failed to update system configs:', error instanceof Error ? error.message : String(error));
|
||||
return NextResponse.json({ error: '更新系统配置失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -3,23 +3,7 @@ import { Prisma } from '@prisma/client';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { OrderStatus } from '@prisma/client';
|
||||
|
||||
/** 业务时区偏移(东八区,+8 小时) */
|
||||
const BIZ_TZ_OFFSET_MS = 8 * 60 * 60 * 1000;
|
||||
const BIZ_TZ_NAME = 'Asia/Shanghai';
|
||||
|
||||
/** 获取业务时区下的 YYYY-MM-DD */
|
||||
function toBizDateStr(d: Date): string {
|
||||
const local = new Date(d.getTime() + BIZ_TZ_OFFSET_MS);
|
||||
return local.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/** 获取业务时区下"今天 00:00"对应的 UTC 时间 */
|
||||
function getBizDayStartUTC(d: Date): Date {
|
||||
const bizDateStr = toBizDateStr(d);
|
||||
// bizDateStr 00:00 在业务时区 = bizDateStr 00:00 - offset 在 UTC
|
||||
return new Date(`${bizDateStr}T00:00:00+08:00`);
|
||||
}
|
||||
import { BIZ_TZ_NAME, getBizDayStartUTC, toBizDateStr } from '@/lib/time/biz-day';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
|
||||
|
||||
15
src/app/api/admin/sub2api/groups/route.ts
Normal file
15
src/app/api/admin/sub2api/groups/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { getAllGroups } from '@/lib/sub2api/client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
|
||||
|
||||
try {
|
||||
const groups = await getAllGroups();
|
||||
return NextResponse.json({ groups });
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Sub2API groups:', error);
|
||||
return NextResponse.json({ error: '获取 Sub2API 分组列表失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
20
src/app/api/admin/sub2api/search-users/route.ts
Normal file
20
src/app/api/admin/sub2api/search-users/route.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { searchUsers } from '@/lib/sub2api/client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
|
||||
|
||||
const keyword = request.nextUrl.searchParams.get('keyword')?.trim();
|
||||
if (!keyword) {
|
||||
return NextResponse.json({ users: [] });
|
||||
}
|
||||
|
||||
try {
|
||||
const users = await searchUsers(keyword);
|
||||
return NextResponse.json({ users });
|
||||
} catch (error) {
|
||||
console.error('Failed to search users:', error instanceof Error ? error.message : String(error));
|
||||
return NextResponse.json({ error: '搜索用户失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
147
src/app/api/admin/subscription-plans/[id]/route.ts
Normal file
147
src/app/api/admin/subscription-plans/[id]/route.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getGroup } from '@/lib/sub2api/client';
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
const existing = await prisma.subscriptionPlan.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: '订阅套餐不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 确定最终 groupId:如果传了 group_id 用传入值,否则用现有值
|
||||
const finalGroupId =
|
||||
body.group_id !== undefined ? (body.group_id ? Number(body.group_id) : null) : existing.groupId;
|
||||
|
||||
// 必须绑定分组才能保存
|
||||
if (finalGroupId === null || finalGroupId === undefined) {
|
||||
return NextResponse.json({ error: '必须关联一个 Sub2API 分组' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 如果更新了 group_id,检查唯一性
|
||||
if (body.group_id !== undefined && Number(body.group_id) !== existing.groupId) {
|
||||
const conflict = await prisma.subscriptionPlan.findUnique({
|
||||
where: { groupId: Number(body.group_id) },
|
||||
});
|
||||
if (conflict) {
|
||||
return NextResponse.json(
|
||||
{ error: `分组 ID ${body.group_id} 已被套餐「${conflict.name}」使用` },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 校验分组在 Sub2API 中仍然存在
|
||||
const group = await getGroup(finalGroupId);
|
||||
if (!group) {
|
||||
// 分组已被删除,自动解绑
|
||||
await prisma.subscriptionPlan.update({
|
||||
where: { id },
|
||||
data: { groupId: null, forSale: false },
|
||||
});
|
||||
return NextResponse.json({ error: '该分组在 Sub2API 中已被删除,已自动解绑,请重新选择分组' }, { status: 409 });
|
||||
}
|
||||
|
||||
if (body.price !== undefined && (typeof body.price !== 'number' || body.price <= 0 || body.price > 99999999.99)) {
|
||||
return NextResponse.json({ error: 'price 必须是 0.01 ~ 99999999.99 之间的数值' }, { status: 400 });
|
||||
}
|
||||
if (
|
||||
body.original_price !== undefined &&
|
||||
body.original_price !== null &&
|
||||
(typeof body.original_price !== 'number' || body.original_price <= 0 || body.original_price > 99999999.99)
|
||||
) {
|
||||
return NextResponse.json({ error: 'original_price 必须是 0.01 ~ 99999999.99 之间的数值' }, { status: 400 });
|
||||
}
|
||||
if (body.validity_days !== undefined && (!Number.isInteger(body.validity_days) || body.validity_days <= 0)) {
|
||||
return NextResponse.json({ error: 'validity_days 必须是正整数' }, { status: 400 });
|
||||
}
|
||||
if (body.name !== undefined && (typeof body.name !== 'string' || body.name.trim() === '')) {
|
||||
return NextResponse.json({ error: 'name 不能为空' }, { status: 400 });
|
||||
}
|
||||
if (body.name !== undefined && body.name.length > 100) {
|
||||
return NextResponse.json({ error: 'name 不能超过 100 个字符' }, { status: 400 });
|
||||
}
|
||||
if (body.sort_order !== undefined && (!Number.isInteger(body.sort_order) || body.sort_order < 0)) {
|
||||
return NextResponse.json({ error: 'sort_order 必须是非负整数' }, { status: 400 });
|
||||
}
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (body.group_id !== undefined) data.groupId = Number(body.group_id);
|
||||
if (body.name !== undefined) data.name = body.name.trim();
|
||||
if (body.description !== undefined) data.description = body.description;
|
||||
if (body.price !== undefined) data.price = body.price;
|
||||
if (body.original_price !== undefined) data.originalPrice = body.original_price;
|
||||
if (body.validity_days !== undefined) data.validityDays = body.validity_days;
|
||||
if (body.validity_unit !== undefined && ['day', 'week', 'month'].includes(body.validity_unit)) {
|
||||
data.validityUnit = body.validity_unit;
|
||||
}
|
||||
if (body.features !== undefined) data.features = body.features ? JSON.stringify(body.features) : null;
|
||||
if (body.product_name !== undefined) data.productName = body.product_name?.trim() || null;
|
||||
if (body.for_sale !== undefined) data.forSale = body.for_sale;
|
||||
if (body.sort_order !== undefined) data.sortOrder = body.sort_order;
|
||||
|
||||
const plan = await prisma.subscriptionPlan.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
id: plan.id,
|
||||
groupId: plan.groupId != null ? String(plan.groupId) : null,
|
||||
groupName: null,
|
||||
name: plan.name,
|
||||
description: plan.description,
|
||||
price: Number(plan.price),
|
||||
originalPrice: plan.originalPrice ? Number(plan.originalPrice) : null,
|
||||
validDays: plan.validityDays,
|
||||
validityUnit: plan.validityUnit,
|
||||
features: plan.features ? JSON.parse(plan.features) : [],
|
||||
sortOrder: plan.sortOrder,
|
||||
enabled: plan.forSale,
|
||||
productName: plan.productName ?? null,
|
||||
createdAt: plan.createdAt,
|
||||
updatedAt: plan.updatedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update subscription plan:', error);
|
||||
return NextResponse.json({ error: '更新订阅套餐失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await prisma.subscriptionPlan.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return NextResponse.json({ error: '订阅套餐不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 检查是否有活跃订单引用此套餐
|
||||
const activeOrderCount = await prisma.order.count({
|
||||
where: {
|
||||
planId: id,
|
||||
status: { in: ['PENDING', 'PAID', 'RECHARGING'] },
|
||||
},
|
||||
});
|
||||
|
||||
if (activeOrderCount > 0) {
|
||||
return NextResponse.json({ error: `该套餐仍有 ${activeOrderCount} 个活跃订单,无法删除` }, { status: 409 });
|
||||
}
|
||||
|
||||
await prisma.subscriptionPlan.delete({ where: { id } });
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete subscription plan:', error);
|
||||
return NextResponse.json({ error: '删除订阅套餐失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
172
src/app/api/admin/subscription-plans/route.ts
Normal file
172
src/app/api/admin/subscription-plans/route.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getGroup } from '@/lib/sub2api/client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
|
||||
|
||||
try {
|
||||
const plans = await prisma.subscriptionPlan.findMany({
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
// 并发检查每个套餐对应的 Sub2API 分组是否仍然存在,并获取分组名称
|
||||
const results = await Promise.all(
|
||||
plans.map(async (plan) => {
|
||||
let groupExists = false;
|
||||
let groupName: string | null = null;
|
||||
let group: Awaited<ReturnType<typeof getGroup>> | null = null;
|
||||
|
||||
if (plan.groupId !== null) {
|
||||
try {
|
||||
group = await getGroup(plan.groupId);
|
||||
groupExists = group !== null;
|
||||
groupName = group?.name ?? null;
|
||||
} catch {
|
||||
groupExists = false;
|
||||
}
|
||||
|
||||
// 分组已失效:自动清除绑定并下架
|
||||
if (!groupExists) {
|
||||
prisma.subscriptionPlan
|
||||
.update({
|
||||
where: { id: plan.id },
|
||||
data: { groupId: null, forSale: false },
|
||||
})
|
||||
.catch((err) => console.error(`Failed to unbind stale group for plan ${plan.id}:`, err));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: plan.id,
|
||||
groupId: groupExists ? String(plan.groupId) : null,
|
||||
groupName,
|
||||
name: plan.name,
|
||||
description: plan.description,
|
||||
price: Number(plan.price),
|
||||
originalPrice: plan.originalPrice ? Number(plan.originalPrice) : null,
|
||||
validDays: plan.validityDays,
|
||||
validityUnit: plan.validityUnit,
|
||||
features: plan.features ? JSON.parse(plan.features) : [],
|
||||
sortOrder: plan.sortOrder,
|
||||
enabled: groupExists ? plan.forSale : false,
|
||||
groupExists,
|
||||
groupPlatform: group?.platform ?? null,
|
||||
groupRateMultiplier: group?.rate_multiplier ?? null,
|
||||
groupDailyLimit: group?.daily_limit_usd ?? null,
|
||||
groupWeeklyLimit: group?.weekly_limit_usd ?? null,
|
||||
groupMonthlyLimit: group?.monthly_limit_usd ?? null,
|
||||
groupModelScopes: group?.supported_model_scopes ?? null,
|
||||
groupAllowMessagesDispatch: group?.allow_messages_dispatch ?? false,
|
||||
groupDefaultMappedModel: group?.default_mapped_model ?? null,
|
||||
productName: plan.productName ?? null,
|
||||
createdAt: plan.createdAt,
|
||||
updatedAt: plan.updatedAt,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return NextResponse.json({ plans: results });
|
||||
} catch (error) {
|
||||
console.error('Failed to list subscription plans:', error);
|
||||
return NextResponse.json({ error: '获取订阅套餐列表失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const {
|
||||
group_id,
|
||||
name,
|
||||
description,
|
||||
price,
|
||||
original_price,
|
||||
validity_days,
|
||||
validity_unit,
|
||||
features,
|
||||
for_sale,
|
||||
sort_order,
|
||||
product_name,
|
||||
} = body;
|
||||
|
||||
if (!group_id || price === undefined) {
|
||||
return NextResponse.json({ error: '缺少必填字段: group_id, price' }, { status: 400 });
|
||||
}
|
||||
if (typeof name !== 'string' || name.trim() === '') {
|
||||
return NextResponse.json({ error: 'name 不能为空' }, { status: 400 });
|
||||
}
|
||||
if (name.length > 100) {
|
||||
return NextResponse.json({ error: 'name 不能超过 100 个字符' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (typeof price !== 'number' || price <= 0 || price > 99999999.99) {
|
||||
return NextResponse.json({ error: 'price 必须是 0.01 ~ 99999999.99 之间的数值' }, { status: 400 });
|
||||
}
|
||||
if (
|
||||
original_price !== undefined &&
|
||||
original_price !== null &&
|
||||
(typeof original_price !== 'number' || original_price <= 0 || original_price > 99999999.99)
|
||||
) {
|
||||
return NextResponse.json({ error: 'original_price 必须是 0.01 ~ 99999999.99 之间的数值' }, { status: 400 });
|
||||
}
|
||||
if (validity_days !== undefined && (!Number.isInteger(validity_days) || validity_days <= 0)) {
|
||||
return NextResponse.json({ error: 'validity_days 必须是正整数' }, { status: 400 });
|
||||
}
|
||||
if (sort_order !== undefined && (!Number.isInteger(sort_order) || sort_order < 0)) {
|
||||
return NextResponse.json({ error: 'sort_order 必须是非负整数' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 验证 group_id 唯一性
|
||||
const existing = await prisma.subscriptionPlan.findUnique({
|
||||
where: { groupId: Number(group_id) },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({ error: `分组 ID ${group_id} 已被套餐「${existing.name}」使用` }, { status: 409 });
|
||||
}
|
||||
|
||||
const plan = await prisma.subscriptionPlan.create({
|
||||
data: {
|
||||
groupId: Number(group_id),
|
||||
name: name.trim(),
|
||||
description: description ?? null,
|
||||
price,
|
||||
originalPrice: original_price ?? null,
|
||||
validityDays: validity_days ?? 30,
|
||||
validityUnit: ['day', 'week', 'month'].includes(validity_unit) ? validity_unit : 'day',
|
||||
features: features ? JSON.stringify(features) : null,
|
||||
productName: product_name?.trim() || null,
|
||||
forSale: for_sale ?? false,
|
||||
sortOrder: sort_order ?? 0,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
id: plan.id,
|
||||
groupId: String(plan.groupId),
|
||||
groupName: null,
|
||||
name: plan.name,
|
||||
description: plan.description,
|
||||
price: Number(plan.price),
|
||||
originalPrice: plan.originalPrice ? Number(plan.originalPrice) : null,
|
||||
validDays: plan.validityDays,
|
||||
validityUnit: plan.validityUnit,
|
||||
features: plan.features ? JSON.parse(plan.features) : [],
|
||||
sortOrder: plan.sortOrder,
|
||||
enabled: plan.forSale,
|
||||
productName: plan.productName ?? null,
|
||||
createdAt: plan.createdAt,
|
||||
updatedAt: plan.updatedAt,
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to create subscription plan:', error);
|
||||
return NextResponse.json({ error: '创建订阅套餐失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
55
src/app/api/admin/subscriptions/route.ts
Normal file
55
src/app/api/admin/subscriptions/route.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
|
||||
import { getUserSubscriptions, getUser, listSubscriptions } from '@/lib/sub2api/client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
|
||||
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const userId = searchParams.get('user_id');
|
||||
const groupId = searchParams.get('group_id');
|
||||
const status = searchParams.get('status');
|
||||
const page = searchParams.get('page');
|
||||
const pageSize = searchParams.get('page_size');
|
||||
|
||||
if (userId) {
|
||||
// 按用户查询(原有逻辑)
|
||||
const parsedUserId = Number(userId);
|
||||
if (!Number.isFinite(parsedUserId) || parsedUserId <= 0) {
|
||||
return NextResponse.json({ error: '无效的 user_id' }, { status: 400 });
|
||||
}
|
||||
|
||||
const [subscriptions, user] = await Promise.all([
|
||||
getUserSubscriptions(parsedUserId),
|
||||
getUser(parsedUserId).catch(() => null),
|
||||
]);
|
||||
|
||||
const filtered = groupId ? subscriptions.filter((s) => s.group_id === Number(groupId)) : subscriptions;
|
||||
|
||||
return NextResponse.json({
|
||||
subscriptions: filtered,
|
||||
user: user ? { id: user.id, username: user.username, email: user.email } : null,
|
||||
});
|
||||
}
|
||||
|
||||
// 无 user_id 时列出所有订阅
|
||||
const result = await listSubscriptions({
|
||||
group_id: groupId ? Number(groupId) : undefined,
|
||||
status: status || undefined,
|
||||
page: page ? Math.max(1, Number(page)) : undefined,
|
||||
page_size: pageSize ? Math.min(200, Math.max(1, Number(pageSize))) : undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
subscriptions: result.subscriptions,
|
||||
total: result.total,
|
||||
page: result.page,
|
||||
page_size: result.page_size,
|
||||
user: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to query subscriptions:', error);
|
||||
return NextResponse.json({ error: '查询订阅信息失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
56
src/app/api/channels/route.ts
Normal file
56
src/app/api/channels/route.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getCurrentUserByToken } from '@/lib/sub2api/client';
|
||||
import { getGroup } from '@/lib/sub2api/client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const token = request.nextUrl.searchParams.get('token')?.trim();
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: '缺少 token' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
await getCurrentUserByToken(token);
|
||||
} catch {
|
||||
return NextResponse.json({ error: '无效的 token' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const channels = await prisma.channel.findMany({
|
||||
where: { enabled: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
// 并发校验每个渠道对应的 Sub2API 分组是否存在
|
||||
const results = await Promise.all(
|
||||
channels.map(async (ch) => {
|
||||
let groupActive = false;
|
||||
try {
|
||||
const group = await getGroup(ch.groupId);
|
||||
groupActive = group !== null && group.status === 'active';
|
||||
} catch {
|
||||
groupActive = false;
|
||||
}
|
||||
|
||||
if (!groupActive) return null; // 过滤掉分组不存在的渠道
|
||||
|
||||
return {
|
||||
id: ch.id,
|
||||
groupId: ch.groupId,
|
||||
name: ch.name,
|
||||
platform: ch.platform,
|
||||
rateMultiplier: Number(ch.rateMultiplier),
|
||||
description: ch.description,
|
||||
models: ch.models ? JSON.parse(ch.models) : [],
|
||||
features: ch.features ? JSON.parse(ch.features) : [],
|
||||
sortOrder: ch.sortOrder,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return NextResponse.json({ channels: results.filter(Boolean) });
|
||||
} catch (error) {
|
||||
console.error('Failed to list channels:', error);
|
||||
return NextResponse.json({ error: '获取渠道列表失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { queryMethodLimits } from '@/lib/order/limits';
|
||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
import { getNextBizDayStartUTC } from '@/lib/time/biz-day';
|
||||
import { getCurrentUserByToken } from '@/lib/sub2api/client';
|
||||
|
||||
/**
|
||||
* GET /api/limits
|
||||
* 返回各支付渠道今日限额使用情况,公开接口(无需鉴权)。
|
||||
* GET /api/limits?token=xxx
|
||||
* 返回各支付渠道今日限额使用情况。
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
@@ -13,19 +15,25 @@ import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
* wxpay: { dailyLimit: 10000, used: 10000, remaining: 0, available: false },
|
||||
* stripe: { dailyLimit: 0, used: 500, remaining: null, available: true }
|
||||
* },
|
||||
* resetAt: "2026-03-02T00:00:00.000Z" // UTC 次日零点(限额重置时间)
|
||||
* resetAt: "2026-03-02T16:00:00.000Z" // 业务时区(Asia/Shanghai)次日零点对应的 UTC 时间
|
||||
* }
|
||||
*/
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
const token = request.nextUrl.searchParams.get('token')?.trim();
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'token is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
await getCurrentUserByToken(token);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
initPaymentProviders();
|
||||
const types = paymentRegistry.getSupportedTypes();
|
||||
|
||||
const todayStart = new Date();
|
||||
todayStart.setUTCHours(0, 0, 0, 0);
|
||||
const resetAt = new Date(todayStart);
|
||||
resetAt.setUTCDate(resetAt.getUTCDate() + 1);
|
||||
|
||||
const methods = await queryMethodLimits(types);
|
||||
const resetAt = getNextBizDayStartUTC();
|
||||
|
||||
return NextResponse.json({ methods, resetAt });
|
||||
}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { verifyAdminToken } from '@/lib/admin-auth';
|
||||
import { deriveOrderState } from '@/lib/order/status';
|
||||
import { ORDER_STATUS_ACCESS_QUERY_KEY, verifyOrderStatusAccessToken } from '@/lib/order/status-access';
|
||||
|
||||
/**
|
||||
* 订单状态轮询接口 — 仅返回 status / expiresAt 两个字段。
|
||||
* 订单状态轮询接口。
|
||||
*
|
||||
* 安全考虑:
|
||||
* - 订单 ID 使用 CUID(25 位随机字符),具有足够的不可预测性,
|
||||
* 暴力猜测的成本远高于信息价值。
|
||||
* - 仅暴露 status 和 expiresAt,不涉及用户隐私或金额信息。
|
||||
* - 前端 PaymentQRCode 组件每 2 秒轮询此接口以更新支付状态,
|
||||
* 添加认证会增加不必要的复杂度且影响轮询性能。
|
||||
* 返回最小必要信息供前端判断:
|
||||
* - 原始订单状态(status / expiresAt)
|
||||
* - 支付是否成功(paymentSuccess)
|
||||
* - 充值是否成功 / 当前充值阶段(rechargeSuccess / rechargeStatus)
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const accessToken = request.nextUrl.searchParams.get(ORDER_STATUS_ACCESS_QUERY_KEY);
|
||||
const isAuthorized = verifyOrderStatusAccessToken(id, accessToken) || (await verifyAdminToken(request));
|
||||
|
||||
if (!isAuthorized) {
|
||||
return NextResponse.json({ error: '未授权访问该订单状态' }, { status: 401 });
|
||||
}
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id },
|
||||
@@ -20,6 +27,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
id: true,
|
||||
status: true,
|
||||
expiresAt: true,
|
||||
paidAt: true,
|
||||
completedAt: true,
|
||||
failedReason: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -27,9 +37,15 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
||||
return NextResponse.json({ error: '订单不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
const derived = deriveOrderState(order);
|
||||
|
||||
return NextResponse.json({
|
||||
id: order.id,
|
||||
status: order.status,
|
||||
expiresAt: order.expiresAt,
|
||||
paymentSuccess: derived.paymentSuccess,
|
||||
rechargeSuccess: derived.rechargeSuccess,
|
||||
rechargeStatus: derived.rechargeStatus,
|
||||
failedReason: order.failedReason ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,16 +3,30 @@ import { z } from 'zod';
|
||||
import { createOrder } from '@/lib/order/service';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import { paymentRegistry } from '@/lib/payment';
|
||||
import { getEnabledPaymentTypes } from '@/lib/payment/resolve-enabled-types';
|
||||
import { getCurrentUserByToken } from '@/lib/sub2api/client';
|
||||
import { handleApiError } from '@/lib/utils/api';
|
||||
|
||||
const createOrderSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
amount: z.number().positive(),
|
||||
amount: z.number().positive().max(99999999.99),
|
||||
payment_type: z.string().min(1),
|
||||
src_host: z.string().max(253).optional(),
|
||||
src_url: z.string().max(2048).optional(),
|
||||
src_url: z
|
||||
.string()
|
||||
.max(2048)
|
||||
.refine((url) => {
|
||||
try {
|
||||
const protocol = new URL(url).protocol;
|
||||
return protocol === 'http:' || protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, 'src_url must be a valid HTTP/HTTPS URL')
|
||||
.optional(),
|
||||
is_mobile: z.boolean().optional(),
|
||||
order_type: z.enum(['balance', 'subscription']).optional(),
|
||||
plan_id: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
@@ -25,7 +39,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: '参数错误', details: parsed.error.flatten().fieldErrors }, { status: 400 });
|
||||
}
|
||||
|
||||
const { token, amount, payment_type, src_host, src_url, is_mobile } = parsed.data;
|
||||
const { token, amount, payment_type, src_host, src_url, is_mobile, order_type, plan_id } = parsed.data;
|
||||
|
||||
// 通过 token 解析用户身份
|
||||
let userId: number;
|
||||
@@ -36,16 +50,19 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: '无效的 token,请重新登录', code: 'INVALID_TOKEN' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Validate amount range
|
||||
// 订阅订单跳过金额范围校验(价格由服务端套餐决定)
|
||||
if (order_type !== 'subscription') {
|
||||
if (amount < env.MIN_RECHARGE_AMOUNT || amount > env.MAX_RECHARGE_AMOUNT) {
|
||||
return NextResponse.json(
|
||||
{ error: `充值金额需在 ${env.MIN_RECHARGE_AMOUNT} - ${env.MAX_RECHARGE_AMOUNT} 之间` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate payment type is enabled
|
||||
if (!paymentRegistry.getSupportedTypes().includes(payment_type)) {
|
||||
// Validate payment type is enabled (registry + ENABLED_PAYMENT_TYPES config)
|
||||
const enabledTypes = await getEnabledPaymentTypes();
|
||||
if (!enabledTypes.includes(payment_type)) {
|
||||
return NextResponse.json({ error: `不支持的支付方式: ${payment_type}` }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -60,6 +77,8 @@ export async function POST(request: NextRequest) {
|
||||
isMobile: is_mobile,
|
||||
srcHost: src_host,
|
||||
srcUrl: src_url,
|
||||
orderType: order_type,
|
||||
planId: plan_id,
|
||||
});
|
||||
|
||||
// 不向客户端暴露 userName / userBalance 等隐私字段
|
||||
|
||||
77
src/app/api/subscription-plans/route.ts
Normal file
77
src/app/api/subscription-plans/route.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { getCurrentUserByToken, getGroup } from '@/lib/sub2api/client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const token = request.nextUrl.searchParams.get('token')?.trim();
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: '缺少 token' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
await getCurrentUserByToken(token);
|
||||
} catch {
|
||||
return NextResponse.json({ error: '无效的 token' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const plans = await prisma.subscriptionPlan.findMany({
|
||||
where: { forSale: true },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
|
||||
// 并发校验每个套餐对应的 Sub2API 分组是否存在
|
||||
const results = await Promise.all(
|
||||
plans.map(async (plan) => {
|
||||
if (plan.groupId === null) return null;
|
||||
|
||||
let groupActive = false;
|
||||
let group: Awaited<ReturnType<typeof getGroup>> = null;
|
||||
let groupInfo: {
|
||||
daily_limit_usd: number | null;
|
||||
weekly_limit_usd: number | null;
|
||||
monthly_limit_usd: number | null;
|
||||
} | null = null;
|
||||
try {
|
||||
group = await getGroup(plan.groupId);
|
||||
groupActive = group !== null && group.status === 'active';
|
||||
if (group) {
|
||||
groupInfo = {
|
||||
daily_limit_usd: group.daily_limit_usd,
|
||||
weekly_limit_usd: group.weekly_limit_usd,
|
||||
monthly_limit_usd: group.monthly_limit_usd,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
groupActive = false;
|
||||
}
|
||||
|
||||
if (!groupActive) return null;
|
||||
|
||||
return {
|
||||
id: plan.id,
|
||||
groupId: plan.groupId,
|
||||
groupName: group?.name ?? null,
|
||||
name: plan.name,
|
||||
description: plan.description,
|
||||
price: Number(plan.price),
|
||||
originalPrice: plan.originalPrice ? Number(plan.originalPrice) : null,
|
||||
validityDays: plan.validityDays,
|
||||
validityUnit: plan.validityUnit,
|
||||
features: plan.features ? JSON.parse(plan.features) : [],
|
||||
productName: plan.productName ?? null,
|
||||
platform: group?.platform ?? null,
|
||||
rateMultiplier: group?.rate_multiplier ?? null,
|
||||
limits: groupInfo,
|
||||
allowMessagesDispatch: group?.allow_messages_dispatch ?? false,
|
||||
defaultMappedModel: group?.default_mapped_model ?? null,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return NextResponse.json({ plans: results.filter(Boolean) });
|
||||
} catch (error) {
|
||||
console.error('Failed to list subscription plans:', error);
|
||||
return NextResponse.json({ error: '获取订阅套餐失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
37
src/app/api/subscriptions/my/route.ts
Normal file
37
src/app/api/subscriptions/my/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCurrentUserByToken, getUserSubscriptions, getAllGroups } from '@/lib/sub2api/client';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const token = request.nextUrl.searchParams.get('token')?.trim();
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: '缺少 token' }, { status: 401 });
|
||||
}
|
||||
|
||||
let userId: number;
|
||||
try {
|
||||
const user = await getCurrentUserByToken(token);
|
||||
userId = user.id;
|
||||
} catch {
|
||||
return NextResponse.json({ error: '无效的 token' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const [subscriptions, groups] = await Promise.all([getUserSubscriptions(userId), getAllGroups().catch(() => [])]);
|
||||
|
||||
const groupMap = new Map(groups.map((g) => [g.id, g]));
|
||||
|
||||
const enriched = subscriptions.map((sub) => {
|
||||
const group = groupMap.get(sub.group_id);
|
||||
return {
|
||||
...sub,
|
||||
group_name: group?.name ?? null,
|
||||
platform: group?.platform ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return NextResponse.json({ subscriptions: enriched });
|
||||
} catch (error) {
|
||||
console.error('Failed to get user subscriptions:', error);
|
||||
return NextResponse.json({ error: '获取订阅信息失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { queryMethodLimits } from '@/lib/order/limits';
|
||||
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
|
||||
import { getPaymentDisplayInfo } from '@/lib/pay-utils';
|
||||
import { resolveLocale } from '@/lib/locale';
|
||||
import { getSystemConfig } from '@/lib/system-config';
|
||||
import { resolveEnabledPaymentTypes } from '@/lib/payment/resolve-enabled-types';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const locale = resolveLocale(request.nextUrl.searchParams.get('lang'));
|
||||
@@ -15,7 +17,10 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const token = request.nextUrl.searchParams.get('token')?.trim();
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: locale === 'en' ? 'Missing token parameter' : '缺少 token 参数' }, { status: 401 });
|
||||
return NextResponse.json(
|
||||
{ error: locale === 'en' ? 'Missing token parameter' : '缺少 token 参数' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -28,13 +33,27 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (tokenUser.id !== userId) {
|
||||
return NextResponse.json({ error: locale === 'en' ? 'Forbidden to access this user' : '无权访问该用户信息' }, { status: 403 });
|
||||
return NextResponse.json(
|
||||
{ error: locale === 'en' ? 'Forbidden to access this user' : '无权访问该用户信息' },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
const env = getEnv();
|
||||
initPaymentProviders();
|
||||
const enabledTypes = paymentRegistry.getSupportedTypes();
|
||||
const [user, methodLimits] = await Promise.all([getUser(userId), queryMethodLimits(enabledTypes)]);
|
||||
const supportedTypes = paymentRegistry.getSupportedTypes();
|
||||
|
||||
// getUser 与 config 查询并行;config 完成后立即启动 queryMethodLimits
|
||||
const configPromise = Promise.all([
|
||||
getSystemConfig('ENABLED_PAYMENT_TYPES'),
|
||||
getSystemConfig('BALANCE_PAYMENT_DISABLED'),
|
||||
]).then(async ([configuredPaymentTypesRaw, balanceDisabledVal]) => {
|
||||
const enabledTypes = resolveEnabledPaymentTypes(supportedTypes, configuredPaymentTypesRaw);
|
||||
const methodLimits = await queryMethodLimits(enabledTypes);
|
||||
return { enabledTypes, methodLimits, balanceDisabled: balanceDisabledVal === 'true' };
|
||||
});
|
||||
|
||||
const [user, { enabledTypes, methodLimits, balanceDisabled }] = await Promise.all([getUser(userId), configPromise]);
|
||||
|
||||
// 收集 sublabel 覆盖
|
||||
const sublabelOverrides: Record<string, string> = {};
|
||||
@@ -77,9 +96,8 @@ export async function GET(request: NextRequest) {
|
||||
helpImageUrl: env.PAY_HELP_IMAGE_URL ?? null,
|
||||
helpText: env.PAY_HELP_TEXT ?? null,
|
||||
stripePublishableKey:
|
||||
enabledTypes.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY
|
||||
? env.STRIPE_PUBLISHABLE_KEY
|
||||
: null,
|
||||
enabledTypes.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY ? env.STRIPE_PUBLISHABLE_KEY : null,
|
||||
balanceDisabled,
|
||||
sublabelOverrides: Object.keys(sublabelOverrides).length > 0 ? sublabelOverrides : null,
|
||||
},
|
||||
});
|
||||
@@ -89,6 +107,9 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: locale === 'en' ? 'User not found' : '用户不存在' }, { status: 404 });
|
||||
}
|
||||
console.error('Get user error:', error);
|
||||
return NextResponse.json({ error: locale === 'en' ? 'Failed to fetch user info' : '获取用户信息失败' }, { status: 500 });
|
||||
return NextResponse.json(
|
||||
{ error: locale === 'en' ? 'Failed to fetch user info' : '获取用户信息失败' },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getUser } from '@/lib/sub2api/client';
|
||||
import { getUser, getCurrentUserByToken } from '@/lib/sub2api/client';
|
||||
|
||||
// 仅返回用户是否存在,不暴露私隐信息(用户名/邮箱/余额需 token 验证)
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const token = searchParams.get('token')?.trim();
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'token is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
let currentUser: { id: number };
|
||||
try {
|
||||
currentUser = await getCurrentUserByToken(token);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const userId = Number(id);
|
||||
|
||||
@@ -10,6 +23,11 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
|
||||
return NextResponse.json({ error: 'Invalid user id' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 只允许查询自身用户信息,防止 IDOR 用户枚举
|
||||
if (userId !== currentUser.id) {
|
||||
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await getUser(userId);
|
||||
return NextResponse.json({ id: user.id, exists: true });
|
||||
|
||||
@@ -22,15 +22,11 @@ export async function POST(request: NextRequest) {
|
||||
return Response.json({ code: 'SUCCESS', message: '成功' });
|
||||
}
|
||||
const success = await handlePaymentNotify(notification, provider.name);
|
||||
return Response.json(
|
||||
success ? { code: 'SUCCESS', message: '成功' } : { code: 'FAIL', message: '处理失败' },
|
||||
{ status: success ? 200 : 500 },
|
||||
);
|
||||
return Response.json(success ? { code: 'SUCCESS', message: '成功' } : { code: 'FAIL', message: '处理失败' }, {
|
||||
status: success ? 200 : 500,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Wxpay notify error:', error);
|
||||
return Response.json(
|
||||
{ code: 'FAIL', message: '处理失败' },
|
||||
{ status: 500 },
|
||||
);
|
||||
return Response.json({ code: 'FAIL', message: '处理失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,61 @@
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: system-ui, -apple-system, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
'PingFang SC',
|
||||
'Hiragino Sans GB',
|
||||
'Microsoft YaHei',
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
/* Scrollbar - Light theme (default) */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 #f1f5f9;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-corner {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
/* Scrollbar - Dark theme */
|
||||
[data-theme='dark'],
|
||||
[data-theme='dark'] * {
|
||||
scrollbar-color: #475569 #1e293b;
|
||||
}
|
||||
|
||||
[data-theme='dark'] *::-webkit-scrollbar-track {
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
[data-theme='dark'] *::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
[data-theme='dark'] *::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
[data-theme='dark'] *::-webkit-scrollbar-corner {
|
||||
background: #1e293b;
|
||||
}
|
||||
|
||||
302
src/app/pay/[orderId]/route.ts
Normal file
302
src/app/pay/[orderId]/route.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { ORDER_STATUS } from '@/lib/constants';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import { buildAlipayPaymentUrl } from '@/lib/alipay/provider';
|
||||
import { deriveOrderState, getOrderDisplayState, type OrderStatusLike } from '@/lib/order/status';
|
||||
import { buildOrderResultUrl } from '@/lib/order/status-access';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const MOBILE_UA_PATTERN = /AlipayClient|Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i;
|
||||
const ALIPAY_APP_UA_PATTERN = /AlipayClient/i;
|
||||
|
||||
type ShortLinkOrderStatus = OrderStatusLike & { id: string };
|
||||
|
||||
function getUserAgent(request: NextRequest): string {
|
||||
return request.headers.get('user-agent') || '';
|
||||
}
|
||||
|
||||
function isMobileRequest(request: NextRequest): boolean {
|
||||
return MOBILE_UA_PATTERN.test(getUserAgent(request));
|
||||
}
|
||||
|
||||
function isAlipayAppRequest(request: NextRequest): boolean {
|
||||
return ALIPAY_APP_UA_PATTERN.test(getUserAgent(request));
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function buildAppUrl(pathname = '/'): string {
|
||||
return new URL(pathname, getEnv().NEXT_PUBLIC_APP_URL).toString();
|
||||
}
|
||||
|
||||
function buildResultUrl(orderId: string): string {
|
||||
return buildOrderResultUrl(getEnv().NEXT_PUBLIC_APP_URL, orderId);
|
||||
}
|
||||
|
||||
function serializeScriptString(value: string): string {
|
||||
return JSON.stringify(value).replace(/</g, '\\u003c');
|
||||
}
|
||||
|
||||
function getStatusDisplay(order: OrderStatusLike) {
|
||||
return getOrderDisplayState({
|
||||
status: order.status,
|
||||
...deriveOrderState(order),
|
||||
});
|
||||
}
|
||||
|
||||
function renderHtml(title: string, body: string, headExtra = ''): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
<title>${escapeHtml(title)}</title>
|
||||
${headExtra}
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: linear-gradient(180deg, #f5faff 0%, #eef6ff 100%);
|
||||
color: #0f172a;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 28px 24px;
|
||||
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.12);
|
||||
text-align: center;
|
||||
}
|
||||
.icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin: 0 auto 18px;
|
||||
border-radius: 18px;
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
font-size: 30px;
|
||||
line-height: 60px;
|
||||
font-weight: 700;
|
||||
}
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
p {
|
||||
margin: 12px 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
color: #475569;
|
||||
}
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
margin-top: 20px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.button.secondary {
|
||||
margin-top: 12px;
|
||||
background: #eff6ff;
|
||||
color: #1677ff;
|
||||
}
|
||||
.spinner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin: 18px auto 0;
|
||||
border-radius: 9999px;
|
||||
border: 3px solid rgba(22, 119, 255, 0.18);
|
||||
border-top-color: #1677ff;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
.order {
|
||||
margin-top: 18px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.hint {
|
||||
margin-top: 16px;
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
.text-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 14px;
|
||||
color: #1677ff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
.text-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${body}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderErrorPage(title: string, message: string, orderId?: string, status = 400): NextResponse {
|
||||
const html = renderHtml(
|
||||
title,
|
||||
`<main class="card">
|
||||
<div class="icon">!</div>
|
||||
<h1>${escapeHtml(title)}</h1>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
${orderId ? `<div class="order">订单号:${escapeHtml(orderId)}</div>` : ''}
|
||||
<a class="button secondary" href="${escapeHtml(buildAppUrl('/'))}">返回支付首页</a>
|
||||
</main>`,
|
||||
);
|
||||
|
||||
return new NextResponse(html, {
|
||||
status,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderStatusPage(order: ShortLinkOrderStatus): NextResponse {
|
||||
const display = getStatusDisplay(order);
|
||||
const html = renderHtml(
|
||||
display.label,
|
||||
`<main class="card">
|
||||
<div class="icon">${escapeHtml(display.icon)}</div>
|
||||
<h1>${escapeHtml(display.label)}</h1>
|
||||
<p>${escapeHtml(display.message)}</p>
|
||||
<div class="order">订单号:${escapeHtml(order.id)}</div>
|
||||
<a class="button secondary" href="${escapeHtml(buildResultUrl(order.id))}">查看订单结果</a>
|
||||
</main>`,
|
||||
);
|
||||
|
||||
return new NextResponse(html, {
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function renderRedirectPage(orderId: string, payUrl: string): NextResponse {
|
||||
const html = renderHtml(
|
||||
'正在跳转支付宝',
|
||||
`<main class="card">
|
||||
<div class="icon">支</div>
|
||||
<h1>正在拉起支付宝</h1>
|
||||
<p>请稍候,系统正在自动跳转到支付宝完成支付。</p>
|
||||
<div class="spinner"></div>
|
||||
<div class="order">订单号:${escapeHtml(orderId)}</div>
|
||||
<p class="hint">如未自动拉起支付宝,请返回原充值页后重新发起支付。</p>
|
||||
<a class="text-link" href="${escapeHtml(buildResultUrl(orderId))}">已支付?查看订单结果</a>
|
||||
<script>
|
||||
const payUrl = ${serializeScriptString(payUrl)};
|
||||
window.location.replace(payUrl);
|
||||
setTimeout(() => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
window.location.replace(payUrl);
|
||||
}
|
||||
}, 800);
|
||||
</script>
|
||||
</main>`,
|
||||
`<noscript><meta http-equiv="refresh" content="0;url=${escapeHtml(payUrl)}" /></noscript>`,
|
||||
);
|
||||
|
||||
return new NextResponse(html, {
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ orderId: string }> }) {
|
||||
const { orderId } = await params;
|
||||
|
||||
const order = await prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
select: {
|
||||
id: true,
|
||||
amount: true,
|
||||
payAmount: true,
|
||||
paymentType: true,
|
||||
status: true,
|
||||
expiresAt: true,
|
||||
paidAt: true,
|
||||
completedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!order) {
|
||||
return renderErrorPage('订单不存在', '未找到对应订单,请确认二维码是否正确', orderId, 404);
|
||||
}
|
||||
|
||||
if (order.paymentType !== 'alipay_direct') {
|
||||
return renderErrorPage('支付方式不匹配', '该订单不是支付宝直连订单,无法通过当前链接支付', orderId, 400);
|
||||
}
|
||||
|
||||
if (order.status !== ORDER_STATUS.PENDING) {
|
||||
return renderStatusPage(order);
|
||||
}
|
||||
|
||||
if (order.expiresAt.getTime() <= Date.now()) {
|
||||
return renderStatusPage({
|
||||
id: order.id,
|
||||
status: ORDER_STATUS.EXPIRED,
|
||||
paidAt: order.paidAt,
|
||||
completedAt: order.completedAt,
|
||||
});
|
||||
}
|
||||
|
||||
const payAmount = Number(order.payAmount ?? order.amount);
|
||||
if (!Number.isFinite(payAmount) || payAmount <= 0) {
|
||||
return renderErrorPage('订单金额异常', '订单金额无效,请返回原页面重新发起支付', order.id, 500);
|
||||
}
|
||||
|
||||
const env = getEnv();
|
||||
const payUrl = buildAlipayPaymentUrl({
|
||||
orderId: order.id,
|
||||
amount: payAmount,
|
||||
subject: `${env.PRODUCT_NAME} ${payAmount.toFixed(2)} CNY`,
|
||||
notifyUrl: env.ALIPAY_NOTIFY_URL,
|
||||
returnUrl: isAlipayAppRequest(request) ? null : buildResultUrl(order.id),
|
||||
isMobile: isMobileRequest(request),
|
||||
});
|
||||
|
||||
return renderRedirectPage(order.id, payUrl);
|
||||
}
|
||||
@@ -30,8 +30,16 @@ function OrdersContent() {
|
||||
|
||||
const text = {
|
||||
missingAuth: pickLocaleText(locale, '缺少认证信息', 'Missing authentication information'),
|
||||
visitOrders: pickLocaleText(locale, '请从 Sub2API 平台正确访问订单页面', 'Please open the orders page from Sub2API'),
|
||||
sessionExpired: pickLocaleText(locale, '登录态已失效,请从 Sub2API 重新进入支付页。', 'Session expired. Please re-enter from Sub2API.'),
|
||||
visitOrders: pickLocaleText(
|
||||
locale,
|
||||
'请从 Sub2API 平台正确访问订单页面',
|
||||
'Please open the orders page from Sub2API',
|
||||
),
|
||||
sessionExpired: pickLocaleText(
|
||||
locale,
|
||||
'登录态已失效,请从 Sub2API 重新进入支付页。',
|
||||
'Session expired. Please re-enter from Sub2API.',
|
||||
),
|
||||
loadFailed: pickLocaleText(locale, '订单加载失败,请稍后重试。', 'Failed to load orders. Please try again later.'),
|
||||
networkError: pickLocaleText(locale, '网络错误,请稍后重试。', 'Network error. Please try again later.'),
|
||||
switchingMobileTab: pickLocaleText(locale, '正在切换到移动端订单 Tab...', 'Switching to mobile orders tab...'),
|
||||
@@ -40,7 +48,11 @@ function OrdersContent() {
|
||||
backToPay: pickLocaleText(locale, '返回充值', 'Back to Top Up'),
|
||||
loading: pickLocaleText(locale, '加载中...', 'Loading...'),
|
||||
userPrefix: pickLocaleText(locale, '用户', 'User'),
|
||||
authError: pickLocaleText(locale, '缺少认证信息,请从 Sub2API 平台正确访问订单页面', 'Missing authentication information. Please open the orders page from Sub2API.'),
|
||||
authError: pickLocaleText(
|
||||
locale,
|
||||
'缺少认证信息,请从 Sub2API 平台正确访问订单页面',
|
||||
'Missing authentication information. Please open the orders page from Sub2API.',
|
||||
),
|
||||
};
|
||||
|
||||
const [isIframeContext, setIsIframeContext] = useState(true);
|
||||
@@ -165,7 +177,7 @@ function OrdersContent() {
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">{text.missingAuth}</p>
|
||||
<p className="mt-2 text-sm text-gray-500">{text.visitOrders}</p>
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.visitOrders}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -226,10 +238,13 @@ function OrdersContent() {
|
||||
function OrdersPageFallback() {
|
||||
const searchParams = useSearchParams();
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
const isDark = searchParams.get('theme') === 'dark';
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">{pickLocaleText(locale, '加载中...', 'Loading...')}</div>
|
||||
<div className={`flex min-h-screen items-center justify-center ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className={isDark ? 'text-slate-400' : 'text-gray-500'}>
|
||||
{pickLocaleText(locale, '加载中...', 'Loading...')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useState, useEffect, Suspense, useCallback } from 'react';
|
||||
import PaymentForm from '@/components/PaymentForm';
|
||||
import PaymentQRCode from '@/components/PaymentQRCode';
|
||||
import OrderStatus from '@/components/OrderStatus';
|
||||
import PayPageLayout from '@/components/PayPageLayout';
|
||||
import MobileOrderList from '@/components/MobileOrderList';
|
||||
import MainTabs from '@/components/MainTabs';
|
||||
import ChannelGrid from '@/components/ChannelGrid';
|
||||
import SubscriptionPlanCard from '@/components/SubscriptionPlanCard';
|
||||
import SubscriptionConfirm from '@/components/SubscriptionConfirm';
|
||||
import UserSubscriptions from '@/components/UserSubscriptions';
|
||||
import PurchaseFlow from '@/components/PurchaseFlow';
|
||||
import { resolveLocale, pickLocaleText, applyLocaleToSearchParams } from '@/lib/locale';
|
||||
import { detectDeviceIsMobile, applySublabelOverrides, type UserInfo, type MyOrder } from '@/lib/pay-utils';
|
||||
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
|
||||
import type { MethodLimitInfo } from '@/components/PaymentForm';
|
||||
import type { ChannelInfo } from '@/components/ChannelGrid';
|
||||
import type { PlanInfo } from '@/components/SubscriptionPlanCard';
|
||||
import type { UserSub } from '@/components/UserSubscriptions';
|
||||
|
||||
interface OrderResult {
|
||||
orderId: string;
|
||||
@@ -21,6 +31,7 @@ interface OrderResult {
|
||||
qrCode?: string | null;
|
||||
clientSecret?: string | null;
|
||||
expiresAt: string;
|
||||
statusAccessToken: string;
|
||||
}
|
||||
|
||||
interface AppConfig {
|
||||
@@ -32,6 +43,7 @@ interface AppConfig {
|
||||
helpImageUrl?: string | null;
|
||||
helpText?: string | null;
|
||||
stripePublishableKey?: string | null;
|
||||
balanceDisabled?: boolean;
|
||||
}
|
||||
|
||||
function PayContent() {
|
||||
@@ -50,8 +62,9 @@ function PayContent() {
|
||||
const [step, setStep] = useState<'form' | 'paying' | 'result'>('form');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [subscriptionError, setSubscriptionError] = useState('');
|
||||
const [orderResult, setOrderResult] = useState<OrderResult | null>(null);
|
||||
const [finalStatus, setFinalStatus] = useState('');
|
||||
const [finalOrderState, setFinalOrderState] = useState<PublicOrderStatusSnapshot | null>(null);
|
||||
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
|
||||
const [resolvedUserId, setResolvedUserId] = useState<number | null>(null);
|
||||
const [myOrders, setMyOrders] = useState<MyOrder[]>([]);
|
||||
@@ -61,6 +74,16 @@ function PayContent() {
|
||||
const [activeMobileTab, setActiveMobileTab] = useState<'pay' | 'orders'>('pay');
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
|
||||
// 新增状态
|
||||
const [mainTab, setMainTab] = useState<'topup' | 'subscribe'>('topup');
|
||||
const [channels, setChannels] = useState<ChannelInfo[]>([]);
|
||||
const [plans, setPlans] = useState<PlanInfo[]>([]);
|
||||
const [userSubscriptions, setUserSubscriptions] = useState<UserSub[]>([]);
|
||||
const [showTopUpForm, setShowTopUpForm] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState<PlanInfo | null>(null);
|
||||
const [channelsLoaded, setChannelsLoaded] = useState(false);
|
||||
const [userLoaded, setUserLoaded] = useState(false);
|
||||
|
||||
const [config, setConfig] = useState<AppConfig>({
|
||||
enabledPaymentTypes: [],
|
||||
minAmount: 1,
|
||||
@@ -75,12 +98,53 @@ function PayContent() {
|
||||
const helpImageUrl = (config.helpImageUrl || '').trim();
|
||||
const helpText = (config.helpText || '').trim();
|
||||
const hasHelpContent = Boolean(helpImageUrl || helpText);
|
||||
|
||||
// 通用帮助/客服信息区块
|
||||
const renderHelpSection = () => {
|
||||
if (!hasHelpContent) return null;
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'mt-6 rounded-2xl border p-4',
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['text-xs font-medium', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{pickLocaleText(locale, '帮助', 'Support')}
|
||||
</div>
|
||||
{helpImageUrl && (
|
||||
<img
|
||||
src={helpImageUrl}
|
||||
alt="help"
|
||||
onClick={() => setHelpImageOpen(true)}
|
||||
className={`mt-3 max-h-40 w-full cursor-zoom-in rounded-lg object-contain p-2 ${isDark ? 'bg-slate-700/50' : 'bg-white/70'}`}
|
||||
/>
|
||||
)}
|
||||
{helpText && (
|
||||
<div className={['mt-3 space-y-1 text-sm leading-6', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
{helpText.split('\n').map((line, i) => (
|
||||
<p key={i}>{line}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MAX_PENDING = 3;
|
||||
const pendingBlocked = pendingCount >= MAX_PENDING;
|
||||
|
||||
// R6: 余额充值是否被禁用
|
||||
const balanceDisabled = config.balanceDisabled === true;
|
||||
// 是否有渠道配置(决定是直接显示充值表单还是渠道卡片+弹窗)
|
||||
const hasChannels = channels.length > 0;
|
||||
// 是否有可售卖套餐
|
||||
const hasPlans = plans.length > 0;
|
||||
// 是否可以充值(未禁用且有支付方式)
|
||||
const canTopUp = !balanceDisabled && config.enabledPaymentTypes.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
setIsIframeContext(window.self !== window.top);
|
||||
setIsMobile(detectDeviceIsMobile());
|
||||
}, []);
|
||||
@@ -94,9 +158,8 @@ function PayContent() {
|
||||
setActiveMobileTab('pay');
|
||||
}, [isMobile, step, tab]);
|
||||
|
||||
const loadUserAndOrders = async () => {
|
||||
const loadUserAndOrders = useCallback(async () => {
|
||||
if (!token) return;
|
||||
|
||||
setUserNotFound(false);
|
||||
try {
|
||||
const meRes = await fetch(`/api/orders/my?token=${encodeURIComponent(token)}`);
|
||||
@@ -148,6 +211,7 @@ function PayContent() {
|
||||
helpImageUrl: cfgData.config.helpImageUrl ?? null,
|
||||
helpText: cfgData.config.helpText ?? null,
|
||||
stripePublishableKey: cfgData.config.stripePublishableKey ?? null,
|
||||
balanceDisabled: cfgData.config.balanceDisabled ?? false,
|
||||
});
|
||||
if (cfgData.config.sublabelOverrides) {
|
||||
applySublabelOverrides(cfgData.config.sublabelOverrides);
|
||||
@@ -155,8 +219,38 @@ function PayContent() {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
} finally {
|
||||
setUserLoaded(true);
|
||||
}
|
||||
};
|
||||
}, [token, locale]);
|
||||
|
||||
// 加载渠道和订阅套餐
|
||||
const loadChannelsAndPlans = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const [chRes, plRes, subRes] = await Promise.all([
|
||||
fetch(`/api/channels?token=${encodeURIComponent(token)}`),
|
||||
fetch(`/api/subscription-plans?token=${encodeURIComponent(token)}`),
|
||||
fetch(`/api/subscriptions/my?token=${encodeURIComponent(token)}`),
|
||||
]);
|
||||
|
||||
if (chRes.ok) {
|
||||
const chData = await chRes.json();
|
||||
setChannels(chData.channels ?? []);
|
||||
}
|
||||
if (plRes.ok) {
|
||||
const plData = await plRes.json();
|
||||
setPlans(plData.plans ?? []);
|
||||
}
|
||||
if (subRes.ok) {
|
||||
const subData = await subRes.json();
|
||||
setUserSubscriptions(subData.subscriptions ?? []);
|
||||
}
|
||||
} catch {
|
||||
} finally {
|
||||
setChannelsLoaded(true);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const loadMoreOrders = async () => {
|
||||
if (!token || ordersLoadingMore || !ordersHasMore) return;
|
||||
@@ -181,27 +275,49 @@ function PayContent() {
|
||||
|
||||
useEffect(() => {
|
||||
loadUserAndOrders();
|
||||
}, [token, locale]);
|
||||
loadChannelsAndPlans();
|
||||
}, [loadUserAndOrders, loadChannelsAndPlans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (step !== 'result' || finalStatus !== 'COMPLETED') return;
|
||||
if (step !== 'result' || finalOrderState?.status !== 'COMPLETED') return;
|
||||
loadUserAndOrders();
|
||||
loadChannelsAndPlans();
|
||||
const timer = setTimeout(() => {
|
||||
setStep('form');
|
||||
setOrderResult(null);
|
||||
setFinalStatus('');
|
||||
setFinalOrderState(null);
|
||||
setError('');
|
||||
setSubscriptionError('');
|
||||
setSelectedPlan(null);
|
||||
}, 2200);
|
||||
return () => clearTimeout(timer);
|
||||
}, [step, finalStatus]);
|
||||
}, [step, finalOrderState, loadUserAndOrders, loadChannelsAndPlans]);
|
||||
|
||||
// 检查订单完成后是否是订阅分组消失的情况
|
||||
useEffect(() => {
|
||||
if (step !== 'result' || !finalOrderState) return;
|
||||
if (finalOrderState.status === 'FAILED' && finalOrderState.failedReason?.includes('SUBSCRIPTION_GROUP_GONE')) {
|
||||
setSubscriptionError(
|
||||
pickLocaleText(
|
||||
locale,
|
||||
'您已成功支付,但订阅分组已下架,无法自动开通。请联系客服处理,提供订单号。',
|
||||
'Payment successful, but the subscription group has been removed. Please contact support with your order ID.',
|
||||
),
|
||||
);
|
||||
}
|
||||
}, [step, finalOrderState, locale]);
|
||||
|
||||
if (!hasToken) {
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">{pickLocaleText(locale, '缺少认证信息', 'Missing authentication info')}</p>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
{pickLocaleText(locale, '请从 Sub2API 平台正确访问充值页面', 'Please open the recharge page from the Sub2API platform')}
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{pickLocaleText(
|
||||
locale,
|
||||
'请从 Sub2API 平台正确访问充值页面',
|
||||
'Please open the recharge page from the Sub2API platform',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -213,8 +329,12 @@ function PayContent() {
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className="text-center text-red-500">
|
||||
<p className="text-lg font-medium">{pickLocaleText(locale, '用户不存在', 'User not found')}</p>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
{pickLocaleText(locale, '请检查链接是否正确,或联系管理员', 'Please check whether the link is correct or contact the administrator')}
|
||||
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{pickLocaleText(
|
||||
locale,
|
||||
'请检查链接是否正确,或联系管理员',
|
||||
'Please check whether the link is correct or contact the administrator',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,6 +357,7 @@ function PayContent() {
|
||||
const mobileOrdersUrl = buildScopedUrl('/pay', true);
|
||||
const ordersUrl = isMobile ? mobileOrdersUrl : pcOrdersUrl;
|
||||
|
||||
// ── 余额充值提交 ──
|
||||
const handleSubmit = async (amount: number, paymentType: string) => {
|
||||
if (pendingBlocked) {
|
||||
setError(
|
||||
@@ -270,15 +391,17 @@ function PayContent() {
|
||||
|
||||
if (!res.ok) {
|
||||
const codeMessages: Record<string, string> = {
|
||||
INVALID_TOKEN: pickLocaleText(locale, '认证已失效,请重新从平台进入充值页面', 'Authentication expired. Please re-enter the recharge page from the platform'),
|
||||
USER_INACTIVE: pickLocaleText(locale, '账户已被禁用,无法充值,请联系管理员', 'This account is disabled and cannot be recharged. Please contact the administrator'),
|
||||
TOO_MANY_PENDING: pickLocaleText(locale, '您有过多待支付订单,请先完成或取消现有订单后再试', 'You have too many pending orders. Please complete or cancel existing orders first'),
|
||||
USER_NOT_FOUND: pickLocaleText(locale, '用户不存在,请检查链接是否正确', 'User not found. Please check whether the link is correct'),
|
||||
INVALID_TOKEN: pickLocaleText(locale, '认证已失效,请重新从平台进入充值页面', 'Authentication expired'),
|
||||
USER_INACTIVE: pickLocaleText(locale, '账户已被禁用,无法充值', 'Account is disabled'),
|
||||
TOO_MANY_PENDING: pickLocaleText(locale, '待支付订单过多,请先处理', 'Too many pending orders'),
|
||||
USER_NOT_FOUND: pickLocaleText(locale, '用户不存在', 'User not found'),
|
||||
DAILY_LIMIT_EXCEEDED: data.error,
|
||||
METHOD_DAILY_LIMIT_EXCEEDED: data.error,
|
||||
PAYMENT_GATEWAY_ERROR: data.error,
|
||||
};
|
||||
setError(codeMessages[data.code] || data.error || pickLocaleText(locale, '创建订单失败', 'Failed to create order'));
|
||||
setError(
|
||||
codeMessages[data.code] || data.error || pickLocaleText(locale, '创建订单失败', 'Failed to create order'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -292,45 +415,108 @@ function PayContent() {
|
||||
qrCode: data.qrCode,
|
||||
clientSecret: data.clientSecret,
|
||||
expiresAt: data.expiresAt,
|
||||
statusAccessToken: data.statusAccessToken,
|
||||
});
|
||||
|
||||
setStep('paying');
|
||||
} catch {
|
||||
setError(pickLocaleText(locale, '网络错误,请稍后重试', 'Network error. Please try again later'));
|
||||
setError(pickLocaleText(locale, '网络错误,请稍后重试', 'Network error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = (status: string) => {
|
||||
setFinalStatus(status);
|
||||
setStep('result');
|
||||
if (isMobile) {
|
||||
setActiveMobileTab('orders');
|
||||
// ── 订阅下单 ──
|
||||
const handleSubscriptionSubmit = async (paymentType: string) => {
|
||||
if (!selectedPlan) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/orders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
amount: selectedPlan.price,
|
||||
payment_type: paymentType,
|
||||
is_mobile: isMobile,
|
||||
src_host: srcHost,
|
||||
src_url: srcUrl,
|
||||
order_type: 'subscription',
|
||||
plan_id: selectedPlan.id,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error || pickLocaleText(locale, '创建订阅订单失败', 'Failed to create subscription order'));
|
||||
return;
|
||||
}
|
||||
|
||||
setOrderResult({
|
||||
orderId: data.orderId,
|
||||
amount: data.amount,
|
||||
payAmount: data.payAmount,
|
||||
status: data.status,
|
||||
paymentType: data.paymentType || paymentType,
|
||||
payUrl: data.payUrl,
|
||||
qrCode: data.qrCode,
|
||||
clientSecret: data.clientSecret,
|
||||
expiresAt: data.expiresAt,
|
||||
statusAccessToken: data.statusAccessToken,
|
||||
});
|
||||
setStep('paying');
|
||||
} catch {
|
||||
setError(pickLocaleText(locale, '网络错误,请稍后重试', 'Network error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = (order: PublicOrderStatusSnapshot) => {
|
||||
setFinalOrderState(order);
|
||||
setStep('result');
|
||||
if (isMobile) setActiveMobileTab('orders');
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setStep('form');
|
||||
setOrderResult(null);
|
||||
setFinalStatus('');
|
||||
setFinalOrderState(null);
|
||||
setError('');
|
||||
setSubscriptionError('');
|
||||
setSelectedPlan(null);
|
||||
setShowTopUpForm(false);
|
||||
};
|
||||
|
||||
// ── 渲染 ──
|
||||
// R7: 检查是否所有入口都关闭(无可用充值方式 且 无订阅套餐)
|
||||
const allEntriesClosed = channelsLoaded && userLoaded && !canTopUp && !hasPlans;
|
||||
const showMainTabs = channelsLoaded && userLoaded && !allEntriesClosed && (hasChannels || hasPlans);
|
||||
const pageTitle = showMainTabs
|
||||
? pickLocaleText(locale, '选择适合你的 充值/订阅服务', 'Choose Your Recharge / Subscription')
|
||||
: pickLocaleText(locale, 'Sub2API 余额充值', 'Sub2API Balance Recharge');
|
||||
const pageSubtitle = showMainTabs
|
||||
? pickLocaleText(locale, '充值余额或者订阅套餐', 'Top up balance or subscribe to a plan')
|
||||
: pickLocaleText(locale, '安全支付,自动到账', 'Secure payment, automatic crediting');
|
||||
|
||||
return (
|
||||
<PayPageLayout
|
||||
isDark={isDark}
|
||||
isEmbedded={isEmbedded}
|
||||
maxWidth={isMobile ? 'sm' : 'lg'}
|
||||
title={pickLocaleText(locale, 'Sub2API 余额充值', 'Sub2API Balance Recharge')}
|
||||
subtitle={pickLocaleText(locale, '安全支付,自动到账', 'Secure payment, automatic crediting')}
|
||||
maxWidth={showMainTabs ? 'full' : isMobile ? 'sm' : 'lg'}
|
||||
title={pageTitle}
|
||||
subtitle={pageSubtitle}
|
||||
locale={locale}
|
||||
actions={
|
||||
!isMobile ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadUserAndOrders}
|
||||
onClick={() => {
|
||||
loadUserAndOrders();
|
||||
loadChannelsAndPlans();
|
||||
}}
|
||||
className={[
|
||||
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
isDark
|
||||
@@ -355,6 +541,24 @@ function PayContent() {
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{/* 订阅分组消失的常驻错误 */}
|
||||
{subscriptionError && (
|
||||
<div
|
||||
className={[
|
||||
'mb-4 rounded-lg border-2 p-4 text-sm',
|
||||
isDark ? 'border-red-600 bg-red-900/40 text-red-300' : 'border-red-400 bg-red-50 text-red-700',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="font-semibold mb-1">{pickLocaleText(locale, '订阅开通失败', 'Subscription Failed')}</div>
|
||||
<div>{subscriptionError}</div>
|
||||
{orderResult && (
|
||||
<div className="mt-2 text-xs opacity-80">
|
||||
{pickLocaleText(locale, '订单号', 'Order ID')}: {orderResult.orderId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className={[
|
||||
@@ -366,7 +570,11 @@ function PayContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'form' && isMobile && (
|
||||
{/* ── 表单阶段 ── */}
|
||||
{step === 'form' && (
|
||||
<>
|
||||
{/* 移动端 Tab:充值/订单 */}
|
||||
{isMobile && (
|
||||
<div
|
||||
className={[
|
||||
'mb-4 grid grid-cols-2 rounded-xl border p-1',
|
||||
@@ -408,7 +616,8 @@ function PayContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'form' && config.enabledPaymentTypes.length === 0 && (
|
||||
{/* 加载中 */}
|
||||
{(!channelsLoaded || !userLoaded) && !allEntriesClosed && (
|
||||
<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(' ')}>
|
||||
@@ -417,7 +626,272 @@ function PayContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'form' && config.enabledPaymentTypes.length > 0 && (
|
||||
{/* R7: 所有入口关闭提示 */}
|
||||
{allEntriesClosed && (activeMobileTab === 'pay' || !isMobile) && (
|
||||
<div
|
||||
className={[
|
||||
'rounded-2xl border p-8 text-center',
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white shadow-sm',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['text-4xl mb-4'].join(' ')}>
|
||||
<svg
|
||||
className={['mx-auto h-12 w-12', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className={['text-lg font-medium mb-2', isDark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
{pickLocaleText(locale, '充值/订阅 入口未开放', 'Recharge / Subscription entry is not available')}
|
||||
</p>
|
||||
<p className={['text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{pickLocaleText(
|
||||
locale,
|
||||
'如有疑问,请联系管理员',
|
||||
'Please contact the administrator if you have questions',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 有渠道配置:新版UI ── */}
|
||||
{channelsLoaded &&
|
||||
showMainTabs &&
|
||||
(activeMobileTab === 'pay' || !isMobile) &&
|
||||
!selectedPlan &&
|
||||
!showTopUpForm && (
|
||||
<>
|
||||
<MainTabs
|
||||
activeTab={!canTopUp ? 'subscribe' : mainTab}
|
||||
onTabChange={setMainTab}
|
||||
showSubscribeTab={hasPlans}
|
||||
showTopUpTab={canTopUp}
|
||||
isDark={isDark}
|
||||
locale={locale}
|
||||
/>
|
||||
|
||||
{mainTab === 'topup' && canTopUp && (
|
||||
<div className="mt-6">
|
||||
{/* 按量付费说明 banner */}
|
||||
<div
|
||||
className={[
|
||||
'mb-6 rounded-2xl border p-6',
|
||||
isDark
|
||||
? 'border-emerald-500/20 bg-gradient-to-r from-emerald-500/10 to-purple-500/10'
|
||||
: 'border-emerald-500/20 bg-gradient-to-r from-emerald-50 to-purple-50',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div
|
||||
className={[
|
||||
'flex-shrink-0 rounded-lg p-2',
|
||||
isDark ? 'bg-emerald-500/20' : 'bg-emerald-500/15',
|
||||
].join(' ')}
|
||||
>
|
||||
<svg
|
||||
className="h-6 w-6 text-emerald-500"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3
|
||||
className={[
|
||||
'text-lg font-semibold mb-2',
|
||||
isDark ? 'text-emerald-400' : 'text-emerald-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{pickLocaleText(locale, '按量付费模式', 'Pay-as-you-go')}
|
||||
</h3>
|
||||
<p className={['text-sm mb-4', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{pickLocaleText(
|
||||
locale,
|
||||
'无需订阅,充值即用,按实际消耗扣费。余额所有渠道通用,可自由切换。价格以美元计价(当前比例:1美元≈1人民币)',
|
||||
'No subscription needed. Top up and use. Charged by actual usage. Balance works across all channels. Priced in USD (current rate: 1 USD ≈ 1 CNY)',
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4 text-sm">
|
||||
<div
|
||||
className={['flex items-center gap-2', isDark ? 'text-slate-400' : 'text-slate-500'].join(
|
||||
' ',
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4 text-green-500"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18" />
|
||||
<polyline points="17 6 23 6 23 12" />
|
||||
</svg>
|
||||
<span>{pickLocaleText(locale, '倍率越低越划算', 'Lower rate = better value')}</span>
|
||||
</div>
|
||||
<div
|
||||
className={['flex items-center gap-2', isDark ? 'text-slate-400' : 'text-slate-500'].join(
|
||||
' ',
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4 text-blue-500"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
<span>
|
||||
{pickLocaleText(
|
||||
locale,
|
||||
'0.15倍率 = 1元可用约6.67美元额度',
|
||||
'0.15 rate = 1 CNY ≈ $6.67 quota',
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasChannels ? (
|
||||
<ChannelGrid
|
||||
channels={channels}
|
||||
onTopUp={() => setShowTopUpForm(true)}
|
||||
isDark={isDark}
|
||||
locale={locale}
|
||||
userBalance={userInfo?.balance}
|
||||
/>
|
||||
) : (
|
||||
<PaymentForm
|
||||
userId={resolvedUserId ?? 0}
|
||||
userName={userInfo?.username}
|
||||
userBalance={userInfo?.balance}
|
||||
enabledPaymentTypes={config.enabledPaymentTypes}
|
||||
methodLimits={config.methodLimits}
|
||||
minAmount={config.minAmount}
|
||||
maxAmount={config.maxAmount}
|
||||
onSubmit={handleSubmit}
|
||||
loading={loading}
|
||||
dark={isDark}
|
||||
pendingBlocked={pendingBlocked}
|
||||
pendingCount={pendingCount}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{renderHelpSection()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mainTab === 'subscribe' && (
|
||||
<div className="mt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{plans.map((plan) => (
|
||||
<SubscriptionPlanCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
onSubscribe={() => setSelectedPlan(plan)}
|
||||
isDark={isDark}
|
||||
locale={locale}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{renderHelpSection()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 用户已有订阅 — 所有 tab 共用 */}
|
||||
{userSubscriptions.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h3
|
||||
className={['text-lg font-semibold mb-3', isDark ? 'text-slate-200' : 'text-slate-800'].join(' ')}
|
||||
>
|
||||
{pickLocaleText(locale, '我的订阅', 'My Subscriptions')}
|
||||
</h3>
|
||||
<UserSubscriptions
|
||||
subscriptions={userSubscriptions}
|
||||
onRenew={(groupId) => {
|
||||
const plan = plans.find((p) => p.groupId === groupId);
|
||||
if (plan) {
|
||||
setSelectedPlan(plan);
|
||||
setMainTab('subscribe');
|
||||
}
|
||||
}}
|
||||
isDark={isDark}
|
||||
locale={locale}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PurchaseFlow isDark={isDark} locale={locale} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 点击"立即充值"后:直接显示 PaymentForm(含金额选择) */}
|
||||
{showTopUpForm && step === 'form' && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTopUpForm(false)}
|
||||
className={[
|
||||
'mb-4 flex items-center gap-1 text-sm transition-colors',
|
||||
isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700',
|
||||
].join(' ')}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{pickLocaleText(locale, '返回', 'Back')}
|
||||
</button>
|
||||
<PaymentForm
|
||||
userId={resolvedUserId ?? 0}
|
||||
userName={userInfo?.username}
|
||||
userBalance={userInfo?.balance}
|
||||
enabledPaymentTypes={config.enabledPaymentTypes}
|
||||
methodLimits={config.methodLimits}
|
||||
minAmount={config.minAmount}
|
||||
maxAmount={config.maxAmount}
|
||||
onSubmit={handleSubmit}
|
||||
loading={loading}
|
||||
dark={isDark}
|
||||
pendingBlocked={pendingBlocked}
|
||||
pendingCount={pendingCount}
|
||||
locale={locale}
|
||||
/>
|
||||
{renderHelpSection()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 订阅确认页 */}
|
||||
{selectedPlan && step === 'form' && (
|
||||
<>
|
||||
<SubscriptionConfirm
|
||||
plan={selectedPlan}
|
||||
paymentTypes={config.enabledPaymentTypes}
|
||||
onBack={() => setSelectedPlan(null)}
|
||||
onSubmit={handleSubscriptionSubmit}
|
||||
loading={loading}
|
||||
isDark={isDark}
|
||||
locale={locale}
|
||||
/>
|
||||
{renderHelpSection()}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 无渠道配置:传统充值UI ── */}
|
||||
{channelsLoaded && userLoaded && !showMainTabs && canTopUp && !selectedPlan && (
|
||||
<>
|
||||
{isMobile ? (
|
||||
activeMobileTab === 'pay' ? (
|
||||
@@ -477,17 +951,23 @@ function PayContent() {
|
||||
<div className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{pickLocaleText(locale, '支付说明', 'Payment Notes')}
|
||||
</div>
|
||||
<ul className={['mt-2 space-y-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
<li>{pickLocaleText(locale, '订单完成后会自动到账', 'Balance will be credited automatically after the order completes')}</li>
|
||||
<li>{pickLocaleText(locale, '如需历史记录请查看「我的订单」', 'Check "My Orders" for payment history')}</li>
|
||||
<ul
|
||||
className={['mt-2 space-y-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}
|
||||
>
|
||||
<li>
|
||||
{pickLocaleText(locale, '订单完成后会自动到账', 'Balance will be credited automatically')}
|
||||
</li>
|
||||
<li>
|
||||
{pickLocaleText(locale, '如需历史记录请查看「我的订单」', 'Check "My Orders" for history')}
|
||||
</li>
|
||||
{config.maxDailyAmount > 0 && (
|
||||
<li>
|
||||
{pickLocaleText(locale, '每日最大充值', 'Maximum daily recharge')} ¥{config.maxDailyAmount.toFixed(2)}
|
||||
{pickLocaleText(locale, '每日最大充值', 'Max daily recharge')} ¥
|
||||
{config.maxDailyAmount.toFixed(2)}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{hasHelpContent && (
|
||||
<div
|
||||
className={[
|
||||
@@ -503,7 +983,7 @@ function PayContent() {
|
||||
src={helpImageUrl}
|
||||
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 p-2 ${isDark ? 'bg-slate-700/50' : 'bg-white/70'}`}
|
||||
/>
|
||||
)}
|
||||
{helpText && (
|
||||
@@ -526,7 +1006,25 @@ function PayContent() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 移动端订单列表 */}
|
||||
{isMobile && activeMobileTab === 'orders' && showMainTabs && (
|
||||
<MobileOrderList
|
||||
isDark={isDark}
|
||||
hasToken={hasToken}
|
||||
orders={myOrders}
|
||||
hasMore={ordersHasMore}
|
||||
loadingMore={ordersLoadingMore}
|
||||
onRefresh={loadUserAndOrders}
|
||||
onLoadMore={loadMoreOrders}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── 支付阶段 ── */}
|
||||
{step === 'paying' && orderResult && (
|
||||
<>
|
||||
<PaymentQRCode
|
||||
orderId={orderResult.orderId}
|
||||
token={token || undefined}
|
||||
@@ -538,6 +1036,7 @@ function PayContent() {
|
||||
amount={orderResult.amount}
|
||||
payAmount={orderResult.payAmount}
|
||||
expiresAt={orderResult.expiresAt}
|
||||
statusAccessToken={orderResult.statusAccessToken}
|
||||
onStatusChange={handleStatusChange}
|
||||
onBack={handleBack}
|
||||
dark={isDark}
|
||||
@@ -545,10 +1044,24 @@ function PayContent() {
|
||||
isMobile={isMobile}
|
||||
locale={locale}
|
||||
/>
|
||||
{renderHelpSection()}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'result' && <OrderStatus status={finalStatus} onBack={handleBack} dark={isDark} locale={locale} />}
|
||||
{/* ── 结果阶段 ── */}
|
||||
{step === 'result' && orderResult && finalOrderState && (
|
||||
<OrderStatus
|
||||
orderId={orderResult.orderId}
|
||||
order={finalOrderState}
|
||||
statusAccessToken={orderResult.statusAccessToken}
|
||||
onStateChange={setFinalOrderState}
|
||||
onBack={handleBack}
|
||||
dark={isDark}
|
||||
locale={locale}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 帮助图片放大 */}
|
||||
{helpImageOpen && helpImageUrl && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 p-4 backdrop-blur-sm"
|
||||
@@ -569,19 +1082,19 @@ function PayContent() {
|
||||
function PayPageFallback() {
|
||||
const searchParams = useSearchParams();
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
|
||||
const isDark = searchParams.get('theme') === 'dark';
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">{pickLocaleText(locale, '加载中...', 'Loading...')}</div>
|
||||
<div className={`flex min-h-screen items-center justify-center ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className={isDark ? 'text-slate-400' : 'text-gray-500'}>
|
||||
{pickLocaleText(locale, '加载中...', 'Loading...')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PayPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={<PayPageFallback />}
|
||||
>
|
||||
<Suspense fallback={<PayPageFallback />}>
|
||||
<PayContent />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -2,11 +2,195 @@
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState, Suspense } from 'react';
|
||||
import { applyLocaleToSearchParams, pickLocaleText, resolveLocale } from '@/lib/locale';
|
||||
import { applyLocaleToSearchParams, pickLocaleText, resolveLocale, type Locale } from '@/lib/locale';
|
||||
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
|
||||
import { buildOrderStatusUrl } from '@/lib/order/status-url';
|
||||
|
||||
type WindowWithAlipayBridge = Window & {
|
||||
AlipayJSBridge?: {
|
||||
call: (name: string, params?: unknown, callback?: (...args: unknown[]) => void) => void;
|
||||
};
|
||||
};
|
||||
|
||||
function tryCloseViaAlipayBridge(): boolean {
|
||||
const bridge = (window as WindowWithAlipayBridge).AlipayJSBridge;
|
||||
if (!bridge?.call) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
bridge.call('closeWebview');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function closeCurrentWindow() {
|
||||
if (tryCloseViaAlipayBridge()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let settled = false;
|
||||
const handleBridgeReady = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
document.removeEventListener('AlipayJSBridgeReady', handleBridgeReady);
|
||||
if (!tryCloseViaAlipayBridge()) {
|
||||
window.close();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('AlipayJSBridgeReady', handleBridgeReady, { once: true });
|
||||
window.setTimeout(() => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
document.removeEventListener('AlipayJSBridgeReady', handleBridgeReady);
|
||||
window.close();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function getStatusConfig(
|
||||
order: PublicOrderStatusSnapshot | null,
|
||||
locale: Locale,
|
||||
hasAccessToken: boolean,
|
||||
isDark = false,
|
||||
) {
|
||||
if (!order) {
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Payment Error',
|
||||
color: isDark ? 'text-red-400' : 'text-red-600',
|
||||
icon: '✗',
|
||||
message: hasAccessToken
|
||||
? 'Unable to load the order status. Please try again later.'
|
||||
: 'Missing order access token. Please go back to the recharge page.',
|
||||
}
|
||||
: {
|
||||
label: '支付异常',
|
||||
color: isDark ? 'text-red-400' : 'text-red-600',
|
||||
icon: '✗',
|
||||
message: hasAccessToken ? '未查询到订单状态,请稍后重试。' : '订单访问凭证缺失,请返回原充值页查看订单结果。',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.rechargeSuccess) {
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Recharge Successful',
|
||||
color: isDark ? 'text-green-400' : 'text-green-600',
|
||||
icon: '✓',
|
||||
message: 'Your balance has been credited successfully.',
|
||||
}
|
||||
: {
|
||||
label: '充值成功',
|
||||
color: isDark ? 'text-green-400' : 'text-green-600',
|
||||
icon: '✓',
|
||||
message: '余额已成功到账!',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.paymentSuccess) {
|
||||
if (order.rechargeStatus === 'paid_pending' || order.rechargeStatus === 'recharging') {
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Top-up Processing',
|
||||
color: isDark ? 'text-blue-400' : 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: 'Payment succeeded, and the balance top-up is being processed.',
|
||||
}
|
||||
: {
|
||||
label: '充值处理中',
|
||||
color: isDark ? 'text-blue-400' : 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: '支付成功,余额正在充值中...',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.rechargeStatus === 'failed') {
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Payment Successful',
|
||||
color: isDark ? 'text-amber-400' : 'text-amber-600',
|
||||
icon: '!',
|
||||
message:
|
||||
'Payment succeeded, but the balance top-up has not completed yet. Please check again later or contact the administrator.',
|
||||
}
|
||||
: {
|
||||
label: '支付成功',
|
||||
color: isDark ? 'text-amber-400' : 'text-amber-600',
|
||||
icon: '!',
|
||||
message: '支付成功,但余额充值暂未完成,请稍后查看订单结果或联系管理员。',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (order.status === 'PENDING') {
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Awaiting Payment',
|
||||
color: isDark ? 'text-yellow-400' : 'text-yellow-600',
|
||||
icon: '⏳',
|
||||
message: 'The order has not been paid yet.',
|
||||
}
|
||||
: {
|
||||
label: '等待支付',
|
||||
color: isDark ? 'text-yellow-400' : 'text-yellow-600',
|
||||
icon: '⏳',
|
||||
message: '订单尚未完成支付。',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.status === 'EXPIRED') {
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Order Expired',
|
||||
color: isDark ? 'text-slate-400' : 'text-gray-500',
|
||||
icon: '⏰',
|
||||
message: 'This order has expired. Please create a new order.',
|
||||
}
|
||||
: {
|
||||
label: '订单已超时',
|
||||
color: isDark ? 'text-slate-400' : 'text-gray-500',
|
||||
icon: '⏰',
|
||||
message: '订单已超时,请重新充值。',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.status === 'CANCELLED') {
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Order Cancelled',
|
||||
color: isDark ? 'text-slate-400' : 'text-gray-500',
|
||||
icon: '✗',
|
||||
message: 'This order has been cancelled.',
|
||||
}
|
||||
: {
|
||||
label: '订单已取消',
|
||||
color: isDark ? 'text-slate-400' : 'text-gray-500',
|
||||
icon: '✗',
|
||||
message: '订单已被取消。',
|
||||
};
|
||||
}
|
||||
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Payment Error',
|
||||
color: isDark ? 'text-red-400' : 'text-red-600',
|
||||
icon: '✗',
|
||||
message: 'Please contact the administrator.',
|
||||
}
|
||||
: { label: '支付异常', color: isDark ? 'text-red-400' : 'text-red-600', icon: '✗', message: '请联系管理员处理。' };
|
||||
}
|
||||
|
||||
function ResultContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const outTradeNo = searchParams.get('out_trade_no') || searchParams.get('order_id');
|
||||
const accessToken = searchParams.get('access_token');
|
||||
const isPopup = searchParams.get('popup') === '1';
|
||||
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
@@ -14,30 +198,16 @@ function ResultContent() {
|
||||
|
||||
const text = {
|
||||
checking: pickLocaleText(locale, '查询支付结果中...', 'Checking payment result...'),
|
||||
success: pickLocaleText(locale, '充值成功', 'Top-up successful'),
|
||||
processing: pickLocaleText(locale, '充值处理中', 'Top-up processing'),
|
||||
successMessage: pickLocaleText(locale, '余额已成功到账!', 'Balance has been credited successfully!'),
|
||||
processingMessage: pickLocaleText(locale, '支付成功,余额正在充值中...', 'Payment succeeded, balance is being credited...'),
|
||||
returning: pickLocaleText(locale, '正在返回...', 'Returning...'),
|
||||
returnNow: pickLocaleText(locale, '立即返回', 'Return now'),
|
||||
pending: pickLocaleText(locale, '等待支付', 'Awaiting payment'),
|
||||
pendingMessage: pickLocaleText(locale, '订单尚未完成支付', 'The order has not been paid yet'),
|
||||
expired: pickLocaleText(locale, '订单已超时', 'Order expired'),
|
||||
cancelled: pickLocaleText(locale, '订单已取消', 'Order cancelled'),
|
||||
abnormal: pickLocaleText(locale, '支付异常', 'Payment error'),
|
||||
expiredMessage: pickLocaleText(locale, '订单已超时,请重新充值', 'This order has expired. Please create a new one.'),
|
||||
cancelledMessage: pickLocaleText(locale, '订单已被取消', 'This order has been cancelled.'),
|
||||
abnormalMessage: pickLocaleText(locale, '请联系管理员处理', 'Please contact the administrator.'),
|
||||
back: pickLocaleText(locale, '返回', 'Back'),
|
||||
closeSoon: pickLocaleText(locale, '此窗口将在 3 秒后自动关闭', 'This window will close automatically in 3 seconds'),
|
||||
closeNow: pickLocaleText(locale, '立即关闭窗口', 'Close now'),
|
||||
orderId: pickLocaleText(locale, '订单号', 'Order ID'),
|
||||
unknown: pickLocaleText(locale, '未知', 'Unknown'),
|
||||
loading: pickLocaleText(locale, '加载中...', 'Loading...'),
|
||||
};
|
||||
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [orderState, setOrderState] = useState<PublicOrderStatusSnapshot | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isInPopup, setIsInPopup] = useState(false);
|
||||
const [countdown, setCountdown] = useState(5);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPopup || window.opener) {
|
||||
@@ -46,17 +216,17 @@ function ResultContent() {
|
||||
}, [isPopup]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!outTradeNo) {
|
||||
if (!outTradeNo || !accessToken || accessToken.length < 10) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const checkOrder = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/orders/${outTradeNo}`);
|
||||
const res = await fetch(buildOrderStatusUrl(outTradeNo, accessToken));
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setStatus(data.status);
|
||||
const data = (await res.json()) as PublicOrderStatusSnapshot;
|
||||
setOrderState(data);
|
||||
}
|
||||
} catch {
|
||||
} finally {
|
||||
@@ -71,13 +241,13 @@ function ResultContent() {
|
||||
clearInterval(timer);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [outTradeNo]);
|
||||
}, [outTradeNo, accessToken]);
|
||||
|
||||
const isSuccess = status === 'COMPLETED' || status === 'PAID' || status === 'RECHARGING';
|
||||
const shouldAutoClose = Boolean(orderState?.paymentSuccess);
|
||||
|
||||
const goBack = () => {
|
||||
if (isInPopup) {
|
||||
window.close();
|
||||
closeCurrentWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -93,20 +263,12 @@ function ResultContent() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuccess) return;
|
||||
setCountdown(5);
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer);
|
||||
goBack();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [isSuccess, isInPopup]);
|
||||
if (!isInPopup || !shouldAutoClose) return;
|
||||
const timer = setTimeout(() => {
|
||||
closeCurrentWindow();
|
||||
}, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [isInPopup, shouldAutoClose]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -116,8 +278,7 @@ function ResultContent() {
|
||||
);
|
||||
}
|
||||
|
||||
const isPending = status === 'PENDING';
|
||||
const countdownText = countdown > 0 ? pickLocaleText(locale, `${countdown} 秒后自动返回`, `${countdown} seconds before returning`) : text.returning;
|
||||
const display = getStatusConfig(orderState, locale, Boolean(accessToken), isDark);
|
||||
|
||||
return (
|
||||
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
@@ -127,58 +288,31 @@ function ResultContent() {
|
||||
isDark ? 'bg-slate-900 text-slate-100' : 'bg-white',
|
||||
].join(' ')}
|
||||
>
|
||||
{isSuccess ? (
|
||||
<>
|
||||
<div className="text-6xl text-green-500">✓</div>
|
||||
<h1 className="mt-4 text-xl font-bold text-green-600">{status === 'COMPLETED' ? text.success : text.processing}</h1>
|
||||
<p className={isDark ? 'mt-2 text-slate-400' : 'mt-2 text-gray-500'}>
|
||||
{status === 'COMPLETED' ? text.successMessage : text.processingMessage}
|
||||
</p>
|
||||
<div className={`text-6xl ${display.color}`}>{display.icon}</div>
|
||||
<h1 className={`mt-4 text-xl font-bold ${display.color}`}>{display.label}</h1>
|
||||
<p className={isDark ? 'mt-2 text-slate-400' : 'mt-2 text-gray-500'}>{display.message}</p>
|
||||
|
||||
{isInPopup ? (
|
||||
shouldAutoClose && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className={isDark ? 'text-sm text-slate-500' : 'text-sm text-gray-400'}>{countdownText}</p>
|
||||
<p className={isDark ? 'text-sm text-slate-500' : 'text-sm text-gray-400'}>{text.closeSoon}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
className="text-sm text-blue-600 underline hover:text-blue-700"
|
||||
onClick={closeCurrentWindow}
|
||||
className={`text-sm underline ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
|
||||
>
|
||||
{text.returnNow}
|
||||
{text.closeNow}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : isPending ? (
|
||||
<>
|
||||
<div className="text-6xl text-yellow-500">⏳</div>
|
||||
<h1 className="mt-4 text-xl font-bold text-yellow-600">{text.pending}</h1>
|
||||
<p className={isDark ? 'mt-2 text-slate-400' : 'mt-2 text-gray-500'}>{text.pendingMessage}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
className="mt-4 text-sm text-blue-600 underline hover:text-blue-700"
|
||||
>
|
||||
{text.back}
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className="text-6xl text-red-500">✗</div>
|
||||
<h1 className="mt-4 text-xl font-bold text-red-600">
|
||||
{status === 'EXPIRED' ? text.expired : status === 'CANCELLED' ? text.cancelled : text.abnormal}
|
||||
</h1>
|
||||
<p className={isDark ? 'mt-2 text-slate-400' : 'mt-2 text-gray-500'}>
|
||||
{status === 'EXPIRED'
|
||||
? text.expiredMessage
|
||||
: status === 'CANCELLED'
|
||||
? text.cancelledMessage
|
||||
: text.abnormalMessage}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goBack}
|
||||
className="mt-4 text-sm text-blue-600 underline hover:text-blue-700"
|
||||
className={`mt-4 text-sm underline ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
|
||||
>
|
||||
{text.back}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className={isDark ? 'mt-4 text-xs text-slate-500' : 'mt-4 text-xs text-gray-400'}>
|
||||
@@ -192,10 +326,13 @@ function ResultContent() {
|
||||
function ResultPageFallback() {
|
||||
const searchParams = useSearchParams();
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
const isDark = searchParams.get('theme') === 'dark';
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-50">
|
||||
<div className="text-gray-500">{pickLocaleText(locale, '加载中...', 'Loading...')}</div>
|
||||
<div className={`flex min-h-screen items-center justify-center ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className={isDark ? 'text-slate-400' : 'text-gray-500'}>
|
||||
{pickLocaleText(locale, '加载中...', 'Loading...')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ function StripePopupContent() {
|
||||
const amount = parseFloat(searchParams.get('amount') || '0') || 0;
|
||||
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
|
||||
const method = searchParams.get('method') || '';
|
||||
const accessToken = searchParams.get('access_token');
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
const isDark = theme === 'dark';
|
||||
const isAlipay = method === 'alipay';
|
||||
@@ -18,12 +19,20 @@ function StripePopupContent() {
|
||||
const text = {
|
||||
init: pickLocaleText(locale, '正在初始化...', 'Initializing...'),
|
||||
orderId: pickLocaleText(locale, '订单号', 'Order ID'),
|
||||
loadFailed: pickLocaleText(locale, '支付组件加载失败,请关闭窗口重试', 'Failed to load payment component. Please close the window and try again.'),
|
||||
loadFailed: pickLocaleText(
|
||||
locale,
|
||||
'支付组件加载失败,请关闭窗口重试',
|
||||
'Failed to load payment component. Please close the window and try again.',
|
||||
),
|
||||
payFailed: pickLocaleText(locale, '支付失败,请重试', 'Payment failed. Please try again.'),
|
||||
closeWindow: pickLocaleText(locale, '关闭窗口', 'Close window'),
|
||||
redirecting: pickLocaleText(locale, '正在跳转到支付页面...', 'Redirecting to payment page...'),
|
||||
loadingForm: pickLocaleText(locale, '正在加载支付表单...', 'Loading payment form...'),
|
||||
successClosing: pickLocaleText(locale, '支付成功,窗口即将自动关闭...', 'Payment successful. This window will close automatically...'),
|
||||
successClosing: pickLocaleText(
|
||||
locale,
|
||||
'支付成功,窗口即将自动关闭...',
|
||||
'Payment successful. This window will close automatically...',
|
||||
),
|
||||
closeWindowManually: pickLocaleText(locale, '手动关闭窗口', 'Close window manually'),
|
||||
processing: pickLocaleText(locale, '处理中...', 'Processing...'),
|
||||
payAmount: pickLocaleText(locale, `支付 ¥${amount.toFixed(2)}`, `Pay ¥${amount.toFixed(2)}`),
|
||||
@@ -50,9 +59,12 @@ function StripePopupContent() {
|
||||
returnUrl.searchParams.set('status', 'success');
|
||||
returnUrl.searchParams.set('popup', '1');
|
||||
returnUrl.searchParams.set('theme', theme);
|
||||
if (accessToken) {
|
||||
returnUrl.searchParams.set('access_token', accessToken);
|
||||
}
|
||||
applyLocaleToSearchParams(returnUrl.searchParams, locale);
|
||||
return returnUrl.toString();
|
||||
}, [orderId, theme, locale]);
|
||||
}, [orderId, theme, locale, accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: MessageEvent) => {
|
||||
@@ -183,19 +195,25 @@ function StripePopupContent() {
|
||||
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">
|
||||
<div className={`text-3xl font-bold ${isDark ? 'text-blue-400' : 'text-blue-600'}`}>
|
||||
{'¥'}
|
||||
{amount.toFixed(2)}
|
||||
</div>
|
||||
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.orderId}: {orderId}</p>
|
||||
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{text.orderId}: {orderId}
|
||||
</p>
|
||||
</div>
|
||||
{stripeError ? (
|
||||
<div className="space-y-3">
|
||||
<div className={`rounded-lg border p-3 text-sm ${isDark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}>{stripeError}</div>
|
||||
<div
|
||||
className={`rounded-lg border p-3 text-sm ${isDark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}
|
||||
>
|
||||
{stripeError}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.close()}
|
||||
className="w-full text-sm text-blue-600 underline hover:text-blue-700"
|
||||
className={`w-full text-sm underline ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
|
||||
>
|
||||
{text.closeWindow}
|
||||
</button>
|
||||
@@ -203,9 +221,7 @@ function StripePopupContent() {
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
|
||||
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{text.redirecting}
|
||||
</span>
|
||||
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.redirecting}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -219,11 +235,13 @@ function StripePopupContent() {
|
||||
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">
|
||||
<div className={`text-3xl font-bold ${isDark ? 'text-blue-400' : 'text-blue-600'}`}>
|
||||
{'¥'}
|
||||
{amount.toFixed(2)}
|
||||
</div>
|
||||
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.orderId}: {orderId}</p>
|
||||
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{text.orderId}: {orderId}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!stripeLoaded ? (
|
||||
@@ -233,14 +251,12 @@ function StripePopupContent() {
|
||||
</div>
|
||||
) : stripeSuccess ? (
|
||||
<div className="py-6 text-center">
|
||||
<div className="text-5xl text-green-600">{'✓'}</div>
|
||||
<p className={`mt-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
{text.successClosing}
|
||||
</p>
|
||||
<div className={`text-5xl ${isDark ? 'text-green-400' : 'text-green-600'}`}>{'✓'}</div>
|
||||
<p className={`mt-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.successClosing}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.close()}
|
||||
className="mt-4 text-sm text-blue-600 underline hover:text-blue-700"
|
||||
className={`mt-4 text-sm underline ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
|
||||
>
|
||||
{text.closeWindowManually}
|
||||
</button>
|
||||
@@ -248,7 +264,11 @@ function StripePopupContent() {
|
||||
) : (
|
||||
<>
|
||||
{stripeError && (
|
||||
<div className={`rounded-lg border p-3 text-sm ${isDark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}>{stripeError}</div>
|
||||
<div
|
||||
className={`rounded-lg border p-3 text-sm ${isDark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}
|
||||
>
|
||||
{stripeError}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={stripeContainerRef}
|
||||
@@ -261,7 +281,9 @@ function StripePopupContent() {
|
||||
className={[
|
||||
'w-full rounded-lg py-3 font-medium text-white shadow-md transition-colors',
|
||||
stripeSubmitting
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
? isDark
|
||||
? 'bg-slate-700 text-slate-400 cursor-not-allowed'
|
||||
: 'bg-gray-400 cursor-not-allowed'
|
||||
: getPaymentMeta('stripe').buttonClass,
|
||||
].join(' ')}
|
||||
>
|
||||
@@ -284,10 +306,13 @@ function StripePopupContent() {
|
||||
function StripePopupFallback() {
|
||||
const searchParams = useSearchParams();
|
||||
const locale = resolveLocale(searchParams.get('lang'));
|
||||
const isDark = searchParams.get('theme') === 'dark';
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-gray-500">{pickLocaleText(locale, '加载中...', 'Loading...')}</div>
|
||||
<div className={`flex min-h-screen items-center justify-center ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
|
||||
<div className={isDark ? 'text-slate-400' : 'text-gray-500'}>
|
||||
{pickLocaleText(locale, '加载中...', 'Loading...')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
143
src/components/ChannelCard.tsx
Normal file
143
src/components/ChannelCard.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import { pickLocaleText } from '@/lib/locale';
|
||||
import { PlatformBadge, getPlatformStyle } from '@/lib/platform-style';
|
||||
|
||||
export interface ChannelInfo {
|
||||
id: string;
|
||||
groupId: number;
|
||||
name: string;
|
||||
platform: string;
|
||||
rateMultiplier: number;
|
||||
description: string | null;
|
||||
models: string[];
|
||||
features: string[];
|
||||
}
|
||||
|
||||
interface ChannelCardProps {
|
||||
channel: ChannelInfo;
|
||||
onTopUp: () => void;
|
||||
isDark: boolean;
|
||||
locale: Locale;
|
||||
userBalance?: number;
|
||||
}
|
||||
|
||||
export default function ChannelCard({ channel, onTopUp, isDark, locale }: ChannelCardProps) {
|
||||
const usableQuota = (1 / channel.rateMultiplier).toFixed(2);
|
||||
const ps = getPlatformStyle(channel.platform);
|
||||
const tagCls = isDark ? ps.modelTag.dark : ps.modelTag.light;
|
||||
const accentCls = isDark ? ps.accent.dark : ps.accent.light;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'flex flex-col rounded-2xl border p-6 transition-shadow hover:shadow-lg',
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white',
|
||||
].join(' ')}
|
||||
>
|
||||
{/* Header: Platform badge + Name */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<PlatformBadge platform={channel.platform} />
|
||||
<h3 className={['text-lg font-bold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
||||
{channel.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Rate display - prominent */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className={['text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{pickLocaleText(locale, '当前倍率', 'Rate')}
|
||||
</span>
|
||||
<div className="flex items-baseline">
|
||||
<span className={['text-xl font-bold', accentCls].join(' ')}>1</span>
|
||||
<span className={['mx-1.5 text-lg', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>:</span>
|
||||
<span className={['text-xl font-bold', accentCls].join(' ')}>{channel.rateMultiplier}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className={['mt-1 text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{pickLocaleText(
|
||||
locale,
|
||||
<>
|
||||
1元可用约<span className={['font-medium', accentCls].join(' ')}>{usableQuota}</span>美元额度
|
||||
</>,
|
||||
<>
|
||||
1 CNY ≈ <span className={['font-medium', accentCls].join(' ')}>{usableQuota}</span> USD quota
|
||||
</>,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{channel.description && (
|
||||
<p className={['text-sm leading-relaxed', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{channel.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Models */}
|
||||
{channel.models.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className={['mb-2 text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
||||
{pickLocaleText(locale, '支持模型', 'Supported Models')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{channel.models.map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className={['inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs', tagCls].join(' ')}
|
||||
>
|
||||
<span className={['h-1.5 w-1.5 rounded-full', ps.modelTag.dot].join(' ')} />
|
||||
{model}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Features */}
|
||||
{channel.features.length > 0 && (
|
||||
<div className="mb-5">
|
||||
<p className={['mb-2 text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
||||
{pickLocaleText(locale, '功能特性', 'Features')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{channel.features.map((feature) => (
|
||||
<span
|
||||
key={feature}
|
||||
className={[
|
||||
'rounded-md px-2 py-1 text-xs',
|
||||
isDark ? 'bg-emerald-500/10 text-emerald-400' : 'bg-emerald-50 text-emerald-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spacer to push button to bottom */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Top-up button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTopUp}
|
||||
className={[
|
||||
'mt-2 inline-flex w-full items-center justify-center gap-2 rounded-xl py-3 text-sm font-semibold text-white transition-colors',
|
||||
isDark ? ps.button.dark : ps.button.light,
|
||||
].join(' ')}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||
</svg>
|
||||
{pickLocaleText(locale, '立即充值', 'Top Up Now')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
src/components/ChannelGrid.tsx
Normal file
33
src/components/ChannelGrid.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import ChannelCard from '@/components/ChannelCard';
|
||||
import type { ChannelInfo } from '@/components/ChannelCard';
|
||||
|
||||
interface ChannelGridProps {
|
||||
channels: ChannelInfo[];
|
||||
onTopUp: () => void;
|
||||
isDark: boolean;
|
||||
locale: Locale;
|
||||
userBalance?: number;
|
||||
}
|
||||
|
||||
export type { ChannelInfo };
|
||||
|
||||
export default function ChannelGrid({ channels, onTopUp, isDark, locale, userBalance }: ChannelGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{channels.map((channel) => (
|
||||
<ChannelCard
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
onTopUp={onTopUp}
|
||||
isDark={isDark}
|
||||
locale={locale}
|
||||
userBalance={userBalance}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
src/components/MainTabs.tsx
Normal file
61
src/components/MainTabs.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import { pickLocaleText } from '@/lib/locale';
|
||||
|
||||
interface MainTabsProps {
|
||||
activeTab: 'topup' | 'subscribe';
|
||||
onTabChange: (tab: 'topup' | 'subscribe') => void;
|
||||
showSubscribeTab: boolean;
|
||||
showTopUpTab?: boolean;
|
||||
isDark: boolean;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export default function MainTabs({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
showSubscribeTab,
|
||||
showTopUpTab = true,
|
||||
isDark,
|
||||
locale,
|
||||
}: MainTabsProps) {
|
||||
if (!showSubscribeTab) return null;
|
||||
|
||||
const tabs: { key: 'topup' | 'subscribe'; label: string }[] = [];
|
||||
if (showTopUpTab) {
|
||||
tabs.push({ key: 'topup', label: pickLocaleText(locale, '余额充值', 'Top Up') });
|
||||
}
|
||||
tabs.push({ key: 'subscribe', label: pickLocaleText(locale, '套餐订阅', 'Subscription') });
|
||||
|
||||
// 只有一个 tab 时不显示切换器
|
||||
if (tabs.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className={['inline-flex rounded-xl p-1', isDark ? 'bg-slate-900' : 'bg-slate-100'].join(' ')}>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.key;
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => onTabChange(tab.key)}
|
||||
className={[
|
||||
'rounded-lg px-5 py-2 text-sm font-medium transition-all',
|
||||
isActive
|
||||
? isDark
|
||||
? 'bg-slate-700 text-slate-100 shadow-sm'
|
||||
: 'bg-white text-slate-900 shadow-sm'
|
||||
: isDark
|
||||
? 'text-slate-400 hover:text-slate-200'
|
||||
: 'text-slate-500 hover:text-slate-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -134,7 +134,7 @@ export default function MobileOrderList({
|
||||
{locale === 'en' ? 'Loading...' : '加载中...'}
|
||||
</span>
|
||||
) : (
|
||||
<span className={['text-xs', isDark ? 'text-slate-600' : 'text-slate-300'].join(' ')}>
|
||||
<span className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-400'].join(' ')}>
|
||||
{locale === 'en' ? 'Scroll up to load more' : '上滑加载更多'}
|
||||
</span>
|
||||
)}
|
||||
@@ -142,7 +142,7 @@ export default function MobileOrderList({
|
||||
)}
|
||||
|
||||
{!hasMore && orders.length > 0 && (
|
||||
<div className={['py-2 text-center text-xs', isDark ? 'text-slate-600' : 'text-slate-400'].join(' ')}>
|
||||
<div className={['py-2 text-center text-xs', isDark ? 'text-slate-400' : 'text-slate-400'].join(' ')}>
|
||||
{locale === 'en' ? 'All orders loaded' : '已显示全部订单'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,100 +1,199 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
|
||||
import { buildOrderStatusUrl } from '@/lib/order/status-url';
|
||||
|
||||
interface OrderStatusProps {
|
||||
status: string;
|
||||
orderId: string;
|
||||
order: PublicOrderStatusSnapshot;
|
||||
statusAccessToken?: string;
|
||||
onBack: () => void;
|
||||
onStateChange?: (order: PublicOrderStatusSnapshot) => void;
|
||||
dark?: boolean;
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG: Record<Locale, Record<string, { label: string; color: string; icon: string; message: string }>> = {
|
||||
zh: {
|
||||
COMPLETED: {
|
||||
label: '充值成功',
|
||||
color: 'text-green-600',
|
||||
icon: '✓',
|
||||
message: '余额已到账,感谢您的充值!',
|
||||
},
|
||||
PAID: {
|
||||
label: '充值中',
|
||||
color: 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: '支付成功,正在充值余额中...',
|
||||
},
|
||||
RECHARGING: {
|
||||
label: '充值中',
|
||||
color: 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: '正在充值余额中,请稍候...',
|
||||
},
|
||||
FAILED: {
|
||||
label: '充值失败',
|
||||
color: 'text-red-600',
|
||||
icon: '✗',
|
||||
message: '充值失败,请联系管理员处理。',
|
||||
},
|
||||
EXPIRED: {
|
||||
label: '订单超时',
|
||||
color: 'text-gray-500',
|
||||
icon: '⏰',
|
||||
message: '订单已超时,请重新创建订单。',
|
||||
},
|
||||
CANCELLED: {
|
||||
label: '已取消',
|
||||
color: 'text-gray-500',
|
||||
icon: '✗',
|
||||
message: '订单已取消。',
|
||||
},
|
||||
},
|
||||
en: {
|
||||
COMPLETED: {
|
||||
function getStatusConfig(order: PublicOrderStatusSnapshot, locale: Locale, isDark = false) {
|
||||
if (order.rechargeSuccess) {
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Recharge Successful',
|
||||
color: 'text-green-600',
|
||||
color: isDark ? 'text-green-400' : 'text-green-600',
|
||||
icon: '✓',
|
||||
message: 'Your balance has been credited. Thank you for your payment.',
|
||||
},
|
||||
PAID: {
|
||||
}
|
||||
: {
|
||||
label: '充值成功',
|
||||
color: isDark ? 'text-green-400' : 'text-green-600',
|
||||
icon: '✓',
|
||||
message: '余额已到账,感谢您的充值!',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.paymentSuccess) {
|
||||
if (order.rechargeStatus === 'paid_pending' || order.rechargeStatus === 'recharging') {
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Recharging',
|
||||
color: 'text-blue-600',
|
||||
color: isDark ? 'text-blue-400' : 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: 'Payment received. Recharging your balance...',
|
||||
},
|
||||
RECHARGING: {
|
||||
label: 'Recharging',
|
||||
color: 'text-blue-600',
|
||||
}
|
||||
: {
|
||||
label: '充值中',
|
||||
color: isDark ? 'text-blue-400' : 'text-blue-600',
|
||||
icon: '⟳',
|
||||
message: 'Recharging your balance. Please wait...',
|
||||
},
|
||||
FAILED: {
|
||||
label: 'Recharge Failed',
|
||||
color: 'text-red-600',
|
||||
message: '支付成功,正在充值余额中,请稍候...',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.rechargeStatus === 'failed') {
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Payment Successful',
|
||||
color: isDark ? 'text-amber-400' : 'text-amber-600',
|
||||
icon: '!',
|
||||
message:
|
||||
'Payment completed, but the balance top-up has not finished yet. The system may retry automatically. Please check the order list later or contact the administrator if it remains unresolved.',
|
||||
}
|
||||
: {
|
||||
label: '支付成功',
|
||||
color: isDark ? 'text-amber-400' : 'text-amber-600',
|
||||
icon: '!',
|
||||
message:
|
||||
'支付已完成,但余额充值暂未完成。系统可能会自动重试,请稍后在订单列表查看;如长时间未到账请联系管理员。',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (order.status === 'FAILED') {
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Payment Failed',
|
||||
color: isDark ? 'text-red-400' : 'text-red-600',
|
||||
icon: '✗',
|
||||
message: 'Recharge failed. Please contact the administrator.',
|
||||
},
|
||||
EXPIRED: {
|
||||
message:
|
||||
'Payment was not completed. Please try again. If funds were deducted but not credited, contact the administrator.',
|
||||
}
|
||||
: {
|
||||
label: '支付失败',
|
||||
color: isDark ? 'text-red-400' : 'text-red-600',
|
||||
icon: '✗',
|
||||
message: '支付未完成,请重新发起支付;如已扣款未到账,请联系管理员处理。',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.status === 'PENDING') {
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Awaiting Payment',
|
||||
color: isDark ? 'text-yellow-400' : 'text-yellow-600',
|
||||
icon: '⏳',
|
||||
message: 'The order has not been paid yet.',
|
||||
}
|
||||
: {
|
||||
label: '等待支付',
|
||||
color: isDark ? 'text-yellow-400' : 'text-yellow-600',
|
||||
icon: '⏳',
|
||||
message: '订单尚未完成支付。',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.status === 'EXPIRED') {
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Order Expired',
|
||||
color: 'text-gray-500',
|
||||
color: isDark ? 'text-slate-400' : 'text-gray-500',
|
||||
icon: '⏰',
|
||||
message: 'This order has expired. Please create a new order.',
|
||||
},
|
||||
CANCELLED: {
|
||||
message: 'This order has expired. Please create a new one.',
|
||||
}
|
||||
: {
|
||||
label: '订单超时',
|
||||
color: isDark ? 'text-slate-400' : 'text-gray-500',
|
||||
icon: '⏰',
|
||||
message: '订单已超时,请重新创建订单。',
|
||||
};
|
||||
}
|
||||
|
||||
if (order.status === 'CANCELLED') {
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Cancelled',
|
||||
color: 'text-gray-500',
|
||||
color: isDark ? 'text-slate-400' : 'text-gray-500',
|
||||
icon: '✗',
|
||||
message: 'The order has been cancelled.',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
: { label: '已取消', color: isDark ? 'text-slate-400' : 'text-gray-500', icon: '✗', message: '订单已取消。' };
|
||||
}
|
||||
|
||||
export default function OrderStatus({ status, onBack, dark = false, locale = 'zh' }: OrderStatusProps) {
|
||||
const config = STATUS_CONFIG[locale][status] || {
|
||||
label: status,
|
||||
color: 'text-gray-600',
|
||||
icon: '?',
|
||||
message: locale === 'en' ? 'Unknown status' : '未知状态',
|
||||
return locale === 'en'
|
||||
? {
|
||||
label: 'Payment Error',
|
||||
color: isDark ? 'text-red-400' : 'text-red-600',
|
||||
icon: '✗',
|
||||
message: 'Payment status is abnormal. Please contact the administrator.',
|
||||
}
|
||||
: {
|
||||
label: '支付异常',
|
||||
color: isDark ? 'text-red-400' : 'text-red-600',
|
||||
icon: '✗',
|
||||
message: '支付状态异常,请联系管理员处理。',
|
||||
};
|
||||
}
|
||||
|
||||
export default function OrderStatus({
|
||||
orderId,
|
||||
order,
|
||||
statusAccessToken,
|
||||
onBack,
|
||||
onStateChange,
|
||||
dark = false,
|
||||
locale = 'zh',
|
||||
}: OrderStatusProps) {
|
||||
const [currentOrder, setCurrentOrder] = useState(order);
|
||||
const onStateChangeRef = useRef(onStateChange);
|
||||
useEffect(() => {
|
||||
onStateChangeRef.current = onStateChange;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentOrder(order);
|
||||
}, [order]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderId || !currentOrder.paymentSuccess || currentOrder.rechargeSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const refreshOrder = async () => {
|
||||
try {
|
||||
const response = await fetch(buildOrderStatusUrl(orderId, statusAccessToken));
|
||||
if (!response.ok) return;
|
||||
const nextOrder = (await response.json()) as PublicOrderStatusSnapshot;
|
||||
if (cancelled) return;
|
||||
setCurrentOrder(nextOrder);
|
||||
onStateChangeRef.current?.(nextOrder);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
refreshOrder();
|
||||
const timer = setInterval(refreshOrder, 3000);
|
||||
const timeout = setTimeout(() => clearInterval(timer), 30000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [orderId, currentOrder.paymentSuccess, currentOrder.rechargeSuccess, statusAccessToken]);
|
||||
|
||||
const config = getStatusConfig(currentOrder, locale, dark);
|
||||
const doneLabel = locale === 'en' ? 'Done' : '完成';
|
||||
const backLabel = locale === 'en' ? 'Back to Recharge' : '返回充值';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4 py-8">
|
||||
@@ -108,7 +207,7 @@ export default function OrderStatus({ status, onBack, dark = false, locale = 'zh
|
||||
dark ? 'bg-blue-600 hover:bg-blue-500' : 'bg-blue-600 hover:bg-blue-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{status === 'COMPLETED' ? (locale === 'en' ? 'Done' : '完成') : locale === 'en' ? 'Back to Recharge' : '返回充值'}
|
||||
{currentOrder.rechargeSuccess ? doneLabel : backLabel}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import { formatStatus, formatCreatedAt, getStatusBadgeClass, getPaymentDisplayInfo, type MyOrder } from '@/lib/pay-utils';
|
||||
import {
|
||||
formatStatus,
|
||||
formatCreatedAt,
|
||||
getStatusBadgeClass,
|
||||
getPaymentDisplayInfo,
|
||||
type MyOrder,
|
||||
} from '@/lib/pay-utils';
|
||||
|
||||
interface OrderTableProps {
|
||||
isDark: boolean;
|
||||
@@ -98,7 +104,9 @@ export default function OrderTable({ isDark, locale, loading, error, orders }: O
|
||||
{formatStatus(order.status, locale)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>{formatCreatedAt(order.createdAt, locale)}</div>
|
||||
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>
|
||||
{formatCreatedAt(order.createdAt, locale)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,7 @@ export default function PayPageLayout({
|
||||
|
||||
return (
|
||||
<div
|
||||
data-theme={isDark ? 'dark' : 'light'}
|
||||
className={[
|
||||
'relative w-full overflow-hidden',
|
||||
isEmbedded ? 'min-h-screen p-2' : 'min-h-screen p-3 sm:p-4',
|
||||
|
||||
@@ -27,6 +27,8 @@ interface PaymentFormProps {
|
||||
pendingBlocked?: boolean;
|
||||
pendingCount?: number;
|
||||
locale?: Locale;
|
||||
/** 固定金额模式:隐藏金额选择,只显示支付方式和提交按钮 */
|
||||
fixedAmount?: number;
|
||||
}
|
||||
|
||||
const QUICK_AMOUNTS = [10, 20, 50, 100, 200, 500, 1000, 2000];
|
||||
@@ -50,10 +52,11 @@ export default function PaymentForm({
|
||||
pendingBlocked = false,
|
||||
pendingCount = 0,
|
||||
locale = 'zh',
|
||||
fixedAmount,
|
||||
}: PaymentFormProps) {
|
||||
const [amount, setAmount] = useState<number | ''>('');
|
||||
const [amount, setAmount] = useState<number | ''>(fixedAmount ?? '');
|
||||
const [paymentType, setPaymentType] = useState(enabledPaymentTypes[0] || 'alipay');
|
||||
const [customAmount, setCustomAmount] = useState('');
|
||||
const [customAmount, setCustomAmount] = useState(fixedAmount ? String(fixedAmount) : '');
|
||||
|
||||
const effectivePaymentType = enabledPaymentTypes.includes(paymentType)
|
||||
? paymentType
|
||||
@@ -166,6 +169,22 @@ export default function PaymentForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{fixedAmount ? (
|
||||
<div
|
||||
className={[
|
||||
'rounded-xl border p-4 text-center',
|
||||
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-slate-50',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['text-xs uppercase tracking-wide', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{locale === 'en' ? 'Recharge Amount' : '充值金额'}
|
||||
</div>
|
||||
<div className={['mt-1 text-3xl font-bold', dark ? 'text-emerald-400' : 'text-emerald-600'].join(' ')}>
|
||||
¥{fixedAmount.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
|
||||
{locale === 'en' ? 'Recharge Amount' : '充值金额'}
|
||||
@@ -178,7 +197,9 @@ export default function PaymentForm({
|
||||
onClick={() => handleQuickAmount(val)}
|
||||
className={`rounded-lg border-2 px-4 py-3 text-center font-medium transition-colors ${
|
||||
amount === val
|
||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
? dark
|
||||
? 'border-blue-500 bg-blue-900/40 text-blue-300'
|
||||
: 'border-blue-500 bg-blue-50 text-blue-700'
|
||||
: dark
|
||||
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
|
||||
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300'
|
||||
@@ -218,17 +239,23 @@ export default function PaymentForm({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{customAmount !== '' &&
|
||||
{!fixedAmount &&
|
||||
customAmount !== '' &&
|
||||
!isValid &&
|
||||
(() => {
|
||||
const num = parseFloat(customAmount);
|
||||
let msg = locale === 'en'
|
||||
let msg =
|
||||
locale === 'en'
|
||||
? 'Amount must be within range and support up to 2 decimal places'
|
||||
: '金额需在范围内,且最多支持 2 位小数(精确到分)';
|
||||
if (!isNaN(num)) {
|
||||
if (num < minAmount) msg = locale === 'en' ? `Minimum per transaction: ¥${minAmount}` : `单笔最低充值 ¥${minAmount}`;
|
||||
else if (num > effectiveMax) msg = locale === 'en' ? `Maximum per transaction: ¥${effectiveMax}` : `单笔最高充值 ¥${effectiveMax}`;
|
||||
if (num < minAmount)
|
||||
msg = locale === 'en' ? `Minimum per transaction: ¥${minAmount}` : `单笔最低充值 ¥${minAmount}`;
|
||||
else if (num > effectiveMax)
|
||||
msg = locale === 'en' ? `Maximum per transaction: ¥${effectiveMax}` : `单笔最高充值 ¥${effectiveMax}`;
|
||||
}
|
||||
return <div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>{msg}</div>;
|
||||
})()}
|
||||
@@ -252,7 +279,13 @@ export default function PaymentForm({
|
||||
type="button"
|
||||
disabled={isUnavailable}
|
||||
onClick={() => !isUnavailable && setPaymentType(type)}
|
||||
title={isUnavailable ? (locale === 'en' ? 'Daily limit reached, please use another payment method' : '今日充值额度已满,请使用其他支付方式') : undefined}
|
||||
title={
|
||||
isUnavailable
|
||||
? locale === 'en'
|
||||
? 'Daily limit reached, please use another payment method'
|
||||
: '今日充值额度已满,请使用其他支付方式'
|
||||
: undefined
|
||||
}
|
||||
className={[
|
||||
'relative flex h-[58px] flex-col items-center justify-center rounded-lg border px-3 transition-all sm:flex-1',
|
||||
isUnavailable
|
||||
@@ -260,7 +293,7 @@ export default function PaymentForm({
|
||||
? 'cursor-not-allowed border-slate-700 bg-slate-800/50 opacity-50'
|
||||
: 'cursor-not-allowed border-gray-200 bg-gray-50 opacity-50'
|
||||
: isSelected
|
||||
? `${meta?.selectedBorder || 'border-blue-500'} ${dark ? (meta?.selectedBgDark || 'bg-blue-950') : (meta?.selectedBg || 'bg-blue-50')} ${dark ? 'text-slate-100' : 'text-slate-900'} shadow-sm`
|
||||
? `${meta?.selectedBorder || 'border-blue-500'} ${dark ? meta?.selectedBgDark || 'bg-blue-950' : meta?.selectedBg || 'bg-blue-50'} ${dark ? 'text-slate-100' : 'text-slate-900'} shadow-sm`
|
||||
: dark
|
||||
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
|
||||
: 'border-gray-300 bg-white text-slate-700 hover:border-gray-400',
|
||||
@@ -271,7 +304,9 @@ export default function PaymentForm({
|
||||
<span className="flex flex-col items-start leading-none">
|
||||
<span className="text-xl font-semibold tracking-tight">{displayInfo.channel || type}</span>
|
||||
{isUnavailable ? (
|
||||
<span className="text-[10px] tracking-wide text-red-400">{locale === 'en' ? 'Daily limit reached' : '今日额度已满'}</span>
|
||||
<span className="text-[10px] tracking-wide text-red-400">
|
||||
{locale === 'en' ? 'Daily limit reached' : '今日额度已满'}
|
||||
</span>
|
||||
) : displayInfo.sublabel ? (
|
||||
<span
|
||||
className={`text-[10px] tracking-wide ${dark ? (isSelected ? 'text-slate-300' : 'text-slate-400') : 'text-slate-600'}`}
|
||||
@@ -292,7 +327,7 @@ export default function PaymentForm({
|
||||
return (
|
||||
<p className={['mt-2 text-xs', dark ? 'text-amber-300' : 'text-amber-600'].join(' ')}>
|
||||
{locale === 'en'
|
||||
? 'The selected payment method has reached today\'s limit. Please switch to another method.'
|
||||
? "The selected payment method has reached today's limit. Please switch to another method."
|
||||
: '所选支付方式今日额度已满,请切换到其他支付方式'}
|
||||
</p>
|
||||
);
|
||||
@@ -331,9 +366,7 @@ export default function PaymentForm({
|
||||
<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',
|
||||
dark ? 'border-amber-700 bg-amber-900/30 text-amber-300' : 'border-amber-200 bg-amber-50 text-amber-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{locale === 'en'
|
||||
@@ -345,12 +378,12 @@ export default function PaymentForm({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid || loading || pendingBlocked}
|
||||
className={`w-full rounded-lg py-3 text-center font-medium text-white transition-colors ${
|
||||
className={`w-full rounded-lg py-3 text-center font-medium transition-colors ${
|
||||
isValid && !loading && !pendingBlocked
|
||||
? getPaymentMeta(effectivePaymentType).buttonClass
|
||||
? `text-white ${getPaymentMeta(effectivePaymentType).buttonClass}`
|
||||
: dark
|
||||
? 'cursor-not-allowed bg-slate-700 text-slate-300'
|
||||
: 'cursor-not-allowed bg-gray-300'
|
||||
? 'cursor-not-allowed bg-slate-700 text-slate-400'
|
||||
: 'cursor-not-allowed bg-gray-300 text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{loading
|
||||
|
||||
@@ -3,12 +3,9 @@
|
||||
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import {
|
||||
isStripeType,
|
||||
getPaymentMeta,
|
||||
getPaymentIconSrc,
|
||||
getPaymentChannelLabel,
|
||||
} from '@/lib/pay-utils';
|
||||
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
|
||||
import { isStripeType, getPaymentMeta, getPaymentIconSrc, getPaymentChannelLabel } from '@/lib/pay-utils';
|
||||
import { buildOrderStatusUrl } from '@/lib/order/status-url';
|
||||
import { TERMINAL_STATUSES } from '@/lib/constants';
|
||||
|
||||
interface PaymentQRCodeProps {
|
||||
@@ -22,7 +19,8 @@ interface PaymentQRCodeProps {
|
||||
amount: number;
|
||||
payAmount?: number;
|
||||
expiresAt: string;
|
||||
onStatusChange: (status: string) => void;
|
||||
statusAccessToken?: string;
|
||||
onStatusChange: (status: PublicOrderStatusSnapshot) => void;
|
||||
onBack: () => void;
|
||||
dark?: boolean;
|
||||
isEmbedded?: boolean;
|
||||
@@ -30,6 +28,10 @@ interface PaymentQRCodeProps {
|
||||
locale?: Locale;
|
||||
}
|
||||
|
||||
function isVisibleOrderOutcome(data: PublicOrderStatusSnapshot): boolean {
|
||||
return data.paymentSuccess || TERMINAL_STATUSES.has(data.status);
|
||||
}
|
||||
|
||||
export default function PaymentQRCode({
|
||||
orderId,
|
||||
token,
|
||||
@@ -41,6 +43,7 @@ export default function PaymentQRCode({
|
||||
amount,
|
||||
payAmount: payAmountProp,
|
||||
expiresAt,
|
||||
statusAccessToken,
|
||||
onStatusChange,
|
||||
onBack,
|
||||
dark = false,
|
||||
@@ -76,23 +79,38 @@ export default function PaymentQRCode({
|
||||
scanPay: locale === 'en' ? 'Please scan with your payment app' : '请使用支付应用扫码支付',
|
||||
back: locale === 'en' ? 'Back' : '返回',
|
||||
cancelOrder: locale === 'en' ? 'Cancel Order' : '取消订单',
|
||||
h5Hint: locale === 'en' ? 'After payment, please return to this page. The system will confirm automatically.' : '支付完成后请返回此页面,系统将自动确认',
|
||||
h5Hint:
|
||||
locale === 'en'
|
||||
? 'After payment, please return to this page. The system will confirm automatically.'
|
||||
: '支付完成后请返回此页面,系统将自动确认',
|
||||
paid: locale === 'en' ? 'Order Paid' : '订单已支付',
|
||||
paidCancelBlocked:
|
||||
locale === 'en' ? 'This order has already been paid and cannot be cancelled. The recharge will be credited automatically.' : '该订单已支付完成,无法取消。充值将自动到账。',
|
||||
locale === 'en'
|
||||
? 'This order has already been paid and cannot be cancelled. The recharge will be credited automatically.'
|
||||
: '该订单已支付完成,无法取消。充值将自动到账。',
|
||||
backToRecharge: locale === 'en' ? 'Back to Recharge' : '返回充值',
|
||||
credited: locale === 'en' ? 'Credited ¥' : '到账 ¥',
|
||||
stripeLoadFailed: locale === 'en' ? 'Failed to load payment component. Please refresh and try again.' : '支付组件加载失败,请刷新页面重试',
|
||||
initFailed: locale === 'en' ? 'Payment initialization failed. Please go back and try again.' : '支付初始化失败,请返回重试',
|
||||
stripeLoadFailed:
|
||||
locale === 'en'
|
||||
? 'Failed to load payment component. Please refresh and try again.'
|
||||
: '支付组件加载失败,请刷新页面重试',
|
||||
initFailed:
|
||||
locale === 'en' ? 'Payment initialization failed. Please go back and try again.' : '支付初始化失败,请返回重试',
|
||||
loadingForm: locale === 'en' ? 'Loading payment form...' : '正在加载支付表单...',
|
||||
payFailed: locale === 'en' ? 'Payment failed. Please try again.' : '支付失败,请重试',
|
||||
successProcessing: locale === 'en' ? 'Payment successful, processing your order...' : '支付成功,正在处理订单...',
|
||||
processing: locale === 'en' ? 'Processing...' : '处理中...',
|
||||
payNow: locale === 'en' ? 'Pay' : '支付',
|
||||
popupBlocked:
|
||||
locale === 'en' ? 'Popup was blocked by your browser. Please allow popups for this site and try again.' : '弹出窗口被浏览器拦截,请允许本站弹出窗口后重试',
|
||||
locale === 'en'
|
||||
? 'Popup was blocked by your browser. Please allow popups for this site and try again.'
|
||||
: '弹出窗口被浏览器拦截,请允许本站弹出窗口后重试',
|
||||
redirectingPrefix: locale === 'en' ? 'Redirecting to ' : '正在跳转到',
|
||||
redirectingSuffix: locale === 'en' ? '...' : '...',
|
||||
redirectRetryHint:
|
||||
locale === 'en'
|
||||
? 'If the payment app does not open automatically, go back and try again.'
|
||||
: '如未自动拉起支付应用,请返回上一页后重新发起支付。',
|
||||
notRedirectedPrefix: locale === 'en' ? 'Not redirected? Open ' : '未跳转?点击前往',
|
||||
goPaySuffix: locale === 'en' ? '' : '',
|
||||
gotoPrefix: locale === 'en' ? 'Open ' : '前往',
|
||||
@@ -109,7 +127,7 @@ export default function PaymentQRCode({
|
||||
if (isEmbedded) {
|
||||
window.open(payUrl!, '_blank');
|
||||
} else {
|
||||
window.location.href = payUrl!;
|
||||
window.location.replace(payUrl!);
|
||||
}
|
||||
}, [shouldAutoRedirect, redirected, payUrl, isEmbedded]);
|
||||
|
||||
@@ -223,6 +241,9 @@ export default function PaymentQRCode({
|
||||
returnUrl.search = '';
|
||||
returnUrl.searchParams.set('order_id', orderId);
|
||||
returnUrl.searchParams.set('status', 'success');
|
||||
if (statusAccessToken) {
|
||||
returnUrl.searchParams.set('access_token', statusAccessToken);
|
||||
}
|
||||
if (locale === 'en') {
|
||||
returnUrl.searchParams.set('lang', 'en');
|
||||
}
|
||||
@@ -254,6 +275,9 @@ export default function PaymentQRCode({
|
||||
popupUrl.searchParams.set('amount', String(amount));
|
||||
popupUrl.searchParams.set('theme', dark ? 'dark' : 'light');
|
||||
popupUrl.searchParams.set('method', stripePaymentMethod);
|
||||
if (statusAccessToken) {
|
||||
popupUrl.searchParams.set('access_token', statusAccessToken);
|
||||
}
|
||||
if (locale === 'en') {
|
||||
popupUrl.searchParams.set('lang', 'en');
|
||||
}
|
||||
@@ -305,16 +329,15 @@ export default function PaymentQRCode({
|
||||
|
||||
const pollStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/orders/${orderId}`);
|
||||
const res = await fetch(buildOrderStatusUrl(orderId, statusAccessToken));
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (TERMINAL_STATUSES.has(data.status)) {
|
||||
onStatusChange(data.status);
|
||||
const data = (await res.json()) as PublicOrderStatusSnapshot;
|
||||
if (isVisibleOrderOutcome(data)) {
|
||||
onStatusChange(data);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}, [orderId, onStatusChange]);
|
||||
} catch {}
|
||||
}, [orderId, onStatusChange, statusAccessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (expired) return;
|
||||
@@ -326,12 +349,12 @@ export default function PaymentQRCode({
|
||||
const handleCancel = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await fetch(`/api/orders/${orderId}`);
|
||||
const res = await fetch(buildOrderStatusUrl(orderId, statusAccessToken));
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
const data = (await res.json()) as PublicOrderStatusSnapshot;
|
||||
|
||||
if (TERMINAL_STATUSES.has(data.status)) {
|
||||
onStatusChange(data.status);
|
||||
if (data.paymentSuccess || TERMINAL_STATUSES.has(data.status)) {
|
||||
onStatusChange(data);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -346,12 +369,18 @@ export default function PaymentQRCode({
|
||||
setCancelBlocked(true);
|
||||
return;
|
||||
}
|
||||
onStatusChange('CANCELLED');
|
||||
onStatusChange({
|
||||
id: orderId,
|
||||
status: 'CANCELLED',
|
||||
expiresAt,
|
||||
paymentSuccess: false,
|
||||
rechargeSuccess: false,
|
||||
rechargeStatus: 'closed',
|
||||
});
|
||||
} else {
|
||||
await pollStatus();
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const meta = getPaymentMeta(paymentType || 'alipay');
|
||||
@@ -362,14 +391,17 @@ export default function PaymentQRCode({
|
||||
if (cancelBlocked) {
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4 py-8">
|
||||
<div className="text-6xl text-green-600">{'✓'}</div>
|
||||
<h2 className="text-xl font-bold text-green-600">{t.paid}</h2>
|
||||
<div className={dark ? 'text-6xl text-green-400' : 'text-6xl text-green-600'}>{'✓'}</div>
|
||||
<h2 className={['text-xl font-bold', dark ? 'text-green-400' : 'text-green-600'].join(' ')}>{t.paid}</h2>
|
||||
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{t.paidCancelBlocked}
|
||||
</p>
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="mt-4 w-full rounded-lg bg-blue-600 py-3 font-medium text-white hover:bg-blue-700"
|
||||
className={[
|
||||
'mt-4 w-full rounded-lg py-3 font-medium text-white',
|
||||
dark ? 'bg-blue-600/90 hover:bg-blue-600' : 'bg-blue-600 hover:bg-blue-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{t.backToRecharge}
|
||||
</button>
|
||||
@@ -380,7 +412,7 @@ 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">
|
||||
<div className={['text-4xl font-bold', dark ? 'text-blue-400' : 'text-blue-600'].join(' ')}>
|
||||
{'¥'}
|
||||
{displayAmount.toFixed(2)}
|
||||
</div>
|
||||
@@ -390,7 +422,9 @@ export default function PaymentQRCode({
|
||||
{amount.toFixed(2)}
|
||||
</div>
|
||||
)}
|
||||
<div className={`mt-1 text-sm ${expired ? 'text-red-500' : !expired && timeLeftSeconds <= 60 ? 'text-red-500 animate-pulse' : dark ? 'text-slate-400' : 'text-gray-500'}`}>
|
||||
<div
|
||||
className={`mt-1 text-sm ${expired ? 'text-red-500' : !expired && timeLeftSeconds <= 60 ? 'text-red-500 animate-pulse' : dark ? 'text-slate-400' : 'text-gray-500'}`}
|
||||
>
|
||||
{expired ? t.expired : `${t.remaining}: ${timeLeft}`}
|
||||
</div>
|
||||
</div>
|
||||
@@ -406,9 +440,7 @@ export default function PaymentQRCode({
|
||||
dark ? 'border-slate-700' : 'border-gray-300',
|
||||
].join(' ')}
|
||||
>
|
||||
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{t.initFailed}
|
||||
</p>
|
||||
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{t.initFailed}</p>
|
||||
</div>
|
||||
) : !stripeLoaded ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
@@ -418,10 +450,14 @@ export default function PaymentQRCode({
|
||||
</span>
|
||||
</div>
|
||||
) : stripeError && !stripeLib ? (
|
||||
<div className={[
|
||||
<div
|
||||
className={[
|
||||
'rounded-lg border p-3 text-sm',
|
||||
dark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600',
|
||||
].join(' ')}>{stripeError}</div>
|
||||
].join(' ')}
|
||||
>
|
||||
{stripeError}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
@@ -432,13 +468,18 @@ export default function PaymentQRCode({
|
||||
].join(' ')}
|
||||
/>
|
||||
{stripeError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
|
||||
<div
|
||||
className={[
|
||||
'rounded-lg border p-3 text-sm',
|
||||
dark ? 'border-red-700/50 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600',
|
||||
].join(' ')}
|
||||
>
|
||||
{stripeError}
|
||||
</div>
|
||||
)}
|
||||
{stripeSuccess ? (
|
||||
<div className="text-center">
|
||||
<div className="text-4xl text-green-600">{'✓'}</div>
|
||||
<div className={dark ? 'text-4xl text-green-400' : 'text-4xl text-green-600'}>{'✓'}</div>
|
||||
<p className={['mt-2 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{t.successProcessing}
|
||||
</p>
|
||||
@@ -450,9 +491,7 @@ export default function PaymentQRCode({
|
||||
onClick={handleStripeSubmit}
|
||||
className={[
|
||||
'w-full rounded-lg py-3 font-medium text-white shadow-md transition-colors',
|
||||
stripeSubmitting
|
||||
? 'cursor-not-allowed bg-gray-400'
|
||||
: meta.buttonClass,
|
||||
stripeSubmitting ? 'cursor-not-allowed bg-gray-400' : meta.buttonClass,
|
||||
].join(' ')}
|
||||
>
|
||||
{stripeSubmitting ? (
|
||||
@@ -483,7 +522,10 @@ export default function PaymentQRCode({
|
||||
) : shouldAutoRedirect ? (
|
||||
<>
|
||||
<div className="flex items-center justify-center py-6">
|
||||
<div className={`h-8 w-8 animate-spin rounded-full border-2 border-t-transparent`} style={{ borderColor: meta.color, borderTopColor: 'transparent' }} />
|
||||
<div
|
||||
className={`h-8 w-8 animate-spin rounded-full border-2 border-t-transparent`}
|
||||
style={{ borderColor: meta.color, borderTopColor: 'transparent' }}
|
||||
/>
|
||||
<span className={['ml-3 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{`${t.redirectingPrefix}${channelLabel}${t.redirectingSuffix}`}
|
||||
</span>
|
||||
@@ -495,11 +537,11 @@ export default function PaymentQRCode({
|
||||
className={`flex w-full items-center justify-center gap-2 rounded-lg py-3 font-medium text-white shadow-md ${meta.buttonClass}`}
|
||||
>
|
||||
{iconSrc && <img src={iconSrc} alt={channelLabel} className="h-5 w-5 brightness-0 invert" />}
|
||||
{redirected ? `${t.notRedirectedPrefix}${channelLabel}` : `${t.gotoPrefix}${channelLabel}${t.gotoSuffix}`}
|
||||
{redirected
|
||||
? `${t.notRedirectedPrefix}${channelLabel}`
|
||||
: `${t.gotoPrefix}${channelLabel}${t.gotoSuffix}`}
|
||||
</a>
|
||||
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
|
||||
{t.h5Hint}
|
||||
</p>
|
||||
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{t.h5Hint}</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -562,9 +604,7 @@ export default function PaymentQRCode({
|
||||
onClick={handleCancel}
|
||||
className={[
|
||||
'flex-1 rounded-lg border py-2 text-sm',
|
||||
dark
|
||||
? 'border-red-700 text-red-400 hover:bg-red-900/30'
|
||||
: 'border-red-300 text-red-600 hover:bg-red-50',
|
||||
dark ? 'border-red-700 text-red-400 hover:bg-red-900/30' : 'border-red-300 text-red-600 hover:bg-red-50',
|
||||
].join(' ')}
|
||||
>
|
||||
{t.cancelOrder}
|
||||
|
||||
136
src/components/PurchaseFlow.tsx
Normal file
136
src/components/PurchaseFlow.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import { pickLocaleText } from '@/lib/locale';
|
||||
|
||||
interface PurchaseFlowProps {
|
||||
isDark: boolean;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
interface Step {
|
||||
icon: React.ReactNode;
|
||||
zh: string;
|
||||
en: string;
|
||||
}
|
||||
|
||||
const STEPS: Step[] = [
|
||||
{
|
||||
icon: (
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
zh: '选择套餐',
|
||||
en: 'Select Plan',
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
zh: '完成支付',
|
||||
en: 'Complete Payment',
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
zh: '获取激活码',
|
||||
en: 'Get Activation',
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
),
|
||||
zh: '激活使用',
|
||||
en: 'Start Using',
|
||||
},
|
||||
];
|
||||
|
||||
export default function PurchaseFlow({ isDark, locale }: PurchaseFlowProps) {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'rounded-2xl border p-6',
|
||||
isDark ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50',
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['mb-5 text-center text-sm font-medium', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{pickLocaleText(locale, '购买流程', 'How It Works')}
|
||||
</h3>
|
||||
|
||||
{/* Desktop: horizontal */}
|
||||
<div className="hidden items-center justify-center sm:flex">
|
||||
{STEPS.map((step, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
{/* Step */}
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div
|
||||
className={[
|
||||
'flex h-12 w-12 items-center justify-center rounded-full',
|
||||
isDark ? 'bg-emerald-900/40 text-emerald-400' : 'bg-emerald-100 text-emerald-600',
|
||||
].join(' ')}
|
||||
>
|
||||
{step.icon}
|
||||
</div>
|
||||
<span className={['text-xs font-medium', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
{pickLocaleText(locale, step.zh, step.en)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Connector */}
|
||||
{idx < STEPS.length - 1 && (
|
||||
<div className={['mx-4 h-px w-12 flex-shrink-0', isDark ? 'bg-slate-700' : 'bg-slate-300'].join(' ')} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Mobile: vertical */}
|
||||
<div className="flex flex-col items-start gap-0 sm:hidden">
|
||||
{STEPS.map((step, idx) => (
|
||||
<React.Fragment key={idx}>
|
||||
{/* Step */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={[
|
||||
'flex h-10 w-10 shrink-0 items-center justify-center rounded-full',
|
||||
isDark ? 'bg-emerald-900/40 text-emerald-400' : 'bg-emerald-100 text-emerald-600',
|
||||
].join(' ')}
|
||||
>
|
||||
{step.icon}
|
||||
</div>
|
||||
<span className={['text-sm font-medium', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
{pickLocaleText(locale, step.zh, step.en)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Connector */}
|
||||
{idx < STEPS.length - 1 && (
|
||||
<div className={['ml-5 h-6 w-px', isDark ? 'bg-slate-700' : 'bg-slate-300'].join(' ')} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
src/components/SubscriptionConfirm.tsx
Normal file
156
src/components/SubscriptionConfirm.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import { pickLocaleText } from '@/lib/locale';
|
||||
import { getPaymentTypeLabel, getPaymentIconSrc } from '@/lib/pay-utils';
|
||||
import type { PlanInfo } from '@/components/SubscriptionPlanCard';
|
||||
import { PlanInfoDisplay } from '@/components/SubscriptionPlanCard';
|
||||
|
||||
interface SubscriptionConfirmProps {
|
||||
plan: PlanInfo;
|
||||
paymentTypes: string[];
|
||||
onBack: () => void;
|
||||
onSubmit: (paymentType: string) => void;
|
||||
loading: boolean;
|
||||
isDark: boolean;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export default function SubscriptionConfirm({
|
||||
plan,
|
||||
paymentTypes,
|
||||
onBack,
|
||||
onSubmit,
|
||||
loading,
|
||||
isDark,
|
||||
locale,
|
||||
}: SubscriptionConfirmProps) {
|
||||
const [selectedPayment, setSelectedPayment] = useState(paymentTypes[0] || '');
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (selectedPayment && !loading) {
|
||||
onSubmit(selectedPayment);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-lg space-y-6">
|
||||
{/* Back link */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className={[
|
||||
'flex items-center gap-1 text-sm transition-colors',
|
||||
isDark ? 'text-slate-400 hover:text-slate-200' : 'text-slate-500 hover:text-slate-700',
|
||||
].join(' ')}
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{pickLocaleText(locale, '返回套餐页面', 'Back to Plans')}
|
||||
</button>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className={['text-xl font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
||||
{pickLocaleText(locale, '确认订单', 'Confirm Order')}
|
||||
</h2>
|
||||
|
||||
{/* Plan detail card — reuse shared component */}
|
||||
<div
|
||||
className={[
|
||||
'rounded-2xl border p-5',
|
||||
isDark ? 'border-slate-700 bg-slate-800/80' : 'border-slate-200 bg-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<PlanInfoDisplay plan={plan} isDark={isDark} locale={locale} />
|
||||
</div>
|
||||
|
||||
{/* Payment method selector */}
|
||||
<div>
|
||||
<label className={['mb-2 block text-sm font-medium', isDark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
|
||||
{pickLocaleText(locale, '支付方式', 'Payment Method')}
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{paymentTypes.map((type) => {
|
||||
const isSelected = selectedPayment === type;
|
||||
const iconSrc = getPaymentIconSrc(type);
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setSelectedPayment(type)}
|
||||
className={[
|
||||
'flex w-full items-center gap-3 rounded-xl border-2 px-4 py-3 text-left transition-all',
|
||||
isSelected
|
||||
? 'border-emerald-500 ring-1 ring-emerald-500/30'
|
||||
: isDark
|
||||
? 'border-slate-700 hover:border-slate-600'
|
||||
: 'border-slate-200 hover:border-slate-300',
|
||||
isSelected
|
||||
? isDark
|
||||
? 'bg-emerald-950/30'
|
||||
: 'bg-emerald-50/50'
|
||||
: isDark
|
||||
? 'bg-slate-800/60'
|
||||
: 'bg-white',
|
||||
].join(' ')}
|
||||
>
|
||||
{/* Radio indicator */}
|
||||
<span
|
||||
className={[
|
||||
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2',
|
||||
isSelected ? 'border-emerald-500' : isDark ? 'border-slate-600' : 'border-slate-300',
|
||||
].join(' ')}
|
||||
>
|
||||
{isSelected && <span className="h-2.5 w-2.5 rounded-full bg-emerald-500" />}
|
||||
</span>
|
||||
|
||||
{/* Icon */}
|
||||
{iconSrc && (
|
||||
<Image src={iconSrc} alt="" width={24} height={24} className="h-6 w-6 shrink-0 object-contain" />
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
<span className={['text-sm font-medium', isDark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
|
||||
{getPaymentTypeLabel(type, locale)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Amount to pay */}
|
||||
<div
|
||||
className={[
|
||||
'flex items-center justify-between rounded-xl border px-4 py-3',
|
||||
isDark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-slate-50',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className={['text-sm font-medium', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
|
||||
{pickLocaleText(locale, '应付金额', 'Amount Due')}
|
||||
</span>
|
||||
<span className="text-xl font-bold text-emerald-500">¥{plan.price}</span>
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!selectedPayment || loading}
|
||||
onClick={handleSubmit}
|
||||
className={[
|
||||
'w-full rounded-xl py-3 text-sm font-bold text-white transition-colors',
|
||||
selectedPayment && !loading
|
||||
? 'bg-emerald-500 hover:bg-emerald-600 active:bg-emerald-700'
|
||||
: isDark
|
||||
? 'cursor-not-allowed bg-slate-700 text-slate-400'
|
||||
: 'cursor-not-allowed bg-slate-200 text-slate-400',
|
||||
].join(' ')}
|
||||
>
|
||||
{loading ? pickLocaleText(locale, '处理中...', 'Processing...') : pickLocaleText(locale, '立即购买', 'Buy Now')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
209
src/components/SubscriptionPlanCard.tsx
Normal file
209
src/components/SubscriptionPlanCard.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import { pickLocaleText } from '@/lib/locale';
|
||||
import { formatValidityLabel, formatValiditySuffix, type ValidityUnit } from '@/lib/subscription-utils';
|
||||
import { PlatformBadge, getPlatformStyle } from '@/lib/platform-style';
|
||||
|
||||
export interface PlanInfo {
|
||||
id: string;
|
||||
groupId: number;
|
||||
groupName: string | null;
|
||||
name: string;
|
||||
price: number;
|
||||
originalPrice: number | null;
|
||||
validityDays: number;
|
||||
validityUnit?: ValidityUnit;
|
||||
features: string[];
|
||||
description: string | null;
|
||||
platform: string | null;
|
||||
rateMultiplier: number | null;
|
||||
limits: {
|
||||
daily_limit_usd: number | null;
|
||||
weekly_limit_usd: number | null;
|
||||
monthly_limit_usd: number | null;
|
||||
} | null;
|
||||
allowMessagesDispatch: boolean;
|
||||
defaultMappedModel: string | null;
|
||||
}
|
||||
|
||||
/** 套餐信息展示(Header + 价格 + 描述 + 倍率/限额 + 特性),不含操作按钮 */
|
||||
export function PlanInfoDisplay({ plan, isDark, locale }: { plan: PlanInfo; isDark: boolean; locale: Locale }) {
|
||||
const unit = plan.validityUnit ?? 'day';
|
||||
const periodLabel = formatValidityLabel(plan.validityDays, unit, locale);
|
||||
const periodSuffix = formatValiditySuffix(plan.validityDays, unit, locale);
|
||||
|
||||
const hasLimits =
|
||||
plan.limits &&
|
||||
(plan.limits.daily_limit_usd !== null ||
|
||||
plan.limits.weekly_limit_usd !== null ||
|
||||
plan.limits.monthly_limit_usd !== null);
|
||||
|
||||
const isOpenAI = plan.platform?.toLowerCase() === 'openai';
|
||||
const ps = getPlatformStyle(plan.platform ?? '');
|
||||
const accentCls = isDark ? ps.accent.dark : ps.accent.light;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Header: Platform badge + Name + Period + /v1/messages */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
{plan.platform && <PlatformBadge platform={plan.platform} />}
|
||||
<h3 className={['text-lg font-bold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>{plan.name}</h3>
|
||||
<span
|
||||
className={[
|
||||
'rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||
isDark ? 'bg-emerald-900/40 text-emerald-300' : 'bg-emerald-50 text-emerald-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{periodLabel}
|
||||
</span>
|
||||
{isOpenAI && plan.allowMessagesDispatch && (
|
||||
<span
|
||||
className={[
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700',
|
||||
].join(' ')}
|
||||
>
|
||||
/v1/messages
|
||||
{plan.defaultMappedModel && (
|
||||
<span className={['font-mono', isDark ? 'text-green-400' : 'text-green-800'].join(' ')}>
|
||||
{plan.defaultMappedModel}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex items-baseline gap-2">
|
||||
{plan.originalPrice !== null && (
|
||||
<span className={['text-sm line-through', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
||||
¥{plan.originalPrice}
|
||||
</span>
|
||||
)}
|
||||
<span className={['text-3xl font-bold', accentCls].join(' ')}>¥{plan.price}</span>
|
||||
<span className={['text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>{periodSuffix}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{plan.description && (
|
||||
<p className={['mb-4 text-sm leading-relaxed', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{plan.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Rate + Limits grid */}
|
||||
{(plan.rateMultiplier != null || hasLimits) && (
|
||||
<div className="mb-4 grid grid-cols-2 gap-3">
|
||||
{plan.rateMultiplier != null && (
|
||||
<div>
|
||||
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
||||
{pickLocaleText(locale, '倍率', 'Rate')}
|
||||
</span>
|
||||
<div className="flex items-baseline">
|
||||
<span className={['text-lg font-bold', accentCls].join(' ')}>1</span>
|
||||
<span className={['mx-1 text-base', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>:</span>
|
||||
<span className={['text-lg font-bold', accentCls].join(' ')}>{plan.rateMultiplier}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{plan.limits?.daily_limit_usd !== null && plan.limits?.daily_limit_usd !== undefined && (
|
||||
<div>
|
||||
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
||||
{pickLocaleText(locale, '日限额', 'Daily Limit')}
|
||||
</span>
|
||||
<div className={['text-lg font-semibold', isDark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
${plan.limits.daily_limit_usd}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{plan.limits?.weekly_limit_usd !== null && plan.limits?.weekly_limit_usd !== undefined && (
|
||||
<div>
|
||||
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
||||
{pickLocaleText(locale, '周限额', 'Weekly Limit')}
|
||||
</span>
|
||||
<div className={['text-lg font-semibold', isDark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
${plan.limits.weekly_limit_usd}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{plan.limits?.monthly_limit_usd !== null && plan.limits?.monthly_limit_usd !== undefined && (
|
||||
<div>
|
||||
<span className={['text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
||||
{pickLocaleText(locale, '月限额', 'Monthly Limit')}
|
||||
</span>
|
||||
<div className={['text-lg font-semibold', isDark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
${plan.limits.monthly_limit_usd}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Features */}
|
||||
{plan.features.length > 0 && (
|
||||
<div className="mb-5">
|
||||
<p className={['mb-2 text-xs', isDark ? 'text-slate-500' : 'text-slate-400'].join(' ')}>
|
||||
{pickLocaleText(locale, '功能特性', 'Features')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{plan.features.map((feature) => (
|
||||
<span
|
||||
key={feature}
|
||||
className={[
|
||||
'rounded-md px-2 py-1 text-xs',
|
||||
isDark ? 'bg-emerald-500/10 text-emerald-400' : 'bg-emerald-50 text-emerald-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface SubscriptionPlanCardProps {
|
||||
plan: PlanInfo;
|
||||
onSubscribe: (planId: string) => void;
|
||||
isDark: boolean;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
export default function SubscriptionPlanCard({ plan, onSubscribe, isDark, locale }: SubscriptionPlanCardProps) {
|
||||
const ps = getPlatformStyle(plan.platform ?? '');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'flex flex-col rounded-2xl border p-6 transition-shadow hover:shadow-lg',
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white',
|
||||
].join(' ')}
|
||||
>
|
||||
<PlanInfoDisplay plan={plan} isDark={isDark} locale={locale} />
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Subscribe button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSubscribe(plan.id)}
|
||||
className={[
|
||||
'mt-2 inline-flex w-full items-center justify-center gap-2 rounded-xl py-3 text-sm font-semibold text-white transition-colors',
|
||||
isDark ? ps.button.dark : ps.button.light,
|
||||
].join(' ')}
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||
</svg>
|
||||
{pickLocaleText(locale, '立即开通', 'Subscribe Now')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
110
src/components/TopUpModal.tsx
Normal file
110
src/components/TopUpModal.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import { pickLocaleText } from '@/lib/locale';
|
||||
|
||||
interface TopUpModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: (amount: number) => void;
|
||||
amounts?: number[];
|
||||
isDark: boolean;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
const DEFAULT_AMOUNTS = [50, 100, 500, 1000];
|
||||
|
||||
export default function TopUpModal({ open, onClose, onConfirm, amounts, isDark, locale }: TopUpModalProps) {
|
||||
const amountOptions = amounts ?? DEFAULT_AMOUNTS;
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selected !== null) {
|
||||
onConfirm(selected);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div
|
||||
className={[
|
||||
'relative mx-4 w-full max-w-md rounded-2xl border p-6 shadow-2xl',
|
||||
isDark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-slate-200 bg-white text-slate-900',
|
||||
].join(' ')}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold">{pickLocaleText(locale, '选择充值金额', 'Select Amount')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={[
|
||||
'flex h-8 w-8 items-center justify-center rounded-full transition-colors',
|
||||
isDark
|
||||
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
|
||||
: 'text-slate-400 hover:bg-slate-100 hover:text-slate-600',
|
||||
].join(' ')}
|
||||
>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Amount grid */}
|
||||
<div className="mb-6 grid grid-cols-2 gap-3">
|
||||
{amountOptions.map((amount) => {
|
||||
const isSelected = selected === amount;
|
||||
return (
|
||||
<button
|
||||
key={amount}
|
||||
type="button"
|
||||
onClick={() => setSelected(amount)}
|
||||
className={[
|
||||
'flex flex-col items-center rounded-xl border-2 px-4 py-4 transition-all',
|
||||
isSelected
|
||||
? 'border-emerald-500 ring-2 ring-emerald-500/30'
|
||||
: isDark
|
||||
? 'border-slate-700 hover:border-slate-600'
|
||||
: 'border-slate-200 hover:border-slate-300',
|
||||
isSelected
|
||||
? isDark
|
||||
? 'bg-emerald-950/40'
|
||||
: 'bg-emerald-50'
|
||||
: isDark
|
||||
? 'bg-slate-800/60'
|
||||
: 'bg-slate-50',
|
||||
].join(' ')}
|
||||
>
|
||||
<span className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
|
||||
{pickLocaleText(locale, `余额充值${amount}$`, `Balance +${amount}$`)}
|
||||
</span>
|
||||
<span className="mt-1 text-2xl font-bold text-emerald-500">¥{amount}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Confirm button */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={selected === null}
|
||||
onClick={handleConfirm}
|
||||
className={[
|
||||
'w-full rounded-xl py-3 text-sm font-semibold text-white transition-colors',
|
||||
selected !== null
|
||||
? 'bg-emerald-500 hover:bg-emerald-600 active:bg-emerald-700'
|
||||
: isDark
|
||||
? 'cursor-not-allowed bg-slate-700 text-slate-400'
|
||||
: 'cursor-not-allowed bg-slate-200 text-slate-400',
|
||||
].join(' ')}
|
||||
>
|
||||
{pickLocaleText(locale, '确认充值', 'Confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
218
src/components/UserSubscriptions.tsx
Normal file
218
src/components/UserSubscriptions.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import type { Locale } from '@/lib/locale';
|
||||
import { pickLocaleText } from '@/lib/locale';
|
||||
import { PlatformBadge } from '@/lib/platform-style';
|
||||
|
||||
export interface UserSub {
|
||||
id: number;
|
||||
group_id: number;
|
||||
starts_at: string;
|
||||
expires_at: string;
|
||||
status: string;
|
||||
daily_usage_usd: number;
|
||||
weekly_usage_usd: number;
|
||||
monthly_usage_usd: number;
|
||||
group_name: string | null;
|
||||
platform: string | null;
|
||||
}
|
||||
|
||||
interface UserSubscriptionsProps {
|
||||
subscriptions: UserSub[];
|
||||
onRenew: (groupId: number) => void;
|
||||
isDark: boolean;
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||
}
|
||||
|
||||
function daysUntil(iso: string): number {
|
||||
const now = new Date();
|
||||
const target = new Date(iso);
|
||||
return Math.ceil((target.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
function getStatusBadge(status: string, isDark: boolean, locale: Locale): { text: string; className: string } {
|
||||
const statusMap: Record<string, { zh: string; en: string; cls: string; clsDark: string }> = {
|
||||
active: {
|
||||
zh: '生效中',
|
||||
en: 'Active',
|
||||
cls: 'bg-emerald-100 text-emerald-700',
|
||||
clsDark: 'bg-emerald-900/40 text-emerald-300',
|
||||
},
|
||||
expired: {
|
||||
zh: '已过期',
|
||||
en: 'Expired',
|
||||
cls: 'bg-slate-100 text-slate-600',
|
||||
clsDark: 'bg-slate-700 text-slate-400',
|
||||
},
|
||||
cancelled: { zh: '已取消', en: 'Cancelled', cls: 'bg-red-100 text-red-700', clsDark: 'bg-red-900/40 text-red-300' },
|
||||
};
|
||||
const entry = statusMap[status] || {
|
||||
zh: status,
|
||||
en: status,
|
||||
cls: 'bg-slate-100 text-slate-600',
|
||||
clsDark: 'bg-slate-700 text-slate-400',
|
||||
};
|
||||
return {
|
||||
text: pickLocaleText(locale, entry.zh, entry.en),
|
||||
className: isDark ? entry.clsDark : entry.cls,
|
||||
};
|
||||
}
|
||||
|
||||
export default function UserSubscriptions({ subscriptions, onRenew, isDark, locale }: UserSubscriptionsProps) {
|
||||
if (subscriptions.length === 0) {
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'flex flex-col items-center justify-center rounded-2xl border py-16',
|
||||
isDark ? 'border-slate-700 bg-slate-800/50 text-slate-400' : 'border-slate-200 bg-slate-50 text-slate-500',
|
||||
].join(' ')}
|
||||
>
|
||||
<svg
|
||||
className="mb-3 h-12 w-12 opacity-40"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm">{pickLocaleText(locale, '暂无订阅', 'No Subscriptions')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{subscriptions.map((sub) => {
|
||||
const remaining = daysUntil(sub.expires_at);
|
||||
const isExpiringSoon = remaining > 0 && remaining <= 7;
|
||||
const badge = getStatusBadge(sub.status, isDark, locale);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={sub.id}
|
||||
className={[
|
||||
'rounded-2xl border p-4',
|
||||
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-white',
|
||||
].join(' ')}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{sub.platform && <PlatformBadge platform={sub.platform} />}
|
||||
<span className={['text-base font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
|
||||
{sub.group_name || pickLocaleText(locale, `#${sub.group_id}`, `#${sub.group_id}`)}
|
||||
</span>
|
||||
<span className={['rounded-full px-2 py-0.5 text-xs font-medium', badge.className].join(' ')}>
|
||||
{badge.text}
|
||||
</span>
|
||||
</div>
|
||||
{sub.status === 'active' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRenew(sub.group_id)}
|
||||
className={[
|
||||
'rounded-lg px-3 py-1.5 text-xs font-semibold text-white transition-colors',
|
||||
isDark
|
||||
? 'bg-emerald-500/80 hover:bg-emerald-500 active:bg-emerald-600'
|
||||
: 'bg-emerald-500 hover:bg-emerald-600 active:bg-emerald-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{pickLocaleText(locale, '续费', 'Renew')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dates */}
|
||||
<div
|
||||
className={['mb-3 grid grid-cols-2 gap-3 text-sm', isDark ? 'text-slate-400' : 'text-slate-500'].join(
|
||||
' ',
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
className={['text-xs uppercase tracking-wide', isDark ? 'text-slate-500' : 'text-slate-400'].join(
|
||||
' ',
|
||||
)}
|
||||
>
|
||||
{pickLocaleText(locale, '开始', 'Start')}
|
||||
</span>
|
||||
<p className={['font-medium', isDark ? 'text-slate-300' : 'text-slate-700'].join(' ')}>
|
||||
{formatDate(sub.starts_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span
|
||||
className={['text-xs uppercase tracking-wide', isDark ? 'text-slate-500' : 'text-slate-400'].join(
|
||||
' ',
|
||||
)}
|
||||
>
|
||||
{pickLocaleText(locale, '到期', 'Expires')}
|
||||
</span>
|
||||
<p className={['font-medium', isDark ? 'text-slate-300' : 'text-slate-700'].join(' ')}>
|
||||
{formatDate(sub.expires_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expiry warning */}
|
||||
{isExpiringSoon && (
|
||||
<div
|
||||
className={[
|
||||
'mb-3 rounded-lg px-3 py-2 text-xs font-medium',
|
||||
isDark ? 'bg-amber-900/30 text-amber-300' : 'bg-amber-50 text-amber-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{pickLocaleText(locale, `即将到期,剩余 ${remaining} 天`, `Expiring soon, ${remaining} days remaining`)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage stats */}
|
||||
<div
|
||||
className={[
|
||||
'grid grid-cols-3 gap-2 rounded-lg p-3 text-center text-xs',
|
||||
isDark ? 'bg-slate-900/60' : 'bg-slate-50',
|
||||
].join(' ')}
|
||||
>
|
||||
<div>
|
||||
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>
|
||||
{pickLocaleText(locale, '日用量', 'Daily')}
|
||||
</span>
|
||||
<p className={['mt-0.5 font-semibold', isDark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
|
||||
${sub.daily_usage_usd.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>
|
||||
{pickLocaleText(locale, '周用量', 'Weekly')}
|
||||
</span>
|
||||
<p className={['mt-0.5 font-semibold', isDark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
|
||||
${sub.weekly_usage_usd.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>
|
||||
{pickLocaleText(locale, '月用量', 'Monthly')}
|
||||
</span>
|
||||
<p className={['mt-0.5 font-semibold', isDark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
|
||||
${sub.monthly_usage_usd.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -85,7 +85,9 @@ export default function DailyChart({ data, dark, locale = 'zh' }: DailyChartProp
|
||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
{chartTitle}
|
||||
</h3>
|
||||
<p className={['text-center text-sm py-16', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>{emptyText}</p>
|
||||
<p className={['text-center text-sm py-16', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>
|
||||
{emptyText}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -121,7 +123,11 @@ export default function DailyChart({ data, dark, locale = 'zh' }: DailyChartProp
|
||||
tickLine={false}
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip dark={dark} currency={currency} amountLabel={amountLabel} countLabel={countLabel} />} />
|
||||
<Tooltip
|
||||
content={
|
||||
<CustomTooltip dark={dark} currency={currency} amountLabel={amountLabel} countLabel={countLabel} />
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="amount"
|
||||
|
||||
@@ -18,9 +18,20 @@ interface DashboardStatsProps {
|
||||
export default function DashboardStats({ summary, dark, locale = 'zh' }: DashboardStatsProps) {
|
||||
const currency = locale === 'en' ? '$' : '¥';
|
||||
const cards = [
|
||||
{ label: locale === 'en' ? 'Today Recharge' : '今日充值', value: `${currency}${summary.today.amount.toLocaleString()}`, accent: true },
|
||||
{ label: locale === 'en' ? 'Today Orders' : '今日订单', value: `${summary.today.paidCount}/${summary.today.orderCount}` },
|
||||
{ label: locale === 'en' ? 'Total Recharge' : '累计充值', value: `${currency}${summary.total.amount.toLocaleString()}`, accent: true },
|
||||
{
|
||||
label: locale === 'en' ? 'Today Recharge' : '今日充值',
|
||||
value: `${currency}${summary.today.amount.toLocaleString()}`,
|
||||
accent: true,
|
||||
},
|
||||
{
|
||||
label: locale === 'en' ? 'Today Orders' : '今日订单',
|
||||
value: `${summary.today.paidCount}/${summary.today.orderCount}`,
|
||||
},
|
||||
{
|
||||
label: locale === 'en' ? 'Total Recharge' : '累计充值',
|
||||
value: `${currency}${summary.total.amount.toLocaleString()}`,
|
||||
accent: true,
|
||||
},
|
||||
{ label: locale === 'en' ? 'Paid Orders' : '累计订单', value: String(summary.total.paidCount) },
|
||||
{ label: locale === 'en' ? 'Success Rate' : '成功率', value: `${summary.successRate}%` },
|
||||
{ label: locale === 'en' ? 'Average Amount' : '平均充值', value: `${currency}${summary.avgAmount.toFixed(2)}` },
|
||||
|
||||
@@ -97,7 +97,8 @@ export default function Leaderboard({ data, dark, locale = 'zh' }: LeaderboardPr
|
||||
<td
|
||||
className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : 'text-slate-900'}`}
|
||||
>
|
||||
{currency}{entry.totalAmount.toLocaleString()}
|
||||
{currency}
|
||||
{entry.totalAmount.toLocaleString()}
|
||||
</td>
|
||||
<td className={tdMuted}>{entry.orderCount}</td>
|
||||
</tr>
|
||||
|
||||
@@ -49,7 +49,8 @@ interface OrderDetailProps {
|
||||
|
||||
export default function OrderDetail({ order, onClose, dark, locale = 'zh' }: OrderDetailProps) {
|
||||
const currency = locale === 'en' ? '$' : '¥';
|
||||
const text = locale === 'en'
|
||||
const text =
|
||||
locale === 'en'
|
||||
? {
|
||||
title: 'Order Details',
|
||||
auditLogs: 'Audit Logs',
|
||||
@@ -166,10 +167,9 @@ export default function OrderDetail({ order, onClose, dark, locale = 'zh' }: Ord
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div
|
||||
className={`max-h-[80vh] w-full max-w-2xl overflow-y-auto rounded-xl p-6 shadow-xl ${dark ? 'bg-slate-800 text-slate-100' : 'bg-white'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-bold">{text.title}</h3>
|
||||
|
||||
@@ -32,7 +32,8 @@ interface OrderTableProps {
|
||||
|
||||
export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, dark, locale = 'zh' }: OrderTableProps) {
|
||||
const currency = locale === 'en' ? '$' : '¥';
|
||||
const text = locale === 'en'
|
||||
const text =
|
||||
locale === 'en'
|
||||
? {
|
||||
orderId: 'Order ID',
|
||||
userName: 'Username',
|
||||
@@ -84,7 +85,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
<th className={thCls}>{text.actions}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className={`divide-y ${dark ? 'divide-slate-700/60' : 'divide-gray-200 bg-white'}`}>
|
||||
<tbody className={`divide-y ${dark ? 'divide-slate-700/60 bg-slate-900' : 'divide-gray-200 bg-white'}`}>
|
||||
{orders.map((order) => {
|
||||
const statusInfo = {
|
||||
label: formatStatus(order.status, locale),
|
||||
@@ -127,13 +128,16 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da
|
||||
{order.id.slice(0, 12)}...
|
||||
</button>
|
||||
</td>
|
||||
<td className={`whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-200' : ''}`}>
|
||||
<td className={`whitespace-nowrap px-4 py-3 text-sm ${dark ? 'text-slate-200' : 'text-slate-900'}`}>
|
||||
{order.userName || `#${order.userId}`}
|
||||
</td>
|
||||
<td className={tdMuted}>{order.userEmail || '-'}</td>
|
||||
<td className={tdMuted}>{order.userNotes || '-'}</td>
|
||||
<td className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : ''}`}>
|
||||
{currency}{order.amount.toFixed(2)}
|
||||
<td
|
||||
className={`whitespace-nowrap px-4 py-3 text-sm font-medium ${dark ? 'text-slate-200' : 'text-slate-900'}`}
|
||||
>
|
||||
{currency}
|
||||
{order.amount.toFixed(2)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<span
|
||||
|
||||
@@ -44,9 +44,7 @@ export default function PaymentMethodChart({ data, dark, locale = 'zh' }: Paymen
|
||||
dark ? 'border-slate-700 bg-slate-800/60' : 'border-slate-200 bg-white shadow-sm',
|
||||
].join(' ')}
|
||||
>
|
||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
|
||||
{title}
|
||||
</h3>
|
||||
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>{title}</h3>
|
||||
<div className="space-y-4">
|
||||
{data.map((method) => {
|
||||
const meta = getPaymentMeta(method.paymentType);
|
||||
@@ -56,7 +54,8 @@ export default function PaymentMethodChart({ data, dark, locale = 'zh' }: Paymen
|
||||
<div className="mb-1.5 flex items-center justify-between text-sm">
|
||||
<span className={dark ? 'text-slate-300' : 'text-slate-700'}>{label}</span>
|
||||
<span className={dark ? 'text-slate-400' : 'text-slate-500'}>
|
||||
{currency}{method.amount.toLocaleString()} · {method.percentage}%
|
||||
{currency}
|
||||
{method.amount.toLocaleString()} · {method.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -65,7 +64,10 @@ export default function PaymentMethodChart({ data, dark, locale = 'zh' }: Paymen
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={['h-full rounded-full transition-all', dark ? meta.chartBar.dark : meta.chartBar.light].join(' ')}
|
||||
className={[
|
||||
'h-full rounded-full transition-all',
|
||||
dark ? meta.chartBar.dark : meta.chartBar.light,
|
||||
].join(' ')}
|
||||
style={{ width: `${method.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -72,25 +72,22 @@ export default function RefundDialog({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onCancel}>
|
||||
<div
|
||||
className={[
|
||||
'w-full max-w-md rounded-xl p-6 shadow-xl',
|
||||
dark ? 'bg-slate-900' : 'bg-white',
|
||||
].join(' ')}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className={['w-full max-w-md rounded-xl p-6 shadow-xl', dark ? 'bg-slate-900' : 'bg-white'].join(' ')}>
|
||||
<h3 className={['text-lg font-bold', dark ? 'text-slate-100' : 'text-gray-900'].join(' ')}>{text.title}</h3>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className={['rounded-lg p-3', dark ? 'bg-slate-800' : 'bg-gray-50'].join(' ')}>
|
||||
<div className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{text.orderId}</div>
|
||||
<div className="text-sm font-mono">{orderId}</div>
|
||||
<div className={['text-sm font-mono', dark ? 'text-slate-200' : 'text-gray-900'].join(' ')}>{orderId}</div>
|
||||
</div>
|
||||
|
||||
<div className={['rounded-lg p-3', dark ? 'bg-slate-800' : 'bg-gray-50'].join(' ')}>
|
||||
<div className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{text.amount}</div>
|
||||
<div className="text-lg font-bold text-red-600">{currency}{amount.toFixed(2)}</div>
|
||||
<div className={['text-lg font-bold', dark ? 'text-red-400' : 'text-red-600'].join(' ')}>
|
||||
{currency}
|
||||
{amount.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{warning && (
|
||||
@@ -128,7 +125,7 @@ export default function RefundDialog({
|
||||
onChange={(e) => setForce(e.target.checked)}
|
||||
className={['rounded', dark ? 'border-slate-600' : 'border-gray-300'].join(' ')}
|
||||
/>
|
||||
<span className="text-red-600">{text.forceRefund}</span>
|
||||
<span className={dark ? 'text-red-400' : 'text-red-600'}>{text.forceRefund}</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
@@ -148,7 +145,12 @@ export default function RefundDialog({
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={loading || (requireForce && !force)}
|
||||
className="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:bg-gray-300"
|
||||
className={[
|
||||
'flex-1 rounded-lg py-2 text-sm font-medium text-white hover:bg-red-700 disabled:cursor-not-allowed',
|
||||
dark
|
||||
? 'bg-red-600/90 disabled:bg-slate-700 disabled:text-slate-500'
|
||||
: 'bg-red-600 disabled:bg-gray-300 disabled:text-gray-400',
|
||||
].join(' ')}
|
||||
>
|
||||
{loading ? text.processing : text.confirm}
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getEnv } from '@/lib/config';
|
||||
import { generateSign } from './sign';
|
||||
import { generateSign, verifyResponseSign } from './sign';
|
||||
import type { AlipayResponse } from './types';
|
||||
import { parseAlipayJsonResponseWithRaw } from './codec';
|
||||
|
||||
const GATEWAY = 'https://openapi.alipay.com/gateway.do';
|
||||
|
||||
@@ -32,7 +33,7 @@ function assertAlipayEnv(env: ReturnType<typeof getEnv>) {
|
||||
*/
|
||||
export function pageExecute(
|
||||
bizContent: Record<string, unknown>,
|
||||
options?: { notifyUrl?: string; returnUrl?: string; method?: string },
|
||||
options?: { notifyUrl?: string; returnUrl?: string | null; method?: string },
|
||||
): string {
|
||||
const env = assertAlipayEnv(getEnv());
|
||||
|
||||
@@ -45,7 +46,7 @@ export function pageExecute(
|
||||
if (options?.notifyUrl || env.ALIPAY_NOTIFY_URL) {
|
||||
params.notify_url = (options?.notifyUrl || env.ALIPAY_NOTIFY_URL)!;
|
||||
}
|
||||
if (options?.returnUrl || env.ALIPAY_RETURN_URL) {
|
||||
if (options?.returnUrl !== null && (options?.returnUrl || env.ALIPAY_RETURN_URL)) {
|
||||
params.return_url = (options?.returnUrl || env.ALIPAY_RETURN_URL)!;
|
||||
}
|
||||
|
||||
@@ -62,6 +63,7 @@ export function pageExecute(
|
||||
export async function execute<T extends AlipayResponse>(
|
||||
method: string,
|
||||
bizContent: Record<string, unknown>,
|
||||
options?: { notifyUrl?: string; returnUrl?: string },
|
||||
): Promise<T> {
|
||||
const env = assertAlipayEnv(getEnv());
|
||||
|
||||
@@ -71,6 +73,13 @@ export async function execute<T extends AlipayResponse>(
|
||||
biz_content: JSON.stringify(bizContent),
|
||||
};
|
||||
|
||||
if (options?.notifyUrl) {
|
||||
params.notify_url = options.notifyUrl;
|
||||
}
|
||||
if (options?.returnUrl) {
|
||||
params.return_url = options.returnUrl;
|
||||
}
|
||||
|
||||
params.sign = generateSign(params, env.ALIPAY_PRIVATE_KEY);
|
||||
|
||||
const response = await fetch(GATEWAY, {
|
||||
@@ -80,11 +89,21 @@ export async function execute<T extends AlipayResponse>(
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const { data, rawText } = await parseAlipayJsonResponseWithRaw(response);
|
||||
|
||||
// 支付宝响应格式:{ "alipay_trade_query_response": { ... }, "sign": "..." }
|
||||
const responseKey = method.replace(/\./g, '_') + '_response';
|
||||
const result = data[responseKey] as T;
|
||||
|
||||
// 响应验签:从原始文本中提取 responseKey 对应的 JSON 子串进行 RSA2 验签
|
||||
const responseSign = data.sign as string | undefined;
|
||||
if (responseSign) {
|
||||
const valid = verifyResponseSign(rawText, responseKey, env.ALIPAY_PUBLIC_KEY, responseSign);
|
||||
if (!valid) {
|
||||
throw new Error(`Alipay API response signature verification failed for ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
const result = data[responseKey] as T | undefined;
|
||||
|
||||
if (!result) {
|
||||
throw new Error(`Alipay API error: unexpected response format for ${method}`);
|
||||
|
||||
117
src/lib/alipay/codec.ts
Normal file
117
src/lib/alipay/codec.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
const HEADER_CHARSET_RE = /charset=([^;]+)/i;
|
||||
const BODY_CHARSET_RE = /(?:^|&)charset=([^&]+)/i;
|
||||
|
||||
function normalizeCharset(charset: string | null | undefined): string | null {
|
||||
if (!charset) return null;
|
||||
|
||||
const normalized = charset
|
||||
.trim()
|
||||
.replace(/^['"]|['"]$/g, '')
|
||||
.toLowerCase();
|
||||
if (!normalized) return null;
|
||||
|
||||
switch (normalized) {
|
||||
case 'utf8':
|
||||
return 'utf-8';
|
||||
case 'gb2312':
|
||||
case 'gb_2312-80':
|
||||
return 'gbk';
|
||||
default:
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
function detectCharsetFromHeaders(headers: Record<string, string>): string | null {
|
||||
const contentType = headers['content-type'];
|
||||
const match = contentType?.match(HEADER_CHARSET_RE);
|
||||
return normalizeCharset(match?.[1]);
|
||||
}
|
||||
|
||||
function detectCharsetFromBody(rawBody: Buffer): string | null {
|
||||
const latin1Body = rawBody.toString('latin1');
|
||||
const match = latin1Body.match(BODY_CHARSET_RE);
|
||||
if (!match) return null;
|
||||
|
||||
try {
|
||||
return normalizeCharset(decodeURIComponent(match[1].replace(/\+/g, ' ')));
|
||||
} catch {
|
||||
return normalizeCharset(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBuffer(rawBody: Buffer, charset: string): string {
|
||||
return new TextDecoder(charset).decode(rawBody);
|
||||
}
|
||||
|
||||
export function decodeAlipayPayload(rawBody: string | Buffer, headers: Record<string, string> = {}): string {
|
||||
if (typeof rawBody === 'string') {
|
||||
return rawBody;
|
||||
}
|
||||
|
||||
const primaryCharset = detectCharsetFromHeaders(headers) || detectCharsetFromBody(rawBody) || 'utf-8';
|
||||
const candidates = Array.from(new Set([primaryCharset, 'utf-8', 'gbk', 'gb18030']));
|
||||
|
||||
let fallbackDecoded: string | null = null;
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (const charset of candidates) {
|
||||
try {
|
||||
const decoded = decodeBuffer(rawBody, charset);
|
||||
if (!decoded.includes('\uFFFD')) {
|
||||
return decoded;
|
||||
}
|
||||
fallbackDecoded ??= decoded;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackDecoded) {
|
||||
return fallbackDecoded;
|
||||
}
|
||||
|
||||
throw new Error(`Failed to decode Alipay payload${lastError instanceof Error ? `: ${lastError.message}` : ''}`);
|
||||
}
|
||||
|
||||
export function normalizeAlipaySignature(sign: string): string {
|
||||
return sign.replace(/ /g, '+').trim();
|
||||
}
|
||||
|
||||
export function parseAlipayNotificationParams(
|
||||
rawBody: string | Buffer,
|
||||
headers: Record<string, string> = {},
|
||||
): Record<string, string> {
|
||||
const body = decodeAlipayPayload(rawBody, headers);
|
||||
const searchParams = new URLSearchParams(body);
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
params[key] = value;
|
||||
}
|
||||
|
||||
if (params.sign) {
|
||||
params.sign = normalizeAlipaySignature(params.sign);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function parseAlipayJsonResponse<T>(response: Response): Promise<T> {
|
||||
const rawBody = Buffer.from(await response.arrayBuffer());
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
const text = decodeAlipayPayload(rawBody, { 'content-type': contentType });
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析支付宝 JSON 响应并返回原始文本,用于响应验签。
|
||||
* 验签要求使用原始 JSON 子串(不能 parse 后再 stringify)。
|
||||
*/
|
||||
export async function parseAlipayJsonResponseWithRaw(
|
||||
response: Response,
|
||||
): Promise<{ data: Record<string, unknown>; rawText: string }> {
|
||||
const rawBody = Buffer.from(await response.arrayBuffer());
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
const rawText = decodeAlipayPayload(rawBody, { 'content-type': contentType });
|
||||
return { data: JSON.parse(rawText), rawText };
|
||||
}
|
||||
@@ -12,6 +12,53 @@ import { pageExecute, execute } from './client';
|
||||
import { verifySign } from './sign';
|
||||
import { getEnv } from '@/lib/config';
|
||||
import type { AlipayTradeQueryResponse, AlipayTradeRefundResponse, AlipayTradeCloseResponse } from './types';
|
||||
import { parseAlipayNotificationParams } from './codec';
|
||||
|
||||
export interface BuildAlipayPaymentUrlInput {
|
||||
orderId: string;
|
||||
amount: number;
|
||||
subject: string;
|
||||
notifyUrl?: string;
|
||||
returnUrl?: string | null;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
function isTradeNotExistError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) return false;
|
||||
return error.message.includes('[ACQ.TRADE_NOT_EXIST]');
|
||||
}
|
||||
|
||||
function getRequiredParam(params: Record<string, string>, key: string): string {
|
||||
const value = params[key]?.trim();
|
||||
if (!value) {
|
||||
throw new Error(`Alipay notification missing required field: ${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function buildAlipayPaymentUrl(input: BuildAlipayPaymentUrlInput): string {
|
||||
const method = input.isMobile ? 'alipay.trade.wap.pay' : 'alipay.trade.page.pay';
|
||||
const productCode = input.isMobile ? 'QUICK_WAP_WAY' : 'FAST_INSTANT_TRADE_PAY';
|
||||
|
||||
return pageExecute(
|
||||
{
|
||||
out_trade_no: input.orderId,
|
||||
product_code: productCode,
|
||||
total_amount: input.amount.toFixed(2),
|
||||
subject: input.subject,
|
||||
},
|
||||
{
|
||||
notifyUrl: input.notifyUrl,
|
||||
returnUrl: input.returnUrl,
|
||||
method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function buildAlipayEntryUrl(orderId: string): string {
|
||||
const env = getEnv();
|
||||
return new URL(`/pay/${orderId}`, env.NEXT_PUBLIC_APP_URL).toString();
|
||||
}
|
||||
|
||||
export class AlipayProvider implements PaymentProvider {
|
||||
readonly name = 'alipay-direct';
|
||||
@@ -22,42 +69,43 @@ export class AlipayProvider implements PaymentProvider {
|
||||
};
|
||||
|
||||
async createPayment(request: CreatePaymentRequest): Promise<CreatePaymentResponse> {
|
||||
const buildPayUrl = (mobile: boolean) => {
|
||||
const method = mobile ? 'alipay.trade.wap.pay' : 'alipay.trade.page.pay';
|
||||
const productCode = mobile ? 'QUICK_WAP_WAY' : 'FAST_INSTANT_TRADE_PAY';
|
||||
return pageExecute(
|
||||
{
|
||||
out_trade_no: request.orderId,
|
||||
product_code: productCode,
|
||||
total_amount: request.amount.toFixed(2),
|
||||
if (!request.isMobile) {
|
||||
const entryUrl = buildAlipayEntryUrl(request.orderId);
|
||||
return {
|
||||
tradeNo: request.orderId,
|
||||
payUrl: entryUrl,
|
||||
qrCode: entryUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const payUrl = buildAlipayPaymentUrl({
|
||||
orderId: request.orderId,
|
||||
amount: request.amount,
|
||||
subject: request.subject,
|
||||
},
|
||||
{
|
||||
notifyUrl: request.notifyUrl,
|
||||
returnUrl: request.returnUrl,
|
||||
method,
|
||||
},
|
||||
);
|
||||
};
|
||||
isMobile: true,
|
||||
});
|
||||
|
||||
let url: string;
|
||||
if (request.isMobile) {
|
||||
try {
|
||||
url = buildPayUrl(true);
|
||||
} catch {
|
||||
url = buildPayUrl(false);
|
||||
}
|
||||
} else {
|
||||
url = buildPayUrl(false);
|
||||
}
|
||||
|
||||
return { tradeNo: request.orderId, payUrl: url };
|
||||
return { tradeNo: request.orderId, payUrl };
|
||||
}
|
||||
|
||||
async queryOrder(tradeNo: string): Promise<QueryOrderResponse> {
|
||||
const result = await execute<AlipayTradeQueryResponse>('alipay.trade.query', {
|
||||
let result: AlipayTradeQueryResponse;
|
||||
try {
|
||||
result = await execute<AlipayTradeQueryResponse>('alipay.trade.query', {
|
||||
out_trade_no: tradeNo,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isTradeNotExistError(error)) {
|
||||
return {
|
||||
tradeNo,
|
||||
status: 'pending',
|
||||
amount: 0,
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let status: 'pending' | 'paid' | 'failed' | 'refunded';
|
||||
switch (result.trade_status) {
|
||||
@@ -72,45 +120,53 @@ export class AlipayProvider implements PaymentProvider {
|
||||
status = 'pending';
|
||||
}
|
||||
|
||||
const amount = parseFloat(result.total_amount || '0');
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
throw new Error(`Alipay queryOrder: invalid total_amount "${result.total_amount}" for trade ${tradeNo}`);
|
||||
}
|
||||
|
||||
return {
|
||||
tradeNo: result.trade_no || tradeNo,
|
||||
status,
|
||||
amount: Math.round(parseFloat(result.total_amount || '0') * 100) / 100,
|
||||
amount: Math.round(amount * 100) / 100,
|
||||
paidAt: result.send_pay_date ? new Date(result.send_pay_date) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async verifyNotification(rawBody: string | Buffer, _headers: Record<string, string>): Promise<PaymentNotification> {
|
||||
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 = parseAlipayNotificationParams(rawBody, headers);
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
params[key] = value;
|
||||
}
|
||||
|
||||
// sign_type 过滤:仅接受 RSA2
|
||||
if (params.sign_type && params.sign_type !== 'RSA2') {
|
||||
if (params.sign_type && params.sign_type.toUpperCase() !== 'RSA2') {
|
||||
throw new Error('Unsupported sign_type, only RSA2 is accepted');
|
||||
}
|
||||
|
||||
const sign = params.sign || '';
|
||||
const sign = getRequiredParam(params, 'sign');
|
||||
if (!env.ALIPAY_PUBLIC_KEY || !verifySign(params, env.ALIPAY_PUBLIC_KEY, sign)) {
|
||||
throw new Error('Alipay notification signature verification failed');
|
||||
throw new Error(
|
||||
'Alipay notification signature verification failed (check ALIPAY_PUBLIC_KEY uses Alipay public key, not app public key, and rebuild/redeploy the latest image)',
|
||||
);
|
||||
}
|
||||
|
||||
// app_id 校验
|
||||
if (params.app_id !== env.ALIPAY_APP_ID) {
|
||||
const tradeNo = getRequiredParam(params, 'trade_no');
|
||||
const orderId = getRequiredParam(params, 'out_trade_no');
|
||||
const tradeStatus = getRequiredParam(params, 'trade_status');
|
||||
const appId = getRequiredParam(params, 'app_id');
|
||||
|
||||
if (appId !== env.ALIPAY_APP_ID) {
|
||||
throw new Error('Alipay notification app_id mismatch');
|
||||
}
|
||||
|
||||
const amount = Number.parseFloat(getRequiredParam(params, 'total_amount'));
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
throw new Error('Alipay notification invalid total_amount');
|
||||
}
|
||||
|
||||
return {
|
||||
tradeNo: params.trade_no || '',
|
||||
orderId: params.out_trade_no || '',
|
||||
amount: Math.round(parseFloat(params.total_amount || '0') * 100) / 100,
|
||||
status:
|
||||
params.trade_status === 'TRADE_SUCCESS' || params.trade_status === 'TRADE_FINISHED' ? 'success' : 'failed',
|
||||
tradeNo,
|
||||
orderId,
|
||||
amount: Math.round(amount * 100) / 100,
|
||||
status: tradeStatus === 'TRADE_SUCCESS' || tradeStatus === 'TRADE_FINISHED' ? 'success' : 'failed',
|
||||
rawData: params,
|
||||
};
|
||||
}
|
||||
@@ -130,8 +186,15 @@ export class AlipayProvider implements PaymentProvider {
|
||||
}
|
||||
|
||||
async cancelPayment(tradeNo: string): Promise<void> {
|
||||
try {
|
||||
await execute<AlipayTradeCloseResponse>('alipay.trade.close', {
|
||||
out_trade_no: tradeNo,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isTradeNotExistError(error)) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
/** 将裸 base64 按 64 字符/行折行,符合 PEM 标准(OpenSSL 3.x 严格模式要求) */
|
||||
function wrapBase64(b64: string): string {
|
||||
return b64.replace(/(.{64})/g, '$1\n').trim();
|
||||
}
|
||||
|
||||
function normalizePemLikeValue(key: string): string {
|
||||
return key
|
||||
.trim()
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\\r\\n/g, '\n')
|
||||
.replace(/\\n/g, '\n');
|
||||
}
|
||||
|
||||
function shouldLogVerifyDebug(): boolean {
|
||||
return process.env.NODE_ENV !== 'production' || process.env.DEBUG_ALIPAY_SIGN === '1';
|
||||
}
|
||||
|
||||
/** 自动补全 PEM 格式(PKCS8) */
|
||||
function formatPrivateKey(key: string): string {
|
||||
if (key.includes('-----BEGIN')) return key;
|
||||
return `-----BEGIN PRIVATE KEY-----\n${key}\n-----END PRIVATE KEY-----`;
|
||||
const normalized = normalizePemLikeValue(key);
|
||||
if (normalized.includes('-----BEGIN')) return normalized;
|
||||
return `-----BEGIN PRIVATE KEY-----\n${wrapBase64(normalized)}\n-----END PRIVATE KEY-----`;
|
||||
}
|
||||
|
||||
function formatPublicKey(key: string): string {
|
||||
if (key.includes('-----BEGIN')) return key;
|
||||
return `-----BEGIN PUBLIC KEY-----\n${key}\n-----END PUBLIC KEY-----`;
|
||||
const normalized = normalizePemLikeValue(key);
|
||||
if (normalized.includes('-----BEGIN')) return normalized;
|
||||
return `-----BEGIN PUBLIC KEY-----\n${wrapBase64(normalized)}\n-----END PUBLIC KEY-----`;
|
||||
}
|
||||
|
||||
/** 生成 RSA2 签名 */
|
||||
/** 生成 RSA2 签名(请求签名:仅排除 sign) */
|
||||
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)
|
||||
.filter(([key, value]) => key !== 'sign' && value !== '' && value !== undefined && value !== null)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
const signStr = filtered.map(([key, value]) => `${key}=${value}`).join('&');
|
||||
@@ -24,15 +43,105 @@ export function generateSign(params: Record<string, string>, privateKey: string)
|
||||
return signer.sign(formatPrivateKey(privateKey), 'base64');
|
||||
}
|
||||
|
||||
/** 用支付宝公钥验证签名 */
|
||||
/**
|
||||
* 验证支付宝服务端 API 响应签名。
|
||||
* 从原始 JSON 文本中提取 responseKey 对应的子串作为验签内容。
|
||||
*/
|
||||
export function verifyResponseSign(
|
||||
rawText: string,
|
||||
responseKey: string,
|
||||
alipayPublicKey: string,
|
||||
sign: string,
|
||||
): boolean {
|
||||
// 从原始文本中精确提取 responseKey 对应的 JSON 子串
|
||||
// 格式: {"responseKey":{ ... },"sign":"..."}
|
||||
const keyPattern = `"${responseKey}"`;
|
||||
const keyIdx = rawText.indexOf(keyPattern);
|
||||
if (keyIdx < 0) return false;
|
||||
|
||||
const colonIdx = rawText.indexOf(':', keyIdx + keyPattern.length);
|
||||
if (colonIdx < 0) return false;
|
||||
|
||||
// 找到 value 的起始位置(跳过冒号后的空白)
|
||||
let start = colonIdx + 1;
|
||||
while (start < rawText.length && rawText[start] === ' ') start++;
|
||||
|
||||
// 使用括号匹配找到完整的 JSON 值
|
||||
let depth = 0;
|
||||
let end = start;
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
for (let i = start; i < rawText.length; i++) {
|
||||
const ch = rawText[i];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\' && inString) {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
if (inString) continue;
|
||||
if (ch === '{') depth++;
|
||||
if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
end = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const signContent = rawText.substring(start, end);
|
||||
const pem = formatPublicKey(alipayPublicKey);
|
||||
try {
|
||||
const verifier = crypto.createVerify('RSA-SHA256');
|
||||
verifier.update(signContent);
|
||||
return verifier.verify(pem, sign, 'base64');
|
||||
} catch (err) {
|
||||
if (shouldLogVerifyDebug()) {
|
||||
console.error('[Alipay verifyResponseSign] crypto error:', err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 用支付宝公钥验证签名(回调验签:排除 sign 和 sign_type) */
|
||||
export function verifySign(params: Record<string, string>, alipayPublicKey: string, sign: string): boolean {
|
||||
const filtered = Object.entries(params)
|
||||
.filter(([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null)
|
||||
.filter(
|
||||
([key, value]) => key !== 'sign' && key !== 'sign_type' && value !== '' && value !== undefined && value !== null,
|
||||
)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
const signStr = filtered.map(([key, value]) => `${key}=${value}`).join('&');
|
||||
|
||||
const pem = formatPublicKey(alipayPublicKey);
|
||||
try {
|
||||
const verifier = crypto.createVerify('RSA-SHA256');
|
||||
verifier.update(signStr);
|
||||
return verifier.verify(formatPublicKey(alipayPublicKey), sign, 'base64');
|
||||
const result = verifier.verify(pem, sign, 'base64');
|
||||
if (!result) {
|
||||
if (shouldLogVerifyDebug()) {
|
||||
console.error('[Alipay verifySign] FAILED. signStr:', signStr.substring(0, 200) + '...');
|
||||
console.error('[Alipay verifySign] sign(first 40):', sign.substring(0, 40));
|
||||
console.error('[Alipay verifySign] pubKey(first 80):', pem.substring(0, 80));
|
||||
} else {
|
||||
console.error('[Alipay verifySign] verification failed');
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (shouldLogVerifyDebug()) {
|
||||
console.error('[Alipay verifySign] crypto error:', err);
|
||||
} else {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error('[Alipay verifySign] crypto error:', message);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ const envSchema = z.object({
|
||||
.pipe(z.number().min(0).optional()),
|
||||
PRODUCT_NAME: z.string().default('Sub2API Balance Recharge'),
|
||||
|
||||
ADMIN_TOKEN: z.string().min(1),
|
||||
ADMIN_TOKEN: z.string().min(16),
|
||||
|
||||
NEXT_PUBLIC_APP_URL: z.string().url(),
|
||||
PAY_HELP_IMAGE_URL: optionalTrimmedString,
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface CreatePaymentOptions {
|
||||
paymentType: string;
|
||||
clientIp: string;
|
||||
productName: string;
|
||||
returnUrl?: string;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
function normalizeCidList(cid?: string): string | undefined {
|
||||
@@ -56,7 +58,7 @@ export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPay
|
||||
type: opts.paymentType,
|
||||
out_trade_no: opts.outTradeNo,
|
||||
notify_url: env.EASY_PAY_NOTIFY_URL,
|
||||
return_url: env.EASY_PAY_RETURN_URL,
|
||||
return_url: opts.returnUrl || env.EASY_PAY_RETURN_URL,
|
||||
name: opts.productName,
|
||||
money: opts.amount,
|
||||
clientip: opts.clientIp,
|
||||
@@ -67,6 +69,10 @@ export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPay
|
||||
params.cid = cid;
|
||||
}
|
||||
|
||||
if (opts.isMobile) {
|
||||
params.device = 'mobile';
|
||||
}
|
||||
|
||||
const sign = generateSign(params, env.EASY_PAY_PKEY);
|
||||
params.sign = sign;
|
||||
params.sign_type = 'MD5';
|
||||
@@ -88,8 +94,17 @@ export async function createPayment(opts: CreatePaymentOptions): Promise<EasyPay
|
||||
|
||||
export async function queryOrder(outTradeNo: string): Promise<EasyPayQueryResponse> {
|
||||
const env = assertEasyPayEnv(getEnv());
|
||||
const url = `${env.EASY_PAY_API_BASE}/api.php?act=order&pid=${env.EASY_PAY_PID}&key=${env.EASY_PAY_PKEY}&out_trade_no=${outTradeNo}`;
|
||||
const response = await fetch(url, {
|
||||
// 使用 POST 避免密钥暴露在 URL 中(URL 会被记录到服务器/CDN 日志)
|
||||
const params = new URLSearchParams({
|
||||
act: 'order',
|
||||
pid: env.EASY_PAY_PID,
|
||||
key: env.EASY_PAY_PKEY,
|
||||
out_trade_no: outTradeNo,
|
||||
});
|
||||
const response = await fetch(`${env.EASY_PAY_API_BASE}/api.php`, {
|
||||
method: 'POST',
|
||||
body: params,
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
const data = (await response.json()) as EasyPayQueryResponse;
|
||||
|
||||
@@ -28,11 +28,13 @@ export class EasyPayProvider implements PaymentProvider {
|
||||
paymentType: request.paymentType as 'alipay' | 'wxpay',
|
||||
clientIp: request.clientIp || '127.0.0.1',
|
||||
productName: request.subject,
|
||||
returnUrl: request.returnUrl,
|
||||
isMobile: request.isMobile,
|
||||
});
|
||||
|
||||
return {
|
||||
tradeNo: result.trade_no,
|
||||
payUrl: result.payurl,
|
||||
payUrl: (request.isMobile && result.payurl2) || result.payurl,
|
||||
qrCode: result.qrcode,
|
||||
};
|
||||
}
|
||||
@@ -69,10 +71,21 @@ export class EasyPayProvider implements PaymentProvider {
|
||||
throw new Error('EasyPay notification signature verification failed');
|
||||
}
|
||||
|
||||
// 校验 pid 与配置一致,防止跨商户回调注入
|
||||
if (params.pid && params.pid !== env.EASY_PAY_PID) {
|
||||
throw new Error(`EasyPay notification pid mismatch: expected ${env.EASY_PAY_PID}, got ${params.pid}`);
|
||||
}
|
||||
|
||||
// 校验金额为有限正数
|
||||
const amount = parseFloat(params.money || '0');
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
throw new Error(`EasyPay notification invalid amount: ${params.money}`);
|
||||
}
|
||||
|
||||
return {
|
||||
tradeNo: params.trade_no || '',
|
||||
orderId: params.out_trade_no || '',
|
||||
amount: parseFloat(params.money || '0'),
|
||||
amount,
|
||||
status: params.trade_status === 'TRADE_SUCCESS' ? 'success' : 'failed',
|
||||
rawData: params,
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface EasyPayCreateResponse {
|
||||
trade_no: string;
|
||||
O_id?: string;
|
||||
payurl?: string;
|
||||
payurl2?: string;
|
||||
qrcode?: string;
|
||||
img?: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
export function generateRechargeCode(orderId: string): string {
|
||||
const prefix = 's2p_';
|
||||
const maxIdLength = 32 - prefix.length; // 28
|
||||
const truncatedId = orderId.slice(0, maxIdLength);
|
||||
return `${prefix}${truncatedId}`;
|
||||
const random = crypto.randomBytes(4).toString('hex'); // 8 chars
|
||||
const maxIdLength = 32 - prefix.length - random.length; // 16
|
||||
const truncatedId = orderId.replace(/-/g, '').slice(0, maxIdLength);
|
||||
return `${prefix}${truncatedId}${random}`;
|
||||
}
|
||||
|
||||
@@ -35,10 +35,10 @@ const ROUND_UP = 0;
|
||||
* feeAmount = ceil(rechargeAmount * feeRate / 100, 保留2位小数)
|
||||
* payAmount = rechargeAmount + feeAmount
|
||||
*/
|
||||
export function calculatePayAmount(rechargeAmount: number, feeRate: number): number {
|
||||
if (feeRate <= 0) return rechargeAmount;
|
||||
export function calculatePayAmount(rechargeAmount: number, feeRate: number): string {
|
||||
if (feeRate <= 0) return rechargeAmount.toFixed(2);
|
||||
const amount = new Prisma.Decimal(rechargeAmount);
|
||||
const rate = new Prisma.Decimal(feeRate.toString());
|
||||
const feeAmount = amount.mul(rate).div(100).toDecimalPlaces(2, ROUND_UP);
|
||||
return amount.plus(feeAmount).toNumber();
|
||||
return amount.plus(feeAmount).toFixed(2);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user