| package app |
|
|
| import ( |
| "context" |
| "fmt" |
| "log" |
| "net/http" |
| "slices" |
| "sort" |
| "strconv" |
| "strings" |
| "time" |
|
|
| "ccLoad/internal/model" |
| "ccLoad/internal/util" |
|
|
| "github.com/bytedance/sonic" |
| "github.com/gin-gonic/gin" |
| ) |
|
|
| |
| |
|
|
| |
| func (s *Server) HandleChannels(c *gin.Context) { |
| switch c.Request.Method { |
| case "GET": |
| s.handleListChannels(c) |
| case "POST": |
| s.handleCreateChannel(c) |
| default: |
| RespondErrorMsg(c, 405, "method not allowed") |
| } |
| } |
|
|
| func channelKeyStrategy(apiKeys []*model.APIKey) string { |
| if len(apiKeys) > 0 && apiKeys[0].KeyStrategy != "" { |
| return apiKeys[0].KeyStrategy |
| } |
| return model.KeyStrategySequential |
| } |
|
|
| |
| |
| |
| |
| func filterConfigs(cfgs []*model.Config, keep func(*model.Config) bool) []*model.Config { |
| out := make([]*model.Config, 0, len(cfgs)) |
| for _, cfg := range cfgs { |
| if keep(cfg) { |
| out = append(out, cfg) |
| } |
| } |
| return out |
| } |
|
|
| func channelExposesProtocol(cfg *model.Config, normalizedProtocol string) bool { |
| if util.NormalizeChannelType(cfg.ChannelType) == normalizedProtocol { |
| return true |
| } |
| for _, transform := range cfg.ProtocolTransforms { |
| if strings.TrimSpace(transform) == "" { |
| continue |
| } |
| if util.NormalizeChannelType(transform) == normalizedProtocol { |
| return true |
| } |
| } |
| return false |
| } |
|
|
| func (s *Server) handleListChannels(c *gin.Context) { |
| cfgs, err := s.store.ListConfigs(c.Request.Context()) |
| if err != nil { |
| RespondError(c, http.StatusInternalServerError, err) |
| return |
| } |
|
|
| now := time.Now() |
|
|
| |
| allChannelCooldowns, err := s.getAllChannelCooldowns(c.Request.Context()) |
| if err != nil { |
| |
| log.Printf("[WARN] 批量查询渠道冷却状态失败: %v", err) |
| allChannelCooldowns = make(map[int64]time.Time) |
| } |
|
|
| |
| |
| |
| cfgs = applyChannelListFilters(cfgs, c, allChannelCooldowns, now) |
|
|
| hasPagination := c.Query("limit") != "" || c.Query("offset") != "" |
|
|
| |
| allKeyCooldowns, err := s.getAllKeyCooldowns(c.Request.Context()) |
| if err != nil { |
| |
| log.Printf("[WARN] 批量查询Key冷却状态失败: %v", err) |
| allKeyCooldowns = make(map[int64]map[int]time.Time) |
| } |
|
|
| |
| allAPIKeys, err := s.store.GetAllAPIKeys(c.Request.Context()) |
| if err != nil { |
| log.Printf("[WARN] 批量查询API Keys失败: %v", err) |
| allAPIKeys = make(map[int64][]*model.APIKey) |
| } |
|
|
| |
| healthEnabled := s.healthCache != nil && s.healthCache.Config().Enabled |
|
|
| |
| |
| priorityMap, successRateMap := s.sortChannelsByEffectivePriority(cfgs, healthEnabled) |
|
|
| totalCount := len(cfgs) |
|
|
| if hasPagination { |
| cfgs = paginateChannels(cfgs, c) |
| } |
|
|
| ectx := &channelEnrichmentContext{ |
| now: now, |
| healthEnabled: healthEnabled, |
| priorityMap: priorityMap, |
| successRateMap: successRateMap, |
| channelCooldownsMap: allChannelCooldowns, |
| keyCooldownsMap: allKeyCooldowns, |
| apiKeysMap: allAPIKeys, |
| } |
| out := make([]ChannelWithCooldown, 0, len(cfgs)) |
| for _, cfg := range cfgs { |
| out = append(out, ectx.enrichChannel(cfg)) |
| } |
|
|
| |
| for i := range out { |
| for j := range out[i].ModelEntries { |
| if out[i].Config.ModelEntries[j].RedirectModel == "" { |
| out[i].Config.ModelEntries[j].RedirectModel = out[i].Config.ModelEntries[j].Model |
| } |
| } |
| } |
|
|
| if hasPagination { |
| RespondPaginated(c, http.StatusOK, out, totalCount) |
| return |
| } |
| RespondJSON(c, http.StatusOK, out) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| func applyChannelListFilters(cfgs []*model.Config, c *gin.Context, channelCooldownsMap map[int64]time.Time, now time.Time) []*model.Config { |
| |
| if t := c.Query("type"); t != "" && t != "all" { |
| normalized := util.NormalizeChannelType(t) |
| cfgs = filterConfigs(cfgs, func(cfg *model.Config) bool { |
| return channelExposesProtocol(cfg, normalized) |
| }) |
| } |
|
|
| |
| if name := strings.TrimSpace(c.Query("channel_name")); name != "" { |
| cfgs = filterConfigs(cfgs, func(cfg *model.Config) bool { |
| return strings.TrimSpace(cfg.Name) == name |
| }) |
| } else if search := strings.TrimSpace(c.Query("search")); search != "" { |
| searchLower := strings.ToLower(search) |
| cfgs = filterConfigs(cfgs, func(cfg *model.Config) bool { |
| return strings.Contains(strings.ToLower(strings.TrimSpace(cfg.Name)), searchLower) |
| }) |
| } |
|
|
| |
| if status := strings.TrimSpace(c.Query("status")); status != "" && status != "all" { |
| cfgs = filterConfigs(cfgs, func(cfg *model.Config) bool { |
| switch status { |
| case "enabled": |
| return cfg.Enabled |
| case "disabled": |
| return !cfg.Enabled |
| case "cooldown": |
| until, cooled := channelCooldownsMap[cfg.ID] |
| return cooled && until.After(now) |
| } |
| return false |
| }) |
| } |
|
|
| |
| if modelName := strings.TrimSpace(c.Query("model")); modelName != "" && modelName != "all" { |
| cfgs = filterConfigs(cfgs, func(cfg *model.Config) bool { |
| for _, entry := range cfg.ModelEntries { |
| if entry.Model == modelName { |
| return true |
| } |
| } |
| return false |
| }) |
| } else if modelLike := strings.TrimSpace(c.Query("model_like")); modelLike != "" && modelLike != "all" { |
| modelLikeLower := strings.ToLower(modelLike) |
| cfgs = filterConfigs(cfgs, func(cfg *model.Config) bool { |
| for _, entry := range cfg.ModelEntries { |
| if strings.Contains(strings.ToLower(strings.TrimSpace(entry.Model)), modelLikeLower) { |
| return true |
| } |
| } |
| return false |
| }) |
| } |
|
|
| return cfgs |
| } |
|
|
| |
| |
| |
| |
| func (s *Server) sortChannelsByEffectivePriority(cfgs []*model.Config, healthEnabled bool) (priorityMap, successRateMap map[int64]float64) { |
| priorityMap = make(map[int64]float64, len(cfgs)) |
| successRateMap = make(map[int64]float64, len(cfgs)) |
| if healthEnabled { |
| hcfg := s.healthCache.Config() |
| for _, cfg := range cfgs { |
| stats := s.healthCache.GetHealthStats(cfg.ID) |
| priorityMap[cfg.ID] = s.calculateEffectivePriority(cfg, stats, hcfg) |
| if stats.SampleCount > 0 { |
| successRateMap[cfg.ID] = stats.SuccessRate |
| } |
| } |
| sort.Slice(cfgs, func(i, j int) bool { |
| return priorityMap[cfgs[i].ID] > priorityMap[cfgs[j].ID] |
| }) |
| } else { |
| sort.Slice(cfgs, func(i, j int) bool { |
| if cfgs[i].Priority != cfgs[j].Priority { |
| return cfgs[i].Priority > cfgs[j].Priority |
| } |
| return cfgs[i].Name < cfgs[j].Name |
| }) |
| } |
| return priorityMap, successRateMap |
| } |
|
|
| |
| |
| func paginateChannels(cfgs []*model.Config, c *gin.Context) []*model.Config { |
| limit := 20 |
| offset := 0 |
| if v, err := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "20"))); err == nil && v > 0 { |
| limit = min(v, 1000) |
| } |
| if v, err := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("offset", "0"))); err == nil && v >= 0 { |
| offset = v |
| } |
| totalCount := len(cfgs) |
| if offset >= totalCount { |
| return []*model.Config{} |
| } |
| end := min(offset+limit, totalCount) |
| return cfgs[offset:end] |
| } |
|
|
| |
| type channelEnrichmentContext struct { |
| now time.Time |
| healthEnabled bool |
| priorityMap map[int64]float64 |
| successRateMap map[int64]float64 |
| channelCooldownsMap map[int64]time.Time |
| keyCooldownsMap map[int64]map[int]time.Time |
| apiKeysMap map[int64][]*model.APIKey |
| } |
|
|
| |
| |
| func (ectx *channelEnrichmentContext) enrichChannel(cfg *model.Config) ChannelWithCooldown { |
| oc := ChannelWithCooldown{Config: cfg} |
|
|
| |
| if until, cooled := ectx.channelCooldownsMap[cfg.ID]; cooled && until.After(ectx.now) { |
| oc.CooldownUntil = &until |
| oc.CooldownRemainingMS = int64(until.Sub(ectx.now) / time.Millisecond) |
| } |
|
|
| |
| if ectx.healthEnabled { |
| if rate, ok := ectx.successRateMap[cfg.ID]; ok { |
| oc.SuccessRate = &rate |
| } |
| effPriority := ectx.priorityMap[cfg.ID] |
| oc.EffectivePriority = &effPriority |
| } |
|
|
| |
| apiKeys := ectx.apiKeysMap[cfg.ID] |
|
|
| |
| oc.KeyStrategy = channelKeyStrategy(apiKeys) |
|
|
| keyCooldowns := make([]KeyCooldownInfo, 0, len(apiKeys)) |
| channelKeyCooldowns := ectx.keyCooldownsMap[cfg.ID] |
| for _, apiKey := range apiKeys { |
| keyInfo := KeyCooldownInfo{KeyIndex: apiKey.KeyIndex} |
| if until, cooled := channelKeyCooldowns[apiKey.KeyIndex]; cooled && until.After(ectx.now) { |
| u := until |
| keyInfo.CooldownUntil = &u |
| keyInfo.CooldownRemainingMS = int64(until.Sub(ectx.now) / time.Millisecond) |
| } |
| keyCooldowns = append(keyCooldowns, keyInfo) |
| } |
| oc.KeyCooldowns = keyCooldowns |
| return oc |
| } |
|
|
| |
| |
| |
| func (s *Server) HandleChannelsFilterOptions(c *gin.Context) { |
| cfgs, err := s.store.ListConfigs(c.Request.Context()) |
| if err != nil { |
| RespondError(c, http.StatusInternalServerError, err) |
| return |
| } |
|
|
| if t := c.Query("type"); t != "" && t != "all" { |
| normalizedQueryType := util.NormalizeChannelType(t) |
| filtered := make([]*model.Config, 0, len(cfgs)) |
| for _, cfg := range cfgs { |
| if channelExposesProtocol(cfg, normalizedQueryType) { |
| filtered = append(filtered, cfg) |
| } |
| } |
| cfgs = filtered |
| } |
|
|
| if status := strings.TrimSpace(c.Query("status")); status != "" && status != "all" { |
| now := time.Now() |
| allChannelCooldowns, err := s.getAllChannelCooldowns(c.Request.Context()) |
| if err != nil { |
| log.Printf("[WARN] 批量查询渠道冷却状态失败: %v", err) |
| allChannelCooldowns = make(map[int64]time.Time) |
| } |
| filtered := make([]*model.Config, 0, len(cfgs)) |
| for _, cfg := range cfgs { |
| switch status { |
| case "enabled": |
| if cfg.Enabled { |
| filtered = append(filtered, cfg) |
| } |
| case "disabled": |
| if !cfg.Enabled { |
| filtered = append(filtered, cfg) |
| } |
| case "cooldown": |
| if until, cooled := allChannelCooldowns[cfg.ID]; cooled && until.After(now) { |
| filtered = append(filtered, cfg) |
| } |
| } |
| } |
| cfgs = filtered |
| } |
|
|
| nameSet := make(map[string]struct{}, len(cfgs)) |
| modelSet := make(map[string]struct{}) |
| for _, cfg := range cfgs { |
| if name := strings.TrimSpace(cfg.Name); name != "" { |
| nameSet[name] = struct{}{} |
| } |
| for _, entry := range cfg.ModelEntries { |
| if entry.Model != "" { |
| modelSet[entry.Model] = struct{}{} |
| } |
| } |
| } |
|
|
| channelNames := make([]string, 0, len(nameSet)) |
| for n := range nameSet { |
| channelNames = append(channelNames, n) |
| } |
| sort.Strings(channelNames) |
|
|
| models := make([]string, 0, len(modelSet)) |
| for m := range modelSet { |
| models = append(models, m) |
| } |
| sort.Strings(models) |
|
|
| RespondJSON(c, http.StatusOK, gin.H{ |
| "channel_names": channelNames, |
| "models": models, |
| }) |
| } |
|
|
| |
| |
| |
| func (s *Server) HandleCheckDuplicateChannel(c *gin.Context) { |
| var req CheckDuplicateRequest |
| if err := BindAndValidate(c, &req); err != nil { |
| RespondErrorMsg(c, http.StatusBadRequest, "invalid request: "+err.Error()) |
| return |
| } |
|
|
| normalizedType := util.NormalizeChannelType(req.ChannelType) |
|
|
| |
| newURLSet := make(map[string]struct{}, len(req.URLs)) |
| for _, u := range req.URLs { |
| u = strings.TrimSpace(u) |
| if u != "" { |
| newURLSet[u] = struct{}{} |
| } |
| } |
|
|
| cfgs, err := s.store.ListConfigs(c.Request.Context()) |
| if err != nil { |
| RespondError(c, http.StatusInternalServerError, err) |
| return |
| } |
|
|
| var duplicates []DuplicateChannelInfo |
| for _, cfg := range cfgs { |
| if util.NormalizeChannelType(cfg.ChannelType) != normalizedType { |
| continue |
| } |
| |
| for line := range strings.SplitSeq(cfg.URL, "\n") { |
| line = strings.TrimSpace(line) |
| if line == "" { |
| continue |
| } |
| if _, ok := newURLSet[line]; ok { |
| duplicates = append(duplicates, DuplicateChannelInfo{ |
| ID: cfg.ID, |
| Name: cfg.Name, |
| ChannelType: cfg.ChannelType, |
| URL: cfg.URL, |
| }) |
| break |
| } |
| } |
| } |
|
|
| if duplicates == nil { |
| duplicates = []DuplicateChannelInfo{} |
| } |
| RespondJSON(c, http.StatusOK, CheckDuplicateResponse{Duplicates: duplicates}) |
| } |
|
|
| |
| func (s *Server) handleCreateChannel(c *gin.Context) { |
| var req ChannelRequest |
| if err := BindAndValidate(c, &req); err != nil { |
| RespondErrorMsg(c, http.StatusBadRequest, "invalid request: "+err.Error()) |
| return |
| } |
|
|
| |
| created, err := s.store.CreateConfig(c.Request.Context(), req.ToConfig()) |
| if err != nil { |
| RespondError(c, http.StatusInternalServerError, err) |
| return |
| } |
|
|
| |
| apiKeys := util.ParseAPIKeys(req.APIKey) |
| keyStrategy := strings.TrimSpace(req.KeyStrategy) |
| if keyStrategy == "" { |
| keyStrategy = model.KeyStrategySequential |
| } |
|
|
| now := time.Now() |
| keysToCreate := make([]*model.APIKey, 0, len(apiKeys)) |
| for i, key := range apiKeys { |
| keysToCreate = append(keysToCreate, &model.APIKey{ |
| ChannelID: created.ID, |
| KeyIndex: i, |
| APIKey: key, |
| KeyStrategy: keyStrategy, |
| CreatedAt: model.JSONTime{Time: now}, |
| UpdatedAt: model.JSONTime{Time: now}, |
| }) |
| } |
| if len(keysToCreate) > 0 { |
| if err := s.store.CreateAPIKeysBatch(c.Request.Context(), keysToCreate); err != nil { |
| log.Printf("[WARN] 批量创建API Key失败 (channel=%d): %v", created.ID, err) |
| } |
| } |
|
|
| |
| s.InvalidateChannelListCache() |
|
|
| RespondJSON(c, http.StatusCreated, created) |
| } |
|
|
| |
| func (s *Server) HandleChannelByID(c *gin.Context) { |
| id, err := ParseInt64Param(c, "id") |
| if err != nil { |
| RespondErrorMsg(c, http.StatusBadRequest, "invalid channel id") |
| return |
| } |
|
|
| |
| switch c.Request.Method { |
| case "GET": |
| s.handleGetChannel(c, id) |
| case "PUT": |
| s.handleUpdateChannel(c, id) |
| case "DELETE": |
| s.handleDeleteChannel(c, id) |
| default: |
| RespondErrorMsg(c, 405, "method not allowed") |
| } |
| } |
|
|
| |
| func (s *Server) handleGetChannel(c *gin.Context, id int64) { |
| cfg, err := s.store.GetConfig(c.Request.Context(), id) |
| if err != nil { |
| RespondError(c, http.StatusNotFound, fmt.Errorf("channel not found")) |
| return |
| } |
| |
| for i := range cfg.ModelEntries { |
| if cfg.ModelEntries[i].RedirectModel == "" { |
| cfg.ModelEntries[i].RedirectModel = cfg.ModelEntries[i].Model |
| } |
| } |
|
|
| apiKeys, err := s.getAPIKeys(c.Request.Context(), id) |
| if err != nil { |
| RespondError(c, http.StatusInternalServerError, err) |
| return |
| } |
|
|
| |
| RespondJSON(c, http.StatusOK, ChannelWithCooldown{ |
| Config: cfg, |
| KeyStrategy: channelKeyStrategy(apiKeys), |
| }) |
| } |
|
|
| |
| |
| func (s *Server) handleGetChannelKeys(c *gin.Context, id int64) { |
| apiKeys, err := s.getAPIKeys(c.Request.Context(), id) |
| if err != nil { |
| RespondError(c, http.StatusInternalServerError, err) |
| return |
| } |
| if apiKeys == nil { |
| apiKeys = make([]*model.APIKey, 0) |
| } |
| RespondJSON(c, http.StatusOK, apiKeys) |
| } |
|
|
| |
| |
| func (s *Server) HandleChannelURLStats(c *gin.Context) { |
| id, err := ParseInt64Param(c, "id") |
| if err != nil { |
| RespondErrorMsg(c, http.StatusBadRequest, "invalid channel id") |
| return |
| } |
|
|
| cfg, err := s.store.GetConfig(c.Request.Context(), id) |
| if err != nil { |
| RespondErrorMsg(c, http.StatusNotFound, "channel not found") |
| return |
| } |
|
|
| urls := cfg.GetURLs() |
| if len(urls) <= 1 || s.urlSelector == nil { |
| RespondJSON(c, http.StatusOK, []URLStat{}) |
| return |
| } |
|
|
| stats := s.urlSelector.GetURLStats(id, urls) |
| RespondJSON(c, http.StatusOK, stats) |
| } |
|
|
| |
| |
| func (s *Server) HandleURLDisable(c *gin.Context) { |
| s.handleURLToggle(c, true) |
| } |
|
|
| |
| |
| func (s *Server) HandleURLEnable(c *gin.Context) { |
| s.handleURLToggle(c, false) |
| } |
|
|
| func (s *Server) handleURLToggle(c *gin.Context, disable bool) { |
| id, err := ParseInt64Param(c, "id") |
| if err != nil { |
| RespondErrorMsg(c, http.StatusBadRequest, "invalid channel id") |
| return |
| } |
|
|
| var req struct { |
| URL string `json:"url" binding:"required"` |
| } |
| if err := c.ShouldBindJSON(&req); err != nil { |
| RespondErrorMsg(c, http.StatusBadRequest, "url is required") |
| return |
| } |
|
|
| cfg, err := s.store.GetConfig(c.Request.Context(), id) |
| if err != nil { |
| RespondErrorMsg(c, http.StatusNotFound, "channel not found") |
| return |
| } |
|
|
| |
| urls := cfg.GetURLs() |
| if !slices.Contains(urls, req.URL) { |
| RespondErrorMsg(c, http.StatusBadRequest, "url not found in channel") |
| return |
| } |
|
|
| if s.urlSelector == nil { |
| RespondErrorMsg(c, http.StatusServiceUnavailable, "url selector not available") |
| return |
| } |
|
|
| if err := s.store.SetURLDisabled(c.Request.Context(), id, req.URL, disable); err != nil { |
| RespondErrorMsg(c, http.StatusInternalServerError, "persist url state failed") |
| return |
| } |
|
|
| if disable { |
| s.urlSelector.DisableURL(id, req.URL) |
| } else { |
| s.urlSelector.EnableURL(id, req.URL) |
| } |
|
|
| RespondJSON(c, http.StatusOK, gin.H{"ok": true}) |
| } |
|
|
| |
| |
| func (s *Server) HandleAPIKeyDisable(c *gin.Context) { |
| s.handleAPIKeyToggle(c, true) |
| } |
|
|
| |
| |
| func (s *Server) HandleAPIKeyEnable(c *gin.Context) { |
| s.handleAPIKeyToggle(c, false) |
| } |
|
|
| func (s *Server) handleAPIKeyToggle(c *gin.Context, disable bool) { |
| id, err := ParseInt64Param(c, "id") |
| if err != nil { |
| RespondErrorMsg(c, http.StatusBadRequest, "invalid channel id") |
| return |
| } |
|
|
| var req struct { |
| KeyIndex *int `json:"key_index"` |
| } |
| if err := c.ShouldBindJSON(&req); err != nil { |
| RespondErrorMsg(c, http.StatusBadRequest, "key_index is required") |
| return |
| } |
| if req.KeyIndex == nil || *req.KeyIndex < 0 { |
| RespondErrorMsg(c, http.StatusBadRequest, "invalid key_index") |
| return |
| } |
| keyIndex := *req.KeyIndex |
|
|
| if _, err := s.store.GetAPIKey(c.Request.Context(), id, keyIndex); err != nil { |
| RespondErrorMsg(c, http.StatusNotFound, "api key not found") |
| return |
| } |
|
|
| if err := s.store.SetAPIKeyDisabled(c.Request.Context(), id, keyIndex, disable); err != nil { |
| RespondErrorMsg(c, http.StatusInternalServerError, "persist key disabled state failed") |
| return |
| } |
|
|
| s.InvalidateAPIKeysCache(id) |
| s.invalidateCooldownCache() |
| s.InvalidateChannelListCache() |
|
|
| RespondJSON(c, http.StatusOK, gin.H{"ok": true}) |
| } |
|
|
| |
| func (s *Server) handleUpdateChannel(c *gin.Context, id int64) { |
| |
| var rawReq map[string]any |
| if err := c.ShouldBindJSON(&rawReq); err != nil { |
| RespondErrorMsg(c, http.StatusBadRequest, "invalid request format") |
| return |
| } |
|
|
| |
| if len(rawReq) == 1 { |
| if enabled, ok := rawReq["enabled"].(bool); ok { |
| upd, err := s.store.UpdateChannelEnabled(c.Request.Context(), id, enabled) |
| if err != nil { |
| if strings.Contains(err.Error(), "not found") { |
| RespondError(c, http.StatusNotFound, fmt.Errorf("channel not found")) |
| } else { |
| RespondError(c, http.StatusInternalServerError, err) |
| } |
| return |
| } |
| |
| s.InvalidateChannelListCache() |
| RespondJSON(c, http.StatusOK, upd) |
| return |
| } |
| } |
|
|
| |
| reqBytes, err := sonic.Marshal(rawReq) |
| if err != nil { |
| RespondErrorMsg(c, http.StatusBadRequest, "invalid request format") |
| return |
| } |
|
|
| var req ChannelRequest |
| if err := sonic.Unmarshal(reqBytes, &req); err != nil { |
| RespondErrorMsg(c, http.StatusBadRequest, "invalid request format") |
| return |
| } |
|
|
| if err := req.Validate(); err != nil { |
| RespondErrorMsg(c, http.StatusBadRequest, err.Error()) |
| return |
| } |
|
|
| |
| oldKeys, err := s.getAPIKeys(c.Request.Context(), id) |
| if err != nil { |
| log.Printf("[WARN] 查询旧API Keys失败: %v", err) |
| oldKeys = []*model.APIKey{} |
| } |
|
|
| newKeys := util.ParseAPIKeys(req.APIKey) |
| keyStrategy := strings.TrimSpace(req.KeyStrategy) |
| if keyStrategy == "" { |
| keyStrategy = model.KeyStrategySequential |
| } |
|
|
| |
| keyChanged := len(oldKeys) != len(newKeys) |
| if !keyChanged { |
| for i, oldKey := range oldKeys { |
| if i >= len(newKeys) || oldKey.APIKey != newKeys[i] { |
| keyChanged = true |
| break |
| } |
| } |
| } |
|
|
| |
| strategyChanged := false |
| if !keyChanged && len(oldKeys) > 0 && len(newKeys) > 0 { |
| |
| oldStrategy := oldKeys[0].KeyStrategy |
| if oldStrategy == "" { |
| oldStrategy = model.KeyStrategySequential |
| } |
| strategyChanged = oldStrategy != keyStrategy |
| } |
|
|
| upd, err := s.store.UpdateConfig(c.Request.Context(), id, req.ToConfig()) |
| if err != nil { |
| RespondError(c, http.StatusNotFound, err) |
| return |
| } |
|
|
| |
| if keyChanged { |
| disabledByAPIKey := make(map[string]bool, len(oldKeys)) |
| for _, oldKey := range oldKeys { |
| if oldKey.Disabled { |
| disabledByAPIKey[oldKey.APIKey] = true |
| } |
| } |
|
|
| |
| _ = s.store.DeleteAllAPIKeys(c.Request.Context(), id) |
|
|
| |
| now := time.Now() |
| apiKeys := make([]*model.APIKey, 0, len(newKeys)) |
| for i, key := range newKeys { |
| apiKeys = append(apiKeys, &model.APIKey{ |
| ChannelID: id, |
| KeyIndex: i, |
| APIKey: key, |
| KeyStrategy: keyStrategy, |
| Disabled: disabledByAPIKey[key], |
| CreatedAt: model.JSONTime{Time: now}, |
| UpdatedAt: model.JSONTime{Time: now}, |
| }) |
| } |
| if err := s.store.CreateAPIKeysBatch(c.Request.Context(), apiKeys); err != nil { |
| log.Printf("[WARN] 批量创建API Keys失败 (channel=%d, count=%d): %v", id, len(apiKeys), err) |
| } |
| } else if strategyChanged { |
| |
| if err := s.store.UpdateAPIKeysStrategy(c.Request.Context(), id, keyStrategy); err != nil { |
| log.Printf("[WARN] 批量更新API Key策略失败 (channel=%d): %v", id, err) |
| } |
| } |
|
|
| |
| |
| if s.cooldownManager != nil { |
| if err := s.cooldownManager.ClearChannelCooldown(c.Request.Context(), id); err != nil { |
| log.Printf("[WARN] 清除渠道冷却状态失败 (channel=%d): %v", id, err) |
| } |
| } |
| |
| s.invalidateCooldownCache() |
|
|
| |
| s.InvalidateChannelListCache() |
|
|
| |
| if keyChanged || strategyChanged { |
| s.InvalidateAPIKeysCache(id) |
| } |
|
|
| |
| if s.urlSelector != nil { |
| s.urlSelector.PruneChannel(id, upd.GetURLs()) |
| } |
| |
| s.cleanupOrphanedURLStates(c.Request.Context(), id, upd.GetURLs()) |
|
|
| RespondJSON(c, http.StatusOK, upd) |
| } |
|
|
| |
| func (s *Server) handleDeleteChannel(c *gin.Context, id int64) { |
| deleted, err := s.deleteChannelByID(c.Request.Context(), id) |
| if err != nil { |
| RespondError(c, http.StatusInternalServerError, err) |
| return |
| } |
| if !deleted { |
| RespondErrorMsg(c, http.StatusNotFound, "channel not found") |
| return |
| } |
|
|
| s.InvalidateChannelListCache() |
| |
| |
| s.InvalidateAPIKeysCache(id) |
| RespondJSON(c, http.StatusOK, gin.H{"id": id}) |
| } |
|
|
| |
| func (s *Server) cleanupOrphanedURLStates(ctx context.Context, channelID int64, keepURLs []string) { |
| if s.store == nil { |
| return |
| } |
|
|
| if err := s.store.CleanupOrphanedURLStates(ctx, channelID, keepURLs); err != nil { |
| log.Printf("[WARN] 清理孤立URL状态失败 (channel=%d, urls=%d): %v", channelID, len(keepURLs), err) |
| } |
| } |
|
|
| |
| func (s *Server) HandleDeleteAPIKey(c *gin.Context) { |
| |
| channelID, err := ParseInt64Param(c, "id") |
| if err != nil { |
| RespondErrorMsg(c, http.StatusBadRequest, "invalid channel id") |
| return |
| } |
|
|
| |
| keyIndexStr := c.Param("keyIndex") |
| keyIndex, err := strconv.Atoi(keyIndexStr) |
| if err != nil || keyIndex < 0 { |
| RespondErrorMsg(c, http.StatusBadRequest, "invalid key index") |
| return |
| } |
|
|
| ctx := c.Request.Context() |
|
|
| |
| apiKeys, err := s.store.GetAPIKeys(ctx, channelID) |
| if err != nil { |
| RespondError(c, http.StatusNotFound, err) |
| return |
| } |
| if len(apiKeys) == 0 { |
| RespondErrorMsg(c, http.StatusNotFound, "channel has no keys") |
| return |
| } |
|
|
| found := false |
| for _, k := range apiKeys { |
| if k.KeyIndex == keyIndex { |
| found = true |
| break |
| } |
| } |
| if !found { |
| RespondErrorMsg(c, http.StatusNotFound, "key not found") |
| return |
| } |
|
|
| |
| if err := s.store.DeleteAPIKey(ctx, channelID, keyIndex); err != nil { |
| RespondError(c, http.StatusInternalServerError, err) |
| return |
| } |
|
|
| |
| if err := s.store.CompactKeyIndices(ctx, channelID, keyIndex); err != nil { |
| RespondError(c, http.StatusInternalServerError, err) |
| return |
| } |
|
|
| remaining := len(apiKeys) - 1 |
|
|
| |
| s.InvalidateAPIKeysCache(channelID) |
| s.invalidateCooldownCache() |
|
|
| RespondJSON(c, http.StatusOK, gin.H{ |
| "remaining_keys": remaining, |
| }) |
| } |
|
|
| |
| |
| func (s *Server) HandleAddModels(c *gin.Context) { |
| channelID, err := ParseInt64Param(c, "id") |
| if err != nil { |
| RespondErrorMsg(c, http.StatusBadRequest, "invalid channel id") |
| return |
| } |
|
|
| var req struct { |
| Models []model.ModelEntry `json:"models" binding:"required,min=1"` |
| } |
| if err := c.ShouldBindJSON(&req); err != nil { |
| RespondErrorMsg(c, http.StatusBadRequest, "invalid request") |
| return |
| } |
|
|
| ctx := c.Request.Context() |
| cfg, err := s.store.GetConfig(ctx, channelID) |
| if err != nil { |
| RespondError(c, http.StatusNotFound, err) |
| return |
| } |
|
|
| |
| for i := range req.Models { |
| if err := req.Models[i].Validate(); err != nil { |
| RespondErrorMsg(c, http.StatusBadRequest, fmt.Sprintf("models[%d]: %s", i, err.Error())) |
| return |
| } |
| } |
|
|
| |
| existing := make(map[string]bool) |
| for _, e := range cfg.ModelEntries { |
| existing[strings.ToLower(e.Model)] = true |
| } |
| for _, e := range req.Models { |
| key := strings.ToLower(e.Model) |
| if !existing[key] { |
| cfg.ModelEntries = append(cfg.ModelEntries, e) |
| existing[key] = true |
| } |
| } |
|
|
| if _, err := s.store.UpdateConfig(ctx, channelID, cfg); err != nil { |
| RespondError(c, http.StatusInternalServerError, err) |
| return |
| } |
|
|
| s.InvalidateChannelListCache() |
| RespondJSON(c, http.StatusOK, gin.H{"total": len(cfg.ModelEntries)}) |
| } |
|
|
| |
| |
| func (s *Server) HandleDeleteModels(c *gin.Context) { |
| channelID, err := ParseInt64Param(c, "id") |
| if err != nil { |
| RespondErrorMsg(c, http.StatusBadRequest, "invalid channel id") |
| return |
| } |
|
|
| var req struct { |
| Models []string `json:"models" binding:"required,min=1"` |
| } |
| if err := c.ShouldBindJSON(&req); err != nil { |
| RespondErrorMsg(c, http.StatusBadRequest, "invalid request") |
| return |
| } |
|
|
| ctx := c.Request.Context() |
| cfg, err := s.store.GetConfig(ctx, channelID) |
| if err != nil { |
| RespondError(c, http.StatusNotFound, err) |
| return |
| } |
|
|
| |
| toDelete := make(map[string]bool) |
| for _, m := range req.Models { |
| toDelete[strings.ToLower(m)] = true |
| } |
| remaining := make([]model.ModelEntry, 0, len(cfg.ModelEntries)) |
| for _, e := range cfg.ModelEntries { |
| if !toDelete[strings.ToLower(e.Model)] { |
| remaining = append(remaining, e) |
| } |
| } |
|
|
| cfg.ModelEntries = remaining |
| if _, err := s.store.UpdateConfig(ctx, channelID, cfg); err != nil { |
| RespondError(c, http.StatusInternalServerError, err) |
| return |
| } |
|
|
| s.InvalidateChannelListCache() |
| RespondJSON(c, http.StatusOK, gin.H{"remaining": len(remaining)}) |
| } |
|
|
| |
| |
| |
| func (s *Server) HandleBatchUpdatePriority(c *gin.Context) { |
| var req struct { |
| Updates []struct { |
| ID int64 `json:"id"` |
| Priority int `json:"priority"` |
| } `json:"updates"` |
| } |
|
|
| if err := c.ShouldBindJSON(&req); err != nil { |
| RespondError(c, http.StatusBadRequest, err) |
| return |
| } |
|
|
| if len(req.Updates) == 0 { |
| RespondError(c, http.StatusBadRequest, fmt.Errorf("updates cannot be empty")) |
| return |
| } |
|
|
| ctx := c.Request.Context() |
|
|
| |
| updates := make([]struct { |
| ID int64 |
| Priority int |
| }, len(req.Updates)) |
| for i, u := range req.Updates { |
| updates[i] = struct { |
| ID int64 |
| Priority int |
| }{ID: u.ID, Priority: u.Priority} |
| } |
|
|
| |
| rowsAffected, err := s.store.BatchUpdatePriority(ctx, updates) |
| if err != nil { |
| log.Printf("批量优先级更新失败: %v", err) |
| RespondError(c, http.StatusInternalServerError, err) |
| return |
| } |
|
|
| |
| s.InvalidateChannelListCache() |
|
|
| RespondJSON(c, http.StatusOK, gin.H{ |
| "updated": rowsAffected, |
| "total": len(req.Updates), |
| }) |
| } |
|
|
| |
| |
| func (s *Server) HandleBatchSetEnabled(c *gin.Context) { |
| var req struct { |
| ChannelIDs []int64 `json:"channel_ids"` |
| Enabled *bool `json:"enabled"` |
| } |
|
|
| if err := c.ShouldBindJSON(&req); err != nil { |
| RespondError(c, http.StatusBadRequest, err) |
| return |
| } |
| if req.Enabled == nil { |
| RespondError(c, http.StatusBadRequest, fmt.Errorf("enabled is required")) |
| return |
| } |
|
|
| channelIDs := normalizeBatchChannelIDs(req.ChannelIDs) |
| if len(channelIDs) == 0 { |
| RespondError(c, http.StatusBadRequest, fmt.Errorf("channel_ids cannot be empty")) |
| return |
| } |
|
|
| ctx := c.Request.Context() |
| updated := 0 |
| unchanged := 0 |
| notFound := make([]int64, 0) |
|
|
| for _, channelID := range channelIDs { |
| cfg, err := s.store.GetConfig(ctx, channelID) |
| if err != nil { |
| notFound = append(notFound, channelID) |
| continue |
| } |
|
|
| if cfg.Enabled == *req.Enabled { |
| unchanged++ |
| continue |
| } |
|
|
| cfg.Enabled = *req.Enabled |
| if _, err := s.store.UpdateChannelEnabled(ctx, channelID, *req.Enabled); err != nil { |
| log.Printf("批量启用更新渠道 %d 失败: %v", channelID, err) |
| RespondError(c, http.StatusInternalServerError, err) |
| return |
| } |
| updated++ |
| } |
|
|
| if updated > 0 { |
| s.InvalidateChannelListCache() |
| } |
|
|
| RespondJSON(c, http.StatusOK, gin.H{ |
| "enabled": *req.Enabled, |
| "total": len(channelIDs), |
| "updated": updated, |
| "unchanged": unchanged, |
| "not_found": notFound, |
| "not_found_count": len(notFound), |
| }) |
| } |
|
|
| |
| func (s *Server) HandleBatchDeleteChannels(c *gin.Context) { |
| var req struct { |
| ChannelIDs []int64 `json:"channel_ids"` |
| } |
|
|
| if err := c.ShouldBindJSON(&req); err != nil { |
| RespondError(c, http.StatusBadRequest, err) |
| return |
| } |
|
|
| channelIDs := normalizeBatchChannelIDs(req.ChannelIDs) |
| if len(channelIDs) == 0 { |
| RespondError(c, http.StatusBadRequest, fmt.Errorf("channel_ids cannot be empty")) |
| return |
| } |
|
|
| ctx := c.Request.Context() |
| deleted := 0 |
| notFound := make([]int64, 0) |
|
|
| for _, channelID := range channelIDs { |
| wasDeleted, err := s.deleteChannelByID(ctx, channelID) |
| if err != nil { |
| log.Printf("批量删除渠道 %d 失败: %v", channelID, err) |
| RespondError(c, http.StatusInternalServerError, err) |
| return |
| } |
| if !wasDeleted { |
| notFound = append(notFound, channelID) |
| continue |
| } |
| deleted++ |
| } |
|
|
| if deleted > 0 { |
| s.InvalidateChannelListCache() |
| |
| |
| s.InvalidateAllAPIKeysCache() |
| } |
|
|
| RespondJSON(c, http.StatusOK, gin.H{ |
| "total": len(channelIDs), |
| "deleted": deleted, |
| "not_found": notFound, |
| "not_found_count": len(notFound), |
| }) |
| } |
|
|
| func normalizeBatchChannelIDs(rawIDs []int64) []int64 { |
| if len(rawIDs) == 0 { |
| return nil |
| } |
|
|
| seen := make(map[int64]struct{}, len(rawIDs)) |
| ids := make([]int64, 0, len(rawIDs)) |
| for _, id := range rawIDs { |
| if id <= 0 { |
| continue |
| } |
| if _, exists := seen[id]; exists { |
| continue |
| } |
| seen[id] = struct{}{} |
| ids = append(ids, id) |
| } |
| return ids |
| } |
|
|
| func (s *Server) deleteChannelByID(ctx context.Context, id int64) (bool, error) { |
| if id <= 0 { |
| return false, nil |
| } |
|
|
| if _, err := s.store.GetConfig(ctx, id); err != nil { |
| if strings.Contains(err.Error(), "not found") { |
| return false, nil |
| } |
| return false, err |
| } |
|
|
| if err := s.store.DeleteConfig(ctx, id); err != nil { |
| return false, err |
| } |
| if s.keySelector != nil { |
| s.keySelector.RemoveChannelCounter(id) |
| } |
| if s.urlSelector != nil { |
| s.urlSelector.RemoveChannel(id) |
| } |
| return true, nil |
| } |
|
|