From 3b5a3ba5dfc86ae87443286a95f78f9329358a2c Mon Sep 17 00:00:00 2001 From: erio Date: Sat, 14 Mar 2026 03:19:39 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=9A=97=E8=89=B2=E4=B8=BB=E9=A2=98?= =?UTF-8?q?=E5=85=A8=E9=9D=A2=E4=BC=98=E5=8C=96=20-=20=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E6=9D=A1=E3=80=81=E9=A2=9C=E8=89=B2=E9=80=82=E9=85=8D=E3=80=81?= =?UTF-8?q?dark:=E4=BC=AA=E7=B1=BB=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加全局暗色滚动条样式 (globals.css) - 修复 channels/subscriptions 页面 dark: 伪类无效的bug (改用 isDark) - 修复 RefundDialog 暗色下退款金额、disabled 按钮颜色 - 修复 admin OrderTable 暗色下缺少背景色 - 统一所有 admin 页面 text-gray-500 为 slate 色系 - 修复 UserSubscriptions 续费按钮暗色适配 - 修复日期标签文字缺少暗色颜色类 - MainTabs 暗色容器改用 slate-900 提升对比度 - OpenAI 默认模型区域暗色边框透明度提升 - 更新 README 文档 --- README.md | 154 +++++++++++++++++++----- src/app/admin/channels/page.tsx | 10 +- src/app/admin/dashboard/page.tsx | 4 +- src/app/admin/orders/page.tsx | 4 +- src/app/admin/page.tsx | 4 +- src/app/admin/subscriptions/page.tsx | 8 +- src/app/globals.css | 28 +++++ src/components/MainTabs.tsx | 2 +- src/components/SubscriptionConfirm.tsx | 2 +- src/components/SubscriptionPlanCard.tsx | 2 +- src/components/UserSubscriptions.tsx | 11 +- src/components/admin/OrderTable.tsx | 6 +- src/components/admin/RefundDialog.tsx | 11 +- 13 files changed, 186 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 4c4f264..8962747 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **语言 / Language**: 中文(当前)| [English](./README.en.md) -Sub2ApiPay 是为 [Sub2API](https://sub2api.com) 平台构建的自托管充值支付网关。支持支付宝、微信支付(通过 EasyPay 聚合)和 Stripe,订单支付成功后自动调用 Sub2API 管理接口完成余额到账,无需人工干预。 +Sub2ApiPay 是为 [Sub2API](https://sub2api.com) 平台构建的自托管支付网关。支持**支付宝直连**、**微信支付直连**、EasyPay 易支付聚合和 Stripe 四种支付渠道,提供按量充值与套餐订阅两种计费模式,支付成功后自动调用 Sub2API 管理接口完成到账,无需人工干预。 --- @@ -16,19 +16,22 @@ 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 嵌入 +- **中英双语** — 支付页面自动适配中英文 +- **管理后台** — 数据概览、订单管理(分页/筛选/重试/退款)、渠道管理、订阅管理 --- @@ -99,22 +102,43 @@ docker compose up -d --build **第一步**:通过 `PAYMENT_PROVIDERS` 声明启用哪些支付服务商(逗号分隔): ```env -# 仅易支付 +# 可选值: easypay, alipay, wxpay, stripe +# 示例:同时启用支付宝直连 + 微信直连 + Stripe +PAYMENT_PROVIDERS=alipay,wxpay,stripe +# 示例:仅使用易支付聚合 PAYMENT_PROVIDERS=easypay -# 仅 Stripe -PAYMENT_PROVIDERS=stripe -# 两者都用 -PAYMENT_PROVIDERS=easypay,stripe ``` -**第二步**:通过 `ENABLED_PAYMENT_TYPES` 控制向用户展示哪些支付渠道: +> **支付宝直连 / 微信支付直连**与**EasyPay**可以共存。直连渠道直接对接官方 API,资金直达商户账户,手续费更低;EasyPay 通过第三方聚合平台代收,接入门槛更低。 -```env -# 易支付支持: alipay, wxpay;Stripe 支持: stripe -ENABLED_PAYMENT_TYPES=alipay,wxpay -``` +#### 支付宝直连 -#### EasyPay(支付宝 / 微信支付) +直接对接支付宝开放平台,支持 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` | 异步回调地址 | + +#### EasyPay(支付宝 / 微信支付聚合) 支付提供商只需兼容**易支付(EasyPay)协议**即可接入,例如 [ZPay](https://z-pay.cn/?uid=23808)(`https://z-pay.cn/?uid=23808`)等平台(链接含本项目作者的邀请码,介意可去掉)。 @@ -150,13 +174,17 @@ ENABLED_PAYMENT_TYPES=alipay,wxpay ### 业务规则 -| 变量 | 说明 | 默认值 | -| --------------------------- | ---------------------------------- | -------------------------- | -| `MIN_RECHARGE_AMOUNT` | 单笔最低充值金额(元) | `1` | -| `MAX_RECHARGE_AMOUNT` | 单笔最高充值金额(元) | `1000` | -| `MAX_DAILY_RECHARGE_AMOUNT` | 每日累计最高充值(元,`0` = 不限) | `10000` | -| `ORDER_TIMEOUT_MINUTES` | 订单超时分钟数 | `5` | -| `PRODUCT_NAME` | 充值商品名称(显示在支付页) | `Sub2API Balance Recharge` | +| 变量 | 说明 | 默认值 | +| -------------------------------- | ---------------------------------------- | -------------------------- | +| `MIN_RECHARGE_AMOUNT` | 单笔最低充值金额(元) | `1` | +| `MAX_RECHARGE_AMOUNT` | 单笔最高充值金额(元) | `1000` | +| `MAX_DAILY_RECHARGE_AMOUNT` | 每日每用户累计最高充值(元,`0` = 不限) | `10000` | +| `MAX_DAILY_AMOUNT_ALIPAY` | 易支付支付宝渠道每日全局限额(可选) | 由提供商默认 | +| `MAX_DAILY_AMOUNT_ALIPAY_DIRECT`| 支付宝直连渠道每日全局限额(可选) | 由提供商默认 | +| `MAX_DAILY_AMOUNT_WXPAY` | 微信支付渠道每日全局限额(可选) | 由提供商默认 | +| `MAX_DAILY_AMOUNT_STRIPE` | Stripe 渠道每日全局限额(可选) | 由提供商默认 | +| `ORDER_TIMEOUT_MINUTES` | 订单超时分钟数 | `5` | +| `PRODUCT_NAME` | 充值商品名称(显示在支付页) | `Sub2API Balance Recharge` | ### UI 定制(可选) @@ -301,29 +329,91 @@ Sub2API **v0.1.88** 及以上版本会自动拼接以下参数,无需手动添 ## 支付流程 ``` -用户提交充值金额 +用户选择充值 / 订阅套餐 │ ▼ 创建订单 (PENDING) - ├─ 校验用户状态 / 待支付订单数 / 每日限额 + ├─ 校验用户状态 / 待支付订单数 / 每日限额 / 渠道限额 └─ 调用支付提供商获取支付链接 │ ▼ 用户完成支付 - ├─ EasyPay → 扫码 / H5 跳转 - └─ Stripe → Payment Element (PaymentIntent) + ├─ 支付宝直连 → PC 页面支付 / H5 手机网站支付 + ├─ 微信直连 → Native 扫码 / H5 支付 + ├─ EasyPay → 扫码 / 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 用户 | + +--- + ## 开发指南 ### 环境要求 diff --git a/src/app/admin/channels/page.tsx b/src/app/admin/channels/page.tsx index 602c9e0..dfb2dfb 100644 --- a/src/app/admin/channels/page.tsx +++ b/src/app/admin/channels/page.tsx @@ -328,7 +328,7 @@ function ChannelsContent() {

{t.missingToken}

-

{t.missingTokenHint}

+

{t.missingTokenHint}

); @@ -721,13 +721,13 @@ function ChannelsContent() { {channel.groupExists ? ( - + ) : ( - + @@ -997,7 +997,7 @@ function ChannelsContent() { disabled={alreadyImported} checked={syncSelected.has(group.id)} onChange={() => toggleSyncGroup(group.id)} - className="mt-0.5 h-4 w-4 rounded border-slate-300 text-indigo-500 focus:ring-indigo-500" + className={`mt-0.5 h-4 w-4 rounded text-indigo-500 focus:ring-indigo-500 ${isDark ? 'border-slate-600 bg-slate-700' : 'border-slate-300'}`} />
@@ -1061,7 +1061,7 @@ function ChannelsPageFallback() { return (
-
{locale === 'en' ? 'Loading...' : '加载中...'}
+
{locale === 'en' ? 'Loading...' : '加载中...'}
); } diff --git a/src/app/admin/dashboard/page.tsx b/src/app/admin/dashboard/page.tsx index 374deef..aec54b2 100644 --- a/src/app/admin/dashboard/page.tsx +++ b/src/app/admin/dashboard/page.tsx @@ -103,7 +103,7 @@ function DashboardContent() {

{text.missingToken}

-

{text.missingTokenHint}

+

{text.missingTokenHint}

); @@ -185,7 +185,7 @@ function DashboardPageFallback() { return (
-
{locale === 'en' ? 'Loading...' : '加载中...'}
+
{locale === 'en' ? 'Loading...' : '加载中...'}
); } diff --git a/src/app/admin/orders/page.tsx b/src/app/admin/orders/page.tsx index 671aea4..b67c2b1 100644 --- a/src/app/admin/orders/page.tsx +++ b/src/app/admin/orders/page.tsx @@ -162,7 +162,7 @@ function AdminContent() {

{text.missingToken}

-

{text.missingTokenHint}

+

{text.missingTokenHint}

); @@ -335,7 +335,7 @@ function AdminPageFallback() { return (
-
{locale === 'en' ? 'Loading...' : '加载中...'}
+
{locale === 'en' ? 'Loading...' : '加载中...'}
); } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 374deef..aec54b2 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -103,7 +103,7 @@ function DashboardContent() {

{text.missingToken}

-

{text.missingTokenHint}

+

{text.missingTokenHint}

); @@ -185,7 +185,7 @@ function DashboardPageFallback() { return (
-
{locale === 'en' ? 'Loading...' : '加载中...'}
+
{locale === 'en' ? 'Loading...' : '加载中...'}
); } diff --git a/src/app/admin/subscriptions/page.tsx b/src/app/admin/subscriptions/page.tsx index 20910d2..1801997 100644 --- a/src/app/admin/subscriptions/page.tsx +++ b/src/app/admin/subscriptions/page.tsx @@ -596,7 +596,7 @@ function SubscriptionsContent() {

{t.missingToken}

-

{t.missingTokenHint}

+

{t.missingTokenHint}

); @@ -920,7 +920,7 @@ function SubscriptionsContent() { <>
/v1/messages 调度 -
+
{plan.groupAllowMessagesDispatch ? '已启用' : '未启用'}
@@ -1241,7 +1241,7 @@ function SubscriptionsContent() { {selectedGroup.platform?.toLowerCase() === 'openai' && (
/v1/messages 调度 -
+
{selectedGroup.allow_messages_dispatch ? '已启用' : '未启用'}
@@ -1431,7 +1431,7 @@ function SubscriptionsPageFallback() { return (
-
{locale === 'en' ? 'Loading...' : '加载中...'}
+
{locale === 'en' ? 'Loading...' : '加载中...'}
); } diff --git a/src/app/globals.css b/src/app/globals.css index 669fdf1..3ab7f95 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -16,3 +16,31 @@ body { 'Microsoft YaHei', sans-serif; } + +/* Scrollbar - Dark theme */ +* { + scrollbar-width: thin; + scrollbar-color: #475569 #1e293b; +} + +*::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +*::-webkit-scrollbar-track { + background: #1e293b; +} + +*::-webkit-scrollbar-thumb { + background: #475569; + border-radius: 4px; +} + +*::-webkit-scrollbar-thumb:hover { + background: #64748b; +} + +*::-webkit-scrollbar-corner { + background: #1e293b; +} diff --git a/src/components/MainTabs.tsx b/src/components/MainTabs.tsx index 31e3362..76dc076 100644 --- a/src/components/MainTabs.tsx +++ b/src/components/MainTabs.tsx @@ -29,7 +29,7 @@ export default function MainTabs({ activeTab, onTabChange, showSubscribeTab, sho
{tabs.map((tab) => { diff --git a/src/components/SubscriptionConfirm.tsx b/src/components/SubscriptionConfirm.tsx index c029b25..c167bb0 100644 --- a/src/components/SubscriptionConfirm.tsx +++ b/src/components/SubscriptionConfirm.tsx @@ -168,7 +168,7 @@ export default function SubscriptionConfirm({ {isOpenAI && plan.defaultMappedModel && (
{pickLocaleText(locale, '默认模型', 'Default Model')} diff --git a/src/components/SubscriptionPlanCard.tsx b/src/components/SubscriptionPlanCard.tsx index 67692b3..c42628b 100644 --- a/src/components/SubscriptionPlanCard.tsx +++ b/src/components/SubscriptionPlanCard.tsx @@ -155,7 +155,7 @@ export default function SubscriptionPlanCard({ plan, onSubscribe, isDark, locale {isOpenAI && plan.defaultMappedModel && (
{pickLocaleText(locale, '默认模型', 'Default Model')} diff --git a/src/components/UserSubscriptions.tsx b/src/components/UserSubscriptions.tsx index 4245550..6fd7721 100644 --- a/src/components/UserSubscriptions.tsx +++ b/src/components/UserSubscriptions.tsx @@ -97,7 +97,12 @@ export default function UserSubscriptions({ subscriptions, onRenew, isDark, loca @@ -107,13 +112,13 @@ export default function UserSubscriptions({ subscriptions, onRenew, isDark, loca {/* Dates */}
- {pickLocaleText(locale, '开始', 'Start')} + {pickLocaleText(locale, '开始', 'Start')}

{formatDate(sub.starts_at)}

- {pickLocaleText(locale, '到期', 'Expires')} + {pickLocaleText(locale, '到期', 'Expires')}

{formatDate(sub.expires_at)}

diff --git a/src/components/admin/OrderTable.tsx b/src/components/admin/OrderTable.tsx index 5ce4c3b..b39a7d5 100644 --- a/src/components/admin/OrderTable.tsx +++ b/src/components/admin/OrderTable.tsx @@ -85,7 +85,7 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da {text.actions} - + {orders.map((order) => { const statusInfo = { label: formatStatus(order.status, locale), @@ -128,12 +128,12 @@ export default function OrderTable({ orders, onRetry, onCancel, onViewDetail, da {order.id.slice(0, 12)}... - + {order.userName || `#${order.userId}`} {order.userEmail || '-'} {order.userNotes || '-'} - + {currency} {order.amount.toFixed(2)} diff --git a/src/components/admin/RefundDialog.tsx b/src/components/admin/RefundDialog.tsx index e5732cf..36958bc 100644 --- a/src/components/admin/RefundDialog.tsx +++ b/src/components/admin/RefundDialog.tsx @@ -81,12 +81,12 @@ export default function RefundDialog({
{text.orderId}
-
{orderId}
+
{orderId}
{text.amount}
-
+
{currency} {amount.toFixed(2)}
@@ -127,7 +127,7 @@ export default function RefundDialog({ onChange={(e) => setForce(e.target.checked)} className={['rounded', dark ? 'border-slate-600' : 'border-gray-300'].join(' ')} /> - {text.forceRefund} + {text.forceRefund} )}
@@ -147,7 +147,10 @@ export default function RefundDialog({