From 886389939e1422cb979f93f4c14ead47ef1f1dd3 Mon Sep 17 00:00:00 2001 From: erio Date: Sat, 14 Mar 2026 03:45:37 +0800 Subject: [PATCH] style: format all files with Prettier --- .claude/plan.md | 149 +++--- README.md | 152 +++--- docs/payment-alipay.md | 32 +- docs/payment-wxpay.md | 37 +- src/__tests__/lib/order/fee.test.ts | 7 +- src/app/admin/channels/page.tsx | 52 +- src/app/admin/subscriptions/page.tsx | 173 ++++--- .../admin/subscription-plans/[id]/route.ts | 11 +- src/app/api/admin/subscription-plans/route.ts | 25 +- src/app/api/admin/subscriptions/route.ts | 4 +- src/app/api/subscription-plans/route.ts | 6 +- src/app/api/subscriptions/my/route.ts | 5 +- src/app/pay/orders/page.tsx | 4 +- src/app/pay/page.tsx | 469 ++++++++++++------ src/app/pay/result/page.tsx | 73 ++- src/app/pay/stripe-popup/page.tsx | 8 +- src/components/ChannelCard.tsx | 13 +- src/components/MainTabs.tsx | 16 +- src/components/OrderStatus.tsx | 49 +- src/components/PaymentForm.tsx | 13 +- src/components/PaymentQRCode.tsx | 10 +- src/components/PurchaseFlow.tsx | 32 +- src/components/SubscriptionConfirm.tsx | 34 +- src/components/SubscriptionPlanCard.tsx | 38 +- src/components/TopUpModal.tsx | 12 +- src/components/UserSubscriptions.tsx | 63 ++- src/components/admin/OrderTable.tsx | 4 +- src/components/admin/RefundDialog.tsx | 8 +- src/lib/order/service.ts | 32 +- src/lib/platform-style.ts | 99 +++- src/lib/sub2api/client.ts | 4 +- src/lib/subscription-utils.ts | 18 +- src/lib/system-config.ts | 18 +- 33 files changed, 1082 insertions(+), 588 deletions(-) diff --git a/.claude/plan.md b/.claude/plan.md index e269813..86d3de2 100644 --- a/.claude/plan.md +++ b/.claude/plan.md @@ -3,6 +3,7 @@ ## 一、概述 基于 Pincc 参考界面,改造 Sub2ApiPay 项目,新增: + - **用户页面**:双 Tab(按量付费 / 包月套餐),渠道卡片展示,充值弹窗,订阅购买流程 - **管理员界面**:渠道管理、订阅套餐管理、系统配置 - **数据库存储配置**:支付渠道等配置从环境变量迁移至数据库,支持运行时修改 @@ -90,6 +91,7 @@ model Order { ``` **设计理由**:订阅订单和余额充值订单共享同一套支付流程(创建→支付→回调→履约),仅在最终「履约」步骤不同: + - `balance`:调用 `createAndRedeem()` 充值余额 - `subscription`:调用 Sub2API `POST /admin/subscriptions/assign` 分配订阅 @@ -101,54 +103,60 @@ model Order { ```typescript // 获取所有分组(管理员) -async function getAllGroups(): Promise +async function getAllGroups(): Promise; // GET /api/v1/admin/groups/all // 获取单个分组 -async function getGroup(groupId: number): Promise +async function getGroup(groupId: number): Promise; // GET /api/v1/admin/groups/:id // 分配订阅(支付成功后调用) -async function assignSubscription(userId: number, groupId: number, validityDays: number, notes?: string): Promise +async function assignSubscription( + userId: number, + groupId: number, + validityDays: number, + notes?: string, +): Promise; // POST /api/v1/admin/subscriptions/assign // 获取用户的订阅列表 -async function getUserSubscriptions(userId: number): Promise +async function getUserSubscriptions(userId: number): Promise; // GET /api/v1/admin/users/:id/subscriptions // 延长订阅(续费) -async function extendSubscription(subscriptionId: number, days: number): Promise +async function extendSubscription(subscriptionId: number, days: number): Promise; // 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 + 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 + 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; } ``` @@ -158,29 +166,29 @@ interface Sub2ApiSubscription { ### 4.1 用户 API -| 方法 | 路径 | 说明 | -|------|------|------| -| GET | `/api/channels` | 获取已启用的渠道列表(用户token验证 + 校验Sub2API分组是否存在) | -| GET | `/api/subscription-plans` | 获取可售卖的订阅套餐列表(同上校验) | -| POST | `/api/orders` | **扩展**:支持 `order_type: "subscription"` + `plan_id` | -| GET | `/api/subscriptions/my` | 获取当前用户的活跃订阅列表 | +| 方法 | 路径 | 说明 | +| ---- | ------------------------- | --------------------------------------------------------------- | +| 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` | 批量更新系统配置 | +| 方法 | 路径 | 说明 | +| ------ | ------------------------------------ | --------------------------------- | +| 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` | 批量更新系统配置 | --- @@ -191,12 +199,13 @@ interface Sub2ApiSubscription { ```typescript interface CreateOrderInput { // 现有字段... - orderType?: 'balance' | 'subscription' // 新增 - planId?: string // 新增(订阅时必填) + orderType?: 'balance' | 'subscription'; // 新增 + planId?: string; // 新增(订阅时必填) } ``` 订阅订单创建时的校验逻辑: + 1. 验证 `planId` 对应的 SubscriptionPlan 存在且 `forSale=true` 2. 调用 Sub2API 验证 `groupId` 对应的分组仍然存在且 status=active 3. 金额使用 plan.price(不允许用户自定义) @@ -259,22 +268,23 @@ if (order.orderType === 'subscription') { ``` **条件逻辑**: + - 如果管理员 **没有配置渠道**(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步骤图标) | +| 组件 | 说明 | +| -------------------------- | -------------------------------------- | +| `ChannelCard.tsx` | 渠道卡片(平台标签、倍率、模型标签等) | +| `ChannelGrid.tsx` | 渠道卡片网格容器 | +| `TopUpModal.tsx` | 充值金额选择弹窗 | +| `SubscriptionPlanCard.tsx` | 订阅套餐卡片 | +| `SubscriptionConfirm.tsx` | 订阅确认订单页 | +| `UserSubscriptions.tsx` | 用户已有订阅展示 | +| `MainTabs.tsx` | 按量付费/包月套餐 Tab 切换 | +| `PurchaseFlow.tsx` | 购买流程说明(4步骤图标) | ### 6.3 异常处理 @@ -289,11 +299,11 @@ if (order.orderType === 'subscription') { ### 7.1 页面路由 -| 路由 | 说明 | -|------|------| -| `/admin/channels` | 渠道管理(列表 + 编辑弹窗,参考 channel-conf.png) | -| `/admin/subscriptions` | 订阅套餐管理 + 已有订阅列表 | -| `/admin/settings` | 系统配置(支付渠道配置、业务参数等) | +| 路由 | 说明 | +| ---------------------- | -------------------------------------------------- | +| `/admin/channels` | 渠道管理(列表 + 编辑弹窗,参考 channel-conf.png) | +| `/admin/subscriptions` | 订阅套餐管理 + 已有订阅列表 | +| `/admin/settings` | 系统配置(支付渠道配置、业务参数等) | ### 7.2 渠道管理页(/admin/channels) @@ -309,6 +319,7 @@ if (order.orderType === 'subscription') { ### 7.3 订阅套餐管理页(/admin/subscriptions) 两个区域: + 1. **套餐配置**: - 列表:套餐名 | 关联分组 | 价格 | 有效天数 | 启用售卖 | Sub2API状态 | 操作 - 新建/编辑表单:选择 Sub2API 分组 → 配置名称、价格、原价、有效天数、特性描述、启用售卖 @@ -320,10 +331,11 @@ if (order.orderType === 'subscription') { ### 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_* 等 +- **显示配置**:PAY*HELP_IMAGE_URL、PAY_HELP_TEXT、PAYMENT_SUBLABEL*\* 等 - **前端定制**:站点名称、联系客服信息等 配置优先级:**数据库配置 > 环境变量**(环境变量作为默认值/回退值) @@ -360,10 +372,12 @@ async function getConfigs(keys: string[]): Promise> { ... ## 九、管理员入口 管理员通过以下方式进入: + 1. Sub2API 管理面板中跳转(携带 admin token) 2. 直接访问 `/admin?token=xxx`(现有机制) 管理员页面新增导航侧边栏: + - 订单管理(现有) - 数据概览(现有) - **渠道管理**(新增) @@ -375,27 +389,32 @@ async function getConfigs(keys: string[]): Promise> { ... ## 十、实施顺序 ### Phase 1:数据库 & 基础设施(预估 2-3 步) + 1. Prisma schema 变更 + migration 2. SystemConfig 服务层(CRUD + 缓存) 3. Sub2API client 扩展(分组/订阅 API) ### Phase 2:管理员 API & 页面(预估 4-5 步) + 4. 渠道管理 API + 页面 5. 订阅套餐管理 API + 页面 6. 系统配置 API + 页面 7. 管理员导航侧边栏 ### Phase 3:订单服务改造(预估 2 步) + 8. Order 模型扩展 + 订阅订单创建逻辑 9. 订阅履约逻辑(executeSubscriptionFulfillment) ### Phase 4:用户页面改造(预估 3-4 步) + 10. 用户 API(channels、subscription-plans、subscriptions/my) 11. 按量付费 Tab(ChannelGrid + TopUpModal) 12. 包月套餐 Tab(SubscriptionPlanCard + SubscriptionConfirm) 13. 用户订阅展示 + 续费 + 异常处理 ### Phase 5:配置迁移 & 收尾(预估 1-2 步) + 14. getEnv() 改造(数据库优先 + 环境变量回退) 15. 测试 + 端到端验证 diff --git a/README.md b/README.md index 24f9087..0ad3213 100644 --- a/README.md +++ b/README.md @@ -115,28 +115,28 @@ PAYMENT_PROVIDERS=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` | 同步跳转地址(可选) | +| 变量 | 说明 | +| -------------------- | ---------------------------- | +| `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` | 异步回调地址 | +| 变量 | 说明 | +| --------------------- | ------------------------------- | +| `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(支付宝 / 微信支付聚合) @@ -178,15 +178,15 @@ PAYMENT_PROVIDERS=easypay | 变量 | 说明 | 默认值 | | -------------------------------- | ---------------------------------------- | -------------------------- | -| `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` | +| `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 定制(可选) @@ -297,11 +297,11 @@ docker compose exec app npx prisma migrate deploy 在 Sub2API 管理后台可配置以下页面链接: -| 页面 | 链接 | 说明 | -| -------- | ------------------------------------ | ----------------------- | -| 充值页面 | `https://pay.example.com/pay` | 用户充值入口 | -| 我的订单 | `https://pay.example.com/pay/orders` | 用户查看自己的充值记录 | -| 管理后台 | `https://pay.example.com/admin` | 管理后台入口(仅管理员)| +| 页面 | 链接 | 说明 | +| -------- | ------------------------------------ | ------------------------ | +| 充值页面 | `https://pay.example.com/pay` | 用户充值入口 | +| 我的订单 | `https://pay.example.com/pay/orders` | 用户查看自己的充值记录 | +| 管理后台 | `https://pay.example.com/admin` | 管理后台入口(仅管理员) | Sub2API **v0.1.88** 及以上版本会自动拼接以下参数,无需手动添加: @@ -319,13 +319,13 @@ Sub2API **v0.1.88** 及以上版本会自动拼接以下参数,无需手动添 访问:`https://pay.example.com/admin?token=YOUR_ADMIN_TOKEN` -| 模块 | 路径 | 说明 | -| -------- | ---------------------- | ------------------------------------------- | -| 总览 | `/admin` | 聚合入口,卡片式导航到各管理模块 | +| 模块 | 路径 | 说明 | +| -------- | ---------------------- | ---------------------------------------------- | +| 总览 | `/admin` | 聚合入口,卡片式导航到各管理模块 | | 订单管理 | `/admin/orders` | 按状态筛选、分页浏览、订单详情、重试/取消/退款 | -| 数据概览 | `/admin/dashboard` | 收入统计、订单趋势、支付方式分布 | -| 渠道管理 | `/admin/channels` | 配置 API 渠道与倍率,支持从 Sub2API 同步 | -| 订阅管理 | `/admin/subscriptions` | 管理订阅套餐与用户订阅 | +| 数据概览 | `/admin/dashboard` | 收入统计、订单趋势、支付方式分布 | +| 渠道管理 | `/admin/channels` | 配置 API 渠道与倍率,支持从 Sub2API 同步 | +| 订阅管理 | `/admin/subscriptions` | 管理订阅套餐与用户订阅 | --- @@ -365,55 +365,55 @@ Sub2API **v0.1.88** 及以上版本会自动拼接以下参数,无需手动添 用户侧接口,通过 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/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 回调 | +| 方法 | 路径 | 说明 | +| ------ | ---------------------- | ---------------------------- | +| `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 用户 | +| 方法 | 路径 | 说明 | +| -------- | ----------------------------------- | -------------------------- | +| `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/docs/payment-alipay.md b/docs/payment-alipay.md index b50b3ec..207c2f3 100644 --- a/docs/payment-alipay.md +++ b/docs/payment-alipay.md @@ -4,10 +4,10 @@ 本项目通过直接对接 **支付宝开放平台** 实现收款,不依赖任何三方聚合支付平台。支持以下产品: -| 产品 | API 方法 | 场景 | -|------|---------|------| -| 电脑网站支付 | `alipay.trade.page.pay` | PC 浏览器扫码 | -| 手机网站支付 | `alipay.trade.wap.pay` | 移动端 H5 拉起支付宝 | +| 产品 | API 方法 | 场景 | +| ------------ | ----------------------- | -------------------- | +| 电脑网站支付 | `alipay.trade.page.pay` | PC 浏览器扫码 | +| 手机网站支付 | `alipay.trade.wap.pay` | 移动端 H5 拉起支付宝 | 签名算法:**RSA2 (SHA256withRSA)**,密钥格式 **PKCS8**。 @@ -22,11 +22,11 @@ 支付宝公钥模式涉及 **三把密钥**,务必区分: -| 密钥 | 来源 | 用途 | 对应环境变量 | -|------|------|------|-------------| -| **应用私钥** | 你自己生成 | 对请求参数签名 | `ALIPAY_PRIVATE_KEY` | -| **支付宝公钥** | 上传应用公钥后,支付宝返回 | 验证回调通知签名 | `ALIPAY_PUBLIC_KEY` | -| 应用公钥 | 你自己生成 | 上传到支付宝后台 | (不配置到项目中) | +| 密钥 | 来源 | 用途 | 对应环境变量 | +| -------------- | -------------------------- | ---------------- | -------------------- | +| **应用私钥** | 你自己生成 | 对请求参数签名 | `ALIPAY_PRIVATE_KEY` | +| **支付宝公钥** | 上传应用公钥后,支付宝返回 | 验证回调通知签名 | `ALIPAY_PUBLIC_KEY` | +| 应用公钥 | 你自己生成 | 上传到支付宝后台 | (不配置到项目中) | > **常见错误**:把「应用公钥」填到 `ALIPAY_PUBLIC_KEY`。必须使用「支付宝公钥」,否则回调验签永远失败。 @@ -113,13 +113,13 @@ src/app/pay/ ## 支持的 API 能力 -| 能力 | API | 说明 | -|------|-----|------| -| 创建支付 | `alipay.trade.page.pay` / `wap.pay` | GET 跳转方式 | -| 查询订单 | `alipay.trade.query` | 主动查询交易状态 | -| 关闭订单 | `alipay.trade.close` | 超时关单 | -| 退款 | `alipay.trade.refund` | 全额退款 | -| 异步通知 | POST 回调 | RSA2 验签 | +| 能力 | API | 说明 | +| -------- | ----------------------------------- | ---------------- | +| 创建支付 | `alipay.trade.page.pay` / `wap.pay` | GET 跳转方式 | +| 查询订单 | `alipay.trade.query` | 主动查询交易状态 | +| 关闭订单 | `alipay.trade.close` | 超时关单 | +| 退款 | `alipay.trade.refund` | 全额退款 | +| 异步通知 | POST 回调 | RSA2 验签 | ## 注意事项 diff --git a/docs/payment-wxpay.md b/docs/payment-wxpay.md index 30300b6..02f7c6d 100644 --- a/docs/payment-wxpay.md +++ b/docs/payment-wxpay.md @@ -4,10 +4,10 @@ 本项目通过直接对接 **微信支付 APIv3** 实现收款。使用 **公钥模式** 验签(非平台证书模式),支持以下产品: -| 产品 | API | 场景 | -|------|-----|------| +| 产品 | API | 场景 | +| ----------- | ----------------------------- | -------------------------------------- | | Native 支付 | `/v3/pay/transactions/native` | PC 扫码支付(生成 `weixin://` 二维码) | -| H5 支付 | `/v3/pay/transactions/h5` | 移动端浏览器拉起微信 | +| H5 支付 | `/v3/pay/transactions/h5` | 移动端浏览器拉起微信 | > H5 支付需要在微信支付商户后台单独签约开通。如果未开通,移动端会自动降级到 Native 扫码。 @@ -26,13 +26,13 @@ 微信支付 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` | +| 密钥 | 来源 | 用途 | 对应环境变量 | +| ------------------- | ----------------- | ------------------------- | --------------------- | +| **商户 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 平台证书模式**:本项目使用公钥模式,直接用微信支付公钥验签,不需要定期拉取/更新平台证书,部署更简单。 @@ -146,14 +146,14 @@ src/app/api/wxpay/ ## 支持的 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 解密 | +| 能力 | 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 的关系 @@ -173,6 +173,7 @@ src/app/api/wxpay/ ### Q: 通知验签失败 检查以下几点: + 1. `WXPAY_PUBLIC_KEY` 是否是 **微信支付公钥**(不是商户公钥或平台证书) 2. `WXPAY_PUBLIC_KEY_ID` 是否与通知 header 中的 `Wechatpay-Serial` 匹配 3. 服务器时间是否准确(NTP 同步) diff --git a/src/__tests__/lib/order/fee.test.ts b/src/__tests__/lib/order/fee.test.ts index 3fe0101..23a0940 100644 --- a/src/__tests__/lib/order/fee.test.ts +++ b/src/__tests__/lib/order/fee.test.ts @@ -7,7 +7,12 @@ describe('calculatePayAmount', () => { { 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: 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 }) => { diff --git a/src/app/admin/channels/page.tsx b/src/app/admin/channels/page.tsx index dfb2dfb..c603a07 100644 --- a/src/app/admin/channels/page.tsx +++ b/src/app/admin/channels/page.tsx @@ -285,7 +285,9 @@ function ChannelsContent() { if (c.key === 'BALANCE_PAYMENT_DISABLED') setRcBalanceEnabled(c.value !== 'true'); } } - } catch { /* ignore */ } + } catch { + /* ignore */ + } }, [token]); const saveRechargeConfig = async () => { @@ -302,7 +304,12 @@ function ChannelsContent() { configs: [ { key: 'PRODUCT_NAME_PREFIX', value: rcPrefix.trim(), group: 'payment', label: '商品名前缀' }, { key: 'PRODUCT_NAME_SUFFIX', value: rcSuffix.trim(), group: 'payment', label: '商品名后缀' }, - { key: 'BALANCE_PAYMENT_DISABLED', value: rcBalanceEnabled ? 'false' : 'true', group: 'payment', label: '余额充值禁用' }, + { + key: 'BALANCE_PAYMENT_DISABLED', + value: rcBalanceEnabled ? 'false' : 'true', + group: 'payment', + label: '余额充值禁用', + }, ], }), }); @@ -633,7 +640,12 @@ function ChannelsContent() {
-
+
{`${rcPrefix.trim() || 'Sub2API'} 100 ${rcSuffix.trim() || 'CNY'}`.trim()}
@@ -687,7 +699,11 @@ function ChannelsContent() { ) : ( - + @@ -721,14 +737,30 @@ function ChannelsContent() { - ); })} @@ -1160,12 +1206,7 @@ function SubscriptionsContent() { isDark ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-white', ].join(' ')} > -

+

{editingPlan ? t.editPlan : t.newPlan}

@@ -1173,11 +1214,7 @@ function SubscriptionsContent() { {/* Group */}
- setFormGroupId(e.target.value)} className={inputCls}> {availableGroups.map((g) => (
- diff --git a/src/components/admin/RefundDialog.tsx b/src/components/admin/RefundDialog.tsx index 36958bc..96ac30f 100644 --- a/src/components/admin/RefundDialog.tsx +++ b/src/components/admin/RefundDialog.tsx @@ -73,9 +73,7 @@ export default function RefundDialog({ return (
-
+

{text.title}

@@ -149,7 +147,9 @@ export default function RefundDialog({ disabled={loading || (requireForce && !force)} className={[ 'flex-1 rounded-lg py-2 text-sm font-medium text-white hover:bg-red-700 disabled:cursor-not-allowed', - dark ? 'bg-red-600/90 disabled:bg-slate-700 disabled:text-slate-500' : 'bg-red-600 disabled:bg-gray-300 disabled:text-gray-400', + dark + ? 'bg-red-600/90 disabled:bg-slate-700 disabled:text-slate-500' + : 'bg-red-600 disabled:bg-gray-300 disabled:text-gray-400', ].join(' ')} > {loading ? text.processing : text.confirm} diff --git a/src/lib/order/service.ts b/src/lib/order/service.ts index a7dd3ef..a189d42 100644 --- a/src/lib/order/service.ts +++ b/src/lib/order/service.ts @@ -60,7 +60,15 @@ export async function createOrder(input: CreateOrderInput): Promise = { border: 'border-orange-500/20', label: 'Claude', icon: 'M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z', - modelTag: { light: 'border-orange-500/20 bg-gradient-to-r from-orange-500/10 to-amber-500/10 text-orange-600', dark: 'border-orange-500/20 bg-gradient-to-r from-orange-500/10 to-amber-500/10 text-orange-300', dot: 'bg-orange-500' }, - button: { light: 'bg-orange-500 hover:bg-orange-600 active:bg-orange-700', dark: 'bg-orange-500/80 hover:bg-orange-500 active:bg-orange-600' }, + modelTag: { + light: 'border-orange-500/20 bg-gradient-to-r from-orange-500/10 to-amber-500/10 text-orange-600', + dark: 'border-orange-500/20 bg-gradient-to-r from-orange-500/10 to-amber-500/10 text-orange-300', + dot: 'bg-orange-500', + }, + button: { + light: 'bg-orange-500 hover:bg-orange-600 active:bg-orange-700', + dark: 'bg-orange-500/80 hover:bg-orange-500 active:bg-orange-600', + }, accent: { light: 'text-orange-600', dark: 'text-orange-300' }, }, anthropic: { @@ -29,16 +36,30 @@ const PLATFORM_STYLES: Record = { border: 'border-orange-500/20', label: 'Anthropic', icon: 'M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z', - modelTag: { light: 'border-orange-500/20 bg-gradient-to-r from-orange-500/10 to-amber-500/10 text-orange-600', dark: 'border-orange-500/20 bg-gradient-to-r from-orange-500/10 to-amber-500/10 text-orange-300', dot: 'bg-orange-500' }, - button: { light: 'bg-orange-500 hover:bg-orange-600 active:bg-orange-700', dark: 'bg-orange-500/80 hover:bg-orange-500 active:bg-orange-600' }, + modelTag: { + light: 'border-orange-500/20 bg-gradient-to-r from-orange-500/10 to-amber-500/10 text-orange-600', + dark: 'border-orange-500/20 bg-gradient-to-r from-orange-500/10 to-amber-500/10 text-orange-300', + dot: 'bg-orange-500', + }, + button: { + light: 'bg-orange-500 hover:bg-orange-600 active:bg-orange-700', + dark: 'bg-orange-500/80 hover:bg-orange-500 active:bg-orange-600', + }, accent: { light: 'text-orange-600', dark: 'text-orange-300' }, }, openai: { badge: 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30', border: 'border-green-500/20', label: 'OpenAI', - modelTag: { light: 'border-green-500/20 bg-gradient-to-r from-green-500/10 to-emerald-500/10 text-green-600', dark: 'border-green-500/20 bg-gradient-to-r from-green-500/10 to-emerald-500/10 text-green-300', dot: 'bg-green-500' }, - button: { light: 'bg-green-600 hover:bg-green-700 active:bg-green-800', dark: 'bg-green-600/80 hover:bg-green-600 active:bg-green-700' }, + modelTag: { + light: 'border-green-500/20 bg-gradient-to-r from-green-500/10 to-emerald-500/10 text-green-600', + dark: 'border-green-500/20 bg-gradient-to-r from-green-500/10 to-emerald-500/10 text-green-300', + dot: 'bg-green-500', + }, + button: { + light: 'bg-green-600 hover:bg-green-700 active:bg-green-800', + dark: 'bg-green-600/80 hover:bg-green-600 active:bg-green-700', + }, accent: { light: 'text-green-600', dark: 'text-green-300' }, icon: 'M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z', }, @@ -46,8 +67,15 @@ const PLATFORM_STYLES: Record = { badge: 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30', border: 'border-green-500/20', label: 'Codex', - modelTag: { light: 'border-green-500/20 bg-gradient-to-r from-green-500/10 to-emerald-500/10 text-green-600', dark: 'border-green-500/20 bg-gradient-to-r from-green-500/10 to-emerald-500/10 text-green-300', dot: 'bg-green-500' }, - button: { light: 'bg-green-600 hover:bg-green-700 active:bg-green-800', dark: 'bg-green-600/80 hover:bg-green-600 active:bg-green-700' }, + modelTag: { + light: 'border-green-500/20 bg-gradient-to-r from-green-500/10 to-emerald-500/10 text-green-600', + dark: 'border-green-500/20 bg-gradient-to-r from-green-500/10 to-emerald-500/10 text-green-300', + dot: 'bg-green-500', + }, + button: { + light: 'bg-green-600 hover:bg-green-700 active:bg-green-800', + dark: 'bg-green-600/80 hover:bg-green-600 active:bg-green-700', + }, accent: { light: 'text-green-600', dark: 'text-green-300' }, icon: 'M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z', }, @@ -55,8 +83,15 @@ const PLATFORM_STYLES: Record = { badge: 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30', border: 'border-blue-500/20', label: 'Gemini', - modelTag: { light: 'border-blue-500/20 bg-gradient-to-r from-blue-500/10 to-indigo-500/10 text-blue-600', dark: 'border-blue-500/20 bg-gradient-to-r from-blue-500/10 to-indigo-500/10 text-blue-300', dot: 'bg-blue-500' }, - button: { light: 'bg-blue-500 hover:bg-blue-600 active:bg-blue-700', dark: 'bg-blue-500/80 hover:bg-blue-500 active:bg-blue-600' }, + modelTag: { + light: 'border-blue-500/20 bg-gradient-to-r from-blue-500/10 to-indigo-500/10 text-blue-600', + dark: 'border-blue-500/20 bg-gradient-to-r from-blue-500/10 to-indigo-500/10 text-blue-300', + dot: 'bg-blue-500', + }, + button: { + light: 'bg-blue-500 hover:bg-blue-600 active:bg-blue-700', + dark: 'bg-blue-500/80 hover:bg-blue-500 active:bg-blue-600', + }, accent: { light: 'text-blue-600', dark: 'text-blue-300' }, icon: 'M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81', }, @@ -64,8 +99,15 @@ const PLATFORM_STYLES: Record = { badge: 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30', border: 'border-blue-500/20', label: 'Google', - modelTag: { light: 'border-blue-500/20 bg-gradient-to-r from-blue-500/10 to-indigo-500/10 text-blue-600', dark: 'border-blue-500/20 bg-gradient-to-r from-blue-500/10 to-indigo-500/10 text-blue-300', dot: 'bg-blue-500' }, - button: { light: 'bg-blue-500 hover:bg-blue-600 active:bg-blue-700', dark: 'bg-blue-500/80 hover:bg-blue-500 active:bg-blue-600' }, + modelTag: { + light: 'border-blue-500/20 bg-gradient-to-r from-blue-500/10 to-indigo-500/10 text-blue-600', + dark: 'border-blue-500/20 bg-gradient-to-r from-blue-500/10 to-indigo-500/10 text-blue-300', + dot: 'bg-blue-500', + }, + button: { + light: 'bg-blue-500 hover:bg-blue-600 active:bg-blue-700', + dark: 'bg-blue-500/80 hover:bg-blue-500 active:bg-blue-600', + }, accent: { light: 'text-blue-600', dark: 'text-blue-300' }, icon: 'M11.04 19.32Q12 21.51 12 24q0-2.49.93-4.68.96-2.19 2.58-3.81t3.81-2.55Q21.51 12 24 12q-2.49 0-4.68-.93a12.3 12.3 0 0 1-3.81-2.58 12.3 12.3 0 0 1-2.58-3.81Q12 2.49 12 0q0 2.49-.96 4.68-.93 2.19-2.55 3.81a12.3 12.3 0 0 1-3.81 2.58Q2.49 12 0 12q2.49 0 4.68.96 2.19.93 3.81 2.55t2.55 3.81', }, @@ -73,8 +115,15 @@ const PLATFORM_STYLES: Record = { badge: 'bg-pink-500/10 text-pink-600 dark:text-pink-400 border-pink-500/30', border: 'border-pink-500/20', label: 'Sora', - modelTag: { light: 'border-pink-500/20 bg-gradient-to-r from-pink-500/10 to-rose-500/10 text-pink-600', dark: 'border-pink-500/20 bg-gradient-to-r from-pink-500/10 to-rose-500/10 text-pink-300', dot: 'bg-pink-500' }, - button: { light: 'bg-pink-500 hover:bg-pink-600 active:bg-pink-700', dark: 'bg-pink-500/80 hover:bg-pink-500 active:bg-pink-600' }, + modelTag: { + light: 'border-pink-500/20 bg-gradient-to-r from-pink-500/10 to-rose-500/10 text-pink-600', + dark: 'border-pink-500/20 bg-gradient-to-r from-pink-500/10 to-rose-500/10 text-pink-300', + dot: 'bg-pink-500', + }, + button: { + light: 'bg-pink-500 hover:bg-pink-600 active:bg-pink-700', + dark: 'bg-pink-500/80 hover:bg-pink-500 active:bg-pink-600', + }, accent: { light: 'text-pink-600', dark: 'text-pink-300' }, // four-pointed sparkle star icon: 'M12 2l2.09 6.26L20.18 10l-6.09 1.74L12 18l-2.09-6.26L3.82 10l6.09-1.74L12 2z', @@ -83,8 +132,15 @@ const PLATFORM_STYLES: Record = { badge: 'bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/30', border: 'border-purple-500/20', label: 'Antigravity', - modelTag: { light: 'border-purple-500/20 bg-gradient-to-r from-purple-500/10 to-violet-500/10 text-purple-600', dark: 'border-purple-500/20 bg-gradient-to-r from-purple-500/10 to-violet-500/10 text-purple-300', dot: 'bg-purple-500' }, - button: { light: 'bg-purple-500 hover:bg-purple-600 active:bg-purple-700', dark: 'bg-purple-500/80 hover:bg-purple-500 active:bg-purple-600' }, + modelTag: { + light: 'border-purple-500/20 bg-gradient-to-r from-purple-500/10 to-violet-500/10 text-purple-600', + dark: 'border-purple-500/20 bg-gradient-to-r from-purple-500/10 to-violet-500/10 text-purple-300', + dot: 'bg-purple-500', + }, + button: { + light: 'bg-purple-500 hover:bg-purple-600 active:bg-purple-700', + dark: 'bg-purple-500/80 hover:bg-purple-500 active:bg-purple-600', + }, accent: { light: 'text-purple-600', dark: 'text-purple-300' }, // stylised angular "A" cursor shape icon: 'M12 2L4 22h4l2-5h4l2 5h4L12 2zm0 7l2.5 6h-5L12 9z', @@ -96,8 +152,15 @@ const FALLBACK_STYLE: PlatformStyleEntry = { border: 'border-slate-500/20', label: '', icon: '', - modelTag: { light: 'border-slate-500/20 bg-gradient-to-r from-slate-500/10 to-slate-400/10 text-slate-600', dark: 'border-slate-500/20 bg-gradient-to-r from-slate-500/10 to-slate-400/10 text-slate-400', dot: 'bg-slate-500' }, - button: { light: 'bg-emerald-500 hover:bg-emerald-600 active:bg-emerald-700', dark: 'bg-emerald-500/80 hover:bg-emerald-500 active:bg-emerald-600' }, + modelTag: { + light: 'border-slate-500/20 bg-gradient-to-r from-slate-500/10 to-slate-400/10 text-slate-600', + dark: 'border-slate-500/20 bg-gradient-to-r from-slate-500/10 to-slate-400/10 text-slate-400', + dot: 'bg-slate-500', + }, + button: { + light: 'bg-emerald-500 hover:bg-emerald-600 active:bg-emerald-700', + dark: 'bg-emerald-500/80 hover:bg-emerald-500 active:bg-emerald-600', + }, accent: { light: 'text-emerald-600', dark: 'text-emerald-300' }, }; diff --git a/src/lib/sub2api/client.ts b/src/lib/sub2api/client.ts index cbbc3e7..89c309b 100644 --- a/src/lib/sub2api/client.ts +++ b/src/lib/sub2api/client.ts @@ -229,7 +229,9 @@ export async function subtractBalance( // ── 用户搜索 API ── -export async function searchUsers(keyword: string): Promise<{ id: number; email: string; username: string; notes?: string }[]> { +export async function searchUsers( + keyword: string, +): Promise<{ id: number; email: string; username: string; notes?: string }[]> { const env = getEnv(); const response = await fetch( `${env.SUB2API_BASE_URL}/api/v1/admin/users?search=${encodeURIComponent(keyword)}&page=1&page_size=30`, diff --git a/src/lib/subscription-utils.ts b/src/lib/subscription-utils.ts index 9b32b71..2a975c9 100644 --- a/src/lib/subscription-utils.ts +++ b/src/lib/subscription-utils.ts @@ -23,11 +23,7 @@ export function computeValidityDays(value: number, unit: ValidityUnit, fromDate? * - unit=week, value=2 → 2周 / 2 Weeks * - unit=day, value=30 → 30天 / 30 Days */ -export function formatValidityLabel( - value: number, - unit: ValidityUnit, - locale: 'zh' | 'en', -): string { +export function formatValidityLabel(value: number, unit: ValidityUnit, locale: 'zh' | 'en'): string { const unitLabels: Record = { day: { zh: '天', en: 'Day', enPlural: 'Days' }, week: { zh: '周', en: 'Week', enPlural: 'Weeks' }, @@ -44,11 +40,7 @@ export function formatValidityLabel( * - unit=week, value=2 → /2周 / /2wk * - unit=day, value=30 → /30天 / /30d */ -export function formatValiditySuffix( - value: number, - unit: ValidityUnit, - locale: 'zh' | 'en', -): string { +export function formatValiditySuffix(value: number, unit: ValidityUnit, locale: 'zh' | 'en'): string { const unitLabels: Record = { day: { zh: '天', en: 'd' }, week: { zh: '周', en: 'wk' }, @@ -65,11 +57,7 @@ export function formatValiditySuffix( * - unit=week → "2 周" * - unit=month → "1 月" */ -export function formatValidityDisplay( - value: number, - unit: ValidityUnit, - locale: 'zh' | 'en', -): string { +export function formatValidityDisplay(value: number, unit: ValidityUnit, locale: 'zh' | 'en'): string { const unitLabels: Record = { day: { zh: '天', en: 'day(s)' }, week: { zh: '周', en: 'week(s)' }, diff --git a/src/lib/system-config.ts b/src/lib/system-config.ts index b8c567f..16a1ff1 100644 --- a/src/lib/system-config.ts +++ b/src/lib/system-config.ts @@ -85,12 +85,18 @@ export async function setSystemConfig(key: string, value: string, group?: string invalidateConfigCache(key); } -export async function setSystemConfigs(configs: { key: string; value: string; group?: string; label?: string }[]): Promise { +export async function setSystemConfigs( + configs: { key: string; value: string; group?: string; label?: string }[], +): Promise { await prisma.$transaction( configs.map((c) => prisma.systemConfig.upsert({ where: { key: c.key }, - update: { value: c.value, ...(c.group !== undefined && { group: c.group }), ...(c.label !== undefined && { label: c.label }) }, + update: { + value: c.value, + ...(c.group !== undefined && { group: c.group }), + ...(c.label !== undefined && { label: c.label }), + }, create: { key: c.key, value: c.value, group: c.group ?? 'general', label: c.label }, }), ), @@ -98,7 +104,9 @@ export async function setSystemConfigs(configs: { key: string; value: string; gr invalidateConfigCache(); } -export async function getSystemConfigsByGroup(group: string): Promise<{ key: string; value: string; label: string | null }[]> { +export async function getSystemConfigsByGroup( + group: string, +): Promise<{ key: string; value: string; label: string | null }[]> { return prisma.systemConfig.findMany({ where: { group }, select: { key: true, value: true, label: true }, @@ -106,7 +114,9 @@ export async function getSystemConfigsByGroup(group: string): Promise<{ key: str }); } -export async function getAllSystemConfigs(): Promise<{ key: string; value: string; group: string; label: string | null }[]> { +export async function getAllSystemConfigs(): Promise< + { key: string; value: string; group: string; label: string | null }[] +> { return prisma.systemConfig.findMany({ select: { key: true, value: true, group: true, label: true }, orderBy: [{ group: 'asc' }, { key: 'asc' }],
{t.colName} {t.colPlatform} {t.colRate} {channel.groupExists ? ( - - + + ) : ( - - + + @@ -1004,9 +1036,7 @@ function ChannelsContent() { {group.name} - - #{group.id} - + #{group.id} {alreadyImported && ( {t.syncAlreadyExists} diff --git a/src/app/admin/subscriptions/page.tsx b/src/app/admin/subscriptions/page.tsx index 1801997..72fadff 100644 --- a/src/app/admin/subscriptions/page.tsx +++ b/src/app/admin/subscriptions/page.tsx @@ -350,7 +350,9 @@ function SubscriptionsContent() { /* --- subs state --- */ const [subsUserId, setSubsUserId] = useState(''); const [subsKeyword, setSubsKeyword] = useState(''); - const [searchResults, setSearchResults] = useState<{ id: number; email: string; username: string; notes?: string }[]>([]); + const [searchResults, setSearchResults] = useState<{ id: number; email: string; username: string; notes?: string }[]>( + [], + ); const [searchDropdownOpen, setSearchDropdownOpen] = useState(false); const [searchTimer, setSearchTimer] = useState | null>(null); const [subs, setSubs] = useState([]); @@ -358,7 +360,6 @@ function SubscriptionsContent() { const [subsLoading, setSubsLoading] = useState(false); const [subsSearched, setSubsSearched] = useState(false); - /* --- fetch plans --- */ const fetchPlans = useCallback(async () => { if (!token) return; @@ -373,7 +374,7 @@ function SubscriptionsContent() { throw new Error(t.requestFailed); } const data = await res.json(); - setPlans(Array.isArray(data) ? data : data.plans ?? []); + setPlans(Array.isArray(data) ? data : (data.plans ?? [])); } catch { setError(t.loadFailed); } finally { @@ -388,7 +389,7 @@ function SubscriptionsContent() { const res = await fetch(`/api/admin/sub2api/groups?token=${encodeURIComponent(token)}`); if (res.ok) { const data = await res.json(); - setGroups(Array.isArray(data) ? data : data.groups ?? []); + setGroups(Array.isArray(data) ? data : (data.groups ?? [])); } } catch { /* ignore */ @@ -467,9 +468,7 @@ function SubscriptionsContent() { product_name: formProductName.trim() || null, }; try { - const url = editingPlan - ? `/api/admin/subscription-plans/${editingPlan.id}` - : '/api/admin/subscription-plans'; + const url = editingPlan ? `/api/admin/subscription-plans/${editingPlan.id}` : '/api/admin/subscription-plans'; const method = editingPlan ? 'PUT' : 'POST'; const res = await fetch(url, { method, @@ -729,12 +728,7 @@ function SubscriptionsContent() { )} {/* Tab switcher */} -
+
@@ -781,15 +775,23 @@ function SubscriptionsContent() {
-

+

{plan.name}

{plan.groupExists ? t.groupExists : t.groupMissing} @@ -844,31 +846,50 @@ function SubscriptionsContent() { {/* Plan fields grid */}
- {t.colGroup} + + {t.colGroup} +
{plan.groupId} - {plan.groupName && ({plan.groupName})} + {plan.groupName && ( + + ({plan.groupName}) + + )}
- {t.colPrice} + + {t.colPrice} +
¥{plan.price.toFixed(2)} {plan.originalPrice != null && ( - + ¥{plan.originalPrice.toFixed(2)} )}
- {t.colValidDays} + + {t.colValidDays} +
- {plan.validDays} {plan.validityUnit === 'month' ? t.unitMonth : plan.validityUnit === 'week' ? t.unitWeek : t.unitDay} + {plan.validDays}{' '} + {plan.validityUnit === 'month' + ? t.unitMonth + : plan.validityUnit === 'week' + ? t.unitWeek + : t.unitDay}
- {t.fieldSortOrder} + + {t.fieldSortOrder} +
{plan.sortOrder}
@@ -876,9 +897,16 @@ function SubscriptionsContent() { {/* ── Sub2API 分组信息(嵌套只读区域) ── */} {plan.groupExists && ( -
+
- + {t.groupInfo} @@ -889,13 +917,17 @@ function SubscriptionsContent() { {plan.groupPlatform && (
{t.platform} -
+
+ +
)} {plan.groupRateMultiplier != null && (
{t.rateMultiplier} -
{plan.groupRateMultiplier}x
+
+ {plan.groupRateMultiplier}x +
)}
@@ -920,14 +952,30 @@ function SubscriptionsContent() { <>
/v1/messages 调度 -
+
{plan.groupAllowMessagesDispatch ? '已启用' : '未启用'}
{plan.groupDefaultMappedModel && (
默认模型 -
+
{plan.groupDefaultMappedModel}
@@ -960,7 +1008,9 @@ function SubscriptionsContent() { fetchSubs(); } }} - onFocus={() => { if (searchResults.length > 0) setSearchDropdownOpen(true); }} + onFocus={() => { + if (searchResults.length > 0) setSearchDropdownOpen(true); + }} placeholder={t.searchUserId} className={inputCls} /> @@ -994,7 +1044,10 @@ function SubscriptionsContent() {
{subsUser.email}
-
- ID: {subsUser.id} -
+
ID: {subsUser.id}
)} @@ -1040,9 +1091,7 @@ function SubscriptionsContent() { {subsLoading ? (
{t.loading}
) : !subsSearched ? ( -
- {t.loading} -
+
{t.loading}
) : subs.length === 0 ? (
{t.noSubs}
) : ( @@ -1134,13 +1183,10 @@ function SubscriptionsContent() { : 'text-slate-500' }`} > - {remaining > 0 - ? `${remaining} ${t.days} ${t.remaining}` - : t.expired} + {remaining > 0 ? `${remaining} ${t.days} ${t.remaining}` : t.expired}
)}
{order.userEmail || '-'} {order.userNotes || '-'} + {currency} {order.amount.toFixed(2)}