package api import ( "encoding/json" "errors" "fmt" "io" "net/http" "strconv" "strings" "time" "opencatd-open/internal/pkg/crypto" "opencatd-open/internal/store" "github.com/gin-gonic/gin" ) // AdminChannels GET /api/admin/channels — 渠道列表(不返回加密 key,返回掩码)。 func (h *Handler) AdminChannels(c *gin.Context) { var chs []store.Channel if err := h.db.Order("id ASC").Find(&chs).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load channels"}) return } out := make([]gin.H, 0, len(chs)) for _, ch := range chs { masked := "" if key, err := crypto.Decrypt(ch.APIKeyEnc); err == nil && len(key) > 8 { masked = maskAPIKey(key) } else if err == nil { masked = "****" } out = append(out, gin.H{ "id": ch.ID, "name": ch.Name, "provider": ch.Provider, "formats": ch.FormatsEffective(), "base_url": ch.BaseURL, "base_urls": ch.BaseURLs, "api_key_masked": masked, "weight": ch.Weight, "priority": ch.Priority, "timeout_ms": ch.TimeoutMS, "max_concurrency": ch.MaxConcurrency, "health_status": ch.HealthStatus, "enabled": ch.Enabled, "created_at": ch.CreatedAt, }) } c.JSON(http.StatusOK, gin.H{"data": out}) } type channelBody struct { Name string `json:"name" binding:"required,min=1,max=64"` Provider string `json:"provider"` Formats []string `json:"formats"` BaseURL string `json:"base_url"` BaseURLs map[string]string `json:"base_urls"` APIKey string `json:"api_key"` Weight *int `json:"weight"` Priority *int `json:"priority"` TimeoutMS *int `json:"timeout_ms"` MaxConcurrency *int `json:"max_concurrency"` Enabled *bool `json:"enabled"` } // normalizeBaseURLs 校验并清理分协议 base_url。 func normalizeBaseURLs(m map[string]string) map[string]string { if len(m) == 0 { return nil } out := map[string]string{} for k, v := range m { if validFormats[k] && strings.TrimSpace(v) != "" { out[k] = strings.TrimRight(strings.TrimSpace(v), "/") } } if len(out) == 0 { return nil } return out } // resolveBaseURL 渠道 base_url:留空按供应商默认;网关按内容智能识别前缀/完整端点。 func resolveBaseURL(provider, raw string) (string, error) { base := strings.TrimRight(raw, "/") if base == "" { switch provider { case store.ChannelProviderOpenAI: base = "https://api.openai.com" case store.ChannelProviderAnthropic: base = "https://api.anthropic.com" } } if base == "" { return "", errors.New("base_url required for compatible channels") } return base, nil } func validateProvider(p string) bool { return p == store.ChannelProviderOpenAI || p == store.ChannelProviderAnthropic || p == store.ChannelProviderCompatible } var validFormats = map[string]bool{ store.FormatChat: true, store.FormatResponses: true, store.FormatMessages: true, } // deriveProvider 按格式推断供应商(仅作内部字段/兼容用途,不参与路由)。 func deriveProvider(formats []string) string { if len(formats) == 0 { return store.ChannelProviderCompatible } messagesOnly, hasResponses := true, false for _, f := range formats { if f != store.FormatMessages { messagesOnly = false } if f == store.FormatResponses { hasResponses = true } } if messagesOnly { return store.ChannelProviderAnthropic } if hasResponses { return store.ChannelProviderOpenAI } return store.ChannelProviderCompatible } // resolveFormats 渠道协议格式:显式给出则校验去重;空则按 provider 推断默认。 func resolveFormats(provider string, formats []string) ([]string, error) { if len(formats) == 0 { switch provider { case store.ChannelProviderAnthropic: return []string{store.FormatMessages}, nil case store.ChannelProviderOpenAI: return []string{store.FormatChat, store.FormatResponses}, nil default: return []string{store.FormatChat}, nil } } seen := map[string]bool{} out := make([]string, 0, len(formats)) for _, f := range formats { if !validFormats[f] { return nil, fmt.Errorf("unsupported format %q", f) } if !seen[f] { seen[f] = true out = append(out, f) } } return out, nil } // AdminCreateChannel POST /api/admin/channels func (h *Handler) AdminCreateChannel(c *gin.Context) { var req channelBody if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input: " + err.Error()}) return } if req.Provider == "" { req.Provider = deriveProvider(req.Formats) } if !validateProvider(req.Provider) { c.JSON(http.StatusBadRequest, gin.H{"error": "provider must be openai, anthropic or compatible"}) return } if req.APIKey == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "api_key required"}) return } formats, err := resolveFormats(req.Provider, req.Formats) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } baseURL, err := resolveBaseURL(req.Provider, req.BaseURL) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } enc, err := crypto.Encrypt(req.APIKey) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt api key"}) return } ch := store.Channel{ Name: req.Name, Provider: req.Provider, Formats: formats, BaseURL: baseURL, BaseURLs: normalizeBaseURLs(req.BaseURLs), APIKeyEnc: enc, Weight: intOr(req.Weight, 1), Priority: intOr(req.Priority, 0), TimeoutMS: intOr(req.TimeoutMS, 120000), MaxConcurrency: intOr(req.MaxConcurrency, 16), HealthStatus: store.ChannelHealthHealthy, Enabled: boolOr(req.Enabled, true), } if err := h.db.Create(&ch).Error; err != nil { c.JSON(http.StatusConflict, gin.H{"error": "failed to create channel (name may already exist)"}) return } c.JSON(http.StatusCreated, gin.H{"id": ch.ID, "name": ch.Name}) } // AdminUpdateChannel PUT /api/admin/channels/:id func (h *Handler) AdminUpdateChannel(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"}) return } var body struct { Name *string `json:"name"` Provider *string `json:"provider"` Formats *[]string `json:"formats"` BaseURL *string `json:"base_url"` BaseURLs *map[string]string `json:"base_urls"` APIKey *string `json:"api_key"` Weight *int `json:"weight"` Priority *int `json:"priority"` TimeoutMS *int `json:"timeout_ms"` MaxConcurrency *int `json:"max_concurrency"` HealthStatus *string `json:"health_status"` Enabled *bool `json:"enabled"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"}) return } var ch store.Channel if err := h.db.First(&ch, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } updates := map[string]any{} if body.Name != nil { updates["name"] = *body.Name } if body.Provider != nil { if !validateProvider(*body.Provider) { c.JSON(http.StatusBadRequest, gin.H{"error": "provider must be openai, anthropic or compatible"}) return } updates["provider"] = *body.Provider } if body.BaseURL != nil { prov := ch.Provider if body.Provider != nil { prov = *body.Provider } b, berr := resolveBaseURL(prov, *body.BaseURL) if berr != nil { c.JSON(http.StatusBadRequest, gin.H{"error": berr.Error()}) return } updates["base_url"] = b } if body.BaseURLs != nil { raw, _ := json.Marshal(normalizeBaseURLs(*body.BaseURLs)) updates["base_urls"] = string(raw) } if body.APIKey != nil && *body.APIKey != "" { enc, err := crypto.Encrypt(*body.APIKey) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt api key"}) return } updates["api_key_enc"] = enc } if body.Weight != nil { updates["weight"] = *body.Weight } if body.Priority != nil { updates["priority"] = *body.Priority } if body.TimeoutMS != nil { updates["timeout_ms"] = *body.TimeoutMS } if body.MaxConcurrency != nil { updates["max_concurrency"] = *body.MaxConcurrency } if body.HealthStatus != nil { updates["health_status"] = *body.HealthStatus } if body.Enabled != nil { updates["enabled"] = *body.Enabled } if body.Formats != nil { prov := ch.Provider if body.Provider != nil { prov = *body.Provider } formats, ferr := resolveFormats(prov, *body.Formats) if ferr != nil { c.JSON(http.StatusBadRequest, gin.H{"error": ferr.Error()}) return } raw, _ := json.Marshal(formats) updates["formats"] = string(raw) } if len(updates) > 0 { if err := h.db.Model(&ch).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"}) return } } c.JSON(http.StatusOK, gin.H{"ok": true}) } // AdminDeleteChannel DELETE /api/admin/channels/:id func (h *Handler) AdminDeleteChannel(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"}) return } res := h.db.Delete(&store.Channel{}, id) if res.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"}) return } if res.RowsAffected == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } h.db.Where("channel_id = ?", id).Delete(&store.ChannelModelBinding{}) c.JSON(http.StatusOK, gin.H{"ok": true}) } // AdminTestChannel POST /api/admin/channels/:id/test — 请求渠道 /v1/models 测连通性。 func (h *Handler) AdminTestChannel(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"}) return } var ch store.Channel if err := h.db.First(&ch, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } key, err := crypto.Decrypt(ch.APIKeyEnc) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to decrypt channel key"}) return } url := ch.UpstreamURL("", "/models") client := &http.Client{Timeout: 10 * time.Second} req, _ := http.NewRequest(http.MethodGet, url, nil) req.Header.Set("Authorization", "Bearer "+key) req.Header.Set("Accept", "application/json") start := time.Now() resp, err := client.Do(req) status := store.ChannelHealthHealthy msg := "ok" latency := 0 if err != nil { status = store.ChannelHealthCooldown msg = err.Error() } else { latency = int(time.Since(start).Milliseconds()) if resp.StatusCode < 200 || resp.StatusCode >= 300 { status = store.ChannelHealthCooldown b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) msg = fmt.Sprintf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) } resp.Body.Close() } h.db.Model(&store.Channel{}).Where("id = ?", ch.ID).Update("health_status", status) if status != store.ChannelHealthHealthy { c.JSON(http.StatusBadGateway, gin.H{"error": msg}) return } c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency, "message": msg}) } // AdminChannelRemoteModels GET /api/admin/channels/:id/models/remote — 拉取远端模型列表。 // 返回本渠道尚未允许的模型(新增候选),排除已绑定的模型。 func (h *Handler) AdminChannelRemoteModels(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"}) return } var ch store.Channel if err := h.db.First(&ch, id).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } key, err := crypto.Decrypt(ch.APIKeyEnc) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to decrypt channel key"}) return } url := ch.UpstreamURL("", "/models") client := &http.Client{Timeout: 10 * time.Second} req, _ := http.NewRequest(http.MethodGet, url, nil) req.Header.Set("Authorization", "Bearer "+key) req.Header.Set("Accept", "application/json") resp, err := client.Do(req) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024)) if resp.StatusCode < 200 || resp.StatusCode >= 300 { c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("http %d: %s", resp.StatusCode, string(body))}) return } // 解析 OpenAI 格式的模型列表 var result struct { Data []struct { ID string `json:"id"` } `json:"data"` } if err := json.Unmarshal(body, &result); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse response: " + err.Error()}) return } // 本渠道已允许的上游模型名:不作为新增候选 var boundNames []string h.db.Model(&store.ChannelModelBinding{}).Where("channel_id = ?", id).Pluck("upstream_model", &boundNames) boundSet := make(map[string]bool, len(boundNames)) for _, n := range boundNames { boundSet[strings.TrimSpace(n)] = true } models := make([]string, 0, len(result.Data)) for _, m := range result.Data { name := strings.TrimSpace(m.ID) if name != "" && !boundSet[name] { models = append(models, name) } } c.JSON(http.StatusOK, gin.H{"data": models}) } // AdminChannelModels GET /api/admin/channels/:id/models — 渠道绑定列表。 func (h *Handler) AdminChannelModels(c *gin.Context) { channelID, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"}) return } var bindings []store.ChannelModelBinding if err := h.db.Preload("Model").Where("channel_id = ?", channelID).Find(&bindings).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load bindings"}) return } out := make([]gin.H, 0, len(bindings)) for _, b := range bindings { out = append(out, gin.H{ "id": b.ID, "model_id": b.ModelID, "model_name": b.Model.Name, "upstream_model": b.UpstreamModel, "weight": b.Weight, }) } c.JSON(http.StatusOK, gin.H{"data": out}) } // AdminChannelAddModel POST /api/admin/channels/:id/models — 手工添加模型绑定。 // 无需渠道具备 /v1/models 接口:直接填上游模型名,可选自定义名称作为客户端调用名。 func (h *Handler) AdminChannelAddModel(c *gin.Context) { channelID, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"}) return } var req struct { UpstreamModel string `json:"upstream_model" binding:"required"` // 渠道侧真实模型名 CustomName string `json:"custom_name"` // 客户端调用名,空=用上游名 Weight *int `json:"weight"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input: upstream_model required"}) return } globalName := req.CustomName if globalName == "" { globalName = req.UpstreamModel } var ch store.Channel if err := h.db.First(&ch, channelID).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"}) return } // 查找或创建全局模型 var m store.Model if err := h.db.Where("name = ?", globalName).First(&m).Error; err != nil { m = store.Model{Name: globalName, Enabled: true} if err := h.db.Create(&m).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create model"}) return } } // 查找已存在的绑定,如果存在则更新 var existing store.ChannelModelBinding if err := h.db.Where("channel_id = ? AND model_id = ?", channelID, m.ID).First(&existing).Error; err == nil { // 已存在,更新 existing.UpstreamModel = req.UpstreamModel existing.Weight = intOr(req.Weight, 1) if err := h.db.Save(&existing).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update binding"}) return } c.JSON(http.StatusOK, gin.H{"id": existing.ID, "model_id": m.ID, "model_name": m.Name, "upstream_model": existing.UpstreamModel, "weight": existing.Weight}) return } // 不存在,创建新的 b := store.ChannelModelBinding{ ChannelID: channelID, ModelID: m.ID, UpstreamModel: req.UpstreamModel, Weight: intOr(req.Weight, 1), } if err := h.db.Create(&b).Error; err != nil { c.JSON(http.StatusConflict, gin.H{"error": "binding may already exist"}) return } c.JSON(http.StatusCreated, gin.H{"id": b.ID, "model_id": m.ID, "model_name": m.Name, "upstream_model": req.UpstreamModel, "weight": b.Weight}) } // AdminChannelUpdateModel PATCH /api/admin/channels/:id/models/:bid — 改映射名/权重。 func (h *Handler) AdminChannelUpdateModel(c *gin.Context) { bid, err := strconv.ParseUint(c.Param("bid"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid binding id"}) return } var req struct { UpstreamModel *string `json:"upstream_model"` Weight *int `json:"weight"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"}) return } var b store.ChannelModelBinding if err := h.db.First(&b, bid).Error; err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "binding not found"}) return } updates := map[string]any{} if req.UpstreamModel != nil { updates["upstream_model"] = *req.UpstreamModel } if req.Weight != nil { updates["weight"] = *req.Weight } if len(updates) > 0 { if err := h.db.Model(&b).Updates(updates).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update binding"}) return } } c.JSON(http.StatusOK, gin.H{"ok": true}) } // AdminChannelDeleteModel DELETE /api/admin/channels/:id/models/:bid — 解除绑定。 func (h *Handler) AdminChannelDeleteModel(c *gin.Context) { bid, err := strconv.ParseUint(c.Param("bid"), 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid binding id"}) return } res := h.db.Delete(&store.ChannelModelBinding{}, bid) if res.Error != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete binding"}) return } if res.RowsAffected == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "binding not found"}) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } // maskAPIKey 掩码渠道密钥:保留前 7 位与后 4 位,中间固定 ****** 遮蔽。 func maskAPIKey(key string) string { if len(key) <= 11 { return strings.Repeat("*", len(key)-4) + key[len(key)-4:] } return key[:7] + "******" + key[len(key)-4:] } func intOr(p *int, def int) int { if p == nil { return def } return *p } func boolOr(p *bool, def bool) bool { if p == nil { return def } return *p }