76 Commits

Author SHA1 Message Date
erio
af9820a2ee fix: 易支付移动端使用 payurl2 进行微信H5支付 2026-03-16 22:35:27 +08:00
erio
a3f3fa83f1 chore: add MIT license
Closes #11
2026-03-16 14:07:26 +08:00
erio
2590145a2c fix: 易支付移动端传 device=jump 以支持微信H5支付唤起 2026-03-16 13:47:56 +08:00
erio
e2018cbcf9 fix: 渠道 PUT schema 兼容字符串类型的 models/features 字段
前端 linesToJsonString 传的是 JSON 字符串,而 .strict() schema
只接受数组/对象,导致所有渠道编辑保存失败"参数校验失败"。
移除 .strict(),models/features 改为 union 接受 string | array/record。
2026-03-16 05:33:24 +08:00
erio
a1d3f3b639 chore: 从 git 中移除 CLAUDE.md 并加入 gitignore 2026-03-15 19:23:23 +08:00
erio
58d4c7efbf fix: 滚动条主题适配 + 套餐 API 输入校验补全
- 滚动条默认浅色,data-theme="dark" 下切换深色
- admin layout / PayPageLayout 根 div 加 data-theme 属性
- 套餐 POST/PUT: name 类型、空值、长度(100)校验 + trim
- 套餐 PUT: 补全 sort_order 非负整数校验
2026-03-15 17:24:44 +08:00
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
erio
9f621713c3 style: 统一代码格式 (prettier) 2026-03-10 18:20:36 +08:00
erio
abff49222b fix: 修复 OrderStatus ref 赋值导致 ESLint react-hooks/refs 报错
将 onStateChangeRef.current 赋值从渲染阶段移入 useEffect
2026-03-10 18:18:58 +08:00
erio
1cb82d8fd7 fix: 消除 buildOrderStatusUrl 重复定义,修复轮询回调引用稳定性
- 将 buildOrderStatusUrl 提取到 status-url.ts(客户端安全模块),
  删除 OrderStatus/PaymentQRCode/result 三处重复定义
- OrderStatus.tsx 轮询 effect 使用 useRef 保存 onStateChange,
  避免非 memoized 回调导致定时器不断重建
- result/page.tsx 增加 accessToken 最小长度校验,
  避免无效参数触发无意义的 API 请求
2026-03-10 14:31:26 +08:00
eriol touwa
d6973256a7 Merge pull request #7 from daguimu/feat/alipay-shortlink-notify-fixes
fix: harden alipay direct pay flow
2026-03-10 14:27:06 +08:00
daguimu
8b10bc3bd5 fix: harden alipay direct pay flow 2026-03-10 11:52:37 +08:00
erio
2492031e13 feat: 全站多语言支持 (i18n),lang=en 显示英文,其余默认中文
新增 src/lib/locale.ts 作为统一多语言入口,覆盖前台支付链路、
管理后台、API/服务层错误文案,共 35 个文件。URL 参数 lang 全链路透传,
包括 Stripe return_url、页面跳转、layout html lang 属性等。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 18:33:57 +08:00
erio
5cebe85079 fix: 用户端订单列表只显示支付渠道,不显示提供商
管理后台仍保留提供商信息显示
2026-03-09 11:32:50 +08:00
erio
5fb16f0ccf fix: 暗色模式下支付方式选中时文字与背景色冲突不可见 2026-03-09 10:12:05 +08:00
erio
43e116a4f2 Revert "fix: 各支付渠道默认单笔限额从 1000 提升至 100000,每日限额改为不限"
This reverts commit e1788437c9.
2026-03-08 00:06:23 +08:00
erio
e1788437c9 fix: 各支付渠道默认单笔限额从 1000 提升至 100000,每日限额改为不限
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 00:03:01 +08:00
erio
2df040e9b9 docs: 更新 EASY_PAY_RETURN_URL 为 /pay/result
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:01:12 +08:00
erio
8a465ae625 feat: 支付结果页增加 5 秒倒计时自动返回和手动返回按钮
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 16:55:49 +08:00
erio
1d19fc86ee fix: Dockerfile 构建时注入 dummy 环境变量避免预渲染报错
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 04:31:36 +08:00
erio
f50a180ec4 fix: 微信支付回调验签 PEM 格式自动补全,Stripe webhook 失败重试
- wxpay client: 添加 formatPublicKey() 自动包裹 PEM 头尾,修复裸 base64 公钥导致的 DECODER routines::unsupported 错误
- stripe webhook: 处理失败时返回 500 让 Stripe 重试
- 修正支付宝测试用例与实际代码对齐

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 04:27:38 +08:00
erio
698df1ee47 Merge branch 'worktree-agent-afaec22d' 2026-03-07 04:16:38 +08:00
erio
37096de05c Merge branch 'worktree-agent-a5caa164' 2026-03-07 04:16:26 +08:00
erio
d43b04cb5c fix: 前端暗色模式补全、Unicode 可读化、UI 优化 12 项
- PaymentQRCode 13 处 Unicode 转义替换为可读中文
- RefundDialog 完整暗色模式 + Escape 键关闭
- PayResult 页面添加暗色模式支持
- OrderStatus 使用 dark prop 调整样式
- PaymentForm 选中态暗色对比度修复
- OrderDetail 英文标签改中文 + Escape 键
- Pay 页面错误提示暗色适配
- 倒计时最后 60 秒脉动提醒
- 全局 CSS 添加中文字体栈
- MobileOrderList HTML 实体替换

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 04:16:01 +08:00
erio
ac0772b0f4 fix: API 路由安全加固与架构优化 — 认证、错误处理、Registry 统一
- /api/user 添加 token 认证,防止用户枚举
- Admin token 支持 Authorization header
- /api/orders/my 区分认证失败和服务端错误
- Admin orders userId/date 参数校验
- Decimal 字段统一 Number() 转换
- 抽取 handleApiError/extractHeaders 工具函数
- Webhook 路由改用 Registry 获取 Provider
- PaymentRegistry lazy init 自动初始化

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 04:15:54 +08:00
erio
4b013370b9 fix: 后端资金安全修复 — 金额覆盖、过期订单、退款原子性等 9 项
- confirmPayment 不再覆盖 amount,实付金额写入 payAmount
- EXPIRED 订单增加 5 分钟宽限窗口
- 退款流程先扣余额再退款,失败可回滚
- 支付宝签名过滤 sign_type
- executeRecharge 使用 CAS 更新
- createOrder rechargeCode 事务保护
- EasyPay/Sub2API client 添加 10s 超时
- db.ts 统一从 getEnv() 获取 DATABASE_URL
- 添加 paymentType+paidAt 复合索引

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 04:15:48 +08:00
138 changed files with 13671 additions and 1617 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

3
.gitignore vendored
View File

@@ -42,3 +42,6 @@ next-env.d.ts
# third-party source code (local reference only)
/third-party
# Claude Code project instructions (contains sensitive deployment info)
CLAUDE.md

View File

@@ -11,7 +11,13 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm prisma generate
RUN pnpm build
# 构建时注入 dummy 环境变量,避免 Next.js 预渲染 API 路由时 getEnv() 报错
RUN DATABASE_URL="postgresql://x:x@localhost/x" \
SUB2API_BASE_URL="https://localhost" \
SUB2API_ADMIN_API_KEY="build-dummy" \
ADMIN_TOKEN="build-dummy-placeholder-key" \
NEXT_PUBLIC_APP_URL="https://localhost" \
pnpm build
FROM node:22-alpine AS runner
WORKDIR /app

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-present touwaeriol
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

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 |
| --------------------- | ---------------------------------------------------------------- |
@@ -133,10 +129,37 @@ Any payment provider compatible with the **EasyPay protocol** can be used, such
| `EASY_PAY_PKEY` | EasyPay merchant secret key |
| `EASY_PAY_API_BASE` | EasyPay API base URL |
| `EASY_PAY_NOTIFY_URL` | Async callback URL: `${NEXT_PUBLIC_APP_URL}/api/easy-pay/notify` |
| `EASY_PAY_RETURN_URL` | Redirect URL after payment: `${NEXT_PUBLIC_APP_URL}/pay` |
| `EASY_PAY_RETURN_URL` | Redirect URL after payment: `${NEXT_PUBLIC_APP_URL}/pay/result` |
| `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

215
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协议**的支付服务商均可接入。
| 变量 | 说明 |
| --------------------- | ------------------------------------------------------------- |
@@ -133,10 +129,37 @@ ENABLED_PAYMENT_TYPES=alipay,wxpay
| `EASY_PAY_PKEY` | EasyPay 商户密钥 |
| `EASY_PAY_API_BASE` | EasyPay API 地址 |
| `EASY_PAY_NOTIFY_URL` | 异步回调地址,填 `${NEXT_PUBLIC_APP_URL}/api/easy-pay/notify` |
| `EASY_PAY_RETURN_URL` | 支付完成跳转地址,填 `${NEXT_PUBLIC_APP_URL}/pay` |
| `EASY_PAY_RETURN_URL` | 支付完成跳转地址,填 `${NEXT_PUBLIC_APP_URL}/pay/result` |
| `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])
@@ -47,6 +54,8 @@ model Order {
@@index([expiresAt])
@@index([createdAt])
@@index([paidAt])
@@index([paymentType, paidAt])
@@index([orderType])
@@map("orders")
}
@@ -76,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,70 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
const mockFindUnique = vi.fn();
const mockVerifyAdminToken = vi.fn();
vi.mock('@/lib/db', () => ({
prisma: {
order: {
findUnique: (...args: unknown[]) => mockFindUnique(...args),
},
},
}));
vi.mock('@/lib/config', () => ({
getEnv: () => ({
ADMIN_TOKEN: 'test-admin-token',
}),
}));
vi.mock('@/lib/admin-auth', () => ({
verifyAdminToken: (...args: unknown[]) => mockVerifyAdminToken(...args),
}));
import { GET } from '@/app/api/orders/[id]/route';
import { createOrderStatusAccessToken } from '@/lib/order/status-access';
function createRequest(orderId: string, accessToken?: string) {
const url = new URL(`https://pay.example.com/api/orders/${orderId}`);
if (accessToken) {
url.searchParams.set('access_token', accessToken);
}
return new NextRequest(url);
}
describe('GET /api/orders/[id]', () => {
beforeEach(() => {
vi.clearAllMocks();
mockVerifyAdminToken.mockResolvedValue(false);
mockFindUnique.mockResolvedValue({
id: 'order-001',
status: 'PENDING',
expiresAt: new Date('2026-03-10T00:00:00.000Z'),
paidAt: null,
completedAt: null,
});
});
it('rejects requests without access token', async () => {
const response = await GET(createRequest('order-001'), { params: Promise.resolve({ id: 'order-001' }) });
expect(response.status).toBe(401);
});
it('returns order status with valid access token', async () => {
const token = createOrderStatusAccessToken('order-001');
const response = await GET(createRequest('order-001', token), { params: Promise.resolve({ id: 'order-001' }) });
const data = await response.json();
expect(response.status).toBe(200);
expect(data.id).toBe('order-001');
expect(data.paymentSuccess).toBe(false);
});
it('allows admin-authenticated access as fallback', async () => {
mockVerifyAdminToken.mockResolvedValue(true);
const response = await GET(createRequest('order-001'), { params: Promise.resolve({ id: 'order-001' }) });
expect(response.status).toBe(200);
});
});

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

@@ -0,0 +1,250 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
import { ORDER_STATUS } from '@/lib/constants';
const mockFindUnique = vi.fn();
const mockBuildAlipayPaymentUrl = vi.fn();
vi.mock('@/lib/db', () => ({
prisma: {
order: {
findUnique: (...args: unknown[]) => mockFindUnique(...args),
},
},
}));
vi.mock('@/lib/config', () => ({
getEnv: () => ({
NEXT_PUBLIC_APP_URL: 'https://pay.example.com',
PRODUCT_NAME: 'Sub2API Balance Recharge',
ALIPAY_NOTIFY_URL: 'https://pay.example.com/api/alipay/notify',
ALIPAY_RETURN_URL: 'https://pay.example.com/pay/result',
ADMIN_TOKEN: 'test-admin-token',
}),
}));
vi.mock('@/lib/alipay/provider', () => ({
buildAlipayPaymentUrl: (...args: unknown[]) => mockBuildAlipayPaymentUrl(...args),
}));
import { GET } from '@/app/pay/[orderId]/route';
import { buildOrderResultUrl } from '@/lib/order/status-access';
function createRequest(userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)') {
return new NextRequest('https://pay.example.com/pay/order-001', {
headers: { 'user-agent': userAgent },
});
}
function createPendingOrder(overrides: Record<string, unknown> = {}) {
return {
id: 'order-001',
amount: 88,
payAmount: 100.5,
paymentType: 'alipay_direct',
status: ORDER_STATUS.PENDING,
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
paidAt: null,
completedAt: null,
...overrides,
};
}
describe('GET /pay/[orderId]', () => {
beforeEach(() => {
vi.useFakeTimers({ now: new Date('2026-03-14T12:00:00Z') });
vi.clearAllMocks();
mockBuildAlipayPaymentUrl.mockReturnValue('https://openapi.alipay.com/gateway.do?mock=1');
});
afterEach(() => {
vi.useRealTimers();
});
it('returns 404 error page when order does not exist', async () => {
mockFindUnique.mockResolvedValue(null);
const response = await GET(createRequest(), {
params: Promise.resolve({ orderId: 'missing-order' }),
});
const html = await response.text();
expect(response.status).toBe(404);
expect(html).toContain('订单不存在');
expect(html).toContain('missing-order');
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
});
it('rejects non-alipay orders', async () => {
mockFindUnique.mockResolvedValue(
createPendingOrder({
paymentType: 'wxpay_direct',
}),
);
const response = await GET(createRequest(), {
params: Promise.resolve({ orderId: 'order-001' }),
});
const html = await response.text();
expect(response.status).toBe(400);
expect(html).toContain('支付方式不匹配');
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
});
it('returns success status page for completed orders', async () => {
mockFindUnique.mockResolvedValue(
createPendingOrder({
status: ORDER_STATUS.COMPLETED,
paidAt: new Date('2026-03-09T10:00:00Z'),
completedAt: new Date('2026-03-09T10:00:03Z'),
}),
);
const response = await GET(createRequest(), {
params: Promise.resolve({ orderId: 'order-001' }),
});
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain('充值成功');
expect(html).toContain('余额已到账');
expect(html).toContain('order_id=order-001');
expect(html).toContain('access_token=');
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
});
it('returns paid-but-recharge-failed status page for failed paid orders', async () => {
mockFindUnique.mockResolvedValue(
createPendingOrder({
status: ORDER_STATUS.FAILED,
paidAt: new Date('2026-03-09T10:00:00Z'),
}),
);
const response = await GET(createRequest(), {
params: Promise.resolve({ orderId: 'order-001' }),
});
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain('支付成功');
expect(html).toContain('余额充值暂未完成');
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
});
it('returns expired status page when order is timed out', async () => {
mockFindUnique.mockResolvedValue(
createPendingOrder({
expiresAt: new Date(Date.now() - 1000),
}),
);
const response = await GET(createRequest(), {
params: Promise.resolve({ orderId: 'order-001' }),
});
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain('订单超时');
expect(html).toContain('订单已超时');
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
});
it('builds desktop redirect page with service-generated alipay url and no manual pay button', async () => {
mockBuildAlipayPaymentUrl.mockReturnValue('https://openapi.alipay.com/gateway.do?desktop=1');
mockFindUnique.mockResolvedValue(createPendingOrder());
const response = await GET(createRequest(), {
params: Promise.resolve({ orderId: 'order-001' }),
});
const html = await response.text();
const expectedReturnUrl = buildOrderResultUrl('https://pay.example.com', 'order-001');
expect(response.status).toBe(200);
expect(html).toContain('正在拉起支付宝');
expect(html).toContain('https://openapi.alipay.com/gateway.do?desktop=1');
expect(html).toContain('http-equiv="refresh"');
expect(html).not.toContain('立即前往支付宝');
expect(html).toContain('查看订单结果');
expect(html).toContain('order_id=order-001');
expect(html).toContain('access_token=');
expect(mockBuildAlipayPaymentUrl).toHaveBeenCalledWith({
orderId: 'order-001',
amount: 100.5,
subject: 'Sub2API Balance Recharge 100.50 CNY',
notifyUrl: 'https://pay.example.com/api/alipay/notify',
returnUrl: expectedReturnUrl,
isMobile: false,
});
});
it('builds mobile redirect page with wap alipay url', async () => {
mockBuildAlipayPaymentUrl.mockReturnValue('https://openapi.alipay.com/gateway.do?mobile=1');
mockFindUnique.mockResolvedValue(
createPendingOrder({
payAmount: null,
amount: 88,
}),
);
const response = await GET(
createRequest('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148'),
{
params: Promise.resolve({ orderId: 'order-001' }),
},
);
const html = await response.text();
const expectedReturnUrl = buildOrderResultUrl('https://pay.example.com', 'order-001');
expect(response.status).toBe(200);
expect(html).toContain('正在拉起支付宝');
expect(html).toContain('https://openapi.alipay.com/gateway.do?mobile=1');
expect(html).not.toContain('立即前往支付宝');
expect(mockBuildAlipayPaymentUrl).toHaveBeenCalledWith({
orderId: 'order-001',
amount: 88,
subject: 'Sub2API Balance Recharge 88.00 CNY',
notifyUrl: 'https://pay.example.com/api/alipay/notify',
returnUrl: expectedReturnUrl,
isMobile: true,
});
});
it('omits returnUrl for Alipay app requests to avoid extra close step', async () => {
mockBuildAlipayPaymentUrl.mockReturnValue('https://openapi.alipay.com/gateway.do?alipayapp=1');
mockFindUnique.mockResolvedValue(createPendingOrder({ payAmount: 66 }));
const response = await GET(
createRequest(
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 AlipayClient/10.5.90',
),
{
params: Promise.resolve({ orderId: 'order-001' }),
},
);
const html = await response.text();
expect(response.status).toBe(200);
expect(html).toContain('https://openapi.alipay.com/gateway.do?alipayapp=1');
expect(html).toContain('window.location.replace(payUrl)');
expect(html).toContain('<noscript><meta http-equiv="refresh"');
expect(html).not.toContain('立即前往支付宝');
expect(mockBuildAlipayPaymentUrl).toHaveBeenCalledWith({
orderId: 'order-001',
amount: 66,
subject: 'Sub2API Balance Recharge 66.00 CNY',
notifyUrl: 'https://pay.example.com/api/alipay/notify',
returnUrl: null,
isMobile: true,
});
});
});

View File

@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/config', () => ({
getEnv: () => ({
ALIPAY_APP_ID: '2021000000000000',
ALIPAY_PRIVATE_KEY: 'test-private-key',
ALIPAY_PUBLIC_KEY: 'test-public-key',
ALIPAY_NOTIFY_URL: 'https://pay.example.com/api/alipay/notify',
ALIPAY_RETURN_URL: 'https://pay.example.com/pay/result',
}),
}));
const { mockGenerateSign } = vi.hoisted(() => ({
mockGenerateSign: vi.fn(() => 'signed-value'),
}));
vi.mock('@/lib/alipay/sign', () => ({
generateSign: mockGenerateSign,
verifyResponseSign: vi.fn(() => true),
}));
import { execute, pageExecute } from '@/lib/alipay/client';
describe('alipay client helpers', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('pageExecute includes notify_url and return_url by default', () => {
const url = new URL(
pageExecute({ out_trade_no: 'order-001', product_code: 'FAST_INSTANT_TRADE_PAY', total_amount: '10.00' }),
);
expect(url.origin + url.pathname).toBe('https://openapi.alipay.com/gateway.do');
expect(url.searchParams.get('notify_url')).toBe('https://pay.example.com/api/alipay/notify');
expect(url.searchParams.get('return_url')).toBe('https://pay.example.com/pay/result');
expect(url.searchParams.get('method')).toBe('alipay.trade.page.pay');
expect(url.searchParams.get('sign')).toBe('signed-value');
});
it('pageExecute omits return_url when explicitly disabled', () => {
const url = new URL(
pageExecute(
{ out_trade_no: 'order-002', product_code: 'QUICK_WAP_WAY', total_amount: '20.00' },
{ returnUrl: null, method: 'alipay.trade.wap.pay' },
),
);
expect(url.searchParams.get('method')).toBe('alipay.trade.wap.pay');
expect(url.searchParams.get('return_url')).toBeNull();
expect(url.searchParams.get('notify_url')).toBe('https://pay.example.com/api/alipay/notify');
});
it('execute posts form data and returns the named response payload', async () => {
global.fetch = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
alipay_trade_query_response: {
code: '10000',
msg: 'Success',
trade_status: 'TRADE_SUCCESS',
},
sign: 'server-sign',
}),
{ headers: { 'content-type': 'application/json; charset=utf-8' } },
),
) as typeof fetch;
const result = await execute('alipay.trade.query', { out_trade_no: 'order-003' });
expect(result).toEqual({ code: '10000', msg: 'Success', trade_status: 'TRADE_SUCCESS' });
expect(global.fetch).toHaveBeenCalledTimes(1);
const [url, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(url).toBe('https://openapi.alipay.com/gateway.do');
expect(init.method).toBe('POST');
expect(init.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded' });
expect(String(init.body)).toContain('method=alipay.trade.query');
});
it('execute throws when alipay response code is not successful', async () => {
global.fetch = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
alipay_trade_query_response: {
code: '40004',
msg: 'Business Failed',
sub_code: 'ACQ.TRADE_NOT_EXIST',
sub_msg: 'trade not exist',
},
}),
{ headers: { 'content-type': 'application/json; charset=utf-8' } },
),
) as typeof fetch;
await expect(execute('alipay.trade.query', { out_trade_no: 'order-004' })).rejects.toThrow(
'[ACQ.TRADE_NOT_EXIST] trade not exist',
);
});
});

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { decodeAlipayPayload, parseAlipayNotificationParams } from '@/lib/alipay/codec';
describe('Alipay codec', () => {
it('should normalize plus signs in notify sign parameter', () => {
const params = parseAlipayNotificationParams(Buffer.from('sign=abc+def&trade_no=1'), {
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
});
expect(params.sign).toBe('abc+def');
expect(params.trade_no).toBe('1');
});
it('should decode payload charset from content-type header', () => {
const body = Buffer.from('charset=utf-8&trade_status=TRADE_SUCCESS', 'utf-8');
const decoded = decodeAlipayPayload(body, {
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
});
expect(decoded).toContain('trade_status=TRADE_SUCCESS');
});
it('should fallback to body charset hint when header is missing', () => {
const body = Buffer.from('charset=gbk&trade_no=202603090001', 'utf-8');
const decoded = decodeAlipayPayload(body);
expect(decoded).toContain('trade_no=202603090001');
});
});

View File

@@ -8,6 +8,7 @@ vi.mock('@/lib/config', () => ({
ALIPAY_NOTIFY_URL: 'https://pay.example.com/api/alipay/notify',
ALIPAY_RETURN_URL: 'https://pay.example.com/pay/result',
NEXT_PUBLIC_APP_URL: 'https://pay.example.com',
PRODUCT_NAME: 'Sub2API Balance Recharge',
}),
}));
@@ -25,7 +26,7 @@ vi.mock('@/lib/alipay/sign', () => ({
verifySign: (...args: unknown[]) => mockVerifySign(...args),
}));
import { AlipayProvider } from '@/lib/alipay/provider';
import { AlipayProvider, buildAlipayEntryUrl } from '@/lib/alipay/provider';
import type { CreatePaymentRequest, RefundRequest } from '@/lib/payment/types';
describe('AlipayProvider', () => {
@@ -57,13 +58,11 @@ describe('AlipayProvider', () => {
});
describe('createPayment', () => {
it('should call pageExecute and return payUrl', async () => {
mockPageExecute.mockReturnValue('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
it('should return service short link as desktop qrCode', async () => {
const request: CreatePaymentRequest = {
orderId: 'order-001',
amount: 100,
paymentType: 'alipay',
paymentType: 'alipay_direct',
subject: 'Sub2API Balance Recharge 100.00 CNY',
clientIp: '127.0.0.1',
};
@@ -71,16 +70,42 @@ describe('AlipayProvider', () => {
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('order-001');
expect(result.qrCode).toBe('https://pay.example.com/pay/order-001');
expect(result.payUrl).toBe('https://pay.example.com/pay/order-001');
expect(mockExecute).not.toHaveBeenCalled();
expect(mockPageExecute).not.toHaveBeenCalled();
});
it('should build short link from app url', () => {
expect(buildAlipayEntryUrl('order-short-link')).toBe('https://pay.example.com/pay/order-short-link');
});
it('should call pageExecute for mobile and return payUrl', async () => {
mockPageExecute.mockReturnValue('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
const request: CreatePaymentRequest = {
orderId: 'order-002',
amount: 50,
paymentType: 'alipay_direct',
subject: 'Sub2API Balance Recharge 50.00 CNY',
clientIp: '127.0.0.1',
isMobile: true,
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('order-002');
expect(result.payUrl).toBe('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
expect(mockPageExecute).toHaveBeenCalledWith(
{
out_trade_no: 'order-001',
product_code: 'FAST_INSTANT_TRADE_PAY',
total_amount: '100.00',
subject: 'Sub2API Balance Recharge 100.00 CNY',
out_trade_no: 'order-002',
product_code: 'QUICK_WAP_WAY',
total_amount: '50.00',
subject: 'Sub2API Balance Recharge 50.00 CNY',
},
expect.objectContaining({}),
expect.objectContaining({ method: 'alipay.trade.wap.pay' }),
);
expect(mockExecute).not.toHaveBeenCalled();
});
});
@@ -140,6 +165,15 @@ describe('AlipayProvider', () => {
const result = await provider.queryOrder('order-004');
expect(result.status).toBe('failed');
});
it('should treat ACQ.TRADE_NOT_EXIST as pending', async () => {
mockExecute.mockRejectedValue(new Error('Alipay API error: [ACQ.TRADE_NOT_EXIST] 交易不存在'));
const result = await provider.queryOrder('order-005');
expect(result.tradeNo).toBe('order-005');
expect(result.status).toBe('pending');
expect(result.amount).toBe(0);
});
});
describe('verifyNotification', () => {
@@ -174,6 +208,7 @@ describe('AlipayProvider', () => {
total_amount: '50.00',
sign: 'test_sign',
sign_type: 'RSA2',
app_id: '2021000000000000',
}).toString();
const result = await provider.verifyNotification(body, {});
@@ -187,88 +222,108 @@ describe('AlipayProvider', () => {
trade_no: '2026030500003',
out_trade_no: 'order-003',
trade_status: 'TRADE_CLOSED',
total_amount: '30.00',
total_amount: '20.00',
sign: 'test_sign',
sign_type: 'RSA2',
app_id: '2021000000000000',
}).toString();
const result = await provider.verifyNotification(body, {});
expect(result.status).toBe('failed');
});
it('should throw on invalid signature', async () => {
mockVerifySign.mockReturnValue(false);
it('should reject unsupported sign_type', async () => {
const body = new URLSearchParams({
trade_no: '2026030500004',
out_trade_no: 'order-004',
trade_status: 'TRADE_SUCCESS',
total_amount: '20.00',
sign: 'test_sign',
sign_type: 'RSA',
app_id: '2021000000000000',
}).toString();
await expect(provider.verifyNotification(body, {})).rejects.toThrow('Unsupported sign_type');
});
it('should reject invalid signature', async () => {
mockVerifySign.mockReturnValue(false);
const body = new URLSearchParams({
trade_no: '2026030500005',
out_trade_no: 'order-005',
trade_status: 'TRADE_SUCCESS',
total_amount: '20.00',
sign: 'bad_sign',
sign_type: 'RSA2',
app_id: '2021000000000000',
}).toString();
await expect(provider.verifyNotification(body, {})).rejects.toThrow(
'Alipay notification signature verification failed',
);
});
it('should reject app_id mismatch', async () => {
mockVerifySign.mockReturnValue(true);
const body = new URLSearchParams({
trade_no: '2026030500006',
out_trade_no: 'order-006',
trade_status: 'TRADE_SUCCESS',
total_amount: '20.00',
sign: 'test_sign',
sign_type: 'RSA2',
app_id: '2021000000009999',
}).toString();
await expect(provider.verifyNotification(body, {})).rejects.toThrow('Alipay notification app_id mismatch');
});
});
describe('refund', () => {
it('should call alipay.trade.refund and return success', async () => {
it('should request refund and map success status', async () => {
mockExecute.mockResolvedValue({
code: '10000',
msg: 'Success',
trade_no: '2026030500001',
trade_no: 'refund-trade-no',
fund_change: 'Y',
});
const request: RefundRequest = {
tradeNo: '2026030500001',
orderId: 'order-001',
amount: 100,
reason: 'customer request',
tradeNo: 'trade-no',
orderId: 'order-refund',
amount: 12.34,
reason: 'test refund',
};
const result = await provider.refund(request);
expect(result.refundId).toBe('2026030500001');
expect(result.status).toBe('success');
expect(result).toEqual({ refundId: 'refund-trade-no', status: 'success' });
expect(mockExecute).toHaveBeenCalledWith('alipay.trade.refund', {
out_trade_no: 'order-001',
refund_amount: '100.00',
refund_reason: 'customer request',
out_trade_no: 'order-refund',
refund_amount: '12.34',
refund_reason: 'test refund',
out_request_no: 'order-refund-refund',
});
});
it('should return pending when fund_change is N', async () => {
mockExecute.mockResolvedValue({
code: '10000',
msg: 'Success',
trade_no: '2026030500002',
fund_change: 'N',
});
const result = await provider.refund({
tradeNo: '2026030500002',
orderId: 'order-002',
amount: 50,
});
expect(result.status).toBe('pending');
});
});
describe('cancelPayment', () => {
it('should call alipay.trade.close', async () => {
mockExecute.mockResolvedValue({
code: '10000',
msg: 'Success',
trade_no: '2026030500001',
});
it('should close payment by out_trade_no', async () => {
mockExecute.mockResolvedValue({ code: '10000', msg: 'Success' });
await provider.cancelPayment('order-close');
await provider.cancelPayment('order-001');
expect(mockExecute).toHaveBeenCalledWith('alipay.trade.close', {
out_trade_no: 'order-001',
out_trade_no: 'order-close',
});
});
it('should ignore ACQ.TRADE_NOT_EXIST when closing payment', async () => {
mockExecute.mockRejectedValue(new Error('Alipay API error: [ACQ.TRADE_NOT_EXIST] 交易不存在'));
await expect(provider.cancelPayment('order-close-missing')).resolves.toBeUndefined();
});
});
});

View File

@@ -34,7 +34,6 @@ describe('Alipay RSA2 Sign', () => {
const sign = generateSign(testParams, privateKey);
expect(sign).toBeTruthy();
expect(typeof sign).toBe('string');
// base64 格式
expect(() => Buffer.from(sign, 'base64')).not.toThrow();
});
@@ -44,13 +43,12 @@ describe('Alipay RSA2 Sign', () => {
expect(sign1).toBe(sign2);
});
it('should filter out sign field but keep sign_type', () => {
it('should filter out sign field but keep sign_type in request signing', () => {
const paramsWithSign = { ...testParams, sign: 'old_sign' };
const sign1 = generateSign(testParams, privateKey);
const sign2 = generateSign(paramsWithSign, privateKey);
expect(sign1).toBe(sign2);
// sign_type should be included in signing
const paramsWithSignType = { ...testParams, sign_type: 'RSA2' };
const sign3 = generateSign(paramsWithSignType, privateKey);
expect(sign3).not.toBe(sign1);
@@ -113,5 +111,35 @@ describe('Alipay RSA2 Sign', () => {
const valid = verifySign(testParams, barePublicKey, sign);
expect(valid).toBe(true);
});
it('should work with private key using literal \\n escapes', () => {
const escapedPrivateKey = privateKey.replace(/\n/g, '\\n');
const sign = generateSign(testParams, escapedPrivateKey);
const valid = verifySign(testParams, publicKey, sign);
expect(valid).toBe(true);
});
it('should work with public key using literal \\n escapes', () => {
const escapedPublicKey = publicKey.replace(/\n/g, '\\n');
const sign = generateSign(testParams, privateKey);
const valid = verifySign(testParams, escapedPublicKey, sign);
expect(valid).toBe(true);
});
it('should work with CRLF-formatted PEM keys', () => {
const crlfPrivateKey = privateKey.replace(/\n/g, '\r\n');
const crlfPublicKey = publicKey.replace(/\n/g, '\r\n');
const sign = generateSign(testParams, crlfPrivateKey);
const valid = verifySign(testParams, crlfPublicKey, sign);
expect(valid).toBe(true);
});
it('should work with literal \\r\\n escapes in PEM keys', () => {
const escapedCrlfPrivateKey = privateKey.replace(/\n/g, '\\r\\n');
const escapedCrlfPublicKey = publicKey.replace(/\n/g, '\\r\\n');
const sign = generateSign(testParams, escapedCrlfPrivateKey);
const valid = verifySign(testParams, escapedCrlfPublicKey, sign);
expect(valid).toBe(true);
});
});
});

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,38 @@
import { describe, expect, it, vi } from 'vitest';
vi.mock('@/lib/config', () => ({
getEnv: () => ({
ADMIN_TOKEN: 'test-admin-token',
}),
}));
import {
ORDER_STATUS_ACCESS_QUERY_KEY,
buildOrderResultUrl,
createOrderStatusAccessToken,
verifyOrderStatusAccessToken,
} from '@/lib/order/status-access';
describe('order status access token helpers', () => {
it('creates and verifies a token bound to the order id', () => {
const token = createOrderStatusAccessToken('order-001');
expect(token).toBeTruthy();
expect(verifyOrderStatusAccessToken('order-001', token)).toBe(true);
expect(verifyOrderStatusAccessToken('order-002', token)).toBe(false);
});
it('rejects missing or malformed tokens', () => {
expect(verifyOrderStatusAccessToken('order-001', null)).toBe(false);
expect(verifyOrderStatusAccessToken('order-001', undefined)).toBe(false);
expect(verifyOrderStatusAccessToken('order-001', 'short')).toBe(false);
});
it('builds a result url with order id and access token', () => {
const url = new URL(buildOrderResultUrl('https://pay.example.com', 'order-009'));
expect(url.origin + url.pathname).toBe('https://pay.example.com/pay/result');
expect(url.searchParams.get('order_id')).toBe('order-009');
const token = url.searchParams.get(ORDER_STATUS_ACCESS_QUERY_KEY);
expect(token).toBeTruthy();
expect(verifyOrderStatusAccessToken('order-009', token)).toBe(true);
});
});

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest';
import { ORDER_STATUS } from '@/lib/constants';
import { deriveOrderState, getOrderDisplayState } from '@/lib/order/status';
describe('order status helpers', () => {
it('derives paid_pending after successful payment but before recharge completion', () => {
const state = deriveOrderState({
status: ORDER_STATUS.PAID,
paidAt: new Date('2026-03-09T10:00:00Z'),
completedAt: null,
});
expect(state).toEqual({
paymentSuccess: true,
rechargeSuccess: false,
rechargeStatus: 'paid_pending',
});
});
it('maps recharge failure after payment to a payment-success display state', () => {
const display = getOrderDisplayState({
status: ORDER_STATUS.FAILED,
paymentSuccess: true,
rechargeSuccess: false,
rechargeStatus: 'failed',
});
expect(display.label).toBe('支付成功');
expect(display.message).toContain('自动重试');
});
it('maps failed order before payment success to failed display', () => {
const display = getOrderDisplayState({
status: ORDER_STATUS.FAILED,
paymentSuccess: false,
rechargeSuccess: false,
rechargeStatus: 'failed',
});
expect(display.label).toBe('支付失败');
expect(display.message).toContain('重新发起支付');
});
it('maps completed order to success display', () => {
const display = getOrderDisplayState({
status: ORDER_STATUS.COMPLETED,
paymentSuccess: true,
rechargeSuccess: true,
rechargeStatus: 'success',
});
expect(display.label).toBe('充值成功');
expect(display.icon).toBe('✓');
});
it('maps pending order to waiting-for-payment display', () => {
const display = getOrderDisplayState({
status: ORDER_STATUS.PENDING,
paymentSuccess: false,
rechargeSuccess: false,
rechargeStatus: 'not_paid',
});
expect(display.label).toBe('等待支付');
});
});

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

View File

@@ -26,7 +26,7 @@ describe('Sub2API Client', () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: mockUser }),
});
}) as typeof fetch;
const user = await getUser(1);
expect(user.username).toBe('testuser');
@@ -37,7 +37,7 @@ describe('Sub2API Client', () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 404,
});
}) as typeof fetch;
await expect(getUser(999)).rejects.toThrow('USER_NOT_FOUND');
});
@@ -57,24 +57,50 @@ describe('Sub2API Client', () => {
used_by: 1,
},
}),
});
}) as typeof fetch;
const result = await createAndRedeem('s2p_test123', 100, 1, 'test notes');
expect(result.code).toBe('s2p_test123');
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
expect(fetchCall[0]).toContain('/redeem-codes/create-and-redeem');
const headers = fetchCall[1].headers;
const headers = fetchCall[1].headers as Record<string, string>;
expect(headers['Idempotency-Key']).toBe('sub2apipay:recharge:s2p_test123');
});
it('createAndRedeem should retry once on timeout', async () => {
const timeoutError = Object.assign(new Error('timed out'), { name: 'TimeoutError' });
global.fetch = vi
.fn()
.mockRejectedValueOnce(timeoutError)
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
redeem_code: {
id: 2,
code: 's2p_retry',
type: 'balance',
value: 88,
status: 'used',
used_by: 1,
},
}),
}) as typeof fetch;
const result = await createAndRedeem('s2p_retry', 88, 1, 'retry notes');
expect(result.code).toBe('s2p_retry');
expect((fetch as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(2);
});
it('subtractBalance should send subtract request', async () => {
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) }) as typeof fetch;
await subtractBalance(1, 50, 'refund', 'idempotency-key-1');
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
const body = JSON.parse(fetchCall[1].body);
const body = JSON.parse(fetchCall[1].body as string);
expect(body.operation).toBe('subtract');
expect(body.amount).toBe(50);
});

View File

@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { getBizDayStartUTC, getNextBizDayStartUTC, toBizDateStr } from '@/lib/time/biz-day';
describe('biz-day helpers', () => {
it('formats business date in Asia/Shanghai timezone', () => {
expect(toBizDateStr(new Date('2026-03-09T15:59:59.000Z'))).toBe('2026-03-09');
expect(toBizDateStr(new Date('2026-03-09T16:00:00.000Z'))).toBe('2026-03-10');
});
it('returns business day start in UTC', () => {
expect(getBizDayStartUTC(new Date('2026-03-09T15:59:59.000Z')).toISOString()).toBe('2026-03-08T16:00:00.000Z');
expect(getBizDayStartUTC(new Date('2026-03-09T16:00:00.000Z')).toISOString()).toBe('2026-03-09T16:00:00.000Z');
});
it('returns next business day start in UTC', () => {
expect(getNextBizDayStartUTC(new Date('2026-03-09T12:00:00.000Z')).toISOString()).toBe('2026-03-09T16:00:00.000Z');
});
});

View File

@@ -0,0 +1,715 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ============================================================
// Mock: EasyPay
// ============================================================
const mockEasyPayCreatePayment = vi.fn();
vi.mock('@/lib/easy-pay/client', () => ({
createPayment: (...args: unknown[]) => mockEasyPayCreatePayment(...args),
queryOrder: vi.fn(),
refund: vi.fn(),
}));
vi.mock('@/lib/easy-pay/sign', () => ({
verifySign: vi.fn(),
generateSign: vi.fn(),
}));
// ============================================================
// Mock: Alipay
// ============================================================
const mockAlipayPageExecute = vi.fn();
vi.mock('@/lib/alipay/client', () => ({
pageExecute: (...args: unknown[]) => mockAlipayPageExecute(...args),
execute: vi.fn(),
}));
vi.mock('@/lib/alipay/sign', () => ({
verifySign: vi.fn(),
generateSign: vi.fn(),
}));
// ============================================================
// Mock: Wxpay
// ============================================================
const mockWxpayCreatePcOrder = vi.fn();
const mockWxpayCreateH5Order = vi.fn();
vi.mock('@/lib/wxpay/client', () => ({
createPcOrder: (...args: unknown[]) => mockWxpayCreatePcOrder(...args),
createH5Order: (...args: unknown[]) => mockWxpayCreateH5Order(...args),
queryOrder: vi.fn(),
closeOrder: vi.fn(),
createRefund: vi.fn(),
decipherNotify: vi.fn(),
verifyNotifySign: vi.fn(),
}));
// ============================================================
// Mock: Config (shared by all providers)
// ============================================================
vi.mock('@/lib/config', () => ({
getEnv: () => ({
// EasyPay
EASY_PAY_PID: 'test-pid',
EASY_PAY_PKEY: 'test-pkey',
EASY_PAY_API_BASE: 'https://easypay.example.com',
EASY_PAY_NOTIFY_URL: 'https://pay.example.com/api/easypay/notify',
EASY_PAY_RETURN_URL: 'https://pay.example.com/pay/result',
// Alipay
ALIPAY_APP_ID: '2021000000000000',
ALIPAY_PRIVATE_KEY: 'test-private-key',
ALIPAY_PUBLIC_KEY: 'test-public-key',
ALIPAY_NOTIFY_URL: 'https://pay.example.com/api/alipay/notify',
ALIPAY_RETURN_URL: 'https://pay.example.com/pay/result',
// Wxpay
WXPAY_APP_ID: 'wx-test-app-id',
WXPAY_MCH_ID: 'wx-test-mch-id',
WXPAY_PRIVATE_KEY: 'test-private-key',
WXPAY_API_V3_KEY: 'test-api-v3-key',
WXPAY_PUBLIC_KEY: 'test-public-key',
WXPAY_PUBLIC_KEY_ID: 'test-public-key-id',
WXPAY_CERT_SERIAL: 'test-cert-serial',
WXPAY_NOTIFY_URL: 'https://pay.example.com/api/wxpay/notify',
// General
NEXT_PUBLIC_APP_URL: 'https://pay.example.com',
}),
}));
// ============================================================
// Imports (must come after mocks)
// ============================================================
import { EasyPayProvider } from '@/lib/easy-pay/provider';
import { AlipayProvider } from '@/lib/alipay/provider';
import { WxpayProvider } from '@/lib/wxpay/provider';
import { isStripeType } from '@/lib/pay-utils';
import { REDIRECT_PAYMENT_TYPES } from '@/lib/constants';
import type { CreatePaymentRequest } from '@/lib/payment/types';
// ============================================================
// Helper: simulate shouldAutoRedirect logic from PaymentQRCode
// ============================================================
function shouldAutoRedirect(opts: {
expired: boolean;
paymentType?: string;
payUrl?: string | null;
qrCode?: string | null;
isMobile: boolean;
}): boolean {
return !opts.expired && !isStripeType(opts.paymentType) && !!opts.payUrl && (opts.isMobile || !opts.qrCode);
}
// ============================================================
// Tests
// ============================================================
describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ----------------------------------------------------------
// EasyPay Provider
// ----------------------------------------------------------
describe('EasyPayProvider', () => {
let provider: EasyPayProvider;
beforeEach(() => {
provider = new EasyPayProvider();
});
it('PC: createPayment returns both payUrl and qrCode', async () => {
mockEasyPayCreatePayment.mockResolvedValue({
code: 1,
trade_no: 'EP-001',
payurl: 'https://easypay.example.com/pay/EP-001',
qrcode: 'https://qr.alipay.com/fkx12345',
});
const request: CreatePaymentRequest = {
orderId: 'order-ep-001',
amount: 50,
paymentType: 'alipay',
subject: 'Test Recharge',
clientIp: '1.2.3.4',
isMobile: false,
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('EP-001');
expect(result.qrCode).toBe('https://qr.alipay.com/fkx12345');
expect(result.payUrl).toBe('https://easypay.example.com/pay/EP-001');
// PC + has qrCode + has payUrl => shouldAutoRedirect = false (show QR)
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay',
payUrl: result.payUrl,
qrCode: result.qrCode,
isMobile: false,
}),
).toBe(false);
});
it('Mobile: createPayment returns payUrl for redirect', async () => {
mockEasyPayCreatePayment.mockResolvedValue({
code: 1,
trade_no: 'EP-002',
payurl: 'https://easypay.example.com/pay/EP-002',
qrcode: 'https://qr.alipay.com/fkx67890',
});
const request: CreatePaymentRequest = {
orderId: 'order-ep-002',
amount: 100,
paymentType: 'wxpay',
subject: 'Test Recharge',
clientIp: '1.2.3.4',
isMobile: true,
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('EP-002');
expect(result.payUrl).toBeDefined();
// Mobile + has payUrl => shouldAutoRedirect = true (redirect)
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'wxpay',
payUrl: result.payUrl,
qrCode: result.qrCode,
isMobile: true,
}),
).toBe(true);
});
it('EasyPay forwards isMobile to client for device=jump on mobile', async () => {
mockEasyPayCreatePayment.mockResolvedValue({
code: 1,
trade_no: 'EP-003',
payurl: 'https://easypay.example.com/pay/EP-003',
qrcode: 'weixin://wxpay/bizpayurl?pr=xxx',
});
const request: CreatePaymentRequest = {
orderId: 'order-ep-003',
amount: 10,
paymentType: 'alipay',
subject: 'Test',
clientIp: '1.2.3.4',
isMobile: true,
};
await provider.createPayment(request);
// EasyPay client receives isMobile so it can set device=jump
expect(mockEasyPayCreatePayment).toHaveBeenCalledWith(
expect.objectContaining({
outTradeNo: 'order-ep-003',
paymentType: 'alipay',
isMobile: true,
}),
);
});
});
// ----------------------------------------------------------
// Alipay Provider
// ----------------------------------------------------------
describe('AlipayProvider', () => {
let provider: AlipayProvider;
beforeEach(() => {
provider = new AlipayProvider();
});
it('PC: returns service short-link payUrl and qrCode', async () => {
const request: CreatePaymentRequest = {
orderId: 'order-ali-001',
amount: 100,
paymentType: 'alipay_direct',
subject: 'Test Recharge',
isMobile: false,
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('order-ali-001');
expect(result.payUrl).toBe('https://pay.example.com/pay/order-ali-001');
expect(result.qrCode).toBe('https://pay.example.com/pay/order-ali-001');
expect(mockAlipayPageExecute).not.toHaveBeenCalled();
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay_direct',
payUrl: result.payUrl,
qrCode: result.qrCode,
isMobile: false,
}),
).toBe(false);
});
it('Mobile: uses alipay.trade.wap.pay, returns payUrl', async () => {
mockAlipayPageExecute.mockReturnValue(
'https://openapi.alipay.com/gateway.do?method=alipay.trade.wap.pay&sign=yyy',
);
const request: CreatePaymentRequest = {
orderId: 'order-ali-002',
amount: 50,
paymentType: 'alipay_direct',
subject: 'Test Recharge',
isMobile: true,
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('order-ali-002');
expect(result.payUrl).toContain('alipay.trade.wap.pay');
expect(result.qrCode).toBeUndefined();
// Verify pageExecute was called with H5 method
expect(mockAlipayPageExecute).toHaveBeenCalledWith(
expect.objectContaining({
product_code: 'QUICK_WAP_WAY',
}),
expect.objectContaining({
method: 'alipay.trade.wap.pay',
}),
);
// Mobile + payUrl => shouldAutoRedirect = true
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay_direct',
payUrl: result.payUrl,
qrCode: result.qrCode,
isMobile: true,
}),
).toBe(true);
});
it('Mobile: surfaces wap.pay creation errors', async () => {
mockAlipayPageExecute.mockImplementationOnce(() => {
throw new Error('WAP pay not available');
});
const request: CreatePaymentRequest = {
orderId: 'order-ali-003',
amount: 30,
paymentType: 'alipay_direct',
subject: 'Test Recharge',
isMobile: true,
};
await expect(provider.createPayment(request)).rejects.toThrow('WAP pay not available');
expect(mockAlipayPageExecute).toHaveBeenCalledTimes(1);
expect(mockAlipayPageExecute).toHaveBeenCalledWith(
expect.objectContaining({ product_code: 'QUICK_WAP_WAY' }),
expect.objectContaining({ method: 'alipay.trade.wap.pay' }),
);
});
it('alipay_direct is in REDIRECT_PAYMENT_TYPES', () => {
expect(REDIRECT_PAYMENT_TYPES.has('alipay_direct')).toBe(true);
});
});
// ----------------------------------------------------------
// Wxpay Provider
// ----------------------------------------------------------
describe('WxpayProvider', () => {
let provider: WxpayProvider;
beforeEach(() => {
provider = new WxpayProvider();
});
it('PC: uses Native order, returns qrCode (no payUrl)', async () => {
mockWxpayCreatePcOrder.mockResolvedValue('weixin://wxpay/bizpayurl?pr=abc123');
const request: CreatePaymentRequest = {
orderId: 'order-wx-001',
amount: 100,
paymentType: 'wxpay_direct',
subject: 'Test Recharge',
clientIp: '1.2.3.4',
isMobile: false,
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('order-wx-001');
expect(result.qrCode).toBe('weixin://wxpay/bizpayurl?pr=abc123');
expect(result.payUrl).toBeUndefined();
// createPcOrder was called, not createH5Order
expect(mockWxpayCreatePcOrder).toHaveBeenCalledTimes(1);
expect(mockWxpayCreateH5Order).not.toHaveBeenCalled();
// PC + qrCode (no payUrl) => shouldAutoRedirect = false (show QR)
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'wxpay_direct',
payUrl: result.payUrl,
qrCode: result.qrCode,
isMobile: false,
}),
).toBe(false);
});
it('Mobile: uses H5 order, returns payUrl (no qrCode)', async () => {
mockWxpayCreateH5Order.mockResolvedValue('https://wx.tenpay.com/cgi-bin/mmpayweb-bin/checkmweb?prepay_id=wx123');
const request: CreatePaymentRequest = {
orderId: 'order-wx-002',
amount: 50,
paymentType: 'wxpay_direct',
subject: 'Test Recharge',
clientIp: '2.3.4.5',
isMobile: true,
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('order-wx-002');
expect(result.payUrl).toContain('tenpay.com');
expect(result.qrCode).toBeUndefined();
// createH5Order was called, not createPcOrder
expect(mockWxpayCreateH5Order).toHaveBeenCalledTimes(1);
expect(mockWxpayCreatePcOrder).not.toHaveBeenCalled();
// Mobile + payUrl => shouldAutoRedirect = true
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'wxpay_direct',
payUrl: result.payUrl,
qrCode: result.qrCode,
isMobile: true,
}),
).toBe(true);
});
it('Mobile: falls back to Native qrCode when H5 returns NO_AUTH', async () => {
mockWxpayCreateH5Order.mockRejectedValue(new Error('Wxpay API error: [NO_AUTH] not authorized'));
mockWxpayCreatePcOrder.mockResolvedValue('weixin://wxpay/bizpayurl?pr=fallback123');
const request: CreatePaymentRequest = {
orderId: 'order-wx-003',
amount: 30,
paymentType: 'wxpay_direct',
subject: 'Test Recharge',
clientIp: '3.4.5.6',
isMobile: true,
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('order-wx-003');
expect(result.qrCode).toBe('weixin://wxpay/bizpayurl?pr=fallback123');
expect(result.payUrl).toBeUndefined();
// Both were called: H5 failed, then Native succeeded
expect(mockWxpayCreateH5Order).toHaveBeenCalledTimes(1);
expect(mockWxpayCreatePcOrder).toHaveBeenCalledTimes(1);
// Mobile + qrCode only (no payUrl) => shouldAutoRedirect = false (show QR)
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'wxpay_direct',
payUrl: result.payUrl,
qrCode: result.qrCode,
isMobile: true,
}),
).toBe(false);
});
it('Mobile: re-throws non-NO_AUTH errors from H5', async () => {
mockWxpayCreateH5Order.mockRejectedValue(new Error('Wxpay API error: [SYSTEMERROR] system error'));
const request: CreatePaymentRequest = {
orderId: 'order-wx-004',
amount: 20,
paymentType: 'wxpay_direct',
subject: 'Test Recharge',
clientIp: '4.5.6.7',
isMobile: true,
};
await expect(provider.createPayment(request)).rejects.toThrow('SYSTEMERROR');
// Should not fall back to PC order
expect(mockWxpayCreatePcOrder).not.toHaveBeenCalled();
});
it('Mobile without clientIp: falls back to Native qrCode directly', async () => {
mockWxpayCreatePcOrder.mockResolvedValue('weixin://wxpay/bizpayurl?pr=noip');
const request: CreatePaymentRequest = {
orderId: 'order-wx-005',
amount: 10,
paymentType: 'wxpay_direct',
subject: 'Test Recharge',
// No clientIp
isMobile: true,
};
const result = await provider.createPayment(request);
expect(result.qrCode).toBe('weixin://wxpay/bizpayurl?pr=noip');
expect(result.payUrl).toBeUndefined();
// H5 was never attempted since clientIp is missing
expect(mockWxpayCreateH5Order).not.toHaveBeenCalled();
});
it('uses request.notifyUrl as fallback when WXPAY_NOTIFY_URL is set', async () => {
mockWxpayCreatePcOrder.mockResolvedValue('weixin://wxpay/bizpayurl?pr=withnotify');
const request: CreatePaymentRequest = {
orderId: 'order-wx-006',
amount: 10,
paymentType: 'wxpay_direct',
subject: 'Test',
isMobile: false,
notifyUrl: 'https://pay.example.com/api/wxpay/notify-alt',
};
const result = await provider.createPayment(request);
expect(result.qrCode).toBe('weixin://wxpay/bizpayurl?pr=withnotify');
// WXPAY_NOTIFY_URL from env takes priority over request.notifyUrl
expect(mockWxpayCreatePcOrder).toHaveBeenCalledWith(
expect.objectContaining({
notify_url: 'https://pay.example.com/api/wxpay/notify',
}),
);
});
});
// ----------------------------------------------------------
// shouldAutoRedirect logic (PaymentQRCode component)
// ----------------------------------------------------------
describe('shouldAutoRedirect (PaymentQRCode logic)', () => {
it('PC + qrCode + payUrl => false (show QR code, do not redirect)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay',
payUrl: 'https://example.com/pay',
qrCode: 'https://qr.alipay.com/xxx',
isMobile: false,
}),
).toBe(false);
});
it('PC + payUrl only (no qrCode) => true (redirect)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay_direct',
payUrl: 'https://openapi.alipay.com/gateway.do?...',
qrCode: undefined,
isMobile: false,
}),
).toBe(true);
});
it('PC + payUrl + empty qrCode string => true (redirect)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay_direct',
payUrl: 'https://openapi.alipay.com/gateway.do?...',
qrCode: '',
isMobile: false,
}),
).toBe(true);
});
it('PC + payUrl + null qrCode => true (redirect)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay_direct',
payUrl: 'https://openapi.alipay.com/gateway.do?...',
qrCode: null,
isMobile: false,
}),
).toBe(true);
});
it('Mobile + payUrl => true (redirect)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'wxpay_direct',
payUrl: 'https://wx.tenpay.com/...',
qrCode: undefined,
isMobile: true,
}),
).toBe(true);
});
it('Mobile + payUrl + qrCode => true (redirect, mobile always prefers payUrl)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay',
payUrl: 'https://easypay.example.com/pay/xxx',
qrCode: 'https://qr.alipay.com/xxx',
isMobile: true,
}),
).toBe(true);
});
it('Mobile + qrCode only (no payUrl) => false (show QR code)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'wxpay_direct',
payUrl: undefined,
qrCode: 'weixin://wxpay/bizpayurl?pr=xxx',
isMobile: true,
}),
).toBe(false);
});
it('Stripe => false (never redirect, uses Payment Element)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'stripe',
payUrl: 'https://checkout.stripe.com/xxx',
qrCode: undefined,
isMobile: false,
}),
).toBe(false);
});
it('Stripe on mobile => false (still no redirect)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'stripe',
payUrl: 'https://checkout.stripe.com/xxx',
qrCode: undefined,
isMobile: true,
}),
).toBe(false);
});
it('Expired order => false (never redirect expired orders)', () => {
expect(
shouldAutoRedirect({
expired: true,
paymentType: 'alipay',
payUrl: 'https://example.com/pay',
qrCode: undefined,
isMobile: true,
}),
).toBe(false);
});
it('No payUrl at all => false (nothing to redirect to)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay',
payUrl: undefined,
qrCode: undefined,
isMobile: true,
}),
).toBe(false);
});
it('Empty payUrl string => false (treated as falsy)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay',
payUrl: '',
qrCode: undefined,
isMobile: true,
}),
).toBe(false);
});
it('Null payUrl => false (treated as falsy)', () => {
expect(
shouldAutoRedirect({
expired: false,
paymentType: 'alipay',
payUrl: null,
qrCode: undefined,
isMobile: true,
}),
).toBe(false);
});
});
// ----------------------------------------------------------
// Utility function tests
// ----------------------------------------------------------
describe('isStripeType', () => {
it('returns true for "stripe"', () => {
expect(isStripeType('stripe')).toBe(true);
});
it('returns true for stripe-prefixed types', () => {
expect(isStripeType('stripe_card')).toBe(true);
});
it('returns false for alipay', () => {
expect(isStripeType('alipay')).toBe(false);
});
it('returns false for wxpay', () => {
expect(isStripeType('wxpay')).toBe(false);
});
it('returns false for undefined', () => {
expect(isStripeType(undefined)).toBe(false);
});
it('returns false for null', () => {
expect(isStripeType(null)).toBe(false);
});
});
describe('REDIRECT_PAYMENT_TYPES', () => {
it('includes alipay_direct', () => {
expect(REDIRECT_PAYMENT_TYPES.has('alipay_direct')).toBe(true);
});
it('does not include alipay (easypay version)', () => {
expect(REDIRECT_PAYMENT_TYPES.has('alipay')).toBe(false);
});
it('does not include wxpay types', () => {
expect(REDIRECT_PAYMENT_TYPES.has('wxpay')).toBe(false);
expect(REDIRECT_PAYMENT_TYPES.has('wxpay_direct')).toBe(false);
});
it('does not include stripe', () => {
expect(REDIRECT_PAYMENT_TYPES.has('stripe')).toBe(false);
});
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +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 } from '@/lib/locale';
interface DashboardData {
summary: {
@@ -34,9 +35,39 @@ function DashboardContent() {
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',
loadFailed: 'Failed to load data',
title: 'Dashboard',
subtitle: 'Recharge order analytics and insights',
daySuffix: 'd',
orders: 'Order Management',
refresh: 'Refresh',
loading: 'Loading...',
}
: {
missingToken: '缺少管理员凭证',
missingTokenHint: '请从 Sub2API 平台正确访问管理页面',
invalidToken: '管理员凭证无效',
requestFailed: '请求失败',
loadFailed: '加载数据失败',
title: '数据概览',
subtitle: '充值订单统计与分析',
daySuffix: '天',
orders: '订单管理',
refresh: '刷新',
loading: '加载中...',
};
const [days, setDays] = useState<number>(30);
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
@@ -50,14 +81,14 @@ function DashboardContent() {
const res = await fetch(`/api/admin/dashboard?token=${encodeURIComponent(token)}&days=${days}`);
if (!res.ok) {
if (res.status === 401) {
setError('管理员凭证无效');
setError(text.invalidToken);
return;
}
throw new Error('请求失败');
throw new Error(text.requestFailed);
}
setData(await res.json());
} catch {
setError('加载数据失败');
setError(text.loadFailed);
} finally {
setLoading(false);
}
@@ -71,8 +102,8 @@ function DashboardContent() {
return (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
<div className="text-center text-red-500">
<p className="text-lg font-medium"></p>
<p className="mt-2 text-sm text-gray-500"> Sub2API 访</p>
<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>
);
@@ -80,6 +111,7 @@ function DashboardContent() {
const navParams = new URLSearchParams();
navParams.set('token', token);
if (locale === 'en') navParams.set('lang', 'en');
if (theme === 'dark') navParams.set('theme', 'dark');
if (isEmbedded) navParams.set('ui_mode', 'embedded');
@@ -100,20 +132,22 @@ function DashboardContent() {
isDark={isDark}
isEmbedded={isEmbedded}
maxWidth="full"
title="数据概览"
subtitle="充值订单统计与分析"
title={text.title}
subtitle={text.subtitle}
locale={locale}
actions={
<>
{DAYS_OPTIONS.map((d) => (
<button key={d} type="button" onClick={() => setDays(d)} className={days === d ? btnActive : btnBase}>
{d}
{d}
{text.daySuffix}
</button>
))}
<a href={`/admin?${navParams}`} className={btnBase}>
<a href={`/admin/orders?${navParams}`} className={btnBase}>
{text.orders}
</a>
<button type="button" onClick={fetchData} className={btnBase}>
{text.refresh}
</button>
</>
}
@@ -130,14 +164,14 @@ function DashboardContent() {
)}
{loading ? (
<div className={`py-24 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>...</div>
<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} />
<DailyChart data={data.dailySeries} dark={isDark} />
<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} />
<PaymentMethodChart data={data.paymentMethods} dark={isDark} />
<Leaderboard data={data.leaderboard} dark={isDark} locale={locale} />
<PaymentMethodChart data={data.paymentMethods} dark={isDark} locale={locale} />
</div>
</div>
) : null}
@@ -145,15 +179,20 @@ function DashboardContent() {
);
}
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-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
</div>
);
}
export default function DashboardPage() {
return (
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center">
<div className="text-gray-500">...</div>
</div>
}
>
<Suspense fallback={<DashboardPageFallback />}>
<DashboardContent />
</Suspense>
);

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 data-theme={theme} className={['min-h-screen', isDark ? 'bg-slate-950' : 'bg-slate-100'].join(' ')}>
<div className="px-2 pt-2 sm:px-3 sm:pt-3">
<nav
className={[
'mb-1 flex flex-wrap gap-1 rounded-xl border p-1',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-100/90',
].join(' ')}
>
{NAV_ITEMS.map((item) => (
<a
key={item.path}
href={buildUrl(item.path)}
className={[
'rounded-lg px-3 py-1.5 text-xs font-medium transition-colors',
isActive(item.path)
? isDark
? 'bg-indigo-500/30 text-indigo-100 ring-1 ring-indigo-300/35'
: 'bg-white text-slate-900 ring-1 ring-slate-300 shadow-sm'
: isDark
? 'text-slate-400 hover:text-slate-200 hover:bg-slate-700/50'
: 'text-slate-500 hover:text-slate-700 hover:bg-slate-200/70',
].join(' ')}
>
{item.label[locale]}
</a>
))}
</nav>
</div>
{children}
</div>
);
}
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return (
<Suspense>
<AdminLayoutInner>{children}</AdminLayoutInner>
</Suspense>
);
}

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,169 +2,117 @@
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 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';
const uiMode = searchParams.get('ui_mode') || 'standalone';
const locale = resolveLocale(searchParams.get('lang'));
const isDark = theme === 'dark';
const isEmbedded = uiMode === 'embedded';
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 text =
locale === 'en'
? {
missingToken: 'Missing admin token',
missingTokenHint: 'Please access the admin page from the Sub2API platform.',
invalidToken: 'Invalid admin token',
requestFailed: 'Request failed',
loadFailed: 'Failed to load data',
title: 'Dashboard',
subtitle: 'Recharge order analytics and insights',
daySuffix: 'd',
orders: 'Order Management',
refresh: 'Refresh',
loading: 'Loading...',
}
: {
missingToken: '缺少管理员凭证',
missingTokenHint: '请从 Sub2API 平台正确访问管理页面',
invalidToken: '管理员凭证无效',
requestFailed: '请求失败',
loadFailed: '加载数据失败',
title: '数据概览',
subtitle: '充值订单统计与分析',
daySuffix: '天',
orders: '订单管理',
refresh: '刷新',
loading: '加载中...',
};
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('管理员凭证无效');
setError(text.invalidToken);
return;
}
throw new Error('请求失败');
throw new Error(text.requestFailed);
}
const data = await res.json();
setOrders(data.orders);
setTotal(data.total);
setTotalPages(data.total_pages);
} catch (e) {
setError('加载订单列表失败');
setData(await res.json());
} catch {
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"></p>
<p className="mt-2 text-sm text-gray-500"> Sub2API 访</p>
<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('确认重试充值?')) 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 || '重试失败');
}
} catch {
setError('重试请求失败');
}
};
const handleCancel = async (orderId: string) => {
if (!confirm('确认取消该订单?')) 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 || '取消失败');
}
} catch {
setError('取消请求失败');
}
};
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('加载订单详情失败');
}
};
const statuses = ['', 'PENDING', 'PAID', 'RECHARGING', 'COMPLETED', 'EXPIRED', 'CANCELLED', 'FAILED', 'REFUNDED'];
const statusLabels: Record<string, string> = {
'': '全部',
PENDING: '待支付',
PAID: '已支付',
RECHARGING: '充值中',
COMPLETED: '已完成',
EXPIRED: '已超时',
CANCELLED: '已取消',
FAILED: '充值失败',
REFUNDED: '已退款',
};
const navParams = new URLSearchParams();
if (token) navParams.set('token', token);
if (isDark) navParams.set('theme', 'dark');
navParams.set('token', token);
if (locale === 'en') navParams.set('lang', 'en');
if (theme === 'dark') navParams.set('theme', 'dark');
if (isEmbedded) navParams.set('ui_mode', 'embedded');
const btnBase = [
@@ -174,20 +122,32 @@ 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}
isEmbedded={isEmbedded}
maxWidth="full"
title="订单管理"
subtitle="查看和管理所有充值订单"
title={text.title}
subtitle={text.subtitle}
locale={locale}
actions={
<>
<a href={`/admin/dashboard?${navParams}`} className={btnBase}>
{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>
</>
}
@@ -203,81 +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'}`}>...</div>
) : (
<OrderTable
orders={orders}
onRetry={handleRetry}
onCancel={handleCancel}
onViewDetail={handleViewDetail}
dark={isDark}
/>
)}
</div>
<PaginationBar
page={page}
totalPages={totalPages}
total={total}
pageSize={pageSize}
loading={loading}
onPageChange={(p) => setPage(p)}
onPageSizeChange={(s) => {
setPageSize(s);
setPage(1);
}}
isDark={isDark}
/>
{/* Order Detail */}
{detailOrder && <OrderDetail order={detailOrder} onClose={() => setDetailOrder(null)} dark={isDark} />}
{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>
);
}
export default function AdminPage() {
function DashboardPageFallback() {
const searchParams = useSearchParams();
const locale = resolveLocale(searchParams.get('lang'));
return (
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center">
<div className="text-gray-500">...</div>
</div>
}
>
<AdminContent />
<div className="flex min-h-screen items-center justify-center">
<div className="text-slate-500">{locale === 'en' ? 'Loading...' : '加载中...'}</div>
</div>
);
}
export default function DashboardPage() {
return (
<Suspense fallback={<DashboardPageFallback />}>
<DashboardContent />
</Suspense>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { prisma } from '@/lib/db';
const updateChannelSchema = z.object({
group_id: z.number().int().positive().optional(),
name: z.string().min(1).max(100).optional(),
platform: z.string().min(1).max(50).optional(),
rate_multiplier: z.number().positive().optional(),
description: z.string().max(500).nullable().optional(),
models: z.union([z.array(z.string()), z.string()]).nullable().optional(),
features: z.union([z.record(z.string(), z.unknown()), z.string()]).nullable().optional(),
sort_order: z.number().int().min(0).optional(),
enabled: z.boolean().optional(),
});
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
try {
const { id } = await params;
const rawBody = await request.json();
const parsed = updateChannelSchema.safeParse(rawBody);
if (!parsed.success) {
return NextResponse.json({ error: '参数校验失败' }, { status: 400 });
}
const body = parsed.data;
const existing = await prisma.channel.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: '渠道不存在' }, { status: 404 });
}
// 如果更新了 group_id检查唯一性
if (body.group_id !== undefined && Number(body.group_id) !== existing.groupId) {
const conflict = await prisma.channel.findUnique({
where: { groupId: Number(body.group_id) },
});
if (conflict) {
return NextResponse.json(
{ error: `分组 ID ${body.group_id} 已被渠道「${conflict.name}」使用` },
{ status: 409 },
);
}
}
const data: Record<string, unknown> = {};
if (body.group_id !== undefined) data.groupId = body.group_id;
if (body.name !== undefined) data.name = body.name;
if (body.platform !== undefined) data.platform = body.platform;
if (body.rate_multiplier !== undefined) data.rateMultiplier = body.rate_multiplier;
if (body.description !== undefined) data.description = body.description;
if (body.models !== undefined) data.models = body.models;
if (body.features !== undefined) data.features = body.features;
if (body.sort_order !== undefined) data.sortOrder = body.sort_order;
if (body.enabled !== undefined) data.enabled = body.enabled;
const channel = await prisma.channel.update({
where: { id },
data,
});
return NextResponse.json({
...channel,
rateMultiplier: Number(channel.rateMultiplier),
});
} catch (error) {
console.error('Failed to update channel:', error);
return NextResponse.json({ error: '更新渠道失败' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
try {
const { id } = await params;
const existing = await prisma.channel.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: '渠道不存在' }, { status: 404 });
}
await prisma.channel.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to delete channel:', error);
return NextResponse.json({ error: '删除渠道失败' }, { status: 500 });
}
}

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

@@ -3,23 +3,7 @@ import { Prisma } from '@prisma/client';
import { prisma } from '@/lib/db';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { OrderStatus } from '@prisma/client';
/** 业务时区偏移(东八区,+8 小时) */
const BIZ_TZ_OFFSET_MS = 8 * 60 * 60 * 1000;
const BIZ_TZ_NAME = 'Asia/Shanghai';
/** 获取业务时区下的 YYYY-MM-DD */
function toBizDateStr(d: Date): string {
const local = new Date(d.getTime() + BIZ_TZ_OFFSET_MS);
return local.toISOString().split('T')[0];
}
/** 获取业务时区下"今天 00:00"对应的 UTC 时间 */
function getBizDayStartUTC(d: Date): Date {
const bizDateStr = toBizDateStr(d);
// bizDateStr 00:00 在业务时区 = bizDateStr 00:00 - offset 在 UTC
return new Date(`${bizDateStr}T00:00:00+08:00`);
}
import { BIZ_TZ_NAME, getBizDayStartUTC, toBizDateStr } from '@/lib/time/biz-day';
export async function GET(request: NextRequest) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse();

View File

@@ -1,22 +1,26 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { adminCancelOrder, OrderError } from '@/lib/order/service';
import { resolveLocale } from '@/lib/locale';
import { adminCancelOrder } from '@/lib/order/service';
import { handleApiError } from '@/lib/utils/api';
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
const locale = resolveLocale(request.nextUrl.searchParams.get('lang'));
try {
const { id } = await params;
const outcome = await adminCancelOrder(id);
const outcome = await adminCancelOrder(id, locale);
if (outcome === 'already_paid') {
return NextResponse.json({ success: true, status: 'PAID', message: '订单已支付完成' });
return NextResponse.json({
success: true,
status: 'PAID',
message: locale === 'en' ? 'Order has already been paid' : '订单已支付完成',
});
}
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof OrderError) {
return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
}
console.error('Admin cancel order error:', error);
return NextResponse.json({ error: '取消订单失败' }, { status: 500 });
return handleApiError(error, locale === 'en' ? 'Cancel order failed' : '取消订单失败', request);
}
}

View File

@@ -1,19 +1,19 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { retryRecharge, OrderError } from '@/lib/order/service';
import { resolveLocale } from '@/lib/locale';
import { retryRecharge } from '@/lib/order/service';
import { handleApiError } from '@/lib/utils/api';
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
const locale = resolveLocale(request.nextUrl.searchParams.get('lang'));
try {
const { id } = await params;
await retryRecharge(id);
await retryRecharge(id, locale);
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof OrderError) {
return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
}
console.error('Retry recharge error:', error);
return NextResponse.json({ error: '重试充值失败' }, { status: 500 });
return handleApiError(error, locale === 'en' ? 'Recharge retry failed' : '重试充值失败', request);
}
}

View File

@@ -1,11 +1,13 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { resolveLocale } from '@/lib/locale';
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
const { id } = await params;
const locale = resolveLocale(request.nextUrl.searchParams.get('lang'));
const order = await prisma.order.findUnique({
where: { id },
@@ -17,12 +19,14 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
});
if (!order) {
return NextResponse.json({ error: '订单不存在' }, { status: 404 });
return NextResponse.json({ error: locale === 'en' ? 'Order not found' : '订单不存在' }, { status: 404 });
}
return NextResponse.json({
...order,
amount: Number(order.amount),
payAmount: order.payAmount ? Number(order.payAmount) : null,
feeRate: order.feeRate ? Number(order.feeRate) : null,
refundAmount: order.refundAmount ? Number(order.refundAmount) : null,
});
}

View File

@@ -16,11 +16,38 @@ export async function GET(request: NextRequest) {
const where: Prisma.OrderWhereInput = {};
if (status && status in OrderStatus) where.status = status as OrderStatus;
if (userId) where.userId = Number(userId);
// userId 校验忽略无效值NaN
if (userId) {
const parsedUserId = Number(userId);
if (Number.isFinite(parsedUserId)) {
where.userId = parsedUserId;
}
}
// 日期校验:忽略无效日期
if (dateFrom || dateTo) {
where.createdAt = {};
if (dateFrom) where.createdAt.gte = new Date(dateFrom);
if (dateTo) where.createdAt.lte = new Date(dateTo);
const createdAt: Prisma.DateTimeFilter = {};
let hasValidDate = false;
if (dateFrom) {
const d = new Date(dateFrom);
if (!isNaN(d.getTime())) {
createdAt.gte = d;
hasValidDate = true;
}
}
if (dateTo) {
const d = new Date(dateTo);
if (!isNaN(d.getTime())) {
createdAt.lte = d;
hasValidDate = true;
}
}
if (hasValidDate) {
where.createdAt = createdAt;
}
}
const [orders, total] = await Promise.all([

View File

@@ -1,7 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { processRefund, OrderError } from '@/lib/order/service';
import { processRefund } from '@/lib/order/service';
import { handleApiError } from '@/lib/utils/api';
import { resolveLocale } from '@/lib/locale';
const refundSchema = z.object({
order_id: z.string().min(1),
@@ -10,28 +12,30 @@ const refundSchema = z.object({
});
export async function POST(request: NextRequest) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
const locale = resolveLocale(request.nextUrl.searchParams.get('lang'));
try {
const body = await request.json();
const parsed = refundSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: '参数错误', details: parsed.error.flatten().fieldErrors }, { status: 400 });
return NextResponse.json(
{ error: locale === 'en' ? 'Invalid parameters' : '参数错误', details: parsed.error.flatten().fieldErrors },
{ status: 400 },
);
}
const result = await processRefund({
orderId: parsed.data.order_id,
reason: parsed.data.reason,
force: parsed.data.force,
locale,
});
return NextResponse.json(result);
} catch (error) {
if (error instanceof OrderError) {
return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
}
console.error('Refund error:', error);
return NextResponse.json({ error: '退款失败' }, { status: 500 });
return handleApiError(error, locale === 'en' ? 'Refund failed' : '退款失败', request);
}
}

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,147 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { prisma } from '@/lib/db';
import { getGroup } from '@/lib/sub2api/client';
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
try {
const { id } = await params;
const body = await request.json();
const existing = await prisma.subscriptionPlan.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: '订阅套餐不存在' }, { status: 404 });
}
// 确定最终 groupId如果传了 group_id 用传入值,否则用现有值
const finalGroupId =
body.group_id !== undefined ? (body.group_id ? Number(body.group_id) : null) : existing.groupId;
// 必须绑定分组才能保存
if (finalGroupId === null || finalGroupId === undefined) {
return NextResponse.json({ error: '必须关联一个 Sub2API 分组' }, { status: 400 });
}
// 如果更新了 group_id检查唯一性
if (body.group_id !== undefined && Number(body.group_id) !== existing.groupId) {
const conflict = await prisma.subscriptionPlan.findUnique({
where: { groupId: Number(body.group_id) },
});
if (conflict) {
return NextResponse.json(
{ error: `分组 ID ${body.group_id} 已被套餐「${conflict.name}」使用` },
{ status: 409 },
);
}
}
// 校验分组在 Sub2API 中仍然存在
const group = await getGroup(finalGroupId);
if (!group) {
// 分组已被删除,自动解绑
await prisma.subscriptionPlan.update({
where: { id },
data: { groupId: null, forSale: false },
});
return NextResponse.json({ error: '该分组在 Sub2API 中已被删除,已自动解绑,请重新选择分组' }, { status: 409 });
}
if (body.price !== undefined && (typeof body.price !== 'number' || body.price <= 0 || body.price > 99999999.99)) {
return NextResponse.json({ error: 'price 必须是 0.01 ~ 99999999.99 之间的数值' }, { status: 400 });
}
if (
body.original_price !== undefined &&
body.original_price !== null &&
(typeof body.original_price !== 'number' || body.original_price <= 0 || body.original_price > 99999999.99)
) {
return NextResponse.json({ error: 'original_price 必须是 0.01 ~ 99999999.99 之间的数值' }, { status: 400 });
}
if (body.validity_days !== undefined && (!Number.isInteger(body.validity_days) || body.validity_days <= 0)) {
return NextResponse.json({ error: 'validity_days 必须是正整数' }, { status: 400 });
}
if (body.name !== undefined && (typeof body.name !== 'string' || body.name.trim() === '')) {
return NextResponse.json({ error: 'name 不能为空' }, { status: 400 });
}
if (body.name !== undefined && body.name.length > 100) {
return NextResponse.json({ error: 'name 不能超过 100 个字符' }, { status: 400 });
}
if (body.sort_order !== undefined && (!Number.isInteger(body.sort_order) || body.sort_order < 0)) {
return NextResponse.json({ error: 'sort_order 必须是非负整数' }, { status: 400 });
}
const data: Record<string, unknown> = {};
if (body.group_id !== undefined) data.groupId = Number(body.group_id);
if (body.name !== undefined) data.name = body.name.trim();
if (body.description !== undefined) data.description = body.description;
if (body.price !== undefined) data.price = body.price;
if (body.original_price !== undefined) data.originalPrice = body.original_price;
if (body.validity_days !== undefined) data.validityDays = body.validity_days;
if (body.validity_unit !== undefined && ['day', 'week', 'month'].includes(body.validity_unit)) {
data.validityUnit = body.validity_unit;
}
if (body.features !== undefined) data.features = body.features ? JSON.stringify(body.features) : null;
if (body.product_name !== undefined) data.productName = body.product_name?.trim() || null;
if (body.for_sale !== undefined) data.forSale = body.for_sale;
if (body.sort_order !== undefined) data.sortOrder = body.sort_order;
const plan = await prisma.subscriptionPlan.update({
where: { id },
data,
});
return NextResponse.json({
id: plan.id,
groupId: plan.groupId != null ? String(plan.groupId) : null,
groupName: null,
name: plan.name,
description: plan.description,
price: Number(plan.price),
originalPrice: plan.originalPrice ? Number(plan.originalPrice) : null,
validDays: plan.validityDays,
validityUnit: plan.validityUnit,
features: plan.features ? JSON.parse(plan.features) : [],
sortOrder: plan.sortOrder,
enabled: plan.forSale,
productName: plan.productName ?? null,
createdAt: plan.createdAt,
updatedAt: plan.updatedAt,
});
} catch (error) {
console.error('Failed to update subscription plan:', error);
return NextResponse.json({ error: '更新订阅套餐失败' }, { status: 500 });
}
}
export async function DELETE(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
try {
const { id } = await params;
const existing = await prisma.subscriptionPlan.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: '订阅套餐不存在' }, { status: 404 });
}
// 检查是否有活跃订单引用此套餐
const activeOrderCount = await prisma.order.count({
where: {
planId: id,
status: { in: ['PENDING', 'PAID', 'RECHARGING'] },
},
});
if (activeOrderCount > 0) {
return NextResponse.json({ error: `该套餐仍有 ${activeOrderCount} 个活跃订单,无法删除` }, { status: 409 });
}
await prisma.subscriptionPlan.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
console.error('Failed to delete subscription plan:', error);
return NextResponse.json({ error: '删除订阅套餐失败' }, { status: 500 });
}
}

View File

@@ -0,0 +1,172 @@
import { NextRequest, NextResponse } from 'next/server';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { prisma } from '@/lib/db';
import { getGroup } from '@/lib/sub2api/client';
export async function GET(request: NextRequest) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
try {
const plans = await prisma.subscriptionPlan.findMany({
orderBy: { sortOrder: 'asc' },
});
// 并发检查每个套餐对应的 Sub2API 分组是否仍然存在,并获取分组名称
const results = await Promise.all(
plans.map(async (plan) => {
let groupExists = false;
let groupName: string | null = null;
let group: Awaited<ReturnType<typeof getGroup>> | null = null;
if (plan.groupId !== null) {
try {
group = await getGroup(plan.groupId);
groupExists = group !== null;
groupName = group?.name ?? null;
} catch {
groupExists = false;
}
// 分组已失效:自动清除绑定并下架
if (!groupExists) {
prisma.subscriptionPlan
.update({
where: { id: plan.id },
data: { groupId: null, forSale: false },
})
.catch((err) => console.error(`Failed to unbind stale group for plan ${plan.id}:`, err));
}
}
return {
id: plan.id,
groupId: groupExists ? String(plan.groupId) : null,
groupName,
name: plan.name,
description: plan.description,
price: Number(plan.price),
originalPrice: plan.originalPrice ? Number(plan.originalPrice) : null,
validDays: plan.validityDays,
validityUnit: plan.validityUnit,
features: plan.features ? JSON.parse(plan.features) : [],
sortOrder: plan.sortOrder,
enabled: groupExists ? plan.forSale : false,
groupExists,
groupPlatform: group?.platform ?? null,
groupRateMultiplier: group?.rate_multiplier ?? null,
groupDailyLimit: group?.daily_limit_usd ?? null,
groupWeeklyLimit: group?.weekly_limit_usd ?? null,
groupMonthlyLimit: group?.monthly_limit_usd ?? null,
groupModelScopes: group?.supported_model_scopes ?? null,
groupAllowMessagesDispatch: group?.allow_messages_dispatch ?? false,
groupDefaultMappedModel: group?.default_mapped_model ?? null,
productName: plan.productName ?? null,
createdAt: plan.createdAt,
updatedAt: plan.updatedAt,
};
}),
);
return NextResponse.json({ plans: results });
} catch (error) {
console.error('Failed to list subscription plans:', error);
return NextResponse.json({ error: '获取订阅套餐列表失败' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse(request);
try {
const body = await request.json();
const {
group_id,
name,
description,
price,
original_price,
validity_days,
validity_unit,
features,
for_sale,
sort_order,
product_name,
} = body;
if (!group_id || price === undefined) {
return NextResponse.json({ error: '缺少必填字段: group_id, price' }, { status: 400 });
}
if (typeof name !== 'string' || name.trim() === '') {
return NextResponse.json({ error: 'name 不能为空' }, { status: 400 });
}
if (name.length > 100) {
return NextResponse.json({ error: 'name 不能超过 100 个字符' }, { status: 400 });
}
if (typeof price !== 'number' || price <= 0 || price > 99999999.99) {
return NextResponse.json({ error: 'price 必须是 0.01 ~ 99999999.99 之间的数值' }, { status: 400 });
}
if (
original_price !== undefined &&
original_price !== null &&
(typeof original_price !== 'number' || original_price <= 0 || original_price > 99999999.99)
) {
return NextResponse.json({ error: 'original_price 必须是 0.01 ~ 99999999.99 之间的数值' }, { status: 400 });
}
if (validity_days !== undefined && (!Number.isInteger(validity_days) || validity_days <= 0)) {
return NextResponse.json({ error: 'validity_days 必须是正整数' }, { status: 400 });
}
if (sort_order !== undefined && (!Number.isInteger(sort_order) || sort_order < 0)) {
return NextResponse.json({ error: 'sort_order 必须是非负整数' }, { status: 400 });
}
// 验证 group_id 唯一性
const existing = await prisma.subscriptionPlan.findUnique({
where: { groupId: Number(group_id) },
});
if (existing) {
return NextResponse.json({ error: `分组 ID ${group_id} 已被套餐「${existing.name}」使用` }, { status: 409 });
}
const plan = await prisma.subscriptionPlan.create({
data: {
groupId: Number(group_id),
name: name.trim(),
description: description ?? null,
price,
originalPrice: original_price ?? null,
validityDays: validity_days ?? 30,
validityUnit: ['day', 'week', 'month'].includes(validity_unit) ? validity_unit : 'day',
features: features ? JSON.stringify(features) : null,
productName: product_name?.trim() || null,
forSale: for_sale ?? false,
sortOrder: sort_order ?? 0,
},
});
return NextResponse.json(
{
id: plan.id,
groupId: String(plan.groupId),
groupName: null,
name: plan.name,
description: plan.description,
price: Number(plan.price),
originalPrice: plan.originalPrice ? Number(plan.originalPrice) : null,
validDays: plan.validityDays,
validityUnit: plan.validityUnit,
features: plan.features ? JSON.parse(plan.features) : [],
sortOrder: plan.sortOrder,
enabled: plan.forSale,
productName: plan.productName ?? null,
createdAt: plan.createdAt,
updatedAt: plan.updatedAt,
},
{ status: 201 },
);
} catch (error) {
console.error('Failed to create subscription plan:', error);
return NextResponse.json({ error: '创建订阅套餐失败' }, { status: 500 });
}
}

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

@@ -1,9 +1,9 @@
import { NextRequest } from 'next/server';
import { handlePaymentNotify } from '@/lib/order/service';
import { AlipayProvider } from '@/lib/alipay/provider';
import { paymentRegistry } from '@/lib/payment';
import type { PaymentType } from '@/lib/payment';
import { getEnv } from '@/lib/config';
const alipayProvider = new AlipayProvider();
import { extractHeaders } from '@/lib/utils/api';
export async function POST(request: NextRequest) {
try {
@@ -13,14 +13,15 @@ export async function POST(request: NextRequest) {
return new Response('success', { headers: { 'Content-Type': 'text/plain' } });
}
const provider = paymentRegistry.getProvider('alipay_direct' as PaymentType);
const rawBody = await request.text();
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
const headers = extractHeaders(request);
const notification = await alipayProvider.verifyNotification(rawBody, headers);
const success = await handlePaymentNotify(notification, alipayProvider.name);
const notification = await provider.verifyNotification(rawBody, headers);
if (!notification) {
return new Response('success', { headers: { 'Content-Type': 'text/plain' } });
}
const success = await handlePaymentNotify(notification, provider.name);
return new Response(success ? 'success' : 'fail', {
headers: { 'Content-Type': 'text/plain' },
});

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,19 +1,21 @@
import { NextRequest } from 'next/server';
import { handlePaymentNotify } from '@/lib/order/service';
import { EasyPayProvider } from '@/lib/easy-pay/provider';
const easyPayProvider = new EasyPayProvider();
import { paymentRegistry } from '@/lib/payment';
import type { PaymentType } from '@/lib/payment';
import { extractHeaders } from '@/lib/utils/api';
export async function GET(request: NextRequest) {
try {
// EasyPay 注册为 'alipay' 和 'wxpay' 类型,任一均可获取同一 provider 实例
const provider = paymentRegistry.getProvider('alipay' as PaymentType);
const rawBody = request.nextUrl.searchParams.toString();
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
const headers = extractHeaders(request);
const notification = await easyPayProvider.verifyNotification(rawBody, headers);
const success = await handlePaymentNotify(notification, easyPayProvider.name);
const notification = await provider.verifyNotification(rawBody, headers);
if (!notification) {
return new Response('success', { headers: { 'Content-Type': 'text/plain' } });
}
const success = await handlePaymentNotify(notification, provider.name);
return new Response(success ? 'success' : 'fail', {
headers: { 'Content-Type': 'text/plain' },
});

View File

@@ -1,10 +1,12 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { queryMethodLimits } from '@/lib/order/limits';
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
import { getNextBizDayStartUTC } from '@/lib/time/biz-day';
import { getCurrentUserByToken } from '@/lib/sub2api/client';
/**
* GET /api/limits
* 返回各支付渠道今日限额使用情况,公开接口(无需鉴权)
* GET /api/limits?token=xxx
* 返回各支付渠道今日限额使用情况。
*
* Response:
* {
@@ -13,19 +15,25 @@ import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
* wxpay: { dailyLimit: 10000, used: 10000, remaining: 0, available: false },
* stripe: { dailyLimit: 0, used: 500, remaining: null, available: true }
* },
* resetAt: "2026-03-02T00:00:00.000Z" // UTC 次日零点(限额重置时间
* resetAt: "2026-03-02T16:00:00.000Z" // 业务时区Asia/Shanghai次日零点对应的 UTC 时间
* }
*/
export async function GET() {
export async function GET(request: NextRequest) {
const token = request.nextUrl.searchParams.get('token')?.trim();
if (!token) {
return NextResponse.json({ error: 'token is required' }, { status: 400 });
}
try {
await getCurrentUserByToken(token);
} catch {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
initPaymentProviders();
const types = paymentRegistry.getSupportedTypes();
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);
const resetAt = new Date(todayStart);
resetAt.setUTCDate(resetAt.getUTCDate() + 1);
const methods = await queryMethodLimits(types);
const resetAt = getNextBizDayStartUTC();
return NextResponse.json({ methods, resetAt });
}

View File

@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { cancelOrder, OrderError } from '@/lib/order/service';
import { cancelOrder } from '@/lib/order/service';
import { getCurrentUserByToken } from '@/lib/sub2api/client';
import { handleApiError } from '@/lib/utils/api';
const cancelSchema = z.object({
token: z.string().min(1),
@@ -31,10 +32,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
}
return NextResponse.json({ success: true });
} catch (error) {
if (error instanceof OrderError) {
return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
}
console.error('Cancel order error:', error);
return NextResponse.json({ error: '取消订单失败' }, { status: 500 });
return handleApiError(error, '取消订单失败');
}
}

View File

@@ -1,9 +1,25 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { verifyAdminToken } from '@/lib/admin-auth';
import { deriveOrderState } from '@/lib/order/status';
import { ORDER_STATUS_ACCESS_QUERY_KEY, verifyOrderStatusAccessToken } from '@/lib/order/status-access';
// 仅返回订单状态相关字段,不暴露任何用户隐私信息
/**
* 订单状态轮询接口。
*
* 返回最小必要信息供前端判断:
* - 原始订单状态status / expiresAt
* - 支付是否成功paymentSuccess
* - 充值是否成功 / 当前充值阶段rechargeSuccess / rechargeStatus
*/
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const accessToken = request.nextUrl.searchParams.get(ORDER_STATUS_ACCESS_QUERY_KEY);
const isAuthorized = verifyOrderStatusAccessToken(id, accessToken) || (await verifyAdminToken(request));
if (!isAuthorized) {
return NextResponse.json({ error: '未授权访问该订单状态' }, { status: 401 });
}
const order = await prisma.order.findUnique({
where: { id },
@@ -11,6 +27,9 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
id: true,
status: true,
expiresAt: true,
paidAt: true,
completedAt: true,
failedReason: true,
},
});
@@ -18,9 +37,15 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
return NextResponse.json({ error: '订单不存在' }, { status: 404 });
}
const derived = deriveOrderState(order);
return NextResponse.json({
id: order.id,
status: order.status,
expiresAt: order.expiresAt,
paymentSuccess: derived.paymentSuccess,
rechargeSuccess: derived.rechargeSuccess,
rechargeStatus: derived.rechargeStatus,
failedReason: order.failedReason ?? null,
});
}

View File

@@ -16,8 +16,16 @@ export async function GET(request: NextRequest) {
const rawPageSize = Number(searchParams.get('page_size') || '20');
const pageSize = VALID_PAGE_SIZES.includes(rawPageSize) ? rawPageSize : 20;
// 单独处理认证,区分认证失败和其他错误
let user;
try {
user = await getCurrentUserByToken(token);
} catch (error) {
console.error('Auth error in /api/orders/my:', error);
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
try {
const user = await getCurrentUserByToken(token);
const where = { userId: user.id };
const [orders, total, statusGroups] = await Promise.all([
@@ -76,6 +84,6 @@ export async function GET(request: NextRequest) {
});
} catch (error) {
console.error('Get my orders error:', error);
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
return NextResponse.json({ error: '获取订单失败' }, { status: 500 });
}
}

View File

@@ -1,23 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { createOrder, OrderError } from '@/lib/order/service';
import { createOrder } from '@/lib/order/service';
import { getEnv } from '@/lib/config';
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
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) {
try {
const env = getEnv();
initPaymentProviders();
const body = await request.json();
const parsed = createOrderSchema.safeParse(body);
@@ -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,16 +77,14 @@ export async function POST(request: NextRequest) {
isMobile: is_mobile,
srcHost: src_host,
srcUrl: src_url,
orderType: order_type,
planId: plan_id,
});
// 不向客户端暴露 userName / userBalance 等隐私字段
const { userName: _u, userBalance: _b, ...safeResult } = result;
return NextResponse.json(safeResult);
} catch (error) {
if (error instanceof OrderError) {
return NextResponse.json({ error: error.message, code: error.code }, { status: error.statusCode });
}
console.error('Create order error:', error);
return NextResponse.json({ error: '创建订单失败,请稍后重试' }, { status: 500 });
return handleApiError(error, '创建订单失败,请稍后重试');
}
}

View File

@@ -1,29 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
import { paymentRegistry } from '@/lib/payment';
import type { PaymentType } from '@/lib/payment';
import { handlePaymentNotify } from '@/lib/order/service';
import { extractHeaders } from '@/lib/utils/api';
// Stripe needs raw body - ensure Next.js doesn't parse it
export const dynamic = 'force-dynamic';
export async function POST(request: NextRequest): Promise<NextResponse> {
try {
initPaymentProviders();
const provider = paymentRegistry.getProvider('stripe' as PaymentType);
const rawBody = Buffer.from(await request.arrayBuffer());
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key.toLowerCase()] = value;
});
const headers = extractHeaders(request);
const notification = await provider.verifyNotification(rawBody, headers);
if (!notification) {
// Unknown event type — acknowledge receipt
return NextResponse.json({ received: true });
}
await handlePaymentNotify(notification, provider.name);
const success = await handlePaymentNotify(notification, provider.name);
if (!success) {
// 处理失败(充值未完成等),返回 500 让 Stripe 重试
return NextResponse.json({ error: 'Processing failed, will retry' }, { status: 500 });
}
return NextResponse.json({ received: true });
} catch (error) {
console.error('Stripe webhook error:', error);

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

@@ -1,21 +1,59 @@
import { NextRequest, NextResponse } from 'next/server';
import { getUser } from '@/lib/sub2api/client';
import { getUser, getCurrentUserByToken } from '@/lib/sub2api/client';
import { getEnv } from '@/lib/config';
import { queryMethodLimits } from '@/lib/order/limits';
import { initPaymentProviders, paymentRegistry } from '@/lib/payment';
import { PAYMENT_TYPE_META } from '@/lib/pay-utils';
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'));
const userId = Number(request.nextUrl.searchParams.get('user_id'));
if (!userId || isNaN(userId) || userId <= 0) {
return NextResponse.json({ error: '无效的用户 ID' }, { status: 400 });
return NextResponse.json({ error: locale === 'en' ? 'Invalid user ID' : '无效的用户 ID' }, { status: 400 });
}
const token = request.nextUrl.searchParams.get('token')?.trim();
if (!token) {
return NextResponse.json(
{ error: locale === 'en' ? 'Missing token parameter' : '缺少 token 参数' },
{ status: 401 },
);
}
try {
// 验证 token 并确保请求的 user_id 与 token 对应的用户匹配
let tokenUser;
try {
tokenUser = await getCurrentUserByToken(token);
} catch {
return NextResponse.json({ error: locale === 'en' ? 'Invalid token' : '无效的 token' }, { status: 401 });
}
if (tokenUser.id !== userId) {
return NextResponse.json(
{ error: locale === 'en' ? 'Forbidden to access this user' : '无权访问该用户信息' },
{ status: 403 },
);
}
const env = getEnv();
initPaymentProviders();
const enabledTypes = paymentRegistry.getSupportedTypes();
const [user, methodLimits] = await Promise.all([getUser(userId), queryMethodLimits(enabledTypes)]);
const supportedTypes = paymentRegistry.getSupportedTypes();
// getUser 与 config 查询并行config 完成后立即启动 queryMethodLimits
const configPromise = Promise.all([
getSystemConfig('ENABLED_PAYMENT_TYPES'),
getSystemConfig('BALANCE_PAYMENT_DISABLED'),
]).then(async ([configuredPaymentTypesRaw, balanceDisabledVal]) => {
const enabledTypes = resolveEnabledPaymentTypes(supportedTypes, configuredPaymentTypesRaw);
const methodLimits = await queryMethodLimits(enabledTypes);
return { enabledTypes, methodLimits, balanceDisabled: balanceDisabledVal === 'true' };
});
const [user, { enabledTypes, methodLimits, balanceDisabled }] = await Promise.all([getUser(userId), configPromise]);
// 收集 sublabel 覆盖
const sublabelOverrides: Record<string, string> = {};
@@ -23,17 +61,16 @@ export async function GET(request: NextRequest) {
// 1. 检测同 label 冲突:多个启用渠道有相同的显示名,自动标记默认 sublabelprovider 名)
const labelCount = new Map<string, string[]>();
for (const type of enabledTypes) {
const meta = PAYMENT_TYPE_META[type];
if (!meta) continue;
const types = labelCount.get(meta.label) || [];
const { channel } = getPaymentDisplayInfo(type, locale);
const types = labelCount.get(channel) || [];
types.push(type);
labelCount.set(meta.label, types);
labelCount.set(channel, types);
}
for (const [, types] of labelCount) {
if (types.length > 1) {
for (const type of types) {
const meta = PAYMENT_TYPE_META[type];
if (meta) sublabelOverrides[type] = meta.provider;
const { provider } = getPaymentDisplayInfo(type, locale);
if (provider) sublabelOverrides[type] = provider;
}
}
}
@@ -59,18 +96,20 @@ export async function GET(request: NextRequest) {
helpImageUrl: env.PAY_HELP_IMAGE_URL ?? null,
helpText: env.PAY_HELP_TEXT ?? null,
stripePublishableKey:
enabledTypes.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY
? env.STRIPE_PUBLISHABLE_KEY
: null,
enabledTypes.includes('stripe') && env.STRIPE_PUBLISHABLE_KEY ? env.STRIPE_PUBLISHABLE_KEY : null,
balanceDisabled,
sublabelOverrides: Object.keys(sublabelOverrides).length > 0 ? sublabelOverrides : null,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message === 'USER_NOT_FOUND') {
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
return NextResponse.json({ error: locale === 'en' ? 'User not found' : '用户不存在' }, { status: 404 });
}
console.error('Get user error:', error);
return NextResponse.json({ error: '获取用户信息失败' }, { status: 500 });
return NextResponse.json(
{ error: locale === 'en' ? 'Failed to fetch user info' : '获取用户信息失败' },
{ status: 500 },
);
}
}

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

@@ -1,9 +1,9 @@
import { NextRequest } from 'next/server';
import { handlePaymentNotify } from '@/lib/order/service';
import { WxpayProvider } from '@/lib/wxpay';
import { paymentRegistry } from '@/lib/payment';
import type { PaymentType } from '@/lib/payment';
import { getEnv } from '@/lib/config';
const wxpayProvider = new WxpayProvider();
import { extractHeaders } from '@/lib/utils/api';
export async function POST(request: NextRequest) {
try {
@@ -13,26 +13,20 @@ export async function POST(request: NextRequest) {
return Response.json({ code: 'SUCCESS', message: '成功' });
}
const provider = paymentRegistry.getProvider('wxpay_direct' as PaymentType);
const rawBody = await request.text();
const headers: Record<string, string> = {};
request.headers.forEach((value, key) => {
headers[key] = value;
});
const headers = extractHeaders(request);
const notification = await wxpayProvider.verifyNotification(rawBody, headers);
const notification = await provider.verifyNotification(rawBody, headers);
if (!notification) {
return Response.json({ code: 'SUCCESS', message: '成功' });
}
const success = await handlePaymentNotify(notification, wxpayProvider.name);
return Response.json(
success ? { code: 'SUCCESS', message: '成功' } : { code: 'FAIL', message: '处理失败' },
{ status: success ? 200 : 500 },
);
const success = await handlePaymentNotify(notification, provider.name);
return Response.json(success ? { code: 'SUCCESS', message: '成功' } : { code: 'FAIL', message: '处理失败' }, {
status: success ? 200 : 500,
});
} catch (error) {
console.error('Wxpay notify error:', error);
return Response.json(
{ code: 'FAIL', message: '处理失败' },
{ status: 500 },
);
return Response.json({ code: 'FAIL', message: '处理失败' }, { status: 500 });
}
}

View File

@@ -8,4 +8,61 @@
body {
background: var(--background);
color: var(--foreground);
font-family:
system-ui,
-apple-system,
'PingFang SC',
'Hiragino Sans GB',
'Microsoft YaHei',
sans-serif;
}
/* Scrollbar - Light theme (default) */
* {
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: #f1f5f9;
}
*::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
*::-webkit-scrollbar-corner {
background: #f1f5f9;
}
/* Scrollbar - Dark theme */
[data-theme='dark'],
[data-theme='dark'] * {
scrollbar-color: #475569 #1e293b;
}
[data-theme='dark'] *::-webkit-scrollbar-track {
background: #1e293b;
}
[data-theme='dark'] *::-webkit-scrollbar-thumb {
background: #475569;
}
[data-theme='dark'] *::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
[data-theme='dark'] *::-webkit-scrollbar-corner {
background: #1e293b;
}

View File

@@ -1,18 +1,25 @@
import type { Metadata } from 'next';
import { headers } from 'next/headers';
import './globals.css';
export const metadata: Metadata = {
title: 'Sub2API 充值',
description: 'Sub2API 余额充值平台',
title: 'Sub2API Recharge',
description: 'Sub2API balance recharge platform',
};
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const headerStore = await headers();
const pathname = headerStore.get('x-pathname') || '';
const search = headerStore.get('x-search') || '';
const locale = new URLSearchParams(search).get('lang')?.trim().toLowerCase() === 'en' ? 'en' : 'zh';
const htmlLang = locale === 'en' ? 'en' : 'zh-CN';
return (
<html lang="zh-CN">
<html lang={htmlLang} data-pathname={pathname}>
<body className="antialiased">{children}</body>
</html>
);

View File

@@ -1,5 +1,11 @@
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/pay');
export default async function Home({
searchParams,
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
const lang = Array.isArray(params?.lang) ? params?.lang[0] : params?.lang;
redirect(lang === 'en' ? '/pay?lang=en' : '/pay');
}

View File

@@ -0,0 +1,302 @@
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/db';
import { ORDER_STATUS } from '@/lib/constants';
import { getEnv } from '@/lib/config';
import { buildAlipayPaymentUrl } from '@/lib/alipay/provider';
import { deriveOrderState, getOrderDisplayState, type OrderStatusLike } from '@/lib/order/status';
import { buildOrderResultUrl } from '@/lib/order/status-access';
export const dynamic = 'force-dynamic';
const MOBILE_UA_PATTERN = /AlipayClient|Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i;
const ALIPAY_APP_UA_PATTERN = /AlipayClient/i;
type ShortLinkOrderStatus = OrderStatusLike & { id: string };
function getUserAgent(request: NextRequest): string {
return request.headers.get('user-agent') || '';
}
function isMobileRequest(request: NextRequest): boolean {
return MOBILE_UA_PATTERN.test(getUserAgent(request));
}
function isAlipayAppRequest(request: NextRequest): boolean {
return ALIPAY_APP_UA_PATTERN.test(getUserAgent(request));
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function buildAppUrl(pathname = '/'): string {
return new URL(pathname, getEnv().NEXT_PUBLIC_APP_URL).toString();
}
function buildResultUrl(orderId: string): string {
return buildOrderResultUrl(getEnv().NEXT_PUBLIC_APP_URL, orderId);
}
function serializeScriptString(value: string): string {
return JSON.stringify(value).replace(/</g, '\\u003c');
}
function getStatusDisplay(order: OrderStatusLike) {
return getOrderDisplayState({
status: order.status,
...deriveOrderState(order),
});
}
function renderHtml(title: string, body: string, headExtra = ''): string {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="robots" content="noindex,nofollow" />
<title>${escapeHtml(title)}</title>
${headExtra}
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: linear-gradient(180deg, #f5faff 0%, #eef6ff 100%);
color: #0f172a;
}
.card {
width: 100%;
max-width: 420px;
background: #fff;
border-radius: 20px;
padding: 28px 24px;
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.12);
text-align: center;
}
.icon {
width: 60px;
height: 60px;
margin: 0 auto 18px;
border-radius: 18px;
background: #1677ff;
color: #fff;
font-size: 30px;
line-height: 60px;
font-weight: 700;
}
h1 {
margin: 0;
font-size: 22px;
line-height: 1.35;
}
p {
margin: 12px 0 0;
font-size: 14px;
line-height: 1.7;
color: #475569;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 46px;
margin-top: 20px;
padding: 12px 16px;
border-radius: 12px;
background: #1677ff;
color: #fff;
font-weight: 600;
text-decoration: none;
}
.button.secondary {
margin-top: 12px;
background: #eff6ff;
color: #1677ff;
}
.spinner {
width: 30px;
height: 30px;
margin: 18px auto 0;
border-radius: 9999px;
border: 3px solid rgba(22, 119, 255, 0.18);
border-top-color: #1677ff;
animation: spin 1s linear infinite;
}
.order {
margin-top: 18px;
padding: 10px 12px;
border-radius: 12px;
background: #f8fafc;
color: #334155;
font-size: 12px;
word-break: break-all;
}
.hint {
margin-top: 16px;
font-size: 13px;
color: #64748b;
}
.text-link {
display: inline-flex;
align-items: center;
justify-content: center;
margin-top: 14px;
color: #1677ff;
font-size: 14px;
font-weight: 500;
text-decoration: none;
}
.text-link:hover {
text-decoration: underline;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
${body}
</body>
</html>`;
}
function renderErrorPage(title: string, message: string, orderId?: string, status = 400): NextResponse {
const html = renderHtml(
title,
`<main class="card">
<div class="icon">!</div>
<h1>${escapeHtml(title)}</h1>
<p>${escapeHtml(message)}</p>
${orderId ? `<div class="order">订单号:${escapeHtml(orderId)}</div>` : ''}
<a class="button secondary" href="${escapeHtml(buildAppUrl('/'))}">返回支付首页</a>
</main>`,
);
return new NextResponse(html, {
status,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
},
});
}
function renderStatusPage(order: ShortLinkOrderStatus): NextResponse {
const display = getStatusDisplay(order);
const html = renderHtml(
display.label,
`<main class="card">
<div class="icon">${escapeHtml(display.icon)}</div>
<h1>${escapeHtml(display.label)}</h1>
<p>${escapeHtml(display.message)}</p>
<div class="order">订单号:${escapeHtml(order.id)}</div>
<a class="button secondary" href="${escapeHtml(buildResultUrl(order.id))}">查看订单结果</a>
</main>`,
);
return new NextResponse(html, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
},
});
}
function renderRedirectPage(orderId: string, payUrl: string): NextResponse {
const html = renderHtml(
'正在跳转支付宝',
`<main class="card">
<div class="icon">支</div>
<h1>正在拉起支付宝</h1>
<p>请稍候,系统正在自动跳转到支付宝完成支付。</p>
<div class="spinner"></div>
<div class="order">订单号:${escapeHtml(orderId)}</div>
<p class="hint">如未自动拉起支付宝,请返回原充值页后重新发起支付。</p>
<a class="text-link" href="${escapeHtml(buildResultUrl(orderId))}">已支付?查看订单结果</a>
<script>
const payUrl = ${serializeScriptString(payUrl)};
window.location.replace(payUrl);
setTimeout(() => {
if (document.visibilityState === 'visible') {
window.location.replace(payUrl);
}
}, 800);
</script>
</main>`,
`<noscript><meta http-equiv="refresh" content="0;url=${escapeHtml(payUrl)}" /></noscript>`,
);
return new NextResponse(html, {
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store, no-cache, must-revalidate',
},
});
}
export async function GET(request: NextRequest, { params }: { params: Promise<{ orderId: string }> }) {
const { orderId } = await params;
const order = await prisma.order.findUnique({
where: { id: orderId },
select: {
id: true,
amount: true,
payAmount: true,
paymentType: true,
status: true,
expiresAt: true,
paidAt: true,
completedAt: true,
},
});
if (!order) {
return renderErrorPage('订单不存在', '未找到对应订单,请确认二维码是否正确', orderId, 404);
}
if (order.paymentType !== 'alipay_direct') {
return renderErrorPage('支付方式不匹配', '该订单不是支付宝直连订单,无法通过当前链接支付', orderId, 400);
}
if (order.status !== ORDER_STATUS.PENDING) {
return renderStatusPage(order);
}
if (order.expiresAt.getTime() <= Date.now()) {
return renderStatusPage({
id: order.id,
status: ORDER_STATUS.EXPIRED,
paidAt: order.paidAt,
completedAt: order.completedAt,
});
}
const payAmount = Number(order.payAmount ?? order.amount);
if (!Number.isFinite(payAmount) || payAmount <= 0) {
return renderErrorPage('订单金额异常', '订单金额无效,请返回原页面重新发起支付', order.id, 500);
}
const env = getEnv();
const payUrl = buildAlipayPaymentUrl({
orderId: order.id,
amount: payAmount,
subject: `${env.PRODUCT_NAME} ${payAmount.toFixed(2)} CNY`,
notifyUrl: env.ALIPAY_NOTIFY_URL,
returnUrl: isAlipayAppRequest(request) ? null : buildResultUrl(order.id),
isMobile: isMobileRequest(request),
});
return renderRedirectPage(order.id, payUrl);
}

View File

@@ -7,6 +7,7 @@ import OrderFilterBar from '@/components/OrderFilterBar';
import OrderSummaryCards from '@/components/OrderSummaryCards';
import OrderTable from '@/components/OrderTable';
import PaginationBar from '@/components/PaginationBar';
import { applyLocaleToSearchParams, pickLocaleText, resolveLocale } from '@/lib/locale';
import { detectDeviceIsMobile, type UserInfo, type MyOrder, type OrderStatusFilter } from '@/lib/pay-utils';
const PAGE_SIZE_OPTIONS = [20, 50, 100];
@@ -24,8 +25,36 @@ function OrdersContent() {
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
const uiMode = searchParams.get('ui_mode') || 'standalone';
const srcHost = searchParams.get('src_host') || '';
const locale = resolveLocale(searchParams.get('lang'));
const isDark = theme === 'dark';
const text = {
missingAuth: pickLocaleText(locale, '缺少认证信息', 'Missing authentication information'),
visitOrders: pickLocaleText(
locale,
'请从 Sub2API 平台正确访问订单页面',
'Please open the orders page from Sub2API',
),
sessionExpired: pickLocaleText(
locale,
'登录态已失效,请从 Sub2API 重新进入支付页。',
'Session expired. Please re-enter from Sub2API.',
),
loadFailed: pickLocaleText(locale, '订单加载失败,请稍后重试。', 'Failed to load orders. Please try again later.'),
networkError: pickLocaleText(locale, '网络错误,请稍后重试。', 'Network error. Please try again later.'),
switchingMobileTab: pickLocaleText(locale, '正在切换到移动端订单 Tab...', 'Switching to mobile orders tab...'),
myOrders: pickLocaleText(locale, '我的订单', 'My Orders'),
refresh: pickLocaleText(locale, '刷新', 'Refresh'),
backToPay: pickLocaleText(locale, '返回充值', 'Back to Top Up'),
loading: pickLocaleText(locale, '加载中...', 'Loading...'),
userPrefix: pickLocaleText(locale, '用户', 'User'),
authError: pickLocaleText(
locale,
'缺少认证信息,请从 Sub2API 平台正确访问订单页面',
'Missing authentication information. Please open the orders page from Sub2API.',
),
};
const [isIframeContext, setIsIframeContext] = useState(true);
const [isMobile, setIsMobile] = useState(false);
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
@@ -56,9 +85,9 @@ function OrdersContent() {
params.set('theme', theme);
params.set('ui_mode', uiMode);
params.set('tab', 'orders');
applyLocaleToSearchParams(params, locale);
window.location.replace(`/pay?${params.toString()}`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMobile, isEmbedded]);
}, [isMobile, isEmbedded, token, theme, uiMode, locale]);
const loadOrders = async (targetPage = page, targetPageSize = pageSize) => {
setLoading(true);
@@ -66,7 +95,7 @@ function OrdersContent() {
try {
if (!hasToken) {
setOrders([]);
setError('缺少认证信息,请从 Sub2API 平台正确访问订单页面。');
setError(text.authError);
return;
}
@@ -77,7 +106,7 @@ function OrdersContent() {
});
const res = await fetch(`/api/orders/my?${params}`);
if (!res.ok) {
setError(res.status === 401 ? '登录态已失效,请从 Sub2API 重新进入支付页。' : '订单加载失败,请稍后重试。');
setError(res.status === 401 ? text.sessionExpired : text.loadFailed);
setOrders([]);
return;
}
@@ -92,7 +121,7 @@ function OrdersContent() {
username:
(typeof meUser.displayName === 'string' && meUser.displayName.trim()) ||
(typeof meUser.username === 'string' && meUser.username.trim()) ||
`用户 #${meId}`,
`${text.userPrefix} #${meId}`,
balance: typeof meUser.balance === 'number' ? meUser.balance : 0,
});
@@ -102,7 +131,7 @@ function OrdersContent() {
setTotalPages(data.total_pages ?? 1);
} catch {
setOrders([]);
setError('网络错误,请稍后重试。');
setError(text.networkError);
} finally {
setLoading(false);
}
@@ -111,7 +140,6 @@ function OrdersContent() {
useEffect(() => {
if (isMobile && !isEmbedded) return;
loadOrders(1, pageSize);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, isMobile, isEmbedded]);
const handlePageChange = (newPage: number) => {
@@ -139,7 +167,7 @@ function OrdersContent() {
<div
className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-50 text-slate-900'}`}
>
Tab...
{text.switchingMobileTab}
</div>
);
}
@@ -148,8 +176,8 @@ function OrdersContent() {
return (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
<div className="text-center text-red-500">
<p className="text-lg font-medium"></p>
<p className="mt-2 text-sm text-gray-500"> Sub2API 访</p>
<p className="text-lg font-medium">{text.missingAuth}</p>
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.visitOrders}</p>
</div>
</div>
);
@@ -160,6 +188,7 @@ function OrdersContent() {
if (token) params.set('token', token);
params.set('theme', theme);
params.set('ui_mode', uiMode);
applyLocaleToSearchParams(params, locale);
return `${path}?${params.toString()}`;
};
@@ -167,28 +196,28 @@ function OrdersContent() {
<PayPageLayout
isDark={isDark}
isEmbedded={isEmbedded}
title="我的订单"
subtitle={userInfo?.username || '我的订单'}
title={text.myOrders}
subtitle={userInfo?.username || text.myOrders}
actions={
<>
<button type="button" onClick={() => loadOrders(page, pageSize)} className={btnClass}>
{text.refresh}
</button>
{!srcHost && (
<a href={buildScopedUrl('/pay')} className={btnClass}>
{text.backToPay}
</a>
)}
</>
}
>
<OrderSummaryCards isDark={isDark} summary={summary} />
<OrderSummaryCards isDark={isDark} locale={locale} summary={summary} />
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<OrderFilterBar isDark={isDark} activeFilter={activeFilter} onChange={setActiveFilter} />
<OrderFilterBar isDark={isDark} locale={locale} activeFilter={activeFilter} onChange={setActiveFilter} />
</div>
<OrderTable isDark={isDark} loading={loading} error={error} orders={filteredOrders} />
<OrderTable isDark={isDark} locale={locale} loading={loading} error={error} orders={filteredOrders} />
<PaginationBar
page={page}
@@ -196,6 +225,7 @@ function OrdersContent() {
total={summary.total}
pageSize={pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
locale={locale}
isDark={isDark}
loading={loading}
onPageChange={handlePageChange}
@@ -205,15 +235,23 @@ 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 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
<div className={isDark ? 'text-slate-400' : 'text-gray-500'}>
{pickLocaleText(locale, '加载中...', 'Loading...')}
</div>
</div>
);
}
export default function OrdersPage() {
return (
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center">
<div className="text-gray-500">...</div>
</div>
}
>
<Suspense fallback={<OrdersPageFallback />}>
<OrdersContent />
</Suspense>
);

File diff suppressed because it is too large Load Diff

View File

@@ -2,19 +2,213 @@
import { useSearchParams } from 'next/navigation';
import { useEffect, useState, Suspense } from 'react';
import { applyLocaleToSearchParams, pickLocaleText, resolveLocale, type Locale } from '@/lib/locale';
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
import { buildOrderStatusUrl } from '@/lib/order/status-url';
type WindowWithAlipayBridge = Window & {
AlipayJSBridge?: {
call: (name: string, params?: unknown, callback?: (...args: unknown[]) => void) => void;
};
};
function tryCloseViaAlipayBridge(): boolean {
const bridge = (window as WindowWithAlipayBridge).AlipayJSBridge;
if (!bridge?.call) {
return false;
}
try {
bridge.call('closeWebview');
return true;
} catch {
return false;
}
}
function closeCurrentWindow() {
if (tryCloseViaAlipayBridge()) {
return;
}
let settled = false;
const handleBridgeReady = () => {
if (settled) {
return;
}
settled = true;
document.removeEventListener('AlipayJSBridgeReady', handleBridgeReady);
if (!tryCloseViaAlipayBridge()) {
window.close();
}
};
document.addEventListener('AlipayJSBridgeReady', handleBridgeReady, { once: true });
window.setTimeout(() => {
if (settled) {
return;
}
settled = true;
document.removeEventListener('AlipayJSBridgeReady', handleBridgeReady);
window.close();
}, 250);
}
function getStatusConfig(
order: PublicOrderStatusSnapshot | null,
locale: Locale,
hasAccessToken: boolean,
isDark = false,
) {
if (!order) {
return locale === 'en'
? {
label: 'Payment Error',
color: isDark ? 'text-red-400' : 'text-red-600',
icon: '✗',
message: hasAccessToken
? 'Unable to load the order status. Please try again later.'
: 'Missing order access token. Please go back to the recharge page.',
}
: {
label: '支付异常',
color: isDark ? 'text-red-400' : 'text-red-600',
icon: '✗',
message: hasAccessToken ? '未查询到订单状态,请稍后重试。' : '订单访问凭证缺失,请返回原充值页查看订单结果。',
};
}
if (order.rechargeSuccess) {
return locale === 'en'
? {
label: 'Recharge Successful',
color: isDark ? 'text-green-400' : 'text-green-600',
icon: '✓',
message: 'Your balance has been credited successfully.',
}
: {
label: '充值成功',
color: isDark ? 'text-green-400' : 'text-green-600',
icon: '✓',
message: '余额已成功到账!',
};
}
if (order.paymentSuccess) {
if (order.rechargeStatus === 'paid_pending' || order.rechargeStatus === 'recharging') {
return locale === 'en'
? {
label: 'Top-up Processing',
color: isDark ? 'text-blue-400' : 'text-blue-600',
icon: '⟳',
message: 'Payment succeeded, and the balance top-up is being processed.',
}
: {
label: '充值处理中',
color: isDark ? 'text-blue-400' : 'text-blue-600',
icon: '⟳',
message: '支付成功,余额正在充值中...',
};
}
if (order.rechargeStatus === 'failed') {
return locale === 'en'
? {
label: 'Payment Successful',
color: isDark ? 'text-amber-400' : 'text-amber-600',
icon: '!',
message:
'Payment succeeded, but the balance top-up has not completed yet. Please check again later or contact the administrator.',
}
: {
label: '支付成功',
color: isDark ? 'text-amber-400' : 'text-amber-600',
icon: '!',
message: '支付成功,但余额充值暂未完成,请稍后查看订单结果或联系管理员。',
};
}
}
if (order.status === 'PENDING') {
return locale === 'en'
? {
label: 'Awaiting Payment',
color: isDark ? 'text-yellow-400' : 'text-yellow-600',
icon: '⏳',
message: 'The order has not been paid yet.',
}
: {
label: '等待支付',
color: isDark ? 'text-yellow-400' : 'text-yellow-600',
icon: '⏳',
message: '订单尚未完成支付。',
};
}
if (order.status === 'EXPIRED') {
return locale === 'en'
? {
label: 'Order Expired',
color: isDark ? 'text-slate-400' : 'text-gray-500',
icon: '⏰',
message: 'This order has expired. Please create a new order.',
}
: {
label: '订单已超时',
color: isDark ? 'text-slate-400' : 'text-gray-500',
icon: '⏰',
message: '订单已超时,请重新充值。',
};
}
if (order.status === 'CANCELLED') {
return locale === 'en'
? {
label: 'Order Cancelled',
color: isDark ? 'text-slate-400' : 'text-gray-500',
icon: '✗',
message: 'This order has been cancelled.',
}
: {
label: '订单已取消',
color: isDark ? 'text-slate-400' : 'text-gray-500',
icon: '✗',
message: '订单已被取消。',
};
}
return locale === 'en'
? {
label: 'Payment Error',
color: isDark ? 'text-red-400' : 'text-red-600',
icon: '✗',
message: 'Please contact the administrator.',
}
: { label: '支付异常', color: isDark ? 'text-red-400' : 'text-red-600', icon: '✗', message: '请联系管理员处理。' };
}
function ResultContent() {
const searchParams = useSearchParams();
// Support both ZPAY (out_trade_no) and Stripe (order_id) callback params
const outTradeNo = searchParams.get('out_trade_no') || searchParams.get('order_id');
const tradeStatus = searchParams.get('trade_status') || searchParams.get('status');
const accessToken = searchParams.get('access_token');
const isPopup = searchParams.get('popup') === '1';
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
const locale = resolveLocale(searchParams.get('lang'));
const isDark = theme === 'dark';
const [status, setStatus] = useState<string | null>(null);
const text = {
checking: pickLocaleText(locale, '查询支付结果中...', 'Checking payment result...'),
back: pickLocaleText(locale, '返回', 'Back'),
closeSoon: pickLocaleText(locale, '此窗口将在 3 秒后自动关闭', 'This window will close automatically in 3 seconds'),
closeNow: pickLocaleText(locale, '立即关闭窗口', 'Close now'),
orderId: pickLocaleText(locale, '订单号', 'Order ID'),
unknown: pickLocaleText(locale, '未知', 'Unknown'),
};
const [orderState, setOrderState] = useState<PublicOrderStatusSnapshot | null>(null);
const [loading, setLoading] = useState(true);
const [isInPopup, setIsInPopup] = useState(false);
// Detect if opened as a popup window (from stripe-popup or via popup=1 param)
useEffect(() => {
if (isPopup || window.opener) {
setIsInPopup(true);
@@ -22,122 +216,122 @@ function ResultContent() {
}, [isPopup]);
useEffect(() => {
if (!outTradeNo) {
if (!outTradeNo || !accessToken || accessToken.length < 10) {
setLoading(false);
return;
}
const checkOrder = async () => {
try {
const res = await fetch(`/api/orders/${outTradeNo}`);
const res = await fetch(buildOrderStatusUrl(outTradeNo, accessToken));
if (res.ok) {
const data = await res.json();
setStatus(data.status);
const data = (await res.json()) as PublicOrderStatusSnapshot;
setOrderState(data);
}
} catch {
// ignore
} finally {
setLoading(false);
}
};
checkOrder();
// Poll a few times in case status hasn't updated yet
const timer = setInterval(checkOrder, 3000);
const timeout = setTimeout(() => clearInterval(timer), 30000);
return () => {
clearInterval(timer);
clearTimeout(timeout);
};
}, [outTradeNo]);
}, [outTradeNo, accessToken]);
// Auto-close popup window on success
const isSuccess = status === 'COMPLETED' || status === 'PAID' || status === 'RECHARGING';
const shouldAutoClose = Boolean(orderState?.paymentSuccess);
const goBack = () => {
if (isInPopup) {
closeCurrentWindow();
return;
}
if (window.history.length > 1) {
window.history.back();
return;
}
const params = new URLSearchParams();
params.set('theme', theme);
applyLocaleToSearchParams(params, locale);
window.location.replace(`/pay?${params.toString()}`);
};
useEffect(() => {
if (!isInPopup || !isSuccess) return;
if (!isInPopup || !shouldAutoClose) return;
const timer = setTimeout(() => {
window.close();
closeCurrentWindow();
}, 3000);
return () => clearTimeout(timer);
}, [isInPopup, isSuccess]);
}, [isInPopup, shouldAutoClose]);
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50">
<div className="text-gray-500">...</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'}>{text.checking}</div>
</div>
);
}
const isPending = status === 'PENDING';
const display = getStatusConfig(orderState, locale, Boolean(accessToken), isDark);
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50 p-4">
<div className="w-full max-w-md rounded-xl bg-white p-8 text-center shadow-lg">
{isSuccess ? (
<>
<div className="text-6xl text-green-500"></div>
<h1 className="mt-4 text-xl font-bold text-green-600">
{status === 'COMPLETED' ? '充值成功' : '充值处理中'}
</h1>
<p className="mt-2 text-gray-500">
{status === 'COMPLETED' ? '余额已成功到账!' : '支付成功,余额正在充值中...'}
</p>
{isInPopup && (
<div className="mt-4 space-y-2">
<p className="text-sm text-gray-400"> 3 </p>
<button
type="button"
onClick={() => window.close()}
className="text-sm text-blue-600 underline hover:text-blue-700"
>
</button>
</div>
)}
</>
) : isPending ? (
<>
<div className="text-6xl text-yellow-500"></div>
<h1 className="mt-4 text-xl font-bold text-yellow-600"></h1>
<p className="mt-2 text-gray-500"></p>
{isInPopup && (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
<div
className={[
'w-full max-w-md rounded-xl p-8 text-center shadow-lg',
isDark ? 'bg-slate-900 text-slate-100' : 'bg-white',
].join(' ')}
>
<div className={`text-6xl ${display.color}`}>{display.icon}</div>
<h1 className={`mt-4 text-xl font-bold ${display.color}`}>{display.label}</h1>
<p className={isDark ? 'mt-2 text-slate-400' : 'mt-2 text-gray-500'}>{display.message}</p>
{isInPopup ? (
shouldAutoClose && (
<div className="mt-4 space-y-2">
<p className={isDark ? 'text-sm text-slate-500' : 'text-sm text-gray-400'}>{text.closeSoon}</p>
<button
type="button"
onClick={() => window.close()}
className="mt-4 text-sm text-blue-600 underline hover:text-blue-700"
onClick={closeCurrentWindow}
className={`text-sm underline ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
>
{text.closeNow}
</button>
)}
</>
</div>
)
) : (
<>
<div className="text-6xl text-red-500"></div>
<h1 className="mt-4 text-xl font-bold text-red-600">
{status === 'EXPIRED' ? '订单已超时' : status === 'CANCELLED' ? '订单已取消' : '支付异常'}
</h1>
<p className="mt-2 text-gray-500">
{status === 'EXPIRED'
? '订单已超时,请重新充值'
: status === 'CANCELLED'
? '订单已被取消'
: '请联系管理员处理'}
</p>
{isInPopup && (
<button
type="button"
onClick={() => window.close()}
className="mt-4 text-sm text-blue-600 underline hover:text-blue-700"
>
</button>
)}
</>
<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>
)}
<p className="mt-4 text-xs text-gray-400">: {outTradeNo || '未知'}</p>
<p className={isDark ? 'mt-4 text-xs text-slate-500' : 'mt-4 text-xs text-gray-400'}>
{text.orderId}: {outTradeNo || text.unknown}
</p>
</div>
</div>
);
}
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 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
<div className={isDark ? 'text-slate-400' : 'text-gray-500'}>
{pickLocaleText(locale, '加载中...', 'Loading...')}
</div>
</div>
);
@@ -145,13 +339,7 @@ function ResultContent() {
export default function PayResultPage() {
return (
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center bg-slate-50">
<div className="text-gray-500">...</div>
</div>
}
>
<Suspense fallback={<ResultPageFallback />}>
<ResultContent />
</Suspense>
);

View File

@@ -2,6 +2,7 @@
import { useSearchParams } from 'next/navigation';
import { useEffect, useState, useCallback, Suspense } from 'react';
import { applyLocaleToSearchParams, pickLocaleText, resolveLocale } from '@/lib/locale';
import { getPaymentMeta } from '@/lib/pay-utils';
function StripePopupContent() {
@@ -10,10 +11,33 @@ function StripePopupContent() {
const amount = parseFloat(searchParams.get('amount') || '0') || 0;
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
const method = searchParams.get('method') || '';
const accessToken = searchParams.get('access_token');
const locale = resolveLocale(searchParams.get('lang'));
const isDark = theme === 'dark';
const isAlipay = method === 'alipay';
// Sensitive data received via postMessage from parent, NOT from URL
const text = {
init: pickLocaleText(locale, '正在初始化...', 'Initializing...'),
orderId: pickLocaleText(locale, '订单号', 'Order ID'),
loadFailed: pickLocaleText(
locale,
'支付组件加载失败,请关闭窗口重试',
'Failed to load payment component. Please close the window and try again.',
),
payFailed: pickLocaleText(locale, '支付失败,请重试', 'Payment failed. Please try again.'),
closeWindow: pickLocaleText(locale, '关闭窗口', 'Close window'),
redirecting: pickLocaleText(locale, '正在跳转到支付页面...', 'Redirecting to payment page...'),
loadingForm: pickLocaleText(locale, '正在加载支付表单...', 'Loading payment form...'),
successClosing: pickLocaleText(
locale,
'支付成功,窗口即将自动关闭...',
'Payment successful. This window will close automatically...',
),
closeWindowManually: pickLocaleText(locale, '手动关闭窗口', 'Close window manually'),
processing: pickLocaleText(locale, '处理中...', 'Processing...'),
payAmount: pickLocaleText(locale, `支付 ¥${amount.toFixed(2)}`, `Pay ¥${amount.toFixed(2)}`),
};
const [credentials, setCredentials] = useState<{
clientSecret: string;
publishableKey: string;
@@ -34,10 +58,14 @@ function StripePopupContent() {
returnUrl.searchParams.set('order_id', orderId);
returnUrl.searchParams.set('status', 'success');
returnUrl.searchParams.set('popup', '1');
returnUrl.searchParams.set('theme', theme);
if (accessToken) {
returnUrl.searchParams.set('access_token', accessToken);
}
applyLocaleToSearchParams(returnUrl.searchParams, locale);
return returnUrl.toString();
}, [orderId]);
}, [orderId, theme, locale, accessToken]);
// Listen for credentials from parent window via postMessage
useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
@@ -48,14 +76,12 @@ function StripePopupContent() {
}
};
window.addEventListener('message', handler);
// Signal parent that popup is ready to receive data
if (window.opener) {
window.opener.postMessage({ type: 'STRIPE_POPUP_READY' }, window.location.origin);
}
return () => window.removeEventListener('message', handler);
}, []);
// Initialize Stripe once credentials are received
useEffect(() => {
if (!credentials) return;
let cancelled = false;
@@ -65,14 +91,13 @@ function StripePopupContent() {
loadStripe(publishableKey).then((stripe) => {
if (cancelled || !stripe) {
if (!cancelled) {
setStripeError('支付组件加载失败,请关闭窗口重试');
setStripeError(text.loadFailed);
setStripeLoaded(true);
}
return;
}
if (isAlipay) {
// Alipay: confirm directly and redirect, no Payment Element needed
stripe
.confirmAlipayPayment(clientSecret, {
return_url: buildReturnUrl(),
@@ -80,15 +105,13 @@ function StripePopupContent() {
.then((result) => {
if (cancelled) return;
if (result.error) {
setStripeError(result.error.message || '支付失败,请重试');
setStripeError(result.error.message || text.payFailed);
setStripeLoaded(true);
}
// If no error, the page has already been redirected
});
return;
}
// Fallback: create Elements for Payment Element flow
const elements = stripe.elements({
clientSecret,
appearance: {
@@ -103,9 +126,8 @@ function StripePopupContent() {
return () => {
cancelled = true;
};
}, [credentials, isDark, isAlipay, buildReturnUrl]);
}, [credentials, isDark, isAlipay, buildReturnUrl, text.loadFailed, text.payFailed]);
// Mount Payment Element (only for non-alipay methods)
const stripeContainerRef = useCallback(
(node: HTMLDivElement | null) => {
if (!node || !stripeLib) return;
@@ -135,7 +157,7 @@ function StripePopupContent() {
});
if (error) {
setStripeError(error.message || '支付失败,请重试');
setStripeError(error.message || text.payFailed);
setStripeSubmitting(false);
} else {
setStripeSuccess(true);
@@ -143,7 +165,6 @@ function StripePopupContent() {
}
};
// Auto-close after success
useEffect(() => {
if (!stripeSuccess) return;
const timer = setTimeout(() => {
@@ -152,7 +173,6 @@ function StripePopupContent() {
return () => clearTimeout(timer);
}, [stripeSuccess]);
// Waiting for credentials from parent
if (!credentials) {
return (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
@@ -161,14 +181,13 @@ function StripePopupContent() {
>
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>...</span>
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.init}</span>
</div>
</div>
</div>
);
}
// Alipay direct confirm: show loading/redirecting state
if (isAlipay) {
return (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
@@ -176,29 +195,33 @@ 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">
{'\u00A5'}
<div className={`text-3xl font-bold ${isDark ? 'text-blue-400' : 'text-blue-600'}`}>
{'¥'}
{amount.toFixed(2)}
</div>
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>: {orderId}</p>
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{text.orderId}: {orderId}
</p>
</div>
{stripeError ? (
<div className="space-y-3">
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">{stripeError}</div>
<div
className={`rounded-lg border p-3 text-sm ${isDark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}
>
{stripeError}
</div>
<button
type="button"
onClick={() => window.close()}
className="w-full text-sm text-blue-600 underline hover:text-blue-700"
className={`w-full text-sm underline ${isDark ? 'text-blue-400 hover:text-blue-300' : 'text-blue-600 hover:text-blue-700'}`}
>
{text.closeWindow}
</button>
</div>
) : (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
...
</span>
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.redirecting}</span>
</div>
)}
</div>
@@ -212,36 +235,40 @@ 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">
{'\u00A5'}
<div className={`text-3xl font-bold ${isDark ? 'text-blue-400' : 'text-blue-600'}`}>
{'¥'}
{amount.toFixed(2)}
</div>
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>: {orderId}</p>
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{text.orderId}: {orderId}
</p>
</div>
{!stripeLoaded ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>...</span>
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.loadingForm}</span>
</div>
) : stripeSuccess ? (
<div className="py-6 text-center">
<div className="text-5xl text-green-600">{'\u2713'}</div>
<p className={`mt-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
...
</p>
<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>
</div>
) : (
<>
{stripeError && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">{stripeError}</div>
<div
className={`rounded-lg border p-3 text-sm ${isDark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}
>
{stripeError}
</div>
)}
<div
ref={stripeContainerRef}
@@ -254,17 +281,19 @@ function StripePopupContent() {
className={[
'w-full rounded-lg py-3 font-medium text-white shadow-md transition-colors',
stripeSubmitting
? 'bg-gray-400 cursor-not-allowed'
? isDark
? 'bg-slate-700 text-slate-400 cursor-not-allowed'
: 'bg-gray-400 cursor-not-allowed'
: getPaymentMeta('stripe').buttonClass,
].join(' ')}
>
{stripeSubmitting ? (
<span className="inline-flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
...
{text.processing}
</span>
) : (
`支付 ¥${amount.toFixed(2)}`
text.payAmount
)}
</button>
</>
@@ -274,15 +303,23 @@ 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 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
<div className={isDark ? 'text-slate-400' : 'text-gray-500'}>
{pickLocaleText(locale, '加载中...', 'Loading...')}
</div>
</div>
);
}
export default function StripePopupPage() {
return (
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center">
<div className="text-gray-500">...</div>
</div>
}
>
<Suspense fallback={<StripePopupFallback />}>
<StripePopupContent />
</Suspense>
);

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

@@ -2,6 +2,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import OrderFilterBar from '@/components/OrderFilterBar';
import type { Locale } from '@/lib/locale';
import {
formatStatus,
formatCreatedAt,
@@ -19,6 +20,7 @@ interface MobileOrderListProps {
loadingMore: boolean;
onRefresh: () => void;
onLoadMore: () => void;
locale?: Locale;
}
export default function MobileOrderList({
@@ -29,6 +31,7 @@ export default function MobileOrderList({
loadingMore,
onRefresh,
onLoadMore,
locale = 'zh',
}: MobileOrderListProps) {
const [activeFilter, setActiveFilter] = useState<OrderStatusFilter>('ALL');
const sentinelRef = useRef<HTMLDivElement>(null);
@@ -59,7 +62,7 @@ export default function MobileOrderList({
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className={['text-base font-semibold', isDark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
{locale === 'en' ? 'My Orders' : '我的订单'}
</h3>
<button
type="button"
@@ -71,11 +74,11 @@ export default function MobileOrderList({
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
].join(' ')}
>
{locale === 'en' ? 'Refresh' : '刷新'}
</button>
</div>
<OrderFilterBar isDark={isDark} activeFilter={activeFilter} onChange={setActiveFilter} />
<OrderFilterBar isDark={isDark} locale={locale} activeFilter={activeFilter} onChange={setActiveFilter} />
{!hasToken ? (
<div
@@ -84,7 +87,9 @@ export default function MobileOrderList({
isDark ? 'border-amber-500/40 text-amber-200' : 'border-amber-300 text-amber-700',
].join(' ')}
>
token&ldquo;&rdquo;
{locale === 'en'
? 'The current link does not include a login token, so "My Orders" is unavailable.'
: '当前链接未携带登录 token无法查询"我的订单"。'}
</div>
) : filteredOrders.length === 0 ? (
<div
@@ -93,7 +98,7 @@ export default function MobileOrderList({
isDark ? 'border-slate-600 text-slate-400' : 'border-slate-300 text-slate-500',
].join(' ')}
>
{locale === 'en' ? 'No matching orders found' : '暂无符合条件的订单记录'}
</div>
) : (
<div className="space-y-2">
@@ -110,37 +115,35 @@ export default function MobileOrderList({
<span
className={['rounded-full px-2 py-0.5 text-xs', getStatusBadgeClass(order.status, isDark)].join(' ')}
>
{formatStatus(order.status)}
{formatStatus(order.status, locale)}
</span>
</div>
<div className={['mt-1 text-sm', isDark ? 'text-slate-300' : 'text-slate-600'].join(' ')}>
{(() => {
const { channel, provider } = getPaymentDisplayInfo(order.paymentType);
return provider ? `${channel} · ${provider}` : channel;
})()}
{getPaymentDisplayInfo(order.paymentType, locale).channel}
</div>
<div className={['mt-0.5 text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{formatCreatedAt(order.createdAt)}
{formatCreatedAt(order.createdAt, locale)}
</div>
</div>
))}
{/* 无限滚动哨兵 */}
{hasMore && (
<div ref={sentinelRef} className="py-3 text-center">
{loadingMore ? (
<span className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>...</span>
<span className={['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{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>
)}
</div>
)}
{!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>
)}
</div>

View File

@@ -1,15 +1,17 @@
import { FILTER_OPTIONS, type OrderStatusFilter } from '@/lib/pay-utils';
import type { Locale } from '@/lib/locale';
import { getFilterOptions, type OrderStatusFilter } from '@/lib/pay-utils';
interface OrderFilterBarProps {
isDark: boolean;
locale: Locale;
activeFilter: OrderStatusFilter;
onChange: (filter: OrderStatusFilter) => void;
}
export default function OrderFilterBar({ isDark, activeFilter, onChange }: OrderFilterBarProps) {
export default function OrderFilterBar({ isDark, locale, activeFilter, onChange }: OrderFilterBarProps) {
return (
<div className="flex flex-wrap gap-2">
{FILTER_OPTIONS.map((item) => (
{getFilterOptions(locale).map((item) => (
<button
key={item.key}
type="button"

View File

@@ -1,68 +1,213 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import type { Locale } from '@/lib/locale';
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
import { buildOrderStatusUrl } from '@/lib/order/status-url';
interface OrderStatusProps {
status: string;
orderId: string;
order: PublicOrderStatusSnapshot;
statusAccessToken?: string;
onBack: () => void;
onStateChange?: (order: PublicOrderStatusSnapshot) => void;
dark?: boolean;
locale?: Locale;
}
const STATUS_CONFIG: Record<string, { label: string; color: string; icon: string; message: string }> = {
COMPLETED: {
label: '充值成功',
color: 'text-green-600',
icon: '✓',
message: '余额已到账,感谢您的充值!',
},
PAID: {
label: '充值中',
color: 'text-blue-600',
icon: '',
message: '支付成功,正在充值余额中...',
},
RECHARGING: {
label: '充值中',
color: 'text-blue-600',
icon: '⟳',
message: '正在充值余额中,请稍候...',
},
FAILED: {
label: '充值失败',
color: 'text-red-600',
icon: '✗',
message: '充值失败,请联系管理员处理。',
},
EXPIRED: {
label: '订单超时',
color: 'text-gray-500',
icon: '⏰',
message: '订单已超时,请重新创建订单。',
},
CANCELLED: {
label: '已取消',
color: 'text-gray-500',
icon: '✗',
message: '订单已取消。',
},
};
function getStatusConfig(order: PublicOrderStatusSnapshot, locale: Locale, isDark = false) {
if (order.rechargeSuccess) {
return locale === 'en'
? {
label: 'Recharge Successful',
color: isDark ? 'text-green-400' : 'text-green-600',
icon: '✓',
message: 'Your balance has been credited. Thank you for your payment.',
}
: {
label: '充值成功',
color: isDark ? 'text-green-400' : 'text-green-600',
icon: '✓',
message: '余额已到账,感谢您的充值!',
};
}
export default function OrderStatus({ status, onBack, dark = false }: OrderStatusProps) {
const config = STATUS_CONFIG[status] || {
label: status,
color: 'text-gray-600',
icon: '?',
message: '未知状态',
};
if (order.paymentSuccess) {
if (order.rechargeStatus === 'paid_pending' || order.rechargeStatus === 'recharging') {
return locale === 'en'
? {
label: 'Recharging',
color: isDark ? 'text-blue-400' : 'text-blue-600',
icon: '⟳',
message: 'Payment received. Recharging your balance...',
}
: {
label: '充值中',
color: isDark ? 'text-blue-400' : 'text-blue-600',
icon: '⟳',
message: '支付成功,正在充值余额中,请稍候...',
};
}
if (order.rechargeStatus === 'failed') {
return locale === 'en'
? {
label: 'Payment Successful',
color: isDark ? 'text-amber-400' : 'text-amber-600',
icon: '!',
message:
'Payment completed, but the balance top-up has not finished yet. The system may retry automatically. Please check the order list later or contact the administrator if it remains unresolved.',
}
: {
label: '支付成功',
color: isDark ? 'text-amber-400' : 'text-amber-600',
icon: '!',
message:
'支付已完成,但余额充值暂未完成。系统可能会自动重试,请稍后在订单列表查看;如长时间未到账请联系管理员。',
};
}
}
if (order.status === 'FAILED') {
return locale === 'en'
? {
label: 'Payment Failed',
color: isDark ? 'text-red-400' : 'text-red-600',
icon: '✗',
message:
'Payment was not completed. Please try again. If funds were deducted but not credited, contact the administrator.',
}
: {
label: '支付失败',
color: isDark ? 'text-red-400' : 'text-red-600',
icon: '✗',
message: '支付未完成,请重新发起支付;如已扣款未到账,请联系管理员处理。',
};
}
if (order.status === 'PENDING') {
return locale === 'en'
? {
label: 'Awaiting Payment',
color: isDark ? 'text-yellow-400' : 'text-yellow-600',
icon: '⏳',
message: 'The order has not been paid yet.',
}
: {
label: '等待支付',
color: isDark ? 'text-yellow-400' : 'text-yellow-600',
icon: '⏳',
message: '订单尚未完成支付。',
};
}
if (order.status === 'EXPIRED') {
return locale === 'en'
? {
label: 'Order Expired',
color: isDark ? 'text-slate-400' : 'text-gray-500',
icon: '⏰',
message: 'This order has expired. Please create a new one.',
}
: {
label: '订单超时',
color: isDark ? 'text-slate-400' : 'text-gray-500',
icon: '⏰',
message: '订单已超时,请重新创建订单。',
};
}
if (order.status === 'CANCELLED') {
return locale === 'en'
? {
label: 'Cancelled',
color: 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: isDark ? 'text-red-400' : 'text-red-600',
icon: '✗',
message: 'Payment status is abnormal. Please contact the administrator.',
}
: {
label: '支付异常',
color: isDark ? 'text-red-400' : 'text-red-600',
icon: '✗',
message: '支付状态异常,请联系管理员处理。',
};
}
export default function OrderStatus({
orderId,
order,
statusAccessToken,
onBack,
onStateChange,
dark = false,
locale = 'zh',
}: OrderStatusProps) {
const [currentOrder, setCurrentOrder] = useState(order);
const onStateChangeRef = useRef(onStateChange);
useEffect(() => {
onStateChangeRef.current = onStateChange;
});
useEffect(() => {
setCurrentOrder(order);
}, [order]);
useEffect(() => {
if (!orderId || !currentOrder.paymentSuccess || currentOrder.rechargeSuccess) {
return;
}
let cancelled = false;
const refreshOrder = async () => {
try {
const response = await fetch(buildOrderStatusUrl(orderId, statusAccessToken));
if (!response.ok) return;
const nextOrder = (await response.json()) as PublicOrderStatusSnapshot;
if (cancelled) return;
setCurrentOrder(nextOrder);
onStateChangeRef.current?.(nextOrder);
} catch {}
};
refreshOrder();
const timer = setInterval(refreshOrder, 3000);
const timeout = setTimeout(() => clearInterval(timer), 30000);
return () => {
cancelled = true;
clearInterval(timer);
clearTimeout(timeout);
};
}, [orderId, currentOrder.paymentSuccess, currentOrder.rechargeSuccess, statusAccessToken]);
const config = getStatusConfig(currentOrder, locale, dark);
const doneLabel = locale === 'en' ? 'Done' : '完成';
const backLabel = locale === 'en' ? 'Back to Recharge' : '返回充值';
return (
<div className="flex flex-col items-center space-y-4 py-8">
<div className={`text-6xl ${config.color}`}>{config.icon}</div>
<h2 className={`text-xl font-bold ${config.color}`}>{config.label}</h2>
<p className="text-center text-gray-500">{config.message}</p>
<p className={['text-center', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{config.message}</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 hover:bg-blue-500' : 'bg-blue-600 hover:bg-blue-700',
].join(' ')}
>
{status === 'COMPLETED' ? '完成' : '返回充值'}
{currentOrder.rechargeSuccess ? doneLabel : backLabel}
</button>
</div>
);

View File

@@ -1,3 +1,5 @@
import type { Locale } from '@/lib/locale';
interface Summary {
total: number;
pending: number;
@@ -7,32 +9,47 @@ interface Summary {
interface OrderSummaryCardsProps {
isDark: boolean;
locale: Locale;
summary: Summary;
}
export default function OrderSummaryCards({ isDark, summary }: OrderSummaryCardsProps) {
export default function OrderSummaryCards({ isDark, locale, summary }: OrderSummaryCardsProps) {
const cardClass = [
'rounded-xl border p-3',
isDark ? 'border-slate-700 bg-slate-800/70' : 'border-slate-200 bg-slate-50',
].join(' ');
const labelClass = ['text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ');
const labels =
locale === 'en'
? {
total: 'Total Orders',
pending: 'Pending',
completed: 'Completed',
failed: 'Closed/Failed',
}
: {
total: '总订单',
pending: '待支付',
completed: '已完成',
failed: '异常/关闭',
};
return (
<div className="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
<div className={cardClass}>
<div className={labelClass}></div>
<div className={labelClass}>{labels.total}</div>
<div className="mt-1 text-xl font-semibold">{summary.total}</div>
</div>
<div className={cardClass}>
<div className={labelClass}></div>
<div className={labelClass}>{labels.pending}</div>
<div className="mt-1 text-xl font-semibold">{summary.pending}</div>
</div>
<div className={cardClass}>
<div className={labelClass}></div>
<div className={labelClass}>{labels.completed}</div>
<div className="mt-1 text-xl font-semibold">{summary.completed}</div>
</div>
<div className={cardClass}>
<div className={labelClass}>/</div>
<div className={labelClass}>{labels.failed}</div>
<div className="mt-1 text-xl font-semibold">{summary.failed}</div>
</div>
</div>

View File

@@ -1,13 +1,40 @@
import { formatStatus, formatCreatedAt, getStatusBadgeClass, getPaymentDisplayInfo, type MyOrder } from '@/lib/pay-utils';
import type { Locale } from '@/lib/locale';
import {
formatStatus,
formatCreatedAt,
getStatusBadgeClass,
getPaymentDisplayInfo,
type MyOrder,
} from '@/lib/pay-utils';
interface OrderTableProps {
isDark: boolean;
locale: Locale;
loading: boolean;
error: string;
orders: MyOrder[];
}
export default function OrderTable({ isDark, loading, error, orders }: OrderTableProps) {
export default function OrderTable({ isDark, locale, loading, error, orders }: OrderTableProps) {
const text =
locale === 'en'
? {
empty: 'No matching orders found',
orderId: 'Order ID',
amount: 'Amount',
payment: 'Payment Method',
status: 'Status',
createdAt: 'Created At',
}
: {
empty: '暂无符合条件的订单记录',
orderId: '订单号',
amount: '金额',
payment: '支付方式',
status: '状态',
createdAt: '创建时间',
};
return (
<div
className={[
@@ -40,7 +67,7 @@ export default function OrderTable({ isDark, loading, error, orders }: OrderTabl
isDark ? 'border-slate-600 text-slate-400' : 'border-slate-300 text-slate-500',
].join(' ')}
>
{text.empty}
</div>
) : (
<>
@@ -50,11 +77,11 @@ export default function OrderTable({ isDark, loading, error, orders }: OrderTabl
isDark ? 'text-slate-300' : 'text-slate-600',
].join(' ')}
>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span>{text.orderId}</span>
<span>{text.amount}</span>
<span>{text.payment}</span>
<span>{text.status}</span>
<span>{text.createdAt}</span>
</div>
<div className="space-y-2 md:space-y-0">
{orders.map((order) => (
@@ -67,31 +94,19 @@ export default function OrderTable({ isDark, loading, error, orders }: OrderTabl
>
<div className="font-medium">#{order.id.slice(0, 12)}</div>
<div className="font-semibold">¥{order.amount.toFixed(2)}</div>
<div>
{(() => {
const { channel, provider } = getPaymentDisplayInfo(order.paymentType);
return (
<>
<span>{channel}</span>
{provider && (
<span className={['ml-1 text-xs', isDark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{provider}
</span>
)}
</>
);
})()}
</div>
<div>{getPaymentDisplayInfo(order.paymentType, locale).channel}</div>
<div>
<span
className={['rounded-full px-2 py-0.5 text-xs', getStatusBadgeClass(order.status, isDark)].join(
' ',
)}
>
{formatStatus(order.status)}
{formatStatus(order.status, locale)}
</span>
</div>
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>{formatCreatedAt(order.createdAt)}</div>
<div className={isDark ? 'text-slate-300' : 'text-slate-600'}>
{formatCreatedAt(order.createdAt, locale)}
</div>
</div>
))}
</div>

View File

@@ -1,9 +1,12 @@
import type { Locale } from '@/lib/locale';
interface PaginationBarProps {
page: number;
totalPages: number;
total: number;
pageSize: number;
pageSizeOptions?: number[];
locale?: Locale;
isDark?: boolean;
loading?: boolean;
onPageChange: (newPage: number) => void;
@@ -16,6 +19,7 @@ export default function PaginationBar({
total,
pageSize,
pageSizeOptions = [20, 50, 100],
locale,
isDark = false,
loading = false,
onPageChange,
@@ -30,17 +34,29 @@ export default function PaginationBar({
: 'border-slate-300 text-slate-600 hover:bg-slate-100',
].join(' ');
const text =
locale === 'en'
? {
total: `Total ${total}${totalPages > 1 ? `, Page ${page} / ${totalPages}` : ''}`,
perPage: 'Per page',
previous: 'Previous',
next: 'Next',
}
: {
total: `${total}${totalPages > 1 ? `,第 ${page} / ${totalPages}` : ''}`,
perPage: '每页',
previous: '上一页',
next: '下一页',
};
return (
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 text-xs">
{/* 左侧:统计 + 每页大小 */}
<div className="flex items-center gap-2">
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>
{total} {totalPages > 1 && `,第 ${page} / ${totalPages}`}
</span>
<span className={isDark ? 'text-slate-400' : 'text-slate-500'}>{text.total}</span>
{onPageSizeChange && (
<>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}></span>
<span className={isDark ? 'text-slate-500' : 'text-slate-400'}>{text.perPage}</span>
{pageSizeOptions.map((s) => (
<button
key={s}
@@ -68,7 +84,6 @@ export default function PaginationBar({
)}
</div>
{/* 右侧:分页导航 */}
{totalPages > 1 && (
<div className="flex items-center gap-1.5">
<button
@@ -85,7 +100,7 @@ export default function PaginationBar({
onClick={() => onPageChange(page - 1)}
className={navBtnClass(page <= 1)}
>
{text.previous}
</button>
<button
type="button"
@@ -93,7 +108,7 @@ export default function PaginationBar({
onClick={() => onPageChange(page + 1)}
className={navBtnClass(page >= totalPages)}
>
{text.next}
</button>
<button
type="button"

View File

@@ -1,4 +1,5 @@
import React from 'react';
import type { Locale } from '@/lib/locale';
interface PayPageLayoutProps {
isDark: boolean;
@@ -8,6 +9,7 @@ interface PayPageLayoutProps {
subtitle: string;
actions?: React.ReactNode;
children: React.ReactNode;
locale?: Locale;
}
export default function PayPageLayout({
@@ -18,11 +20,13 @@ export default function PayPageLayout({
subtitle,
actions,
children,
locale = 'zh',
}: PayPageLayoutProps) {
const maxWidthClass = maxWidth === 'sm' ? 'max-w-lg' : maxWidth === 'lg' ? 'max-w-6xl' : '';
return (
<div
data-theme={isDark ? 'dark' : 'light'}
className={[
'relative w-full overflow-hidden',
isEmbedded ? 'min-h-screen p-2' : 'min-h-screen p-3 sm:p-4',
@@ -64,7 +68,7 @@ export default function PayPageLayout({
isDark ? 'bg-indigo-500/20 text-indigo-200' : 'bg-indigo-50 text-indigo-700',
].join(' ')}
>
Sub2API Secure Pay
{locale === 'en' ? 'Sub2API Secure Pay' : 'Sub2API 安全支付'}
</div>
<h1
className={['text-2xl font-semibold tracking-tight', isDark ? 'text-slate-100' : 'text-slate-900'].join(

View File

@@ -1,7 +1,8 @@
'use client';
import { useState } from 'react';
import { PAYMENT_TYPE_META, getPaymentIconType, getPaymentMeta } from '@/lib/pay-utils';
import type { Locale } from '@/lib/locale';
import { PAYMENT_TYPE_META, getPaymentIconType, getPaymentMeta, getPaymentDisplayInfo } from '@/lib/pay-utils';
export interface MethodLimitInfo {
available: boolean;
@@ -25,6 +26,9 @@ interface PaymentFormProps {
dark?: boolean;
pendingBlocked?: boolean;
pendingCount?: number;
locale?: Locale;
/** 固定金额模式:隐藏金额选择,只显示支付方式和提交按钮 */
fixedAmount?: number;
}
const QUICK_AMOUNTS = [10, 20, 50, 100, 200, 500, 1000, 2000];
@@ -47,12 +51,13 @@ export default function PaymentForm({
dark = false,
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) : '');
// Reset paymentType when enabledPaymentTypes changes (e.g. after config loads)
const effectivePaymentType = enabledPaymentTypes.includes(paymentType)
? paymentType
: enabledPaymentTypes[0] || 'stripe';
@@ -107,7 +112,7 @@ export default function PaymentForm({
if (iconType === 'alipay') {
return (
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-[#00AEEF] text-xl font-bold leading-none text-white">
{locale === 'en' ? 'A' : '支'}
</span>
);
}
@@ -144,7 +149,6 @@ export default function PaymentForm({
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* User Info */}
<div
className={[
'rounded-xl border p-4',
@@ -152,94 +156,119 @@ export default function PaymentForm({
].join(' ')}
>
<div className={['text-xs uppercase tracking-wide', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
{locale === 'en' ? 'Recharge Account' : '充值账户'}
</div>
<div className={['mt-1 text-base font-medium', dark ? 'text-slate-100' : 'text-slate-900'].join(' ')}>
{userName || `用户 #${userId}`}
{userName || (locale === 'en' ? `User #${userId}` : `用户 #${userId}`)}
</div>
{userBalance !== undefined && (
<div className={['mt-1 text-sm', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>
: <span className="font-medium text-green-600">{userBalance.toFixed(2)}</span>
{locale === 'en' ? 'Current Balance:' : '当前余额:'}{' '}
<span className="font-medium text-green-600">{userBalance.toFixed(2)}</span>
</div>
)}
</div>
{/* Quick Amount Selection */}
<div>
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
</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>
{/* Custom Amount */}
<div>
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-slate-700'].join(' ')}>
</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);
let msg = '金额需在范围内,且最多支持 2 位小数(精确到分)';
let msg =
locale === 'en'
? 'Amount must be within range and support up to 2 decimal places'
: '金额需在范围内,且最多支持 2 位小数(精确到分)';
if (!isNaN(num)) {
if (num < minAmount) msg = `单笔最低充值 ¥${minAmount}`;
else if (num > effectiveMax) msg = `单笔最充值 ¥${effectiveMax}`;
if (num < minAmount)
msg = locale === 'en' ? `Minimum per transaction: ¥${minAmount}` : `单笔最充值 ¥${minAmount}`;
else if (num > effectiveMax)
msg = locale === 'en' ? `Maximum per transaction: ¥${effectiveMax}` : `单笔最高充值 ¥${effectiveMax}`;
}
return <div className={['text-xs', dark ? 'text-amber-300' : 'text-amber-700'].join(' ')}>{msg}</div>;
})()}
{/* Payment Type — only show when multiple types available */}
{enabledPaymentTypes.length > 1 && (
<div>
<label className={['mb-2 block text-sm font-medium', dark ? 'text-slate-200' : 'text-gray-700'].join(' ')}>
{locale === 'en' ? 'Payment Method' : '支付方式'}
</label>
<div className="grid grid-cols-2 gap-3 sm:flex">
{enabledPaymentTypes.map((type) => {
const meta = PAYMENT_TYPE_META[type];
const displayInfo = getPaymentDisplayInfo(type, locale);
const isSelected = effectivePaymentType === type;
const limitInfo = methodLimits?.[type];
const isUnavailable = limitInfo !== undefined && !limitInfo.available;
@@ -250,7 +279,13 @@ export default function PaymentForm({
type="button"
disabled={isUnavailable}
onClick={() => !isUnavailable && setPaymentType(type)}
title={isUnavailable ? '今日充值额度已满,请使用其他支付方式' : undefined}
title={
isUnavailable
? locale === 'en'
? 'Daily limit reached, please use another payment method'
: '今日充值额度已满,请使用其他支付方式'
: undefined
}
className={[
'relative flex h-[58px] flex-col items-center justify-center rounded-lg border px-3 transition-all sm:flex-1',
isUnavailable
@@ -258,7 +293,7 @@ export default function PaymentForm({
? 'cursor-not-allowed border-slate-700 bg-slate-800/50 opacity-50'
: 'cursor-not-allowed border-gray-200 bg-gray-50 opacity-50'
: isSelected
? `${meta?.selectedBorder || 'border-blue-500'} ${meta?.selectedBg || 'bg-blue-50'} text-slate-900 shadow-sm`
? `${meta?.selectedBorder || 'border-blue-500'} ${dark ? meta?.selectedBgDark || 'bg-blue-950' : meta?.selectedBg || 'bg-blue-50'} ${dark ? 'text-slate-100' : 'text-slate-900'} shadow-sm`
: dark
? 'border-slate-700 bg-slate-900 text-slate-200 hover:border-slate-500'
: 'border-gray-300 bg-white text-slate-700 hover:border-gray-400',
@@ -267,14 +302,16 @@ export default function PaymentForm({
<span className="flex items-center gap-2">
{renderPaymentIcon(type)}
<span className="flex flex-col items-start leading-none">
<span className="text-xl font-semibold tracking-tight">{meta?.label || type}</span>
<span className="text-xl font-semibold tracking-tight">{displayInfo.channel || type}</span>
{isUnavailable ? (
<span className="text-[10px] tracking-wide text-red-400"></span>
) : meta?.sublabel ? (
<span className="text-[10px] tracking-wide text-red-400">
{locale === 'en' ? 'Daily limit reached' : '今日额度已满'}
</span>
) : displayInfo.sublabel ? (
<span
className={`text-[10px] tracking-wide ${dark && !isSelected ? 'text-slate-400' : 'text-slate-600'}`}
className={`text-[10px] tracking-wide ${dark ? (isSelected ? 'text-slate-300' : 'text-slate-400') : 'text-slate-600'}`}
>
{meta.sublabel}
{displayInfo.sublabel}
</span>
) : null}
</span>
@@ -284,20 +321,20 @@ export default function PaymentForm({
})}
</div>
{/* 当前选中渠道额度不足时的提示 */}
{(() => {
const limitInfo = methodLimits?.[effectivePaymentType];
if (!limitInfo || limitInfo.available) return null;
return (
<p className={['mt-2 text-xs', dark ? 'text-amber-300' : 'text-amber-600'].join(' ')}>
{locale === 'en'
? "The selected payment method has reached today's limit. Please switch to another method."
: '所选支付方式今日额度已满,请切换到其他支付方式'}
</p>
);
})()}
</div>
)}
{/* Fee Detail */}
{feeRate > 0 && selectedAmount > 0 && (
<div
className={[
@@ -306,56 +343,60 @@ export default function PaymentForm({
].join(' ')}
>
<div className="flex items-center justify-between">
<span></span>
<span>{locale === 'en' ? 'Recharge Amount' : '充值金额'}</span>
<span>¥{selectedAmount.toFixed(2)}</span>
</div>
<div className="flex items-center justify-between mt-1">
<span>{feeRate}%</span>
<div className="mt-1 flex items-center justify-between">
<span>{locale === 'en' ? `Fee (${feeRate}%)` : `手续费(${feeRate}%`}</span>
<span>¥{feeAmount.toFixed(2)}</span>
</div>
<div
className={[
'flex items-center justify-between mt-1.5 pt-1.5 border-t font-medium',
'mt-1.5 flex items-center justify-between border-t pt-1.5 font-medium',
dark ? 'border-slate-700 text-slate-100' : 'border-slate-200 text-slate-900',
].join(' ')}
>
<span></span>
<span>{locale === 'en' ? 'Amount to Pay' : '实付金额'}</span>
<span>¥{payAmount.toFixed(2)}</span>
</div>
</div>
)}
{/* Pending order limit warning */}
{pendingBlocked && (
<div
className={[
'rounded-lg border p-3 text-sm',
dark
? 'border-amber-700 bg-amber-900/30 text-amber-300'
: 'border-amber-200 bg-amber-50 text-amber-700',
dark ? 'border-amber-700 bg-amber-900/30 text-amber-300' : 'border-amber-200 bg-amber-50 text-amber-700',
].join(' ')}
>
{pendingCount}
{locale === 'en'
? `You have ${pendingCount} pending orders. Please complete or cancel them before recharging.`
: `您有 ${pendingCount} 个待支付订单,请先完成或取消后再充值`}
</div>
)}
{/* Submit */}
<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
? '处理中...'
? locale === 'en'
? 'Processing...'
: '处理中...'
: pendingBlocked
? '待支付订单过多'
: `立即充值 ¥${(feeRate > 0 && selectedAmount > 0 ? payAmount : selectedAmount || 0).toFixed(2)}`}
? locale === 'en'
? 'Too many pending orders'
: '待支付订单过多'
: locale === 'en'
? `Recharge Now ¥${(feeRate > 0 && selectedAmount > 0 ? payAmount : selectedAmount || 0).toFixed(2)}`
: `立即充值 ¥${(feeRate > 0 && selectedAmount > 0 ? payAmount : selectedAmount || 0).toFixed(2)}`}
</button>
</form>
);

View File

@@ -2,12 +2,10 @@
import { useEffect, useMemo, useState, useCallback, useRef } from 'react';
import QRCode from 'qrcode';
import {
isStripeType,
getPaymentMeta,
getPaymentIconSrc,
getPaymentChannelLabel,
} from '@/lib/pay-utils';
import type { Locale } from '@/lib/locale';
import type { PublicOrderStatusSnapshot } from '@/lib/order/status';
import { isStripeType, getPaymentMeta, getPaymentIconSrc, getPaymentChannelLabel } from '@/lib/pay-utils';
import { buildOrderStatusUrl } from '@/lib/order/status-url';
import { TERMINAL_STATUSES } from '@/lib/constants';
interface PaymentQRCodeProps {
@@ -21,21 +19,18 @@ interface PaymentQRCodeProps {
amount: number;
payAmount?: number;
expiresAt: string;
onStatusChange: (status: string) => void;
statusAccessToken?: string;
onStatusChange: (status: PublicOrderStatusSnapshot) => void;
onBack: () => void;
dark?: boolean;
isEmbedded?: boolean;
isMobile?: boolean;
locale?: Locale;
}
const TEXT_EXPIRED = '\u8BA2\u5355\u5DF2\u8D85\u65F6';
const TEXT_REMAINING = '\u5269\u4F59\u652F\u4ED8\u65F6\u95F4';
const TEXT_GO_PAY = '\u70B9\u51FB\u524D\u5F80\u652F\u4ED8';
const TEXT_SCAN_PAY = '\u8BF7\u4F7F\u7528\u652F\u4ED8\u5E94\u7528\u626B\u7801\u652F\u4ED8';
const TEXT_BACK = '\u8FD4\u56DE';
const TEXT_CANCEL_ORDER = '\u53D6\u6D88\u8BA2\u5355';
const TEXT_H5_HINT =
'\u652F\u4ED8\u5B8C\u6210\u540E\u8BF7\u8FD4\u56DE\u6B64\u9875\u9762\uFF0C\u7CFB\u7EDF\u5C06\u81EA\u52A8\u786E\u8BA4';
function isVisibleOrderOutcome(data: PublicOrderStatusSnapshot): boolean {
return data.paymentSuccess || TERMINAL_STATUSES.has(data.status);
}
export default function PaymentQRCode({
orderId,
@@ -48,22 +43,24 @@ export default function PaymentQRCode({
amount,
payAmount: payAmountProp,
expiresAt,
statusAccessToken,
onStatusChange,
onBack,
dark = false,
isEmbedded = false,
isMobile = false,
locale = 'zh',
}: PaymentQRCodeProps) {
const displayAmount = payAmountProp ?? amount;
const hasFeeDiff = payAmountProp !== undefined && payAmountProp !== amount;
const [timeLeft, setTimeLeft] = useState('');
const [timeLeftSeconds, setTimeLeftSeconds] = useState(Infinity);
const [expired, setExpired] = useState(false);
const [qrDataUrl, setQrDataUrl] = useState('');
const [imageLoading, setImageLoading] = useState(false);
const [cancelBlocked, setCancelBlocked] = useState(false);
const [redirected, setRedirected] = useState(false);
// Stripe Payment Element state
const [stripeLoaded, setStripeLoaded] = useState(false);
const [stripeSubmitting, setStripeSubmitting] = useState(false);
const [stripeError, setStripeError] = useState('');
@@ -72,12 +69,56 @@ export default function PaymentQRCode({
stripe: import('@stripe/stripe-js').Stripe;
elements: import('@stripe/stripe-js').StripeElements;
} | null>(null);
// Track selected payment method in Payment Element (for embedded popup decision)
const [stripePaymentMethod, setStripePaymentMethod] = useState('card');
const [popupBlocked, setPopupBlocked] = useState(false);
const paymentMethodListenerAdded = useRef(false);
// PC 端有二维码时优先展示二维码;仅移动端或无二维码时才跳转
const t = {
expired: locale === 'en' ? 'Order Expired' : '订单已超时',
remaining: locale === 'en' ? 'Time Remaining' : '剩余支付时间',
scanPay: locale === 'en' ? 'Please scan with your payment app' : '请使用支付应用扫码支付',
back: locale === 'en' ? 'Back' : '返回',
cancelOrder: locale === 'en' ? 'Cancel Order' : '取消订单',
h5Hint:
locale === 'en'
? 'After payment, please return to this page. The system will confirm automatically.'
: '支付完成后请返回此页面,系统将自动确认',
paid: locale === 'en' ? 'Order Paid' : '订单已支付',
paidCancelBlocked:
locale === 'en'
? 'This order has already been paid and cannot be cancelled. The recharge will be credited automatically.'
: '该订单已支付完成,无法取消。充值将自动到账。',
backToRecharge: locale === 'en' ? 'Back to Recharge' : '返回充值',
credited: locale === 'en' ? 'Credited ¥' : '到账 ¥',
stripeLoadFailed:
locale === 'en'
? 'Failed to load payment component. Please refresh and try again.'
: '支付组件加载失败,请刷新页面重试',
initFailed:
locale === 'en' ? 'Payment initialization failed. Please go back and try again.' : '支付初始化失败,请返回重试',
loadingForm: locale === 'en' ? 'Loading payment form...' : '正在加载支付表单...',
payFailed: locale === 'en' ? 'Payment failed. Please try again.' : '支付失败,请重试',
successProcessing: locale === 'en' ? 'Payment successful, processing your order...' : '支付成功,正在处理订单...',
processing: locale === 'en' ? 'Processing...' : '处理中...',
payNow: locale === 'en' ? 'Pay' : '支付',
popupBlocked:
locale === 'en'
? 'Popup was blocked by your browser. Please allow popups for this site and try again.'
: '弹出窗口被浏览器拦截,请允许本站弹出窗口后重试',
redirectingPrefix: locale === 'en' ? 'Redirecting to ' : '正在跳转到',
redirectingSuffix: locale === 'en' ? '...' : '...',
redirectRetryHint:
locale === 'en'
? 'If the payment app does not open automatically, go back and try again.'
: '如未自动拉起支付应用,请返回上一页后重新发起支付。',
notRedirectedPrefix: locale === 'en' ? 'Not redirected? Open ' : '未跳转?点击前往',
goPaySuffix: locale === 'en' ? '' : '',
gotoPrefix: locale === 'en' ? 'Open ' : '前往',
gotoSuffix: locale === 'en' ? ' to pay' : '支付',
openScanPrefix: locale === 'en' ? 'Open ' : '请打开',
openScanSuffix: locale === 'en' ? ' and scan to complete payment' : '扫一扫完成支付',
};
const shouldAutoRedirect = !expired && !isStripeType(paymentType) && !!payUrl && (isMobile || !qrCode);
useEffect(() => {
@@ -86,7 +127,7 @@ export default function PaymentQRCode({
if (isEmbedded) {
window.open(payUrl!, '_blank');
} else {
window.location.href = payUrl!;
window.location.replace(payUrl!);
}
}, [shouldAutoRedirect, redirected, payUrl, isEmbedded]);
@@ -128,7 +169,6 @@ export default function PaymentQRCode({
};
}, [qrPayload]);
// Initialize Stripe Payment Element
const isStripe = isStripeType(paymentType);
useEffect(() => {
@@ -139,7 +179,7 @@ export default function PaymentQRCode({
loadStripe(stripePublishableKey).then((stripe) => {
if (cancelled) return;
if (!stripe) {
setStripeError('支付组件加载失败,请刷新页面重试');
setStripeError(t.stripeLoadFailed);
setStripeLoaded(true);
return;
}
@@ -160,9 +200,8 @@ export default function PaymentQRCode({
return () => {
cancelled = true;
};
}, [isStripe, clientSecret, stripePublishableKey, dark]);
}, [isStripe, clientSecret, stripePublishableKey, dark, t.stripeLoadFailed]);
// Mount Payment Element when container is available
const stripeContainerRef = useCallback(
(node: HTMLDivElement | null) => {
if (!node || !stripeLib) return;
@@ -188,7 +227,6 @@ export default function PaymentQRCode({
const handleStripeSubmit = async () => {
if (!stripeLib || stripeSubmitting) return;
// In embedded mode, Alipay redirects to a page with X-Frame-Options that breaks iframe
if (isEmbedded && stripePaymentMethod === 'alipay') {
handleOpenPopup();
return;
@@ -203,6 +241,12 @@ export default function PaymentQRCode({
returnUrl.search = '';
returnUrl.searchParams.set('order_id', orderId);
returnUrl.searchParams.set('status', 'success');
if (statusAccessToken) {
returnUrl.searchParams.set('access_token', statusAccessToken);
}
if (locale === 'en') {
returnUrl.searchParams.set('lang', 'en');
}
const { error } = await stripe.confirmPayment({
elements,
@@ -213,20 +257,17 @@ export default function PaymentQRCode({
});
if (error) {
setStripeError(error.message || '支付失败,请重试');
setStripeError(error.message || t.payFailed);
setStripeSubmitting(false);
} else {
// Payment succeeded (or no redirect needed)
setStripeSuccess(true);
setStripeSubmitting(false);
// Polling will pick up the status change
}
};
const handleOpenPopup = () => {
if (!clientSecret || !stripePublishableKey) return;
setPopupBlocked(false);
// Only pass display params in URL — sensitive data sent via postMessage
const popupUrl = new URL(window.location.href);
popupUrl.pathname = '/pay/stripe-popup';
popupUrl.search = '';
@@ -234,13 +275,18 @@ export default function PaymentQRCode({
popupUrl.searchParams.set('amount', String(amount));
popupUrl.searchParams.set('theme', dark ? 'dark' : 'light');
popupUrl.searchParams.set('method', stripePaymentMethod);
if (statusAccessToken) {
popupUrl.searchParams.set('access_token', statusAccessToken);
}
if (locale === 'en') {
popupUrl.searchParams.set('lang', 'en');
}
const popup = window.open(popupUrl.toString(), 'stripe_payment', 'width=500,height=700,scrollbars=yes');
if (!popup || popup.closed) {
setPopupBlocked(true);
return;
}
// Send sensitive data via postMessage after popup loads
const onReady = (event: MessageEvent) => {
if (event.source !== popup || event.data?.type !== 'STRIPE_POPUP_READY') return;
window.removeEventListener('message', onReady);
@@ -263,34 +309,35 @@ export default function PaymentQRCode({
const diff = expiry - now;
if (diff <= 0) {
setTimeLeft(TEXT_EXPIRED);
setTimeLeft(t.expired);
setTimeLeftSeconds(0);
setExpired(true);
return;
}
const totalSeconds = Math.floor(diff / 1000);
const minutes = Math.floor(diff / 60000);
const seconds = Math.floor((diff % 60000) / 1000);
setTimeLeft(`${minutes}:${seconds.toString().padStart(2, '0')}`);
setTimeLeftSeconds(totalSeconds);
};
updateTimer();
const timer = setInterval(updateTimer, 1000);
return () => clearInterval(timer);
}, [expiresAt]);
}, [expiresAt, t.expired]);
const pollStatus = useCallback(async () => {
try {
const res = await fetch(`/api/orders/${orderId}`);
const res = await fetch(buildOrderStatusUrl(orderId, statusAccessToken));
if (res.ok) {
const data = await res.json();
if (TERMINAL_STATUSES.has(data.status)) {
onStatusChange(data.status);
const data = (await res.json()) as PublicOrderStatusSnapshot;
if (isVisibleOrderOutcome(data)) {
onStatusChange(data);
}
}
} catch {
// ignore polling errors
}
}, [orderId, onStatusChange]);
} catch {}
}, [orderId, onStatusChange, statusAccessToken]);
useEffect(() => {
if (expired) return;
@@ -302,13 +349,12 @@ export default function PaymentQRCode({
const handleCancel = async () => {
if (!token) return;
try {
// 先检查当前订单状态
const res = await fetch(`/api/orders/${orderId}`);
const res = await fetch(buildOrderStatusUrl(orderId, statusAccessToken));
if (!res.ok) return;
const data = await res.json();
const data = (await res.json()) as PublicOrderStatusSnapshot;
if (TERMINAL_STATUSES.has(data.status)) {
onStatusChange(data.status);
if (data.paymentSuccess || TERMINAL_STATUSES.has(data.status)) {
onStatusChange(data);
return;
}
@@ -323,35 +369,41 @@ export default function PaymentQRCode({
setCancelBlocked(true);
return;
}
onStatusChange('CANCELLED');
onStatusChange({
id: orderId,
status: 'CANCELLED',
expiresAt,
paymentSuccess: false,
rechargeSuccess: false,
rechargeStatus: 'closed',
});
} else {
await pollStatus();
}
} catch {
// ignore
}
} catch {}
};
const meta = getPaymentMeta(paymentType || 'alipay');
const iconSrc = getPaymentIconSrc(paymentType || 'alipay');
const channelLabel = getPaymentChannelLabel(paymentType || 'alipay');
const channelLabel = getPaymentChannelLabel(paymentType || 'alipay', locale);
const iconBgClass = meta.iconBg;
if (cancelBlocked) {
return (
<div className="flex flex-col items-center space-y-4 py-8">
<div className="text-6xl text-green-600">{'\u2713'}</div>
<h2 className="text-xl font-bold text-green-600">{'\u8BA2\u5355\u5DF2\u652F\u4ED8'}</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(' ')}>
{
'\u8BE5\u8BA2\u5355\u5DF2\u652F\u4ED8\u5B8C\u6210\uFF0C\u65E0\u6CD5\u53D6\u6D88\u3002\u5145\u503C\u5C06\u81EA\u52A8\u5230\u8D26\u3002'
}
{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(' ')}
>
{'\u8FD4\u56DE\u5145\u503C'}
{t.backToRecharge}
</button>
</div>
);
@@ -360,17 +412,20 @@ export default function PaymentQRCode({
return (
<div className="flex flex-col items-center space-y-4">
<div className="text-center">
<div className="text-4xl font-bold text-blue-600">
{'\u00A5'}
<div className={['text-4xl font-bold', dark ? 'text-blue-400' : 'text-blue-600'].join(' ')}>
{'¥'}
{displayAmount.toFixed(2)}
</div>
{hasFeeDiff && (
<div className={['mt-1 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
¥{amount.toFixed(2)}
{t.credited}
{amount.toFixed(2)}
</div>
)}
<div className={`mt-1 text-sm ${expired ? 'text-red-500' : dark ? 'text-slate-400' : 'text-gray-500'}`}>
{expired ? TEXT_EXPIRED : `${TEXT_REMAINING}: ${timeLeft}`}
<div
className={`mt-1 text-sm ${expired ? 'text-red-500' : !expired && timeLeftSeconds <= 60 ? 'text-red-500 animate-pulse' : dark ? 'text-slate-400' : 'text-gray-500'}`}
>
{expired ? t.expired : `${t.remaining}: ${timeLeft}`}
</div>
</div>
@@ -385,19 +440,24 @@ export default function PaymentQRCode({
dark ? 'border-slate-700' : 'border-gray-300',
].join(' ')}
>
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
</p>
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{t.initFailed}</p>
</div>
) : !stripeLoaded ? (
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
<span className={['ml-3 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
...
{t.loadingForm}
</span>
</div>
) : stripeError && !stripeLib ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">{stripeError}</div>
<div
className={[
'rounded-lg border p-3 text-sm',
dark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600',
].join(' ')}
>
{stripeError}
</div>
) : (
<>
<div
@@ -408,15 +468,20 @@ 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">{'\u2713'}</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>
</div>
) : (
@@ -426,18 +491,16 @@ export default function PaymentQRCode({
onClick={handleStripeSubmit}
className={[
'w-full rounded-lg py-3 font-medium text-white shadow-md transition-colors',
stripeSubmitting
? 'bg-gray-400 cursor-not-allowed'
: meta.buttonClass,
stripeSubmitting ? 'cursor-not-allowed bg-gray-400' : meta.buttonClass,
].join(' ')}
>
{stripeSubmitting ? (
<span className="inline-flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
...
{t.processing}
</span>
) : (
`支付 ¥${amount.toFixed(2)}`
`${t.payNow} ¥${amount.toFixed(2)}`
)}
</button>
)}
@@ -450,7 +513,7 @@ export default function PaymentQRCode({
: 'border-amber-200 bg-amber-50 text-amber-700',
].join(' ')}
>
{t.popupBlocked}
</div>
)}
</>
@@ -459,9 +522,12 @@ export default function PaymentQRCode({
) : shouldAutoRedirect ? (
<>
<div className="flex items-center justify-center py-6">
<div className={`h-8 w-8 animate-spin rounded-full border-2 border-t-transparent`} style={{ borderColor: meta.color, borderTopColor: 'transparent' }} />
<div
className={`h-8 w-8 animate-spin rounded-full border-2 border-t-transparent`}
style={{ borderColor: meta.color, borderTopColor: 'transparent' }}
/>
<span className={['ml-3 text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
{channelLabel}...
{`${t.redirectingPrefix}${channelLabel}${t.redirectingSuffix}`}
</span>
</div>
<a
@@ -471,11 +537,11 @@ export default function PaymentQRCode({
className={`flex w-full items-center justify-center gap-2 rounded-lg py-3 font-medium text-white shadow-md ${meta.buttonClass}`}
>
{iconSrc && <img src={iconSrc} alt={channelLabel} className="h-5 w-5 brightness-0 invert" />}
{redirected ? `未跳转?点击前往${channelLabel}` : `前往${channelLabel}支付`}
{redirected
? `${t.notRedirectedPrefix}${channelLabel}`
: `${t.gotoPrefix}${channelLabel}${t.gotoSuffix}`}
</a>
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
{TEXT_H5_HINT}
</p>
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{t.h5Hint}</p>
</>
) : (
<>
@@ -508,13 +574,13 @@ export default function PaymentQRCode({
dark ? 'border-slate-700' : 'border-gray-300',
].join(' ')}
>
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{TEXT_SCAN_PAY}</p>
<p className={['text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>{t.scanPay}</p>
</div>
</div>
)}
<p className={['text-center text-sm', dark ? 'text-slate-400' : 'text-gray-500'].join(' ')}>
{`\u8BF7\u6253\u5F00${channelLabel}\u626B\u4E00\u626B\u5B8C\u6210\u652F\u4ED8`}
{`${t.openScanPrefix}${channelLabel}${t.openScanSuffix}`}
</p>
</>
)}
@@ -531,14 +597,17 @@ export default function PaymentQRCode({
: 'border-gray-300 text-gray-600 hover:bg-gray-50',
].join(' ')}
>
{TEXT_BACK}
{t.back}
</button>
{!expired && token && (
<button
onClick={handleCancel}
className="flex-1 rounded-lg border border-red-300 py-2 text-sm text-red-600 hover:bg-red-50"
className={[
'flex-1 rounded-lg border py-2 text-sm',
dark ? 'border-red-700 text-red-400 hover:bg-red-900/30' : 'border-red-300 text-red-600 hover:bg-red-50',
].join(' ')}
>
{TEXT_CANCEL_ORDER}
{t.cancelOrder}
</button>
)}
</div>

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

@@ -1,6 +1,7 @@
'use client';
import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts';
import type { Locale } from '@/lib/locale';
interface DailyData {
date: string;
@@ -11,6 +12,7 @@ interface DailyData {
interface DailyChartProps {
data: DailyData[];
dark?: boolean;
locale?: Locale;
}
function formatDate(dateStr: string) {
@@ -34,11 +36,17 @@ function CustomTooltip({
payload,
label,
dark,
currency,
amountLabel,
countLabel,
}: {
active?: boolean;
payload?: TooltipPayload[];
label?: string;
dark?: boolean;
currency: string;
amountLabel: string;
countLabel: string;
}) {
if (!active || !payload?.length) return null;
return (
@@ -51,16 +59,20 @@ function CustomTooltip({
<p className={['mb-1 text-xs', dark ? 'text-slate-400' : 'text-slate-500'].join(' ')}>{label}</p>
{payload.map((p) => (
<p key={p.dataKey}>
{p.dataKey === 'amount' ? '金额' : '笔数'}:{' '}
{p.dataKey === 'amount' ? `¥${p.value.toLocaleString()}` : p.value}
{p.dataKey === 'amount' ? amountLabel : countLabel}:{' '}
{p.dataKey === 'amount' ? `${currency}${p.value.toLocaleString()}` : p.value}
</p>
))}
</div>
);
}
export default function DailyChart({ data, dark }: DailyChartProps) {
// Auto-calculate tick interval: show ~10-15 labels max
export default function DailyChart({ data, dark, locale = 'zh' }: DailyChartProps) {
const currency = locale === 'en' ? '$' : '¥';
const chartTitle = locale === 'en' ? 'Daily Recharge Trend' : '每日充值趋势';
const emptyText = locale === 'en' ? 'No data' : '暂无数据';
const amountLabel = locale === 'en' ? 'Amount' : '金额';
const countLabel = locale === 'en' ? 'Orders' : '笔数';
const tickInterval = data.length > 30 ? Math.ceil(data.length / 12) - 1 : 0;
if (data.length === 0) {
return (
@@ -71,9 +83,11 @@ export default function DailyChart({ data, dark }: DailyChartProps) {
].join(' ')}
>
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
{chartTitle}
</h3>
<p className={['text-center text-sm py-16', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}></p>
<p className={['text-center text-sm py-16', dark ? 'text-slate-500' : 'text-gray-400'].join(' ')}>
{emptyText}
</p>
</div>
);
}
@@ -89,7 +103,7 @@ export default function DailyChart({ data, dark }: DailyChartProps) {
].join(' ')}
>
<h3 className={['mb-4 text-sm font-semibold', dark ? 'text-slate-200' : 'text-slate-800'].join(' ')}>
{chartTitle}
</h3>
<ResponsiveContainer width="100%" height={320}>
<LineChart data={data} margin={{ top: 5, right: 20, bottom: 5, left: 10 }}>
@@ -109,7 +123,11 @@ export default function DailyChart({ data, dark }: DailyChartProps) {
tickLine={false}
width={60}
/>
<Tooltip content={<CustomTooltip dark={dark} />} />
<Tooltip
content={
<CustomTooltip dark={dark} currency={currency} amountLabel={amountLabel} countLabel={countLabel} />
}
/>
<Line
type="monotone"
dataKey="amount"

View File

@@ -1,5 +1,7 @@
'use client';
import type { Locale } from '@/lib/locale';
interface Summary {
today: { amount: number; orderCount: number; paidCount: number };
total: { amount: number; orderCount: number; paidCount: number };
@@ -10,16 +12,29 @@ interface Summary {
interface DashboardStatsProps {
summary: Summary;
dark?: boolean;
locale?: Locale;
}
export default function DashboardStats({ summary, dark }: DashboardStatsProps) {
export default function DashboardStats({ summary, dark, locale = 'zh' }: DashboardStatsProps) {
const currency = locale === 'en' ? '$' : '¥';
const cards = [
{ label: '今日充值', value: `¥${summary.today.amount.toLocaleString()}`, accent: true },
{ label: '今日订单', value: `${summary.today.paidCount}/${summary.today.orderCount}` },
{ label: '累计充值', value: `¥${summary.total.amount.toLocaleString()}`, accent: true },
{ label: '累计订单', value: String(summary.total.paidCount) },
{ label: '成功率', value: `${summary.successRate}%` },
{ label: '平均充值', value: `¥${summary.avgAmount.toFixed(2)}` },
{
label: locale === 'en' ? 'Today Recharge' : '今日充值',
value: `${currency}${summary.today.amount.toLocaleString()}`,
accent: true,
},
{
label: locale === 'en' ? 'Today Orders' : '今日订单',
value: `${summary.today.paidCount}/${summary.today.orderCount}`,
},
{
label: locale === 'en' ? 'Total Recharge' : '累计充值',
value: `${currency}${summary.total.amount.toLocaleString()}`,
accent: true,
},
{ label: locale === 'en' ? 'Paid Orders' : '累计订单', value: String(summary.total.paidCount) },
{ label: locale === 'en' ? 'Success Rate' : '成功率', value: `${summary.successRate}%` },
{ label: locale === 'en' ? 'Average Amount' : '平均充值', value: `${currency}${summary.avgAmount.toFixed(2)}` },
];
return (

Some files were not shown because too many files have changed in this diff Show More