使用注释方式隐藏分组列和分组倍率标签,便于后续恢复: 1. 注释 UsageLogsColumnDefs.jsx 中的分组列定义 2. 注释 useUsageLogsData.jsx 中的 COLUMN_KEYS.GROUP 和默认可见性配置 3. 将计费详情中的"分组倍率"改为"倍率" 后端计费逻辑不变,仍正常应用分组倍率。 Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>master
| @@ -0,0 +1,7 @@ | |||
| # backend-dev - 发现索引 | |||
| > 纯索引——每个条目应简短(Status + Report 链接 + Summary)。 | |||
| --- | |||
| <初始为空,工作中填写> | |||
| @@ -0,0 +1,7 @@ | |||
| # backend-dev - 工作日志 | |||
| > 用于上下文恢复。压缩/重启后先读此文件。 | |||
| --- | |||
| <初始为空,工作中填写> | |||
| @@ -0,0 +1,7 @@ | |||
| # 支付宝后端 - 发现记录 | |||
| > 此任务开发中的技术发现。 | |||
| --- | |||
| <初始为空> | |||
| @@ -0,0 +1,7 @@ | |||
| # 支付宝后端 - 工作日志 | |||
| > 上下文恢复时只需读此文件。 | |||
| --- | |||
| <初始为空> | |||
| @@ -0,0 +1,46 @@ | |||
| # 支付宝后端 - 任务计划 | |||
| > 所属智能体: backend-dev | |||
| > 状态: pending | |||
| > 创建: 2026-04-14 | |||
| ## 目标 | |||
| 实现支付宝当面付(扫码支付)的完整后端功能,包括配置层、控制器、模型和路由。 | |||
| ## 详细步骤 | |||
| - [ ] 1. 新建 `setting/payment_alipay.go`:配置变量 + IsAlipayConfigured() + OnAlipayConfigChanged | |||
| - [ ] 2. 修改 `model/option.go`:InitOptionMap + updateOptionMap + triggerAlipayReset | |||
| - [ ] 3. 新建 `model/topup_alipay.go`:RechargeAlipay 函数(事务+行锁+幂等) | |||
| - [ ] 4. 新建 `controller/topup_alipay.go`:客户端缓存 + 5 个 HTTP 处理函数 | |||
| - [ ] 5. 修改 `controller/topup.go`:GetTopUpInfo 添加支付宝支付方式 | |||
| - [ ] 6. 修改 `model/topup.go`:ManualCompleteTopUp 添加 "alipay" | |||
| - [ ] 7. 修改 `router/api-router.go`:注册 4 个路由 | |||
| - [ ] 8. `go build` 编译验证 | |||
| ## 涉及文件 | |||
| - `setting/payment_alipay.go` — 新建:支付宝配置 | |||
| - `setting/payment_wechat.go` — 参考:微信支付配置模式 | |||
| - `controller/topup_alipay.go` — 新建:支付宝控制器 | |||
| - `controller/topup_wechat.go` — 参考:微信支付控制器 | |||
| - `model/topup_alipay.go` — 新建:支付宝充值模型 | |||
| - `model/topup_wechat.go` — 参考:微信充值模型 | |||
| - `model/option.go` — 修改:注册配置 key | |||
| - `model/topup.go` — 修改:添加 alipay 支付方式 | |||
| - `controller/topup.go` — 修改:GetTopUpInfo | |||
| - `router/api-router.go` — 修改:注册路由 | |||
| ## 关键技术点 | |||
| - gopay alipay 子包:`github.com/go-pay/gopay/alipay` | |||
| - TradePrecreate API:当面付预下单 | |||
| - 金额单位:元(字符串 "7.00"),不是微信的分 | |||
| - 回调验签:alipay.VerifySign(alipayPublicKey, notifyReq) | |||
| - 回调响应:纯文本 "success" | |||
| - 订单号前缀:ali(区分微信的 wx) | |||
| ## 依赖 | |||
| - 无外部依赖,go-pay 已在项目中 | |||
| @@ -0,0 +1,23 @@ | |||
| # backend-dev - 任务计划 | |||
| > 角色: 后端开发 | |||
| > 状态: pending | |||
| > 分配的任务: 支付宝当面付后端完整实现 | |||
| ## 任务 | |||
| - [ ] 步骤 1: 新建 setting/payment_alipay.go — 支付宝配置变量 | |||
| - [ ] 步骤 2: 修改 model/option.go — 注册配置 key + 热更新 | |||
| - [ ] 步骤 3: 新建 model/topup_alipay.go — 充值完成处理逻辑 | |||
| - [ ] 步骤 4: 新建 controller/topup_alipay.go — 核心控制器 | |||
| - [ ] 步骤 5: 修改 controller/topup.go — GetTopUpInfo 添加支付宝 | |||
| - [ ] 步骤 6: 修改 model/topup.go — ManualCompleteTopUp 添加 alipay | |||
| - [ ] 步骤 7: 修改 router/api-router.go — 注册路由 | |||
| - [ ] 步骤 8: 编译验证 | |||
| ## 备注 | |||
| - 参考文件:controller/topup_wechat.go(微信支付控制器)、setting/payment_wechat.go(配置)、model/topup_wechat.go(模型) | |||
| - 使用 gopay v1.5.117 的 alipay 子包 | |||
| - 关键差异:金额单位是元(不是分)、回调响纯文本 "success"(不是 JSON) | |||
| - 完成后找 reviewer 审查 | |||
| @@ -0,0 +1,26 @@ | |||
| # alipay-payment - 架构决策记录 | |||
| > 记录每个决策及其理由。 | |||
| --- | |||
| ## D1: 支付产品选择 | |||
| - 日期: 2026-04-14 | |||
| - 决策: 使用支付宝当面付(扫码支付) | |||
| - 理由: 与现有微信 Native 支付模式一致,可最大程度复用架构 | |||
| - 考虑过的替代方案: PC 网站支付、H5 手机支付 | |||
| ## D2: 签名方式 | |||
| - 日期: 2026-04-14 | |||
| - 决策: 公钥模式(RSA2) | |||
| - 理由: 参数少,配置简单,只需 AppID + 应用私钥 + 支付宝公钥 | |||
| - 考虑过的替代方案: 证书模式(更安全但配置复杂) | |||
| ## D3: 团队配置 | |||
| - 日期: 2026-04-14 | |||
| - 决策: 3 角色(backend-dev + frontend-dev + reviewer) | |||
| - 理由: 前后端可并行开发,支付涉及资金安全需要代码审查 | |||
| - 考虑过的替代方案: 2 角色(无审查)、1 角色(全栈) | |||
| @@ -0,0 +1,7 @@ | |||
| # alipay-payment - 发现与技术记录 | |||
| > 由团队智能体自动更新。每条标注来源。 | |||
| --- | |||
| <工作中添加条目> | |||
| @@ -0,0 +1,7 @@ | |||
| # frontend-dev - 发现索引 | |||
| > 纯索引——每个条目应简短。 | |||
| --- | |||
| <初始为空,工作中填写> | |||
| @@ -0,0 +1,7 @@ | |||
| # frontend-dev - 工作日志 | |||
| > 用于上下文恢复。 | |||
| --- | |||
| <初始为空,工作中填写> | |||
| @@ -0,0 +1,7 @@ | |||
| # 支付宝前端 - 发现记录 | |||
| > 此任务开发中的技术发现。 | |||
| --- | |||
| <初始为空> | |||
| @@ -0,0 +1,7 @@ | |||
| # 支付宝前端 - 工作日志 | |||
| > 上下文恢复时只需读此文件。 | |||
| --- | |||
| <初始为空> | |||
| @@ -0,0 +1,36 @@ | |||
| # 支付宝前端 - 任务计划 | |||
| > 所属智能体: frontend-dev | |||
| > 状态: pending | |||
| > 创建: 2026-04-14 | |||
| ## 目标 | |||
| 实现支付宝当面付的前端功能,包括管理后台设置页、用户充值页改造、二维码模态框重构。 | |||
| ## 详细步骤 | |||
| - [ ] 1. 重构 `WechatPayQRCodeModal.jsx` → 通用 `QRCodePayModal`(新增 title/subtitle/statusApiPath props) | |||
| - [ ] 2. 新建 `SettingsPaymentGatewayAlipay.jsx`:管理后台支付宝设置页 | |||
| - [ ] 3. 修改 `PaymentSetting.jsx`:导入支付宝设置组件 + getOptions 解析 + 渲染 | |||
| - [ ] 4. 修改 `topup/index.jsx`:支付宝状态变量 + API 调用 + 模态框渲染 | |||
| - [ ] 5. 修改 `RechargeCard.jsx`:传递 enableAlipayTopUp prop | |||
| - [ ] 6. 更新 i18n 翻译文件 | |||
| - [ ] 7. `bun run build` 验证编译通过 | |||
| ## 涉及文件 | |||
| - `web/src/components/topup/WechatPayQRCodeModal.jsx` — 重构为通用模态框 | |||
| - `web/src/pages/Setting/Payment/SettingsPaymentGatewayAlipay.jsx` — 新建 | |||
| - `web/src/components/settings/PaymentSetting.jsx` — 修改 | |||
| - `web/src/components/topup/index.jsx` — 修改 | |||
| - `web/src/components/topup/RechargeCard.jsx` — 修改(最小) | |||
| - `web/src/i18n/locales/en.json` — 修改(翻译) | |||
| ## 关键技术点 | |||
| - QRCodePayModal 需要兼容微信和支付宝两种场景 | |||
| - 支付宝轮询 API: /api/user/alipay/pay/status | |||
| - 支付宝创建订单 API: POST /api/user/alipay/pay | |||
| - 支付宝图标 SiAlipay 已存在于 RechargeCard.jsx | |||
| - i18n key 遵循现有命名规范 | |||
| @@ -0,0 +1,21 @@ | |||
| # frontend-dev - 任务计划 | |||
| > 角色: 前端开发 | |||
| > 状态: pending | |||
| > 分配的任务: 支付宝当面付前端完整实现 | |||
| ## 任务 | |||
| - [ ] 步骤 1: 重构 WechatPayQRCodeModal.jsx 为通用 QRCodePayModal | |||
| - [ ] 步骤 2: 新建 SettingsPaymentGatewayAlipay.jsx 管理后台设置页 | |||
| - [ ] 步骤 3: 修改 PaymentSetting.jsx 注册支付宝设置 | |||
| - [ ] 步骤 4: 修改 topup/index.jsx 添加支付宝支付逻辑 | |||
| - [ ] 步骤 5: 修改 RechargeCard.jsx 传递 enableAlipayTopUp | |||
| - [ ] 步骤 6: 更新 i18n 翻译文件 | |||
| - [ ] 步骤 7: bun run build 验证 | |||
| ## 备注 | |||
| - 参考文件:WechatPayQRCodeModal.jsx(二维码模态框)、SettingsPaymentGatewayWechat.jsx(设置页) | |||
| - 支付宝图标 SiAlipay 已存在于 RechargeCard.jsx | |||
| - 完成后找 reviewer 审查 | |||
| @@ -0,0 +1,16 @@ | |||
| # alipay-payment - 进度日志 | |||
| > 按时间线记录。每条记录谁做了什么。 | |||
| --- | |||
| ## 2026-04-14 Session 1 — 团队搭建 | |||
| ### 已完成 | |||
| - [x] 读取设计文档 | |||
| - [x] 确认团队配置:3 角色(backend-dev, frontend-dev, reviewer) | |||
| - [x] 创建规划文件和目录结构 | |||
| ### 待办 | |||
| - [ ] 启动团队成员 | |||
| - [ ] 开始并行开发 | |||
| @@ -0,0 +1,7 @@ | |||
| # reviewer - 发现索引 | |||
| > 纯索引。 | |||
| --- | |||
| <初始为空,工作中填写> | |||
| @@ -0,0 +1,5 @@ | |||
| # reviewer - 工作日志 | |||
| --- | |||
| <初始为空> | |||
| @@ -0,0 +1,16 @@ | |||
| # reviewer - 任务计划 | |||
| > 角色: 代码审查 | |||
| > 状态: pending | |||
| > 分配的任务: 等待 backend-dev 和 frontend-dev 完成后进行代码审查 | |||
| ## 任务 | |||
| - [ ] 审查 backend-dev 支付宝后端代码(安全 + 质量) | |||
| - [ ] 审查 frontend-dev 支付宝前端代码(质量 + 体验) | |||
| ## 备注 | |||
| - 支付涉及资金安全,重点关注:签名验签、金额处理、幂等性、并发安全 | |||
| - 后端审查重点:回调验签、金额单位(元 vs 分)、订单幂等 | |||
| - 前端审查重点:二维码模态框重构兼容性、支付状态轮询 | |||
| @@ -0,0 +1,47 @@ | |||
| # alipay-payment - 主计划 | |||
| > 状态: PLANNING | |||
| > 创建: 2026-04-14 | |||
| > 更新: 2026-04-14 | |||
| > 团队: alipay-payment (backend-dev, frontend-dev, reviewer) | |||
| > 决策记录: .plans/alipay-payment/decisions.md | |||
| --- | |||
| ## 1. 项目概述 | |||
| 在 New-API 项目中集成支付宝当面付(扫码支付),复用微信支付架构模式。使用 go-pay 框架,公钥模式签名,仅支持充值业务。 | |||
| 设计文档:`D:\markdown\workspace-lanqi\new-api\2026-04-14\支付宝当面付集成设计.md` | |||
| --- | |||
| ## 2. 文档索引 | |||
| | 文档 | 位置 | 内容 | | |||
| |------|------|------| | |||
| | 设计文档(Obsidian) | D:\markdown\workspace-lanqi\new-api\2026-04-14\支付宝当面付集成设计.md | 完整设计方案 | | |||
| --- | |||
| ## 3. 阶段概览 | |||
| - 阶段 1: 后端开发 — backend-dev 实现配置层、控制器、模型、路由 | |||
| - 阶段 2: 前端开发 — frontend-dev 实现管理设置页、充值页改造、二维码模态框重构(可与阶段 1 并行) | |||
| - 阶段 3: 代码审查 — reviewer 审查后端和前端代码 | |||
| --- | |||
| ## 4. 任务汇总 | |||
| | # | 任务 | 负责人 | 状态 | 计划文件 | | |||
| |---|------|--------|------|----------| | |||
| | T1 | 后端支付宝支付完整实现 | backend-dev | pending | .plans/alipay-payment/backend-dev/task-alipay-backend/ | | |||
| | T2 | 前端支付宝支付完整实现 | frontend-dev | pending | .plans/alipay-payment/frontend-dev/task-alipay-frontend/ | | |||
| | T3 | 代码审查 | reviewer | pending | .plans/alipay-payment/reviewer/ | | |||
| --- | |||
| ## 5. 当前阶段 | |||
| 准备启动阶段 1 和阶段 2(并行开发)。 | |||
| @@ -0,0 +1,569 @@ | |||
| # Channel PublicName 实施计划 | |||
| > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |||
| **Goal:** 为 Channel 添加 `public_name` 字段,让管理员可以为每个渠道设置用户可见的友好名称,替代前端硬编码的"通道一/二/三"。 | |||
| **Architecture:** 后端 Channel 模型新增字段 → AutoMigrate 自动建列 → 迁移函数回填数据 → 定价查询和 API 返回公共名称 → 前端表单支持编辑 → 展示层使用公共名称。 | |||
| **Tech Stack:** Go (GORM) / React (Semi Design) / SQLite+MySQL+PostgreSQL | |||
| --- | |||
| ### Task 1: Channel 模型添加 PublicName 字段 | |||
| **Files:** | |||
| - Modify: `model/channel.go:28` | |||
| - [ ] **Step 1: 在 Channel 结构体 Name 字段后添加 PublicName** | |||
| 当前代码(`model/channel.go:28`): | |||
| ```go | |||
| Name string `json:"name" gorm:"index"` | |||
| Weight *uint `json:"weight" gorm:"default:0"` | |||
| ``` | |||
| 改为: | |||
| ```go | |||
| Name string `json:"name" gorm:"index"` | |||
| PublicName string `json:"public_name" gorm:"size:255;default:''"` | |||
| Weight *uint `json:"weight" gorm:"default:0"` | |||
| ``` | |||
| - [ ] **Step 2: 验证编译通过** | |||
| Run: `cd D:/code/new-api && go build ./model/...` | |||
| Expected: 编译成功,无错误 | |||
| - [ ] **Step 3: Commit** | |||
| ```bash | |||
| git add model/channel.go | |||
| git commit -m "feat: add PublicName field to Channel model" | |||
| ``` | |||
| --- | |||
| ### Task 2: 修改 GetAllChannelsForBinding 查询包含 public_name | |||
| **Files:** | |||
| - Modify: `model/channel.go:282` | |||
| - [ ] **Step 1: 修改 Select 列** | |||
| 当前代码(`model/channel.go:282`): | |||
| ```go | |||
| err := DB.Select("id, name, type, remark"). | |||
| ``` | |||
| 改为: | |||
| ```go | |||
| err := DB.Select("id, name, public_name, type, remark"). | |||
| ``` | |||
| - [ ] **Step 2: 验证编译通过** | |||
| Run: `cd D:/code/new-api && go build ./model/...` | |||
| Expected: 编译成功 | |||
| - [ ] **Step 3: Commit** | |||
| ```bash | |||
| git add model/channel.go | |||
| git commit -m "feat: include public_name in GetAllChannelsForBinding query" | |||
| ``` | |||
| --- | |||
| ### Task 3: ChannelPricingWithChannel 扩展 + SQL 查询修改 | |||
| **Files:** | |||
| - Modify: `model/channel_pricing.go:254` (结构体) | |||
| - Modify: `model/channel_pricing.go:299` (SELECT) | |||
| - Modify: `model/channel_pricing.go:319` (GROUP BY) | |||
| - [ ] **Step 1: 结构体添加 ChannelPublicName 字段** | |||
| 当前代码(`model/channel_pricing.go:254`): | |||
| ```go | |||
| ChannelName string `json:"channel_name"` | |||
| ChannelType int `json:"channel_type"` | |||
| ``` | |||
| 改为: | |||
| ```go | |||
| ChannelName string `json:"channel_name"` | |||
| ChannelPublicName string `json:"channel_public_name"` | |||
| ChannelType int `json:"channel_type"` | |||
| ``` | |||
| - [ ] **Step 2: SELECT 子句添加 channels.public_name** | |||
| 当前代码(`model/channel_pricing.go:299`): | |||
| ```go | |||
| Select(`abilities.channel_id, channels.name as channel_name, channels.type as channel_type, | |||
| ``` | |||
| 改为: | |||
| ```go | |||
| Select(`abilities.channel_id, channels.name as channel_name, channels.public_name as channel_public_name, channels.type as channel_type, | |||
| ``` | |||
| - [ ] **Step 3: GROUP BY 子句添加 channels.public_name** | |||
| 当前代码(`model/channel_pricing.go:319`): | |||
| ```go | |||
| Group("abilities.channel_id, channels.name, channels.type, channel_pricings.quota_type, | |||
| ``` | |||
| 改为: | |||
| ```go | |||
| Group("abilities.channel_id, channels.name, channels.public_name, channels.type, channel_pricings.quota_type, | |||
| ``` | |||
| 注意:`channels.public_name` 插入在 `channels.name` 后面。 | |||
| - [ ] **Step 4: 验证编译通过** | |||
| Run: `cd D:/code/new-api && go build ./model/...` | |||
| Expected: 编译成功 | |||
| - [ ] **Step 5: Commit** | |||
| ```bash | |||
| git add model/channel_pricing.go | |||
| git commit -m "feat: add public_name to channel pricing query" | |||
| ``` | |||
| --- | |||
| ### Task 4: pricing.go 默认通道名称使用 public_name | |||
| **Files:** | |||
| - Modify: `model/pricing.go:290-303` | |||
| - [ ] **Step 1: 扩展匿名结构体** | |||
| 当前代码(`model/pricing.go:290-293`): | |||
| ```go | |||
| var allCPs []struct { | |||
| ChannelPricing | |||
| ChannelName string | |||
| } | |||
| ``` | |||
| 改为: | |||
| ```go | |||
| var allCPs []struct { | |||
| ChannelPricing | |||
| ChannelName string | |||
| ChannelPublicName string | |||
| } | |||
| ``` | |||
| - [ ] **Step 2: 扩展 Select 子句** | |||
| 当前代码(`model/pricing.go:295`): | |||
| ```go | |||
| Select("channel_pricings.*, channels.name as channel_name"). | |||
| ``` | |||
| 改为: | |||
| ```go | |||
| Select("channel_pricings.*, channels.name as channel_name, channels.public_name as channel_public_name"). | |||
| ``` | |||
| - [ ] **Step 3: 修改 channelNameMap 构建逻辑** | |||
| 当前代码(`model/pricing.go:303`): | |||
| ```go | |||
| channelNameMap[allCPs[i].ChannelId] = allCPs[i].ChannelName | |||
| ``` | |||
| 改为: | |||
| ```go | |||
| name := allCPs[i].ChannelPublicName | |||
| if name == "" { | |||
| name = allCPs[i].ChannelName | |||
| } | |||
| channelNameMap[allCPs[i].ChannelId] = name | |||
| ``` | |||
| - [ ] **Step 4: 验证编译通过** | |||
| Run: `cd D:/code/new-api && go build ./model/...` | |||
| Expected: 编译成功 | |||
| - [ ] **Step 5: Commit** | |||
| ```bash | |||
| git add model/pricing.go | |||
| git commit -m "feat: use public_name as default channel display name" | |||
| ``` | |||
| --- | |||
| ### Task 5: 数据回填迁移函数 | |||
| **Files:** | |||
| - Modify: `model/main.go:306-307` (调用位置) | |||
| - Modify: `model/main.go:695` (函数定义位置) | |||
| - [ ] **Step 1: 在 migrateDB 的 return nil 之前插入调用** | |||
| 当前代码(`model/main.go:304-308`): | |||
| ```go | |||
| // 将现有 sort_order=0 的模型和供应商更新为默认大数 | |||
| DB.Model(&Model{}).Where("sort_order = 0").Update("sort_order", 999999) | |||
| DB.Model(&Vendor{}).Where("sort_order = 0").Update("sort_order", 999999) | |||
| return nil | |||
| } | |||
| ``` | |||
| 改为: | |||
| ```go | |||
| // 将现有 sort_order=0 的模型和供应商更新为默认大数 | |||
| DB.Model(&Model{}).Where("sort_order = 0").Update("sort_order", 999999) | |||
| DB.Model(&Vendor{}).Where("sort_order = 0").Update("sort_order", 999999) | |||
| migrateChannelPublicName() | |||
| return nil | |||
| } | |||
| ``` | |||
| - [ ] **Step 2: 在文件末尾(第 696 行后)添加迁移函数** | |||
| ```go | |||
| func migrateChannelPublicName() { | |||
| result := DB.Model(&Channel{}). | |||
| Where("public_name = '' OR public_name IS NULL"). | |||
| Update("public_name", gorm.Expr("name")) | |||
| if result.Error != nil { | |||
| common.SysError("[Migration] migrateChannelPublicName failed: " + result.Error.Error()) | |||
| } else if result.RowsAffected > 0 { | |||
| common.SysLog(fmt.Sprintf("[Migration] migrateChannelPublicName: backfilled %d channels", result.RowsAffected)) | |||
| } | |||
| } | |||
| ``` | |||
| **说明:** | |||
| - AutoMigrate(`migrateDB()` 第 262 行 `&Channel{}`)会先创建列 | |||
| - WHERE 条件保证幂等 | |||
| - `gorm.Expr("name")` 引用列名,SQLite/MySQL/PostgreSQL 通用 | |||
| - `model/main.go` 已有 `fmt`、`gorm`、`common` 的 import,无需额外导入 | |||
| - [ ] **Step 3: 验证编译通过** | |||
| Run: `cd D:/code/new-api && go build ./model/...` | |||
| Expected: 编译成功 | |||
| - [ ] **Step 4: Commit** | |||
| ```bash | |||
| git add model/main.go | |||
| git commit -m "feat: add migration to backfill public_name from name" | |||
| ``` | |||
| --- | |||
| ### Task 6: controller 层验证 + API 返回 | |||
| **Files:** | |||
| - Modify: `controller/channel.go:582-585` (验证) | |||
| - Modify: `controller/channel.go:2110-2115` (API 返回) | |||
| - [ ] **Step 1: 在 validateChannel 的 isAdd 块中添加 public_name 校验** | |||
| 当前代码(`controller/channel.go:582-585`): | |||
| ```go | |||
| if isAdd { | |||
| if channel == nil || channel.Key == "" { | |||
| return fmt.Errorf("channel cannot be empty") | |||
| } | |||
| // 检查模型名称长度是否超过 255 | |||
| ``` | |||
| 改为: | |||
| ```go | |||
| if isAdd { | |||
| if channel == nil || channel.Key == "" { | |||
| return fmt.Errorf("channel cannot be empty") | |||
| } | |||
| if strings.TrimSpace(channel.PublicName) == "" { | |||
| return fmt.Errorf("public name cannot be empty") | |||
| } | |||
| // 检查模型名称长度是否超过 255 | |||
| ``` | |||
| **说明:** 保持英文错误消息与现有代码一致。 | |||
| - [ ] **Step 2: 在 GetUserChannelsForBinding 返回值中添加 public_name** | |||
| 当前代码(`controller/channel.go:2110-2115`): | |||
| ```go | |||
| result = append(result, gin.H{ | |||
| "id": ch.Id, | |||
| "name": ch.Name, | |||
| "type": ch.Type, | |||
| "remark": ch.Remark, | |||
| }) | |||
| ``` | |||
| 改为: | |||
| ```go | |||
| result = append(result, gin.H{ | |||
| "id": ch.Id, | |||
| "name": ch.Name, | |||
| "public_name": ch.PublicName, | |||
| "type": ch.Type, | |||
| "remark": ch.Remark, | |||
| }) | |||
| ``` | |||
| - [ ] **Step 3: 验证编译通过** | |||
| Run: `cd D:/code/new-api && go build ./controller/...` | |||
| Expected: 编译成功 | |||
| - [ ] **Step 4: Commit** | |||
| ```bash | |||
| git add controller/channel.go | |||
| git commit -m "feat: validate public_name on channel creation and return in binding API" | |||
| ``` | |||
| --- | |||
| ### Task 7: 前端 i18n 翻译 | |||
| **Files:** | |||
| - Modify: `web/src/i18n/locales/zh-CN.json` | |||
| - Modify: `web/src/i18n/locales/en.json` | |||
| - [ ] **Step 1: zh-CN.json 添加翻译** | |||
| 在 `"请为渠道命名"` 条目(第 2454 行)之后添加: | |||
| ```json | |||
| "对外名称": "对外名称", | |||
| "用户看到的渠道名称,如"标准通道"、"高速通道"": "用户看到的渠道名称,如"标准通道"、"高速通道"", | |||
| "请填写对外名称": "请填写对外名称", | |||
| "请填写渠道名称、对外名称和渠道密钥!": "请填写渠道名称、对外名称和渠道密钥!", | |||
| ``` | |||
| 注意:zh-CN.json 的 key 和 value 相同(中文 → 中文)。 | |||
| - [ ] **Step 2: en.json 添加翻译** | |||
| 在 `"Please name the channel"` 条目(第 2473 行)之后添加: | |||
| ```json | |||
| "对外名称": "Public Name", | |||
| "用户看到的渠道名称,如"标准通道"、"高速通道"": "User-facing channel name, e.g. \"Standard\", \"Fast\"", | |||
| "请填写对外名称": "Please enter a public name", | |||
| "请填写渠道名称、对外名称和渠道密钥!": "Please enter channel name, public name and key!", | |||
| ``` | |||
| - [ ] **Step 3: Commit** | |||
| ```bash | |||
| git add web/src/i18n/locales/zh-CN.json web/src/i18n/locales/en.json | |||
| git commit -m "feat: add i18n translations for channel public_name" | |||
| ``` | |||
| --- | |||
| ### Task 8: EditChannelModal 表单添加对外名称字段 | |||
| **Files:** | |||
| - Modify: `web/src/components/table/channels/modals/EditChannelModal.jsx:142` (originInputs) | |||
| - Modify: `web/src/components/table/channels/modals/EditChannelModal.jsx:1980` (表单 UI) | |||
| - Modify: `web/src/components/table/channels/modals/EditChannelModal.jsx:1329` (提交校验) | |||
| - [ ] **Step 1: originInputs 添加 public_name 默认值** | |||
| 当前代码(第 142-143 行): | |||
| ```javascript | |||
| const originInputs = { | |||
| name: '', | |||
| type: 1, | |||
| ``` | |||
| 改为: | |||
| ```javascript | |||
| const originInputs = { | |||
| name: '', | |||
| public_name: '', | |||
| type: 1, | |||
| ``` | |||
| - [ ] **Step 2: 在 name 的 Form.Input 后添加 public_name 输入框** | |||
| 当前代码(第 1972-1980 行): | |||
| ```jsx | |||
| <Form.Input | |||
| field='name' | |||
| label={t('名称')} | |||
| placeholder={t('请为渠道命名')} | |||
| rules={[{ required: true, message: t('请为渠道命名') }]} | |||
| showClear | |||
| onChange={(value) => handleInputChange('name', value)} | |||
| autoComplete='new-password' | |||
| /> | |||
| {inputs.type === 33 && ( | |||
| ``` | |||
| 在第 1980 行 `/>` 和第 1982 行 `{inputs.type === 33` 之间插入: | |||
| ```jsx | |||
| <Form.Input | |||
| field='name' | |||
| label={t('名称')} | |||
| placeholder={t('请为渠道命名')} | |||
| rules={[{ required: true, message: t('请为渠道命名') }]} | |||
| showClear | |||
| onChange={(value) => handleInputChange('name', value)} | |||
| autoComplete='new-password' | |||
| /> | |||
| <Form.Input | |||
| field='public_name' | |||
| label={t('对外名称')} | |||
| placeholder={t('用户看到的渠道名称,如"标准通道"、"高速通道"')} | |||
| rules={!isEdit ? [{ required: true, message: t('请填写对外名称') }] : []} | |||
| showClear | |||
| onChange={(value) => handleInputChange('public_name', value)} | |||
| autoComplete='new-password' | |||
| /> | |||
| {inputs.type === 33 && ( | |||
| ``` | |||
| **说明:** `!isEdit` 条件确保新建时必填、编辑时允许清空。 | |||
| - [ ] **Step 3: 修改提交前校验** | |||
| 当前代码(第 1329-1332 行): | |||
| ```javascript | |||
| if (!isEdit && (!localInputs.name || !localInputs.key)) { | |||
| showInfo(t('请填写渠道名称和渠道密钥!')); | |||
| return; | |||
| } | |||
| ``` | |||
| 改为: | |||
| ```javascript | |||
| if (!isEdit && (!localInputs.name || !localInputs.public_name || !localInputs.key)) { | |||
| showInfo(t('请填写渠道名称、对外名称和渠道密钥!')); | |||
| return; | |||
| } | |||
| ``` | |||
| - [ ] **Step 4: Commit** | |||
| ```bash | |||
| git add web/src/components/table/channels/modals/EditChannelModal.jsx | |||
| git commit -m "feat: add public_name field to channel edit form" | |||
| ``` | |||
| --- | |||
| ### Task 9: ChannelPricingCard 展示公共名称 | |||
| **Files:** | |||
| - Modify: `web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx:107` | |||
| - [ ] **Step 1: 修改 channelName 赋值逻辑** | |||
| 当前代码(第 104-108 行): | |||
| ```javascript | |||
| const tableData = channelPricingData.map((item, index) => ({ | |||
| key: item.channel_id || index, | |||
| channelId: item.channel_id, | |||
| channelName: `通道${['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][index] || ` ${index + 1}`}`, | |||
| channelTags: item.tags || [], | |||
| ``` | |||
| 改为: | |||
| ```javascript | |||
| const tableData = channelPricingData.map((item, index) => ({ | |||
| key: item.channel_id || index, | |||
| channelId: item.channel_id, | |||
| channelName: item.channel_public_name || ('通道' + (['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][index] || (index + 1))), | |||
| channelTags: item.tags || [], | |||
| ``` | |||
| **说明:** 优先使用后端 `channel_public_name`,为空时回退到中文数字。同时修复原代码反引号嵌套语法问题。 | |||
| - [ ] **Step 2: Commit** | |||
| ```bash | |||
| git add web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx | |||
| git commit -m "feat: display public_name in ChannelPricingCard" | |||
| ``` | |||
| --- | |||
| ### Task 10: 端到端验证 | |||
| **Files:** 无代码修改,纯验证 | |||
| - [ ] **Step 1: 后端编译** | |||
| Run: `cd D:/code/new-api && go build -o new-api main.go` | |||
| Expected: 编译成功 | |||
| - [ ] **Step 2: 启动服务** | |||
| Run: `cd D:/code/new-api && go run main.go` | |||
| Expected: 日志中出现 `[Migration] migrateChannelPublicName: backfilled N channels` | |||
| - [ ] **Step 3: 再次重启确认幂等** | |||
| 重启服务后 Expected: 日志中不再出现 backfilled(RowsAffected=0) | |||
| - [ ] **Step 4: API 测试 — 创建渠道不带 public_name** | |||
| Run: | |||
| ```bash | |||
| curl -s -X POST http://localhost:3000/api/channel/ \ | |||
| -H "Authorization: Bearer $ADMIN_KEY" \ | |||
| -H "Content-Type: application/json" \ | |||
| -d '{"mode":"single","channel":{"type":1,"name":"测试","key":"sk-test"}}' | |||
| ``` | |||
| Expected: 返回错误 `"public name cannot be empty"` | |||
| - [ ] **Step 5: API 测试 — 创建渠道带 public_name** | |||
| Run: | |||
| ```bash | |||
| curl -s -X POST http://localhost:3000/api/channel/ \ | |||
| -H "Authorization: Bearer $ADMIN_KEY" \ | |||
| -H "Content-Type: application/json" \ | |||
| -d '{"mode":"single","channel":{"type":1,"name":"测试","public_name":"标准通道","key":"sk-test"}}' | |||
| ``` | |||
| Expected: 返回成功 | |||
| - [ ] **Step 6: API 测试 — 定价接口包含 public_name** | |||
| Run: | |||
| ```bash | |||
| curl -s http://localhost:3000/api/channel-pricing/model/gpt-4 | |||
| ``` | |||
| Expected: 响应中包含 `channel_public_name` 字段 | |||
| - [ ] **Step 7: 前端验证** | |||
| 在浏览器中依次验证: | |||
| 1. 渠道管理 → 新建渠道 → 可见"对外名称"输入框 | |||
| 2. 不填"对外名称"提交 → 显示验证错误 | |||
| 3. 填写后提交成功 | |||
| 4. 编辑渠道 → 可见已保存的对外名称 | |||
| 5. 编辑时清空对外名称并保存 → 成功(向后兼容) | |||
| 6. 模型详情侧边栏 → ChannelPricingCard 显示对外名称 | |||
| 7. 首页定价卡片 → "默认通道"标签显示对外名称 | |||
| 8. 未设 public_name 的渠道 → 显示"通道一/二/三..." | |||
| @@ -0,0 +1,72 @@ | |||
| <h2>"去体验"按钮位置选择</h2> | |||
| <p class="subtitle">当前模型卡片结构示意,请选择按钮的最佳放置位置</p> | |||
| <div class="cards"> | |||
| <div class="card" data-choice="bottom-center" onclick="toggleSelect(this)"> | |||
| <div class="card-image"> | |||
| <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px; color: white;"> | |||
| <div style="display:flex; align-items:center; gap:10px; margin-bottom:12px;"> | |||
| <div style="width:36px;height:36px;border-radius:8px;background:rgba(255,255,255,0.2);display:flex;align-items:center;justify-content:center;font-size:18px;">GPT</div> | |||
| <span style="font-weight:600;font-size:16px;">gpt-4o</span> | |||
| <span style="margin-left:auto;font-size:12px;opacity:0.7;">📋</span> | |||
| </div> | |||
| <p style="font-size:13px;opacity:0.85;margin:0 0 12px 0;line-height:1.4;">OpenAI 旗舰多模态模型,支持文本和图像输入输出</p> | |||
| <div style="display:flex;gap:6px;margin-bottom:16px;"> | |||
| <span style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:4px;font-size:11px;">按量计费</span> | |||
| <span style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:4px;font-size:11px;">多模态</span> | |||
| </div> | |||
| <button style="width:100%;padding:8px 0;background:rgba(255,255,255,0.25);border:1px solid rgba(255,255,255,0.4);border-radius:6px;color:white;font-size:13px;cursor:pointer;">去体验 →</button> | |||
| </div> | |||
| </div> | |||
| <div class="card-body"> | |||
| <h3>方案 A:卡片底部居中</h3> | |||
| <p>按钮占据卡片底部整行,醒目且易于点击。适合卡片高度固定的场景。</p> | |||
| </div> | |||
| </div> | |||
| <div class="card" data-choice="footer-right" onclick="toggleSelect(this)"> | |||
| <div class="card-image"> | |||
| <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px; color: white;"> | |||
| <div style="display:flex; align-items:center; gap:10px; margin-bottom:12px;"> | |||
| <div style="width:36px;height:36px;border-radius:8px;background:rgba(255,255,255,0.2);display:flex;align-items:center;justify-content:center;font-size:18px;">GPT</div> | |||
| <span style="font-weight:600;font-size:16px;">gpt-4o</span> | |||
| <span style="margin-left:auto;font-size:12px;opacity:0.7;">📋</span> | |||
| </div> | |||
| <p style="font-size:13px;opacity:0.85;margin:0 0 12px 0;line-height:1.4;">OpenAI 旗舰多模态模型,支持文本和图像输入输出</p> | |||
| <div style="display:flex;align-items:center;gap:6px;"> | |||
| <span style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:4px;font-size:11px;">按量计费</span> | |||
| <span style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:4px;font-size:11px;">多模态</span> | |||
| <span style="margin-left:auto;"></span> | |||
| <button style="padding:5px 12px;background:rgba(255,255,255,0.25);border:1px solid rgba(255,255,255,0.4);border-radius:6px;color:white;font-size:12px;cursor:pointer;">去体验 →</button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div class="card-body"> | |||
| <h3>方案 B:标签行右侧</h3> | |||
| <p>按钮与标签同行,紧凑不占额外空间。但可能在小屏幕上显得拥挤。</p> | |||
| </div> | |||
| </div> | |||
| <div class="card" data-choice="header-right" onclick="toggleSelect(this)"> | |||
| <div class="card-image"> | |||
| <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px; color: white;"> | |||
| <div style="display:flex; align-items:center; gap:10px; margin-bottom:12px;"> | |||
| <div style="width:36px;height:36px;border-radius:8px;background:rgba(255,255,255,0.2);display:flex;align-items:center;justify-content:center;font-size:18px;">GPT</div> | |||
| <span style="font-weight:600;font-size:16px;">gpt-4o</span> | |||
| <span style="margin-left:auto;"></span> | |||
| <button style="padding:5px 12px;background:rgba(255,255,255,0.25);border:1px solid rgba(255,255,255,0.4);border-radius:6px;color:white;font-size:12px;cursor:pointer;">去体验 →</button> | |||
| <span style="font-size:12px;opacity:0.7;">📋</span> | |||
| </div> | |||
| <p style="font-size:13px;opacity:0.85;margin:0 0 12px 0;line-height:1.4;">OpenAI 旗舰多模态模型,支持文本和图像输入输出</p> | |||
| <div style="display:flex;gap:6px;"> | |||
| <span style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:4px;font-size:11px;">按量计费</span> | |||
| <span style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:4px;font-size:11px;">多模态</span> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div class="card-body"> | |||
| <h3>方案 C:标题行右侧</h3> | |||
| <p>按钮在模型名称旁边,最显眼的位置。但与复制按钮竞争空间。</p> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| @@ -0,0 +1,3 @@ | |||
| <div style="display:flex;align-items:center;justify-content:center;min-height:60vh"> | |||
| <p class="subtitle">Continuing in terminal...</p> | |||
| </div> | |||
| @@ -0,0 +1 @@ | |||
| {"reason":"idle timeout","timestamp":1777286270028} | |||
| @@ -0,0 +1 @@ | |||
| 6600 | |||
| @@ -0,0 +1,410 @@ | |||
| package controller | |||
| import ( | |||
| "encoding/json" | |||
| "net/http" | |||
| "net/http/httptest" | |||
| "testing" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| "github.com/QuantumNous/new-api/setting/ratio_setting" | |||
| "github.com/glebarez/sqlite" | |||
| "github.com/gin-gonic/gin" | |||
| "github.com/stretchr/testify/assert" | |||
| "github.com/stretchr/testify/require" | |||
| "gorm.io/gorm" | |||
| ) | |||
| // setupPricingTestDB 初始化测试数据库 | |||
| func setupPricingTestDB(t *testing.T) *gorm.DB { | |||
| t.Helper() | |||
| db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) | |||
| require.NoError(t, err) | |||
| sqlDB, _ := db.DB() | |||
| sqlDB.SetMaxOpenConns(1) | |||
| origDB := model.DB | |||
| model.DB = db | |||
| common.UsingSQLite = true | |||
| common.RedisEnabled = false | |||
| require.NoError(t, db.AutoMigrate(&model.UserChannelRatio{}, &model.User{})) | |||
| t.Cleanup(func() { | |||
| model.DB = origDB | |||
| sqlDB.Close() | |||
| }) | |||
| return db | |||
| } | |||
| // setupPricingTestRouter 创建无认证路由(未登录场景) | |||
| func setupPricingTestRouter() *gin.Engine { | |||
| gin.SetMode(gin.TestMode) | |||
| r := gin.New() | |||
| r.GET("/api/pricing/user/:model", GetUserPricing) | |||
| return r | |||
| } | |||
| // setupAuthRouter 创建带用户 ID 注入的路由(已登录场景) | |||
| func setupAuthRouter(userID int) *gin.Engine { | |||
| gin.SetMode(gin.TestMode) | |||
| r := gin.New() | |||
| r.GET("/api/pricing/user/:model", func(c *gin.Context) { | |||
| c.Set("id", userID) | |||
| c.Next() | |||
| }, GetUserPricing) | |||
| return r | |||
| } | |||
| // setPricingCache 直接设置定价缓存用于测试 | |||
| func setPricingCache(pricing []model.Pricing) { | |||
| model.SetTestPricing(pricing) | |||
| } | |||
| // withGroupRatio 临时设置分组倍率,测试结束后恢复 | |||
| func withGroupRatio(t *testing.T, jsonStr string) { | |||
| t.Helper() | |||
| original := ratio_setting.GetGroupRatioCopy() | |||
| ratio_setting.UpdateGroupRatioByJSONString(jsonStr) | |||
| t.Cleanup(func() { | |||
| origJSON, _ := json.Marshal(original) | |||
| ratio_setting.UpdateGroupRatioByJSONString(string(origJSON)) | |||
| }) | |||
| } | |||
| // ---- 测试用例 ---- | |||
| // TestGetUserPricing_ModelNotFound 模型不存在时应返回错误 | |||
| func TestGetUserPricing_ModelNotFound(t *testing.T) { | |||
| setupPricingTestDB(t) | |||
| router := setupPricingTestRouter() | |||
| setPricingCache([]model.Pricing{}) | |||
| w := httptest.NewRecorder() | |||
| req, _ := http.NewRequest("GET", "/api/pricing/user/nonexistent-model", nil) | |||
| router.ServeHTTP(w, req) | |||
| var resp map[string]interface{} | |||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||
| assert.False(t, resp["success"].(bool)) | |||
| assert.Contains(t, resp["message"], "未找到") | |||
| } | |||
| // TestGetUserPricing_NotLoggedIn 未登录用户应只返回原价 | |||
| func TestGetUserPricing_NotLoggedIn(t *testing.T) { | |||
| setupPricingTestDB(t) | |||
| router := setupPricingTestRouter() | |||
| setPricingCache([]model.Pricing{ | |||
| { | |||
| ModelName: "gpt-4o", | |||
| QuotaType: 0, | |||
| ModelRatio: 15, | |||
| CompletionRatio: 4, | |||
| EnableGroup: []string{"default", "vip"}, | |||
| }, | |||
| }) | |||
| w := httptest.NewRecorder() | |||
| req, _ := http.NewRequest("GET", "/api/pricing/user/gpt-4o", nil) | |||
| router.ServeHTTP(w, req) | |||
| var resp map[string]interface{} | |||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||
| assert.True(t, resp["success"].(bool)) | |||
| assert.Equal(t, false, resp["logged_in"]) | |||
| assert.Equal(t, "gpt-4o", resp["model_name"]) | |||
| assert.Equal(t, float64(0), resp["quota_type"]) | |||
| // 验证原价: model_ratio * 2 = 15 * 2 = 30 | |||
| assert.Equal(t, float64(30), resp["original_input"]) | |||
| // 输出原价: model_ratio * completion_ratio * 2 = 15 * 4 * 2 = 120 | |||
| assert.Equal(t, float64(120), resp["original_output"]) | |||
| // 不应有用户价字段 | |||
| _, hasUserInput := resp["user_input"] | |||
| assert.False(t, hasUserInput) | |||
| } | |||
| // TestGetUserPricing_NotLoggedIn_PerCall 按次计费模型,未登录 | |||
| func TestGetUserPricing_NotLoggedIn_PerCall(t *testing.T) { | |||
| setupPricingTestDB(t) | |||
| router := setupPricingTestRouter() | |||
| setPricingCache([]model.Pricing{ | |||
| { | |||
| ModelName: "dall-e-3", | |||
| QuotaType: 1, | |||
| ModelPrice: 0.04, | |||
| EnableGroup: []string{"default"}, | |||
| }, | |||
| }) | |||
| w := httptest.NewRecorder() | |||
| req, _ := http.NewRequest("GET", "/api/pricing/user/dall-e-3", nil) | |||
| router.ServeHTTP(w, req) | |||
| var resp map[string]interface{} | |||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||
| assert.True(t, resp["success"].(bool)) | |||
| assert.Equal(t, false, resp["logged_in"]) | |||
| assert.Equal(t, float64(0.04), resp["original_price"]) | |||
| } | |||
| // TestGetUserPricing_LoggedIn_NoDiscount 已登录但无折扣(分组倍率=1,无个人倍率) | |||
| func TestGetUserPricing_LoggedIn_NoDiscount(t *testing.T) { | |||
| db := setupPricingTestDB(t) | |||
| setPricingCache([]model.Pricing{ | |||
| { | |||
| ModelName: "gpt-4o", | |||
| QuotaType: 0, | |||
| ModelRatio: 15, | |||
| CompletionRatio: 4, | |||
| EnableGroup: []string{"default", "vip"}, | |||
| }, | |||
| }) | |||
| // 创建测试用户(default 分组,默认倍率为 1) | |||
| user := &model.User{Id: 100, Group: "default", Username: "testuser", Status: 1} | |||
| require.NoError(t, db.Create(user).Error) | |||
| router := setupAuthRouter(100) | |||
| w := httptest.NewRecorder() | |||
| req, _ := http.NewRequest("GET", "/api/pricing/user/gpt-4o", nil) | |||
| router.ServeHTTP(w, req) | |||
| var resp map[string]interface{} | |||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||
| assert.True(t, resp["success"].(bool)) | |||
| assert.Equal(t, true, resp["logged_in"]) | |||
| // default 分组默认倍率为 1,无折扣 | |||
| assert.Equal(t, float64(0), resp["savings_percent"]) | |||
| } | |||
| // TestGetUserPricing_LoggedIn_GroupDiscount 已登录,有分组折扣 | |||
| func TestGetUserPricing_LoggedIn_GroupDiscount(t *testing.T) { | |||
| db := setupPricingTestDB(t) | |||
| setPricingCache([]model.Pricing{ | |||
| { | |||
| ModelName: "gpt-4o", | |||
| QuotaType: 0, | |||
| ModelRatio: 15, | |||
| CompletionRatio: 4, | |||
| EnableGroup: []string{"default", "vip"}, | |||
| }, | |||
| }) | |||
| // 创建 VIP 用户 | |||
| user := &model.User{Id: 200, Group: "vip", Username: "vipuser", Status: 1} | |||
| require.NoError(t, db.Create(user).Error) | |||
| // 设置 VIP 分组倍率为 0.8 | |||
| withGroupRatio(t, `{"default":1,"vip":0.8}`) | |||
| router := setupAuthRouter(200) | |||
| w := httptest.NewRecorder() | |||
| req, _ := http.NewRequest("GET", "/api/pricing/user/gpt-4o", nil) | |||
| router.ServeHTTP(w, req) | |||
| var resp map[string]interface{} | |||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||
| assert.True(t, resp["success"].(bool)) | |||
| assert.Equal(t, true, resp["logged_in"]) | |||
| assert.Equal(t, "vip", resp["group"]) | |||
| assert.Equal(t, float64(0.8), resp["group_ratio"]) | |||
| // 用户价 = 原价 * 0.8 | |||
| // 输入: 30 * 0.8 = 24 | |||
| assert.Equal(t, float64(24), resp["user_input"]) | |||
| // 输出: 120 * 0.8 = 96 | |||
| assert.Equal(t, float64(96), resp["user_output"]) | |||
| assert.Equal(t, float64(20), resp["savings_percent"]) | |||
| assert.Equal(t, "8折", resp["discount"]) | |||
| } | |||
| // TestGetUserPricing_LoggedIn_UserChannelRatio 已登录,有用户渠道倍率 | |||
| func TestGetUserPricing_LoggedIn_UserChannelRatio(t *testing.T) { | |||
| db := setupPricingTestDB(t) | |||
| setPricingCache([]model.Pricing{ | |||
| { | |||
| ModelName: "gpt-4o", | |||
| QuotaType: 0, | |||
| ModelRatio: 15, | |||
| CompletionRatio: 4, | |||
| EnableGroup: []string{"default"}, | |||
| }, | |||
| }) | |||
| // 创建用户(default 分组,倍率为1) | |||
| user := &model.User{Id: 300, Group: "default", Username: "specialuser", Status: 1} | |||
| require.NoError(t, db.Create(user).Error) | |||
| // 插入用户渠道倍率 | |||
| ucr := &model.UserChannelRatio{ | |||
| UserId: 300, | |||
| ModelName: "gpt-4o", | |||
| ChannelId: 1, | |||
| Ratio: 0.9, | |||
| } | |||
| require.NoError(t, ucr.Insert()) | |||
| router := setupAuthRouter(300) | |||
| w := httptest.NewRecorder() | |||
| req, _ := http.NewRequest("GET", "/api/pricing/user/gpt-4o", nil) | |||
| router.ServeHTTP(w, req) | |||
| var resp map[string]interface{} | |||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||
| assert.True(t, resp["success"].(bool)) | |||
| assert.Equal(t, true, resp["logged_in"]) | |||
| // group_ratio=1 * user_channel_ratio=0.9 = 0.9 | |||
| assert.Equal(t, float64(0.9), resp["user_channel_ratio"]) | |||
| assert.Equal(t, float64(10), resp["savings_percent"]) | |||
| // 输入用户价: 30 * 0.9 = 27 | |||
| assert.Equal(t, float64(27), resp["user_input"]) | |||
| } | |||
| // TestGetUserPricing_LoggedIn_BothDiscounts 分组倍率 + 用户渠道倍率叠加 | |||
| func TestGetUserPricing_LoggedIn_BothDiscounts(t *testing.T) { | |||
| db := setupPricingTestDB(t) | |||
| setPricingCache([]model.Pricing{ | |||
| { | |||
| ModelName: "gpt-4o", | |||
| QuotaType: 0, | |||
| ModelRatio: 15, | |||
| CompletionRatio: 4, | |||
| EnableGroup: []string{"default", "vip"}, | |||
| }, | |||
| }) | |||
| user := &model.User{Id: 400, Group: "vip", Username: "bothdiscount", Status: 1} | |||
| require.NoError(t, db.Create(user).Error) | |||
| // VIP 分组倍率 0.8 | |||
| withGroupRatio(t, `{"default":1,"vip":0.8}`) | |||
| // 用户渠道倍率 0.9 | |||
| ucr := &model.UserChannelRatio{ | |||
| UserId: 400, | |||
| ModelName: "gpt-4o", | |||
| ChannelId: 1, | |||
| Ratio: 0.9, | |||
| } | |||
| require.NoError(t, ucr.Insert()) | |||
| router := setupAuthRouter(400) | |||
| w := httptest.NewRecorder() | |||
| req, _ := http.NewRequest("GET", "/api/pricing/user/gpt-4o", nil) | |||
| router.ServeHTTP(w, req) | |||
| var resp map[string]interface{} | |||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||
| assert.True(t, resp["success"].(bool)) | |||
| assert.Equal(t, true, resp["logged_in"]) | |||
| // total = 0.8 * 0.9 = 0.72, savings = 28% | |||
| assert.Equal(t, float64(28), resp["savings_percent"]) | |||
| // 输入: 30 * 0.72 = 21.6 | |||
| assert.Equal(t, float64(21.6), resp["user_input"]) | |||
| // 输出: 120 * 0.72 = 86.4 | |||
| assert.Equal(t, float64(86.4), resp["user_output"]) | |||
| } | |||
| // TestGetUserPricing_PerCall_WithDiscount 按次计费 + 折扣 | |||
| func TestGetUserPricing_PerCall_WithDiscount(t *testing.T) { | |||
| db := setupPricingTestDB(t) | |||
| setPricingCache([]model.Pricing{ | |||
| { | |||
| ModelName: "dall-e-3", | |||
| QuotaType: 1, | |||
| ModelPrice: 0.04, | |||
| EnableGroup: []string{"default", "vip"}, | |||
| }, | |||
| }) | |||
| user := &model.User{Id: 500, Group: "vip", Username: "percallvip", Status: 1} | |||
| require.NoError(t, db.Create(user).Error) | |||
| withGroupRatio(t, `{"default":1,"vip":0.5}`) | |||
| router := setupAuthRouter(500) | |||
| w := httptest.NewRecorder() | |||
| req, _ := http.NewRequest("GET", "/api/pricing/user/dall-e-3", nil) | |||
| router.ServeHTTP(w, req) | |||
| var resp map[string]interface{} | |||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||
| assert.True(t, resp["success"].(bool)) | |||
| assert.Equal(t, float64(0.04), resp["original_price"]) | |||
| assert.Equal(t, float64(0.02), resp["user_price"]) | |||
| assert.Equal(t, float64(50), resp["savings_percent"]) | |||
| assert.Equal(t, "5折", resp["discount"]) | |||
| } | |||
| // TestFormatDiscount 折扣格式化测试 | |||
| func TestFormatDiscount(t *testing.T) { | |||
| tests := []struct { | |||
| ratio float64 | |||
| expected string | |||
| }{ | |||
| {0.5, "5折"}, | |||
| {0.8, "8折"}, | |||
| {0.9, "9折"}, | |||
| {0.85, "8.5折"}, | |||
| {0.75, "7.5折"}, | |||
| {0.95, "9.5折"}, | |||
| {1.0, ""}, | |||
| {0.0, "免费"}, | |||
| } | |||
| for _, tt := range tests { | |||
| result := formatDiscount(tt.ratio) | |||
| assert.Equal(t, tt.expected, result, "ratio=%.2f", tt.ratio) | |||
| } | |||
| } | |||
| // TestGetUserPricing_MultipleUserChannelRatios 多个渠道倍率取最低值 | |||
| func TestGetUserPricing_MultipleUserChannelRatios(t *testing.T) { | |||
| db := setupPricingTestDB(t) | |||
| setPricingCache([]model.Pricing{ | |||
| { | |||
| ModelName: "gpt-4o", | |||
| QuotaType: 0, | |||
| ModelRatio: 15, | |||
| CompletionRatio: 4, | |||
| EnableGroup: []string{"default"}, | |||
| }, | |||
| }) | |||
| user := &model.User{Id: 600, Group: "default", Username: "multichannel", Status: 1} | |||
| require.NoError(t, db.Create(user).Error) | |||
| // 多个渠道倍率,取最低值 0.7 | |||
| for i, ratio := range []float64{0.9, 0.7, 0.8} { | |||
| ucr := &model.UserChannelRatio{ | |||
| UserId: 600, | |||
| ModelName: "gpt-4o", | |||
| ChannelId: i + 1, | |||
| Ratio: ratio, | |||
| } | |||
| require.NoError(t, ucr.Insert()) | |||
| } | |||
| router := setupAuthRouter(600) | |||
| w := httptest.NewRecorder() | |||
| req, _ := http.NewRequest("GET", "/api/pricing/user/gpt-4o", nil) | |||
| router.ServeHTTP(w, req) | |||
| var resp map[string]interface{} | |||
| require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) | |||
| assert.True(t, resp["success"].(bool)) | |||
| // 应取最低倍率 0.7 | |||
| assert.Equal(t, float64(0.7), resp["user_channel_ratio"]) | |||
| assert.Equal(t, float64(30), resp["savings_percent"]) | |||
| } | |||
| @@ -0,0 +1,306 @@ | |||
| <!DOCTYPE html> | |||
| <html lang="zh-CN"> | |||
| <head> | |||
| <meta charset="UTF-8"> | |||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |||
| <title>方案A:简洁划线对比</title> | |||
| <style> | |||
| * { margin: 0; padding: 0; box-sizing: border-box; } | |||
| body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; } | |||
| .panel { | |||
| max-width: 580px; | |||
| margin: 40px auto; | |||
| background: #fff; | |||
| border-radius: 16px; | |||
| box-shadow: 0 2px 12px rgba(0,0,0,0.08); | |||
| overflow: hidden; | |||
| } | |||
| .panel-header { | |||
| display: flex; | |||
| align-items: center; | |||
| gap: 12px; | |||
| padding: 20px 24px 16px; | |||
| border-bottom: 1px solid #f0f0f0; | |||
| } | |||
| .model-icon { | |||
| width: 48px; height: 48px; | |||
| border-radius: 12px; | |||
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |||
| display: flex; align-items: center; justify-content: center; | |||
| color: #fff; font-weight: 700; font-size: 18px; | |||
| } | |||
| .model-name { font-size: 18px; font-weight: 600; } | |||
| .model-desc { font-size: 13px; color: #888; margin-top: 2px; } | |||
| .section { padding: 16px 24px; } | |||
| .section-title { | |||
| display: flex; align-items: center; gap: 8px; | |||
| margin-bottom: 12px; | |||
| } | |||
| .section-title .icon { | |||
| width: 28px; height: 28px; border-radius: 8px; | |||
| display: flex; align-items: center; justify-content: center; | |||
| font-size: 14px; color: #fff; | |||
| } | |||
| .section-title .icon.orange { background: #fa8c16; } | |||
| .section-title .icon.cyan { background: #13c2c2; } | |||
| .section-title h3 { font-size: 15px; font-weight: 600; } | |||
| .section-title small { font-size: 12px; color: #999; } | |||
| /* ===== 方案A核心样式:简洁划线对比 ===== */ | |||
| .price-comparison { | |||
| background: linear-gradient(135deg, #fff7e6 0%, #fff 100%); | |||
| border: 1px solid #ffe7ba; | |||
| border-radius: 12px; | |||
| padding: 16px 20px; | |||
| margin-bottom: 12px; | |||
| } | |||
| .group-tag { | |||
| display: inline-flex; align-items: center; gap: 4px; | |||
| background: #722ed1; color: #fff; | |||
| padding: 2px 10px; border-radius: 10px; | |||
| font-size: 12px; font-weight: 500; | |||
| margin-bottom: 12px; | |||
| } | |||
| .price-row { | |||
| display: flex; | |||
| align-items: baseline; | |||
| gap: 12px; | |||
| margin-bottom: 8px; | |||
| } | |||
| .price-label { | |||
| font-size: 13px; | |||
| color: #666; | |||
| min-width: 56px; | |||
| } | |||
| .original-price { | |||
| font-size: 14px; | |||
| color: #bbb; | |||
| text-decoration: line-through; | |||
| text-decoration-color: #d48806; | |||
| text-decoration-thickness: 2px; | |||
| } | |||
| .discount-price { | |||
| font-size: 22px; | |||
| font-weight: 700; | |||
| color: #cf1322; | |||
| } | |||
| .discount-badge { | |||
| display: inline-flex; align-items: center; | |||
| background: linear-gradient(135deg, #cf1322, #a8071a); | |||
| color: #fff; | |||
| padding: 2px 8px; | |||
| border-radius: 4px; | |||
| font-size: 12px; | |||
| font-weight: 600; | |||
| margin-left: 8px; | |||
| } | |||
| .savings-line { | |||
| font-size: 12px; | |||
| color: #52c41a; | |||
| margin-top: 4px; | |||
| display: flex; align-items: center; gap: 4px; | |||
| } | |||
| .price-unit { | |||
| font-size: 12px; | |||
| color: #999; | |||
| margin-left: 4px; | |||
| } | |||
| .divider { | |||
| height: 1px; | |||
| background: #f0f0f0; | |||
| margin: 0 24px; | |||
| } | |||
| /* 表格样式 */ | |||
| .channel-table { | |||
| width: 100%; | |||
| border-collapse: collapse; | |||
| font-size: 13px; | |||
| } | |||
| .channel-table th { | |||
| text-align: left; | |||
| padding: 8px 0; | |||
| color: #999; | |||
| font-weight: 500; | |||
| font-size: 12px; | |||
| border-bottom: 1px solid #f0f0f0; | |||
| } | |||
| .channel-table td { | |||
| padding: 10px 0; | |||
| border-bottom: 1px solid #f5f5f5; | |||
| } | |||
| .channel-table tr:last-child td { border-bottom: none; } | |||
| .channel-tag { | |||
| display: inline-block; | |||
| padding: 1px 8px; | |||
| border-radius: 8px; | |||
| font-size: 11px; | |||
| margin-right: 4px; | |||
| } | |||
| .channel-tag.cyan { background: #e6fffb; color: #13c2c2; } | |||
| .channel-tag.violet { background: #f9f0ff; color: #722ed1; } | |||
| .channel-tag.amber { background: #fff7e6; color: #d48806; } | |||
| .channel-tag.green { background: #f6ffed; color: #52c41a; } | |||
| .price-orange { color: #d4380d; font-weight: 600; } | |||
| /* 方案标签 */ | |||
| .scheme-label { | |||
| text-align: center; | |||
| padding: 12px; | |||
| background: #667eea; | |||
| color: #fff; | |||
| font-weight: 600; | |||
| font-size: 16px; | |||
| letter-spacing: 1px; | |||
| } | |||
| .scheme-sub { | |||
| font-size: 12px; | |||
| font-weight: 400; | |||
| opacity: 0.85; | |||
| } | |||
| </style> | |||
| </head> | |||
| <body> | |||
| <div class="scheme-label"> | |||
| 方案A:简洁划线对比 | |||
| <div class="scheme-sub">原价划掉 + 折后价突出 + 折扣标签</div> | |||
| </div> | |||
| <div class="panel"> | |||
| <!-- 模型头部 --> | |||
| <div class="panel-header"> | |||
| <div class="model-icon">G4</div> | |||
| <div> | |||
| <div class="model-name">gpt-4o</div> | |||
| <div class="model-desc">OpenAI 最新多模态模型,支持文本和视觉</div> | |||
| </div> | |||
| </div> | |||
| <!-- 用户分组价格区域 --> | |||
| <div class="section"> | |||
| <div class="section-title"> | |||
| <div class="icon orange">$</div> | |||
| <div> | |||
| <h3>您的分组价格</h3> | |||
| <small>基于您所在分组的专属价格</small> | |||
| </div> | |||
| </div> | |||
| <div class="price-comparison"> | |||
| <div class="group-tag"> | |||
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> | |||
| VIP 分组 | |||
| </div> | |||
| <!-- 输入价格 --> | |||
| <div class="price-row"> | |||
| <span class="price-label">输入价格</span> | |||
| <span class="original-price">¥0.0300</span> | |||
| <span class="discount-price">¥0.0240</span> | |||
| <span class="discount-badge">8折</span> | |||
| <span class="price-unit">/ 1K tokens</span> | |||
| </div> | |||
| <div class="savings-line"> | |||
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#52c41a" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg> | |||
| 每千 tokens 节省 ¥0.006 | |||
| </div> | |||
| <!-- 输出价格 --> | |||
| <div class="price-row" style="margin-top: 12px;"> | |||
| <span class="price-label">输出价格</span> | |||
| <span class="original-price">¥0.0600</span> | |||
| <span class="discount-price">¥0.0480</span> | |||
| <span class="discount-badge">8折</span> | |||
| <span class="price-unit">/ 1K tokens</span> | |||
| </div> | |||
| <div class="savings-line"> | |||
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#52c41a" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg> | |||
| 每千 tokens 节省 ¥0.012 | |||
| </div> | |||
| </div> | |||
| <!-- 如果是按次计费的模型 --> | |||
| <div class="price-comparison" style="background: linear-gradient(135deg, #f0f5ff 0%, #fff 100%); border-color: #adc6ff;"> | |||
| <div style="font-size: 12px; color: #999; margin-bottom: 8px;">按次计费模型示例</div> | |||
| <div class="group-tag" style="background: #1890ff;"> | |||
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> | |||
| SVIP 分组 | |||
| </div> | |||
| <div class="price-row"> | |||
| <span class="price-label">每次调用</span> | |||
| <span class="original-price">¥0.0500</span> | |||
| <span class="discount-price">¥0.0350</span> | |||
| <span class="discount-badge">7折</span> | |||
| <span class="price-unit">/ 次</span> | |||
| </div> | |||
| <div class="savings-line"> | |||
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#52c41a" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg> | |||
| 每次调用节省 ¥0.015 | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div class="divider"></div> | |||
| <!-- 通道价格 --> | |||
| <div class="section"> | |||
| <div class="section-title"> | |||
| <div class="icon cyan"> | |||
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg> | |||
| </div> | |||
| <div> | |||
| <h3>通道价格</h3> | |||
| <small>所有支持该模型的通道价格</small> | |||
| </div> | |||
| </div> | |||
| <table class="channel-table"> | |||
| <thead> | |||
| <tr> | |||
| <th>通道</th> | |||
| <th>计费类型</th> | |||
| <th>输入价格</th> | |||
| <th>输出价格</th> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| <tr> | |||
| <td> | |||
| <span class="channel-tag cyan">OpenAI 官方</span> | |||
| <span class="channel-tag amber">默认</span> | |||
| </td> | |||
| <td><span class="channel-tag violet">按量计费</span></td> | |||
| <td class="price-orange">¥0.0240<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td> | |||
| <td class="price-orange">¥0.0480<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td> | |||
| </tr> | |||
| <tr> | |||
| <td> | |||
| <span class="channel-tag cyan">Azure 中转</span> | |||
| <span class="channel-tag green">高速</span> | |||
| </td> | |||
| <td><span class="channel-tag violet">按量计费</span></td> | |||
| <td class="price-orange">¥0.0280<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td> | |||
| <td class="price-orange">¥0.0560<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td> | |||
| </tr> | |||
| </tbody> | |||
| </table> | |||
| </div> | |||
| </div> | |||
| </body> | |||
| </html> | |||
| @@ -0,0 +1,289 @@ | |||
| <!DOCTYPE html> | |||
| <html lang="zh-CN"> | |||
| <head> | |||
| <meta charset="UTF-8"> | |||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |||
| <title>方案B:双卡片对比</title> | |||
| <style> | |||
| * { margin: 0; padding: 0; box-sizing: border-box; } | |||
| body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; } | |||
| .panel { | |||
| max-width: 580px; | |||
| margin: 40px auto; | |||
| background: #fff; | |||
| border-radius: 16px; | |||
| box-shadow: 0 2px 12px rgba(0,0,0,0.08); | |||
| overflow: hidden; | |||
| } | |||
| .panel-header { | |||
| display: flex; align-items: center; gap: 12px; | |||
| padding: 20px 24px 16px; | |||
| border-bottom: 1px solid #f0f0f0; | |||
| } | |||
| .model-icon { | |||
| width: 48px; height: 48px; border-radius: 12px; | |||
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |||
| display: flex; align-items: center; justify-content: center; | |||
| color: #fff; font-weight: 700; font-size: 18px; | |||
| } | |||
| .model-name { font-size: 18px; font-weight: 600; } | |||
| .model-desc { font-size: 13px; color: #888; margin-top: 2px; } | |||
| .section { padding: 16px 24px; } | |||
| .section-title { | |||
| display: flex; align-items: center; gap: 8px; margin-bottom: 12px; | |||
| } | |||
| .section-title .icon { | |||
| width: 28px; height: 28px; border-radius: 8px; | |||
| display: flex; align-items: center; justify-content: center; | |||
| font-size: 14px; color: #fff; | |||
| } | |||
| .section-title .icon.orange { background: #fa8c16; } | |||
| .section-title .icon.cyan { background: #13c2c2; } | |||
| .section-title h3 { font-size: 15px; font-weight: 600; } | |||
| .section-title small { font-size: 12px; color: #999; } | |||
| .divider { height: 1px; background: #f0f0f0; margin: 0 24px; } | |||
| /* ===== 方案B核心样式:双卡片对比 ===== */ | |||
| .price-cards { | |||
| display: grid; | |||
| grid-template-columns: 1fr 1fr; | |||
| gap: 12px; | |||
| margin-bottom: 12px; | |||
| } | |||
| .price-card { | |||
| border-radius: 12px; | |||
| padding: 16px; | |||
| position: relative; | |||
| overflow: hidden; | |||
| } | |||
| .price-card.standard { | |||
| background: #fafafa; | |||
| border: 1px solid #e8e8e8; | |||
| } | |||
| .price-card.discounted { | |||
| background: linear-gradient(135deg, #f6ffed 0%, #d9f7be 100%); | |||
| border: 2px solid #52c41a; | |||
| } | |||
| .price-card .card-label { | |||
| font-size: 11px; | |||
| text-transform: uppercase; | |||
| letter-spacing: 1px; | |||
| font-weight: 600; | |||
| margin-bottom: 8px; | |||
| } | |||
| .price-card.standard .card-label { color: #999; } | |||
| .price-card.discounted .card-label { color: #389e0d; } | |||
| .price-card .card-group { | |||
| font-size: 12px; | |||
| color: #888; | |||
| margin-bottom: 12px; | |||
| } | |||
| .price-card.discounted .card-group { | |||
| color: #389e0d; | |||
| } | |||
| .price-item { | |||
| margin-bottom: 8px; | |||
| } | |||
| .price-item:last-child { margin-bottom: 0; } | |||
| .price-item-label { | |||
| font-size: 11px; | |||
| color: #999; | |||
| margin-bottom: 2px; | |||
| } | |||
| .price-item-value { | |||
| font-weight: 700; | |||
| font-size: 18px; | |||
| } | |||
| .price-card.standard .price-item-value { color: #bbb; } | |||
| .price-card.discounted .price-item-value { color: #237804; } | |||
| .price-unit { | |||
| font-size: 11px; | |||
| font-weight: 400; | |||
| opacity: 0.6; | |||
| } | |||
| .savings-banner { | |||
| background: linear-gradient(90deg, #52c41a, #73d13d); | |||
| color: #fff; | |||
| border-radius: 8px; | |||
| padding: 8px 16px; | |||
| text-align: center; | |||
| font-weight: 600; | |||
| font-size: 13px; | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| gap: 8px; | |||
| } | |||
| .savings-amount { | |||
| font-size: 18px; | |||
| font-weight: 700; | |||
| } | |||
| /* 通道表格 */ | |||
| .channel-table { | |||
| width: 100%; border-collapse: collapse; font-size: 13px; | |||
| } | |||
| .channel-table th { | |||
| text-align: left; padding: 8px 0; color: #999; | |||
| font-weight: 500; font-size: 12px; | |||
| border-bottom: 1px solid #f0f0f0; | |||
| } | |||
| .channel-table td { | |||
| padding: 10px 0; border-bottom: 1px solid #f5f5f5; | |||
| } | |||
| .channel-table tr:last-child td { border-bottom: none; } | |||
| .channel-tag { | |||
| display: inline-block; padding: 1px 8px; border-radius: 8px; | |||
| font-size: 11px; margin-right: 4px; | |||
| } | |||
| .channel-tag.cyan { background: #e6fffb; color: #13c2c2; } | |||
| .channel-tag.violet { background: #f9f0ff; color: #722ed1; } | |||
| .channel-tag.amber { background: #fff7e6; color: #d48806; } | |||
| .channel-tag.green { background: #f6ffed; color: #52c41a; } | |||
| .price-orange { color: #d4380d; font-weight: 600; } | |||
| /* 方案标签 */ | |||
| .scheme-label { | |||
| text-align: center; padding: 12px; | |||
| background: #52c41a; color: #fff; | |||
| font-weight: 600; font-size: 16px; letter-spacing: 1px; | |||
| } | |||
| .scheme-sub { font-size: 12px; font-weight: 400; opacity: 0.85; } | |||
| /* 节省箭头 */ | |||
| .arrow-down { | |||
| display: flex; justify-content: center; margin: 8px 0; | |||
| } | |||
| .arrow-down svg { color: #52c41a; } | |||
| </style> | |||
| </head> | |||
| <body> | |||
| <div class="scheme-label"> | |||
| 方案B:双卡片对比 | |||
| <div class="scheme-sub">标准价格 vs 用户价格 左右对比,突出节省金额</div> | |||
| </div> | |||
| <div class="panel"> | |||
| <div class="panel-header"> | |||
| <div class="model-icon">G4</div> | |||
| <div> | |||
| <div class="model-name">gpt-4o</div> | |||
| <div class="model-desc">OpenAI 最新多模态模型</div> | |||
| </div> | |||
| </div> | |||
| <div class="section"> | |||
| <div class="section-title"> | |||
| <div class="icon orange">$</div> | |||
| <div> | |||
| <h3>分组价格对比</h3> | |||
| <small>VIP 分组专属折扣</small> | |||
| </div> | |||
| </div> | |||
| <!-- 输入价格对比 --> | |||
| <div style="font-size:12px;color:#888;margin-bottom:6px;">输入价格</div> | |||
| <div class="price-cards"> | |||
| <div class="price-card standard"> | |||
| <div class="card-label">标准价格</div> | |||
| <div class="card-group">default 分组</div> | |||
| <div class="price-item"> | |||
| <div class="price-item-label">每千 tokens</div> | |||
| <div class="price-item-value">¥0.0300 <span class="price-unit">/ 1K</span></div> | |||
| </div> | |||
| </div> | |||
| <div class="price-card discounted"> | |||
| <div class="card-label">您的价格</div> | |||
| <div class="card-group">VIP 分组 · 8折</div> | |||
| <div class="price-item"> | |||
| <div class="price-item-label">每千 tokens</div> | |||
| <div class="price-item-value">¥0.0240 <span class="price-unit">/ 1K</span></div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div class="savings-banner"> | |||
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg> | |||
| 输入节省 <span class="savings-amount">20%</span> · 每千 tokens 省 ¥0.006 | |||
| </div> | |||
| <!-- 输出价格对比 --> | |||
| <div style="font-size:12px;color:#888;margin-bottom:6px;margin-top:20px;">输出价格</div> | |||
| <div class="price-cards"> | |||
| <div class="price-card standard"> | |||
| <div class="card-label">标准价格</div> | |||
| <div class="card-group">default 分组</div> | |||
| <div class="price-item"> | |||
| <div class="price-item-label">每千 tokens</div> | |||
| <div class="price-item-value">¥0.0600 <span class="price-unit">/ 1K</span></div> | |||
| </div> | |||
| </div> | |||
| <div class="price-card discounted"> | |||
| <div class="card-label">您的价格</div> | |||
| <div class="card-group">VIP 分组 · 8折</div> | |||
| <div class="price-item"> | |||
| <div class="price-item-label">每千 tokens</div> | |||
| <div class="price-item-value">¥0.0480 <span class="price-unit">/ 1K</span></div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div class="savings-banner"> | |||
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg> | |||
| 输出节省 <span class="savings-amount">20%</span> · 每千 tokens 省 ¥0.012 | |||
| </div> | |||
| </div> | |||
| <div class="divider"></div> | |||
| <div class="section"> | |||
| <div class="section-title"> | |||
| <div class="icon cyan"> | |||
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg> | |||
| </div> | |||
| <div> | |||
| <h3>通道价格</h3> | |||
| <small>所有支持该模型的通道价格</small> | |||
| </div> | |||
| </div> | |||
| <table class="channel-table"> | |||
| <thead> | |||
| <tr><th>通道</th><th>计费类型</th><th>输入价格</th><th>输出价格</th></tr> | |||
| </thead> | |||
| <tbody> | |||
| <tr> | |||
| <td><span class="channel-tag cyan">OpenAI 官方</span><span class="channel-tag amber">默认</span></td> | |||
| <td><span class="channel-tag violet">按量计费</span></td> | |||
| <td class="price-orange">¥0.0240<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td> | |||
| <td class="price-orange">¥0.0480<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td> | |||
| </tr> | |||
| <tr> | |||
| <td><span class="channel-tag cyan">Azure 中转</span><span class="channel-tag green">高速</span></td> | |||
| <td><span class="channel-tag violet">按量计费</span></td> | |||
| <td class="price-orange">¥0.0280<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td> | |||
| <td class="price-orange">¥0.0560<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td> | |||
| </tr> | |||
| </tbody> | |||
| </table> | |||
| </div> | |||
| </div> | |||
| </body> | |||
| </html> | |||
| @@ -0,0 +1,362 @@ | |||
| <!DOCTYPE html> | |||
| <html lang="zh-CN"> | |||
| <head> | |||
| <meta charset="UTF-8"> | |||
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |||
| <title>方案C:进度条式折扣可视化</title> | |||
| <style> | |||
| * { margin: 0; padding: 0; box-sizing: border-box; } | |||
| body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; } | |||
| .panel { | |||
| max-width: 580px; margin: 40px auto; background: #fff; | |||
| border-radius: 16px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); overflow: hidden; | |||
| } | |||
| .panel-header { | |||
| display: flex; align-items: center; gap: 12px; | |||
| padding: 20px 24px 16px; border-bottom: 1px solid #f0f0f0; | |||
| } | |||
| .model-icon { | |||
| width: 48px; height: 48px; border-radius: 12px; | |||
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |||
| display: flex; align-items: center; justify-content: center; | |||
| color: #fff; font-weight: 700; font-size: 18px; | |||
| } | |||
| .model-name { font-size: 18px; font-weight: 600; } | |||
| .model-desc { font-size: 13px; color: #888; margin-top: 2px; } | |||
| .section { padding: 16px 24px; } | |||
| .section-title { | |||
| display: flex; align-items: center; gap: 8px; margin-bottom: 12px; | |||
| } | |||
| .section-title .icon { | |||
| width: 28px; height: 28px; border-radius: 8px; | |||
| display: flex; align-items: center; justify-content: center; | |||
| font-size: 14px; color: #fff; | |||
| } | |||
| .section-title .icon.orange { background: #fa8c16; } | |||
| .section-title .icon.cyan { background: #13c2c2; } | |||
| .section-title h3 { font-size: 15px; font-weight: 600; } | |||
| .section-title small { font-size: 12px; color: #999; } | |||
| .divider { height: 1px; background: #f0f0f0; margin: 0 24px; } | |||
| /* ===== 方案C核心样式:进度条式折扣可视化 ===== */ | |||
| .discount-card { | |||
| background: #fff; | |||
| border: 1px solid #f0f0f0; | |||
| border-radius: 12px; | |||
| padding: 16px 20px; | |||
| margin-bottom: 12px; | |||
| position: relative; | |||
| } | |||
| .discount-card-header { | |||
| display: flex; | |||
| justify-content: space-between; | |||
| align-items: center; | |||
| margin-bottom: 12px; | |||
| } | |||
| .discount-big-number { | |||
| font-size: 36px; | |||
| font-weight: 800; | |||
| background: linear-gradient(135deg, #ff4d4f, #cf1322); | |||
| -webkit-background-clip: text; | |||
| -webkit-text-fill-color: transparent; | |||
| background-clip: text; | |||
| line-height: 1; | |||
| } | |||
| .discount-percent { | |||
| font-size: 14px; | |||
| font-weight: 600; | |||
| color: #cf1322; | |||
| } | |||
| .group-badge { | |||
| display: inline-flex; align-items: center; gap: 4px; | |||
| padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 500; | |||
| } | |||
| .group-badge.vip { background: #f9f0ff; color: #722ed1; border: 1px solid #d3adf7; } | |||
| .group-badge.svip { background: #fff7e6; color: #d48806; border: 1px solid #ffe58f; } | |||
| /* 进度条 */ | |||
| .bar-container { | |||
| position: relative; | |||
| height: 32px; | |||
| background: #f5f5f5; | |||
| border-radius: 6px; | |||
| overflow: hidden; | |||
| margin-bottom: 8px; | |||
| } | |||
| .bar-full { | |||
| position: absolute; | |||
| top: 0; left: 0; | |||
| height: 100%; | |||
| background: #e8e8e8; | |||
| border-radius: 6px; | |||
| display: flex; align-items: center; justify-content: flex-end; | |||
| padding-right: 8px; | |||
| font-size: 11px; color: #999; font-weight: 500; | |||
| transition: width 0.5s ease; | |||
| } | |||
| .bar-discount { | |||
| position: absolute; | |||
| top: 0; left: 0; | |||
| height: 100%; | |||
| border-radius: 6px; | |||
| display: flex; align-items: center; | |||
| padding-left: 10px; | |||
| font-size: 12px; color: #fff; font-weight: 600; | |||
| transition: width 0.5s ease; | |||
| } | |||
| .bar-discount.green { background: linear-gradient(90deg, #52c41a, #73d13d); } | |||
| .bar-discount.blue { background: linear-gradient(90deg, #1890ff, #40a9ff); } | |||
| .bar-discount.orange { background: linear-gradient(90deg, #fa8c16, #ffa940); } | |||
| .bar-labels { | |||
| display: flex; | |||
| justify-content: space-between; | |||
| font-size: 11px; | |||
| color: #bbb; | |||
| margin-bottom: 16px; | |||
| } | |||
| /* 价格明细 */ | |||
| .price-details { | |||
| display: grid; | |||
| grid-template-columns: 1fr 1fr; | |||
| gap: 12px; | |||
| } | |||
| .price-detail-item { | |||
| background: #fafafa; | |||
| border-radius: 8px; | |||
| padding: 10px 12px; | |||
| } | |||
| .price-detail-label { | |||
| font-size: 11px; | |||
| color: #999; | |||
| margin-bottom: 4px; | |||
| } | |||
| .price-detail-row { | |||
| display: flex; | |||
| align-items: baseline; | |||
| gap: 6px; | |||
| } | |||
| .price-old { | |||
| font-size: 13px; | |||
| color: #ccc; | |||
| text-decoration: line-through; | |||
| } | |||
| .price-new { | |||
| font-size: 16px; | |||
| font-weight: 700; | |||
| color: #237804; | |||
| } | |||
| .price-new.red { color: #cf1322; } | |||
| .price-detail-unit { | |||
| font-size: 10px; | |||
| color: #bbb; | |||
| } | |||
| /* 通道表格 */ | |||
| .channel-table { | |||
| width: 100%; border-collapse: collapse; font-size: 13px; | |||
| } | |||
| .channel-table th { | |||
| text-align: left; padding: 8px 0; color: #999; | |||
| font-weight: 500; font-size: 12px; | |||
| border-bottom: 1px solid #f0f0f0; | |||
| } | |||
| .channel-table td { | |||
| padding: 10px 0; border-bottom: 1px solid #f5f5f5; | |||
| } | |||
| .channel-table tr:last-child td { border-bottom: none; } | |||
| .channel-tag { | |||
| display: inline-block; padding: 1px 8px; border-radius: 8px; | |||
| font-size: 11px; margin-right: 4px; | |||
| } | |||
| .channel-tag.cyan { background: #e6fffb; color: #13c2c2; } | |||
| .channel-tag.violet { background: #f9f0ff; color: #722ed1; } | |||
| .channel-tag.amber { background: #fff7e6; color: #d48806; } | |||
| .channel-tag.green { background: #f6ffed; color: #52c41a; } | |||
| .price-orange { color: #d4380d; font-weight: 600; } | |||
| .scheme-label { | |||
| text-align: center; padding: 12px; | |||
| background: #722ed1; color: #fff; | |||
| font-weight: 600; font-size: 16px; letter-spacing: 1px; | |||
| } | |||
| .scheme-sub { font-size: 12px; font-weight: 400; opacity: 0.85; } | |||
| </style> | |||
| </head> | |||
| <body> | |||
| <div class="scheme-label"> | |||
| 方案C:进度条式折扣可视化 | |||
| <div class="scheme-sub">用进度条直观展示折扣力度 + 价格明细</div> | |||
| </div> | |||
| <div class="panel"> | |||
| <div class="panel-header"> | |||
| <div class="model-icon">G4</div> | |||
| <div> | |||
| <div class="model-name">gpt-4o</div> | |||
| <div class="model-desc">OpenAI 最新多模态模型</div> | |||
| </div> | |||
| </div> | |||
| <div class="section"> | |||
| <div class="section-title"> | |||
| <div class="icon orange">$</div> | |||
| <div> | |||
| <h3>您的分组价格</h3> | |||
| <small>VIP 分组专属折扣</small> | |||
| </div> | |||
| </div> | |||
| <!-- 折扣大卡片 --> | |||
| <div class="discount-card"> | |||
| <div class="discount-card-header"> | |||
| <div> | |||
| <span class="group-badge vip"> | |||
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg> | |||
| VIP 分组 | |||
| </span> | |||
| </div> | |||
| <div style="text-align:right;"> | |||
| <div class="discount-big-number">8折</div> | |||
| <div class="discount-percent">节省 20%</div> | |||
| </div> | |||
| </div> | |||
| <!-- 输入价格进度条 --> | |||
| <div style="font-size:12px;color:#888;margin-bottom:4px;">输入价格</div> | |||
| <div class="bar-container"> | |||
| <div class="bar-full" style="width: 100%;"> | |||
| ¥0.0300 | |||
| </div> | |||
| <div class="bar-discount green" style="width: 80%;"> | |||
| ¥0.0240 | |||
| </div> | |||
| </div> | |||
| <div class="bar-labels"> | |||
| <span>default 分组基准价</span> | |||
| <span style="color:#52c41a;font-weight:500;">VIP 折扣价</span> | |||
| </div> | |||
| <!-- 输出价格进度条 --> | |||
| <div style="font-size:12px;color:#888;margin-bottom:4px;">输出价格</div> | |||
| <div class="bar-container"> | |||
| <div class="bar-full" style="width: 100%;"> | |||
| ¥0.0600 | |||
| </div> | |||
| <div class="bar-discount blue" style="width: 80%;"> | |||
| ¥0.0480 | |||
| </div> | |||
| </div> | |||
| <div class="bar-labels"> | |||
| <span>default 分组基准价</span> | |||
| <span style="color:#1890ff;font-weight:500;">VIP 折扣价</span> | |||
| </div> | |||
| <!-- 价格明细网格 --> | |||
| <div class="price-details"> | |||
| <div class="price-detail-item"> | |||
| <div class="price-detail-label">输入价格</div> | |||
| <div class="price-detail-row"> | |||
| <span class="price-old">¥0.0300</span> | |||
| <span class="price-new">¥0.0240</span> | |||
| </div> | |||
| <div class="price-detail-unit">/ 1K tokens</div> | |||
| </div> | |||
| <div class="price-detail-item"> | |||
| <div class="price-detail-label">输出价格</div> | |||
| <div class="price-detail-row"> | |||
| <span class="price-old">¥0.0600</span> | |||
| <span class="price-new">¥0.0480</span> | |||
| </div> | |||
| <div class="price-detail-unit">/ 1K tokens</div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <!-- 按次计费示例 --> | |||
| <div class="discount-card" style="border-color:#e6f7ff;"> | |||
| <div class="discount-card-header"> | |||
| <div> | |||
| <span class="group-badge svip"> | |||
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z"/></svg> | |||
| SVIP 分组 | |||
| </span> | |||
| <span style="font-size:11px;color:#999;margin-left:4px;">按次计费示例</span> | |||
| </div> | |||
| <div style="text-align:right;"> | |||
| <div class="discount-big-number" style="background:linear-gradient(135deg, #d48806, #ad6800);-webkit-background-clip:text;background-clip:text;">7折</div> | |||
| <div class="discount-percent" style="color:#d48806;">节省 30%</div> | |||
| </div> | |||
| </div> | |||
| <div style="font-size:12px;color:#888;margin-bottom:4px;">每次调用价格</div> | |||
| <div class="bar-container"> | |||
| <div class="bar-full" style="width: 100%;"> | |||
| ¥0.0500 | |||
| </div> | |||
| <div class="bar-discount orange" style="width: 70%;"> | |||
| ¥0.0350 | |||
| </div> | |||
| </div> | |||
| <div class="bar-labels"> | |||
| <span>default 分组基准价</span> | |||
| <span style="color:#fa8c16;font-weight:500;">SVIP 折扣价</span> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| <div class="divider"></div> | |||
| <div class="section"> | |||
| <div class="section-title"> | |||
| <div class="icon cyan"> | |||
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg> | |||
| </div> | |||
| <div> | |||
| <h3>通道价格</h3> | |||
| <small>所有支持该模型的通道价格</small> | |||
| </div> | |||
| </div> | |||
| <table class="channel-table"> | |||
| <thead> | |||
| <tr><th>通道</th><th>计费类型</th><th>输入价格</th><th>输出价格</th></tr> | |||
| </thead> | |||
| <tbody> | |||
| <tr> | |||
| <td><span class="channel-tag cyan">OpenAI 官方</span><span class="channel-tag amber">默认</span></td> | |||
| <td><span class="channel-tag violet">按量计费</span></td> | |||
| <td class="price-orange">¥0.0240<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td> | |||
| <td class="price-orange">¥0.0480<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td> | |||
| </tr> | |||
| <tr> | |||
| <td><span class="channel-tag cyan">Azure 中转</span><span class="channel-tag green">高速</span></td> | |||
| <td><span class="channel-tag violet">按量计费</span></td> | |||
| <td class="price-orange">¥0.0280<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td> | |||
| <td class="price-orange">¥0.0560<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td> | |||
| </tr> | |||
| </tbody> | |||
| </table> | |||
| </div> | |||
| </div> | |||
| </body> | |||
| </html> | |||
| @@ -0,0 +1,451 @@ | |||
| package model | |||
| import ( | |||
| "encoding/json" | |||
| "fmt" | |||
| "sort" | |||
| "strings" | |||
| "sync" | |||
| "time" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/constant" | |||
| "github.com/QuantumNous/new-api/setting/ratio_setting" | |||
| "github.com/QuantumNous/new-api/types" | |||
| ) | |||
| type Pricing struct { | |||
| ModelName string `json:"model_name"` | |||
| Description string `json:"description,omitempty"` | |||
| Icon string `json:"icon,omitempty"` | |||
| Tags string `json:"tags,omitempty"` | |||
| VendorID int `json:"vendor_id,omitempty"` | |||
| QuotaType int `json:"quota_type"` | |||
| ModelRatio float64 `json:"model_ratio"` | |||
| ModelPrice float64 `json:"model_price"` | |||
| OwnerBy string `json:"owner_by"` | |||
| CompletionRatio float64 `json:"completion_ratio"` | |||
| CacheRatio float64 `json:"cache_ratio"` | |||
| CacheCreationRatio float64 `json:"cache_creation_ratio"` | |||
| EnableGroup []string `json:"enable_groups"` | |||
| SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` | |||
| PricingVersion string `json:"pricing_version,omitempty"` | |||
| Type int `json:"type"` | |||
| DefaultChannelName string `json:"default_channel_name,omitempty"` | |||
| } | |||
| type PricingVendor struct { | |||
| ID int `json:"id"` | |||
| Name string `json:"name"` | |||
| Description string `json:"description,omitempty"` | |||
| Icon string `json:"icon,omitempty"` | |||
| } | |||
| var ( | |||
| pricingMap []Pricing | |||
| vendorsList []PricingVendor | |||
| supportedEndpointMap map[string]common.EndpointInfo | |||
| lastGetPricingTime time.Time | |||
| updatePricingLock sync.Mutex | |||
| // 缓存映射:模型名 -> 启用分组 / 计费类型 | |||
| modelEnableGroups = make(map[string][]string) | |||
| modelQuotaTypeMap = make(map[string]int) | |||
| modelEnableGroupsLock = sync.RWMutex{} | |||
| ) | |||
| var ( | |||
| modelSupportEndpointTypes = make(map[string][]constant.EndpointType) | |||
| modelSupportEndpointsLock = sync.RWMutex{} | |||
| ) | |||
| func GetPricing() []Pricing { | |||
| if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 { | |||
| updatePricingLock.Lock() | |||
| defer updatePricingLock.Unlock() | |||
| // Double check after acquiring the lock | |||
| if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 { | |||
| modelSupportEndpointsLock.Lock() | |||
| defer modelSupportEndpointsLock.Unlock() | |||
| updatePricing() | |||
| } | |||
| } | |||
| return pricingMap | |||
| } | |||
| // GetVendors 返回当前定价接口使用到的供应商信息 | |||
| func GetVendors() []PricingVendor { | |||
| if time.Since(lastGetPricingTime) > time.Minute*1 || len(pricingMap) == 0 { | |||
| // 保证先刷新一次 | |||
| GetPricing() | |||
| } | |||
| return vendorsList | |||
| } | |||
| func GetModelSupportEndpointTypes(model string) []constant.EndpointType { | |||
| if model == "" { | |||
| return make([]constant.EndpointType, 0) | |||
| } | |||
| modelSupportEndpointsLock.RLock() | |||
| defer modelSupportEndpointsLock.RUnlock() | |||
| if endpoints, ok := modelSupportEndpointTypes[model]; ok { | |||
| return endpoints | |||
| } | |||
| return make([]constant.EndpointType, 0) | |||
| } | |||
| func updatePricing() { | |||
| //modelRatios := common.GetModelRatios() | |||
| enableAbilities, err := GetAllEnableAbilityWithChannels() | |||
| if err != nil { | |||
| common.SysLog(fmt.Sprintf("GetAllEnableAbilityWithChannels error: %v", err)) | |||
| return | |||
| } | |||
| // 预加载模型元数据与供应商一次,避免循环查询 | |||
| var allMeta []Model | |||
| _ = DB.Find(&allMeta).Error | |||
| metaMap := make(map[string]*Model) | |||
| prefixList := make([]*Model, 0) | |||
| suffixList := make([]*Model, 0) | |||
| containsList := make([]*Model, 0) | |||
| for i := range allMeta { | |||
| m := &allMeta[i] | |||
| if m.NameRule == NameRuleExact { | |||
| metaMap[m.ModelName] = m | |||
| } else { | |||
| switch m.NameRule { | |||
| case NameRulePrefix: | |||
| prefixList = append(prefixList, m) | |||
| case NameRuleSuffix: | |||
| suffixList = append(suffixList, m) | |||
| case NameRuleContains: | |||
| containsList = append(containsList, m) | |||
| } | |||
| } | |||
| } | |||
| // 将非精确规则模型匹配到 metaMap | |||
| for _, m := range prefixList { | |||
| for _, pricingModel := range enableAbilities { | |||
| if strings.HasPrefix(pricingModel.Model, m.ModelName) { | |||
| if _, exists := metaMap[pricingModel.Model]; !exists { | |||
| metaMap[pricingModel.Model] = m | |||
| } | |||
| } | |||
| } | |||
| } | |||
| for _, m := range suffixList { | |||
| for _, pricingModel := range enableAbilities { | |||
| if strings.HasSuffix(pricingModel.Model, m.ModelName) { | |||
| if _, exists := metaMap[pricingModel.Model]; !exists { | |||
| metaMap[pricingModel.Model] = m | |||
| } | |||
| } | |||
| } | |||
| } | |||
| for _, m := range containsList { | |||
| for _, pricingModel := range enableAbilities { | |||
| if strings.Contains(pricingModel.Model, m.ModelName) { | |||
| if _, exists := metaMap[pricingModel.Model]; !exists { | |||
| metaMap[pricingModel.Model] = m | |||
| } | |||
| } | |||
| } | |||
| } | |||
| // 预加载供应商 | |||
| var vendors []Vendor | |||
| _ = DB.Find(&vendors).Error | |||
| vendorMap := make(map[int]*Vendor) | |||
| for i := range vendors { | |||
| vendorMap[vendors[i].Id] = &vendors[i] | |||
| } | |||
| // 初始化默认供应商映射 | |||
| initDefaultVendorMapping(metaMap, vendorMap, enableAbilities) | |||
| // 构建对前端友好的供应商列表 | |||
| vendorOrderMap := make(map[int]int) | |||
| for _, v := range vendorMap { | |||
| vendorOrderMap[v.Id] = v.SortOrder | |||
| } | |||
| vendorsList = make([]PricingVendor, 0, len(vendorMap)) | |||
| for _, v := range vendorMap { | |||
| vendorsList = append(vendorsList, PricingVendor{ | |||
| ID: v.Id, | |||
| Name: v.Name, | |||
| Description: v.Description, | |||
| Icon: v.Icon, | |||
| }) | |||
| } | |||
| sort.Slice(vendorsList, func(i, j int) bool { | |||
| oi := vendorOrderMap[vendorsList[i].ID] | |||
| oj := vendorOrderMap[vendorsList[j].ID] | |||
| if oi != oj { | |||
| return oi < oj | |||
| } | |||
| return vendorsList[i].ID < vendorsList[j].ID | |||
| }) | |||
| modelGroupsMap := make(map[string]*types.Set[string]) | |||
| for _, ability := range enableAbilities { | |||
| groups, ok := modelGroupsMap[ability.Model] | |||
| if !ok { | |||
| groups = types.NewSet[string]() | |||
| modelGroupsMap[ability.Model] = groups | |||
| } | |||
| groups.Add(ability.Group) | |||
| } | |||
| //这里使用切片而不是Set,因为一个模型可能支持多个端点类型,并且第一个端点是优先使用端点 | |||
| modelSupportEndpointsStr := make(map[string][]string) | |||
| // 先根据已有能力填充原生端点 | |||
| for _, ability := range enableAbilities { | |||
| endpoints := modelSupportEndpointsStr[ability.Model] | |||
| channelTypes := common.GetEndpointTypesByChannelType(ability.ChannelType, ability.Model) | |||
| for _, channelType := range channelTypes { | |||
| if !common.StringsContains(endpoints, string(channelType)) { | |||
| endpoints = append(endpoints, string(channelType)) | |||
| } | |||
| } | |||
| modelSupportEndpointsStr[ability.Model] = endpoints | |||
| } | |||
| // 再补充模型自定义端点:若配置有效则替换默认端点,不做合并 | |||
| for modelName, meta := range metaMap { | |||
| if strings.TrimSpace(meta.Endpoints) == "" { | |||
| continue | |||
| } | |||
| var raw map[string]interface{} | |||
| if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil { | |||
| endpoints := make([]string, 0, len(raw)) | |||
| for k, v := range raw { | |||
| switch v.(type) { | |||
| case string, map[string]interface{}: | |||
| if !common.StringsContains(endpoints, k) { | |||
| endpoints = append(endpoints, k) | |||
| } | |||
| } | |||
| } | |||
| if len(endpoints) > 0 { | |||
| modelSupportEndpointsStr[modelName] = endpoints | |||
| } | |||
| } | |||
| } | |||
| modelSupportEndpointTypes = make(map[string][]constant.EndpointType) | |||
| for model, endpoints := range modelSupportEndpointsStr { | |||
| supportedEndpoints := make([]constant.EndpointType, 0) | |||
| for _, endpointStr := range endpoints { | |||
| endpointType := constant.EndpointType(endpointStr) | |||
| supportedEndpoints = append(supportedEndpoints, endpointType) | |||
| } | |||
| modelSupportEndpointTypes[model] = supportedEndpoints | |||
| } | |||
| // 构建全局 supportedEndpointMap(默认 + 自定义覆盖) | |||
| supportedEndpointMap = make(map[string]common.EndpointInfo) | |||
| // 1. 默认端点 | |||
| for _, endpoints := range modelSupportEndpointTypes { | |||
| for _, et := range endpoints { | |||
| if info, ok := common.GetDefaultEndpointInfo(et); ok { | |||
| if _, exists := supportedEndpointMap[string(et)]; !exists { | |||
| supportedEndpointMap[string(et)] = info | |||
| } | |||
| } | |||
| } | |||
| } | |||
| // 2. 自定义端点(models 表)覆盖默认 | |||
| for _, meta := range metaMap { | |||
| if strings.TrimSpace(meta.Endpoints) == "" { | |||
| continue | |||
| } | |||
| var raw map[string]interface{} | |||
| if err := json.Unmarshal([]byte(meta.Endpoints), &raw); err == nil { | |||
| for k, v := range raw { | |||
| switch val := v.(type) { | |||
| case string: | |||
| supportedEndpointMap[k] = common.EndpointInfo{Path: val, Method: "POST"} | |||
| case map[string]interface{}: | |||
| ep := common.EndpointInfo{Method: "POST"} | |||
| if p, ok := val["path"].(string); ok { | |||
| ep.Path = p | |||
| } | |||
| if m, ok := val["method"].(string); ok { | |||
| ep.Method = strings.ToUpper(m) | |||
| } | |||
| supportedEndpointMap[k] = ep | |||
| default: | |||
| // ignore unsupported types | |||
| } | |||
| } | |||
| } | |||
| } | |||
| // 从渠道定价表加载实际定价数据(仅启用渠道),同时获取渠道名称 | |||
| var allCPs []struct { | |||
| ChannelPricing | |||
| ChannelName string | |||
| ChannelPublicName string | |||
| } | |||
| DB.Table("channel_pricings"). | |||
| Select("channel_pricings.*, channels.name as channel_name, channels.public_name as channel_public_name"). | |||
| Joins("JOIN channels ON channel_pricings.channel_id = channels.id"). | |||
| Where("channels.status = 1 AND channel_pricings.deleted_at IS NULL"). | |||
| Find(&allCPs) | |||
| cpMap := make(map[string][]ChannelPricing) | |||
| channelNameMap := make(map[int]string) | |||
| for i := range allCPs { | |||
| cpMap[allCPs[i].ModelName] = append(cpMap[allCPs[i].ModelName], allCPs[i].ChannelPricing) | |||
| channelNameMap[allCPs[i].ChannelId] = allCPs[i].ChannelName | |||
| } | |||
| pricingMap = make([]Pricing, 0) | |||
| for model, groups := range modelGroupsMap { | |||
| pricing := Pricing{ | |||
| ModelName: model, | |||
| EnableGroup: groups.Items(), | |||
| SupportedEndpointTypes: modelSupportEndpointTypes[model], | |||
| } | |||
| // 补充模型元数据(描述、标签、供应商、状态) | |||
| if meta, ok := metaMap[model]; ok { | |||
| // 若模型被禁用(status==0),则直接跳过,不返回给前端 | |||
| if meta.Status == 0 { | |||
| continue | |||
| } | |||
| pricing.Description = meta.Description | |||
| pricing.Icon = meta.Icon | |||
| pricing.Tags = meta.Tags | |||
| pricing.VendorID = meta.VendorID | |||
| pricing.Type = meta.Type | |||
| } | |||
| // 使用渠道定价表中的实际数据,选取最便宜的渠道 | |||
| applyBestChannelPricing(&pricing, cpMap[model], model) | |||
| // 填充默认通道名称 | |||
| if chId, ok := GetDefaultChannelId(model); ok { | |||
| if name, found := channelNameMap[chId]; found { | |||
| pricing.DefaultChannelName = name | |||
| } | |||
| } | |||
| pricingMap = append(pricingMap, pricing) | |||
| } | |||
| // 按 sort_order 排序 pricingMap,999999 视为未设置 | |||
| sort.Slice(pricingMap, func(i, j int) bool { | |||
| mi, okI := metaMap[pricingMap[i].ModelName] | |||
| mj, okJ := metaMap[pricingMap[j].ModelName] | |||
| si := 999999 | |||
| sj := 999999 | |||
| if okI { | |||
| si = mi.SortOrder | |||
| } | |||
| if okJ { | |||
| sj = mj.SortOrder | |||
| } | |||
| if si != sj { | |||
| return si < sj | |||
| } | |||
| return pricingMap[i].ModelName < pricingMap[j].ModelName | |||
| }) | |||
| // 防止大更新后数据不通用 | |||
| if len(pricingMap) > 0 { | |||
| pricingMap[0].PricingVersion = "82c4a357505fff6fee8462c3f7ec8a645bb95532669cb73b2cabee6a416ec24f" | |||
| } | |||
| // 刷新缓存映射,供高并发快速查询 | |||
| modelEnableGroupsLock.Lock() | |||
| modelEnableGroups = make(map[string][]string) | |||
| modelQuotaTypeMap = make(map[string]int) | |||
| for _, p := range pricingMap { | |||
| modelEnableGroups[p.ModelName] = p.EnableGroup | |||
| modelQuotaTypeMap[p.ModelName] = p.QuotaType | |||
| } | |||
| modelEnableGroupsLock.Unlock() | |||
| lastGetPricingTime = time.Now() | |||
| } | |||
| // GetSupportedEndpointMap 返回全局端点到路径的映射 | |||
| func GetSupportedEndpointMap() map[string]common.EndpointInfo { | |||
| return supportedEndpointMap | |||
| } | |||
| // applyGlobalDefault 用全局默认值填充 Pricing(无渠道定价时的回退) | |||
| func applyGlobalDefault(pricing *Pricing, model string) { | |||
| modelPrice, findPrice := ratio_setting.GetModelPrice(model, false) | |||
| if findPrice { | |||
| pricing.ModelPrice = modelPrice | |||
| pricing.QuotaType = 1 | |||
| } else { | |||
| modelRatio, _, _ := ratio_setting.GetModelRatio(model) | |||
| pricing.ModelRatio = modelRatio | |||
| pricing.CompletionRatio = ratio_setting.GetCompletionRatio(model) | |||
| pricing.QuotaType = 0 | |||
| } | |||
| pricing.CacheRatio, _ = ratio_setting.GetCacheRatio(model) | |||
| pricing.CacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(model) | |||
| } | |||
| // applyBestChannelPricing 从渠道定价中选取最优(最便宜)的价格填充 Pricing | |||
| // 优先选按量计费(quota_type=0)的渠道,因为缓存价格仅对按量计费有意义 | |||
| // 扩展比率字段(cache_ratio 等)为 0 表示未设置,需回退到全局默认值 | |||
| func applyBestChannelPricing(pricing *Pricing, cps []ChannelPricing, model string) { | |||
| if len(cps) == 0 { | |||
| applyGlobalDefault(pricing, model) | |||
| return | |||
| } | |||
| // 优先选按量计费 (quota_type=0) 中 model_ratio 最低的渠道 | |||
| var bestPerToken *ChannelPricing | |||
| for i := range cps { | |||
| cp := &cps[i] | |||
| if cp.QuotaType == 0 { | |||
| if bestPerToken == nil || cp.ModelRatio < bestPerToken.ModelRatio { | |||
| bestPerToken = cp | |||
| } | |||
| } | |||
| } | |||
| if bestPerToken != nil { | |||
| pricing.QuotaType = 0 | |||
| pricing.ModelRatio = bestPerToken.ModelRatio | |||
| pricing.CompletionRatio = bestPerToken.CompletionRatio | |||
| if bestPerToken.CacheRatio > 0 { | |||
| pricing.CacheRatio = bestPerToken.CacheRatio | |||
| } else { | |||
| pricing.CacheRatio, _ = ratio_setting.GetCacheRatio(model) | |||
| } | |||
| if bestPerToken.CacheCreationRatio > 0 { | |||
| pricing.CacheCreationRatio = bestPerToken.CacheCreationRatio | |||
| } else { | |||
| pricing.CacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(model) | |||
| } | |||
| return | |||
| } | |||
| // 没有按量渠道,选按次计费 (quota_type=1) 中 model_price 最低的渠道 | |||
| var bestPerCall *ChannelPricing | |||
| for i := range cps { | |||
| cp := &cps[i] | |||
| if cp.QuotaType == 1 { | |||
| if bestPerCall == nil || cp.ModelPrice < bestPerCall.ModelPrice { | |||
| bestPerCall = cp | |||
| } | |||
| } | |||
| } | |||
| if bestPerCall != nil { | |||
| pricing.QuotaType = 1 | |||
| pricing.ModelPrice = bestPerCall.ModelPrice | |||
| return | |||
| } | |||
| applyGlobalDefault(pricing, model) | |||
| } | |||
| @@ -0,0 +1,44 @@ | |||
| package relay | |||
| import ( | |||
| "testing" | |||
| "github.com/QuantumNous/new-api/constant" | |||
| "github.com/QuantumNous/new-api/dto" | |||
| relaycommon "github.com/QuantumNous/new-api/relay/common" | |||
| relayconstant "github.com/QuantumNous/new-api/relay/constant" | |||
| "github.com/stretchr/testify/assert" | |||
| ) | |||
| func TestShouldUseChatCompletionsViaResponses_CodexAlwaysEnabled(t *testing.T) { | |||
| info := &relaycommon.RelayInfo{ | |||
| RelayMode: relayconstant.RelayModeChatCompletions, | |||
| OriginModelName: "gpt-5-codex", | |||
| ChannelMeta: &relaycommon.ChannelMeta{ | |||
| ChannelType: constant.ChannelTypeCodex, | |||
| ChannelId: 1, | |||
| ApiType: constant.APITypeCodex, | |||
| }, | |||
| } | |||
| // Codex should always convert, regardless of pass-through settings | |||
| assert.True(t, shouldUseChatCompletionsViaResponses(info, false)) | |||
| assert.True(t, shouldUseChatCompletionsViaResponses(info, true)) | |||
| } | |||
| func TestShouldUseChatCompletionsViaResponses_NonCodexDisabledByPassThrough(t *testing.T) { | |||
| info := &relaycommon.RelayInfo{ | |||
| RelayMode: relayconstant.RelayModeChatCompletions, | |||
| ChannelMeta: &relaycommon.ChannelMeta{ | |||
| ChannelType: constant.ChannelTypeOpenAI, | |||
| ChannelId: 1, | |||
| ApiType: constant.APITypeOpenAI, | |||
| ChannelSetting: dto.ChannelSettings{ | |||
| PassThroughBodyEnabled: true, | |||
| }, | |||
| }, | |||
| } | |||
| assert.False(t, shouldUseChatCompletionsViaResponses(info, false)) | |||
| assert.False(t, shouldUseChatCompletionsViaResponses(info, true)) | |||
| } | |||
| @@ -0,0 +1,166 @@ | |||
| package helper | |||
| import ( | |||
| "net/http" | |||
| "net/http/httptest" | |||
| "testing" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/dto" | |||
| "github.com/QuantumNous/new-api/model" | |||
| relaycommon "github.com/QuantumNous/new-api/relay/common" | |||
| "github.com/QuantumNous/new-api/setting/ratio_setting" | |||
| "github.com/QuantumNous/new-api/types" | |||
| "github.com/glebarez/sqlite" | |||
| "github.com/gin-gonic/gin" | |||
| "github.com/stretchr/testify/assert" | |||
| "github.com/stretchr/testify/require" | |||
| "gorm.io/gorm" | |||
| ) | |||
| const testModelUsePriceSwitch = "test-useprice-switch-model" | |||
| func setupUsePriceSwitchTest(t *testing.T) { | |||
| t.Helper() | |||
| // 初始化 SQLite 内存数据库 | |||
| db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) | |||
| require.NoError(t, err) | |||
| sqlDB, _ := db.DB() | |||
| sqlDB.SetMaxOpenConns(1) | |||
| origDB := model.DB | |||
| model.DB = db | |||
| common.UsingSQLite = true | |||
| common.RedisEnabled = false | |||
| require.NoError(t, db.AutoMigrate(&model.ChannelPricing{})) | |||
| // 全局定价:按次计费(modelPrice 存在 → usePrice=true) | |||
| require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{"`+testModelUsePriceSwitch+`":0.5}`)) | |||
| // 全局 modelRatio 也设上(渠道按量计费时会用到) | |||
| require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"`+testModelUsePriceSwitch+`":15}`)) | |||
| require.NoError(t, ratio_setting.UpdateCompletionRatioByJSONString(`{"`+testModelUsePriceSwitch+`":3}`)) | |||
| // 全局扩展比率 | |||
| require.NoError(t, ratio_setting.UpdateCacheRatioByJSONString(`{"`+testModelUsePriceSwitch+`":0.1}`)) | |||
| require.NoError(t, ratio_setting.UpdateCreateCacheRatioByJSONString(`{"`+testModelUsePriceSwitch+`":1.25}`)) | |||
| require.NoError(t, ratio_setting.UpdateImageRatioByJSONString(`{"`+testModelUsePriceSwitch+`":2.0}`)) | |||
| require.NoError(t, ratio_setting.UpdateAudioRatioByJSONString(`{"`+testModelUsePriceSwitch+`":5.0}`)) | |||
| require.NoError(t, ratio_setting.UpdateAudioCompletionRatioByJSONString(`{"`+testModelUsePriceSwitch+`":3.0}`)) | |||
| // 渠道定价:按量计费(QuotaTypeByTokens),扩展比率全部为 0(用全局) | |||
| cp := &model.ChannelPricing{ | |||
| ModelName: testModelUsePriceSwitch, | |||
| ChannelId: 9901, | |||
| QuotaType: model.QuotaTypeByTokens, | |||
| ModelRatio: 15, | |||
| CompletionRatio: 3, | |||
| // 扩展比率全部为 0 → 意味着回退全局 | |||
| } | |||
| require.NoError(t, cp.Insert()) | |||
| t.Cleanup(func() { | |||
| model.DB = origDB | |||
| sqlDB.Close() | |||
| // 清理全局定价 | |||
| ratio_setting.UpdateModelPriceByJSONString(`{}`) | |||
| ratio_setting.UpdateModelRatioByJSONString(`{}`) | |||
| ratio_setting.UpdateCompletionRatioByJSONString(`{}`) | |||
| ratio_setting.UpdateCacheRatioByJSONString(`{}`) | |||
| ratio_setting.UpdateCreateCacheRatioByJSONString(`{}`) | |||
| ratio_setting.UpdateImageRatioByJSONString(`{}`) | |||
| ratio_setting.UpdateAudioRatioByJSONString(`{}`) | |||
| ratio_setting.UpdateAudioCompletionRatioByJSONString(`{}`) | |||
| }) | |||
| } | |||
| func buildTestContext(t *testing.T) *gin.Context { | |||
| t.Helper() | |||
| gin.SetMode(gin.TestMode) | |||
| w := httptest.NewRecorder() | |||
| c, _ := gin.CreateTestContext(w) | |||
| c.Request, _ = http.NewRequest("POST", "/v1/chat/completions", nil) | |||
| return c | |||
| } | |||
| // TestGlobalRatiosFallbackWhenGlobalUsePriceTrue 验证核心场景: | |||
| // 全局按次计费 → 渠道按量计费 → PriceData 中扩展比率仍应被全局默认值填充 | |||
| // 而不是保持 0(否则后续 UpdatePriceDataForChannelPricing 切换为按量时会丢失比率) | |||
| func TestGlobalRatiosFallbackWhenGlobalUsePriceTrue(t *testing.T) { | |||
| setupUsePriceSwitchTest(t) | |||
| c := buildTestContext(t) | |||
| // 模拟渠道选择后的场景:ChannelMeta 不为空,能查到渠道定价 | |||
| info := &relaycommon.RelayInfo{ | |||
| OriginModelName: testModelUsePriceSwitch, | |||
| ChannelMeta: &relaycommon.ChannelMeta{ | |||
| ChannelId: 9901, | |||
| }, | |||
| UserSetting: dto.UserSetting{}, | |||
| } | |||
| meta := &types.TokenCountMeta{} | |||
| priceData, err := ModelPriceHelper(c, info, 100, meta) | |||
| require.NoError(t, err) | |||
| // 断言:渠道按量计费覆盖了全局按次,扩展比率必须被全局默认值填充,不能是 0 | |||
| assert.Equal(t, 0.1, priceData.CacheRatio, "CacheRatio 应从全局回退填充") | |||
| assert.Equal(t, 1.25, priceData.CacheCreationRatio, "CacheCreationRatio 应从全局回退填充") | |||
| assert.Equal(t, 2.0, priceData.ImageRatio, "ImageRatio 应从全局回退填充") | |||
| assert.Equal(t, 5.0, priceData.AudioRatio, "AudioRatio 应从全局回退填充") | |||
| assert.Equal(t, 3.0, priceData.AudioCompletionRatio, "AudioCompletionRatio 应从全局回退填充") | |||
| } | |||
| // TestGlobalRatiosFallbackWithoutChannelPricing 验证无渠道定价时全局比率也正常回退 | |||
| func TestGlobalRatiosFallbackWithoutChannelPricing(t *testing.T) { | |||
| setupUsePriceSwitchTest(t) | |||
| c := buildTestContext(t) | |||
| // 无 ChannelMeta → 渠道定价不可用,走全局 | |||
| info := &relaycommon.RelayInfo{ | |||
| OriginModelName: testModelUsePriceSwitch, | |||
| UserSetting: dto.UserSetting{}, | |||
| } | |||
| meta := &types.TokenCountMeta{} | |||
| priceData, err := ModelPriceHelper(c, info, 100, meta) | |||
| require.NoError(t, err) | |||
| // 全局是按次计费,UsePrice=true,但比率仍应被填充 | |||
| assert.True(t, priceData.UsePrice) | |||
| assert.Equal(t, 0.1, priceData.CacheRatio, "CacheRatio 应从全局回退填充") | |||
| assert.Equal(t, 2.0, priceData.ImageRatio, "ImageRatio 应从全局回退填充") | |||
| } | |||
| // TestChannelRatiosOverrideGlobal 验证渠道非零值优先于全局 | |||
| func TestChannelRatiosOverrideGlobal(t *testing.T) { | |||
| setupUsePriceSwitchTest(t) | |||
| // 渠道定价设了自定义 cacheRatio=0.5 | |||
| cp := &model.ChannelPricing{ | |||
| ModelName: testModelUsePriceSwitch, | |||
| ChannelId: 9902, | |||
| QuotaType: model.QuotaTypeByTokens, | |||
| ModelRatio: 15, | |||
| CompletionRatio: 3, | |||
| CacheRatio: 0.5, // 渠道自定义,覆盖全局 0.1 | |||
| // 其他扩展比率保持 0,用全局 | |||
| } | |||
| require.NoError(t, cp.Insert()) | |||
| c := buildTestContext(t) | |||
| info := &relaycommon.RelayInfo{ | |||
| OriginModelName: testModelUsePriceSwitch, | |||
| ChannelMeta: &relaycommon.ChannelMeta{ | |||
| ChannelId: 9902, | |||
| }, | |||
| UserSetting: dto.UserSetting{}, | |||
| } | |||
| priceData, err := ModelPriceHelper(c, info, 100, &types.TokenCountMeta{}) | |||
| require.NoError(t, err) | |||
| assert.Equal(t, 0.5, priceData.CacheRatio, "渠道自定义 CacheRatio 应覆盖全局") | |||
| assert.Equal(t, 2.0, priceData.ImageRatio, "ImageRatio 未设渠道值,应从全局回退") | |||
| assert.Equal(t, 5.0, priceData.AudioRatio, "AudioRatio 未设渠道值,应从全局回退") | |||
| } | |||
| @@ -0,0 +1,157 @@ | |||
| #!/bin/bash | |||
| # scripts/deploy-test.sh - 构建镜像并部署到 WSL 本地测试环境 | |||
| # | |||
| # 用法: | |||
| # bash scripts/deploy-test.sh [cn|ov] [--no-build] | |||
| # | |||
| # 测试环境: | |||
| # 国内: /root/new-api-cn 端口 23000 容器 cn-new-api | |||
| # 国外: /root/new-api-ov 端口 13000 容器 ov-new-api | |||
| set -euo pipefail | |||
| # ==================== 配置 ==================== | |||
| DOCKER_REGISTRY="registry.cn-hangzhou.aliyuncs.com/fengsilin/new-api" | |||
| PROJECT_DIR="/mnt/d/code/new-api" | |||
| # 测试节点 | |||
| CN_DIR="/root/new-api-cn" | |||
| OV_DIR="/root/new-api-ov" | |||
| # ==================== 颜色 ==================== | |||
| RED='\033[0;31m' | |||
| GREEN='\033[0;32m' | |||
| YELLOW='\033[1;33m' | |||
| CYAN='\033[0;36m' | |||
| BOLD='\033[1m' | |||
| NC='\033[0m' | |||
| info() { echo -e " ${CYAN}[INFO]${NC} $*"; } | |||
| ok() { echo -e " ${GREEN}[OK]${NC} $*"; } | |||
| warn() { echo -e " ${YELLOW}[WARN]${NC} $*"; } | |||
| error() { echo -e " ${RED}[ERROR]${NC} $*"; } | |||
| section() { | |||
| echo "" | |||
| echo -e " ${BOLD}=========================================${NC}" | |||
| echo -e " ${BOLD} $*${NC}" | |||
| echo -e " ${BOLD}=========================================${NC}" | |||
| } | |||
| # ==================== 参数解析 ==================== | |||
| TARGET="all" | |||
| NO_BUILD=false | |||
| for arg in "$@"; do | |||
| case "$arg" in | |||
| cn) TARGET="cn" ;; | |||
| ov) TARGET="ov" ;; | |||
| --no-build) NO_BUILD=true ;; | |||
| -h|--help) | |||
| echo "用法: $0 [cn|ov] [--no-build]" | |||
| echo "" | |||
| echo "测试环境:" | |||
| echo " 国内: ${CN_DIR} 端口 23000" | |||
| echo " 国外: ${OV_DIR} 端口 13000" | |||
| exit 0 | |||
| ;; | |||
| *) | |||
| error "未知参数: $arg" | |||
| exit 1 | |||
| ;; | |||
| esac | |||
| done | |||
| # ==================== Step 1: 构建并推送 ==================== | |||
| if [ "$NO_BUILD" = false ]; then | |||
| section "Step 1/2: 构建并推送镜像" | |||
| cd "$PROJECT_DIR" | |||
| BRANCH=$(git rev-parse --abbrev-ref HEAD | sed 's/\//-/g') | |||
| TAG="$(date +%Y%m%d%H%M)-${BRANCH}" | |||
| IMAGE="${DOCKER_REGISTRY}:${TAG}" | |||
| info "分支: ${BRANCH}" | |||
| info "Tag: ${TAG}" | |||
| info "镜像: ${IMAGE}" | |||
| echo "" | |||
| docker build -t "${IMAGE}" . | |||
| echo "" | |||
| info "推送镜像..." | |||
| docker push "${IMAGE}" | |||
| ok "构建并推送完成: ${TAG}" | |||
| else | |||
| section "跳过构建,查找本地最新镜像..." | |||
| IMAGE=$(docker images --format "{{.Repository}}:{{.Tag}}" "${DOCKER_REGISTRY}" | head -1) | |||
| if [ -z "$IMAGE" ]; then | |||
| error "本地未找到 ${DOCKER_REGISTRY} 的镜像" | |||
| error "请先不带 --no-build 运行一次" | |||
| exit 1 | |||
| fi | |||
| TAG="${IMAGE##*:}" | |||
| ok "使用镜像: ${IMAGE}" | |||
| fi | |||
| # ==================== Step 2: 部署到测试节点 ==================== | |||
| section "Step 2/2: 部署到测试环境" | |||
| FAILED=() | |||
| deploy_local() { | |||
| local NAME="$1" | |||
| local DIR="$2" | |||
| echo "" | |||
| info "部署到 ${NAME}..." | |||
| info " 目录: ${DIR} Tag: ${TAG}" | |||
| echo "" | |||
| if [ ! -d "$DIR" ]; then | |||
| error "目录不存在: ${DIR}" | |||
| FAILED+=("$NAME") | |||
| return | |||
| fi | |||
| cd "$DIR" | |||
| echo " [1/3] 更新 image tag..." | |||
| sed -i "s|${DOCKER_REGISTRY}:.*|${DOCKER_REGISTRY}:${TAG}|" docker-compose.yml | |||
| echo " [2/3] 拉取新镜像..." | |||
| docker compose pull new-api | |||
| echo " [3/3] 重启服务..." | |||
| docker compose up -d new-api | |||
| sleep 5 | |||
| docker compose ps new-api | |||
| ok "${NAME} 部署成功" | |||
| } | |||
| case "$TARGET" in | |||
| cn) deploy_local "国内测试(CN)" "$CN_DIR" ;; | |||
| ov) deploy_local "国外测试(OV)" "$OV_DIR" ;; | |||
| all) | |||
| deploy_local "国内测试(CN)" "$CN_DIR" | |||
| deploy_local "国外测试(OV)" "$OV_DIR" | |||
| ;; | |||
| esac | |||
| # ==================== 总结 ==================== | |||
| echo "" | |||
| section "部署总结" | |||
| echo -e " 镜像: ${GREEN}${IMAGE}${NC}" | |||
| echo -e " 测试环境:" | |||
| echo -e " 国内: ${CYAN}http://localhost:23000${NC}" | |||
| echo -e " 国外: ${CYAN}http://localhost:13000${NC}" | |||
| if [ ${#FAILED[@]} -gt 0 ]; then | |||
| error "失败节点: ${FAILED[*]}" | |||
| exit 1 | |||
| else | |||
| ok "全部节点部署成功" | |||
| fi | |||
| @@ -0,0 +1,166 @@ | |||
| #!/bin/bash | |||
| # scripts/deploy.sh - 构建镜像并部署到国内/国外服务器 | |||
| # | |||
| # 用法 (在 Windows 中通过 WSL 调用): | |||
| # wsl -- bash -c "bash /mnt/d/code/new-api/scripts/deploy.sh [选项]" | |||
| # | |||
| # 选项: | |||
| # (无参数) 构建镜像 + 推送 + 部署到两台服务器 | |||
| # cn 只部署到国内服务器 | |||
| # ov 只部署到国外服务器 | |||
| # --no-build 跳过构建推送,使用本地最新镜像部署 | |||
| # --no-build cn 可组合 | |||
| # | |||
| # 前提: WSL 中已配置 SSH 免密登录到两台服务器 | |||
| set -euo pipefail | |||
| # ==================== 配置 ==================== | |||
| DOCKER_REGISTRY="registry.cn-hangzhou.aliyuncs.com/fengsilin/new-api" | |||
| PROJECT_DIR="/mnt/d/code/new-api" | |||
| # 服务器: SSH_HOST COMPOSE_DIR | |||
| CN_HOST="root@123.57.74.135" | |||
| CN_DIR="/root/new-api" | |||
| OV_HOST="root@139.180.189.205" | |||
| OV_DIR="/root/new-api/new-api" | |||
| # ==================== 颜色 ==================== | |||
| RED='\033[0;31m' | |||
| GREEN='\033[0;32m' | |||
| YELLOW='\033[1;33m' | |||
| CYAN='\033[0;36m' | |||
| BOLD='\033[1m' | |||
| NC='\033[0m' | |||
| info() { echo -e " ${CYAN}[INFO]${NC} $*"; } | |||
| ok() { echo -e " ${GREEN}[OK]${NC} $*"; } | |||
| warn() { echo -e " ${YELLOW}[WARN]${NC} $*"; } | |||
| error() { echo -e " ${RED}[ERROR]${NC} $*"; } | |||
| section() { | |||
| echo "" | |||
| echo -e " ${BOLD}=========================================${NC}" | |||
| echo -e " ${BOLD} $*${NC}" | |||
| echo -e " ${BOLD}=========================================${NC}" | |||
| } | |||
| # ==================== 参数解析 ==================== | |||
| TARGET="all" | |||
| NO_BUILD=false | |||
| for arg in "$@"; do | |||
| case "$arg" in | |||
| cn) TARGET="cn" ;; | |||
| ov) TARGET="ov" ;; | |||
| --no-build) NO_BUILD=true ;; | |||
| -h|--help) | |||
| echo "用法: $0 [cn|ov] [--no-build]" | |||
| exit 0 | |||
| ;; | |||
| *) | |||
| error "未知参数: $arg" | |||
| exit 1 | |||
| ;; | |||
| esac | |||
| done | |||
| # ==================== Step 1: 构建并推送 ==================== | |||
| if [ "$NO_BUILD" = false ]; then | |||
| section "Step 1/2: 构建并推送镜像" | |||
| cd "$PROJECT_DIR" | |||
| BRANCH=$(git rev-parse --abbrev-ref HEAD | sed 's/\//-/g') | |||
| TAG="$(date +%Y%m%d%H%M)-${BRANCH}" | |||
| IMAGE="${DOCKER_REGISTRY}:${TAG}" | |||
| info "分支: ${BRANCH}" | |||
| info "Tag: ${TAG}" | |||
| info "镜像: ${IMAGE}" | |||
| echo "" | |||
| docker build -t "${IMAGE}" . | |||
| echo "" | |||
| info "推送镜像..." | |||
| docker push "${IMAGE}" | |||
| ok "构建并推送完成: ${TAG}" | |||
| else | |||
| section "跳过构建,查找本地最新镜像..." | |||
| IMAGE=$(docker images --format "{{.Repository}}:{{.Tag}}" "${DOCKER_REGISTRY}" | head -1) | |||
| if [ -z "$IMAGE" ]; then | |||
| error "本地未找到 ${DOCKER_REGISTRY} 的镜像" | |||
| error "请先不带 --no-build 运行一次" | |||
| exit 1 | |||
| fi | |||
| TAG="${IMAGE##*:}" | |||
| ok "使用镜像: ${IMAGE}" | |||
| fi | |||
| # ==================== Step 2: 部署 ==================== | |||
| section "Step 2/2: 部署到服务器" | |||
| FAILED=() | |||
| deploy_server() { | |||
| local NAME="$1" | |||
| local HOST="$2" | |||
| local DIR="$3" | |||
| echo "" | |||
| info "部署到 ${NAME} (${HOST})..." | |||
| info " 目录: ${DIR} Tag: ${TAG}" | |||
| echo "" | |||
| if ssh -o ConnectTimeout=15 "$HOST" bash -s "$DIR" "$DOCKER_REGISTRY" "$TAG" <<'REMOTE'; then | |||
| set -e | |||
| DIR="$1" | |||
| REGISTRY="$2" | |||
| TAG="$3" | |||
| cd "$DIR" | |||
| echo " [1/4] 更新 image tag..." | |||
| sed -i "s|${REGISTRY}:.*|${REGISTRY}:${TAG}|" docker-compose.yml | |||
| echo " [2/4] 拉取新镜像..." | |||
| docker compose pull new-api | |||
| echo " [3/4] 重启服务..." | |||
| docker compose up -d new-api | |||
| echo " [4/4] 等待启动..." | |||
| sleep 8 | |||
| docker compose ps new-api | |||
| docker image prune -f >/dev/null 2>&1 | |||
| REMOTE | |||
| ok "${NAME} 部署成功" | |||
| else | |||
| error "${NAME} 部署失败" | |||
| FAILED+=("$NAME") | |||
| fi | |||
| } | |||
| case "$TARGET" in | |||
| cn) deploy_server "国内(CN)" "$CN_HOST" "$CN_DIR" ;; | |||
| ov) deploy_server "国外(OV)" "$OV_HOST" "$OV_DIR" ;; | |||
| all) | |||
| deploy_server "国内(CN)" "$CN_HOST" "$CN_DIR" | |||
| deploy_server "国外(OV)" "$OV_HOST" "$OV_DIR" | |||
| ;; | |||
| esac | |||
| # ==================== 总结 ==================== | |||
| echo "" | |||
| section "部署总结" | |||
| echo -e " 镜像: ${GREEN}${IMAGE}${NC}" | |||
| if [ ${#FAILED[@]} -gt 0 ]; then | |||
| error "失败节点: ${FAILED[*]}" | |||
| exit 1 | |||
| else | |||
| ok "全部节点部署成功" | |||
| fi | |||
| @@ -0,0 +1,10 @@ | |||
| #!/bin/bash | |||
| docker exec mysql mysql -uroot -pLanqi123456 new-api -e " | |||
| SELECT l.request_id, FROM_UNIXTIME(l.created_at) as time_utc, l.model_name, l.upstream_id, l.quota, l.prompt_tokens, l.completion_tokens, l.use_time, l.channel_id, l.content, l.other | |||
| FROM logs l | |||
| WHERE l.request_id IN ( | |||
| '20260508144012128695707Koi8kaar', | |||
| '20260508153319872959264rIR9KR6a' | |||
| ) | |||
| ORDER BY l.created_at ASC; | |||
| " | |||
| @@ -0,0 +1,774 @@ | |||
| #!/usr/bin/env python3 | |||
| """Write test implementation plan files for Phase 1-5.""" | |||
| import os | |||
| BASE = r"D:\code\new-api\docs\superpowers\plans" | |||
| def write_file(name, content): | |||
| path = os.path.join(BASE, name) | |||
| with open(path, 'w', encoding='utf-8') as f: | |||
| f.write(content) | |||
| lines = content.count('\n') | |||
| print(f" Written: {name} ({lines} lines)") | |||
| def main(): | |||
| # Phase 1 | |||
| write_file("phase1-model-tests.md", PHASE1) | |||
| # Phase 2 | |||
| write_file("phase2-service-billing.md", PHASE2) | |||
| # Phase 3 | |||
| write_file("phase3-middleware.md", PHASE3) | |||
| # Phase 4 | |||
| write_file("phase4-openai-adaptor.md", PHASE4) | |||
| # Phase 5 | |||
| write_file("phase5-e2e.md", PHASE5) | |||
| print("All plan files written.") | |||
| # ─── Phase 1 ─────────────────────────────────────────────────────────────── | |||
| PHASE1 = r"""# Phase 1: Model 层核心 CRUD 测试 实施计划 | |||
| > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. | |||
| **Goal:** 为 model/ 目录下的 user, token, channel, ability, utils 添加约 73 个集成测试 | |||
| **Architecture:** SQLite 内存 DB + 真实业务逻辑,testutil.SetupTestDB 初始化,表驱动测试优先。 | |||
| **Tech Stack:** Go, github.com/glebarez/sqlite, github.com/stretchr/testify, gorm.io/gorm | |||
| **Pre-requisites:** Phase 0 (testutil) 已完成 | |||
| --- | |||
| ## 通用模式 | |||
| 所有 model 测试共享 import 和 DB setup: | |||
| ```go | |||
| import ( | |||
| "fmt" | |||
| "sync" | |||
| "testing" | |||
| "time" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| "github.com/QuantumNous/new-api/testutil" | |||
| "github.com/stretchr/testify/assert" | |||
| "github.com/stretchr/testify/require" | |||
| ) | |||
| func setupModelTestDB(t *testing.T) *gorm.DB { | |||
| t.Helper() | |||
| return testutil.SetupTestDB(t, | |||
| &model.User{}, &model.Token{}, &model.Channel{}, | |||
| &model.Ability{}, &model.Log{}, &model.Model{}, | |||
| ) | |||
| } | |||
| ``` | |||
| --- | |||
| ## Task 1: model/user_test.go (22 tests) | |||
| - [ ] Write TestUserInsert: normal registration, verify hashed password, defaults, aff code | |||
| - [ ] Write TestUserInsert_DuplicateUsername: unique constraint error | |||
| - [ ] Write TestUserValidateAndFill: table-driven (correct/wrong/not-found/disabled) | |||
| - [ ] Write TestIncreaseUserQuota / TestDecreaseUserQuota: atomic quota operations | |||
| - [ ] Write TestDecreaseUserQuota_Insufficient: behavior when quota goes negative | |||
| - [ ] Write TestAtomicDecreaseSyncedQuota: table-driven (sufficient/exact/insufficient/threshold/zero) | |||
| - [ ] Write TestUserInsert_WithInviter: verify inviter AffCount increment | |||
| - [ ] Write TestUserInsert_WithInviter_Concurrent: 10 goroutines, verify known race condition | |||
| - [ ] Write TestTransferAffQuotaToQuota / _Insufficient / _Concurrent: FOR UPDATE lock verification | |||
| - [ ] Write P2 tests: Update, Edit, Delete, HardDelete, GetAllUsers, SearchUsers | |||
| - [ ] Run: `go test -v -run "TestUser" ./model/ -count=1` | |||
| - [ ] Commit: `git commit -m "test: add user model integration tests (22 tests)"` | |||
| ### Key Test Code | |||
| ```go | |||
| func TestUserInsert(t *testing.T) { | |||
| db := setupModelTestDB(t) | |||
| _ = db | |||
| user := &model.User{Username: "testuser", Password: "password123"} | |||
| err := user.Insert(0) | |||
| require.NoError(t, err) | |||
| assert.Greater(t, user.Id, 0) | |||
| assert.NotEqual(t, "password123", user.Password) | |||
| assert.Equal(t, common.RoleCommonUser, user.Role) | |||
| assert.NotEmpty(t, user.AffCode) | |||
| } | |||
| ``` | |||
| ```go | |||
| func TestAtomicDecreaseSyncedQuota(t *testing.T) { | |||
| db := setupModelTestDB(t) | |||
| _ = db | |||
| tests := []struct{ name string; syncedQuota, amount, threshold int; wantOk bool }{ | |||
| {"sufficient", 1000, 300, 0, true}, | |||
| {"exactly_zero", 300, 300, 0, true}, | |||
| {"below_threshold", 500, 300, 300, false}, | |||
| {"insufficient", 100, 200, 0, false}, | |||
| {"zero_quota", 0, 100, 0, false}, | |||
| } | |||
| for _, tt := range tests { | |||
| t.Run(tt.name, func(t *testing.T) { | |||
| user := testutil.SeedUser(t, db, func(u *model.User) { | |||
| u.Source = "synced"; u.SyncedQuota = tt.syncedQuota | |||
| }) | |||
| model.DB.Model(user).Update("synced_quota", tt.syncedQuota) | |||
| balance, ok, err := model.AtomicDecreaseSyncedQuota(user.Id, tt.amount, tt.threshold) | |||
| require.NoError(t, err) | |||
| assert.Equal(t, tt.wantOk, ok) | |||
| if tt.wantOk { assert.Equal(t, tt.syncedQuota-tt.amount, balance) } | |||
| }) | |||
| } | |||
| } | |||
| ``` | |||
| --- | |||
| ## Task 2: model/token_test.go (16 tests) | |||
| - [ ] Write TestValidateUserToken: table-driven (enabled/exhausted/expired/disabled) | |||
| - [ ] Write TestValidateUserToken_NotFound | |||
| - [ ] Write TestIncreaseTokenQuota / TestDecreaseTokenQuota | |||
| - [ ] Write TestTokenInsert / TestTokenUpdate / TestTokenDelete | |||
| - [ ] Write TestBatchDeleteTokens | |||
| - [ ] Write P2: GetAllUserTokens_Pagination, CountUserTokens, TokenSelectUpdate | |||
| - [ ] Run: `go test -v -run "TestToken|TestValidate|TestIncrease|TestDecrease|TestBatch" ./model/ -count=1` | |||
| - [ ] Commit: `git commit -m "test: add token model integration tests (16 tests)"` | |||
| ### Key Test Code | |||
| ```go | |||
| func TestValidateUserToken(t *testing.T) { | |||
| db := setupModelTestDB(t) | |||
| _ = db | |||
| tests := []struct { | |||
| name string; setupToken func() *model.Token; wantErr bool; errContains string | |||
| }{ | |||
| {"enabled", func() *model.Token { | |||
| return testutil.SeedToken(t, db, 0, func(tok *model.Token) { | |||
| tok.Status = common.TokenStatusEnabled; tok.RemainQuota = 1000; tok.ExpiredTime = -1 | |||
| }) | |||
| }, false, ""}, | |||
| {"exhausted", func() *model.Token { | |||
| return testutil.SeedToken(t, db, 0, func(tok *model.Token) { | |||
| tok.Status = common.TokenStatusExhausted | |||
| }) | |||
| }, true, "耗尽"}, | |||
| {"expired", func() *model.Token { | |||
| return testutil.SeedToken(t, db, 0, func(tok *model.Token) { | |||
| tok.Status = common.TokenStatusEnabled; tok.ExpiredTime = time.Now().Unix() - 3600 | |||
| }) | |||
| }, true, "过期"}, | |||
| {"disabled", func() *model.Token { | |||
| return testutil.SeedToken(t, db, 0, func(tok *model.Token) { | |||
| tok.Status = common.TokenStatusDisabled | |||
| }) | |||
| }, true, ""}, | |||
| } | |||
| for _, tt := range tests { | |||
| t.Run(tt.name, func(t *testing.T) { | |||
| token := tt.setupToken() | |||
| found, err := model.ValidateUserToken(token.Key) | |||
| if tt.wantErr { assert.Error(t, err) } else { | |||
| assert.NoError(t, err); assert.Equal(t, token.Id, found.Id) | |||
| } | |||
| }) | |||
| } | |||
| } | |||
| ``` | |||
| --- | |||
| ## Task 3: model/channel_test.go (17 tests) | |||
| - [ ] Write TestChannelInsert: verify Ability auto-generation | |||
| - [ ] Write TestChannelDelete: verify Ability cleanup | |||
| - [ ] Write TestUpdateChannelStatus_SingleKey: enable/disable cycle | |||
| - [ ] Write TestUpdateChannelStatus_MultiKey_AllDisabled: auto AutoDisabled | |||
| - [ ] Write TestUpdateChannelStatus_MultiKey_PartialDisabled | |||
| - [ ] Write TestGetNextEnabledKey_Random / _Polling | |||
| - [ ] Write P1: ChannelUpdate_AbilityRebuild, BatchInsertChannels, BatchDeleteChannels, EnableChannelByTag, EditChannelByTag | |||
| - [ ] Write P2: ChannelSave, GetAllChannels, SearchChannels, DisableChannelByTag, DeleteChannelByStatus | |||
| - [ ] Run: `go test -v -run "TestChannel|TestUpdateChannel|TestGetNext|TestBatch" ./model/ -count=1` | |||
| - [ ] Commit: `git commit -m "test: add channel model integration tests (17 tests)"` | |||
| ### Key Test Code | |||
| ```go | |||
| func TestChannelInsert(t *testing.T) { | |||
| db := setupModelTestDB(t) | |||
| _ = db | |||
| channel := testutil.SeedChannel(t, db) | |||
| assert.Greater(t, channel.Id, 0) | |||
| abilities, err := model.GetAbilitiesByChannelId(channel.Id) | |||
| require.NoError(t, err) | |||
| assert.GreaterOrEqual(t, len(abilities), 2) // gpt-4 + gpt-3.5-turbo | |||
| } | |||
| func TestUpdateChannelStatus_MultiKey_AllDisabled(t *testing.T) { | |||
| db := setupModelTestDB(t) | |||
| _ = db | |||
| keys := []string{"key1", "key2", "key3"} | |||
| channel := testutil.SeedChannel(t, db, func(ch *model.Channel) { | |||
| ch.Keys = keys | |||
| ch.ChannelInfo = model.ChannelInfo{ | |||
| IsMultiKey: true, MultiKeySize: 3, | |||
| MultiKeyStatusList: map[int]int{0: 1, 1: 1, 2: 1}, | |||
| } | |||
| }) | |||
| for i, key := range keys { | |||
| model.UpdateChannelStatus(channel.Id, key, common.ChannelStatusManuallyDisabled, fmt.Sprintf("key %d", i)) | |||
| } | |||
| found, _ := model.GetChannelById(channel.Id, true) | |||
| assert.Equal(t, common.ChannelStatusAutoDisabled, found.Status) | |||
| } | |||
| ``` | |||
| --- | |||
| ## Task 4: model/ability_test.go (9 tests) | |||
| - [ ] Write TestGetChannel_WeightedRandom: 100 iterations, higher weight selected more | |||
| - [ ] Write TestGetChannel_PriorityFallback: retry=0 high priority, retry=1 fallback | |||
| - [ ] Write TestAddAbilities_ModelSync: verify Model table auto-sync | |||
| - [ ] Write P1: UpdateAbilities, FixAbility, UpdateAbilityStatus | |||
| - [ ] Write P2: GetGroupEnabledModels, GetAllEnableAbilities, GetAbilitiesByChannelId | |||
| - [ ] Run + Commit | |||
| --- | |||
| ## Task 5: model/utils_test.go (6 tests) | |||
| - [ ] Write TestAddNewRecord_Concurrent: 100 goroutines no panic | |||
| - [ ] Write TestBatchUpdate_Flush: addNewRecord + batchUpdate + verify DB | |||
| - [ ] Write TestBatchUpdate_MultipleTypes: user+token+used quota types | |||
| - [ ] Write TestBatchUpdate_EmptyMap: no data no SQL | |||
| - [ ] Write TestShouldUpdateRedis / TestRecordExist | |||
| - [ ] Run + Commit | |||
| --- | |||
| ## Task 6: Final Verification | |||
| - [ ] `go test -v ./model/ -count=1` — all pass | |||
| - [ ] `go test -race ./model/ -count=1` — no race conditions | |||
| """ | |||
| # ─── Phase 2 ─────────────────────────────────────────────────────────────── | |||
| PHASE2 = r"""# Phase 2: Service 层计费系统测试 实施计划 | |||
| > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. | |||
| **Goal:** 为计费系统(funding_source, billing_session, billing, quota)添加约 55 个测试 | |||
| **Architecture:** SQLite 内存 DB + 真实业务逻辑。需要构造 RelayInfo 和 gin.Context。BillingSession 使用真实 FundingSource 实现。 | |||
| **Tech Stack:** Go, github.com/glebarez/sqlite, github.com/stretchr/testify, gorm.io/gorm, github.com/gin-gonic/gin | |||
| **Pre-requisites:** Phase 0 (testutil) + Phase 1 (model tests) 已完成 | |||
| --- | |||
| ## 通用 setup | |||
| ```go | |||
| import ( | |||
| "testing" | |||
| "github.com/QuantumNous/new-api/common" | |||
| "github.com/QuantumNous/new-api/model" | |||
| "github.com/QuantumNous/new-api/service" | |||
| "github.com/QuantumNous/new-api/testutil" | |||
| relaycommon "github.com/QuantumNous/new-api/relay/common" | |||
| "github.com/gin-gonic/gin" | |||
| "github.com/stretchr/testify/assert" | |||
| "github.com/stretchr/testify/require" | |||
| ) | |||
| func setupBillingTestDB(t *testing.T) *gorm.DB { | |||
| t.Helper() | |||
| return testutil.SetupTestDB(t, | |||
| &model.User{}, &model.Token{}, &model.Channel{}, | |||
| &model.Ability{}, &model.Log{}, &model.Model{}, | |||
| &model.UserSubscription{}, &model.SubscriptionPlan{}, | |||
| ) | |||
| } | |||
| ``` | |||
| --- | |||
| ## Task 1: service/funding_source_test.go (15 tests) | |||
| - [ ] Write WalletFunding tests (8): Source, PreConsume, PreConsume_Zero, Settle_Positive, Settle_Negative, Settle_Zero, Refund, Refund_NotConsumed | |||
| - [ ] Write SubscriptionFunding tests (7): Source, PreConsume, PreConsume_IgnoresAmount, Settle_Positive, Settle_Negative, Refund, Refund_Retry | |||
| - [ ] Run: `go test -v -run "TestWalletFunding|TestSubscriptionFunding" ./service/ -count=1` | |||
| - [ ] Commit: `git commit -m "test: add funding source tests (15 tests)"` | |||
| ### Key Test Code | |||
| ```go | |||
| func TestWalletFunding_PreConsume(t *testing.T) { | |||
| db := setupBillingTestDB(t) | |||
| _ = db | |||
| user := testutil.SeedUser(t, db, func(u *model.User) { u.Quota = 10000 }) | |||
| w := service.NewWalletFunding(user.Id) // 或构造函数 | |||
| err := w.PreConsume(1000) | |||
| require.NoError(t, err) | |||
| testutil.AssertQuotaEquals(t, db, user.Id, 9000) | |||
| } | |||
| func TestWalletFunding_Refund(t *testing.T) { | |||
| db := setupBillingTestDB(t) | |||
| _ = db | |||
| user := testutil.SeedUser(t, db, func(u *model.User) { u.Quota = 10000 }) | |||
| w := service.NewWalletFunding(user.Id) | |||
| _ = w.PreConsume(1000) | |||
| err := w.Refund() | |||
| require.NoError(t, err) | |||
| testutil.AssertQuotaEquals(t, db, user.Id, 10000) // 全额退回 | |||
| } | |||
| func TestSubscriptionFunding_PreConsume_IgnoresAmount(t *testing.T) { | |||
| db := setupBillingTestDB(t) | |||
| _ = db | |||
| user := testutil.SeedUser(t, db) | |||
| sub := testutil.SeedSubscription(t, db, user.Id) | |||
| s := service.NewSubscriptionFunding("req-123", user.Id, "gpt-4", 5000, sub.Id) | |||
| // 传入 9999 但应该被忽略 | |||
| err := s.PreConsume(9999) | |||
| require.NoError(t, err) | |||
| assert.Equal(t, "subscription", s.Source()) | |||
| } | |||
| ``` | |||
| Note: `NewWalletFunding` / `NewSubscriptionFunding` 构造函数签名需根据实际代码调整。如果这些类型不是导出的,测试需要在 service 包内。 | |||
| --- | |||
| ## Task 2: service/billing_session_test.go (25 tests) | |||
| - [ ] Write PreConsume tests (12): 4 preference paths, trust bypass, force pre-consume, subscription no bypass, funding fail rollback | |||
| - [ ] Write Settle tests (6): zero/positive/negative delta, idempotent, wallet/subscription full flow | |||
| - [ ] Write Refund tests (4): full refund, idempotent, after settle skipped, zero consumed skipped | |||
| - [ ] Write Lifecycle tests (3): normal PreConsume->Settle, failure PreConsume->Refund, concurrent | |||
| - [ ] Run: `go test -v -run "TestNewBillingSession|TestSettle|TestRefund|TestBillingSession_Lifecycle" ./service/ -count=1` | |||
| - [ ] Commit: `git commit -m "test: add billing session tests (25 tests)"` | |||
| ### Key Test Code | |||
| ```go | |||
| func TestNewBillingSession_WalletOnly(t *testing.T) { | |||
| db := setupBillingTestDB(t) | |||
| _ = db | |||
| user := testutil.SeedUser(t, db, func(u *model.User) { u.Quota = 100000 }) | |||
| token := testutil.SeedToken(t, db, user.Id, func(tok *model.Token) { | |||
| tok.UnlimitedQuota = true | |||
| }) | |||
| c, _ := testutil.NewTestGinContext("POST", "/v1/chat/completions", nil, nil) | |||
| relayInfo := &relaycommon.RelayInfo{ | |||
| UserId: user.Id, TokenId: token.Id, TokenKey: token.Key, | |||
| UserGroup: "default", UsingGroup: "default", | |||
| } | |||
| session, apiErr := service.NewBillingSession(c, relayInfo, 1000) | |||
| require.Nil(t, apiErr) | |||
| assert.NotNil(t, session) | |||
| assert.Equal(t, 1000, session.GetPreConsumedQuota()) | |||
| } | |||
| ``` | |||
| Note: `BillingSession` 的构造需要设置用户计费偏好 (`billing_preference`)。可能需要 mock 或设置 `common.NormalizeBillingPreference` 返回值。具体实现取决于代码中偏好设置的读取方式。 | |||
| --- | |||
| ## Task 3: service/billing_test.go (5 tests) | |||
| - [ ] Write SettleBilling_WithBillingSession / _WithoutBillingSession | |||
| - [ ] Write PreConsumeBilling_AssignsRelayInfo | |||
| - [ ] Write SettleBilling_NotifiesQuota / _ZeroActualQuota | |||
| - [ ] Run + Commit | |||
| --- | |||
| ## Task 4: service/quota_test.go (5 tests) | |||
| - [ ] Write PostConsumeQuota_Wallet / _Subscription / _NegativeQuota | |||
| - [ ] Write PreConsumeTokenQuota_Success / _Insufficient | |||
| - [ ] Run + Commit | |||
| --- | |||
| ## Task 5: Final Verification | |||
| - [ ] `go test -v ./service/ -count=1` — all pass | |||
| """ | |||
| # ─── Phase 3 ─────────────────────────────────────────────────────────────── | |||
| PHASE3 = r"""# Phase 3: Middleware 层测试 实施计划 | |||
| > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. | |||
| **Goal:** 为 middleware/ 目录下的 auth, rate-limit, distributor 添加约 55 个测试 | |||
| **Architecture:** gin.TestMode + httptest.NewRecorder 构造 Context。需要 mock model 层全局函数(通过 DB 种子数据或设置全局变量)。 | |||
| **Tech Stack:** Go, github.com/gin-gonic/gin, net/http/httptest, github.com/stretchr/testify | |||
| **Pre-requisites:** Phase 0-1 已完成 | |||
| --- | |||
| ## Task 1: middleware/auth_test.go (20 tests) | |||
| - [ ] Write TokenAuth tests (11): BearerToken, MissingAuth, InvalidKey, DisabledToken, ExpiredToken, ExhaustedToken, WithChannelId, SetsContextCorrectly, DisabledUser, GroupNotInRatio, IPWhitelist | |||
| - [ ] Write MultiProtocol tests (4): AnthropicXApiKey, GeminiQueryParam, GeminiHeader, WebSocketProtocol | |||
| - [ ] Write authHelper tests (5): UserAuth_Valid, AdminAuth_NonAdmin, RootAuth_NonRoot, TokenOrUserAuth_Fallback | |||
| - [ ] Run: `go test -v -run "TestTokenAuth|TestUserAuth|TestAdminAuth|TestRootAuth" ./middleware/ -count=1` | |||
| - [ ] Commit | |||
| ### Key Pattern | |||
| ```go | |||
| func setupAuthTest(t *testing.T, token *model.Token, user *model.User) (*gin.Context, *httptest.ResponseRecorder) { | |||
| t.Helper() | |||
| gin.SetMode(gin.TestMode) | |||
| w := httptest.NewRecorder() | |||
| c, _ := gin.CreateTestContext(w) | |||
| c.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil) | |||
| if token != nil { | |||
| c.Request.Header.Set("Authorization", "Bearer "+token.Key) | |||
| } | |||
| return c, w | |||
| } | |||
| ``` | |||
| Note: TokenAuth 内部直接调用 `model.ValidateUserToken` 等全局函数。测试需要设置真实的 DB 数据(通过 testutil 种子数据),让这些函数正常工作。 | |||
| --- | |||
| ## Task 2: middleware/rate_limit_test.go (15 tests) | |||
| - [ ] Write GlobalAPIRateLimit tests (3): within/over/window expires | |||
| - [ ] Write CriticalRateLimit, MemoryMode, ConcurrentRequests | |||
| - [ ] Write ModelRateLimit tests (4): within/over/bygroup/success only | |||
| - [ ] Write other rate limit tests (5): email, search, upload, download, web | |||
| - [ ] Run + Commit | |||
| ### Key Pattern | |||
| ```go | |||
| func TestRateLimit_MemoryMode(t *testing.T) { | |||
| common.RedisEnabled = false | |||
| defer func() { common.RedisEnabled = true }() | |||
| // 设置限流参数 | |||
| common.GlobalApiRateLimitNum = 3 | |||
| common.GlobalApiRateLimitDuration = 60 | |||
| defer func() { | |||
| common.GlobalApiRateLimitNum = 180 | |||
| common.GlobalApiRateLimitDuration = 60 | |||
| }() | |||
| handler := middleware.GlobalAPIRateLimit() | |||
| for i := 0; i < 3; i++ { | |||
| c, w := testutil.NewTestGinContext("GET", "/api/test", nil, nil) | |||
| handler(c) | |||
| assert.Equal(t, 200, w.Code) // 或者 c.IsAborted() == false | |||
| } | |||
| // 第4次应被限流 | |||
| c, w := testutil.NewTestGinContext("GET", "/api/test", nil, nil) | |||
| handler(c) | |||
| assert.True(t, c.IsAborted()) | |||
| } | |||
| ``` | |||
| --- | |||
| ## Task 3: middleware/distributor_test.go (20 tests) | |||
| - [ ] Write getModelRequest tests (9): ChatCompletions, Embeddings, Images, AudioSpeech, AudioTranscription, Rerank, Responses, GeminiNative, Realtime | |||
| - [ ] Write Distribute tests (8): SpecificChannel, Disabled, ModelNotSupported, RandomSelection, AffinityReuse, NoAvailableChannel, TokenModelLimit, Allowed | |||
| - [ ] Write helper tests (3): CORS, RequestId, Recover | |||
| - [ ] Run + Commit | |||
| ### Key Pattern | |||
| ```go | |||
| func TestGetModelRequest_ChatCompletions(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| w := httptest.NewRecorder() | |||
| c, _ := gin.CreateTestContext(w) | |||
| body := strings.NewReader(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`) | |||
| c.Request = httptest.NewRequest("POST", "/v1/chat/completions", body) | |||
| c.Request.Header.Set("Content-Type", "application/json") | |||
| req, shouldSelect, err := getModelRequest(c) | |||
| require.NoError(t, err) | |||
| assert.True(t, shouldSelect) | |||
| assert.Equal(t, "gpt-4", req.Model) | |||
| } | |||
| ``` | |||
| --- | |||
| ## Task 4: Final Verification | |||
| - [ ] `go test -v ./middleware/ -count=1` | |||
| """ | |||
| # ─── Phase 4 ─────────────────────────────────────────────────────────────── | |||
| PHASE4 = r"""# Phase 4: OpenAI 适配器测试 实施计划 | |||
| > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. | |||
| **Goal:** 为 relay/channel/openai/ 添加约 55 个测试 | |||
| **Architecture:** 纯逻辑函数直接测试。HTTP handler 使用 httptest.NewServer mock 上游。不需要数据库。 | |||
| **Tech Stack:** Go, github.com/gin-gonic/gin, net/http/httptest, github.com/stretchr/testify | |||
| **Pre-requisites:** Phase 0 已完成(使用 testutil.NewTestGinContext) | |||
| --- | |||
| ## Task 1: relay/channel/openai/adaptor_test.go (15 tests) | |||
| - [ ] Write parseReasoningEffortFromModelSuffix tests (table-driven) | |||
| - [ ] Write detectImageMimeType tests (table-driven) | |||
| - [ ] Write Init, GetRequestURL (default/azure/custom), SetupRequestHeader (bearer/azure/org) | |||
| - [ ] Write ConvertOpenAIRequest tests (6): MaxCompletionTokens, TemperatureCleared, SystemToDeveloper, ReasoningEffort, NormalModel, OpenRouter | |||
| - [ ] Run + Commit | |||
| ### Key Test Code | |||
| ```go | |||
| func TestParseReasoningEffortFromModelSuffix(t *testing.T) { | |||
| tests := []struct{ model, wantEffort, wantOrigin string }{ | |||
| {"o3-mini:low", "low", "o3-mini"}, | |||
| {"o3-mini:high", "high", "o3-mini"}, | |||
| {"o3-mini:medium", "medium", "o3-mini"}, | |||
| {"o3-mini", "", "o3-mini"}, | |||
| {"gpt-4", "", "gpt-4"}, | |||
| } | |||
| for _, tt := range tests { | |||
| t.Run(tt.model, func(t *testing.T) { | |||
| effort, origin := parseReasoningEffortFromModelSuffix(tt.model) | |||
| assert.Equal(t, tt.wantEffort, effort) | |||
| assert.Equal(t, tt.wantOrigin, origin) | |||
| }) | |||
| } | |||
| } | |||
| func TestDetectImageMimeType(t *testing.T) { | |||
| tests := []struct{ filename, want string }{ | |||
| {"photo.png", "image/png"}, | |||
| {"photo.jpg", "image/jpeg"}, | |||
| {"photo.jpeg", "image/jpeg"}, | |||
| {"photo.webp", "image/webp"}, | |||
| {"photo.gif", "image/png"}, // fallback | |||
| } | |||
| for _, tt := range tests { | |||
| t.Run(tt.filename, func(t *testing.T) { | |||
| assert.Equal(t, tt.want, detectImageMimeType(tt.filename)) | |||
| }) | |||
| } | |||
| } | |||
| ``` | |||
| --- | |||
| ## Task 2: relay/channel/openai/relay_openai_test.go (20 tests) | |||
| - [ ] Write OpenaiHandler tests (5): Success, WithCacheTokens, ErrorResponse, NoPromptTokens, ContentFilter | |||
| - [ ] Write OaiStreamHandler tests (4): BasicStream, UsageExtraction, ThinkingContent, DoneSignal | |||
| - [ ] Write applyUsagePostProcessing tests (6): DeepSeek, Zhipu, Moonshot, NoProvider, ExtractCachedTokens, ExtractMoonshot | |||
| - [ ] Write special handler tests (5): ImageResponse, TTS, STT, FormatConversion_Claude, FormatConversion_Gemini | |||
| - [ ] Run + Commit | |||
| ### Key Pattern — HTTP Mock | |||
| ```go | |||
| func mockOpenAIResponse(t *testing.T, body string) *http.Response { | |||
| return &http.Response{ | |||
| StatusCode: 200, | |||
| Body: io.NopCloser(strings.NewReader(body)), | |||
| Header: http.Header{"Content-Type": []string{"application/json"}}, | |||
| } | |||
| } | |||
| func TestOpenaiHandler_Success(t *testing.T) { | |||
| gin.SetMode(gin.TestMode) | |||
| c, _ := testutil.NewTestGinContext("POST", "/v1/chat/completions", nil, nil) | |||
| respBody := `{"id":"chatcmpl-123","object":"chat.completion","model":"gpt-4",` + | |||
| `"choices":[{"message":{"role":"assistant","content":"hello"}}],` + | |||
| `"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}` | |||
| resp := mockOpenAIResponse(t, respBody) | |||
| info := &relaycommon.RelayInfo{ | |||
| IsStream: false, RelayMode: relayconstant.RelayModeChatCompletions, | |||
| UpstreamModelName: "gpt-4", | |||
| } | |||
| usage, apiErr := OpenaiHandler(c, info, resp) | |||
| assert.Nil(t, apiErr) | |||
| require.NotNil(t, usage) | |||
| assert.Equal(t, 10, usage.PromptTokens) | |||
| assert.Equal(t, 5, usage.CompletionTokens) | |||
| } | |||
| ``` | |||
| --- | |||
| ## Task 3: relay/channel/openai/helper_test.go (10 tests) | |||
| - [ ] Write ProcessStreamResponse, processTokens, handleLastResponse tests | |||
| - [ ] Write HandleStreamFormat_OpenAI, HandleStreamFormat_Claude tests | |||
| - [ ] Run + Commit | |||
| --- | |||
| ## Task 4: relay/channel/openai/chat_via_responses_test.go (5 tests) | |||
| - [ ] Write stringDeltaFromPrefix tests (table-driven) | |||
| - [ ] Write responsesStreamIndexKey tests | |||
| - [ ] Write OaiResponsesToChatHandler, OaiResponsesToChatStreamHandler, ToolCalls tests | |||
| - [ ] Run + Commit | |||
| ### Key Test Code | |||
| ```go | |||
| func TestStringDeltaFromPrefix(t *testing.T) { | |||
| tests := []struct{ prev, next, want string }{ | |||
| {"", "hello", "hello"}, | |||
| {"hel", "hello", "lo"}, | |||
| {"hello", "hello", ""}, | |||
| {"abc", "xyz", "xyz"}, // no prefix match | |||
| } | |||
| for _, tt := range tests { | |||
| got := stringDeltaFromPrefix(tt.prev, tt.next) | |||
| assert.Equal(t, tt.want, got) | |||
| } | |||
| } | |||
| ``` | |||
| --- | |||
| ## Task 5: relay/channel/openai/audio_test.go (5 tests) | |||
| - [ ] Write TTS and STT handler tests with mock HTTP responses | |||
| - [ ] Run + Commit | |||
| --- | |||
| ## Task 6: Final Verification | |||
| - [ ] `go test -v ./relay/channel/openai/ -count=1` | |||
| """ | |||
| # ─── Phase 5 ─────────────────────────────────────────────────────────────── | |||
| PHASE5 = r"""# Phase 5: 端到端集成测试 实施计划 | |||
| > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. | |||
| **Goal:** 编写 20 个端到端集成测试,验证完整请求生命周期 | |||
| **Architecture:** 完整 middleware chain (TokenAuth -> Distribute -> Relay) + mock 上游 HTTP 服务器。使用 SQLite 内存 DB。 | |||
| **Tech Stack:** Go, github.com/glebarez/sqlite, github.com/gin-gonic/gin, net/http/httptest | |||
| **Pre-requisites:** Phase 0-4 全部完成 | |||
| --- | |||
| ## Task 1: test/e2e/billing_lifecycle_test.go (20 tests) | |||
| - [ ] Write TestE2E_ChatRequest_WalletBilling: full lifecycle with wallet | |||
| - [ ] Write TestE2E_ChatRequest_SubscriptionBilling: full lifecycle with subscription | |||
| - [ ] Write TestE2E_ChatRequest_UpstreamError_Refund: upstream failure triggers refund | |||
| - [ ] Write TestE2E_StreamingRequest: SSE stream with token counting | |||
| - [ ] Write TestE2E_QuotaExceeded: insufficient quota returns 429 | |||
| - [ ] Write TestE2E_TokenExpired: expired token returns 401 | |||
| - [ ] Write TestE2E_ChannelFailover: channel failure triggers retry | |||
| - [ ] Write TestE2E_ConcurrentRequests: concurrent quota accuracy | |||
| - [ ] Write TestE2E_FreeModel_NoBilling: free model skips billing | |||
| - [ ] Write TestE2E_PerCallBilling: per-call billing model | |||
| - [ ] Write remaining 10 tests (Embeddings, Images, Audio, Rerank, Responses, Claude/Gemini format, TrustQuota, MultiChannel, Affinity) | |||
| - [ ] Run: `go test -v ./test/e2e/ -count=1` | |||
| - [ ] Commit | |||
| ### Key Pattern — E2E Test Setup | |||
| ```go | |||
| func setupE2ETest(t *testing.T) (*gin.Engine, *httptest.Server, *gorm.DB) { | |||
| t.Helper() | |||
| db := testutil.SetupTestDB(t, | |||
| &model.User{}, &model.Token{}, &model.Channel{}, | |||
| &model.Ability{}, &model.Log{}, &model.Model{}, | |||
| &model.UserSubscription{}, &model.SubscriptionPlan{}, | |||
| ) | |||
| // Mock upstream OpenAI server | |||
| upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| w.Header().Set("Content-Type", "application/json") | |||
| w.WriteHeader(200) | |||
| json.NewEncoder(w).Encode(map[string]any{ | |||
| "id": "chatcmpl-test", "object": "chat.completion", | |||
| "model": "gpt-4", | |||
| "choices": []map[string]any{{"message": map[string]any{"role": "assistant", "content": "hi"}}}, | |||
| "usage": map[string]any{"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, | |||
| }) | |||
| })) | |||
| t.Cleanup(upstream.Close) | |||
| // Setup router with real middleware chain | |||
| gin.SetMode(gin.TestMode) | |||
| router := gin.New() | |||
| // ... register middleware + routes | |||
| return router, upstream, db | |||
| } | |||
| func TestE2E_ChatRequest_WalletBilling(t *testing.T) { | |||
| router, upstream, db := setupE2ETest(t) | |||
| _ = upstream | |||
| user := testutil.SeedUser(t, db, func(u *model.User) { u.Quota = 1000000 }) | |||
| token := testutil.SeedToken(t, db, user.Id, func(tok *model.Token) { | |||
| tok.UnlimitedQuota = true | |||
| }) | |||
| initialQuota := user.Quota | |||
| // Make request | |||
| body := `{"model":"gpt-4","messages":[{"role":"user","content":"hello"}]}` | |||
| req := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(body)) | |||
| req.Header.Set("Authorization", "Bearer "+token.Key) | |||
| req.Header.Set("Content-Type", "application/json") | |||
| w := httptest.NewRecorder() | |||
| router.ServeHTTP(w, req) | |||
| assert.Equal(t, 200, w.Code) | |||
| testutil.AssertQuotaEquals(t, db, user.Id, initialQuota-expectedCost) | |||
| } | |||
| ``` | |||
| Note: E2E tests are the most complex. The exact setup depends on how the router is configured. The key is to: | |||
| 1. Create a real gin.Engine with the actual middleware chain | |||
| 2. Replace the upstream HTTP call with a mock server | |||
| 3. Verify the full request/response cycle and quota changes | |||
| --- | |||
| ## Task 2: Final Verification | |||
| - [ ] `go test -v ./test/e2e/ -count=1` | |||
| - [ ] `go test -v ./... -count=1` — full suite | |||
| """ | |||
| if __name__ == "__main__": | |||
| main() | |||
| @@ -0,0 +1,297 @@ | |||
| import React, { useState, useEffect, useMemo } from 'react'; | |||
| import { Card, Avatar, Typography, Tag, Spin } from '@douyinfe/semi-ui'; | |||
| import { IconCoinMoneyStroked } from '@douyinfe/semi-icons'; | |||
| import { API } from '../../../../../helpers'; | |||
| const { Text } = Typography; | |||
| const UserPriceComparison = ({ | |||
| modelName, | |||
| currency, | |||
| tokenUnit, | |||
| displayPrice, | |||
| t, | |||
| }) => { | |||
| const [loading, setLoading] = useState(false); | |||
| const [priceData, setPriceData] = useState(null); | |||
| useEffect(() => { | |||
| const fetchUserPrice = async () => { | |||
| if (!modelName) return; | |||
| setLoading(true); | |||
| try { | |||
| const res = await API.get( | |||
| `/api/pricing/user/${encodeURIComponent(modelName)}`, | |||
| { params: { _t: Date.now() } } | |||
| ); | |||
| const result = res.data; | |||
| if (result.success) { | |||
| setPriceData(result); | |||
| } else { | |||
| setPriceData(null); | |||
| } | |||
| } catch { | |||
| setPriceData(null); | |||
| } finally { | |||
| setLoading(false); | |||
| } | |||
| }; | |||
| fetchUserPrice(); | |||
| }, [modelName]); | |||
| const currencySymbol = useMemo(() => { | |||
| if (currency === 'CNY') return '¥'; | |||
| if (currency === 'CUSTOM') { | |||
| try { | |||
| const statusStr = localStorage.getItem('status'); | |||
| if (statusStr) { | |||
| const s = JSON.parse(statusStr); | |||
| return s?.custom_currency_symbol || '¤'; | |||
| } | |||
| } catch { | |||
| // ignore | |||
| } | |||
| return '¤'; | |||
| } | |||
| return '$'; | |||
| }, [currency]); | |||
| const unitDivisor = tokenUnit === 'K' ? 1000 : 1; | |||
| const unitLabel = tokenUnit === 'K' ? '1K' : '1M'; | |||
| const formatValue = (val) => { | |||
| if (val === undefined || val === null) return '-'; | |||
| const displayVal = displayPrice(val); | |||
| const num = parseFloat(displayVal.replace(/[^0-9.]/g, '')) / unitDivisor; | |||
| return `${currencySymbol}${num.toFixed(4)}`; | |||
| }; | |||
| const formatSavings = (original, user) => { | |||
| if (!original || !user) return '-'; | |||
| const diff = original - user; | |||
| const displayDiff = displayPrice(diff); | |||
| const num = parseFloat(displayDiff.replace(/[^0-9.]/g, '')) / unitDivisor; | |||
| return `${currencySymbol}${num.toFixed(4)}`; | |||
| }; | |||
| if (loading) { | |||
| return ( | |||
| <Card className='!rounded-2xl shadow-sm border-0 mb-4'> | |||
| <div className='flex justify-center items-center py-6'> | |||
| <Spin size='small' /> | |||
| </div> | |||
| </Card> | |||
| ); | |||
| } | |||
| if (!priceData) return null; | |||
| const { logged_in, savings_percent, quota_type } = priceData; | |||
| // 未登录:显示简洁原价 | |||
| if (!logged_in) { | |||
| return ( | |||
| <Card className='!rounded-2xl shadow-sm border-0 mb-4'> | |||
| <div className='flex items-center mb-3'> | |||
| <Avatar size='small' color='orange' className='mr-2 shadow-md'> | |||
| <IconCoinMoneyStroked size={16} /> | |||
| </Avatar> | |||
| <Text className='text-base font-medium'>{t('标准价格')}</Text> | |||
| </div> | |||
| <div className='price-comparison-simple'> | |||
| {quota_type === 0 ? ( | |||
| <div className='flex flex-wrap gap-6'> | |||
| <div> | |||
| <span className='text-xs text-gray-500'>{t('输入')}</span> | |||
| <div className='font-semibold text-orange-600'> | |||
| {formatValue(priceData.original_input)} | |||
| </div> | |||
| <div className='text-xs text-gray-400'>/ {unitLabel} tokens</div> | |||
| </div> | |||
| <div> | |||
| <span className='text-xs text-gray-500'>{t('输出')}</span> | |||
| <div className='font-semibold text-orange-600'> | |||
| {formatValue(priceData.original_output)} | |||
| </div> | |||
| <div className='text-xs text-gray-400'>/ {unitLabel} tokens</div> | |||
| </div> | |||
| </div> | |||
| ) : ( | |||
| <div> | |||
| <div className='font-semibold text-orange-600'> | |||
| {formatValue(priceData.original_price)} | |||
| </div> | |||
| <div className='text-xs text-gray-400'>/ {t('次')}</div> | |||
| </div> | |||
| )} | |||
| </div> | |||
| </Card> | |||
| ); | |||
| } | |||
| // 已登录但无折扣:不显示 | |||
| if (!savings_percent || savings_percent <= 0) return null; | |||
| // 已登录且有折扣:显示方案A划线对比 | |||
| const discount = priceData.discount || ''; | |||
| return ( | |||
| <Card | |||
| className='!rounded-2xl shadow-sm border-0 mb-4' | |||
| style={{ | |||
| background: 'linear-gradient(135deg, #fff7e6 0%, #fff 100%)', | |||
| border: '1px solid #ffe7ba', | |||
| }} | |||
| > | |||
| <div className='flex items-center justify-between mb-3'> | |||
| <div className='flex items-center'> | |||
| <Avatar size='small' color='orange' className='mr-2 shadow-md'> | |||
| <IconCoinMoneyStroked size={16} /> | |||
| </Avatar> | |||
| <Text className='text-base font-medium'>{t('您的分组价格')}</Text> | |||
| </div> | |||
| {discount && ( | |||
| <Tag | |||
| style={{ | |||
| background: 'linear-gradient(135deg, #cf1322, #a8071a)', | |||
| color: '#fff', | |||
| border: 'none', | |||
| fontSize: '12px', | |||
| fontWeight: 600, | |||
| }} | |||
| size='small' | |||
| shape='circle' | |||
| > | |||
| {discount} | |||
| </Tag> | |||
| )} | |||
| </div> | |||
| <Tag | |||
| color='purple' | |||
| size='small' | |||
| shape='circle' | |||
| className='mb-3' | |||
| > | |||
| {priceData.group} {t('分组')} | |||
| </Tag> | |||
| {quota_type === 0 ? ( | |||
| <> | |||
| {/* 输入价格 */} | |||
| <div className='mb-3'> | |||
| <div className='text-xs text-gray-500 mb-1'>{t('输入价格')}</div> | |||
| <div className='flex items-baseline flex-wrap gap-2'> | |||
| <span | |||
| style={{ | |||
| textDecoration: 'line-through', | |||
| textDecorationColor: '#d48806', | |||
| textDecorationThickness: '2px', | |||
| color: '#bbb', | |||
| fontSize: '14px', | |||
| }} | |||
| > | |||
| {formatValue(priceData.original_input)} | |||
| </span> | |||
| <span | |||
| style={{ | |||
| fontSize: '20px', | |||
| fontWeight: 700, | |||
| color: '#cf1322', | |||
| }} | |||
| > | |||
| {formatValue(priceData.user_input)} | |||
| </span> | |||
| <span className='text-xs text-gray-400'> | |||
| / {unitLabel} tokens | |||
| </span> | |||
| </div> | |||
| <div className='flex items-center gap-1 text-xs text-green-600 mt-1'> | |||
| <svg width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2'> | |||
| <polyline points='23 6 13.5 15.5 8.5 10.5 1 18' /> | |||
| <polyline points='17 6 23 6 23 12' /> | |||
| </svg> | |||
| {t('每')} {unitLabel} tokens {t('节省')} {formatSavings(priceData.original_input, priceData.user_input)} | |||
| </div> | |||
| </div> | |||
| {/* 输出价格 */} | |||
| <div> | |||
| <div className='text-xs text-gray-500 mb-1'>{t('输出价格')}</div> | |||
| <div className='flex items-baseline flex-wrap gap-2'> | |||
| <span | |||
| style={{ | |||
| textDecoration: 'line-through', | |||
| textDecorationColor: '#d48806', | |||
| textDecorationThickness: '2px', | |||
| color: '#bbb', | |||
| fontSize: '14px', | |||
| }} | |||
| > | |||
| {formatValue(priceData.original_output)} | |||
| </span> | |||
| <span | |||
| style={{ | |||
| fontSize: '20px', | |||
| fontWeight: 700, | |||
| color: '#cf1322', | |||
| }} | |||
| > | |||
| {formatValue(priceData.user_output)} | |||
| </span> | |||
| <span className='text-xs text-gray-400'> | |||
| / {unitLabel} tokens | |||
| </span> | |||
| </div> | |||
| <div className='flex items-center gap-1 text-xs text-green-600 mt-1'> | |||
| <svg width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2'> | |||
| <polyline points='23 6 13.5 15.5 8.5 10.5 1 18' /> | |||
| <polyline points='17 6 23 6 23 12' /> | |||
| </svg> | |||
| {t('每')} {unitLabel} tokens {t('节省')} {formatSavings(priceData.original_output, priceData.user_output)} | |||
| </div> | |||
| </div> | |||
| </> | |||
| ) : ( | |||
| <> | |||
| {/* 按次计费 */} | |||
| <div> | |||
| <div className='text-xs text-gray-500 mb-1'>{t('每次调用')}</div> | |||
| <div className='flex items-baseline flex-wrap gap-2'> | |||
| <span | |||
| style={{ | |||
| textDecoration: 'line-through', | |||
| textDecorationColor: '#d48806', | |||
| textDecorationThickness: '2px', | |||
| color: '#bbb', | |||
| fontSize: '14px', | |||
| }} | |||
| > | |||
| {formatValue(priceData.original_price)} | |||
| </span> | |||
| <span | |||
| style={{ | |||
| fontSize: '20px', | |||
| fontWeight: 700, | |||
| color: '#cf1322', | |||
| }} | |||
| > | |||
| {formatValue(priceData.user_price)} | |||
| </span> | |||
| <span className='text-xs text-gray-400'>/ {t('次')}</span> | |||
| </div> | |||
| <div className='flex items-center gap-1 text-xs text-green-600 mt-1'> | |||
| <svg width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2'> | |||
| <polyline points='23 6 13.5 15.5 8.5 10.5 1 18' /> | |||
| <polyline points='17 6 23 6 23 12' /> | |||
| </svg> | |||
| {t('每次调用节省')} {formatSavings(priceData.original_price, priceData.user_price)} | |||
| </div> | |||
| </div> | |||
| </> | |||
| )} | |||
| </Card> | |||
| ); | |||
| }; | |||
| export default UserPriceComparison; | |||
| @@ -483,6 +483,7 @@ export const getLogsColumns = ({ | |||
| ); | |||
| }, | |||
| }, | |||
| /* 暂时隐藏分组列 - 如需恢复请删除此注释包裹 | |||
| { | |||
| key: COLUMN_KEYS.GROUP, | |||
| title: t('分组'), | |||
| @@ -515,6 +516,7 @@ export const getLogsColumns = ({ | |||
| } | |||
| }, | |||
| }, | |||
| */ | |||
| { | |||
| key: COLUMN_KEYS.TYPE, | |||
| title: t('类型'), | |||
| @@ -1181,7 +1181,8 @@ function getEffectiveRatio(groupRatio, user_group_ratio) { | |||
| const useUserGroupRatio = isValidGroupRatio(user_group_ratio); | |||
| const ratioLabel = useUserGroupRatio | |||
| ? i18next.t('专属倍率') | |||
| : i18next.t('分组倍率'); | |||
| // : i18next.t('分组倍率'); // 原文字,已改为"倍率" | |||
| : i18next.t('倍率'); | |||
| const effectiveRatio = useUserGroupRatio ? user_group_ratio : groupRatio; | |||
| return { | |||
| @@ -48,7 +48,7 @@ export const useLogsData = () => { | |||
| CHANNEL: 'channel', | |||
| USERNAME: 'username', | |||
| TOKEN: 'token', | |||
| GROUP: 'group', | |||
| // GROUP: 'group', // 暂时隐藏分组列 | |||
| TYPE: 'type', | |||
| MODEL: 'model', | |||
| USE_TIME: 'use_time', | |||
| @@ -150,7 +150,7 @@ export const useLogsData = () => { | |||
| [COLUMN_KEYS.CHANNEL]: isAdminUser, | |||
| [COLUMN_KEYS.USERNAME]: isAdminUser, | |||
| [COLUMN_KEYS.TOKEN]: true, | |||
| [COLUMN_KEYS.GROUP]: true, | |||
| // [COLUMN_KEYS.GROUP]: true, // 暂时隐藏分组列 | |||
| [COLUMN_KEYS.TYPE]: true, | |||
| [COLUMN_KEYS.MODEL]: true, | |||
| [COLUMN_KEYS.USE_TIME]: true, | |||
| @@ -522,6 +522,7 @@ export const useLogsData = () => { | |||
| other.cache_creation_ratio_1h || | |||
| other.cache_creation_ratio || | |||
| 1.0, | |||
| other.user_channel_ratio, | |||
| ); | |||
| } else { | |||
| content = renderModelPrice( | |||