Просмотр исходного кода

feat(logs): 隐藏使用日志中的分组信息展示

使用注释方式隐藏分组列和分组倍率标签,便于后续恢复:
1. 注释 UsageLogsColumnDefs.jsx 中的分组列定义
2. 注释 useUsageLogsData.jsx 中的 COLUMN_KEYS.GROUP 和默认可见性配置
3. 将计费详情中的"分组倍率"改为"倍率"

后端计费逻辑不变,仍正常应用分组倍率。

Co-Authored-By: Claude Sonnet 4 <noreply@anthropic.com>
master
fengsilin 3 дней назад
Родитель
Сommit
e6b4a06557
53 измененных файлов: 5412 добавлений и 3 удалений
  1. +7
    -0
      .plans/alipay-payment/backend-dev/findings.md
  2. +7
    -0
      .plans/alipay-payment/backend-dev/progress.md
  3. +7
    -0
      .plans/alipay-payment/backend-dev/task-alipay-backend/findings.md
  4. +7
    -0
      .plans/alipay-payment/backend-dev/task-alipay-backend/progress.md
  5. +46
    -0
      .plans/alipay-payment/backend-dev/task-alipay-backend/task_plan.md
  6. +23
    -0
      .plans/alipay-payment/backend-dev/task_plan.md
  7. +26
    -0
      .plans/alipay-payment/decisions.md
  8. +7
    -0
      .plans/alipay-payment/findings.md
  9. +7
    -0
      .plans/alipay-payment/frontend-dev/findings.md
  10. +7
    -0
      .plans/alipay-payment/frontend-dev/progress.md
  11. +7
    -0
      .plans/alipay-payment/frontend-dev/task-alipay-frontend/findings.md
  12. +7
    -0
      .plans/alipay-payment/frontend-dev/task-alipay-frontend/progress.md
  13. +36
    -0
      .plans/alipay-payment/frontend-dev/task-alipay-frontend/task_plan.md
  14. +21
    -0
      .plans/alipay-payment/frontend-dev/task_plan.md
  15. +16
    -0
      .plans/alipay-payment/progress.md
  16. +7
    -0
      .plans/alipay-payment/reviewer/findings.md
  17. +5
    -0
      .plans/alipay-payment/reviewer/progress.md
  18. +16
    -0
      .plans/alipay-payment/reviewer/task_plan.md
  19. +47
    -0
      .plans/alipay-payment/task_plan.md
  20. +569
    -0
      .plans/channel-public-name.md
  21. +72
    -0
      .superpowers/brainstorm/6600-1777284109/content/button-position.html
  22. +3
    -0
      .superpowers/brainstorm/6600-1777284109/content/waiting.html
  23. +1
    -0
      .superpowers/brainstorm/6600-1777284109/state/server-stopped
  24. +1
    -0
      .superpowers/brainstorm/6600-1777284109/state/server.pid
  25. +410
    -0
      controller/pricing_user_test.go
  26. Двоичные данные
      current-page
  27. Двоичные данные
      login-page
  28. +306
    -0
      mockup-pricing-A.html
  29. +289
    -0
      mockup-pricing-B.html
  30. +362
    -0
      mockup-pricing-C.html
  31. +1020
    -0
      model/channel.go.bak
  32. +451
    -0
      model/pricing.go.bak
  33. Двоичные данные
      models-page
  34. +44
    -0
      relay/compatible_handler_test.go
  35. +166
    -0
      relay/helper/price_test.go
  36. +1
    -0
      scripts/cache_test.json
  37. +157
    -0
      scripts/deploy-test.sh
  38. +166
    -0
      scripts/deploy.sh
  39. +10
    -0
      scripts/query_orangels.sh
  40. +774
    -0
      scripts/write_plans.py
  41. Двоичные данные
      test-after-login.png
  42. Двоичные данные
      test-login.png
  43. Двоичные данные
      test-login2.png
  44. Двоичные данные
      test-page.png
  45. Двоичные данные
      test-screenshot.png
  46. Двоичные данные
      test-user-click.png
  47. Двоичные данные
      test-user-page.png
  48. Двоичные данные
      test-user-pricing.png
  49. Двоичные данные
      test-users.png
  50. +297
    -0
      web/src/components/table/model-pricing/modal/components/UserPriceComparison.jsx
  51. +2
    -0
      web/src/components/table/usage-logs/UsageLogsColumnDefs.jsx
  52. +2
    -1
      web/src/helpers/render.jsx
  53. +3
    -2
      web/src/hooks/usage-logs/useUsageLogsData.jsx

+ 7
- 0
.plans/alipay-payment/backend-dev/findings.md Просмотреть файл

@@ -0,0 +1,7 @@
# backend-dev - 发现索引

> 纯索引——每个条目应简短(Status + Report 链接 + Summary)。

---

<初始为空,工作中填写>

+ 7
- 0
.plans/alipay-payment/backend-dev/progress.md Просмотреть файл

@@ -0,0 +1,7 @@
# backend-dev - 工作日志

> 用于上下文恢复。压缩/重启后先读此文件。

---

<初始为空,工作中填写>

+ 7
- 0
.plans/alipay-payment/backend-dev/task-alipay-backend/findings.md Просмотреть файл

@@ -0,0 +1,7 @@
# 支付宝后端 - 发现记录

> 此任务开发中的技术发现。

---

<初始为空>

+ 7
- 0
.plans/alipay-payment/backend-dev/task-alipay-backend/progress.md Просмотреть файл

@@ -0,0 +1,7 @@
# 支付宝后端 - 工作日志

> 上下文恢复时只需读此文件。

---

<初始为空>

+ 46
- 0
.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 已在项目中

+ 23
- 0
.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 审查

+ 26
- 0
.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 角色(全栈)

+ 7
- 0
.plans/alipay-payment/findings.md Просмотреть файл

@@ -0,0 +1,7 @@
# alipay-payment - 发现与技术记录

> 由团队智能体自动更新。每条标注来源。

---

<工作中添加条目>

+ 7
- 0
.plans/alipay-payment/frontend-dev/findings.md Просмотреть файл

@@ -0,0 +1,7 @@
# frontend-dev - 发现索引

> 纯索引——每个条目应简短。

---

<初始为空,工作中填写>

+ 7
- 0
.plans/alipay-payment/frontend-dev/progress.md Просмотреть файл

@@ -0,0 +1,7 @@
# frontend-dev - 工作日志

> 用于上下文恢复。

---

<初始为空,工作中填写>

+ 7
- 0
.plans/alipay-payment/frontend-dev/task-alipay-frontend/findings.md Просмотреть файл

@@ -0,0 +1,7 @@
# 支付宝前端 - 发现记录

> 此任务开发中的技术发现。

---

<初始为空>

+ 7
- 0
.plans/alipay-payment/frontend-dev/task-alipay-frontend/progress.md Просмотреть файл

@@ -0,0 +1,7 @@
# 支付宝前端 - 工作日志

> 上下文恢复时只需读此文件。

---

<初始为空>

+ 36
- 0
.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 遵循现有命名规范

+ 21
- 0
.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 审查

+ 16
- 0
.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] 创建规划文件和目录结构

### 待办
- [ ] 启动团队成员
- [ ] 开始并行开发

+ 7
- 0
.plans/alipay-payment/reviewer/findings.md Просмотреть файл

@@ -0,0 +1,7 @@
# reviewer - 发现索引

> 纯索引。

---

<初始为空,工作中填写>

+ 5
- 0
.plans/alipay-payment/reviewer/progress.md Просмотреть файл

@@ -0,0 +1,5 @@
# reviewer - 工作日志

---

<初始为空>

+ 16
- 0
.plans/alipay-payment/reviewer/task_plan.md Просмотреть файл

@@ -0,0 +1,16 @@
# reviewer - 任务计划

> 角色: 代码审查
> 状态: pending
> 分配的任务: 等待 backend-dev 和 frontend-dev 完成后进行代码审查

## 任务

- [ ] 审查 backend-dev 支付宝后端代码(安全 + 质量)
- [ ] 审查 frontend-dev 支付宝前端代码(质量 + 体验)

## 备注

- 支付涉及资金安全,重点关注:签名验签、金额处理、幂等性、并发安全
- 后端审查重点:回调验签、金额单位(元 vs 分)、订单幂等
- 前端审查重点:二维码模态框重构兼容性、支付状态轮询

+ 47
- 0
.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(并行开发)。

+ 569
- 0
.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
<Form.Input
field='name'
label={t('名称')}
placeholder={t('请为渠道命名')}
rules={[{ required: true, message: t('请为渠道命名') }]}
showClear
onChange={(value) => handleInputChange('name', value)}
autoComplete='new-password'
/>

{inputs.type === 33 && (
```

在第 1980 行 `/>` 和第 1982 行 `{inputs.type === 33` 之间插入:

```jsx
<Form.Input
field='name'
label={t('名称')}
placeholder={t('请为渠道命名')}
rules={[{ required: true, message: t('请为渠道命名') }]}
showClear
onChange={(value) => handleInputChange('name', value)}
autoComplete='new-password'
/>
<Form.Input
field='public_name'
label={t('对外名称')}
placeholder={t('用户看到的渠道名称,如"标准通道"、"高速通道"')}
rules={!isEdit ? [{ required: true, message: t('请填写对外名称') }] : []}
showClear
onChange={(value) => handleInputChange('public_name', value)}
autoComplete='new-password'
/>

{inputs.type === 33 && (
```

**说明:** `!isEdit` 条件确保新建时必填、编辑时允许清空。

- [ ] **Step 3: 修改提交前校验**

当前代码(第 1329-1332 行):
```javascript
if (!isEdit && (!localInputs.name || !localInputs.key)) {
showInfo(t('请填写渠道名称和渠道密钥!'));
return;
}
```

改为:
```javascript
if (!isEdit && (!localInputs.name || !localInputs.public_name || !localInputs.key)) {
showInfo(t('请填写渠道名称、对外名称和渠道密钥!'));
return;
}
```

- [ ] **Step 4: Commit**

```bash
git add web/src/components/table/channels/modals/EditChannelModal.jsx
git commit -m "feat: add public_name field to channel edit form"
```

---

### Task 9: ChannelPricingCard 展示公共名称

**Files:**
- Modify: `web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx:107`

- [ ] **Step 1: 修改 channelName 赋值逻辑**

当前代码(第 104-108 行):
```javascript
const tableData = channelPricingData.map((item, index) => ({
key: item.channel_id || index,
channelId: item.channel_id,
channelName: `通道${['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][index] || ` ${index + 1}`}`,
channelTags: item.tags || [],
```

改为:
```javascript
const tableData = channelPricingData.map((item, index) => ({
key: item.channel_id || index,
channelId: item.channel_id,
channelName: item.channel_public_name || ('通道' + (['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][index] || (index + 1))),
channelTags: item.tags || [],
```

**说明:** 优先使用后端 `channel_public_name`,为空时回退到中文数字。同时修复原代码反引号嵌套语法问题。

- [ ] **Step 2: Commit**

```bash
git add web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx
git commit -m "feat: display public_name in ChannelPricingCard"
```

---

### Task 10: 端到端验证

**Files:** 无代码修改,纯验证

- [ ] **Step 1: 后端编译**

Run: `cd D:/code/new-api && go build -o new-api main.go`
Expected: 编译成功

- [ ] **Step 2: 启动服务**

Run: `cd D:/code/new-api && go run main.go`
Expected: 日志中出现 `[Migration] migrateChannelPublicName: backfilled N channels`

- [ ] **Step 3: 再次重启确认幂等**

重启服务后 Expected: 日志中不再出现 backfilled(RowsAffected=0)

- [ ] **Step 4: API 测试 — 创建渠道不带 public_name**

Run:
```bash
curl -s -X POST http://localhost:3000/api/channel/ \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"mode":"single","channel":{"type":1,"name":"测试","key":"sk-test"}}'
```
Expected: 返回错误 `"public name cannot be empty"`

- [ ] **Step 5: API 测试 — 创建渠道带 public_name**

Run:
```bash
curl -s -X POST http://localhost:3000/api/channel/ \
-H "Authorization: Bearer $ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"mode":"single","channel":{"type":1,"name":"测试","public_name":"标准通道","key":"sk-test"}}'
```
Expected: 返回成功

- [ ] **Step 6: API 测试 — 定价接口包含 public_name**

Run:
```bash
curl -s http://localhost:3000/api/channel-pricing/model/gpt-4
```
Expected: 响应中包含 `channel_public_name` 字段

- [ ] **Step 7: 前端验证**

在浏览器中依次验证:
1. 渠道管理 → 新建渠道 → 可见"对外名称"输入框
2. 不填"对外名称"提交 → 显示验证错误
3. 填写后提交成功
4. 编辑渠道 → 可见已保存的对外名称
5. 编辑时清空对外名称并保存 → 成功(向后兼容)
6. 模型详情侧边栏 → ChannelPricingCard 显示对外名称
7. 首页定价卡片 → "默认通道"标签显示对外名称
8. 未设 public_name 的渠道 → 显示"通道一/二/三..."

+ 72
- 0
.superpowers/brainstorm/6600-1777284109/content/button-position.html Просмотреть файл

@@ -0,0 +1,72 @@
<h2>"去体验"按钮位置选择</h2>
<p class="subtitle">当前模型卡片结构示意,请选择按钮的最佳放置位置</p>

<div class="cards">
<div class="card" data-choice="bottom-center" onclick="toggleSelect(this)">
<div class="card-image">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px; color: white;">
<div style="display:flex; align-items:center; gap:10px; margin-bottom:12px;">
<div style="width:36px;height:36px;border-radius:8px;background:rgba(255,255,255,0.2);display:flex;align-items:center;justify-content:center;font-size:18px;">GPT</div>
<span style="font-weight:600;font-size:16px;">gpt-4o</span>
<span style="margin-left:auto;font-size:12px;opacity:0.7;">📋</span>
</div>
<p style="font-size:13px;opacity:0.85;margin:0 0 12px 0;line-height:1.4;">OpenAI 旗舰多模态模型,支持文本和图像输入输出</p>
<div style="display:flex;gap:6px;margin-bottom:16px;">
<span style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:4px;font-size:11px;">按量计费</span>
<span style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:4px;font-size:11px;">多模态</span>
</div>
<button style="width:100%;padding:8px 0;background:rgba(255,255,255,0.25);border:1px solid rgba(255,255,255,0.4);border-radius:6px;color:white;font-size:13px;cursor:pointer;">去体验 →</button>
</div>
</div>
<div class="card-body">
<h3>方案 A:卡片底部居中</h3>
<p>按钮占据卡片底部整行,醒目且易于点击。适合卡片高度固定的场景。</p>
</div>
</div>

<div class="card" data-choice="footer-right" onclick="toggleSelect(this)">
<div class="card-image">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px; color: white;">
<div style="display:flex; align-items:center; gap:10px; margin-bottom:12px;">
<div style="width:36px;height:36px;border-radius:8px;background:rgba(255,255,255,0.2);display:flex;align-items:center;justify-content:center;font-size:18px;">GPT</div>
<span style="font-weight:600;font-size:16px;">gpt-4o</span>
<span style="margin-left:auto;font-size:12px;opacity:0.7;">📋</span>
</div>
<p style="font-size:13px;opacity:0.85;margin:0 0 12px 0;line-height:1.4;">OpenAI 旗舰多模态模型,支持文本和图像输入输出</p>
<div style="display:flex;align-items:center;gap:6px;">
<span style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:4px;font-size:11px;">按量计费</span>
<span style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:4px;font-size:11px;">多模态</span>
<span style="margin-left:auto;"></span>
<button style="padding:5px 12px;background:rgba(255,255,255,0.25);border:1px solid rgba(255,255,255,0.4);border-radius:6px;color:white;font-size:12px;cursor:pointer;">去体验 →</button>
</div>
</div>
</div>
<div class="card-body">
<h3>方案 B:标签行右侧</h3>
<p>按钮与标签同行,紧凑不占额外空间。但可能在小屏幕上显得拥挤。</p>
</div>
</div>

<div class="card" data-choice="header-right" onclick="toggleSelect(this)">
<div class="card-image">
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 20px; border-radius: 8px; color: white;">
<div style="display:flex; align-items:center; gap:10px; margin-bottom:12px;">
<div style="width:36px;height:36px;border-radius:8px;background:rgba(255,255,255,0.2);display:flex;align-items:center;justify-content:center;font-size:18px;">GPT</div>
<span style="font-weight:600;font-size:16px;">gpt-4o</span>
<span style="margin-left:auto;"></span>
<button style="padding:5px 12px;background:rgba(255,255,255,0.25);border:1px solid rgba(255,255,255,0.4);border-radius:6px;color:white;font-size:12px;cursor:pointer;">去体验 →</button>
<span style="font-size:12px;opacity:0.7;">📋</span>
</div>
<p style="font-size:13px;opacity:0.85;margin:0 0 12px 0;line-height:1.4;">OpenAI 旗舰多模态模型,支持文本和图像输入输出</p>
<div style="display:flex;gap:6px;">
<span style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:4px;font-size:11px;">按量计费</span>
<span style="background:rgba(255,255,255,0.2);padding:2px 8px;border-radius:4px;font-size:11px;">多模态</span>
</div>
</div>
</div>
<div class="card-body">
<h3>方案 C:标题行右侧</h3>
<p>按钮在模型名称旁边,最显眼的位置。但与复制按钮竞争空间。</p>
</div>
</div>
</div>

+ 3
- 0
.superpowers/brainstorm/6600-1777284109/content/waiting.html Просмотреть файл

@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>

+ 1
- 0
.superpowers/brainstorm/6600-1777284109/state/server-stopped Просмотреть файл

@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1777286270028}

+ 1
- 0
.superpowers/brainstorm/6600-1777284109/state/server.pid Просмотреть файл

@@ -0,0 +1 @@
6600

+ 410
- 0
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"])
}

Двоичные данные
current-page Просмотреть файл

До После
Ширина: 2074  |  Высота: 1574  |  Размер: 96 KiB

Двоичные данные
login-page Просмотреть файл

До После
Ширина: 1034  |  Высота: 785  |  Размер: 80 KiB

+ 306
- 0
mockup-pricing-A.html Просмотреть файл

@@ -0,0 +1,306 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>方案A:简洁划线对比</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }

.panel {
max-width: 580px;
margin: 40px auto;
background: #fff;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
overflow: hidden;
}

.panel-header {
display: flex;
align-items: center;
gap: 12px;
padding: 20px 24px 16px;
border-bottom: 1px solid #f0f0f0;
}

.model-icon {
width: 48px; height: 48px;
border-radius: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex; align-items: center; justify-content: center;
color: #fff; font-weight: 700; font-size: 18px;
}

.model-name { font-size: 18px; font-weight: 600; }
.model-desc { font-size: 13px; color: #888; margin-top: 2px; }

.section { padding: 16px 24px; }
.section-title {
display: flex; align-items: center; gap: 8px;
margin-bottom: 12px;
}
.section-title .icon {
width: 28px; height: 28px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 14px; color: #fff;
}
.section-title .icon.orange { background: #fa8c16; }
.section-title .icon.cyan { background: #13c2c2; }
.section-title h3 { font-size: 15px; font-weight: 600; }
.section-title small { font-size: 12px; color: #999; }

/* ===== 方案A核心样式:简洁划线对比 ===== */
.price-comparison {
background: linear-gradient(135deg, #fff7e6 0%, #fff 100%);
border: 1px solid #ffe7ba;
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 12px;
}

.group-tag {
display: inline-flex; align-items: center; gap: 4px;
background: #722ed1; color: #fff;
padding: 2px 10px; border-radius: 10px;
font-size: 12px; font-weight: 500;
margin-bottom: 12px;
}

.price-row {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 8px;
}

.price-label {
font-size: 13px;
color: #666;
min-width: 56px;
}

.original-price {
font-size: 14px;
color: #bbb;
text-decoration: line-through;
text-decoration-color: #d48806;
text-decoration-thickness: 2px;
}

.discount-price {
font-size: 22px;
font-weight: 700;
color: #cf1322;
}

.discount-badge {
display: inline-flex; align-items: center;
background: linear-gradient(135deg, #cf1322, #a8071a);
color: #fff;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
margin-left: 8px;
}

.savings-line {
font-size: 12px;
color: #52c41a;
margin-top: 4px;
display: flex; align-items: center; gap: 4px;
}

.price-unit {
font-size: 12px;
color: #999;
margin-left: 4px;
}

.divider {
height: 1px;
background: #f0f0f0;
margin: 0 24px;
}

/* 表格样式 */
.channel-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.channel-table th {
text-align: left;
padding: 8px 0;
color: #999;
font-weight: 500;
font-size: 12px;
border-bottom: 1px solid #f0f0f0;
}
.channel-table td {
padding: 10px 0;
border-bottom: 1px solid #f5f5f5;
}
.channel-table tr:last-child td { border-bottom: none; }
.channel-tag {
display: inline-block;
padding: 1px 8px;
border-radius: 8px;
font-size: 11px;
margin-right: 4px;
}
.channel-tag.cyan { background: #e6fffb; color: #13c2c2; }
.channel-tag.violet { background: #f9f0ff; color: #722ed1; }
.channel-tag.amber { background: #fff7e6; color: #d48806; }
.channel-tag.green { background: #f6ffed; color: #52c41a; }
.price-orange { color: #d4380d; font-weight: 600; }

/* 方案标签 */
.scheme-label {
text-align: center;
padding: 12px;
background: #667eea;
color: #fff;
font-weight: 600;
font-size: 16px;
letter-spacing: 1px;
}
.scheme-sub {
font-size: 12px;
font-weight: 400;
opacity: 0.85;
}
</style>
</head>
<body>

<div class="scheme-label">
方案A:简洁划线对比
<div class="scheme-sub">原价划掉 + 折后价突出 + 折扣标签</div>
</div>

<div class="panel">
<!-- 模型头部 -->
<div class="panel-header">
<div class="model-icon">G4</div>
<div>
<div class="model-name">gpt-4o</div>
<div class="model-desc">OpenAI 最新多模态模型,支持文本和视觉</div>
</div>
</div>

<!-- 用户分组价格区域 -->
<div class="section">
<div class="section-title">
<div class="icon orange">$</div>
<div>
<h3>您的分组价格</h3>
<small>基于您所在分组的专属价格</small>
</div>
</div>

<div class="price-comparison">
<div class="group-tag">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
VIP 分组
</div>

<!-- 输入价格 -->
<div class="price-row">
<span class="price-label">输入价格</span>
<span class="original-price">¥0.0300</span>
<span class="discount-price">¥0.0240</span>
<span class="discount-badge">8折</span>
<span class="price-unit">/ 1K tokens</span>
</div>
<div class="savings-line">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#52c41a" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>
每千 tokens 节省 ¥0.006
</div>

<!-- 输出价格 -->
<div class="price-row" style="margin-top: 12px;">
<span class="price-label">输出价格</span>
<span class="original-price">¥0.0600</span>
<span class="discount-price">¥0.0480</span>
<span class="discount-badge">8折</span>
<span class="price-unit">/ 1K tokens</span>
</div>
<div class="savings-line">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#52c41a" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>
每千 tokens 节省 ¥0.012
</div>
</div>

<!-- 如果是按次计费的模型 -->
<div class="price-comparison" style="background: linear-gradient(135deg, #f0f5ff 0%, #fff 100%); border-color: #adc6ff;">
<div style="font-size: 12px; color: #999; margin-bottom: 8px;">按次计费模型示例</div>
<div class="group-tag" style="background: #1890ff;">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
SVIP 分组
</div>
<div class="price-row">
<span class="price-label">每次调用</span>
<span class="original-price">¥0.0500</span>
<span class="discount-price">¥0.0350</span>
<span class="discount-badge">7折</span>
<span class="price-unit">/ 次</span>
</div>
<div class="savings-line">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#52c41a" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>
每次调用节省 ¥0.015
</div>
</div>
</div>

<div class="divider"></div>

<!-- 通道价格 -->
<div class="section">
<div class="section-title">
<div class="icon cyan">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</div>
<div>
<h3>通道价格</h3>
<small>所有支持该模型的通道价格</small>
</div>
</div>

<table class="channel-table">
<thead>
<tr>
<th>通道</th>
<th>计费类型</th>
<th>输入价格</th>
<th>输出价格</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<span class="channel-tag cyan">OpenAI 官方</span>
<span class="channel-tag amber">默认</span>
</td>
<td><span class="channel-tag violet">按量计费</span></td>
<td class="price-orange">¥0.0240<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td>
<td class="price-orange">¥0.0480<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td>
</tr>
<tr>
<td>
<span class="channel-tag cyan">Azure 中转</span>
<span class="channel-tag green">高速</span>
</td>
<td><span class="channel-tag violet">按量计费</span></td>
<td class="price-orange">¥0.0280<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td>
<td class="price-orange">¥0.0560<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td>
</tr>
</tbody>
</table>
</div>
</div>

</body>
</html>

+ 289
- 0
mockup-pricing-B.html Просмотреть файл

@@ -0,0 +1,289 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>方案B:双卡片对比</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }

.panel {
max-width: 580px;
margin: 40px auto;
background: #fff;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
overflow: hidden;
}

.panel-header {
display: flex; align-items: center; gap: 12px;
padding: 20px 24px 16px;
border-bottom: 1px solid #f0f0f0;
}

.model-icon {
width: 48px; height: 48px; border-radius: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex; align-items: center; justify-content: center;
color: #fff; font-weight: 700; font-size: 18px;
}
.model-name { font-size: 18px; font-weight: 600; }
.model-desc { font-size: 13px; color: #888; margin-top: 2px; }

.section { padding: 16px 24px; }
.section-title {
display: flex; align-items: center; gap: 8px; margin-bottom: 12px;
}
.section-title .icon {
width: 28px; height: 28px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 14px; color: #fff;
}
.section-title .icon.orange { background: #fa8c16; }
.section-title .icon.cyan { background: #13c2c2; }
.section-title h3 { font-size: 15px; font-weight: 600; }
.section-title small { font-size: 12px; color: #999; }

.divider { height: 1px; background: #f0f0f0; margin: 0 24px; }

/* ===== 方案B核心样式:双卡片对比 ===== */
.price-cards {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}

.price-card {
border-radius: 12px;
padding: 16px;
position: relative;
overflow: hidden;
}

.price-card.standard {
background: #fafafa;
border: 1px solid #e8e8e8;
}

.price-card.discounted {
background: linear-gradient(135deg, #f6ffed 0%, #d9f7be 100%);
border: 2px solid #52c41a;
}

.price-card .card-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
margin-bottom: 8px;
}

.price-card.standard .card-label { color: #999; }
.price-card.discounted .card-label { color: #389e0d; }

.price-card .card-group {
font-size: 12px;
color: #888;
margin-bottom: 12px;
}

.price-card.discounted .card-group {
color: #389e0d;
}

.price-item {
margin-bottom: 8px;
}
.price-item:last-child { margin-bottom: 0; }
.price-item-label {
font-size: 11px;
color: #999;
margin-bottom: 2px;
}
.price-item-value {
font-weight: 700;
font-size: 18px;
}
.price-card.standard .price-item-value { color: #bbb; }
.price-card.discounted .price-item-value { color: #237804; }

.price-unit {
font-size: 11px;
font-weight: 400;
opacity: 0.6;
}

.savings-banner {
background: linear-gradient(90deg, #52c41a, #73d13d);
color: #fff;
border-radius: 8px;
padding: 8px 16px;
text-align: center;
font-weight: 600;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}

.savings-amount {
font-size: 18px;
font-weight: 700;
}

/* 通道表格 */
.channel-table {
width: 100%; border-collapse: collapse; font-size: 13px;
}
.channel-table th {
text-align: left; padding: 8px 0; color: #999;
font-weight: 500; font-size: 12px;
border-bottom: 1px solid #f0f0f0;
}
.channel-table td {
padding: 10px 0; border-bottom: 1px solid #f5f5f5;
}
.channel-table tr:last-child td { border-bottom: none; }
.channel-tag {
display: inline-block; padding: 1px 8px; border-radius: 8px;
font-size: 11px; margin-right: 4px;
}
.channel-tag.cyan { background: #e6fffb; color: #13c2c2; }
.channel-tag.violet { background: #f9f0ff; color: #722ed1; }
.channel-tag.amber { background: #fff7e6; color: #d48806; }
.channel-tag.green { background: #f6ffed; color: #52c41a; }
.price-orange { color: #d4380d; font-weight: 600; }

/* 方案标签 */
.scheme-label {
text-align: center; padding: 12px;
background: #52c41a; color: #fff;
font-weight: 600; font-size: 16px; letter-spacing: 1px;
}
.scheme-sub { font-size: 12px; font-weight: 400; opacity: 0.85; }

/* 节省箭头 */
.arrow-down {
display: flex; justify-content: center; margin: 8px 0;
}
.arrow-down svg { color: #52c41a; }
</style>
</head>
<body>

<div class="scheme-label">
方案B:双卡片对比
<div class="scheme-sub">标准价格 vs 用户价格 左右对比,突出节省金额</div>
</div>

<div class="panel">
<div class="panel-header">
<div class="model-icon">G4</div>
<div>
<div class="model-name">gpt-4o</div>
<div class="model-desc">OpenAI 最新多模态模型</div>
</div>
</div>

<div class="section">
<div class="section-title">
<div class="icon orange">$</div>
<div>
<h3>分组价格对比</h3>
<small>VIP 分组专属折扣</small>
</div>
</div>

<!-- 输入价格对比 -->
<div style="font-size:12px;color:#888;margin-bottom:6px;">输入价格</div>
<div class="price-cards">
<div class="price-card standard">
<div class="card-label">标准价格</div>
<div class="card-group">default 分组</div>
<div class="price-item">
<div class="price-item-label">每千 tokens</div>
<div class="price-item-value">¥0.0300 <span class="price-unit">/ 1K</span></div>
</div>
</div>
<div class="price-card discounted">
<div class="card-label">您的价格</div>
<div class="card-group">VIP 分组 · 8折</div>
<div class="price-item">
<div class="price-item-label">每千 tokens</div>
<div class="price-item-value">¥0.0240 <span class="price-unit">/ 1K</span></div>
</div>
</div>
</div>

<div class="savings-banner">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>
输入节省 <span class="savings-amount">20%</span> · 每千 tokens 省 ¥0.006
</div>

<!-- 输出价格对比 -->
<div style="font-size:12px;color:#888;margin-bottom:6px;margin-top:20px;">输出价格</div>
<div class="price-cards">
<div class="price-card standard">
<div class="card-label">标准价格</div>
<div class="card-group">default 分组</div>
<div class="price-item">
<div class="price-item-label">每千 tokens</div>
<div class="price-item-value">¥0.0600 <span class="price-unit">/ 1K</span></div>
</div>
</div>
<div class="price-card discounted">
<div class="card-label">您的价格</div>
<div class="card-group">VIP 分组 · 8折</div>
<div class="price-item">
<div class="price-item-label">每千 tokens</div>
<div class="price-item-value">¥0.0480 <span class="price-unit">/ 1K</span></div>
</div>
</div>
</div>

<div class="savings-banner">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 6 13.5 15.5 8.5 10.5 1 18"/><polyline points="17 6 23 6 23 12"/></svg>
输出节省 <span class="savings-amount">20%</span> · 每千 tokens 省 ¥0.012
</div>
</div>

<div class="divider"></div>

<div class="section">
<div class="section-title">
<div class="icon cyan">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</div>
<div>
<h3>通道价格</h3>
<small>所有支持该模型的通道价格</small>
</div>
</div>
<table class="channel-table">
<thead>
<tr><th>通道</th><th>计费类型</th><th>输入价格</th><th>输出价格</th></tr>
</thead>
<tbody>
<tr>
<td><span class="channel-tag cyan">OpenAI 官方</span><span class="channel-tag amber">默认</span></td>
<td><span class="channel-tag violet">按量计费</span></td>
<td class="price-orange">¥0.0240<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td>
<td class="price-orange">¥0.0480<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td>
</tr>
<tr>
<td><span class="channel-tag cyan">Azure 中转</span><span class="channel-tag green">高速</span></td>
<td><span class="channel-tag violet">按量计费</span></td>
<td class="price-orange">¥0.0280<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td>
<td class="price-orange">¥0.0560<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td>
</tr>
</tbody>
</table>
</div>
</div>

</body>
</html>

+ 362
- 0
mockup-pricing-C.html Просмотреть файл

@@ -0,0 +1,362 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>方案C:进度条式折扣可视化</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; color: #333; }

.panel {
max-width: 580px; margin: 40px auto; background: #fff;
border-radius: 16px; box-shadow: 0 2px 12px rgba(0,0,0,0.08); overflow: hidden;
}

.panel-header {
display: flex; align-items: center; gap: 12px;
padding: 20px 24px 16px; border-bottom: 1px solid #f0f0f0;
}
.model-icon {
width: 48px; height: 48px; border-radius: 12px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex; align-items: center; justify-content: center;
color: #fff; font-weight: 700; font-size: 18px;
}
.model-name { font-size: 18px; font-weight: 600; }
.model-desc { font-size: 13px; color: #888; margin-top: 2px; }

.section { padding: 16px 24px; }
.section-title {
display: flex; align-items: center; gap: 8px; margin-bottom: 12px;
}
.section-title .icon {
width: 28px; height: 28px; border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 14px; color: #fff;
}
.section-title .icon.orange { background: #fa8c16; }
.section-title .icon.cyan { background: #13c2c2; }
.section-title h3 { font-size: 15px; font-weight: 600; }
.section-title small { font-size: 12px; color: #999; }

.divider { height: 1px; background: #f0f0f0; margin: 0 24px; }

/* ===== 方案C核心样式:进度条式折扣可视化 ===== */
.discount-card {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 12px;
position: relative;
}

.discount-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}

.discount-big-number {
font-size: 36px;
font-weight: 800;
background: linear-gradient(135deg, #ff4d4f, #cf1322);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
line-height: 1;
}

.discount-percent {
font-size: 14px;
font-weight: 600;
color: #cf1322;
}

.group-badge {
display: inline-flex; align-items: center; gap: 4px;
padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: 500;
}
.group-badge.vip { background: #f9f0ff; color: #722ed1; border: 1px solid #d3adf7; }
.group-badge.svip { background: #fff7e6; color: #d48806; border: 1px solid #ffe58f; }

/* 进度条 */
.bar-container {
position: relative;
height: 32px;
background: #f5f5f5;
border-radius: 6px;
overflow: hidden;
margin-bottom: 8px;
}

.bar-full {
position: absolute;
top: 0; left: 0;
height: 100%;
background: #e8e8e8;
border-radius: 6px;
display: flex; align-items: center; justify-content: flex-end;
padding-right: 8px;
font-size: 11px; color: #999; font-weight: 500;
transition: width 0.5s ease;
}

.bar-discount {
position: absolute;
top: 0; left: 0;
height: 100%;
border-radius: 6px;
display: flex; align-items: center;
padding-left: 10px;
font-size: 12px; color: #fff; font-weight: 600;
transition: width 0.5s ease;
}

.bar-discount.green { background: linear-gradient(90deg, #52c41a, #73d13d); }
.bar-discount.blue { background: linear-gradient(90deg, #1890ff, #40a9ff); }
.bar-discount.orange { background: linear-gradient(90deg, #fa8c16, #ffa940); }

.bar-labels {
display: flex;
justify-content: space-between;
font-size: 11px;
color: #bbb;
margin-bottom: 16px;
}

/* 价格明细 */
.price-details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}

.price-detail-item {
background: #fafafa;
border-radius: 8px;
padding: 10px 12px;
}

.price-detail-label {
font-size: 11px;
color: #999;
margin-bottom: 4px;
}

.price-detail-row {
display: flex;
align-items: baseline;
gap: 6px;
}

.price-old {
font-size: 13px;
color: #ccc;
text-decoration: line-through;
}

.price-new {
font-size: 16px;
font-weight: 700;
color: #237804;
}

.price-new.red { color: #cf1322; }

.price-detail-unit {
font-size: 10px;
color: #bbb;
}

/* 通道表格 */
.channel-table {
width: 100%; border-collapse: collapse; font-size: 13px;
}
.channel-table th {
text-align: left; padding: 8px 0; color: #999;
font-weight: 500; font-size: 12px;
border-bottom: 1px solid #f0f0f0;
}
.channel-table td {
padding: 10px 0; border-bottom: 1px solid #f5f5f5;
}
.channel-table tr:last-child td { border-bottom: none; }
.channel-tag {
display: inline-block; padding: 1px 8px; border-radius: 8px;
font-size: 11px; margin-right: 4px;
}
.channel-tag.cyan { background: #e6fffb; color: #13c2c2; }
.channel-tag.violet { background: #f9f0ff; color: #722ed1; }
.channel-tag.amber { background: #fff7e6; color: #d48806; }
.channel-tag.green { background: #f6ffed; color: #52c41a; }
.price-orange { color: #d4380d; font-weight: 600; }

.scheme-label {
text-align: center; padding: 12px;
background: #722ed1; color: #fff;
font-weight: 600; font-size: 16px; letter-spacing: 1px;
}
.scheme-sub { font-size: 12px; font-weight: 400; opacity: 0.85; }
</style>
</head>
<body>

<div class="scheme-label">
方案C:进度条式折扣可视化
<div class="scheme-sub">用进度条直观展示折扣力度 + 价格明细</div>
</div>

<div class="panel">
<div class="panel-header">
<div class="model-icon">G4</div>
<div>
<div class="model-name">gpt-4o</div>
<div class="model-desc">OpenAI 最新多模态模型</div>
</div>
</div>

<div class="section">
<div class="section-title">
<div class="icon orange">$</div>
<div>
<h3>您的分组价格</h3>
<small>VIP 分组专属折扣</small>
</div>
</div>

<!-- 折扣大卡片 -->
<div class="discount-card">
<div class="discount-card-header">
<div>
<span class="group-badge vip">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
VIP 分组
</span>
</div>
<div style="text-align:right;">
<div class="discount-big-number">8折</div>
<div class="discount-percent">节省 20%</div>
</div>
</div>

<!-- 输入价格进度条 -->
<div style="font-size:12px;color:#888;margin-bottom:4px;">输入价格</div>
<div class="bar-container">
<div class="bar-full" style="width: 100%;">
¥0.0300
</div>
<div class="bar-discount green" style="width: 80%;">
¥0.0240
</div>
</div>
<div class="bar-labels">
<span>default 分组基准价</span>
<span style="color:#52c41a;font-weight:500;">VIP 折扣价</span>
</div>

<!-- 输出价格进度条 -->
<div style="font-size:12px;color:#888;margin-bottom:4px;">输出价格</div>
<div class="bar-container">
<div class="bar-full" style="width: 100%;">
¥0.0600
</div>
<div class="bar-discount blue" style="width: 80%;">
¥0.0480
</div>
</div>
<div class="bar-labels">
<span>default 分组基准价</span>
<span style="color:#1890ff;font-weight:500;">VIP 折扣价</span>
</div>

<!-- 价格明细网格 -->
<div class="price-details">
<div class="price-detail-item">
<div class="price-detail-label">输入价格</div>
<div class="price-detail-row">
<span class="price-old">¥0.0300</span>
<span class="price-new">¥0.0240</span>
</div>
<div class="price-detail-unit">/ 1K tokens</div>
</div>
<div class="price-detail-item">
<div class="price-detail-label">输出价格</div>
<div class="price-detail-row">
<span class="price-old">¥0.0600</span>
<span class="price-new">¥0.0480</span>
</div>
<div class="price-detail-unit">/ 1K tokens</div>
</div>
</div>
</div>

<!-- 按次计费示例 -->
<div class="discount-card" style="border-color:#e6f7ff;">
<div class="discount-card-header">
<div>
<span class="group-badge svip">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z"/></svg>
SVIP 分组
</span>
<span style="font-size:11px;color:#999;margin-left:4px;">按次计费示例</span>
</div>
<div style="text-align:right;">
<div class="discount-big-number" style="background:linear-gradient(135deg, #d48806, #ad6800);-webkit-background-clip:text;background-clip:text;">7折</div>
<div class="discount-percent" style="color:#d48806;">节省 30%</div>
</div>
</div>
<div style="font-size:12px;color:#888;margin-bottom:4px;">每次调用价格</div>
<div class="bar-container">
<div class="bar-full" style="width: 100%;">
¥0.0500
</div>
<div class="bar-discount orange" style="width: 70%;">
¥0.0350
</div>
</div>
<div class="bar-labels">
<span>default 分组基准价</span>
<span style="color:#fa8c16;font-weight:500;">SVIP 折扣价</span>
</div>
</div>
</div>

<div class="divider"></div>

<div class="section">
<div class="section-title">
<div class="icon cyan">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
</div>
<div>
<h3>通道价格</h3>
<small>所有支持该模型的通道价格</small>
</div>
</div>
<table class="channel-table">
<thead>
<tr><th>通道</th><th>计费类型</th><th>输入价格</th><th>输出价格</th></tr>
</thead>
<tbody>
<tr>
<td><span class="channel-tag cyan">OpenAI 官方</span><span class="channel-tag amber">默认</span></td>
<td><span class="channel-tag violet">按量计费</span></td>
<td class="price-orange">¥0.0240<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td>
<td class="price-orange">¥0.0480<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td>
</tr>
<tr>
<td><span class="channel-tag cyan">Azure 中转</span><span class="channel-tag green">高速</span></td>
<td><span class="channel-tag violet">按量计费</span></td>
<td class="price-orange">¥0.0280<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td>
<td class="price-orange">¥0.0560<br><span style="font-size:11px;color:#999">/ 1K tokens</span></td>
</tr>
</tbody>
</table>
</div>
</div>

</body>
</html>

+ 1020
- 0
model/channel.go.bak
Разница между файлами не показана из-за своего большого размера
Просмотреть файл


+ 451
- 0
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)
}

Двоичные данные
models-page Просмотреть файл

До После
Ширина: 3200  |  Высота: 1626  |  Размер: 481 KiB

+ 44
- 0
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))
}

+ 166
- 0
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 未设渠道值,应从全局回退")
}

+ 1
- 0
scripts/cache_test.json
Разница между файлами не показана из-за своего большого размера
Просмотреть файл


+ 157
- 0
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

+ 166
- 0
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

+ 10
- 0
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;
"

+ 774
- 0
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()

Двоичные данные
test-after-login.png Просмотреть файл

До После
Ширина: 3200  |  Высота: 1626  |  Размер: 335 KiB

Двоичные данные
test-login.png Просмотреть файл

До После
Ширина: 3200  |  Высота: 1626  |  Размер: 320 KiB

Двоичные данные
test-login2.png Просмотреть файл

До После
Ширина: 3200  |  Высота: 1626  |  Размер: 302 KiB

Двоичные данные
test-page.png Просмотреть файл

До После
Ширина: 3200  |  Высота: 1626  |  Размер: 319 KiB

Двоичные данные
test-screenshot.png Просмотреть файл

До После
Ширина: 1920  |  Высота: 893  |  Размер: 134 KiB

Двоичные данные
test-user-click.png Просмотреть файл

До После
Ширина: 3200  |  Высота: 1626  |  Размер: 105 KiB

Двоичные данные
test-user-page.png Просмотреть файл

До После
Ширина: 3200  |  Высота: 1626  |  Размер: 105 KiB

Двоичные данные
test-user-pricing.png Просмотреть файл

До После
Ширина: 1920  |  Высота: 893  |  Размер: 251 KiB

Двоичные данные
test-users.png Просмотреть файл

До После
Ширина: 3200  |  Высота: 1626  |  Размер: 105 KiB

+ 297
- 0
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 (
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
<div className='flex justify-center items-center py-6'>
<Spin size='small' />
</div>
</Card>
);
}

if (!priceData) return null;

const { logged_in, savings_percent, quota_type } = priceData;

// 未登录:显示简洁原价
if (!logged_in) {
return (
<Card className='!rounded-2xl shadow-sm border-0 mb-4'>
<div className='flex items-center mb-3'>
<Avatar size='small' color='orange' className='mr-2 shadow-md'>
<IconCoinMoneyStroked size={16} />
</Avatar>
<Text className='text-base font-medium'>{t('标准价格')}</Text>
</div>
<div className='price-comparison-simple'>
{quota_type === 0 ? (
<div className='flex flex-wrap gap-6'>
<div>
<span className='text-xs text-gray-500'>{t('输入')}</span>
<div className='font-semibold text-orange-600'>
{formatValue(priceData.original_input)}
</div>
<div className='text-xs text-gray-400'>/ {unitLabel} tokens</div>
</div>
<div>
<span className='text-xs text-gray-500'>{t('输出')}</span>
<div className='font-semibold text-orange-600'>
{formatValue(priceData.original_output)}
</div>
<div className='text-xs text-gray-400'>/ {unitLabel} tokens</div>
</div>
</div>
) : (
<div>
<div className='font-semibold text-orange-600'>
{formatValue(priceData.original_price)}
</div>
<div className='text-xs text-gray-400'>/ {t('次')}</div>
</div>
)}
</div>
</Card>
);
}

// 已登录但无折扣:不显示
if (!savings_percent || savings_percent <= 0) return null;

// 已登录且有折扣:显示方案A划线对比
const discount = priceData.discount || '';

return (
<Card
className='!rounded-2xl shadow-sm border-0 mb-4'
style={{
background: 'linear-gradient(135deg, #fff7e6 0%, #fff 100%)',
border: '1px solid #ffe7ba',
}}
>
<div className='flex items-center justify-between mb-3'>
<div className='flex items-center'>
<Avatar size='small' color='orange' className='mr-2 shadow-md'>
<IconCoinMoneyStroked size={16} />
</Avatar>
<Text className='text-base font-medium'>{t('您的分组价格')}</Text>
</div>
{discount && (
<Tag
style={{
background: 'linear-gradient(135deg, #cf1322, #a8071a)',
color: '#fff',
border: 'none',
fontSize: '12px',
fontWeight: 600,
}}
size='small'
shape='circle'
>
{discount}
</Tag>
)}
</div>

<Tag
color='purple'
size='small'
shape='circle'
className='mb-3'
>
{priceData.group} {t('分组')}
</Tag>

{quota_type === 0 ? (
<>
{/* 输入价格 */}
<div className='mb-3'>
<div className='text-xs text-gray-500 mb-1'>{t('输入价格')}</div>
<div className='flex items-baseline flex-wrap gap-2'>
<span
style={{
textDecoration: 'line-through',
textDecorationColor: '#d48806',
textDecorationThickness: '2px',
color: '#bbb',
fontSize: '14px',
}}
>
{formatValue(priceData.original_input)}
</span>
<span
style={{
fontSize: '20px',
fontWeight: 700,
color: '#cf1322',
}}
>
{formatValue(priceData.user_input)}
</span>
<span className='text-xs text-gray-400'>
/ {unitLabel} tokens
</span>
</div>
<div className='flex items-center gap-1 text-xs text-green-600 mt-1'>
<svg width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2'>
<polyline points='23 6 13.5 15.5 8.5 10.5 1 18' />
<polyline points='17 6 23 6 23 12' />
</svg>
{t('每')} {unitLabel} tokens {t('节省')} {formatSavings(priceData.original_input, priceData.user_input)}
</div>
</div>

{/* 输出价格 */}
<div>
<div className='text-xs text-gray-500 mb-1'>{t('输出价格')}</div>
<div className='flex items-baseline flex-wrap gap-2'>
<span
style={{
textDecoration: 'line-through',
textDecorationColor: '#d48806',
textDecorationThickness: '2px',
color: '#bbb',
fontSize: '14px',
}}
>
{formatValue(priceData.original_output)}
</span>
<span
style={{
fontSize: '20px',
fontWeight: 700,
color: '#cf1322',
}}
>
{formatValue(priceData.user_output)}
</span>
<span className='text-xs text-gray-400'>
/ {unitLabel} tokens
</span>
</div>
<div className='flex items-center gap-1 text-xs text-green-600 mt-1'>
<svg width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2'>
<polyline points='23 6 13.5 15.5 8.5 10.5 1 18' />
<polyline points='17 6 23 6 23 12' />
</svg>
{t('每')} {unitLabel} tokens {t('节省')} {formatSavings(priceData.original_output, priceData.user_output)}
</div>
</div>
</>
) : (
<>
{/* 按次计费 */}
<div>
<div className='text-xs text-gray-500 mb-1'>{t('每次调用')}</div>
<div className='flex items-baseline flex-wrap gap-2'>
<span
style={{
textDecoration: 'line-through',
textDecorationColor: '#d48806',
textDecorationThickness: '2px',
color: '#bbb',
fontSize: '14px',
}}
>
{formatValue(priceData.original_price)}
</span>
<span
style={{
fontSize: '20px',
fontWeight: 700,
color: '#cf1322',
}}
>
{formatValue(priceData.user_price)}
</span>
<span className='text-xs text-gray-400'>/ {t('次')}</span>
</div>
<div className='flex items-center gap-1 text-xs text-green-600 mt-1'>
<svg width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2'>
<polyline points='23 6 13.5 15.5 8.5 10.5 1 18' />
<polyline points='17 6 23 6 23 12' />
</svg>
{t('每次调用节省')} {formatSavings(priceData.original_price, priceData.user_price)}
</div>
</div>
</>
)}
</Card>
);
};

export default UserPriceComparison;

+ 2
- 0
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('类型'),


+ 2
- 1
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 {


+ 3
- 2
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(


Загрузка…
Отмена
Сохранить