From bfa7cc7b97c717d6ef1d72300e3d68698b3617dc Mon Sep 17 00:00:00 2001 From: fengsilin Date: Wed, 25 Mar 2026 15:47:54 +0800 Subject: [PATCH] feat(token): support dynamic channel specification via colon separator - Add support for sk-{key}:{channelId} format to specify channel dynamically - Remove BoundChannelId field and related code - All users can now specify channels, not just admins - Add model validation when channel is specified - Remove bound channel UI from token management Breaking change: tokens can no longer be bound to channels at creation time. Use sk-{key}:{channelId} format instead. Co-Authored-By: Claude Opus 4.6 --- constant/context_key.go | 1 - controller/token.go | 2 - middleware/auth.go | 32 +++- middleware/distributor.go | 181 ++++++++---------- model/token.go | 3 +- .../table/tokens/modals/EditTokenModal.jsx | 12 -- 6 files changed, 101 insertions(+), 130 deletions(-) diff --git a/constant/context_key.go b/constant/context_key.go index 2cdce7e..2ba2fe2 100644 --- a/constant/context_key.go +++ b/constant/context_key.go @@ -19,7 +19,6 @@ const ( ContextKeyTokenModelLimitEnabled ContextKey = "token_model_limit_enabled" ContextKeyTokenModelLimit ContextKey = "token_model_limit" ContextKeyTokenCrossGroupRetry ContextKey = "token_cross_group_retry" - ContextKeyTokenBoundChannelId ContextKey = "token_bound_channel_id" /* channel related keys */ ContextKeyChannelId ContextKey = "channel_id" diff --git a/controller/token.go b/controller/token.go index f445e54..50da7e3 100644 --- a/controller/token.go +++ b/controller/token.go @@ -194,7 +194,6 @@ func AddToken(c *gin.Context) { AllowIps: token.AllowIps, Group: token.Group, CrossGroupRetry: token.CrossGroupRetry, - BoundChannelId: token.BoundChannelId, } err = cleanToken.Insert() if err != nil { @@ -275,7 +274,6 @@ func UpdateToken(c *gin.Context) { cleanToken.AllowIps = token.AllowIps cleanToken.Group = token.Group cleanToken.CrossGroupRetry = token.CrossGroupRetry - cleanToken.BoundChannelId = token.BoundChannelId } err = cleanToken.Update() if err != nil { diff --git a/middleware/auth.go b/middleware/auth.go index 6cb20ab..1a70f54 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -294,12 +294,32 @@ func TokenAuth() func(c *gin.Context) { key = strings.TrimSpace(key[7:]) } key = strings.TrimPrefix(key, "sk-") + // 先按冒号分割获取 channelId(新格式:sk-{key}:{channelId}) + var colonChannelId string + if colonIdx := strings.Index(key, ":"); colonIdx != -1 { + colonChannelId = key[colonIdx+1:] + key = key[:colonIdx] + } parts = strings.Split(key, "-") key = parts[0] + // 如果冒号方式指定了渠道,追加到 parts + if colonChannelId != "" { + parts = append(parts, colonChannelId) + } } else { key = strings.TrimPrefix(key, "sk-") + // 先按冒号分割获取 channelId(新格式:sk-{key}:{channelId}) + var colonChannelId string + if colonIdx := strings.Index(key, ":"); colonIdx != -1 { + colonChannelId = key[colonIdx+1:] + key = key[:colonIdx] + } parts = strings.Split(key, "-") key = parts[0] + // 如果冒号方式指定了渠道,追加到 parts + if colonChannelId != "" { + parts = append(parts, colonChannelId) + } } token, err := model.ValidateUserToken(key) if token != nil { @@ -389,17 +409,9 @@ func SetupContextForToken(c *gin.Context, token *model.Token, parts ...string) e } common.SetContextKey(c, constant.ContextKeyTokenGroup, token.Group) common.SetContextKey(c, constant.ContextKeyTokenCrossGroupRetry, token.CrossGroupRetry) - if token.BoundChannelId != nil { - common.SetContextKey(c, constant.ContextKeyTokenBoundChannelId, *token.BoundChannelId) - } + // 所有用户都可以通过 sk-{key}:{channelId} 或 sk-{key}-{channelId} 指定渠道 if len(parts) > 1 { - if model.IsAdmin(token.UserId) { - c.Set("specific_channel_id", parts[1]) - } else { - c.Header("specific_channel_version", "701e3ae1dc3f7975556d354e0675168d004891c8") - abortWithOpenAiMessage(c, http.StatusForbidden, "普通用户不支持指定渠道") - return fmt.Errorf("普通用户不支持指定渠道") - } + c.Set("specific_channel_id", parts[1]) } return nil } diff --git a/middleware/distributor.go b/middleware/distributor.go index fe08db6..0c2211b 100644 --- a/middleware/distributor.go +++ b/middleware/distributor.go @@ -51,129 +51,104 @@ func Distribute() func(c *gin.Context) { abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorChannelDisabled)) return } + // 验证指定渠道是否支持请求的模型 + if shouldSelectChannel && modelRequest.Model != "" { + usingGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) + if !model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, channel.Id) { + abortWithOpenAiMessage(c, http.StatusServiceUnavailable, i18n.T(c, i18n.MsgDistributorNoAvailableChannel, map[string]any{"Group": usingGroup, "Model": modelRequest.Model}), types.ErrorCodeModelNotFound) + return + } + } } else { - // Select a channel for the user - // Check if token has a bound channel - boundChannelId, hasBoundChannel := common.GetContextKey(c, constant.ContextKeyTokenBoundChannelId) - if hasBoundChannel { - id, ok := boundChannelId.(int) + // Normal channel selection logic + // check token model mapping + modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled) + if modelLimitEnable { + s, ok := common.GetContextKey(c, constant.ContextKeyTokenModelLimit) if !ok { - abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidChannelId)) + // token model limit is empty, all models are not allowed + abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorTokenNoModelAccess)) return } - channel, err = model.GetChannelById(id, true) - if err != nil { - abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidChannelId)) - return + var tokenModelLimit map[string]bool + tokenModelLimit, ok = s.(map[string]bool) + if !ok { + tokenModelLimit = map[string]bool{} } - if channel.Status != common.ChannelStatusEnabled { - abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorChannelDisabled)) + matchName := ratio_setting.FormatMatchingModelName(modelRequest.Model) // match gpts & thinking-* + if _, ok := tokenModelLimit[matchName]; !ok { + abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorTokenModelForbidden, map[string]any{"Model": modelRequest.Model})) return } - // Verify the bound channel supports the requested model - if shouldSelectChannel && modelRequest.Model != "" { - usingGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) - if !model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, channel.Id) { - abortWithOpenAiMessage(c, http.StatusServiceUnavailable, i18n.T(c, i18n.MsgDistributorNoAvailableChannel, map[string]any{"Group": usingGroup, "Model": modelRequest.Model}), types.ErrorCodeModelNotFound) - return - } - } - } else { - // Normal channel selection logic - // check token model mapping - modelLimitEnable := common.GetContextKeyBool(c, constant.ContextKeyTokenModelLimitEnabled) - if modelLimitEnable { - s, ok := common.GetContextKey(c, constant.ContextKeyTokenModelLimit) - if !ok { - // token model limit is empty, all models are not allowed - abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorTokenNoModelAccess)) - return - } - var tokenModelLimit map[string]bool - tokenModelLimit, ok = s.(map[string]bool) - if !ok { - tokenModelLimit = map[string]bool{} - } - matchName := ratio_setting.FormatMatchingModelName(modelRequest.Model) // match gpts & thinking-* - if _, ok := tokenModelLimit[matchName]; !ok { - abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorTokenModelForbidden, map[string]any{"Model": modelRequest.Model})) - return - } - } + } - if shouldSelectChannel { - if modelRequest.Model == "" { - abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorModelNameRequired)) + if shouldSelectChannel { + if modelRequest.Model == "" { + abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorModelNameRequired)) + return + } + var selectGroup string + usingGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) + // check path is /pg/chat/completions + if strings.HasPrefix(c.Request.URL.Path, "/pg/chat/completions") { + playgroundRequest := &dto.PlayGroundRequest{} + err = common.UnmarshalBodyReusable(c, playgroundRequest) + if err != nil { + abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidPlayground, map[string]any{"Error": err.Error()})) return } - var selectGroup string - usingGroup := common.GetContextKeyString(c, constant.ContextKeyUsingGroup) - // check path is /pg/chat/completions - if strings.HasPrefix(c.Request.URL.Path, "/pg/chat/completions") { - playgroundRequest := &dto.PlayGroundRequest{} - err = common.UnmarshalBodyReusable(c, playgroundRequest) - if err != nil { - abortWithOpenAiMessage(c, http.StatusBadRequest, i18n.T(c, i18n.MsgDistributorInvalidPlayground, map[string]any{"Error": err.Error()})) + if playgroundRequest.Group != "" { + if !service.GroupInUserUsableGroups(usingGroup, playgroundRequest.Group) && playgroundRequest.Group != usingGroup { + abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorGroupAccessDenied)) return } - if playgroundRequest.Group != "" { - if !service.GroupInUserUsableGroups(usingGroup, playgroundRequest.Group) && playgroundRequest.Group != usingGroup { - abortWithOpenAiMessage(c, http.StatusForbidden, i18n.T(c, i18n.MsgDistributorGroupAccessDenied)) - return - } - usingGroup = playgroundRequest.Group - common.SetContextKey(c, constant.ContextKeyUsingGroup, usingGroup) - } + usingGroup = playgroundRequest.Group + common.SetContextKey(c, constant.ContextKeyUsingGroup, usingGroup) } + } - 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 - } + 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) } + } else if model.IsChannelEnabledForGroupModel(usingGroup, modelRequest.Model, preferred.Id) { + channel = preferred + selectGroup = usingGroup + service.MarkChannelAffinityUsed(c, usingGroup, preferred.Id) } } + } - if channel == nil { - channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(&service.RetryParam{ - Ctx: c, - ModelName: modelRequest.Model, - TokenGroup: usingGroup, - Retry: common.GetPointer(0), - }) - if err != nil { - showGroup := usingGroup - if usingGroup == "auto" { - showGroup = fmt.Sprintf("auto(%s)", selectGroup) - } - message := i18n.T(c, i18n.MsgDistributorGetChannelFailed, map[string]any{"Group": showGroup, "Model": modelRequest.Model, "Error": err.Error()}) - // 如果错误,但是渠道不为空,说明是数据库一致性问题 - //if channel != nil { - // common.SysError(fmt.Sprintf("渠道不存在:%d", channel.Id)) - // message = "数据库一致性已被破坏,请联系管理员" - //} - abortWithOpenAiMessage(c, http.StatusServiceUnavailable, message, types.ErrorCodeModelNotFound) - return - } - if channel == nil { - abortWithOpenAiMessage(c, http.StatusServiceUnavailable, i18n.T(c, i18n.MsgDistributorNoAvailableChannel, map[string]any{"Group": usingGroup, "Model": modelRequest.Model}), types.ErrorCodeModelNotFound) - return + if channel == nil { + channel, selectGroup, err = service.CacheGetRandomSatisfiedChannel(&service.RetryParam{ + Ctx: c, + ModelName: modelRequest.Model, + TokenGroup: usingGroup, + Retry: common.GetPointer(0), + }) + if err != nil { + showGroup := usingGroup + if usingGroup == "auto" { + showGroup = fmt.Sprintf("auto(%s)", selectGroup) } + message := i18n.T(c, i18n.MsgDistributorGetChannelFailed, map[string]any{"Group": showGroup, "Model": modelRequest.Model, "Error": err.Error()}) + abortWithOpenAiMessage(c, http.StatusServiceUnavailable, message, types.ErrorCodeModelNotFound) + return + } + if channel == nil { + abortWithOpenAiMessage(c, http.StatusServiceUnavailable, i18n.T(c, i18n.MsgDistributorNoAvailableChannel, map[string]any{"Group": usingGroup, "Model": modelRequest.Model}), types.ErrorCodeModelNotFound) + return } } } diff --git a/model/token.go b/model/token.go index 3bee9e8..2f318f8 100644 --- a/model/token.go +++ b/model/token.go @@ -28,7 +28,6 @@ type Token struct { UsedQuota int `json:"used_quota" gorm:"default:0"` // used quota Group string `json:"group" gorm:"default:''"` CrossGroupRetry bool `json:"cross_group_retry"` // 跨分组重试,仅auto分组有效 - BoundChannelId *int `json:"bound_channel_id" gorm:"index"` // 绑定的渠道ID,nil表示不绑定 DeletedAt gorm.DeletedAt `gorm:"index"` } @@ -284,7 +283,7 @@ func (token *Token) Update() (err error) { } }() err = DB.Model(token).Select("name", "status", "expired_time", "remain_quota", "unlimited_quota", - "model_limits_enabled", "model_limits", "allow_ips", "group", "cross_group_retry", "bound_channel_id").Updates(token).Error + "model_limits_enabled", "model_limits", "allow_ips", "group", "cross_group_retry").Updates(token).Error return err } diff --git a/web/src/components/table/tokens/modals/EditTokenModal.jsx b/web/src/components/table/tokens/modals/EditTokenModal.jsx index c6315cd..f477916 100644 --- a/web/src/components/table/tokens/modals/EditTokenModal.jsx +++ b/web/src/components/table/tokens/modals/EditTokenModal.jsx @@ -76,7 +76,6 @@ const EditTokenModal = (props) => { group: '', cross_group_retry: false, tokenCount: 1, - bound_channel_id: null, }); const handleCancel = () => { @@ -578,17 +577,6 @@ const EditTokenModal = (props) => { style={{ width: '100%' }} /> - - -