使用注释方式隐藏分组列和分组倍率标签,便于后续恢复: 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, | key: COLUMN_KEYS.GROUP, | ||||
| title: t('分组'), | title: t('分组'), | ||||
| @@ -515,6 +516,7 @@ export const getLogsColumns = ({ | |||||
| } | } | ||||
| }, | }, | ||||
| }, | }, | ||||
| */ | |||||
| { | { | ||||
| key: COLUMN_KEYS.TYPE, | key: COLUMN_KEYS.TYPE, | ||||
| title: t('类型'), | title: t('类型'), | ||||
| @@ -1181,7 +1181,8 @@ function getEffectiveRatio(groupRatio, user_group_ratio) { | |||||
| const useUserGroupRatio = isValidGroupRatio(user_group_ratio); | const useUserGroupRatio = isValidGroupRatio(user_group_ratio); | ||||
| const ratioLabel = useUserGroupRatio | const ratioLabel = useUserGroupRatio | ||||
| ? i18next.t('专属倍率') | ? i18next.t('专属倍率') | ||||
| : i18next.t('分组倍率'); | |||||
| // : i18next.t('分组倍率'); // 原文字,已改为"倍率" | |||||
| : i18next.t('倍率'); | |||||
| const effectiveRatio = useUserGroupRatio ? user_group_ratio : groupRatio; | const effectiveRatio = useUserGroupRatio ? user_group_ratio : groupRatio; | ||||
| return { | return { | ||||
| @@ -48,7 +48,7 @@ export const useLogsData = () => { | |||||
| CHANNEL: 'channel', | CHANNEL: 'channel', | ||||
| USERNAME: 'username', | USERNAME: 'username', | ||||
| TOKEN: 'token', | TOKEN: 'token', | ||||
| GROUP: 'group', | |||||
| // GROUP: 'group', // 暂时隐藏分组列 | |||||
| TYPE: 'type', | TYPE: 'type', | ||||
| MODEL: 'model', | MODEL: 'model', | ||||
| USE_TIME: 'use_time', | USE_TIME: 'use_time', | ||||
| @@ -150,7 +150,7 @@ export const useLogsData = () => { | |||||
| [COLUMN_KEYS.CHANNEL]: isAdminUser, | [COLUMN_KEYS.CHANNEL]: isAdminUser, | ||||
| [COLUMN_KEYS.USERNAME]: isAdminUser, | [COLUMN_KEYS.USERNAME]: isAdminUser, | ||||
| [COLUMN_KEYS.TOKEN]: true, | [COLUMN_KEYS.TOKEN]: true, | ||||
| [COLUMN_KEYS.GROUP]: true, | |||||
| // [COLUMN_KEYS.GROUP]: true, // 暂时隐藏分组列 | |||||
| [COLUMN_KEYS.TYPE]: true, | [COLUMN_KEYS.TYPE]: true, | ||||
| [COLUMN_KEYS.MODEL]: true, | [COLUMN_KEYS.MODEL]: true, | ||||
| [COLUMN_KEYS.USE_TIME]: true, | [COLUMN_KEYS.USE_TIME]: true, | ||||
| @@ -522,6 +522,7 @@ export const useLogsData = () => { | |||||
| other.cache_creation_ratio_1h || | other.cache_creation_ratio_1h || | ||||
| other.cache_creation_ratio || | other.cache_creation_ratio || | ||||
| 1.0, | 1.0, | ||||
| other.user_channel_ratio, | |||||
| ); | ); | ||||
| } else { | } else { | ||||
| content = renderModelPrice( | content = renderModelPrice( | ||||