| @@ -1,6 +1,7 @@ | |||||
| package controller | package controller | ||||
| import ( | import ( | ||||
| "fmt" | |||||
| "strconv" | "strconv" | ||||
| "strings" | "strings" | ||||
| @@ -50,14 +51,19 @@ func GetChannelPricingByModel(c *gin.Context) { | |||||
| // CreateChannelPricingRequest 创建渠道定价请求 | // CreateChannelPricingRequest 创建渠道定价请求 | ||||
| type CreateChannelPricingRequest struct { | 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 创建或更新渠道定价 | // CreateChannelPricing 创建或更新渠道定价 | ||||
| @@ -68,6 +74,11 @@ func CreateChannelPricing(c *gin.Context) { | |||||
| return | 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) | existing, _ := model.GetChannelPricing(req.ModelName, req.ChannelId) | ||||
| if existing != nil { | if existing != nil { | ||||
| @@ -77,29 +88,43 @@ func CreateChannelPricing(c *gin.Context) { | |||||
| existing.CompletionRatio = req.CompletionRatio | existing.CompletionRatio = req.CompletionRatio | ||||
| existing.ModelPrice = req.ModelPrice | existing.ModelPrice = req.ModelPrice | ||||
| existing.TagIds = req.TagIds | 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 { | if err := existing.Update(); err != nil { | ||||
| common.ApiError(c, err) | common.ApiError(c, err) | ||||
| return | return | ||||
| } | } | ||||
| common.SysLog(fmt.Sprintf("[ChannelPricing] updated: id=%d model=%s channel=%d", existing.Id, existing.ModelName, existing.ChannelId)) | |||||
| common.ApiSuccess(c, existing) | common.ApiSuccess(c, existing) | ||||
| return | return | ||||
| } | } | ||||
| // 创建 | // 创建 | ||||
| cp := &model.ChannelPricing{ | 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 { | if err := cp.Insert(); err != nil { | ||||
| common.ApiError(c, err) | common.ApiError(c, err) | ||||
| return | 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) | common.ApiSuccess(c, cp) | ||||
| } | } | ||||
| @@ -119,13 +144,18 @@ func BatchCreateChannelPricing(c *gin.Context) { | |||||
| pricings := make([]*model.ChannelPricing, 0, len(req.Items)) | pricings := make([]*model.ChannelPricing, 0, len(req.Items)) | ||||
| for _, item := range req.Items { | for _, item := range req.Items { | ||||
| pricings = append(pricings, &model.ChannelPricing{ | 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.CompletionRatio = cp.CompletionRatio | ||||
| existing.ModelPrice = cp.ModelPrice | existing.ModelPrice = cp.ModelPrice | ||||
| existing.TagIds = cp.TagIds | 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 { | if err := existing.Update(); err != nil { | ||||
| common.ApiError(c, err) | common.ApiError(c, err) | ||||
| return | return | ||||
| @@ -168,6 +203,7 @@ func DeleteChannelPricing(c *gin.Context) { | |||||
| return | return | ||||
| } | } | ||||
| common.SysLog(fmt.Sprintf("[ChannelPricing] deleted: id=%d", id)) | |||||
| common.ApiSuccess(c, nil) | common.ApiSuccess(c, nil) | ||||
| } | } | ||||
| @@ -204,6 +240,22 @@ func CopyGlobalPricing(c *gin.Context) { | |||||
| continue | 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 quotaType int | ||||
| var ratio, completionRatio, price float64 | var ratio, completionRatio, price float64 | ||||
| @@ -228,17 +280,27 @@ func CopyGlobalPricing(c *gin.Context) { | |||||
| existing.ModelRatio = ratio | existing.ModelRatio = ratio | ||||
| existing.CompletionRatio = completionRatio | existing.CompletionRatio = completionRatio | ||||
| existing.ModelPrice = price | existing.ModelPrice = price | ||||
| existing.CacheRatio = globalCacheRatio | |||||
| existing.CacheCreationRatio = globalCacheCreationRatio | |||||
| existing.ImageRatio = globalImageRatio | |||||
| existing.AudioRatio = globalAudioRatio | |||||
| existing.AudioCompletionRatio = globalAudioCompletionRatio | |||||
| if err := existing.Update(); err == nil { | if err := existing.Update(); err == nil { | ||||
| imported++ | imported++ | ||||
| } | } | ||||
| } else { | } else { | ||||
| cp := &model.ChannelPricing{ | 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 { | if err := cp.Insert(); err == nil { | ||||
| imported++ | 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{ | common.ApiSuccess(c, gin.H{ | ||||
| "total": len(abilities), | "total": len(abilities), | ||||
| "imported": imported, | "imported": imported, | ||||
| @@ -302,16 +365,7 @@ func GetChannelPricingWithTags(c *gin.Context) { | |||||
| for _, cp := range list { | for _, cp := range list { | ||||
| item := &ChannelPricingWithTags{ | item := &ChannelPricingWithTags{ | ||||
| ChannelPricing: cp, | 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) | result = append(result, item) | ||||
| } | } | ||||
| @@ -5,7 +5,6 @@ import ( | |||||
| "strconv" | "strconv" | ||||
| "strings" | "strings" | ||||
| "sync" | "sync" | ||||
| "time" | |||||
| "github.com/QuantumNous/new-api/common" | "github.com/QuantumNous/new-api/common" | ||||
| "github.com/QuantumNous/new-api/setting/ratio_setting" | "github.com/QuantumNous/new-api/setting/ratio_setting" | ||||
| @@ -17,8 +16,6 @@ import ( | |||||
| var ( | var ( | ||||
| channelPricingCache = make(map[string]*ChannelPricing) // key: "modelName:channelId" | channelPricingCache = make(map[string]*ChannelPricing) // key: "modelName:channelId" | ||||
| channelPricingCacheLock sync.RWMutex | channelPricingCacheLock sync.RWMutex | ||||
| channelPricingCacheTime time.Time | |||||
| channelPricingCacheTTL = time.Minute * 5 // 缓存5分钟 | |||||
| ) | ) | ||||
| // QuotaType 计费类型 | // QuotaType 计费类型 | ||||
| @@ -41,6 +38,12 @@ type ChannelPricing struct { | |||||
| CreatedTime int64 `json:"created_time" gorm:"bigint"` | CreatedTime int64 `json:"created_time" gorm:"bigint"` | ||||
| UpdatedTime int64 `json:"updated_time" gorm:"bigint"` | UpdatedTime int64 `json:"updated_time" gorm:"bigint"` | ||||
| DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` | 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 { | func (cp *ChannelPricing) Insert() error { | ||||
| @@ -49,31 +52,41 @@ func (cp *ChannelPricing) Insert() error { | |||||
| cp.UpdatedTime = now | cp.UpdatedTime = now | ||||
| err := DB.Create(cp).Error | err := DB.Create(cp).Error | ||||
| if err == nil { | if err == nil { | ||||
| InvalidateChannelPricingCache() | |||||
| key := getChannelPricingCacheKey(cp.ModelName, cp.ChannelId) | |||||
| channelPricingCacheLock.Lock() | |||||
| channelPricingCache[key] = cp | |||||
| channelPricingCacheLock.Unlock() | |||||
| } | } | ||||
| return err | return err | ||||
| } | } | ||||
| func (cp *ChannelPricing) Update() error { | func (cp *ChannelPricing) Update() error { | ||||
| cp.UpdatedTime = common.GetTimestamp() | 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 { | if err == nil { | ||||
| InvalidateChannelPricingCache() | |||||
| key := getChannelPricingCacheKey(cp.ModelName, cp.ChannelId) | |||||
| channelPricingCacheLock.Lock() | |||||
| channelPricingCache[key] = cp | |||||
| channelPricingCacheLock.Unlock() | |||||
| } | } | ||||
| return err | return err | ||||
| } | } | ||||
| func (cp *ChannelPricing) Delete() error { | 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 | err := DB.Delete(cp).Error | ||||
| if err == nil { | if err == nil { | ||||
| InvalidateChannelPricingCache() | |||||
| key := getChannelPricingCacheKey(existing.ModelName, existing.ChannelId) | |||||
| channelPricingCacheLock.Lock() | |||||
| delete(channelPricingCache, key) | |||||
| channelPricingCacheLock.Unlock() | |||||
| } | } | ||||
| return err | return err | ||||
| } | } | ||||
| @@ -132,6 +145,11 @@ func BatchUpsertChannelPricing(pricings []*ChannelPricing) error { | |||||
| "completion_ratio", | "completion_ratio", | ||||
| "model_price", | "model_price", | ||||
| "tag_ids", | "tag_ids", | ||||
| "cache_ratio", | |||||
| "cache_creation_ratio", | |||||
| "image_ratio", | |||||
| "audio_ratio", | |||||
| "audio_completion_ratio", | |||||
| "updated_time", | "updated_time", | ||||
| }), | }), | ||||
| }).Create(&pricings).Error | }).Create(&pricings).Error | ||||
| @@ -142,71 +160,49 @@ func getChannelPricingCacheKey(modelName string, channelId int) string { | |||||
| return fmt.Sprintf("%s:%d", modelName, channelId) | 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() | 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() | 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 | |||||
| if !ok { | |||||
| return nil, false | |||||
| } | } | ||||
| return cp, true | |||||
| } | |||||
| // 更新缓存 | |||||
| channelPricingCacheLock.Lock() | |||||
| if channelPricingCacheTime.IsZero() || time.Since(channelPricingCacheTime) >= channelPricingCacheTTL { | |||||
| // 缓存过期,清空并更新时间 | |||||
| channelPricingCache = make(map[string]*ChannelPricing) | |||||
| channelPricingCacheTime = time.Now() | |||||
| // ParseTagIds 解析逗号分隔的标签ID字符串为 PricingTag 切片 | |||||
| func ParseTagIds(tagIds string, tagMap map[int]*PricingTag) []*PricingTag { | |||||
| if tagIds == "" { | |||||
| return nil | |||||
| } | } | ||||
| channelPricingCache[cacheKey] = &cp | |||||
| channelPricingCacheLock.Unlock() | |||||
| return cp.ModelRatio, cp.CompletionRatio, cp.ModelPrice, cp.QuotaType == QuotaTypeByCall, true | |||||
| 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 | |||||
| } | } | ||||
| // RefreshChannelPricingCache 刷新渠道定价缓存 | |||||
| func RefreshChannelPricingCache() { | |||||
| channelPricingCacheLock.Lock() | |||||
| defer channelPricingCacheLock.Unlock() | |||||
| // 清空缓存 | |||||
| channelPricingCache = make(map[string]*ChannelPricing) | |||||
| channelPricingCacheTime = time.Now() | |||||
| // 预加载所有渠道定价 | |||||
| // LoadChannelPricingCache 全量加载渠道定价到内存(启动时调用) | |||||
| func LoadChannelPricingCache() { | |||||
| var pricings []*ChannelPricing | var pricings []*ChannelPricing | ||||
| if err := DB.Find(&pricings).Error; err != nil { | if err := DB.Find(&pricings).Error; err != nil { | ||||
| common.SysError("[ChannelPricing] LoadChannelPricingCache failed: " + err.Error()) | |||||
| return | return | ||||
| } | } | ||||
| channelPricingCacheLock.Lock() | |||||
| channelPricingCache = make(map[string]*ChannelPricing, len(pricings)) | |||||
| for _, cp := range 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 带渠道信息的定价响应 | // ChannelPricingWithChannel 带渠道信息的定价响应 | ||||
| @@ -221,7 +217,12 @@ type ChannelPricingWithChannel struct { | |||||
| ModelRatio float64 `json:"model_ratio"` | ModelRatio float64 `json:"model_ratio"` | ||||
| CompletionRatio float64 `json:"completion_ratio"` | CompletionRatio float64 `json:"completion_ratio"` | ||||
| ModelPrice float64 `json:"model_price"` | 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 获取指定模型的渠道定价(带渠道信息) | // GetChannelPricingByModelWithChannelInfo 获取指定模型的渠道定价(带渠道信息) | ||||
| @@ -249,7 +250,12 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin | |||||
| if !hasPrice { | if !hasPrice { | ||||
| globalModelPrice = 0 | 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"). | err := DB.Table("abilities"). | ||||
| Select(`abilities.channel_id, channels.name as channel_name, channels.type as channel_type, | Select(`abilities.channel_id, channels.name as channel_name, channels.type as channel_type, | ||||
| @@ -259,14 +265,19 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin | |||||
| COALESCE(channel_pricings.model_price, ?) as model_price, | COALESCE(channel_pricings.model_price, ?) as model_price, | ||||
| channel_pricings.id as id, | channel_pricings.id as id, | ||||
| channel_pricings.tag_ids as tag_ids, | 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`, | (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 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). | 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.model = ?", modelName). | ||||
| Where("abilities.enabled = ?", true). | Where("abilities.enabled = ?", true). | ||||
| Where("channels.status = ?", 1). // 只显示启用的渠道 | 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 | Scan(&results).Error | ||||
| if err != nil { | if err != nil { | ||||
| return nil, err | return nil, err | ||||
| @@ -286,16 +297,7 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin | |||||
| // 为每个渠道定价填充标签 | // 为每个渠道定价填充标签 | ||||
| for _, result := range results { | 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 | return results, nil | ||||
| @@ -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) | |||||
| } | |||||
| @@ -203,7 +203,11 @@ func InitDB() (err error) { | |||||
| } | } | ||||
| common.SysLog("database migration started") | common.SysLog("database migration started") | ||||
| err = migrateDB() | err = migrateDB() | ||||
| return err | |||||
| if err != nil { | |||||
| return err | |||||
| } | |||||
| LoadChannelPricingCache() | |||||
| return nil | |||||
| } else { | } else { | ||||
| common.FatalLog(err) | common.FatalLog(err) | ||||
| } | } | ||||
| @@ -52,17 +52,44 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens | |||||
| var modelRatio float64 | var modelRatio float64 | ||||
| var completionRatio float64 | var completionRatio float64 | ||||
| var channelPricingFound bool | 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 | channelMetaAvailable := info != nil && info.ChannelMeta != nil && info.ChannelId > 0 | ||||
| if channelMetaAvailable { | if channelMetaAvailable { | ||||
| cpRatio, cpCompletionRatio, cpPrice, cpUsePrice, found := model.GetEffectivePricing(info.OriginModelName, info.ChannelId) | |||||
| cp, found := model.GetEffectivePricing(info.OriginModelName, info.ChannelId) | |||||
| if found { | 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 | 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) | groupRatioInfo := HandleGroupRatio(c, info) | ||||
| var preConsumedQuota int | 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 | var freeModel bool | ||||
| if !usePrice { | if !usePrice { | ||||
| preConsumedTokens := common.Max(promptTokens, common.PreConsumedQuota) | 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) | 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 | ratio := modelRatio * groupRatioInfo.GroupRatio | ||||
| preConsumedQuota = int(float64(preConsumedTokens) * ratio) | preConsumedQuota = int(float64(preConsumedTokens) * ratio) | ||||
| } else { | } else { | ||||
| @@ -217,30 +247,51 @@ func UpdatePriceDataForChannelPricing(c *gin.Context, info *relaycommon.RelayInf | |||||
| return | return | ||||
| } | } | ||||
| cpRatio, cpCompletionRatio, cpPrice, cpUsePrice, found := model.GetEffectivePricing(info.OriginModelName, channelId) | |||||
| cp, found := model.GetEffectivePricing(info.OriginModelName, channelId) | |||||
| if !found { | if !found { | ||||
| return | 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 { | } else { | ||||
| info.PriceData.ModelPrice = -1 | 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 { | } else { | ||||
| estimateTokens := info.GetEstimatePromptTokens() | estimateTokens := info.GetEstimatePromptTokens() | ||||
| if estimateTokens > 0 { | if estimateTokens > 0 { | ||||
| ratio := cpRatio * info.PriceData.GroupRatioInfo.GroupRatio | |||||
| ratio := cp.ModelRatio * info.PriceData.GroupRatioInfo.GroupRatio | |||||
| info.PriceData.QuotaToPreConsume = int(float64(estimateTokens) * ratio) | 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)) | |||||
| } | |||||
| } | } | ||||
| @@ -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 <base_url> <admin_key>" | |||||
| 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 "=== 集成测试完成 ===" | |||||
| @@ -112,6 +112,8 @@ const ChannelPricingCard = ({ | |||||
| completionRatio: item.completion_ratio, | completionRatio: item.completion_ratio, | ||||
| modelPrice: item.model_price, | modelPrice: item.model_price, | ||||
| hasCustomPricing: item.has_custom_pricing, | 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 | const callBasedColumn = hasCallBased | ||||
| ? [ | ? [ | ||||
| @@ -226,7 +248,7 @@ const ChannelPricingCard = ({ | |||||
| ] | ] | ||||
| : []; | : []; | ||||
| const columns = [...baseColumns, ...tokenBasedColumns, ...callBasedColumn]; | |||||
| const columns = [...baseColumns, ...tokenBasedColumns, ...advancedColumns, ...callBasedColumn]; | |||||
| return ( | return ( | ||||
| <Card className='!rounded-2xl shadow-sm border-0 mb-6'> | <Card className='!rounded-2xl shadow-sm border-0 mb-6'> | ||||
| @@ -463,7 +463,6 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => { | |||||
| rowKey="modelName" | rowKey="modelName" | ||||
| expandedRowKeys={expandedRowKeys} | expandedRowKeys={expandedRowKeys} | ||||
| onExpandedRowsChange={(keys) => { | onExpandedRowsChange={(keys) => { | ||||
| console.log('Expanded keys:', keys); | |||||
| setExpandedRowKeys(keys); | setExpandedRowKeys(keys); | ||||
| }} | }} | ||||
| expandedRowRender={expandedRowRender} | expandedRowRender={expandedRowRender} | ||||
| @@ -490,6 +489,11 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => { | |||||
| completion_ratio: editingRecord?.pricing?.completion_ratio ?? 0, | completion_ratio: editingRecord?.pricing?.completion_ratio ?? 0, | ||||
| model_price: editingRecord?.pricing?.model_price ?? 0, | model_price: editingRecord?.pricing?.model_price ?? 0, | ||||
| tag_ids: getInitialTagIds(editingRecord?.pricing), | 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, | |||||
| }} | }} | ||||
| > | > | ||||
| <Form.Input | <Form.Input | ||||
| @@ -576,6 +580,14 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => { | |||||
| initValue={tokenPrices.outputTokenPrice} | initValue={tokenPrices.outputTokenPrice} | ||||
| /> | /> | ||||
| </div> | </div> | ||||
| <Form.Section text={t('高级比例(留空使用全局默认值)')}> | |||||
| <Form.InputNumber field="cache_ratio" label={t('缓存读取倍率')} min={0} step={0.01} placeholder={t('全局默认值')} /> | |||||
| <Form.InputNumber field="cache_creation_ratio" label={t('缓存创建倍率(5分钟)')} min={0} step={0.01} placeholder={t('全局默认值(1小时自动按 1.6x 计算)')} /> | |||||
| <Form.InputNumber field="image_ratio" label={t('图片倍率')} min={0} step={0.01} placeholder={t('全局默认值')} /> | |||||
| <Form.InputNumber field="audio_ratio" label={t('音频输入倍率')} min={0} step={0.01} placeholder={t('全局默认值')} /> | |||||
| <Form.InputNumber field="audio_completion_ratio" label={t('音频输出倍率')} min={0} step={0.01} placeholder={t('全局默认值')} /> | |||||
| </Form.Section> | |||||
| </> | </> | ||||
| )} | )} | ||||
| {currentQuotaType === 1 && ( | {currentQuotaType === 1 && ( | ||||