refactor: complete backend rewrite for multi-protocol proxy
Major rewrite of the Go backend to support:
- Three API format imports: openai, anthropic, compatible
- Three protocol conversions: Chat Completions, Responses, Messages
- Hub-and-spoke architecture with Chat as intermediate format
Deleted:
- opencat.go (old entry)
- store/, team/, pkg/team/, pkg/store/ (old data layer)
- internal/model/, internal/consts/ (old types)
- internal/service/team/, internal/controller/team/ (old handlers)
- llm/ (removed LLM client library, pure proxy mode)
- dist/, assets/ (old build artifacts)
Added:
- internal/store/ — 9 GORM models + multi-DB support
- internal/pkg/ — crypto (AES-GCM), apikey, jwt, ratelimit, resp, tokenizer
- internal/channel/ — channel selection, weighted LB, health checks
- internal/proxy/convert/ — 6 protocol conversion functions + SSE streaming
- internal/proxy/ — gateway with request dispatch and upstream selection
- internal/usage/ — async usage recorder with batch writes
- internal/api/ — management API (auth, users, keys, channels, models)
- Makefile for build/test/deploy
Fixed API to match frontend expectations:
- Login response wraps token in { data: { token } }
- GET /api/profile route added
- Profile response wraps user in { code, data }
- Role returned as number (10=admin, 1=user)
This commit is contained in:
@@ -0,0 +1,565 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"opencatd-open/internal/dao"
|
||||
"opencatd-open/internal/store"
|
||||
"opencatd-open/internal/pkg/apikey"
|
||||
"opencatd-open/internal/pkg/crypto"
|
||||
"opencatd-open/internal/pkg/jwt"
|
||||
"opencatd-open/internal/auth"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
db *gorm.DB
|
||||
userDAO *dao.UserDAO
|
||||
apiKeyDAO *dao.ApiKeyDAO
|
||||
channelDAO *dao.ChannelDAO
|
||||
modelDAO *dao.ModelDAO
|
||||
usageDAO *dao.UsageDAO
|
||||
dailyDAO *dao.DailyUsageDAO
|
||||
}
|
||||
|
||||
func NewHandler(db *gorm.DB) *Handler {
|
||||
return &Handler{
|
||||
db: db,
|
||||
userDAO: dao.NewUserDAO(db),
|
||||
apiKeyDAO: dao.NewApiKeyDAO(db),
|
||||
channelDAO: dao.NewChannelDAO(db),
|
||||
modelDAO: dao.NewModelDAO(db),
|
||||
usageDAO: dao.NewUsageDAO(db),
|
||||
dailyDAO: dao.NewDailyUsageDAO(db),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
|
||||
func (h *Handler) Register(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if first user (becomes admin)
|
||||
var count int64
|
||||
h.db.Model(&store.User{}).Count(&count)
|
||||
|
||||
role := store.RoleUser
|
||||
if count == 0 {
|
||||
role = store.RoleAdmin
|
||||
}
|
||||
|
||||
hash := crypto.Sha256Hex(req.Password)
|
||||
user := &store.User{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
PasswordHash: hash,
|
||||
Role: role,
|
||||
Status: store.UserStatusActive,
|
||||
}
|
||||
|
||||
if err := h.userDAO.Create(user); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "username or email already exists"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "registered"})
|
||||
}
|
||||
|
||||
func (h *Handler) Login(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userDAO.GetByUsername(req.Username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
hash := crypto.Sha256Hex(req.Password)
|
||||
if user.PasswordHash != hash {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
secret := auth.GetSecretKey()
|
||||
accessToken, refreshToken, err := jwt.GenerateTokenPair(user.ID, user.Username, user.Role, secret, 24*time.Hour, 7*24*time.Hour)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate token"})
|
||||
return
|
||||
}
|
||||
|
||||
// Update last login
|
||||
now := time.Now()
|
||||
user.LastLoginAt = &now
|
||||
h.userDAO.Update(user)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"data": gin.H{
|
||||
"token": accessToken,
|
||||
"access_token": accessToken,
|
||||
"refresh_token": refreshToken,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) Me(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
user, err := h.userDAO.GetByID(userID.(uint64))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
// Map role string to number for frontend compatibility
|
||||
roleNum := 1 // default user
|
||||
if user.Role == store.RoleAdmin {
|
||||
roleNum = 10
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 200,
|
||||
"data": gin.H{
|
||||
"id": user.ID,
|
||||
"username": user.Username,
|
||||
"email": user.Email,
|
||||
"role": roleNum,
|
||||
"status": user.Status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
|
||||
func (h *Handler) ListUsers(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
users, total, err := h.userDAO.List(limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": users, "total": total})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateUser(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
role := store.RoleUser
|
||||
if req.Role != "" {
|
||||
role = req.Role
|
||||
}
|
||||
|
||||
hash := crypto.Sha256Hex(req.Password)
|
||||
user := &store.User{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
PasswordHash: hash,
|
||||
Role: role,
|
||||
Status: store.UserStatusActive,
|
||||
}
|
||||
|
||||
if err := h.userDAO.Create(user); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "username or email already exists"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteUser(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
if err := h.userDAO.Delete(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
// --- API Keys ---
|
||||
|
||||
func (h *Handler) ListApiKeys(c *gin.Context) {
|
||||
userID, _ := c.Get("user_id")
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
keys, total, err := h.apiKeyDAO.ListByUserID(userID.(uint64), limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": keys, "total": total})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateApiKey(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
QuotaTokensPerDay *int64 `json:"quota_tokens_per_day"`
|
||||
QuotaRequestsPerDay *int `json:"quota_requests_per_day"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := c.Get("user_id")
|
||||
keyValue, _ := apikey.Generate()
|
||||
|
||||
key := &store.APIKey{
|
||||
UserID: userID.(uint64),
|
||||
Name: req.Name,
|
||||
KeyHash: apikey.Hash(keyValue),
|
||||
KeyPrefix: keyValue[:8],
|
||||
QuotaTokensPerDay: req.QuotaTokensPerDay,
|
||||
QuotaRequestsPerDay: req.QuotaRequestsPerDay,
|
||||
Status: store.KeyStatusActive,
|
||||
}
|
||||
|
||||
if err := h.apiKeyDAO.Create(key); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"key": keyValue,
|
||||
"id": key.ID,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteApiKey(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
if err := h.apiKeyDAO.Delete(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
// --- Channels ---
|
||||
|
||||
func (h *Handler) ListChannels(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
channels, total, err := h.channelDAO.List(limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": channels, "total": total})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateChannel(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Provider string `json:"provider" binding:"required"`
|
||||
BaseURL string `json:"base_url" binding:"required"`
|
||||
APIKey string `json:"api_key" binding:"required"`
|
||||
Priority int `json:"priority"`
|
||||
Weight int `json:"weight"`
|
||||
Formats []string `json:"formats"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
encrypted, err := crypto.Encrypt(req.APIKey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Weight == 0 {
|
||||
req.Weight = 1
|
||||
}
|
||||
|
||||
ch := &store.Channel{
|
||||
Name: req.Name,
|
||||
Provider: req.Provider,
|
||||
BaseURL: req.BaseURL,
|
||||
APIKeyEnc: encrypted,
|
||||
Weight: req.Weight,
|
||||
Priority: req.Priority,
|
||||
Formats: req.Formats,
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
if err := h.channelDAO.Create(ch); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "channel name already exists"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ch)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateChannel(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
ch, err := h.channelDAO.GetByID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "channel not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key"`
|
||||
Priority *int `json:"priority"`
|
||||
Weight *int `json:"weight"`
|
||||
Formats []string `json:"formats"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name != "" {
|
||||
ch.Name = req.Name
|
||||
}
|
||||
if req.BaseURL != "" {
|
||||
ch.BaseURL = req.BaseURL
|
||||
}
|
||||
if req.APIKey != "" {
|
||||
encrypted, err := crypto.Encrypt(req.APIKey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to encrypt API key"})
|
||||
return
|
||||
}
|
||||
ch.APIKeyEnc = encrypted
|
||||
}
|
||||
if req.Priority != nil {
|
||||
ch.Priority = *req.Priority
|
||||
}
|
||||
if req.Weight != nil {
|
||||
ch.Weight = *req.Weight
|
||||
}
|
||||
if req.Formats != nil {
|
||||
ch.Formats = req.Formats
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
ch.Enabled = *req.Enabled
|
||||
}
|
||||
|
||||
if err := h.channelDAO.Update(ch); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, ch)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteChannel(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
if err := h.channelDAO.Delete(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
// --- Models ---
|
||||
|
||||
func (h *Handler) ListModels(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
models, total, err := h.modelDAO.List(limit, offset)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": models, "total": total})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateModel(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
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"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": 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: true,
|
||||
}
|
||||
|
||||
if err := h.modelDAO.Create(m); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "model name already exists"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, m)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateModel(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
m, err := h.modelDAO.GetByID(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "model not found"})
|
||||
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"`
|
||||
Sort *int `json:"sort"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if req.DisplayName != "" {
|
||||
m.DisplayName = req.DisplayName
|
||||
}
|
||||
if req.InputPrice != nil {
|
||||
m.InputPrice = *req.InputPrice
|
||||
}
|
||||
if req.OutputPrice != nil {
|
||||
m.OutputPrice = *req.OutputPrice
|
||||
}
|
||||
if req.CacheReadPrice != nil {
|
||||
m.CacheReadPrice = *req.CacheReadPrice
|
||||
}
|
||||
if req.Sort != nil {
|
||||
m.Sort = *req.Sort
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
m.Enabled = *req.Enabled
|
||||
}
|
||||
|
||||
if err := h.modelDAO.Update(m); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, m)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteModel(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
if err := h.modelDAO.Delete(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
// --- Channel-Model Bindings ---
|
||||
|
||||
func (h *Handler) BindChannelModels(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 {
|
||||
Bindings []struct {
|
||||
ModelID uint64 `json:"model_id"`
|
||||
UpstreamModel string `json:"upstream_model"`
|
||||
Weight int `json:"weight"`
|
||||
} `json:"bindings"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
bindings := make([]store.ChannelModelBinding, len(req.Bindings))
|
||||
for i, b := range req.Bindings {
|
||||
bindings[i] = store.ChannelModelBinding{
|
||||
ChannelID: channelID,
|
||||
ModelID: b.ModelID,
|
||||
UpstreamModel: b.UpstreamModel,
|
||||
Weight: b.Weight,
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.channelDAO.BindModels(channelID, bindings); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "bound"})
|
||||
}
|
||||
|
||||
func (h *Handler) GetChannelModels(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
|
||||
}
|
||||
|
||||
bindings, err := h.channelDAO.GetChannelModels(channelID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": bindings})
|
||||
}
|
||||
Reference in New Issue
Block a user