- 新增 is_default 字段和缓存层,支持管理员为模型指定默认通道 - Distribute 中间件优先级调整:Token 指定 → 默认通道 → 亲和性 → 随机 - 模型定价卡片和详情弹窗展示默认通道 amber 标识 - 管理后台定价页面新增星标切换默认通道 - 新增 set_default / clear_default API 和 6 个单元测试 - 简化卡片价格显示(移除内联缓存价格,改为详情弹窗展示) Co-Authored-By: Claude <noreply@anthropic.com>master
| @@ -340,3 +340,51 @@ func GetChannelPricingWithTags(c *gin.Context) { | |||||
| "items": result, | "items": result, | ||||
| }) | }) | ||||
| } | } | ||||
| // SetDefaultChannelRequest 设置默认通道请求 | |||||
| type SetDefaultChannelRequest struct { | |||||
| ModelName string `json:"model_name" binding:"required"` | |||||
| ChannelId int `json:"channel_id" binding:"required"` | |||||
| } | |||||
| // SetDefaultChannel 设置指定模型的默认通道 | |||||
| func SetDefaultChannel(c *gin.Context) { | |||||
| var req SetDefaultChannelRequest | |||||
| if err := c.ShouldBindJSON(&req); err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| // 验证渠道定价记录存在 | |||||
| existing, err := model.GetChannelPricing(req.ModelName, req.ChannelId) | |||||
| if err != nil || existing == nil { | |||||
| common.ApiErrorMsg(c, "channel pricing not found for this model and channel") | |||||
| return | |||||
| } | |||||
| if err := model.SetDefaultChannel(req.ModelName, req.ChannelId); err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| common.SysLog(fmt.Sprintf("[ChannelPricing] set default: model=%s channel=%d", req.ModelName, req.ChannelId)) | |||||
| common.ApiSuccess(c, nil) | |||||
| } | |||||
| // ClearDefaultChannel 清除指定模型的默认通道 | |||||
| func ClearDefaultChannel(c *gin.Context) { | |||||
| modelName := c.Param("name") | |||||
| modelName = strings.TrimPrefix(modelName, "/") | |||||
| if modelName == "" { | |||||
| common.ApiErrorMsg(c, "model name is required") | |||||
| return | |||||
| } | |||||
| if err := model.ClearDefaultChannel(modelName); err != nil { | |||||
| common.ApiError(c, err) | |||||
| return | |||||
| } | |||||
| common.SysLog(fmt.Sprintf("[ChannelPricing] cleared default: model=%s", modelName)) | |||||
| common.ApiSuccess(c, nil) | |||||
| } | |||||
| @@ -107,25 +107,61 @@ func Distribute() func(c *gin.Context) { | |||||
| } | } | ||||
| } | } | ||||
| if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found { | |||||
| preferred, err := model.CacheGetChannel(preferredChannelID) | |||||
| if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled { | |||||
| if usingGroup == "auto" { | |||||
| // 默认通道检查(管理员配置的模型默认通道) | |||||
| if channel == nil { | |||||
| if defaultChannelId, ok := model.GetDefaultChannelId(modelRequest.Model); ok { | |||||
| defaultCh, err := model.CacheGetChannel(defaultChannelId) | |||||
| if err != nil || defaultCh == nil { | |||||
| common.SysLog(fmt.Sprintf("[Distribute] model=%s default_channel=%d not found in cache, fallback", modelRequest.Model, defaultChannelId)) | |||||
| } else if defaultCh.Status != common.ChannelStatusEnabled { | |||||
| common.SysLog(fmt.Sprintf("[Distribute] model=%s default_channel=%d disabled(status=%d), fallback", modelRequest.Model, defaultChannelId, defaultCh.Status)) | |||||
| } else if usingGroup == "auto" { | |||||
| userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) | userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) | ||||
| autoGroups := service.GetUserAutoGroup(userGroup) | autoGroups := service.GetUserAutoGroup(userGroup) | ||||
| for _, g := range autoGroups { | for _, g := range autoGroups { | ||||
| if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) { | |||||
| if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, defaultCh.Id) { | |||||
| channel = defaultCh | |||||
| selectGroup = g | selectGroup = g | ||||
| common.SetContextKey(c, constant.ContextKeyAutoGroup, g) | common.SetContextKey(c, constant.ContextKeyAutoGroup, g) | ||||
| channel = preferred | |||||
| service.MarkChannelAffinityUsed(c, g, preferred.Id) | |||||
| common.SysLog(fmt.Sprintf("[Distribute] model=%s using default_channel=%d (auto group=%s)", modelRequest.Model, defaultChannelId, g)) | |||||
| break | break | ||||
| } | } | ||||
| } | } | ||||
| } else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, preferred.Id) { | |||||
| channel = preferred | |||||
| if channel == nil { | |||||
| common.SysLog(fmt.Sprintf("[Distribute] model=%s default_channel=%d not enabled for any auto group, fallback", modelRequest.Model, defaultChannelId)) | |||||
| } | |||||
| } else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, defaultCh.Id) { | |||||
| channel = defaultCh | |||||
| selectGroup = usingGroup | selectGroup = usingGroup | ||||
| service.MarkChannelAffinityUsed(c, usingGroup, preferred.Id) | |||||
| common.SysLog(fmt.Sprintf("[Distribute] model=%s using default_channel=%d (group=%s)", modelRequest.Model, defaultChannelId, usingGroup)) | |||||
| } else { | |||||
| common.SysLog(fmt.Sprintf("[Distribute] model=%s default_channel=%d not enabled for group=%s, fallback", modelRequest.Model, defaultChannelId, usingGroup)) | |||||
| } | |||||
| } | |||||
| } | |||||
| // 通道亲和性检查(仅在未选中默认通道时生效) | |||||
| if channel == nil { | |||||
| if preferredChannelID, found := service.GetPreferredChannelByAffinity(c, modelRequest.Model, usingGroup); found { | |||||
| preferred, err := model.CacheGetChannel(preferredChannelID) | |||||
| if err == nil && preferred != nil && preferred.Status == common.ChannelStatusEnabled { | |||||
| if usingGroup == "auto" { | |||||
| userGroup := common.GetContextKeyString(c, constant.ContextKeyUserGroup) | |||||
| autoGroups := service.GetUserAutoGroup(userGroup) | |||||
| for _, g := range autoGroups { | |||||
| if model.IsChannelEnabledForGroupModel(g, modelRequest.Model, preferred.Id) { | |||||
| selectGroup = g | |||||
| common.SetContextKey(c, constant.ContextKeyAutoGroup, g) | |||||
| channel = preferred | |||||
| service.MarkChannelAffinityUsed(c, g, preferred.Id) | |||||
| break | |||||
| } | |||||
| } | |||||
| } else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, preferred.Id) { | |||||
| channel = preferred | |||||
| selectGroup = usingGroup | |||||
| service.MarkChannelAffinityUsed(c, usingGroup, preferred.Id) | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| } | } | ||||
| @@ -16,6 +16,10 @@ 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 | ||||
| // 默认通道缓存:modelName → channelId | |||||
| defaultChannelCache = make(map[string]int) | |||||
| defaultChannelCacheLock sync.RWMutex | |||||
| ) | ) | ||||
| // QuotaType 计费类型 | // QuotaType 计费类型 | ||||
| @@ -44,6 +48,7 @@ type ChannelPricing struct { | |||||
| ImageRatio float64 `json:"image_ratio" gorm:"default:0"` | ImageRatio float64 `json:"image_ratio" gorm:"default:0"` | ||||
| AudioRatio float64 `json:"audio_ratio" gorm:"default:0"` | AudioRatio float64 `json:"audio_ratio" gorm:"default:0"` | ||||
| AudioCompletionRatio float64 `json:"audio_completion_ratio" gorm:"default:0"` | AudioCompletionRatio float64 `json:"audio_completion_ratio" gorm:"default:0"` | ||||
| IsDefault bool `json:"is_default" gorm:"default:false;index"` | |||||
| } | } | ||||
| // setCache 写穿透缓存 | // setCache 写穿透缓存 | ||||
| @@ -80,6 +85,9 @@ func (cp *ChannelPricing) Insert() error { | |||||
| err := DB.Create(cp).Error | err := DB.Create(cp).Error | ||||
| if err == nil { | if err == nil { | ||||
| setCache(getChannelPricingCacheKey(cp.ModelName, cp.ChannelId), cp) | setCache(getChannelPricingCacheKey(cp.ModelName, cp.ChannelId), cp) | ||||
| if cp.IsDefault { | |||||
| setDefaultChannelCache(cp.ModelName, cp.ChannelId) | |||||
| } | |||||
| } | } | ||||
| return err | return err | ||||
| } | } | ||||
| @@ -89,10 +97,15 @@ func (cp *ChannelPricing) Update() error { | |||||
| err := DB.Model(&ChannelPricing{}).Where("id = ?", cp.Id). | err := DB.Model(&ChannelPricing{}).Where("id = ?", cp.Id). | ||||
| Select("quota_type", "model_ratio", "completion_ratio", "model_price", | Select("quota_type", "model_ratio", "completion_ratio", "model_price", | ||||
| "tag_ids", "cache_ratio", "cache_creation_ratio", "image_ratio", | "tag_ids", "cache_ratio", "cache_creation_ratio", "image_ratio", | ||||
| "audio_ratio", "audio_completion_ratio", "updated_time"). | |||||
| "audio_ratio", "audio_completion_ratio", "is_default", "updated_time"). | |||||
| Updates(cp).Error | Updates(cp).Error | ||||
| if err == nil { | if err == nil { | ||||
| setCache(getChannelPricingCacheKey(cp.ModelName, cp.ChannelId), cp) | setCache(getChannelPricingCacheKey(cp.ModelName, cp.ChannelId), cp) | ||||
| if cp.IsDefault { | |||||
| setDefaultChannelCache(cp.ModelName, cp.ChannelId) | |||||
| } else { | |||||
| clearDefaultChannelCacheIfMatch(cp.ModelName, cp.Id) | |||||
| } | |||||
| } | } | ||||
| return err | return err | ||||
| } | } | ||||
| @@ -105,6 +118,9 @@ func (cp *ChannelPricing) Delete() error { | |||||
| err := DB.Delete(cp).Error | err := DB.Delete(cp).Error | ||||
| if err == nil { | if err == nil { | ||||
| removeCache(getChannelPricingCacheKey(existing.ModelName, existing.ChannelId)) | removeCache(getChannelPricingCacheKey(existing.ModelName, existing.ChannelId)) | ||||
| if existing.IsDefault { | |||||
| clearDefaultChannelCache(existing.ModelName) | |||||
| } | |||||
| } | } | ||||
| return err | return err | ||||
| } | } | ||||
| @@ -226,6 +242,8 @@ func LoadChannelPricingCache() { | |||||
| channelPricingCache[key] = cp | channelPricingCache[key] = cp | ||||
| } | } | ||||
| channelPricingCacheLock.Unlock() | channelPricingCacheLock.Unlock() | ||||
| rebuildDefaultChannelCache(pricings) | |||||
| common.SysLog(fmt.Sprintf("[ChannelPricing] cache loaded %d records", len(pricings))) | common.SysLog(fmt.Sprintf("[ChannelPricing] cache loaded %d records", len(pricings))) | ||||
| } | } | ||||
| @@ -247,6 +265,7 @@ type ChannelPricingWithChannel struct { | |||||
| ImageRatio float64 `json:"image_ratio"` | ImageRatio float64 `json:"image_ratio"` | ||||
| AudioRatio float64 `json:"audio_ratio"` | AudioRatio float64 `json:"audio_ratio"` | ||||
| AudioCompletionRatio float64 `json:"audio_completion_ratio"` | AudioCompletionRatio float64 `json:"audio_completion_ratio"` | ||||
| IsDefault bool `json:"is_default"` | |||||
| } | } | ||||
| // GetChannelPricingByModelWithChannelInfo 获取指定模型的渠道定价(带渠道信息) | // GetChannelPricingByModelWithChannelInfo 获取指定模型的渠道定价(带渠道信息) | ||||
| @@ -289,6 +308,7 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin | |||||
| COALESCE(channel_pricings.image_ratio, 0) as image_ratio, | COALESCE(channel_pricings.image_ratio, 0) as image_ratio, | ||||
| COALESCE(channel_pricings.audio_ratio, 0) as audio_ratio, | COALESCE(channel_pricings.audio_ratio, 0) as audio_ratio, | ||||
| COALESCE(channel_pricings.audio_completion_ratio, 0) as audio_completion_ratio, | COALESCE(channel_pricings.audio_completion_ratio, 0) as audio_completion_ratio, | ||||
| COALESCE(channel_pricings.is_default, false) as is_default, | |||||
| (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). | ||||
| Joins("LEFT JOIN channels ON abilities.channel_id = channels.id"). | Joins("LEFT JOIN channels ON abilities.channel_id = channels.id"). | ||||
| @@ -296,7 +316,7 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin | |||||
| 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, channel_pricings.cache_ratio, channel_pricings.cache_creation_ratio, channel_pricings.image_ratio, channel_pricings.audio_ratio, channel_pricings.audio_completion_ratio"). | |||||
| 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, channel_pricings.is_default"). | |||||
| Scan(&results).Error | Scan(&results).Error | ||||
| if err != nil { | if err != nil { | ||||
| return nil, err | return nil, err | ||||
| @@ -321,3 +341,108 @@ func GetChannelPricingByModelWithChannelInfo(modelName string) ([]*ChannelPricin | |||||
| return results, nil | return results, nil | ||||
| } | } | ||||
| // === 默认通道缓存 === | |||||
| // syncIsDefaultToCache 将默认标记的变更同步到 channelPricingCache | |||||
| func syncIsDefaultToCache(modelName string, channelId int, isDefault bool) { | |||||
| channelPricingCacheLock.Lock() | |||||
| for key, cp := range channelPricingCache { | |||||
| if cp.ModelName == modelName { | |||||
| if isDefault { | |||||
| cp.IsDefault = cp.ChannelId == channelId | |||||
| } else { | |||||
| cp.IsDefault = false | |||||
| } | |||||
| } | |||||
| channelPricingCache[key] = cp | |||||
| } | |||||
| channelPricingCacheLock.Unlock() | |||||
| } | |||||
| // setDefaultChannelCache 设置默认通道缓存 | |||||
| func setDefaultChannelCache(modelName string, channelId int) { | |||||
| defaultChannelCacheLock.Lock() | |||||
| defaultChannelCache[modelName] = channelId | |||||
| defaultChannelCacheLock.Unlock() | |||||
| } | |||||
| // clearDefaultChannelCache 清除指定模型的默认通道缓存 | |||||
| func clearDefaultChannelCache(modelName string) { | |||||
| defaultChannelCacheLock.Lock() | |||||
| delete(defaultChannelCache, modelName) | |||||
| defaultChannelCacheLock.Unlock() | |||||
| } | |||||
| // clearDefaultChannelCacheIfMatch 如果默认通道的定价记录 ID 匹配则清除 | |||||
| func clearDefaultChannelCacheIfMatch(modelName string, pricingId int) { | |||||
| defaultChannelCacheLock.RLock() | |||||
| cachedId, ok := defaultChannelCache[modelName] | |||||
| defaultChannelCacheLock.RUnlock() | |||||
| if !ok { | |||||
| return | |||||
| } | |||||
| // 需要通过缓存找到对应的 pricing 来比对 | |||||
| key := getChannelPricingCacheKey(modelName, cachedId) | |||||
| channelPricingCacheLock.RLock() | |||||
| cp, exists := channelPricingCache[key] | |||||
| channelPricingCacheLock.RUnlock() | |||||
| if exists && cp.Id == pricingId { | |||||
| clearDefaultChannelCache(modelName) | |||||
| } | |||||
| } | |||||
| // rebuildDefaultChannelCache 从全量数据构建默认通道缓存(启动时调用) | |||||
| func rebuildDefaultChannelCache(pricings []*ChannelPricing) { | |||||
| defaultChannelCacheLock.Lock() | |||||
| defaultChannelCache = make(map[string]int) | |||||
| for _, cp := range pricings { | |||||
| if cp.IsDefault { | |||||
| defaultChannelCache[cp.ModelName] = cp.ChannelId | |||||
| } | |||||
| } | |||||
| defaultChannelCacheLock.Unlock() | |||||
| common.SysLog(fmt.Sprintf("[ChannelPricing] default channel cache loaded %d records", len(defaultChannelCache))) | |||||
| } | |||||
| // GetDefaultChannelId 获取指定模型的默认通道 ID(纯内存读) | |||||
| func GetDefaultChannelId(modelName string) (int, bool) { | |||||
| defaultChannelCacheLock.RLock() | |||||
| id, ok := defaultChannelCache[modelName] | |||||
| defaultChannelCacheLock.RUnlock() | |||||
| return id, ok | |||||
| } | |||||
| // SetDefaultChannel 设置指定模型的默认通道(事务保证互斥) | |||||
| func SetDefaultChannel(modelName string, channelId int) error { | |||||
| return DB.Transaction(func(tx *gorm.DB) error { | |||||
| // 清除该模型所有现有的默认标记 | |||||
| if err := tx.Model(&ChannelPricing{}). | |||||
| Where("model_name = ? AND is_default = ?", modelName, true). | |||||
| Update("is_default", false).Error; err != nil { | |||||
| return err | |||||
| } | |||||
| // 设置新的默认 | |||||
| if err := tx.Model(&ChannelPricing{}). | |||||
| Where("model_name = ? AND channel_id = ?", modelName, channelId). | |||||
| Update("is_default", true).Error; err != nil { | |||||
| return err | |||||
| } | |||||
| // 更新缓存 | |||||
| setDefaultChannelCache(modelName, channelId) | |||||
| syncIsDefaultToCache(modelName, channelId, true) | |||||
| return nil | |||||
| }) | |||||
| } | |||||
| // ClearDefaultChannel 清除指定模型的默认通道标记 | |||||
| func ClearDefaultChannel(modelName string) error { | |||||
| err := DB.Model(&ChannelPricing{}). | |||||
| Where("model_name = ? AND is_default = ?", modelName, true). | |||||
| Update("is_default", false).Error | |||||
| if err == nil { | |||||
| clearDefaultChannelCache(modelName) | |||||
| syncIsDefaultToCache(modelName, 0, false) | |||||
| } | |||||
| return err | |||||
| } | |||||
| @@ -119,3 +119,124 @@ func TestGetEffectivePricing_NotFound(t *testing.T) { | |||||
| assert.False(t, ok) | assert.False(t, ok) | ||||
| assert.Nil(t, found) | assert.Nil(t, found) | ||||
| } | } | ||||
| // === 默认通道测试 === | |||||
| func TestDefaultChannel_SetAndGet(t *testing.T) { | |||||
| setupChannelPricingDB(t) | |||||
| // 创建两条定价记录 | |||||
| cp1 := &ChannelPricing{ModelName: "default-test-model", ChannelId: 100, QuotaType: QuotaTypeByTokens, ModelRatio: 1.0} | |||||
| cp2 := &ChannelPricing{ModelName: "default-test-model", ChannelId: 200, QuotaType: QuotaTypeByTokens, ModelRatio: 2.0} | |||||
| require.NoError(t, cp1.Insert()) | |||||
| require.NoError(t, cp2.Insert()) | |||||
| t.Cleanup(func() { cp1.Delete(); cp2.Delete() }) | |||||
| // 初始没有默认 | |||||
| _, ok := GetDefaultChannelId("default-test-model") | |||||
| assert.False(t, ok) | |||||
| // 设置通道 100 为默认 | |||||
| require.NoError(t, SetDefaultChannel("default-test-model", 100)) | |||||
| id, ok := GetDefaultChannelId("default-test-model") | |||||
| assert.True(t, ok) | |||||
| assert.Equal(t, 100, id) | |||||
| // 切换默认到通道 200 | |||||
| require.NoError(t, SetDefaultChannel("default-test-model", 200)) | |||||
| id, ok = GetDefaultChannelId("default-test-model") | |||||
| assert.True(t, ok) | |||||
| assert.Equal(t, 200, id) | |||||
| // 验证旧的默认标记被清除 | |||||
| found, _ := GetEffectivePricing("default-test-model", 100) | |||||
| assert.False(t, found.IsDefault) | |||||
| found, _ = GetEffectivePricing("default-test-model", 200) | |||||
| assert.True(t, found.IsDefault) | |||||
| } | |||||
| func TestDefaultChannel_Clear(t *testing.T) { | |||||
| setupChannelPricingDB(t) | |||||
| cp := &ChannelPricing{ModelName: "clear-test-model", ChannelId: 300, QuotaType: QuotaTypeByTokens, ModelRatio: 1.0} | |||||
| require.NoError(t, cp.Insert()) | |||||
| t.Cleanup(func() { cp.Delete() }) | |||||
| require.NoError(t, SetDefaultChannel("clear-test-model", 300)) | |||||
| _, ok := GetDefaultChannelId("clear-test-model") | |||||
| assert.True(t, ok) | |||||
| require.NoError(t, ClearDefaultChannel("clear-test-model")) | |||||
| _, ok = GetDefaultChannelId("clear-test-model") | |||||
| assert.False(t, ok) | |||||
| } | |||||
| func TestDefaultChannel_DeleteClearsCache(t *testing.T) { | |||||
| setupChannelPricingDB(t) | |||||
| cp := &ChannelPricing{ModelName: "delete-test-model", ChannelId: 400, QuotaType: QuotaTypeByTokens, ModelRatio: 1.0, IsDefault: true} | |||||
| require.NoError(t, cp.Insert()) | |||||
| id, ok := GetDefaultChannelId("delete-test-model") | |||||
| assert.True(t, ok) | |||||
| assert.Equal(t, 400, id) | |||||
| // 删除记录后缓存应清除 | |||||
| require.NoError(t, cp.Delete()) | |||||
| _, ok = GetDefaultChannelId("delete-test-model") | |||||
| assert.False(t, ok) | |||||
| } | |||||
| func TestDefaultChannel_LoadCache(t *testing.T) { | |||||
| db := setupChannelPricingDB(t) | |||||
| // 直接插入数据(绕过缓存) | |||||
| db.Create(&ChannelPricing{ModelName: "load-model", ChannelId: 500, ModelRatio: 1.0, IsDefault: true}) | |||||
| db.Create(&ChannelPricing{ModelName: "load-model", ChannelId: 501, ModelRatio: 2.0, IsDefault: false}) | |||||
| db.Create(&ChannelPricing{ModelName: "other-model", ChannelId: 502, ModelRatio: 1.0, IsDefault: true}) | |||||
| // 全量加载缓存 | |||||
| LoadChannelPricingCache() | |||||
| id, ok := GetDefaultChannelId("load-model") | |||||
| assert.True(t, ok) | |||||
| assert.Equal(t, 500, id) | |||||
| id, ok = GetDefaultChannelId("other-model") | |||||
| assert.True(t, ok) | |||||
| assert.Equal(t, 502, id) | |||||
| // 无默认的模型 | |||||
| _, ok = GetDefaultChannelId("nonexistent") | |||||
| assert.False(t, ok) | |||||
| } | |||||
| func TestDefaultChannel_InsertWithDefault(t *testing.T) { | |||||
| setupChannelPricingDB(t) | |||||
| cp := &ChannelPricing{ModelName: "insert-default-model", ChannelId: 600, ModelRatio: 1.0, IsDefault: true} | |||||
| require.NoError(t, cp.Insert()) | |||||
| t.Cleanup(func() { cp.Delete() }) | |||||
| id, ok := GetDefaultChannelId("insert-default-model") | |||||
| assert.True(t, ok) | |||||
| assert.Equal(t, 600, id) | |||||
| } | |||||
| func TestDefaultChannel_UpdateWithDefault(t *testing.T) { | |||||
| setupChannelPricingDB(t) | |||||
| cp1 := &ChannelPricing{ModelName: "update-default-model", ChannelId: 700, ModelRatio: 1.0, IsDefault: true} | |||||
| cp2 := &ChannelPricing{ModelName: "update-default-model", ChannelId: 701, ModelRatio: 2.0} | |||||
| require.NoError(t, cp1.Insert()) | |||||
| require.NoError(t, cp2.Insert()) | |||||
| t.Cleanup(func() { cp1.Delete(); cp2.Delete() }) | |||||
| // cp1 是默认,通过 Update 把 cp2 设为默认 | |||||
| cp2.IsDefault = true | |||||
| require.NoError(t, cp2.Update()) | |||||
| id, ok := GetDefaultChannelId("update-default-model") | |||||
| assert.True(t, ok) | |||||
| assert.Equal(t, 701, id) | |||||
| } | |||||
| @@ -31,6 +31,7 @@ type Pricing struct { | |||||
| SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` | SupportedEndpointTypes []constant.EndpointType `json:"supported_endpoint_types"` | ||||
| PricingVersion string `json:"pricing_version,omitempty"` | PricingVersion string `json:"pricing_version,omitempty"` | ||||
| Type int `json:"type"` | Type int `json:"type"` | ||||
| DefaultChannelName string `json:"default_channel_name,omitempty"` | |||||
| } | } | ||||
| type PricingVendor struct { | type PricingVendor struct { | ||||
| @@ -271,16 +272,21 @@ func updatePricing() { | |||||
| } | } | ||||
| } | } | ||||
| // 从渠道定价表加载实际定价数据(仅启用渠道) | |||||
| var allCPs []ChannelPricing | |||||
| // 从渠道定价表加载实际定价数据(仅启用渠道),同时获取渠道名称 | |||||
| var allCPs []struct { | |||||
| ChannelPricing | |||||
| ChannelName string | |||||
| } | |||||
| DB.Table("channel_pricings"). | DB.Table("channel_pricings"). | ||||
| Select("channel_pricings.*"). | |||||
| Select("channel_pricings.*, channels.name as channel_name"). | |||||
| Joins("JOIN channels ON channel_pricings.channel_id = channels.id"). | Joins("JOIN channels ON channel_pricings.channel_id = channels.id"). | ||||
| Where("channels.status = 1 AND channel_pricings.deleted_at IS NULL"). | Where("channels.status = 1 AND channel_pricings.deleted_at IS NULL"). | ||||
| Find(&allCPs) | Find(&allCPs) | ||||
| cpMap := make(map[string][]ChannelPricing) | cpMap := make(map[string][]ChannelPricing) | ||||
| channelNameMap := make(map[int]string) | |||||
| for i := range allCPs { | for i := range allCPs { | ||||
| cpMap[allCPs[i].ModelName] = append(cpMap[allCPs[i].ModelName], allCPs[i]) | |||||
| cpMap[allCPs[i].ModelName] = append(cpMap[allCPs[i].ModelName], allCPs[i].ChannelPricing) | |||||
| channelNameMap[allCPs[i].ChannelId] = allCPs[i].ChannelName | |||||
| } | } | ||||
| pricingMap = make([]Pricing, 0) | pricingMap = make([]Pricing, 0) | ||||
| @@ -306,6 +312,12 @@ func updatePricing() { | |||||
| // 使用渠道定价表中的实际数据,选取最便宜的渠道 | // 使用渠道定价表中的实际数据,选取最便宜的渠道 | ||||
| applyBestChannelPricing(&pricing, cpMap[model], model) | applyBestChannelPricing(&pricing, cpMap[model], model) | ||||
| // 填充默认通道名称 | |||||
| if chId, ok := GetDefaultChannelId(model); ok { | |||||
| if name, found := channelNameMap[chId]; found { | |||||
| pricing.DefaultChannelName = name | |||||
| } | |||||
| } | |||||
| pricingMap = append(pricingMap, pricing) | pricingMap = append(pricingMap, pricing) | ||||
| } | } | ||||
| @@ -194,6 +194,8 @@ func SetApiRouter(router *gin.Engine) { | |||||
| channelPricingRoute.POST("/batch", controller.BatchCreateChannelPricing) | channelPricingRoute.POST("/batch", controller.BatchCreateChannelPricing) | ||||
| channelPricingRoute.POST("/copy_global/:channel_id", controller.CopyGlobalPricing) | channelPricingRoute.POST("/copy_global/:channel_id", controller.CopyGlobalPricing) | ||||
| channelPricingRoute.DELETE("/:id", controller.DeleteChannelPricing) | channelPricingRoute.DELETE("/:id", controller.DeleteChannelPricing) | ||||
| channelPricingRoute.POST("/set_default", controller.SetDefaultChannel) | |||||
| channelPricingRoute.DELETE("/default/*name", controller.ClearDefaultChannel) | |||||
| } | } | ||||
| // 定价标签路由(管理员权限) | // 定价标签路由(管理员权限) | ||||
| @@ -104,7 +104,7 @@ const ChannelPricingCard = ({ | |||||
| const tableData = channelPricingData.map((item, index) => ({ | const tableData = channelPricingData.map((item, index) => ({ | ||||
| key: item.channel_id || index, | key: item.channel_id || index, | ||||
| channelId: item.channel_id, | channelId: item.channel_id, | ||||
| channelName: `渠道${['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][index] || ` ${index + 1}`}`, | |||||
| channelName: `通道${['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'][index] || ` ${index + 1}`}`, | |||||
| channelTags: item.tags || [], // 渠道定价的标签列表 | channelTags: item.tags || [], // 渠道定价的标签列表 | ||||
| channelType: item.channel_type, | channelType: item.channel_type, | ||||
| quotaType: item.quota_type, | quotaType: item.quota_type, | ||||
| @@ -114,6 +114,7 @@ const ChannelPricingCard = ({ | |||||
| hasCustomPricing: item.has_custom_pricing, | hasCustomPricing: item.has_custom_pricing, | ||||
| cacheRatio: item.cache_ratio, | cacheRatio: item.cache_ratio, | ||||
| cacheCreationRatio: item.cache_creation_ratio, | cacheCreationRatio: item.cache_creation_ratio, | ||||
| isDefault: item.is_default || false, | |||||
| })); | })); | ||||
| // 判断是否存在按次计费的渠道 | // 判断是否存在按次计费的渠道 | ||||
| @@ -124,11 +125,11 @@ const ChannelPricingCard = ({ | |||||
| // 定义基础列 | // 定义基础列 | ||||
| const baseColumns = [ | const baseColumns = [ | ||||
| { | { | ||||
| title: t('渠道'), | |||||
| title: t('通道'), | |||||
| dataIndex: 'channelName', | dataIndex: 'channelName', | ||||
| render: (text, record) => ( | render: (text, record) => ( | ||||
| <div className='flex items-center gap-2 flex-wrap'> | <div className='flex items-center gap-2 flex-wrap'> | ||||
| <Tooltip content={t('点击复制渠道 ID')}> | |||||
| <Tooltip content={t('点击复制通道 ID')}> | |||||
| <Tag | <Tag | ||||
| color='grey' | color='grey' | ||||
| size='small' | size='small' | ||||
| @@ -156,6 +157,11 @@ const ChannelPricingCard = ({ | |||||
| {tag.name} | {tag.name} | ||||
| </Tag> | </Tag> | ||||
| ))} | ))} | ||||
| {record.isDefault && ( | |||||
| <Tag color='amber' size='small' shape='circle'> | |||||
| {t('默认')} | |||||
| </Tag> | |||||
| )} | |||||
| </div> | </div> | ||||
| ), | ), | ||||
| }, | }, | ||||
| @@ -282,15 +288,15 @@ const ChannelPricingCard = ({ | |||||
| <IconServer size={16} /> | <IconServer size={16} /> | ||||
| </Avatar> | </Avatar> | ||||
| <div> | <div> | ||||
| <Text className='text-lg font-medium'>{t('渠道价格')}</Text> | |||||
| <Text className='text-lg font-medium'>{t('通道价格')}</Text> | |||||
| <div className='text-xs text-gray-600'> | <div className='text-xs text-gray-600'> | ||||
| {t('所有支持该模型的渠道价格(自定义定价已标记)')} | |||||
| {t('所有支持该模型的通道价格(自定义定价已标记)')} | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <Banner | <Banner | ||||
| type='info' | type='info' | ||||
| description={t('在 API Key 后添加 ":渠道ID" 可指定使用特定渠道,如:sk-xxxx:1')} | |||||
| description={t('在 API Key 后添加 ":通道ID" 可指定使用特定通道,如:sk-xxxx:1')} | |||||
| className='mb-4' | className='mb-4' | ||||
| /> | /> | ||||
| <Table | <Table | ||||
| @@ -262,7 +262,7 @@ const PricingCardView = ({ | |||||
| <div className='flex items-start space-x-3 flex-1 min-w-0'> | <div className='flex items-start space-x-3 flex-1 min-w-0'> | ||||
| {getModelIcon(model)} | {getModelIcon(model)} | ||||
| <div className='flex-1 min-w-0'> | <div className='flex-1 min-w-0'> | ||||
| <h3 className='text-lg font-bold text-gray-900 truncate'> | |||||
| <h3 className='text-lg font-bold text-gray-900 break-all'> | |||||
| {model.model_name} | {model.model_name} | ||||
| </h3> | </h3> | ||||
| <div className='text-xs mt-1'> | <div className='text-xs mt-1'> | ||||
| @@ -312,6 +312,18 @@ const PricingCardView = ({ | |||||
| {/* 标签区域 */} | {/* 标签区域 */} | ||||
| {renderTags(model)} | {renderTags(model)} | ||||
| {/* 默认通道标识 */} | |||||
| {model.default_channel_name && ( | |||||
| <Tag | |||||
| color='amber' | |||||
| size='small' | |||||
| shape='circle' | |||||
| style={{ marginTop: 4 }} | |||||
| > | |||||
| {t('默认')}: {model.default_channel_name} | |||||
| </Tag> | |||||
| )} | |||||
| {/* 倍率信息(可选) */} | {/* 倍率信息(可选) */} | ||||
| {showRatio && ( | {showRatio && ( | ||||
| <div className='pt-3'> | <div className='pt-3'> | ||||
| @@ -401,6 +401,14 @@ export const channelPricingApi = { | |||||
| // 复制全局定价 | // 复制全局定价 | ||||
| copyGlobal: (channelId, overwrite = false) => | copyGlobal: (channelId, overwrite = false) => | ||||
| API.post(`/api/channel_pricing/copy_global/${channelId}`, { overwrite }), | API.post(`/api/channel_pricing/copy_global/${channelId}`, { overwrite }), | ||||
| // 设置默认通道 | |||||
| setDefault: (modelName, channelId) => | |||||
| API.post('/api/channel_pricing/set_default', { model_name: modelName, channel_id: channelId }), | |||||
| // 清除默认通道 | |||||
| clearDefault: (modelName) => | |||||
| API.delete(`/api/channel_pricing/default/${encodeURIComponent(modelName)}`), | |||||
| }; | }; | ||||
| // 定价标签 API | // 定价标签 API | ||||
| @@ -680,26 +680,9 @@ export const calculateModelPrice = ({ | |||||
| } | } | ||||
| } | } | ||||
| const cacheReadRatio = record.cache_ratio || 0; | |||||
| const cacheCreationRatio = record.cache_creation_ratio || 0; | |||||
| let cacheReadPrice = null; | |||||
| let cacheCreationPrice = null; | |||||
| if (cacheReadRatio > 0) { | |||||
| const cacheReadUSD = record.model_ratio * cacheReadRatio * 2 * usedGroupRatio; | |||||
| const numCacheRead = parseFloat(displayPrice(cacheReadUSD).replace(/[^0-9.]/g, '')) / unitDivisor; | |||||
| cacheReadPrice = `${symbol}${numCacheRead.toFixed(precision)}`; | |||||
| } | |||||
| if (cacheCreationRatio > 0) { | |||||
| const cacheCreationUSD = record.model_ratio * cacheCreationRatio * 2 * usedGroupRatio; | |||||
| const numCacheCreation = parseFloat(displayPrice(cacheCreationUSD).replace(/[^0-9.]/g, '')) / unitDivisor; | |||||
| cacheCreationPrice = `${symbol}${numCacheCreation.toFixed(precision)}`; | |||||
| } | |||||
| return { | return { | ||||
| inputPrice: `${symbol}${numInput.toFixed(precision)}`, | inputPrice: `${symbol}${numInput.toFixed(precision)}`, | ||||
| completionPrice: `${symbol}${numCompletion.toFixed(precision)}`, | completionPrice: `${symbol}${numCompletion.toFixed(precision)}`, | ||||
| cacheReadPrice, | |||||
| cacheCreationPrice, | |||||
| unitLabel, | unitLabel, | ||||
| isPerToken: true, | isPerToken: true, | ||||
| usedGroup, | usedGroup, | ||||
| @@ -733,39 +716,16 @@ export const calculateModelPrice = ({ | |||||
| export const formatPriceInfo = (priceData, t) => { | export const formatPriceInfo = (priceData, t) => { | ||||
| if (priceData.isPerToken) { | if (priceData.isPerToken) { | ||||
| return ( | return ( | ||||
| <div className='flex flex-col gap-0.5'> | |||||
| <div className='flex items-center gap-2 flex-wrap'> | |||||
| <span style={{ color: 'var(--semi-color-text-1)' }}> | |||||
| {t('输入')} {priceData.inputPrice}/{priceData.unitLabel} | |||||
| </span> | |||||
| <span style={{ color: 'var(--semi-color-text-1)' }}> | |||||
| {t('输出')} {priceData.completionPrice}/{priceData.unitLabel} | |||||
| </span> | |||||
| </div> | |||||
| {(priceData.cacheReadPrice || priceData.cacheCreationPrice) && ( | |||||
| <div className='flex items-center gap-2 flex-wrap'> | |||||
| {priceData.cacheReadPrice && ( | |||||
| <span style={{ color: 'var(--semi-color-text-2)' }}> | |||||
| {t('缓存读取')} {priceData.cacheReadPrice}/{priceData.unitLabel} | |||||
| </span> | |||||
| )} | |||||
| {priceData.cacheCreationPrice && ( | |||||
| <span style={{ color: 'var(--semi-color-text-2)' }}> | |||||
| {t('缓存创建')} {priceData.cacheCreationPrice}/{priceData.unitLabel} | |||||
| </span> | |||||
| )} | |||||
| </div> | |||||
| )} | |||||
| </div> | |||||
| <span style={{ color: 'var(--semi-color-text-1)' }}> | |||||
| {t('输入')} {priceData.inputPrice} / {t('输出')} {priceData.completionPrice} / {priceData.unitLabel} tokens | |||||
| </span> | |||||
| ); | ); | ||||
| } | } | ||||
| return ( | return ( | ||||
| <> | |||||
| <span style={{ color: 'var(--semi-color-text-1)' }}> | |||||
| {t('模型价格')} {priceData.price} | |||||
| </span> | |||||
| </> | |||||
| <span style={{ color: 'var(--semi-color-text-1)' }}> | |||||
| {t('模型价格')} {priceData.price} | |||||
| </span> | |||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -2230,9 +2230,15 @@ | |||||
| "统一的": "The Unified", | "统一的": "The Unified", | ||||
| "统一的大模型接口网关": "Unified LLM API Gateway", | "统一的大模型接口网关": "Unified LLM API Gateway", | ||||
| "链接顶级 AI 能力": "Connect Top-Tier AI", | "链接顶级 AI 能力": "Connect Top-Tier AI", | ||||
| "点击复制渠道 ID": "Click to copy channel ID", | |||||
| "点击复制通道 ID": "Click to copy channel ID", | |||||
| "统一监控": "Unified Monitoring", | "统一监控": "Unified Monitoring", | ||||
| "统计Tokens": "Statistical Tokens", | "统计Tokens": "Statistical Tokens", | ||||
| "通道": "Channel", | |||||
| "通道价格": "Channel Pricing", | |||||
| "已设为默认通道": "Set as default channel", | |||||
| "已取消默认通道": "Removed default channel", | |||||
| "所有支持该模型的通道价格(自定义定价已标记)": "Pricing from all channels supporting this model (custom pricing marked)", | |||||
| "在 API Key 后添加 \":通道ID\" 可指定使用特定通道,如:sk-xxxx:1": "Append \":ChannelID\" to the API Key to use a specific channel, e.g. sk-xxxx:1", | |||||
| "统计已重置": "Statistics reset", | "统计已重置": "Statistics reset", | ||||
| "统计次数": "Statistical count", | "统计次数": "Statistical count", | ||||
| "统计额度": "Statistical quota", | "统计额度": "Statistical quota", | ||||
| @@ -2212,9 +2212,15 @@ | |||||
| "统一的": "统一的", | "统一的": "统一的", | ||||
| "统一的大模型接口网关": "统一的大模型接口网关", | "统一的大模型接口网关": "统一的大模型接口网关", | ||||
| "链接顶级 AI 能力": "链接顶级 AI 能力", | "链接顶级 AI 能力": "链接顶级 AI 能力", | ||||
| "点击复制渠道 ID": "点击复制渠道 ID", | |||||
| "点击复制通道 ID": "点击复制通道 ID", | |||||
| "统一监控": "统一监控", | "统一监控": "统一监控", | ||||
| "统计Tokens": "统计Tokens", | "统计Tokens": "统计Tokens", | ||||
| "通道": "通道", | |||||
| "通道价格": "通道价格", | |||||
| "已设为默认通道": "已设为默认通道", | |||||
| "已取消默认通道": "已取消默认通道", | |||||
| "所有支持该模型的通道价格(自定义定价已标记)": "所有支持该模型的通道价格(自定义定价已标记)", | |||||
| "在 API Key 后添加 \":通道ID\" 可指定使用特定通道,如:sk-xxxx:1": "在 API Key 后添加 \":通道ID\" 可指定使用特定通道,如:sk-xxxx:1", | |||||
| "统计已重置": "统计已重置", | "统计已重置": "统计已重置", | ||||
| "统计次数": "统计次数", | "统计次数": "统计次数", | ||||
| "统计额度": "统计额度", | "统计额度": "统计额度", | ||||
| @@ -33,6 +33,7 @@ import { | |||||
| RadioGroup, | RadioGroup, | ||||
| Radio, | Radio, | ||||
| TreeSelect, | TreeSelect, | ||||
| Tooltip, | |||||
| } from '@douyinfe/semi-ui'; | } from '@douyinfe/semi-ui'; | ||||
| import { | import { | ||||
| IconEdit, | IconEdit, | ||||
| @@ -40,6 +41,7 @@ import { | |||||
| IconChevronDown, | IconChevronDown, | ||||
| IconChevronRight, | IconChevronRight, | ||||
| IconSearch, | IconSearch, | ||||
| IconStar, | |||||
| } from '@douyinfe/semi-icons'; | } from '@douyinfe/semi-icons'; | ||||
| import { useTranslation } from 'react-i18next'; | import { useTranslation } from 'react-i18next'; | ||||
| import { channelPricingApi } from '../../../helpers/api'; | import { channelPricingApi } from '../../../helpers/api'; | ||||
| @@ -315,6 +317,42 @@ const ChannelPricingView = ({ channels, channelPricings, tags, onRefresh }) => { | |||||
| )); | )); | ||||
| }, | }, | ||||
| }, | }, | ||||
| { | |||||
| title: t('默认'), | |||||
| key: 'isDefault', | |||||
| width: 70, | |||||
| render: (_, record) => { | |||||
| const isDefault = record.pricing?.is_default === true; | |||||
| return ( | |||||
| <Tooltip content={isDefault ? t('此为该模型的默认通道') : t('设为默认通道')}> | |||||
| <div className="flex items-center gap-1"> | |||||
| <Button | |||||
| size="small" | |||||
| type={isDefault ? 'warning' : 'tertiary'} | |||||
| icon={<IconStar />} | |||||
| onClick={async () => { | |||||
| try { | |||||
| if (isDefault) { | |||||
| await channelPricingApi.clearDefault(record.modelName); | |||||
| showSuccess(t('已取消默认通道')); | |||||
| } else { | |||||
| await channelPricingApi.setDefault(record.modelName, record.channelId); | |||||
| showSuccess(t('已设为默认通道')); | |||||
| } | |||||
| onRefresh(); | |||||
| } catch (e) { | |||||
| showError(e); | |||||
| } | |||||
| }} | |||||
| /> | |||||
| {isDefault && ( | |||||
| <span className="text-xs text-amber-500 font-medium">{t('默认')}</span> | |||||
| )} | |||||
| </div> | |||||
| </Tooltip> | |||||
| ); | |||||
| }, | |||||
| }, | |||||
| { | { | ||||
| title: t('操作'), | title: t('操作'), | ||||
| key: 'action', | key: 'action', | ||||