package service import ( "context" "errors" "fmt" "net/http" "strings" "time" dbent "github.com/Wei-Shaw/sub2api/ent" "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" "github.com/Wei-Shaw/sub2api/internal/pkg/claude" infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" "github.com/Wei-Shaw/sub2api/internal/pkg/logger" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/xai" ) // Group management implementations func (s *adminServiceImpl) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]Group, int64, error) { params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} groups, result, err := s.groupRepo.ListWithFilters(ctx, params, platform, status, search, isExclusive) if err != nil { return nil, 0, err } return groups, result.Total, nil } func (s *adminServiceImpl) GetAllGroups(ctx context.Context) ([]Group, error) { return s.groupRepo.ListActive(ctx) } func (s *adminServiceImpl) GetAllGroupsByPlatform(ctx context.Context, platform string) ([]Group, error) { return s.groupRepo.ListActiveByPlatform(ctx, platform) } func (s *adminServiceImpl) GetAllGroupsIncludingInactive(ctx context.Context) ([]Group, error) { // ListWithFilters with empty status = no status filter, so active + disabled groups are returned. // PageSize 10000 is intentionally large; group count is O(dozens) in practice. groups, _, err := s.groupRepo.ListWithFilters(ctx, pagination.PaginationParams{Page: 1, PageSize: 10000}, "", "", "", nil) return groups, err } func (s *adminServiceImpl) GetGroup(ctx context.Context, id int64) (*Group, error) { return s.groupRepo.GetByID(ctx, id) } func (s *adminServiceImpl) GetGroupModelsListCandidates(ctx context.Context, id int64, platform string) ([]string, error) { platform = strings.TrimSpace(platform) if id > 0 { group, err := s.groupRepo.GetByIDLite(ctx, id) if err != nil { return nil, err } if platform == "" { platform = group.Platform } } if platform == "" { platform = PlatformAnthropic } candidates := defaultModelsListCandidateIDs(platform) if id <= 0 || s.accountRepo == nil { return candidates, nil } accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, id) if err != nil { return nil, err } seen := make(map[string]struct{}, len(candidates)) for _, model := range candidates { seen[model] = struct{}{} } for _, acc := range accounts { if platform == PlatformComposite { if !isConcreteRequestPlatform(acc.Platform) { continue } } else if acc.Platform != platform { continue } for model := range acc.GetModelMapping() { model = strings.TrimSpace(model) if model == "" { continue } if _, ok := seen[model]; ok { continue } seen[model] = struct{}{} candidates = append(candidates, model) } } return candidates, nil } func (s *adminServiceImpl) ListCompositeRoutes(ctx context.Context, groupID int64) ([]CompositeModelRoute, error) { if err := s.requireCompositeGroup(ctx, groupID); err != nil { return nil, err } if s.compositeRouteRepo == nil { return nil, fmt.Errorf("composite route repository is not configured") } return s.compositeRouteRepo.ListByGroup(ctx, groupID, true) } func (s *adminServiceImpl) CreateCompositeRoute(ctx context.Context, groupID int64, input CompositeRouteInput) (*CompositeModelRoute, error) { if err := s.requireCompositeGroup(ctx, groupID); err != nil { return nil, err } if s.compositeRouteRepo == nil { return nil, fmt.Errorf("composite route repository is not configured") } route, err := compositeRouteFromInput(groupID, input) if err != nil { return nil, err } if err := s.compositeRouteRepo.Create(ctx, route); err != nil { return nil, err } return route, nil } func (s *adminServiceImpl) UpdateCompositeRoute(ctx context.Context, groupID, routeID int64, input CompositeRouteInput) (*CompositeModelRoute, error) { if err := s.requireCompositeGroup(ctx, groupID); err != nil { return nil, err } if s.compositeRouteRepo == nil { return nil, fmt.Errorf("composite route repository is not configured") } if ok, err := s.compositeRouteBelongsToGroup(ctx, groupID, routeID); err != nil { return nil, err } else if !ok { return nil, ErrCompositeRouteNotFound } route, err := compositeRouteFromInput(groupID, input) if err != nil { return nil, err } route.ID = routeID if err := s.compositeRouteRepo.Update(ctx, route); err != nil { return nil, err } return route, nil } func (s *adminServiceImpl) DeleteCompositeRoute(ctx context.Context, groupID, routeID int64) error { if err := s.requireCompositeGroup(ctx, groupID); err != nil { return err } if s.compositeRouteRepo == nil { return fmt.Errorf("composite route repository is not configured") } if ok, err := s.compositeRouteBelongsToGroup(ctx, groupID, routeID); err != nil { return err } else if !ok { return ErrCompositeRouteNotFound } return s.compositeRouteRepo.Delete(ctx, routeID) } func (s *adminServiceImpl) PreviewCompositeRoute(ctx context.Context, groupID int64, input CompositeRoutePreviewRequest) (*CompositeRouteDecision, error) { if err := s.requireCompositeGroup(ctx, groupID); err != nil { return nil, err } resolver := s.compositeResolver if resolver == nil { resolver = NewCompositeRouteResolver(s.compositeRouteRepo) } decision, err := resolver.Resolve(ctx, groupID, input.Model, input.Endpoint) if err != nil { return nil, err } return &decision, nil } func (s *adminServiceImpl) requireCompositeGroup(ctx context.Context, groupID int64) error { group, err := s.groupRepo.GetByIDLite(ctx, groupID) if err != nil { return err } if group.Platform != PlatformComposite { return fmt.Errorf("group %d is not a composite group", groupID) } return nil } func (s *adminServiceImpl) compositeRouteBelongsToGroup(ctx context.Context, groupID, routeID int64) (bool, error) { routes, err := s.compositeRouteRepo.ListByGroup(ctx, groupID, true) if err != nil { return false, err } for i := range routes { if routes[i].ID == routeID { return true, nil } } return false, nil } func compositeRouteFromInput(groupID int64, input CompositeRouteInput) (*CompositeModelRoute, error) { input = normalizeCompositeRouteInput(input) if input.PublicModel == "" { return nil, fmt.Errorf("public_model is required") } if !isConcreteRequestPlatform(input.TargetPlatform) { return nil, fmt.Errorf("target_platform must be a concrete provider") } if input.Priority == 0 { input.Priority = 100 } return &CompositeModelRoute{ GroupID: groupID, PublicModel: input.PublicModel, MatchType: input.MatchType, TargetPlatform: input.TargetPlatform, UpstreamModel: input.UpstreamModel, Endpoint: input.Endpoint, Priority: input.Priority, Enabled: input.Enabled, Notes: input.Notes, }, nil } func defaultModelsListCandidateIDs(platform string) []string { switch platform { case PlatformOpenAI: return openai.DefaultModelIDs() case PlatformGemini: ids := make([]string, 0, len(geminicli.DefaultModels)) for _, model := range geminicli.DefaultModels { ids = append(ids, model.ID) } return ids case PlatformAntigravity: models := antigravity.DefaultModels() ids := make([]string, 0, len(models)) for _, model := range models { ids = append(ids, model.ID) } return ids case PlatformGrok: return xai.DefaultModelIDs() case PlatformComposite: return compositeDefaultModelsListCandidateIDs() default: ids := make([]string, 0, len(claude.DefaultModels)) for _, model := range claude.DefaultModels { ids = append(ids, model.ID) } return ids } } func defaultAllowImageGenerationForPlatform(platform string) bool { // Grok image and video generation routes share the legacy image-generation gate. // Older clients send the false zero value, so Grok groups must default enabled. return platform == PlatformGrok } func compositeDefaultModelsListCandidateIDs() []string { seen := make(map[string]struct{}) ids := make([]string, 0) for _, platform := range []string{PlatformAnthropic, PlatformGemini, PlatformOpenAI, PlatformAntigravity, PlatformGrok, PlatformKimi, PlatformZhipu, PlatformDeepseek} { for _, id := range defaultModelsListCandidateIDs(platform) { if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} ids = append(ids, id) } } return ids } func canCopyAccountsFromGroupPlatform(targetPlatform, sourcePlatform string) bool { if targetPlatform == PlatformComposite { return sourcePlatform == PlatformComposite || isConcreteRequestPlatform(sourcePlatform) } return sourcePlatform == targetPlatform } func groupSupportsOAuthOnlyFilter(platform string) bool { return platform == PlatformOpenAI || platform == PlatformAntigravity || platform == PlatformAnthropic || platform == PlatformGemini || platform == PlatformGrok || platform == PlatformComposite } func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) { if input.RateMultiplier <= 0 { return nil, errors.New("rate_multiplier must be > 0") } platform := NormalizeGroupPlatform(input.Platform) modelPricing, err := normalizeGroupModelPricing(platform, input.ModelPricing) if err != nil { return nil, err } maxReasoningEffort, err := normalizeMaxReasoningEffortForPlatform(platform, input.MaxReasoningEffort) if err != nil { return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_MAX_REASONING_EFFORT", "%v", err) } reasoningEffortMappings, err := NormalizeReasoningEffortMappings(platform, input.ReasoningEffortMappings) if err != nil { return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_REASONING_EFFORT_MAPPING", "%v", err) } subscriptionType := input.SubscriptionType if subscriptionType == "" { subscriptionType = SubscriptionTypeStandard } // 限额字段:nil/负数 表示"无限制",0 表示"不允许用量",正数表示具体限额 dailyLimit := normalizeLimit(input.DailyLimitUSD) weeklyLimit := normalizeLimit(input.WeeklyLimitUSD) monthlyLimit := normalizeLimit(input.MonthlyLimitUSD) // 图片价格:负数表示清除(使用默认价格),0 保留(表示免费) imagePrice1K := normalizePrice(input.ImagePrice1K) imagePrice2K := normalizePrice(input.ImagePrice2K) imagePrice4K := normalizePrice(input.ImagePrice4K) videoPrice480P := normalizePrice(input.VideoPrice480P) videoPrice720P := normalizePrice(input.VideoPrice720P) videoPrice1080P := normalizePrice(input.VideoPrice1080P) webSearchPricePerCall := normalizePrice(input.WebSearchPricePerCall) searchPricePer1k := normalizePrice(input.SearchPricePer1k) audioRealtimePricePerMin := normalizePrice(input.AudioRealtimePricePerMin) audioTTSPricePerMillionChars := normalizePrice(input.AudioTTSPricePerMillionChars) audioSTTPricePerHour := normalizePrice(input.AudioSTTPricePerHour) imageRateMultiplier := 1.0 if input.ImageRateMultiplier != nil { if *input.ImageRateMultiplier < 0 { return nil, errors.New("image_rate_multiplier must be >= 0") } imageRateMultiplier = *input.ImageRateMultiplier } batchImageDiscountMultiplier := defaultBatchImageDiscountMultiplier if input.BatchImageDiscountMultiplier != nil { if *input.BatchImageDiscountMultiplier < 0 { return nil, errors.New("batch_image_discount_multiplier must be >= 0") } batchImageDiscountMultiplier = *input.BatchImageDiscountMultiplier } batchImageHoldMultiplier := defaultBatchImageHoldMultiplier if input.BatchImageHoldMultiplier != nil { if *input.BatchImageHoldMultiplier < 0 { return nil, errors.New("batch_image_hold_multiplier must be >= 0") } batchImageHoldMultiplier = *input.BatchImageHoldMultiplier } // 不变式:hold 比例 >= discount 比例。否则批量任务成功率足够高时 // 实际成本会超过冻结额,结算永远失败、用户冻结余额无法解冻。 if batchImageHoldMultiplier < batchImageDiscountMultiplier { return nil, errors.New("batch_image_hold_multiplier must be >= batch_image_discount_multiplier") } videoRateMultiplier := 1.0 if input.VideoRateMultiplier != nil { if *input.VideoRateMultiplier < 0 { return nil, errors.New("video_rate_multiplier must be >= 0") } videoRateMultiplier = *input.VideoRateMultiplier } peakRateMultiplier := 1.0 if input.PeakRateMultiplier != nil { peakRateMultiplier = *input.PeakRateMultiplier } // 先归一化(非订阅分组清空高峰配置、清洗停用状态下的脏字段)再校验,与 UpdateGroup 同一收口。 peakRateEnabled, peakStart, peakEnd, peakRateMultiplier := NormalizePeakRateConfig(subscriptionType, input.PeakRateEnabled, input.PeakStart, input.PeakEnd, peakRateMultiplier) if err := ValidatePeakRateConfig(subscriptionType, peakRateEnabled, peakStart, peakEnd, peakRateMultiplier); err != nil { return nil, err } profitMinMargin := 0.0 if input.ProfitMinMargin != nil { profitMinMargin = *input.ProfitMinMargin } profitSafetyBuffer := 0.0 if input.ProfitSafetyBuffer != nil { profitSafetyBuffer = *input.ProfitSafetyBuffer } // 利润控制与高峰倍率同一收口顺序:先按平台归一化(不支持的平台重置),再校验。 profitControlEnabled, profitMinMargin, profitSafetyBuffer := NormalizeProfitControlConfig(platform, input.ProfitControlEnabled, profitMinMargin, profitSafetyBuffer) if err := ValidateProfitControlConfig(platform, profitControlEnabled, profitMinMargin, profitSafetyBuffer); err != nil { return nil, err } // 校验降级分组 if input.FallbackGroupID != nil { if err := s.validateFallbackGroup(ctx, 0, *input.FallbackGroupID); err != nil { return nil, err } } fallbackOnInvalidRequest := input.FallbackGroupIDOnInvalidRequest if fallbackOnInvalidRequest != nil && *fallbackOnInvalidRequest <= 0 { fallbackOnInvalidRequest = nil } // 校验无效请求兜底分组 if fallbackOnInvalidRequest != nil { if err := s.validateFallbackGroupOnInvalidRequest(ctx, 0, platform, subscriptionType, *fallbackOnInvalidRequest); err != nil { return nil, err } } // MCPXMLInject:默认为 true,仅当显式传入 false 时关闭 mcpXMLInject := true if input.MCPXMLInject != nil { mcpXMLInject = *input.MCPXMLInject } allowImageGeneration := input.AllowImageGeneration || defaultAllowImageGenerationForPlatform(platform) allowBatchImageGeneration := input.AllowBatchImageGeneration && allowImageGeneration && platform == PlatformGemini // 如果指定了复制账号的源分组,先获取账号 ID 列表 var accountIDsToCopy []int64 if len(input.CopyAccountsFromGroupIDs) > 0 { // 去重源分组 IDs seen := make(map[int64]struct{}) uniqueSourceGroupIDs := make([]int64, 0, len(input.CopyAccountsFromGroupIDs)) for _, srcGroupID := range input.CopyAccountsFromGroupIDs { if _, exists := seen[srcGroupID]; !exists { seen[srcGroupID] = struct{}{} uniqueSourceGroupIDs = append(uniqueSourceGroupIDs, srcGroupID) } } // 校验源分组的平台是否与新分组一致 for _, srcGroupID := range uniqueSourceGroupIDs { srcGroup, err := s.groupRepo.GetByIDLite(ctx, srcGroupID) if err != nil { return nil, fmt.Errorf("source group %d not found: %w", srcGroupID, err) } if !canCopyAccountsFromGroupPlatform(platform, srcGroup.Platform) { return nil, fmt.Errorf("source group %d platform mismatch: expected %s, got %s", srcGroupID, platform, srcGroup.Platform) } } // 获取所有源分组的账号(去重) var err error accountIDsToCopy, err = s.groupRepo.GetAccountIDsByGroupIDs(ctx, uniqueSourceGroupIDs) if err != nil { return nil, fmt.Errorf("failed to get accounts from source groups: %w", err) } } group := &Group{ Name: input.Name, Description: input.Description, Platform: platform, RateMultiplier: input.RateMultiplier, IsExclusive: input.IsExclusive, Status: StatusActive, SubscriptionType: subscriptionType, DailyLimitUSD: dailyLimit, WeeklyLimitUSD: weeklyLimit, MonthlyLimitUSD: monthlyLimit, LongContextPricingEnabled: input.LongContextPricingEnabled, ModelPricing: modelPricing, AllowImageGeneration: allowImageGeneration, AllowBatchImageGeneration: allowBatchImageGeneration, ImageRateIndependent: input.ImageRateIndependent, ImageRateMultiplier: imageRateMultiplier, BatchImageDiscountMultiplier: batchImageDiscountMultiplier, BatchImageHoldMultiplier: batchImageHoldMultiplier, VideoRateIndependent: input.VideoRateIndependent, VideoRateMultiplier: videoRateMultiplier, PeakRateEnabled: peakRateEnabled, PeakStart: peakStart, PeakEnd: peakEnd, PeakRateMultiplier: peakRateMultiplier, ProfitControlEnabled: profitControlEnabled, ProfitMinMargin: profitMinMargin, ProfitSafetyBuffer: profitSafetyBuffer, ImagePrice1K: imagePrice1K, ImagePrice2K: imagePrice2K, ImagePrice4K: imagePrice4K, VideoPrice480P: videoPrice480P, VideoPrice720P: videoPrice720P, VideoPrice1080P: videoPrice1080P, VideoModelPrices: NormalizeVideoModelPrices(input.VideoModelPrices), WebSearchPricePerCall: webSearchPricePerCall, SearchPricePer1k: searchPricePer1k, AudioRealtimePricePerMin: audioRealtimePricePerMin, AudioTTSPricePerMillionChars: audioTTSPricePerMillionChars, AudioSTTPricePerHour: audioSTTPricePerHour, ClaudeCodeOnly: input.ClaudeCodeOnly, FallbackGroupID: input.FallbackGroupID, FallbackGroupIDOnInvalidRequest: fallbackOnInvalidRequest, ModelRouting: input.ModelRouting, MCPXMLInject: mcpXMLInject, SupportedModelScopes: input.SupportedModelScopes, AllowMessagesDispatch: input.AllowMessagesDispatch, AllowLive: input.AllowLive, RequireOAuthOnly: input.RequireOAuthOnly, RequirePrivacySet: input.RequirePrivacySet, DefaultMappedModel: input.DefaultMappedModel, MessagesDispatchModelConfig: normalizeOpenAIMessagesDispatchModelConfig(input.MessagesDispatchModelConfig), ModelsListConfig: normalizeGroupModelsListConfig(input.ModelsListConfig), RPMLimit: input.RPMLimit, MaxReasoningEffort: maxReasoningEffort, ReasoningEffortMappings: reasoningEffortMappings, } sanitizeGroupMessagesDispatchFields(group) if group.Platform != PlatformOpenAI && group.Platform != PlatformComposite { group.AllowLive = false } sanitizeGroupReasoningEffortPolicy(group) if err := s.groupRepo.Create(ctx, group); err != nil { return nil, err } // require_oauth_only: 过滤掉 apikey 类型账号 if group.RequireOAuthOnly && groupSupportsOAuthOnlyFilter(group.Platform) && len(accountIDsToCopy) > 0 { accounts, err := s.accountRepo.GetByIDs(ctx, accountIDsToCopy) if err != nil { return nil, fmt.Errorf("failed to fetch accounts for oauth filter: %w", err) } oauthIDs := make(map[int64]struct{}, len(accounts)) for _, acc := range accounts { if acc.Type != AccountTypeAPIKey { oauthIDs[acc.ID] = struct{}{} } } var filtered []int64 for _, aid := range accountIDsToCopy { if _, ok := oauthIDs[aid]; ok { filtered = append(filtered, aid) } } accountIDsToCopy = filtered } // 如果有需要复制的账号,绑定到新分组 if len(accountIDsToCopy) > 0 { if err := s.groupRepo.BindAccountsToGroup(ctx, group.ID, accountIDsToCopy); err != nil { return nil, fmt.Errorf("failed to bind accounts to new group: %w", err) } group.AccountCount = int64(len(accountIDsToCopy)) } return group, nil } // normalizeLimit 将负数转换为 nil(表示无限制),0 保留(表示限额为零) func normalizeLimit(limit *float64) *float64 { if limit == nil || *limit < 0 { return nil } return limit } // normalizePrice 将负数转换为 nil(表示使用默认价格),0 保留(表示免费) func normalizePrice(price *float64) *float64 { if price == nil || *price < 0 { return nil } return price } // validateFallbackGroup 校验降级分组的有效性 // currentGroupID: 当前分组 ID(新建时为 0) // fallbackGroupID: 降级分组 ID func (s *adminServiceImpl) validateFallbackGroup(ctx context.Context, currentGroupID, fallbackGroupID int64) error { // 不能将自己设置为降级分组 if currentGroupID > 0 && currentGroupID == fallbackGroupID { return fmt.Errorf("cannot set self as fallback group") } visited := map[int64]struct{}{} nextID := fallbackGroupID for { if _, seen := visited[nextID]; seen { return fmt.Errorf("fallback group cycle detected") } visited[nextID] = struct{}{} if currentGroupID > 0 && nextID == currentGroupID { return fmt.Errorf("fallback group cycle detected") } // 检查降级分组是否存在 fallbackGroup, err := s.groupRepo.GetByIDLite(ctx, nextID) if err != nil { return fmt.Errorf("fallback group not found: %w", err) } // 降级分组不能启用 claude_code_only,否则会造成死循环 if nextID == fallbackGroupID && fallbackGroup.ClaudeCodeOnly { return fmt.Errorf("fallback group cannot have claude_code_only enabled") } if fallbackGroup.FallbackGroupID == nil { return nil } nextID = *fallbackGroup.FallbackGroupID } } // validateFallbackGroupOnInvalidRequest 校验无效请求兜底分组的有效性 // currentGroupID: 当前分组 ID(新建时为 0) // platform/subscriptionType: 当前分组的有效平台/订阅类型 // fallbackGroupID: 兜底分组 ID func (s *adminServiceImpl) validateFallbackGroupOnInvalidRequest(ctx context.Context, currentGroupID int64, platform, subscriptionType string, fallbackGroupID int64) error { if platform != PlatformAnthropic && platform != PlatformAntigravity { return fmt.Errorf("invalid request fallback only supported for anthropic or antigravity groups") } if subscriptionType == SubscriptionTypeSubscription { return fmt.Errorf("subscription groups cannot set invalid request fallback") } if currentGroupID > 0 && currentGroupID == fallbackGroupID { return fmt.Errorf("cannot set self as invalid request fallback group") } fallbackGroup, err := s.groupRepo.GetByIDLite(ctx, fallbackGroupID) if err != nil { return fmt.Errorf("fallback group not found: %w", err) } if fallbackGroup.Platform != PlatformAnthropic { return fmt.Errorf("fallback group must be anthropic platform") } if fallbackGroup.SubscriptionType == SubscriptionTypeSubscription { return fmt.Errorf("fallback group cannot be subscription type") } if fallbackGroup.FallbackGroupIDOnInvalidRequest != nil { return fmt.Errorf("fallback group cannot have invalid request fallback configured") } return nil } func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *UpdateGroupInput) (*Group, error) { group, err := s.groupRepo.GetByID(ctx, id) if err != nil { return nil, err } // 渠道缓存里存了 groupID → platform 的映射,改了平台要让它失效(见函数末尾) previousPlatform := group.Platform if input.Name != "" { group.Name = input.Name } if input.Description != nil { group.Description = *input.Description } if input.Platform != "" { group.Platform = input.Platform } if input.RateMultiplier != nil { if *input.RateMultiplier <= 0 { return nil, errors.New("rate_multiplier must be > 0") } group.RateMultiplier = *input.RateMultiplier } if input.IsExclusive != nil { group.IsExclusive = *input.IsExclusive } if input.Status != "" { group.Status = input.Status } if input.LongContextPricingEnabled != nil { group.LongContextPricingEnabled = *input.LongContextPricingEnabled } if input.ModelPricing != nil { modelPricing, normalizeErr := normalizeGroupModelPricing(group.Platform, *input.ModelPricing) if normalizeErr != nil { return nil, normalizeErr } group.ModelPricing = modelPricing } // 订阅相关字段 if input.SubscriptionType != "" { group.SubscriptionType = input.SubscriptionType } // 限额字段:nil/负数 表示"无限制",0 表示"不允许用量",正数表示具体限额 // 前端始终发送这三个字段,无需 nil 守卫 group.DailyLimitUSD = normalizeLimit(input.DailyLimitUSD) group.WeeklyLimitUSD = normalizeLimit(input.WeeklyLimitUSD) group.MonthlyLimitUSD = normalizeLimit(input.MonthlyLimitUSD) // 图片生成计费配置:负数表示清除(使用默认价格) if input.AllowImageGeneration != nil { group.AllowImageGeneration = *input.AllowImageGeneration } if input.AllowBatchImageGeneration != nil { group.AllowBatchImageGeneration = *input.AllowBatchImageGeneration } if !group.AllowImageGeneration || group.Platform != PlatformGemini { group.AllowBatchImageGeneration = false } if input.ImageRateIndependent != nil { group.ImageRateIndependent = *input.ImageRateIndependent } if input.ImageRateMultiplier != nil { if *input.ImageRateMultiplier < 0 { return nil, errors.New("image_rate_multiplier must be >= 0") } group.ImageRateMultiplier = *input.ImageRateMultiplier } if input.BatchImageDiscountMultiplier != nil { if *input.BatchImageDiscountMultiplier < 0 { return nil, errors.New("batch_image_discount_multiplier must be >= 0") } group.BatchImageDiscountMultiplier = *input.BatchImageDiscountMultiplier } if input.BatchImageHoldMultiplier != nil { if *input.BatchImageHoldMultiplier < 0 { return nil, errors.New("batch_image_hold_multiplier must be >= 0") } group.BatchImageHoldMultiplier = *input.BatchImageHoldMultiplier } // 仅在本次更新显式触碰任一比例时校验合并后的不变式(hold >= discount), // 避免存量脏数据阻塞其他字段的正常更新(提交侧另有钳制兜底)。 if (input.BatchImageDiscountMultiplier != nil || input.BatchImageHoldMultiplier != nil) && group.BatchImageHoldMultiplier < group.BatchImageDiscountMultiplier { return nil, errors.New("batch_image_hold_multiplier must be >= batch_image_discount_multiplier") } if input.VideoRateIndependent != nil { group.VideoRateIndependent = *input.VideoRateIndependent } if input.VideoRateMultiplier != nil { if *input.VideoRateMultiplier < 0 { return nil, errors.New("video_rate_multiplier must be >= 0") } group.VideoRateMultiplier = *input.VideoRateMultiplier } if input.PeakRateEnabled != nil { group.PeakRateEnabled = *input.PeakRateEnabled } if input.PeakStart != nil { group.PeakStart = *input.PeakStart } if input.PeakEnd != nil { group.PeakEnd = *input.PeakEnd } if input.PeakRateMultiplier != nil { group.PeakRateMultiplier = *input.PeakRateMultiplier } // 先归一化(非订阅分组——含本次更新转为非订阅——静默清空高峰配置,清洗停用状态下的脏字段), // 再收敛校验:Update 可能只传部分 peak 字段,需对合并后的最终配置统一校验, // 防止单独修改 start/end 导致最终 start>=end 等非法配置入库。与 CreateGroup 同一收口。 group.PeakRateEnabled, group.PeakStart, group.PeakEnd, group.PeakRateMultiplier = NormalizePeakRateConfig(group.SubscriptionType, group.PeakRateEnabled, group.PeakStart, group.PeakEnd, group.PeakRateMultiplier) if err := ValidatePeakRateConfig(group.SubscriptionType, group.PeakRateEnabled, group.PeakStart, group.PeakEnd, group.PeakRateMultiplier); err != nil { return nil, err } if input.ProfitControlEnabled != nil { group.ProfitControlEnabled = *input.ProfitControlEnabled } if input.ProfitMinMargin != nil { group.ProfitMinMargin = *input.ProfitMinMargin } if input.ProfitSafetyBuffer != nil { group.ProfitSafetyBuffer = *input.ProfitSafetyBuffer } // 利润控制与高峰同一收口:按合并后的最终平台归一化(转到不支持平台时静默重置), // 再对合并后的最终配置统一校验,防止部分字段更新拼出非法组合入库。 group.ProfitControlEnabled, group.ProfitMinMargin, group.ProfitSafetyBuffer = NormalizeProfitControlConfig(group.Platform, group.ProfitControlEnabled, group.ProfitMinMargin, group.ProfitSafetyBuffer) if err := ValidateProfitControlConfig(group.Platform, group.ProfitControlEnabled, group.ProfitMinMargin, group.ProfitSafetyBuffer); err != nil { return nil, err } if input.ImagePrice1K != nil { group.ImagePrice1K = normalizePrice(input.ImagePrice1K) } if input.ImagePrice2K != nil { group.ImagePrice2K = normalizePrice(input.ImagePrice2K) } if input.ImagePrice4K != nil { group.ImagePrice4K = normalizePrice(input.ImagePrice4K) } if input.VideoPrice480P != nil { group.VideoPrice480P = normalizePrice(input.VideoPrice480P) } if input.VideoPrice720P != nil { group.VideoPrice720P = normalizePrice(input.VideoPrice720P) } if input.VideoPrice1080P != nil { group.VideoPrice1080P = normalizePrice(input.VideoPrice1080P) } // nil = leave unchanged; empty map = clear per-model prices. if input.VideoModelPrices != nil { group.VideoModelPrices = NormalizeVideoModelPrices(input.VideoModelPrices) } if input.WebSearchPricePerCall != nil { group.WebSearchPricePerCall = normalizePrice(input.WebSearchPricePerCall) } if input.SearchPricePer1k != nil { group.SearchPricePer1k = normalizePrice(input.SearchPricePer1k) } if input.AudioRealtimePricePerMin != nil { group.AudioRealtimePricePerMin = normalizePrice(input.AudioRealtimePricePerMin) } if input.AudioTTSPricePerMillionChars != nil { group.AudioTTSPricePerMillionChars = normalizePrice(input.AudioTTSPricePerMillionChars) } if input.AudioSTTPricePerHour != nil { group.AudioSTTPricePerHour = normalizePrice(input.AudioSTTPricePerHour) } // Claude Code 客户端限制 if input.ClaudeCodeOnly != nil { group.ClaudeCodeOnly = *input.ClaudeCodeOnly } if input.FallbackGroupID != nil { // 校验降级分组 if *input.FallbackGroupID > 0 { if err := s.validateFallbackGroup(ctx, id, *input.FallbackGroupID); err != nil { return nil, err } group.FallbackGroupID = input.FallbackGroupID } else { // 传入 0 或负数表示清除降级分组 group.FallbackGroupID = nil } } fallbackOnInvalidRequest := group.FallbackGroupIDOnInvalidRequest if input.FallbackGroupIDOnInvalidRequest != nil { if *input.FallbackGroupIDOnInvalidRequest > 0 { fallbackOnInvalidRequest = input.FallbackGroupIDOnInvalidRequest } else { fallbackOnInvalidRequest = nil } } if fallbackOnInvalidRequest != nil { if err := s.validateFallbackGroupOnInvalidRequest(ctx, id, group.Platform, group.SubscriptionType, *fallbackOnInvalidRequest); err != nil { return nil, err } } group.FallbackGroupIDOnInvalidRequest = fallbackOnInvalidRequest // 模型路由配置 if input.ModelRouting != nil { group.ModelRouting = input.ModelRouting } if input.ModelRoutingEnabled != nil { group.ModelRoutingEnabled = *input.ModelRoutingEnabled } if input.MCPXMLInject != nil { group.MCPXMLInject = *input.MCPXMLInject } // 支持的模型系列(仅 antigravity 平台使用) if input.SupportedModelScopes != nil { group.SupportedModelScopes = *input.SupportedModelScopes } // OpenAI Messages 调度配置 if input.AllowMessagesDispatch != nil { group.AllowMessagesDispatch = *input.AllowMessagesDispatch } if input.AllowLive != nil { group.AllowLive = *input.AllowLive } if input.RequireOAuthOnly != nil { group.RequireOAuthOnly = *input.RequireOAuthOnly } if input.RequirePrivacySet != nil { group.RequirePrivacySet = *input.RequirePrivacySet } if input.DefaultMappedModel != nil { group.DefaultMappedModel = *input.DefaultMappedModel } if input.MessagesDispatchModelConfig != nil { group.MessagesDispatchModelConfig = normalizeOpenAIMessagesDispatchModelConfig(*input.MessagesDispatchModelConfig) } if input.ModelsListConfig != nil { group.ModelsListConfig = normalizeGroupModelsListConfig(*input.ModelsListConfig) } if input.RPMLimit != nil { group.RPMLimit = *input.RPMLimit } if input.MaxReasoningEffort != nil { maxReasoningEffort, err := normalizeMaxReasoningEffortForPlatform(group.Platform, *input.MaxReasoningEffort) if err != nil { return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_MAX_REASONING_EFFORT", "%v", err) } group.MaxReasoningEffort = maxReasoningEffort } if input.ReasoningEffortMappings != nil { reasoningEffortMappings, err := NormalizeReasoningEffortMappings(group.Platform, *input.ReasoningEffortMappings) if err != nil { return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_REASONING_EFFORT_MAPPING", "%v", err) } group.ReasoningEffortMappings = reasoningEffortMappings } sanitizeGroupMessagesDispatchFields(group) if group.Platform != PlatformOpenAI && group.Platform != PlatformComposite { group.AllowLive = false } sanitizeGroupReasoningEffortPolicy(group) if err := s.groupRepo.Update(ctx, group); err != nil { return nil, err } if s.authCacheInvalidator != nil { s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, id) } // 平台变了就失效渠道缓存:该缓存持有 groupID → platform,而渠道定价 / 模型映射 / // 模型白名单都按平台严格隔离。不失效的话,缓存最长 10 分钟仍按旧平台匹配, // 期间定价查不到会静默回落到 LiteLLM 价格表、映射与白名单也不生效。 if group.Platform != previousPlatform && s.channelCacheInvalidator != nil { s.channelCacheInvalidator.InvalidateCache() } // 如果指定了复制账号的源分组,同步绑定(替换当前分组的账号) if len(input.CopyAccountsFromGroupIDs) > 0 { // 去重源分组 IDs seen := make(map[int64]struct{}) uniqueSourceGroupIDs := make([]int64, 0, len(input.CopyAccountsFromGroupIDs)) for _, srcGroupID := range input.CopyAccountsFromGroupIDs { // 校验:源分组不能是自身 if srcGroupID == id { return nil, fmt.Errorf("cannot copy accounts from self") } // 去重 if _, exists := seen[srcGroupID]; !exists { seen[srcGroupID] = struct{}{} uniqueSourceGroupIDs = append(uniqueSourceGroupIDs, srcGroupID) } } // 校验源分组的平台是否与当前分组一致 for _, srcGroupID := range uniqueSourceGroupIDs { srcGroup, err := s.groupRepo.GetByIDLite(ctx, srcGroupID) if err != nil { return nil, fmt.Errorf("source group %d not found: %w", srcGroupID, err) } if !canCopyAccountsFromGroupPlatform(group.Platform, srcGroup.Platform) { return nil, fmt.Errorf("source group %d platform mismatch: expected %s, got %s", srcGroupID, group.Platform, srcGroup.Platform) } } // 获取所有源分组的账号(去重) accountIDsToCopy, err := s.groupRepo.GetAccountIDsByGroupIDs(ctx, uniqueSourceGroupIDs) if err != nil { return nil, fmt.Errorf("failed to get accounts from source groups: %w", err) } // 先清空当前分组的所有账号绑定 if _, err := s.groupRepo.DeleteAccountGroupsByGroupID(ctx, id); err != nil { return nil, fmt.Errorf("failed to clear existing account bindings: %w", err) } // require_oauth_only: 过滤掉 apikey 类型账号 if group.RequireOAuthOnly && groupSupportsOAuthOnlyFilter(group.Platform) && len(accountIDsToCopy) > 0 { accounts, err := s.accountRepo.GetByIDs(ctx, accountIDsToCopy) if err != nil { return nil, fmt.Errorf("failed to fetch accounts for oauth filter: %w", err) } oauthIDs := make(map[int64]struct{}, len(accounts)) for _, acc := range accounts { if acc.Type != AccountTypeAPIKey { oauthIDs[acc.ID] = struct{}{} } } var filtered []int64 for _, aid := range accountIDsToCopy { if _, ok := oauthIDs[aid]; ok { filtered = append(filtered, aid) } } accountIDsToCopy = filtered } // 再绑定源分组的账号 if len(accountIDsToCopy) > 0 { if err := s.groupRepo.BindAccountsToGroup(ctx, id, accountIDsToCopy); err != nil { return nil, fmt.Errorf("failed to bind accounts to group: %w", err) } } } return group, nil } func normalizeGroupModelPricing(platform string, pricing []ChannelModelPricing) ([]ChannelModelPricing, error) { out := make([]ChannelModelPricing, len(pricing)) for i := range pricing { out[i] = pricing[i].Clone() out[i].ID = 0 out[i].ChannelID = 0 if out[i].TimePricing != nil && len(out[i].TimePricing.Periods) > 0 { return nil, infraerrors.BadRequest( "GROUP_MODEL_TIME_PRICING_UNSUPPORTED", "group model pricing does not support time pricing", ) } if strings.TrimSpace(out[i].Platform) == "" { out[i].Platform = platform } for j := range out[i].Models { out[i].Models[j] = strings.TrimSpace(out[i].Models[j]) } if len(out[i].Models) == 0 { return nil, infraerrors.New(http.StatusBadRequest, "GROUP_MODEL_PRICING_MODELS_REQUIRED", "group model pricing entry requires at least one model") } } if err := validatePricingEntries(out); err != nil { return nil, err } return out, nil } func (s *adminServiceImpl) DeleteGroup(ctx context.Context, id int64) error { var groupKeys []string if s.authCacheInvalidator != nil { keys, err := s.apiKeyRepo.ListKeysByGroupID(ctx, id) if err == nil { groupKeys = keys } } affectedUserIDs, err := s.groupRepo.DeleteCascade(ctx, id) if err != nil { return err } // 注意:user_group_rate_multipliers 表通过外键 ON DELETE CASCADE 自动清理 // 事务成功后,异步失效受影响用户的订阅缓存 if len(affectedUserIDs) > 0 && s.billingCacheService != nil { groupID := id go func() { cacheCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() for _, userID := range affectedUserIDs { if err := s.billingCacheService.InvalidateSubscription(cacheCtx, userID, groupID); err != nil { logger.LegacyPrintf("service.admin", "invalidate subscription cache failed: user_id=%d group_id=%d err=%v", userID, groupID, err) } } }() } if s.authCacheInvalidator != nil { for _, key := range groupKeys { s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, key) } } return nil } func (s *adminServiceImpl) GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]APIKey, int64, error) { params := pagination.PaginationParams{Page: page, PageSize: pageSize} keys, result, err := s.apiKeyRepo.ListByGroupID(ctx, groupID, params) if err != nil { return nil, 0, err } return keys, result.Total, nil } func (s *adminServiceImpl) GetGroupRateMultipliers(ctx context.Context, groupID int64) ([]UserGroupRateEntry, error) { if s.userGroupRateRepo == nil { return nil, nil } return s.userGroupRateRepo.GetByGroupID(ctx, groupID) } func (s *adminServiceImpl) ClearGroupRateMultipliers(ctx context.Context, groupID int64) error { if s.userGroupRateRepo == nil { return nil } return s.userGroupRateRepo.DeleteByGroupID(ctx, groupID) } func (s *adminServiceImpl) BatchSetGroupRateMultipliers(ctx context.Context, groupID int64, entries []GroupRateMultiplierInput) error { if s.userGroupRateRepo == nil { return nil } for _, e := range entries { if e.RateMultiplier <= 0 { return fmt.Errorf("rate_multiplier must be > 0 (user_id=%d)", e.UserID) } } return s.userGroupRateRepo.SyncGroupRateMultipliers(ctx, groupID, entries) } func (s *adminServiceImpl) ClearGroupRPMOverrides(ctx context.Context, groupID int64) error { if s.userGroupRateRepo == nil { return nil } if err := s.userGroupRateRepo.ClearGroupRPMOverrides(ctx, groupID); err != nil { return err } // RPM override 已嵌入 auth cache snapshot (v7),变更后必须失效相关缓存。 if s.authCacheInvalidator != nil { s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, groupID) } return nil } func (s *adminServiceImpl) BatchSetGroupRPMOverrides(ctx context.Context, groupID int64, entries []GroupRPMOverrideInput) error { if s.userGroupRateRepo == nil { return nil } for _, e := range entries { if e.RPMOverride != nil && *e.RPMOverride < 0 { return infraerrors.BadRequest("INVALID_RPM_OVERRIDE", fmt.Sprintf("rpm_override must be >= 0 (user_id=%d)", e.UserID)) } } if err := s.userGroupRateRepo.SyncGroupRPMOverrides(ctx, groupID, entries); err != nil { return err } // RPM override 已嵌入 auth cache snapshot (v7),变更后必须失效相关缓存。 if s.authCacheInvalidator != nil { s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, groupID) } return nil } func (s *adminServiceImpl) UpdateGroupSortOrders(ctx context.Context, updates []GroupSortOrderUpdate) error { return s.groupRepo.UpdateSortOrders(ctx, updates) } // AdminUpdateAPIKeyGroupID 管理员修改 API Key 分组绑定 // groupID: nil=不修改, 指向0=解绑, 指向正整数=绑定到目标分组 func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID int64, groupID *int64) (*AdminUpdateAPIKeyGroupIDResult, error) { apiKey, err := s.apiKeyRepo.GetByID(ctx, keyID) if err != nil { return nil, err } if groupID == nil { // nil 表示不修改,直接返回 return &AdminUpdateAPIKeyGroupIDResult{APIKey: apiKey}, nil } if *groupID < 0 { return nil, infraerrors.BadRequest("INVALID_GROUP_ID", "group_id must be non-negative") } result := &AdminUpdateAPIKeyGroupIDResult{} if *groupID == 0 { // 0 表示解绑分组(不修改 user_allowed_groups,避免影响用户其他 Key) apiKey.GroupID = nil apiKey.Group = nil } else { // 验证目标分组存在且状态为 active group, err := s.groupRepo.GetByID(ctx, *groupID) if err != nil { return nil, err } if group.Status != StatusActive { return nil, infraerrors.BadRequest("GROUP_NOT_ACTIVE", "target group is not active") } // 订阅类型分组:用户须持有该分组的有效订阅才可绑定 if group.IsSubscriptionType() { if s.userSubRepo == nil { return nil, infraerrors.InternalServer("SUBSCRIPTION_REPOSITORY_UNAVAILABLE", "subscription repository is not configured") } if _, err := s.userSubRepo.GetActiveByUserIDAndGroupID(ctx, apiKey.UserID, *groupID); err != nil { if errors.Is(err, ErrSubscriptionNotFound) { return nil, infraerrors.BadRequest("SUBSCRIPTION_REQUIRED", "user does not have an active subscription for this group") } return nil, err } } gid := *groupID apiKey.GroupID = &gid apiKey.Group = group // 专属标准分组:使用事务保证「添加分组权限」与「更新 API Key」的原子性 if group.IsExclusive && !group.IsSubscriptionType() { opCtx := ctx var tx *dbent.Tx if s.entClient == nil { logger.LegacyPrintf("service.admin", "Warning: entClient is nil, skipping transaction protection for exclusive group binding") } else { var txErr error tx, txErr = s.entClient.Tx(ctx) if txErr != nil { return nil, fmt.Errorf("begin transaction: %w", txErr) } defer func() { _ = tx.Rollback() }() opCtx = dbent.NewTxContext(ctx, tx) } if addErr := s.userRepo.AddGroupToAllowedGroups(opCtx, apiKey.UserID, gid); addErr != nil { return nil, fmt.Errorf("add group to user allowed groups: %w", addErr) } if err := s.apiKeyRepo.Update(opCtx, apiKey, APIKeyUpdateFields{GroupID: true}); err != nil { return nil, fmt.Errorf("update api key: %w", err) } if tx != nil { if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit transaction: %w", err) } } result.AutoGrantedGroupAccess = true result.GrantedGroupID = &gid result.GrantedGroupName = group.Name // 失效认证缓存(在事务提交后执行) if s.authCacheInvalidator != nil { s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, apiKey.Key) } result.APIKey = apiKey return result, nil } } // 非专属分组 / 解绑:无需事务,单步更新即可 if err := s.apiKeyRepo.Update(ctx, apiKey, APIKeyUpdateFields{GroupID: true}); err != nil { return nil, fmt.Errorf("update api key: %w", err) } // 失效认证缓存 if s.authCacheInvalidator != nil { s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, apiKey.Key) } result.APIKey = apiKey return result, nil } // AdminResetAPIKeyRateLimitUsage resets all API key rate-limit usage windows. func (s *adminServiceImpl) AdminResetAPIKeyRateLimitUsage(ctx context.Context, keyID int64) (*APIKey, error) { apiKey, err := s.apiKeyRepo.GetByID(ctx, keyID) if err != nil { return nil, err } apiKey.Usage5h = 0 apiKey.Usage1d = 0 apiKey.Usage7d = 0 apiKey.Window5hStart = nil apiKey.Window1dStart = nil apiKey.Window7dStart = nil if err := s.apiKeyRepo.Update(ctx, apiKey, APIKeyUpdateFields{RateLimitUsage: true}); err != nil { return nil, fmt.Errorf("reset api key rate limit usage: %w", err) } if s.authCacheInvalidator != nil { s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, apiKey.Key) } if s.billingCacheService != nil { _ = s.billingCacheService.InvalidateAPIKeyRateLimit(ctx, apiKey.ID) } return apiKey, nil } // ReplaceUserGroup 替换用户的专属分组 func (s *adminServiceImpl) ReplaceUserGroup(ctx context.Context, userID, oldGroupID, newGroupID int64) (*ReplaceUserGroupResult, error) { if oldGroupID == newGroupID { return nil, infraerrors.BadRequest("SAME_GROUP", "old and new group must be different") } // 验证新分组存在且为活跃的专属标准分组 newGroup, err := s.groupRepo.GetByID(ctx, newGroupID) if err != nil { return nil, err } if newGroup.Status != StatusActive { return nil, infraerrors.BadRequest("GROUP_NOT_ACTIVE", "target group is not active") } if !newGroup.IsExclusive { return nil, infraerrors.BadRequest("GROUP_NOT_EXCLUSIVE", "target group is not exclusive") } if newGroup.IsSubscriptionType() { return nil, infraerrors.BadRequest("GROUP_IS_SUBSCRIPTION", "subscription groups are not supported for replacement") } // 事务保证原子性 if s.entClient == nil { return nil, fmt.Errorf("entClient is nil, cannot perform group replacement") } tx, err := s.entClient.Tx(ctx) if err != nil { return nil, fmt.Errorf("begin transaction: %w", err) } defer func() { _ = tx.Rollback() }() opCtx := dbent.NewTxContext(ctx, tx) // 1. 授予新分组权限 if err := s.userRepo.AddGroupToAllowedGroups(opCtx, userID, newGroupID); err != nil { return nil, fmt.Errorf("add new group to allowed groups: %w", err) } // 2. 迁移绑定旧分组的 Key 到新分组 migrated, err := s.apiKeyRepo.UpdateGroupIDByUserAndGroup(opCtx, userID, oldGroupID, newGroupID) if err != nil { return nil, fmt.Errorf("migrate api keys: %w", err) } // 3. 移除旧分组权限 if err := s.userRepo.RemoveGroupFromUserAllowedGroups(opCtx, userID, oldGroupID); err != nil { return nil, fmt.Errorf("remove old group from allowed groups: %w", err) } if err := tx.Commit(); err != nil { return nil, fmt.Errorf("commit transaction: %w", err) } // 失效该用户所有 Key 的认证缓存 if s.authCacheInvalidator != nil { keys, keyErr := s.apiKeyRepo.ListKeysByUserID(ctx, userID) if keyErr == nil { for _, k := range keys { s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, k) } } } return &ReplaceUserGroupResult{MigratedKeys: migrated}, nil }