From 2d4c73d3aac94c9edef5f0a1c8d1e57cbfc483cd Mon Sep 17 00:00:00 2001 From: fengsilin Date: Thu, 16 Apr 2026 23:28:25 +0800 Subject: [PATCH 1/9] =?UTF-8?q?refactor(channel-pricing):=20=E7=BB=93?= =?UTF-8?q?=E6=9E=84=E4=BD=93=E6=96=B0=E5=A2=9E=E6=89=A9=E5=B1=95=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=20+=20=E7=BC=93=E5=AD=98=E9=87=8D=E5=86=99=E4=B8=BA?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E5=8A=A0=E8=BD=BD=E5=86=99=E7=A9=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChannelPricing 结构体新增 5 个扩展计费字段: cache_ratio, cache_creation_ratio, image_ratio, audio_ratio, audio_completion_ratio - 缓存机制从 TTL+惰性加载重写为全量加载+写穿模式,消除 DB 查询延迟 - Insert/Update/Delete 改为写穿缓存(直接更新内存,不再整表失效) - 新增 LoadChannelPricingCache 启动时全量加载函数 - 删除 RefreshChannelPricingCache/InvalidateChannelPricingCache(不再需要) - BatchUpsertChannelPricing DoUpdates 列表同步新增 5 字段 - GetEffectivePricing 签名改为 (*ChannelPricing, bool)(调用方适配在后续 Task) Co-Authored-By: Claude Opus 4.6 --- model/channel_pricing.go | 116 ++++++++++++++++----------------------- 1 file changed, 48 insertions(+), 68 deletions(-) diff --git a/model/channel_pricing.go b/model/channel_pricing.go index ddc764f..6f82634 100644 --- a/model/channel_pricing.go +++ b/model/channel_pricing.go @@ -5,7 +5,6 @@ import ( "strconv" "strings" "sync" - "time" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/setting/ratio_setting" @@ -17,8 +16,6 @@ import ( var ( channelPricingCache = make(map[string]*ChannelPricing) // key: "modelName:channelId" channelPricingCacheLock sync.RWMutex - channelPricingCacheTime time.Time - channelPricingCacheTTL = time.Minute * 5 // 缓存5分钟 ) // QuotaType 计费类型 @@ -41,6 +38,12 @@ type ChannelPricing struct { CreatedTime int64 `json:"created_time" gorm:"bigint"` UpdatedTime int64 `json:"updated_time" gorm:"bigint"` DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + // === 新增字段(0 = 未设置,回退全局值) === + CacheRatio float64 `json:"cache_ratio" gorm:"default:0"` + CacheCreationRatio float64 `json:"cache_creation_ratio" gorm:"default:0"` + ImageRatio float64 `json:"image_ratio" gorm:"default:0"` + AudioRatio float64 `json:"audio_ratio" gorm:"default:0"` + AudioCompletionRatio float64 `json:"audio_completion_ratio" gorm:"default:0"` } func (cp *ChannelPricing) Insert() error { @@ -49,31 +52,41 @@ func (cp *ChannelPricing) Insert() error { cp.UpdatedTime = now err := DB.Create(cp).Error if err == nil { - InvalidateChannelPricingCache() + key := getChannelPricingCacheKey(cp.ModelName, cp.ChannelId) + channelPricingCacheLock.Lock() + channelPricingCache[key] = cp + channelPricingCacheLock.Unlock() } return err } func (cp *ChannelPricing) Update() error { cp.UpdatedTime = common.GetTimestamp() - err := DB.Model(&ChannelPricing{}).Where("id = ?", cp.Id).Updates(map[string]interface{}{ - "quota_type": cp.QuotaType, - "model_ratio": cp.ModelRatio, - "completion_ratio": cp.CompletionRatio, - "model_price": cp.ModelPrice, - "tag_ids": cp.TagIds, - "updated_time": cp.UpdatedTime, - }).Error + err := DB.Model(&ChannelPricing{}).Where("id = ?", cp.Id). + Select("quota_type", "model_ratio", "completion_ratio", "model_price", + "tag_ids", "cache_ratio", "cache_creation_ratio", "image_ratio", + "audio_ratio", "audio_completion_ratio", "updated_time"). + Updates(cp).Error if err == nil { - InvalidateChannelPricingCache() + key := getChannelPricingCacheKey(cp.ModelName, cp.ChannelId) + channelPricingCacheLock.Lock() + channelPricingCache[key] = cp + channelPricingCacheLock.Unlock() } return err } func (cp *ChannelPricing) Delete() error { + var existing ChannelPricing + if err := DB.First(&existing, cp.Id).Error; err != nil { + return err + } err := DB.Delete(cp).Error if err == nil { - InvalidateChannelPricingCache() + key := getChannelPricingCacheKey(existing.ModelName, existing.ChannelId) + channelPricingCacheLock.Lock() + delete(channelPricingCache, key) + channelPricingCacheLock.Unlock() } return err } @@ -132,6 +145,11 @@ func BatchUpsertChannelPricing(pricings []*ChannelPricing) error { "completion_ratio", "model_price", "tag_ids", + "cache_ratio", + "cache_creation_ratio", + "image_ratio", + "audio_ratio", + "audio_completion_ratio", "updated_time", }), }).Create(&pricings).Error @@ -142,71 +160,33 @@ func getChannelPricingCacheKey(modelName string, channelId int) string { return fmt.Sprintf("%s:%d", modelName, channelId) } -// GetEffectivePricing 获取有效定价(优先渠道定价,回退全局定价) -// 返回: modelRatio, completionRatio, modelPrice, usePrice, found -func GetEffectivePricing(modelName string, channelId int) (modelRatio, completionRatio, modelPrice float64, usePrice, found bool) { - cacheKey := getChannelPricingCacheKey(modelName, channelId) - - // 首先检查缓存 +// GetEffectivePricing 获取有效定价(纯内存查找) +func GetEffectivePricing(modelName string, channelId int) (*ChannelPricing, bool) { + key := getChannelPricingCacheKey(modelName, channelId) channelPricingCacheLock.RLock() - // 检查缓存是否过期 - if time.Since(channelPricingCacheTime) < channelPricingCacheTTL { - if cp, ok := channelPricingCache[cacheKey]; ok { - channelPricingCacheLock.RUnlock() - return cp.ModelRatio, cp.CompletionRatio, cp.ModelPrice, cp.QuotaType == QuotaTypeByCall, true - } - } + cp, ok := channelPricingCache[key] channelPricingCacheLock.RUnlock() - - // 缓存未命中或已过期,查询数据库 - var cp ChannelPricing - err := DB.Where("model_name = ? AND channel_id = ?", modelName, channelId).First(&cp).Error - if err != nil { - // 未找到渠道定价,返回 false 让调用者使用全局定价 - return 0, 0, 0, false, false - } - - // 更新缓存 - channelPricingCacheLock.Lock() - if channelPricingCacheTime.IsZero() || time.Since(channelPricingCacheTime) >= channelPricingCacheTTL { - // 缓存过期,清空并更新时间 - channelPricingCache = make(map[string]*ChannelPricing) - channelPricingCacheTime = time.Now() + if !ok { + return nil, false } - channelPricingCache[cacheKey] = &cp - channelPricingCacheLock.Unlock() - - return cp.ModelRatio, cp.CompletionRatio, cp.ModelPrice, cp.QuotaType == QuotaTypeByCall, true + return cp, true } -// RefreshChannelPricingCache 刷新渠道定价缓存 -func RefreshChannelPricingCache() { - channelPricingCacheLock.Lock() - defer channelPricingCacheLock.Unlock() - - // 清空缓存 - channelPricingCache = make(map[string]*ChannelPricing) - channelPricingCacheTime = time.Now() - - // 预加载所有渠道定价 +// LoadChannelPricingCache 全量加载渠道定价到内存(启动时调用) +func LoadChannelPricingCache() { var pricings []*ChannelPricing if err := DB.Find(&pricings).Error; err != nil { + common.SysError("[ChannelPricing] LoadChannelPricingCache failed: " + err.Error()) return } - + channelPricingCacheLock.Lock() + channelPricingCache = make(map[string]*ChannelPricing, len(pricings)) for _, cp := range pricings { - cacheKey := getChannelPricingCacheKey(cp.ModelName, cp.ChannelId) - channelPricingCache[cacheKey] = cp + key := getChannelPricingCacheKey(cp.ModelName, cp.ChannelId) + channelPricingCache[key] = cp } -} - -// InvalidateChannelPricingCache 使渠道定价缓存失效 -func InvalidateChannelPricingCache() { - channelPricingCacheLock.Lock() - defer channelPricingCacheLock.Unlock() - - channelPricingCache = make(map[string]*ChannelPricing) - channelPricingCacheTime = time.Time{} // 重置为零值 + channelPricingCacheLock.Unlock() + common.SysLog(fmt.Sprintf("[ChannelPricing] cache loaded %d records", len(pricings))) } // ChannelPricingWithChannel 带渠道信息的定价响应 From e5f029f91ee5a6ba9d4be28e7685a587145e83de Mon Sep 17 00:00:00 2001 From: fengsilin Date: Thu, 16 Apr 2026 23:31:43 +0800 Subject: [PATCH 2/9] =?UTF-8?q?feat(channel-pricing):=20InitDB=20=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E6=97=B6=E5=85=A8=E9=87=8F=E5=8A=A0=E8=BD=BD=E6=B8=A0?= =?UTF-8?q?=E9=81=93=E5=AE=9A=E4=BB=B7=E7=BC=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- model/main.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/model/main.go b/model/main.go index a78a317..412c52c 100644 --- a/model/main.go +++ b/model/main.go @@ -203,7 +203,11 @@ func InitDB() (err error) { } common.SysLog("database migration started") err = migrateDB() - return err + if err != nil { + return err + } + LoadChannelPricingCache() + return nil } else { common.FatalLog(err) } From 3a24fd598fdb14849506cdb77414d60352d3ce39 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Thu, 16 Apr 2026 23:35:50 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat(channel-pricing):=20ModelPriceHelper?= =?UTF-8?q?=20+=20UpdatePriceDataForChannelPricing=20=E9=80=82=E9=85=8D?= =?UTF-8?q?=E6=96=B0=E7=AD=BE=E5=90=8D=EF=BC=8C=E6=94=AF=E6=8C=81=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E6=AF=94=E7=8E=87=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- relay/helper/price.go | 115 ++++++++++++++++++++++++++++++------------ 1 file changed, 83 insertions(+), 32 deletions(-) diff --git a/relay/helper/price.go b/relay/helper/price.go index b63467a..532f739 100644 --- a/relay/helper/price.go +++ b/relay/helper/price.go @@ -52,17 +52,44 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens var modelRatio float64 var completionRatio float64 var channelPricingFound bool + var cacheRatio float64 + var imageRatio float64 + var cacheCreationRatio float64 + var cacheCreationRatio5m float64 + var cacheCreationRatio1h float64 + var audioRatio float64 + var audioCompletionRatio float64 // 尝试获取渠道定价(优先于全局定价) channelMetaAvailable := info != nil && info.ChannelMeta != nil && info.ChannelId > 0 if channelMetaAvailable { - cpRatio, cpCompletionRatio, cpPrice, cpUsePrice, found := model.GetEffectivePricing(info.OriginModelName, info.ChannelId) + cp, found := model.GetEffectivePricing(info.OriginModelName, info.ChannelId) if found { - modelRatio = cpRatio - completionRatio = cpCompletionRatio - modelPrice = cpPrice - usePrice = cpUsePrice + modelRatio = cp.ModelRatio + completionRatio = cp.CompletionRatio + modelPrice = cp.ModelPrice + usePrice = cp.QuotaType == model.QuotaTypeByCall channelPricingFound = true + if cp.CacheRatio != 0 { + cacheRatio = cp.CacheRatio + } + if cp.CacheCreationRatio != 0 { + cacheCreationRatio = cp.CacheCreationRatio + cacheCreationRatio5m = cp.CacheCreationRatio + cacheCreationRatio1h = cp.CacheCreationRatio * claudeCacheCreation1hMultiplier + } + if cp.ImageRatio != 0 { + imageRatio = cp.ImageRatio + } + if cp.AudioRatio != 0 { + audioRatio = cp.AudioRatio + } + if cp.AudioCompletionRatio != 0 { + audioCompletionRatio = cp.AudioCompletionRatio + } + if common.DebugEnabled { + println(fmt.Sprintf("[ChannelPricing] hit: model=%s channel=%d source=cache", info.OriginModelName, info.ChannelId)) + } } } @@ -74,13 +101,6 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens groupRatioInfo := HandleGroupRatio(c, info) var preConsumedQuota int - var cacheRatio float64 - var imageRatio float64 - var cacheCreationRatio float64 - var cacheCreationRatio5m float64 - var cacheCreationRatio1h float64 - var audioRatio float64 - var audioCompletionRatio float64 var freeModel bool if !usePrice { preConsumedTokens := common.Max(promptTokens, common.PreConsumedQuota) @@ -103,14 +123,24 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens } completionRatio = ratio_setting.GetCompletionRatio(info.OriginModelName) } - cacheRatio, _ = ratio_setting.GetCacheRatio(info.OriginModelName) - cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(info.OriginModelName) - cacheCreationRatio5m = cacheCreationRatio - // 固定1h和5min缓存写入价格的比例 - cacheCreationRatio1h = cacheCreationRatio * claudeCacheCreation1hMultiplier - imageRatio, _ = ratio_setting.GetImageRatio(info.OriginModelName) - audioRatio = ratio_setting.GetAudioRatio(info.OriginModelName) - audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(info.OriginModelName) + if cacheRatio == 0 { + cacheRatio, _ = ratio_setting.GetCacheRatio(info.OriginModelName) + } + if cacheCreationRatio == 0 { + cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(info.OriginModelName) + cacheCreationRatio5m = cacheCreationRatio + // 固定1h和5min缓存写入价格的比例 + cacheCreationRatio1h = cacheCreationRatio * claudeCacheCreation1hMultiplier + } + 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) + } ratio := modelRatio * groupRatioInfo.GroupRatio preConsumedQuota = int(float64(preConsumedTokens) * ratio) } else { @@ -217,30 +247,51 @@ func UpdatePriceDataForChannelPricing(c *gin.Context, info *relaycommon.RelayInf return } - cpRatio, cpCompletionRatio, cpPrice, cpUsePrice, found := model.GetEffectivePricing(info.OriginModelName, channelId) + cp, found := model.GetEffectivePricing(info.OriginModelName, channelId) if !found { return } - // 更新 PriceData 中的定价相关字段 - info.PriceData.ModelRatio = cpRatio - info.PriceData.CompletionRatio = cpCompletionRatio - info.PriceData.UsePrice = cpUsePrice - // 按次计费模式下使用渠道价格,按量计费模式下设置 ModelPrice = -1 让前端识别计费模式 - if cpUsePrice { - info.PriceData.ModelPrice = cpPrice + 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 } - // 重新计算预扣费额度(用于后续可能的引用) - if cpUsePrice { - info.PriceData.QuotaToPreConsume = int(cpPrice * common.QuotaPerUnit * info.PriceData.GroupRatioInfo.GroupRatio) + if cp.CacheRatio != 0 { + info.PriceData.CacheRatio = cp.CacheRatio + } + if cp.CacheCreationRatio != 0 { + info.PriceData.CacheCreationRatio = cp.CacheCreationRatio + info.PriceData.CacheCreation5mRatio = cp.CacheCreationRatio + info.PriceData.CacheCreation1hRatio = cp.CacheCreationRatio * claudeCacheCreation1hMultiplier + } + if cp.ImageRatio != 0 { + info.PriceData.ImageRatio = cp.ImageRatio + } + if cp.AudioRatio != 0 { + info.PriceData.AudioRatio = cp.AudioRatio + } + if cp.AudioCompletionRatio != 0 { + info.PriceData.AudioCompletionRatio = 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 := cpRatio * info.PriceData.GroupRatioInfo.GroupRatio + 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)) + } } From 646f37dd4b87cf5dcbe5ecf21feb6b3d83e8d01f Mon Sep 17 00:00:00 2001 From: fengsilin Date: Fri, 17 Apr 2026 09:15:48 +0800 Subject: [PATCH 4/9] =?UTF-8?q?feat(channel-pricing):=20Controller=20?= =?UTF-8?q?=E6=89=A9=E5=B1=95=20API=20=E6=94=AF=E6=8C=81=E6=96=B0=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=20+=20=E8=BE=93=E5=85=A5=E6=A0=A1=E9=AA=8C=20+=20?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- controller/channel_pricing.go | 119 ++++++++++++++++++++++++++-------- 1 file changed, 91 insertions(+), 28 deletions(-) diff --git a/controller/channel_pricing.go b/controller/channel_pricing.go index f904205..5e9d456 100644 --- a/controller/channel_pricing.go +++ b/controller/channel_pricing.go @@ -1,6 +1,7 @@ package controller import ( + "fmt" "strconv" "strings" @@ -50,14 +51,19 @@ func GetChannelPricingByModel(c *gin.Context) { // 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"` + 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"` } // CreateChannelPricing 创建或更新渠道定价 @@ -68,6 +74,11 @@ func CreateChannelPricing(c *gin.Context) { 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 { @@ -77,29 +88,43 @@ func CreateChannelPricing(c *gin.Context) { existing.CompletionRatio = req.CompletionRatio existing.ModelPrice = req.ModelPrice existing.TagIds = req.TagIds + existing.CacheRatio = req.CacheRatio + existing.CacheCreationRatio = req.CacheCreationRatio + existing.ImageRatio = req.ImageRatio + existing.AudioRatio = req.AudioRatio + existing.AudioCompletionRatio = req.AudioCompletionRatio 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, - QuotaType: req.QuotaType, - ModelRatio: req.ModelRatio, - CompletionRatio: req.CompletionRatio, - ModelPrice: req.ModelPrice, - TagIds: req.TagIds, + ModelName: req.ModelName, + ChannelId: req.ChannelId, + QuotaType: req.QuotaType, + ModelRatio: req.ModelRatio, + CompletionRatio: req.CompletionRatio, + ModelPrice: req.ModelPrice, + TagIds: req.TagIds, + CacheRatio: req.CacheRatio, + CacheCreationRatio: req.CacheCreationRatio, + ImageRatio: req.ImageRatio, + AudioRatio: req.AudioRatio, + AudioCompletionRatio: req.AudioCompletionRatio, } 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) } @@ -119,13 +144,18 @@ func BatchCreateChannelPricing(c *gin.Context) { pricings := make([]*model.ChannelPricing, 0, len(req.Items)) for _, item := range req.Items { pricings = append(pricings, &model.ChannelPricing{ - ModelName: item.ModelName, - ChannelId: item.ChannelId, - QuotaType: item.QuotaType, - ModelRatio: item.ModelRatio, - CompletionRatio: item.CompletionRatio, - ModelPrice: item.ModelPrice, - TagIds: item.TagIds, + ModelName: item.ModelName, + ChannelId: item.ChannelId, + QuotaType: item.QuotaType, + ModelRatio: item.ModelRatio, + CompletionRatio: item.CompletionRatio, + ModelPrice: item.ModelPrice, + TagIds: item.TagIds, + CacheRatio: item.CacheRatio, + CacheCreationRatio: item.CacheCreationRatio, + ImageRatio: item.ImageRatio, + AudioRatio: item.AudioRatio, + AudioCompletionRatio: item.AudioCompletionRatio, }) } @@ -138,6 +168,11 @@ func BatchCreateChannelPricing(c *gin.Context) { existing.CompletionRatio = cp.CompletionRatio existing.ModelPrice = cp.ModelPrice existing.TagIds = cp.TagIds + existing.CacheRatio = cp.CacheRatio + existing.CacheCreationRatio = cp.CacheCreationRatio + existing.ImageRatio = cp.ImageRatio + existing.AudioRatio = cp.AudioRatio + existing.AudioCompletionRatio = cp.AudioCompletionRatio if err := existing.Update(); err != nil { common.ApiError(c, err) return @@ -168,6 +203,7 @@ func DeleteChannelPricing(c *gin.Context) { return } + common.SysLog(fmt.Sprintf("[ChannelPricing] deleted: id=%d", id)) common.ApiSuccess(c, nil) } @@ -204,6 +240,22 @@ func CopyGlobalPricing(c *gin.Context) { 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 := ratio_setting.GetAudioRatio(ability.Model) + globalAudioCompletionRatio := ratio_setting.GetAudioCompletionRatio(ability.Model) + // 确定定价类型 var quotaType int var ratio, completionRatio, price float64 @@ -228,17 +280,27 @@ func CopyGlobalPricing(c *gin.Context) { existing.ModelRatio = ratio existing.CompletionRatio = completionRatio existing.ModelPrice = price + existing.CacheRatio = globalCacheRatio + existing.CacheCreationRatio = globalCacheCreationRatio + existing.ImageRatio = globalImageRatio + existing.AudioRatio = globalAudioRatio + existing.AudioCompletionRatio = globalAudioCompletionRatio if err := existing.Update(); err == nil { imported++ } } else { cp := &model.ChannelPricing{ - ModelName: ability.Model, - ChannelId: channelId, - QuotaType: quotaType, - ModelRatio: ratio, - CompletionRatio: completionRatio, - ModelPrice: price, + ModelName: ability.Model, + ChannelId: channelId, + QuotaType: quotaType, + ModelRatio: ratio, + CompletionRatio: completionRatio, + ModelPrice: price, + CacheRatio: globalCacheRatio, + CacheCreationRatio: globalCacheCreationRatio, + ImageRatio: globalImageRatio, + AudioRatio: globalAudioRatio, + AudioCompletionRatio: globalAudioCompletionRatio, } if err := cp.Insert(); err == nil { imported++ @@ -246,6 +308,7 @@ func CopyGlobalPricing(c *gin.Context) { } } + 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, From d5d4714908b6b07a6240d1e7c3932eb7c7fb63dd Mon Sep 17 00:00:00 2001 From: fengsilin Date: Fri, 17 Apr 2026 09:19:53 +0800 Subject: [PATCH 5/9] =?UTF-8?q?test(channel-pricing):=20=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E5=86=99=E7=A9=BF=20+=20=E5=AD=97=E6=AE=B5=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E5=80=BC=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 验证 ChannelPricing 的 Insert/Update/Delete 操作正确更新内存缓存, 以及未设置的扩展字段(CacheRatio 等)默认为零值。 Co-Authored-By: Claude Opus 4.6 --- model/channel_pricing.go | 7 +- model/channel_pricing_test.go | 121 ++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 model/channel_pricing_test.go diff --git a/model/channel_pricing.go b/model/channel_pricing.go index 6f82634..8bc308b 100644 --- a/model/channel_pricing.go +++ b/model/channel_pricing.go @@ -201,7 +201,12 @@ type ChannelPricingWithChannel struct { ModelRatio float64 `json:"model_ratio"` CompletionRatio float64 `json:"completion_ratio"` ModelPrice float64 `json:"model_price"` - HasCustomPricing bool `json:"has_custom_pricing"` // 是否有自定义定价 + HasCustomPricing bool `json:"has_custom_pricing"` // 是否有自定义定价 + 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"` } // GetChannelPricingByModelWithChannelInfo 获取指定模型的渠道定价(带渠道信息) diff --git a/model/channel_pricing_test.go b/model/channel_pricing_test.go new file mode 100644 index 0000000..9ed8a9f --- /dev/null +++ b/model/channel_pricing_test.go @@ -0,0 +1,121 @@ +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 setupChannelPricingDB(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(&ChannelPricing{})) + + t.Cleanup(func() { + DB = origDB + sqlDB.Close() + }) + return db +} + +func TestCacheWriteThrough_Insert(t *testing.T) { + setupChannelPricingDB(t) + + cp := &ChannelPricing{ + ModelName: "test-insert-model", + ChannelId: 9001, + QuotaType: QuotaTypeByTokens, + ModelRatio: 1.0, + CacheRatio: 0.8, + ImageRatio: 1.2, + } + require.NoError(t, cp.Insert()) + defer cp.Delete() + + found, ok := GetEffectivePricing("test-insert-model", 9001) + assert.True(t, ok) + assert.Equal(t, 0.8, found.CacheRatio) + assert.Equal(t, 1.2, found.ImageRatio) +} + +func TestCacheWriteThrough_Update(t *testing.T) { + setupChannelPricingDB(t) + + cp := &ChannelPricing{ + ModelName: "test-update-model", + ChannelId: 9002, + QuotaType: QuotaTypeByTokens, + ModelRatio: 1.0, + } + require.NoError(t, cp.Insert()) + defer cp.Delete() + + cp.CacheRatio = 0.9 + cp.AudioRatio = 1.5 + require.NoError(t, cp.Update()) + + found, ok := GetEffectivePricing("test-update-model", 9002) + assert.True(t, ok) + assert.Equal(t, 0.9, found.CacheRatio) + assert.Equal(t, 1.5, found.AudioRatio) +} + +func TestCacheWriteThrough_Delete(t *testing.T) { + setupChannelPricingDB(t) + + cp := &ChannelPricing{ + ModelName: "test-delete-model", + ChannelId: 9003, + QuotaType: QuotaTypeByTokens, + ModelRatio: 1.0, + } + require.NoError(t, cp.Insert()) + + require.NoError(t, cp.Delete()) + + found, ok := GetEffectivePricing("test-delete-model", 9003) + assert.False(t, ok) + assert.Nil(t, found) +} + +func TestExtendedFields_DefaultZero(t *testing.T) { + setupChannelPricingDB(t) + + cp := &ChannelPricing{ + ModelName: "test-default-model", + ChannelId: 9004, + QuotaType: QuotaTypeByTokens, + ModelRatio: 1.0, + } + require.NoError(t, cp.Insert()) + defer cp.Delete() + + found, ok := GetEffectivePricing("test-default-model", 9004) + assert.True(t, ok) + assert.Equal(t, 0.0, found.CacheRatio) + assert.Equal(t, 0.0, found.CacheCreationRatio) + assert.Equal(t, 0.0, found.ImageRatio) + assert.Equal(t, 0.0, found.AudioRatio) + assert.Equal(t, 0.0, found.AudioCompletionRatio) +} + +func TestGetEffectivePricing_NotFound(t *testing.T) { + setupChannelPricingDB(t) + + found, ok := GetEffectivePricing("nonexistent-model-xyz", 99999) + assert.False(t, ok) + assert.Nil(t, found) +} From c84c26b5a2738d527c1104d546033a9733fc93da Mon Sep 17 00:00:00 2001 From: fengsilin Date: Fri, 17 Apr 2026 09:23:27 +0800 Subject: [PATCH 6/9] =?UTF-8?q?feat(channel-pricing):=20GetChannelPricingB?= =?UTF-8?q?yModelWithChannelInfo=20=E6=94=AF=E6=8C=81=20CASE=20WHEN=20?= =?UTF-8?q?=E5=9B=9E=E9=80=80=20+=20=E6=89=A9=E5=B1=95=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在 SQL 查询中使用 CASE WHEN > 0 回退策略,让渠道定价的扩展比率 (cache_ratio, cache_creation_ratio, image_ratio, audio_ratio, audio_completion_ratio) 在未设置时自动回退到全局默认值, 而不是像 COALESCE 那样被零值拦截。同时更新 GROUP BY 子句包含所有 新选择的列,确保 MySQL 兼容。 Co-Authored-By: Claude Opus 4.6 --- model/channel_pricing.go | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/model/channel_pricing.go b/model/channel_pricing.go index 8bc308b..a5280fb 100644 --- a/model/channel_pricing.go +++ b/model/channel_pricing.go @@ -235,6 +235,13 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin globalModelPrice = 0 } + + // 获取全局扩展比率(用于 CASE WHEN 回退) + globalCacheRatio, _ := ratio_setting.GetCacheRatio(modelName) + globalCacheCreationRatio, _ := ratio_setting.GetCreateCacheRatio(modelName) + globalImageRatio, _ := ratio_setting.GetImageRatio(modelName) + globalAudioRatio := ratio_setting.GetAudioRatio(modelName) + globalAudioCompletionRatio := ratio_setting.GetAudioCompletionRatio(modelName) // 查询所有支持该模型的渠道,左连接渠道定价表 err := DB.Table("abilities"). Select(`abilities.channel_id, channels.name as channel_name, channels.type as channel_type, @@ -244,14 +251,19 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin COALESCE(channel_pricings.model_price, ?) as model_price, channel_pricings.id as id, channel_pricings.tag_ids as tag_ids, + CASE WHEN channel_pricings.cache_ratio > 0 THEN channel_pricings.cache_ratio ELSE ? END as cache_ratio, + CASE WHEN channel_pricings.cache_creation_ratio > 0 THEN channel_pricings.cache_creation_ratio ELSE ? END as cache_creation_ratio, + CASE WHEN channel_pricings.image_ratio > 0 THEN channel_pricings.image_ratio ELSE ? END as image_ratio, + CASE WHEN channel_pricings.audio_ratio > 0 THEN channel_pricings.audio_ratio ELSE ? END as audio_ratio, + CASE WHEN channel_pricings.audio_completion_ratio > 0 THEN channel_pricings.audio_completion_ratio ELSE ? END as audio_completion_ratio, (channel_pricings.id IS NOT NULL) as has_custom_pricing`, - defaultQuotaType, globalModelRatio, globalCompletionRatio, globalModelPrice). + defaultQuotaType, globalModelRatio, globalCompletionRatio, globalModelPrice, globalCacheRatio, globalCacheCreationRatio, globalImageRatio, globalAudioRatio, globalAudioCompletionRatio). Joins("LEFT JOIN channels ON abilities.channel_id = channels.id"). Joins("LEFT JOIN channel_pricings ON abilities.channel_id = channel_pricings.channel_id AND channel_pricings.model_name = ? AND channel_pricings.deleted_at IS NULL", modelName). Where("abilities.model = ?", modelName). Where("abilities.enabled = ?", true). Where("channels.status = ?", 1). // 只显示启用的渠道 - Group("abilities.channel_id, channels.name, channels.type, channel_pricings.quota_type, channel_pricings.model_ratio, channel_pricings.completion_ratio, channel_pricings.model_price, channel_pricings.id, channel_pricings.tag_ids"). + Group("abilities.channel_id, channels.name, channels.type, channel_pricings.quota_type, channel_pricings.model_ratio, channel_pricings.completion_ratio, channel_pricings.model_price, channel_pricings.id, channel_pricings.tag_ids, channel_pricings.cache_ratio, channel_pricings.cache_creation_ratio, channel_pricings.image_ratio, channel_pricings.audio_ratio, channel_pricings.audio_completion_ratio"). Scan(&results).Error if err != nil { return nil, err From 129e17ac42884e71b913bbbbffbc73533ae15d71 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Fri, 17 Apr 2026 09:27:16 +0800 Subject: [PATCH 7/9] =?UTF-8?q?feat(channel-pricing):=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E7=BC=93=E5=AD=98/=E5=9B=BE=E7=89=87/?= =?UTF-8?q?=E9=9F=B3=E9=A2=91=E5=80=8D=E7=8E=87=E7=BC=96=E8=BE=91=E5=92=8C?= =?UTF-8?q?=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChannelPricingView.jsx: initValues 添加 5 个高级比例字段 + 按量计费模式下新增高级比例表单区块 - ChannelPricingCard.jsx: tableData 映射 cache_ratio/cache_creation_ratio + 条件列展示 Co-Authored-By: Claude Opus 4.6 --- .../modal/components/ChannelPricingCard.jsx | 24 ++++++++++++++++++- .../Setting/Ratio/ChannelPricingView.jsx | 13 ++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx b/web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx index 6e19339..786f886 100644 --- a/web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx +++ b/web/src/components/table/model-pricing/modal/components/ChannelPricingCard.jsx @@ -112,6 +112,8 @@ const ChannelPricingCard = ({ completionRatio: item.completion_ratio, modelPrice: item.model_price, hasCustomPricing: item.has_custom_pricing, + cacheRatio: item.cache_ratio, + cacheCreationRatio: item.cache_creation_ratio, })); // 判断是否存在按次计费的渠道 @@ -205,6 +207,26 @@ const ChannelPricingCard = ({ ] : []; + // 判断是否存在高级比例数据 + const hasAdvancedPricing = tableData.some( + (item) => item.cacheRatio > 0 || item.cacheCreationRatio > 0 + ); + + const advancedColumns = hasAdvancedPricing + ? [ + { + title: t('缓存读取'), + dataIndex: 'cacheRatio', + render: (v) => (v > 0 ? v : '-'), + }, + { + title: t('缓存创建'), + dataIndex: 'cacheCreationRatio', + render: (v) => (v > 0 ? v : '-'), + }, + ] + : []; + // 只有存在按次计费的渠道时才显示固定价格列 const callBasedColumn = hasCallBased ? [ @@ -226,7 +248,7 @@ const ChannelPricingCard = ({ ] : []; - const columns = [...baseColumns, ...tokenBasedColumns, ...callBasedColumn]; + const columns = [...baseColumns, ...tokenBasedColumns, ...advancedColumns, ...callBasedColumn]; return ( diff --git a/web/src/pages/Setting/Ratio/ChannelPricingView.jsx b/web/src/pages/Setting/Ratio/ChannelPricingView.jsx index 77b580a..f097bc9 100644 --- a/web/src/pages/Setting/Ratio/ChannelPricingView.jsx +++ b/web/src/pages/Setting/Ratio/ChannelPricingView.jsx @@ -490,6 +490,11 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => { completion_ratio: editingRecord?.pricing?.completion_ratio ?? 0, model_price: editingRecord?.pricing?.model_price ?? 0, tag_ids: getInitialTagIds(editingRecord?.pricing), + cache_ratio: editingRecord?.pricing?.cache_ratio ?? 0, + cache_creation_ratio: editingRecord?.pricing?.cache_creation_ratio ?? 0, + image_ratio: editingRecord?.pricing?.image_ratio ?? 0, + audio_ratio: editingRecord?.pricing?.audio_ratio ?? 0, + audio_completion_ratio: editingRecord?.pricing?.audio_completion_ratio ?? 0, }} > { initValue={tokenPrices.outputTokenPrice} /> + + + + + + + + )} {currentQuotaType === 1 && ( From f3577590bcde0dedbe6ff2f170084983ee3ff5b0 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Fri, 17 Apr 2026 09:30:31 +0800 Subject: [PATCH 8/9] =?UTF-8?q?test(channel-pricing):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=20API=20=E7=AB=AF=E5=88=B0=E7=AB=AF=E9=9B=86=E6=88=90=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- test-scripts/test_channel_pricing_extended.sh | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 test-scripts/test_channel_pricing_extended.sh diff --git a/test-scripts/test_channel_pricing_extended.sh b/test-scripts/test_channel_pricing_extended.sh new file mode 100644 index 0000000..bf79c26 --- /dev/null +++ b/test-scripts/test_channel_pricing_extended.sh @@ -0,0 +1,122 @@ +#!/bin/bash +set -e + +BASE_URL="${1:-http://localhost:3000}" +ADMIN_KEY="${2}" + +if [ -z "$ADMIN_KEY" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "=== 渠道定价增强 - 集成测试 ===" + +# ---------- 1. 创建渠道定价(含扩展字段) ---------- +echo "--- Test 1: Create with extended ratios ---" +RESP=$(curl -s -X POST "$BASE_URL/api/channel-pricing/" \ + -H "Authorization: Bearer $ADMIN_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model_name": "claude-3-5-sonnet", + "channel_id": 1, + "quota_type": 0, + "model_ratio": 3.0, + "completion_ratio": 15.0, + "cache_ratio": 0.5, + "cache_creation_ratio": 0.625, + "image_ratio": 1.5, + "audio_ratio": 2.0, + "audio_completion_ratio": 1.8 + }') +echo "$RESP" | python3 -m json.tool + +CACHE_RATIO=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['cache_ratio'])") +if [ "$CACHE_RATIO" = "0.5" ]; then + echo " [PASS] cache_ratio = 0.5" +else + echo " [FAIL] cache_ratio expected 0.5, got $CACHE_RATIO" +fi + +# ---------- 2. 查询渠道定价(验证新字段返回) ---------- +echo "--- Test 2: Query and verify extended fields ---" +RESP=$(curl -s "$BASE_URL/api/channel-pricing/model/claude-3-5-sonnet" \ + -H "Authorization: Bearer $ADMIN_KEY") +echo "$RESP" | python3 -m json.tool + +# ---------- 3. 更新渠道定价(修改扩展字段) ---------- +echo "--- Test 3: Update extended ratios ---" +RESP=$(curl -s -X POST "$BASE_URL/api/channel-pricing/" \ + -H "Authorization: Bearer $ADMIN_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model_name": "claude-3-5-sonnet", + "channel_id": 1, + "quota_type": 0, + "model_ratio": 3.0, + "completion_ratio": 15.0, + "cache_ratio": 0.8, + "cache_creation_ratio": 0.0 + }') +echo "$RESP" | python3 -m json.tool + +# ---------- 4. 验证回退:未设置的字段为 0 ---------- +echo "--- Test 4: Verify unset fields = 0 ---" +CACHE_CREATION=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['cache_creation_ratio'])") +if [ "$CACHE_CREATION" = "0.0" ]; then + echo " [PASS] cache_creation_ratio = 0 (unset)" +else + echo " [FAIL] cache_creation_ratio expected 0.0, got $CACHE_CREATION" +fi + +# ---------- 5. 负值校验 ---------- +echo "--- Test 5: Reject negative values ---" +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE_URL/api/channel-pricing/" \ + -H "Authorization: Bearer $ADMIN_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model_name": "claude-3-5-sonnet", + "channel_id": 1, + "quota_type": 0, + "model_ratio": 3.0, + "cache_ratio": -1.0 + }') +if [ "$HTTP_CODE" = "400" ] || [ "$HTTP_CODE" = "422" ]; then + echo " [PASS] negative value rejected with HTTP $HTTP_CODE" +else + echo " [FAIL] expected 400/422, got HTTP $HTTP_CODE" +fi + +# ---------- 6. 用户端查询(带渠道信息 + CASE WHEN 回退) ---------- +echo "--- Test 6: User-facing query with global fallback ---" +RESP=$(curl -s "$BASE_URL/api/channel-pricing/model/claude-3-5-sonnet" \ + -H "Authorization: Bearer $ADMIN_KEY") +echo "$RESP" | python3 -c " +import sys, json +data = json.load(sys.stdin) +for item in data.get('data', []): + ch = item.get('channel_id', '?') + cr = item.get('cache_ratio', 'N/A') + ccr = item.get('cache_creation_ratio', 'N/A') + ir = item.get('image_ratio', 'N/A') + print(f' channel={ch} cache_ratio={cr} cache_creation_ratio={ccr} image_ratio={ir}') +" + +# ---------- 7. 删除 + 验证缓存清除 ---------- +echo "--- Test 7: Delete and verify cache cleared ---" +ID=$(curl -s "$BASE_URL/api/channel-pricing/model/claude-3-5-sonnet" \ + -H "Authorization: Bearer $ADMIN_KEY" | \ + python3 -c "import sys,json; items=json.load(sys.stdin).get('data',[]); print(items[0]['id'] if items else '')") +if [ -n "$ID" ]; then + curl -s -X DELETE "$BASE_URL/api/channel-pricing/$ID" \ + -H "Authorization: Bearer $ADMIN_KEY" + echo " Deleted pricing id=$ID" + + RESP=$(curl -s "$BASE_URL/api/channel-pricing/model/claude-3-5-sonnet" \ + -H "Authorization: Bearer $ADMIN_KEY") + COUNT=$(echo "$RESP" | python3 -c "import sys,json; print(len(json.load(sys.stdin).get('data',[])))") + echo " Remaining pricings for claude-3-5-sonnet: $COUNT" +else + echo " [SKIP] No pricing to delete" +fi + +echo "=== 集成测试完成 ===" From 235f7c6e5f0c97c0bacf6744f19bf9b25135a6d8 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Fri, 17 Apr 2026 10:34:33 +0800 Subject: [PATCH 9/9] =?UTF-8?q?refactor(channel-pricing):=20=E6=8F=90?= =?UTF-8?q?=E5=8F=96=20ParseTagIds=20=E8=BE=85=E5=8A=A9=E5=87=BD=E6=95=B0?= =?UTF-8?q?=20+=20=E7=A7=BB=E9=99=A4=E8=B0=83=E8=AF=95=20console.log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 tag ID 解析逻辑提取为 model.ParseTagIds,消除 model 层和 controller 层重复代码 - 统一 TrimSpace 处理(之前 controller 版本漏了) - 移除 ChannelPricingView 残留的 console.log('Expanded keys:') - 删除多余空行 Co-Authored-By: Claude Opus 4.6 --- controller/channel_pricing.go | 11 +------ model/channel_pricing.go | 29 +++++++++++-------- .../Setting/Ratio/ChannelPricingView.jsx | 1 - 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/controller/channel_pricing.go b/controller/channel_pricing.go index 5e9d456..9768e70 100644 --- a/controller/channel_pricing.go +++ b/controller/channel_pricing.go @@ -365,16 +365,7 @@ func GetChannelPricingWithTags(c *gin.Context) { for _, cp := range list { item := &ChannelPricingWithTags{ ChannelPricing: cp, - Tags: make([]*model.PricingTag, 0), - } - if cp.TagIds != "" { - for _, idStr := range strings.Split(cp.TagIds, ",") { - if id, err := strconv.Atoi(idStr); err == nil { - if tag, ok := tagMap[id]; ok { - item.Tags = append(item.Tags, tag) - } - } - } + Tags: model.ParseTagIds(cp.TagIds, tagMap), } result = append(result, item) } diff --git a/model/channel_pricing.go b/model/channel_pricing.go index a5280fb..9a5c05d 100644 --- a/model/channel_pricing.go +++ b/model/channel_pricing.go @@ -172,6 +172,22 @@ func GetEffectivePricing(modelName string, channelId int) (*ChannelPricing, bool return cp, true } +// ParseTagIds 解析逗号分隔的标签ID字符串为 PricingTag 切片 +func ParseTagIds(tagIds string, tagMap map[int]*PricingTag) []*PricingTag { + if tagIds == "" { + return nil + } + tags := make([]*PricingTag, 0) + for _, idStr := range strings.Split(tagIds, ",") { + if id, err := strconv.Atoi(strings.TrimSpace(idStr)); err == nil { + if tag, ok := tagMap[id]; ok { + tags = append(tags, tag) + } + } + } + return tags +} + // LoadChannelPricingCache 全量加载渠道定价到内存(启动时调用) func LoadChannelPricingCache() { var pricings []*ChannelPricing @@ -234,8 +250,6 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin if !hasPrice { globalModelPrice = 0 } - - // 获取全局扩展比率(用于 CASE WHEN 回退) globalCacheRatio, _ := ratio_setting.GetCacheRatio(modelName) globalCacheCreationRatio, _ := ratio_setting.GetCreateCacheRatio(modelName) @@ -283,16 +297,7 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin // 为每个渠道定价填充标签 for _, result := range results { - if result.TagIds != "" { - result.Tags = make([]*PricingTag, 0) - for _, idStr := range strings.Split(result.TagIds, ",") { - if id, err := strconv.Atoi(strings.TrimSpace(idStr)); err == nil { - if tag, ok := tagMap[id]; ok { - result.Tags = append(result.Tags, tag) - } - } - } - } + result.Tags = ParseTagIds(result.TagIds, tagMap) } return results, nil diff --git a/web/src/pages/Setting/Ratio/ChannelPricingView.jsx b/web/src/pages/Setting/Ratio/ChannelPricingView.jsx index f097bc9..cd09e3e 100644 --- a/web/src/pages/Setting/Ratio/ChannelPricingView.jsx +++ b/web/src/pages/Setting/Ratio/ChannelPricingView.jsx @@ -463,7 +463,6 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => { rowKey="modelName" expandedRowKeys={expandedRowKeys} onExpandedRowsChange={(keys) => { - console.log('Expanded keys:', keys); setExpandedRowKeys(keys); }} expandedRowRender={expandedRowRender}