Files
gpt-load/internal/handler/group_handler.go
T
tbphp 7c30bbfd7b feat: Aggregate groups (#280)
* feat: Create Aggregate Group

* feat: Subgroup List

* refactor: Optimize code

* refactor: Frontend Optimization

* feat: ValidationEndpoint

* feat: Subgroup modification verification

* fix: Aggregate Grouping Editing Issue

* feat: Show Aggregate References

* feat: Filter duplicate prompts

* feat: Subgroup manager

* feat: Log aggregate

* feat: Group statistics

* fix: up url

* fix: Aggregate group config

* feat: Log add aggregate group

* fix: Cron filter group
2025-09-30 22:12:10 +08:00

468 lines
15 KiB
Go

// Package handler provides HTTP handlers for the application
package handler
import (
"encoding/json"
"net/url"
"strconv"
"strings"
"time"
app_errors "gpt-load/internal/errors"
"gpt-load/internal/i18n"
"gpt-load/internal/models"
"gpt-load/internal/response"
"gpt-load/internal/services"
"github.com/gin-gonic/gin"
"github.com/sirupsen/logrus"
"gorm.io/datatypes"
)
func (s *Server) handleGroupError(c *gin.Context, err error) bool {
if err == nil {
return false
}
if svcErr, ok := err.(*services.I18nError); ok {
if svcErr.Template != nil {
response.ErrorI18nFromAPIError(c, svcErr.APIError, svcErr.MessageID, svcErr.Template)
} else {
response.ErrorI18nFromAPIError(c, svcErr.APIError, svcErr.MessageID)
}
return true
}
if apiErr, ok := err.(*app_errors.APIError); ok {
response.Error(c, apiErr)
return true
}
logrus.WithContext(c.Request.Context()).WithError(err).Error("unexpected group service error")
response.Error(c, app_errors.ErrInternalServer)
return true
}
// GroupCreateRequest defines the payload for creating a group.
type GroupCreateRequest struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
GroupType string `json:"group_type"` // 'standard' or 'aggregate'
Upstreams json.RawMessage `json:"upstreams"`
ChannelType string `json:"channel_type"`
Sort int `json:"sort"`
TestModel string `json:"test_model"`
ValidationEndpoint string `json:"validation_endpoint"`
ParamOverrides map[string]any `json:"param_overrides"`
Config map[string]any `json:"config"`
HeaderRules []models.HeaderRule `json:"header_rules"`
ProxyKeys string `json:"proxy_keys"`
}
// CreateGroup handles the creation of a new group.
func (s *Server) CreateGroup(c *gin.Context) {
var req GroupCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, app_errors.NewAPIError(app_errors.ErrInvalidJSON, err.Error()))
return
}
params := services.GroupCreateParams{
Name: req.Name,
DisplayName: req.DisplayName,
Description: req.Description,
GroupType: req.GroupType,
Upstreams: req.Upstreams,
ChannelType: req.ChannelType,
Sort: req.Sort,
TestModel: req.TestModel,
ValidationEndpoint: req.ValidationEndpoint,
ParamOverrides: req.ParamOverrides,
Config: req.Config,
HeaderRules: req.HeaderRules,
ProxyKeys: req.ProxyKeys,
}
group, err := s.GroupService.CreateGroup(c.Request.Context(), params)
if s.handleGroupError(c, err) {
return
}
response.Success(c, s.newGroupResponse(group))
}
// ListGroups handles listing all groups.
func (s *Server) ListGroups(c *gin.Context) {
groups, err := s.GroupService.ListGroups(c.Request.Context())
if s.handleGroupError(c, err) {
return
}
groupResponses := make([]GroupResponse, 0, len(groups))
for i := range groups {
groupResponses = append(groupResponses, *s.newGroupResponse(&groups[i]))
}
response.Success(c, groupResponses)
}
// GroupUpdateRequest defines the payload for updating a group.
// Using a dedicated struct avoids issues with zero values being ignored by GORM's Update.
type GroupUpdateRequest struct {
Name *string `json:"name,omitempty"`
DisplayName *string `json:"display_name,omitempty"`
Description *string `json:"description,omitempty"`
GroupType *string `json:"group_type,omitempty"`
Upstreams json.RawMessage `json:"upstreams"`
ChannelType *string `json:"channel_type,omitempty"`
Sort *int `json:"sort"`
TestModel string `json:"test_model"`
ValidationEndpoint *string `json:"validation_endpoint,omitempty"`
ParamOverrides map[string]any `json:"param_overrides"`
Config map[string]any `json:"config"`
HeaderRules []models.HeaderRule `json:"header_rules"`
ProxyKeys *string `json:"proxy_keys,omitempty"`
}
// UpdateGroup handles updating an existing group.
func (s *Server) UpdateGroup(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
response.ErrorI18nFromAPIError(c, app_errors.ErrBadRequest, "validation.invalid_group_id")
return
}
var req GroupUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, app_errors.NewAPIError(app_errors.ErrInvalidJSON, err.Error()))
return
}
params := services.GroupUpdateParams{
Name: req.Name,
DisplayName: req.DisplayName,
Description: req.Description,
GroupType: req.GroupType,
ChannelType: req.ChannelType,
Sort: req.Sort,
ValidationEndpoint: req.ValidationEndpoint,
ParamOverrides: req.ParamOverrides,
Config: req.Config,
ProxyKeys: req.ProxyKeys,
}
if req.Upstreams != nil {
params.Upstreams = req.Upstreams
params.HasUpstreams = true
}
if req.TestModel != "" {
params.TestModel = req.TestModel
params.HasTestModel = true
}
if req.HeaderRules != nil {
rules := req.HeaderRules
params.HeaderRules = &rules
}
group, err := s.GroupService.UpdateGroup(c.Request.Context(), uint(id), params)
if s.handleGroupError(c, err) {
return
}
response.Success(c, s.newGroupResponse(group))
}
// GroupResponse defines the structure for a group response, excluding sensitive or large fields.
type GroupResponse struct {
ID uint `json:"id"`
Name string `json:"name"`
Endpoint string `json:"endpoint"`
DisplayName string `json:"display_name"`
Description string `json:"description"`
GroupType string `json:"group_type"`
Upstreams datatypes.JSON `json:"upstreams"`
ChannelType string `json:"channel_type"`
Sort int `json:"sort"`
TestModel string `json:"test_model"`
ValidationEndpoint string `json:"validation_endpoint"`
ParamOverrides datatypes.JSONMap `json:"param_overrides"`
Config datatypes.JSONMap `json:"config"`
HeaderRules []models.HeaderRule `json:"header_rules"`
ProxyKeys string `json:"proxy_keys"`
LastValidatedAt *time.Time `json:"last_validated_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// newGroupResponse creates a new GroupResponse from a models.Group.
func (s *Server) newGroupResponse(group *models.Group) *GroupResponse {
appURL := s.SettingsManager.GetAppUrl()
endpoint := ""
if appURL != "" {
u, err := url.Parse(appURL)
if err == nil {
u.Path = strings.TrimRight(u.Path, "/") + "/proxy/" + group.Name
endpoint = u.String()
}
}
// Parse header rules from JSON
var headerRules []models.HeaderRule
if len(group.HeaderRules) > 0 {
if err := json.Unmarshal(group.HeaderRules, &headerRules); err != nil {
logrus.WithError(err).Error("Failed to unmarshal header rules")
headerRules = make([]models.HeaderRule, 0)
}
}
return &GroupResponse{
ID: group.ID,
Name: group.Name,
Endpoint: endpoint,
DisplayName: group.DisplayName,
Description: group.Description,
GroupType: group.GroupType,
Upstreams: group.Upstreams,
ChannelType: group.ChannelType,
Sort: group.Sort,
TestModel: group.TestModel,
ValidationEndpoint: group.ValidationEndpoint,
ParamOverrides: group.ParamOverrides,
Config: group.Config,
HeaderRules: headerRules,
ProxyKeys: group.ProxyKeys,
LastValidatedAt: group.LastValidatedAt,
CreatedAt: group.CreatedAt,
UpdatedAt: group.UpdatedAt,
}
}
// DeleteGroup handles deleting a group.
func (s *Server) DeleteGroup(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
response.ErrorI18nFromAPIError(c, app_errors.ErrBadRequest, "validation.invalid_group_id")
return
}
if s.handleGroupError(c, s.GroupService.DeleteGroup(c.Request.Context(), uint(id))) {
return
}
response.SuccessI18n(c, "success.group_deleted", nil)
}
// 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) {
options, err := s.GroupService.GetGroupConfigOptions()
if s.handleGroupError(c, err) {
return
}
translated := make([]ConfigOption, 0, len(options))
for _, option := range options {
name := option.Name
if strings.HasPrefix(name, "config.") {
name = i18n.Message(c, name)
}
description := option.Description
if strings.HasPrefix(description, "config.") {
description = i18n.Message(c, description)
}
translated = append(translated, ConfigOption{
Key: option.Key,
Name: name,
Description: description,
DefaultValue: option.DefaultValue,
})
}
response.Success(c, translated)
}
// calculateRequestStats is a helper to compute request statistics.
func (s *Server) GetGroupStats(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
response.ErrorI18nFromAPIError(c, app_errors.ErrBadRequest, "validation.invalid_group_id")
return
}
stats, err := s.GroupService.GetGroupStats(c.Request.Context(), uint(id))
if s.handleGroupError(c, err) {
return
}
response.Success(c, stats)
}
// GroupCopyRequest defines the payload for copying a group.
type GroupCopyRequest struct {
CopyKeys string `json:"copy_keys"` // "none"|"valid_only"|"all"
}
// GroupCopyResponse defines the response for group copy operation.
type GroupCopyResponse struct {
Group *GroupResponse `json:"group"`
}
// CopyGroup handles copying a group with optional content.
func (s *Server) CopyGroup(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
response.ErrorI18nFromAPIError(c, app_errors.ErrBadRequest, "validation.invalid_group_id")
return
}
var req GroupCopyRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, app_errors.NewAPIError(app_errors.ErrInvalidJSON, err.Error()))
return
}
newGroup, err := s.GroupService.CopyGroup(c.Request.Context(), uint(id), req.CopyKeys)
if s.handleGroupError(c, err) {
return
}
groupResponse := s.newGroupResponse(newGroup)
copyResponse := &GroupCopyResponse{
Group: groupResponse,
}
response.Success(c, copyResponse)
}
// List godoc
func (s *Server) List(c *gin.Context) {
var groups []models.Group
if err := s.DB.Select("id, name,display_name").Find(&groups).Error; err != nil {
response.ErrorI18nFromAPIError(c, app_errors.ErrDatabase, "database.cannot_get_groups")
return
}
response.Success(c, groups)
}
// AddSubGroupsRequest defines the payload for adding sub groups to an aggregate group
type AddSubGroupsRequest struct {
SubGroups []services.SubGroupInput `json:"sub_groups"`
}
// UpdateSubGroupWeightRequest defines the payload for updating a sub group weight
type UpdateSubGroupWeightRequest struct {
Weight int `json:"weight"`
}
// GetSubGroups handles getting sub groups of an aggregate group
func (s *Server) GetSubGroups(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
response.ErrorI18nFromAPIError(c, app_errors.ErrBadRequest, "validation.invalid_group_id")
return
}
subGroups, err := s.AggregateGroupService.GetSubGroups(c.Request.Context(), uint(id))
if s.handleGroupError(c, err) {
return
}
response.Success(c, subGroups)
}
// AddSubGroups handles adding sub groups to an aggregate group
func (s *Server) AddSubGroups(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
response.ErrorI18nFromAPIError(c, app_errors.ErrBadRequest, "validation.invalid_group_id")
return
}
var req AddSubGroupsRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, app_errors.NewAPIError(app_errors.ErrInvalidJSON, err.Error()))
return
}
if err := s.AggregateGroupService.AddSubGroups(c.Request.Context(), uint(id), req.SubGroups); s.handleGroupError(c, err) {
return
}
response.SuccessI18n(c, "success.sub_groups_added", nil)
}
// UpdateSubGroupWeight handles updating the weight of a sub group
func (s *Server) UpdateSubGroupWeight(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
response.ErrorI18nFromAPIError(c, app_errors.ErrBadRequest, "validation.invalid_group_id")
return
}
subGroupID, err := strconv.Atoi(c.Param("subGroupId"))
if err != nil {
response.ErrorI18nFromAPIError(c, app_errors.ErrBadRequest, "validation.invalid_sub_group_id")
return
}
var req UpdateSubGroupWeightRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, app_errors.NewAPIError(app_errors.ErrInvalidJSON, err.Error()))
return
}
if err := s.AggregateGroupService.UpdateSubGroupWeight(c.Request.Context(), uint(id), uint(subGroupID), req.Weight); s.handleGroupError(c, err) {
return
}
response.SuccessI18n(c, "success.sub_group_weight_updated", nil)
}
// DeleteSubGroup handles deleting a sub group from an aggregate group
func (s *Server) DeleteSubGroup(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
response.ErrorI18nFromAPIError(c, app_errors.ErrBadRequest, "validation.invalid_group_id")
return
}
subGroupID, err := strconv.Atoi(c.Param("subGroupId"))
if err != nil {
response.ErrorI18nFromAPIError(c, app_errors.ErrBadRequest, "validation.invalid_sub_group_id")
return
}
if err := s.AggregateGroupService.DeleteSubGroup(c.Request.Context(), uint(id), uint(subGroupID)); s.handleGroupError(c, err) {
return
}
response.SuccessI18n(c, "success.sub_group_deleted", nil)
}
// GetParentAggregateGroups handles getting parent aggregate groups that reference a group
func (s *Server) GetParentAggregateGroups(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
response.ErrorI18nFromAPIError(c, app_errors.ErrBadRequest, "validation.invalid_group_id")
return
}
parentGroups, err := s.AggregateGroupService.GetParentAggregateGroups(c.Request.Context(), uint(id))
if s.handleGroupError(c, err) {
return
}
response.Success(c, parentGroups)
}