M0-M4: 推倒重来基线(基建+用户/密钥/核心代理+前端+管理后台+三协议互转)

- 后端 Go+Gin+GORM: 配置(OT_ env)/SQLite/Postgres 双驱动、用户体系(argon2id+JWT access/refresh)、
  API Key(sk- 48位, 仅存 SHA-256 哈希)
- 代理网关: /v1/chat/completions、/v1/responses、/v1/messages、/v1/models;错误按客户端协议返回
- 三协议互转(convert 包): Chat↔Messages↔Responses 请求/响应 + 流式 SSE 逐事件转换(直通优先)
- 用量计费: 异步批量记账、余额扣减、balance_logs、usage_daily 日聚合
- 管理 API: 用户/渠道 CRUD+测试+模型导入/模型定价+绑定/统计/系统配置
- 前端 Vue3+TS+Tailwind(taste-skill 设计 tokens): Landing/登录注册/控制台/管理后台,
  自建组件+Phosphor 图标+自建 SVG 趋势图, 已过 web-design-guidelines 复查
- mock 上游: OpenAI+Anthropic 双协议模拟(含流式)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Sakurasan
2026-08-15 15:34:06 +08:00
co-authored by Claude
parent b25e9ec8a7
commit ec4de8d913
92 changed files with 6203 additions and 3064 deletions
+199
View File
@@ -0,0 +1,199 @@
package api
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/openteam/server/internal/pkg/resp"
"github.com/openteam/server/internal/store"
"gorm.io/gorm"
)
// AdminModels GET /api/v1/admin/models — 模型列表(含价格与渠道绑定)。
func (h *Handler) AdminModels(c *gin.Context) {
var ms []store.Model
if err := h.a.DB.Order("sort ASC, id ASC").Find(&ms).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to load models")
return
}
out := make([]gin.H, 0, len(ms))
for _, m := range ms {
var bindings []store.ChannelModelBinding
h.a.DB.Preload("Channel").Where("model_id = ?", m.ID).Find(&bindings)
chs := make([]gin.H, 0, len(bindings))
for _, b := range bindings {
chs = append(chs, gin.H{
"id": b.ID, "channel_id": b.ChannelID, "channel_name": b.Channel.Name,
"upstream_model": b.UpstreamModel, "weight": b.Weight,
})
}
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,
})
}
resp.OK(c, gin.H{"items": out})
}
// AdminCreateModel POST /api/v1/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"`
Enabled *bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid input: "+err.Error())
return
}
m := store.Model{
Name: req.Name, DisplayName: req.DisplayName,
InputPrice: req.InputPrice, OutputPrice: req.OutputPrice, CacheReadPrice: req.CacheReadPrice,
Enabled: boolOr(req.Enabled, true),
}
if m.DisplayName == "" {
m.DisplayName = m.Name
}
if err := h.a.DB.Create(&m).Error; err != nil {
resp.Fail(c, http.StatusConflict, "failed to create model (name may already exist)")
return
}
resp.Created(c, gin.H{"id": m.ID, "name": m.Name})
}
// AdminUpdateModel PUT /api/v1/admin/models/:id — 价格/展示名/启停/排序。
func (h *Handler) AdminUpdateModel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
resp.Fail(c, http.StatusBadRequest, "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 {
resp.Fail(c, http.StatusBadRequest, "invalid input")
return
}
var m store.Model
if err := h.a.DB.First(&m, id).Error; err != nil {
resp.Fail(c, http.StatusNotFound, "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.a.DB.Model(&m).Updates(updates).Error; err != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to update model")
return
}
}
resp.OK(c, gin.H{"ok": true})
}
// AdminDeleteModel DELETE /api/v1/admin/models/:id
func (h *Handler) AdminDeleteModel(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid model id")
return
}
res := h.a.DB.Delete(&store.Model{}, id)
if res.Error != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to delete model")
return
}
if res.RowsAffected == 0 {
resp.Fail(c, http.StatusNotFound, "model not found")
return
}
h.a.DB.Where("model_id = ?", id).Delete(&store.ChannelModelBinding{})
resp.OK(c, gin.H{"ok": true})
}
// AdminCreateModelBinding POST /api/v1/admin/models/:id/bindings
func (h *Handler) AdminCreateModelBinding(c *gin.Context) {
modelID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
resp.Fail(c, http.StatusBadRequest, "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 {
resp.Fail(c, http.StatusBadRequest, "invalid input: channel_id and upstream_model required")
return
}
var m store.Model
if err := h.a.DB.First(&m, modelID).Error; err != nil {
resp.Fail(c, http.StatusNotFound, "model not found")
return
}
var ch store.Channel
if err := h.a.DB.First(&ch, req.ChannelID).Error; err != nil {
resp.Fail(c, http.StatusNotFound, "channel not found")
return
}
b := store.ChannelModelBinding{
ChannelID: req.ChannelID, ModelID: modelID,
UpstreamModel: req.UpstreamModel, Weight: intOr(req.Weight, 1),
}
if err := h.a.DB.Create(&b).Error; err != nil {
resp.Fail(c, http.StatusConflict, "binding may already exist")
return
}
resp.Created(c, gin.H{"id": b.ID})
}
// AdminDeleteModelBinding DELETE /api/v1/admin/models/:id/bindings/:bid
func (h *Handler) AdminDeleteModelBinding(c *gin.Context) {
bid, err := strconv.ParseUint(c.Param("bid"), 10, 64)
if err != nil {
resp.Fail(c, http.StatusBadRequest, "invalid binding id")
return
}
res := h.a.DB.Delete(&store.ChannelModelBinding{}, bid)
if res.Error != nil {
resp.Fail(c, http.StatusInternalServerError, "failed to delete binding")
return
}
if res.RowsAffected == 0 {
resp.Fail(c, http.StatusNotFound, "binding not found")
return
}
resp.OK(c, gin.H{"ok": true})
}
var _ = errors.Is
var _ = gorm.ErrRecordNotFound