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, "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" binding:"required"` BaseURL string `json:"base_url" binding:"required"` 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"` } func validateProvider(p string) bool { return p == store.ChannelProviderOpenAI || p == store.ChannelProviderAnthropic || p == store.ChannelProviderCompatible } // 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 !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 } 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, BaseURL: strings.TrimRight(req.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"` 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 { updates["base_url"] = strings.TrimRight(*body.BaseURL, "/") } 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 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 } } 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 }