Files
openteam/server/internal/api/admin_channels.go
T
SakurasanandClaude 4137b6fe20 渠道: 远程候选按渠道过滤 + key 掩码 + 移除批量导入端点
- 远程模型候选只排除本渠道已允许的模型,同名模型可被多个渠道各自允许
- 渠道 key 掩码统一 xxxxxxx******Mq4Y(保留前 7 位与后 4 位)
- 移除已弃用的批量导入端点 POST /channels/:id/models/import

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-16 18:55:05 +08:00

394 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
)
// 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 = 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,
})
}
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"` // 可选:留空按供应商默认;支持前缀或完整端点
BaseURLs map[string]string `json:"base_urls"` // 分协议 base_url 覆盖(chat/responses/messages)
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/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,
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.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"`
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 {
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.BaseURLs != nil {
// base_urls 是 jsonb:手动序列化
raw, _ := json.Marshal(normalizeBaseURLs(*body.BaseURLs))
updates["base_urls"] = string(raw)
}
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
}
// formats 是 jsonb:手动序列化为 JSON 字符串(map 更新不走序列化)
raw, _ := json.Marshal(formats)
updates["formats"] = string(raw)
}
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 := 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()
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})
}
// maskAPIKey 掩码渠道密钥:保留前 7 位与后 4 位,中间固定 ****** 遮蔽。
// 示例:xxxxxxx******Mq4Y;密钥较短时退化为仅保留后 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
}