143 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
erio
a5e07edda6 fix: PC 端易支付优先显示二维码;订单页移除 user_id 依赖改用 token
- PaymentQRCode: PC 端有 qrCode 时不跳转,优先展示二维码
- /pay/orders: 移除 user_id 参数,统一通过 token 认证

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 01:59:43 +08:00
erio
7627066549 fix: 官方支付未配置时 notify 路由静默返回成功,避免错误日志
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 00:07:56 +08:00
erio
387bc96fc9 feat: 创建订单必须通过 token 认证,移除 user_id 参数
- POST /api/orders 改为通过 token 解析用户身份,移除 user_id
- 前端不再从 URL 读取 user_id,完全依赖 token
- 前端提交前检查 pending 订单数量,超过 3 个禁止提交并提示
- 后端 createOrder 保留 MAX_PENDING_ORDERS=3 的服务端校验
- PaymentForm 增加 pendingBlocked 状态提示和按钮禁用

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 23:05:12 +08:00
erio
e846cc1cce feat: sublabel 智能显示 — 仅同名渠道冲突时自动标记
- 默认去掉所有渠道的 sublabel
- 当多个启用渠道有相同显示名(如支付宝+支付宝)时,
  自动用 provider 名标记区分
- 用户手动配置的 PAYMENT_SUBLABEL_* 优先级最高
- 管理后台 getPaymentTypeLabel 自动检测同名并区分

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:58:10 +08:00
erio
bdf2577f28 fix: 支付安全审核修复(支付宝+微信)
支付宝:
- 回调增加 app_id 校验,防止跨商户通知
- 回调增加 sign_type 过滤,仅接受 RSA2
- 退款增加 out_request_no 保证幂等
- 金额解析增加精度保护
- timestamp 改用 CST 时区

微信:
- 自行实现 AES-GCM 解密替代库的 decipher_gcm(修复 AuthTag 未验证)
- WXPAY_PUBLIC_KEY_ID 改为必填
- serial 匹配检查改为强制
- 时间戳校验移到签名验证之前
- nonce 改用 crypto.randomBytes
- publicKey 不允许空 Buffer fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:57:55 +08:00
erio
5253bc8d35 fix: 支付宝 H5 下单失败时 fallback 到 PC 页面支付
与微信支付保持一致的 fallback 策略。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:40:09 +08:00
erio
e2a6895bb7 fix: H5 fallback 仅在 NO_AUTH 时触发,其他错误正常抛出
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:35:23 +08:00
erio
0c6c6e0ea6 fix: 微信 H5 支付未开通时 fallback 到 Native 扫码
移动端尝试 H5 下单失败时自动降级为 Native 二维码模式,
避免因 H5 权限未开通导致支付不可用。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:34:57 +08:00
erio
918750047a Revert "fix: 微信支付暂时统一使用 Native 扫码模式"
This reverts commit ef1078279a.
2026-03-06 22:32:53 +08:00
erio
ef1078279a fix: 微信支付暂时统一使用 Native 扫码模式
H5 支付需要在微信商户平台单独开通权限,当前未开通会报
NO_AUTH 错误。暂时移动端也走 Native 二维码,待 H5 权限
开通后再恢复。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:32:13 +08:00
erio
225f2e0c5a fix: 微信支付回调验签改用公钥直接验证
wechatpay-node-v3 的 verifySign 会尝试拉取平台证书,
但我们使用的是微信支付公钥模式,不需要平台证书。
改用 crypto.createVerify 直接用公钥做 RSA-SHA256 验签。
同时增加 serial 与 WXPAY_PUBLIC_KEY_ID 的匹配校验。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:25:58 +08:00
erio
96962ec38e fix: 微信支付密钥支持文件路径自动解析
在 getEnv() 中对 WXPAY_PRIVATE_KEY 和 WXPAY_PUBLIC_KEY 调用
resolveKeyValue(),与支付宝保持一致,支持通过文件路径配置密钥。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 22:10:50 +08:00
erio
137a780269 refactor: 简化支付展示逻辑 - 有 payUrl 直接跳转,有 qrCode 显示二维码
移除 isRedirectPayment / mobileRedirectUrl 等支付类型判断,
前端只根据后端返回的字段决定行为:
- payUrl → 自动跳转,无需确认
- qrCode → 展示二维码 + 中心图标
- clientSecret → Stripe 嵌入式表单

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:13:06 +08:00
erio
7be0614c7d fix: wxpay_direct supportedTypes 修正,避免与易支付 wxpay 冲突
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:05:21 +08:00
erio
7cab333213 merge: 合并 wxpay_direct 微信支付直连分支
解决冲突:保留 main 的常量化/provider 字段/ENABLED_PAYMENT_TYPES 移除,
合并 worktree 的微信支付直连实现、notifyUrl/returnUrl 传递、签名验证优化。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 19:00:16 +08:00
erio
e72325140b style: 移动端支付方式选择器改为两列网格布局
避免三个选项挤在一行,移动端使用 grid-cols-2,PC 端保持 flex 等宽一行。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:54:28 +08:00
erio
d46793f072 feat: 支付宝直连 H5 端使用 wap.pay 唤起支付宝 APP
前端传递 is_mobile 参数,AlipayProvider 根据设备类型选择:
- PC: alipay.trade.page.pay (FAST_INSTANT_TRADE_PAY)
- H5: alipay.trade.wap.pay (QUICK_WAP_WAY)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:04:11 +08:00
erio
94d25ddc31 refactor: 移除 ENABLED_PAYMENT_TYPES,支付类型由 PAYMENT_PROVIDERS 自动推导
PAYMENT_PROVIDERS 配置提供商后,各 provider 的 supportedTypes 自动注册为可用支付类型,
无需再手动配置 ENABLED_PAYMENT_TYPES。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:53:47 +08:00
erio
254ead1908 refactor: 常量化订单状态 + 支付渠道/提供商分离显示 + H5自动跳转
- 新增 src/lib/constants.ts,集中管理 ORDER_STATUS / PAYMENT_TYPE / PAYMENT_PREFIX 等常量
- 后端 service/status/timeout/limits 全量替换魔法字符串为 ORDER_STATUS.*
- PaymentTypeMeta 新增 provider 字段,分离 sublabel(选择器展示)与 provider(提供商名称)
- getPaymentDisplayInfo() 返回 { channel, provider } 用于用户端/管理端展示
- 支持通过 PAYMENT_SUBLABEL_* 环境变量覆盖默认 sublabel
- PaymentQRCode: H5 支付自动跳转(含易支付微信 weixin:// scheme 兜底)
- 订单列表/详情页:显示可读的渠道名+提供商,不再暴露内部标识符

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 17:34:42 +08:00
erio
3829d0e52e refactor: 将支付类型硬编码抽取到 pay-utils 统一管理
- PaymentTypeMeta 新增 iconSrc、chartBar、buttonClass 字段
- 新增工具函数: getPaymentMeta、getPaymentIconSrc、
  getPaymentChannelLabel、isStripeType、isRedirectPayment 等
- PaymentQRCode: 用 meta/工具函数替换散落的颜色和类型判断
- PaymentForm: 提交按钮颜色改用 meta.buttonClass
- PaymentMethodChart: 删除重复的 TYPE_CONFIG,改用 getPaymentMeta
- stripe-popup: 按钮颜色改用 meta.buttonClass

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:46:36 +08:00
erio
cee24c3afb fix: 修复 isAlipayDirect 变量声明顺序导致构建失败
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:34:01 +08:00
erio
dc78a41912 fix: alipay_direct 桌面端跳转到支付宝收银台而非显示二维码
alipay.trade.page.pay 返回的 payUrl 是跳转链接,不应作为
二维码内容展示。改为显示"前往支付宝收银台"按钮,在新标签页
打开支付宝收银台页面。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:32:50 +08:00
erio
ad6b63dd9e fix: 修复支付宝签名时错误排除 sign_type 导致 invalid-signature
支付宝验签字符串包含 sign_type 字段,但 generateSign 错误地
将 sign_type 与 sign 一起排除。只需排除 sign,保留 sign_type。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 16:14:52 +08:00
erio
0763d72a89 fix: 各支付 provider 自行获取 notifyUrl/returnUrl
service.ts 不再硬编码传 EASY_PAY 的 notify/return URL,
避免官方支付宝错误使用 EasyPay 的回调地址导致签名失败。
2026-03-06 15:49:54 +08:00
erio
f53aa9e14c feat: 支持官方支付宝与易支付支付宝同时展示
- PaymentType 改为 string 类型,支持复合 key(如 alipay_direct)
- 官方支付宝注册为 alipay_direct,易支付保持 alipay/wxpay
- 前端按 PAYMENT_TYPE_META 展示标签区分(官方直连/易支付)
- 管理后台显示统一改为 getPaymentTypeLabel 通用映射
- 修复 admin/OrderTable 中 wechat 拼写错误
2026-03-06 15:33:22 +08:00
erio
01d5a0b3c4 feat: 支付宝密钥支持文件路径配置
ALIPAY_PRIVATE_KEY / ALIPAY_PUBLIC_KEY 可直接填密钥内容,
也可填文件路径,代码自动检测并读取文件。
2026-03-06 15:11:47 +08:00
erio
cba8acdd60 refactor: 微信支付命名从 Native 改为 PC扫码,统一术语
- WxpayNativeOrderParams → WxpayPcOrderParams
- createNativeOrder → createPcOrder
- 文档注释统一为「PC扫码 / H5」
2026-03-06 14:31:43 +08:00
erio
b0f1daf469 fix: 修复微信支付 Native/H5 判断逻辑,改为前端设备检测驱动
- 修复 clientIp 始终存在导致永远走 H5 的 bug,改用 isMobile 判断
- 前端通过 detectDeviceIsMobile() 传 is_mobile 给后端
- ENABLED_PAYMENT_TYPES 默认改为空,必须显式配置才启用
- 补充 .env.example 配置说明
2026-03-06 14:04:51 +08:00
erio
937f54dec2 feat: 集成微信支付直连(Native + H5)及金融级安全修复
- 新增 wxpay provider(wechatpay-node-v3 SDK),支持 Native 扫码和 H5 跳转
- 新增 /api/wxpay/notify 回调路由,AES-256-GCM 解密 + RSA 签名验证
- 修复 confirmPayment count=0 静默成功、充值失败返回 true 等 P0 问题
- 修复 notifyUrl 硬编码 easypay、回调金额覆盖订单金额等 P1 问题
- 手续费计算改用 Prisma.Decimal 精确运算,消除浮点误差
- 支付宝 provider 移除冗余 paramsForVerify,fetch 添加超时
- 补充 .env.example 配置文档和 CLAUDE.md 支付渠道说明
2026-03-06 13:57:52 +08:00
erio
e9e164babc chore: 添加 .gitattributes 强制 LF 行尾 2026-03-05 23:12:01 +08:00
erio
0a35ba9002 style: 全量 prettier 格式化 2026-03-05 23:10:44 +08:00
erio
ab961e669a fix: 修复 lint errors(hooks 条件调用、未转义引号、effect 内 setState) 2026-03-05 23:08:48 +08:00
eriol touwa
93a417b312 Merge pull request #4 from dexcoder6/feat/admin-dashboard
fix: 数据看板时区统一为 Asia/Shanghai + 订单列表支付方式显示修复
2026-03-05 23:03:24 +08:00
erio
ba1ce6b696 fix: CI 各 job 添加 prisma generate 步骤
Prisma 7 需要先 generate 才能生成 @prisma/client 类型,
缺少此步骤导致 typecheck/lint/test 全部失败。
2026-03-05 23:02:27 +08:00
erio
448d36fe2b feat: 移动端 H5 支付跳转 + 改进移动端检测
- PaymentQRCode: 移动端有 payUrl 时直接跳转支付,iframe 中新窗口打开
- detectDeviceIsMobile: 优先使用 navigator.userAgentData.mobile API
2026-03-05 16:21:12 +08:00
miwei
f1e3fd35ef fix: 数据看板时区统一为 Asia/Shanghai + 订单列表支付方式显示修复
- dashboard API 日期计算和 SQL GROUP BY 统一使用东八区,避免 UTC 服务器上"今日"统计偏移
- OrderTable 支付方式显示支持 stripe/wechat/alipay 三种,修复 stripe 误显为微信支付

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 10:13:57 +08:00
erio
8746f474d1 refactor: 支付宝 providerKey 改为 alipay 2026-03-05 01:52:59 +08:00
erio
55756744a1 feat: 集成支付宝电脑网站支付(alipay direct)
- 新增 src/lib/alipay/ 模块:RSA2 签名、网关客户端、AlipayProvider
- 新增 /api/alipay/notify 异步通知回调路由
- config.ts 添加 ALIPAY_* 环境变量
- payment/index.ts 注册 alipaydirect 提供商
- 27 个单元测试全部通过
2026-03-05 01:48:10 +08:00
erio
9a90a7ebb9 fix: embedded 模式下也强制 min-h-screen,防止 dark 模式底部白底 2026-03-04 21:55:43 +08:00
erio
f96f89b7bb fix: 移除 body 硬编码背景色,修复 dark 模式底部白底问题 2026-03-04 21:23:48 +08:00
erio
56bf0916e3 perf: 添加 paid_at 索引优化 dashboard 查询性能 2026-03-04 20:08:58 +08:00
eriol touwa
3380b808e2 Merge pull request #3 from dexcoder6/feat/admin-dashboard
Feat/admin dashboard
2026-03-04 20:07:49 +08:00
dexcoder6
96436f617a Merge branch 'touwaeriol:main' into feat/admin-dashboard 2026-03-04 19:06:25 +08:00
eriol touwa
d461880a9e Merge pull request #2 from dexcoder6/fix/stripe-popup-security
fix: Stripe 弹窗安全加固 + 清理未使用依赖
2026-03-04 18:11:14 +08:00
erio
69cf0d00d1 fix: 添加 packageManager 字段修复 CI pnpm 版本检测 2026-03-04 18:10:24 +08:00
miwei
3a9a32e2c2 feat: 管理后台数据看板
新增 /admin/dashboard 页面,提供充值订单统计与分析:
- 汇总统计卡片(今日/累计充值金额、订单数、成功率、平均充值)
- 每日充值趋势折线图(recharts,支持 7/30/90 天切换)
- 充值排行榜(Top 10 用户)
- 支付方式分布(水平条形图)
- 与 /admin 订单管理页面互相导航

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 17:06:27 +08:00
miwei
d7d91857c7 fix: Stripe 弹窗安全加固 + 清理未使用依赖
安全修复:
- client_secret 和 publishableKey 不再通过 URL 传递,改用 postMessage
  弹窗发送 STRIPE_POPUP_READY 信号,父页面响应 STRIPE_POPUP_INIT 传递敏感数据
  校验 event.origin 防止跨域消息伪造
- confirmAlipayPayment 改为显式调用,移除动态方法查找
- handleStripeSubmit 中 returnUrl 清理残留 query params

依赖清理:
- 移除未使用的 @stripe/react-stripe-js

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-04 15:27:51 +08:00
eriol touwa
84f38f985f Merge pull request #1 from dexcoder6/feat/stripe-embedded-popup
feat: Stripe 改用 PaymentIntent + Payment Element,iframe 嵌入支付宝弹窗支付
2026-03-04 14:43:30 +08:00
miwei
964a2aa6d9 feat: Stripe 改用 PaymentIntent + Payment Element,iframe 嵌入支付宝弹窗支付
Stripe 集成重构:
- 从 Checkout Session 改为 PaymentIntent + Payment Element 模式
- 前端内联渲染 Stripe 支付表单,支持信用卡、支付宝等多种方式
- Webhook 事件改为 payment_intent.succeeded / payment_intent.payment_failed
- provider/test 同步更新

iframe 嵌入模式 (ui_mode=embedded):
- 支付宝等需跳转的方式改为弹出新窗口处理,避免 X-Frame-Options 冲破 iframe
- 信用卡等无跳转方式仍在 iframe 内联完成
- 弹窗使用 confirmAlipayPayment 直接跳转,无需二次操作
- result 页面检测弹窗模式,支付成功后自动关闭窗口

Bug 修复:
- 修复配置加载前支付方式闪烁(初始值改为空数组 + loading)
- 修复桌面端 PaymentForm 缺少 methodLimits prop
- 修复 stripeError 隐藏表单导致无法重试
- 快捷金额增加 1000/2000 选项,过滤低于 minAmount 的选项

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-04 11:11:46 +08:00
erio
5be0616e78 feat: 支付手续费功能
- 支持提供商级别和渠道级别手续费率配置(FEE_RATE_PROVIDER_* / FEE_RATE_*)
- 用户多付手续费,到账金额不变(充值 ¥100 + 1.6% = 实付 ¥101.60)
- 前端显示手续费明细和实付金额
- 退款时按实付金额退款,余额扣减到账金额
2026-03-03 22:00:44 +08:00
erio
1a44e94bb5 docs: 集成说明补充我的订单和订单管理页面链接
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:15:43 +08:00
erio
c326c6edf1 docs: ZPay 超链接 + 明文 URL 方便复制
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 08:19:20 +08:00
erio
5992c06d67 docs: 同步英文 README,ZPay 链接明文显示,添加 release workflow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 08:17:59 +08:00
erio
90ad0e0895 docs: README 补充易支付协议说明、ZPay 推荐及免责声明
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 08:03:02 +08:00
erio
52aa484202 feat: 列表页占满宽度,充值页保持居中卡片,嵌入模式优化
- maxWidth 新增 'lg' 选项(max-w-6xl),'full' 改为无限制
- 充值页 PC 端使用 'lg',管理后台/我的订单使用 'full' 占满宽度
- 嵌入模式:减小外边距、隐藏装饰光斑、取消 min-h-screen

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 05:54:46 +08:00
erio
42da18484c feat: 管理后台订单列表展示用户备注,用户信息摊平显示
- 新增 userNotes 字段,创建订单时从 Sub2API 读取用户 notes 保存
- 管理后台订单列表将用户名、邮箱、备注拆分为独立列,节约行高

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 04:37:39 +08:00
erio
f4709b784f fix: 有 src_host 时隐藏订单页「返回充值」按钮
从 iframe 嵌入(带 src_host)时不显示返回充值按钮,避免用户跳出。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 03:55:26 +08:00
erio
880f0211f3 feat: 管理后台统一 PayPageLayout 布局,支持 dark mode
管理后台使用与充值页面相同的 PayPageLayout 组件,OrderTable 和
OrderDetail 组件新增 dark prop,所有样式支持暗色模式切换。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 03:31:20 +08:00
erio
930ce60fcc fix: 审查修复 — 来源字段长度限制、鉴权超时、支付配置启动校验
- src_host max 253, src_url max 2048
- Sub2API 鉴权请求加 5s AbortController 超时
- initPaymentProviders 启动时校验 ENABLED_PAYMENT_TYPES 与已注册 provider 一致性

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 01:56:22 +08:00
erio
8cf78dc295 fix: frame-ancestors 自动从 SUB2API_BASE_URL 推导,无需手动配置
不再依赖 IFRAME_ALLOW_ORIGINS 手动配置 Sub2API 域名,
自动从 SUB2API_BASE_URL 提取 origin 加入 CSP frame-ancestors。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 01:36:22 +08:00
erio
21cc90a71f feat: 管理后台支持 Sub2API 管理员 token 认证
保留原有 ADMIN_TOKEN 认证,同时支持传入 Sub2API 用户 token,
通过 /api/v1/auth/me 验证 role=admin 身份。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 00:41:27 +08:00
erio
c9462f4f14 feat: 管理后台订单列表显示来源域名(srcHost)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 00:19:01 +08:00
erio
d952942627 feat: 订单来源追踪,保存 src_host / src_url 到订单记录
iframe 嵌入充值页面时 URL 自动附带来源参数,写入数据库用于追踪分析。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 20:40:16 +08:00
erio
c083880cbc docs+feat: 完善 README 帮助内容配置说明,支持多行文字
- README (中/英) 修正 NEXT_PUBLIC_PAY_HELP_* → PAY_HELP_*
- 新增 PAYMENT_PROVIDERS 配置说明(两步配置服务商+渠道)
- 说明帮助图片支持外部 URL 或本地 uploads/ 两种方式
- PAY_HELP_TEXT 支持 \n 换行,渲染为多行段落
2026-03-02 04:17:51 +08:00
erio
a9ea9d4862 feat: 帮助图片点击放大(lightbox)
点击支付页右侧帮助区域的联系二维码图片,在屏幕正中以全屏遮罩放大展示;
点击背景或再次点击可关闭。
2026-03-02 03:39:49 +08:00
erio
e170d5451e fix: 帮助内容改为服务端变量经 API 下发,运行时可配无需重新构建 2026-03-02 02:46:51 +08:00
erio
e5424e6c5e feat: 显式 PAYMENT_PROVIDERS 配置服务商,缺密钥启动即报错 2026-03-02 02:04:53 +08:00
160 changed files with 17697 additions and 1681 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防止重复分配

86
.env.example Normal file
View File

@@ -0,0 +1,86 @@
# 数据库
DATABASE_URL="postgresql://sub2apipay:password@localhost:5432/sub2apipay"
# Sub2API
SUB2API_BASE_URL="https://your-sub2api-domain.com"
SUB2API_ADMIN_API_KEY="your-admin-api-key"
# ── 支付服务商(逗号分隔,决定加载哪些服务商) ───────────────────────────────
# 可选值: easypay, alipay, wxpay, stripe
# 示例(仅易支付): PAYMENT_PROVIDERS=easypay
# 示例(仅 Stripe: PAYMENT_PROVIDERS=stripe
# 示例(支付宝+微信直连): PAYMENT_PROVIDERS=alipay,wxpay
# 示例(全部启用): PAYMENT_PROVIDERS=easypay,alipay,wxpay,stripe
PAYMENT_PROVIDERS=easypay
# ── 易支付配置PAYMENT_PROVIDERS 含 easypay 时必填) ────────────────────────
EASY_PAY_PID="your-pid"
EASY_PAY_PKEY="your-pkey"
EASY_PAY_API_BASE="https://zpayz.cn"
EASY_PAY_NOTIFY_URL="https://pay.example.com/api/easy-pay/notify"
EASY_PAY_RETURN_URL="https://pay.example.com/pay/result"
# 渠道 ID部分易支付平台需要可选
#EASY_PAY_CID_ALIPAY=""
#EASY_PAY_CID_WXPAY=""
# ── Stripe 配置PAYMENT_PROVIDERS 含 stripe 时必填) ────────────────────────
#STRIPE_SECRET_KEY="sk_live_..."
#STRIPE_PUBLISHABLE_KEY="pk_live_..."
#STRIPE_WEBHOOK_SECRET="whsec_..."
# ── 支付宝直连PAYMENT_PROVIDERS 含 alipay 时必填) ────────────────────
# 不在 PAYMENT_PROVIDERS 中配置 alipay 则不启用支付宝直连
# ALIPAY_APP_ID=
# ALIPAY_PRIVATE_KEY= # PKCS8 格式私钥(不含 -----BEGIN/END----- 头尾)
# ALIPAY_PUBLIC_KEY= # 支付宝公钥(非应用公钥,从开放平台获取)
# ALIPAY_NOTIFY_URL=https://pay.example.com/api/alipay/notify
# ALIPAY_RETURN_URL=https://pay.example.com/pay/result
# ── 微信支付直连PAYMENT_PROVIDERS 含 wxpay 时必填) ────────────────────
# 前端自动检测设备类型PC 端扫码支付,移动端 H5 跳转微信 APP 支付
# 不在 PAYMENT_PROVIDERS 中配置 wxpay 则不启用微信支付
# WXPAY_APP_ID= # 公众号或移动应用 AppID
# WXPAY_MCH_ID= # 商户号10位数字
# WXPAY_PRIVATE_KEY= # 商户 API 私钥 PEM含 -----BEGIN/END----- 头尾)
# WXPAY_CERT_SERIAL= # 商户 API 证书序列号40位十六进制
# WXPAY_API_V3_KEY= # APIv3 密钥32位字符串
# WXPAY_PUBLIC_KEY= # 微信支付公钥 PEM从商户平台下载
# WXPAY_PUBLIC_KEY_ID= # 微信支付公钥 ID
# WXPAY_NOTIFY_URL=https://pay.example.com/api/wxpay/notify
# ── 启用的支付渠道(必须显式配置,未列出的渠道不会展示给用户) ─────────────
# 可选值: alipay, wxpay, stripe
# 默认值为空 = 不启用任何渠道,必须手动配置
ENABLED_PAYMENT_TYPES="alipay,wxpay"
# ── 订单配置 ──────────────────────────────────────────────────────────────────
ORDER_TIMEOUT_MINUTES="5"
MIN_RECHARGE_AMOUNT="1"
MAX_RECHARGE_AMOUNT="10000"
# 每用户每日累计充值上限0 = 不限制
MAX_DAILY_RECHARGE_AMOUNT="0"
# 各渠道全平台每日总限额0 = 不限制(未设置则使用各服务商默认值)
#MAX_DAILY_AMOUNT_ALIPAY="0"
#MAX_DAILY_AMOUNT_WXPAY="0"
#MAX_DAILY_AMOUNT_STRIPE="0"
PRODUCT_NAME="Sub2API 余额充值"
# ── 手续费(百分比,可选) ─────────────────────────────────────────────────────
# 提供商级别(应用于该提供商下所有渠道)
#FEE_RATE_PROVIDER_EASYPAY=1.6
#FEE_RATE_PROVIDER_STRIPE=5.9
# 渠道级别(覆盖提供商级别)
#FEE_RATE_ALIPAY=
#FEE_RATE_WXPAY=
#FEE_RATE_STRIPE=
# ── 管理员 ────────────────────────────────────────────────────────────────────
ADMIN_TOKEN="your-admin-token"
# ── 应用 ──────────────────────────────────────────────────────────────────────
NEXT_PUBLIC_APP_URL="https://pay.example.com"
# iframe 允许嵌入的域名(逗号分隔)
IFRAME_ALLOW_ORIGINS="https://example.com"
# 充值页面底部帮助内容(可选)
#PAY_HELP_IMAGE_URL="https://example.com/qrcode.png"
#PAY_HELP_TEXT="如需帮助请联系客服微信xxxxx"

1
.gitattributes vendored Normal file
View File

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

View File

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

79
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,79 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
release:
name: Create Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog
id: changelog
run: |
# Get previous tag
PREV_TAG=$(git tag --sort=-v:refname | sed -n '2p')
if [ -z "$PREV_TAG" ]; then
COMMITS=$(git log --pretty=format:"- %s (%h)" HEAD)
else
COMMITS=$(git log --pretty=format:"- %s (%h)" "${PREV_TAG}..HEAD")
fi
{
echo 'body<<EOF'
echo "## What's Changed"
echo ""
echo "$COMMITS"
echo ""
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV_TAG:-$(git rev-list --max-parents=0 HEAD | head -1)}...${{ github.ref_name }}"
echo 'EOF'
} >> "$GITHUB_OUTPUT"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
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

1
.idea/vcs.xml generated
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

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,35 +14,46 @@ 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>
---
## Tech Stack
| Category | Technology |
|----------|------------|
| Framework | Next.js 16 (App Router) |
| Language | TypeScript 5 + React 19 |
| Styling | TailwindCSS 4 |
| ORM | Prisma 7 (adapter-pg mode) |
| Database | PostgreSQL 16 |
| Container | Docker + Docker Compose |
| Package Manager | pnpm |
| Category | Technology |
| --------------- | -------------------------- |
| Framework | Next.js 16 (App Router) |
| Language | TypeScript 5 + React 19 |
| Styling | TailwindCSS 4 |
| ORM | Prisma 7 (adapter-pg mode) |
| Database | PostgreSQL 16 |
| Container | Docker + Docker Compose |
| Package Manager | pnpm |
---
@@ -85,68 +96,129 @@ See [`.env.example`](./.env.example) for the full template.
### Core (Required)
| Variable | Description |
|----------|-------------|
| `SUB2API_BASE_URL` | Sub2API service URL, e.g. `https://sub2api.com` |
| `SUB2API_ADMIN_API_KEY` | Sub2API admin API key |
| `ADMIN_TOKEN` | Admin panel access token (use a strong random string) |
| `NEXT_PUBLIC_APP_URL` | Public URL of this service, e.g. `https://pay.example.com` |
| Variable | Description |
| ----------------------- | ---------------------------------------------------------- |
| `SUB2API_BASE_URL` | Sub2API service URL, e.g. `https://sub2api.com` |
| `SUB2API_ADMIN_API_KEY` | Sub2API admin API key |
| `ADMIN_TOKEN` | Admin panel access token (use a strong random string) |
| `NEXT_PUBLIC_APP_URL` | Public URL of this service, e.g. `https://pay.example.com` |
> `DATABASE_URL` is automatically injected by Docker Compose when using the bundled database.
### Payment Methods
### Payment Providers & Methods
Control which payment methods are enabled via `ENABLED_PAYMENT_TYPES` (comma-separated):
**Step 1**: Declare which payment providers to load via `PAYMENT_PROVIDERS` (comma-separated):
```env
ENABLED_PAYMENT_TYPES=alipay,wxpay,stripe
# Available: easypay, alipay, wxpay, stripe
# Example: EasyPay only
PAYMENT_PROVIDERS=easypay
# Example: Alipay + WeChat Pay + Stripe (official channels)
PAYMENT_PROVIDERS=alipay,wxpay,stripe
```
#### EasyPay (Alipay / WeChat Pay)
> **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.
| Variable | Description |
|----------|-------------|
| `EASY_PAY_PID` | EasyPay merchant ID |
| `EASY_PAY_PKEY` | EasyPay merchant secret key |
| `EASY_PAY_API_BASE` | EasyPay API base URL |
#### EasyPay (Alipay / WeChat Pay Aggregation)
Any payment provider compatible with the **EasyPay protocol** can be used.
| Variable | Description |
| --------------------- | ---------------------------------------------------------------- |
| `EASY_PAY_PID` | EasyPay merchant ID |
| `EASY_PAY_PKEY` | EasyPay merchant secret key |
| `EASY_PAY_API_BASE` | EasyPay API base URL |
| `EASY_PAY_NOTIFY_URL` | Async callback URL: `${NEXT_PUBLIC_APP_URL}/api/easy-pay/notify` |
| `EASY_PAY_RETURN_URL` | Redirect URL after payment: `${NEXT_PUBLIC_APP_URL}/pay` |
| `EASY_PAY_CID_ALIPAY` | Alipay channel ID (optional) |
| `EASY_PAY_CID_WXPAY` | WeChat Pay channel ID (optional) |
| `EASY_PAY_RETURN_URL` | Redirect URL after payment: `${NEXT_PUBLIC_APP_URL}/pay/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 |
|----------|-------------|
| `STRIPE_SECRET_KEY` | Stripe secret key (`sk_live_...`) |
| `STRIPE_PUBLISHABLE_KEY` | Stripe publishable key (`pk_live_...`) |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret (`whsec_...`) |
| Variable | Description |
| ------------------------ | ------------------------------------------- |
| `STRIPE_SECRET_KEY` | Stripe secret key (`sk_live_...`) |
| `STRIPE_PUBLISHABLE_KEY` | Stripe publishable key (`pk_live_...`) |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret (`whsec_...`) |
> Stripe webhook endpoint: `${NEXT_PUBLIC_APP_URL}/api/stripe/webhook`
> Subscribe to: `checkout.session.completed`, `checkout.session.expired`
> Subscribe to: `payment_intent.succeeded`, `payment_intent.payment_failed`
### Business Rules
| Variable | Description | Default |
|----------|-------------|---------|
| `MIN_RECHARGE_AMOUNT` | Minimum amount per transaction (CNY) | `1` |
| `MAX_RECHARGE_AMOUNT` | Maximum amount per transaction (CNY) | `1000` |
| `MAX_DAILY_RECHARGE_AMOUNT` | Daily cumulative max per user (`0` = unlimited) | `10000` |
| `ORDER_TIMEOUT_MINUTES` | Order expiry in minutes | `5` |
| `PRODUCT_NAME` | Product name shown on payment page | `Sub2API Balance Recharge` |
| Variable | Description | Default |
| -------------------------------- | ------------------------------------------------- | -------------------------- |
| `MIN_RECHARGE_AMOUNT` | Minimum amount per transaction (CNY) | `1` |
| `MAX_RECHARGE_AMOUNT` | Maximum amount per transaction (CNY) | `1000` |
| `MAX_DAILY_RECHARGE_AMOUNT` | Daily cumulative max per user (`0` = unlimited) | `10000` |
| `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)
| Variable | Description |
|----------|-------------|
| `NEXT_PUBLIC_PAY_HELP_IMAGE_URL` | Help image URL (e.g. customer service QR code) |
| `NEXT_PUBLIC_PAY_HELP_TEXT` | Help text displayed on payment page |
Display a support contact image and description on the right side of the payment page.
| Variable | Description |
| -------------------- | ------------------------------------------------------------------------------- |
| `PAY_HELP_IMAGE_URL` | Help image URL — external URL or local path (see below) |
| `PAY_HELP_TEXT` | Help text; use `\n` for line breaks, e.g. `Scan to add WeChat\nMonFri 9am6pm` |
**Two ways to provide the image:**
- **External URL** (recommended — no Compose changes needed): any publicly accessible image link (CDN, OSS, image hosting).
```env
PAY_HELP_IMAGE_URL=https://cdn.example.com/help-qr.jpg
```
- **Local file**: place the image in `./uploads/` and reference it as `/uploads/<filename>`.
The directory must be mounted in `docker-compose.app.yml` (included by default):
```yaml
volumes:
- ./uploads:/app/public/uploads:ro
```
```env
PAY_HELP_IMAGE_URL=/uploads/help-qr.jpg
```
> Clicking the help image opens it full-screen in the center of the screen.
### Docker Compose Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `APP_PORT` | Host port mapping | `3001` |
| Variable | Description | Default |
| ------------- | -------------------------------- | ------------------------------------- |
| `APP_PORT` | Host port mapping | `3001` |
| `DB_PASSWORD` | PostgreSQL password (bundled DB) | `password` (**change in production**) |
---
@@ -220,61 +292,131 @@ docker compose exec app npx prisma migrate deploy
## Sub2API Integration
Configure the recharge URL in the Sub2API admin panel:
Assuming this service is deployed at `https://pay.example.com`.
```
https://pay.example.com/pay?user_id={USER_ID}&token={TOKEN}&theme={THEME}
```
### User-Facing Pages
| Parameter | Description |
|-----------|-------------|
| `user_id` | Sub2API user ID (required) |
| `token` | User login token (optional — required to view order history) |
| `theme` | `light` (default) or `dark` |
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:
| Parameter | Description |
| --------- | ------------------------------------------------- |
| `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 → Checkout Session
├─ 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

304
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,35 +14,46 @@ 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>
---
## 技术栈
| 类别 | 技术 |
|------|------|
| 框架 | Next.js 16 (App Router) |
| 语言 | TypeScript 5 + React 19 |
| 样式 | TailwindCSS 4 |
| ORM | Prisma 7adapter-pg 模式) |
| 数据库 | PostgreSQL 16 |
| 容器 | Docker + Docker Compose |
| 包管理 | pnpm |
| 类别 | 技术 |
| ------ | --------------------------- |
| 框架 | Next.js 16 (App Router) |
| 语言 | TypeScript 5 + React 19 |
| 样式 | TailwindCSS 4 |
| ORM | Prisma 7adapter-pg 模式) |
| 数据库 | PostgreSQL 16 |
| 容器 | Docker + Docker Compose |
| 包管理 | pnpm |
---
@@ -85,68 +96,129 @@ docker compose up -d --build
### 核心(必填)
| 变量 | 说明 |
|------|------|
| `SUB2API_BASE_URL` | Sub2API 服务地址,如 `https://sub2api.com` |
| `SUB2API_ADMIN_API_KEY` | Sub2API 管理 API 密钥 |
| `ADMIN_TOKEN` | 管理后台访问令牌(自定义强密码) |
| `NEXT_PUBLIC_APP_URL` | 本服务的公网地址,如 `https://pay.example.com` |
| 变量 | 说明 |
| ----------------------- | ---------------------------------------------- |
| `SUB2API_BASE_URL` | Sub2API 服务地址,如 `https://sub2api.com` |
| `SUB2API_ADMIN_API_KEY` | Sub2API 管理 API 密钥 |
| `ADMIN_TOKEN` | 管理后台访问令牌(自定义强密码) |
| `NEXT_PUBLIC_APP_URL` | 本服务的公网地址,如 `https://pay.example.com` |
> `DATABASE_URL` 使用自带数据库时由 Compose 自动注入,无需手动填写。
### 支付方式
### 支付服务商与支付方式
通过 `ENABLED_PAYMENT_TYPES` 控制开启哪些支付方式(逗号分隔):
**第一步**:通过 `PAYMENT_PROVIDERS` 声明启用哪些支付服务商(逗号分隔):
```env
ENABLED_PAYMENT_TYPES=alipay,wxpay,stripe
# 可选值: easypay, alipay, wxpay, stripe
# 示例:仅使用 EasyPay 易支付聚合
PAYMENT_PROVIDERS=easypay
# 示例:同时启用支付宝官方 + 微信官方 + Stripe
PAYMENT_PROVIDERS=alipay,wxpay,stripe
```
#### EasyPay支付宝 / 微信支付)
> **支付宝官方 / 微信官方**与 **EasyPay** 可以共存。官方渠道直接对接支付宝/微信 API资金直达商户账户手续费更低EasyPay 通过第三方平台代收/转发官方,接入门槛更低。使用 EasyPay 时请尽量选择资金直接走转发官方直达自己账户的形式,而非第三方代收的服务商。
| 变量 | 说明 |
|------|------|
| `EASY_PAY_PID` | EasyPay 商户 ID |
| `EASY_PAY_PKEY` | EasyPay 商户密钥 |
| `EASY_PAY_API_BASE` | EasyPay API 地址 |
#### EasyPay支付宝 / 微信支付聚合)
任何兼容**易支付EasyPay协议**的支付服务商均可接入。
| 变量 | 说明 |
| --------------------- | ------------------------------------------------------------- |
| `EASY_PAY_PID` | EasyPay 商户 ID |
| `EASY_PAY_PKEY` | EasyPay 商户密钥 |
| `EASY_PAY_API_BASE` | EasyPay API 地址 |
| `EASY_PAY_NOTIFY_URL` | 异步回调地址,填 `${NEXT_PUBLIC_APP_URL}/api/easy-pay/notify` |
| `EASY_PAY_RETURN_URL` | 支付完成跳转地址,填 `${NEXT_PUBLIC_APP_URL}/pay` |
| `EASY_PAY_CID_ALIPAY` | 支付宝通道 ID可选 |
| `EASY_PAY_CID_WXPAY` | 微信支付通道 ID可选 |
| `EASY_PAY_RETURN_URL` | 支付完成跳转地址,填 `${NEXT_PUBLIC_APP_URL}/pay/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
| 变量 | 说明 |
|------|------|
| `STRIPE_SECRET_KEY` | Stripe 密钥(`sk_live_...` |
| `STRIPE_PUBLISHABLE_KEY` | Stripe 可公开密钥(`pk_live_...` |
| `STRIPE_WEBHOOK_SECRET` | Stripe Webhook 签名密钥(`whsec_...` |
| 变量 | 说明 |
| ------------------------ | -------------------------------------- |
| `STRIPE_SECRET_KEY` | Stripe 密钥(`sk_live_...` |
| `STRIPE_PUBLISHABLE_KEY` | Stripe 可公开密钥(`pk_live_...` |
| `STRIPE_WEBHOOK_SECRET` | Stripe Webhook 签名密钥(`whsec_...` |
> Stripe Webhook 端点:`${NEXT_PUBLIC_APP_URL}/api/stripe/webhook`
> 需订阅事件:`checkout.session.completed`、`checkout.session.expired`
> 需订阅事件:`payment_intent.succeeded`、`payment_intent.payment_failed`
### 业务规则
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `MIN_RECHARGE_AMOUNT` | 单笔最低充值金额(元) | `1` |
| `MAX_RECHARGE_AMOUNT` | 单笔最高充值金额(元) | `1000` |
| `MAX_DAILY_RECHARGE_AMOUNT` | 每日累计最高充值(元,`0` = 不限) | `10000` |
| `ORDER_TIMEOUT_MINUTES` | 订单超时分钟数 | `5` |
| `PRODUCT_NAME` | 充值商品名称(显示在支付页) | `Sub2API Balance Recharge` |
| 变量 | 说明 | 默认值 |
| -------------------------------- | ---------------------------------------- | -------------------------- |
| `MIN_RECHARGE_AMOUNT` | 单笔最低充值金额(元) | `1` |
| `MAX_RECHARGE_AMOUNT` | 单笔最高充值金额(元) | `1000` |
| `MAX_DAILY_RECHARGE_AMOUNT` | 每日每用户累计最高充值(元,`0` = 不限) | `10000` |
| `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 定制(可选)
| 变量 | 说明 |
|------|------|
| `NEXT_PUBLIC_PAY_HELP_IMAGE_URL` | 帮助图片 URL如客服二维码 |
| `NEXT_PUBLIC_PAY_HELP_TEXT` | 帮助说明文字 |
在充值页面右侧可展示客服联系方式、说明图片等帮助内容。
| 变量 | 说明 |
| -------------------- | --------------------------------------------------------------- |
| `PAY_HELP_IMAGE_URL` | 帮助图片地址(支持外部 URL 或本地路径,见下方说明) |
| `PAY_HELP_TEXT` | 帮助说明文字,用 `\n` 换行,如 `扫码加微信\n工作日 9-18 点在线` |
**图片地址两种方式:**
- **外部 URL**(推荐,无需改 Compose 配置):直接填图片的公网地址,如 OSS / CDN / 图床链接。
```env
PAY_HELP_IMAGE_URL=https://cdn.example.com/help-qr.jpg
```
- **本地文件**:将图片放到 `./uploads/` 目录,通过 `/uploads/文件名` 引用。
需在 `docker-compose.app.yml` 中挂载目录(默认已包含):
```yaml
volumes:
- ./uploads:/app/public/uploads:ro
```
```env
PAY_HELP_IMAGE_URL=/uploads/help-qr.jpg
```
> 点击帮助图片可在屏幕中央全屏放大查看。
### Docker Compose 专用
| 变量 | 说明 | 默认值 |
|------|------|--------|
| `APP_PORT` | 宿主机映射端口 | `3001` |
| 变量 | 说明 | 默认值 |
| ------------- | ----------------------------------- | ---------------------------- |
| `APP_PORT` | 宿主机映射端口 | `3001` |
| `DB_PASSWORD` | PostgreSQL 密码(使用自带数据库时) | `password`**生产请修改** |
---
@@ -220,61 +292,131 @@ docker compose exec app npx prisma migrate deploy
## 集成到 Sub2API
在 Sub2API 管理后台将充值链接配置为:
假设本服务部署在 `https://pay.example.com`。
```
https://pay.example.com/pay?user_id={USER_ID}&token={TOKEN}&theme={THEME}
```
### 用户端页面
| 参数 | 说明 |
|------|------|
| `user_id` | Sub2API 用户 ID必填 |
| `token` | 用户登录 Token可选有 token 才能查看订单历史) |
| `theme` | `light`(默认)或 `dark` |
在 Sub2API 管理后台的**充值设置**中配置以下链接,用户即可从 Sub2API 平台跳转到充值和订单页面:
| 配置项 | URL | 说明 |
| -------- | ------------------------------------ | --------------------------- |
| 充值页面 | `https://pay.example.com/pay` | 用户充值、购买订阅套餐入口 |
| 我的订单 | `https://pay.example.com/pay/orders` | 用户查看自己的充值/订阅记录 |
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 → Checkout Session
├─ 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 用户 |
---
## 开发指南
### 环境要求

View File

@@ -12,4 +12,7 @@ services:
ports:
- '${APP_PORT:-3001}:3000'
env_file: .env
volumes:
# 宿主机 uploads 目录挂载到 Next.js public/uploads可通过 /uploads/* 访问
- ./uploads:/app/public/uploads:ro
restart: unless-stopped

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

BIN
docs/zpay-preview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

View File

@@ -2,6 +2,7 @@ import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
output: 'standalone',
serverExternalPackages: ['wechatpay-node-v3'],
};
export default nextConfig;

View File

@@ -2,6 +2,7 @@
"name": "sub2apipay",
"version": "0.1.0",
"private": true,
"packageManager": "pnpm@10.30.3",
"scripts": {
"dev": "next dev",
"build": "next build",
@@ -16,12 +17,15 @@
"dependencies": {
"@prisma/adapter-pg": "7.4.1",
"@prisma/client": "^7.4.2",
"@stripe/stripe-js": "^8.9.0",
"next": "16.1.6",
"pg": "^8.19.0",
"qrcode": "^1.5.4",
"react": "19.2.3",
"react-dom": "19.2.3",
"recharts": "^3.7.0",
"stripe": "^20.4.0",
"wechatpay-node-v3": "^2.2.1",
"zod": "^4.3.6"
},
"pnpm": {

499
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@prisma/client':
specifier: ^7.4.2
version: 7.4.2(prisma@7.4.1(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3))(typescript@5.9.3)
'@stripe/stripe-js':
specifier: ^8.9.0
version: 8.9.0
next:
specifier: 16.1.6
version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -29,9 +32,15 @@ importers:
react-dom:
specifier: 19.2.3
version: 19.2.3(react@19.2.3)
recharts:
specifier: ^3.7.0
version: 3.7.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1)
stripe:
specifier: ^20.4.0
version: 20.4.0(@types/node@20.19.35)
wechatpay-node-v3:
specifier: ^2.2.1
version: 2.2.1
zod:
specifier: ^4.3.6
version: 4.3.6
@@ -397,6 +406,14 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@fidm/asn1@1.0.4':
resolution: {integrity: sha512-esd1jyNvRb2HVaQGq2Gg8Z0kbQPXzV9Tq5Z14KNIov6KfFD6PTaRIO8UpcsYiTNzOqJpmyzWgVTrUwFV3UF4TQ==}
engines: {node: '>= 8'}
'@fidm/x509@1.2.1':
resolution: {integrity: sha512-nwc2iesjyc9hkuzcrMCBXQRn653XuAUKorfWM8PZyJawiy1QzLj4vahwzaI25+pfpwOLvMzbJ0uKpWLDNmo16w==}
engines: {node: '>= 8'}
'@hono/node-server@1.19.9':
resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==}
engines: {node: '>=18.14.1'}
@@ -653,6 +670,10 @@ packages:
cpu: [x64]
os: [win32]
'@noble/hashes@1.8.0':
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -669,6 +690,9 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
'@prisma/adapter-pg@7.4.1':
resolution: {integrity: sha512-AH9XrqvSoBAaStn0Gm/sAnF97pDKz8uLpNmn51j1S9O9dhUva6LIxGdoDiiU9VXRIR89wAJXsvJSy+mK40m2xw==}
@@ -727,6 +751,17 @@ packages:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
'@reduxjs/toolkit@2.11.2':
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
peerDependencies:
react: ^16.9.0 || ^17.0.0 || ^18 || ^19
react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
peerDependenciesMeta:
react:
optional: true
react-redux:
optional: true
'@rolldown/pluginutils@1.0.0-rc.3':
resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==}
@@ -874,6 +909,13 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@stripe/stripe-js@8.9.0':
resolution: {integrity: sha512-OJkXvUI5GAc56QdiSRimQDvWYEqn475J+oj8RzRtFTCPtkJNO2TWW619oDY+nn1ExR+2tCVTQuRQBbR4dRugww==}
engines: {node: '>=12.16'}
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -987,6 +1029,33 @@ packages:
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/d3-array@3.2.2':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-shape@3.1.8':
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
@@ -1016,6 +1085,9 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
'@types/use-sync-external-store@0.0.6':
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
'@typescript-eslint/eslint-plugin@8.56.1':
resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1273,6 +1345,9 @@ packages:
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
engines: {node: '>= 0.4'}
asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@@ -1284,6 +1359,9 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'}
@@ -1386,6 +1464,10 @@ packages:
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -1393,6 +1475,13 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
component-emitter@1.3.1:
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -1406,6 +1495,9 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cookiejar@2.1.4:
resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -1413,6 +1505,50 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-format@3.1.2:
resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
@@ -1449,6 +1585,9 @@ packages:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -1467,6 +1606,10 @@ packages:
defu@6.1.4:
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
denque@2.1.0:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'}
@@ -1478,6 +1621,9 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
dezalgo@1.0.4:
resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
@@ -1548,6 +1694,9 @@ packages:
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
engines: {node: '>= 0.4'}
es-toolkit@1.45.0:
resolution: {integrity: sha512-RArCX+Zea16+R1jg4mH223Z8p/ivbJjIkU3oC6ld2bdUfmDxiCkFYSi9zLOR2anucWJUeH4Djnzgd0im0nD3dw==}
esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
@@ -1684,6 +1833,9 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
eventemitter3@5.0.4:
resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@@ -1708,6 +1860,9 @@ packages:
fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fast-safe-stringify@2.1.1:
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
@@ -1751,6 +1906,13 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
form-data@4.0.5:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
formidable@2.1.5:
resolution: {integrity: sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1888,6 +2050,12 @@ packages:
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
engines: {node: '>= 4'}
immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
immer@11.1.4:
resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@@ -1900,6 +2068,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
is-array-buffer@3.0.5:
resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==}
engines: {node: '>= 0.4'}
@@ -2189,10 +2361,27 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
methods@1.1.2:
resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
engines: {node: '>= 0.6'}
micromatch@4.0.8:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
mime@2.6.0:
resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
engines: {node: '>=4.0.0'}
hasBin: true
minimatch@10.2.4:
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
engines: {node: 18 || 20 || >=22}
@@ -2301,6 +2490,9 @@ packages:
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -2478,6 +2670,10 @@ packages:
engines: {node: '>=10.13.0'}
hasBin: true
qs@6.15.0:
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
engines: {node: '>=0.6'}
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -2492,6 +2688,18 @@ packages:
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
react-redux@9.2.0:
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
peerDependencies:
'@types/react': ^18.2.25 || ^19
react: ^18.0 || ^19
redux: ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
redux:
optional: true
react-refresh@0.18.0:
resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==}
engines: {node: '>=0.10.0'}
@@ -2504,6 +2712,22 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
recharts@3.7.0:
resolution: {integrity: sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==}
engines: {node: '>=18'}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
redux: ^5.0.0
redux@5.0.1:
resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -2525,6 +2749,9 @@ packages:
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
reselect@5.1.1:
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -2727,6 +2954,11 @@ packages:
babel-plugin-macros:
optional: true
superagent@8.0.6:
resolution: {integrity: sha512-HqSe6DSIh3hEn6cJvCkaM1BLi466f1LHi4yubR0tpewlMpk4RUFFy35bKz8SsPBwYfIIJy5eclp+3tCYAuX0bw==}
engines: {node: '>=6.4.0 <13 || >=14'}
deprecated: Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -2742,6 +2974,9 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -2773,6 +3008,9 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tweetnacl@1.0.3:
resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -2824,6 +3062,11 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
valibot@1.2.0:
resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==}
peerDependencies:
@@ -2832,6 +3075,9 @@ packages:
typescript:
optional: true
victory-vendor@37.3.6:
resolution: {integrity: sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==}
vite@7.3.1:
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -2906,6 +3152,9 @@ packages:
jsdom:
optional: true
wechatpay-node-v3@2.2.1:
resolution: {integrity: sha512-z+n8Mrzn0UNoLJPBRrY8ZG6yo9xxNihlGvwvAbV8Nlnm4tTap2UjwIikGkhryC8gOmwrlvJfSUd+x1cK3ks1hA==}
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -2943,6 +3192,9 @@ packages:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
@@ -3258,6 +3510,13 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
'@fidm/asn1@1.0.4': {}
'@fidm/x509@1.2.1':
dependencies:
'@fidm/asn1': 1.0.4
tweetnacl: 1.0.3
'@hono/node-server@1.19.9(hono@4.11.4)':
dependencies:
hono: 4.11.4
@@ -3431,6 +3690,8 @@ snapshots:
'@next/swc-win32-x64-msvc@16.1.6':
optional: true
'@noble/hashes@1.8.0': {}
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -3445,6 +3706,10 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@paralleldrive/cuid2@2.3.1':
dependencies:
'@noble/hashes': 1.8.0
'@prisma/adapter-pg@7.4.1':
dependencies:
'@prisma/driver-adapter-utils': 7.4.1
@@ -3532,6 +3797,18 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1))(react@19.2.3)':
dependencies:
'@standard-schema/spec': 1.1.0
'@standard-schema/utils': 0.3.0
immer: 11.1.4
redux: 5.0.1
redux-thunk: 3.1.0(redux@5.0.1)
reselect: 5.1.1
optionalDependencies:
react: 19.2.3
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1)
'@rolldown/pluginutils@1.0.0-rc.3': {}
'@rollup/rollup-android-arm-eabi@4.59.0':
@@ -3613,6 +3890,10 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
'@standard-schema/utils@0.3.0': {}
'@stripe/stripe-js@8.9.0': {}
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@@ -3717,6 +3998,30 @@ snapshots:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/d3-array@3.2.2': {}
'@types/d3-color@3.1.3': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-shape@3.1.8':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/deep-eql@4.0.2': {}
'@types/estree@1.0.8': {}
@@ -3747,6 +4052,8 @@ snapshots:
dependencies:
csstype: 3.2.3
'@types/use-sync-external-store@0.0.6': {}
'@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -4038,12 +4345,16 @@ snapshots:
get-intrinsic: 1.3.0
is-array-buffer: 3.0.5
asap@2.0.6: {}
assertion-error@2.0.1: {}
ast-types-flow@0.0.8: {}
async-function@1.0.0: {}
asynckit@0.4.0: {}
available-typed-arrays@1.0.7:
dependencies:
possible-typed-array-names: 1.1.0
@@ -4153,12 +4464,20 @@ snapshots:
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
clsx@2.1.1: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
component-emitter@1.3.1: {}
concat-map@0.0.1: {}
confbox@0.2.4: {}
@@ -4167,6 +4486,8 @@ snapshots:
convert-source-map@2.0.0: {}
cookiejar@2.1.4: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -4175,6 +4496,44 @@ snapshots:
csstype@3.2.3: {}
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-ease@3.0.1: {}
d3-format@3.1.2: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.2
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
damerau-levenshtein@1.0.8: {}
data-view-buffer@1.0.2:
@@ -4205,6 +4564,8 @@ snapshots:
decamelize@1.2.0: {}
decimal.js-light@2.5.1: {}
deep-is@0.1.4: {}
deepmerge-ts@7.1.5: {}
@@ -4223,12 +4584,19 @@ snapshots:
defu@6.1.4: {}
delayed-stream@1.0.0: {}
denque@2.1.0: {}
destr@2.0.5: {}
detect-libc@2.1.2: {}
dezalgo@1.0.4:
dependencies:
asap: 2.0.6
wrappy: 1.0.2
dijkstrajs@1.0.3: {}
doctrine@2.1.0:
@@ -4364,6 +4732,8 @@ snapshots:
is-date-object: 1.1.0
is-symbol: 1.1.1
es-toolkit@1.45.0: {}
esbuild@0.27.3:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3
@@ -4606,6 +4976,8 @@ snapshots:
esutils@2.0.3: {}
eventemitter3@5.0.4: {}
expect-type@1.3.0: {}
exsolve@1.0.8: {}
@@ -4628,6 +5000,8 @@ snapshots:
fast-levenshtein@2.0.6: {}
fast-safe-stringify@2.1.1: {}
fastq@1.20.1:
dependencies:
reusify: 1.1.0
@@ -4670,6 +5044,21 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
form-data@4.0.5:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
mime-types: 2.1.35
formidable@2.1.5:
dependencies:
'@paralleldrive/cuid2': 2.3.1
dezalgo: 1.0.4
once: 1.4.0
qs: 6.15.0
fsevents@2.3.3:
optional: true
@@ -4800,6 +5189,10 @@ snapshots:
ignore@7.0.5: {}
immer@10.2.0: {}
immer@11.1.4: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@@ -4813,6 +5206,8 @@ snapshots:
hasown: 2.0.2
side-channel: 1.1.0
internmap@2.0.3: {}
is-array-buffer@3.0.5:
dependencies:
call-bind: 1.0.8
@@ -5069,11 +5464,21 @@ snapshots:
merge2@1.4.1: {}
methods@1.1.2: {}
micromatch@4.0.8:
dependencies:
braces: 3.0.3
picomatch: 2.3.1
mime-db@1.52.0: {}
mime-types@2.1.35:
dependencies:
mime-db: 1.52.0
mime@2.6.0: {}
minimatch@10.2.4:
dependencies:
brace-expansion: 5.0.4
@@ -5195,6 +5600,10 @@ snapshots:
ohash@2.0.11: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -5361,6 +5770,10 @@ snapshots:
pngjs: 5.0.0
yargs: 15.4.1
qs@6.15.0:
dependencies:
side-channel: 1.1.0
queue-microtask@1.2.3: {}
rc9@2.1.2:
@@ -5375,12 +5788,47 @@ snapshots:
react-is@16.13.1: {}
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1):
dependencies:
'@types/use-sync-external-store': 0.0.6
react: 19.2.3
use-sync-external-store: 1.6.0(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.14
redux: 5.0.1
react-refresh@0.18.0: {}
react@19.2.3: {}
readdirp@4.1.2: {}
recharts@3.7.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react-is@16.13.1)(react@19.2.3)(redux@5.0.1):
dependencies:
'@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1))(react@19.2.3)
clsx: 2.1.1
decimal.js-light: 2.5.1
es-toolkit: 1.45.0
eventemitter3: 5.0.4
immer: 10.2.0
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
react-is: 16.13.1
react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.3)(redux@5.0.1)
reselect: 5.1.1
tiny-invariant: 1.3.3
use-sync-external-store: 1.6.0(react@19.2.3)
victory-vendor: 37.3.6
transitivePeerDependencies:
- '@types/react'
- redux
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
redux@5.0.1: {}
reflect.getprototypeof@1.0.10:
dependencies:
call-bind: 1.0.8
@@ -5409,6 +5857,8 @@ snapshots:
require-main-filename@2.0.0: {}
reselect@5.1.1: {}
resolve-from@4.0.0: {}
resolve-pkg-maps@1.0.0: {}
@@ -5684,6 +6134,21 @@ snapshots:
optionalDependencies:
'@babel/core': 7.29.0
superagent@8.0.6:
dependencies:
component-emitter: 1.3.1
cookiejar: 2.1.4
debug: 4.4.3
fast-safe-stringify: 2.1.1
form-data: 4.0.5
formidable: 2.1.5
methods: 1.1.2
mime: 2.6.0
qs: 6.15.0
semver: 7.7.4
transitivePeerDependencies:
- supports-color
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
@@ -5694,6 +6159,8 @@ snapshots:
tapable@2.3.0: {}
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
tinyexec@1.0.2: {}
@@ -5722,6 +6189,8 @@ snapshots:
tslib@2.8.1: {}
tweetnacl@1.0.3: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -5815,10 +6284,31 @@ snapshots:
dependencies:
punycode: 2.3.1
use-sync-external-store@1.6.0(react@19.2.3):
dependencies:
react: 19.2.3
valibot@1.2.0(typescript@5.9.3):
optionalDependencies:
typescript: 5.9.3
victory-vendor@37.3.6:
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.8
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
vite@7.3.1(@types/node@20.19.35)(jiti@2.6.1)(lightningcss@1.31.1):
dependencies:
esbuild: 0.27.3
@@ -5870,6 +6360,13 @@ snapshots:
- tsx
- yaml
wechatpay-node-v3@2.2.1:
dependencies:
'@fidm/x509': 1.2.1
superagent: 8.0.6
transitivePeerDependencies:
- supports-color
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
@@ -5930,6 +6427,8 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
wrappy@1.0.2: {}
xtend@4.0.2: {}
y18n@4.0.3: {}

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "orders" ADD COLUMN "src_host" TEXT,
ADD COLUMN "src_url" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "orders" ADD COLUMN "user_notes" TEXT;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "orders" ADD COLUMN "pay_amount" DECIMAL(10,2),
ADD COLUMN "fee_rate" DECIMAL(5,2);

View File

@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "orders_paid_at_idx" ON "orders"("paid_at");

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

@@ -11,7 +11,10 @@ model Order {
userId Int @map("user_id")
userEmail String? @map("user_email")
userName String? @map("user_name")
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, 4) @map("fee_rate")
rechargeCode String @unique @map("recharge_code")
status OrderStatus @default(PENDING)
paymentType String @map("payment_type")
@@ -34,6 +37,15 @@ model Order {
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
clientIp String? @map("client_ip")
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[]
@@ -41,6 +53,9 @@ model Order {
@@index([status])
@@index([expiresAt])
@@index([createdAt])
@@index([paidAt])
@@index([paymentType, paidAt])
@@index([orderType])
@@map("orders")
}
@@ -70,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

@@ -0,0 +1,329 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('@/lib/config', () => ({
getEnv: () => ({
ALIPAY_APP_ID: '2021000000000000',
ALIPAY_PRIVATE_KEY: 'test-private-key',
ALIPAY_PUBLIC_KEY: 'test-public-key',
ALIPAY_NOTIFY_URL: 'https://pay.example.com/api/alipay/notify',
ALIPAY_RETURN_URL: 'https://pay.example.com/pay/result',
NEXT_PUBLIC_APP_URL: 'https://pay.example.com',
PRODUCT_NAME: 'Sub2API Balance Recharge',
}),
}));
const mockPageExecute = vi.fn();
const mockExecute = vi.fn();
vi.mock('@/lib/alipay/client', () => ({
pageExecute: (...args: unknown[]) => mockPageExecute(...args),
execute: (...args: unknown[]) => mockExecute(...args),
}));
const mockVerifySign = vi.fn();
vi.mock('@/lib/alipay/sign', () => ({
verifySign: (...args: unknown[]) => mockVerifySign(...args),
}));
import { AlipayProvider, buildAlipayEntryUrl } from '@/lib/alipay/provider';
import type { CreatePaymentRequest, RefundRequest } from '@/lib/payment/types';
describe('AlipayProvider', () => {
let provider: AlipayProvider;
beforeEach(() => {
vi.clearAllMocks();
provider = new AlipayProvider();
});
describe('metadata', () => {
it('should have name "alipay-direct"', () => {
expect(provider.name).toBe('alipay-direct');
});
it('should have providerKey "alipay"', () => {
expect(provider.providerKey).toBe('alipay');
});
it('should support "alipay_direct" payment type', () => {
expect(provider.supportedTypes).toEqual(['alipay_direct']);
});
it('should have default limits', () => {
expect(provider.defaultLimits).toEqual({
alipay_direct: { singleMax: 1000, dailyMax: 10000 },
});
});
});
describe('createPayment', () => {
it('should return service short link as desktop qrCode', async () => {
const request: CreatePaymentRequest = {
orderId: 'order-001',
amount: 100,
paymentType: 'alipay_direct',
subject: 'Sub2API Balance Recharge 100.00 CNY',
clientIp: '127.0.0.1',
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('order-001');
expect(result.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-002',
product_code: 'QUICK_WAP_WAY',
total_amount: '50.00',
subject: 'Sub2API Balance Recharge 50.00 CNY',
},
expect.objectContaining({ method: 'alipay.trade.wap.pay' }),
);
expect(mockExecute).not.toHaveBeenCalled();
});
});
describe('queryOrder', () => {
it('should return paid status for TRADE_SUCCESS', async () => {
mockExecute.mockResolvedValue({
code: '10000',
msg: 'Success',
trade_no: '2026030500001',
trade_status: 'TRADE_SUCCESS',
total_amount: '100.00',
send_pay_date: '2026-03-05 12:00:00',
});
const result = await provider.queryOrder('order-001');
expect(result.tradeNo).toBe('2026030500001');
expect(result.status).toBe('paid');
expect(result.amount).toBe(100);
expect(result.paidAt).toBeInstanceOf(Date);
});
it('should return paid status for TRADE_FINISHED', async () => {
mockExecute.mockResolvedValue({
code: '10000',
msg: 'Success',
trade_no: '2026030500002',
trade_status: 'TRADE_FINISHED',
total_amount: '50.00',
});
const result = await provider.queryOrder('order-002');
expect(result.status).toBe('paid');
});
it('should return pending status for WAIT_BUYER_PAY', async () => {
mockExecute.mockResolvedValue({
code: '10000',
msg: 'Success',
trade_no: '2026030500003',
trade_status: 'WAIT_BUYER_PAY',
total_amount: '30.00',
});
const result = await provider.queryOrder('order-003');
expect(result.status).toBe('pending');
});
it('should return failed status for TRADE_CLOSED', async () => {
mockExecute.mockResolvedValue({
code: '10000',
msg: 'Success',
trade_no: '2026030500004',
trade_status: 'TRADE_CLOSED',
total_amount: '20.00',
});
const result = await provider.queryOrder('order-004');
expect(result.status).toBe('failed');
});
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', () => {
it('should verify and parse successful payment notification', async () => {
mockVerifySign.mockReturnValue(true);
const body = new URLSearchParams({
trade_no: '2026030500001',
out_trade_no: 'order-001',
trade_status: 'TRADE_SUCCESS',
total_amount: '100.00',
sign: 'test_sign',
sign_type: 'RSA2',
app_id: '2021000000000000',
}).toString();
const result = await provider.verifyNotification(body, {});
expect(result.tradeNo).toBe('2026030500001');
expect(result.orderId).toBe('order-001');
expect(result.amount).toBe(100);
expect(result.status).toBe('success');
});
it('should parse TRADE_FINISHED as success', async () => {
mockVerifySign.mockReturnValue(true);
const body = new URLSearchParams({
trade_no: '2026030500002',
out_trade_no: 'order-002',
trade_status: 'TRADE_FINISHED',
total_amount: '50.00',
sign: 'test_sign',
sign_type: 'RSA2',
app_id: '2021000000000000',
}).toString();
const result = await provider.verifyNotification(body, {});
expect(result.status).toBe('success');
});
it('should parse TRADE_CLOSED as failed', async () => {
mockVerifySign.mockReturnValue(true);
const body = new URLSearchParams({
trade_no: '2026030500003',
out_trade_no: 'order-003',
trade_status: 'TRADE_CLOSED',
total_amount: '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 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 request refund and map success status', async () => {
mockExecute.mockResolvedValue({
code: '10000',
msg: 'Success',
trade_no: 'refund-trade-no',
fund_change: 'Y',
});
const request: RefundRequest = {
tradeNo: 'trade-no',
orderId: 'order-refund',
amount: 12.34,
reason: 'test refund',
};
const result = await provider.refund(request);
expect(result).toEqual({ refundId: 'refund-trade-no', status: 'success' });
expect(mockExecute).toHaveBeenCalledWith('alipay.trade.refund', {
out_trade_no: 'order-refund',
refund_amount: '12.34',
refund_reason: 'test refund',
out_request_no: 'order-refund-refund',
});
});
});
describe('cancelPayment', () => {
it('should close payment by out_trade_no', async () => {
mockExecute.mockResolvedValue({ code: '10000', msg: 'Success' });
await provider.cancelPayment('order-close');
expect(mockExecute).toHaveBeenCalledWith('alipay.trade.close', {
out_trade_no: 'order-close',
});
});
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

@@ -0,0 +1,145 @@
import { describe, it, expect } from 'vitest';
import crypto from 'crypto';
import { generateSign, verifySign } from '@/lib/alipay/sign';
// 生成测试用 RSA 密钥对
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
});
// 提取裸 base64去掉 PEM 头尾)
const barePrivateKey = privateKey
.replace(/-----BEGIN PRIVATE KEY-----/, '')
.replace(/-----END PRIVATE KEY-----/, '')
.replace(/\n/g, '');
const barePublicKey = publicKey
.replace(/-----BEGIN PUBLIC KEY-----/, '')
.replace(/-----END PUBLIC KEY-----/, '')
.replace(/\n/g, '');
describe('Alipay RSA2 Sign', () => {
const testParams: Record<string, string> = {
app_id: '2021000000000000',
method: 'alipay.trade.page.pay',
charset: 'utf-8',
timestamp: '2026-03-05 12:00:00',
version: '1.0',
biz_content: '{"out_trade_no":"order-001","total_amount":"100.00"}',
};
describe('generateSign', () => {
it('should generate a valid RSA2 signature', () => {
const sign = generateSign(testParams, privateKey);
expect(sign).toBeTruthy();
expect(typeof sign).toBe('string');
expect(() => Buffer.from(sign, 'base64')).not.toThrow();
});
it('should produce consistent signatures for same input', () => {
const sign1 = generateSign(testParams, privateKey);
const sign2 = generateSign(testParams, privateKey);
expect(sign1).toBe(sign2);
});
it('should filter out sign 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);
const paramsWithSignType = { ...testParams, sign_type: 'RSA2' };
const sign3 = generateSign(paramsWithSignType, privateKey);
expect(sign3).not.toBe(sign1);
});
it('should filter out empty values', () => {
const paramsWithEmpty = { ...testParams, empty_field: '' };
const sign1 = generateSign(testParams, privateKey);
const sign2 = generateSign(paramsWithEmpty, privateKey);
expect(sign1).toBe(sign2);
});
it('should sort parameters alphabetically', () => {
const reversed: Record<string, string> = {};
const keys = Object.keys(testParams).reverse();
for (const key of keys) {
reversed[key] = testParams[key];
}
const sign1 = generateSign(testParams, privateKey);
const sign2 = generateSign(reversed, privateKey);
expect(sign1).toBe(sign2);
});
});
describe('verifySign', () => {
it('should verify a valid signature', () => {
const sign = generateSign(testParams, privateKey);
const valid = verifySign(testParams, publicKey, sign);
expect(valid).toBe(true);
});
it('should reject an invalid signature', () => {
const valid = verifySign(testParams, publicKey, 'invalid_base64_signature');
expect(valid).toBe(false);
});
it('should reject tampered params', () => {
const sign = generateSign(testParams, privateKey);
const tampered = { ...testParams, total_amount: '999.99' };
const valid = verifySign(tampered, publicKey, sign);
expect(valid).toBe(false);
});
});
describe('PEM auto-formatting', () => {
it('should work with bare base64 private key (no PEM headers)', () => {
const sign = generateSign(testParams, barePrivateKey);
const valid = verifySign(testParams, publicKey, sign);
expect(valid).toBe(true);
});
it('should work with bare base64 public key (no PEM headers)', () => {
const sign = generateSign(testParams, privateKey);
const valid = verifySign(testParams, barePublicKey, sign);
expect(valid).toBe(true);
});
it('should work with both bare keys', () => {
const sign = generateSign(testParams, barePrivateKey);
const valid = verifySign(testParams, barePublicKey, sign);
expect(valid).toBe(true);
});
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

@@ -12,10 +12,12 @@ import type {
class MockProvider implements PaymentProvider {
readonly name: string;
readonly providerKey: string;
readonly supportedTypes: PaymentType[];
constructor(name: string, types: PaymentType[]) {
this.name = name;
this.providerKey = name;
this.supportedTypes = types;
}

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

@@ -9,18 +9,18 @@ vi.mock('@/lib/config', () => ({
}),
}));
const mockSessionCreate = vi.fn();
const mockSessionRetrieve = vi.fn();
const mockPaymentIntentCreate = vi.fn();
const mockPaymentIntentRetrieve = vi.fn();
const mockPaymentIntentCancel = vi.fn();
const mockRefundCreate = vi.fn();
const mockWebhooksConstructEvent = vi.fn();
vi.mock('stripe', () => {
const StripeMock = function (this: Record<string, unknown>) {
this.checkout = {
sessions: {
create: mockSessionCreate,
retrieve: mockSessionRetrieve,
},
this.paymentIntents = {
create: mockPaymentIntentCreate,
retrieve: mockPaymentIntentRetrieve,
cancel: mockPaymentIntentCancel,
};
this.refunds = {
create: mockRefundCreate,
@@ -54,10 +54,10 @@ describe('StripeProvider', () => {
});
describe('createPayment', () => {
it('should create a checkout session and return checkoutUrl', async () => {
mockSessionCreate.mockResolvedValue({
id: 'cs_test_abc123',
url: 'https://checkout.stripe.com/pay/cs_test_abc123',
it('should create a PaymentIntent and return clientSecret', async () => {
mockPaymentIntentCreate.mockResolvedValue({
id: 'pi_test_abc123',
client_secret: 'pi_test_abc123_secret_xyz',
});
const request: CreatePaymentRequest = {
@@ -70,34 +70,26 @@ describe('StripeProvider', () => {
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('cs_test_abc123');
expect(result.checkoutUrl).toBe('https://checkout.stripe.com/pay/cs_test_abc123');
expect(mockSessionCreate).toHaveBeenCalledWith(
expect(result.tradeNo).toBe('pi_test_abc123');
expect(result.clientSecret).toBe('pi_test_abc123_secret_xyz');
expect(mockPaymentIntentCreate).toHaveBeenCalledWith(
expect.objectContaining({
mode: 'payment',
payment_method_types: ['card'],
amount: 9999,
currency: 'cny',
automatic_payment_methods: { enabled: true },
metadata: { orderId: 'order-001' },
expires_at: expect.any(Number),
line_items: [
expect.objectContaining({
price_data: expect.objectContaining({
currency: 'cny',
unit_amount: 9999,
}),
quantity: 1,
}),
],
description: 'Sub2API Balance Recharge 99.99 CNY',
}),
expect.objectContaining({
idempotencyKey: 'checkout-order-001',
idempotencyKey: 'pi-order-001',
}),
);
});
it('should handle session with null url', async () => {
mockSessionCreate.mockResolvedValue({
id: 'cs_test_no_url',
url: null,
it('should handle null client_secret', async () => {
mockPaymentIntentCreate.mockResolvedValue({
id: 'pi_test_no_secret',
client_secret: null,
});
const request: CreatePaymentRequest = {
@@ -108,61 +100,58 @@ describe('StripeProvider', () => {
};
const result = await provider.createPayment(request);
expect(result.tradeNo).toBe('cs_test_no_url');
expect(result.checkoutUrl).toBeUndefined();
expect(result.tradeNo).toBe('pi_test_no_secret');
expect(result.clientSecret).toBeUndefined();
});
});
describe('queryOrder', () => {
it('should return paid status for paid session', async () => {
mockSessionRetrieve.mockResolvedValue({
id: 'cs_test_abc123',
payment_status: 'paid',
amount_total: 9999,
it('should return paid status for succeeded PaymentIntent', async () => {
mockPaymentIntentRetrieve.mockResolvedValue({
id: 'pi_test_abc123',
status: 'succeeded',
amount: 9999,
});
const result = await provider.queryOrder('cs_test_abc123');
expect(result.tradeNo).toBe('cs_test_abc123');
const result = await provider.queryOrder('pi_test_abc123');
expect(result.tradeNo).toBe('pi_test_abc123');
expect(result.status).toBe('paid');
expect(result.amount).toBe(99.99);
});
it('should return failed status for expired session', async () => {
mockSessionRetrieve.mockResolvedValue({
id: 'cs_test_expired',
payment_status: 'unpaid',
status: 'expired',
amount_total: 5000,
it('should return failed status for canceled PaymentIntent', async () => {
mockPaymentIntentRetrieve.mockResolvedValue({
id: 'pi_test_canceled',
status: 'canceled',
amount: 5000,
});
const result = await provider.queryOrder('cs_test_expired');
const result = await provider.queryOrder('pi_test_canceled');
expect(result.status).toBe('failed');
expect(result.amount).toBe(50);
});
it('should return pending status for unpaid session', async () => {
mockSessionRetrieve.mockResolvedValue({
id: 'cs_test_pending',
payment_status: 'unpaid',
status: 'open',
amount_total: 1000,
it('should return pending status for requires_payment_method', async () => {
mockPaymentIntentRetrieve.mockResolvedValue({
id: 'pi_test_pending',
status: 'requires_payment_method',
amount: 1000,
});
const result = await provider.queryOrder('cs_test_pending');
const result = await provider.queryOrder('pi_test_pending');
expect(result.status).toBe('pending');
});
});
describe('verifyNotification', () => {
it('should verify and parse checkout.session.completed event', async () => {
it('should verify and parse payment_intent.succeeded event', async () => {
const mockEvent = {
type: 'checkout.session.completed',
type: 'payment_intent.succeeded',
data: {
object: {
id: 'cs_test_abc123',
id: 'pi_test_abc123',
metadata: { orderId: 'order-001' },
amount_total: 9999,
payment_status: 'paid',
amount: 9999,
},
},
};
@@ -172,21 +161,20 @@ describe('StripeProvider', () => {
const result = await provider.verifyNotification('{"raw":"body"}', { 'stripe-signature': 'sig_test_123' });
expect(result).not.toBeNull();
expect(result!.tradeNo).toBe('cs_test_abc123');
expect(result!.tradeNo).toBe('pi_test_abc123');
expect(result!.orderId).toBe('order-001');
expect(result!.amount).toBe(99.99);
expect(result!.status).toBe('success');
});
it('should return failed status for unpaid session', async () => {
it('should return failed status for payment_intent.payment_failed', async () => {
const mockEvent = {
type: 'checkout.session.completed',
type: 'payment_intent.payment_failed',
data: {
object: {
id: 'cs_test_unpaid',
id: 'pi_test_failed',
metadata: { orderId: 'order-002' },
amount_total: 5000,
payment_status: 'unpaid',
amount: 5000,
},
},
};
@@ -210,19 +198,14 @@ describe('StripeProvider', () => {
});
describe('refund', () => {
it('should refund via payment intent from session', async () => {
mockSessionRetrieve.mockResolvedValue({
id: 'cs_test_abc123',
payment_intent: 'pi_test_payment_intent',
});
it('should refund directly using PaymentIntent ID', async () => {
mockRefundCreate.mockResolvedValue({
id: 're_test_refund_001',
status: 'succeeded',
});
const request: RefundRequest = {
tradeNo: 'cs_test_abc123',
tradeNo: 'pi_test_abc123',
orderId: 'order-001',
amount: 50,
reason: 'customer request',
@@ -232,50 +215,34 @@ describe('StripeProvider', () => {
expect(result.refundId).toBe('re_test_refund_001');
expect(result.status).toBe('success');
expect(mockRefundCreate).toHaveBeenCalledWith({
payment_intent: 'pi_test_payment_intent',
payment_intent: 'pi_test_abc123',
amount: 5000,
reason: 'requested_by_customer',
});
});
it('should handle payment intent as object', async () => {
mockSessionRetrieve.mockResolvedValue({
id: 'cs_test_abc123',
payment_intent: { id: 'pi_test_obj_intent', amount: 10000 },
});
it('should handle pending refund status', async () => {
mockRefundCreate.mockResolvedValue({
id: 're_test_refund_002',
status: 'pending',
});
const result = await provider.refund({
tradeNo: 'cs_test_abc123',
tradeNo: 'pi_test_abc123',
orderId: 'order-002',
amount: 100,
});
expect(result.status).toBe('pending');
expect(mockRefundCreate).toHaveBeenCalledWith(
expect.objectContaining({
payment_intent: 'pi_test_obj_intent',
}),
);
});
});
it('should throw if no payment intent found', async () => {
mockSessionRetrieve.mockResolvedValue({
id: 'cs_test_no_pi',
payment_intent: null,
});
describe('cancelPayment', () => {
it('should cancel a PaymentIntent', async () => {
mockPaymentIntentCancel.mockResolvedValue({ id: 'pi_test_abc123', status: 'canceled' });
await expect(
provider.refund({
tradeNo: 'cs_test_no_pi',
orderId: 'order-003',
amount: 20,
}),
).rejects.toThrow('No payment intent found');
await provider.cancelPayment('pi_test_abc123');
expect(mockPaymentIntentCancel).toHaveBeenCalledWith('pi_test_abc123');
});
});
});

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

@@ -0,0 +1,199 @@
'use client';
import { useSearchParams } from 'next/navigation';
import { useState, useEffect, useCallback, Suspense } from 'react';
import PayPageLayout from '@/components/PayPageLayout';
import DashboardStats from '@/components/admin/DashboardStats';
import DailyChart from '@/components/admin/DailyChart';
import Leaderboard from '@/components/admin/Leaderboard';
import PaymentMethodChart from '@/components/admin/PaymentMethodChart';
import { resolveLocale } from '@/lib/locale';
interface DashboardData {
summary: {
today: { amount: number; orderCount: number; paidCount: number };
total: { amount: number; orderCount: number; paidCount: number };
successRate: number;
avgAmount: number;
};
dailySeries: { date: string; amount: number; count: number }[];
leaderboard: {
userId: number;
userName: string | null;
userEmail: string | null;
totalAmount: number;
orderCount: number;
}[];
paymentMethods: { paymentType: string; amount: number; count: number; percentage: number }[];
meta: { days: number; generatedAt: string };
}
const DAYS_OPTIONS = [7, 30, 90] as const;
function DashboardContent() {
const searchParams = useSearchParams();
const token = searchParams.get('token');
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
const uiMode = searchParams.get('ui_mode') || 'standalone';
const 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);
const [error, setError] = useState('');
const fetchData = useCallback(async () => {
if (!token) return;
setLoading(true);
setError('');
try {
const res = await fetch(`/api/admin/dashboard?token=${encodeURIComponent(token)}&days=${days}`);
if (!res.ok) {
if (res.status === 401) {
setError(text.invalidToken);
return;
}
throw new Error(text.requestFailed);
}
setData(await res.json());
} catch {
setError(text.loadFailed);
} finally {
setLoading(false);
}
}, [token, days]);
useEffect(() => {
fetchData();
}, [fetchData]);
if (!token) {
return (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
<div className="text-center text-red-500">
<p className="text-lg font-medium">{text.missingToken}</p>
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-slate-500'}`}>{text.missingTokenHint}</p>
</div>
</div>
);
}
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');
const btnBase = [
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
isDark
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
].join(' ');
const btnActive = [
'inline-flex items-center rounded-lg px-3 py-1.5 text-xs font-medium',
isDark ? 'bg-indigo-500/30 text-indigo-200 ring-1 ring-indigo-400/40' : 'bg-blue-600 text-white',
].join(' ');
return (
<PayPageLayout
isDark={isDark}
isEmbedded={isEmbedded}
maxWidth="full"
title={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}
{text.daySuffix}
</button>
))}
<a href={`/admin/orders?${navParams}`} className={btnBase}>
{text.orders}
</a>
<button type="button" onClick={fetchData} 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>
)}
{loading ? (
<div className={`py-24 text-center ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.loading}</div>
) : data ? (
<div className="space-y-6">
<DashboardStats summary={data.summary} dark={isDark} locale={locale} />
<DailyChart data={data.dailySeries} dark={isDark} locale={locale} />
<div className="grid gap-6 lg:grid-cols-2">
<Leaderboard data={data.leaderboard} dark={isDark} locale={locale} />
<PaymentMethodChart data={data.paymentMethods} dark={isDark} locale={locale} />
</div>
</div>
) : null}
</PayPageLayout>
);
}
function 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={<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,229 +2,198 @@
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;
amount: number;
status: string;
paymentType: string;
createdAt: string;
paidAt: string | null;
completedAt: string | null;
failedReason: string | null;
expiresAt: string;
interface DashboardData {
summary: {
today: { amount: number; orderCount: number; paidCount: number };
total: { amount: number; orderCount: number; paidCount: number };
successRate: number;
avgAmount: number;
};
dailySeries: { date: string; amount: number; count: number }[];
leaderboard: {
userId: number;
userName: string | null;
userEmail: string | null;
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;
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">
<div className="text-red-500"></div>
<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('确认重试充值?')) 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 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');
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 btnBase = [
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
isDark
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
].join(' ');
const 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 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 (
<div className="mx-auto min-h-screen max-w-6xl p-4">
<div className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Sub2ApiPay </h1>
<button
type="button"
onClick={fetchOrders}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-100"
>
</button>
</div>
<PayPageLayout
isDark={isDark}
isEmbedded={isEmbedded}
maxWidth="full"
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}
{text.daySuffix}
</button>
))}
<a href={`/admin/orders?${navParams}`} className={btnBase}>
{text.orders}
</a>
<button type="button" onClick={fetchData} className={btnBase}>
{text.refresh}
</button>
</>
}
>
{error && (
<div className="mb-4 rounded-lg bg-red-50 p-3 text-sm text-red-600">
<div
className={`mb-4 rounded-lg border p-3 text-sm ${isDark ? 'border-red-800 bg-red-950/50 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}
>
{error}
<button onClick={() => setError('')} className="ml-2 text-red-400 hover:text-red-600">
<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 ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{statusLabels[s]}
</button>
))}
</div>
{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>
);
}
{/* Table */}
<div className="rounded-xl bg-white shadow-sm">
{loading ? (
<div className="py-12 text-center text-gray-500">...</div>
) : (
<OrderTable orders={orders} onRetry={handleRetry} onCancel={handleCancel} onViewDetail={handleViewDetail} />
)}
</div>
function DashboardPageFallback() {
const searchParams = useSearchParams();
const locale = resolveLocale(searchParams.get('lang'));
<PaginationBar
page={page}
totalPages={totalPages}
total={total}
pageSize={pageSize}
loading={loading}
onPageChange={(p) => setPage(p)}
onPageSizeChange={(s) => { setPageSize(s); setPage(1); }}
/>
{/* Order Detail */}
{detailOrder && <OrderDetail order={detailOrder} onClose={() => setDetailOrder(null)} />}
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() {
export default function DashboardPage() {
return (
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center">
<div className="text-gray-500">...</div>
</div>
}
>
<AdminContent />
<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

@@ -0,0 +1,144 @@
import { NextRequest, NextResponse } from 'next/server';
import { Prisma } from '@prisma/client';
import { prisma } from '@/lib/db';
import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { OrderStatus } from '@prisma/client';
import { BIZ_TZ_NAME, getBizDayStartUTC, toBizDateStr } from '@/lib/time/biz-day';
export async function GET(request: NextRequest) {
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
const searchParams = request.nextUrl.searchParams;
const days = Math.min(365, Math.max(1, Number(searchParams.get('days') || '30')));
const now = new Date();
const todayStart = getBizDayStartUTC(now);
const startDate = new Date(todayStart.getTime() - days * 24 * 60 * 60 * 1000);
const paidStatuses: OrderStatus[] = [
OrderStatus.PAID,
OrderStatus.RECHARGING,
OrderStatus.COMPLETED,
OrderStatus.REFUNDING,
OrderStatus.REFUNDED,
OrderStatus.REFUND_FAILED,
];
const [todayStats, totalStats, todayOrders, totalOrders, dailyRaw, leaderboardRaw, paymentMethodStats] =
await Promise.all([
// Today paid aggregate
prisma.order.aggregate({
where: { status: { in: paidStatuses }, paidAt: { gte: todayStart } },
_sum: { amount: true },
_count: { _all: true },
}),
// Total paid aggregate
prisma.order.aggregate({
where: { status: { in: paidStatuses } },
_sum: { amount: true },
_count: { _all: true },
}),
// Today total orders
prisma.order.count({ where: { createdAt: { gte: todayStart } } }),
// Total orders
prisma.order.count(),
// Daily series: use AT TIME ZONE to group by business timezone date
// Prisma.raw() inlines the timezone name to avoid parameterization mismatch between SELECT and GROUP BY
prisma.$queryRaw<{ date: string; amount: string; count: bigint }[]>`
SELECT (paid_at AT TIME ZONE 'UTC' AT TIME ZONE ${Prisma.raw(`'${BIZ_TZ_NAME}'`)})::date::text as date,
SUM(amount)::text as amount, COUNT(*) as count
FROM orders
WHERE status IN ('PAID', 'RECHARGING', 'COMPLETED', 'REFUNDING', 'REFUNDED', 'REFUND_FAILED')
AND paid_at >= ${startDate}
GROUP BY (paid_at AT TIME ZONE 'UTC' AT TIME ZONE ${Prisma.raw(`'${BIZ_TZ_NAME}'`)})::date
ORDER BY date
`,
// Leaderboard: GROUP BY user_id only, MAX() for name/email
prisma.$queryRaw<
{
user_id: number;
user_name: string | null;
user_email: string | null;
total_amount: string;
order_count: bigint;
}[]
>`
SELECT user_id, MAX(user_name) as user_name, MAX(user_email) as user_email,
SUM(amount)::text as total_amount, COUNT(*) as order_count
FROM orders
WHERE status IN ('PAID', 'RECHARGING', 'COMPLETED', 'REFUNDING', 'REFUNDED', 'REFUND_FAILED')
AND paid_at >= ${startDate}
GROUP BY user_id
ORDER BY SUM(amount) DESC
LIMIT 10
`,
// Payment method distribution (within time range)
prisma.order.groupBy({
by: ['paymentType'],
where: { status: { in: paidStatuses }, paidAt: { gte: startDate } },
_sum: { amount: true },
_count: { _all: true },
}),
]);
// Fill missing dates for continuous line chart
const dailyMap = new Map<string, { amount: number; count: number }>();
for (const row of dailyRaw) {
dailyMap.set(row.date, { amount: Number(row.amount), count: Number(row.count) });
}
const dailySeries: { date: string; amount: number; count: number }[] = [];
const cursor = new Date(startDate);
while (cursor <= now) {
const dateStr = toBizDateStr(cursor);
const entry = dailyMap.get(dateStr);
dailySeries.push({ date: dateStr, amount: entry?.amount ?? 0, count: entry?.count ?? 0 });
cursor.setTime(cursor.getTime() + 24 * 60 * 60 * 1000);
}
// Deduplicate: toBizDateStr on consecutive UTC days near midnight can produce the same biz date
const seen = new Set<string>();
const deduped = dailySeries.filter((d) => {
if (seen.has(d.date)) return false;
seen.add(d.date);
return true;
});
// Calculate summary
const todayPaidAmount = Number(todayStats._sum?.amount || 0);
const todayPaidCount = todayStats._count._all;
const totalPaidAmount = Number(totalStats._sum?.amount || 0);
const totalPaidCount = totalStats._count._all;
const successRate = totalOrders > 0 ? (totalPaidCount / totalOrders) * 100 : 0;
const avgAmount = totalPaidCount > 0 ? totalPaidAmount / totalPaidCount : 0;
// Payment method total for percentage calc
const paymentTotal = paymentMethodStats.reduce((sum, m) => sum + Number(m._sum?.amount || 0), 0);
return NextResponse.json({
summary: {
today: { amount: todayPaidAmount, orderCount: todayOrders, paidCount: todayPaidCount },
total: { amount: totalPaidAmount, orderCount: totalOrders, paidCount: totalPaidCount },
successRate: Math.round(successRate * 10) / 10,
avgAmount: Math.round(avgAmount * 100) / 100,
},
dailySeries: deduped,
leaderboard: leaderboardRaw.map((row) => ({
userId: row.user_id,
userName: row.user_name,
userEmail: row.user_email,
totalAmount: Number(row.total_amount),
orderCount: Number(row.order_count),
})),
paymentMethods: paymentMethodStats.map((m) => {
const amount = Number(m._sum?.amount || 0);
return {
paymentType: m.paymentType,
amount,
count: m._count._all,
percentage: paymentTotal > 0 ? Math.round((amount / paymentTotal) * 1000) / 10 : 0,
};
}),
meta: { days, generatedAt: now.toISOString() },
});
}

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 (!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 (!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 (!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

@@ -4,7 +4,7 @@ import { verifyAdminToken, unauthorizedResponse } from '@/lib/admin-auth';
import { Prisma, OrderStatus } from '@prisma/client';
export async function GET(request: NextRequest) {
if (!verifyAdminToken(request)) return unauthorizedResponse();
if (!(await verifyAdminToken(request))) return unauthorizedResponse();
const searchParams = request.nextUrl.searchParams;
const page = Math.max(1, Number(searchParams.get('page') || '1'));
@@ -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([
@@ -34,6 +61,7 @@ export async function GET(request: NextRequest) {
userId: true,
userName: true,
userEmail: true,
userNotes: true,
amount: true,
status: true,
paymentType: true,
@@ -42,6 +70,7 @@ export async function GET(request: NextRequest) {
completedAt: true,
failedReason: true,
expiresAt: true,
srcHost: true,
},
}),
prisma.order.count({ where }),

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 (!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

@@ -0,0 +1,34 @@
import { NextRequest } from 'next/server';
import { handlePaymentNotify } from '@/lib/order/service';
import { paymentRegistry } from '@/lib/payment';
import type { PaymentType } from '@/lib/payment';
import { getEnv } from '@/lib/config';
import { extractHeaders } from '@/lib/utils/api';
export async function POST(request: NextRequest) {
try {
// 官方支付宝未配置时,直接返回成功(避免旧回调重试产生错误日志)
const env = getEnv();
if (!env.ALIPAY_APP_ID || !env.ALIPAY_PRIVATE_KEY) {
return new Response('success', { headers: { 'Content-Type': 'text/plain' } });
}
const provider = paymentRegistry.getProvider('alipay_direct' as PaymentType);
const rawBody = await request.text();
const headers = extractHeaders(request);
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' },
});
} catch (error) {
console.error('Alipay notify error:', error);
return new Response('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 { getEnv } from '@/lib/config';
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 { queryMethodLimits } from '@/lib/order/limits';
* 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() {
const env = getEnv();
const types = env.ENABLED_PAYMENT_TYPES;
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 });
}
const todayStart = new Date();
todayStart.setUTCHours(0, 0, 0, 0);
const resetAt = new Date(todayStart);
resetAt.setUTCDate(resetAt.getUTCDate() + 1);
try {
await getCurrentUserByToken(token);
} catch {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}
initPaymentProviders();
const types = paymentRegistry.getSupportedTypes();
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,12 +1,32 @@
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 { 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({
user_id: z.number().int().positive(),
amount: z.number().positive(),
payment_type: z.enum(['alipay', 'wxpay', 'stripe']),
token: z.string().min(1),
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)
.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) {
@@ -19,18 +39,30 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '参数错误', details: parsed.error.flatten().fieldErrors }, { status: 400 });
}
const { user_id, amount, payment_type } = parsed.data;
const { token, amount, payment_type, src_host, src_url, is_mobile, order_type, plan_id } = parsed.data;
// 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 },
);
// 通过 token 解析用户身份
let userId: number;
try {
const user = await getCurrentUserByToken(token);
userId = user.id;
} catch {
return NextResponse.json({ error: '无效的 token请重新登录', code: 'INVALID_TOKEN' }, { status: 401 });
}
// Validate payment type is enabled
if (!env.ENABLED_PAYMENT_TYPES.includes(payment_type)) {
// 订阅订单跳过金额范围校验(价格由服务端套餐决定)
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 (registry + ENABLED_PAYMENT_TYPES config)
const enabledTypes = await getEnabledPaymentTypes();
if (!enabledTypes.includes(payment_type)) {
return NextResponse.json({ error: `不支持的支付方式: ${payment_type}` }, { status: 400 });
}
@@ -38,20 +70,21 @@ export async function POST(request: NextRequest) {
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || request.headers.get('x-real-ip') || '127.0.0.1';
const result = await createOrder({
userId: user_id,
userId,
amount,
paymentType: payment_type,
clientIp,
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,35 +1,33 @@
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);
return NextResponse.json(
{ error: 'Webhook processing failed' },
{ status: 400 },
);
return NextResponse.json({ error: 'Webhook processing failed' }, { status: 400 });
}
}

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,20 +1,86 @@
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 { 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();
const [user, methodLimits] = await Promise.all([
getUser(userId),
queryMethodLimits(env.ENABLED_PAYMENT_TYPES),
]);
initPaymentProviders();
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> = {};
// 1. 检测同 label 冲突:多个启用渠道有相同的显示名,自动标记默认 sublabelprovider 名)
const labelCount = new Map<string, string[]>();
for (const type of enabledTypes) {
const { channel } = getPaymentDisplayInfo(type, locale);
const types = labelCount.get(channel) || [];
types.push(type);
labelCount.set(channel, types);
}
for (const [, types] of labelCount) {
if (types.length > 1) {
for (const type of types) {
const { provider } = getPaymentDisplayInfo(type, locale);
if (provider) sublabelOverrides[type] = provider;
}
}
}
// 2. 用户手动配置的 PAYMENT_SUBLABEL_* 优先级最高,覆盖自动生成的
if (env.PAYMENT_SUBLABEL_ALIPAY) sublabelOverrides.alipay = env.PAYMENT_SUBLABEL_ALIPAY;
if (env.PAYMENT_SUBLABEL_ALIPAY_DIRECT) sublabelOverrides.alipay_direct = env.PAYMENT_SUBLABEL_ALIPAY_DIRECT;
if (env.PAYMENT_SUBLABEL_WXPAY) sublabelOverrides.wxpay = env.PAYMENT_SUBLABEL_WXPAY;
if (env.PAYMENT_SUBLABEL_WXPAY_DIRECT) sublabelOverrides.wxpay_direct = env.PAYMENT_SUBLABEL_WXPAY_DIRECT;
if (env.PAYMENT_SUBLABEL_STRIPE) sublabelOverrides.stripe = env.PAYMENT_SUBLABEL_STRIPE;
return NextResponse.json({
user: {
@@ -22,19 +88,28 @@ export async function GET(request: NextRequest) {
status: user.status,
},
config: {
enabledPaymentTypes: env.ENABLED_PAYMENT_TYPES,
enabledPaymentTypes: enabledTypes,
minAmount: env.MIN_RECHARGE_AMOUNT,
maxAmount: env.MAX_RECHARGE_AMOUNT,
maxDailyAmount: env.MAX_DAILY_RECHARGE_AMOUNT,
methodLimits,
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,
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

@@ -0,0 +1,32 @@
import { NextRequest } from 'next/server';
import { handlePaymentNotify } from '@/lib/order/service';
import { paymentRegistry } from '@/lib/payment';
import type { PaymentType } from '@/lib/payment';
import { getEnv } from '@/lib/config';
import { extractHeaders } from '@/lib/utils/api';
export async function POST(request: NextRequest) {
try {
// 微信支付未配置时,直接返回成功(避免旧回调重试产生错误日志)
const env = getEnv();
if (!env.WXPAY_PUBLIC_KEY || !env.WXPAY_MCH_ID) {
return Response.json({ code: 'SUCCESS', message: '成功' });
}
const provider = paymentRegistry.getProvider('wxpay_direct' as PaymentType);
const rawBody = await request.text();
const headers = extractHeaders(request);
const notification = await provider.verifyNotification(rawBody, headers);
if (!notification) {
return Response.json({ code: 'SUCCESS', message: '成功' });
}
const success = await handlePaymentNotify(notification, provider.name);
return Response.json(success ? { code: 'SUCCESS', message: '成功' } : { code: 'FAIL', message: '处理失败' }, {
status: success ? 200 : 500,
});
} catch (error) {
console.error('Wxpay notify error:', error);
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,19 +1,26 @@
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">
<body className="bg-gray-50 text-gray-900 antialiased">{children}</body>
<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];
@@ -20,12 +21,40 @@ interface Summary {
function OrdersContent() {
const searchParams = useSearchParams();
const userId = Number(searchParams.get('user_id'));
const token = (searchParams.get('token') || '').trim();
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);
@@ -42,7 +71,6 @@ function OrdersContent() {
const isEmbedded = uiMode === 'embedded' && isIframeContext;
const hasToken = token.length > 0;
const effectiveUserId = resolvedUserId || userId;
useEffect(() => {
if (typeof window === 'undefined') return;
@@ -53,28 +81,21 @@ function OrdersContent() {
useEffect(() => {
if (!isMobile || isEmbedded || typeof window === 'undefined') return;
const params = new URLSearchParams();
if (userId && !Number.isNaN(userId)) params.set('user_id', String(userId));
if (token) params.set('token', token);
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);
setError('');
try {
if (!userId || Number.isNaN(userId) || userId <= 0) {
setError('无效的用户 ID');
setOrders([]);
return;
}
if (!hasToken) {
setUserInfo({ id: userId, username: `用户 #${userId}` });
setOrders([]);
setError('当前链接未携带登录 token无法查询"我的订单"。');
setError(text.authError);
return;
}
@@ -85,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;
}
@@ -96,11 +117,11 @@ function OrdersContent() {
if (Number.isInteger(meId) && meId > 0) setResolvedUserId(meId);
setUserInfo({
id: Number.isInteger(meId) && meId > 0 ? meId : userId,
id: Number.isInteger(meId) && meId > 0 ? meId : undefined,
username:
(typeof meUser.displayName === 'string' && meUser.displayName.trim()) ||
(typeof meUser.username === 'string' && meUser.username.trim()) ||
`用户 #${userId}`,
`${text.userPrefix} #${meId}`,
balance: typeof meUser.balance === 'number' ? meUser.balance : 0,
});
@@ -110,7 +131,7 @@ function OrdersContent() {
setTotalPages(data.total_pages ?? 1);
} catch {
setOrders([]);
setError('网络错误,请稍后重试。');
setError(text.networkError);
} finally {
setLoading(false);
}
@@ -119,8 +140,7 @@ function OrdersContent() {
useEffect(() => {
if (isMobile && !isEmbedded) return;
loadOrders(1, pageSize);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId, token, isMobile, isEmbedded]);
}, [token, isMobile, isEmbedded]);
const handlePageChange = (newPage: number) => {
setPage(newPage);
@@ -133,28 +153,31 @@ function OrdersContent() {
loadOrders(1, newSize);
};
const filteredOrders =
activeFilter === 'ALL' ? orders : orders.filter((o) => o.status === activeFilter);
const filteredOrders = activeFilter === 'ALL' ? orders : orders.filter((o) => o.status === activeFilter);
const btnClass = [
'inline-flex items-center rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors',
isDark ? 'border-slate-600 text-slate-200 hover:bg-slate-800' : 'border-slate-300 text-slate-700 hover:bg-slate-100',
isDark
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
: 'border-slate-300 text-slate-700 hover:bg-slate-100',
].join(' ');
if (isMobile) {
return (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950 text-slate-100' : 'bg-slate-50 text-slate-900'}`}>
Tab...
<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'}`}
>
{text.switchingMobileTab}
</div>
);
}
if (!effectiveUserId || Number.isNaN(effectiveUserId) || effectiveUserId <= 0) {
if (!hasToken && !resolvedUserId) {
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"> ID</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>
);
@@ -162,10 +185,10 @@ function OrdersContent() {
const buildScopedUrl = (path: string) => {
const params = new URLSearchParams();
if (effectiveUserId) params.set('user_id', String(effectiveUserId));
if (token) params.set('token', token);
params.set('theme', theme);
params.set('ui_mode', uiMode);
applyLocaleToSearchParams(params, locale);
return `${path}?${params.toString()}`;
};
@@ -173,22 +196,28 @@ function OrdersContent() {
<PayPageLayout
isDark={isDark}
isEmbedded={isEmbedded}
title="我的订单"
subtitle={userInfo?.username || `用户 #${effectiveUserId}`}
title={text.myOrders}
subtitle={userInfo?.username || text.myOrders}
actions={
<>
<button type="button" onClick={() => loadOrders(page, pageSize)} className={btnClass}></button>
<a href={buildScopedUrl('/pay')} className={btnClass}></a>
<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,9 +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,93 +2,336 @@
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);
useEffect(() => {
if (!outTradeNo) {
if (isPopup || window.opener) {
setIsInPopup(true);
}
}, [isPopup]);
useEffect(() => {
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]);
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 || !shouldAutoClose) return;
const timer = setTimeout(() => {
closeCurrentWindow();
}, 3000);
return () => clearTimeout(timer);
}, [isInPopup, shouldAutoClose]);
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center">
<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 isSuccess = status === 'COMPLETED' || status === 'PAID' || status === 'RECHARGING';
const isPending = status === 'PENDING';
const display = getStatusConfig(orderState, locale, Boolean(accessToken), isDark);
return (
<div className="flex min-h-screen items-center justify-center 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>
</>
) : 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>
</>
<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={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>
</>
<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>
);
@@ -96,13 +339,7 @@ function ResultContent() {
export default function PayResultPage() {
return (
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center">
<div className="text-gray-500">...</div>
</div>
}
>
<Suspense fallback={<ResultPageFallback />}>
<ResultContent />
</Suspense>
);

View File

@@ -0,0 +1,326 @@
'use client';
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() {
const searchParams = useSearchParams();
const orderId = searchParams.get('order_id') || '';
const amount = parseFloat(searchParams.get('amount') || '0') || 0;
const theme = searchParams.get('theme') === 'dark' ? 'dark' : 'light';
const method = searchParams.get('method') || '';
const accessToken = searchParams.get('access_token');
const locale = resolveLocale(searchParams.get('lang'));
const isDark = theme === 'dark';
const isAlipay = method === 'alipay';
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;
} | null>(null);
const [stripeLoaded, setStripeLoaded] = useState(false);
const [stripeSubmitting, setStripeSubmitting] = useState(false);
const [stripeError, setStripeError] = useState('');
const [stripeSuccess, setStripeSuccess] = useState(false);
const [stripeLib, setStripeLib] = useState<{
stripe: import('@stripe/stripe-js').Stripe;
elements: import('@stripe/stripe-js').StripeElements;
} | null>(null);
const buildReturnUrl = useCallback(() => {
const returnUrl = new URL(window.location.href);
returnUrl.pathname = '/pay/result';
returnUrl.search = '';
returnUrl.searchParams.set('order_id', orderId);
returnUrl.searchParams.set('status', 'success');
returnUrl.searchParams.set('popup', '1');
returnUrl.searchParams.set('theme', theme);
if (accessToken) {
returnUrl.searchParams.set('access_token', accessToken);
}
applyLocaleToSearchParams(returnUrl.searchParams, locale);
return returnUrl.toString();
}, [orderId, theme, locale, accessToken]);
useEffect(() => {
const handler = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== 'STRIPE_POPUP_INIT') return;
const { clientSecret, publishableKey } = event.data;
if (clientSecret && publishableKey) {
setCredentials({ clientSecret, publishableKey });
}
};
window.addEventListener('message', handler);
if (window.opener) {
window.opener.postMessage({ type: 'STRIPE_POPUP_READY' }, window.location.origin);
}
return () => window.removeEventListener('message', handler);
}, []);
useEffect(() => {
if (!credentials) return;
let cancelled = false;
const { clientSecret, publishableKey } = credentials;
import('@stripe/stripe-js').then(({ loadStripe }) => {
loadStripe(publishableKey).then((stripe) => {
if (cancelled || !stripe) {
if (!cancelled) {
setStripeError(text.loadFailed);
setStripeLoaded(true);
}
return;
}
if (isAlipay) {
stripe
.confirmAlipayPayment(clientSecret, {
return_url: buildReturnUrl(),
})
.then((result) => {
if (cancelled) return;
if (result.error) {
setStripeError(result.error.message || text.payFailed);
setStripeLoaded(true);
}
});
return;
}
const elements = stripe.elements({
clientSecret,
appearance: {
theme: isDark ? 'night' : 'stripe',
variables: { borderRadius: '8px' },
},
});
setStripeLib({ stripe, elements });
setStripeLoaded(true);
});
});
return () => {
cancelled = true;
};
}, [credentials, isDark, isAlipay, buildReturnUrl, text.loadFailed, text.payFailed]);
const stripeContainerRef = useCallback(
(node: HTMLDivElement | null) => {
if (!node || !stripeLib) return;
const existing = stripeLib.elements.getElement('payment');
if (existing) {
existing.mount(node);
} else {
stripeLib.elements.create('payment', { layout: 'tabs' }).mount(node);
}
},
[stripeLib],
);
const handleSubmit = async () => {
if (!stripeLib || stripeSubmitting) return;
setStripeSubmitting(true);
setStripeError('');
const { stripe, elements } = stripeLib;
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: buildReturnUrl(),
},
redirect: 'if_required',
});
if (error) {
setStripeError(error.message || text.payFailed);
setStripeSubmitting(false);
} else {
setStripeSuccess(true);
setStripeSubmitting(false);
}
};
useEffect(() => {
if (!stripeSuccess) return;
const timer = setTimeout(() => {
window.close();
}, 2000);
return () => clearTimeout(timer);
}, [stripeSuccess]);
if (!credentials) {
return (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
<div
className={`w-full max-w-md space-y-4 rounded-2xl border p-6 ${isDark ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-white'} shadow-lg`}
>
<div className="flex items-center justify-center py-8">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-[#635bff] border-t-transparent" />
<span className={`ml-3 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{text.init}</span>
</div>
</div>
</div>
);
}
if (isAlipay) {
return (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
<div
className={`w-full max-w-md space-y-4 rounded-2xl border p-6 ${isDark ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-white'} shadow-lg`}
>
<div className="text-center">
<div className={`text-3xl font-bold ${isDark ? 'text-blue-400' : 'text-blue-600'}`}>
{'¥'}
{amount.toFixed(2)}
</div>
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{text.orderId}: {orderId}
</p>
</div>
{stripeError ? (
<div className="space-y-3">
<div
className={`rounded-lg border p-3 text-sm ${isDark ? 'border-red-700 bg-red-900/30 text-red-400' : 'border-red-200 bg-red-50 text-red-600'}`}
>
{stripeError}
</div>
<button
type="button"
onClick={() => window.close()}
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'}`}>{text.redirecting}</span>
</div>
)}
</div>
</div>
);
}
return (
<div className={`flex min-h-screen items-center justify-center p-4 ${isDark ? 'bg-slate-950' : 'bg-slate-50'}`}>
<div
className={`w-full max-w-md space-y-4 rounded-2xl border p-6 ${isDark ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-white'} shadow-lg`}
>
<div className="text-center">
<div className={`text-3xl font-bold ${isDark ? 'text-blue-400' : 'text-blue-600'}`}>
{'¥'}
{amount.toFixed(2)}
</div>
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{text.orderId}: {orderId}
</p>
</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'}`}>{text.loadingForm}</span>
</div>
) : stripeSuccess ? (
<div className="py-6 text-center">
<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 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 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}
className={`rounded-lg border p-4 ${isDark ? 'border-slate-700 bg-slate-800' : 'border-gray-200 bg-white'}`}
/>
<button
type="button"
disabled={stripeSubmitting}
onClick={handleSubmit}
className={[
'w-full rounded-lg py-3 font-medium text-white shadow-md transition-colors',
stripeSubmitting
? 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>
) : (
text.payAmount
)}
</button>
</>
)}
</div>
</div>
);
}
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={<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>
);
}

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