| package admin |
|
|
| import ( |
| "bytes" |
| "encoding/json" |
| "fmt" |
| "strconv" |
| "strings" |
|
|
| "github.com/Wei-Shaw/sub2api/internal/handler/dto" |
| "github.com/Wei-Shaw/sub2api/internal/pkg/response" |
| "github.com/Wei-Shaw/sub2api/internal/pkg/timezone" |
| "github.com/Wei-Shaw/sub2api/internal/service" |
|
|
| "github.com/gin-gonic/gin" |
| ) |
|
|
| |
| type GroupHandler struct { |
| adminService service.AdminService |
| dashboardService *service.DashboardService |
| groupCapacityService *service.GroupCapacityService |
| } |
|
|
| 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 |
| } |
| zero := 0.0 |
| return &zero |
| } |
|
|
| |
| func NewGroupHandler(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService) *GroupHandler { |
| return &GroupHandler{ |
| adminService: adminService, |
| dashboardService: dashboardService, |
| groupCapacityService: groupCapacityService, |
| } |
| } |
|
|
| |
| type CreateGroupRequest struct { |
| Name string `json:"name" binding:"required"` |
| Description string `json:"description"` |
| Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity sora"` |
| 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"` |
| |
| ImagePrice1K *float64 `json:"image_price_1k"` |
| ImagePrice2K *float64 `json:"image_price_2k"` |
| ImagePrice4K *float64 `json:"image_price_4k"` |
| SoraImagePrice360 *float64 `json:"sora_image_price_360"` |
| SoraImagePrice540 *float64 `json:"sora_image_price_540"` |
| SoraVideoPricePerRequest *float64 `json:"sora_video_price_per_request"` |
| SoraVideoPricePerRequestHD *float64 `json:"sora_video_price_per_request_hd"` |
| ClaudeCodeOnly bool `json:"claude_code_only"` |
| FallbackGroupID *int64 `json:"fallback_group_id"` |
| FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"` |
| |
| ModelRouting map[string][]int64 `json:"model_routing"` |
| ModelRoutingEnabled bool `json:"model_routing_enabled"` |
| MCPXMLInject *bool `json:"mcp_xml_inject"` |
| |
| SupportedModelScopes []string `json:"supported_model_scopes"` |
| |
| SoraStorageQuotaBytes int64 `json:"sora_storage_quota_bytes"` |
| |
| AllowMessagesDispatch bool `json:"allow_messages_dispatch"` |
| DefaultMappedModel string `json:"default_mapped_model"` |
| |
| CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"` |
| } |
|
|
| |
| type UpdateGroupRequest struct { |
| Name string `json:"name"` |
| Description string `json:"description"` |
| Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity sora"` |
| 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"` |
| |
| ImagePrice1K *float64 `json:"image_price_1k"` |
| ImagePrice2K *float64 `json:"image_price_2k"` |
| ImagePrice4K *float64 `json:"image_price_4k"` |
| SoraImagePrice360 *float64 `json:"sora_image_price_360"` |
| SoraImagePrice540 *float64 `json:"sora_image_price_540"` |
| SoraVideoPricePerRequest *float64 `json:"sora_video_price_per_request"` |
| SoraVideoPricePerRequestHD *float64 `json:"sora_video_price_per_request_hd"` |
| ClaudeCodeOnly *bool `json:"claude_code_only"` |
| FallbackGroupID *int64 `json:"fallback_group_id"` |
| FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"` |
| |
| ModelRouting map[string][]int64 `json:"model_routing"` |
| ModelRoutingEnabled *bool `json:"model_routing_enabled"` |
| MCPXMLInject *bool `json:"mcp_xml_inject"` |
| |
| SupportedModelScopes *[]string `json:"supported_model_scopes"` |
| |
| SoraStorageQuotaBytes *int64 `json:"sora_storage_quota_bytes"` |
| |
| AllowMessagesDispatch *bool `json:"allow_messages_dispatch"` |
| DefaultMappedModel *string `json:"default_mapped_model"` |
| |
| CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"` |
| } |
|
|
| |
| |
| func (h *GroupHandler) List(c *gin.Context) { |
| page, pageSize := response.ParsePagination(c) |
| platform := c.Query("platform") |
| status := c.Query("status") |
| search := c.Query("search") |
| |
| search = strings.TrimSpace(search) |
| if len(search) > 100 { |
| search = search[:100] |
| } |
| isExclusiveStr := c.Query("is_exclusive") |
|
|
| var isExclusive *bool |
| if isExclusiveStr != "" { |
| val := isExclusiveStr == "true" |
| isExclusive = &val |
| } |
|
|
| groups, total, err := h.adminService.ListGroups(c.Request.Context(), page, pageSize, platform, status, search, isExclusive) |
| if err != nil { |
| response.ErrorFrom(c, err) |
| 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) |
| } |
|
|
| |
| |
| func (h *GroupHandler) GetAll(c *gin.Context) { |
| platform := c.Query("platform") |
|
|
| var groups []service.Group |
| var err error |
|
|
| 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 |
| } |
|
|
| outGroups := make([]dto.AdminGroup, 0, len(groups)) |
| for i := range groups { |
| outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i])) |
| } |
| response.Success(c, outGroups) |
| } |
|
|
| |
| |
| func (h *GroupHandler) GetByID(c *gin.Context) { |
| 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 |
| } |
|
|
| response.Success(c, dto.GroupFromServiceAdmin(group)) |
| } |
|
|
| |
| |
| func (h *GroupHandler) Create(c *gin.Context) { |
| var req CreateGroupRequest |
| if err := c.ShouldBindJSON(&req); err != nil { |
| response.BadRequest(c, "Invalid request: "+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, |
| SubscriptionType: req.SubscriptionType, |
| DailyLimitUSD: req.DailyLimitUSD.ToServiceInput(), |
| WeeklyLimitUSD: req.WeeklyLimitUSD.ToServiceInput(), |
| MonthlyLimitUSD: req.MonthlyLimitUSD.ToServiceInput(), |
| ImagePrice1K: req.ImagePrice1K, |
| ImagePrice2K: req.ImagePrice2K, |
| ImagePrice4K: req.ImagePrice4K, |
| SoraImagePrice360: req.SoraImagePrice360, |
| SoraImagePrice540: req.SoraImagePrice540, |
| SoraVideoPricePerRequest: req.SoraVideoPricePerRequest, |
| SoraVideoPricePerRequestHD: req.SoraVideoPricePerRequestHD, |
| ClaudeCodeOnly: req.ClaudeCodeOnly, |
| FallbackGroupID: req.FallbackGroupID, |
| FallbackGroupIDOnInvalidRequest: req.FallbackGroupIDOnInvalidRequest, |
| ModelRouting: req.ModelRouting, |
| ModelRoutingEnabled: req.ModelRoutingEnabled, |
| MCPXMLInject: req.MCPXMLInject, |
| SupportedModelScopes: req.SupportedModelScopes, |
| SoraStorageQuotaBytes: req.SoraStorageQuotaBytes, |
| AllowMessagesDispatch: req.AllowMessagesDispatch, |
| DefaultMappedModel: req.DefaultMappedModel, |
| CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs, |
| }) |
| if err != nil { |
| response.ErrorFrom(c, err) |
| return |
| } |
|
|
| response.Success(c, dto.GroupFromServiceAdmin(group)) |
| } |
|
|
| |
| |
| func (h *GroupHandler) Update(c *gin.Context) { |
| 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 |
| } |
|
|
| 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, |
| Status: req.Status, |
| SubscriptionType: req.SubscriptionType, |
| DailyLimitUSD: req.DailyLimitUSD.ToServiceInput(), |
| WeeklyLimitUSD: req.WeeklyLimitUSD.ToServiceInput(), |
| MonthlyLimitUSD: req.MonthlyLimitUSD.ToServiceInput(), |
| ImagePrice1K: req.ImagePrice1K, |
| ImagePrice2K: req.ImagePrice2K, |
| ImagePrice4K: req.ImagePrice4K, |
| SoraImagePrice360: req.SoraImagePrice360, |
| SoraImagePrice540: req.SoraImagePrice540, |
| SoraVideoPricePerRequest: req.SoraVideoPricePerRequest, |
| SoraVideoPricePerRequestHD: req.SoraVideoPricePerRequestHD, |
| ClaudeCodeOnly: req.ClaudeCodeOnly, |
| FallbackGroupID: req.FallbackGroupID, |
| FallbackGroupIDOnInvalidRequest: req.FallbackGroupIDOnInvalidRequest, |
| ModelRouting: req.ModelRouting, |
| ModelRoutingEnabled: req.ModelRoutingEnabled, |
| MCPXMLInject: req.MCPXMLInject, |
| SupportedModelScopes: req.SupportedModelScopes, |
| SoraStorageQuotaBytes: req.SoraStorageQuotaBytes, |
| AllowMessagesDispatch: req.AllowMessagesDispatch, |
| DefaultMappedModel: req.DefaultMappedModel, |
| CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs, |
| }) |
| if err != nil { |
| response.ErrorFrom(c, err) |
| return |
| } |
|
|
| response.Success(c, dto.GroupFromServiceAdmin(group)) |
| } |
|
|
| |
| |
| func (h *GroupHandler) Delete(c *gin.Context) { |
| groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) |
| if err != nil { |
| response.BadRequest(c, "Invalid group ID") |
| return |
| } |
|
|
| 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"}) |
| } |
|
|
| |
| |
| func (h *GroupHandler) GetStats(c *gin.Context) { |
| groupID, err := strconv.ParseInt(c.Param("id"), 10, 64) |
| if err != nil { |
| response.BadRequest(c, "Invalid group ID") |
| return |
| } |
|
|
| |
| response.Success(c, gin.H{ |
| "total_api_keys": 0, |
| "active_api_keys": 0, |
| "total_requests": 0, |
| "total_cost": 0.0, |
| }) |
| _ = groupID |
| } |
|
|
| |
| |
| func (h *GroupHandler) GetUsageSummary(c *gin.Context) { |
| userTZ := c.Query("timezone") |
| now := timezone.NowInUserLocation(userTZ) |
| todayStart := timezone.StartOfDayInUserLocation(now, userTZ) |
|
|
| 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) |
| } |
|
|
| |
| |
| func (h *GroupHandler) GetCapacitySummary(c *gin.Context) { |
| 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) |
| } |
|
|
| |
| |
| func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) { |
| 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) |
| } |
|
|
| |
| |
| func (h *GroupHandler) GetGroupRateMultipliers(c *gin.Context) { |
| 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) |
| } |
|
|
| |
| |
| func (h *GroupHandler) ClearGroupRateMultipliers(c *gin.Context) { |
| 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"}) |
| } |
|
|
| |
| type BatchSetGroupRateMultipliersRequest struct { |
| Entries []service.GroupRateMultiplierInput `json:"entries" binding:"required"` |
| } |
|
|
| |
| |
| func (h *GroupHandler) BatchSetGroupRateMultipliers(c *gin.Context) { |
| 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"}) |
| } |
|
|
| |
| type UpdateSortOrderRequest struct { |
| Updates []struct { |
| ID int64 `json:"id" binding:"required"` |
| SortOrder int `json:"sort_order"` |
| } `json:"updates" binding:"required,min=1"` |
| } |
|
|
| |
| |
| func (h *GroupHandler) UpdateSortOrder(c *gin.Context) { |
| 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"}) |
| } |
|
|