Browse Source

feat(pricing): 添加用户-模型-渠道倍率功能

为每个用户添加独立于全局定价的倍率乘数,支持按 (userId, model, channel) 三维
精确控制用户级别定价。倍率以乘法叠加在现有 modelRatio × groupRatio 之上。

- 新增 user_channel_ratios 表,写入内存缓存(写穿透模式)
- 新增 /api/user_channel_ratio/ CRUD API(AdminAuth)
- 计费路径集成:compatible_handler、PostClaudeConsumeQuota、
  PostWssConsumeQuota、PostAudioConsumeQuota、calculateAudioQuota
- 前端:EditUserModal 内嵌 UserRatioSection 组件
- 修复 Update() 缓存 key 零值 bug(先加载再更新)
- 修复 PreWssConsumeQuota 缺少 UserChannelRatio 导致归零
- 修复 ModelPriceHelperPerCall 缺少默认值 1.0

Co-Authored-By: Claude <noreply@anthropic.com>
master
fengsilin 1 day ago
parent
commit
9dbf3365fe
11 changed files with 613 additions and 37 deletions
  1. +116
    -0
      controller/user_channel_ratio.go
  2. +3
    -0
      model/main.go
  3. +114
    -0
      model/user_channel_ratio.go
  4. +105
    -0
      model/user_channel_ratio_test.go
  5. +2
    -2
      relay/compatible_handler.go
  6. +45
    -23
      relay/helper/price.go
  7. +10
    -0
      router/api-router.go
  8. +16
    -11
      service/quota.go
  9. +2
    -1
      types/price_data.go
  10. +8
    -0
      web/src/components/table/users/modals/EditUserModal.jsx
  11. +192
    -0
      web/src/components/table/users/modals/UserRatioSection.jsx

+ 116
- 0
controller/user_channel_ratio.go View File

@@ -0,0 +1,116 @@
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,
})
}

+ 3
- 0
model/main.go View File

@@ -208,6 +208,7 @@ func InitDB() (err error) {
}
LoadEmailQuotaCache()
LoadChannelPricingCache()
LoadUserChannelRatioCache()
return nil
} else {
common.FatalLog(err)
@@ -285,6 +286,7 @@ func migrateDB() error {
&UserOAuthBinding{},
&ChannelPricing{},
&PricingTag{},
&UserChannelRatio{},
&PendingSyncRecord{},
&QuotaSyncLog{},
&EmailQuotaRule{},
@@ -345,6 +347,7 @@ func migrateDBFast() error {
{&UserOAuthBinding{}, "UserOAuthBinding"},
{&ChannelPricing{}, "ChannelPricing"},
{&PricingTag{}, "PricingTag"},
{&UserChannelRatio{}, "UserChannelRatio"},
{&PendingSyncRecord{}, "PendingSyncRecord"},
{&QuotaSyncLog{}, "QuotaSyncLog"},
{&EmailQuotaRule{}, "EmailQuotaRule"},


+ 114
- 0
model/user_channel_ratio.go View File

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

import (
"strconv"
"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
}

+ 105
- 0
model/user_channel_ratio_test.go View File

@@ -0,0 +1,105 @@
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 View File

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

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

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


+ 45
- 23
relay/helper/price.go View File

@@ -168,6 +168,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
CacheCreationRatio: cacheCreationRatio,
CacheCreation5mRatio: cacheCreationRatio,
CacheCreation1hRatio: cacheCreationRatio * types.ClaudeCacheCreation1hMultiplier,
UserChannelRatio: 1.0,
}

if common.DebugEnabled {
@@ -207,6 +208,7 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) types.
ModelPrice: modelPrice,
Quota: quota,
GroupRatioInfo: groupRatioInfo,
UserChannelRatio: 1.0,
}
return priceData
}
@@ -232,34 +234,54 @@ func UpdatePriceDataForChannelPricing(c *gin.Context, info *relaycommon.RelayInf
}

cp, found := model.GetEffectivePricing(info.OriginModelName, channelId)
if !found {
return
}
if found {
info.PriceData.ModelRatio = cp.ModelRatio
info.PriceData.CompletionRatio = cp.CompletionRatio
info.PriceData.UsePrice = cp.QuotaType == model.QuotaTypeByCall
if info.PriceData.UsePrice {
info.PriceData.ModelPrice = cp.ModelPrice
} else {
info.PriceData.ModelPrice = -1
}

info.PriceData.ModelRatio = cp.ModelRatio
info.PriceData.CompletionRatio = cp.CompletionRatio
info.PriceData.UsePrice = cp.QuotaType == model.QuotaTypeByCall
if info.PriceData.UsePrice {
info.PriceData.ModelPrice = cp.ModelPrice
} else {
info.PriceData.ModelPrice = -1
}
info.PriceData.ApplyChannelPricingRatios(cp.CacheRatio, cp.CacheCreationRatio, cp.ImageRatio, cp.AudioRatio, cp.AudioCompletionRatio)

info.PriceData.ApplyChannelPricingRatios(cp.CacheRatio, cp.CacheCreationRatio, cp.ImageRatio, cp.AudioRatio, cp.AudioCompletionRatio)
if info.PriceData.UsePrice {
info.PriceData.QuotaToPreConsume = int(
cp.ModelPrice * common.QuotaPerUnit * info.PriceData.GroupRatioInfo.GroupRatio)
} else {
estimateTokens := info.GetEstimatePromptTokens()
if estimateTokens > 0 {
ratio := cp.ModelRatio * info.PriceData.GroupRatioInfo.GroupRatio
info.PriceData.QuotaToPreConsume = int(float64(estimateTokens) * ratio)
}
}

if info.PriceData.UsePrice {
info.PriceData.QuotaToPreConsume = int(
cp.ModelPrice * common.QuotaPerUnit * info.PriceData.GroupRatioInfo.GroupRatio)
} else {
estimateTokens := info.GetEstimatePromptTokens()
if estimateTokens > 0 {
ratio := cp.ModelRatio * info.PriceData.GroupRatioInfo.GroupRatio
info.PriceData.QuotaToPreConsume = int(float64(estimateTokens) * ratio)
if common.DebugEnabled {
println(fmt.Sprintf("[ChannelPricing] updatePriceData: model=%s channel=%d modelRatio=%.4f completionRatio=%.4f cacheRatio=%.4f imageRatio=%.4f audioRatio=%.4f",
info.OriginModelName, channelId, cp.ModelRatio, cp.CompletionRatio, cp.CacheRatio, cp.ImageRatio, cp.AudioRatio))
}
}

if common.DebugEnabled {
println(fmt.Sprintf("[ChannelPricing] updatePriceData: model=%s channel=%d modelRatio=%.4f completionRatio=%.4f cacheRatio=%.4f imageRatio=%.4f audioRatio=%.4f",
info.OriginModelName, channelId, cp.ModelRatio, cp.CompletionRatio, cp.CacheRatio, cp.ImageRatio, cp.AudioRatio))
// 应用用户-模型-渠道倍率(始终执行,不依赖渠道定价是否存在)
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)
}
}

+ 10
- 0
router/api-router.go View File

@@ -202,6 +202,16 @@ func SetApiRouter(router *gin.Engine) {
channelPricingRoute.DELETE("/default/*name", controller.ClearDefaultChannel)
}

// 用户倍率路由(管理员权限)
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)
}

// 定价标签路由(管理员权限)
pricingTagRoute := apiRouter.Group("/pricing_tag")
pricingTagRoute.Use(middleware.AdminAuth())


+ 16
- 11
service/quota.go View File

@@ -30,13 +30,14 @@ type TokenDetails struct {
}

type QuotaInfo struct {
InputDetails TokenDetails
OutputDetails TokenDetails
ModelName string
UsePrice bool
ModelPrice float64
ModelRatio float64
GroupRatio float64
InputDetails TokenDetails
OutputDetails TokenDetails
ModelName string
UsePrice bool
ModelPrice float64
ModelRatio float64
GroupRatio float64
UserChannelRatio float64
}

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

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

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

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

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

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

quota := calculateAudioQuota(quotaInfo)
@@ -249,6 +252,7 @@ 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
@@ -283,9 +287,9 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo,
calculateQuota += float64(remainingCacheCreationTokens) * cacheCreationRatio
}
calculateQuota += float64(completionTokens) * completionRatio
calculateQuota = calculateQuota * groupRatio * modelRatio
calculateQuota = calculateQuota * groupRatio * modelRatio * userChannelRatio
} else {
calculateQuota = modelPrice * common.QuotaPerUnit * groupRatio
calculateQuota = modelPrice * common.QuotaPerUnit * groupRatio * userChannelRatio
}

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

quota := calculateAudioQuota(quotaInfo)


+ 2
- 1
types/price_data.go View File

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

// ApplyChannelPricingRatios 将渠道定价的扩展比率应用到 PriceData(非零值覆盖)
@@ -64,5 +65,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", 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)
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)
}

+ 8
- 0
web/src/components/table/users/modals/EditUserModal.jsx View File

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

const { Text, Title } = Typography;

@@ -327,6 +328,13 @@ const EditUserModal = (props) => {
</Card>
)}


{/* 倍率设置 */}
{userId && (
<Card className='!rounded-2xl shadow-sm border-0'>
<UserRatioSection userId={userId} />
</Card>
)}
{/* 绑定信息入口 */}
{userId && (
<Card className='!rounded-2xl shadow-sm border-0'>


+ 192
- 0
web/src/components/table/users/modals/UserRatioSection.jsx View File

@@ -0,0 +1,192 @@
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 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);
};

useEffect(() => {
loadRatios();
}, [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('渠道 ID'), dataIndex: 'channel_id', key: 'channel_id' },
{
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.Input
field='model_name'
label={t('模型名称')}
placeholder='gpt-4o'
rules={[{ required: true, message: t('请输入模型名称') }]}
/>
<Form.InputNumber
field='channel_id'
label={t('渠道 ID')}
placeholder={t('请输入渠道 ID')}
rules={[{ required: true, message: t('请输入渠道 ID') }]}
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;

Loading…
Cancel
Save