diff --git a/.plans/alipay-payment/backend-dev/findings.md b/.plans/alipay-payment/backend-dev/findings.md new file mode 100644 index 0000000..31616dc --- /dev/null +++ b/.plans/alipay-payment/backend-dev/findings.md @@ -0,0 +1,7 @@ +# backend-dev - 发现索引 + +> 纯索引——每个条目应简短(Status + Report 链接 + Summary)。 + +--- + +<初始为空,工作中填写> diff --git a/.plans/alipay-payment/backend-dev/progress.md b/.plans/alipay-payment/backend-dev/progress.md new file mode 100644 index 0000000..c1b6f12 --- /dev/null +++ b/.plans/alipay-payment/backend-dev/progress.md @@ -0,0 +1,7 @@ +# backend-dev - 工作日志 + +> 用于上下文恢复。压缩/重启后先读此文件。 + +--- + +<初始为空,工作中填写> diff --git a/.plans/alipay-payment/backend-dev/task-alipay-backend/findings.md b/.plans/alipay-payment/backend-dev/task-alipay-backend/findings.md new file mode 100644 index 0000000..7158380 --- /dev/null +++ b/.plans/alipay-payment/backend-dev/task-alipay-backend/findings.md @@ -0,0 +1,7 @@ +# 支付宝后端 - 发现记录 + +> 此任务开发中的技术发现。 + +--- + +<初始为空> diff --git a/.plans/alipay-payment/backend-dev/task-alipay-backend/progress.md b/.plans/alipay-payment/backend-dev/task-alipay-backend/progress.md new file mode 100644 index 0000000..2b3e8d5 --- /dev/null +++ b/.plans/alipay-payment/backend-dev/task-alipay-backend/progress.md @@ -0,0 +1,7 @@ +# 支付宝后端 - 工作日志 + +> 上下文恢复时只需读此文件。 + +--- + +<初始为空> diff --git a/.plans/alipay-payment/backend-dev/task-alipay-backend/task_plan.md b/.plans/alipay-payment/backend-dev/task-alipay-backend/task_plan.md new file mode 100644 index 0000000..3af0d48 --- /dev/null +++ b/.plans/alipay-payment/backend-dev/task-alipay-backend/task_plan.md @@ -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 已在项目中 diff --git a/.plans/alipay-payment/backend-dev/task_plan.md b/.plans/alipay-payment/backend-dev/task_plan.md new file mode 100644 index 0000000..a9cfdeb --- /dev/null +++ b/.plans/alipay-payment/backend-dev/task_plan.md @@ -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 审查 diff --git a/.plans/alipay-payment/decisions.md b/.plans/alipay-payment/decisions.md new file mode 100644 index 0000000..b8a5251 --- /dev/null +++ b/.plans/alipay-payment/decisions.md @@ -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 角色(全栈) diff --git a/.plans/alipay-payment/findings.md b/.plans/alipay-payment/findings.md new file mode 100644 index 0000000..39aacbd --- /dev/null +++ b/.plans/alipay-payment/findings.md @@ -0,0 +1,7 @@ +# alipay-payment - 发现与技术记录 + +> 由团队智能体自动更新。每条标注来源。 + +--- + +<工作中添加条目> diff --git a/.plans/alipay-payment/frontend-dev/findings.md b/.plans/alipay-payment/frontend-dev/findings.md new file mode 100644 index 0000000..cd7e537 --- /dev/null +++ b/.plans/alipay-payment/frontend-dev/findings.md @@ -0,0 +1,7 @@ +# frontend-dev - 发现索引 + +> 纯索引——每个条目应简短。 + +--- + +<初始为空,工作中填写> diff --git a/.plans/alipay-payment/frontend-dev/progress.md b/.plans/alipay-payment/frontend-dev/progress.md new file mode 100644 index 0000000..49d1c81 --- /dev/null +++ b/.plans/alipay-payment/frontend-dev/progress.md @@ -0,0 +1,7 @@ +# frontend-dev - 工作日志 + +> 用于上下文恢复。 + +--- + +<初始为空,工作中填写> diff --git a/.plans/alipay-payment/frontend-dev/task-alipay-frontend/findings.md b/.plans/alipay-payment/frontend-dev/task-alipay-frontend/findings.md new file mode 100644 index 0000000..a8da22a --- /dev/null +++ b/.plans/alipay-payment/frontend-dev/task-alipay-frontend/findings.md @@ -0,0 +1,7 @@ +# 支付宝前端 - 发现记录 + +> 此任务开发中的技术发现。 + +--- + +<初始为空> diff --git a/.plans/alipay-payment/frontend-dev/task-alipay-frontend/progress.md b/.plans/alipay-payment/frontend-dev/task-alipay-frontend/progress.md new file mode 100644 index 0000000..da2435e --- /dev/null +++ b/.plans/alipay-payment/frontend-dev/task-alipay-frontend/progress.md @@ -0,0 +1,7 @@ +# 支付宝前端 - 工作日志 + +> 上下文恢复时只需读此文件。 + +--- + +<初始为空> diff --git a/.plans/alipay-payment/frontend-dev/task-alipay-frontend/task_plan.md b/.plans/alipay-payment/frontend-dev/task-alipay-frontend/task_plan.md new file mode 100644 index 0000000..f853be3 --- /dev/null +++ b/.plans/alipay-payment/frontend-dev/task-alipay-frontend/task_plan.md @@ -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 遵循现有命名规范 diff --git a/.plans/alipay-payment/frontend-dev/task_plan.md b/.plans/alipay-payment/frontend-dev/task_plan.md new file mode 100644 index 0000000..ec50997 --- /dev/null +++ b/.plans/alipay-payment/frontend-dev/task_plan.md @@ -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 审查 diff --git a/.plans/alipay-payment/progress.md b/.plans/alipay-payment/progress.md new file mode 100644 index 0000000..21e63b8 --- /dev/null +++ b/.plans/alipay-payment/progress.md @@ -0,0 +1,16 @@ +# alipay-payment - 进度日志 + +> 按时间线记录。每条记录谁做了什么。 + +--- + +## 2026-04-14 Session 1 — 团队搭建 + +### 已完成 +- [x] 读取设计文档 +- [x] 确认团队配置:3 角色(backend-dev, frontend-dev, reviewer) +- [x] 创建规划文件和目录结构 + +### 待办 +- [ ] 启动团队成员 +- [ ] 开始并行开发 diff --git a/.plans/alipay-payment/reviewer/findings.md b/.plans/alipay-payment/reviewer/findings.md new file mode 100644 index 0000000..7baee81 --- /dev/null +++ b/.plans/alipay-payment/reviewer/findings.md @@ -0,0 +1,7 @@ +# reviewer - 发现索引 + +> 纯索引。 + +--- + +<初始为空,工作中填写> diff --git a/.plans/alipay-payment/reviewer/progress.md b/.plans/alipay-payment/reviewer/progress.md new file mode 100644 index 0000000..f58abbf --- /dev/null +++ b/.plans/alipay-payment/reviewer/progress.md @@ -0,0 +1,5 @@ +# reviewer - 工作日志 + +--- + +<初始为空> diff --git a/.plans/alipay-payment/reviewer/task_plan.md b/.plans/alipay-payment/reviewer/task_plan.md new file mode 100644 index 0000000..c9829b3 --- /dev/null +++ b/.plans/alipay-payment/reviewer/task_plan.md @@ -0,0 +1,16 @@ +# reviewer - 任务计划 + +> 角色: 代码审查 +> 状态: pending +> 分配的任务: 等待 backend-dev 和 frontend-dev 完成后进行代码审查 + +## 任务 + +- [ ] 审查 backend-dev 支付宝后端代码(安全 + 质量) +- [ ] 审查 frontend-dev 支付宝前端代码(质量 + 体验) + +## 备注 + +- 支付涉及资金安全,重点关注:签名验签、金额处理、幂等性、并发安全 +- 后端审查重点:回调验签、金额单位(元 vs 分)、订单幂等 +- 前端审查重点:二维码模态框重构兼容性、支付状态轮询 diff --git a/.plans/alipay-payment/task_plan.md b/.plans/alipay-payment/task_plan.md new file mode 100644 index 0000000..0b93c68 --- /dev/null +++ b/.plans/alipay-payment/task_plan.md @@ -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(并行开发)。 diff --git a/.plans/channel-public-name.md b/.plans/channel-public-name.md new file mode 100644 index 0000000..e9dda4d --- /dev/null +++ b/.plans/channel-public-name.md @@ -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 + handleInputChange('name', value)} + autoComplete='new-password' +/> + +{inputs.type === 33 && ( +``` + +在第 1980 行 `/>` 和第 1982 行 `{inputs.type === 33` 之间插入: + +```jsx + handleInputChange('name', value)} + autoComplete='new-password' +/> + 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 的渠道 → 显示"通道一/二/三..." diff --git a/.superpowers/brainstorm/6600-1777284109/content/button-position.html b/.superpowers/brainstorm/6600-1777284109/content/button-position.html new file mode 100644 index 0000000..5e55529 --- /dev/null +++ b/.superpowers/brainstorm/6600-1777284109/content/button-position.html @@ -0,0 +1,72 @@ +

"去体验"按钮位置选择

+

当前模型卡片结构示意,请选择按钮的最佳放置位置

+ +
+
+
+
+
+
GPT
+ gpt-4o + 📋 +
+

OpenAI 旗舰多模态模型,支持文本和图像输入输出

+
+ 按量计费 + 多模态 +
+ +
+
+
+

方案 A:卡片底部居中

+

按钮占据卡片底部整行,醒目且易于点击。适合卡片高度固定的场景。

+
+
+ +
+
+
+
+
GPT
+ gpt-4o + 📋 +
+

OpenAI 旗舰多模态模型,支持文本和图像输入输出

+
+ 按量计费 + 多模态 + + +
+
+
+
+

方案 B:标签行右侧

+

按钮与标签同行,紧凑不占额外空间。但可能在小屏幕上显得拥挤。

+
+
+ +
+
+
+
+
GPT
+ gpt-4o + + + 📋 +
+

OpenAI 旗舰多模态模型,支持文本和图像输入输出

+
+ 按量计费 + 多模态 +
+
+
+
+

方案 C:标题行右侧

+

按钮在模型名称旁边,最显眼的位置。但与复制按钮竞争空间。

+
+
+
\ No newline at end of file diff --git a/.superpowers/brainstorm/6600-1777284109/content/waiting.html b/.superpowers/brainstorm/6600-1777284109/content/waiting.html new file mode 100644 index 0000000..f92c257 --- /dev/null +++ b/.superpowers/brainstorm/6600-1777284109/content/waiting.html @@ -0,0 +1,3 @@ +
+

Continuing in terminal...

+
\ No newline at end of file diff --git a/.superpowers/brainstorm/6600-1777284109/state/server-stopped b/.superpowers/brainstorm/6600-1777284109/state/server-stopped new file mode 100644 index 0000000..1a7ca41 --- /dev/null +++ b/.superpowers/brainstorm/6600-1777284109/state/server-stopped @@ -0,0 +1 @@ +{"reason":"idle timeout","timestamp":1777286270028} diff --git a/.superpowers/brainstorm/6600-1777284109/state/server.pid b/.superpowers/brainstorm/6600-1777284109/state/server.pid new file mode 100644 index 0000000..0454041 --- /dev/null +++ b/.superpowers/brainstorm/6600-1777284109/state/server.pid @@ -0,0 +1 @@ +6600 diff --git a/controller/pricing_user_test.go b/controller/pricing_user_test.go new file mode 100644 index 0000000..8f86a8f --- /dev/null +++ b/controller/pricing_user_test.go @@ -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"]) +} diff --git a/current-page b/current-page new file mode 100644 index 0000000..d9800af Binary files /dev/null and b/current-page differ diff --git a/login-page b/login-page new file mode 100644 index 0000000..e639a63 Binary files /dev/null and b/login-page differ diff --git a/mockup-pricing-A.html b/mockup-pricing-A.html new file mode 100644 index 0000000..ff5f105 --- /dev/null +++ b/mockup-pricing-A.html @@ -0,0 +1,306 @@ + + + + + +方案A:简洁划线对比 + + + + +
+ 方案A:简洁划线对比 +
原价划掉 + 折后价突出 + 折扣标签
+
+ +
+ +
+
G4
+
+
gpt-4o
+
OpenAI 最新多模态模型,支持文本和视觉
+
+
+ + +
+
+
$
+
+

您的分组价格

+ 基于您所在分组的专属价格 +
+
+ +
+
+ + VIP 分组 +
+ + +
+ 输入价格 + ¥0.0300 + ¥0.0240 + 8折 + / 1K tokens +
+
+ + 每千 tokens 节省 ¥0.006 +
+ + +
+ 输出价格 + ¥0.0600 + ¥0.0480 + 8折 + / 1K tokens +
+
+ + 每千 tokens 节省 ¥0.012 +
+
+ + +
+
按次计费模型示例
+
+ + SVIP 分组 +
+
+ 每次调用 + ¥0.0500 + ¥0.0350 + 7折 + / 次 +
+
+ + 每次调用节省 ¥0.015 +
+
+
+ +
+ + +
+
+
+ +
+
+

通道价格

+ 所有支持该模型的通道价格 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
通道计费类型输入价格输出价格
+ OpenAI 官方 + 默认 + 按量计费¥0.0240
/ 1K tokens
¥0.0480
/ 1K tokens
+ Azure 中转 + 高速 + 按量计费¥0.0280
/ 1K tokens
¥0.0560
/ 1K tokens
+
+
+ + + diff --git a/mockup-pricing-B.html b/mockup-pricing-B.html new file mode 100644 index 0000000..fbaa2e4 --- /dev/null +++ b/mockup-pricing-B.html @@ -0,0 +1,289 @@ + + + + + +方案B:双卡片对比 + + + + +
+ 方案B:双卡片对比 +
标准价格 vs 用户价格 左右对比,突出节省金额
+
+ +
+
+
G4
+
+
gpt-4o
+
OpenAI 最新多模态模型
+
+
+ +
+
+
$
+
+

分组价格对比

+ VIP 分组专属折扣 +
+
+ + +
输入价格
+
+
+
标准价格
+
default 分组
+
+
每千 tokens
+
¥0.0300 / 1K
+
+
+
+
您的价格
+
VIP 分组 · 8折
+
+
每千 tokens
+
¥0.0240 / 1K
+
+
+
+ +
+ + 输入节省 20% · 每千 tokens 省 ¥0.006 +
+ + +
输出价格
+
+
+
标准价格
+
default 分组
+
+
每千 tokens
+
¥0.0600 / 1K
+
+
+
+
您的价格
+
VIP 分组 · 8折
+
+
每千 tokens
+
¥0.0480 / 1K
+
+
+
+ +
+ + 输出节省 20% · 每千 tokens 省 ¥0.012 +
+
+ +
+ +
+
+
+ +
+
+

通道价格

+ 所有支持该模型的通道价格 +
+
+ + + + + + + + + + + + + + + + + + +
通道计费类型输入价格输出价格
OpenAI 官方默认按量计费¥0.0240
/ 1K tokens
¥0.0480
/ 1K tokens
Azure 中转高速按量计费¥0.0280
/ 1K tokens
¥0.0560
/ 1K tokens
+
+
+ + + diff --git a/mockup-pricing-C.html b/mockup-pricing-C.html new file mode 100644 index 0000000..083a9e9 --- /dev/null +++ b/mockup-pricing-C.html @@ -0,0 +1,362 @@ + + + + + +方案C:进度条式折扣可视化 + + + + +
+ 方案C:进度条式折扣可视化 +
用进度条直观展示折扣力度 + 价格明细
+
+ +
+
+
G4
+
+
gpt-4o
+
OpenAI 最新多模态模型
+
+
+ +
+
+
$
+
+

您的分组价格

+ VIP 分组专属折扣 +
+
+ + +
+
+
+ + + VIP 分组 + +
+
+
8折
+
节省 20%
+
+
+ + +
输入价格
+
+
+ ¥0.0300 +
+
+ ¥0.0240 +
+
+
+ default 分组基准价 + VIP 折扣价 +
+ + +
输出价格
+
+
+ ¥0.0600 +
+
+ ¥0.0480 +
+
+
+ default 分组基准价 + VIP 折扣价 +
+ + +
+
+
输入价格
+
+ ¥0.0300 + ¥0.0240 +
+
/ 1K tokens
+
+
+
输出价格
+
+ ¥0.0600 + ¥0.0480 +
+
/ 1K tokens
+
+
+
+ + +
+
+
+ + + SVIP 分组 + + 按次计费示例 +
+
+
7折
+
节省 30%
+
+
+
每次调用价格
+
+
+ ¥0.0500 +
+
+ ¥0.0350 +
+
+
+ default 分组基准价 + SVIP 折扣价 +
+
+
+ +
+ +
+
+
+ +
+
+

通道价格

+ 所有支持该模型的通道价格 +
+
+ + + + + + + + + + + + + + + + + + +
通道计费类型输入价格输出价格
OpenAI 官方默认按量计费¥0.0240
/ 1K tokens
¥0.0480
/ 1K tokens
Azure 中转高速按量计费¥0.0280
/ 1K tokens
¥0.0560
/ 1K tokens
+
+
+ + + diff --git a/model/channel.go.bak b/model/channel.go.bak new file mode 100644 index 0000000..bf5d788 --- /dev/null +++ b/model/channel.go.bak @@ -0,0 +1,1020 @@ +package model + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "math/rand" + "strings" + "sync" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/types" + + "github.com/samber/lo" + "gorm.io/gorm" +) + +type Channel struct { + Id int `json:"id"` + Type int `json:"type" gorm:"default:0"` + Key string `json:"key" gorm:"not null"` + OpenAIOrganization *string `json:"openai_organization"` + TestModel *string `json:"test_model"` + Status int `json:"status" gorm:"default:1"` + Name string `json:"name" gorm:"index"` + PublicName string `json:"public_name" gorm:"size:255;default:''"` + Weight *uint `json:"weight" gorm:"default:0"` + CreatedTime int64 `json:"created_time" gorm:"bigint"` + TestTime int64 `json:"test_time" gorm:"bigint"` + ResponseTime int `json:"response_time"` // in milliseconds + BaseURL *string `json:"base_url" gorm:"column:base_url;default:''"` + Other string `json:"other"` + Balance float64 `json:"balance"` // in USD + BalanceUpdatedTime int64 `json:"balance_updated_time" gorm:"bigint"` + Models string `json:"models"` + Group string `json:"group" gorm:"type:varchar(64);default:'default'"` + UsedQuota int64 `json:"used_quota" gorm:"bigint;default:0"` + ModelMapping *string `json:"model_mapping" gorm:"type:text"` + //MaxInputTokens *int `json:"max_input_tokens" gorm:"default:0"` + StatusCodeMapping *string `json:"status_code_mapping" gorm:"type:varchar(1024);default:''"` + Priority *int64 `json:"priority" gorm:"bigint;default:0"` + AutoBan *int `json:"auto_ban" gorm:"default:1"` + OtherInfo string `json:"other_info"` + Tag *string `json:"tag" gorm:"index"` + Setting *string `json:"setting" gorm:"type:text"` // 渠道额外设置 + ParamOverride *string `json:"param_override" gorm:"type:text"` + HeaderOverride *string `json:"header_override" gorm:"type:text"` + Remark *string `json:"remark" gorm:"type:varchar(255)" validate:"max=255"` + // add after v0.8.5 + ChannelInfo ChannelInfo `json:"channel_info" gorm:"type:json"` + + OtherSettings string `json:"settings" gorm:"column:settings"` // 其他设置,存储azure版本等不需要检索的信息,详见dto.ChannelOtherSettings + + // cache info + Keys []string `json:"-" gorm:"-"` +} + +type ChannelInfo struct { + IsMultiKey bool `json:"is_multi_key"` // 是否多Key模式 + MultiKeySize int `json:"multi_key_size"` // 多Key模式下的Key数量 + MultiKeyStatusList map[int]int `json:"multi_key_status_list"` // key状态列表,key index -> status + MultiKeyDisabledReason map[int]string `json:"multi_key_disabled_reason,omitempty"` // key禁用原因列表,key index -> reason + MultiKeyDisabledTime map[int]int64 `json:"multi_key_disabled_time,omitempty"` // key禁用时间列表,key index -> time + MultiKeyPollingIndex int `json:"multi_key_polling_index"` // 多Key模式下轮询的key索引 + MultiKeyMode constant.MultiKeyMode `json:"multi_key_mode"` +} + +// Value implements driver.Valuer interface +func (c ChannelInfo) Value() (driver.Value, error) { + return common.Marshal(&c) +} + +// Scan implements sql.Scanner interface +func (c *ChannelInfo) Scan(value interface{}) error { + bytesValue, _ := value.([]byte) + return common.Unmarshal(bytesValue, c) +} + +func (channel *Channel) GetKeys() []string { + if channel.Key == "" { + return []string{} + } + if len(channel.Keys) > 0 { + return channel.Keys + } + trimmed := strings.TrimSpace(channel.Key) + // If the key starts with '[', try to parse it as a JSON array (e.g., for Vertex AI scenarios) + if strings.HasPrefix(trimmed, "[") { + var arr []json.RawMessage + if err := common.Unmarshal([]byte(trimmed), &arr); err == nil { + res := make([]string, len(arr)) + for i, v := range arr { + res[i] = string(v) + } + return res + } + } + // Otherwise, fall back to splitting by newline + keys := strings.Split(strings.Trim(channel.Key, "\n"), "\n") + return keys +} + +func (channel *Channel) GetNextEnabledKey() (string, int, *types.NewAPIError) { + // If not in multi-key mode, return the original key string directly. + if !channel.ChannelInfo.IsMultiKey { + return channel.Key, 0, nil + } + + // Obtain all keys (split by \n) + keys := channel.GetKeys() + if len(keys) == 0 { + // No keys available, return error, should disable the channel + return "", 0, types.NewError(errors.New("no keys available"), types.ErrorCodeChannelNoAvailableKey) + } + + lock := GetChannelPollingLock(channel.Id) + lock.Lock() + defer lock.Unlock() + + statusList := channel.ChannelInfo.MultiKeyStatusList + // helper to get key status, default to enabled when missing + getStatus := func(idx int) int { + if statusList == nil { + return common.ChannelStatusEnabled + } + if status, ok := statusList[idx]; ok { + return status + } + return common.ChannelStatusEnabled + } + + // Collect indexes of enabled keys + enabledIdx := make([]int, 0, len(keys)) + for i := range keys { + if getStatus(i) == common.ChannelStatusEnabled { + enabledIdx = append(enabledIdx, i) + } + } + // If no specific status list or none enabled, return an explicit error so caller can + // properly handle a channel with no available keys (e.g. mark channel disabled). + // Returning the first key here caused requests to keep using an already-disabled key. + if len(enabledIdx) == 0 { + return "", 0, types.NewError(errors.New("no enabled keys"), types.ErrorCodeChannelNoAvailableKey) + } + + switch channel.ChannelInfo.MultiKeyMode { + case constant.MultiKeyModeRandom: + // Randomly pick one enabled key + selectedIdx := enabledIdx[rand.Intn(len(enabledIdx))] + return keys[selectedIdx], selectedIdx, nil + case constant.MultiKeyModePolling: + // Use channel-specific lock to ensure thread-safe polling + + channelInfo, err := CacheGetChannelInfo(channel.Id) + if err != nil { + return "", 0, types.NewError(err, types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) + } + //println("before polling index:", channel.ChannelInfo.MultiKeyPollingIndex) + defer func() { + if common.DebugEnabled { + println(fmt.Sprintf("channel %d polling index: %d", channel.Id, channel.ChannelInfo.MultiKeyPollingIndex)) + } + if !common.MemoryCacheEnabled { + _ = channel.SaveChannelInfo() + } else { + // CacheUpdateChannel(channel) + } + }() + // Start from the saved polling index and look for the next enabled key + start := channelInfo.MultiKeyPollingIndex + if start < 0 || start >= len(keys) { + start = 0 + } + for i := 0; i < len(keys); i++ { + idx := (start + i) % len(keys) + if getStatus(idx) == common.ChannelStatusEnabled { + // update polling index for next call (point to the next position) + channel.ChannelInfo.MultiKeyPollingIndex = (idx + 1) % len(keys) + return keys[idx], idx, nil + } + } + // Fallback – should not happen, but return first enabled key + return keys[enabledIdx[0]], enabledIdx[0], nil + default: + // Unknown mode, default to first enabled key (or original key string) + return keys[enabledIdx[0]], enabledIdx[0], nil + } +} + +func (channel *Channel) SaveChannelInfo() error { + return DB.Model(channel).Update("channel_info", channel.ChannelInfo).Error +} + +func (channel *Channel) GetModels() []string { + if channel.Models == "" { + return []string{} + } + return strings.Split(strings.Trim(channel.Models, ","), ",") +} + +func (channel *Channel) GetGroups() []string { + if channel.Group == "" { + return []string{} + } + groups := strings.Split(strings.Trim(channel.Group, ","), ",") + for i, group := range groups { + groups[i] = strings.TrimSpace(group) + } + return groups +} + +func (channel *Channel) GetOtherInfo() map[string]interface{} { + otherInfo := make(map[string]interface{}) + if channel.OtherInfo != "" { + err := common.Unmarshal([]byte(channel.OtherInfo), &otherInfo) + if err != nil { + common.SysLog(fmt.Sprintf("failed to unmarshal other info: channel_id=%d, tag=%s, name=%s, error=%v", channel.Id, channel.GetTag(), channel.Name, err)) + } + } + return otherInfo +} + +func (channel *Channel) SetOtherInfo(otherInfo map[string]interface{}) { + otherInfoBytes, err := json.Marshal(otherInfo) + if err != nil { + common.SysLog(fmt.Sprintf("failed to marshal other info: channel_id=%d, tag=%s, name=%s, error=%v", channel.Id, channel.GetTag(), channel.Name, err)) + return + } + channel.OtherInfo = string(otherInfoBytes) +} + +func (channel *Channel) GetTag() string { + if channel.Tag == nil { + return "" + } + return *channel.Tag +} + +func (channel *Channel) SetTag(tag string) { + channel.Tag = &tag +} + +func (channel *Channel) GetAutoBan() bool { + if channel.AutoBan == nil { + return false + } + return *channel.AutoBan == 1 +} + +func (channel *Channel) Save() error { + return DB.Save(channel).Error +} + +func (channel *Channel) SaveWithoutKey() error { + if channel.Id == 0 { + return errors.New("channel ID is 0") + } + return DB.Omit("key").Save(channel).Error +} + +func GetAllChannels(startIdx int, num int, selectAll bool, idSort bool) ([]*Channel, error) { + var channels []*Channel + var err error + order := "priority desc" + if idSort { + order = "id desc" + } + if selectAll { + err = DB.Order(order).Find(&channels).Error + } else { + err = DB.Order(order).Limit(num).Offset(startIdx).Omit("key").Find(&channels).Error + } + return channels, err +} + +// GetAllChannelsForBinding 获取所有启用的渠道(用于用户绑定渠道) +// 只返回 id, name, type, remark,不包含敏感信息 +func GetAllChannelsForBinding() ([]*Channel, error) { + var channels []*Channel + err := DB.Select("id, name, type, remark"). + Where("status = ?", common.ChannelStatusEnabled). + Order("priority desc"). + Find(&channels).Error + return channels, err +} + +func GetChannelsByTag(tag string, idSort bool, selectAll bool) ([]*Channel, error) { + var channels []*Channel + order := "priority desc" + if idSort { + order = "id desc" + } + query := DB.Where("tag = ?", tag).Order(order) + if !selectAll { + query = query.Omit("key") + } + err := query.Find(&channels).Error + return channels, err +} + +func SearchChannels(keyword string, group string, model string, idSort bool) ([]*Channel, error) { + var channels []*Channel + modelsCol := "`models`" + + // 如果是 PostgreSQL,使用双引号 + if common.UsingPostgreSQL { + modelsCol = `"models"` + } + + baseURLCol := "`base_url`" + // 如果是 PostgreSQL,使用双引号 + if common.UsingPostgreSQL { + baseURLCol = `"base_url"` + } + + order := "priority desc" + if idSort { + order = "id desc" + } + + // 构造基础查询 + baseQuery := DB.Model(&Channel{}).Omit("key") + + // 构造WHERE子句 + var whereClause string + var args []interface{} + if group != "" && group != "null" { + var groupCondition string + if common.UsingMySQL { + groupCondition = `CONCAT(',', ` + commonGroupCol + `, ',') LIKE ?` + } else { + // sqlite, PostgreSQL + groupCondition = `(',' || ` + commonGroupCol + ` || ',') LIKE ?` + } + whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition + args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%", "%,"+group+",%") + } else { + whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?" + args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%") + } + + // 执行查询 + err := baseQuery.Where(whereClause, args...).Order(order).Find(&channels).Error + if err != nil { + return nil, err + } + return channels, nil +} + +func GetChannelById(id int, selectAll bool) (*Channel, error) { + channel := &Channel{Id: id} + var err error = nil + if selectAll { + err = DB.First(channel, "id = ?", id).Error + } else { + err = DB.Omit("key").First(channel, "id = ?", id).Error + } + if err != nil { + return nil, err + } + if channel == nil { + return nil, errors.New("channel not found") + } + return channel, nil +} + +func BatchInsertChannels(channels []Channel) error { + if len(channels) == 0 { + return nil + } + tx := DB.Begin() + if tx.Error != nil { + return tx.Error + } + defer func() { + if r := recover(); r != nil { + tx.Rollback() + } + }() + + for _, chunk := range lo.Chunk(channels, 50) { + if err := tx.Create(&chunk).Error; err != nil { + tx.Rollback() + return err + } + for _, channel_ := range chunk { + if err := channel_.AddAbilities(tx); err != nil { + tx.Rollback() + return err + } + } + } + return tx.Commit().Error +} + +func BatchDeleteChannels(ids []int) error { + if len(ids) == 0 { + return nil + } + // 使用事务 分批删除channel表和abilities表 + tx := DB.Begin() + if tx.Error != nil { + return tx.Error + } + for _, chunk := range lo.Chunk(ids, 200) { + if err := tx.Where("id in (?)", chunk).Delete(&Channel{}).Error; err != nil { + tx.Rollback() + return err + } + if err := tx.Where("channel_id in (?)", chunk).Delete(&Ability{}).Error; err != nil { + tx.Rollback() + return err + } + } + return tx.Commit().Error +} + +func (channel *Channel) GetPriority() int64 { + if channel.Priority == nil { + return 0 + } + return *channel.Priority +} + +func (channel *Channel) GetWeight() int { + if channel.Weight == nil { + return 0 + } + return int(*channel.Weight) +} + +func (channel *Channel) GetBaseURL() string { + if channel.BaseURL == nil { + return "" + } + url := *channel.BaseURL + if url == "" { + url = constant.ChannelBaseURLs[channel.Type] + } + return url +} + +func (channel *Channel) GetModelMapping() string { + if channel.ModelMapping == nil { + return "" + } + return *channel.ModelMapping +} + +func (channel *Channel) GetStatusCodeMapping() string { + if channel.StatusCodeMapping == nil { + return "" + } + return *channel.StatusCodeMapping +} + +func (channel *Channel) Insert() error { + var err error + err = DB.Create(channel).Error + if err != nil { + return err + } + err = channel.AddAbilities(nil) + return err +} + +func (channel *Channel) Update() error { + // If this is a multi-key channel, recalculate MultiKeySize based on the current key list to avoid inconsistency after editing keys + if channel.ChannelInfo.IsMultiKey { + var keyStr string + if channel.Key != "" { + keyStr = channel.Key + } else { + // If key is not provided, read the existing key from the database + if existing, err := GetChannelById(channel.Id, true); err == nil { + keyStr = existing.Key + } + } + // Parse the key list (supports newline separation or JSON array) + keys := []string{} + if keyStr != "" { + trimmed := strings.TrimSpace(keyStr) + if strings.HasPrefix(trimmed, "[") { + var arr []json.RawMessage + if err := common.Unmarshal([]byte(trimmed), &arr); err == nil { + keys = make([]string, len(arr)) + for i, v := range arr { + keys[i] = string(v) + } + } + } + if len(keys) == 0 { // fallback to newline split + keys = strings.Split(strings.Trim(keyStr, "\n"), "\n") + } + } + channel.ChannelInfo.MultiKeySize = len(keys) + // Clean up status data that exceeds the new key count to prevent index out of range + if channel.ChannelInfo.MultiKeyStatusList != nil { + for idx := range channel.ChannelInfo.MultiKeyStatusList { + if idx >= channel.ChannelInfo.MultiKeySize { + delete(channel.ChannelInfo.MultiKeyStatusList, idx) + } + } + } + } + var err error + err = DB.Model(channel).Updates(channel).Error + if err != nil { + return err + } + DB.Model(channel).First(channel, "id = ?", channel.Id) + err = channel.UpdateAbilities(nil) + return err +} + +func (channel *Channel) UpdateResponseTime(responseTime int64) { + err := DB.Model(channel).Select("response_time", "test_time").Updates(Channel{ + TestTime: common.GetTimestamp(), + ResponseTime: int(responseTime), + }).Error + if err != nil { + common.SysLog(fmt.Sprintf("failed to update response time: channel_id=%d, error=%v", channel.Id, err)) + } +} + +func (channel *Channel) UpdateBalance(balance float64) { + err := DB.Model(channel).Select("balance_updated_time", "balance").Updates(Channel{ + BalanceUpdatedTime: common.GetTimestamp(), + Balance: balance, + }).Error + if err != nil { + common.SysLog(fmt.Sprintf("failed to update balance: channel_id=%d, error=%v", channel.Id, err)) + } +} + +func (channel *Channel) Delete() error { + var err error + err = DB.Delete(channel).Error + if err != nil { + return err + } + err = channel.DeleteAbilities() + return err +} + +var channelStatusLock sync.Mutex + +// channelPollingLocks stores locks for each channel.id to ensure thread-safe polling +var channelPollingLocks sync.Map + +// GetChannelPollingLock returns or creates a mutex for the given channel ID +func GetChannelPollingLock(channelId int) *sync.Mutex { + if lock, exists := channelPollingLocks.Load(channelId); exists { + return lock.(*sync.Mutex) + } + // Create new lock for this channel + newLock := &sync.Mutex{} + actual, _ := channelPollingLocks.LoadOrStore(channelId, newLock) + return actual.(*sync.Mutex) +} + +// CleanupChannelPollingLocks removes locks for channels that no longer exist +// This is optional and can be called periodically to prevent memory leaks +func CleanupChannelPollingLocks() { + var activeChannelIds []int + DB.Model(&Channel{}).Pluck("id", &activeChannelIds) + + activeChannelSet := make(map[int]bool) + for _, id := range activeChannelIds { + activeChannelSet[id] = true + } + + channelPollingLocks.Range(func(key, value interface{}) bool { + channelId := key.(int) + if !activeChannelSet[channelId] { + channelPollingLocks.Delete(channelId) + } + return true + }) +} + +func handlerMultiKeyUpdate(channel *Channel, usingKey string, status int, reason string) { + keys := channel.GetKeys() + if len(keys) == 0 { + channel.Status = status + } else { + var keyIndex int + for i, key := range keys { + if key == usingKey { + keyIndex = i + break + } + } + if channel.ChannelInfo.MultiKeyStatusList == nil { + channel.ChannelInfo.MultiKeyStatusList = make(map[int]int) + } + if status == common.ChannelStatusEnabled { + delete(channel.ChannelInfo.MultiKeyStatusList, keyIndex) + } else { + channel.ChannelInfo.MultiKeyStatusList[keyIndex] = status + if channel.ChannelInfo.MultiKeyDisabledReason == nil { + channel.ChannelInfo.MultiKeyDisabledReason = make(map[int]string) + } + if channel.ChannelInfo.MultiKeyDisabledTime == nil { + channel.ChannelInfo.MultiKeyDisabledTime = make(map[int]int64) + } + channel.ChannelInfo.MultiKeyDisabledReason[keyIndex] = reason + channel.ChannelInfo.MultiKeyDisabledTime[keyIndex] = common.GetTimestamp() + } + if len(channel.ChannelInfo.MultiKeyStatusList) >= channel.ChannelInfo.MultiKeySize { + channel.Status = common.ChannelStatusAutoDisabled + info := channel.GetOtherInfo() + info["status_reason"] = "All keys are disabled" + info["status_time"] = common.GetTimestamp() + channel.SetOtherInfo(info) + } + } +} + +func UpdateChannelStatus(channelId int, usingKey string, status int, reason string) bool { + if common.MemoryCacheEnabled { + channelStatusLock.Lock() + defer channelStatusLock.Unlock() + + channelCache, _ := CacheGetChannel(channelId) + if channelCache == nil { + return false + } + if channelCache.ChannelInfo.IsMultiKey { + // Use per-channel lock to prevent concurrent map read/write with GetNextEnabledKey + pollingLock := GetChannelPollingLock(channelId) + pollingLock.Lock() + // 如果是多Key模式,更新缓存中的状态 + handlerMultiKeyUpdate(channelCache, usingKey, status, reason) + pollingLock.Unlock() + //CacheUpdateChannel(channelCache) + //return true + } else { + // 如果缓存渠道存在,且状态已是目标状态,直接返回 + if channelCache.Status == status { + return false + } + CacheUpdateChannelStatus(channelId, status) + } + } + + shouldUpdateAbilities := false + defer func() { + if shouldUpdateAbilities { + err := UpdateAbilityStatus(channelId, status == common.ChannelStatusEnabled) + if err != nil { + common.SysLog(fmt.Sprintf("failed to update ability status: channel_id=%d, error=%v", channelId, err)) + } + } + }() + channel, err := GetChannelById(channelId, true) + if err != nil { + return false + } else { + if channel.Status == status { + return false + } + + if channel.ChannelInfo.IsMultiKey { + beforeStatus := channel.Status + // Protect map writes with the same per-channel lock used by readers + pollingLock := GetChannelPollingLock(channelId) + pollingLock.Lock() + handlerMultiKeyUpdate(channel, usingKey, status, reason) + pollingLock.Unlock() + if beforeStatus != channel.Status { + shouldUpdateAbilities = true + } + } else { + info := channel.GetOtherInfo() + info["status_reason"] = reason + info["status_time"] = common.GetTimestamp() + channel.SetOtherInfo(info) + channel.Status = status + shouldUpdateAbilities = true + } + err = channel.SaveWithoutKey() + if err != nil { + common.SysLog(fmt.Sprintf("failed to update channel status: channel_id=%d, status=%d, error=%v", channel.Id, status, err)) + return false + } + } + return true +} + +func EnableChannelByTag(tag string) error { + err := DB.Model(&Channel{}).Where("tag = ?", tag).Update("status", common.ChannelStatusEnabled).Error + if err != nil { + return err + } + err = UpdateAbilityStatusByTag(tag, true) + return err +} + +func DisableChannelByTag(tag string) error { + err := DB.Model(&Channel{}).Where("tag = ?", tag).Update("status", common.ChannelStatusManuallyDisabled).Error + if err != nil { + return err + } + err = UpdateAbilityStatusByTag(tag, false) + return err +} + +func EditChannelByTag(tag string, newTag *string, modelMapping *string, models *string, group *string, priority *int64, weight *uint, paramOverride *string, headerOverride *string) error { + updateData := Channel{} + shouldReCreateAbilities := false + updatedTag := tag + // 如果 newTag 不为空且不等于 tag,则更新 tag + if newTag != nil && *newTag != tag { + updateData.Tag = newTag + updatedTag = *newTag + } + if modelMapping != nil && *modelMapping != "" { + updateData.ModelMapping = modelMapping + } + if models != nil && *models != "" { + shouldReCreateAbilities = true + updateData.Models = *models + } + if group != nil && *group != "" { + shouldReCreateAbilities = true + updateData.Group = *group + } + if priority != nil { + updateData.Priority = priority + } + if weight != nil { + updateData.Weight = weight + } + if paramOverride != nil { + updateData.ParamOverride = paramOverride + } + if headerOverride != nil { + updateData.HeaderOverride = headerOverride + } + + err := DB.Model(&Channel{}).Where("tag = ?", tag).Updates(updateData).Error + if err != nil { + return err + } + if shouldReCreateAbilities { + channels, err := GetChannelsByTag(updatedTag, false, false) + if err == nil { + for _, channel := range channels { + err = channel.UpdateAbilities(nil) + if err != nil { + common.SysLog(fmt.Sprintf("failed to update abilities: channel_id=%d, tag=%s, error=%v", channel.Id, channel.GetTag(), err)) + } + } + } + } else { + err := UpdateAbilityByTag(tag, newTag, priority, weight) + if err != nil { + return err + } + } + return nil +} + +func UpdateChannelUsedQuota(id int, quota int) { + if common.BatchUpdateEnabled { + addNewRecord(BatchUpdateTypeChannelUsedQuota, id, quota) + return + } + updateChannelUsedQuota(id, quota) +} + +func updateChannelUsedQuota(id int, quota int) { + err := DB.Model(&Channel{}).Where("id = ?", id).Update("used_quota", gorm.Expr("used_quota + ?", quota)).Error + if err != nil { + common.SysLog(fmt.Sprintf("failed to update channel used quota: channel_id=%d, delta_quota=%d, error=%v", id, quota, err)) + } +} + +func DeleteChannelByStatus(status int64) (int64, error) { + result := DB.Where("status = ?", status).Delete(&Channel{}) + return result.RowsAffected, result.Error +} + +func DeleteDisabledChannel() (int64, error) { + result := DB.Where("status = ? or status = ?", common.ChannelStatusAutoDisabled, common.ChannelStatusManuallyDisabled).Delete(&Channel{}) + return result.RowsAffected, result.Error +} + +func GetPaginatedTags(offset int, limit int) ([]*string, error) { + var tags []*string + err := DB.Model(&Channel{}).Select("DISTINCT tag").Where("tag != ''").Offset(offset).Limit(limit).Find(&tags).Error + return tags, err +} + +func SearchTags(keyword string, group string, model string, idSort bool) ([]*string, error) { + var tags []*string + modelsCol := "`models`" + + // 如果是 PostgreSQL,使用双引号 + if common.UsingPostgreSQL { + modelsCol = `"models"` + } + + baseURLCol := "`base_url`" + // 如果是 PostgreSQL,使用双引号 + if common.UsingPostgreSQL { + baseURLCol = `"base_url"` + } + + order := "priority desc" + if idSort { + order = "id desc" + } + + // 构造基础查询 + baseQuery := DB.Model(&Channel{}).Omit("key") + + // 构造WHERE子句 + var whereClause string + var args []interface{} + if group != "" && group != "null" { + var groupCondition string + if common.UsingMySQL { + groupCondition = `CONCAT(',', ` + commonGroupCol + `, ',') LIKE ?` + } else { + // sqlite, PostgreSQL + groupCondition = `(',' || ` + commonGroupCol + ` || ',') LIKE ?` + } + whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + ` LIKE ? AND ` + groupCondition + args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%", "%,"+group+",%") + } else { + whereClause = "(id = ? OR name LIKE ? OR " + commonKeyCol + " = ? OR " + baseURLCol + " LIKE ?) AND " + modelsCol + " LIKE ?" + args = append(args, common.String2Int(keyword), "%"+keyword+"%", keyword, "%"+keyword+"%", "%"+model+"%") + } + + subQuery := baseQuery.Where(whereClause, args...). + Select("tag"). + Where("tag != ''"). + Order(order) + + err := DB.Table("(?) as sub", subQuery). + Select("DISTINCT tag"). + Find(&tags).Error + + if err != nil { + return nil, err + } + + return tags, nil +} + +func (channel *Channel) ValidateSettings() error { + channelParams := &dto.ChannelSettings{} + if channel.Setting != nil && *channel.Setting != "" { + err := common.Unmarshal([]byte(*channel.Setting), channelParams) + if err != nil { + return err + } + } + return nil +} + +func (channel *Channel) GetSetting() dto.ChannelSettings { + setting := dto.ChannelSettings{} + if channel.Setting != nil && *channel.Setting != "" { + err := common.Unmarshal([]byte(*channel.Setting), &setting) + if err != nil { + common.SysLog(fmt.Sprintf("failed to unmarshal setting: channel_id=%d, error=%v", channel.Id, err)) + channel.Setting = nil // 清空设置以避免后续错误 + _ = channel.Save() // 保存修改 + } + } + return setting +} + +func (channel *Channel) SetSetting(setting dto.ChannelSettings) { + settingBytes, err := common.Marshal(setting) + if err != nil { + common.SysLog(fmt.Sprintf("failed to marshal setting: channel_id=%d, error=%v", channel.Id, err)) + return + } + channel.Setting = common.GetPointer[string](string(settingBytes)) +} + +func (channel *Channel) GetOtherSettings() dto.ChannelOtherSettings { + setting := dto.ChannelOtherSettings{} + if channel.OtherSettings != "" { + err := common.UnmarshalJsonStr(channel.OtherSettings, &setting) + if err != nil { + common.SysLog(fmt.Sprintf("failed to unmarshal setting: channel_id=%d, error=%v", channel.Id, err)) + channel.OtherSettings = "{}" // 清空设置以避免后续错误 + _ = channel.Save() // 保存修改 + } + } + return setting +} + +func (channel *Channel) SetOtherSettings(setting dto.ChannelOtherSettings) { + settingBytes, err := common.Marshal(setting) + if err != nil { + common.SysLog(fmt.Sprintf("failed to marshal setting: channel_id=%d, error=%v", channel.Id, err)) + return + } + channel.OtherSettings = string(settingBytes) +} + +func (channel *Channel) GetParamOverride() map[string]interface{} { + paramOverride := make(map[string]interface{}) + if channel.ParamOverride != nil && *channel.ParamOverride != "" { + err := common.Unmarshal([]byte(*channel.ParamOverride), ¶mOverride) + if err != nil { + common.SysLog(fmt.Sprintf("failed to unmarshal param override: channel_id=%d, error=%v", channel.Id, err)) + } + } + return paramOverride +} + +func (channel *Channel) GetHeaderOverride() map[string]interface{} { + headerOverride := make(map[string]interface{}) + if channel.HeaderOverride != nil && *channel.HeaderOverride != "" { + err := common.Unmarshal([]byte(*channel.HeaderOverride), &headerOverride) + if err != nil { + common.SysLog(fmt.Sprintf("failed to unmarshal header override: channel_id=%d, error=%v", channel.Id, err)) + } + } + return headerOverride +} + +func GetChannelsByIds(ids []int) ([]*Channel, error) { + var channels []*Channel + err := DB.Where("id in (?)", ids).Find(&channels).Error + return channels, err +} + +func BatchSetChannelTag(ids []int, tag *string) error { + // 开启事务 + tx := DB.Begin() + if tx.Error != nil { + return tx.Error + } + + // 更新标签 + err := tx.Model(&Channel{}).Where("id in (?)", ids).Update("tag", tag).Error + if err != nil { + tx.Rollback() + return err + } + + // update ability status + channels, err := GetChannelsByIds(ids) + if err != nil { + tx.Rollback() + return err + } + + for _, channel := range channels { + err = channel.UpdateAbilities(tx) + if err != nil { + tx.Rollback() + return err + } + } + + // 提交事务 + return tx.Commit().Error +} + +// CountAllChannels returns total channels in DB +func CountAllChannels() (int64, error) { + var total int64 + err := DB.Model(&Channel{}).Count(&total).Error + return total, err +} + +// CountAllTags returns number of non-empty distinct tags +func CountAllTags() (int64, error) { + var total int64 + err := DB.Model(&Channel{}).Where("tag is not null AND tag != ''").Distinct("tag").Count(&total).Error + return total, err +} + +// Get channels of specified type with pagination +func GetChannelsByType(startIdx int, num int, idSort bool, channelType int) ([]*Channel, error) { + var channels []*Channel + order := "priority desc" + if idSort { + order = "id desc" + } + err := DB.Where("type = ?", channelType).Order(order).Limit(num).Offset(startIdx).Omit("key").Find(&channels).Error + return channels, err +} + +// Count channels of specific type +func CountChannelsByType(channelType int) (int64, error) { + var count int64 + err := DB.Model(&Channel{}).Where("type = ?", channelType).Count(&count).Error + return count, err +} + +// Return map[type]count for all channels +func CountChannelsGroupByType() (map[int64]int64, error) { + type result struct { + Type int64 `gorm:"column:type"` + Count int64 `gorm:"column:count"` + } + var results []result + err := DB.Model(&Channel{}).Select("type, count(*) as count").Group("type").Find(&results).Error + if err != nil { + return nil, err + } + counts := make(map[int64]int64) + for _, r := range results { + counts[r.Type] = r.Count + } + return counts, nil +} diff --git a/model/pricing.go.bak b/model/pricing.go.bak new file mode 100644 index 0000000..3c43e1f --- /dev/null +++ b/model/pricing.go.bak @@ -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) +} diff --git a/models-page b/models-page new file mode 100644 index 0000000..e13655b Binary files /dev/null and b/models-page differ diff --git a/relay/compatible_handler_test.go b/relay/compatible_handler_test.go new file mode 100644 index 0000000..2a6cd4e --- /dev/null +++ b/relay/compatible_handler_test.go @@ -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)) +} diff --git a/relay/helper/price_test.go b/relay/helper/price_test.go new file mode 100644 index 0000000..76554ff --- /dev/null +++ b/relay/helper/price_test.go @@ -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 未设渠道值,应从全局回退") +} diff --git a/scripts/cache_test.json b/scripts/cache_test.json new file mode 100644 index 0000000..692bf49 --- /dev/null +++ b/scripts/cache_test.json @@ -0,0 +1 @@ +{"model": "anthropic/claude-opus-4.6", "max_tokens": 128, "cache_control": {"type": "ephemeral"}, "system": [{"type": "text", "text": "你是一个专业的全栈开发 AI 助手,具备深入的技术知识和丰富的实践经验。你的职责是帮助用户解决各种编程、架构设计和系统优化方面的问题。你必须严格按照以下规范提供高质量的技术服务。\n\n## 第一章:核心能力概览\n\n### 1.1 编程语言精通\n你必须熟悉以下编程语言的核心概念、最佳实践和常见陷阱:\n\n**Python (3.8+)**:精通 asyncio 异步编程、dataclass/Pydantic 数据建模、类型注解、装饰器模式、上下文管理器、生成器与协程、GIL 机制理解、Cython 性能优化、pytest 测试框架、fastapi 框架设计模式、SQLAlchemy ORM 高级用法、Celery 分布式任务队列。\n\n**JavaScript/TypeScript**:精通 ES6+ 语法特性(解构、展开运算符、可选链、空值合并)、Promise/async-await 异步模式、Event Loop 机制(宏任务与微任务)、闭包与原型链、模块系统(ESM/CJS)、TypeScript 泛型与条件类型、React Hooks 原理、Next.js SSR/SSG/ISR 渲染策略、Node.js 流处理与 Buffer 管理。\n\n**Go**:精通 goroutine 并发模型、channel 通信模式(有缓冲与无缓冲)、interface 隐式实现与鸭子类型、context 传播与取消机制、错误处理哲学(errors.Is/As)、泛型(Go 1.18+ 类型约束)、性能 profiling(pprof CPU/内存/阻塞分析)、标准库高级用法(sync.Pool、singleflight、errgroup)。\n\n**Rust**:理解所有权系统(move 语义)、借用检查器(生命周期标注 \"a)、trait 系统(关联类型、默认实现)、智能指针(Box/Rc/Arc/RefCell)、零成本抽象(泛型单态化)、unsafe 使用场景与安全边界、async/await 与 Pin/Unpin、宏系统(声明宏与过程宏)。\n\n**Java**:精通 JVM 内存模型(堆/栈/方法区/元空间)、垃圾回收算法(G1/ZGC/Shenandoah/CMS 对比)、Spring 生态(Boot 自动配置原理/Security 过滤器链/Data JPA/Cloud 微服务组件)、并发编程(JUC 包:ReentrantLock/CountDownLatch/CompletableFuture/Volatile)、JIT 编译优化(C1/C2 编译器、逃逸分析、内联)。\n\n**C++**:现代 C++(17/20/23)特性(结构化绑定、std::optional/variant/any、协程、概念约束、模块)、RAII 资源管理、模板元编程(SFINAE/Concepts)、移动语义(右值引用/perfect forwarding)、智能指针(unique_ptr/shared_ptr/weak_ptr)、STL 容器与算法选择策略。\n\n### 1.2 架构设计原则\n- SOLID 原则及其在微服务架构中的具体应用场景\n- DDD(领域驱动设计):聚合根识别、值对象定义、领域事件设计、限界上下文划分\n- CQRS(命令查询职责分离)与 Event Sourcing 的联合使用模式\n- 六边形架构(Ports & Adapters):端口与适配器的抽象层次设计\n- 洋葱架构与整洁架构的实践差异\n- CAP 定理与 BASE 理论在分布式系统中的工程权衡\n- 事件驱动架构:事件风暴工作坊、Saga 编排与协调模式\n- Service Mesh 与 Sidecar 模式:Istio 流量管理、mTLS、可观测性\n- BFF(Backend for Frontend)模式:前端定制化 API 层设计\n\n### 1.3 数据库与存储\n**关系型数据库**:\n- PostgreSQL:JSONB 索引策略(GIN vs GIST)、Partial Index 条件索引、CTE 优化(可写 CTE)、Window Functions 高级排名、LISTEN/NOTIFY 实时通知、逻辑复制与 CDC、分区表策略(Range/List/Hash)、EXPLAIN ANALYZE 执行计划深度分析、pg_stat_statements 慢查询监控。\n- MySQL:InnoDB 存储引擎原理(B+Tree 索引结构、聚簇索引 vs 二级索引)、索引优化(覆盖索引、索引下推 ICP、多范围读 MRR)、执行计划分析(EXPLAIN FORMAT=JSON)、主从复制(GTID、半同步、并行复制)、组复制与 InnoDB Cluster。\n- SQLite:WAL 模式原理、并发控制策略(读并发写串行)、嵌入式场景最佳实践、PRAGMA 调优。\n\n**NoSQL 数据库**:\n- MongoDB:聚合管道优化($lookup/$unwind/$facet)、分片策略(范围/哈希/区域)、Change Streams 实时数据同步、事务支持(4.0+ 多文档 ACID)、索引类型(文本/地理空间/通配符)。\n- Redis:数据结构选择指南(String/Hash/List/Set/ZSet/Stream)、持久化策略(RDB 快照 vs AOF 日志 vs 混合)、集群模式(主从/Sentinel/Cluster)、Lua 脚本原子操作、Stream 消费者组、Redis Modules(RediSearch/RedisJSON/RedisTimeSeries)。\n- Elasticsearch:倒排索引原理与 FST、分词器配置(ik_max_word/jieba)、聚合分析(Bucket/Metrics/Pipeline)、索引生命周期管理(ILM)、跨集群搜索(CCS)。\n\n### 1.4 云原生与 DevOps\n**容器化**:\n- Docker:多阶段构建优化、镜像瘦身策略(distroless/alpine/scratch)、BuildKit 缓存挂载、安全最佳实践(非 root 运行、只读文件系统)、Docker Compose 复杂编排。\n- Kubernetes:Pod 生命周期(Pending/Running/Succeeded/Failed)、Deployment 滚动更新策略、Service Mesh(Istio VirtualService/DestinationRule)、HPA/VPA 自动伸缩指标选择、Operator 模式开发(kubebuilder/operator-sdk)、Helm Chart 模板设计、Network Policy 网络隔离、Resource Quota 与 LimitRange。\n\n**CI/CD**:\n- GitHub Actions:Reusable Workflows 调用、Matrix 策略多维度测试、缓存优化(actions/cache)、Environment 保护规则、OIDC 无密钥认证。\n- GitLab CI:Pipeline 设计模式(DAG/父子管道)、Artifact 管理与过期策略、环境变量安全(Vault 集成)。\n- ArgoCD:GitOps 模式实践、ApplicationSet 集群分发、Progressive Delivery(Rollout/Experiment)。\n\n**基础设施即代码**:\n- Terraform:State 远程管理(S3/OSS/Consul)、Module 组合设计、Workspace 多环境策略、Drift 检测与修正。\n- Pulumi:使用通用语言(Python/Go/TypeScript)定义基础设施、状态管理与加密。\n\n### 1.5 安全与合规\n- OWASP Top 10 漏洞识别(SQL 注入/XSS/CSRF/SSRF/反序列化)与防护方案\n- JWT/OAuth2.0/OIDC 认证协议深度理解(授权码流程/PKCE/客户端凭证)\n- API 安全:速率限制(令牌桶/漏桶/滑动窗口)、输入验证(白名单/参数化查询)、CORS 配置(预检请求/凭证传递)\n- 数据加密:传输加密(TLS 1.3 握手流程/证书链验证)、静态加密(AES-256-GCM)、密钥管理(KMS/HashiCorp Vault)\n- 安全审计与合规:SOC 2 Type II、GDPR 数据处理、等保 2.0 三级要求\n\n## 第二章:代码审查标准\n\n### 2.1 代码质量维度\n每个代码审查必须覆盖以下维度:\n\n**正确性**:逻辑是否正确、边界条件是否处理(空值/溢出/并发)、错误路径是否覆盖。\n**可读性**:命名是否清晰(意图揭示而非实现描述)、函数是否过长(单一职责原则)、注释是否有价值(解释 WHY 而非 WHAT)。\n**性能**:时间复杂度是否合理(O(n) vs O(n2) 场景分析)、是否有不必要的内存分配(对象池/复用)、是否可以利用缓存(计算结果缓存/HTTP 缓存)。\n**安全性**:输入是否验证(类型/长度/范围/格式)、是否有注入风险(SQL/命令/模板注入)、敏感数据是否正确处理(脱敏/加密/日志过滤)。\n**可维护性**:耦合度是否合理(依赖注入/接口抽象)、是否易于扩展(开闭原则)、测试是否充分(单元/集成/端到端覆盖)。\n\n### 2.2 常见代码坏味道识别与重构\n- God Object(上帝对象):拆分为内聚的模块\n- Feature Envy(特性嫉妒):将方法移到数据所在的类\n- Long Method(过长方法):提取子函数、使用卫语句\n- Duplicate Code(重复代码):提取公共方法/模板方法模式\n- Magic Number(魔法数字):使用命名常量/枚举\n- Dead Code(死代码):直接删除,版本控制保留历史\n- Premature Abstraction(过早抽象):YAGNI 原则,三个实例再抽象\n\n## 第三章:API 设计规范\n\n### 3.1 RESTful API 最佳实践\n- 资源命名:使用复数名词(/users 而非 /user)、嵌套资源表达关系(/users/id/orders)\n- HTTP 方法语义:GET 幂等安全、POST 非幂等创建、PUT 幂等全量替换、PATCH 部分更新、DELETE 幂等删除\n- 状态码选择:200 成功/201 已创建/204 无内容/400 错误请求/401 未认证/403 禁止/404 未找到/409 冲突/422 不可处理/429 限流/500 内部错误/503 服务不可用\n- 分页策略:Offset-based(简单但不适合大数据)vs Cursor-based(稳定适合无限滚动)\n- 版本控制:URL path(/v1/)vs Header(Accept: application/vnd.api.v1+json)vs Content negotiation\n\n### 3.2 GraphQL 适用场景分析\n- 优势:客户端精确查询减少 over-fetching、强类型 Schema 自文档化、实时订阅\n- 劣势:缓存复杂性(无 HTTP 缓存语义)、查询深度限制(DDoS 防护)、N+1 问题\n- 与 REST 共存策略:查询用 GraphQL、命令用 REST、混合网关模式\n\n### 3.3 gRPC 与 Protocol Buffers\n- 适用场景:微服务间高性能通信、流式处理(双向流)、强类型约束\n- 与 REST 选型决策树:内部通信用 gRPC、外部 API 用 REST/GraphQL\n- gRPC-Web 与 Envoy 代理的前端集成方案\n\n## 第四章:系统可靠性\n\n### 4.1 容错模式\n- Circuit Breaker(断路器):三态转换(Closed/Open/Half-Open)、滑动窗口统计\n- Bulkhead(舱壁):线程池隔离、信号量隔离、资源分配策略\n- Retry with Exponential Backoff(指数退避重试):抖动策略、重试预算\n- Fallback(降级):优雅降级策略、缓存兜底、默认值方案\n- Timeout(超时):连接超时 vs 读取超时 vs 全局超时、超时传播\n\n### 4.2 可观测性三支柱\n- Metrics(指标):RED 方法(Rate/Error/Duration)、USE 方法(Utilization/Saturation/Errors)、Prometheus + Grafana 监控体系\n- Logging(日志):结构化日志(JSON 格式)、关联 ID 传播(Trace ID/Request ID)、日志级别策略(动态调整)\n- Tracing(链路追踪):OpenTelemetry 标准、Span 与 Context 传播、采样策略(头部采样/尾部采样)\n\n### 4.3 混沌工程\n- Chaos Monkey 及其生态系统(Chaos Monkey for Spring Boot/Kubernetes)\n- 故障注入测试策略(网络延迟/丢包/分区/资源耗尽)\n- GameDay 演练设计(故障场景库/观察指标/恢复验证)\n\n## 第五章:性能优化方法论\n\n### 5.1 系统化性能优化流程\n1. 建立基线(Baseline):确定关键指标和当前表现\n2. 确定瓶颈(Profiling/Flame Graph):CPU/内存/IO/锁竞争分析\n3. 提出假设:基于数据推断根因\n4. 实施优化:最小化变更范围\n5. 验证效果(A/B 测试或基准测试):对比基线确认改善\n6. 记录决策和结果:建立知识库供后续参考\n\n### 5.2 常见优化策略\n- 数据库:索引优化、查询重写、读写分离、缓存层(应用缓存/查询缓存)、连接池调优\n- 网络:连接池复用、HTTP/2 多路复用、CDN 策略、gzip/brotli 压缩\n- 计算:算法优化(时间/空间复杂度)、并发处理(多线程/协程)、向量化(SIMD)、编译优化\n- 内存:对象池、内存对齐、GC 调优、off-heap 分配、逃逸分析\n\n## 第六章:AI 与机器学习集成\n\n### 6.1 LLM 应用架构\n- Prompt Engineering 最佳实践(系统提示/少样本/思维链/ReAct)\n- RAG(检索增强生成)系统设计(文档切分/嵌入模型/向量检索/重排序)\n- Agent 架构模式(ReAct、Plan-and-Execute、Tool Use、Multi-Agent)\n- Token 优化策略(Prompt Caching、上下文窗口管理、摘要压缩)\n- 流式响应处理(SSE/WebSocket/结构化流)\n\n### 6.2 向量数据库选型\n- Pinecone、Weaviate、Milvus、Qdrant、Chroma 功能与性能对比\n- 索引算法(HNSW/IVF/Flat)选择策略\n- 相似度度量(余弦相似度/欧氏距离/点积)适用场景\n- 混合检索(向量检索 + 关键词检索 BM25)架构设计"}], "messages": [{"role": "user", "content": "你好,简短回复"}]} \ No newline at end of file diff --git a/scripts/deploy-test.sh b/scripts/deploy-test.sh new file mode 100644 index 0000000..19d32bf --- /dev/null +++ b/scripts/deploy-test.sh @@ -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 diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..7065bcd --- /dev/null +++ b/scripts/deploy.sh @@ -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 diff --git a/scripts/query_orangels.sh b/scripts/query_orangels.sh new file mode 100644 index 0000000..d9a9a0d --- /dev/null +++ b/scripts/query_orangels.sh @@ -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; +" diff --git a/scripts/write_plans.py b/scripts/write_plans.py new file mode 100644 index 0000000..4af66f0 --- /dev/null +++ b/scripts/write_plans.py @@ -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() diff --git a/test-after-login.png b/test-after-login.png new file mode 100644 index 0000000..0842784 Binary files /dev/null and b/test-after-login.png differ diff --git a/test-login.png b/test-login.png new file mode 100644 index 0000000..b0994e2 Binary files /dev/null and b/test-login.png differ diff --git a/test-login2.png b/test-login2.png new file mode 100644 index 0000000..115913c Binary files /dev/null and b/test-login2.png differ diff --git a/test-page.png b/test-page.png new file mode 100644 index 0000000..437c696 Binary files /dev/null and b/test-page.png differ diff --git a/test-screenshot.png b/test-screenshot.png new file mode 100644 index 0000000..21c99fa Binary files /dev/null and b/test-screenshot.png differ diff --git a/test-user-click.png b/test-user-click.png new file mode 100644 index 0000000..58e28c3 Binary files /dev/null and b/test-user-click.png differ diff --git a/test-user-page.png b/test-user-page.png new file mode 100644 index 0000000..58e28c3 Binary files /dev/null and b/test-user-page.png differ diff --git a/test-user-pricing.png b/test-user-pricing.png new file mode 100644 index 0000000..a7dc61a Binary files /dev/null and b/test-user-pricing.png differ diff --git a/test-users.png b/test-users.png new file mode 100644 index 0000000..58e28c3 Binary files /dev/null and b/test-users.png differ diff --git a/web/src/components/table/model-pricing/modal/components/UserPriceComparison.jsx b/web/src/components/table/model-pricing/modal/components/UserPriceComparison.jsx new file mode 100644 index 0000000..a103a89 --- /dev/null +++ b/web/src/components/table/model-pricing/modal/components/UserPriceComparison.jsx @@ -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 ( + +
+ +
+
+ ); + } + + if (!priceData) return null; + + const { logged_in, savings_percent, quota_type } = priceData; + + // 未登录:显示简洁原价 + if (!logged_in) { + return ( + +
+ + + + {t('标准价格')} +
+
+ {quota_type === 0 ? ( +
+
+ {t('输入')} +
+ {formatValue(priceData.original_input)} +
+
/ {unitLabel} tokens
+
+
+ {t('输出')} +
+ {formatValue(priceData.original_output)} +
+
/ {unitLabel} tokens
+
+
+ ) : ( +
+
+ {formatValue(priceData.original_price)} +
+
/ {t('次')}
+
+ )} +
+
+ ); + } + + // 已登录但无折扣:不显示 + if (!savings_percent || savings_percent <= 0) return null; + + // 已登录且有折扣:显示方案A划线对比 + const discount = priceData.discount || ''; + + return ( + +
+
+ + + + {t('您的分组价格')} +
+ {discount && ( + + {discount} + + )} +
+ + + {priceData.group} {t('分组')} + + + {quota_type === 0 ? ( + <> + {/* 输入价格 */} +
+
{t('输入价格')}
+
+ + {formatValue(priceData.original_input)} + + + {formatValue(priceData.user_input)} + + + / {unitLabel} tokens + +
+
+ + + + + {t('每')} {unitLabel} tokens {t('节省')} {formatSavings(priceData.original_input, priceData.user_input)} +
+
+ + {/* 输出价格 */} +
+
{t('输出价格')}
+
+ + {formatValue(priceData.original_output)} + + + {formatValue(priceData.user_output)} + + + / {unitLabel} tokens + +
+
+ + + + + {t('每')} {unitLabel} tokens {t('节省')} {formatSavings(priceData.original_output, priceData.user_output)} +
+
+ + ) : ( + <> + {/* 按次计费 */} +
+
{t('每次调用')}
+
+ + {formatValue(priceData.original_price)} + + + {formatValue(priceData.user_price)} + + / {t('次')} +
+
+ + + + + {t('每次调用节省')} {formatSavings(priceData.original_price, priceData.user_price)} +
+
+ + )} +
+ ); +}; + +export default UserPriceComparison; diff --git a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx index b153887..a7273e9 100644 --- a/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx +++ b/web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx @@ -483,6 +483,7 @@ export const getLogsColumns = ({ ); }, }, + /* 暂时隐藏分组列 - 如需恢复请删除此注释包裹 { key: COLUMN_KEYS.GROUP, title: t('分组'), @@ -515,6 +516,7 @@ export const getLogsColumns = ({ } }, }, + */ { key: COLUMN_KEYS.TYPE, title: t('类型'), diff --git a/web/src/helpers/render.jsx b/web/src/helpers/render.jsx index 0e02f96..f029fb1 100644 --- a/web/src/helpers/render.jsx +++ b/web/src/helpers/render.jsx @@ -1181,7 +1181,8 @@ function getEffectiveRatio(groupRatio, user_group_ratio) { const useUserGroupRatio = isValidGroupRatio(user_group_ratio); const ratioLabel = useUserGroupRatio ? i18next.t('专属倍率') - : i18next.t('分组倍率'); + // : i18next.t('分组倍率'); // 原文字,已改为"倍率" + : i18next.t('倍率'); const effectiveRatio = useUserGroupRatio ? user_group_ratio : groupRatio; return { diff --git a/web/src/hooks/usage-logs/useUsageLogsData.jsx b/web/src/hooks/usage-logs/useUsageLogsData.jsx index c8a9771..0680f41 100644 --- a/web/src/hooks/usage-logs/useUsageLogsData.jsx +++ b/web/src/hooks/usage-logs/useUsageLogsData.jsx @@ -48,7 +48,7 @@ export const useLogsData = () => { CHANNEL: 'channel', USERNAME: 'username', TOKEN: 'token', - GROUP: 'group', + // GROUP: 'group', // 暂时隐藏分组列 TYPE: 'type', MODEL: 'model', USE_TIME: 'use_time', @@ -150,7 +150,7 @@ export const useLogsData = () => { [COLUMN_KEYS.CHANNEL]: isAdminUser, [COLUMN_KEYS.USERNAME]: isAdminUser, [COLUMN_KEYS.TOKEN]: true, - [COLUMN_KEYS.GROUP]: true, + // [COLUMN_KEYS.GROUP]: true, // 暂时隐藏分组列 [COLUMN_KEYS.TYPE]: true, [COLUMN_KEYS.MODEL]: true, [COLUMN_KEYS.USE_TIME]: true, @@ -522,6 +522,7 @@ export const useLogsData = () => { other.cache_creation_ratio_1h || other.cache_creation_ratio || 1.0, + other.user_channel_ratio, ); } else { content = renderModelPrice(