1314 lines
47 KiB
Go
1314 lines
47 KiB
Go
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
|
|||
|
|
}
|