Quellcode durchsuchen

refactor: remove user-channel-ratio feature

删除用户渠道倍率(UserChannelRatio)功能,清理全链路相关代码:
后端删除 model/controller/router 层实现,前端删除 UI 组件和计费展示逻辑。
同时恢复 Token 分组选择和价格侧边栏分组过滤器的注释代码。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
master
fengsilin vor 1 Woche
Ursprung
Commit
87a0f94975
22 geänderte Dateien mit 91 neuen und 977 gelöschten Zeilen
  1. +3
    -4
      controller/pricing.go
  2. +1
    -128
      controller/pricing_user_test.go
  3. +0
    -2
      controller/relay.go
  4. +0
    -116
      controller/user_channel_ratio.go
  5. +60
    -26
      docs/superpowers/specs/2026-06-02-overseas-user-migration-design.md
  6. +1
    -4
      model/main.go
  7. +0
    -129
      model/user_channel_ratio.go
  8. +0
    -105
      model/user_channel_ratio_test.go
  9. +2
    -2
      relay/compatible_handler.go
  10. +0
    -31
      relay/helper/price.go
  11. +0
    -35
      relay/helper/price_test.go
  12. +0
    -10
      router/api-router.go
  13. +0
    -3
      service/log_info_generate.go
  14. +4
    -9
      service/quota.go
  15. +1
    -2
      types/price_data.go
  16. +2
    -2
      web/src/components/table/model-pricing/layout/PricingSidebar.jsx
  17. +5
    -5
      web/src/components/table/tokens/modals/EditTokenModal.jsx
  18. +0
    -7
      web/src/components/table/users/modals/EditUserModal.jsx
  19. +0
    -241
      web/src/components/table/users/modals/UserRatioSection.jsx
  20. +8
    -22
      web/src/helpers/render.jsx
  21. +0
    -10
      web/src/hooks/usage-logs/useUsageLogsData.jsx
  22. +4
    -84
      web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx

+ 3
- 4
controller/pricing.go Datei anzeigen

@@ -115,14 +115,14 @@ func GetUserPricing(c *gin.Context) {
return
}

loggedIn, userId, userGroup, groupRatio := resolveCurrentUser(c)
loggedIn, _, userGroup, groupRatio := resolveCurrentUser(c)
if !loggedIn {
respondOriginalPrice(c, pricingData)
return
}

bestUserChannelRatio := model.GetBestUserChannelRatio(userId, modelName)
totalRatio := groupRatio * bestUserChannelRatio
totalRatio := groupRatio
savingsPercent := int(math.Round((1 - totalRatio) * 100))

result := gin.H{
@@ -131,7 +131,6 @@ func GetUserPricing(c *gin.Context) {
"quota_type": pricingData.QuotaType,
"group": userGroup,
"group_ratio": groupRatio,
"user_channel_ratio": bestUserChannelRatio,
"savings_percent": savingsPercent,
"logged_in": true,
}


+ 1
- 128
controller/pricing_user_test.go Datei anzeigen

@@ -29,7 +29,7 @@ func setupPricingTestDB(t *testing.T) *gorm.DB {
common.UsingSQLite = true
common.RedisEnabled = false

require.NoError(t, db.AutoMigrate(&model.UserChannelRatio{}, &model.User{}))
require.NoError(t, db.AutoMigrate(&model.User{}))

t.Cleanup(func() {
model.DB = origDB
@@ -226,94 +226,7 @@ func TestGetUserPricing_LoggedIn_GroupDiscount(t *testing.T) {
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) {
@@ -368,43 +281,3 @@ func TestFormatDiscount(t *testing.T) {
}
}

// TestGetUserPricing_MultipleUserChannelRatios 多个渠道倍率取最低值
func TestGetUserPricing_MultipleUserChannelRatios(t *testing.T) {
db := setupPricingTestDB(t)

setPricingCache([]model.Pricing{
{
ModelName: "gpt-4o",
QuotaType: 0,
ModelRatio: 15,
CompletionRatio: 4,
EnableGroup: []string{"default"},
},
})

user := &model.User{Id: 600, Group: "default", Username: "multichannel", Status: 1}
require.NoError(t, db.Create(user).Error)

// 多个渠道倍率,取最低值 0.7
for i, ratio := range []float64{0.9, 0.7, 0.8} {
ucr := &model.UserChannelRatio{
UserId: 600,
ModelName: "gpt-4o",
ChannelId: i + 1,
Ratio: ratio,
}
require.NoError(t, ucr.Insert())
}

router := setupAuthRouter(600)
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/pricing/user/gpt-4o", nil)
router.ServeHTTP(w, req)

var resp map[string]interface{}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
assert.True(t, resp["success"].(bool))
// 应取最低倍率 0.7
assert.Equal(t, float64(0.7), resp["user_channel_ratio"])
assert.Equal(t, float64(30), resp["savings_percent"])
}

+ 0
- 2
controller/relay.go Datei anzeigen

@@ -204,8 +204,6 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
break
}

// 在渠道选择后更新价格数据以使用渠道定价
helper.UpdatePriceDataForChannelPricing(c, relayInfo, channel.Id)

addUsedChannel(c, channel.Id)
bodyStorage, bodyErr := common.GetBodyStorage(c)


+ 0
- 116
controller/user_channel_ratio.go Datei anzeigen

@@ -1,116 +0,0 @@
package controller

import (
"net/http"
"strconv"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/model"

"github.com/gin-gonic/gin"
)

func GetUserChannelRatios(c *gin.Context) {
userIdStr := c.Param("user_id")
userId, err := strconv.Atoi(userIdStr)
if err != nil {
common.ApiError(c, err)
return
}

ratios, err := model.GetUserChannelRatiosByUserId(userId)
if err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": ratios,
})
}

type createUserChannelRatioRequest struct {
UserId int `json:"user_id"`
ModelName string `json:"model_name"`
ChannelId int `json:"channel_id"`
Ratio float64 `json:"ratio"`
}

func CreateUserChannelRatio(c *gin.Context) {
var req createUserChannelRatioRequest
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}

if req.Ratio <= 0 {
common.ApiErrorMsg(c, "ratio must be positive")
return
}

ucr := &model.UserChannelRatio{
UserId: req.UserId,
ModelName: req.ModelName,
ChannelId: req.ChannelId,
Ratio: req.Ratio,
}
if err := ucr.Insert(); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": ucr,
})
}

func UpdateUserChannelRatio(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}

var req struct {
Ratio float64 `json:"ratio"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.ApiError(c, err)
return
}
if req.Ratio <= 0 {
common.ApiErrorMsg(c, "ratio must be positive")
return
}

ucr := &model.UserChannelRatio{
Id: id,
Ratio: req.Ratio,
}
if err := ucr.Update(); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": ucr,
})
}

func DeleteUserChannelRatio(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.Atoi(idStr)
if err != nil {
common.ApiError(c, err)
return
}

if err := model.DeleteUserChannelRatioById(id); err != nil {
common.ApiError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
})
}

+ 60
- 26
docs/superpowers/specs/2026-06-02-overseas-user-migration-design.md Datei anzeigen

@@ -324,23 +324,43 @@

- 无冲突:推荐 `create_new`
- 仅 `aff_code` 冲突:推荐 `create_new`
- 同邮箱且无其他矛盾:推荐 `merge_into_existing`
- 同邮箱且无其他矛盾、且目标国内用户尚未在海外存在其他 `synced` 副本:推荐 `merge_into_existing`
- 任意 OAuth 多对一、一对多关系:强制人工处理

---

## 执行一致性约束

- 预检查快照只用于冲突决策和界面展示,不作为最终额度结算依据。
- 执行阶段必须重新拉取海外用户最新快照,并以该快照中的最终额度为准。
- V1 版本要求在运维冻结窗口内执行批次。冻结窗口至少要覆盖待迁用户的充值、兑换码、额度转移和新请求入口,并等待已在途请求自然结束。
- 如果执行时最新快照与预检查结论发生冲突性漂移(例如邮箱归属变化、OAuth 绑定目标变化、原本计划合并的国内账号已存在其他海外 `synced` 副本),该条目必须失败并要求重新扫描,不得静默沿用旧决议。

---

## 单用户迁移执行流程

执行顺序不能改变,必须先构建国内主账号,再将海外原账号降级。
执行顺序不能改变,必须先构建国内主账号,再将海外原账号降级。执行阶段读取的“最新快照”必须发生在冻结窗口内。

### `create_new`

1. 从海外拉取该用户最新快照
1. 在冻结窗口内,从海外拉取该用户最新快照
2. 在国内开启事务
3. 通过“按哈希导入”的专用逻辑创建国内主账号
- 不能走普通 `Insert()`
- 否则密码会再次哈希
4. 复制主字段:
4. 在同一个国内事务中完成以下动作:
- 创建国内主账号
- 复制主字段
- 复制内置 OAuth 字段
- 复制 `user_oauth_bindings`
- 处理 `aff_code` 冲突
- 回写 `user_migration_items.target_user_id`
5. 如在执行阶段遇到新的唯一键冲突:
- 直接将该迁移项标记为失败
- 要求重新扫描或人工修正
- 不允许按用户名/邮箱自动认领为“上次已创建成功”的账号
6. 复制主字段:
- `username`
- `password`
- `email`
@@ -350,31 +370,35 @@
- `group`
- `created_at`
- `setting`
5. 复制内置 OAuth ID 字段
6. 复制 `user_oauth_bindings`
7. 复制内置 OAuth ID 字段
8. 复制 `user_oauth_bindings`
- 先通过 `slug` 将海外 `provider_id` 映射为国内 `provider_id`
7. 处理邀请码
9. 处理邀请码
- 如冲突则生成新的 `aff_code`
8. 将国内额度设置为海外当前最终额度
9. 国内事务提交,得到 `cn_user_id`
10. 调海外内部接口,将原海外用户转换为 `synced`
- `source = synced`
- `remote_user_id = cn_user_id`
- `synced_quota = 当前额度`
- `last_sync_at = now`
11. 更新迁移项为成功
10. 将国内额度设置为“执行时最新快照中的最终额度”
11. 国内事务提交,得到 `cn_user_id`
12. 调海外内部接口,将原海外用户转换为 `synced`
- `source = synced`
- `remote_user_id = cn_user_id`
- `synced_quota = 国内提交后的最终额度`
- `last_sync_at = now`
13. 更新迁移项为成功

### `merge_into_existing`

1. 锁定目标国内账号
2. 重新校验该用户仍应合并到这个目标
3. 补齐国内为空、海外有值的字段
4. 补齐国内缺失的 OAuth 绑定
5. 额度合并规则:
- `国内最终额度 = 国内原额度 + 海外当前额度`
6. 提交国内事务
7. 调海外接口将原账号转换为 `synced`
8. 更新迁移项为成功
1. 在冻结窗口内拉取该用户最新快照
2. 锁定目标国内账号
3. 重新校验该用户仍应合并到这个目标
- 同邮箱或唯一 OAuth 目标仍然成立
- 目标国内账号在海外不存在其他 `synced` 副本
4. 补齐国内为空、海外有值的字段
5. 补齐国内缺失的 OAuth 绑定
6. 额度合并规则:
- `国内最终额度 = 国内原额度 + 执行时最新海外额度`
- 通过 `migration_token` 保证每个迁移项最多叠加一次
7. 提交国内事务
8. 调海外接口将原账号转换为 `synced`
9. 更新迁移项为成功

### 为什么不删除海外原用户

@@ -393,8 +417,11 @@

- `GET /api/internal/migration/users`
- 分页返回候选用户快照
- 仅返回 `source=local` 的海外用户
- `GET /api/internal/migration/users/:id`
- 返回单用户最新快照
- `GET /api/internal/migration/users/check-synced-copy?cn_user_id=:id`
- 检查某个国内用户是否已在海外存在 `synced` 副本
- `POST /api/internal/migration/users/:id/convert-to-synced`
- 将海外原用户降级为 `synced`

@@ -406,6 +433,7 @@
- 用户存在
- 用户尚未迁移,或已迁移且目标一致
- `remote_user_id` 不为空
- 如果用户已是 `synced` 但 `remote_user_id` 与请求不一致,返回冲突错误

幂等语义:

@@ -486,6 +514,7 @@
- 触发预检查
- `POST /api/user-migrations/items/:id/resolve`
- 设置处理策略
- 成功后重新统计批次状态;若所有项都已变为 `ready` 或 `skipped`,批次自动转为 `ready`
- `POST /api/user-migrations/batches/:id/execute`
- 执行迁移
- `POST /api/user-migrations/batches/:id/retry`
@@ -537,9 +566,9 @@

## 冻结窗口设计

本次迁移不要求全站停机,但建议对“待迁批次中的海外用户”做短冻结窗口
本次迁移不要求全站停机,但 V1 版本将“冻结窗口”定义为执行前的强制运维前置条件,而不是可选建议

冻结范围建议仅限该批次用户:
冻结范围仅限该批次用户:

- 禁止充值
- 禁止兑换码
@@ -547,6 +576,11 @@
- 禁止发起新请求
- 等待已在途请求自然结束

执行要求:

- root 操作员在点击“开始迁移”前确认冻结窗口已经生效
- 批次执行期间如果发现冻结条件被破坏,应立即停止后续条目并重新扫描

目的:

- 稳定海外最终额度


+ 1
- 4
model/main.go Datei anzeigen

@@ -207,8 +207,7 @@ func InitDB() (err error) {
return err
}
LoadEmailQuotaCache()
LoadUserChannelRatioCache()
return nil
return nil
} else {
common.FatalLog(err)
}
@@ -283,7 +282,6 @@ func migrateDB() error {
&SubscriptionPreConsumeRecord{},
&CustomOAuthProvider{},
&UserOAuthBinding{},
&UserChannelRatio{},
&PendingSyncRecord{},
&QuotaSyncLog{},
&EmailQuotaRule{},
@@ -349,7 +347,6 @@ func migrateDBFast() error {
{&SubscriptionPreConsumeRecord{}, "SubscriptionPreConsumeRecord"},
{&CustomOAuthProvider{}, "CustomOAuthProvider"},
{&UserOAuthBinding{}, "UserOAuthBinding"},
{&UserChannelRatio{}, "UserChannelRatio"},
{&PendingSyncRecord{}, "PendingSyncRecord"},
{&QuotaSyncLog{}, "QuotaSyncLog"},
{&EmailQuotaRule{}, "EmailQuotaRule"},


+ 0
- 129
model/user_channel_ratio.go Datei anzeigen

@@ -1,129 +0,0 @@
package model

import (
"strconv"
"strings"
"sync"

"github.com/QuantumNous/new-api/common"
)

var (
userChannelRatioCache = make(map[string]float64) // key: "userId:modelName:channelId" -> ratio
userChannelRatioCacheLock sync.RWMutex
)

// UserChannelRatio 用户-模型-渠道倍率表
type UserChannelRatio struct {
Id int `json:"id" gorm:"primaryKey"`
UserId int `json:"user_id" gorm:"not null;uniqueIndex:idx_user_model_channel,priority:1"`
ModelName string `json:"model_name" gorm:"size:128;not null;uniqueIndex:idx_user_model_channel,priority:2"`
ChannelId int `json:"channel_id" gorm:"not null;uniqueIndex:idx_user_model_channel,priority:3"`
Ratio float64 `json:"ratio" gorm:"default:1"`
CreatedAt int64 `json:"created_at" gorm:"bigint"`
UpdatedAt int64 `json:"updated_at" gorm:"bigint"`
}

func getUserChannelRatioCacheKey(userId int, modelName string, channelId int) string {
return strconv.Itoa(userId) + ":" + modelName + ":" + strconv.Itoa(channelId)
}

func setUserChannelRatioCache(key string, ratio float64) {
userChannelRatioCacheLock.Lock()
userChannelRatioCache[key] = ratio
userChannelRatioCacheLock.Unlock()
}

func removeUserChannelRatioCache(key string) {
userChannelRatioCacheLock.Lock()
delete(userChannelRatioCache, key)
userChannelRatioCacheLock.Unlock()
}

// GetUserChannelRatio 获取用户在指定模型+渠道的倍率(纯内存读)
// 未命中返回 1.0(不影响计费)
func GetUserChannelRatio(userId int, modelName string, channelId int) float64 {
key := getUserChannelRatioCacheKey(userId, modelName, channelId)
userChannelRatioCacheLock.RLock()
ratio, ok := userChannelRatioCache[key]
userChannelRatioCacheLock.RUnlock()
if !ok {
return 1.0
}
return ratio
}

// LoadUserChannelRatioCache 全量加载到内存(启动时调用)
func LoadUserChannelRatioCache() {
var records []*UserChannelRatio
if err := DB.Find(&records).Error; err != nil {
common.SysError("[UserChannelRatio] LoadCache failed: " + err.Error())
return
}
userChannelRatioCacheLock.Lock()
userChannelRatioCache = make(map[string]float64, len(records))
for _, r := range records {
key := getUserChannelRatioCacheKey(r.UserId, r.ModelName, r.ChannelId)
userChannelRatioCache[key] = r.Ratio
}
userChannelRatioCacheLock.Unlock()
common.SysLog("[UserChannelRatio] cache loaded " + strconv.Itoa(len(records)) + " records")
}

func (ucr *UserChannelRatio) Insert() error {
ucr.CreatedAt = common.GetTimestamp()
ucr.UpdatedAt = common.GetTimestamp()
err := DB.Create(ucr).Error
if err == nil {
setUserChannelRatioCache(getUserChannelRatioCacheKey(ucr.UserId, ucr.ModelName, ucr.ChannelId), ucr.Ratio)
}
return err
}

func (ucr *UserChannelRatio) Update() error {
var existing UserChannelRatio
if err := DB.First(&existing, ucr.Id).Error; err != nil {
return err
}
ucr.UpdatedAt = common.GetTimestamp()
err := DB.Model(&UserChannelRatio{}).Where("id = ?", ucr.Id).
Select("ratio", "updated_at").
Updates(ucr).Error
if err == nil {
setUserChannelRatioCache(getUserChannelRatioCacheKey(existing.UserId, existing.ModelName, existing.ChannelId), ucr.Ratio)
}
return err
}

func DeleteUserChannelRatioById(id int) error {
var existing UserChannelRatio
if err := DB.First(&existing, id).Error; err != nil {
return err
}
err := DB.Delete(&existing).Error
if err == nil {
removeUserChannelRatioCache(getUserChannelRatioCacheKey(existing.UserId, existing.ModelName, existing.ChannelId))
}
return err
}

// GetUserChannelRatiosByUserId 获取指定用户的所有倍率记录
func GetUserChannelRatiosByUserId(userId int) ([]*UserChannelRatio, error) {
var list []*UserChannelRatio
err := DB.Where("user_id = ?", userId).Find(&list).Error
return list, err
}

// GetBestUserChannelRatio 从内存缓存中查找指定用户+模型的最低渠道倍率(未命中返回 1.0)
func GetBestUserChannelRatio(userId int, modelName string) float64 {
best := 1.0
prefix := strconv.Itoa(userId) + ":" + modelName + ":"
userChannelRatioCacheLock.RLock()
for key, ratio := range userChannelRatioCache {
if len(key) > len(prefix) && strings.HasPrefix(key, prefix) && ratio < best {
best = ratio
}
}
userChannelRatioCacheLock.RUnlock()
return best
}

+ 0
- 105
model/user_channel_ratio_test.go Datei anzeigen

@@ -1,105 +0,0 @@
package model

import (
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)

func setupUserChannelRatioDB(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 := DB
DB = db
common.UsingSQLite = true
common.RedisEnabled = false

require.NoError(t, db.AutoMigrate(&UserChannelRatio{}))

t.Cleanup(func() {
DB = origDB
sqlDB.Close()
})
return db
}

func TestUserChannelRatioCRUD(t *testing.T) {
setupUserChannelRatioDB(t)
LoadUserChannelRatioCache()

ucr := &UserChannelRatio{
UserId: 1,
ModelName: "gpt-4o",
ChannelId: 5,
Ratio: 0.8,
}
err := ucr.Insert()
require.NoError(t, err)
assert.True(t, ucr.Id > 0)

ratio := GetUserChannelRatio(1, "gpt-4o", 5)
assert.Equal(t, 0.8, ratio)

ratio = GetUserChannelRatio(999, "nonexistent", 999)
assert.Equal(t, 1.0, ratio)

ucr.Ratio = 1.2
err = ucr.Update()
require.NoError(t, err)
ratio = GetUserChannelRatio(1, "gpt-4o", 5)
assert.Equal(t, 1.2, ratio)

list, err := GetUserChannelRatiosByUserId(1)
require.NoError(t, err)
assert.Len(t, list, 1)
assert.Equal(t, "gpt-4o", list[0].ModelName)

err = DeleteUserChannelRatioById(ucr.Id)
require.NoError(t, err)
ratio = GetUserChannelRatio(1, "gpt-4o", 5)
assert.Equal(t, 1.0, ratio)

list, err = GetUserChannelRatiosByUserId(1)
require.NoError(t, err)
assert.Len(t, list, 0)
}

func TestUserChannelRatioUniqueConstraint(t *testing.T) {
setupUserChannelRatioDB(t)
LoadUserChannelRatioCache()

ucr1 := &UserChannelRatio{UserId: 1, ModelName: "gpt-4o", ChannelId: 5, Ratio: 0.8}
err := ucr1.Insert()
require.NoError(t, err)

ucr2 := &UserChannelRatio{UserId: 1, ModelName: "gpt-4o", ChannelId: 5, Ratio: 1.0}
err = ucr2.Insert()
assert.Error(t, err)

ucr3 := &UserChannelRatio{UserId: 1, ModelName: "gpt-4o", ChannelId: 6, Ratio: 1.5}
err = ucr3.Insert()
assert.NoError(t, err)
}

func TestLoadUserChannelRatioCache(t *testing.T) {
setupUserChannelRatioDB(t)

ucr1 := &UserChannelRatio{UserId: 1, ModelName: "gpt-4o", ChannelId: 5, Ratio: 0.8}
ucr2 := &UserChannelRatio{UserId: 2, ModelName: "claude-3", ChannelId: 10, Ratio: 1.5}
require.NoError(t, ucr1.Insert())
require.NoError(t, ucr2.Insert())

LoadUserChannelRatioCache()

assert.Equal(t, 0.8, GetUserChannelRatio(1, "gpt-4o", 5))
assert.Equal(t, 1.5, GetUserChannelRatio(2, "claude-3", 10))
assert.Equal(t, 1.0, GetUserChannelRatio(3, "nonexistent", 99))
}

+ 2
- 2
relay/compatible_handler.go Datei anzeigen

@@ -287,7 +287,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
dCachedCreationRatio := decimal.NewFromFloat(cachedCreationRatio)
dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)

ratio := dModelRatio.Mul(dGroupRatio).Mul(decimal.NewFromFloat(relayInfo.PriceData.UserChannelRatio))
ratio := dModelRatio.Mul(dGroupRatio)

// openai web search 工具计费
var dWebSearchQuota decimal.Decimal
@@ -401,7 +401,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage
quotaCalculateDecimal = decimal.NewFromInt(1)
}
} else {
quotaCalculateDecimal = dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio).Mul(decimal.NewFromFloat(relayInfo.PriceData.UserChannelRatio))
quotaCalculateDecimal = dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio)
}
// 添加 responses tools call 调用的配额
quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota)


+ 0
- 31
relay/helper/price.go Datei anzeigen

@@ -4,9 +4,7 @@ import (
"fmt"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
@@ -116,7 +114,6 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
CacheCreationRatio: cacheCreationRatio,
CacheCreation5mRatio: cacheCreationRatio,
CacheCreation1hRatio: cacheCreationRatio * types.ClaudeCacheCreation1hMultiplier,
UserChannelRatio: 1.0,
}

if common.DebugEnabled {
@@ -154,7 +151,6 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) types.
ModelPrice: modelPrice,
Quota: quota,
GroupRatioInfo: groupRatioInfo,
UserChannelRatio: 1.0,
}
return priceData
}
@@ -170,30 +166,3 @@ func ContainPriceOrRatio(modelName string) bool {
}
return false
}

// UpdatePriceDataForChannelPricing 保留原调用点,仅在选定渠道后应用用户渠道倍率。
func UpdatePriceDataForChannelPricing(c *gin.Context, info *relaycommon.RelayInfo, channelId int) {
if info == nil || channelId <= 0 {
return
}
ApplyUserChannelRatio(c, info, channelId)
}

// ApplyUserChannelRatio 在渠道选定后应用用户倍率
func ApplyUserChannelRatio(c *gin.Context, info *relaycommon.RelayInfo, channelId int) {
if info == nil || channelId <= 0 {
return
}
userId := common.GetContextKeyInt(c, constant.ContextKeyUserId)
if userId <= 0 {
return
}
userRatio := model.GetUserChannelRatio(userId, info.OriginModelName, channelId)
if userRatio == 1.0 {
return
}
info.PriceData.UserChannelRatio = userRatio
if info.PriceData.QuotaToPreConsume > 0 {
info.PriceData.QuotaToPreConsume = int(float64(info.PriceData.QuotaToPreConsume) * userRatio)
}
}

+ 0
- 35
relay/helper/price_test.go Datei anzeigen

@@ -6,7 +6,6 @@ import (
"testing"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/dto"
"github.com/QuantumNous/new-api/model"
relaycommon "github.com/QuantumNous/new-api/relay/common"
@@ -39,8 +38,6 @@ func setupUsePriceSwitchTest(t *testing.T) {
common.UsingSQLite = true
common.RedisEnabled = false

require.NoError(t, db.AutoMigrate(&model.UserChannelRatio{}))

require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{"`+testModelUsePriceSwitch+`":0.5}`))
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"`+testModelUsePriceSwitch+`":15}`))
require.NoError(t, ratio_setting.UpdateCompletionRatioByJSONString(`{"`+testModelUsePriceSwitch+`":3}`))
@@ -119,38 +116,6 @@ func TestGlobalRatiosFilledWhenUseRatioMode(t *testing.T) {
assert.Equal(t, 2.0, priceData.ImageRatio)
}

func TestUpdatePriceDataForChannelPricingOnlyAppliesUserChannelRatio(t *testing.T) {
setupUsePriceSwitchTest(t)

ratio := &model.UserChannelRatio{
UserId: 9,
ModelName: testModelUsePriceSwitch,
ChannelId: 9901,
Ratio: 0.8,
}
require.NoError(t, ratio.Insert())

c := buildTestContext(t)
c.Set(string(constant.ContextKeyUserId), 9)

info := &relaycommon.RelayInfo{
OriginModelName: testModelUsePriceSwitch,
PriceData: types.PriceData{
ModelPrice: 0.5,
UsePrice: true,
QuotaToPreConsume: 1000,
UserChannelRatio: 1.0,
},
}

UpdatePriceDataForChannelPricing(c, info, 9901)

assert.Equal(t, 0.8, info.PriceData.UserChannelRatio)
assert.Equal(t, 800, info.PriceData.QuotaToPreConsume)
assert.Equal(t, 0.5, info.PriceData.ModelPrice)
}

// --- HandleGroupRatio tests ---

func TestHandleGroupRatio_DefaultGroup(t *testing.T) {
setupUsePriceSwitchTest(t)


+ 0
- 10
router/api-router.go Datei anzeigen

@@ -191,16 +191,6 @@ func SetApiRouter(router *gin.Engine) {
optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除
}

// 用户倍率路由(管理员权限)
userChannelRatioRoute := apiRouter.Group("/user_channel_ratio")
userChannelRatioRoute.Use(middleware.AdminAuth())
{
userChannelRatioRoute.GET("/:user_id", controller.GetUserChannelRatios)
userChannelRatioRoute.POST("/", controller.CreateUserChannelRatio)
userChannelRatioRoute.PUT("/:id", controller.UpdateUserChannelRatio)
userChannelRatioRoute.DELETE("/:id", controller.DeleteUserChannelRatio)
}

// 邮箱后缀额度规则路由(管理员权限)
emailQuotaRuleRoute := apiRouter.Group("/email_quota_rule")
emailQuotaRuleRoute.Use(middleware.AdminAuth())


+ 0
- 3
service/log_info_generate.go Datei anzeigen

@@ -41,9 +41,6 @@ func GenerateTextOtherInfo(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, m
other["cache_ratio"] = cacheRatio
other["model_price"] = modelPrice
other["user_group_ratio"] = userGroupRatio
if relayInfo != nil && relayInfo.PriceData.UserChannelRatio != 1.0 {
other["user_channel_ratio"] = relayInfo.PriceData.UserChannelRatio
}
other["frt"] = float64(relayInfo.FirstResponseTime.UnixMilli() - relayInfo.StartTime.UnixMilli())
if relayInfo.ReasoningEffort != "" {
other["reasoning_effort"] = relayInfo.ReasoningEffort


+ 4
- 9
service/quota.go Datei anzeigen

@@ -37,7 +37,6 @@ type QuotaInfo struct {
ModelPrice float64
ModelRatio float64
GroupRatio float64
UserChannelRatio float64
}

func hasCustomModelRatio(modelName string, currentRatio float64) bool {
@@ -54,7 +53,7 @@ func calculateAudioQuota(info QuotaInfo) int {
quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit)
groupRatio := decimal.NewFromFloat(info.GroupRatio)

quota := modelPrice.Mul(quotaPerUnit).Mul(groupRatio).Mul(decimal.NewFromFloat(info.UserChannelRatio))
quota := modelPrice.Mul(quotaPerUnit).Mul(groupRatio)
return int(quota.IntPart())
}

@@ -64,7 +63,7 @@ func calculateAudioQuota(info QuotaInfo) int {

groupRatio := decimal.NewFromFloat(info.GroupRatio)
modelRatio := decimal.NewFromFloat(info.ModelRatio)
ratio := groupRatio.Mul(modelRatio).Mul(decimal.NewFromFloat(info.UserChannelRatio))
ratio := groupRatio.Mul(modelRatio)

inputTextTokens := decimal.NewFromInt(int64(info.InputDetails.TextTokens))
outputTextTokens := decimal.NewFromInt(int64(info.OutputDetails.TextTokens))
@@ -135,7 +134,6 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag
UsePrice: relayInfo.UsePrice,
ModelRatio: modelRatio,
GroupRatio: actualGroupRatio,
UserChannelRatio: relayInfo.PriceData.UserChannelRatio,
}

quota := calculateAudioQuota(quotaInfo)
@@ -189,7 +187,6 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod
UsePrice: usePrice,
ModelRatio: modelRatio,
GroupRatio: groupRatio,
UserChannelRatio: relayInfo.PriceData.UserChannelRatio,
}

quota := calculateAudioQuota(quotaInfo)
@@ -252,7 +249,6 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
completionRatio := relayInfo.PriceData.CompletionRatio
modelRatio := relayInfo.PriceData.ModelRatio
groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio
userChannelRatio := relayInfo.PriceData.UserChannelRatio
modelPrice := relayInfo.PriceData.ModelPrice
cacheRatio := relayInfo.PriceData.CacheRatio
cacheTokens := usage.PromptTokensDetails.CachedTokens
@@ -287,9 +283,9 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
calculateQuota += float64(remainingCacheCreationTokens) * cacheCreationRatio
}
calculateQuota += float64(completionTokens) * completionRatio
calculateQuota = calculateQuota * groupRatio * modelRatio * userChannelRatio
calculateQuota = calculateQuota * groupRatio * modelRatio
} else {
calculateQuota = modelPrice * common.QuotaPerUnit * groupRatio * userChannelRatio
calculateQuota = modelPrice * common.QuotaPerUnit * groupRatio
}

if modelRatio != 0 && calculateQuota <= 0 {
@@ -394,7 +390,6 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u
UsePrice: usePrice,
ModelRatio: modelRatio,
GroupRatio: groupRatio,
UserChannelRatio: relayInfo.PriceData.UserChannelRatio,
}

quota := calculateAudioQuota(quotaInfo)


+ 1
- 2
types/price_data.go Datei anzeigen

@@ -29,7 +29,6 @@ type PriceData struct {
Quota int // 按次计费的最终额度(MJ / Task)
QuotaToPreConsume int // 按量计费的预消耗额度
GroupRatioInfo GroupRatioInfo
UserChannelRatio float64 // 用户-模型-渠道倍率(默认 1.0)
}

// ApplyChannelPricingRatios 将渠道定价的扩展比率应用到 PriceData(非零值覆盖)
@@ -65,5 +64,5 @@ func (p *PriceData) AddOtherRatio(key string, ratio float64) {
}

func (p *PriceData) ToSetting() string {
return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, CacheCreation5mRatio: %f, CacheCreation1hRatio: %f, QuotaToPreConsume: %d, ImageRatio: %f, AudioRatio: %f, AudioCompletionRatio: %f, UserChannelRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.CacheCreation5mRatio, p.CacheCreation1hRatio, p.QuotaToPreConsume, p.ImageRatio, p.AudioRatio, p.AudioCompletionRatio, p.UserChannelRatio)
return fmt.Sprintf("ModelPrice: %f, ModelRatio: %f, CompletionRatio: %f, CacheRatio: %f, GroupRatio: %f, UsePrice: %t, CacheCreationRatio: %f, CacheCreation5mRatio: %f, CacheCreation1hRatio: %f, QuotaToPreConsume: %d, ImageRatio: %f, AudioRatio: %f, AudioCompletionRatio: %f", p.ModelPrice, p.ModelRatio, p.CompletionRatio, p.CacheRatio, p.GroupRatioInfo.GroupRatio, p.UsePrice, p.CacheCreationRatio, p.CacheCreation5mRatio, p.CacheCreation1hRatio, p.QuotaToPreConsume, p.ImageRatio, p.AudioRatio, p.AudioCompletionRatio)
}

+ 2
- 2
web/src/components/table/model-pricing/layout/PricingSidebar.jsx Datei anzeigen

@@ -122,7 +122,7 @@ const PricingSidebar = ({
t={t}
/> */}

{/* <PricingGroups
<PricingGroups
filterGroup={filterGroup}
setFilterGroup={handleGroupClick}
usableGroup={categoryProps.usableGroup}
@@ -130,7 +130,7 @@ const PricingSidebar = ({
models={groupCountModels}
loading={loading}
t={t}
/> */}
/>

<PricingQuotaTypes
filterQuotaType={filterQuotaType}


+ 5
- 5
web/src/components/table/tokens/modals/EditTokenModal.jsx Datei anzeigen

@@ -143,9 +143,9 @@ const EditTokenModal = (props) => {
}
}
setGroups(localGroupOptions);
// if (statusState?.status?.default_use_auto_group && formApiRef.current) {
// formApiRef.current.setValue('group', 'auto');
// }
if (statusState?.status?.default_use_auto_group && formApiRef.current) {
formApiRef.current.setValue('group', 'auto');
}
} else {
showError(t(message));
}
@@ -360,7 +360,7 @@ const EditTokenModal = (props) => {
showClear
/>
</Col>
{/* <Col span={24}>
<Col span={24}>
{groups.length > 0 ? (
<Form.Select
field='group'
@@ -394,7 +394,7 @@ const EditTokenModal = (props) => {
'开启后,当前分组渠道失败时会按顺序尝试下一个分组的渠道',
)}
/>
</Col> */}
</Col>
<Col xs={24} sm={24} md={24} lg={10} xl={10}>
<Form.DatePicker
field='expired_time'


+ 0
- 7
web/src/components/table/users/modals/EditUserModal.jsx Datei anzeigen

@@ -56,7 +56,6 @@ import {
IconPlus,
} from '@douyinfe/semi-icons';
import UserBindingManagementModal from './UserBindingManagementModal';
import UserRatioSection from './UserRatioSection';
import UserModelRateLimitSection from './UserModelRateLimitSection';

const { Text, Title } = Typography;
@@ -345,12 +344,6 @@ const EditUserModal = (props) => {
)}


{/* 倍率设置 */}
{userId && (
<Card className='!rounded-2xl shadow-sm border-0'>
<UserRatioSection userId={userId} />
</Card>
)}
{/* 模型速率限制 */}
{userId && (
<Card className='!rounded-2xl shadow-sm border-0'>


+ 0
- 241
web/src/components/table/users/modals/UserRatioSection.jsx Datei anzeigen

@@ -1,241 +0,0 @@
import React, { useEffect, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { API, showError, showSuccess } from '../../../../helpers';
import {
Button,
Table,
InputNumber,
Modal,
Form,
Avatar,
Typography,
Popconfirm,
} from '@douyinfe/semi-ui';
import { IconStar } from '@douyinfe/semi-icons';

const { Text } = Typography;

const UserRatioSection = ({ userId }) => {
const { t } = useTranslation();
const [ratios, setRatios] = useState([]);
const [loading, setLoading] = useState(false);
const [addModalVisible, setAddModalVisible] = useState(false);
const [channelOptions, setChannelOptions] = useState([]);
const [modelOptions, setModelOptions] = useState([]);
const formApiRef = useRef(null);

const loadRatios = async () => {
if (!userId) return;
setLoading(true);
try {
const res = await API.get(`/api/user_channel_ratio/${userId}`);
const { success, data, message } = res.data;
if (success) {
setRatios(data || []);
} else {
showError(message);
}
} catch (e) {
showError(e.message);
}
setLoading(false);
};

const loadChannels = async () => {
try {
const res = await API.get('/api/channel/?p=0&page_size=500');
const { success, data, message } = res.data;
if (success) {
const options = (data?.items || []).map((ch) => ({
label: ch.remark
? `${ch.name} (${ch.remark})`
: `${ch.name} (ID: ${ch.id})`,
value: ch.id,
models: (ch.models || '').split(',').map((m) => m.trim()).filter(Boolean),
}));
setChannelOptions(options);
} else {
showError(message);
}
} catch (e) {
showError(e.message);
}
};

useEffect(() => {
loadRatios();
loadChannels();
}, [userId]);

const handleAdd = async (values) => {
const res = await API.post('/api/user_channel_ratio/', {
user_id: userId,
model_name: values.model_name,
channel_id: values.channel_id,
ratio: values.ratio,
});
const { success, message } = res.data;
if (success) {
showSuccess(t('添加成功'));
setAddModalVisible(false);
loadRatios();
} else {
showError(message);
}
};

const handleUpdate = async (id, newRatio) => {
const res = await API.put(`/api/user_channel_ratio/${id}`, {
ratio: newRatio,
});
const { success, message } = res.data;
if (success) {
showSuccess(t('更新成功'));
loadRatios();
} else {
showError(message);
}
};

const handleDelete = async (id) => {
const res = await API.delete(`/api/user_channel_ratio/${id}`);
const { success, message } = res.data;
if (success) {
showSuccess(t('删除成功'));
loadRatios();
} else {
showError(message);
}
};

const columns = [
{ title: t('模型'), dataIndex: 'model_name', key: 'model_name' },
{
title: t('渠道'),
dataIndex: 'channel_id',
key: 'channel_id',
render: (channelId) => {
const ch = channelOptions.find((c) => c.value === channelId);
return ch ? ch.label : channelId;
},
},
{
title: t('倍率'),
dataIndex: 'ratio',
key: 'ratio',
render: (text, record) => (
<InputNumber
value={text}
min={0.01}
step={0.1}
onBlur={(val) => {
if (val !== text) handleUpdate(record.id, val);
}}
style={{ width: 100 }}
/>
),
},
{
title: t('操作'),
key: 'action',
render: (_, record) => (
<Popconfirm
title={t('确认删除?')}
onConfirm={() => handleDelete(record.id)}
>
<Button type='danger' size='small'>
{t('删除')}
</Button>
</Popconfirm>
),
},
];

return (
<>
<div className='flex items-center justify-between mb-2'>
<div className='flex items-center'>
<Avatar size='small' color='orange' className='mr-2 shadow-md'>
<IconStar size={16} />
</Avatar>
<div>
<Text className='text-lg font-medium'>{t('倍率设置')}</Text>
<div className='text-xs text-gray-600'>
{t(
'为该用户设置特定模型+渠道的倍率乘数,未设置时默认为 1',
)}
</div>
</div>
</div>
<Button size='small' onClick={() => setAddModalVisible(true)}>
{t('添加倍率')}
</Button>
</div>

<Table
columns={columns}
dataSource={ratios}
loading={loading}
rowKey='id'
size='small'
pagination={false}
empty={t('暂无倍率设置')}
/>

<Modal
title={t('添加倍率')}
visible={addModalVisible}
onOk={() => formApiRef.current?.submitForm()}
onCancel={() => setAddModalVisible(false)}
>
<Form
getFormApi={(api) => (formApiRef.current = api)}
onSubmit={handleAdd}
>
<Form.Select
field='channel_id'
label={t('渠道')}
placeholder={t('请选择渠道')}
optionList={channelOptions}
filter
showClear
rules={[{ required: true, message: t('请选择渠道') }]}
style={{ width: '100%' }}
onChange={(val) => {
formApiRef.current?.setValue('model_name', undefined);
const selected = channelOptions.find((c) => c.value === val);
if (selected?.models) {
setModelOptions(selected.models.map((m) => ({ label: m, value: m })));
} else {
setModelOptions([]);
}
}}
/>
<Form.Select
field='model_name'
label={t('模型')}
placeholder={t('请选择模型')}
optionList={modelOptions}
filter
showClear
disabled={modelOptions.length === 0}
rules={[{ required: true, message: t('请选择模型') }]}
style={{ width: '100%' }}
/>
<Form.InputNumber
field='ratio'
label={t('倍率')}
placeholder='1.0'
initValue={1.0}
min={0.01}
step={0.1}
rules={[{ required: true, message: t('请输入倍率') }]}
style={{ width: '100%' }}
/>
</Form>
</Modal>
</>
);
};

export default UserRatioSection;

+ 8
- 22
web/src/helpers/render.jsx Datei anzeigen

@@ -1184,8 +1184,7 @@ 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 {
@@ -1316,7 +1315,6 @@ export function renderModelPrice(
audioInputPrice = 0,
imageGenerationCall = false,
imageGenerationCallPrice = 0,
userChannelRatio,
) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
groupRatio,
@@ -1327,12 +1325,11 @@ export function renderModelPrice(
// 获取货币配置
const { symbol, rate } = getCurrencyConfig();

const ucr = (userChannelRatio != null && userChannelRatio !== 1.0) ? userChannelRatio : null;

if (modelPrice !== -1) {
const displayPrice = (modelPrice * rate).toFixed(6);
const displayTotal = (modelPrice * groupRatio * (ucr || 1) * rate).toFixed(6);
const ratioParts = `${ratioLabel}:${groupRatio}` + (ucr ? ` * 用户倍率:${ucr}` : '');
const displayTotal = (modelPrice * groupRatio * rate).toFixed(6);
const ratioParts = `${ratioLabel}:${groupRatio}` ;
return i18next.t(
'模型价格:{{symbol}}{{price}} * {{ratioParts}} = {{symbol}}{{total}}',
{
@@ -1370,9 +1367,6 @@ export function renderModelPrice(
(fileSearchCallCount / 1000) * fileSearchPrice * groupRatio +
imageGenerationCallPrice * groupRatio;

if (ucr) {
price *= ucr;
}

return (
<>
@@ -1501,14 +1495,14 @@ export function renderModelPrice(

// 构建输出部分描述
const outputDesc = i18next.t(
'输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}{{userRatio}}',
'输出 {{completion}} tokens / 1M tokens * {{symbol}}{{compPrice}}) * {{ratioType}} {{ratio}}',
{
completion: completionTokens,
symbol: symbol,
compPrice: (completionRatioPrice * rate).toFixed(6),
ratio: groupRatio,
ratioType: ratioLabel,
userRatio: ucr ? ` * 用户倍率 ${ucr}` : '',
},
);

@@ -1583,7 +1577,6 @@ export function renderLogContent(
webSearchCallCount = 0,
fileSearch = false,
fileSearchCallCount = 0,
userChannelRatio,
) {
const {
ratio,
@@ -1593,10 +1586,7 @@ export function renderLogContent(

// 获取货币配置
const { symbol, rate } = getCurrencyConfig();

const userRatioSuffix = (userChannelRatio != null && userChannelRatio !== 1.0)
? i18next.t(',用户倍率 {{userChannelRatio}}', { userChannelRatio })
: '';
const userRatioSuffix = '';

let result;
if (modelPrice !== -1) {
@@ -1897,7 +1887,6 @@ export function renderClaudeModelPrice(
cacheCreationRatio5m = 1.0,
cacheCreationTokens1h = 0,
cacheCreationRatio1h = 1.0,
userChannelRatio,
) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
groupRatio,
@@ -1955,7 +1944,7 @@ export function renderClaudeModelPrice(
cacheCreationTokens5m * cacheCreationRatio5m +
cacheCreationTokens1h * cacheCreationRatio1h;

const userRatio = userChannelRatio ?? 1.0;
const userRatio = 1.0;
let price =
((effectiveInputTokens / 1000000) * inputRatioPrice * groupRatio +
(completionTokens / 1000000) * completionRatioPrice * groupRatio) *
@@ -2169,7 +2158,6 @@ export function renderClaudeLogContent(
cacheCreationRatio5m = 1.0,
cacheCreationTokens1h = 0,
cacheCreationRatio1h = 1.0,
userChannelRatio,
) {
const { ratio: effectiveGroupRatio, label: ratioLabel } = getEffectiveRatio(
groupRatio,
@@ -2179,10 +2167,8 @@ export function renderClaudeLogContent(

// 获取货币配置
const { symbol, rate } = getCurrencyConfig();
const userRatioSuffix = '';

const userRatioSuffix = (userChannelRatio != null && userChannelRatio !== 1.0)
? i18next.t(',用户倍率 {{userChannelRatio}}', { userChannelRatio })
: '';

if (modelPrice !== -1) {
return i18next.t('模型价格 {{symbol}}{{price}},{{ratioType}} {{ratio}}', {


+ 0
- 10
web/src/hooks/usage-logs/useUsageLogsData.jsx Datei anzeigen

@@ -431,7 +431,6 @@ export const useLogsData = () => {
other.cache_creation_ratio_1h ||
other.cache_creation_ratio ||
1.0,
other?.user_channel_ratio,
)
: renderLogContent(
other?.model_ratio,
@@ -446,7 +445,6 @@ export const useLogsData = () => {
other.web_search_call_count || 0,
other.file_search || false,
other.file_search_call_count || 0,
other?.user_channel_ratio,
),
});
if (logs[i]?.content) {
@@ -522,7 +520,6 @@ export const useLogsData = () => {
other.cache_creation_ratio_1h ||
other.cache_creation_ratio ||
1.0,
other.user_channel_ratio,
);
} else {
content = renderModelPrice(
@@ -549,7 +546,6 @@ export const useLogsData = () => {
other?.audio_input_price || 0,
other?.image_generation_call || false,
other?.image_generation_call_price || 0,
other?.user_channel_ratio,
);
}
expandDataLocal.push({
@@ -563,12 +559,6 @@ export const useLogsData = () => {
value: other.reasoning_effort,
});
}
if (other?.user_channel_ratio) {
expandDataLocal.push({
key: t('用户倍率'),
value: other.user_channel_ratio,
});
}
}
if (logs[i].type === 6) {
if (other?.task_id) {


+ 4
- 84
web/src/pages/Setting/Ratio/ModelSettingsVisualEditor.jsx Datei anzeigen

@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/

import React, { useEffect, useState, useRef, useCallback } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import {
Table,
Button,
@@ -38,9 +38,7 @@ import {
IconEdit,
} from '@douyinfe/semi-icons';
import { API, showError, showSuccess, getQuotaPerUnit } from '../../../helpers';
import { channelPricingApi, pricingTagApi } from '../../../helpers/api';
import { useTranslation } from 'react-i18next';
import ChannelPricingView from './ChannelPricingView';

export default function ModelSettingsVisualEditor(props) {
const { t } = useTranslation();
@@ -58,12 +56,6 @@ export default function ModelSettingsVisualEditor(props) {
const pageSize = 10;
const quotaPerUnit = getQuotaPerUnit();

// Channel pricing view mode
const [viewMode, setViewMode] = useState('global'); // 'global' | 'channel'
const [channels, setChannels] = useState([]);
const [channelPricings, setChannelPricings] = useState([]);
const [tags, setTags] = useState([]);

useEffect(() => {
try {
const modelPrice = JSON.parse(props.options.ModelPrice || '{}');
@@ -98,53 +90,6 @@ export default function ModelSettingsVisualEditor(props) {
}
}, [props.options]);

// Load channels for channel pricing view
const loadChannels = useCallback(async () => {
try {
const res = await API.get('/api/channel/?page_size=100');
if (res.data.success) {
// API returns {items: [...], total: ...}, extract items array
const data = res.data.data;
setChannels(Array.isArray(data) ? data : (data?.items || []));
}
} catch (e) {
console.error('Failed to load channels', e);
}
}, []);

// Load channel pricings
const loadChannelPricings = useCallback(async () => {
try {
const res = await channelPricingApi.getWithTags(1, 1000);
if (res.data.success) {
setChannelPricings(res.data.data.items || []);
}
} catch (e) {
console.error('Failed to load channel pricings', e);
}
}, []);

// Load tags
const loadTags = useCallback(async () => {
try {
const res = await pricingTagApi.getAll();
if (res.data.success) {
setTags(res.data.data || []);
}
} catch (e) {
console.error('Failed to load tags', e);
}
}, []);

// Load channel data when switching to channel view
useEffect(() => {
if (viewMode === 'channel') {
loadChannels();
loadChannelPricings();
loadTags();
}
}, [viewMode, loadChannels, loadChannelPricings, loadTags]);

// 首先声明分页相关的工具函数
const getPagedData = (data, currentPage, pageSize) => {
const start = (currentPage - 1) * pageSize;
@@ -510,23 +455,9 @@ export default function ModelSettingsVisualEditor(props) {

return (
<>
{/* View mode switch */}
<div style={{ marginBottom: 16 }}>
<RadioGroup
value={viewMode}
onChange={(e) => setViewMode(e.target.value)}
type="button"
>
<Radio value="global">{t('全局定价')}</Radio>
<Radio value="channel">{t('渠道定价')}</Radio>
</RadioGroup>
</div>

{/* Global pricing view */}
{viewMode === 'global' && (
<Space vertical align='start' style={{ width: '100%' }}>
<Space className='mt-2'>
<Button
<Space vertical align='start' style={{ width: '100%' }}>
<Space className='mt-2'>
<Button
icon={<IconPlus />}
onClick={() => {
resetModalState();
@@ -572,17 +503,6 @@ export default function ModelSettingsVisualEditor(props) {
}}
/>
</Space>
)}

{/* Channel pricing view */}
{viewMode === 'channel' && (
<ChannelPricingView
channels={channels}
channelPricings={channelPricings}
tags={tags}
onRefresh={loadChannelPricings}
/>
)}

<Modal
title={isEditMode ? t('编辑模型') : t('添加模型')}


Laden…
Abbrechen
Speichern