Reorganize project structure: - backend/cmd/openteam/ — entry point - backend/internal/ — core packages - backend/middleware/ — HTTP middleware - backend/router/ — route setup - backend/wire/ — dependency injection - backend/pkg/ — shared utilities - backend/go.mod, go.sum — Go module files Updated Makefile to work from backend/ directory. Removed old lowercase makefile.
566 lines
14 KiB
Go
566 lines
14 KiB
Go
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})
|
|
}
|