diff --git a/controller/user_channel_ratio.go b/controller/user_channel_ratio.go new file mode 100644 index 0000000..293b1ab --- /dev/null +++ b/controller/user_channel_ratio.go @@ -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, + }) +} diff --git a/model/main.go b/model/main.go index 63a4412..c832546 100644 --- a/model/main.go +++ b/model/main.go @@ -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"}, diff --git a/model/user_channel_ratio.go b/model/user_channel_ratio.go new file mode 100644 index 0000000..e430013 --- /dev/null +++ b/model/user_channel_ratio.go @@ -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 +} diff --git a/model/user_channel_ratio_test.go b/model/user_channel_ratio_test.go new file mode 100644 index 0000000..d3c3053 --- /dev/null +++ b/model/user_channel_ratio_test.go @@ -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)) +} diff --git a/relay/compatible_handler.go b/relay/compatible_handler.go index 6bf5f3b..17340a8 100644 --- a/relay/compatible_handler.go +++ b/relay/compatible_handler.go @@ -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) diff --git a/relay/helper/price.go b/relay/helper/price.go index 83a8b35..86ffcc0 100644 --- a/relay/helper/price.go +++ b/relay/helper/price.go @@ -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) } } diff --git a/router/api-router.go b/router/api-router.go index 04cb916..40ba0f9 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -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()) diff --git a/service/quota.go b/service/quota.go index 7ee70ed..63442b5 100644 --- a/service/quota.go +++ b/service/quota.go @@ -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) diff --git a/types/price_data.go b/types/price_data.go index 6076f3b..892e8cc 100644 --- a/types/price_data.go +++ b/types/price_data.go @@ -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) } diff --git a/web/src/components/table/users/modals/EditUserModal.jsx b/web/src/components/table/users/modals/EditUserModal.jsx index 90676d8..1cd7b74 100644 --- a/web/src/components/table/users/modals/EditUserModal.jsx +++ b/web/src/components/table/users/modals/EditUserModal.jsx @@ -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) => { )} + + {/* 倍率设置 */} + {userId && ( + + + + )} {/* 绑定信息入口 */} {userId && ( diff --git a/web/src/components/table/users/modals/UserRatioSection.jsx b/web/src/components/table/users/modals/UserRatioSection.jsx new file mode 100644 index 0000000..d404d49 --- /dev/null +++ b/web/src/components/table/users/modals/UserRatioSection.jsx @@ -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) => ( + { + if (val !== text) handleUpdate(record.id, val); + }} + style={{ width: 100 }} + /> + ), + }, + { + title: t('操作'), + key: 'action', + render: (_, record) => ( + handleDelete(record.id)} + > + + {t('删除')} + + + ), + }, + ]; + + return ( + <> + + + + + + + {t('倍率设置')} + + {t( + '为该用户设置特定模型+渠道的倍率乘数,未设置时默认为 1', + )} + + + + setAddModalVisible(true)}> + {t('添加倍率')} + + + + + + formApiRef.current?.submitForm()} + onCancel={() => setAddModalVisible(false)} + > + (formApiRef.current = api)} + onSubmit={handleAdd} + > + + + + + + > + ); +}; + +export default UserRatioSection;