- 提取 PriceData.ApplyChannelPricingRatios 消除 ModelPriceHelper 和 UpdatePriceDataForChannelPricing 中重复的 ~30 行比率回退逻辑 - 提取 ChannelPricing.ApplyFields 消除 controller 中 4 处相同的字段赋值 - 提取 setCache/removeCache 辅助函数统一写穿透缓存操作 - 将 claudeCacheCreation1hMultiplier 常量移至 types 包避免循环依赖 - 修复 BatchUpsertChannelPricing 成功后未刷新内存缓存的 bug Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>master
| @@ -66,6 +66,12 @@ type CreateChannelPricingRequest struct { | |||
| 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 | |||
| @@ -82,17 +88,7 @@ func CreateChannelPricing(c *gin.Context) { | |||
| // 检查是否已存在 | |||
| existing, _ := model.GetChannelPricing(req.ModelName, req.ChannelId) | |||
| if existing != nil { | |||
| // 更新 | |||
| existing.QuotaType = req.QuotaType | |||
| existing.ModelRatio = req.ModelRatio | |||
| 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 | |||
| applyRequestFields(existing, &req) | |||
| if err := existing.Update(); err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| @@ -104,19 +100,10 @@ func CreateChannelPricing(c *gin.Context) { | |||
| // 创建 | |||
| cp := &model.ChannelPricing{ | |||
| 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, | |||
| ModelName: req.ModelName, | |||
| ChannelId: req.ChannelId, | |||
| } | |||
| applyRequestFields(cp, &req) | |||
| if err := cp.Insert(); err != nil { | |||
| common.ApiError(c, err) | |||
| return | |||
| @@ -143,36 +130,20 @@ 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, | |||
| CacheRatio: item.CacheRatio, | |||
| CacheCreationRatio: item.CacheCreationRatio, | |||
| ImageRatio: item.ImageRatio, | |||
| AudioRatio: item.AudioRatio, | |||
| AudioCompletionRatio: item.AudioCompletionRatio, | |||
| }) | |||
| 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.QuotaType = cp.QuotaType | |||
| existing.ModelRatio = cp.ModelRatio | |||
| 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 | |||
| 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 | |||
| @@ -241,18 +212,9 @@ func CopyGlobalPricing(c *gin.Context) { | |||
| } | |||
| // 获取全局扩展比率 | |||
| 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 | |||
| } | |||
| globalCacheRatio, _ := ratio_setting.GetCacheRatio(ability.Model) | |||
| globalCacheCreationRatio, _ := ratio_setting.GetCreateCacheRatio(ability.Model) | |||
| globalImageRatio, _ := ratio_setting.GetImageRatio(ability.Model) | |||
| globalAudioRatio := ratio_setting.GetAudioRatio(ability.Model) | |||
| globalAudioCompletionRatio := ratio_setting.GetAudioCompletionRatio(ability.Model) | |||
| @@ -276,32 +238,18 @@ func CopyGlobalPricing(c *gin.Context) { | |||
| } | |||
| if existing != nil { | |||
| existing.QuotaType = quotaType | |||
| existing.ModelRatio = ratio | |||
| existing.CompletionRatio = completionRatio | |||
| existing.ModelPrice = price | |||
| existing.CacheRatio = globalCacheRatio | |||
| existing.CacheCreationRatio = globalCacheCreationRatio | |||
| existing.ImageRatio = globalImageRatio | |||
| existing.AudioRatio = globalAudioRatio | |||
| existing.AudioCompletionRatio = globalAudioCompletionRatio | |||
| 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, | |||
| QuotaType: quotaType, | |||
| ModelRatio: ratio, | |||
| CompletionRatio: completionRatio, | |||
| ModelPrice: price, | |||
| CacheRatio: globalCacheRatio, | |||
| CacheCreationRatio: globalCacheCreationRatio, | |||
| ImageRatio: globalImageRatio, | |||
| AudioRatio: globalAudioRatio, | |||
| AudioCompletionRatio: globalAudioCompletionRatio, | |||
| ModelName: ability.Model, | |||
| ChannelId: channelId, | |||
| } | |||
| cp.ApplyFields(quotaType, ratio, completionRatio, price, "", | |||
| globalCacheRatio, globalCacheCreationRatio, globalImageRatio, globalAudioRatio, globalAudioCompletionRatio) | |||
| if err := cp.Insert(); err == nil { | |||
| imported++ | |||
| } | |||
| @@ -46,16 +46,40 @@ type ChannelPricing struct { | |||
| AudioCompletionRatio float64 `json:"audio_completion_ratio" gorm:"default:0"` | |||
| } | |||
| // setCache 写穿透缓存 | |||
| func setCache(key string, cp *ChannelPricing) { | |||
| channelPricingCacheLock.Lock() | |||
| channelPricingCache[key] = cp | |||
| channelPricingCacheLock.Unlock() | |||
| } | |||
| func removeCache(key string) { | |||
| channelPricingCacheLock.Lock() | |||
| delete(channelPricingCache, key) | |||
| channelPricingCacheLock.Unlock() | |||
| } | |||
| // ApplyFields 批量设置定价字段(消除 controller 层的重复赋值) | |||
| func (cp *ChannelPricing) ApplyFields(quotaType int, modelRatio, completionRatio, modelPrice float64, tagIds string, cacheRatio, cacheCreationRatio, imageRatio, audioRatio, audioCompletionRatio float64) { | |||
| cp.QuotaType = quotaType | |||
| cp.ModelRatio = modelRatio | |||
| cp.CompletionRatio = completionRatio | |||
| cp.ModelPrice = modelPrice | |||
| cp.TagIds = tagIds | |||
| cp.CacheRatio = cacheRatio | |||
| cp.CacheCreationRatio = cacheCreationRatio | |||
| cp.ImageRatio = imageRatio | |||
| cp.AudioRatio = audioRatio | |||
| cp.AudioCompletionRatio = audioCompletionRatio | |||
| } | |||
| func (cp *ChannelPricing) Insert() error { | |||
| now := common.GetTimestamp() | |||
| cp.CreatedTime = now | |||
| cp.UpdatedTime = now | |||
| err := DB.Create(cp).Error | |||
| if err == nil { | |||
| key := getChannelPricingCacheKey(cp.ModelName, cp.ChannelId) | |||
| channelPricingCacheLock.Lock() | |||
| channelPricingCache[key] = cp | |||
| channelPricingCacheLock.Unlock() | |||
| setCache(getChannelPricingCacheKey(cp.ModelName, cp.ChannelId), cp) | |||
| } | |||
| return err | |||
| } | |||
| @@ -68,10 +92,7 @@ func (cp *ChannelPricing) Update() error { | |||
| "audio_ratio", "audio_completion_ratio", "updated_time"). | |||
| Updates(cp).Error | |||
| if err == nil { | |||
| key := getChannelPricingCacheKey(cp.ModelName, cp.ChannelId) | |||
| channelPricingCacheLock.Lock() | |||
| channelPricingCache[key] = cp | |||
| channelPricingCacheLock.Unlock() | |||
| setCache(getChannelPricingCacheKey(cp.ModelName, cp.ChannelId), cp) | |||
| } | |||
| return err | |||
| } | |||
| @@ -83,10 +104,7 @@ func (cp *ChannelPricing) Delete() error { | |||
| } | |||
| err := DB.Delete(cp).Error | |||
| if err == nil { | |||
| key := getChannelPricingCacheKey(existing.ModelName, existing.ChannelId) | |||
| channelPricingCacheLock.Lock() | |||
| delete(channelPricingCache, key) | |||
| channelPricingCacheLock.Unlock() | |||
| removeCache(getChannelPricingCacheKey(existing.ModelName, existing.ChannelId)) | |||
| } | |||
| return err | |||
| } | |||
| @@ -134,7 +152,7 @@ func BatchUpsertChannelPricing(pricings []*ChannelPricing) error { | |||
| } | |||
| // 使用 GORM 的 OnConflict 实现 upsert | |||
| // 唯一索引为 idx_model_channel (model_name, channel_id) | |||
| return DB.Clauses(clause.OnConflict{ | |||
| err := DB.Clauses(clause.OnConflict{ | |||
| Columns: []clause.Column{ | |||
| {Name: "model_name"}, | |||
| {Name: "channel_id"}, | |||
| @@ -153,6 +171,12 @@ func BatchUpsertChannelPricing(pricings []*ChannelPricing) error { | |||
| "updated_time", | |||
| }), | |||
| }).Create(&pricings).Error | |||
| if err == nil { | |||
| for _, cp := range pricings { | |||
| setCache(getChannelPricingCacheKey(cp.ModelName, cp.ChannelId), cp) | |||
| } | |||
| } | |||
| return err | |||
| } | |||
| // getChannelPricingCacheKey 生成缓存键 | |||
| @@ -14,9 +14,6 @@ import ( | |||
| "github.com/gin-gonic/gin" | |||
| ) | |||
| // https://docs.claude.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration | |||
| const claudeCacheCreation1hMultiplier = 6 / 3.75 | |||
| // 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{ | |||
| @@ -52,13 +49,6 @@ 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 | |||
| @@ -70,23 +60,6 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens | |||
| 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)) | |||
| } | |||
| @@ -123,24 +96,6 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens | |||
| } | |||
| completionRatio = ratio_setting.GetCompletionRatio(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 { | |||
| @@ -170,20 +125,38 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens | |||
| } | |||
| priceData := types.PriceData{ | |||
| FreeModel: freeModel, | |||
| ModelPrice: modelPrice, | |||
| ModelRatio: modelRatio, | |||
| CompletionRatio: completionRatio, | |||
| GroupRatioInfo: groupRatioInfo, | |||
| UsePrice: usePrice, | |||
| CacheRatio: cacheRatio, | |||
| ImageRatio: imageRatio, | |||
| AudioRatio: audioRatio, | |||
| AudioCompletionRatio: audioCompletionRatio, | |||
| CacheCreationRatio: cacheCreationRatio, | |||
| CacheCreation5mRatio: cacheCreationRatio5m, | |||
| CacheCreation1hRatio: cacheCreationRatio1h, | |||
| QuotaToPreConsume: preConsumedQuota, | |||
| FreeModel: freeModel, | |||
| ModelPrice: modelPrice, | |||
| ModelRatio: modelRatio, | |||
| CompletionRatio: completionRatio, | |||
| GroupRatioInfo: groupRatioInfo, | |||
| UsePrice: usePrice, | |||
| QuotaToPreConsume: preConsumedQuota, | |||
| } | |||
| // 应用渠道定价的扩展比率(非零值覆盖),然后回退全局比率 | |||
| if channelMetaAvailable { | |||
| if cp, found := model.GetEffectivePricing(info.OriginModelName, info.ChannelId); found { | |||
| priceData.ApplyChannelPricingRatios(cp.CacheRatio, cp.CacheCreationRatio, cp.ImageRatio, cp.AudioRatio, cp.AudioCompletionRatio) | |||
| } | |||
| } | |||
| // 全局比率作为回退(仅当对应字段仍为 0 时生效) | |||
| if priceData.CacheRatio == 0 { | |||
| priceData.CacheRatio, _ = ratio_setting.GetCacheRatio(info.OriginModelName) | |||
| } | |||
| if priceData.CacheCreationRatio == 0 { | |||
| priceData.CacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(info.OriginModelName) | |||
| priceData.CacheCreation5mRatio = priceData.CacheCreationRatio | |||
| priceData.CacheCreation1hRatio = priceData.CacheCreationRatio * types.ClaudeCacheCreation1hMultiplier | |||
| } | |||
| if priceData.ImageRatio == 0 { | |||
| priceData.ImageRatio, _ = ratio_setting.GetImageRatio(info.OriginModelName) | |||
| } | |||
| if priceData.AudioRatio == 0 { | |||
| priceData.AudioRatio = ratio_setting.GetAudioRatio(info.OriginModelName) | |||
| } | |||
| if priceData.AudioCompletionRatio == 0 { | |||
| priceData.AudioCompletionRatio = ratio_setting.GetAudioCompletionRatio(info.OriginModelName) | |||
| } | |||
| if common.DebugEnabled { | |||
| @@ -261,23 +234,7 @@ func UpdatePriceDataForChannelPricing(c *gin.Context, info *relaycommon.RelayInf | |||
| info.PriceData.ModelPrice = -1 | |||
| } | |||
| 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 | |||
| } | |||
| info.PriceData.ApplyChannelPricingRatios(cp.CacheRatio, cp.CacheCreationRatio, cp.ImageRatio, cp.AudioRatio, cp.AudioCompletionRatio) | |||
| if info.PriceData.UsePrice { | |||
| info.PriceData.QuotaToPreConsume = int( | |||
| @@ -2,6 +2,10 @@ package types | |||
| import "fmt" | |||
| // ClaudeCacheCreation1hMultiplier 1小时缓存写入价格相对于5分钟的比例 | |||
| // https://docs.claude.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration | |||
| const ClaudeCacheCreation1hMultiplier = 6 / 3.75 | |||
| type GroupRatioInfo struct { | |||
| GroupRatio float64 | |||
| GroupSpecialRatio float64 | |||
| @@ -27,6 +31,28 @@ type PriceData struct { | |||
| GroupRatioInfo GroupRatioInfo | |||
| } | |||
| // ApplyChannelPricingRatios 将渠道定价的扩展比率应用到 PriceData(非零值覆盖) | |||
| // cacheRatio, cacheCreationRatio, imageRatio, audioRatio, audioCompletionRatio | |||
| func (p *PriceData) ApplyChannelPricingRatios(cacheRatio, cacheCreationRatio, imageRatio, audioRatio, audioCompletionRatio float64) { | |||
| if cacheRatio != 0 { | |||
| p.CacheRatio = cacheRatio | |||
| } | |||
| if cacheCreationRatio != 0 { | |||
| p.CacheCreationRatio = cacheCreationRatio | |||
| p.CacheCreation5mRatio = cacheCreationRatio | |||
| p.CacheCreation1hRatio = cacheCreationRatio * ClaudeCacheCreation1hMultiplier | |||
| } | |||
| if imageRatio != 0 { | |||
| p.ImageRatio = imageRatio | |||
| } | |||
| if audioRatio != 0 { | |||
| p.AudioRatio = audioRatio | |||
| } | |||
| if audioCompletionRatio != 0 { | |||
| p.AudioCompletionRatio = audioCompletionRatio | |||
| } | |||
| } | |||
| func (p *PriceData) AddOtherRatio(key string, ratio float64) { | |||
| if p.OtherRatios == nil { | |||
| p.OtherRatios = make(map[string]float64) | |||