From 2af3a9301d5626374bad3b4764eb6c180e24fd69 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Mon, 1 Jun 2026 16:19:11 +0800 Subject: [PATCH] fix(pricing): remove stale channel pricing backend Delete leftover channel pricing controllers and routes after removing channel pricing, and align relay price helpers with global pricing plus user channel ratio only. Co-Authored-By: Codex --- controller/channel_pricing.go | 359 ---------------------------------- controller/pricing_tag.go | 131 ------------- relay/helper/price.go | 146 +++----------- relay/helper/price_test.go | 134 ++++++------- router/api-router.go | 24 --- 5 files changed, 88 insertions(+), 706 deletions(-) delete mode 100644 controller/channel_pricing.go delete mode 100644 controller/pricing_tag.go diff --git a/controller/channel_pricing.go b/controller/channel_pricing.go deleted file mode 100644 index 0adfd9d..0000000 --- a/controller/channel_pricing.go +++ /dev/null @@ -1,359 +0,0 @@ -package controller - -import ( - "fmt" - "strconv" - "strings" - - "github.com/QuantumNous/new-api/common" - "github.com/QuantumNous/new-api/model" - "github.com/QuantumNous/new-api/setting/ratio_setting" - - "github.com/gin-gonic/gin" -) - -// GetAllChannelPricing 获取所有渠道定价(分页) -func GetAllChannelPricing(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("p", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10")) - offset := (page - 1) * pageSize - - list, total, err := model.GetAllChannelPricing(offset, pageSize) - if err != nil { - common.ApiError(c, err) - return - } - - common.ApiSuccess(c, gin.H{ - "page": page, - "page_size": pageSize, - "total": total, - "items": list, - }) -} - -// GetChannelPricingByModel 获取指定模型的所有渠道定价 -func GetChannelPricingByModel(c *gin.Context) { - modelName := c.Param("name") - if modelName == "" { - common.ApiErrorMsg(c, "model name is required") - return - } - - list, err := model.GetChannelPricingByModel(modelName) - if err != nil { - common.ApiError(c, err) - return - } - - common.ApiSuccess(c, list) -} - -// CreateChannelPricingRequest 创建渠道定价请求 -type CreateChannelPricingRequest struct { - Id int `json:"id"` - ModelName string `json:"model_name" binding:"required"` - ChannelId int `json:"channel_id" binding:"required"` - QuotaType int `json:"quota_type"` - ModelRatio float64 `json:"model_ratio"` - CompletionRatio float64 `json:"completion_ratio"` - ModelPrice float64 `json:"model_price"` - TagIds string `json:"tag_ids"` - CacheRatio float64 `json:"cache_ratio"` - CacheCreationRatio float64 `json:"cache_creation_ratio"` - ImageRatio float64 `json:"image_ratio"` - AudioRatio float64 `json:"audio_ratio"` - AudioCompletionRatio float64 `json:"audio_completion_ratio"` -} - -// applyRequest 将请求字段应用到 ChannelPricing -func applyRequestFields(cp *model.ChannelPricing, req *CreateChannelPricingRequest) { - cp.ApplyFields(req.QuotaType, req.ModelRatio, req.CompletionRatio, req.ModelPrice, req.TagIds, - req.CacheRatio, req.CacheCreationRatio, req.ImageRatio, req.AudioRatio, req.AudioCompletionRatio) -} - -// CreateChannelPricing 创建或更新渠道定价 -func CreateChannelPricing(c *gin.Context) { - var req CreateChannelPricingRequest - if err := c.ShouldBindJSON(&req); err != nil { - common.ApiError(c, err) - return - } - - if req.CacheRatio < 0 || req.CacheCreationRatio < 0 || req.ImageRatio < 0 || req.AudioRatio < 0 || req.AudioCompletionRatio < 0 { - common.ApiErrorMsg(c, "ratio values must be >= 0") - return - } - - // 检查是否已存在 - existing, _ := model.GetChannelPricing(req.ModelName, req.ChannelId) - if existing != nil { - applyRequestFields(existing, &req) - if err := existing.Update(); err != nil { - common.ApiError(c, err) - return - } - common.SysLog(fmt.Sprintf("[ChannelPricing] updated: id=%d model=%s channel=%d", existing.Id, existing.ModelName, existing.ChannelId)) - common.ApiSuccess(c, existing) - return - } - - // 创建 - cp := &model.ChannelPricing{ - ModelName: req.ModelName, - ChannelId: req.ChannelId, - } - applyRequestFields(cp, &req) - if err := cp.Insert(); err != nil { - common.ApiError(c, err) - return - } - - common.SysLog(fmt.Sprintf("[ChannelPricing] created: model=%s channel=%d quotaType=%d modelRatio=%.4f completionRatio=%.4f modelPrice=%.4f cacheRatio=%.4f cacheCreationRatio=%.4f imageRatio=%.4f audioRatio=%.4f audioCompletionRatio=%.4f", - req.ModelName, req.ChannelId, req.QuotaType, req.ModelRatio, req.CompletionRatio, req.ModelPrice, - req.CacheRatio, req.CacheCreationRatio, req.ImageRatio, req.AudioRatio, req.AudioCompletionRatio)) - common.ApiSuccess(c, cp) -} - -// BatchCreateChannelPricingRequest 批量创建请求 -type BatchCreateChannelPricingRequest struct { - Items []*CreateChannelPricingRequest `json:"items" binding:"required"` -} - -// BatchCreateChannelPricing 批量创建或更新渠道定价 -func BatchCreateChannelPricing(c *gin.Context) { - var req BatchCreateChannelPricingRequest - if err := c.ShouldBindJSON(&req); err != nil { - common.ApiError(c, err) - return - } - - pricings := make([]*model.ChannelPricing, 0, len(req.Items)) - for _, item := range req.Items { - cp := &model.ChannelPricing{ - ModelName: item.ModelName, - ChannelId: item.ChannelId, - } - applyRequestFields(cp, item) - pricings = append(pricings, cp) - } - - // 使用事务逐个处理(GORM 的批量 upsert 在不同数据库表现不一致) - for _, cp := range pricings { - existing, _ := model.GetChannelPricing(cp.ModelName, cp.ChannelId) - if existing != nil { - existing.ApplyFields(cp.QuotaType, cp.ModelRatio, cp.CompletionRatio, cp.ModelPrice, cp.TagIds, - cp.CacheRatio, cp.CacheCreationRatio, cp.ImageRatio, cp.AudioRatio, cp.AudioCompletionRatio) - if err := existing.Update(); err != nil { - common.ApiError(c, err) - return - } - } else { - if err := cp.Insert(); err != nil { - common.ApiError(c, err) - return - } - } - } - - common.ApiSuccess(c, gin.H{"affected": len(pricings)}) -} - -// DeleteChannelPricing 删除渠道定价 -func DeleteChannelPricing(c *gin.Context) { - idStr := c.Param("id") - id, err := strconv.Atoi(idStr) - if err != nil { - common.ApiError(c, err) - return - } - - cp := &model.ChannelPricing{Id: id} - if err := cp.Delete(); err != nil { - common.ApiError(c, err) - return - } - - common.SysLog(fmt.Sprintf("[ChannelPricing] deleted: id=%d", id)) - common.ApiSuccess(c, nil) -} - -// CopyGlobalPricingRequest 复制全局定价请求 -type CopyGlobalPricingRequest struct { - Overwrite bool `json:"overwrite"` // 是否覆盖已存在的渠道定价 -} - -// CopyGlobalPricing 复制全局定价到指定渠道 -// 从 ratio_setting 读取全局定价信息,复制到 channel_pricing 表 -func CopyGlobalPricing(c *gin.Context) { - channelIdStr := c.Param("channel_id") - channelId, err := strconv.Atoi(channelIdStr) - if err != nil { - common.ApiError(c, err) - return - } - - var req CopyGlobalPricingRequest - c.ShouldBindJSON(&req) - - // 获取该渠道支持的所有模型(使用 GetAbilitiesByChannelId) - abilities, err := model.GetAbilitiesByChannelId(channelId) - if err != nil { - common.ApiError(c, err) - return - } - - imported := 0 - for _, ability := range abilities { - // 检查是否已存在 - existing, _ := model.GetChannelPricing(ability.Model, channelId) - if existing != nil && !req.Overwrite { - continue - } - - // 获取全局扩展比率 - globalCacheRatio, hasCacheRatio := ratio_setting.GetCacheRatio(ability.Model) - if !hasCacheRatio { - globalCacheRatio = 0 - } - globalCacheCreationRatio, hasCacheCreationRatio := ratio_setting.GetCreateCacheRatio(ability.Model) - if !hasCacheCreationRatio { - globalCacheCreationRatio = 0 - } - globalImageRatio, hasImageRatio := ratio_setting.GetImageRatio(ability.Model) - if !hasImageRatio { - globalImageRatio = 0 - } - globalAudioRatio, hasAudioRatio := ratio_setting.GetAudioRatioV2(ability.Model) - if !hasAudioRatio { - globalAudioRatio = 0 - } - globalAudioCompletionRatio, hasAudioCompRatio := ratio_setting.GetAudioCompletionRatioV2(ability.Model) - if !hasAudioCompRatio { - globalAudioCompletionRatio = 0 - } - - // 确定定价类型 - var quotaType int - var ratio, completionRatio, price float64 - - // 优先检查是否有按次计费的价格 - modelPrice, hasPrice := ratio_setting.GetModelPrice(ability.Model, false) - if hasPrice { - quotaType = model.QuotaTypeByCall - price = modelPrice - } else { - // 使用按量计费 - quotaType = model.QuotaTypeByTokens - modelRatio, hasRatio, _ := ratio_setting.GetModelRatio(ability.Model) - if hasRatio { - ratio = modelRatio - } - completionRatio = ratio_setting.GetCompletionRatio(ability.Model) - } - - if existing != nil { - existing.ApplyFields(quotaType, ratio, completionRatio, price, "", - globalCacheRatio, globalCacheCreationRatio, globalImageRatio, globalAudioRatio, globalAudioCompletionRatio) - if err := existing.Update(); err == nil { - imported++ - } - } else { - cp := &model.ChannelPricing{ - ModelName: ability.Model, - ChannelId: channelId, - } - cp.ApplyFields(quotaType, ratio, completionRatio, price, "", - globalCacheRatio, globalCacheCreationRatio, globalImageRatio, globalAudioRatio, globalAudioCompletionRatio) - if err := cp.Insert(); err == nil { - imported++ - } - } - } - - common.SysLog(fmt.Sprintf("[ChannelPricing] copyGlobalPricing: channel=%d imported=%d/%d", channelId, imported, len(abilities))) - common.ApiSuccess(c, gin.H{ - "total": len(abilities), - "imported": imported, - }) -} - -// ChannelPricingWithTags 带标签详情的渠道定价响应 -type ChannelPricingWithTags struct { - *model.ChannelPricing - Tags []*model.PricingTag `json:"tags"` -} - -// GetChannelPricingByModelWithChannelInfo 获取指定模型的渠道定价(带渠道信息,所有用户可访问) -func GetChannelPricingByModelWithChannelInfo(c *gin.Context) { - // 使用通配符路由时,参数包含前导斜杠,需要去除 - modelName := c.Param("name") - if modelName == "" { - common.ApiErrorMsg(c, "model name is required") - return - } - // 去除前导斜杠(路由是 /channel-pricing/model/*name,name 会是 "/deepseek-ai/xxx") - modelName = strings.TrimPrefix(modelName, "/") - - list, err := model.GetChannelPricingByModelWithChannelInfo(modelName) - if err != nil { - common.ApiError(c, err) - return - } - - loggedIn, userId, userGroup, groupRatio := resolveCurrentUser(c) - - if loggedIn { - for _, item := range list { - userChannelRatio := model.GetUserChannelRatio(userId, modelName, item.ChannelId) - item.UserRatio = groupRatio * userChannelRatio - } - } - - c.JSON(200, gin.H{ - "success": true, - "message": "", - "data": list, - "logged_in": loggedIn, - "group": userGroup, - "group_ratio": groupRatio, - }) -} - -// GetChannelPricingWithTags 获取渠道定价(带标签详情) -func GetChannelPricingWithTags(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("p", "1")) - pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10")) - offset := (page - 1) * pageSize - - list, total, err := model.GetAllChannelPricing(offset, pageSize) - if err != nil { - common.ApiError(c, err) - return - } - - // 获取所有标签 - tags, _ := model.GetAllPricingTags() - tagMap := make(map[int]*model.PricingTag) - for _, tag := range tags { - tagMap[tag.Id] = tag - } - - // 为每个定价填充标签详情 - result := make([]*ChannelPricingWithTags, 0, len(list)) - for _, cp := range list { - item := &ChannelPricingWithTags{ - ChannelPricing: cp, - Tags: model.ParseTagIds(cp.TagIds, tagMap), - } - result = append(result, item) - } - - common.ApiSuccess(c, gin.H{ - "page": page, - "page_size": pageSize, - "total": total, - "items": result, - }) -} - diff --git a/controller/pricing_tag.go b/controller/pricing_tag.go deleted file mode 100644 index c6ebfff..0000000 --- a/controller/pricing_tag.go +++ /dev/null @@ -1,131 +0,0 @@ -package controller - -import ( - "strconv" - - "github.com/QuantumNous/new-api/common" - "github.com/QuantumNous/new-api/model" - - "github.com/gin-gonic/gin" -) - -// GetAllPricingTags 获取所有定价标签 -func GetAllPricingTags(c *gin.Context) { - list, err := model.GetAllPricingTags() - if err != nil { - common.ApiError(c, err) - return - } - common.ApiSuccess(c, list) -} - -// CreatePricingTagRequest 创建定价标签请求 -type CreatePricingTagRequest struct { - Name string `json:"name" binding:"required"` - Color string `json:"color"` - Description string `json:"description"` - SortOrder int `json:"sort_order"` -} - -// CreatePricingTag 创建定价标签 -func CreatePricingTag(c *gin.Context) { - var req CreatePricingTagRequest - if err := c.ShouldBindJSON(&req); err != nil { - common.ApiError(c, err) - return - } - - // 检查名称是否重复 - existing, _ := model.GetPricingTagByName(req.Name) - if existing != nil { - common.ApiErrorMsg(c, "tag name already exists") - return - } - - pt := &model.PricingTag{ - Name: req.Name, - Color: req.Color, - Description: req.Description, - SortOrder: req.SortOrder, - } - if pt.Color == "" { - pt.Color = "#1890ff" - } - if err := pt.Insert(); err != nil { - common.ApiError(c, err) - return - } - - common.ApiSuccess(c, pt) -} - -// UpdatePricingTagRequest 更新定价标签请求 -type UpdatePricingTagRequest struct { - Name string `json:"name"` - Color string `json:"color"` - Description string `json:"description"` - SortOrder int `json:"sort_order"` -} - -// UpdatePricingTag 更新定价标签 -func UpdatePricingTag(c *gin.Context) { - idStr := c.Param("id") - id, err := strconv.Atoi(idStr) - if err != nil { - common.ApiError(c, err) - return - } - - var req UpdatePricingTagRequest - if err := c.ShouldBindJSON(&req); err != nil { - common.ApiError(c, err) - return - } - - pt, err := model.GetPricingTagById(id) - if err != nil { - common.ApiError(c, err) - return - } - - // 检查名称是否与其他标签重复 - if req.Name != "" && req.Name != pt.Name { - existing, _ := model.GetPricingTagByName(req.Name) - if existing != nil && existing.Id != id { - common.ApiErrorMsg(c, "tag name already exists") - return - } - pt.Name = req.Name - } - - if req.Color != "" { - pt.Color = req.Color - } - pt.Description = req.Description - pt.SortOrder = req.SortOrder - - if err := pt.Update(); err != nil { - common.ApiError(c, err) - return - } - - common.ApiSuccess(c, pt) -} - -// DeletePricingTag 删除定价标签 -func DeletePricingTag(c *gin.Context) { - idStr := c.Param("id") - id, err := strconv.Atoi(idStr) - if err != nil { - common.ApiError(c, err) - return - } - - pt := &model.PricingTag{Id: id} - if err := pt.Delete(); err != nil { - common.ApiError(c, err) - return - } - - common.ApiSuccess(c, nil) -} diff --git a/relay/helper/price.go b/relay/helper/price.go index 86ffcc0..f893d11 100644 --- a/relay/helper/price.go +++ b/relay/helper/price.go @@ -18,26 +18,22 @@ import ( // HandleGroupRatio checks for "auto_group" in the context and updates the group ratio and relayInfo.UsingGroup if present func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) types.GroupRatioInfo { groupRatioInfo := types.GroupRatioInfo{ - GroupRatio: 1.0, // default ratio + GroupRatio: 1.0, GroupSpecialRatio: -1, } - // check auto group autoGroup, exists := ctx.Get("auto_group") if exists { logger.LogDebug(ctx, fmt.Sprintf("final group: %s", autoGroup)) relayInfo.UsingGroup = autoGroup.(string) } - // check user group special ratio userGroupRatio, ok := ratio_setting.GetGroupGroupRatio(relayInfo.UserGroup, relayInfo.UsingGroup) if ok { - // user group special ratio groupRatioInfo.GroupSpecialRatio = userGroupRatio groupRatioInfo.GroupRatio = userGroupRatio groupRatioInfo.HasSpecialRatio = true } else { - // normal group ratio groupRatioInfo.GroupRatio = ratio_setting.GetGroupRatio(relayInfo.UsingGroup) } @@ -49,39 +45,8 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens var usePrice bool var modelRatio float64 var completionRatio float64 - var channelPricingFound bool - var cacheRatio, cacheCreationRatio, imageRatio, audioRatio, audioCompletionRatio float64 - - // 尝试获取渠道定价(优先于全局定价) - // ChannelMeta 在 InitChannelMeta 之前为 nil,但 Distribute 已将 channelId 写入 context - channelId := 0 - if info != nil && info.ChannelMeta != nil && info.ChannelId > 0 { - channelId = info.ChannelId - } else { - channelId = common.GetContextKeyInt(c, constant.ContextKeyChannelId) - } - if channelId > 0 { - cp, found := model.GetEffectivePricing(info.OriginModelName, channelId) - if found && cp != nil { - modelRatio = cp.ModelRatio - completionRatio = cp.CompletionRatio - modelPrice = cp.ModelPrice - usePrice = cp.QuotaType == model.QuotaTypeByCall - channelPricingFound = true - // 渠道定价的扩展比率(非零值直接使用) - cacheRatio = cp.CacheRatio - cacheCreationRatio = cp.CacheCreationRatio - imageRatio = cp.ImageRatio - audioRatio = cp.AudioRatio - audioCompletionRatio = cp.AudioCompletionRatio - } - } - - // 如果没有渠道定价,使用全局定价 - if !channelPricingFound { - modelPrice, usePrice = ratio_setting.GetModelPrice(info.OriginModelName, false) - } + modelPrice, usePrice = ratio_setting.GetModelPrice(info.OriginModelName, false) groupRatioInfo := HandleGroupRatio(c, info) var preConsumedQuota int @@ -91,22 +56,21 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens if meta.MaxTokens != 0 { preConsumedTokens += meta.MaxTokens } - // 如果没有找到渠道定价,获取全局 modelRatio 和 completionRatio - if !channelPricingFound { - var success bool - var matchName string - modelRatio, success, matchName = ratio_setting.GetModelRatio(info.OriginModelName) - if !success { - acceptUnsetRatio := false - if info.UserSetting.AcceptUnsetRatioModel { - acceptUnsetRatio = true - } - if !acceptUnsetRatio { - return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开启自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName) - } + + var success bool + var matchName string + modelRatio, success, matchName = ratio_setting.GetModelRatio(info.OriginModelName) + if !success { + acceptUnsetRatio := false + if info.UserSetting.AcceptUnsetRatioModel { + acceptUnsetRatio = true + } + if !acceptUnsetRatio { + return types.PriceData{}, fmt.Errorf("模型 %s 倍率或价格未配置,请联系管理员设置或开启自用模式;Model %s ratio or price not set, please set or start self-use mode", matchName, matchName) } - completionRatio = ratio_setting.GetCompletionRatio(info.OriginModelName) } + completionRatio = ratio_setting.GetCompletionRatio(info.OriginModelName) + ratio := modelRatio * groupRatioInfo.GroupRatio preConsumedQuota = int(float64(preConsumedTokens) * ratio) } else { @@ -116,27 +80,13 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens preConsumedQuota = int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) } - // 全局比率作为回退(仅当渠道未设置、值为 0 时生效) - // 必须放在 usePrice 判断之外,因为 UpdatePriceDataForChannelPricing 可能改变 UsePrice - if cacheRatio == 0 { - cacheRatio, _ = ratio_setting.GetCacheRatio(info.OriginModelName) - } - if cacheCreationRatio == 0 { - cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(info.OriginModelName) - } - if imageRatio == 0 { - imageRatio, _ = ratio_setting.GetImageRatio(info.OriginModelName) - } - if audioRatio == 0 { - audioRatio = ratio_setting.GetAudioRatio(info.OriginModelName) - } - if audioCompletionRatio == 0 { - audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(info.OriginModelName) - } + cacheRatio, _ := ratio_setting.GetCacheRatio(info.OriginModelName) + cacheCreationRatio, _ := ratio_setting.GetCreateCacheRatio(info.OriginModelName) + imageRatio, _ := ratio_setting.GetImageRatio(info.OriginModelName) + audioRatio := ratio_setting.GetAudioRatio(info.OriginModelName) + audioCompletionRatio := ratio_setting.GetAudioCompletionRatio(info.OriginModelName) - // check if free model pre-consume is disabled if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume { - // if model price or ratio is 0, do not pre-consume quota if groupRatioInfo.GroupRatio == 0 { preConsumedQuota = 0 freeModel = true @@ -145,11 +95,9 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens preConsumedQuota = 0 freeModel = true } - } else { - if modelRatio == 0 { - preConsumedQuota = 0 - freeModel = true - } + } else if modelRatio == 0 { + preConsumedQuota = 0 + freeModel = true } } @@ -183,7 +131,6 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) types. groupRatioInfo := HandleGroupRatio(c, info) modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true) - // 如果没有配置价格,则使用默认价格 if !success { defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName] if !ok { @@ -194,7 +141,6 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) types. } quota := int(modelPrice * common.QuotaPerUnit * groupRatioInfo.GroupRatio) - // 免费模型检测(与 ModelPriceHelper 对齐) freeModel := false if !operation_setting.GetQuotaSetting().EnableFreeModelPreConsume { if groupRatioInfo.GroupRatio == 0 || modelPrice == 0 { @@ -204,11 +150,11 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) types. } priceData := types.PriceData{ - FreeModel: freeModel, - ModelPrice: modelPrice, - Quota: quota, - GroupRatioInfo: groupRatioInfo, - UserChannelRatio: 1.0, + FreeModel: freeModel, + ModelPrice: modelPrice, + Quota: quota, + GroupRatioInfo: groupRatioInfo, + UserChannelRatio: 1.0, } return priceData } @@ -225,45 +171,11 @@ func ContainPriceOrRatio(modelName string) bool { return false } -// UpdatePriceDataForChannelPricing 在渠道选择后更新 PriceData 以使用渠道定价 -// ModelPriceHelper 在渠道选择之前被调用,此时 ChannelMeta 为 nil,导致渠道定价无法使用。 -// 需要在渠道选择后调用此函数更新价格数据,用于后续的日志记录和计费计算。 +// UpdatePriceDataForChannelPricing 保留原调用点,仅在选定渠道后应用用户渠道倍率。 func UpdatePriceDataForChannelPricing(c *gin.Context, info *relaycommon.RelayInfo, channelId int) { if info == nil || channelId <= 0 { return } - - cp, found := model.GetEffectivePricing(info.OriginModelName, channelId) - 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.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 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) } diff --git a/relay/helper/price_test.go b/relay/helper/price_test.go index 76554ff..81b2780 100644 --- a/relay/helper/price_test.go +++ b/relay/helper/price_test.go @@ -6,13 +6,14 @@ 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" "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/types" - "github.com/glebarez/sqlite" "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "gorm.io/gorm" @@ -23,54 +24,44 @@ const testModelUsePriceSwitch = "test-useprice-switch-model" func setupUsePriceSwitchTest(t *testing.T) { t.Helper() - // 初始化 SQLite 内存数据库 db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) require.NoError(t, err) - sqlDB, _ := db.DB() + + sqlDB, err := db.DB() + require.NoError(t, err) sqlDB.SetMaxOpenConns(1) origDB := model.DB + origUsingSQLite := common.UsingSQLite + origRedisEnabled := common.RedisEnabled model.DB = db common.UsingSQLite = true common.RedisEnabled = false - require.NoError(t, db.AutoMigrate(&model.ChannelPricing{})) + require.NoError(t, db.AutoMigrate(&model.UserChannelRatio{})) - // 全局定价:按次计费(modelPrice 存在 → usePrice=true) require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{"`+testModelUsePriceSwitch+`":0.5}`)) - // 全局 modelRatio 也设上(渠道按量计费时会用到) require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{"`+testModelUsePriceSwitch+`":15}`)) require.NoError(t, ratio_setting.UpdateCompletionRatioByJSONString(`{"`+testModelUsePriceSwitch+`":3}`)) - // 全局扩展比率 require.NoError(t, ratio_setting.UpdateCacheRatioByJSONString(`{"`+testModelUsePriceSwitch+`":0.1}`)) require.NoError(t, ratio_setting.UpdateCreateCacheRatioByJSONString(`{"`+testModelUsePriceSwitch+`":1.25}`)) require.NoError(t, ratio_setting.UpdateImageRatioByJSONString(`{"`+testModelUsePriceSwitch+`":2.0}`)) require.NoError(t, ratio_setting.UpdateAudioRatioByJSONString(`{"`+testModelUsePriceSwitch+`":5.0}`)) require.NoError(t, ratio_setting.UpdateAudioCompletionRatioByJSONString(`{"`+testModelUsePriceSwitch+`":3.0}`)) - // 渠道定价:按量计费(QuotaTypeByTokens),扩展比率全部为 0(用全局) - cp := &model.ChannelPricing{ - ModelName: testModelUsePriceSwitch, - ChannelId: 9901, - QuotaType: model.QuotaTypeByTokens, - ModelRatio: 15, - CompletionRatio: 3, - // 扩展比率全部为 0 → 意味着回退全局 - } - require.NoError(t, cp.Insert()) - t.Cleanup(func() { model.DB = origDB - sqlDB.Close() - // 清理全局定价 - ratio_setting.UpdateModelPriceByJSONString(`{}`) - ratio_setting.UpdateModelRatioByJSONString(`{}`) - ratio_setting.UpdateCompletionRatioByJSONString(`{}`) - ratio_setting.UpdateCacheRatioByJSONString(`{}`) - ratio_setting.UpdateCreateCacheRatioByJSONString(`{}`) - ratio_setting.UpdateImageRatioByJSONString(`{}`) - ratio_setting.UpdateAudioRatioByJSONString(`{}`) - ratio_setting.UpdateAudioCompletionRatioByJSONString(`{}`) + common.UsingSQLite = origUsingSQLite + common.RedisEnabled = origRedisEnabled + require.NoError(t, sqlDB.Close()) + require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{}`)) + require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(`{}`)) + require.NoError(t, ratio_setting.UpdateCompletionRatioByJSONString(`{}`)) + require.NoError(t, ratio_setting.UpdateCacheRatioByJSONString(`{}`)) + require.NoError(t, ratio_setting.UpdateCreateCacheRatioByJSONString(`{}`)) + require.NoError(t, ratio_setting.UpdateImageRatioByJSONString(`{}`)) + require.NoError(t, ratio_setting.UpdateAudioRatioByJSONString(`{}`)) + require.NoError(t, ratio_setting.UpdateAudioCompletionRatioByJSONString(`{}`)) }) } @@ -79,88 +70,81 @@ func buildTestContext(t *testing.T) *gin.Context { gin.SetMode(gin.TestMode) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) - c.Request, _ = http.NewRequest("POST", "/v1/chat/completions", nil) + req, err := http.NewRequest(http.MethodPost, "/v1/chat/completions", nil) + require.NoError(t, err) + c.Request = req return c } -// TestGlobalRatiosFallbackWhenGlobalUsePriceTrue 验证核心场景: -// 全局按次计费 → 渠道按量计费 → PriceData 中扩展比率仍应被全局默认值填充 -// 而不是保持 0(否则后续 UpdatePriceDataForChannelPricing 切换为按量时会丢失比率) -func TestGlobalRatiosFallbackWhenGlobalUsePriceTrue(t *testing.T) { +func TestGlobalRatiosFilledWhenUsePriceTrue(t *testing.T) { setupUsePriceSwitchTest(t) c := buildTestContext(t) - // 模拟渠道选择后的场景:ChannelMeta 不为空,能查到渠道定价 info := &relaycommon.RelayInfo{ OriginModelName: testModelUsePriceSwitch, - ChannelMeta: &relaycommon.ChannelMeta{ - ChannelId: 9901, - }, - UserSetting: dto.UserSetting{}, + UserSetting: dto.UserSetting{}, } - meta := &types.TokenCountMeta{} - priceData, err := ModelPriceHelper(c, info, 100, meta) + priceData, err := ModelPriceHelper(c, info, 100, &types.TokenCountMeta{}) require.NoError(t, err) - // 断言:渠道按量计费覆盖了全局按次,扩展比率必须被全局默认值填充,不能是 0 - assert.Equal(t, 0.1, priceData.CacheRatio, "CacheRatio 应从全局回退填充") - assert.Equal(t, 1.25, priceData.CacheCreationRatio, "CacheCreationRatio 应从全局回退填充") - assert.Equal(t, 2.0, priceData.ImageRatio, "ImageRatio 应从全局回退填充") - assert.Equal(t, 5.0, priceData.AudioRatio, "AudioRatio 应从全局回退填充") - assert.Equal(t, 3.0, priceData.AudioCompletionRatio, "AudioCompletionRatio 应从全局回退填充") + assert.True(t, priceData.UsePrice) + assert.Equal(t, 0.5, priceData.ModelPrice) + assert.Equal(t, 0.1, priceData.CacheRatio) + assert.Equal(t, 1.25, priceData.CacheCreationRatio) + assert.Equal(t, 2.0, priceData.ImageRatio) + assert.Equal(t, 5.0, priceData.AudioRatio) + assert.Equal(t, 3.0, priceData.AudioCompletionRatio) } -// TestGlobalRatiosFallbackWithoutChannelPricing 验证无渠道定价时全局比率也正常回退 -func TestGlobalRatiosFallbackWithoutChannelPricing(t *testing.T) { +func TestGlobalRatiosFilledWhenUseRatioMode(t *testing.T) { setupUsePriceSwitchTest(t) c := buildTestContext(t) - // 无 ChannelMeta → 渠道定价不可用,走全局 + require.NoError(t, ratio_setting.UpdateModelPriceByJSONString(`{}`)) + info := &relaycommon.RelayInfo{ OriginModelName: testModelUsePriceSwitch, UserSetting: dto.UserSetting{}, } - meta := &types.TokenCountMeta{} - priceData, err := ModelPriceHelper(c, info, 100, meta) + priceData, err := ModelPriceHelper(c, info, 100, &types.TokenCountMeta{}) require.NoError(t, err) - // 全局是按次计费,UsePrice=true,但比率仍应被填充 - assert.True(t, priceData.UsePrice) - assert.Equal(t, 0.1, priceData.CacheRatio, "CacheRatio 应从全局回退填充") - assert.Equal(t, 2.0, priceData.ImageRatio, "ImageRatio 应从全局回退填充") + assert.False(t, priceData.UsePrice) + assert.Equal(t, 15.0, priceData.ModelRatio) + assert.Equal(t, 3.0, priceData.CompletionRatio) + assert.Equal(t, 0.1, priceData.CacheRatio) + assert.Equal(t, 2.0, priceData.ImageRatio) } -// TestChannelRatiosOverrideGlobal 验证渠道非零值优先于全局 -func TestChannelRatiosOverrideGlobal(t *testing.T) { +func TestUpdatePriceDataForChannelPricingOnlyAppliesUserChannelRatio(t *testing.T) { setupUsePriceSwitchTest(t) - // 渠道定价设了自定义 cacheRatio=0.5 - cp := &model.ChannelPricing{ - ModelName: testModelUsePriceSwitch, - ChannelId: 9902, - QuotaType: model.QuotaTypeByTokens, - ModelRatio: 15, - CompletionRatio: 3, - CacheRatio: 0.5, // 渠道自定义,覆盖全局 0.1 - // 其他扩展比率保持 0,用全局 + ratio := &model.UserChannelRatio{ + UserId: 9, + ModelName: testModelUsePriceSwitch, + ChannelId: 9901, + Ratio: 0.8, } - require.NoError(t, cp.Insert()) + require.NoError(t, ratio.Insert()) c := buildTestContext(t) + c.Set(string(constant.ContextKeyUserId), 9) + info := &relaycommon.RelayInfo{ OriginModelName: testModelUsePriceSwitch, - ChannelMeta: &relaycommon.ChannelMeta{ - ChannelId: 9902, + PriceData: types.PriceData{ + ModelPrice: 0.5, + UsePrice: true, + QuotaToPreConsume: 1000, + UserChannelRatio: 1.0, }, - UserSetting: dto.UserSetting{}, } - priceData, err := ModelPriceHelper(c, info, 100, &types.TokenCountMeta{}) - require.NoError(t, err) + UpdatePriceDataForChannelPricing(c, info, 9901) - assert.Equal(t, 0.5, priceData.CacheRatio, "渠道自定义 CacheRatio 应覆盖全局") - assert.Equal(t, 2.0, priceData.ImageRatio, "ImageRatio 未设渠道值,应从全局回退") - assert.Equal(t, 5.0, priceData.AudioRatio, "AudioRatio 未设渠道值,应从全局回退") + assert.Equal(t, 0.8, info.PriceData.UserChannelRatio) + assert.Equal(t, 800, info.PriceData.QuotaToPreConsume) + assert.Equal(t, 0.5, info.PriceData.ModelPrice) } diff --git a/router/api-router.go b/router/api-router.go index f3606d9..946b316 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -33,7 +33,6 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/home_page_content", controller.GetHomePageContent) apiRouter.GET("/pricing", middleware.TryUserAuth(), controller.GetPricing) apiRouter.GET("/pricing/user/*model", middleware.TryUserAuth(), controller.GetUserPricing) - apiRouter.GET("/channel-pricing/model/*name", middleware.TryUserAuth(), controller.GetChannelPricingByModelWithChannelInfo) apiRouter.GET("/captcha", controller.GetCaptcha) apiRouter.GET("/verification", middleware.EmailVerificationRateLimit(), middleware.TurnstileCheck(), controller.SendEmailVerification) apiRouter.GET("/reset_password", middleware.CriticalRateLimit(), middleware.TurnstileCheck(), controller.SendPasswordResetEmail) @@ -192,19 +191,6 @@ func SetApiRouter(router *gin.Engine) { optionRoute.POST("/migrate_console_setting", controller.MigrateConsoleSetting) // 用于迁移检测的旧键,下个版本会删除 } - // 渠道定价路由(管理员权限) - channelPricingRoute := apiRouter.Group("/channel_pricing") - channelPricingRoute.Use(middleware.AdminAuth()) - { - channelPricingRoute.GET("/", controller.GetAllChannelPricing) - channelPricingRoute.GET("/with_tags", controller.GetChannelPricingWithTags) - channelPricingRoute.GET("/model/:name", controller.GetChannelPricingByModel) - channelPricingRoute.POST("/", controller.CreateChannelPricing) - channelPricingRoute.POST("/batch", controller.BatchCreateChannelPricing) - channelPricingRoute.POST("/copy_global/:channel_id", controller.CopyGlobalPricing) - channelPricingRoute.DELETE("/:id", controller.DeleteChannelPricing) - } - // 用户倍率路由(管理员权限) userChannelRatioRoute := apiRouter.Group("/user_channel_ratio") userChannelRatioRoute.Use(middleware.AdminAuth()) @@ -215,16 +201,6 @@ func SetApiRouter(router *gin.Engine) { userChannelRatioRoute.DELETE("/:id", controller.DeleteUserChannelRatio) } - // 定价标签路由(管理员权限) - pricingTagRoute := apiRouter.Group("/pricing_tag") - pricingTagRoute.Use(middleware.AdminAuth()) - { - pricingTagRoute.GET("/", controller.GetAllPricingTags) - pricingTagRoute.POST("/", controller.CreatePricingTag) - pricingTagRoute.PUT("/:id", controller.UpdatePricingTag) - pricingTagRoute.DELETE("/:id", controller.DeletePricingTag) - } - // 邮箱后缀额度规则路由(管理员权限) emailQuotaRuleRoute := apiRouter.Group("/email_quota_rule") emailQuotaRuleRoute.Use(middleware.AdminAuth())