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 @@
+"去体验"按钮位置选择
+当前模型卡片结构示意,请选择按钮的最佳放置位置
+
+
+
+
+
+
+
OpenAI 旗舰多模态模型,支持文本和图像输入输出
+
+ 按量计费
+ 多模态
+
+
去体验 →
+
+
+
+
方案 A:卡片底部居中
+
按钮占据卡片底部整行,醒目且易于点击。适合卡片高度固定的场景。
+
+
+
+
+
+
+
+
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:简洁划线对比
+
原价划掉 + 折后价突出 + 折扣标签
+
+
+
+
+
+
+
+
+
+
$
+
+
您的分组价格
+ 基于您所在分组的专属价格
+
+
+
+
+
+
+
+
+ 输入价格
+ ¥0.0300
+ ¥0.0240
+ 8折
+ / 1K tokens
+
+
+
+ 每千 tokens 节省 ¥0.006
+
+
+
+
+ 输出价格
+ ¥0.0600
+ ¥0.0480
+ 8折
+ / 1K tokens
+
+
+
+ 每千 tokens 节省 ¥0.012
+
+
+
+
+
+
按次计费模型示例
+
+
+ 每次调用
+ ¥0.0500
+ ¥0.0350
+ 7折
+ / 次
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
通道价格
+ 所有支持该模型的通道价格
+
+
+
+
+
+
+ 通道
+ 计费类型
+ 输入价格
+ 输出价格
+
+
+
+
+
+ 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 用户价格 左右对比,突出节省金额
+
+
+
+
+
+
+
+
$
+
+
分组价格对比
+ 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:进度条式折扣可视化
+
用进度条直观展示折扣力度 + 价格明细
+
+
+
+
+
+
+
+
$
+
+
您的分组价格
+ VIP 分组专属折扣
+
+
+
+
+
+
+
+
+
输入价格
+
+
+ ¥0.0300
+
+
+ ¥0.0240
+
+
+
+ default 分组基准价
+ VIP 折扣价
+
+
+
+
输出价格
+
+
+ ¥0.0600
+
+
+ ¥0.0480
+
+
+
+ default 分组基准价
+ VIP 折扣价
+
+
+
+
+
+
输入价格
+
+ ¥0.0300
+ ¥0.0240
+
+
/ 1K tokens
+
+
+
输出价格
+
+ ¥0.0600
+ ¥0.0480
+
+
/ 1K tokens
+
+
+
+
+
+
+
+
每次调用价格
+
+
+ ¥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 (
+
+
+
+ {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 (
+
+
+
+ {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(