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
+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