51 Commits

Author SHA1 Message Date
erio
a7089936a4 fix: 修复页面加载时闪现「入口未开放」的问题
allEntriesClosed 判断需要等 userLoaded 和 channelsLoaded 都完成,
避免 channelsLoaded 先完成但 config 还未加载时误判为入口关闭。
2026-03-15 12:03:27 +08:00
erio
6bca9853b3 style: fix prettier formatting in user route 2026-03-15 03:14:47 +08:00
erio
33e4a811f3 fix: 提取 resolveEnabledPaymentTypes 共享函数,下单接口同步校验 + 恢复并发
- 将 resolveEnabledPaymentTypes 提取到 src/lib/payment/resolve-enabled-types.ts
- /api/orders 下单时也校验 ENABLED_PAYMENT_TYPES 配置,防止绕过前端直接调用
- /api/user 恢复 queryMethodLimits 与 getUser 并发执行,避免性能退化
2026-03-15 02:56:28 +08:00
eriol touwa
0a94cecad8 Merge pull request #10 from gopkg-dev/main
fix: ENABLED_PAYMENT_TYPES configuration not being applied correctly
2026-03-15 02:54:22 +08:00
Karen
b3730b567e Merge pull request #1 from gopkg-dev/copilot/fix-enabled-payment-types-issue
Honor `ENABLED_PAYMENT_TYPES` in `/api/user` response config
2026-03-14 22:08:52 +08:00
copilot-swe-agent[bot]
9af7133d93 test: cover ENABLED_PAYMENT_TYPES fallback paths
Co-authored-by: gopkg-dev <58848833+gopkg-dev@users.noreply.github.com>
2026-03-14 13:52:02 +00:00
copilot-swe-agent[bot]
1f2d0499ed fix: honor ENABLED_PAYMENT_TYPES in user config api
Co-authored-by: gopkg-dev <58848833+gopkg-dev@users.noreply.github.com>
2026-03-14 13:49:40 +00:00
copilot-swe-agent[bot]
ae3aa2e0e4 Initial plan 2026-03-14 13:46:39 +00:00
erio
48244f270b ci: GitHub Actions 自动构建推送 Docker 镜像到 Docker Hub 2026-03-14 14:09:09 +08:00
erio
6dd7583b6c style: prettier 格式化 2026-03-14 05:10:28 +08:00
erio
bd1db1efd8 feat: 套餐分组清理 + 续费延期 + UI统一
- Schema: groupId 改为 nullable,新增迁移
- GET 套餐列表自动检测并清除 Sub2API 中已删除的分组绑定
- PUT 保存时校验分组存在性,已删除则自动解绑并返回 409
- 续费逻辑:同分组有活跃订阅时从到期日计算天数再 createAndRedeem
- 提取 PlanInfoDisplay 共享组件,SubscriptionConfirm 复用
- 默认模型统一到 /v1/messages badge 内
- 前端编辑表单适配 nullable groupId,未绑定时禁用保存
2026-03-14 05:06:36 +08:00
erio
ef4241b82f fix: admin布局间隙主题适配 + 默认模型移至/v1/messages badge内
- AdminLayout添加主题背景色包裹,消除tab与内容间的未适配间隙
- 缩小nav底部margin (mb-4 → mb-1)
- SubscriptionPlanCard默认模型从独立区块移到/v1/messages badge后面
2026-03-14 04:42:39 +08:00
erio
4ce3484179 fix: 全面安全审计修复 — 支付验签、IDOR、竞态、token过期等
- H1: 支付宝响应验签 (verifyResponseSign + bracket-matching 提取签名内容)
- H2/H3: EasyPay queryOrder 从 GET 改 POST,PKEY 不再暴露于 URL
- H5: users/[id] IDOR 修复,校验当前用户只能查询自身信息
- H6: 限额校验移入 prisma.$transaction() 防止 TOCTOU 竞态
- C1: access_token 增加 24h 过期、userId 绑定、派生密钥分离
- M1: EasyPay 回调增加 pid 校验防跨商户注入
- M4: 充值码增加 crypto.randomBytes 随机后缀
- M5: 过期订单批量处理增加 BATCH_SIZE 限制
- M6: 退款失败增加 [CRITICAL] 日志和余额补偿标记
- M7: admin channels PUT 增加 Zod schema 校验
- M8: admin subscriptions 分页参数增加上限
- M9: orders src_url 限制 HTTP/HTTPS 协议
- L1: 微信支付回调时间戳 NaN 检查
- L9: WXPAY_API_V3_KEY 长度校验
2026-03-14 04:36:33 +08:00
erio
34ad876626 docs: 合并重复的管理后台章节,保留集成说明中的完整列表 2026-03-14 04:09:02 +08:00
erio
e98f01f472 docs: ZPay推荐补充明文URL显示 2026-03-14 04:07:53 +08:00
erio
8f0ec3d9de docs: EasyPay描述改为代收/转发官方,强调资金直达自己账户 2026-03-14 04:06:31 +08:00
erio
b44a8db9ef docs: 推荐区块精简,去掉重复的资金安全提示 2026-03-14 04:00:40 +08:00
erio
ac70635879 docs: 免责声明合并到推荐区块,EasyPay章节精简 2026-03-14 03:58:46 +08:00
erio
87a6237a8f docs: ZPay推荐从EasyPay章节移到功能特性下方,更醒目 2026-03-14 03:57:55 +08:00
erio
6d01fcb822 docs: README重构 — EasyPay前置、支付宝/微信改称官方、集成说明增加管理后台端点
- GitHub About 双语描述
- "Stripe 国际信用卡" → "Stripe"
- EasyPay 放在支付服务商最前面
- "支付宝直连" → "支付宝官方"、"微信支付直连" → "微信官方"
- 集成到 Sub2API 章节重写:用户端页面 + 管理后台端点完整列表
- 管理后台集成无需带 query 参数
- "使用第三方聚合支付时" → "使用第三方平台时"
- 中英文 README 同步更新
2026-03-14 03:53:20 +08:00
erio
886389939e style: format all files with Prettier 2026-03-14 03:45:37 +08:00
erio
78ecd206de fix: resolve GitHub Actions CI failures (lint, typecheck, test, format)
- Remove unused `Locale` type imports from admin pages
- Fix type annotations in easy-pay client test (CID fields as string | undefined)
- Replace `as any` with proper types in limits test
- Fix listSubscriptions test mock response structure (data.data.items)
- Fix formatting with Prettier
2026-03-14 03:42:16 +08:00
erio
d30c663f29 docs: README补充EasyPay安全提示和ZPay个人/企业额度说明 2026-03-14 03:33:29 +08:00
erio
48e94c205a fix: 暗色主题二轮修复 - 支付页面、订单状态、Stripe弹窗、Fallback组件
- PaymentForm: 快速金额选中暗色态、禁用按钮暗色态
- PaymentQRCode: Stripe错误框、成功图标、取消按钮暗色适配
- MobileOrderList: 底部文字对比度修复(slate-600→slate-400)
- OrderStatus: getStatusConfig增加isDark参数,所有状态色适配暗色
- result/page: getStatusConfig传isDark、链接按钮暗色、Fallback暗色背景
- stripe-popup/page: 金额/成功图标/链接/禁用按钮/Fallback暗色适配
- pay/page: 帮助图片容器、错误提示、Fallback暗色背景
- orders/page: 错误提示、Fallback暗色背景
- README: 补充lang参数说明
2026-03-14 03:27:24 +08:00
erio
3b5a3ba5df fix: 暗色主题全面优化 - 滚动条、颜色适配、dark:伪类修复
- 添加全局暗色滚动条样式 (globals.css)
- 修复 channels/subscriptions 页面 dark: 伪类无效的bug (改用 isDark)
- 修复 RefundDialog 暗色下退款金额、disabled 按钮颜色
- 修复 admin OrderTable 暗色下缺少背景色
- 统一所有 admin 页面 text-gray-500 为 slate 色系
- 修复 UserSubscriptions 续费按钮暗色适配
- 修复日期标签文字缺少暗色颜色类
- MainTabs 暗色容器改用 slate-900 提升对比度
- OpenAI 默认模型区域暗色边框透明度提升
- 更新 README 文档
2026-03-14 03:19:39 +08:00
erio
8a5f8662d0 fix: 暗色主题下平台颜色降低亮度,按钮和文字更柔和 2026-03-14 03:09:29 +08:00
erio
5050544d4b test: 补充订单费用和限额单元测试 2026-03-14 02:22:31 +08:00
erio
45713aeb57 fix: 订阅套餐卡片也适配平台颜色(按钮、倍率、价格) 2026-03-14 02:11:06 +08:00
erio
8dd0d1144b feat: 页面标题/副标题更新、按钮和倍率跟随平台颜色、messages调度移到标题行、返回按钮统一
1. 标题改为"选择适合你的 充值/订阅服务",副标题改为"充值余额或者订阅套餐"
2. ChannelCard: 按钮bg、倍率数字、额度描述 跟随平台颜色
3. platform-style: 新增 button + accent 字段
4. SubscriptionPlanCard/SubscriptionConfirm: /v1/messages 调度 badge 移到标题行
5. 充值返回按钮样式与订阅确认页统一(灰色+箭头图标)
2026-03-14 02:08:14 +08:00
erio
5ce7ba3cb8 fix: tab名称改为余额充值/套餐订阅、有效期直接显示配置值、模型标签跟随平台颜色
1. MainTabs: "按量付费"→"余额充值","包月套餐"→"套餐订阅"
2. subscription-utils: 去掉所有智能转换,配置什么就显示什么(1月/30天/2周)
3. platform-style: 新增 modelTag 样式字段,每个平台独立颜色
4. ChannelCard: 模型标签颜色跟随平台(橙/绿/蓝/粉/紫)而非统一蓝色
2026-03-14 02:02:03 +08:00
erio
d8078eb38c fix: 有效期30天不再显示"包月"、我的订阅移到tab外、确认订单展示完整信息
1. subscription-utils: day=30 不再特殊处理为"包月",直接显示"30天"
2. pay page: "我的订阅"移到 tab 外部,按量付费和包月套餐下都可见
3. SubscriptionConfirm: 展示平台badge、倍率、限额grid、OpenAI messages调度信息
2026-03-14 01:57:09 +08:00
erio
14ec33fc69 fix: 清空搜索关键词后查询所有订阅而非保留上次用户
- handleKeywordChange 清空时同步清空 subsUserId
- fetchSubs 在无 user_id 时不传该参数,查询所有订阅
2026-03-14 01:52:22 +08:00
erio
aeaa09d2c1 fix: searchUsers 也修复分页响应解析(data.data.items) 2026-03-14 01:47:57 +08:00
erio
10e9189bcb fix: 修复订阅列表API响应解析和管理后台messages调度字段缺失
1. listSubscriptions 解析 data.data.items 而非 data.data(Sub2API分页格式)
2. 管理后台套餐API返回 groupAllowMessagesDispatch 和 groupDefaultMappedModel
2026-03-14 01:46:37 +08:00
erio
4427c17417 feat: 渠道/订阅管理UI优化 — 平台图标、布局改善、分组信息卡片
- 渠道管理:平台列用 PlatformBadge 带图标,分类下拉显示 label
- 渠道管理:添加 antigravity/anthropic 平台选项
- 订阅管理:/v1/messages 调度改为"已启用/未启用"文字
- 订阅管理:编辑 modal 选择分组后展示只读分组信息卡片
- 订阅管理:有效期+单位独立一行,排序单独一行
2026-03-14 01:34:49 +08:00
erio
6e0fe54720 test: 补充 platform-style 和 listSubscriptions 单元测试
- platform-style: getPlatformStyle/PlatformBadge/PlatformIcon 共 17 个测试
- listSubscriptions: URL 拼接、响应解析、错误处理共 4 个测试
2026-03-14 01:26:34 +08:00
erio
1218b31461 feat: 订阅套餐展示优化、平台图标、默认月、用户订阅查询
- 新建共享平台样式模块 platform-style.ts,含各平台 SVG 图标 + 彩色 badge
- SubscriptionPlanCard 重设计:平台图标 badge、倍率/限额 grid 展示、OpenAI messages 调度信息
- UserSubscriptions 显示 group_name + 平台 badge
- ChannelCard 复用共享平台样式模块
- 管理后台:新建套餐默认 1 月、去掉模型展示、平台图标 badge、OpenAI 信息
- 管理后台用户订阅 tab 默认查询所有订阅(user_id 可选)
- Sub2API client 新增 listSubscriptions 函数
- API 返回 allowMessagesDispatch / defaultMappedModel / group_name / platform
2026-03-14 01:23:21 +08:00
erio
10e3e445ed fix: EasyPay支付回调缺少access_token导致结果页显示"凭证缺失"
EasyPay provider未将动态returnUrl传递给底层client,
导致return_url使用静态环境变量,不含order_id和access_token参数。
2026-03-14 01:07:33 +08:00
erio
ce223f09d8 fix: 所有支付入口关闭时正确显示"入口未开放"提示
当没有可用支付方式(未配置或被禁用)且没有订阅套餐时,
显示"充值/订阅 入口未开放"提示,而不是空的订阅UI
2026-03-14 00:54:05 +08:00
erio
405fab8c8d fix: Dockerfile ADMIN_TOKEN dummy value too short for zod validation 2026-03-14 00:47:21 +08:00
erio
6c61c3f877 feat: 订阅管理增强、商品名称配置、余额充值开关
- R1: 用户订阅搜索改为模糊关键词(邮箱/用户名/备注/APIKey)
- R2: "分组状态"列名改为"Sub2API 状态"
- R3: 订阅套餐可配置支付商品名称(productName)
- R4: 订阅订单校验 subscription_type 必须为 subscription
- R5: 渠道管理配置余额充值商品名前缀/后缀
- R6: 渠道管理可关闭余额充值,前端隐藏入口,API 拒绝
- R7: 所有入口关闭时显示"入口被管理员关闭"提示
- fix: easy-pay client 测试 mock 方式修复(vi.fn + 参数快照)
2026-03-14 00:43:00 +08:00
erio
1bb11ee32b feat: 金额上限校验、订阅详情展示优化、支付商品名称区分
- 硬编码 MAX_AMOUNT=99999999.99,所有金额输入(API+前端)统一校验上限
- 管理后台订阅列表改为卡片布局,Sub2API 分组信息嵌套只读展示(平台/倍率/限额/模型)
- 用户端套餐卡片和确认页展示平台、倍率、用量限制
- 订阅订单支付商品名改为 "Sub2API 订阅 {分组名}",余额充值保持原格式
2026-03-13 23:40:23 +08:00
erio
ca03a501f2 fix: 全面安全审计修复
安全加固:
- 系统配置 API 增加写入 key 白名单,防止任意配置注入
- ADMIN_TOKEN 最小长度要求 16 字符
- 补充安全响应头(X-Content-Type-Options, X-Frame-Options, Referrer-Policy)
- /api/users/[id] 和 /api/limits 增加 token 鉴权
- console.error 敏感信息脱敏(config route)
- 敏感值 mask 修复短值完全隐藏

输入校验:
- admin 渠道接口校验 rate_multiplier > 0、sort_order >= 0、name 非空
- admin 订阅套餐接口校验 price > 0、validity_days > 0、sort_order >= 0

金额精度:
- feeRate 字段精度从 Decimal(5,2) 提升到 Decimal(5,4)
- calculatePayAmount 返回 string 避免 Number 中间转换精度丢失
- 支付宝查询订单增加金额有效性校验(isFinite && > 0)

UI 统一:
- 订阅管理「售卖」列改为 toggle switch 开关(与渠道管理一致)
- 表单中 checkbox 改为 toggle switch
- 列名统一为「启用售卖」,支持直接点击切换
2026-03-13 23:03:01 +08:00
erio
38156bd4ef fix: 订阅套餐 API features 字段序列化及返回格式修复
- POST/PUT 写入前 JSON.stringify(features),修复数组写入 TEXT 列报错
- GET/POST/PUT 返回时统一字段映射(validDays/enabled/groupName/features 数组)
2026-03-13 22:30:20 +08:00
erio
bc9ae8370c fix: /admin 直接显示数据概览,去掉管理首页导航项 2026-03-13 22:15:19 +08:00
erio
b1c90d4b04 refactor: /admin 聚合入口(数据概览优先),订单管理移至 /admin/orders 2026-03-13 22:08:14 +08:00
erio
41f7059585 revert: 去掉总览入口,/admin 直接显示订单管理 2026-03-13 22:03:06 +08:00
erio
1399cd277b refactor: /admin 改为聚合入口页面,订单管理移至 /admin/orders
- 新建 /admin/orders 页面(原 /admin 订单管理内容)
- /admin 重写为卡片式导航入口(订单/数据/渠道/订阅)
- 导航栏新增"总览"项,更新所有交叉引用链接
- README 管理后台章节同步更新
2026-03-13 21:54:37 +08:00
erio
687336cfd8 feat: 套餐有效期支持日/周/月单位,订阅履约改用兑换码流程,UI层次感优化
- Prisma: SubscriptionPlan 新增 validityUnit 字段 (day/week/month)
- 新增 subscription-utils.ts 计算实际天数及格式化显示
- Sub2API client createAndRedeem 支持 subscription 类型 (group_id, validity_days)
- 订阅履约从 assignSubscription 改为 createAndRedeem,在 Sub2API 留痕
- 订单创建动态计算天数(月单位按自然月差值)
- 管理后台表单支持有效期数值+单位下拉
- 前端 ChannelCard 渠道卡片视觉层次优化(模型标签渐变、倍率突出、闪电图标)
- 按量付费 banner 改为渐变背景+底部倍率说明标签
- 帮助/客服信息区块添加到充值、订阅、支付全流程页面
- 移除系统配置独立页面入口,subscriptions API 返回用户信息
2026-03-13 21:19:22 +08:00
erio
9096271307 fix: 禁止所有模态框点击外部关闭,防止数据丢失
移除 channels 编辑/同步、TopUpModal、OrderDetail、RefundDialog
四处模态框 backdrop 上的 onClick 关闭行为及 stopPropagation,
用户只能通过显式关闭按钮或取消按钮关闭弹窗。

同时新增支付宝直连和微信支付直连接入文档。
2026-03-13 19:51:20 +08:00
erio
eafb7e49fa feat: 渠道展示、订阅套餐、系统配置全功能
- 新增 Channel / SubscriptionPlan / SystemConfig 三个数据模型
- Order 模型扩展支持订阅订单(order_type, plan_id, subscription_group_id)
- Sub2API client 新增分组查询、订阅分配/续期、用户订阅查询
- 订单服务支持订阅履约流程(CAS 锁 + 分组消失安全处理)
- 管理后台:渠道管理、订阅套餐管理、系统配置、Sub2API 分组同步
- 用户页面:双 Tab UI(按量付费/包月订阅)、渠道卡片、充值弹窗、订阅确认
- PaymentForm 支持 fixedAmount 固定金额模式
- 订单状态 API 返回 failedReason 用于订阅异常展示
- 数据库迁移脚本
2026-03-13 19:06:25 +08:00
92 changed files with 9436 additions and 816 deletions

429
.claude/plan.md Normal file
View 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') {
// 标记 FAILEDreason = "订阅分组已不存在"
// 前端展示常驻错误提示
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. 用户 APIchannels、subscription-plans、subscriptions/my
11. 按量付费 TabChannelGrid + TopUpModal
12. 包月套餐 TabSubscriptionPlanCard + SubscriptionConfirm
13. 用户订阅展示 + 续费 + 异常处理
### Phase 5配置迁移 & 收尾(预估 1-2 步)
14. getEnv() 改造(数据库优先 + 环境变量回退)
15. 测试 + 端到端验证
---
## 十一、安全考虑
1. **订阅分组校验**:每次展示和下单都实时校验 Sub2API 分组是否存在且 active
2. **价格篡改防护**:订阅订单金额从服务端 SubscriptionPlan.price 读取,不信任客户端传值
3. **支付后分组消失**:订单标记 FAILED + 常驻错误提示 + 审计日志,不自动退款(需人工确认)
4. **敏感配置**:支付密钥在 API 响应中脱敏,前端仅展示 `****...最后4位`
5. **幂等性**:订阅分配使用 `orderId` 作为幂等 key防止重复分配

View File

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

View File

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

View File

@@ -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>
![ZPay Preview](./docs/zpay-preview.png)
</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>
![ZPay Preview](./docs/zpay-preview.png)
</details>
> **Disclaimer**: Please evaluate the security, reliability, and compliance of any third-party payment provider on your own. This project does not endorse or guarantee any specific provider.
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 |
@@ -150,13 +173,17 @@ 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` |
| `ORDER_TIMEOUT_MINUTES` | Order expiry in minutes | `5` |
| `PRODUCT_NAME` | Product name shown on payment page | `Sub2API Balance Recharge` |
| Variable | Description | Default |
| -------------------------------- | ------------------------------------------------- | -------------------------- |
| `MIN_RECHARGE_AMOUNT` | Minimum amount per transaction (CNY) | `1` |
| `MAX_RECHARGE_AMOUNT` | Maximum amount per transaction (CNY) | `1000` |
| `MAX_DAILY_RECHARGE_AMOUNT` | Daily cumulative max per user (`0` = unlimited) | `10000` |
| `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` |
### UI Customization (Optional)
@@ -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
Stripe → Payment Element (PaymentIntent)
├─ 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

213
README.md
View File

@@ -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>
![ZPay 预览](./docs/zpay-preview.png)
</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, wxpayStripe 支持: stripe
ENABLED_PAYMENT_TYPES=alipay,wxpay
```
#### EasyPay支付宝 / 微信支付聚合)
#### EasyPay支付宝 / 微信支付)
支付提供商只需兼容**易支付EasyPay协议**即可接入,例如 [ZPay](https://z-pay.cn/?uid=23808)`https://z-pay.cn/?uid=23808`)等平台(链接含本项目作者的邀请码,介意可去掉)。
<details>
<summary>ZPay 申请二维码</summary>
![ZPay 预览](./docs/zpay-preview.png)
</details>
> **注意**:支付渠道的安全性、稳定性及合规性请自行鉴别,本项目不对任何第三方支付服务商做担保或背书。
任何兼容**易支付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
| 变量 | 说明 |
@@ -150,13 +173,17 @@ ENABLED_PAYMENT_TYPES=alipay,wxpay
### 业务规则
| 变量 | 说明 | 默认值 |
| --------------------------- | ---------------------------------- | -------------------------- |
| `MIN_RECHARGE_AMOUNT` | 单笔最低充值金额(元) | `1` |
| `MAX_RECHARGE_AMOUNT` | 单笔最高充值金额(元) | `1000` |
| `MAX_DAILY_RECHARGE_AMOUNT` | 每日累计最高充值(元,`0` = 不限) | `10000` |
| `ORDER_TIMEOUT_MINUTES` | 订单超时分钟数 | `5` |
| `PRODUCT_NAME` | 充值商品名称(显示在支付页) | `Sub2API Balance Recharge` |
| 变量 | 说明 | 默认值 |
| -------------------------------- | ---------------------------------------- | -------------------------- |
| `MIN_RECHARGE_AMOUNT` | 单笔最低充值金额(元) | `1` |
| `MAX_RECHARGE_AMOUNT` | 单笔最高充值金额(元) | `1000` |
| `MAX_DAILY_RECHARGE_AMOUNT` | 每日每用户累计最高充值(元,`0` = 不限) | `10000` |
| `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` |
### UI 定制(可选)
@@ -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 跳转
Stripe → Payment Element (PaymentIntent)
├─ 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
View 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
View 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 年后推出的公钥模式,区别于之前的平台证书模式。如果你的商户号不支持公钥模式,需要联系微信支付升级。

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
docs/refrence/subscribe.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

BIN
docs/refrence/top-up.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

View File

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

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "subscription_plans" ADD COLUMN "validity_unit" TEXT NOT NULL DEFAULT 'day';

View File

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

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "subscription_plans" ADD COLUMN "product_name" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable: make group_id nullable on subscription_plans
ALTER TABLE "subscription_plans" ALTER COLUMN "group_id" DROP NOT NULL;

View File

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

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

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
import { ORDER_STATUS } from '@/lib/constants';
@@ -52,10 +52,15 @@ function createPendingOrder(overrides: Record<string, unknown> = {}) {
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);

View File

@@ -15,6 +15,7 @@ const { mockGenerateSign } = vi.hoisted(() => ({
}));
vi.mock('@/lib/alipay/sign', () => ({
generateSign: mockGenerateSign,
verifyResponseSign: vi.fn(() => true),
}));
import { execute, pageExecute } from '@/lib/alipay/client';

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

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

View File

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

View 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.99Decimal(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}$/);
});
});
});

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

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -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: {
@@ -103,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>
);
@@ -143,7 +143,7 @@ function DashboardContent() {
{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}>
@@ -185,7 +185,7 @@ 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>
);
}

77
src/app/admin/layout.tsx Normal file
View 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 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>
);
}

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

View File

@@ -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;
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 DashboardData {
summary: {
today: { amount: number; orderCount: number; paidCount: number };
total: { amount: number; orderCount: number; paidCount: number };
successRate: number;
avgAmount: number;
};
dailySeries: { date: string; amount: number; count: number }[];
leaderboard: {
userId: number;
userName: string | null;
userEmail: string | null;
totalAmount: number;
orderCount: number;
}[];
paymentMethods: { paymentType: string; amount: number; count: number; percentage: number }[];
meta: { days: number; generatedAt: string };
}
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';
@@ -59,81 +46,39 @@ function AdminContent() {
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);
@@ -141,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 = [
@@ -230,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}
@@ -240,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>
</>
@@ -260,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>
<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} />
)}
{loading ? (
<div className={`py-24 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.loading}</div>
) : data ? (
<div className="space-y-6">
<DashboardStats summary={data.summary} dark={isDark} locale={locale} />
<DailyChart data={data.dailySeries} dark={isDark} locale={locale} />
<div className="grid gap-6 lg:grid-cols-2">
<Leaderboard data={data.leaderboard} dark={isDark} locale={locale} />
<PaymentMethodChart data={data.paymentMethods} dark={isDark} locale={locale} />
</div>
</div>
) : null}
</PayPageLayout>
);
}
function 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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
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.array(z.string()).nullable().optional(),
features: z.record(z.string(), z.unknown()).nullable().optional(),
sort_order: z.number().int().min(0).optional(),
enabled: z.boolean().optional(),
})
.strict();
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 });
}
}

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

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

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

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

View File

@@ -0,0 +1,138 @@
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 });
}
const data: Record<string, unknown> = {};
if (body.group_id !== undefined) data.groupId = Number(body.group_id);
if (body.name !== undefined) data.name = body.name;
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 });
}
}

View File

@@ -0,0 +1,166 @@
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 || !name || price === undefined) {
return NextResponse.json({ error: '缺少必填字段: group_id, name, price' }, { 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,
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 });
}
}

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

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

View File

@@ -1,11 +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:
* {
@@ -17,7 +18,18 @@ import { getNextBizDayStartUTC } from '@/lib/time/biz-day';
* 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 methods = await queryMethodLimits(types);

View File

@@ -29,6 +29,7 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
expiresAt: true,
paidAt: true,
completedAt: true,
failedReason: true,
},
});
@@ -45,5 +46,6 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
paymentSuccess: derived.paymentSuccess,
rechargeSuccess: derived.rechargeSuccess,
rechargeStatus: derived.rechargeStatus,
failedReason: order.failedReason ?? null,
});
}

View File

@@ -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 (amount < env.MIN_RECHARGE_AMOUNT || amount > env.MAX_RECHARGE_AMOUNT) {
return NextResponse.json(
{ error: `充值金额需在 ${env.MIN_RECHARGE_AMOUNT} - ${env.MAX_RECHARGE_AMOUNT} 之间` },
{ status: 400 },
);
// 订阅订单跳过金额范围校验(价格由服务端套餐决定)
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 等隐私字段

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

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

View File

@@ -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'));
@@ -39,8 +41,19 @@ export async function GET(request: NextRequest) {
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> = {};
@@ -84,6 +97,7 @@ export async function GET(request: NextRequest) {
helpText: env.PAY_HELP_TEXT ?? null,
stripePublishableKey:
enabledTypes.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY ? env.STRIPE_PUBLISHABLE_KEY : null,
balanceDisabled,
sublabelOverrides: Object.keys(sublabelOverrides).length > 0 ? sublabelOverrides : null,
},
});

View File

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

View File

@@ -16,3 +16,31 @@ body {
'Microsoft YaHei',
sans-serif;
}
/* Scrollbar - Dark theme */
* {
scrollbar-width: thin;
scrollbar-color: #475569 #1e293b;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: #1e293b;
}
*::-webkit-scrollbar-thumb {
background: #475569;
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
*::-webkit-scrollbar-corner {
background: #1e293b;
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -54,12 +54,17 @@ function closeCurrentWindow() {
}, 250);
}
function getStatusConfig(order: PublicOrderStatusSnapshot | null, locale: Locale, hasAccessToken: boolean) {
function getStatusConfig(
order: PublicOrderStatusSnapshot | null,
locale: Locale,
hasAccessToken: boolean,
isDark = false,
) {
if (!order) {
return locale === 'en'
? {
label: 'Payment Error',
color: 'text-red-600',
color: isDark ? 'text-red-400' : 'text-red-600',
icon: '✗',
message: hasAccessToken
? 'Unable to load the order status. Please try again later.'
@@ -67,7 +72,7 @@ function getStatusConfig(order: PublicOrderStatusSnapshot | null, locale: Locale
}
: {
label: '支付异常',
color: 'text-red-600',
color: isDark ? 'text-red-400' : 'text-red-600',
icon: '✗',
message: hasAccessToken ? '未查询到订单状态,请稍后重试。' : '订单访问凭证缺失,请返回原充值页查看订单结果。',
};
@@ -77,11 +82,16 @@ function getStatusConfig(order: PublicOrderStatusSnapshot | null, locale: Locale
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 successfully.',
}
: { label: '充值成功', color: 'text-green-600', icon: '✓', message: '余额已成功到账!' };
: {
label: '充值成功',
color: isDark ? 'text-green-400' : 'text-green-600',
icon: '✓',
message: '余额已成功到账!',
};
}
if (order.paymentSuccess) {
@@ -89,25 +99,30 @@ function getStatusConfig(order: PublicOrderStatusSnapshot | null, locale: Locale
return locale === 'en'
? {
label: 'Top-up Processing',
color: 'text-blue-600',
color: isDark ? 'text-blue-400' : 'text-blue-600',
icon: '⟳',
message: 'Payment succeeded, and the balance top-up is being processed.',
}
: { label: '充值处理中', color: 'text-blue-600', icon: '⟳', message: '支付成功,余额正在充值中...' };
: {
label: '充值处理中',
color: isDark ? 'text-blue-400' : 'text-blue-600',
icon: '⟳',
message: '支付成功,余额正在充值中...',
};
}
if (order.rechargeStatus === 'failed') {
return locale === 'en'
? {
label: 'Payment Successful',
color: 'text-amber-600',
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: 'text-amber-600',
color: isDark ? 'text-amber-400' : 'text-amber-600',
icon: '!',
message: '支付成功,但余额充值暂未完成,请稍后查看订单结果或联系管理员。',
};
@@ -116,30 +131,60 @@ function getStatusConfig(order: PublicOrderStatusSnapshot | null, locale: Locale
if (order.status === 'PENDING') {
return locale === 'en'
? { label: 'Awaiting Payment', color: 'text-yellow-600', icon: '⏳', message: 'The order has not been paid yet.' }
: { label: '等待支付', color: 'text-yellow-600', icon: '⏳', message: '订单尚未完成支付。' };
? {
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.',
}
: { label: '订单已超时', color: 'text-gray-500', icon: '⏰', message: '订单已超时,请重新充值。' };
: {
label: '订单已超时',
color: isDark ? 'text-slate-400' : 'text-gray-500',
icon: '⏰',
message: '订单已超时,请重新充值。',
};
}
if (order.status === 'CANCELLED') {
return locale === 'en'
? { label: 'Order Cancelled', color: 'text-gray-500', icon: '✗', message: 'This order has been cancelled.' }
: { label: '订单已取消', color: 'text-gray-500', icon: '✗', message: '订单已被取消。' };
? {
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: 'text-red-600', icon: '✗', message: 'Please contact the administrator.' }
: { label: '支付异常', color: 'text-red-600', icon: '✗', message: '请联系管理员处理。' };
? {
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() {
@@ -233,7 +278,7 @@ function ResultContent() {
);
}
const display = getStatusConfig(orderState, locale, Boolean(accessToken));
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'}`}>
@@ -254,14 +299,18 @@ function ResultContent() {
<button
type="button"
onClick={closeCurrentWindow}
className="text-sm text-blue-600 underline hover:text-blue-700"
className={`text-sm underline ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
>
{text.closeNow}
</button>
</div>
)
) : (
<button type="button" onClick={goBack} className="mt-4 text-sm text-blue-600 underline hover:text-blue-700">
<button
type="button"
onClick={goBack}
className={`mt-4 text-sm underline ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
>
{text.back}
</button>
)}
@@ -277,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>
);
}

View File

@@ -195,7 +195,7 @@ 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>
@@ -213,7 +213,7 @@ function StripePopupContent() {
<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>
@@ -235,7 +235,7 @@ 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>
@@ -251,12 +251,12 @@ function StripePopupContent() {
</div>
) : stripeSuccess ? (
<div className="py-6 text-center">
<div className="text-5xl text-green-600">{'✓'}</div>
<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>
@@ -280,7 +280,11 @@ function StripePopupContent() {
onClick={handleSubmit}
className={[
'w-full rounded-lg py-3 font-medium text-white shadow-md transition-colors',
stripeSubmitting ? 'bg-gray-400 cursor-not-allowed' : getPaymentMeta('stripe').buttonClass,
stripeSubmitting
? isDark
? 'bg-slate-700 text-slate-400 cursor-not-allowed'
: 'bg-gray-400 cursor-not-allowed'
: getPaymentMeta('stripe').buttonClass,
].join(' ')}
>
{stripeSubmitting ? (
@@ -302,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>
);
}

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

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

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

View File

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

View File

@@ -15,16 +15,21 @@ interface OrderStatusProps {
locale?: Locale;
}
function getStatusConfig(order: PublicOrderStatusSnapshot, locale: Locale) {
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.',
}
: { label: '充值成功', color: 'text-green-600', icon: '✓', message: '余额已到账,感谢您的充值!' };
: {
label: '充值成功',
color: isDark ? 'text-green-400' : 'text-green-600',
icon: '✓',
message: '余额已到账,感谢您的充值!',
};
}
if (order.paymentSuccess) {
@@ -32,25 +37,30 @@ function getStatusConfig(order: PublicOrderStatusSnapshot, locale: Locale) {
return locale === 'en'
? {
label: 'Recharging',
color: 'text-blue-600',
color: isDark ? 'text-blue-400' : 'text-blue-600',
icon: '⟳',
message: 'Payment received. Recharging your balance...',
}
: { label: '充值中', color: 'text-blue-600', icon: '⟳', message: '支付成功,正在充值余额中,请稍候...' };
: {
label: '充值中',
color: isDark ? 'text-blue-400' : 'text-blue-600',
icon: '⟳',
message: '支付成功,正在充值余额中,请稍候...',
};
}
if (order.rechargeStatus === 'failed') {
return locale === 'en'
? {
label: 'Payment Successful',
color: 'text-amber-600',
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: 'text-amber-600',
color: isDark ? 'text-amber-400' : 'text-amber-600',
icon: '!',
message:
'支付已完成,但余额充值暂未完成。系统可能会自动重试,请稍后在订单列表查看;如长时间未到账请联系管理员。',
@@ -62,14 +72,14 @@ function getStatusConfig(order: PublicOrderStatusSnapshot, locale: Locale) {
return locale === 'en'
? {
label: 'Payment Failed',
color: 'text-red-600',
color: isDark ? 'text-red-400' : 'text-red-600',
icon: '✗',
message:
'Payment was not completed. Please try again. If funds were deducted but not credited, contact the administrator.',
}
: {
label: '支付失败',
color: 'text-red-600',
color: isDark ? 'text-red-400' : 'text-red-600',
icon: '✗',
message: '支付未完成,请重新发起支付;如已扣款未到账,请联系管理员处理。',
};
@@ -77,35 +87,60 @@ function getStatusConfig(order: PublicOrderStatusSnapshot, locale: Locale) {
if (order.status === 'PENDING') {
return locale === 'en'
? { label: 'Awaiting Payment', color: 'text-yellow-600', icon: '⏳', message: 'The order has not been paid yet.' }
: { label: '等待支付', color: 'text-yellow-600', icon: '⏳', message: '订单尚未完成支付。' };
? {
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 one.',
}
: { label: '订单超时', color: 'text-gray-500', icon: '⏰', message: '订单已超时,请重新创建订单。' };
: {
label: '订单超时',
color: isDark ? 'text-slate-400' : 'text-gray-500',
icon: '⏰',
message: '订单已超时,请重新创建订单。',
};
}
if (order.status === 'CANCELLED') {
return locale === 'en'
? { label: 'Cancelled', color: 'text-gray-500', icon: '✗', message: 'The order has been cancelled.' }
: { label: '已取消', color: 'text-gray-500', icon: '✗', message: '订单已取消。' };
? {
label: 'Cancelled',
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: '订单已取消。' };
}
return locale === 'en'
? {
label: 'Payment Error',
color: 'text-red-600',
color: isDark ? 'text-red-400' : 'text-red-600',
icon: '✗',
message: 'Payment status is abnormal. Please contact the administrator.',
}
: { label: '支付异常', color: 'text-red-600', icon: '✗', message: '支付状态异常,请联系管理员处理。' };
: {
label: '支付异常',
color: isDark ? 'text-red-400' : 'text-red-600',
icon: '✗',
message: '支付状态异常,请联系管理员处理。',
};
}
export default function OrderStatus({
@@ -156,7 +191,7 @@ export default function OrderStatus({
};
}, [orderId, currentOrder.paymentSuccess, currentOrder.rechargeSuccess, statusAccessToken]);
const config = getStatusConfig(currentOrder, locale);
const config = getStatusConfig(currentOrder, locale, dark);
const doneLabel = locale === 'en' ? 'Done' : '完成';
const backLabel = locale === 'en' ? 'Back to Recharge' : '返回充值';

View File

@@ -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,60 +169,81 @@ export default function PaymentForm({
)}
</div>
<div>
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
{locale === 'en' ? 'Recharge Amount' : '充值金额'}
</label>
<div className="grid grid-cols-3 gap-2">
{QUICK_AMOUNTS.filter((val) => val >= minAmount && val <= effectiveMax).map((val) => (
<button
key={val}
type="button"
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-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
: 'border-gray-200 bg-white text-gray-700 hover:border-gray-300'
}`}
>
¥{val}
</button>
))}
{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>
) : (
<>
<div>
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
{locale === 'en' ? 'Recharge Amount' : '充值金额'}
</label>
<div className="grid grid-cols-3 gap-2">
{QUICK_AMOUNTS.filter((val) => val >= minAmount && val <= effectiveMax).map((val) => (
<button
key={val}
type="button"
onClick={() => handleQuickAmount(val)}
className={`rounded-lg border-2 px-4 py-3 text-center font-medium transition-colors ${
amount === val
? 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'
}`}
>
¥{val}
</button>
))}
</div>
</div>
<div>
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
{locale === 'en' ? 'Custom Amount' : '自定义金额'}
</label>
<div className="relative">
<span
className={['absolute left-3 top-1/2 -translate-y-1/2', dark ? 'text-slate-500' : 'text-gray-400'].join(
' ',
)}
>
¥
</span>
<input
type="text"
inputMode="decimal"
step="0.01"
min={minAmount}
max={effectiveMax}
value={customAmount}
onChange={(e) => handleCustomAmountChange(e.target.value)}
placeholder={`${minAmount} - ${effectiveMax}`}
className={[
'w-full rounded-lg border py-3 pl-8 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500',
dark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-gray-300 bg-white text-gray-900',
].join(' ')}
/>
</div>
</div>
<div>
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
{locale === 'en' ? 'Custom Amount' : '自定义金额'}
</label>
<div className="relative">
<span
className={['absolute left-3 top-1/2 -translate-y-1/2', dark ? 'text-slate-500' : 'text-gray-400'].join(
' ',
)}
>
¥
</span>
<input
type="text"
inputMode="decimal"
step="0.01"
min={minAmount}
max={effectiveMax}
value={customAmount}
onChange={(e) => handleCustomAmountChange(e.target.value)}
placeholder={`${minAmount} - ${effectiveMax}`}
className={[
'w-full rounded-lg border py-3 pl-8 pr-4 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500',
dark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-gray-300 bg-white text-gray-900',
].join(' ')}
/>
</div>
</div>
</>
)}
{customAmount !== '' &&
{!fixedAmount &&
customAmount !== '' &&
!isValid &&
(() => {
const num = parseFloat(customAmount);
@@ -354,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

View File

@@ -391,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>
@@ -409,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>
@@ -465,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>

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

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

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

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

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

View File

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

View File

@@ -85,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),
@@ -128,12 +128,14 @@ 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' : ''}`}>
<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>

View File

@@ -72,22 +72,19 @@ 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">
<div className={['text-lg font-bold', dark ? 'text-red-400' : 'text-red-600'].join(' ')}>
{currency}
{amount.toFixed(2)}
</div>
@@ -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>

View File

@@ -1,7 +1,7 @@
import { getEnv } from '@/lib/config';
import { generateSign } from './sign';
import { generateSign, verifyResponseSign } from './sign';
import type { AlipayResponse } from './types';
import { parseAlipayJsonResponse } from './codec';
import { parseAlipayJsonResponseWithRaw } from './codec';
const GATEWAY = 'https://openapi.alipay.com/gateway.do';
@@ -89,10 +89,20 @@ export async function execute<T extends AlipayResponse>(
signal: AbortSignal.timeout(10_000),
});
const data = await parseAlipayJsonResponse<Record<string, unknown>>(response);
const { data, rawText } = await parseAlipayJsonResponseWithRaw(response);
// 支付宝响应格式:{ "alipay_trade_query_response": { ... }, "sign": "..." }
const responseKey = method.replace(/\./g, '_') + '_response';
// 响应验签:从原始文本中提取 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) {

View File

@@ -102,3 +102,16 @@ export async function parseAlipayJsonResponse<T>(response: Response): Promise<T>
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 };
}

View File

@@ -120,10 +120,15 @@ 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,
};
}

View File

@@ -43,6 +43,73 @@ 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)

View File

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

View File

@@ -8,6 +8,7 @@ export interface CreatePaymentOptions {
paymentType: string;
clientIp: string;
productName: string;
returnUrl?: string;
}
function normalizeCidList(cid?: string): string | undefined {
@@ -56,7 +57,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,
@@ -88,8 +89,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;

View File

@@ -28,6 +28,7 @@ export class EasyPayProvider implements PaymentProvider {
paymentType: request.paymentType as 'alipay' | 'wxpay',
clientIp: request.clientIp || '127.0.0.1',
productName: request.subject,
returnUrl: request.returnUrl,
});
return {
@@ -69,10 +70,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,
};

View File

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

View File

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

View File

@@ -6,14 +6,25 @@ import { getMethodDailyLimit } from './limits';
import { getMethodFeeRate, calculatePayAmount } from './fee';
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
import type { PaymentType, PaymentNotification } from '@/lib/payment';
import { getUser, createAndRedeem, subtractBalance, addBalance } from '@/lib/sub2api/client';
import {
getUser,
createAndRedeem,
subtractBalance,
addBalance,
getGroup,
getUserSubscriptions,
} from '@/lib/sub2api/client';
import { computeValidityDays, type ValidityUnit } from '@/lib/subscription-utils';
import { Prisma } from '@prisma/client';
import { deriveOrderState, isRefundStatus } from './status';
import { pickLocaleText, type Locale } from '@/lib/locale';
import { getBizDayStartUTC } from '@/lib/time/biz-day';
import { buildOrderResultUrl, createOrderStatusAccessToken } from '@/lib/order/status-access';
import { getSystemConfig, getSystemConfigs } from '@/lib/system-config';
const MAX_PENDING_ORDERS = 3;
/** Decimal(10,2) 允许的最大金额 */
export const MAX_AMOUNT = 99999999.99;
function message(locale: Locale, zh: string, en: string): string {
return pickLocaleText(locale, zh, en);
@@ -28,6 +39,9 @@ export interface CreateOrderInput {
srcHost?: string;
srcUrl?: string;
locale?: Locale;
// 订阅订单专用
orderType?: 'balance' | 'subscription';
planId?: string;
}
export interface CreateOrderResult {
@@ -50,89 +64,165 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
const env = getEnv();
const locale = input.locale ?? 'zh';
const todayStart = getBizDayStartUTC();
const orderType = input.orderType ?? 'balance';
// ── 订阅订单前置校验 ──
let subscriptionPlan: {
id: string;
groupId: number | null;
price: Prisma.Decimal;
validityDays: number;
validityUnit: string;
name: string;
productName: string | null;
} | null = null;
let subscriptionGroupName = '';
// R6: 余额充值禁用检查
if (orderType === 'balance') {
const balanceDisabled = await getSystemConfig('BALANCE_PAYMENT_DISABLED');
if (balanceDisabled === 'true') {
throw new OrderError(
'BALANCE_PAYMENT_DISABLED',
message(locale, '余额充值已被管理员关闭', 'Balance recharge has been disabled by the administrator'),
403,
);
}
}
if (orderType === 'subscription') {
if (!input.planId) {
throw new OrderError(
'INVALID_INPUT',
message(locale, '订阅订单必须指定套餐', 'Subscription order requires a plan'),
400,
);
}
const plan = await prisma.subscriptionPlan.findUnique({ where: { id: input.planId } });
if (!plan || !plan.forSale) {
throw new OrderError(
'PLAN_NOT_AVAILABLE',
message(locale, '该套餐不存在或未上架', 'Plan not found or not for sale'),
404,
);
}
// 校验分组绑定有效
if (plan.groupId === null) {
throw new OrderError(
'GROUP_NOT_BOUND',
message(locale, '该套餐尚未绑定分组,无法购买', 'Plan is not bound to a group'),
400,
);
}
// 校验 Sub2API 分组仍然存在
const group = await getGroup(plan.groupId);
if (!group || group.status !== 'active') {
throw new OrderError(
'GROUP_NOT_FOUND',
message(locale, '订阅分组已下架,无法购买', 'Subscription group is no longer available'),
410,
);
}
// R4: 校验分组必须为订阅类型
if (group.subscription_type !== 'subscription') {
throw new OrderError(
'GROUP_TYPE_MISMATCH',
message(locale, '该分组不是订阅类型,无法购买订阅', 'This group is not a subscription type'),
400,
);
}
subscriptionGroupName = group?.name || plan.name;
subscriptionPlan = plan;
// 订阅订单金额使用服务端套餐价格,不信任客户端
input.amount = Number(plan.price);
}
const user = await getUser(input.userId);
if (user.status !== 'active') {
throw new OrderError('USER_INACTIVE', message(locale, '用户账号已被禁用', 'User account is disabled'), 422);
}
const pendingCount = await prisma.order.count({
where: { userId: input.userId, status: ORDER_STATUS.PENDING },
});
if (pendingCount >= MAX_PENDING_ORDERS) {
throw new OrderError(
'TOO_MANY_PENDING',
message(
locale,
`待支付订单过多(最多 ${MAX_PENDING_ORDERS} 笔)`,
`Too many pending orders (${MAX_PENDING_ORDERS})`,
),
429,
);
}
const feeRate = getMethodFeeRate(input.paymentType);
const payAmountStr = calculatePayAmount(input.amount, feeRate);
const payAmountNum = Number(payAmountStr);
// 每日累计充值限额校验0 = 不限制)
if (env.MAX_DAILY_RECHARGE_AMOUNT > 0) {
const dailyAgg = await prisma.order.aggregate({
where: {
userId: input.userId,
status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.RECHARGING, ORDER_STATUS.COMPLETED] },
paidAt: { gte: todayStart },
},
_sum: { amount: true },
const expiresAt = new Date(Date.now() + env.ORDER_TIMEOUT_MINUTES * 60 * 1000);
// 将限额校验与订单创建放在同一个 serializable 事务中,防止并发突破限额
const order = await prisma.$transaction(async (tx) => {
// 待支付订单数限制
const pendingCount = await tx.order.count({
where: { userId: input.userId, status: ORDER_STATUS.PENDING },
});
const alreadyPaid = Number(dailyAgg._sum.amount ?? 0);
if (alreadyPaid + input.amount > env.MAX_DAILY_RECHARGE_AMOUNT) {
const remaining = Math.max(0, env.MAX_DAILY_RECHARGE_AMOUNT - alreadyPaid);
if (pendingCount >= MAX_PENDING_ORDERS) {
throw new OrderError(
'DAILY_LIMIT_EXCEEDED',
'TOO_MANY_PENDING',
message(
locale,
`今日累计充值已达上限,剩余可充值 ${remaining.toFixed(2)} `,
`Daily recharge limit reached. Remaining amount: ${remaining.toFixed(2)} CNY`,
`待支付订单过多(最多 ${MAX_PENDING_ORDERS} 笔)`,
`Too many pending orders (${MAX_PENDING_ORDERS})`,
),
429,
);
}
}
// 渠道每日全平台限额校验0 = 不限)
const methodDailyLimit = getMethodDailyLimit(input.paymentType);
if (methodDailyLimit > 0) {
const methodAgg = await prisma.order.aggregate({
where: {
paymentType: input.paymentType,
status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.RECHARGING, ORDER_STATUS.COMPLETED] },
paidAt: { gte: todayStart },
},
_sum: { amount: true },
});
const methodUsed = Number(methodAgg._sum.amount ?? 0);
if (methodUsed + input.amount > methodDailyLimit) {
const remaining = Math.max(0, methodDailyLimit - methodUsed);
throw new OrderError(
'METHOD_DAILY_LIMIT_EXCEEDED',
remaining > 0
? message(
locale,
`${input.paymentType} 今日剩余额度 ${remaining.toFixed(2)} 元,请减少充值金额或使用其他支付方式`,
`${input.paymentType} remaining daily quota: ${remaining.toFixed(2)} CNY. Reduce the amount or use another payment method`,
)
: message(
locale,
`${input.paymentType} 今日充值额度已满,请使用其他支付方式`,
`${input.paymentType} daily quota is full. Please use another payment method`,
),
429,
);
// 每日累计充值限额校验0 = 不限
if (env.MAX_DAILY_RECHARGE_AMOUNT > 0) {
const dailyAgg = await tx.order.aggregate({
where: {
userId: input.userId,
status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.RECHARGING, ORDER_STATUS.COMPLETED] },
paidAt: { gte: todayStart },
},
_sum: { amount: true },
});
const alreadyPaid = Number(dailyAgg._sum.amount ?? 0);
if (alreadyPaid + input.amount > env.MAX_DAILY_RECHARGE_AMOUNT) {
const remaining = Math.max(0, env.MAX_DAILY_RECHARGE_AMOUNT - alreadyPaid);
throw new OrderError(
'DAILY_LIMIT_EXCEEDED',
message(
locale,
`今日累计充值已达上限,剩余可充值 ${remaining.toFixed(2)}`,
`Daily recharge limit reached. Remaining amount: ${remaining.toFixed(2)} CNY`,
),
429,
);
}
}
}
const feeRate = getMethodFeeRate(input.paymentType);
const payAmount = calculatePayAmount(input.amount, feeRate);
// 渠道每日全平台限额校验0 = 不限)
const methodDailyLimit = getMethodDailyLimit(input.paymentType);
if (methodDailyLimit > 0) {
const methodAgg = await tx.order.aggregate({
where: {
paymentType: input.paymentType,
status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.RECHARGING, ORDER_STATUS.COMPLETED] },
paidAt: { gte: todayStart },
},
_sum: { amount: true },
});
const methodUsed = Number(methodAgg._sum.amount ?? 0);
if (methodUsed + input.amount > methodDailyLimit) {
const remaining = Math.max(0, methodDailyLimit - methodUsed);
throw new OrderError(
'METHOD_DAILY_LIMIT_EXCEEDED',
remaining > 0
? message(
locale,
`${input.paymentType} 今日剩余额度 ${remaining.toFixed(2)} 元,请减少充值金额或使用其他支付方式`,
`${input.paymentType} remaining daily quota: ${remaining.toFixed(2)} CNY. Reduce the amount or use another payment method`,
)
: message(
locale,
`${input.paymentType} 今日充值额度已满,请使用其他支付方式`,
`${input.paymentType} daily quota is full. Please use another payment method`,
),
429,
);
}
}
const expiresAt = new Date(Date.now() + env.ORDER_TIMEOUT_MINUTES * 60 * 1000);
const order = await prisma.$transaction(async (tx) => {
const created = await tx.order.create({
data: {
userId: input.userId,
@@ -140,8 +230,8 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
userName: user.username,
userNotes: user.notes || null,
amount: new Prisma.Decimal(input.amount.toFixed(2)),
payAmount: new Prisma.Decimal(payAmount.toFixed(2)),
feeRate: feeRate > 0 ? new Prisma.Decimal(feeRate.toFixed(2)) : null,
payAmount: new Prisma.Decimal(payAmountStr),
feeRate: feeRate > 0 ? new Prisma.Decimal(feeRate.toFixed(4)) : null,
rechargeCode: '',
status: 'PENDING',
paymentType: input.paymentType,
@@ -149,6 +239,12 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
clientIp: input.clientIp,
srcHost: input.srcHost || null,
srcUrl: input.srcUrl || null,
orderType,
planId: subscriptionPlan?.id ?? null,
subscriptionGroupId: subscriptionPlan?.groupId ?? null,
subscriptionDays: subscriptionPlan
? computeValidityDays(subscriptionPlan.validityDays, subscriptionPlan.validityUnit as ValidityUnit)
: null,
},
});
@@ -165,8 +261,8 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
initPaymentProviders();
const provider = paymentRegistry.getProvider(input.paymentType);
const statusAccessToken = createOrderStatusAccessToken(order.id);
const orderResultUrl = buildOrderResultUrl(env.NEXT_PUBLIC_APP_URL, order.id);
const statusAccessToken = createOrderStatusAccessToken(order.id, input.userId);
const orderResultUrl = buildOrderResultUrl(env.NEXT_PUBLIC_APP_URL, order.id, input.userId);
// 只有 easypay 从外部传入 notifyUrlreturn_url 统一回到带访问令牌的结果页
let notifyUrl: string | undefined;
@@ -176,11 +272,28 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
returnUrl = orderResultUrl;
}
// R3+R5: 构建支付商品名称
let paymentSubject: string;
if (subscriptionPlan) {
// R3: 订阅订单优先使用套餐自定义商品名称
paymentSubject = subscriptionPlan.productName || `Sub2API 订阅 ${subscriptionGroupName || subscriptionPlan.name}`;
} else {
// R5: 余额订单使用前缀/后缀配置
const nameConfigs = await getSystemConfigs(['PRODUCT_NAME_PREFIX', 'PRODUCT_NAME_SUFFIX']);
const prefix = nameConfigs['PRODUCT_NAME_PREFIX']?.trim();
const suffix = nameConfigs['PRODUCT_NAME_SUFFIX']?.trim();
if (prefix || suffix) {
paymentSubject = `${prefix || ''} ${payAmountStr} ${suffix || ''}`.trim();
} else {
paymentSubject = `${env.PRODUCT_NAME} ${payAmountStr} CNY`;
}
}
const paymentResult = await provider.createPayment({
orderId: order.id,
amount: payAmount,
amount: payAmountNum,
paymentType: input.paymentType,
subject: `${env.PRODUCT_NAME} ${payAmount.toFixed(2)} CNY`,
subject: paymentSubject,
notifyUrl,
returnUrl,
clientIp: input.clientIp,
@@ -200,7 +313,17 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
data: {
orderId: order.id,
action: 'ORDER_CREATED',
detail: JSON.stringify({ userId: input.userId, amount: input.amount, paymentType: input.paymentType }),
detail: JSON.stringify({
userId: input.userId,
amount: input.amount,
paymentType: input.paymentType,
orderType,
...(subscriptionPlan && {
planId: subscriptionPlan.id,
planName: subscriptionPlan.name,
groupId: subscriptionPlan.groupId,
}),
}),
operator: `user:${input.userId}`,
},
});
@@ -208,7 +331,7 @@ export async function createOrder(input: CreateOrderInput): Promise<CreateOrderR
return {
orderId: order.id,
amount: input.amount,
payAmount,
payAmount: payAmountNum,
feeRate,
status: ORDER_STATUS.PENDING,
paymentType: input.paymentType,
@@ -453,10 +576,10 @@ export async function confirmPayment(input: {
// FAILED 状态 — 之前充值失败,利用重试通知自动重试充值
if (current.status === ORDER_STATUS.FAILED) {
try {
await executeRecharge(order.id);
await executeFulfillment(order.id);
return true;
} catch (err) {
console.error('Recharge retry failed for order:', order.id, err);
console.error('Fulfillment retry failed for order:', order.id, err);
return false; // 让支付平台继续重试
}
}
@@ -485,9 +608,9 @@ export async function confirmPayment(input: {
});
try {
await executeRecharge(order.id);
await executeFulfillment(order.id);
} catch (err) {
console.error('Recharge failed for order:', order.id, err);
console.error('Fulfillment failed for order:', order.id, err);
return false;
}
@@ -512,6 +635,136 @@ export async function handlePaymentNotify(notification: PaymentNotification, pro
});
}
/**
* 统一履约入口 — 根据 orderType 分派到余额充值或订阅分配。
*/
export async function executeFulfillment(orderId: string): Promise<void> {
const order = await prisma.order.findUnique({
where: { id: orderId },
select: { orderType: true },
});
if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404);
if (order.orderType === 'subscription') {
await executeSubscriptionFulfillment(orderId);
} else {
await executeRecharge(orderId);
}
}
/**
* 订阅履约 — 支付成功后调用 Sub2API 分配订阅。
*/
export async function executeSubscriptionFulfillment(orderId: string): Promise<void> {
const order = await prisma.order.findUnique({ where: { id: orderId } });
if (!order) throw new OrderError('NOT_FOUND', 'Order not found', 404);
if (order.status === ORDER_STATUS.COMPLETED) return;
if (isRefundStatus(order.status)) {
throw new OrderError('INVALID_STATUS', 'Refund-related order cannot fulfill', 400);
}
if (order.status !== ORDER_STATUS.PAID && order.status !== ORDER_STATUS.FAILED) {
throw new OrderError('INVALID_STATUS', `Order cannot fulfill in status ${order.status}`, 400);
}
if (!order.subscriptionGroupId || !order.subscriptionDays) {
throw new OrderError('INVALID_STATUS', 'Missing subscription info on order', 400);
}
// CAS 锁
const lockResult = await prisma.order.updateMany({
where: { id: orderId, status: { in: [ORDER_STATUS.PAID, ORDER_STATUS.FAILED] } },
data: { status: ORDER_STATUS.RECHARGING },
});
if (lockResult.count === 0) return;
try {
// 校验分组是否仍然存在
const group = await getGroup(order.subscriptionGroupId);
if (!group || group.status !== 'active') {
throw new Error(`Subscription group ${order.subscriptionGroupId} no longer exists or inactive`);
}
// 检测是否续费:查找同分组的活跃订阅,决定天数计算起点
let validityDays = order.subscriptionDays;
let fulfillMethod: 'renew' | 'new' = 'new';
let renewedSubscriptionId: number | undefined;
const userSubs = await getUserSubscriptions(order.userId);
const activeSub = userSubs.find((s) => s.group_id === order.subscriptionGroupId && s.status === 'active');
if (activeSub) {
// 续费:从到期日往后推算天数
const plan = await prisma.subscriptionPlan.findFirst({
where: { groupId: order.subscriptionGroupId },
select: { validityDays: true, validityUnit: true },
});
if (plan) {
validityDays = computeValidityDays(
plan.validityDays,
plan.validityUnit as ValidityUnit,
new Date(activeSub.expires_at),
);
}
fulfillMethod = 'renew';
renewedSubscriptionId = activeSub.id;
}
await createAndRedeem(
order.rechargeCode,
Number(order.amount),
order.userId,
`sub2apipay subscription order:${orderId}`,
{
type: 'subscription',
groupId: order.subscriptionGroupId,
validityDays,
},
);
await prisma.order.updateMany({
where: { id: orderId, status: ORDER_STATUS.RECHARGING },
data: { status: ORDER_STATUS.COMPLETED, completedAt: new Date() },
});
await prisma.auditLog.create({
data: {
orderId,
action: 'SUBSCRIPTION_SUCCESS',
detail: JSON.stringify({
groupId: order.subscriptionGroupId,
days: order.subscriptionDays,
amount: Number(order.amount),
method: fulfillMethod,
...(renewedSubscriptionId && { renewedSubscriptionId }),
}),
operator: 'system',
},
});
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
const isGroupGone = reason.includes('no longer exists');
await prisma.order.update({
where: { id: orderId },
data: {
status: ORDER_STATUS.FAILED,
failedAt: new Date(),
failedReason: isGroupGone ? `SUBSCRIPTION_GROUP_GONE: ${reason}` : reason,
},
});
await prisma.auditLog.create({
data: {
orderId,
action: 'SUBSCRIPTION_FAILED',
detail: reason,
operator: 'system',
},
});
throw error;
}
}
export async function executeRecharge(orderId: string): Promise<void> {
const order = await prisma.order.findUnique({ where: { id: orderId } });
if (!order) {
@@ -698,7 +951,7 @@ export async function retryRecharge(orderId: string, locale: Locale = 'zh'): Pro
},
});
await executeRecharge(orderId);
await executeFulfillment(orderId);
}
export interface RefundInput {
@@ -794,7 +1047,10 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
`sub2apipay:refund-rollback:${order.id}`,
);
} catch (rollbackError) {
// 余额恢复也失败,记录审计日志,需人工介入
// 余额恢复也失败,记录审计日志并标记需要补偿,便于定时任务或管理员重试
console.error(
`[CRITICAL] Refund rollback failed for order ${input.orderId}: balance deducted ${rechargeAmount} but gateway refund and balance restoration both failed. Manual intervention required.`,
);
await prisma.auditLog.create({
data: {
orderId: input.orderId,
@@ -803,6 +1059,7 @@ export async function processRefund(input: RefundInput): Promise<RefundResult> {
gatewayError: gatewayError instanceof Error ? gatewayError.message : String(gatewayError),
rollbackError: rollbackError instanceof Error ? rollbackError.message : String(rollbackError),
rechargeAmount,
needsBalanceCompensation: true,
}),
operator: 'admin',
},

View File

@@ -2,25 +2,48 @@ import crypto from 'crypto';
import { getEnv } from '@/lib/config';
export const ORDER_STATUS_ACCESS_QUERY_KEY = 'access_token';
const ORDER_STATUS_ACCESS_PURPOSE = 'order-status-access:v1';
const ORDER_STATUS_ACCESS_PURPOSE = 'order-status-access:v2';
/** access_token 有效期24 小时) */
const TOKEN_TTL_MS = 24 * 60 * 60 * 1000;
function buildSignature(orderId: string): string {
/** 使用独立派生密钥,不直接使用 ADMIN_TOKEN */
function deriveKey(): string {
return crypto.createHmac('sha256', getEnv().ADMIN_TOKEN).update('order-status-access-key').digest('hex');
}
function buildSignature(orderId: string, userId: number, expiresAt: number): string {
return crypto
.createHmac('sha256', getEnv().ADMIN_TOKEN)
.update(`${ORDER_STATUS_ACCESS_PURPOSE}:${orderId}`)
.createHmac('sha256', deriveKey())
.update(`${ORDER_STATUS_ACCESS_PURPOSE}:${orderId}:${userId}:${expiresAt}`)
.digest('base64url');
}
export function createOrderStatusAccessToken(orderId: string): string {
return buildSignature(orderId);
/** 生成格式: {expiresAt}.{userId}.{signature} */
export function createOrderStatusAccessToken(orderId: string, userId?: number): string {
const expiresAt = Date.now() + TOKEN_TTL_MS;
const uid = userId ?? 0;
const sig = buildSignature(orderId, uid, expiresAt);
return `${expiresAt}.${uid}.${sig}`;
}
export function verifyOrderStatusAccessToken(orderId: string, token: string | null | undefined): boolean {
if (!token) return false;
const expected = buildSignature(orderId);
const parts = token.split('.');
if (parts.length !== 3) return false;
const [expiresAtStr, userIdStr, sig] = parts;
const expiresAt = Number(expiresAtStr);
const userId = Number(userIdStr);
if (!Number.isFinite(expiresAt) || !Number.isFinite(userId)) return false;
// 检查过期
if (Date.now() > expiresAt) return false;
const expected = buildSignature(orderId, userId, expiresAt);
const expectedBuffer = Buffer.from(expected, 'utf8');
const receivedBuffer = Buffer.from(token, 'utf8');
const receivedBuffer = Buffer.from(sig, 'utf8');
if (expectedBuffer.length !== receivedBuffer.length) {
return false;
@@ -29,9 +52,9 @@ export function verifyOrderStatusAccessToken(orderId: string, token: string | nu
return crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
}
export function buildOrderResultUrl(appUrl: string, orderId: string): string {
export function buildOrderResultUrl(appUrl: string, orderId: string, userId?: number): string {
const url = new URL('/pay/result', appUrl);
url.searchParams.set('order_id', orderId);
url.searchParams.set(ORDER_STATUS_ACCESS_QUERY_KEY, createOrderStatusAccessToken(orderId));
url.searchParams.set(ORDER_STATUS_ACCESS_QUERY_KEY, createOrderStatusAccessToken(orderId, userId));
return url.toString();
}

View File

@@ -18,6 +18,7 @@ export interface PublicOrderStatusSnapshot extends DerivedOrderState {
id: string;
status: string;
expiresAt: Date | string;
failedReason?: string | null;
}
export interface OrderDisplayState {

View File

@@ -3,9 +3,12 @@ import { ORDER_STATUS } from '@/lib/constants';
import { cancelOrderCore } from './service';
const INTERVAL_MS = 30_000; // 30 seconds
const BATCH_SIZE = 50;
let timer: ReturnType<typeof setInterval> | null = null;
export async function expireOrders(): Promise<number> {
// 查询到期订单(限制批次大小防止内存爆炸)
// cancelOrderCore 内部 WHERE status='PENDING' 的 CAS 保证多实例不会重复处理同一订单
const orders = await prisma.order.findMany({
where: {
status: ORDER_STATUS.PENDING,
@@ -16,6 +19,8 @@ export async function expireOrders(): Promise<number> {
paymentTradeNo: true,
paymentType: true,
},
take: BATCH_SIZE,
orderBy: { expiresAt: 'asc' },
});
if (orders.length === 0) return 0;

View File

@@ -0,0 +1,30 @@
import { getSystemConfig } from '@/lib/system-config';
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
/**
* 根据 ENABLED_PAYMENT_TYPES 配置过滤支持的支付类型。
* configuredTypes 为 undefined 或空字符串时回退到全部支持类型。
*/
export function resolveEnabledPaymentTypes(supportedTypes: string[], configuredTypes: string | undefined): string[] {
if (configuredTypes === undefined) return supportedTypes;
const configuredTypeSet = new Set(
configuredTypes
.split(',')
.map((type) => type.trim())
.filter(Boolean),
);
if (configuredTypeSet.size === 0) return supportedTypes;
return supportedTypes.filter((type) => configuredTypeSet.has(type));
}
/**
* 获取当前启用的支付类型(结合 registry 支持类型 + 数据库 ENABLED_PAYMENT_TYPES 配置)。
*/
export async function getEnabledPaymentTypes(): Promise<string[]> {
initPaymentProviders();
const supportedTypes = paymentRegistry.getSupportedTypes();
const configuredTypes = await getSystemConfig('ENABLED_PAYMENT_TYPES');
return resolveEnabledPaymentTypes(supportedTypes, configuredTypes);
}

222
src/lib/platform-style.ts Normal file
View File

@@ -0,0 +1,222 @@
import React from 'react';
export interface PlatformStyleEntry {
badge: string;
border: string;
label: string;
/** SVG path data (viewBox 0 0 24 24) */
icon: string;
/** Model tag classes: { light: border+bg+text, dark: border+bg+text, dot: bg for the dot } */
modelTag: { light: string; dark: string; dot: string };
/** Button bg + hover + active classes */
button: { light: string; dark: string };
/** Accent text color for rate numbers, quota descriptions etc. */
accent: { light: string; dark: string };
}
const PLATFORM_STYLES: Record<string, PlatformStyleEntry> = {
claude: {
badge: 'bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/30',
border: 'border-orange-500/20',
label: 'Claude',
icon: 'M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z',
modelTag: {
light: 'border-orange-500/20 bg-gradient-to-r from-orange-500/10 to-amber-500/10 text-orange-600',
dark: 'border-orange-500/20 bg-gradient-to-r from-orange-500/10 to-amber-500/10 text-orange-300',
dot: 'bg-orange-500',
},
button: {
light: 'bg-orange-500 hover:bg-orange-600 active:bg-orange-700',
dark: 'bg-orange-500/80 hover:bg-orange-500 active:bg-orange-600',
},
accent: { light: 'text-orange-600', dark: 'text-orange-300' },
},
anthropic: {
badge: 'bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/30',
border: 'border-orange-500/20',
label: 'Anthropic',
icon: 'M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z',
modelTag: {
light: 'border-orange-500/20 bg-gradient-to-r from-orange-500/10 to-amber-500/10 text-orange-600',
dark: 'border-orange-500/20 bg-gradient-to-r from-orange-500/10 to-amber-500/10 text-orange-300',
dot: 'bg-orange-500',
},
button: {
light: 'bg-orange-500 hover:bg-orange-600 active:bg-orange-700',
dark: 'bg-orange-500/80 hover:bg-orange-500 active:bg-orange-600',
},
accent: { light: 'text-orange-600', dark: 'text-orange-300' },
},
openai: {
badge: 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30',
border: 'border-green-500/20',
label: 'OpenAI',
modelTag: {
light: 'border-green-500/20 bg-gradient-to-r from-green-500/10 to-emerald-500/10 text-green-600',
dark: 'border-green-500/20 bg-gradient-to-r from-green-500/10 to-emerald-500/10 text-green-300',
dot: 'bg-green-500',
},
button: {
light: 'bg-green-600 hover:bg-green-700 active:bg-green-800',
dark: 'bg-green-600/80 hover:bg-green-600 active:bg-green-700',
},
accent: { light: 'text-green-600', dark: 'text-green-300' },
icon: 'M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z',
},
codex: {
badge: 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30',
border: 'border-green-500/20',
label: 'Codex',
modelTag: {
light: 'border-green-500/20 bg-gradient-to-r from-green-500/10 to-emerald-500/10 text-green-600',
dark: 'border-green-500/20 bg-gradient-to-r from-green-500/10 to-emerald-500/10 text-green-300',
dot: 'bg-green-500',
},
button: {
light: 'bg-green-600 hover:bg-green-700 active:bg-green-800',
dark: 'bg-green-600/80 hover:bg-green-600 active:bg-green-700',
},
accent: { light: 'text-green-600', dark: 'text-green-300' },
icon: 'M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z',
},
gemini: {
badge: 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30',
border: 'border-blue-500/20',
label: 'Gemini',
modelTag: {
light: 'border-blue-500/20 bg-gradient-to-r from-blue-500/10 to-indigo-500/10 text-blue-600',
dark: 'border-blue-500/20 bg-gradient-to-r from-blue-500/10 to-indigo-500/10 text-blue-300',
dot: 'bg-blue-500',
},
button: {
light: 'bg-blue-500 hover:bg-blue-600 active:bg-blue-700',
dark: 'bg-blue-500/80 hover:bg-blue-500 active:bg-blue-600',
},
accent: { light: 'text-blue-600', dark: 'text-blue-300' },
icon: 'M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81',
},
google: {
badge: 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30',
border: 'border-blue-500/20',
label: 'Google',
modelTag: {
light: 'border-blue-500/20 bg-gradient-to-r from-blue-500/10 to-indigo-500/10 text-blue-600',
dark: 'border-blue-500/20 bg-gradient-to-r from-blue-500/10 to-indigo-500/10 text-blue-300',
dot: 'bg-blue-500',
},
button: {
light: 'bg-blue-500 hover:bg-blue-600 active:bg-blue-700',
dark: 'bg-blue-500/80 hover:bg-blue-500 active:bg-blue-600',
},
accent: { light: 'text-blue-600', dark: 'text-blue-300' },
icon: 'M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81',
},
sora: {
badge: 'bg-pink-500/10 text-pink-600 dark:text-pink-400 border-pink-500/30',
border: 'border-pink-500/20',
label: 'Sora',
modelTag: {
light: 'border-pink-500/20 bg-gradient-to-r from-pink-500/10 to-rose-500/10 text-pink-600',
dark: 'border-pink-500/20 bg-gradient-to-r from-pink-500/10 to-rose-500/10 text-pink-300',
dot: 'bg-pink-500',
},
button: {
light: 'bg-pink-500 hover:bg-pink-600 active:bg-pink-700',
dark: 'bg-pink-500/80 hover:bg-pink-500 active:bg-pink-600',
},
accent: { light: 'text-pink-600', dark: 'text-pink-300' },
// four-pointed sparkle star
icon: 'M12 2l2.09 6.26L20.18 10l-6.09 1.74L12 18l-2.09-6.26L3.82 10l6.09-1.74L12 2z',
},
antigravity: {
badge: 'bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/30',
border: 'border-purple-500/20',
label: 'Antigravity',
modelTag: {
light: 'border-purple-500/20 bg-gradient-to-r from-purple-500/10 to-violet-500/10 text-purple-600',
dark: 'border-purple-500/20 bg-gradient-to-r from-purple-500/10 to-violet-500/10 text-purple-300',
dot: 'bg-purple-500',
},
button: {
light: 'bg-purple-500 hover:bg-purple-600 active:bg-purple-700',
dark: 'bg-purple-500/80 hover:bg-purple-500 active:bg-purple-600',
},
accent: { light: 'text-purple-600', dark: 'text-purple-300' },
// stylised angular "A" cursor shape
icon: 'M12 2L4 22h4l2-5h4l2 5h4L12 2zm0 7l2.5 6h-5L12 9z',
},
};
const FALLBACK_STYLE: PlatformStyleEntry = {
badge: 'bg-slate-500/10 text-slate-600 dark:text-slate-400 border-slate-500/30',
border: 'border-slate-500/20',
label: '',
icon: '',
modelTag: {
light: 'border-slate-500/20 bg-gradient-to-r from-slate-500/10 to-slate-400/10 text-slate-600',
dark: 'border-slate-500/20 bg-gradient-to-r from-slate-500/10 to-slate-400/10 text-slate-400',
dot: 'bg-slate-500',
},
button: {
light: 'bg-emerald-500 hover:bg-emerald-600 active:bg-emerald-700',
dark: 'bg-emerald-500/80 hover:bg-emerald-500 active:bg-emerald-600',
},
accent: { light: 'text-emerald-600', dark: 'text-emerald-300' },
};
export function getPlatformStyle(platform: string): PlatformStyleEntry {
const key = platform.toLowerCase();
const entry = PLATFORM_STYLES[key];
if (entry) return entry;
return { ...FALLBACK_STYLE, label: platform };
}
/**
* Inline SVG icon for a platform (16×16 by default).
* Returns null when the platform has no known icon.
*/
export function PlatformIcon({
platform,
className = 'h-4 w-4',
}: {
platform: string;
className?: string;
}): React.ReactElement | null {
const style = getPlatformStyle(platform);
if (!style.icon) return null;
return React.createElement(
'svg',
{
className,
viewBox: '0 0 24 24',
fill: 'currentColor',
'aria-hidden': true,
},
React.createElement('path', { d: style.icon }),
);
}
/**
* Renders a coloured badge with icon + label for a platform.
*/
export function PlatformBadge({
platform,
className = '',
}: {
platform: string;
className?: string;
}): React.ReactElement {
const style = getPlatformStyle(platform);
return React.createElement(
'span',
{
className: [
'inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs font-medium',
style.badge,
className,
].join(' '),
},
PlatformIcon({ platform, className: 'h-3.5 w-3.5' }),
style.label,
);
}

View File

@@ -1,5 +1,5 @@
import { getEnv } from '@/lib/config';
import type { Sub2ApiUser, Sub2ApiRedeemCode } from './types';
import type { Sub2ApiUser, Sub2ApiRedeemCode, Sub2ApiGroup, Sub2ApiSubscription } from './types';
const DEFAULT_TIMEOUT_MS = 10_000;
const RECHARGE_TIMEOUT_MS = 30_000;
@@ -60,15 +60,20 @@ export async function createAndRedeem(
value: number,
userId: number,
notes: string,
options?: { type?: 'balance' | 'subscription'; groupId?: number; validityDays?: number },
): Promise<Sub2ApiRedeemCode> {
const env = getEnv();
const url = `${env.SUB2API_BASE_URL}/api/v1/admin/redeem-codes/create-and-redeem`;
const body = JSON.stringify({
code,
type: 'balance',
type: options?.type ?? 'balance',
value,
user_id: userId,
notes,
...(options?.type === 'subscription' && {
group_id: options.groupId,
validity_days: options.validityDays,
}),
});
let lastError: unknown;
@@ -101,6 +106,103 @@ export async function createAndRedeem(
throw lastError instanceof Error ? lastError : new Error('Recharge failed');
}
// ── 分组 API ──
export async function getAllGroups(): Promise<Sub2ApiGroup[]> {
const env = getEnv();
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/groups/all`, {
headers: getHeaders(),
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`Failed to get groups: ${response.status}`);
}
const data = await response.json();
return (data.data ?? []) as Sub2ApiGroup[];
}
export async function getGroup(groupId: number): Promise<Sub2ApiGroup | null> {
const env = getEnv();
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/groups/${groupId}`, {
headers: getHeaders(),
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
if (!response.ok) {
if (response.status === 404) return null;
throw new Error(`Failed to get group ${groupId}: ${response.status}`);
}
const data = await response.json();
return data.data as Sub2ApiGroup;
}
// ── 订阅 API ──
export async function assignSubscription(
userId: number,
groupId: number,
validityDays: number,
notes?: string,
idempotencyKey?: string,
): Promise<Sub2ApiSubscription> {
const env = getEnv();
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/subscriptions/assign`, {
method: 'POST',
headers: getHeaders(idempotencyKey),
body: JSON.stringify({
user_id: userId,
group_id: groupId,
validity_days: validityDays,
notes: notes || `Sub2ApiPay subscription order`,
}),
signal: AbortSignal.timeout(RECHARGE_TIMEOUT_MS),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`Assign subscription failed (${response.status}): ${JSON.stringify(errorData)}`);
}
const data = await response.json();
return data.data as Sub2ApiSubscription;
}
export async function getUserSubscriptions(userId: number): Promise<Sub2ApiSubscription[]> {
const env = getEnv();
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}/subscriptions`, {
headers: getHeaders(),
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
if (!response.ok) {
if (response.status === 404) return [];
throw new Error(`Failed to get user subscriptions: ${response.status}`);
}
const data = await response.json();
return (data.data ?? []) as Sub2ApiSubscription[];
}
export async function extendSubscription(subscriptionId: number, days: number): Promise<void> {
const env = getEnv();
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/subscriptions/${subscriptionId}/extend`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ days }),
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`Extend subscription failed (${response.status}): ${JSON.stringify(errorData)}`);
}
}
// ── 余额 API ──
export async function subtractBalance(
userId: number,
amount: number,
@@ -125,6 +227,63 @@ export async function subtractBalance(
}
}
// ── 用户搜索 API ──
export async function searchUsers(
keyword: string,
): Promise<{ id: number; email: string; username: string; notes?: string }[]> {
const env = getEnv();
const response = await fetch(
`${env.SUB2API_BASE_URL}/api/v1/admin/users?search=${encodeURIComponent(keyword)}&page=1&page_size=30`,
{
headers: getHeaders(),
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
},
);
if (!response.ok) {
throw new Error(`Failed to search users: ${response.status}`);
}
const data = await response.json();
const paginated = data.data ?? {};
return (paginated.items ?? []) as { id: number; email: string; username: string; notes?: string }[];
}
export async function listSubscriptions(params?: {
user_id?: number;
group_id?: number;
status?: string;
page?: number;
page_size?: number;
}): Promise<{ subscriptions: Sub2ApiSubscription[]; total: number; page: number; page_size: number }> {
const env = getEnv();
const qs = new URLSearchParams();
if (params?.user_id != null) qs.set('user_id', String(params.user_id));
if (params?.group_id != null) qs.set('group_id', String(params.group_id));
if (params?.status) qs.set('status', params.status);
if (params?.page != null) qs.set('page', String(params.page));
if (params?.page_size != null) qs.set('page_size', String(params.page_size));
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/subscriptions?${qs}`, {
headers: getHeaders(),
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`Failed to list subscriptions: ${response.status}`);
}
const data = await response.json();
const paginated = data.data ?? {};
return {
subscriptions: (paginated.items ?? []) as Sub2ApiSubscription[],
total: paginated.total ?? 0,
page: paginated.page ?? 1,
page_size: paginated.page_size ?? 50,
};
}
export async function addBalance(userId: number, amount: number, notes: string, idempotencyKey: string): Promise<void> {
const env = getEnv();
const response = await fetch(`${env.SUB2API_BASE_URL}/api/v1/admin/users/${userId}/balance`, {

View File

@@ -22,3 +22,45 @@ export interface Sub2ApiResponse<T> {
data?: T;
message?: string;
}
// ── 分组 ──
export interface Sub2ApiGroup {
id: number;
name: string;
description: string;
platform: string;
status: string;
rate_multiplier: number;
subscription_type: string; // "standard" | "subscription"
daily_limit_usd: number | null;
weekly_limit_usd: number | null;
monthly_limit_usd: number | null;
default_validity_days: number;
sort_order: number;
supported_model_scopes: string[] | null;
allow_messages_dispatch?: boolean;
default_mapped_model?: string;
}
// ── 订阅 ──
export interface Sub2ApiSubscription {
id: number;
user_id: number;
group_id: number;
starts_at: string;
expires_at: string;
status: string; // "active" | "expired" | "suspended"
daily_usage_usd: number;
weekly_usage_usd: number;
monthly_usage_usd: number;
daily_window_start: string | null;
weekly_window_start: string | null;
monthly_window_start: string | null;
assigned_by: number;
assigned_at: string;
notes: string | null;
created_at: string;
updated_at: string;
}

View File

@@ -0,0 +1,68 @@
export type ValidityUnit = 'day' | 'week' | 'month';
/**
* 根据数值和单位计算实际有效天数。
* - day: 直接返回
* - week: value * 7
* - month: 从 fromDate 到 value 个月后同一天的天数差
*/
export function computeValidityDays(value: number, unit: ValidityUnit, fromDate?: Date): number {
if (unit === 'day') return value;
if (unit === 'week') return value * 7;
// month: 计算到 value 个月后同一天的天数差
const from = fromDate ?? new Date();
const target = new Date(from);
target.setMonth(target.getMonth() + value);
return Math.round((target.getTime() - from.getTime()) / (1000 * 60 * 60 * 24));
}
/**
* 格式化有效期显示文本(配置什么就显示什么,不做转换)。
* - unit=month, value=1 → 1月 / 1 Month
* - unit=week, value=2 → 2周 / 2 Weeks
* - unit=day, value=30 → 30天 / 30 Days
*/
export function formatValidityLabel(value: number, unit: ValidityUnit, locale: 'zh' | 'en'): string {
const unitLabels: Record<ValidityUnit, { zh: string; en: string; enPlural: string }> = {
day: { zh: '天', en: 'Day', enPlural: 'Days' },
week: { zh: '周', en: 'Week', enPlural: 'Weeks' },
month: { zh: '月', en: 'Month', enPlural: 'Months' },
};
const u = unitLabels[unit];
if (locale === 'zh') return `${value}${u.zh}`;
return `${value} ${value === 1 ? u.en : u.enPlural}`;
}
/**
* 格式化有效期后缀(用于价格展示,配置什么就显示什么)。
* - unit=month, value=1 → /1月 / /1mo
* - unit=week, value=2 → /2周 / /2wk
* - unit=day, value=30 → /30天 / /30d
*/
export function formatValiditySuffix(value: number, unit: ValidityUnit, locale: 'zh' | 'en'): string {
const unitLabels: Record<ValidityUnit, { zh: string; en: string }> = {
day: { zh: '天', en: 'd' },
week: { zh: '周', en: 'wk' },
month: { zh: '月', en: 'mo' },
};
const u = unitLabels[unit];
if (locale === 'zh') return `/${value}${u.zh}`;
return `/${value}${u.en}`;
}
/**
* 格式化有效期列表展示文本(管理后台表格用)。
* - unit=day → "30 天"
* - unit=week → "2 周"
* - unit=month → "1 月"
*/
export function formatValidityDisplay(value: number, unit: ValidityUnit, locale: 'zh' | 'en'): string {
const unitLabels: Record<ValidityUnit, { zh: string; en: string }> = {
day: { zh: '天', en: 'day(s)' },
week: { zh: '周', en: 'week(s)' },
month: { zh: '月', en: 'month(s)' },
};
const label = locale === 'zh' ? unitLabels[unit].zh : unitLabels[unit].en;
return `${value} ${label}`;
}

129
src/lib/system-config.ts Normal file
View File

@@ -0,0 +1,129 @@
import { prisma } from '@/lib/db';
// 内存缓存key → { value, expiresAt }
const cache = new Map<string, { value: string; expiresAt: number }>();
const CACHE_TTL_MS = 30_000; // 30 秒
function getCached(key: string): string | undefined {
const entry = cache.get(key);
if (!entry) return undefined;
if (Date.now() > entry.expiresAt) {
cache.delete(key);
return undefined;
}
return entry.value;
}
function setCache(key: string, value: string): void {
cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS });
}
export function invalidateConfigCache(key?: string): void {
if (key) {
cache.delete(key);
} else {
cache.clear();
}
}
export async function getSystemConfig(key: string): Promise<string | undefined> {
const cached = getCached(key);
if (cached !== undefined) return cached;
const row = await prisma.systemConfig.findUnique({ where: { key } });
if (row) {
setCache(key, row.value);
return row.value;
}
// 回退到环境变量
const envVal = process.env[key];
if (envVal !== undefined) {
setCache(key, envVal);
}
return envVal;
}
export async function getSystemConfigs(keys: string[]): Promise<Record<string, string>> {
const result: Record<string, string> = {};
const missing: string[] = [];
for (const key of keys) {
const cached = getCached(key);
if (cached !== undefined) {
result[key] = cached;
} else {
missing.push(key);
}
}
if (missing.length > 0) {
const rows = await prisma.systemConfig.findMany({
where: { key: { in: missing } },
});
const dbMap = new Map(rows.map((r) => [r.key, r.value]));
for (const key of missing) {
const val = dbMap.get(key) ?? process.env[key];
if (val !== undefined) {
result[key] = val;
setCache(key, val);
}
}
}
return result;
}
export async function setSystemConfig(key: string, value: string, group?: string, label?: string): Promise<void> {
await prisma.systemConfig.upsert({
where: { key },
update: { value, ...(group !== undefined && { group }), ...(label !== undefined && { label }) },
create: { key, value, group: group ?? 'general', label },
});
invalidateConfigCache(key);
}
export async function setSystemConfigs(
configs: { key: string; value: string; group?: string; label?: string }[],
): Promise<void> {
await prisma.$transaction(
configs.map((c) =>
prisma.systemConfig.upsert({
where: { key: c.key },
update: {
value: c.value,
...(c.group !== undefined && { group: c.group }),
...(c.label !== undefined && { label: c.label }),
},
create: { key: c.key, value: c.value, group: c.group ?? 'general', label: c.label },
}),
),
);
invalidateConfigCache();
}
export async function getSystemConfigsByGroup(
group: string,
): Promise<{ key: string; value: string; label: string | null }[]> {
return prisma.systemConfig.findMany({
where: { group },
select: { key: true, value: true, label: true },
orderBy: { key: 'asc' },
});
}
export async function getAllSystemConfigs(): Promise<
{ key: string; value: string; group: string; label: string | null }[]
> {
return prisma.systemConfig.findMany({
select: { key: true, value: true, group: true, label: true },
orderBy: [{ group: 'asc' }, { key: 'asc' }],
});
}
export async function deleteSystemConfig(key: string): Promise<void> {
await prisma.systemConfig.delete({ where: { key } }).catch(() => {});
invalidateConfigCache(key);
}

View File

@@ -17,6 +17,9 @@ function assertWxpayEnv(env: ReturnType<typeof getEnv>) {
'Wxpay environment variables (WXPAY_APP_ID, WXPAY_MCH_ID, WXPAY_PRIVATE_KEY, WXPAY_API_V3_KEY) are required',
);
}
if (env.WXPAY_API_V3_KEY.length !== 32) {
throw new Error(`WXPAY_API_V3_KEY must be exactly 32 bytes for AES-256-GCM, got ${env.WXPAY_API_V3_KEY.length}`);
}
return env as typeof env & {
WXPAY_APP_ID: string;
WXPAY_MCH_ID: string;

View File

@@ -117,8 +117,9 @@ export class WxpayProvider implements PaymentProvider {
}
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > 300) {
throw new Error('Wechatpay notification timestamp expired');
const tsNum = Number(timestamp);
if (!Number.isFinite(tsNum) || Math.abs(now - tsNum) > 300) {
throw new Error('Wechatpay notification timestamp invalid or expired');
}
const valid = await verifyNotifySign({ timestamp, nonce, body, serial, signature });

View File

@@ -27,6 +27,10 @@ export function middleware(request: NextRequest) {
response.headers.set('Content-Security-Policy', `frame-ancestors 'self' ${[...origins].join(' ')}`);
}
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'SAMEORIGIN');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
return response;
}