Files
openteam/server/internal/api/admin_channels.go
T
SakurasanandClaude 4f92d7e0a4 渠道: 新增主页字段 + 表格展示 favicon
- Channel 新增 homepage 字段(渠道主页)
- 后端 /admin/channels/:id/favicon 代理抓取 {homepage}/favicon.ico, 内存缓存 1h;
  服务端抓取可解析 localhost/内网主页, 避免浏览器跨域
- 前端渠道表单加"渠道主页"输入; 表格新增主页列(favicon + 域名), 加载失败回退灰色地球图标
- mock 上游提供 /favicon.ico 演示

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-15 17:01:45 +08:00

468 lines
14 KiB
Go

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(), "homepage": ch.Homepage, "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(主配置)
Homepage string `json:"homepage"`
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
}
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
}
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, Homepage: req.Homepage, 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"`
Formats *[]string `json:"formats"`
Homepage *string `json:"homepage"`
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.Homepage != nil {
updates["homepage"] = *body.Homepage
}
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})
}
// AdminChannelFavicon GET /api/v1/admin/channels/:id/favicon — 代理获取渠道主页 favicon(内存缓存 1h)。
func (h *Handler) AdminChannelFavicon(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.Status(http.StatusBadRequest)
return
}
var ch store.Channel
if err := h.a.DB.First(&ch, id).Error; err != nil || ch.Homepage == "" {
c.Status(http.StatusNotFound)
return
}
h.favMu.Lock()
if f, ok := h.favCache[id]; ok && time.Since(f.at) < time.Hour {
h.favMu.Unlock()
c.Data(http.StatusOK, f.ct, f.data)
return
}
h.favMu.Unlock()
url := strings.TrimRight(ch.Homepage, "/") + "/favicon.ico"
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url)
if err != nil || resp.StatusCode != http.StatusOK {
if resp != nil {
resp.Body.Close()
}
c.Status(http.StatusNotFound)
return
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 256*1024))
if err != nil || len(data) == 0 {
c.Status(http.StatusNotFound)
return
}
ct := resp.Header.Get("Content-Type")
if ct == "" {
ct = "image/x-icon"
}
h.favMu.Lock()
h.favCache[id] = favEntry{data: data, ct: ct, at: time.Now()}
h.favMu.Unlock()
c.Data(http.StatusOK, ct, data)
}
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
}