feat: 三协议互转网关 + 鉴权修复 + 管理端增强

后端
- 新增 proxy/convert 三协议(chat/messages/responses)请求、响应与 SSE 流式互转,
  以 Chat 为中间模型;usage.go 统一提取三协议 token 用量(含单测)
- gateway: 跨协议调度(渠道未声明客户端协议时转为渠道首选格式),
  streamResponse 按 \n\n 分块逐行转换直通,bufferResponse 转换失败时剥非 JSON 前缀
- gateway: 新增 SetUsageRecorder 注入异步用量记录器
- auth_llm: 修复 key_prefix 查询长度错配([:8] vs 存储的 [:12])导致全部 401;
  修复长度 8-11 的 key 切片越界 panic;统一 unauthorized 响应
- usage: 日报表改为增量累加 upsert,避免多次 flush 互相清零;记录协议/错误码/时延等字段
- channel: 新增渠道并发槽 TryAcquire;健康检查支持可配置参数
- api: 新增 admin 渠道/模型/系统配置管理端点(旧端点保留兼容)

前端
- 新增渠道管理、模型管理、系统配置视图与 ChannelModelsDrawer
- 新增 ui 基础组件(Button/Badge/Input/Modal)与 protocol.ts
- 调整 Toast 样式、密钥页、路由菜单;dev 代理默认指向 3000 端口
This commit is contained in:
Sakurasan
2026-08-31 22:29:09 +08:00
parent e472ed93d5
commit f81b364436
34 changed files with 5171 additions and 179 deletions
+588
View File
@@ -0,0 +1,588 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"opencatd-open/internal/pkg/crypto"
"opencatd-open/internal/store"
"github.com/gin-gonic/gin"
)
// AdminChannels GET /api/admin/channels — 渠道列表(不返回加密 key,返回掩码)。
func (h *Handler) AdminChannels(c *gin.Context) {
var chs []store.Channel
if err := h.db.Order("id ASC").Find(&chs).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load channels"})
return
}
out := make([]gin.H, 0, len(chs))
for _, ch := range chs {
masked := ""
if key, err := crypto.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,
})
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
type channelBody struct {
Name string `json:"name" binding:"required,min=1,max=64"`
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"`
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/admin/channels
func (h *Handler) AdminCreateChannel(c *gin.Context) {
var req channelBody
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input: " + err.Error()})
return
}
if req.Provider == "" {
req.Provider = deriveProvider(req.Formats)
}
if !validateProvider(req.Provider) {
c.JSON(http.StatusBadRequest, gin.H{"error": "provider must be openai, anthropic or compatible"})
return
}
if req.APIKey == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "api_key required"})
return
}
formats, err := resolveFormats(req.Provider, req.Formats)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
baseURL, err := resolveBaseURL(req.Provider, req.BaseURL)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
enc, err := crypto.Encrypt(req.APIKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "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.db.Create(&ch).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "failed to create channel (name may already exist)"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": ch.ID, "name": ch.Name})
}
// AdminUpdateChannel PUT /api/admin/channels/:id
func (h *Handler) AdminUpdateChannel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "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 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
return
}
var ch store.Channel
if err := h.db.First(&ch, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
updates := map[string]any{}
if body.Name != nil {
updates["name"] = *body.Name
}
if body.Provider != nil {
if !validateProvider(*body.Provider) {
c.JSON(http.StatusBadRequest, gin.H{"error": "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 {
c.JSON(http.StatusBadRequest, gin.H{"error": berr.Error()})
return
}
updates["base_url"] = b
}
if body.BaseURLs != nil {
raw, _ := json.Marshal(normalizeBaseURLs(*body.BaseURLs))
updates["base_urls"] = string(raw)
}
if body.APIKey != nil && *body.APIKey != "" {
enc, err := crypto.Encrypt(*body.APIKey)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "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 {
c.JSON(http.StatusBadRequest, gin.H{"error": ferr.Error()})
return
}
raw, _ := json.Marshal(formats)
updates["formats"] = string(raw)
}
if len(updates) > 0 {
if err := h.db.Model(&ch).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update channel"})
return
}
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminDeleteChannel DELETE /api/admin/channels/:id
func (h *Handler) AdminDeleteChannel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
return
}
res := h.db.Delete(&store.Channel{}, id)
if res.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete channel"})
return
}
if res.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
h.db.Where("channel_id = ?", id).Delete(&store.ChannelModelBinding{})
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminTestChannel POST /api/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 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
return
}
var ch store.Channel
if err := h.db.First(&ch, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
key, err := crypto.Decrypt(ch.APIKeyEnc)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "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()
resp, 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 resp.StatusCode < 200 || resp.StatusCode >= 300 {
status = store.ChannelHealthCooldown
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
msg = fmt.Sprintf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
resp.Body.Close()
}
h.db.Model(&store.Channel{}).Where("id = ?", ch.ID).Update("health_status", status)
if status != store.ChannelHealthHealthy {
c.JSON(http.StatusBadGateway, gin.H{"error": msg})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency, "message": msg})
}
// AdminChannelRemoteModels GET /api/admin/channels/:id/models/remote — 拉取远端模型列表。
// 返回本渠道尚未允许的模型(新增候选),排除已绑定的模型。
func (h *Handler) AdminChannelRemoteModels(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
return
}
var ch store.Channel
if err := h.db.First(&ch, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
key, err := crypto.Decrypt(ch.APIKeyEnc)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "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")
resp, err := client.Do(req)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
c.JSON(http.StatusBadGateway, gin.H{"error": fmt.Sprintf("http %d: %s", resp.StatusCode, string(body))})
return
}
// 解析 OpenAI 格式的模型列表
var result struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := json.Unmarshal(body, &result); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to parse response: " + err.Error()})
return
}
// 本渠道已允许的上游模型名:不作为新增候选
var boundNames []string
h.db.Model(&store.ChannelModelBinding{}).Where("channel_id = ?", id).Pluck("upstream_model", &boundNames)
boundSet := make(map[string]bool, len(boundNames))
for _, n := range boundNames {
boundSet[strings.TrimSpace(n)] = true
}
models := make([]string, 0, len(result.Data))
for _, m := range result.Data {
name := strings.TrimSpace(m.ID)
if name != "" && !boundSet[name] {
models = append(models, name)
}
}
c.JSON(http.StatusOK, gin.H{"data": models})
}
// AdminChannelModels GET /api/admin/channels/:id/models — 渠道绑定列表。
func (h *Handler) AdminChannelModels(c *gin.Context) {
channelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
return
}
var bindings []store.ChannelModelBinding
if err := h.db.Preload("Model").Where("channel_id = ?", channelID).Find(&bindings).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load bindings"})
return
}
out := make([]gin.H, 0, len(bindings))
for _, b := range bindings {
out = append(out, gin.H{
"id": b.ID, "model_id": b.ModelID, "model_name": b.Model.Name,
"upstream_model": b.UpstreamModel, "weight": b.Weight,
})
}
c.JSON(http.StatusOK, gin.H{"data": out})
}
// AdminChannelAddModel POST /api/admin/channels/:id/models — 手工添加模型绑定。
// 无需渠道具备 /v1/models 接口:直接填上游模型名,可选自定义名称作为客户端调用名。
func (h *Handler) AdminChannelAddModel(c *gin.Context) {
channelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid channel id"})
return
}
var req struct {
UpstreamModel string `json:"upstream_model" binding:"required"` // 渠道侧真实模型名
CustomName string `json:"custom_name"` // 客户端调用名,空=用上游名
Weight *int `json:"weight"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input: upstream_model required"})
return
}
globalName := req.CustomName
if globalName == "" {
globalName = req.UpstreamModel
}
var ch store.Channel
if err := h.db.First(&ch, channelID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
// 查找或创建全局模型
var m store.Model
if err := h.db.Where("name = ?", globalName).First(&m).Error; err != nil {
m = store.Model{Name: globalName, Enabled: true}
if err := h.db.Create(&m).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create model"})
return
}
}
// 查找已存在的绑定,如果存在则更新
var existing store.ChannelModelBinding
if err := h.db.Where("channel_id = ? AND model_id = ?", channelID, m.ID).First(&existing).Error; err == nil {
// 已存在,更新
existing.UpstreamModel = req.UpstreamModel
existing.Weight = intOr(req.Weight, 1)
if err := h.db.Save(&existing).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update binding"})
return
}
c.JSON(http.StatusOK, gin.H{"id": existing.ID, "model_id": m.ID, "model_name": m.Name, "upstream_model": existing.UpstreamModel, "weight": existing.Weight})
return
}
// 不存在,创建新的
b := store.ChannelModelBinding{
ChannelID: channelID, ModelID: m.ID,
UpstreamModel: req.UpstreamModel, Weight: intOr(req.Weight, 1),
}
if err := h.db.Create(&b).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "binding may already exist"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": b.ID, "model_id": m.ID, "model_name": m.Name, "upstream_model": req.UpstreamModel, "weight": b.Weight})
}
// AdminChannelUpdateModel PATCH /api/admin/channels/:id/models/:bid — 改映射名/权重。
func (h *Handler) AdminChannelUpdateModel(c *gin.Context) {
bid, err := strconv.ParseUint(c.Param("bid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid binding id"})
return
}
var req struct {
UpstreamModel *string `json:"upstream_model"`
Weight *int `json:"weight"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
return
}
var b store.ChannelModelBinding
if err := h.db.First(&b, bid).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "binding not found"})
return
}
updates := map[string]any{}
if req.UpstreamModel != nil {
updates["upstream_model"] = *req.UpstreamModel
}
if req.Weight != nil {
updates["weight"] = *req.Weight
}
if len(updates) > 0 {
if err := h.db.Model(&b).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update binding"})
return
}
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminChannelDeleteModel DELETE /api/admin/channels/:id/models/:bid — 解除绑定。
func (h *Handler) AdminChannelDeleteModel(c *gin.Context) {
bid, err := strconv.ParseUint(c.Param("bid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid binding id"})
return
}
res := h.db.Delete(&store.ChannelModelBinding{}, bid)
if res.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete binding"})
return
}
if res.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "binding not found"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// maskAPIKey 掩码渠道密钥:保留前 7 位与后 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
}
+109
View File
@@ -0,0 +1,109 @@
package api
import (
"net/http"
"opencatd-open/internal/store"
"github.com/gin-gonic/gin"
)
// AdminGetConfig GET /api/admin/config — 获取系统配置。
func (h *Handler) AdminGetConfig(c *gin.Context) {
configs := map[string]string{}
var rows []store.SystemConfig
h.db.Find(&rows)
for _, r := range rows {
configs[r.Key] = r.Value
}
c.JSON(http.StatusOK, gin.H{"data": configs})
}
// AdminUpdateConfig PUT /api/admin/config — 更新系统配置。
func (h *Handler) AdminUpdateConfig(c *gin.Context) {
var req map[string]string
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
return
}
for key, value := range req {
var sc store.SystemConfig
result := h.db.Where("key = ?", key).First(&sc)
if result.Error == nil {
sc.Value = value
h.db.Save(&sc)
} else {
sc = store.SystemConfig{Key: key, Value: value}
h.db.Create(&sc)
}
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminGetRegistration GET /api/admin/config/registration — 获取注册配置。
func (h *Handler) AdminGetRegistration(c *gin.Context) {
var sc store.SystemConfig
enabled := "true"
if err := h.db.Where("key = ?", "registration_enabled").First(&sc).Error; err == nil {
enabled = sc.Value
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"enabled": enabled == "true"}})
}
// AdminUpdateRegistration PUT /api/admin/config/registration — 更新注册配置。
func (h *Handler) AdminUpdateRegistration(c *gin.Context) {
var req struct {
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
return
}
value := "false"
if req.Enabled {
value = "true"
}
var sc store.SystemConfig
if err := h.db.Where("key = ?", "registration_enabled").First(&sc).Error; err == nil {
sc.Value = value
h.db.Save(&sc)
} else {
sc = store.SystemConfig{Key: "registration_enabled", Value: value}
h.db.Create(&sc)
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminGetPasswordLogin GET /api/admin/config/password-login — 获取密码登录配置。
func (h *Handler) AdminGetPasswordLogin(c *gin.Context) {
var sc store.SystemConfig
enabled := "true"
if err := h.db.Where("key = ?", "password_login_enabled").First(&sc).Error; err == nil {
enabled = sc.Value
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"enabled": enabled == "true"}})
}
// AdminUpdatePasswordLogin PUT /api/admin/config/password-login — 更新密码登录配置。
func (h *Handler) AdminUpdatePasswordLogin(c *gin.Context) {
var req struct {
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
return
}
value := "false"
if req.Enabled {
value = "true"
}
var sc store.SystemConfig
if err := h.db.Where("key = ?", "password_login_enabled").First(&sc).Error; err == nil {
sc.Value = value
h.db.Save(&sc)
} else {
sc = store.SystemConfig{Key: "password_login_enabled", Value: value}
h.db.Create(&sc)
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
+291
View File
@@ -0,0 +1,291 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"opencatd-open/internal/store"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// AdminModels GET /api/admin/models — 模型列表(含价格、渠道绑定、定价/禁止状态)。
func (h *Handler) AdminModels(c *gin.Context) {
var ms []store.Model
if err := h.db.Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load models"})
return
}
allow, deny := h.modelPolicyConfig()
out := make([]gin.H, 0, len(ms))
for _, m := range ms {
var bindings []store.ChannelModelBinding
h.db.Preload("Channel").Where("model_id = ?", m.ID).Find(&bindings)
chs := make([]gin.H, 0, len(bindings))
for _, b := range bindings {
if !b.Channel.Enabled {
continue
}
chs = append(chs, gin.H{
"id": b.ID, "channel_id": b.ChannelID, "channel_name": b.Channel.Name,
"upstream_model": b.UpstreamModel, "weight": b.Weight,
})
}
used := len(chs) > 0
needsPricing := used && m.InputPrice == 0 && m.OutputPrice == 0 && m.CacheReadPrice == 0
denied := containsStr(deny, m.Name) || (len(allow) > 0 && !containsStr(allow, m.Name))
out = append(out, gin.H{
"id": m.ID, "name": m.Name, "display_name": m.DisplayName,
"input_price": m.InputPrice, "output_price": m.OutputPrice, "cache_read_price": m.CacheReadPrice,
"enabled": m.Enabled, "sort": m.Sort, "channels": chs,
"used": used, "needs_pricing": needsPricing, "denied": denied,
})
}
var orphans []struct {
ChannelName string
UpstreamModel string
ModelID uint64
}
h.db.Raw(`SELECT c.name as channel_name, b.model_id, b.upstream_model
FROM channel_model_bindings b
LEFT JOIN models m ON m.id = b.model_id
LEFT JOIN channels c ON c.id = b.channel_id
WHERE m.id IS NULL`).Scan(&orphans)
missing := make([]gin.H, 0, len(orphans))
for _, o := range orphans {
missing = append(missing, gin.H{
"channel": o.ChannelName, "model_id": o.ModelID, "upstream_model": o.UpstreamModel,
})
}
unpriced := 0
{
var usedBindings []struct {
ModelID uint64
}
h.db.Model(&store.ChannelModelBinding{}).Distinct("model_id").Scan(&usedBindings)
usedIDs := map[uint64]bool{}
for _, u := range usedBindings {
usedIDs[u.ModelID] = true
}
for _, m := range ms {
if usedIDs[m.ID] && m.InputPrice == 0 && m.OutputPrice == 0 && m.CacheReadPrice == 0 {
unpriced++
}
}
}
c.JSON(http.StatusOK, gin.H{
"data": out,
"summary": gin.H{
"total": len(ms),
"unpriced": unpriced,
"missing": missing,
"denied_count": len(deny),
},
})
}
// modelPolicyConfig 读取全局模型允许/禁止列表。
func (h *Handler) modelPolicyConfig() (allow, deny []string) {
var raw string
h.db.Model(&store.SystemConfig{}).Where("key = ?", "model_allowlist").Pluck("value", &raw)
_ = json.Unmarshal([]byte(raw), &allow)
raw = ""
h.db.Model(&store.SystemConfig{}).Where("key = ?", "model_denylist").Pluck("value", &raw)
_ = json.Unmarshal([]byte(raw), &deny)
return
}
func containsStr(list []string, s string) bool {
for _, v := range list {
if v == s {
return true
}
}
return false
}
// AdminCreateModel POST /api/admin/models
func (h *Handler) AdminCreateModel(c *gin.Context) {
var req struct {
Name string `json:"name" binding:"required,min=1,max=128"`
DisplayName string `json:"display_name"`
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
CacheReadPrice float64 `json:"cache_read_price"`
Sort int `json:"sort"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input: " + err.Error()})
return
}
m := store.Model{
Name: req.Name, DisplayName: req.DisplayName,
InputPrice: req.InputPrice, OutputPrice: req.OutputPrice, CacheReadPrice: req.CacheReadPrice,
Sort: req.Sort, Enabled: boolOr(req.Enabled, true),
}
if err := h.db.Create(&m).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "failed to create model (name may already exist)"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": m.ID, "name": m.Name})
}
// AdminUpdateModel PUT /api/admin/models/:id — 价格/启停/排序。
func (h *Handler) AdminUpdateModel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid model id"})
return
}
var req struct {
DisplayName *string `json:"display_name"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
Enabled *bool `json:"enabled"`
Sort *int `json:"sort"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input"})
return
}
var m store.Model
if err := h.db.First(&m, id).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
return
}
updates := map[string]any{}
if req.DisplayName != nil {
updates["display_name"] = *req.DisplayName
}
if req.InputPrice != nil {
updates["input_price"] = *req.InputPrice
}
if req.OutputPrice != nil {
updates["output_price"] = *req.OutputPrice
}
if req.CacheReadPrice != nil {
updates["cache_read_price"] = *req.CacheReadPrice
}
if req.Enabled != nil {
updates["enabled"] = *req.Enabled
}
if req.Sort != nil {
updates["sort"] = *req.Sort
}
if len(updates) > 0 {
if err := h.db.Model(&m).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update model"})
return
}
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminDeleteModel DELETE /api/admin/models/:id
func (h *Handler) AdminDeleteModel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid model id"})
return
}
res := h.db.Delete(&store.Model{}, id)
if res.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete model"})
return
}
if res.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
return
}
h.db.Where("model_id = ?", id).Delete(&store.ChannelModelBinding{})
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// AdminDeleteUnusedModels DELETE /api/admin/models/unused — 一键清除未绑定任何渠道的模型。
func (h *Handler) AdminDeleteUnusedModels(c *gin.Context) {
var orphans []store.Model
if err := h.db.Where("id NOT IN (SELECT DISTINCT model_id FROM channel_model_bindings)").Find(&orphans).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load models"})
return
}
names := make([]string, 0, len(orphans))
ids := make([]uint64, 0, len(orphans))
for _, m := range orphans {
names = append(names, m.Name)
ids = append(ids, m.ID)
}
if len(ids) > 0 {
if err := h.db.Delete(&store.Model{}, ids).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete models"})
return
}
}
c.JSON(http.StatusOK, gin.H{"deleted": names, "count": len(names)})
}
// AdminCreateModelBinding POST /api/admin/models/:id/bindings
func (h *Handler) AdminCreateModelBinding(c *gin.Context) {
modelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid model id"})
return
}
var req struct {
ChannelID uint64 `json:"channel_id" binding:"required"`
UpstreamModel string `json:"upstream_model" binding:"required"`
Weight *int `json:"weight"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid input: channel_id and upstream_model required"})
return
}
var m store.Model
if err := h.db.First(&m, modelID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
return
}
var ch store.Channel
if err := h.db.First(&ch, req.ChannelID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
return
}
b := store.ChannelModelBinding{
ChannelID: req.ChannelID, ModelID: modelID,
UpstreamModel: req.UpstreamModel, Weight: intOr(req.Weight, 1),
}
if err := h.db.Create(&b).Error; err != nil {
c.JSON(http.StatusConflict, gin.H{"error": "binding may already exist"})
return
}
c.JSON(http.StatusCreated, gin.H{"id": b.ID})
}
// AdminDeleteModelBinding DELETE /api/admin/models/:id/bindings/:bid
func (h *Handler) AdminDeleteModelBinding(c *gin.Context) {
bid, err := strconv.ParseUint(c.Param("bid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid binding id"})
return
}
res := h.db.Delete(&store.ChannelModelBinding{}, bid)
if res.Error != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete binding"})
return
}
if res.RowsAffected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "binding not found"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
var _ = gorm.ErrRecordNotFound
+3 -3
View File
@@ -556,7 +556,7 @@ func (h *Handler) DeleteApiKey(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
// --- Channels ---
// --- Legacy Channel endpoints (kept for backward compatibility) ---
func (h *Handler) ListChannels(c *gin.Context) {
// Support both limit/offset and pageSize/page parameters
@@ -702,7 +702,7 @@ func (h *Handler) DeleteChannel(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
// --- Models ---
// --- Legacy Model endpoints (kept for backward compatibility) ---
func (h *Handler) ListModels(c *gin.Context) {
// Support both limit/offset and pageSize/page parameters
@@ -827,7 +827,7 @@ func (h *Handler) DeleteModel(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
// --- Channel-Model Bindings ---
// --- Legacy Channel-Model Bindings (kept for backward compatibility) ---
func (h *Handler) BindChannelModels(c *gin.Context) {
channelID, err := strconv.ParseUint(c.Param("id"), 10, 64)