ccpoad / internal /app /admin_channels.go
anyalerob's picture
Upload folder using huggingface_hub
2986042 verified
Raw
History Blame Contribute Delete
36.7 kB
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"
)
// ==================== 渠道CRUD管理 ====================
// 从admin.go拆分渠道CRUD,遵循SRP原则
// HandleChannels 处理渠道列表请求
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
}
// 获取渠道列表
// 使用批量查询优化N+1问题
// filterConfigs 用谓词筛选 *model.Config 切片,消除 handleListChannels 中重复的
// "make/for/append/cfgs=filtered" 五行片段。空容量预分配避免短切片再次扩容。
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)
}
// 应用所有列表过滤(type / channel_name|search / status / model|model_like)
// 注意:筛选下拉的全集走独立接口 /admin/channels/filter-options,
// 这里只负责按所有筛选条件返回当前页,避免列表数据与下拉选项耦合。
cfgs = applyChannelListFilters(cfgs, c, allChannelCooldowns, now)
hasPagination := c.Query("limit") != "" || c.Query("offset") != ""
// 批量查询所有Key冷却状态(缓存优先)
allKeyCooldowns, err := s.getAllKeyCooldowns(c.Request.Context())
if err != nil {
// Key冷却查询失败不影响主流程,仅记录错误
log.Printf("[WARN] 批量查询Key冷却状态失败: %v", err)
allKeyCooldowns = make(map[int64]map[int]time.Time)
}
// 批量查询所有API Keys(一次查询替代 N 次)
allAPIKeys, err := s.store.GetAllAPIKeys(c.Request.Context())
if err != nil {
log.Printf("[WARN] 批量查询API Keys失败: %v", err)
allAPIKeys = make(map[int64][]*model.APIKey) // 降级:使用空map
}
// 健康度模式检查
healthEnabled := s.healthCache != nil && s.healthCache.Config().Enabled
// 排序:健康度开启按 effective_priority 降序;关闭按 priority DESC, name ASC,
// 与前端 filterChannels 的排序键对齐,保证分页跨页顺序稳定。
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)
}
// applyChannelListFilters 串联应用所有列表过滤条件:
// - type: 渠道类型(标准化比较)
// - channel_name | search: 名称精确/模糊(互斥,channel_name 优先)
// - status: enabled / disabled / cooldown(cooldown 依赖 channelCooldownsMap)
// - model | model_like: 模型精确/模糊(互斥,model 优先)
//
// 空字符串或 "all" 视为不过滤。
func applyChannelListFilters(cfgs []*model.Config, c *gin.Context, channelCooldownsMap map[int64]time.Time, now time.Time) []*model.Config {
// type
if t := c.Query("type"); t != "" && t != "all" {
normalized := util.NormalizeChannelType(t)
cfgs = filterConfigs(cfgs, func(cfg *model.Config) bool {
return channelExposesProtocol(cfg, normalized)
})
}
// channel_name | search(互斥)
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)
})
}
// status
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
})
}
// model | model_like(互斥)
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
}
// sortChannelsByEffectivePriority 原地排序 cfgs。
// 健康度开启时:用 healthCache 计算 effectivePriority 与 successRate(仅 SampleCount>0),
// 按 effective 降序;关闭时按 priority DESC, name ASC(与前端 filterChannels 排序键对齐)。
// 返回的两个 map 供 enrichChannel 复用,避免重复计算。
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
}
// paginateChannels 按 query 中的 limit/offset 截取 cfgs。
// limit: [1, 1000],默认 20;offset: [0, +∞),默认 0。offset 越界返回空切片。
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]
}
// channelEnrichmentContext 聚合 enrichChannel 所需的批量预计算数据,避免长参数列表。
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
}
// enrichChannel 把单个 cfg 拼装为 ChannelWithCooldown:
// 渠道冷却剩余时间、健康度模式下的有效优先级与成功率、Key 策略与各 Key 冷却详情。
func (ectx *channelEnrichmentContext) enrichChannel(cfg *model.Config) ChannelWithCooldown {
oc := ChannelWithCooldown{Config: cfg}
// 渠道级别冷却:使用批量查询结果(性能提升:N -> 1 次查询)
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
}
// 从预加载的map中获取API Keys(O(1)查找)
apiKeys := ectx.apiKeysMap[cfg.ID]
// Key 策略属于渠道行为,详情和列表都必须返回同一语义。
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
}
// HandleChannelsFilterOptions 返回渠道筛选下拉的全集(渠道名/模型),
// 仅按 type/status 联动,与列表分页/搜索/模型筛选解耦。
// GET /admin/channels/filter-options?type=&status=
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,
})
}
// HandleCheckDuplicateChannel 检测渠道是否与已有渠道重复
// POST /admin/channels/check-duplicate
// 判断条件:channel_type 相同 且 任意 URL 行与已有渠道任意 URL 行相交
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)
// 构建新渠道 URL 集合(去除空行)
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
}
// 遍历已有渠道的 URL 行,检查是否与新渠道 URL 有交集
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
}
// 创建渠道(不包含API Key)
created, err := s.store.CreateConfig(c.Request.Context(), req.ToConfig())
if err != nil {
RespondError(c, http.StatusInternalServerError, err)
return
}
// 解析并创建API Keys
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)
}
// HandleChannelByID 处理单个渠道的CRUD操作
func (s *Server) HandleChannelByID(c *gin.Context) {
id, err := ParseInt64Param(c, "id")
if err != nil {
RespondErrorMsg(c, http.StatusBadRequest, "invalid channel id")
return
}
// [INFO] Linus风格:直接switch,删除不必要的抽象
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")
}
}
// 获取单个渠道(包含key_strategy信息)
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
}
// 渠道详情返回配置和策略,但仍不返回明文 Key;API Keys 继续走 /keys 端点。
RespondJSON(c, http.StatusOK, ChannelWithCooldown{
Config: cfg,
KeyStrategy: channelKeyStrategy(apiKeys),
})
}
// handleGetChannelKeys 获取渠道的所有 API Keys
// GET /admin/channels/{id}/keys
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)
}
// HandleChannelURLStats 返回多URL渠道各URL的实时状态(延迟、冷却)
// GET /admin/channels/:id/url-stats
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)
}
// HandleURLDisable 手动禁用渠道的指定URL
// POST /admin/channels/:id/url-disable
func (s *Server) HandleURLDisable(c *gin.Context) {
s.handleURLToggle(c, true)
}
// HandleURLEnable 重新启用渠道的指定URL
// POST /admin/channels/:id/url-enable
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
}
// 验证URL属于该渠道
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})
}
// HandleAPIKeyDisable 手动禁用渠道的指定 API Key
// POST /admin/channels/:id/key-disable
func (s *Server) HandleAPIKeyDisable(c *gin.Context) {
s.handleAPIKeyToggle(c, true)
}
// HandleAPIKeyEnable 重新启用渠道的指定 API Key
// POST /admin/channels/:id/key-enable
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) {
// 解析请求为通用map以支持部分更新
var rawReq map[string]any
if err := c.ShouldBindJSON(&rawReq); err != nil {
RespondErrorMsg(c, http.StatusBadRequest, "invalid request format")
return
}
// 检查是否为简单的enabled字段更新
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
}
// enabled 状态变更影响渠道选择,必须立即失效缓存
s.InvalidateChannelListCache()
RespondJSON(c, http.StatusOK, upd)
return
}
}
// 处理完整更新:重新序列化为ChannelRequest
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
}
// 检测api_key是否变化(需要重建API Keys)
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
}
// 比较Key数量和内容是否变化
keyChanged := len(oldKeys) != len(newKeys)
if !keyChanged {
for i, oldKey := range oldKeys {
if i >= len(newKeys) || oldKey.APIKey != newKeys[i] {
keyChanged = true
break
}
}
}
// [INFO] 修复 (2025-10-11): 检测策略变化
strategyChanged := false
if !keyChanged && len(oldKeys) > 0 && len(newKeys) > 0 {
// Key内容未变化时,检查策略是否变化
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
}
// Key或策略变化时更新API Keys
if keyChanged {
disabledByAPIKey := make(map[string]bool, len(oldKeys))
for _, oldKey := range oldKeys {
if oldKey.Disabled {
disabledByAPIKey[oldKey.APIKey] = true
}
}
// Key内容/数量变化:删除旧Key并重建
_ = s.store.DeleteAllAPIKeys(c.Request.Context(), id)
// 批量创建新的API Keys(优化:单次事务插入替代循环单条插入)
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 {
// 仅策略变化:单条SQL批量更新所有Key的策略字段
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()
// Key变更时必须失效API Keys缓存,否则再次编辑会读到旧缓存
if keyChanged || strategyChanged {
s.InvalidateAPIKeysCache(id)
}
// URL 更新后立即清理失效的 URL 状态(内存+数据库同步)
if s.urlSelector != nil {
s.urlSelector.PruneChannel(id, upd.GetURLs())
}
// 同步清理数据库中已移除URL的禁用状态记录
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()
// 删除渠道后必须同步失效该渠道的 API Keys 缓存,
// 否则若后续以同 ID 重新创建渠道(显式主键路径,例如混合存储恢复),可能读到旧 keys。
s.InvalidateAPIKeysCache(id)
RespondJSON(c, http.StatusOK, gin.H{"id": id})
}
// cleanupOrphanedURLStates 清理数据库中已移除URL的禁用状态记录,失败仅警告不影响主流程
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)
}
}
// HandleDeleteAPIKey 删除渠道下的单个Key,并保持key_index连续
func (s *Server) HandleDeleteAPIKey(c *gin.Context) {
// 解析渠道ID
channelID, err := ParseInt64Param(c, "id")
if err != nil {
RespondErrorMsg(c, http.StatusBadRequest, "invalid channel id")
return
}
// 解析Key索引
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()
// 获取当前Keys,确认目标存在并计算剩余数量
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
}
// 删除目标Key
if err := s.store.DeleteAPIKey(ctx, channelID, keyIndex); err != nil {
RespondError(c, http.StatusInternalServerError, err)
return
}
// 紧凑索引,确保key_index连续
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,
})
}
// HandleAddModels 添加模型到渠道(去重)
// POST /admin/channels/:id/models
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
}
// 验证模型条目(DRY: 使用 ModelEntry.Validate())
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
}
}
// 去重合并(大小写不敏感,兼容 MySQL utf8mb4_general_ci 排序规则)
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)})
}
// HandleDeleteModels 删除渠道中的指定模型
// DELETE /admin/channels/:id/models
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
}
// 过滤掉要删除的模型(大小写不敏感,兼容 MySQL utf8mb4_general_ci)
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)})
}
// HandleBatchUpdatePriority 批量更新渠道优先级
// POST /admin/channels/batch-priority
// 使用单条批量 UPDATE 语句更新多个渠道优先级
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()
// 转换为storage层的类型
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}
}
// 调用storage层批量更新方法
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),
})
}
// HandleBatchSetEnabled 批量启用/禁用渠道
// POST /admin/channels/batch-enabled
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),
})
}
// HandleBatchDeleteChannels 批量删除渠道
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()
// 同步失效所有 API Keys 缓存:批量删除涉及多个渠道,
// 全量清空比逐个 InvalidateAPIKeysCache(id) 更便宜,且不会造成残留。
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
}