渠道: 分协议 Base URL + 模型抽屉/远程拉取/操作图标

- 渠道支持分协议 base_url(chat/responses/messages 各一), 网关按协议选 base 直通
  (如智谱三种格式不同 base, 一个渠道即可), UpstreamURL 按 proto 拼接
- 渠道模型改为下方抽屉: 当前绑定列表(内联改上游/解除)、从接口拉取(remote 预览+勾选添加)、手动添加
- 新增 /channels/:id/models/remote 预览接口; 操作按钮加 Phosphor 图标
- 修复 formats jsonb 更新未序列化问题; 手机端渠道卡片化

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-16 04:31:28 +08:00
co-authored by Claude
parent 7c4e80afac
commit db83972b6f
11 changed files with 484 additions and 246 deletions
+52 -32
View File
@@ -34,7 +34,7 @@ func (h *Handler) AdminChannels(c *gin.Context) {
}
out = append(out, gin.H{
"id": ch.ID, "name": ch.Name, "provider": ch.Provider, "formats": ch.FormatsEffective(),
"base_url": ch.BaseURL,
"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,
@@ -45,16 +45,34 @@ func (h *Handler) AdminChannels(c *gin.Context) {
}
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"` // 可选:留空按供应商默认;支持前缀或完整端点
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"`
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:留空按供应商默认;网关按内容智能识别前缀/完整端点。
@@ -166,6 +184,7 @@ func (h *Handler) AdminCreateChannel(c *gin.Context) {
}
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),
@@ -185,17 +204,18 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
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"`
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")
@@ -229,6 +249,11 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
}
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 {
@@ -265,20 +290,15 @@ func (h *Handler) AdminUpdateChannel(c *gin.Context) {
resp.Fail(c, http.StatusBadRequest, ferr.Error())
return
}
updates["formats"] = formats
// 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
}
// 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})
}
@@ -321,7 +341,7 @@ func (h *Handler) AdminTestChannel(c *gin.Context) {
resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key")
return
}
url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models"
url := ch.UpstreamURL("", "/models")
client := &http.Client{Timeout: 10 * time.Second}
req, _ := http.NewRequest(http.MethodGet, url, nil)
req.Header.Set("Authorization", "Bearer "+key)
@@ -369,7 +389,7 @@ func (h *Handler) AdminImportChannelModels(c *gin.Context) {
resp.Fail(c, http.StatusInternalServerError, "failed to decrypt channel key")
return
}
url := strings.TrimRight(ch.BaseURL, "/") + "/v1/models"
url := ch.UpstreamURL("", "/models")
client := &http.Client{Timeout: 15 * time.Second}
req, _ := http.NewRequest(http.MethodGet, url, nil)
req.Header.Set("Authorization", "Bearer "+key)