package api import ( "encoding/json" "errors" "fmt" "io" "net/http" "strconv" "strings" "time" "github.com/gin-gonic/gin" "github.com/openteam/server/internal/pkg/resp" "github.com/openteam/server/internal/store" "gorm.io/gorm" "gorm.io/gorm/clause" ) // AdminChannels GET /api/v1/admin/channels — 渠道列表(不返回加密 key,返回掩码)。 func (h *Handler) AdminChannels(c *gin.Context) { var chs []store.Channel if err := h.a.DB.Order("id ASC").Find(&chs).Error; err != nil { resp.Fail(c, http.StatusInternalServerError, "failed to load channels") return } out := make([]gin.H, 0, len(chs)) for _, ch := range chs { masked := "" if key, err := h.a.Enc.Decrypt(ch.APIKeyEnc); err == nil && len(key) > 8 { masked = strings.Repeat("*", len(key)-4) + key[len(key)-4:] } 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, "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, }) } resp.OK(c, gin.H{"items": out}) } type channelBody struct { Name string `json:"name" binding:"required,min=1,max=64"` Provider string `json:"provider"` // 可选:为空时按 formats 推断(兼容旧数据) Formats []string `json:"formats"` // 原生支持的协议 chat|responses|messages(主配置) BaseURL string `json:"base_url"` // 可选:为空时按供应商默认(openai/anthropic) 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"` } // resolveBaseURL 渠道 base_url:留空按供应商默认;兼容用户填完整地址(含 /v1)。 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 strings.TrimSuffix(base, "/v1"), 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/v1/admin/channels func (h *Handler) AdminCreateChannel(c *gin.Context) { var req channelBody if err := c.ShouldBindJSON(&req); err != nil { resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error()) return } if req.Provider == "" { req.Provider = deriveProvider(req.Formats) } if !validateProvider(req.Provider) { resp.Fail(c, http.StatusBadRequest, "provider must be openai, anthropic or compatible") return } if req.APIKey == "" { resp.Fail(c, http.StatusBadRequest, "api_key required") return } formats, err := resolveFormats(req.Provider, req.Formats) if err != nil { resp.Fail(c, http.StatusBadRequest, err.Error()) return } baseURL, err := resolveBaseURL(req.Provider, req.BaseURL) if err != nil { resp.Fail(c, http.StatusBadRequest, err.Error()) return } enc, err := h.a.Enc.Encrypt(req.APIKey) if err != nil { resp.Fail(c, http.StatusInternalServerError, "failed to encrypt api key") return } ch := store.Channel{ Name: req.Name, Provider: req.Provider, Formats: formats, BaseURL: baseURL, 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.a.DB.Create(&ch).Error; err != nil { resp.Fail(c, http.StatusConflict, "failed to create channel (name may already exist)") return } resp.Created(c, gin.H{"id": ch.ID, "name": ch.Name}) } // AdminUpdateChannel PUT /api/v1/admin/channels/:id func (h *Handler) AdminUpdateChannel(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { resp.Fail(c, http.StatusBadRequest, "invalid channel id") return } var body struct { Name *string `json:"name"` Provider *string `json:"provider"` Formats *[]string `json:"formats"` BaseURL *string `json:"base_url"` 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 { resp.Fail(c, http.StatusBadRequest, "invalid input") return } var ch store.Channel if err := h.a.DB.First(&ch, id).Error; err != nil { resp.Fail(c, http.StatusNotFound, "channel not found") return } updates := map[string]any{} if body.Name != nil { updates["name"] = *body.Name } if body.Provider != nil { if !validateProvider(*body.Provider) { resp.Fail(c, http.StatusBadRequest, "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 { resp.Fail(c, http.StatusBadRequest, berr.Error()) return } updates["base_url"] = b } if body.APIKey != nil && *body.APIKey != "" { enc, err := h.a.Enc.Encrypt(*body.APIKey) if err != nil { resp.Fail(c, http.StatusInternalServerError, "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 { resp.Fail(c, http.StatusBadRequest, ferr.Error()) return } updates["formats"] = formats } if len(updates) > 0 { if err := h.a.DB.Model(&ch).Updates(updates).Error; err != nil { resp.Fail(c, http.StatusInternalServerError, "failed to update channel") return } // jsonb 序列化走模型字段更新 if f, ok := updates["formats"]; ok { if err := h.a.DB.Model(&ch).Update("formats", f).Error; err != nil { resp.Fail(c, http.StatusInternalServerError, "failed to update formats") return } } } resp.OK(c, gin.H{"ok": true}) } // AdminDeleteChannel DELETE /api/v1/admin/channels/:id func (h *Handler) AdminDeleteChannel(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { resp.Fail(c, http.StatusBadRequest, "invalid channel id") return } res := h.a.DB.Delete(&store.Channel{}, id) if res.Error != nil { resp.Fail(c, http.StatusInternalServerError, "failed to delete channel") return } if res.RowsAffected == 0 { resp.Fail(c, http.StatusNotFound, "channel not found") return } // 清理模型绑定 h.a.DB.Where("channel_id = ?", id).Delete(&store.ChannelModelBinding{}) resp.OK(c, gin.H{"ok": true}) } // AdminTestChannel POST /api/v1/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 { resp.Fail(c, http.StatusBadRequest, "invalid channel id") return } var ch store.Channel if err := h.a.DB.First(&ch, id).Error; err != nil { resp.Fail(c, http.StatusNotFound, "channel not found") return } key, err := h.a.Enc.Decrypt(ch.APIKeyEnc) if err != nil { resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key") return } url := strings.TrimRight(ch.BaseURL, "/") + "/v1/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() resp2, 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 resp2.StatusCode < 200 || resp2.StatusCode >= 300 { status = store.ChannelHealthCooldown b, _ := io.ReadAll(io.LimitReader(resp2.Body, 1024)) msg = fmt.Sprintf("http %d: %s", resp2.StatusCode, strings.TrimSpace(string(b))) } resp2.Body.Close() } h.a.DB.Model(&store.Channel{}).Where("id = ?", ch.ID).Update("health_status", status) if status != store.ChannelHealthHealthy { resp.Fail(c, http.StatusBadGateway, msg) return } resp.OK(c, gin.H{"ok": true, "latency_ms": latency, "message": msg}) } // AdminImportChannelModels POST /api/v1/admin/channels/:id/models/import // 拉取渠道 GET /v1/models,导入模型库并绑定。 func (h *Handler) AdminImportChannelModels(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { resp.Fail(c, http.StatusBadRequest, "invalid channel id") return } var ch store.Channel if err := h.a.DB.First(&ch, id).Error; err != nil { resp.Fail(c, http.StatusNotFound, "channel not found") return } key, err := h.a.Enc.Decrypt(ch.APIKeyEnc) if err != nil { resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key") return } url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models" client := &http.Client{Timeout: 15 * time.Second} req, _ := http.NewRequest(http.MethodGet, url, nil) req.Header.Set("Authorization", "Bearer "+key) resp2, err := client.Do(req) if err != nil { resp.Fail(c, http.StatusBadGateway, "failed to reach channel: "+err.Error()) return } defer resp2.Body.Close() if resp2.StatusCode != http.StatusOK { resp.Fail(c, http.StatusBadGateway, "channel returned http "+strconv.Itoa(resp2.StatusCode)) return } var list struct { Data []struct { ID string `json:"id"` } `json:"data"` } if err := json.NewDecoder(resp2.Body).Decode(&list); err != nil { resp.Fail(c, http.StatusBadGateway, "failed to parse model list") return } if len(list.Data) == 0 { resp.Fail(c, http.StatusNotFound, "channel returned no models") return } imported := 0 err = h.a.DB.Transaction(func(tx *gorm.DB) error { for _, item := range list.Data { name := strings.TrimSpace(item.ID) if name == "" { continue } var m store.Model if err := tx.Where("name = ?", name).FirstOrCreate(&m, store.Model{ Name: name, DisplayName: name, Enabled: true, }).Error; err != nil { return err } // upsert 绑定(upstream_model 默认同名) var binding store.ChannelModelBinding err := tx.Where("channel_id = ? AND model_id = ?", ch.ID, m.ID).First(&binding).Error if errors.Is(err, gorm.ErrRecordNotFound) { binding = store.ChannelModelBinding{ChannelID: ch.ID, ModelID: m.ID, UpstreamModel: name, Weight: 1} if err := tx.Create(&binding).Error; err != nil { return err } } imported++ } return nil }) if err != nil { resp.Fail(c, http.StatusInternalServerError, "failed to import models") return } resp.OK(c, gin.H{"imported": imported}) } var _ = clause.Assignments // 保留 gorm/clause 引用(后续定价批处理用) 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 }