Files
sub2api/backend/internal/service/admin_group.go
T
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

1314 lines
47 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}