| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170 |
- package admin
- import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "log/slog"
- "strconv"
- "strings"
- "time"
- "github.com/Wei-Shaw/sub2api/internal/config"
- "github.com/Wei-Shaw/sub2api/internal/handler/dto"
- infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
- "github.com/Wei-Shaw/sub2api/internal/pkg/response"
- "github.com/Wei-Shaw/sub2api/internal/platform/liveattestation"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
- )
- // GroupHandler handles admin group management
- type GroupHandler struct {
- adminService service.AdminService
- dashboardService *service.DashboardService
- groupCapacityService *service.GroupCapacityService
- cfg *config.Config
- }
- // GetLiveCapability 返回当前服务端是否具备生成 Live attestation 的运行环境。
- func (h *GroupHandler) GetLiveCapability(c *gin.Context) {
- if h.rejectUnsupportedSimpleModeOperation(c, "live_capability") {
- return
- }
- if !service.LiveBillingAvailable() {
- response.Success(c, gin.H{"supported": false, "reason": service.ErrLiveBillingUnavailable.Message})
- return
- }
- err := liveattestation.NewProvider().Check(c.Request.Context())
- result := gin.H{"supported": err == nil}
- if err != nil {
- result["reason"] = err.Error()
- }
- response.Success(c, result)
- }
- type optionalLimitField struct {
- set bool
- value *float64
- }
- func (f *optionalLimitField) UnmarshalJSON(data []byte) error {
- f.set = true
- trimmed := bytes.TrimSpace(data)
- if bytes.Equal(trimmed, []byte("null")) {
- f.value = nil
- return nil
- }
- var number float64
- if err := json.Unmarshal(trimmed, &number); err == nil {
- f.value = &number
- return nil
- }
- var text string
- if err := json.Unmarshal(trimmed, &text); err == nil {
- text = strings.TrimSpace(text)
- if text == "" {
- f.value = nil
- return nil
- }
- number, err = strconv.ParseFloat(text, 64)
- if err != nil {
- return fmt.Errorf("invalid numeric limit value %q: %w", text, err)
- }
- f.value = &number
- return nil
- }
- return fmt.Errorf("invalid limit value: %s", string(trimmed))
- }
- func (f optionalLimitField) ToServiceInput() *float64 {
- if !f.set {
- return nil
- }
- if f.value != nil {
- return f.value
- }
- unlimited := -1.0
- return &unlimited
- }
- // NewGroupHandler creates a new admin group handler
- func NewGroupHandler(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService) *GroupHandler {
- return NewGroupHandlerWithConfig(adminService, dashboardService, groupCapacityService, nil)
- }
- func NewGroupHandlerWithConfig(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService, cfg *config.Config) *GroupHandler {
- return &GroupHandler{
- adminService: adminService,
- dashboardService: dashboardService,
- groupCapacityService: groupCapacityService,
- cfg: cfg,
- }
- }
- func (h *GroupHandler) isSimpleMode() bool {
- return h != nil && h.cfg != nil && h.cfg.RunMode == config.RunModeSimple
- }
- type simpleModeGroupOperation string
- const (
- simpleModeGroupList simpleModeGroupOperation = "list"
- simpleModeGroupGetAll simpleModeGroupOperation = "get_all"
- simpleModeGroupGet simpleModeGroupOperation = "get"
- simpleModeGroupCreate simpleModeGroupOperation = "create"
- simpleModeGroupUpdate simpleModeGroupOperation = "update"
- simpleModeGroupDelete simpleModeGroupOperation = "delete"
- )
- var simpleModeGroupOperations = map[simpleModeGroupOperation]struct{}{
- simpleModeGroupList: {}, simpleModeGroupGetAll: {}, simpleModeGroupGet: {},
- simpleModeGroupCreate: {}, simpleModeGroupUpdate: {}, simpleModeGroupDelete: {},
- }
- func (h *GroupHandler) rejectUnsupportedSimpleModeOperation(c *gin.Context, operation simpleModeGroupOperation) bool {
- if _, allowed := simpleModeGroupOperations[operation]; allowed {
- return false
- }
- if err := service.ValidateSimpleModeGroupOperation(h.cfg, service.AdminGroupOperation(operation)); err != nil {
- response.ErrorFrom(c, err)
- return true
- }
- return false
- }
- type simpleModeGroupResponse struct {
- ID int64 `json:"id"`
- Name string `json:"name"`
- Description string `json:"description"`
- Platform string `json:"platform"`
- Status string `json:"status"`
- AccountCount int64 `json:"account_count,omitempty"`
- ActiveAccountCount int64 `json:"active_account_count,omitempty"`
- RateLimitedAccountCount int64 `json:"rate_limited_account_count,omitempty"`
- SortOrder int `json:"sort_order"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- }
- func groupForSimpleMode(group *service.Group) *simpleModeGroupResponse {
- if group == nil {
- return nil
- }
- return &simpleModeGroupResponse{
- ID: group.ID, Name: group.Name, Description: group.Description, Platform: group.Platform,
- Status: group.Status,
- AccountCount: group.AccountCount,
- ActiveAccountCount: group.ActiveAccountCount, RateLimitedAccountCount: group.RateLimitedAccountCount,
- SortOrder: group.SortOrder, CreatedAt: group.CreatedAt, UpdatedAt: group.UpdatedAt,
- }
- }
- func sanitizeCreateGroupRequestForSimpleMode(req *CreateGroupRequest) {
- if req == nil {
- return
- }
- allowed := CreateGroupRequest{Name: req.Name, Description: req.Description, Platform: req.Platform}
- allowed.RateMultiplier = 1
- allowed.SubscriptionType = service.SubscriptionTypeStandard
- *req = allowed
- }
- func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) {
- if req == nil {
- return
- }
- *req = UpdateGroupRequest{Name: req.Name, Description: req.Description}
- }
- // CreateGroupRequest represents create group request
- type CreateGroupRequest struct {
- SecurityPolicyEnabled bool `json:"security_policy_enabled"`
- SecurityPolicyMode string `json:"security_policy_mode"`
- SecurityPolicyEmailEnabled *bool `json:"security_policy_email_enabled"`
- Name string `json:"name" binding:"required"`
- Description string `json:"description"`
- Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax composite"`
- RateMultiplier float64 `json:"rate_multiplier"`
- IsExclusive bool `json:"is_exclusive"`
- SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
- DailyLimitUSD optionalLimitField `json:"daily_limit_usd"`
- WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"`
- MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"`
- LongContextPricingEnabled bool `json:"long_context_pricing_enabled"`
- ModelPricing []service.ChannelModelPricing `json:"model_pricing"`
- // 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置)
- AllowImageGeneration bool `json:"allow_image_generation"`
- AllowBatchImageGeneration bool `json:"allow_batch_image_generation"`
- ImageRateIndependent bool `json:"image_rate_independent"`
- ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
- BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"`
- BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"`
- VideoRateIndependent bool `json:"video_rate_independent"`
- VideoRateMultiplier *float64 `json:"video_rate_multiplier"`
- PeakRateEnabled bool `json:"peak_rate_enabled"`
- PeakStart string `json:"peak_start"`
- PeakEnd string `json:"peak_end"`
- PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
- ProfitControlEnabled bool `json:"profit_control_enabled"`
- ProfitMinMargin *float64 `json:"profit_min_margin"`
- ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"`
- ImagePrice1K *float64 `json:"image_price_1k"`
- ImagePrice2K *float64 `json:"image_price_2k"`
- ImagePrice4K *float64 `json:"image_price_4k"`
- VideoPrice480P *float64 `json:"video_price_480p"`
- VideoPrice720P *float64 `json:"video_price_720p"`
- VideoPrice1080P *float64 `json:"video_price_1080p"`
- VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"`
- WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
- SearchPricePer1k *float64 `json:"search_price_per_1k"`
- AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min"`
- AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars"`
- AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour"`
- ClaudeCodeOnly bool `json:"claude_code_only"`
- FallbackGroupID *int64 `json:"fallback_group_id"`
- FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"`
- // 模型路由配置(仅 anthropic 平台使用)
- ModelRouting map[string][]int64 `json:"model_routing"`
- ModelRoutingEnabled bool `json:"model_routing_enabled"`
- MCPXMLInject *bool `json:"mcp_xml_inject"`
- // 支持的模型系列(仅 antigravity 平台使用)
- SupportedModelScopes []string `json:"supported_model_scopes"`
- // OpenAI Messages 调度配置(仅 openai 平台使用)
- AllowMessagesDispatch bool `json:"allow_messages_dispatch"`
- AllowLive bool `json:"allow_live"`
- ForceOpenAIFast bool `json:"force_openai_fast"`
- FreeOpenAIFast bool `json:"free_openai_fast"`
- RequireOAuthOnly bool `json:"require_oauth_only"`
- RequirePrivacySet bool `json:"require_privacy_set"`
- DefaultMappedModel string `json:"default_mapped_model"`
- MessagesDispatchModelConfig service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
- ModelAllowlist service.GroupModelAllowlist `json:"model_allowlist"`
- // 固定账号 manifest 配置;创建路径禁止开启,仅编辑可配置。
- CodexModelsManifestConfig service.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"`
- // 分组 RPM 上限(0 = 不限制)
- RPMLimit int `json:"rpm_limit"`
- // Anthropic/OpenAI 请求推理强度上限,空字符串表示不限制。
- MaxReasoningEffort string `json:"max_reasoning_effort"`
- // 超过上限时的访问控制:downgrade(默认)或 deny。
- MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"`
- // Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。
- ReasoningEffortMappings []service.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
- // 从指定分组复制账号(创建后自动绑定)
- CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"`
- }
- // UpdateGroupRequest represents update group request
- type UpdateGroupRequest struct {
- SecurityPolicyEnabled *bool `json:"security_policy_enabled"`
- SecurityPolicyMode *string `json:"security_policy_mode"`
- SecurityPolicyEmailEnabled *bool `json:"security_policy_email_enabled"`
- Name string `json:"name"`
- Description *string `json:"description"`
- Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax composite"`
- RateMultiplier *float64 `json:"rate_multiplier"`
- IsExclusive *bool `json:"is_exclusive"`
- Status string `json:"status" binding:"omitempty,oneof=active inactive"`
- SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
- DailyLimitUSD optionalLimitField `json:"daily_limit_usd"`
- WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"`
- MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"`
- LongContextPricingEnabled *bool `json:"long_context_pricing_enabled"`
- ModelPricing *[]service.ChannelModelPricing `json:"model_pricing"`
- // 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置)
- AllowImageGeneration *bool `json:"allow_image_generation"`
- AllowBatchImageGeneration *bool `json:"allow_batch_image_generation"`
- ImageRateIndependent *bool `json:"image_rate_independent"`
- ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
- BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"`
- BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"`
- VideoRateIndependent *bool `json:"video_rate_independent"`
- VideoRateMultiplier *float64 `json:"video_rate_multiplier"`
- PeakRateEnabled *bool `json:"peak_rate_enabled"`
- PeakStart *string `json:"peak_start"`
- PeakEnd *string `json:"peak_end"`
- PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
- ProfitControlEnabled *bool `json:"profit_control_enabled"`
- ProfitMinMargin *float64 `json:"profit_min_margin"`
- ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"`
- ImagePrice1K *float64 `json:"image_price_1k"`
- ImagePrice2K *float64 `json:"image_price_2k"`
- ImagePrice4K *float64 `json:"image_price_4k"`
- VideoPrice480P *float64 `json:"video_price_480p"`
- VideoPrice720P *float64 `json:"video_price_720p"`
- VideoPrice1080P *float64 `json:"video_price_1080p"`
- VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"`
- WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
- SearchPricePer1k *float64 `json:"search_price_per_1k"`
- AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min"`
- AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars"`
- AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour"`
- ClaudeCodeOnly *bool `json:"claude_code_only"`
- FallbackGroupID *int64 `json:"fallback_group_id"`
- FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"`
- // 模型路由配置(仅 anthropic 平台使用)
- ModelRouting map[string][]int64 `json:"model_routing"`
- ModelRoutingEnabled *bool `json:"model_routing_enabled"`
- MCPXMLInject *bool `json:"mcp_xml_inject"`
- // 支持的模型系列(仅 antigravity 平台使用)
- SupportedModelScopes *[]string `json:"supported_model_scopes"`
- // OpenAI Messages 调度配置(仅 openai 平台使用)
- AllowMessagesDispatch *bool `json:"allow_messages_dispatch"`
- AllowLive *bool `json:"allow_live"`
- ForceOpenAIFast *bool `json:"force_openai_fast"`
- FreeOpenAIFast *bool `json:"free_openai_fast"`
- RequireOAuthOnly *bool `json:"require_oauth_only"`
- RequirePrivacySet *bool `json:"require_privacy_set"`
- DefaultMappedModel *string `json:"default_mapped_model"`
- MessagesDispatchModelConfig *service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
- ModelAllowlist *service.GroupModelAllowlist `json:"model_allowlist"`
- // 固定账号 manifest 配置;nil 表示不修改。
- CodexModelsManifestConfig *service.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"`
- // 分组 RPM 上限(0 = 不限制);nil 表示未提供不改动
- RPMLimit *int `json:"rpm_limit"`
- // Anthropic/OpenAI 请求推理强度上限;空字符串清除,nil 不修改。
- MaxReasoningEffort *string `json:"max_reasoning_effort"`
- // 超过上限时的访问控制;空字符串视为 downgrade,nil 不修改。
- MaxReasoningEffortOverLimit *string `json:"max_reasoning_effort_over_limit"`
- // nil 不修改,空数组清空,非空数组替换。
- ReasoningEffortMappings *[]service.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
- // 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号)
- CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"`
- }
- type CompositeRouteRequest struct {
- PublicModel string `json:"public_model" binding:"required"`
- MatchType string `json:"match_type" binding:"omitempty,oneof=exact prefix"`
- TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax"`
- UpstreamModel string `json:"upstream_model"`
- Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"`
- Priority int `json:"priority"`
- Enabled *bool `json:"enabled"`
- Notes string `json:"notes"`
- }
- type CompositeRoutePreviewRequest struct {
- Model string `json:"model" binding:"required"`
- Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"`
- }
- // List handles listing all groups with pagination
- // GET /api/v1/admin/groups
- func (h *GroupHandler) List(c *gin.Context) {
- if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupList) {
- return
- }
- page, pageSize := response.ParsePagination(c)
- platform := c.Query("platform")
- status := c.Query("status")
- search := c.Query("search")
- // 标准化和验证 search 参数
- search = strings.TrimSpace(search)
- if len(search) > 100 {
- search = search[:100]
- }
- isExclusiveStr := c.Query("is_exclusive")
- sortBy := c.DefaultQuery("sort_by", "sort_order")
- sortOrder := c.DefaultQuery("sort_order", "asc")
- var isExclusive *bool
- if !h.isSimpleMode() && isExclusiveStr != "" {
- val := isExclusiveStr == "true"
- isExclusive = &val
- }
- groups, total, err := h.adminService.ListGroups(c.Request.Context(), page, pageSize, platform, status, search, isExclusive, sortBy, sortOrder)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- if h.isSimpleMode() {
- simpleGroups := make([]simpleModeGroupResponse, 0, len(groups))
- for i := range groups {
- if service.IsGroupBindableInSimpleMode(&groups[i]) {
- simpleGroups = append(simpleGroups, *groupForSimpleMode(&groups[i]))
- }
- }
- response.Paginated(c, simpleGroups, total, page, pageSize)
- return
- }
- outGroups := make([]dto.AdminGroup, 0, len(groups))
- for i := range groups {
- outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i]))
- }
- response.Paginated(c, outGroups, total, page, pageSize)
- }
- // ListCompositeRoutes handles listing composite model routes for one group.
- // GET /api/v1/admin/groups/:id/composite-routes
- func (h *GroupHandler) ListCompositeRoutes(c *gin.Context) {
- if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
- return
- }
- groupID, ok := parsePositiveIDParam(c, "id")
- if !ok {
- return
- }
- routes, err := h.adminService.ListCompositeRoutes(c.Request.Context(), groupID)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, routes)
- }
- // CreateCompositeRoute handles creating one composite model route.
- // POST /api/v1/admin/groups/:id/composite-routes
- func (h *GroupHandler) CreateCompositeRoute(c *gin.Context) {
- if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
- return
- }
- groupID, ok := parsePositiveIDParam(c, "id")
- if !ok {
- return
- }
- var req CompositeRouteRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request body: "+err.Error())
- return
- }
- route, err := h.adminService.CreateCompositeRoute(c.Request.Context(), groupID, compositeRouteRequestToInput(req, true))
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Created(c, route)
- }
- // UpdateCompositeRoute handles replacing one composite model route.
- // PUT /api/v1/admin/groups/:id/composite-routes/:route_id
- func (h *GroupHandler) UpdateCompositeRoute(c *gin.Context) {
- if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
- return
- }
- groupID, ok := parsePositiveIDParam(c, "id")
- if !ok {
- return
- }
- routeID, ok := parsePositiveIDParam(c, "route_id")
- if !ok {
- return
- }
- var req CompositeRouteRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request body: "+err.Error())
- return
- }
- route, err := h.adminService.UpdateCompositeRoute(c.Request.Context(), groupID, routeID, compositeRouteRequestToInput(req, true))
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, route)
- }
- // DeleteCompositeRoute handles deleting one composite model route.
- // DELETE /api/v1/admin/groups/:id/composite-routes/:route_id
- func (h *GroupHandler) DeleteCompositeRoute(c *gin.Context) {
- if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
- return
- }
- groupID, ok := parsePositiveIDParam(c, "id")
- if !ok {
- return
- }
- routeID, ok := parsePositiveIDParam(c, "route_id")
- if !ok {
- return
- }
- if err := h.adminService.DeleteCompositeRoute(c.Request.Context(), groupID, routeID); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, gin.H{"message": "Composite route deleted"})
- }
- // PreviewCompositeRoute resolves a model without mutating routes.
- // POST /api/v1/admin/groups/:id/composite-routes/preview
- func (h *GroupHandler) PreviewCompositeRoute(c *gin.Context) {
- if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
- return
- }
- groupID, ok := parsePositiveIDParam(c, "id")
- if !ok {
- return
- }
- var req CompositeRoutePreviewRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request body: "+err.Error())
- return
- }
- decision, err := h.adminService.PreviewCompositeRoute(c.Request.Context(), groupID, service.CompositeRoutePreviewRequest{
- Model: req.Model,
- Endpoint: req.Endpoint,
- })
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, decision)
- }
- func compositeRouteRequestToInput(req CompositeRouteRequest, defaultEnabled bool) service.CompositeRouteInput {
- enabled := defaultEnabled
- if req.Enabled != nil {
- enabled = *req.Enabled
- }
- return service.CompositeRouteInput{
- PublicModel: req.PublicModel,
- MatchType: req.MatchType,
- TargetPlatform: req.TargetPlatform,
- UpstreamModel: req.UpstreamModel,
- Endpoint: req.Endpoint,
- Priority: req.Priority,
- Enabled: enabled,
- Notes: req.Notes,
- }
- }
- func parsePositiveIDParam(c *gin.Context, name string) (int64, bool) {
- raw := c.Param(name)
- id, err := strconv.ParseInt(raw, 10, 64)
- if err != nil || id <= 0 {
- response.BadRequest(c, "Invalid "+name)
- return 0, false
- }
- return id, true
- }
- // GetAll handles getting all active groups without pagination.
- // Pass ?include_inactive=true to also include disabled groups (used by the
- // API Key group filter, which needs to surface groups that still have API keys
- // bound to them even after the group is disabled).
- // GET /api/v1/admin/groups/all
- func (h *GroupHandler) GetAll(c *gin.Context) {
- if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupGetAll) {
- return
- }
- platform := c.Query("platform")
- includeInactive := c.Query("include_inactive") == "true"
- var groups []service.Group
- var err error
- if includeInactive {
- groups, err = h.adminService.GetAllGroupsIncludingInactive(c.Request.Context())
- } else if platform != "" {
- groups, err = h.adminService.GetAllGroupsByPlatform(c.Request.Context(), platform)
- } else {
- groups, err = h.adminService.GetAllGroups(c.Request.Context())
- }
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- if h.isSimpleMode() {
- simpleGroups := make([]simpleModeGroupResponse, 0, len(groups))
- for i := range groups {
- if service.IsGroupBindableInSimpleMode(&groups[i]) {
- simpleGroups = append(simpleGroups, *groupForSimpleMode(&groups[i]))
- }
- }
- response.Success(c, simpleGroups)
- return
- }
- outGroups := make([]dto.AdminGroup, 0, len(groups))
- for i := range groups {
- outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i]))
- }
- response.Success(c, outGroups)
- }
- // GetByID handles getting a group by ID
- // GET /api/v1/admin/groups/:id
- func (h *GroupHandler) GetByID(c *gin.Context) {
- if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupGet) {
- return
- }
- groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid group ID")
- return
- }
- group, err := h.adminService.GetGroup(c.Request.Context(), groupID)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- if h.isSimpleMode() {
- response.Success(c, groupForSimpleMode(group))
- return
- }
- response.Success(c, dto.GroupFromServiceAdmin(group))
- }
- // GetGroupModelAllowlistCandidates handles getting candidate model IDs for the group model allowlist.
- // GET /api/v1/admin/groups/:id/model-allowlist-candidates
- func (h *GroupHandler) GetGroupModelAllowlistCandidates(c *gin.Context) {
- if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
- return
- }
- groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil || groupID < 0 {
- response.BadRequest(c, "Invalid group ID")
- return
- }
- models, err := h.adminService.GetGroupModelsListCandidates(
- c.Request.Context(),
- groupID,
- c.Query("platform"),
- )
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, gin.H{"models": models})
- }
- // Create handles creating a new group
- // POST /api/v1/admin/groups
- func (h *GroupHandler) Create(c *gin.Context) {
- if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupCreate) {
- return
- }
- var req CreateGroupRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- if h.isSimpleMode() && req.Platform == service.PlatformComposite {
- response.BadRequest(c, "Platform is not supported in simple mode")
- return
- }
- if h.isSimpleMode() {
- sanitizeCreateGroupRequestForSimpleMode(&req)
- }
- if err := service.ValidatePeakRateConfig(req.SubscriptionType, req.PeakRateEnabled, req.PeakStart, req.PeakEnd, float64ValueOrDefault(req.PeakRateMultiplier, 1.0)); err != nil {
- response.BadRequest(c, err.Error())
- return
- }
- // platform 是 omitempty:预校验必须用与 CreateGroup 落库一致的归一化平台,
- // 否则省略 platform 的请求会被误判成「平台不支持利润控制」。
- if err := service.ValidateProfitControlConfig(service.NormalizeGroupPlatform(req.Platform), req.ProfitControlEnabled, float64ValueOrDefault(req.ProfitMinMargin, 0), float64ValueOrDefault(req.ProfitSafetyBuffer, 0)); err != nil {
- response.BadRequest(c, err.Error())
- return
- }
- group, err := h.adminService.CreateGroup(c.Request.Context(), &service.CreateGroupInput{
- Name: req.Name,
- Description: req.Description,
- Platform: req.Platform,
- RateMultiplier: req.RateMultiplier,
- IsExclusive: req.IsExclusive,
- SecurityPolicyEnabled: req.SecurityPolicyEnabled,
- SecurityPolicyMode: req.SecurityPolicyMode,
- SecurityPolicyEmailEnabled: req.SecurityPolicyEmailEnabled,
- SubscriptionType: req.SubscriptionType,
- DailyLimitUSD: req.DailyLimitUSD.ToServiceInput(),
- WeeklyLimitUSD: req.WeeklyLimitUSD.ToServiceInput(),
- MonthlyLimitUSD: req.MonthlyLimitUSD.ToServiceInput(),
- LongContextPricingEnabled: req.LongContextPricingEnabled,
- ModelPricing: req.ModelPricing,
- AllowImageGeneration: req.AllowImageGeneration,
- AllowBatchImageGeneration: req.AllowBatchImageGeneration,
- ImageRateIndependent: req.ImageRateIndependent,
- ImageRateMultiplier: req.ImageRateMultiplier,
- BatchImageDiscountMultiplier: req.BatchImageDiscountMultiplier,
- BatchImageHoldMultiplier: req.BatchImageHoldMultiplier,
- VideoRateIndependent: req.VideoRateIndependent,
- VideoRateMultiplier: req.VideoRateMultiplier,
- PeakRateEnabled: req.PeakRateEnabled,
- PeakStart: req.PeakStart,
- PeakEnd: req.PeakEnd,
- PeakRateMultiplier: req.PeakRateMultiplier,
- ProfitControlEnabled: req.ProfitControlEnabled,
- ProfitMinMargin: req.ProfitMinMargin,
- ProfitSafetyBuffer: req.ProfitSafetyBuffer,
- ImagePrice1K: req.ImagePrice1K,
- ImagePrice2K: req.ImagePrice2K,
- ImagePrice4K: req.ImagePrice4K,
- VideoPrice480P: req.VideoPrice480P,
- VideoPrice720P: req.VideoPrice720P,
- VideoPrice1080P: req.VideoPrice1080P,
- VideoModelPrices: req.VideoModelPrices,
- WebSearchPricePerCall: req.WebSearchPricePerCall,
- SearchPricePer1k: req.SearchPricePer1k,
- AudioRealtimePricePerMin: req.AudioRealtimePricePerMin,
- AudioTTSPricePerMillionChars: req.AudioTtsPricePerMillionChars,
- AudioSTTPricePerHour: req.AudioSttPricePerHour,
- ClaudeCodeOnly: req.ClaudeCodeOnly,
- FallbackGroupID: req.FallbackGroupID,
- FallbackGroupIDOnInvalidRequest: req.FallbackGroupIDOnInvalidRequest,
- ModelRouting: req.ModelRouting,
- ModelRoutingEnabled: req.ModelRoutingEnabled,
- MCPXMLInject: req.MCPXMLInject,
- SupportedModelScopes: req.SupportedModelScopes,
- AllowMessagesDispatch: req.AllowMessagesDispatch,
- AllowLive: req.AllowLive,
- ForceOpenAIFast: req.ForceOpenAIFast,
- FreeOpenAIFast: req.FreeOpenAIFast,
- RequireOAuthOnly: req.RequireOAuthOnly,
- RequirePrivacySet: req.RequirePrivacySet,
- DefaultMappedModel: req.DefaultMappedModel,
- MessagesDispatchModelConfig: req.MessagesDispatchModelConfig,
- ModelAllowlist: req.ModelAllowlist,
- CodexModelsManifestConfig: req.CodexModelsManifestConfig,
- RPMLimit: req.RPMLimit,
- MaxReasoningEffort: req.MaxReasoningEffort,
- MaxReasoningEffortOverLimit: req.MaxReasoningEffortOverLimit,
- ReasoningEffortMappings: req.ReasoningEffortMappings,
- CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs,
- })
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- if h.isSimpleMode() {
- response.Success(c, groupForSimpleMode(group))
- return
- }
- response.Success(c, dto.GroupFromServiceAdmin(group))
- }
- // Duplicate handles creating an inactive group copy with the source account bindings.
- // POST /api/v1/admin/groups/:id/duplicate
- func (h *GroupHandler) Duplicate(c *gin.Context) {
- if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
- return
- }
- groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil || groupID <= 0 {
- response.BadRequest(c, "Invalid group ID")
- return
- }
- actorScope := adminActorScope(c)
- result, err := executeAdminIdempotent(
- c,
- "admin.groups.duplicate",
- struct {
- GroupID int64 `json:"group_id"`
- }{GroupID: groupID},
- service.DefaultWriteIdempotencyTTL(),
- func(ctx context.Context) (any, error) {
- group, execErr := h.adminService.DuplicateGroup(ctx, groupID, actorScope, c.GetHeader("Idempotency-Key"))
- if execErr != nil {
- return nil, execErr
- }
- return dto.GroupFromServiceAdmin(group), nil
- },
- )
- if err != nil {
- reason := infraerrors.Reason(err)
- if reason == infraerrors.Reason(service.ErrIdempotencyInProgress) || reason == infraerrors.Reason(service.ErrIdempotencyStoreUnavail) {
- recovered, recoverErr := h.adminService.RecoverDuplicateGroup(c.Request.Context(), groupID, actorScope, c.GetHeader("Idempotency-Key"))
- if recoverErr != nil {
- slog.Warn("group_duplicate_recovery_failed", "group_id", groupID, "actor_scope", actorScope, "reason", reason, "error", recoverErr)
- } else if recovered != nil {
- c.Header("X-Idempotency-Recovered", "true")
- response.Success(c, dto.GroupFromServiceAdmin(recovered))
- return
- }
- }
- response.ErrorFrom(c, err)
- return
- }
- if result != nil && result.Replayed {
- c.Header("X-Idempotency-Replayed", "true")
- }
- response.Success(c, result.Data)
- }
- // Update handles updating a group
- // PUT /api/v1/admin/groups/:id
- func (h *GroupHandler) Update(c *gin.Context) {
- if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupUpdate) {
- return
- }
- groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid group ID")
- return
- }
- var req UpdateGroupRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- if h.isSimpleMode() {
- sanitizeUpdateGroupRequestForSimpleMode(&req)
- }
- group, err := h.adminService.UpdateGroup(c.Request.Context(), groupID, &service.UpdateGroupInput{
- Name: req.Name,
- Description: req.Description,
- Platform: req.Platform,
- RateMultiplier: req.RateMultiplier,
- IsExclusive: req.IsExclusive,
- SecurityPolicyEnabled: req.SecurityPolicyEnabled,
- SecurityPolicyMode: req.SecurityPolicyMode,
- SecurityPolicyEmailEnabled: req.SecurityPolicyEmailEnabled,
- Status: req.Status,
- SubscriptionType: req.SubscriptionType,
- DailyLimitUSD: req.DailyLimitUSD.ToServiceInput(),
- WeeklyLimitUSD: req.WeeklyLimitUSD.ToServiceInput(),
- MonthlyLimitUSD: req.MonthlyLimitUSD.ToServiceInput(),
- LongContextPricingEnabled: req.LongContextPricingEnabled,
- ModelPricing: req.ModelPricing,
- AllowImageGeneration: req.AllowImageGeneration,
- AllowBatchImageGeneration: req.AllowBatchImageGeneration,
- ImageRateIndependent: req.ImageRateIndependent,
- ImageRateMultiplier: req.ImageRateMultiplier,
- BatchImageDiscountMultiplier: req.BatchImageDiscountMultiplier,
- BatchImageHoldMultiplier: req.BatchImageHoldMultiplier,
- VideoRateIndependent: req.VideoRateIndependent,
- VideoRateMultiplier: req.VideoRateMultiplier,
- PeakRateEnabled: req.PeakRateEnabled,
- PeakStart: req.PeakStart,
- PeakEnd: req.PeakEnd,
- PeakRateMultiplier: req.PeakRateMultiplier,
- ProfitControlEnabled: req.ProfitControlEnabled,
- ProfitMinMargin: req.ProfitMinMargin,
- ProfitSafetyBuffer: req.ProfitSafetyBuffer,
- ImagePrice1K: req.ImagePrice1K,
- ImagePrice2K: req.ImagePrice2K,
- ImagePrice4K: req.ImagePrice4K,
- VideoPrice480P: req.VideoPrice480P,
- VideoPrice720P: req.VideoPrice720P,
- VideoPrice1080P: req.VideoPrice1080P,
- VideoModelPrices: req.VideoModelPrices,
- WebSearchPricePerCall: req.WebSearchPricePerCall,
- SearchPricePer1k: req.SearchPricePer1k,
- AudioRealtimePricePerMin: req.AudioRealtimePricePerMin,
- AudioTTSPricePerMillionChars: req.AudioTtsPricePerMillionChars,
- AudioSTTPricePerHour: req.AudioSttPricePerHour,
- ClaudeCodeOnly: req.ClaudeCodeOnly,
- FallbackGroupID: req.FallbackGroupID,
- FallbackGroupIDOnInvalidRequest: req.FallbackGroupIDOnInvalidRequest,
- ModelRouting: req.ModelRouting,
- ModelRoutingEnabled: req.ModelRoutingEnabled,
- MCPXMLInject: req.MCPXMLInject,
- SupportedModelScopes: req.SupportedModelScopes,
- AllowMessagesDispatch: req.AllowMessagesDispatch,
- AllowLive: req.AllowLive,
- ForceOpenAIFast: req.ForceOpenAIFast,
- FreeOpenAIFast: req.FreeOpenAIFast,
- RequireOAuthOnly: req.RequireOAuthOnly,
- RequirePrivacySet: req.RequirePrivacySet,
- DefaultMappedModel: req.DefaultMappedModel,
- MessagesDispatchModelConfig: req.MessagesDispatchModelConfig,
- ModelAllowlist: req.ModelAllowlist,
- CodexModelsManifestConfig: req.CodexModelsManifestConfig,
- RPMLimit: req.RPMLimit,
- MaxReasoningEffort: req.MaxReasoningEffort,
- MaxReasoningEffortOverLimit: req.MaxReasoningEffortOverLimit,
- ReasoningEffortMappings: req.ReasoningEffortMappings,
- CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs,
- })
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- if h.isSimpleMode() {
- response.Success(c, groupForSimpleMode(group))
- return
- }
- response.Success(c, dto.GroupFromServiceAdmin(group))
- }
- // Delete handles deleting a group
- // DELETE /api/v1/admin/groups/:id
- func (h *GroupHandler) Delete(c *gin.Context) {
- if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupDelete) {
- return
- }
- groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid group ID")
- return
- }
- if h.isSimpleMode() {
- err = h.adminService.DeleteGroupIfEmpty(c.Request.Context(), groupID)
- } else {
- err = h.adminService.DeleteGroup(c.Request.Context(), groupID)
- }
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, gin.H{"message": "Group deleted successfully"})
- }
- // GetStats handles getting group statistics
- // GET /api/v1/admin/groups/:id/stats
- func (h *GroupHandler) GetStats(c *gin.Context) {
- c.Header("Cache-Control", "no-store")
- groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil || groupID < 1 {
- response.BadRequest(c, "Invalid group ID")
- return
- }
- var from, to *time.Time
- for key, target := range map[string]**time.Time{"from": &from, "to": &to} {
- if raw := strings.TrimSpace(c.Query(key)); raw != "" {
- value, parseErr := time.Parse(time.RFC3339, raw)
- if parseErr != nil {
- response.BadRequest(c, "时间必须使用 ISO 8601 格式")
- return
- }
- *target = &value
- }
- }
- stats, err := h.dashboardService.GetGroupDetailStats(c.Request.Context(), groupID, from, to)
- if !response.ErrorFrom(c, err) {
- response.Success(c, stats)
- }
- }
- // GetUsageSummary returns today's, yesterday's, and cumulative cost for all groups.
- // GET /api/v1/admin/groups/usage-summary
- func (h *GroupHandler) GetUsageSummary(c *gin.Context) {
- if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
- return
- }
- todayStart := service.GroupUsageTodayStart(time.Now())
- results, err := h.dashboardService.GetGroupUsageSummary(c.Request.Context(), todayStart)
- if err != nil {
- response.Error(c, 500, "Failed to get group usage summary")
- return
- }
- response.Success(c, results)
- }
- // GetCapacitySummary returns aggregated capacity (concurrency/sessions/RPM) for all active groups.
- // GET /api/v1/admin/groups/capacity-summary
- func (h *GroupHandler) GetCapacitySummary(c *gin.Context) {
- if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
- return
- }
- results, err := h.groupCapacityService.GetAllGroupCapacity(c.Request.Context())
- if err != nil {
- response.Error(c, 500, "Failed to get group capacity summary")
- return
- }
- response.Success(c, results)
- }
- // GetGroupAPIKeys handles getting API keys in a group
- // GET /api/v1/admin/groups/:id/api-keys
- func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) {
- if h.rejectUnsupportedSimpleModeOperation(c, "api_keys") {
- return
- }
- groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid group ID")
- return
- }
- page, pageSize := response.ParsePagination(c)
- keys, total, err := h.adminService.GetGroupAPIKeys(c.Request.Context(), groupID, page, pageSize)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- outKeys := make([]dto.APIKey, 0, len(keys))
- for i := range keys {
- outKeys = append(outKeys, *dto.APIKeyFromService(&keys[i]))
- }
- response.Paginated(c, outKeys, total, page, pageSize)
- }
- // GetGroupRateMultipliers handles getting rate multipliers for users in a group
- // GET /api/v1/admin/groups/:id/rate-multipliers
- func (h *GroupHandler) GetGroupRateMultipliers(c *gin.Context) {
- if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
- return
- }
- groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid group ID")
- return
- }
- entries, err := h.adminService.GetGroupRateMultipliers(c.Request.Context(), groupID)
- if err != nil {
- response.ErrorFrom(c, err)
- return
- }
- if entries == nil {
- entries = []service.UserGroupRateEntry{}
- }
- response.Success(c, entries)
- }
- // ClearGroupRateMultipliers handles clearing all rate multipliers for a group
- // DELETE /api/v1/admin/groups/:id/rate-multipliers
- func (h *GroupHandler) ClearGroupRateMultipliers(c *gin.Context) {
- if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
- return
- }
- groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid group ID")
- return
- }
- if err := h.adminService.ClearGroupRateMultipliers(c.Request.Context(), groupID); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, gin.H{"message": "Rate multipliers cleared successfully"})
- }
- // BatchSetGroupRateMultipliersRequest represents batch set rate multipliers request
- type BatchSetGroupRateMultipliersRequest struct {
- Entries []service.GroupRateMultiplierInput `json:"entries" binding:"required"`
- }
- // BatchSetGroupRateMultipliers handles batch setting rate multipliers for a group
- // PUT /api/v1/admin/groups/:id/rate-multipliers
- func (h *GroupHandler) BatchSetGroupRateMultipliers(c *gin.Context) {
- if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
- return
- }
- groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid group ID")
- return
- }
- var req BatchSetGroupRateMultipliersRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- if err := h.adminService.BatchSetGroupRateMultipliers(c.Request.Context(), groupID, req.Entries); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, gin.H{"message": "Rate multipliers updated successfully"})
- }
- // BatchSetGroupRPMOverridesRequest represents batch set rpm_override request
- type BatchSetGroupRPMOverridesRequest struct {
- Entries []service.GroupRPMOverrideInput `json:"entries" binding:"required"`
- }
- // BatchSetGroupRPMOverrides handles batch setting rpm_override for users in a group
- // PUT /api/v1/admin/groups/:id/rpm-overrides
- func (h *GroupHandler) BatchSetGroupRPMOverrides(c *gin.Context) {
- if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
- return
- }
- groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid group ID")
- return
- }
- var req BatchSetGroupRPMOverridesRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- if err := h.adminService.BatchSetGroupRPMOverrides(c.Request.Context(), groupID, req.Entries); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, gin.H{"message": "RPM overrides updated successfully"})
- }
- // ClearGroupRPMOverrides handles clearing all rpm_override for a group
- // DELETE /api/v1/admin/groups/:id/rpm-overrides
- func (h *GroupHandler) ClearGroupRPMOverrides(c *gin.Context) {
- if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
- return
- }
- groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
- if err != nil {
- response.BadRequest(c, "Invalid group ID")
- return
- }
- if err := h.adminService.ClearGroupRPMOverrides(c.Request.Context(), groupID); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, gin.H{"message": "RPM overrides cleared successfully"})
- }
- // UpdateSortOrderRequest represents the request to update group sort orders
- type UpdateSortOrderRequest struct {
- Updates []struct {
- ID int64 `json:"id" binding:"required"`
- SortOrder int `json:"sort_order"`
- } `json:"updates" binding:"required,min=1"`
- }
- // UpdateSortOrder handles updating group sort orders
- // PUT /api/v1/admin/groups/sort-order
- func (h *GroupHandler) UpdateSortOrder(c *gin.Context) {
- if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
- return
- }
- var req UpdateSortOrderRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- response.BadRequest(c, "Invalid request: "+err.Error())
- return
- }
- updates := make([]service.GroupSortOrderUpdate, 0, len(req.Updates))
- for _, u := range req.Updates {
- updates = append(updates, service.GroupSortOrderUpdate{
- ID: u.ID,
- SortOrder: u.SortOrder,
- })
- }
- if err := h.adminService.UpdateGroupSortOrders(c.Request.Context(), updates); err != nil {
- response.ErrorFrom(c, err)
- return
- }
- response.Success(c, gin.H{"message": "Sort order updated successfully"})
- }
|