为每个用户添加独立于全局定价的倍率乘数,支持按 (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
| @@ -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, | |||||
| }) | |||||
| } | |||||
| @@ -208,6 +208,7 @@ func InitDB() (err error) { | |||||
| } | } | ||||
| LoadEmailQuotaCache() | LoadEmailQuotaCache() | ||||
| LoadChannelPricingCache() | LoadChannelPricingCache() | ||||
| LoadUserChannelRatioCache() | |||||
| return nil | return nil | ||||
| } else { | } else { | ||||
| common.FatalLog(err) | common.FatalLog(err) | ||||
| @@ -285,6 +286,7 @@ func migrateDB() error { | |||||
| &UserOAuthBinding{}, | &UserOAuthBinding{}, | ||||
| &ChannelPricing{}, | &ChannelPricing{}, | ||||
| &PricingTag{}, | &PricingTag{}, | ||||
| &UserChannelRatio{}, | |||||
| &PendingSyncRecord{}, | &PendingSyncRecord{}, | ||||
| &QuotaSyncLog{}, | &QuotaSyncLog{}, | ||||
| &EmailQuotaRule{}, | &EmailQuotaRule{}, | ||||
| @@ -345,6 +347,7 @@ func migrateDBFast() error { | |||||
| {&UserOAuthBinding{}, "UserOAuthBinding"}, | {&UserOAuthBinding{}, "UserOAuthBinding"}, | ||||
| {&ChannelPricing{}, "ChannelPricing"}, | {&ChannelPricing{}, "ChannelPricing"}, | ||||
| {&PricingTag{}, "PricingTag"}, | {&PricingTag{}, "PricingTag"}, | ||||
| {&UserChannelRatio{}, "UserChannelRatio"}, | |||||
| {&PendingSyncRecord{}, "PendingSyncRecord"}, | {&PendingSyncRecord{}, "PendingSyncRecord"}, | ||||
| {&QuotaSyncLog{}, "QuotaSyncLog"}, | {&QuotaSyncLog{}, "QuotaSyncLog"}, | ||||
| {&EmailQuotaRule{}, "EmailQuotaRule"}, | {&EmailQuotaRule{}, "EmailQuotaRule"}, | ||||
| @@ -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 | |||||
| } | |||||
| @@ -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)) | |||||
| } | |||||
| @@ -285,7 +285,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage | |||||
| dCachedCreationRatio := decimal.NewFromFloat(cachedCreationRatio) | dCachedCreationRatio := decimal.NewFromFloat(cachedCreationRatio) | ||||
| dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) | dQuotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) | ||||
| ratio := dModelRatio.Mul(dGroupRatio) | |||||
| ratio := dModelRatio.Mul(dGroupRatio).Mul(decimal.NewFromFloat(relayInfo.PriceData.UserChannelRatio)) | |||||
| // openai web search 工具计费 | // openai web search 工具计费 | ||||
| var dWebSearchQuota decimal.Decimal | var dWebSearchQuota decimal.Decimal | ||||
| @@ -399,7 +399,7 @@ func postConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usage | |||||
| quotaCalculateDecimal = decimal.NewFromInt(1) | quotaCalculateDecimal = decimal.NewFromInt(1) | ||||
| } | } | ||||
| } else { | } else { | ||||
| quotaCalculateDecimal = dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio) | |||||
| quotaCalculateDecimal = dModelPrice.Mul(dQuotaPerUnit).Mul(dGroupRatio).Mul(decimal.NewFromFloat(relayInfo.PriceData.UserChannelRatio)) | |||||
| } | } | ||||
| // 添加 responses tools call 调用的配额 | // 添加 responses tools call 调用的配额 | ||||
| quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota) | quotaCalculateDecimal = quotaCalculateDecimal.Add(dWebSearchQuota) | ||||
| @@ -168,6 +168,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens | |||||
| CacheCreationRatio: cacheCreationRatio, | CacheCreationRatio: cacheCreationRatio, | ||||
| CacheCreation5mRatio: cacheCreationRatio, | CacheCreation5mRatio: cacheCreationRatio, | ||||
| CacheCreation1hRatio: cacheCreationRatio * types.ClaudeCacheCreation1hMultiplier, | CacheCreation1hRatio: cacheCreationRatio * types.ClaudeCacheCreation1hMultiplier, | ||||
| UserChannelRatio: 1.0, | |||||
| } | } | ||||
| if common.DebugEnabled { | if common.DebugEnabled { | ||||
| @@ -207,6 +208,7 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) types. | |||||
| ModelPrice: modelPrice, | ModelPrice: modelPrice, | ||||
| Quota: quota, | Quota: quota, | ||||
| GroupRatioInfo: groupRatioInfo, | GroupRatioInfo: groupRatioInfo, | ||||
| UserChannelRatio: 1.0, | |||||
| } | } | ||||
| return priceData | return priceData | ||||
| } | } | ||||
| @@ -232,34 +234,54 @@ func UpdatePriceDataForChannelPricing(c *gin.Context, info *relaycommon.RelayInf | |||||
| } | } | ||||
| cp, found := model.GetEffectivePricing(info.OriginModelName, channelId) | 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) | |||||
| } | } | ||||
| } | } | ||||
| @@ -202,6 +202,16 @@ func SetApiRouter(router *gin.Engine) { | |||||
| channelPricingRoute.DELETE("/default/*name", controller.ClearDefaultChannel) | 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 := apiRouter.Group("/pricing_tag") | ||||
| pricingTagRoute.Use(middleware.AdminAuth()) | pricingTagRoute.Use(middleware.AdminAuth()) | ||||
| @@ -30,13 +30,14 @@ type TokenDetails struct { | |||||
| } | } | ||||
| type QuotaInfo 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 { | func hasCustomModelRatio(modelName string, currentRatio float64) bool { | ||||
| @@ -53,7 +54,7 @@ func calculateAudioQuota(info QuotaInfo) int { | |||||
| quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) | quotaPerUnit := decimal.NewFromFloat(common.QuotaPerUnit) | ||||
| groupRatio := decimal.NewFromFloat(info.GroupRatio) | 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()) | return int(quota.IntPart()) | ||||
| } | } | ||||
| @@ -63,7 +64,7 @@ func calculateAudioQuota(info QuotaInfo) int { | |||||
| groupRatio := decimal.NewFromFloat(info.GroupRatio) | groupRatio := decimal.NewFromFloat(info.GroupRatio) | ||||
| modelRatio := decimal.NewFromFloat(info.ModelRatio) | 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)) | inputTextTokens := decimal.NewFromInt(int64(info.InputDetails.TextTokens)) | ||||
| outputTextTokens := decimal.NewFromInt(int64(info.OutputDetails.TextTokens)) | outputTextTokens := decimal.NewFromInt(int64(info.OutputDetails.TextTokens)) | ||||
| @@ -134,6 +135,7 @@ func PreWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, usag | |||||
| UsePrice: relayInfo.UsePrice, | UsePrice: relayInfo.UsePrice, | ||||
| ModelRatio: modelRatio, | ModelRatio: modelRatio, | ||||
| GroupRatio: actualGroupRatio, | GroupRatio: actualGroupRatio, | ||||
| UserChannelRatio: relayInfo.PriceData.UserChannelRatio, | |||||
| } | } | ||||
| quota := calculateAudioQuota(quotaInfo) | quota := calculateAudioQuota(quotaInfo) | ||||
| @@ -187,6 +189,7 @@ func PostWssConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, mod | |||||
| UsePrice: usePrice, | UsePrice: usePrice, | ||||
| ModelRatio: modelRatio, | ModelRatio: modelRatio, | ||||
| GroupRatio: groupRatio, | GroupRatio: groupRatio, | ||||
| UserChannelRatio: relayInfo.PriceData.UserChannelRatio, | |||||
| } | } | ||||
| quota := calculateAudioQuota(quotaInfo) | quota := calculateAudioQuota(quotaInfo) | ||||
| @@ -249,6 +252,7 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, | |||||
| completionRatio := relayInfo.PriceData.CompletionRatio | completionRatio := relayInfo.PriceData.CompletionRatio | ||||
| modelRatio := relayInfo.PriceData.ModelRatio | modelRatio := relayInfo.PriceData.ModelRatio | ||||
| groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio | groupRatio := relayInfo.PriceData.GroupRatioInfo.GroupRatio | ||||
| userChannelRatio := relayInfo.PriceData.UserChannelRatio | |||||
| modelPrice := relayInfo.PriceData.ModelPrice | modelPrice := relayInfo.PriceData.ModelPrice | ||||
| cacheRatio := relayInfo.PriceData.CacheRatio | cacheRatio := relayInfo.PriceData.CacheRatio | ||||
| cacheTokens := usage.PromptTokensDetails.CachedTokens | cacheTokens := usage.PromptTokensDetails.CachedTokens | ||||
| @@ -283,9 +287,9 @@ func PostClaudeConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, | |||||
| calculateQuota += float64(remainingCacheCreationTokens) * cacheCreationRatio | calculateQuota += float64(remainingCacheCreationTokens) * cacheCreationRatio | ||||
| } | } | ||||
| calculateQuota += float64(completionTokens) * completionRatio | calculateQuota += float64(completionTokens) * completionRatio | ||||
| calculateQuota = calculateQuota * groupRatio * modelRatio | |||||
| calculateQuota = calculateQuota * groupRatio * modelRatio * userChannelRatio | |||||
| } else { | } else { | ||||
| calculateQuota = modelPrice * common.QuotaPerUnit * groupRatio | |||||
| calculateQuota = modelPrice * common.QuotaPerUnit * groupRatio * userChannelRatio | |||||
| } | } | ||||
| if modelRatio != 0 && calculateQuota <= 0 { | if modelRatio != 0 && calculateQuota <= 0 { | ||||
| @@ -390,6 +394,7 @@ func PostAudioConsumeQuota(ctx *gin.Context, relayInfo *relaycommon.RelayInfo, u | |||||
| UsePrice: usePrice, | UsePrice: usePrice, | ||||
| ModelRatio: modelRatio, | ModelRatio: modelRatio, | ||||
| GroupRatio: groupRatio, | GroupRatio: groupRatio, | ||||
| UserChannelRatio: relayInfo.PriceData.UserChannelRatio, | |||||
| } | } | ||||
| quota := calculateAudioQuota(quotaInfo) | quota := calculateAudioQuota(quotaInfo) | ||||
| @@ -29,6 +29,7 @@ type PriceData struct { | |||||
| Quota int // 按次计费的最终额度(MJ / Task) | Quota int // 按次计费的最终额度(MJ / Task) | ||||
| QuotaToPreConsume int // 按量计费的预消耗额度 | QuotaToPreConsume int // 按量计费的预消耗额度 | ||||
| GroupRatioInfo GroupRatioInfo | GroupRatioInfo GroupRatioInfo | ||||
| UserChannelRatio float64 // 用户-模型-渠道倍率(默认 1.0) | |||||
| } | } | ||||
| // ApplyChannelPricingRatios 将渠道定价的扩展比率应用到 PriceData(非零值覆盖) | // ApplyChannelPricingRatios 将渠道定价的扩展比率应用到 PriceData(非零值覆盖) | ||||
| @@ -64,5 +65,5 @@ func (p *PriceData) AddOtherRatio(key string, ratio float64) { | |||||
| } | } | ||||
| func (p *PriceData) ToSetting() string { | 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) | |||||
| } | } | ||||
| @@ -56,6 +56,7 @@ import { | |||||
| IconPlus, | IconPlus, | ||||
| } from '@douyinfe/semi-icons'; | } from '@douyinfe/semi-icons'; | ||||
| import UserBindingManagementModal from './UserBindingManagementModal'; | import UserBindingManagementModal from './UserBindingManagementModal'; | ||||
| import UserRatioSection from './UserRatioSection'; | |||||
| const { Text, Title } = Typography; | const { Text, Title } = Typography; | ||||
| @@ -327,6 +328,13 @@ const EditUserModal = (props) => { | |||||
| </Card> | </Card> | ||||
| )} | )} | ||||
| {/* 倍率设置 */} | |||||
| {userId && ( | |||||
| <Card className='!rounded-2xl shadow-sm border-0'> | |||||
| <UserRatioSection userId={userId} /> | |||||
| </Card> | |||||
| )} | |||||
| {/* 绑定信息入口 */} | {/* 绑定信息入口 */} | ||||
| {userId && ( | {userId && ( | ||||
| <Card className='!rounded-2xl shadow-sm border-0'> | <Card className='!rounded-2xl shadow-sm border-0'> | ||||
| @@ -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; | |||||