feat: 分组相关接口完善
This commit is contained in:
4 files changed
+229
-38
No files matched your search
@@ -96,7 +96,7 @@ func main() {
|
|||||||
defer proxyServer.Close()
|
defer proxyServer.Close()
|
||||||
|
|
||||||
// Create handlers
|
// Create handlers
|
||||||
serverHandler := handler.NewServer(database, configManager, keyValidatorService, keyManualValidationService, taskService, keyService)
|
serverHandler := handler.NewServer(database, configManager, settingsManager, keyValidatorService, keyManualValidationService, taskService, keyService)
|
||||||
logCleanupHandler := handler.NewLogCleanupHandler(logCleanupService)
|
logCleanupHandler := handler.NewLogCleanupHandler(logCleanupService)
|
||||||
|
|
||||||
// Setup routes using the new router package
|
// Setup routes using the new router package
|
||||||
|
|||||||
@@ -4,16 +4,76 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"gpt-load/internal/config"
|
||||||
app_errors "gpt-load/internal/errors"
|
app_errors "gpt-load/internal/errors"
|
||||||
"gpt-load/internal/models"
|
"gpt-load/internal/models"
|
||||||
"gpt-load/internal/response"
|
"gpt-load/internal/response"
|
||||||
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gpt-load/internal/channel"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/datatypes"
|
"gorm.io/datatypes"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// isValidChannelType checks if the channel type is valid by checking against the registered channels.
|
||||||
|
func isValidChannelType(channelType string) bool {
|
||||||
|
channels := channel.GetChannels()
|
||||||
|
for _, t := range channels {
|
||||||
|
if t == channelType {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpstreamDefinition defines the structure for an upstream in the request.
|
||||||
|
type UpstreamDefinition struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Weight int `json:"weight"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateAndCleanUpstreams validates and cleans the upstreams JSON.
|
||||||
|
func validateAndCleanUpstreams(upstreams json.RawMessage) (datatypes.JSON, error) {
|
||||||
|
if len(upstreams) == 0 {
|
||||||
|
return nil, fmt.Errorf("upstreams field is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var defs []UpstreamDefinition
|
||||||
|
if err := json.Unmarshal(upstreams, &defs); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid format for upstreams: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(defs) == 0 {
|
||||||
|
return nil, fmt.Errorf("at least one upstream is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range defs {
|
||||||
|
defs[i].URL = strings.TrimSpace(defs[i].URL)
|
||||||
|
if defs[i].URL == "" {
|
||||||
|
return nil, fmt.Errorf("upstream URL cannot be empty")
|
||||||
|
}
|
||||||
|
// Basic URL format validation
|
||||||
|
if !strings.HasPrefix(defs[i].URL, "http://") && !strings.HasPrefix(defs[i].URL, "https://") {
|
||||||
|
return nil, fmt.Errorf("invalid URL format for upstream: %s", defs[i].URL)
|
||||||
|
}
|
||||||
|
if defs[i].Weight <= 0 {
|
||||||
|
return nil, fmt.Errorf("upstream weight must be a positive integer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanedUpstreams, err := json.Marshal(defs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal cleaned upstreams: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cleanedUpstreams, nil
|
||||||
|
}
|
||||||
|
|
||||||
// isValidGroupName checks if the group name is valid.
|
// isValidGroupName checks if the group name is valid.
|
||||||
func isValidGroupName(name string) bool {
|
func isValidGroupName(name string) bool {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
@@ -40,6 +100,29 @@ func validateAndCleanConfig(configMap map[string]any) (map[string]any, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strict check for unknown fields
|
||||||
|
var cleanedMap map[string]any
|
||||||
|
if err := json.Unmarshal(configBytes, &cleanedMap); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
val := reflect.ValueOf(validatedConfig)
|
||||||
|
typ := val.Type()
|
||||||
|
validFields := make(map[string]bool)
|
||||||
|
for i := 0; i < typ.NumField(); i++ {
|
||||||
|
jsonTag := typ.Field(i).Tag.Get("json")
|
||||||
|
fieldName := strings.Split(jsonTag, ",")[0]
|
||||||
|
if fieldName != "" && fieldName != "-" {
|
||||||
|
validFields[fieldName] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for key := range configMap {
|
||||||
|
if !validFields[key] {
|
||||||
|
return nil, fmt.Errorf("unknown config field: '%s'", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 验证配置项的合理范围
|
// 验证配置项的合理范围
|
||||||
if validatedConfig.BlacklistThreshold != nil && *validatedConfig.BlacklistThreshold < 0 {
|
if validatedConfig.BlacklistThreshold != nil && *validatedConfig.BlacklistThreshold < 0 {
|
||||||
return nil, fmt.Errorf("blacklist_threshold must be >= 0")
|
return nil, fmt.Errorf("blacklist_threshold must be >= 0")
|
||||||
@@ -54,52 +137,70 @@ func validateAndCleanConfig(configMap map[string]any) (map[string]any, error) {
|
|||||||
return nil, fmt.Errorf("key_validation_interval_minutes must be between 5 and 1440 minutes")
|
return nil, fmt.Errorf("key_validation_interval_minutes must be between 5 and 1440 minutes")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Marshal back to a map to remove any fields not in GroupConfig
|
// Marshal back to a map to ensure consistency
|
||||||
validatedBytes, err := json.Marshal(validatedConfig)
|
validatedBytes, err := json.Marshal(validatedConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
var finalMap map[string]any
|
||||||
var cleanedMap map[string]any
|
if err := json.Unmarshal(validatedBytes, &finalMap); err != nil {
|
||||||
if err := json.Unmarshal(validatedBytes, &cleanedMap); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return cleanedMap, nil
|
return finalMap, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateGroup handles the creation of a new group.
|
// CreateGroup handles the creation of a new group.
|
||||||
func (s *Server) CreateGroup(c *gin.Context) {
|
func (s *Server) CreateGroup(c *gin.Context) {
|
||||||
var group models.Group
|
var req models.Group
|
||||||
if err := c.ShouldBindJSON(&group); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
response.Error(c, app_errors.NewAPIError(app_errors.ErrInvalidJSON, err.Error()))
|
response.Error(c, app_errors.NewAPIError(app_errors.ErrInvalidJSON, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validation
|
// Data Cleaning and Validation
|
||||||
if !isValidGroupName(group.Name) {
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if !isValidGroupName(name) {
|
||||||
response.Error(c, app_errors.NewAPIError(app_errors.ErrValidation, "Invalid group name format. Use 3-30 lowercase letters, numbers, and underscores."))
|
response.Error(c, app_errors.NewAPIError(app_errors.ErrValidation, "Invalid group name format. Use 3-30 lowercase letters, numbers, and underscores."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(group.Upstreams) == 0 {
|
|
||||||
response.Error(c, app_errors.NewAPIError(app_errors.ErrValidation, "At least one upstream is required"))
|
channelType := strings.TrimSpace(req.ChannelType)
|
||||||
|
if !isValidChannelType(channelType) {
|
||||||
|
supported := strings.Join(channel.GetChannels(), ", ")
|
||||||
|
response.Error(c, app_errors.NewAPIError(app_errors.ErrValidation, fmt.Sprintf("Invalid channel type. Supported types are: %s", supported)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if group.ChannelType == "" {
|
|
||||||
response.Error(c, app_errors.NewAPIError(app_errors.ErrValidation, "Channel type is required"))
|
testModel := strings.TrimSpace(req.TestModel)
|
||||||
return
|
if testModel == "" {
|
||||||
}
|
|
||||||
if group.TestModel == "" {
|
|
||||||
response.Error(c, app_errors.NewAPIError(app_errors.ErrValidation, "Test model is required"))
|
response.Error(c, app_errors.NewAPIError(app_errors.ErrValidation, "Test model is required"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanedConfig, err := validateAndCleanConfig(group.Config)
|
cleanedUpstreams, err := validateAndCleanUpstreams(json.RawMessage(req.Upstreams))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, app_errors.NewAPIError(app_errors.ErrValidation, "Invalid config format"))
|
response.Error(c, app_errors.NewAPIError(app_errors.ErrValidation, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
group.Config = cleanedConfig
|
|
||||||
|
cleanedConfig, err := validateAndCleanConfig(req.Config)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, app_errors.NewAPIError(app_errors.ErrValidation, fmt.Sprintf("Invalid config format: %v", err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
group := models.Group{
|
||||||
|
Name: name,
|
||||||
|
DisplayName: strings.TrimSpace(req.DisplayName),
|
||||||
|
Description: strings.TrimSpace(req.Description),
|
||||||
|
Upstreams: cleanedUpstreams,
|
||||||
|
ChannelType: channelType,
|
||||||
|
Sort: req.Sort,
|
||||||
|
TestModel: testModel,
|
||||||
|
ParamOverrides: req.ParamOverrides,
|
||||||
|
Config: cleanedConfig,
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.DB.Create(&group).Error; err != nil {
|
if err := s.DB.Create(&group).Error; err != nil {
|
||||||
response.Error(c, app_errors.ParseDBError(err))
|
response.Error(c, app_errors.ParseDBError(err))
|
||||||
@@ -122,11 +223,11 @@ func (s *Server) ListGroups(c *gin.Context) {
|
|||||||
// GroupUpdateRequest defines the payload for updating a group.
|
// GroupUpdateRequest defines the payload for updating a group.
|
||||||
// Using a dedicated struct avoids issues with zero values being ignored by GORM's Update.
|
// Using a dedicated struct avoids issues with zero values being ignored by GORM's Update.
|
||||||
type GroupUpdateRequest struct {
|
type GroupUpdateRequest struct {
|
||||||
Name string `json:"name"`
|
Name *string `json:"name,omitempty"`
|
||||||
DisplayName string `json:"display_name"`
|
DisplayName *string `json:"display_name,omitempty"`
|
||||||
Description string `json:"description"`
|
Description *string `json:"description,omitempty"`
|
||||||
Upstreams json.RawMessage `json:"upstreams"`
|
Upstreams json.RawMessage `json:"upstreams"`
|
||||||
ChannelType string `json:"channel_type"`
|
ChannelType *string `json:"channel_type,omitempty"`
|
||||||
Sort *int `json:"sort"`
|
Sort *int `json:"sort"`
|
||||||
TestModel string `json:"test_model"`
|
TestModel string `json:"test_model"`
|
||||||
ParamOverrides map[string]any `json:"param_overrides"`
|
ParamOverrides map[string]any `json:"param_overrides"`
|
||||||
@@ -161,31 +262,52 @@ func (s *Server) UpdateGroup(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
defer tx.Rollback() // Rollback on panic
|
defer tx.Rollback() // Rollback on panic
|
||||||
|
|
||||||
// Apply updates from the request
|
// Apply updates from the request, with cleaning and validation
|
||||||
if req.Name != "" {
|
if req.Name != nil {
|
||||||
if !isValidGroupName(req.Name) {
|
cleanedName := strings.TrimSpace(*req.Name)
|
||||||
response.Error(c, app_errors.NewAPIError(app_errors.ErrValidation, "Invalid group name format."))
|
if !isValidGroupName(cleanedName) {
|
||||||
|
response.Error(c, app_errors.NewAPIError(app_errors.ErrValidation, "Invalid group name format. Name is required and must be 3-30 lowercase letters, numbers, or underscores."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
group.Name = req.Name
|
group.Name = cleanedName
|
||||||
}
|
}
|
||||||
if req.DisplayName != "" {
|
|
||||||
group.DisplayName = req.DisplayName
|
if req.DisplayName != nil {
|
||||||
|
group.DisplayName = strings.TrimSpace(*req.DisplayName)
|
||||||
}
|
}
|
||||||
if req.Description != "" {
|
|
||||||
group.Description = req.Description
|
if req.Description != nil {
|
||||||
|
group.Description = strings.TrimSpace(*req.Description)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Upstreams != nil {
|
if req.Upstreams != nil {
|
||||||
group.Upstreams = datatypes.JSON(req.Upstreams)
|
cleanedUpstreams, err := validateAndCleanUpstreams(req.Upstreams)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, app_errors.NewAPIError(app_errors.ErrValidation, err.Error()))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if req.ChannelType != "" {
|
group.Upstreams = cleanedUpstreams
|
||||||
group.ChannelType = req.ChannelType
|
}
|
||||||
|
|
||||||
|
if req.ChannelType != nil {
|
||||||
|
cleanedChannelType := strings.TrimSpace(*req.ChannelType)
|
||||||
|
if !isValidChannelType(cleanedChannelType) {
|
||||||
|
supported := strings.Join(channel.GetChannels(), ", ")
|
||||||
|
response.Error(c, app_errors.NewAPIError(app_errors.ErrValidation, fmt.Sprintf("Invalid channel type. Supported types are: %s", supported)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
group.ChannelType = cleanedChannelType
|
||||||
}
|
}
|
||||||
if req.Sort != nil {
|
if req.Sort != nil {
|
||||||
group.Sort = *req.Sort
|
group.Sort = *req.Sort
|
||||||
}
|
}
|
||||||
if req.TestModel != "" {
|
if req.TestModel != "" {
|
||||||
group.TestModel = req.TestModel
|
cleanedTestModel := strings.TrimSpace(req.TestModel)
|
||||||
|
if cleanedTestModel == "" {
|
||||||
|
response.Error(c, app_errors.NewAPIError(app_errors.ErrValidation, "Test model cannot be empty or just spaces."))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
group.TestModel = cleanedTestModel
|
||||||
}
|
}
|
||||||
if req.ParamOverrides != nil {
|
if req.ParamOverrides != nil {
|
||||||
group.ParamOverrides = req.ParamOverrides
|
group.ParamOverrides = req.ParamOverrides
|
||||||
@@ -193,7 +315,7 @@ func (s *Server) UpdateGroup(c *gin.Context) {
|
|||||||
if req.Config != nil {
|
if req.Config != nil {
|
||||||
cleanedConfig, err := validateAndCleanConfig(req.Config)
|
cleanedConfig, err := validateAndCleanConfig(req.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, app_errors.NewAPIError(app_errors.ErrValidation, "Invalid config format"))
|
response.Error(c, app_errors.NewAPIError(app_errors.ErrValidation, fmt.Sprintf("Invalid config format: %v", err)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
group.Config = cleanedConfig
|
group.Config = cleanedConfig
|
||||||
@@ -258,3 +380,67 @@ func (s *Server) DeleteGroup(c *gin.Context) {
|
|||||||
|
|
||||||
response.Success(c, gin.H{"message": "Group and associated keys deleted successfully"})
|
response.Success(c, gin.H{"message": "Group and associated keys deleted successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ConfigOption represents a single configurable option for a group.
|
||||||
|
type ConfigOption struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
DefaultValue any `json:"default_value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetGroupConfigOptions returns a list of available configuration options for groups.
|
||||||
|
func (s *Server) GetGroupConfigOptions(c *gin.Context) {
|
||||||
|
var options []ConfigOption
|
||||||
|
|
||||||
|
// 1. Get all system setting definitions from the struct tags
|
||||||
|
defaultSettings := config.DefaultSystemSettings()
|
||||||
|
settingDefinitions := config.GenerateSettingsMetadata(&defaultSettings)
|
||||||
|
defMap := make(map[string]models.SystemSettingInfo)
|
||||||
|
for _, def := range settingDefinitions {
|
||||||
|
defMap[def.Key] = def
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Get current system setting values
|
||||||
|
currentSettings := s.SettingsManager.GetSettings()
|
||||||
|
currentSettingsValue := reflect.ValueOf(currentSettings)
|
||||||
|
currentSettingsType := currentSettingsValue.Type()
|
||||||
|
jsonToFieldMap := make(map[string]string)
|
||||||
|
for i := 0; i < currentSettingsType.NumField(); i++ {
|
||||||
|
field := currentSettingsType.Field(i)
|
||||||
|
jsonTag := strings.Split(field.Tag.Get("json"), ",")[0]
|
||||||
|
if jsonTag != "" {
|
||||||
|
jsonToFieldMap[jsonTag] = field.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Iterate over GroupConfig fields to maintain order and build the response
|
||||||
|
groupConfigType := reflect.TypeOf(models.GroupConfig{})
|
||||||
|
|
||||||
|
for i := 0; i < groupConfigType.NumField(); i++ {
|
||||||
|
field := groupConfigType.Field(i)
|
||||||
|
jsonTag := field.Tag.Get("json")
|
||||||
|
key := strings.Split(jsonTag, ",")[0]
|
||||||
|
|
||||||
|
if key == "" || key == "-" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if definition, ok := defMap[key]; ok {
|
||||||
|
var defaultValue any
|
||||||
|
if fieldName, ok := jsonToFieldMap[key]; ok {
|
||||||
|
defaultValue = currentSettingsValue.FieldByName(fieldName).Interface()
|
||||||
|
}
|
||||||
|
|
||||||
|
option := ConfigOption{
|
||||||
|
Key: key,
|
||||||
|
Name: definition.Name,
|
||||||
|
Description: definition.Description,
|
||||||
|
DefaultValue: defaultValue,
|
||||||
|
}
|
||||||
|
options = append(options, option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, options)
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gpt-load/internal/config"
|
||||||
"gpt-load/internal/models"
|
"gpt-load/internal/models"
|
||||||
"gpt-load/internal/services"
|
"gpt-load/internal/services"
|
||||||
"gpt-load/internal/types"
|
"gpt-load/internal/types"
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
type Server struct {
|
type Server struct {
|
||||||
DB *gorm.DB
|
DB *gorm.DB
|
||||||
config types.ConfigManager
|
config types.ConfigManager
|
||||||
|
SettingsManager *config.SystemSettingsManager
|
||||||
KeyValidatorService *services.KeyValidatorService
|
KeyValidatorService *services.KeyValidatorService
|
||||||
KeyManualValidationService *services.KeyManualValidationService
|
KeyManualValidationService *services.KeyManualValidationService
|
||||||
TaskService *services.TaskService
|
TaskService *services.TaskService
|
||||||
@@ -27,6 +29,7 @@ type Server struct {
|
|||||||
func NewServer(
|
func NewServer(
|
||||||
db *gorm.DB,
|
db *gorm.DB,
|
||||||
config types.ConfigManager,
|
config types.ConfigManager,
|
||||||
|
settingsManager *config.SystemSettingsManager,
|
||||||
keyValidatorService *services.KeyValidatorService,
|
keyValidatorService *services.KeyValidatorService,
|
||||||
keyManualValidationService *services.KeyManualValidationService,
|
keyManualValidationService *services.KeyManualValidationService,
|
||||||
taskService *services.TaskService,
|
taskService *services.TaskService,
|
||||||
@@ -35,6 +38,7 @@ func NewServer(
|
|||||||
return &Server{
|
return &Server{
|
||||||
DB: db,
|
DB: db,
|
||||||
config: config,
|
config: config,
|
||||||
|
SettingsManager: settingsManager,
|
||||||
KeyValidatorService: keyValidatorService,
|
KeyValidatorService: keyValidatorService,
|
||||||
KeyManualValidationService: keyManualValidationService,
|
KeyManualValidationService: keyManualValidationService,
|
||||||
TaskService: taskService,
|
TaskService: taskService,
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ func registerProtectedAPIRoutes(api *gin.RouterGroup, serverHandler *handler.Ser
|
|||||||
{
|
{
|
||||||
groups.POST("", serverHandler.CreateGroup)
|
groups.POST("", serverHandler.CreateGroup)
|
||||||
groups.GET("", serverHandler.ListGroups)
|
groups.GET("", serverHandler.ListGroups)
|
||||||
|
groups.GET("/config-options", serverHandler.GetGroupConfigOptions)
|
||||||
groups.PUT("/:id", serverHandler.UpdateGroup)
|
groups.PUT("/:id", serverHandler.UpdateGroup)
|
||||||
groups.DELETE("/:id", serverHandler.DeleteGroup)
|
groups.DELETE("/:id", serverHandler.DeleteGroup)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user