From f013bc11418280ec22fac50c22b1b0d958dc40e9 Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 7 Jul 2026 23:35:10 +0800 Subject: [PATCH] =?UTF-8?q?refactor(service):=20=E7=BA=AF=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E6=8B=86=E5=88=86=20admin=5Fservice.go=EF=BC=884409?= =?UTF-8?q?=E2=86=92642=E8=A1=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/internal/service/admin_account.go | 1075 ++++++ backend/internal/service/admin_group.go | 965 ++++++ backend/internal/service/admin_proxy.go | 608 ++++ backend/internal/service/admin_service.go | 3787 --------------------- backend/internal/service/admin_user.go | 1188 +++++++ 5 files changed, 3836 insertions(+), 3787 deletions(-) create mode 100644 backend/internal/service/admin_account.go create mode 100644 backend/internal/service/admin_group.go create mode 100644 backend/internal/service/admin_proxy.go create mode 100644 backend/internal/service/admin_user.go diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go new file mode 100644 index 0000000000..52e5ce719b --- /dev/null +++ b/backend/internal/service/admin_account.go @@ -0,0 +1,1075 @@ +package service + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" +) + +// Account management implementations +func (s *adminServiceImpl) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64, privacyMode string, sortBy, sortOrder string) ([]Account, int64, error) { + params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} + accounts, result, err := s.accountRepo.ListWithFilters(ctx, params, platform, accountType, status, search, groupID, privacyMode) + if err != nil { + return nil, 0, err + } + return accounts, result.Total, nil +} + +func (s *adminServiceImpl) ListAccountsForSchedulerScoreFilter(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, error) { + if s == nil || s.accountRepo == nil { + return nil, nil + } + return s.accountRepo.ListAllWithFilters(ctx, platform, accountType, status, search, groupID, privacyMode) +} + +func (s *adminServiceImpl) ListOpenAISchedulableAccountsForSchedulerScore(ctx context.Context, groupID *int64) ([]Account, error) { + if s == nil || s.accountRepo == nil { + return nil, nil + } + if groupID != nil { + return s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, PlatformOpenAI) + } + return s.accountRepo.ListSchedulableUngroupedByPlatform(ctx, PlatformOpenAI) +} + +func (s *adminServiceImpl) GetAccount(ctx context.Context, id int64) (*Account, error) { + return s.accountRepo.GetByID(ctx, id) +} + +func (s *adminServiceImpl) GetAccountsByIDs(ctx context.Context, ids []int64) ([]*Account, error) { + if len(ids) == 0 { + return []*Account{}, nil + } + + accounts, err := s.accountRepo.GetByIDs(ctx, ids) + if err != nil { + return nil, fmt.Errorf("failed to get accounts by IDs: %w", err) + } + + return accounts, nil +} + +func normalizeAccountConcurrency(platform, accountType string, concurrency int) int { + if platform == PlatformGrok && accountType == AccountTypeOAuth { + if concurrency <= 0 { + return 1 + } + } + return concurrency +} + +func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) { + // 绑定分组 + groupIDs := input.GroupIDs + // 如果没有指定分组,自动绑定对应平台的默认分组 + if len(groupIDs) == 0 && !input.SkipDefaultGroupBind { + defaultGroupName := input.Platform + "-default" + groups, err := s.groupRepo.ListActiveByPlatform(ctx, input.Platform) + if err == nil { + for _, g := range groups { + if g.Name == defaultGroupName { + groupIDs = []int64{g.ID} + break + } + } + } + } + + // 检查混合渠道风险(除非用户已确认) + if len(groupIDs) > 0 && !input.SkipMixedChannelCheck { + if err := s.checkMixedChannelRisk(ctx, 0, input.Platform, groupIDs); err != nil { + return nil, err + } + } + + // 校验并规范化请求头覆写配置(header 名小写化、格式检查) + if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil { + return nil, err + } + + account := &Account{ + Name: input.Name, + Notes: normalizeAccountNotes(input.Notes), + Platform: input.Platform, + Type: input.Type, + Credentials: input.Credentials, + Extra: input.Extra, + ProxyID: input.ProxyID, + Concurrency: normalizeAccountConcurrency(input.Platform, input.Type, input.Concurrency), + Priority: input.Priority, + Status: StatusActive, + Schedulable: true, + } + // 预计算固定时间重置的下次重置时间 + if account.Extra != nil { + if err := ValidateQuotaResetConfig(account.Extra); err != nil { + return nil, err + } + ComputeQuotaResetAt(account.Extra) + NormalizeFixedQuotaWindows(account.Extra) + } + if input.ExpiresAt != nil && *input.ExpiresAt > 0 { + expiresAt := time.Unix(*input.ExpiresAt, 0) + account.ExpiresAt = &expiresAt + } + if input.AutoPauseOnExpired != nil { + account.AutoPauseOnExpired = *input.AutoPauseOnExpired + } else { + account.AutoPauseOnExpired = true + } + if input.RateMultiplier != nil { + if *input.RateMultiplier < 0 { + return nil, errors.New("rate_multiplier must be >= 0") + } + account.RateMultiplier = input.RateMultiplier + } + if input.LoadFactor != nil && *input.LoadFactor > 0 { + if *input.LoadFactor > 10000 { + return nil, errors.New("load_factor must be <= 10000") + } + account.LoadFactor = input.LoadFactor + } + if err := s.accountRepo.Create(ctx, account); err != nil { + return nil, err + } + + // 绑定分组 + if len(groupIDs) > 0 { + if err := s.accountRepo.BindGroups(ctx, account.ID, groupIDs); err != nil { + return nil, err + } + } + + // OAuth 账号:创建后异步设置隐私。 + // 使用 Ensure(幂等)而非 Force:新建账号 Extra 为空时效果相同,但更安全。 + if account.Type == AccountTypeOAuth { + switch account.Platform { + case PlatformOpenAI: + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("create_account_openai_privacy_panic", "account_id", account.ID, "recover", r) + } + }() + s.EnsureOpenAIPrivacy(context.Background(), account) + }() + case PlatformAntigravity: + go func() { + defer func() { + if r := recover(); r != nil { + slog.Error("create_account_antigravity_privacy_panic", "account_id", account.ID, "recover", r) + } + }() + s.EnsureAntigravityPrivacy(context.Background(), account) + }() + } + } + + return account, nil +} + +func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *UpdateAccountInput) (*Account, error) { + account, err := s.accountRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + // 安全/身份不变量(影子账号):通用更新路径被 edit/re-auth/refresh/batch 共用, + // 必须在此守住,否则仅在创建时的保证可被这些路径绕过。 + if account.IsCredentialShadow() { + // 影子绝不持有凭据(凭据只在母账号)——外审 F5。 + if !isAllowedSparkShadowCredentialsUpdate(input.Credentials) { + return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_NO_CREDENTIALS", + "spark shadow accounts do not hold auth credentials; only model mapping can be configured on the shadow account") + } + // 影子 type 不可变——很多上游逻辑按 account.Type 分支(OAuth transform / ChatGPT + // header 注入 / WS OAuth 决策),改成 apikey 会让 spark 影子被选中后按错误协议转发(外审 G7)。 + if input.Type != "" && input.Type != account.Type { + return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_IMMUTABLE_TYPE", + "spark shadow account type cannot be changed; it must remain an OpenAI OAuth shadow") + } + } else if input.Type != "" && input.Type != account.Type && input.Type != AccountTypeOAuth { + // 母账号守卫(外审 D/P1):有 spark 影子的账号不能把 type 改出 OpenAI OAuth——影子读透母 + // 凭据,母变成 apikey/setup_token 会让影子被调度后按错协议失败(resolveCredentialAccount + // 必报错)。须先删影子再改 type。 + shadows, serr := s.accountRepo.ListShadowsByParent(ctx, id) + if serr != nil { + return nil, serr + } + if len(shadows) > 0 { + return nil, infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_PARENT_IMMUTABLE_TYPE", + "cannot change account type while it has a spark shadow; delete the shadow first") + } + } + wasOveragesEnabled := account.IsOveragesEnabled() + + if input.Name != "" { + account.Name = input.Name + } + if input.Type != "" { + account.Type = input.Type + } + if input.Notes != nil { + account.Notes = normalizeAccountNotes(input.Notes) + } + if account.IsCredentialShadow() && input.Credentials != nil { + account.Credentials = sanitizeSparkShadowCredentials(input.Credentials) + } else if len(input.Credentials) > 0 { + // 敏感子键采用"incoming 没提供就保留"的合并语义:前端响应已脱敏, + // 全对象 PUT 编辑时不会再带回 token,避免覆盖时清空已有凭证。 + account.Credentials = MergePreservingSensitiveCreds(account.Credentials, input.Credentials) + // 校验并规范化请求头覆写配置(header 名小写化、格式检查) + if err := NormalizeHeaderOverrideCredentials(account.Credentials); err != nil { + return nil, err + } + } + // Extra 使用 map:需要区分“未提供(nil)”与“显式清空({})”。 + // 关闭配额限制时前端会删除 quota_* 键并提交 extra:{},此时也必须落库。 + if input.Extra != nil { + // 保留配额用量字段,防止编辑账号时意外重置 + for _, key := range []string{"quota_used", "quota_daily_used", "quota_daily_start", "quota_weekly_used", "quota_weekly_start"} { + if v, ok := account.Extra[key]; ok { + input.Extra[key] = v + } + } + account.Extra = input.Extra + if account.Platform == PlatformAntigravity && wasOveragesEnabled && !account.IsOveragesEnabled() { + delete(account.Extra, "antigravity_credits_overages") // 清理旧版 overages 运行态 + // 清除 AICredits 限流 key + if rawLimits, ok := account.Extra[modelRateLimitsKey].(map[string]any); ok { + delete(rawLimits, creditsExhaustedKey) + } + } + if account.Platform == PlatformAntigravity && !wasOveragesEnabled && account.IsOveragesEnabled() { + delete(account.Extra, modelRateLimitsKey) + delete(account.Extra, "antigravity_credits_overages") // 清理旧版 overages 运行态 + } + // 校验并预计算固定时间重置的下次重置时间 + if err := ValidateQuotaResetConfig(account.Extra); err != nil { + return nil, err + } + ComputeQuotaResetAt(account.Extra) + NormalizeFixedQuotaWindows(account.Extra) + } + // 影子代理恒继承母账号(由 propagateProxyToShadows 同步),不接受独立编辑——外审 B/P1; + // 否则要等母账号下次改 proxy 才被覆盖,期间影子会出现"有时继承、有时独立"的漂移。 + if input.ProxyID != nil && !account.IsCredentialShadow() { + // 0 表示清除代理(前端发送 0 而不是 null 来表达清除意图) + if *input.ProxyID == 0 { + account.ProxyID = nil + } else { + account.ProxyID = input.ProxyID + } + account.Proxy = nil // 清除关联对象,防止 GORM Save 时根据 Proxy.ID 覆盖 ProxyID + } + // 只在指针非 nil 时更新 Concurrency(支持设置为 0) + if input.Concurrency != nil { + account.Concurrency = normalizeAccountConcurrency(account.Platform, account.Type, *input.Concurrency) + } + // 只在指针非 nil 时更新 Priority(支持设置为 0) + if input.Priority != nil { + account.Priority = *input.Priority + } + if input.RateMultiplier != nil { + if *input.RateMultiplier < 0 { + return nil, errors.New("rate_multiplier must be >= 0") + } + account.RateMultiplier = input.RateMultiplier + } + if input.LoadFactor != nil { + if *input.LoadFactor <= 0 { + account.LoadFactor = nil // 0 或负数表示清除 + } else if *input.LoadFactor > 10000 { + return nil, errors.New("load_factor must be <= 10000") + } else { + account.LoadFactor = input.LoadFactor + } + } + if input.Status != "" { + account.Status = input.Status + } + if input.ExpiresAt != nil { + if *input.ExpiresAt <= 0 { + account.ExpiresAt = nil + } else { + expiresAt := time.Unix(*input.ExpiresAt, 0) + account.ExpiresAt = &expiresAt + } + } + if input.AutoPauseOnExpired != nil { + account.AutoPauseOnExpired = *input.AutoPauseOnExpired + } + + // 先验证分组是否存在(在任何写操作之前) + if input.GroupIDs != nil { + if err := s.validateGroupIDsExist(ctx, *input.GroupIDs); err != nil { + return nil, err + } + + // 检查混合渠道风险(除非用户已确认) + if !input.SkipMixedChannelCheck { + if err := s.checkMixedChannelRisk(ctx, account.ID, account.Platform, *input.GroupIDs); err != nil { + return nil, err + } + } + } + + if err := s.accountRepo.Update(ctx, account); err != nil { + return nil, err + } + + // 将 proxy 变更传播到 spark 影子账号(同步;Update 内部已触发调度快照)。 + // 影子自身 proxy 不可独立编辑(见上),故对影子的更新不触发传播。 + if input.ProxyID != nil && !account.IsCredentialShadow() { + if err := s.propagateProxyToShadows(ctx, id, account.ProxyID); err != nil { + return nil, err + } + } + + // 绑定分组 + if input.GroupIDs != nil { + if err := s.accountRepo.BindGroups(ctx, account.ID, *input.GroupIDs); err != nil { + return nil, err + } + } + + // 重新查询以确保返回完整数据(包括正确的 Proxy 关联对象) + updated, err := s.accountRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + return updated, nil +} + +// UpdateAccountExtra 仅对 Extra JSONB 做 key 级合并,避免覆盖其它运行态键 +// (如 model_rate_limits / passive_usage_* 等)。 +func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, updates map[string]any) error { + if len(updates) == 0 { + return nil + } + return s.accountRepo.UpdateExtra(ctx, id, updates) +} + +// BulkUpdateAccounts updates multiple accounts in one request. +// It merges credentials/extra keys instead of overwriting the whole object. +func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error) { + if len(input.AccountIDs) == 0 && input.Filters != nil { + accountIDs, err := s.resolveBulkUpdateTargetIDs(ctx, input.Filters) + if err != nil { + return nil, err + } + input.AccountIDs = accountIDs + } + + result := &BulkUpdateAccountsResult{ + SuccessIDs: make([]int64, 0, len(input.AccountIDs)), + FailedIDs: make([]int64, 0, len(input.AccountIDs)), + Results: make([]BulkUpdateAccountResult, 0, len(input.AccountIDs)), + } + + if len(input.AccountIDs) == 0 { + return result, nil + } + if input.GroupIDs != nil { + if err := s.validateGroupIDsExist(ctx, *input.GroupIDs); err != nil { + return nil, err + } + } + + needMixedChannelCheck := input.GroupIDs != nil && !input.SkipMixedChannelCheck + + // 预取所有目标账号,供凭据守卫/代理守卫/混合渠道检查共用,避免多次 DB 查询。 + var cachedTargets []*Account + if len(input.Credentials) > 0 || input.ProxyID != nil || needMixedChannelCheck { + loaded, err := s.accountRepo.GetByIDs(ctx, input.AccountIDs) + if err != nil { + return nil, err + } + cachedTargets = loaded + } + + // 影子账号绝不持有凭据:批量更新携带凭据时,目标中不得含影子(外审 G5,与单账号 + // UpdateAccount 守卫对齐)。覆盖显式 IDs 与 filter 解析出的 IDs(此处 AccountIDs 已解析完成)。 + if len(input.Credentials) > 0 { + for _, acc := range cachedTargets { + if acc != nil && acc.IsCredentialShadow() { + return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_NO_CREDENTIALS", + "spark shadow account %d cannot hold credentials; manage credentials on the parent account", acc.ID) + } + } + } + + // 影子账号 proxy 恒继承母账号(与单账号 UpdateAccount 守卫对齐——外审第4轮 P1):批量携带 proxy + // 时目标不得含影子,否则影子会获得独立 proxy、破坏继承不变量(网关按所选影子自身 proxy 出站, + // 要等母账号下次改 proxy 才覆盖→漂移)。含影子即整体拒绝,提示从选择中剔除影子。 + if input.ProxyID != nil { + for _, acc := range cachedTargets { + if acc != nil && acc.IsCredentialShadow() { + return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_PROXY_INHERITED", + "spark shadow account %d proxy is inherited from its parent and cannot be set in bulk; manage it on the parent account", acc.ID) + } + } + } + + // 预加载账号平台信息(混合渠道检查需要)。 + platformByID := map[int64]string{} + if needMixedChannelCheck { + for _, account := range cachedTargets { + if account != nil { + platformByID[account.ID] = account.Platform + } + } + } + + // 预检查混合渠道风险:在任何写操作之前,若发现风险立即返回错误。 + if needMixedChannelCheck { + for _, accountID := range input.AccountIDs { + platform := platformByID[accountID] + if platform == "" { + continue + } + if err := s.checkMixedChannelRisk(ctx, accountID, platform, *input.GroupIDs); err != nil { + return nil, err + } + } + } + + if input.RateMultiplier != nil { + if *input.RateMultiplier < 0 { + return nil, errors.New("rate_multiplier must be >= 0") + } + } + + // 校验并规范化请求头覆写配置(批量路径为 JSONB 顶层 key 合并,直接校验增量即可) + if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil { + return nil, err + } + + // Prepare bulk updates for columns and JSONB fields. + repoUpdates := AccountBulkUpdate{ + Credentials: input.Credentials, + Extra: input.Extra, + } + if input.Name != "" { + repoUpdates.Name = &input.Name + } + if input.ProxyID != nil { + repoUpdates.ProxyID = input.ProxyID + } + if input.Concurrency != nil { + repoUpdates.Concurrency = input.Concurrency + } + if input.Priority != nil { + repoUpdates.Priority = input.Priority + } + if input.RateMultiplier != nil { + repoUpdates.RateMultiplier = input.RateMultiplier + } + if input.LoadFactor != nil { + if *input.LoadFactor <= 0 { + repoUpdates.LoadFactor = nil // 0 或负数表示清除 + } else if *input.LoadFactor > 10000 { + return nil, errors.New("load_factor must be <= 10000") + } else { + repoUpdates.LoadFactor = input.LoadFactor + } + } + if input.Status != "" { + repoUpdates.Status = &input.Status + } + if input.Schedulable != nil { + repoUpdates.Schedulable = input.Schedulable + } + + // Run bulk update for column/jsonb fields first. + if _, err := s.accountRepo.BulkUpdate(ctx, input.AccountIDs, repoUpdates); err != nil { + return nil, err + } + + // 将 proxy 变更传播到每个目标账号的 spark 影子账号 + if repoUpdates.ProxyID != nil { + var effectiveProxyID *int64 + if *repoUpdates.ProxyID != 0 { + effectiveProxyID = repoUpdates.ProxyID + } + for _, accountID := range input.AccountIDs { + if err := s.propagateProxyToShadows(ctx, accountID, effectiveProxyID); err != nil { + return nil, err + } + } + } + + // Handle group bindings per account (requires individual operations). + for _, accountID := range input.AccountIDs { + entry := BulkUpdateAccountResult{AccountID: accountID} + + if input.GroupIDs != nil { + if err := s.accountRepo.BindGroups(ctx, accountID, *input.GroupIDs); err != nil { + entry.Success = false + entry.Error = err.Error() + result.Failed++ + result.FailedIDs = append(result.FailedIDs, accountID) + result.Results = append(result.Results, entry) + continue + } + } + + entry.Success = true + result.Success++ + result.SuccessIDs = append(result.SuccessIDs, accountID) + result.Results = append(result.Results, entry) + } + + return result, nil +} + +func (s *adminServiceImpl) resolveBulkUpdateTargetIDs(ctx context.Context, filters *BulkUpdateAccountFilters) ([]int64, error) { + if filters == nil { + return nil, nil + } + + groupID := int64(0) + switch strings.TrimSpace(filters.Group) { + case "": + case "ungrouped": + groupID = AccountListGroupUngrouped + default: + parsedGroupID, err := strconv.ParseInt(strings.TrimSpace(filters.Group), 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid group filter: %w", err) + } + groupID = parsedGroupID + } + + const pageSize = 500 + page := 1 + accountIDs := make([]int64, 0, pageSize) + + for { + accounts, total, err := s.ListAccounts( + ctx, + page, + pageSize, + filters.Platform, + filters.Type, + filters.Status, + filters.Search, + groupID, + filters.PrivacyMode, + "", + "", + ) + if err != nil { + return nil, err + } + for _, account := range accounts { + accountIDs = append(accountIDs, account.ID) + } + if int64(len(accountIDs)) >= total || len(accounts) == 0 { + return accountIDs, nil + } + page++ + } +} + +func (s *adminServiceImpl) DeleteAccount(ctx context.Context, id int64) error { + // 级联删除 spark 影子账号(先删影子,再删母账号) + shadows, err := s.accountRepo.ListShadowsByParent(ctx, id) + if err != nil { + return fmt.Errorf("list spark shadows for cascade delete: %w", err) + } + for _, shadow := range shadows { + if err := s.accountRepo.Delete(ctx, shadow.ID); err != nil { + return fmt.Errorf("cascade delete spark shadow %d: %w", shadow.ID, err) + } + } + if err := s.accountRepo.Delete(ctx, id); err != nil { + return err + } + return nil +} + +func (s *adminServiceImpl) RefreshAccountCredentials(ctx context.Context, id int64) (*Account, error) { + account, err := s.accountRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + // TODO: Implement refresh logic + return account, nil +} + +func (s *adminServiceImpl) ClearAccountError(ctx context.Context, id int64) (*Account, error) { + if err := s.accountRepo.ClearError(ctx, id); err != nil { + return nil, err + } + if err := s.accountRepo.ClearRateLimit(ctx, id); err != nil { + return nil, err + } + if err := s.accountRepo.ClearAntigravityQuotaScopes(ctx, id); err != nil { + return nil, err + } + if err := s.accountRepo.ClearModelRateLimits(ctx, id); err != nil { + return nil, err + } + if err := s.accountRepo.ClearTempUnschedulable(ctx, id); err != nil { + return nil, err + } + if s.runtimeBlocker != nil { + s.runtimeBlocker.ClearAccountSchedulingBlock(id) + } + return s.accountRepo.GetByID(ctx, id) +} + +func (s *adminServiceImpl) SetAccountError(ctx context.Context, id int64, errorMsg string) error { + return s.accountRepo.SetError(ctx, id, errorMsg) +} + +func (s *adminServiceImpl) SetAccountSchedulable(ctx context.Context, id int64, schedulable bool) (*Account, error) { + if err := s.accountRepo.SetSchedulable(ctx, id, schedulable); err != nil { + return nil, err + } + updated, err := s.accountRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + return updated, nil +} + +func (s *adminServiceImpl) RevertAccountProxyFallback(ctx context.Context, id int64) error { + if err := s.accountRepo.RevertProxyFallback(ctx, id); err != nil { + return err + } + // 加载回退后的账号以获取实际 ProxyID,再传播到影子账号 + account, err := s.accountRepo.GetByID(ctx, id) + if err != nil { + return fmt.Errorf("get account after proxy revert: %w", err) + } + return s.propagateProxyToShadows(ctx, id, account.ProxyID) +} + +// CreateShadow 为指定 OpenAI OAuth 母账号创建 spark 维度影子账号(一母一影)。 +// 安全不变量:Credentials 恒不含 auth token(仅 model_mapping,守卫 isAllowedSparkShadowCredentialsUpdate 放行)。 +func (s *adminServiceImpl) CreateShadow(ctx context.Context, parentID int64, opts ShadowOptions) (*Account, error) { + // 1. 加载母账号并校验平台/类型 + parent, err := s.accountRepo.GetByID(ctx, parentID) + if err != nil { + return nil, fmt.Errorf("get parent account: %w", err) + } + if !parent.IsOpenAIOAuth() { + return nil, infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_INVALID_PARENT", + "spark shadow requires an OpenAI OAuth parent account") + } + // G6:母账号本身不能是影子,否则会建出二级影子——resolveCredentialAccount 只解一层, + // 会解析到无凭据的一级影子,进入坏调度/上游失败。 + if parent.IsCredentialShadow() { + return nil, infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_PARENT_IS_SHADOW", + "spark shadow parent must be a real account, not another spark shadow") + } + + // 2. 一母一影校验 + shadows, err := s.accountRepo.ListShadowsByParent(ctx, parentID) + if err != nil { + return nil, fmt.Errorf("check existing spark shadows: %w", err) + } + if len(shadows) > 0 { + return nil, infraerrors.New(http.StatusConflict, "SPARK_SHADOW_ALREADY_EXISTS", + "parent account already has a spark shadow account") + } + + // 3. 解析分组。未指定 GroupIDs 时:优先**继承母账号当前分组**(影子与母同路由域,母在自定义 + // 组时该组的 spark 请求也能选到影子;G1 决策);母无分组再回落 openai-default(F4)。 + // 显式指定 GroupIDs 时,与 UpdateAccount 对齐先校验存在性(创建前),避免建出影子后再因无效组 + // 失败而留下孤儿影子(一母一影唯一索引会挡住重试)——外审 C/P1。 + groupIDs := opts.GroupIDs + if len(groupIDs) > 0 { + if s.groupRepo != nil { + if err := s.validateGroupIDsExist(ctx, groupIDs); err != nil { + return nil, err + } + } + } else if len(parent.GroupIDs) > 0 { + groupIDs = append([]int64(nil), parent.GroupIDs...) + } else if s.groupRepo != nil { + defaultGroupName := PlatformOpenAI + "-default" + if groups, gerr := s.groupRepo.ListActiveByPlatform(ctx, PlatformOpenAI); gerr == nil { + for _, g := range groups { + if g.Name == defaultGroupName { + groupIDs = []int64{g.ID} + break + } + } + } + } + + // 4. 构造影子账号(安全不变量:Credentials 恒不含 auth token,仅含 model_mapping)。 + // name 为空时默认 "<母账号名> (Spark)"——否则空 name 会在 ent(name NotEmpty)处变成裸 500 + // (外审 E/P2);并 rune 安全截断到 ent MaxLen(100)。 + name := strings.TrimSpace(opts.Name) + if name == "" { + name = parent.Name + " (Spark)" + } + if runes := []rune(name); len(runes) > 100 { + name = string(runes[:100]) + } + // 并发未指定(<=0)时继承母账号,避免 0 被限流器解读为"无限并发"(外审 F3)。 + concurrency := opts.Concurrency + if concurrency <= 0 { + concurrency = parent.Concurrency + } + // 优先级未指定(<=0)时继承母账号——前端一键创建只传 name,opts.Priority 省略即 0,而调度 + // 比较是「数值越小越优先」(openai_account_scheduler.isOpenAIAccountCandidateBetter),且 repo + // 显式 SetPriority 会绕过 ent 默认 50,直写 0 会让影子意外抢到最高优先级(外审第5轮 P1)。 + // 与上方 Concurrency 一致采用「省略继承母账号」语义(影子的 proxy/分组/并发亦全部继承母账号)。 + priority := opts.Priority + if priority <= 0 { + priority = parent.Priority + } + shadow := &Account{ + Name: name, + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Credentials: map[string]any{"model_mapping": defaultSparkShadowModelMapping()}, + ParentAccountID: &parentID, + QuotaDimension: QuotaDimensionSpark, + ProxyID: parent.ProxyID, + Priority: priority, + Concurrency: concurrency, + Schedulable: true, + } + + // 5. 持久化(Create 填充 shadow.ID)。并发竞态:预查(步骤2)放行后另一请求抢先建成,本次会撞 + // 一母一影唯一索引。复查确认确为"已存在"竞态时返回结构化 409 而非裸 500——外审 A/P1。 + if err := s.accountRepo.Create(ctx, shadow); err != nil { + if existing, qerr := s.accountRepo.ListShadowsByParent(ctx, parentID); qerr == nil && len(existing) > 0 { + return nil, infraerrors.New(http.StatusConflict, "SPARK_SHADOW_ALREADY_EXISTS", + "parent account already has a spark shadow account") + } + return nil, fmt.Errorf("create spark shadow: %w", err) + } + + // 6. 绑定分组。注意:create+bind 非单一 DB 事务(通用 Create 走 r.client、outbox 走 r.sql, + // 无现成共享事务路径),故绑组失败时做 best-effort 补偿删除刚建的影子,避免半成品影子(否则 + // 一母一影唯一索引会挡住重试)——外审 C/P1。补偿删除用 detached ctx,即便请求 ctx 已取消/超时 + // 仍能完成清理(外审第4轮);进程崩溃这种极端仍可能残留,属已知权衡。 + if len(groupIDs) > 0 { + if err := s.accountRepo.BindGroups(ctx, shadow.ID, groupIDs); err != nil { + if delErr := s.accountRepo.Delete(context.WithoutCancel(ctx), shadow.ID); delErr != nil { + slog.Error("spark_shadow_bind_groups_rollback_failed", + "shadow_id", shadow.ID, "parent_id", parentID, "delete_err", delErr) + } + return nil, fmt.Errorf("bind groups for spark shadow: %w", err) + } + shadow.GroupIDs = groupIDs + } + + return shadow, nil +} + +// propagateProxyToShadows syncs proxyID to all spark shadow accounts of parentID. +// It is called synchronously so that proxy changes are immediately consistent; +// accountRepo.Update triggers the scheduler outbox + cache propagation internally. +// Calling this for a non-parent account is a harmless no-op. +func (s *adminServiceImpl) propagateProxyToShadows(ctx context.Context, parentID int64, proxyID *int64) error { + return propagateAccountProxyToShadows(ctx, s.accountRepo, parentID, proxyID) +} + +// propagateAccountProxyToShadows 把母账号的 proxy 同步到其所有 spark 影子(影子 proxy 恒继承母账号)。 +// 供 AdminService 编辑路径与 CRS 同步路径共用——后者改动母账号 proxy 后必须同样传播,否则影子保留 +// 旧 proxy 出现出站漂移(外审第8轮)。 +func propagateAccountProxyToShadows(ctx context.Context, repo AccountRepository, parentID int64, proxyID *int64) error { + shadows, err := repo.ListShadowsByParent(ctx, parentID) + if err != nil { + return fmt.Errorf("list spark shadows for proxy propagation: %w", err) + } + for _, shadow := range shadows { + shadow.ProxyID = proxyID + if err := repo.Update(ctx, shadow); err != nil { + return fmt.Errorf("update spark shadow %d proxy: %w", shadow.ID, err) + } + } + return nil +} + +// checkMixedChannelRisk 检查分组中是否存在混合渠道(Antigravity + Anthropic) +// 如果存在混合,返回错误提示用户确认 +func (s *adminServiceImpl) checkMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error { + // 判断当前账号的渠道类型(基于 platform 字段,而不是 type 字段) + currentPlatform := getAccountPlatform(currentAccountPlatform) + if currentPlatform == "" { + // 不是 Antigravity 或 Anthropic,无需检查 + return nil + } + + // 检查每个分组中的其他账号 + for _, groupID := range groupIDs { + accounts, err := s.accountRepo.ListByGroup(ctx, groupID) + if err != nil { + return fmt.Errorf("get accounts in group %d: %w", groupID, err) + } + + // 检查是否存在不同渠道的账号 + for _, account := range accounts { + if currentAccountID > 0 && account.ID == currentAccountID { + continue // 跳过当前账号 + } + + otherPlatform := getAccountPlatform(account.Platform) + if otherPlatform == "" { + continue // 不是 Antigravity 或 Anthropic,跳过 + } + + // 检测混合渠道 + if currentPlatform != otherPlatform { + group, _ := s.groupRepo.GetByID(ctx, groupID) + groupName := fmt.Sprintf("Group %d", groupID) + if group != nil { + groupName = group.Name + } + + return &MixedChannelError{ + GroupID: groupID, + GroupName: groupName, + CurrentPlatform: currentPlatform, + OtherPlatform: otherPlatform, + } + } + } + } + + return nil +} + +func (s *adminServiceImpl) validateGroupIDsExist(ctx context.Context, groupIDs []int64) error { + if len(groupIDs) == 0 { + return nil + } + if s.groupRepo == nil { + return errors.New("group repository not configured") + } + + if batchReader, ok := s.groupRepo.(groupExistenceBatchReader); ok { + existsByID, err := batchReader.ExistsByIDs(ctx, groupIDs) + if err != nil { + return fmt.Errorf("check groups exists: %w", err) + } + for _, groupID := range groupIDs { + if groupID <= 0 || !existsByID[groupID] { + return fmt.Errorf("get group: %w", ErrGroupNotFound) + } + } + return nil + } + + for _, groupID := range groupIDs { + if _, err := s.groupRepo.GetByID(ctx, groupID); err != nil { + return fmt.Errorf("get group: %w", err) + } + } + return nil +} + +// CheckMixedChannelRisk checks whether target groups contain mixed channels for the current account platform. +func (s *adminServiceImpl) CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error { + return s.checkMixedChannelRisk(ctx, currentAccountID, currentAccountPlatform, groupIDs) +} + +// getAccountPlatform 根据账号 platform 判断混合渠道检查用的平台标识 +func getAccountPlatform(accountPlatform string) string { + switch strings.ToLower(strings.TrimSpace(accountPlatform)) { + case PlatformAntigravity: + return "Antigravity" + case PlatformAnthropic, "claude": + return "Anthropic" + default: + return "" + } +} + +// MixedChannelError 混合渠道错误 +type MixedChannelError struct { + GroupID int64 + GroupName string + CurrentPlatform string + OtherPlatform string +} + +func (e *MixedChannelError) Error() string { + return fmt.Sprintf("mixed_channel_warning: Group '%s' contains both %s and %s accounts. Using mixed channels in the same context may cause thinking block signature validation issues, which will fallback to non-thinking mode for historical messages.", + e.GroupName, e.CurrentPlatform, e.OtherPlatform) +} + +func (s *adminServiceImpl) ResetAccountQuota(ctx context.Context, id int64) error { + account, err := s.accountRepo.GetByID(ctx, id) + if err != nil { + return err + } + // spark 影子账号不持自有配额(凭据透传母账号、spark 用量走独立 codex_* 维度由 QueryUsage 维护), + // 通用 quota 重置对其无意义且语义不一致——明确 400 拒绝(与 OpenAI reset-credit 对影子一致)(外审第7轮 P2)。 + if account.IsCredentialShadow() { + return infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_NO_QUOTA_RESET", + "cannot reset quota for a spark shadow account; manage it on the parent account") + } + return s.accountRepo.ResetQuotaUsed(ctx, id) +} + +// EnsureOpenAIPrivacy 检查 OpenAI OAuth 账号是否已设置 privacy_mode, +// 未设置则调用 disableOpenAITraining 并持久化到 Extra,返回设置的 mode 值。 +func (s *adminServiceImpl) EnsureOpenAIPrivacy(ctx context.Context, account *Account) string { + // 影子账号不持凭据,隐私设置由母账号管理,直接跳过。 + if account.IsCredentialShadow() { + return "" + } + if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth { + return "" + } + if s.privacyClientFactory == nil { + return "" + } + if shouldSkipOpenAIPrivacyEnsure(account.Extra) { + return "" + } + + token, _ := account.Credentials["access_token"].(string) + if token == "" { + return "" + } + + var proxyURL string + if account.ProxyID != nil { + if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil { + proxyURL = p.URL() + } + } + + mode := disableOpenAITraining(ctx, s.privacyClientFactory, token, proxyURL) + if mode == "" { + return "" + } + + _ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}) + return mode +} + +// ForceOpenAIPrivacy 强制重新设置 OpenAI OAuth 账号隐私,无论当前状态。 +func (s *adminServiceImpl) ForceOpenAIPrivacy(ctx context.Context, account *Account) string { + // 影子账号不持凭据,隐私由母账号管理,直接跳过(与 EnsureOpenAIPrivacy 一致——外审第4轮)。 + if account.IsCredentialShadow() { + return "" + } + if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth { + return "" + } + if s.privacyClientFactory == nil { + return "" + } + + token, _ := account.Credentials["access_token"].(string) + if token == "" { + return "" + } + + var proxyURL string + if account.ProxyID != nil { + if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil { + proxyURL = p.URL() + } + } + + mode := disableOpenAITraining(ctx, s.privacyClientFactory, token, proxyURL) + if mode == "" { + return "" + } + + if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil { + logger.LegacyPrintf("service.admin", "force_update_openai_privacy_mode_failed: account_id=%d err=%v", account.ID, err) + return mode + } + if account.Extra == nil { + account.Extra = make(map[string]any) + } + account.Extra["privacy_mode"] = mode + return mode +} + +// EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号隐私状态。 +// 仅当 privacy_mode 已成功设置("privacy_set")时跳过; +// 未设置或之前失败("privacy_set_failed")均会重试。 +func (s *adminServiceImpl) EnsureAntigravityPrivacy(ctx context.Context, account *Account) string { + if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth { + return "" + } + if account.Extra != nil { + if existing, ok := account.Extra["privacy_mode"].(string); ok && existing == AntigravityPrivacySet { + return existing + } + } + + token, _ := account.Credentials["access_token"].(string) + if token == "" { + return "" + } + + projectID, _ := account.Credentials["project_id"].(string) + + var proxyURL string + if account.ProxyID != nil { + if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil { + proxyURL = p.URL() + } + } + + mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL) + if mode == "" { + return "" + } + + if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil { + logger.LegacyPrintf("service.admin", "update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err) + return mode + } + applyAntigravityPrivacyMode(account, mode) + return mode +} + +// ForceAntigravityPrivacy 强制重新设置 Antigravity OAuth 账号隐私,无论当前状态。 +func (s *adminServiceImpl) ForceAntigravityPrivacy(ctx context.Context, account *Account) string { + if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth { + return "" + } + + token, _ := account.Credentials["access_token"].(string) + if token == "" { + return "" + } + + projectID, _ := account.Credentials["project_id"].(string) + + var proxyURL string + if account.ProxyID != nil { + if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil { + proxyURL = p.URL() + } + } + + mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL) + if mode == "" { + return "" + } + + if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil { + logger.LegacyPrintf("service.admin", "force_update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err) + return mode + } + applyAntigravityPrivacyMode(account, mode) + return mode +} diff --git a/backend/internal/service/admin_group.go b/backend/internal/service/admin_group.go new file mode 100644 index 0000000000..43f7508722 --- /dev/null +++ b/backend/internal/service/admin_group.go @@ -0,0 +1,965 @@ +package service + +import ( + "context" + "errors" + "fmt" + "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 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 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() + 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 (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) { + if input.RateMultiplier <= 0 { + return nil, errors.New("rate_multiplier must be > 0") + } + + platform := input.Platform + if platform == "" { + platform = PlatformAnthropic + } + + 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) + 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") + } + + 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 + } + + // 校验降级分组 + 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 srcGroup.Platform != 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, + AllowImageGeneration: allowImageGeneration, + AllowBatchImageGeneration: allowBatchImageGeneration, + ImageRateIndependent: input.ImageRateIndependent, + ImageRateMultiplier: imageRateMultiplier, + BatchImageDiscountMultiplier: batchImageDiscountMultiplier, + BatchImageHoldMultiplier: batchImageHoldMultiplier, + PeakRateEnabled: peakRateEnabled, + PeakStart: peakStart, + PeakEnd: peakEnd, + PeakRateMultiplier: peakRateMultiplier, + ImagePrice1K: imagePrice1K, + ImagePrice2K: imagePrice2K, + ImagePrice4K: imagePrice4K, + ClaudeCodeOnly: input.ClaudeCodeOnly, + FallbackGroupID: input.FallbackGroupID, + FallbackGroupIDOnInvalidRequest: fallbackOnInvalidRequest, + ModelRouting: input.ModelRouting, + MCPXMLInject: mcpXMLInject, + SupportedModelScopes: input.SupportedModelScopes, + AllowMessagesDispatch: input.AllowMessagesDispatch, + RequireOAuthOnly: input.RequireOAuthOnly, + RequirePrivacySet: input.RequirePrivacySet, + DefaultMappedModel: input.DefaultMappedModel, + MessagesDispatchModelConfig: normalizeOpenAIMessagesDispatchModelConfig(input.MessagesDispatchModelConfig), + ModelsListConfig: normalizeGroupModelsListConfig(input.ModelsListConfig), + RPMLimit: input.RPMLimit, + } + sanitizeGroupMessagesDispatchFields(group) + if err := s.groupRepo.Create(ctx, group); err != nil { + return nil, err + } + + // require_oauth_only: 过滤掉 apikey 类型账号 + if group.RequireOAuthOnly && (group.Platform == PlatformOpenAI || group.Platform == PlatformAntigravity || group.Platform == PlatformAnthropic || group.Platform == PlatformGemini || group.Platform == PlatformGrok) && 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 + } + + 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.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.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.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) + } + + // 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.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 + } + sanitizeGroupMessagesDispatchFields(group) + + if err := s.groupRepo.Update(ctx, group); err != nil { + return nil, err + } + + if s.authCacheInvalidator != nil { + s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, id) + } + + // 如果指定了复制账号的源分组,同步绑定(替换当前分组的账号) + 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 srcGroup.Platform != group.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 && (group.Platform == PlatformOpenAI || group.Platform == PlatformAntigravity || group.Platform == PlatformAnthropic || group.Platform == PlatformGemini || group.Platform == PlatformGrok) && 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 (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); 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); 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); 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 +} diff --git a/backend/internal/service/admin_proxy.go b/backend/internal/service/admin_proxy.go new file mode 100644 index 0000000000..9b5a9223c7 --- /dev/null +++ b/backend/internal/service/admin_proxy.go @@ -0,0 +1,608 @@ +package service + +import ( + "context" + "fmt" + "io" + "net/http" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/httpclient" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" + "github.com/Wei-Shaw/sub2api/internal/util/httputil" +) + +// Proxy management implementations +func (s *adminServiceImpl) ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string, sortBy, sortOrder string) ([]Proxy, int64, error) { + params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} + proxies, result, err := s.proxyRepo.ListWithFilters(ctx, params, protocol, status, search) + if err != nil { + return nil, 0, err + } + return proxies, result.Total, nil +} + +func (s *adminServiceImpl) ListProxiesWithAccountCount(ctx context.Context, page, pageSize int, protocol, status, search string, sortBy, sortOrder string) ([]ProxyWithAccountCount, int64, error) { + params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} + proxies, result, err := s.proxyRepo.ListWithFiltersAndAccountCount(ctx, params, protocol, status, search) + if err != nil { + return nil, 0, err + } + s.attachProxyLatency(ctx, proxies) + return proxies, result.Total, nil +} + +func (s *adminServiceImpl) GetAllProxies(ctx context.Context) ([]Proxy, error) { + return s.proxyRepo.ListActive(ctx) +} + +func (s *adminServiceImpl) GetAllProxiesWithAccountCount(ctx context.Context) ([]ProxyWithAccountCount, error) { + proxies, err := s.proxyRepo.ListActiveWithAccountCount(ctx) + if err != nil { + return nil, err + } + s.attachProxyLatency(ctx, proxies) + return proxies, nil +} + +func (s *adminServiceImpl) GetProxy(ctx context.Context, id int64) (*Proxy, error) { + return s.proxyRepo.GetByID(ctx, id) +} + +func (s *adminServiceImpl) GetProxiesByIDs(ctx context.Context, ids []int64) ([]Proxy, error) { + return s.proxyRepo.ListByIDs(ctx, ids) +} + +func (s *adminServiceImpl) CreateProxy(ctx context.Context, input *CreateProxyInput) (*Proxy, error) { + // 规范化 fallback_mode + mode := input.FallbackMode + if mode == "" { + mode = FallbackModeNone + } + // 校验:mode=proxy 必须有 backup + if mode == FallbackModeProxy && input.BackupProxyID == nil { + return nil, infraerrors.BadRequest("PROXY_BACKUP_REQUIRED", "backup proxy required when fallback_mode=proxy") + } + if input.ExpiryWarnDays < 0 { + return nil, infraerrors.BadRequest("PROXY_WARN_DAYS_INVALID", "expiry_warn_days must be >= 0") + } + + proxy := &Proxy{ + Name: input.Name, + Protocol: input.Protocol, + Host: input.Host, + Port: input.Port, + Username: input.Username, + Password: input.Password, + Status: StatusActive, + ExpiresAt: input.ExpiresAt, + FallbackMode: mode, + BackupProxyID: input.BackupProxyID, + ExpiryWarnDays: input.ExpiryWarnDays, + } + if err := s.proxyRepo.Create(ctx, proxy); err != nil { + return nil, err + } + // Probe latency asynchronously so creation isn't blocked by network timeout. + go s.probeProxyLatency(context.Background(), proxy) + return proxy, nil +} + +func (s *adminServiceImpl) UpdateProxy(ctx context.Context, id int64, input *UpdateProxyInput) (*Proxy, error) { + // 校验:backup_proxy_id 不能是自身 + if input.BackupProxyID != nil && *input.BackupProxyID == id { + return nil, infraerrors.BadRequest("PROXY_BACKUP_SELF", "backup proxy cannot be itself") + } + // 规范化 fallback_mode + mode := input.FallbackMode + if mode == "" { + mode = FallbackModeNone + } + // 校验:mode=proxy 必须有 backup + if mode == FallbackModeProxy && input.BackupProxyID == nil { + return nil, infraerrors.BadRequest("PROXY_BACKUP_REQUIRED", "backup proxy required when fallback_mode=proxy") + } + if input.ExpiryWarnDays < 0 { + return nil, infraerrors.BadRequest("PROXY_WARN_DAYS_INVALID", "expiry_warn_days must be >= 0") + } + + proxy, err := s.proxyRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + if input.Name != "" { + proxy.Name = input.Name + } + if input.Protocol != "" { + proxy.Protocol = input.Protocol + } + if input.Host != "" { + proxy.Host = input.Host + } + if input.Port != 0 { + proxy.Port = input.Port + } + if input.Username != "" { + proxy.Username = input.Username + } + if input.Password != "" { + proxy.Password = input.Password + } + if input.Status != "" { + proxy.Status = input.Status + } + // 透传有效期与回退字段 + proxy.ExpiresAt = input.ExpiresAt + proxy.FallbackMode = mode + proxy.BackupProxyID = input.BackupProxyID + proxy.ExpiryWarnDays = input.ExpiryWarnDays + + if err := s.proxyRepo.Update(ctx, proxy); err != nil { + return nil, err + } + return proxy, nil +} + +func (s *adminServiceImpl) DeleteProxy(ctx context.Context, id int64) error { + count, err := s.proxyRepo.CountAccountsByProxyID(ctx, id) + if err != nil { + return err + } + if count > 0 { + return ErrProxyInUse + } + return s.proxyRepo.Delete(ctx, id) +} + +func (s *adminServiceImpl) BatchDeleteProxies(ctx context.Context, ids []int64) (*ProxyBatchDeleteResult, error) { + result := &ProxyBatchDeleteResult{} + if len(ids) == 0 { + return result, nil + } + + for _, id := range ids { + count, err := s.proxyRepo.CountAccountsByProxyID(ctx, id) + if err != nil { + result.Skipped = append(result.Skipped, ProxyBatchDeleteSkipped{ + ID: id, + Reason: err.Error(), + }) + continue + } + if count > 0 { + result.Skipped = append(result.Skipped, ProxyBatchDeleteSkipped{ + ID: id, + Reason: ErrProxyInUse.Error(), + }) + continue + } + if err := s.proxyRepo.Delete(ctx, id); err != nil { + result.Skipped = append(result.Skipped, ProxyBatchDeleteSkipped{ + ID: id, + Reason: err.Error(), + }) + continue + } + result.DeletedIDs = append(result.DeletedIDs, id) + } + + return result, nil +} + +func (s *adminServiceImpl) GetProxyAccounts(ctx context.Context, proxyID int64) ([]ProxyAccountSummary, error) { + return s.proxyRepo.ListAccountSummariesByProxyID(ctx, proxyID) +} + +func (s *adminServiceImpl) CheckProxyExists(ctx context.Context, host string, port int, username, password string) (bool, error) { + return s.proxyRepo.ExistsByHostPortAuth(ctx, host, port, username, password) +} + +func (s *adminServiceImpl) TestProxy(ctx context.Context, id int64) (*ProxyTestResult, error) { + proxy, err := s.proxyRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + proxyURL := proxy.URL() + exitInfo, latencyMs, err := s.proxyProber.ProbeProxy(ctx, proxyURL) + if err != nil { + s.saveProxyLatency(ctx, id, &ProxyLatencyInfo{ + Success: false, + Message: err.Error(), + UpdatedAt: time.Now(), + }) + return &ProxyTestResult{ + Success: false, + Message: err.Error(), + }, nil + } + + latency := latencyMs + s.saveProxyLatency(ctx, id, &ProxyLatencyInfo{ + Success: true, + LatencyMs: &latency, + Message: "Proxy is accessible", + IPAddress: exitInfo.IP, + Country: exitInfo.Country, + CountryCode: exitInfo.CountryCode, + Region: exitInfo.Region, + City: exitInfo.City, + UpdatedAt: time.Now(), + }) + return &ProxyTestResult{ + Success: true, + Message: "Proxy is accessible", + LatencyMs: latencyMs, + IPAddress: exitInfo.IP, + City: exitInfo.City, + Region: exitInfo.Region, + Country: exitInfo.Country, + CountryCode: exitInfo.CountryCode, + }, nil +} + +func (s *adminServiceImpl) CheckProxyQuality(ctx context.Context, id int64) (*ProxyQualityCheckResult, error) { + proxy, err := s.proxyRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + result := &ProxyQualityCheckResult{ + ProxyID: id, + Score: 100, + Grade: "A", + CheckedAt: time.Now().Unix(), + Items: make([]ProxyQualityCheckItem, 0, len(proxyQualityTargets)+1), + } + + proxyURL := proxy.URL() + if s.proxyProber == nil { + result.Items = append(result.Items, ProxyQualityCheckItem{ + Target: "base_connectivity", + Status: "fail", + Message: "代理探测服务未配置", + }) + result.FailedCount++ + finalizeProxyQualityResult(result) + s.saveProxyQualitySnapshot(ctx, id, result, nil) + return result, nil + } + + exitInfo, latencyMs, err := s.proxyProber.ProbeProxy(ctx, proxyURL) + if err != nil { + result.Items = append(result.Items, ProxyQualityCheckItem{ + Target: "base_connectivity", + Status: "fail", + LatencyMs: latencyMs, + Message: err.Error(), + }) + result.FailedCount++ + finalizeProxyQualityResult(result) + s.saveProxyQualitySnapshot(ctx, id, result, nil) + return result, nil + } + + result.ExitIP = exitInfo.IP + result.Country = exitInfo.Country + result.CountryCode = exitInfo.CountryCode + result.BaseLatencyMs = latencyMs + result.Items = append(result.Items, ProxyQualityCheckItem{ + Target: "base_connectivity", + Status: "pass", + LatencyMs: latencyMs, + Message: "代理出口连通正常", + }) + result.PassedCount++ + + client, err := httpclient.GetClient(httpclient.Options{ + ProxyURL: proxyURL, + Timeout: proxyQualityRequestTimeout, + ResponseHeaderTimeout: proxyQualityResponseHeaderTimeout, + }) + if err != nil { + result.Items = append(result.Items, ProxyQualityCheckItem{ + Target: "http_client", + Status: "fail", + Message: fmt.Sprintf("创建检测客户端失败: %v", err), + }) + result.FailedCount++ + finalizeProxyQualityResult(result) + s.saveProxyQualitySnapshot(ctx, id, result, exitInfo) + return result, nil + } + + for _, target := range proxyQualityTargets { + item := runProxyQualityTarget(ctx, client, target) + result.Items = append(result.Items, item) + switch item.Status { + case "pass": + result.PassedCount++ + case "warn": + result.WarnCount++ + case "challenge": + result.ChallengeCount++ + default: + result.FailedCount++ + } + } + + finalizeProxyQualityResult(result) + s.saveProxyQualitySnapshot(ctx, id, result, exitInfo) + return result, nil +} + +func runProxyQualityTarget(ctx context.Context, client *http.Client, target proxyQualityTarget) ProxyQualityCheckItem { + item := ProxyQualityCheckItem{ + Target: target.Target, + } + + req, err := http.NewRequestWithContext(ctx, target.Method, target.URL, nil) + if err != nil { + item.Status = "fail" + item.Message = fmt.Sprintf("构建请求失败: %v", err) + return item + } + req.Header.Set("Accept", "application/json,text/html,*/*") + req.Header.Set("User-Agent", proxyQualityClientUserAgent) + + start := time.Now() + resp, err := client.Do(req) + if err != nil { + item.Status = "fail" + item.LatencyMs = time.Since(start).Milliseconds() + item.Message = fmt.Sprintf("请求失败: %v", err) + return item + } + defer func() { _ = resp.Body.Close() }() + item.LatencyMs = time.Since(start).Milliseconds() + item.HTTPStatus = resp.StatusCode + + body, readErr := io.ReadAll(io.LimitReader(resp.Body, proxyQualityMaxBodyBytes+1)) + if readErr != nil { + item.Status = "fail" + item.Message = fmt.Sprintf("读取响应失败: %v", readErr) + return item + } + if int64(len(body)) > proxyQualityMaxBodyBytes { + body = body[:proxyQualityMaxBodyBytes] + } + + // Cloudflare challenge 检测 + if httputil.IsCloudflareChallengeResponse(resp.StatusCode, resp.Header, body) { + item.Status = "challenge" + item.CFRay = httputil.ExtractCloudflareRayID(resp.Header, body) + item.Message = "命中 Cloudflare challenge" + return item + } + + if _, ok := target.AllowedStatuses[resp.StatusCode]; ok { + // 白名单内的状态码均代表目标可达:2xx 表示接口直接可用, + // 401/405 等是无鉴权探测的预期结果,同样视为连通正常,不再扣分。 + item.Status = "pass" + if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { + item.Message = fmt.Sprintf("HTTP %d", resp.StatusCode) + } else { + item.Message = fmt.Sprintf("HTTP %d(目标可达)", resp.StatusCode) + } + return item + } + + if resp.StatusCode == http.StatusTooManyRequests { + item.Status = "warn" + item.Message = "目标返回 429,可能存在频控" + return item + } + + item.Status = "fail" + item.Message = fmt.Sprintf("非预期状态码: %d", resp.StatusCode) + return item +} + +func finalizeProxyQualityResult(result *ProxyQualityCheckResult) { + if result == nil { + return + } + score := 100 - result.WarnCount*10 - result.FailedCount*22 - result.ChallengeCount*30 + if score < 0 { + score = 0 + } + result.Score = score + result.Grade = proxyQualityGrade(score) + result.Summary = fmt.Sprintf( + "通过 %d 项,告警 %d 项,失败 %d 项,挑战 %d 项", + result.PassedCount, + result.WarnCount, + result.FailedCount, + result.ChallengeCount, + ) +} + +func proxyQualityGrade(score int) string { + switch { + case score >= 90: + return "A" + case score >= 75: + return "B" + case score >= 60: + return "C" + case score >= 40: + return "D" + default: + return "F" + } +} + +func proxyQualityOverallStatus(result *ProxyQualityCheckResult) string { + if result == nil { + return "" + } + if result.ChallengeCount > 0 { + return "challenge" + } + if result.FailedCount > 0 { + return "failed" + } + if result.WarnCount > 0 { + return "warn" + } + if result.PassedCount > 0 { + return "healthy" + } + return "failed" +} + +func proxyQualityFirstCFRay(result *ProxyQualityCheckResult) string { + if result == nil { + return "" + } + for _, item := range result.Items { + if item.CFRay != "" { + return item.CFRay + } + } + return "" +} + +func proxyQualityBaseConnectivityPass(result *ProxyQualityCheckResult) bool { + if result == nil { + return false + } + for _, item := range result.Items { + if item.Target == "base_connectivity" { + return item.Status == "pass" + } + } + return false +} + +func (s *adminServiceImpl) saveProxyQualitySnapshot(ctx context.Context, proxyID int64, result *ProxyQualityCheckResult, exitInfo *ProxyExitInfo) { + if result == nil { + return + } + score := result.Score + checkedAt := result.CheckedAt + info := &ProxyLatencyInfo{ + Success: proxyQualityBaseConnectivityPass(result), + Message: result.Summary, + QualityStatus: proxyQualityOverallStatus(result), + QualityScore: &score, + QualityGrade: result.Grade, + QualitySummary: result.Summary, + QualityCheckedAt: &checkedAt, + QualityCFRay: proxyQualityFirstCFRay(result), + UpdatedAt: time.Now(), + } + if result.BaseLatencyMs > 0 { + latency := result.BaseLatencyMs + info.LatencyMs = &latency + } + if exitInfo != nil { + info.IPAddress = exitInfo.IP + info.Country = exitInfo.Country + info.CountryCode = exitInfo.CountryCode + info.Region = exitInfo.Region + info.City = exitInfo.City + } + s.saveProxyLatency(ctx, proxyID, info) +} + +func (s *adminServiceImpl) probeProxyLatency(ctx context.Context, proxy *Proxy) { + if s.proxyProber == nil || proxy == nil { + return + } + exitInfo, latencyMs, err := s.proxyProber.ProbeProxy(ctx, proxy.URL()) + if err != nil { + s.saveProxyLatency(ctx, proxy.ID, &ProxyLatencyInfo{ + Success: false, + Message: err.Error(), + UpdatedAt: time.Now(), + }) + return + } + + latency := latencyMs + s.saveProxyLatency(ctx, proxy.ID, &ProxyLatencyInfo{ + Success: true, + LatencyMs: &latency, + Message: "Proxy is accessible", + IPAddress: exitInfo.IP, + Country: exitInfo.Country, + CountryCode: exitInfo.CountryCode, + Region: exitInfo.Region, + City: exitInfo.City, + UpdatedAt: time.Now(), + }) +} + +func (s *adminServiceImpl) attachProxyLatency(ctx context.Context, proxies []ProxyWithAccountCount) { + if s.proxyLatencyCache == nil || len(proxies) == 0 { + return + } + + ids := make([]int64, 0, len(proxies)) + for i := range proxies { + ids = append(ids, proxies[i].ID) + } + + latencies, err := s.proxyLatencyCache.GetProxyLatencies(ctx, ids) + if err != nil { + logger.LegacyPrintf("service.admin", "Warning: load proxy latency cache failed: %v", err) + return + } + + for i := range proxies { + info := latencies[proxies[i].ID] + if info == nil { + continue + } + if info.Success { + proxies[i].LatencyStatus = "success" + proxies[i].LatencyMs = info.LatencyMs + } else { + proxies[i].LatencyStatus = "failed" + } + proxies[i].LatencyMessage = info.Message + proxies[i].IPAddress = info.IPAddress + proxies[i].Country = info.Country + proxies[i].CountryCode = info.CountryCode + proxies[i].Region = info.Region + proxies[i].City = info.City + proxies[i].QualityStatus = info.QualityStatus + proxies[i].QualityScore = info.QualityScore + proxies[i].QualityGrade = info.QualityGrade + proxies[i].QualitySummary = info.QualitySummary + proxies[i].QualityChecked = info.QualityCheckedAt + } +} + +func (s *adminServiceImpl) saveProxyLatency(ctx context.Context, proxyID int64, info *ProxyLatencyInfo) { + if s.proxyLatencyCache == nil || info == nil { + return + } + + merged := *info + if latencies, err := s.proxyLatencyCache.GetProxyLatencies(ctx, []int64{proxyID}); err == nil { + if existing := latencies[proxyID]; existing != nil { + if merged.QualityCheckedAt == nil && + merged.QualityScore == nil && + merged.QualityGrade == "" && + merged.QualityStatus == "" && + merged.QualitySummary == "" && + merged.QualityCFRay == "" { + merged.QualityStatus = existing.QualityStatus + merged.QualityScore = existing.QualityScore + merged.QualityGrade = existing.QualityGrade + merged.QualitySummary = existing.QualitySummary + merged.QualityCheckedAt = existing.QualityCheckedAt + merged.QualityCFRay = existing.QualityCFRay + } + } + } + + if err := s.proxyLatencyCache.SetProxyLatency(ctx, proxyID, &merged); err != nil { + logger.LegacyPrintf("service.admin", "Warning: store proxy latency cache failed: %v", err) + } +} diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index f3b6fd555a..beffca8e41 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -2,31 +2,11 @@ package service import ( "context" - "database/sql" - "encoding/json" - "errors" - "fmt" - "io" - "log/slog" "net/http" - "sort" - "strconv" - "strings" "time" dbent "github.com/Wei-Shaw/sub2api/ent" - "github.com/Wei-Shaw/sub2api/ent/authidentity" - "github.com/Wei-Shaw/sub2api/ent/authidentitychannel" - "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/httpclient" - "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" - "github.com/Wei-Shaw/sub2api/internal/util/httputil" ) // AdminService interface defines admin management operations @@ -640,3770 +620,3 @@ func NewAdminService( runtimeBlocker: runtimeBlocker, } } - -// User management implementations -func (s *adminServiceImpl) ListUsers(ctx context.Context, page, pageSize int, filters UserListFilters, sortBy, sortOrder string) ([]User, int64, error) { - params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} - users, result, err := s.userRepo.ListWithFilters(ctx, params, filters) - if err != nil { - return nil, 0, err - } - if len(users) > 0 { - userIDs := make([]int64, 0, len(users)) - for i := range users { - userIDs = append(userIDs, users[i].ID) - } - lastUsedByUserID, latestErr := s.userRepo.GetLatestUsedAtByUserIDs(ctx, userIDs) - if latestErr != nil { - logger.LegacyPrintf("service.admin", "failed to load user last_used_at in batch: err=%v", latestErr) - } else { - for i := range users { - users[i].LastUsedAt = lastUsedByUserID[users[i].ID] - } - } - } - // 批量加载用户专属分组倍率 - if s.userGroupRateRepo != nil && len(users) > 0 { - if batchRepo, ok := s.userGroupRateRepo.(userGroupRateBatchReader); ok { - userIDs := make([]int64, 0, len(users)) - for i := range users { - userIDs = append(userIDs, users[i].ID) - } - ratesByUser, err := batchRepo.GetByUserIDs(ctx, userIDs) - if err != nil { - logger.LegacyPrintf("service.admin", "failed to load user group rates in batch: err=%v", err) - s.loadUserGroupRatesOneByOne(ctx, users) - } else { - for i := range users { - if rates, ok := ratesByUser[users[i].ID]; ok { - users[i].GroupRates = rates - } - } - } - } else { - s.loadUserGroupRatesOneByOne(ctx, users) - } - } - return users, result.Total, nil -} - -func (s *adminServiceImpl) loadUserGroupRatesOneByOne(ctx context.Context, users []User) { - if s.userGroupRateRepo == nil { - return - } - for i := range users { - rates, err := s.userGroupRateRepo.GetByUserID(ctx, users[i].ID) - if err != nil { - logger.LegacyPrintf("service.admin", "failed to load user group rates: user_id=%d err=%v", users[i].ID, err) - continue - } - users[i].GroupRates = rates - } -} - -func (s *adminServiceImpl) GetUser(ctx context.Context, id int64) (*User, error) { - user, err := s.userRepo.GetByID(ctx, id) - if err != nil { - return nil, err - } - lastUsedAt, latestErr := s.userRepo.GetLatestUsedAtByUserID(ctx, id) - if latestErr != nil { - logger.LegacyPrintf("service.admin", "failed to load user last_used_at: user_id=%d err=%v", id, latestErr) - } else { - user.LastUsedAt = lastUsedAt - } - // 加载用户专属分组倍率 - if s.userGroupRateRepo != nil { - rates, err := s.userGroupRateRepo.GetByUserID(ctx, id) - if err != nil { - logger.LegacyPrintf("service.admin", "failed to load user group rates: user_id=%d err=%v", id, err) - } else { - user.GroupRates = rates - } - } - return user, nil -} - -func (s *adminServiceImpl) GetUserIncludeDeleted(ctx context.Context, id int64) (*User, error) { - return s.userRepo.GetByIDIncludeDeleted(ctx, id) -} - -func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInput) (*User, error) { - balance := 0.0 - if input.Balance != nil { - balance = *input.Balance - } else if s.settingService != nil { - balance = s.settingService.GetDefaultBalance(ctx) - } - - user := &User{ - Email: input.Email, - Username: input.Username, - Notes: input.Notes, - Role: RoleUser, // Always create as regular user, never admin - Balance: balance, - Concurrency: input.Concurrency, - RPMLimit: input.RPMLimit, - Status: StatusActive, - AllowedGroups: input.AllowedGroups, - } - if err := user.SetPassword(input.Password); err != nil { - return nil, err - } - if err := s.userRepo.Create(ctx, user); err != nil { - return nil, err - } - s.assignDefaultSubscriptions(ctx, user.ID) - return user, nil -} - -func (s *adminServiceImpl) assignDefaultSubscriptions(ctx context.Context, userID int64) { - if s.settingService == nil || s.defaultSubAssigner == nil || userID <= 0 { - return - } - items := s.settingService.GetDefaultSubscriptions(ctx) - for _, item := range items { - if _, _, err := s.defaultSubAssigner.AssignOrExtendSubscription(ctx, &AssignSubscriptionInput{ - UserID: userID, - GroupID: item.GroupID, - ValidityDays: item.ValidityDays, - Notes: "auto assigned by default user subscriptions setting", - }); err != nil { - logger.LegacyPrintf("service.admin", "failed to assign default subscription: user_id=%d group_id=%d err=%v", userID, item.GroupID, err) - } - } -} - -func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error) { - // 校验用户专属分组倍率:必须 > 0(nil 合法,表示清除专属倍率) - if input.GroupRates != nil { - for groupID, rate := range input.GroupRates { - if rate != nil && *rate <= 0 { - return nil, fmt.Errorf("rate_multiplier must be > 0 (group_id=%d)", groupID) - } - } - } - - user, err := s.userRepo.GetByID(ctx, id) - if err != nil { - return nil, err - } - - // Protect admin users: cannot disable admin accounts - if user.Role == "admin" && input.Status == "disabled" { - return nil, errors.New("cannot disable admin user") - } - - oldConcurrency := user.Concurrency - oldStatus := user.Status - oldRole := user.Role - oldRPMLimit := user.RPMLimit - oldAllowedGroups := append([]int64(nil), user.AllowedGroups...) - - if input.Email != "" { - user.Email = input.Email - } - if input.Password != "" { - if err := user.SetPassword(input.Password); err != nil { - return nil, err - } - } - - if input.Username != nil { - user.Username = *input.Username - } - if input.Notes != nil { - user.Notes = *input.Notes - } - - if input.Status != "" { - user.Status = input.Status - } - - if input.Concurrency != nil { - user.Concurrency = *input.Concurrency - } - - if input.RPMLimit != nil { - user.RPMLimit = *input.RPMLimit - } - - if input.AllowedGroups != nil { - user.AllowedGroups = *input.AllowedGroups - } - - if err := s.userRepo.Update(ctx, user); err != nil { - return nil, err - } - - // 同步用户专属分组倍率 - if input.GroupRates != nil && s.userGroupRateRepo != nil { - if err := s.userGroupRateRepo.SyncUserGroupRates(ctx, user.ID, input.GroupRates); err != nil { - logger.LegacyPrintf("service.admin", "failed to sync user group rates: user_id=%d err=%v", user.ID, err) - } - } - - if s.authCacheInvalidator != nil { - // RPMLimit 直接参与 billing_cache_service.checkRPM 的三级级联, - // allowed_groups 参与 API Key 专属分组授权判断;不失效缓存会让修改在一个 L2 TTL 内失去效果。 - if user.Concurrency != oldConcurrency || user.Status != oldStatus || user.Role != oldRole || user.RPMLimit != oldRPMLimit || !sameInt64Set(user.AllowedGroups, oldAllowedGroups) { - s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, user.ID) - } - } - - concurrencyDiff := user.Concurrency - oldConcurrency - if concurrencyDiff != 0 { - code, err := GenerateRedeemCode() - if err != nil { - logger.LegacyPrintf("service.admin", "failed to generate adjustment redeem code: %v", err) - return user, nil - } - adjustmentRecord := &RedeemCode{ - Code: code, - Type: AdjustmentTypeAdminConcurrency, - Value: float64(concurrencyDiff), - Status: StatusUsed, - UsedBy: &user.ID, - } - now := time.Now() - adjustmentRecord.UsedAt = &now - if err := s.redeemCodeRepo.Create(ctx, adjustmentRecord); err != nil { - logger.LegacyPrintf("service.admin", "failed to create concurrency adjustment redeem code: %v", err) - } - } - - return user, nil -} - -func sameInt64Set(a, b []int64) bool { - if len(a) != len(b) { - return false - } - if len(a) == 0 { - return true - } - counts := make(map[int64]int, len(a)) - for _, v := range a { - counts[v]++ - } - for _, v := range b { - if counts[v] == 0 { - return false - } - counts[v]-- - } - return true -} - -func (s *adminServiceImpl) DeleteUser(ctx context.Context, id int64) error { - // Protect admin users: cannot delete admin accounts - user, err := s.userRepo.GetByID(ctx, id) - if err != nil { - return err - } - if user.Role == "admin" { - return errors.New("cannot delete admin user") - } - - apiKeys, err := s.listUserAPIKeysForDeletion(ctx, id) - if err != nil { - return err - } - - if s.entClient != nil { - tx, err := s.entClient.Tx(ctx) - if err != nil { - return err - } - defer func() { _ = tx.Rollback() }() - - opCtx := dbent.NewTxContext(ctx, tx) - if err := s.deleteUserWithAPIKeys(opCtx, id, apiKeys); err != nil { - return err - } - if err := tx.Commit(); err != nil { - return err - } - } else { - if err := s.deleteUserWithAPIKeys(ctx, id, apiKeys); err != nil { - return err - } - } - - if s.authCacheInvalidator != nil { - for _, key := range apiKeys { - if keyValue := strings.TrimSpace(key.Key); keyValue != "" { - s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, keyValue) - } - } - s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, id) - } - return nil -} - -func (s *adminServiceImpl) listUserAPIKeysForDeletion(ctx context.Context, userID int64) ([]APIKey, error) { - if s.apiKeyRepo == nil { - return nil, nil - } - - const pageSize = 1000 - keys := make([]APIKey, 0) - for page := 1; ; page++ { - batch, result, err := s.apiKeyRepo.ListByUserID(ctx, userID, pagination.PaginationParams{ - Page: page, - PageSize: pageSize, - SortBy: "id", - SortOrder: pagination.SortOrderAsc, - }, APIKeyListFilters{}) - if err != nil { - return nil, fmt.Errorf("list user api keys: %w", err) - } - keys = append(keys, batch...) - if len(batch) == 0 || len(batch) < pageSize || result == nil || int64(len(keys)) >= result.Total { - break - } - } - return keys, nil -} - -func (s *adminServiceImpl) deleteUserWithAPIKeys(ctx context.Context, userID int64, apiKeys []APIKey) error { - if s.apiKeyRepo != nil { - for _, key := range apiKeys { - if key.ID <= 0 { - continue - } - if err := s.apiKeyRepo.DeleteWithAudit(ctx, key.ID); err != nil { - logger.LegacyPrintf("service.admin", "delete user api key failed: user_id=%d api_key_id=%d err=%v", userID, key.ID, err) - return fmt.Errorf("delete user api key %d: %w", key.ID, err) - } - } - } - - if err := s.userRepo.Delete(ctx, userID); err != nil { - logger.LegacyPrintf("service.admin", "delete user failed: user_id=%d err=%v", userID, err) - return err - } - return nil -} - -func (s *adminServiceImpl) BatchUpdateConcurrency(ctx context.Context, userIDs []int64, value int, mode string) (int, error) { - cleaned := make([]int64, 0, len(userIDs)) - for _, uid := range userIDs { - if uid > 0 { - cleaned = append(cleaned, uid) - } - } - if len(cleaned) == 0 { - return 0, nil - } - - var affected int - var err error - switch mode { - case "set": - affected, err = s.userRepo.BatchSetConcurrency(ctx, cleaned, value) - case "add": - affected, err = s.userRepo.BatchAddConcurrency(ctx, cleaned, value) - default: - return 0, errors.New("invalid mode: must be 'set' or 'add'") - } - if err != nil { - return 0, err - } - - if s.authCacheInvalidator != nil { - for _, uid := range cleaned { - s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, uid) - } - } - return affected, nil -} - -func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) { - user, err := s.userRepo.GetByID(ctx, userID) - if err != nil { - return nil, err - } - - oldBalance := user.Balance - - switch operation { - case "set": - user.Balance = balance - case "add": - user.Balance += balance - case "subtract": - user.Balance -= balance - } - - if user.Balance < 0 { - return nil, fmt.Errorf("balance cannot be negative, current balance: %.2f, requested operation would result in: %.2f", oldBalance, user.Balance) - } - - if err := s.userRepo.Update(ctx, user); err != nil { - return nil, err - } - balanceDiff := user.Balance - oldBalance - if s.authCacheInvalidator != nil && balanceDiff != 0 { - s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID) - } - - if s.billingCacheService != nil { - go func() { - cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := s.billingCacheService.InvalidateUserBalance(cacheCtx, userID); err != nil { - logger.LegacyPrintf("service.admin", "invalidate user balance cache failed: user_id=%d err=%v", userID, err) - } - }() - } - - if balanceDiff != 0 { - code, err := GenerateRedeemCode() - if err != nil { - logger.LegacyPrintf("service.admin", "failed to generate adjustment redeem code: %v", err) - return user, nil - } - - adjustmentRecord := &RedeemCode{ - Code: code, - Type: AdjustmentTypeAdminBalance, - Value: balanceDiff, - Status: StatusUsed, - UsedBy: &user.ID, - Notes: notes, - } - now := time.Now() - adjustmentRecord.UsedAt = &now - - if err := s.redeemCodeRepo.Create(ctx, adjustmentRecord); err != nil { - logger.LegacyPrintf("service.admin", "failed to create balance adjustment redeem code: %v", err) - } - } - - return user, nil -} - -func (s *adminServiceImpl) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]APIKey, int64, error) { - params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} - keys, result, err := s.apiKeyRepo.ListByUserID(ctx, userID, params, APIKeyListFilters{}) - if err != nil { - return nil, 0, err - } - return keys, result.Total, nil -} - -func (s *adminServiceImpl) GetUserRPMStatus(ctx context.Context, userID int64) (*UserRPMStatus, error) { - if s.userRPMCache == nil { - return nil, ErrRPMStatusUnavailable - } - - user, err := s.userRepo.GetByID(ctx, userID) - if err != nil { - return nil, err - } - - userRPMUsed, err := s.userRPMCache.GetUserRPM(ctx, userID) - if err != nil { - logger.LegacyPrintf("service.admin", "failed to get user rpm: user_id=%d err=%v", userID, err) - } - - keys, _, err := s.GetUserAPIKeys(ctx, userID, 1, 1000, "", "") - if err != nil { - return nil, err - } - - groupIDSet := make(map[int64]struct{}) - for _, key := range keys { - if key.GroupID != nil && *key.GroupID > 0 { - groupIDSet[*key.GroupID] = struct{}{} - } - } - - groupIDs := make([]int64, 0, len(groupIDSet)) - for groupID := range groupIDSet { - groupIDs = append(groupIDs, groupID) - } - sort.Slice(groupIDs, func(i, j int) bool { return groupIDs[i] < groupIDs[j] }) - - var perGroup []UserGroupRPMStatus - for _, groupID := range groupIDs { - used, getErr := s.userRPMCache.GetUserGroupRPM(ctx, userID, groupID) - if getErr != nil { - logger.LegacyPrintf("service.admin", "failed to get user group rpm: user_id=%d group_id=%d err=%v", userID, groupID, getErr) - } - - entry := UserGroupRPMStatus{ - GroupID: groupID, - Used: used, - } - - if s.groupRepo != nil { - if group, groupErr := s.groupRepo.GetByIDLite(ctx, groupID); groupErr == nil && group != nil { - entry.GroupName = group.Name - entry.Limit = group.RPMLimit - entry.Source = "group" - } else if groupErr != nil { - logger.LegacyPrintf("service.admin", "failed to get group rpm status metadata: group_id=%d err=%v", groupID, groupErr) - } - } - - if s.userGroupRateRepo != nil { - override, overrideErr := s.userGroupRateRepo.GetRPMOverrideByUserAndGroup(ctx, userID, groupID) - if overrideErr != nil { - logger.LegacyPrintf("service.admin", "failed to get rpm override: user_id=%d group_id=%d err=%v", userID, groupID, overrideErr) - } else if override != nil { - entry.Limit = *override - entry.Source = "override" - } - } - - perGroup = append(perGroup, entry) - } - - return &UserRPMStatus{ - UserRPMUsed: userRPMUsed, - UserRPMLimit: user.RPMLimit, - PerGroup: perGroup, - }, nil -} - -func (s *adminServiceImpl) GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error) { - // Return mock data for now - return map[string]any{ - "period": period, - "total_requests": 0, - "total_cost": 0.0, - "total_tokens": 0, - "avg_duration_ms": 0, - }, nil -} - -// GetUserBalanceHistory returns paginated balance/concurrency change records for a user. -func (s *adminServiceImpl) GetUserBalanceHistory(ctx context.Context, userID int64, page, pageSize int, codeType string) ([]RedeemCode, int64, float64, error) { - params := pagination.PaginationParams{Page: page, PageSize: pageSize} - if codeType == RedeemTypeAffiliateBalance { - codes, total, err := s.listAffiliateBalanceHistory(ctx, userID, params) - if err != nil { - return nil, 0, 0, err - } - totalRecharged, err := s.redeemCodeRepo.SumPositiveBalanceByUser(ctx, userID) - if err != nil { - return nil, 0, 0, err - } - return codes, total, totalRecharged, nil - } - - if codeType == "" { - return s.getAllUserBalanceHistory(ctx, userID, params) - } - - codes, result, err := s.redeemCodeRepo.ListByUserPaginated(ctx, userID, params, codeType) - if err != nil { - return nil, 0, 0, err - } - total := result.Total - // Aggregate total recharged amount (only once, regardless of type filter) - totalRecharged, err := s.redeemCodeRepo.SumPositiveBalanceByUser(ctx, userID) - if err != nil { - return nil, 0, 0, err - } - return codes, total, totalRecharged, nil -} - -func (s *adminServiceImpl) getAllUserBalanceHistory(ctx context.Context, userID int64, params pagination.PaginationParams) ([]RedeemCode, int64, float64, error) { - needed := params.Offset() + params.Limit() - if needed < params.Limit() { - needed = params.Limit() - } - - redeemCodes, redeemTotal, err := s.listRedeemBalanceHistoryForMerge(ctx, userID, needed) - if err != nil { - return nil, 0, 0, err - } - affiliateCodes, affiliateTotal, err := s.listAffiliateBalanceHistoryForMerge(ctx, userID, needed) - if err != nil { - return nil, 0, 0, err - } - codes := mergeBalanceHistoryCodes(redeemCodes, affiliateCodes, params) - - totalRecharged, err := s.redeemCodeRepo.SumPositiveBalanceByUser(ctx, userID) - if err != nil { - return nil, 0, 0, err - } - return codes, redeemTotal + affiliateTotal, totalRecharged, nil -} - -func (s *adminServiceImpl) listRedeemBalanceHistoryForMerge(ctx context.Context, userID int64, needed int) ([]RedeemCode, int64, error) { - if needed <= 0 { - return nil, 0, nil - } - - var ( - out []RedeemCode - total int64 - ) - for page := 1; len(out) < needed; page++ { - params := pagination.PaginationParams{Page: page, PageSize: 1000} - codes, result, err := s.redeemCodeRepo.ListByUserPaginated(ctx, userID, params, "") - if err != nil { - return nil, 0, err - } - if result != nil { - total = result.Total - } - out = append(out, codes...) - if len(codes) < params.Limit() || int64(len(out)) >= total { - break - } - } - if len(out) > needed { - out = out[:needed] - } - return out, total, nil -} - -func (s *adminServiceImpl) listAffiliateBalanceHistoryForMerge(ctx context.Context, userID int64, needed int) ([]RedeemCode, int64, error) { - if needed <= 0 { - return nil, 0, nil - } - - var ( - out []RedeemCode - total int64 - ) - for page := 1; len(out) < needed; page++ { - params := pagination.PaginationParams{Page: page, PageSize: 1000} - codes, currentTotal, err := s.listAffiliateBalanceHistory(ctx, userID, params) - if err != nil { - return nil, 0, err - } - total = currentTotal - out = append(out, codes...) - if len(codes) < params.Limit() || int64(len(out)) >= total { - break - } - } - if len(out) > needed { - out = out[:needed] - } - return out, total, nil -} - -func (s *adminServiceImpl) listAffiliateBalanceHistory(ctx context.Context, userID int64, params pagination.PaginationParams) ([]RedeemCode, int64, error) { - if s == nil || s.entClient == nil || userID <= 0 { - return nil, 0, nil - } - - rows, err := s.entClient.QueryContext(ctx, ` -SELECT id, - amount::double precision, - created_at -FROM user_affiliate_ledger -WHERE user_id = $1 - AND action = 'transfer' -ORDER BY created_at DESC, id DESC -OFFSET $2 -LIMIT $3`, userID, params.Offset(), params.Limit()) - if err != nil { - return nil, 0, err - } - defer func() { _ = rows.Close() }() - - codes := make([]RedeemCode, 0, params.Limit()) - for rows.Next() { - var id int64 - var amount float64 - var createdAt time.Time - if err := rows.Scan(&id, &amount, &createdAt); err != nil { - return nil, 0, err - } - usedBy := userID - usedAt := createdAt - codes = append(codes, RedeemCode{ - ID: -id, - Code: fmt.Sprintf("AFF-%d", id), - Type: RedeemTypeAffiliateBalance, - Value: amount, - Status: StatusUsed, - UsedBy: &usedBy, - UsedAt: &usedAt, - CreatedAt: createdAt, - }) - } - if err := rows.Err(); err != nil { - return nil, 0, err - } - - total, err := countAffiliateBalanceHistory(ctx, s.entClient, userID) - if err != nil { - return nil, 0, err - } - return codes, total, nil -} - -func countAffiliateBalanceHistory(ctx context.Context, client *dbent.Client, userID int64) (int64, error) { - rows, err := client.QueryContext(ctx, ` -SELECT COUNT(*) -FROM user_affiliate_ledger -WHERE user_id = $1 - AND action = 'transfer'`, userID) - if err != nil { - return 0, err - } - defer func() { _ = rows.Close() }() - - var total sql.NullInt64 - if rows.Next() { - if err := rows.Scan(&total); err != nil { - return 0, err - } - } - if err := rows.Err(); err != nil { - return 0, err - } - if !total.Valid { - return 0, nil - } - return total.Int64, nil -} - -func mergeBalanceHistoryCodes(redeemCodes, affiliateCodes []RedeemCode, params pagination.PaginationParams) []RedeemCode { - combined := append(append([]RedeemCode{}, redeemCodes...), affiliateCodes...) - sort.SliceStable(combined, func(i, j int) bool { - return redeemCodeHistoryTime(combined[i]).After(redeemCodeHistoryTime(combined[j])) - }) - offset := params.Offset() - if offset >= len(combined) { - return []RedeemCode{} - } - end := offset + params.Limit() - if end > len(combined) { - end = len(combined) - } - return combined[offset:end] -} - -func redeemCodeHistoryTime(code RedeemCode) time.Time { - if code.UsedAt != nil { - return *code.UsedAt - } - return code.CreatedAt -} - -func (s *adminServiceImpl) BindUserAuthIdentity(ctx context.Context, userID int64, input AdminBindAuthIdentityInput) (*AdminBoundAuthIdentity, error) { - if userID <= 0 { - return nil, infraerrors.BadRequest("INVALID_INPUT", "user_id must be greater than 0") - } - if s == nil || s.entClient == nil || s.userRepo == nil { - return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_BIND_UNAVAILABLE", "auth identity binding service is unavailable") - } - if _, err := s.userRepo.GetByID(ctx, userID); err != nil { - return nil, err - } - - providerType := normalizeAdminAuthIdentityProviderType(input.ProviderType) - providerKey := strings.TrimSpace(input.ProviderKey) - providerSubject := strings.TrimSpace(input.ProviderSubject) - if providerType == "" { - return nil, infraerrors.BadRequest("INVALID_INPUT", "provider_type must be one of email, linuxdo, oidc, wechat, or dingtalk") - } - if providerKey == "" || providerSubject == "" { - return nil, infraerrors.BadRequest("INVALID_INPUT", "provider_type, provider_key, and provider_subject are required") - } - canonicalProviderKey := canonicalAdminAuthIdentityProviderKey(providerType, "", providerKey) - compatibleProviderKeys := compatibleAdminAuthIdentityProviderKeys(providerType, providerKey) - - var issuer *string - if input.Issuer != nil { - trimmed := strings.TrimSpace(*input.Issuer) - if trimmed != "" { - issuer = &trimmed - } - } - - channelInput := normalizeAdminBindChannelInput(input.Channel) - if input.Channel != nil && channelInput == nil { - return nil, infraerrors.BadRequest("INVALID_INPUT", "channel, channel_app_id, and channel_subject are required when channel binding is provided") - } - - verifiedAt := time.Now().UTC() - tx, err := s.entClient.Tx(ctx) - if err != nil { - return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_BIND_TX_FAILED", "failed to start auth identity bind transaction").WithCause(err) - } - defer func() { _ = tx.Rollback() }() - - identityRecords, err := tx.AuthIdentity.Query(). - Where( - authidentity.ProviderTypeEQ(providerType), - authidentity.ProviderKeyIn(compatibleProviderKeys...), - authidentity.ProviderSubjectEQ(providerSubject), - ). - All(ctx) - if err != nil { - return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_BIND_LOOKUP_FAILED", "failed to inspect auth identity ownership").WithCause(err) - } - if hasAdminAuthIdentityOwnershipConflict(identityRecords, userID) { - return nil, infraerrors.Conflict("AUTH_IDENTITY_OWNERSHIP_CONFLICT", "auth identity already belongs to another user") - } - identity := selectOwnedAdminAuthIdentity(identityRecords, userID) - - if identity == nil { - create := tx.AuthIdentity.Create(). - SetUserID(userID). - SetProviderType(providerType). - SetProviderKey(canonicalProviderKey). - SetProviderSubject(providerSubject). - SetVerifiedAt(verifiedAt) - if issuer != nil { - create = create.SetIssuer(*issuer) - } - if input.Metadata != nil { - create = create.SetMetadata(cloneAdminAuthIdentityMetadata(input.Metadata)) - } - identity, err = create.Save(ctx) - if err != nil { - return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_BIND_SAVE_FAILED", "failed to save auth identity").WithCause(err) - } - } else { - update := tx.AuthIdentity.UpdateOneID(identity.ID). - SetVerifiedAt(verifiedAt). - SetProviderKey(canonicalProviderKey) - if issuer != nil { - update = update.SetIssuer(*issuer) - } - if input.Metadata != nil { - update = update.SetMetadata(cloneAdminAuthIdentityMetadata(input.Metadata)) - } - identity, err = update.Save(ctx) - if err != nil { - return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_BIND_SAVE_FAILED", "failed to save auth identity").WithCause(err) - } - } - - var channel *dbent.AuthIdentityChannel - if channelInput != nil { - channelRecords, err := tx.AuthIdentityChannel.Query(). - Where( - authidentitychannel.ProviderTypeEQ(providerType), - authidentitychannel.ProviderKeyIn(compatibleProviderKeys...), - authidentitychannel.ChannelEQ(channelInput.Channel), - authidentitychannel.ChannelAppIDEQ(channelInput.ChannelAppID), - authidentitychannel.ChannelSubjectEQ(channelInput.ChannelSubject), - ). - WithIdentity(). - All(ctx) - if err != nil { - return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_CHANNEL_LOOKUP_FAILED", "failed to inspect auth identity channel ownership").WithCause(err) - } - if hasAdminAuthIdentityChannelOwnershipConflict(channelRecords, userID) { - return nil, infraerrors.Conflict("AUTH_IDENTITY_CHANNEL_OWNERSHIP_CONFLICT", "auth identity channel already belongs to another user") - } - channel = selectOwnedAdminAuthIdentityChannel(channelRecords, userID) - if channel == nil { - create := tx.AuthIdentityChannel.Create(). - SetIdentityID(identity.ID). - SetProviderType(providerType). - SetProviderKey(canonicalProviderKey). - SetChannel(channelInput.Channel). - SetChannelAppID(channelInput.ChannelAppID). - SetChannelSubject(channelInput.ChannelSubject) - if channelInput.Metadata != nil { - create = create.SetMetadata(cloneAdminAuthIdentityMetadata(channelInput.Metadata)) - } - channel, err = create.Save(ctx) - if err != nil { - return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_CHANNEL_SAVE_FAILED", "failed to save auth identity channel").WithCause(err) - } - } else { - update := tx.AuthIdentityChannel.UpdateOneID(channel.ID). - SetIdentityID(identity.ID). - SetProviderKey(canonicalProviderKey) - if channelInput.Metadata != nil { - update = update.SetMetadata(cloneAdminAuthIdentityMetadata(channelInput.Metadata)) - } - channel, err = update.Save(ctx) - if err != nil { - return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_CHANNEL_SAVE_FAILED", "failed to save auth identity channel").WithCause(err) - } - } - } - - if err := tx.Commit(); err != nil { - return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_BIND_COMMIT_FAILED", "failed to commit auth identity bind").WithCause(err) - } - return buildAdminBoundAuthIdentity(identity, channel), nil -} - -func compatibleAdminAuthIdentityProviderKeys(providerType, providerKey string) []string { - providerType = strings.TrimSpace(strings.ToLower(providerType)) - providerKey = strings.TrimSpace(providerKey) - if providerKey == "" { - return []string{providerKey} - } - if providerType != "wechat" { - return []string{providerKey} - } - - keys := []string{providerKey} - if !strings.EqualFold(providerKey, "wechat-main") { - keys = append(keys, "wechat-main") - } - if !strings.EqualFold(providerKey, "wechat") { - keys = append(keys, "wechat") - } - return keys -} - -func canonicalAdminAuthIdentityProviderKey(providerType, existingKey, requestedKey string) string { - providerType = strings.TrimSpace(strings.ToLower(providerType)) - existingKey = strings.TrimSpace(existingKey) - requestedKey = strings.TrimSpace(requestedKey) - if providerType != "wechat" { - if requestedKey != "" { - return requestedKey - } - return existingKey - } - if strings.EqualFold(existingKey, "wechat") || strings.EqualFold(existingKey, "wechat-main") || strings.EqualFold(requestedKey, "wechat-main") { - return "wechat-main" - } - if requestedKey != "" { - return requestedKey - } - return existingKey -} - -func adminAuthIdentityProviderKeyRank(providerType, providerKey string) int { - providerType = strings.TrimSpace(strings.ToLower(providerType)) - providerKey = strings.TrimSpace(providerKey) - if providerType != "wechat" { - return 0 - } - switch { - case strings.EqualFold(providerKey, "wechat-main"): - return 0 - case strings.EqualFold(providerKey, "wechat"): - return 2 - default: - return 1 - } -} - -func selectOwnedAdminAuthIdentity(records []*dbent.AuthIdentity, userID int64) *dbent.AuthIdentity { - var selected *dbent.AuthIdentity - for _, record := range records { - if record.UserID != userID { - continue - } - if selected == nil || adminAuthIdentityProviderKeyRank(record.ProviderType, record.ProviderKey) < adminAuthIdentityProviderKeyRank(selected.ProviderType, selected.ProviderKey) { - selected = record - } - } - return selected -} - -func hasAdminAuthIdentityOwnershipConflict(records []*dbent.AuthIdentity, userID int64) bool { - for _, record := range records { - if record.UserID != userID { - return true - } - } - return false -} - -func selectOwnedAdminAuthIdentityChannel(records []*dbent.AuthIdentityChannel, userID int64) *dbent.AuthIdentityChannel { - var selected *dbent.AuthIdentityChannel - for _, record := range records { - if record.Edges.Identity == nil || record.Edges.Identity.UserID != userID { - continue - } - if selected == nil || adminAuthIdentityProviderKeyRank(record.ProviderType, record.ProviderKey) < adminAuthIdentityProviderKeyRank(selected.ProviderType, selected.ProviderKey) { - selected = record - } - } - return selected -} - -func hasAdminAuthIdentityChannelOwnershipConflict(records []*dbent.AuthIdentityChannel, userID int64) bool { - for _, record := range records { - if record.Edges.Identity != nil && record.Edges.Identity.UserID != userID { - return true - } - } - return false -} - -func normalizeAdminBindChannelInput(input *AdminBindAuthIdentityChannelInput) *AdminBindAuthIdentityChannelInput { - if input == nil { - return nil - } - channel := &AdminBindAuthIdentityChannelInput{ - Channel: strings.TrimSpace(input.Channel), - ChannelAppID: strings.TrimSpace(input.ChannelAppID), - ChannelSubject: strings.TrimSpace(input.ChannelSubject), - Metadata: cloneAdminAuthIdentityMetadata(input.Metadata), - } - if channel.Channel == "" || channel.ChannelAppID == "" || channel.ChannelSubject == "" { - return nil - } - return channel -} - -func normalizeAdminAuthIdentityProviderType(input string) string { - switch strings.ToLower(strings.TrimSpace(input)) { - case "email": - return "email" - case "linuxdo": - return "linuxdo" - case "oidc": - return "oidc" - case "wechat": - return "wechat" - case "dingtalk": - return "dingtalk" - default: - return "" - } -} - -func buildAdminBoundAuthIdentity(identity *dbent.AuthIdentity, channel *dbent.AuthIdentityChannel) *AdminBoundAuthIdentity { - if identity == nil { - return nil - } - result := &AdminBoundAuthIdentity{ - UserID: identity.UserID, - ProviderType: strings.TrimSpace(identity.ProviderType), - ProviderKey: strings.TrimSpace(identity.ProviderKey), - ProviderSubject: strings.TrimSpace(identity.ProviderSubject), - VerifiedAt: identity.VerifiedAt, - Issuer: identity.Issuer, - Metadata: cloneAdminAuthIdentityMetadata(identity.Metadata), - CreatedAt: identity.CreatedAt, - UpdatedAt: identity.UpdatedAt, - } - if channel != nil { - result.Channel = &AdminBoundAuthIdentityChannel{ - Channel: strings.TrimSpace(channel.Channel), - ChannelAppID: strings.TrimSpace(channel.ChannelAppID), - ChannelSubject: strings.TrimSpace(channel.ChannelSubject), - Metadata: cloneAdminAuthIdentityMetadata(channel.Metadata), - CreatedAt: channel.CreatedAt, - UpdatedAt: channel.UpdatedAt, - } - } - return result -} - -func cloneAdminAuthIdentityMetadata(input map[string]any) map[string]any { - if input == nil { - return nil - } - if len(input) == 0 { - return map[string]any{} - } - data, err := json.Marshal(input) - if err != nil { - out := make(map[string]any, len(input)) - for key, value := range input { - out[key] = value - } - return out - } - var out map[string]any - if err := json.Unmarshal(data, &out); err != nil { - out = make(map[string]any, len(input)) - for key, value := range input { - out[key] = value - } - } - return out -} - -// 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 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 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() - 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 (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupInput) (*Group, error) { - if input.RateMultiplier <= 0 { - return nil, errors.New("rate_multiplier must be > 0") - } - - platform := input.Platform - if platform == "" { - platform = PlatformAnthropic - } - - 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) - 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") - } - - 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 - } - - // 校验降级分组 - 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 srcGroup.Platform != 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, - AllowImageGeneration: allowImageGeneration, - AllowBatchImageGeneration: allowBatchImageGeneration, - ImageRateIndependent: input.ImageRateIndependent, - ImageRateMultiplier: imageRateMultiplier, - BatchImageDiscountMultiplier: batchImageDiscountMultiplier, - BatchImageHoldMultiplier: batchImageHoldMultiplier, - PeakRateEnabled: peakRateEnabled, - PeakStart: peakStart, - PeakEnd: peakEnd, - PeakRateMultiplier: peakRateMultiplier, - ImagePrice1K: imagePrice1K, - ImagePrice2K: imagePrice2K, - ImagePrice4K: imagePrice4K, - ClaudeCodeOnly: input.ClaudeCodeOnly, - FallbackGroupID: input.FallbackGroupID, - FallbackGroupIDOnInvalidRequest: fallbackOnInvalidRequest, - ModelRouting: input.ModelRouting, - MCPXMLInject: mcpXMLInject, - SupportedModelScopes: input.SupportedModelScopes, - AllowMessagesDispatch: input.AllowMessagesDispatch, - RequireOAuthOnly: input.RequireOAuthOnly, - RequirePrivacySet: input.RequirePrivacySet, - DefaultMappedModel: input.DefaultMappedModel, - MessagesDispatchModelConfig: normalizeOpenAIMessagesDispatchModelConfig(input.MessagesDispatchModelConfig), - ModelsListConfig: normalizeGroupModelsListConfig(input.ModelsListConfig), - RPMLimit: input.RPMLimit, - } - sanitizeGroupMessagesDispatchFields(group) - if err := s.groupRepo.Create(ctx, group); err != nil { - return nil, err - } - - // require_oauth_only: 过滤掉 apikey 类型账号 - if group.RequireOAuthOnly && (group.Platform == PlatformOpenAI || group.Platform == PlatformAntigravity || group.Platform == PlatformAnthropic || group.Platform == PlatformGemini || group.Platform == PlatformGrok) && 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 - } - - 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.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.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.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) - } - - // 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.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 - } - sanitizeGroupMessagesDispatchFields(group) - - if err := s.groupRepo.Update(ctx, group); err != nil { - return nil, err - } - - if s.authCacheInvalidator != nil { - s.authCacheInvalidator.InvalidateAuthCacheByGroupID(ctx, id) - } - - // 如果指定了复制账号的源分组,同步绑定(替换当前分组的账号) - 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 srcGroup.Platform != group.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 && (group.Platform == PlatformOpenAI || group.Platform == PlatformAntigravity || group.Platform == PlatformAnthropic || group.Platform == PlatformGemini || group.Platform == PlatformGrok) && 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 (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); 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); 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); 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 -} - -// Account management implementations -func (s *adminServiceImpl) ListAccounts(ctx context.Context, page, pageSize int, platform, accountType, status, search string, groupID int64, privacyMode string, sortBy, sortOrder string) ([]Account, int64, error) { - params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} - accounts, result, err := s.accountRepo.ListWithFilters(ctx, params, platform, accountType, status, search, groupID, privacyMode) - if err != nil { - return nil, 0, err - } - return accounts, result.Total, nil -} - -func (s *adminServiceImpl) ListAccountsForSchedulerScoreFilter(ctx context.Context, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, error) { - if s == nil || s.accountRepo == nil { - return nil, nil - } - return s.accountRepo.ListAllWithFilters(ctx, platform, accountType, status, search, groupID, privacyMode) -} - -func (s *adminServiceImpl) ListOpenAISchedulableAccountsForSchedulerScore(ctx context.Context, groupID *int64) ([]Account, error) { - if s == nil || s.accountRepo == nil { - return nil, nil - } - if groupID != nil { - return s.accountRepo.ListSchedulableByGroupIDAndPlatform(ctx, *groupID, PlatformOpenAI) - } - return s.accountRepo.ListSchedulableUngroupedByPlatform(ctx, PlatformOpenAI) -} - -func (s *adminServiceImpl) GetAccount(ctx context.Context, id int64) (*Account, error) { - return s.accountRepo.GetByID(ctx, id) -} - -func (s *adminServiceImpl) GetAccountsByIDs(ctx context.Context, ids []int64) ([]*Account, error) { - if len(ids) == 0 { - return []*Account{}, nil - } - - accounts, err := s.accountRepo.GetByIDs(ctx, ids) - if err != nil { - return nil, fmt.Errorf("failed to get accounts by IDs: %w", err) - } - - return accounts, nil -} - -func normalizeAccountConcurrency(platform, accountType string, concurrency int) int { - if platform == PlatformGrok && accountType == AccountTypeOAuth { - if concurrency <= 0 { - return 1 - } - } - return concurrency -} - -func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccountInput) (*Account, error) { - // 绑定分组 - groupIDs := input.GroupIDs - // 如果没有指定分组,自动绑定对应平台的默认分组 - if len(groupIDs) == 0 && !input.SkipDefaultGroupBind { - defaultGroupName := input.Platform + "-default" - groups, err := s.groupRepo.ListActiveByPlatform(ctx, input.Platform) - if err == nil { - for _, g := range groups { - if g.Name == defaultGroupName { - groupIDs = []int64{g.ID} - break - } - } - } - } - - // 检查混合渠道风险(除非用户已确认) - if len(groupIDs) > 0 && !input.SkipMixedChannelCheck { - if err := s.checkMixedChannelRisk(ctx, 0, input.Platform, groupIDs); err != nil { - return nil, err - } - } - - // 校验并规范化请求头覆写配置(header 名小写化、格式检查) - if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil { - return nil, err - } - - account := &Account{ - Name: input.Name, - Notes: normalizeAccountNotes(input.Notes), - Platform: input.Platform, - Type: input.Type, - Credentials: input.Credentials, - Extra: input.Extra, - ProxyID: input.ProxyID, - Concurrency: normalizeAccountConcurrency(input.Platform, input.Type, input.Concurrency), - Priority: input.Priority, - Status: StatusActive, - Schedulable: true, - } - // 预计算固定时间重置的下次重置时间 - if account.Extra != nil { - if err := ValidateQuotaResetConfig(account.Extra); err != nil { - return nil, err - } - ComputeQuotaResetAt(account.Extra) - NormalizeFixedQuotaWindows(account.Extra) - } - if input.ExpiresAt != nil && *input.ExpiresAt > 0 { - expiresAt := time.Unix(*input.ExpiresAt, 0) - account.ExpiresAt = &expiresAt - } - if input.AutoPauseOnExpired != nil { - account.AutoPauseOnExpired = *input.AutoPauseOnExpired - } else { - account.AutoPauseOnExpired = true - } - if input.RateMultiplier != nil { - if *input.RateMultiplier < 0 { - return nil, errors.New("rate_multiplier must be >= 0") - } - account.RateMultiplier = input.RateMultiplier - } - if input.LoadFactor != nil && *input.LoadFactor > 0 { - if *input.LoadFactor > 10000 { - return nil, errors.New("load_factor must be <= 10000") - } - account.LoadFactor = input.LoadFactor - } - if err := s.accountRepo.Create(ctx, account); err != nil { - return nil, err - } - - // 绑定分组 - if len(groupIDs) > 0 { - if err := s.accountRepo.BindGroups(ctx, account.ID, groupIDs); err != nil { - return nil, err - } - } - - // OAuth 账号:创建后异步设置隐私。 - // 使用 Ensure(幂等)而非 Force:新建账号 Extra 为空时效果相同,但更安全。 - if account.Type == AccountTypeOAuth { - switch account.Platform { - case PlatformOpenAI: - go func() { - defer func() { - if r := recover(); r != nil { - slog.Error("create_account_openai_privacy_panic", "account_id", account.ID, "recover", r) - } - }() - s.EnsureOpenAIPrivacy(context.Background(), account) - }() - case PlatformAntigravity: - go func() { - defer func() { - if r := recover(); r != nil { - slog.Error("create_account_antigravity_privacy_panic", "account_id", account.ID, "recover", r) - } - }() - s.EnsureAntigravityPrivacy(context.Background(), account) - }() - } - } - - return account, nil -} - -func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *UpdateAccountInput) (*Account, error) { - account, err := s.accountRepo.GetByID(ctx, id) - if err != nil { - return nil, err - } - // 安全/身份不变量(影子账号):通用更新路径被 edit/re-auth/refresh/batch 共用, - // 必须在此守住,否则仅在创建时的保证可被这些路径绕过。 - if account.IsCredentialShadow() { - // 影子绝不持有凭据(凭据只在母账号)——外审 F5。 - if !isAllowedSparkShadowCredentialsUpdate(input.Credentials) { - return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_NO_CREDENTIALS", - "spark shadow accounts do not hold auth credentials; only model mapping can be configured on the shadow account") - } - // 影子 type 不可变——很多上游逻辑按 account.Type 分支(OAuth transform / ChatGPT - // header 注入 / WS OAuth 决策),改成 apikey 会让 spark 影子被选中后按错误协议转发(外审 G7)。 - if input.Type != "" && input.Type != account.Type { - return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_IMMUTABLE_TYPE", - "spark shadow account type cannot be changed; it must remain an OpenAI OAuth shadow") - } - } else if input.Type != "" && input.Type != account.Type && input.Type != AccountTypeOAuth { - // 母账号守卫(外审 D/P1):有 spark 影子的账号不能把 type 改出 OpenAI OAuth——影子读透母 - // 凭据,母变成 apikey/setup_token 会让影子被调度后按错协议失败(resolveCredentialAccount - // 必报错)。须先删影子再改 type。 - shadows, serr := s.accountRepo.ListShadowsByParent(ctx, id) - if serr != nil { - return nil, serr - } - if len(shadows) > 0 { - return nil, infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_PARENT_IMMUTABLE_TYPE", - "cannot change account type while it has a spark shadow; delete the shadow first") - } - } - wasOveragesEnabled := account.IsOveragesEnabled() - - if input.Name != "" { - account.Name = input.Name - } - if input.Type != "" { - account.Type = input.Type - } - if input.Notes != nil { - account.Notes = normalizeAccountNotes(input.Notes) - } - if account.IsCredentialShadow() && input.Credentials != nil { - account.Credentials = sanitizeSparkShadowCredentials(input.Credentials) - } else if len(input.Credentials) > 0 { - // 敏感子键采用"incoming 没提供就保留"的合并语义:前端响应已脱敏, - // 全对象 PUT 编辑时不会再带回 token,避免覆盖时清空已有凭证。 - account.Credentials = MergePreservingSensitiveCreds(account.Credentials, input.Credentials) - // 校验并规范化请求头覆写配置(header 名小写化、格式检查) - if err := NormalizeHeaderOverrideCredentials(account.Credentials); err != nil { - return nil, err - } - } - // Extra 使用 map:需要区分“未提供(nil)”与“显式清空({})”。 - // 关闭配额限制时前端会删除 quota_* 键并提交 extra:{},此时也必须落库。 - if input.Extra != nil { - // 保留配额用量字段,防止编辑账号时意外重置 - for _, key := range []string{"quota_used", "quota_daily_used", "quota_daily_start", "quota_weekly_used", "quota_weekly_start"} { - if v, ok := account.Extra[key]; ok { - input.Extra[key] = v - } - } - account.Extra = input.Extra - if account.Platform == PlatformAntigravity && wasOveragesEnabled && !account.IsOveragesEnabled() { - delete(account.Extra, "antigravity_credits_overages") // 清理旧版 overages 运行态 - // 清除 AICredits 限流 key - if rawLimits, ok := account.Extra[modelRateLimitsKey].(map[string]any); ok { - delete(rawLimits, creditsExhaustedKey) - } - } - if account.Platform == PlatformAntigravity && !wasOveragesEnabled && account.IsOveragesEnabled() { - delete(account.Extra, modelRateLimitsKey) - delete(account.Extra, "antigravity_credits_overages") // 清理旧版 overages 运行态 - } - // 校验并预计算固定时间重置的下次重置时间 - if err := ValidateQuotaResetConfig(account.Extra); err != nil { - return nil, err - } - ComputeQuotaResetAt(account.Extra) - NormalizeFixedQuotaWindows(account.Extra) - } - // 影子代理恒继承母账号(由 propagateProxyToShadows 同步),不接受独立编辑——外审 B/P1; - // 否则要等母账号下次改 proxy 才被覆盖,期间影子会出现"有时继承、有时独立"的漂移。 - if input.ProxyID != nil && !account.IsCredentialShadow() { - // 0 表示清除代理(前端发送 0 而不是 null 来表达清除意图) - if *input.ProxyID == 0 { - account.ProxyID = nil - } else { - account.ProxyID = input.ProxyID - } - account.Proxy = nil // 清除关联对象,防止 GORM Save 时根据 Proxy.ID 覆盖 ProxyID - } - // 只在指针非 nil 时更新 Concurrency(支持设置为 0) - if input.Concurrency != nil { - account.Concurrency = normalizeAccountConcurrency(account.Platform, account.Type, *input.Concurrency) - } - // 只在指针非 nil 时更新 Priority(支持设置为 0) - if input.Priority != nil { - account.Priority = *input.Priority - } - if input.RateMultiplier != nil { - if *input.RateMultiplier < 0 { - return nil, errors.New("rate_multiplier must be >= 0") - } - account.RateMultiplier = input.RateMultiplier - } - if input.LoadFactor != nil { - if *input.LoadFactor <= 0 { - account.LoadFactor = nil // 0 或负数表示清除 - } else if *input.LoadFactor > 10000 { - return nil, errors.New("load_factor must be <= 10000") - } else { - account.LoadFactor = input.LoadFactor - } - } - if input.Status != "" { - account.Status = input.Status - } - if input.ExpiresAt != nil { - if *input.ExpiresAt <= 0 { - account.ExpiresAt = nil - } else { - expiresAt := time.Unix(*input.ExpiresAt, 0) - account.ExpiresAt = &expiresAt - } - } - if input.AutoPauseOnExpired != nil { - account.AutoPauseOnExpired = *input.AutoPauseOnExpired - } - - // 先验证分组是否存在(在任何写操作之前) - if input.GroupIDs != nil { - if err := s.validateGroupIDsExist(ctx, *input.GroupIDs); err != nil { - return nil, err - } - - // 检查混合渠道风险(除非用户已确认) - if !input.SkipMixedChannelCheck { - if err := s.checkMixedChannelRisk(ctx, account.ID, account.Platform, *input.GroupIDs); err != nil { - return nil, err - } - } - } - - if err := s.accountRepo.Update(ctx, account); err != nil { - return nil, err - } - - // 将 proxy 变更传播到 spark 影子账号(同步;Update 内部已触发调度快照)。 - // 影子自身 proxy 不可独立编辑(见上),故对影子的更新不触发传播。 - if input.ProxyID != nil && !account.IsCredentialShadow() { - if err := s.propagateProxyToShadows(ctx, id, account.ProxyID); err != nil { - return nil, err - } - } - - // 绑定分组 - if input.GroupIDs != nil { - if err := s.accountRepo.BindGroups(ctx, account.ID, *input.GroupIDs); err != nil { - return nil, err - } - } - - // 重新查询以确保返回完整数据(包括正确的 Proxy 关联对象) - updated, err := s.accountRepo.GetByID(ctx, id) - if err != nil { - return nil, err - } - return updated, nil -} - -// UpdateAccountExtra 仅对 Extra JSONB 做 key 级合并,避免覆盖其它运行态键 -// (如 model_rate_limits / passive_usage_* 等)。 -func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, updates map[string]any) error { - if len(updates) == 0 { - return nil - } - return s.accountRepo.UpdateExtra(ctx, id, updates) -} - -// BulkUpdateAccounts updates multiple accounts in one request. -// It merges credentials/extra keys instead of overwriting the whole object. -func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error) { - if len(input.AccountIDs) == 0 && input.Filters != nil { - accountIDs, err := s.resolveBulkUpdateTargetIDs(ctx, input.Filters) - if err != nil { - return nil, err - } - input.AccountIDs = accountIDs - } - - result := &BulkUpdateAccountsResult{ - SuccessIDs: make([]int64, 0, len(input.AccountIDs)), - FailedIDs: make([]int64, 0, len(input.AccountIDs)), - Results: make([]BulkUpdateAccountResult, 0, len(input.AccountIDs)), - } - - if len(input.AccountIDs) == 0 { - return result, nil - } - if input.GroupIDs != nil { - if err := s.validateGroupIDsExist(ctx, *input.GroupIDs); err != nil { - return nil, err - } - } - - needMixedChannelCheck := input.GroupIDs != nil && !input.SkipMixedChannelCheck - - // 预取所有目标账号,供凭据守卫/代理守卫/混合渠道检查共用,避免多次 DB 查询。 - var cachedTargets []*Account - if len(input.Credentials) > 0 || input.ProxyID != nil || needMixedChannelCheck { - loaded, err := s.accountRepo.GetByIDs(ctx, input.AccountIDs) - if err != nil { - return nil, err - } - cachedTargets = loaded - } - - // 影子账号绝不持有凭据:批量更新携带凭据时,目标中不得含影子(外审 G5,与单账号 - // UpdateAccount 守卫对齐)。覆盖显式 IDs 与 filter 解析出的 IDs(此处 AccountIDs 已解析完成)。 - if len(input.Credentials) > 0 { - for _, acc := range cachedTargets { - if acc != nil && acc.IsCredentialShadow() { - return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_NO_CREDENTIALS", - "spark shadow account %d cannot hold credentials; manage credentials on the parent account", acc.ID) - } - } - } - - // 影子账号 proxy 恒继承母账号(与单账号 UpdateAccount 守卫对齐——外审第4轮 P1):批量携带 proxy - // 时目标不得含影子,否则影子会获得独立 proxy、破坏继承不变量(网关按所选影子自身 proxy 出站, - // 要等母账号下次改 proxy 才覆盖→漂移)。含影子即整体拒绝,提示从选择中剔除影子。 - if input.ProxyID != nil { - for _, acc := range cachedTargets { - if acc != nil && acc.IsCredentialShadow() { - return nil, infraerrors.Newf(http.StatusBadRequest, "SPARK_SHADOW_PROXY_INHERITED", - "spark shadow account %d proxy is inherited from its parent and cannot be set in bulk; manage it on the parent account", acc.ID) - } - } - } - - // 预加载账号平台信息(混合渠道检查需要)。 - platformByID := map[int64]string{} - if needMixedChannelCheck { - for _, account := range cachedTargets { - if account != nil { - platformByID[account.ID] = account.Platform - } - } - } - - // 预检查混合渠道风险:在任何写操作之前,若发现风险立即返回错误。 - if needMixedChannelCheck { - for _, accountID := range input.AccountIDs { - platform := platformByID[accountID] - if platform == "" { - continue - } - if err := s.checkMixedChannelRisk(ctx, accountID, platform, *input.GroupIDs); err != nil { - return nil, err - } - } - } - - if input.RateMultiplier != nil { - if *input.RateMultiplier < 0 { - return nil, errors.New("rate_multiplier must be >= 0") - } - } - - // 校验并规范化请求头覆写配置(批量路径为 JSONB 顶层 key 合并,直接校验增量即可) - if err := NormalizeHeaderOverrideCredentials(input.Credentials); err != nil { - return nil, err - } - - // Prepare bulk updates for columns and JSONB fields. - repoUpdates := AccountBulkUpdate{ - Credentials: input.Credentials, - Extra: input.Extra, - } - if input.Name != "" { - repoUpdates.Name = &input.Name - } - if input.ProxyID != nil { - repoUpdates.ProxyID = input.ProxyID - } - if input.Concurrency != nil { - repoUpdates.Concurrency = input.Concurrency - } - if input.Priority != nil { - repoUpdates.Priority = input.Priority - } - if input.RateMultiplier != nil { - repoUpdates.RateMultiplier = input.RateMultiplier - } - if input.LoadFactor != nil { - if *input.LoadFactor <= 0 { - repoUpdates.LoadFactor = nil // 0 或负数表示清除 - } else if *input.LoadFactor > 10000 { - return nil, errors.New("load_factor must be <= 10000") - } else { - repoUpdates.LoadFactor = input.LoadFactor - } - } - if input.Status != "" { - repoUpdates.Status = &input.Status - } - if input.Schedulable != nil { - repoUpdates.Schedulable = input.Schedulable - } - - // Run bulk update for column/jsonb fields first. - if _, err := s.accountRepo.BulkUpdate(ctx, input.AccountIDs, repoUpdates); err != nil { - return nil, err - } - - // 将 proxy 变更传播到每个目标账号的 spark 影子账号 - if repoUpdates.ProxyID != nil { - var effectiveProxyID *int64 - if *repoUpdates.ProxyID != 0 { - effectiveProxyID = repoUpdates.ProxyID - } - for _, accountID := range input.AccountIDs { - if err := s.propagateProxyToShadows(ctx, accountID, effectiveProxyID); err != nil { - return nil, err - } - } - } - - // Handle group bindings per account (requires individual operations). - for _, accountID := range input.AccountIDs { - entry := BulkUpdateAccountResult{AccountID: accountID} - - if input.GroupIDs != nil { - if err := s.accountRepo.BindGroups(ctx, accountID, *input.GroupIDs); err != nil { - entry.Success = false - entry.Error = err.Error() - result.Failed++ - result.FailedIDs = append(result.FailedIDs, accountID) - result.Results = append(result.Results, entry) - continue - } - } - - entry.Success = true - result.Success++ - result.SuccessIDs = append(result.SuccessIDs, accountID) - result.Results = append(result.Results, entry) - } - - return result, nil -} - -func (s *adminServiceImpl) resolveBulkUpdateTargetIDs(ctx context.Context, filters *BulkUpdateAccountFilters) ([]int64, error) { - if filters == nil { - return nil, nil - } - - groupID := int64(0) - switch strings.TrimSpace(filters.Group) { - case "": - case "ungrouped": - groupID = AccountListGroupUngrouped - default: - parsedGroupID, err := strconv.ParseInt(strings.TrimSpace(filters.Group), 10, 64) - if err != nil { - return nil, fmt.Errorf("invalid group filter: %w", err) - } - groupID = parsedGroupID - } - - const pageSize = 500 - page := 1 - accountIDs := make([]int64, 0, pageSize) - - for { - accounts, total, err := s.ListAccounts( - ctx, - page, - pageSize, - filters.Platform, - filters.Type, - filters.Status, - filters.Search, - groupID, - filters.PrivacyMode, - "", - "", - ) - if err != nil { - return nil, err - } - for _, account := range accounts { - accountIDs = append(accountIDs, account.ID) - } - if int64(len(accountIDs)) >= total || len(accounts) == 0 { - return accountIDs, nil - } - page++ - } -} - -func (s *adminServiceImpl) DeleteAccount(ctx context.Context, id int64) error { - // 级联删除 spark 影子账号(先删影子,再删母账号) - shadows, err := s.accountRepo.ListShadowsByParent(ctx, id) - if err != nil { - return fmt.Errorf("list spark shadows for cascade delete: %w", err) - } - for _, shadow := range shadows { - if err := s.accountRepo.Delete(ctx, shadow.ID); err != nil { - return fmt.Errorf("cascade delete spark shadow %d: %w", shadow.ID, err) - } - } - if err := s.accountRepo.Delete(ctx, id); err != nil { - return err - } - return nil -} - -func (s *adminServiceImpl) RefreshAccountCredentials(ctx context.Context, id int64) (*Account, error) { - account, err := s.accountRepo.GetByID(ctx, id) - if err != nil { - return nil, err - } - // TODO: Implement refresh logic - return account, nil -} - -func (s *adminServiceImpl) ClearAccountError(ctx context.Context, id int64) (*Account, error) { - if err := s.accountRepo.ClearError(ctx, id); err != nil { - return nil, err - } - if err := s.accountRepo.ClearRateLimit(ctx, id); err != nil { - return nil, err - } - if err := s.accountRepo.ClearAntigravityQuotaScopes(ctx, id); err != nil { - return nil, err - } - if err := s.accountRepo.ClearModelRateLimits(ctx, id); err != nil { - return nil, err - } - if err := s.accountRepo.ClearTempUnschedulable(ctx, id); err != nil { - return nil, err - } - if s.runtimeBlocker != nil { - s.runtimeBlocker.ClearAccountSchedulingBlock(id) - } - return s.accountRepo.GetByID(ctx, id) -} - -func (s *adminServiceImpl) SetAccountError(ctx context.Context, id int64, errorMsg string) error { - return s.accountRepo.SetError(ctx, id, errorMsg) -} - -func (s *adminServiceImpl) SetAccountSchedulable(ctx context.Context, id int64, schedulable bool) (*Account, error) { - if err := s.accountRepo.SetSchedulable(ctx, id, schedulable); err != nil { - return nil, err - } - updated, err := s.accountRepo.GetByID(ctx, id) - if err != nil { - return nil, err - } - return updated, nil -} - -func (s *adminServiceImpl) RevertAccountProxyFallback(ctx context.Context, id int64) error { - if err := s.accountRepo.RevertProxyFallback(ctx, id); err != nil { - return err - } - // 加载回退后的账号以获取实际 ProxyID,再传播到影子账号 - account, err := s.accountRepo.GetByID(ctx, id) - if err != nil { - return fmt.Errorf("get account after proxy revert: %w", err) - } - return s.propagateProxyToShadows(ctx, id, account.ProxyID) -} - -// CreateShadow 为指定 OpenAI OAuth 母账号创建 spark 维度影子账号(一母一影)。 -// 安全不变量:Credentials 恒不含 auth token(仅 model_mapping,守卫 isAllowedSparkShadowCredentialsUpdate 放行)。 -func (s *adminServiceImpl) CreateShadow(ctx context.Context, parentID int64, opts ShadowOptions) (*Account, error) { - // 1. 加载母账号并校验平台/类型 - parent, err := s.accountRepo.GetByID(ctx, parentID) - if err != nil { - return nil, fmt.Errorf("get parent account: %w", err) - } - if !parent.IsOpenAIOAuth() { - return nil, infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_INVALID_PARENT", - "spark shadow requires an OpenAI OAuth parent account") - } - // G6:母账号本身不能是影子,否则会建出二级影子——resolveCredentialAccount 只解一层, - // 会解析到无凭据的一级影子,进入坏调度/上游失败。 - if parent.IsCredentialShadow() { - return nil, infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_PARENT_IS_SHADOW", - "spark shadow parent must be a real account, not another spark shadow") - } - - // 2. 一母一影校验 - shadows, err := s.accountRepo.ListShadowsByParent(ctx, parentID) - if err != nil { - return nil, fmt.Errorf("check existing spark shadows: %w", err) - } - if len(shadows) > 0 { - return nil, infraerrors.New(http.StatusConflict, "SPARK_SHADOW_ALREADY_EXISTS", - "parent account already has a spark shadow account") - } - - // 3. 解析分组。未指定 GroupIDs 时:优先**继承母账号当前分组**(影子与母同路由域,母在自定义 - // 组时该组的 spark 请求也能选到影子;G1 决策);母无分组再回落 openai-default(F4)。 - // 显式指定 GroupIDs 时,与 UpdateAccount 对齐先校验存在性(创建前),避免建出影子后再因无效组 - // 失败而留下孤儿影子(一母一影唯一索引会挡住重试)——外审 C/P1。 - groupIDs := opts.GroupIDs - if len(groupIDs) > 0 { - if s.groupRepo != nil { - if err := s.validateGroupIDsExist(ctx, groupIDs); err != nil { - return nil, err - } - } - } else if len(parent.GroupIDs) > 0 { - groupIDs = append([]int64(nil), parent.GroupIDs...) - } else if s.groupRepo != nil { - defaultGroupName := PlatformOpenAI + "-default" - if groups, gerr := s.groupRepo.ListActiveByPlatform(ctx, PlatformOpenAI); gerr == nil { - for _, g := range groups { - if g.Name == defaultGroupName { - groupIDs = []int64{g.ID} - break - } - } - } - } - - // 4. 构造影子账号(安全不变量:Credentials 恒不含 auth token,仅含 model_mapping)。 - // name 为空时默认 "<母账号名> (Spark)"——否则空 name 会在 ent(name NotEmpty)处变成裸 500 - // (外审 E/P2);并 rune 安全截断到 ent MaxLen(100)。 - name := strings.TrimSpace(opts.Name) - if name == "" { - name = parent.Name + " (Spark)" - } - if runes := []rune(name); len(runes) > 100 { - name = string(runes[:100]) - } - // 并发未指定(<=0)时继承母账号,避免 0 被限流器解读为"无限并发"(外审 F3)。 - concurrency := opts.Concurrency - if concurrency <= 0 { - concurrency = parent.Concurrency - } - // 优先级未指定(<=0)时继承母账号——前端一键创建只传 name,opts.Priority 省略即 0,而调度 - // 比较是「数值越小越优先」(openai_account_scheduler.isOpenAIAccountCandidateBetter),且 repo - // 显式 SetPriority 会绕过 ent 默认 50,直写 0 会让影子意外抢到最高优先级(外审第5轮 P1)。 - // 与上方 Concurrency 一致采用「省略继承母账号」语义(影子的 proxy/分组/并发亦全部继承母账号)。 - priority := opts.Priority - if priority <= 0 { - priority = parent.Priority - } - shadow := &Account{ - Name: name, - Platform: PlatformOpenAI, - Type: AccountTypeOAuth, - Status: StatusActive, - Credentials: map[string]any{"model_mapping": defaultSparkShadowModelMapping()}, - ParentAccountID: &parentID, - QuotaDimension: QuotaDimensionSpark, - ProxyID: parent.ProxyID, - Priority: priority, - Concurrency: concurrency, - Schedulable: true, - } - - // 5. 持久化(Create 填充 shadow.ID)。并发竞态:预查(步骤2)放行后另一请求抢先建成,本次会撞 - // 一母一影唯一索引。复查确认确为"已存在"竞态时返回结构化 409 而非裸 500——外审 A/P1。 - if err := s.accountRepo.Create(ctx, shadow); err != nil { - if existing, qerr := s.accountRepo.ListShadowsByParent(ctx, parentID); qerr == nil && len(existing) > 0 { - return nil, infraerrors.New(http.StatusConflict, "SPARK_SHADOW_ALREADY_EXISTS", - "parent account already has a spark shadow account") - } - return nil, fmt.Errorf("create spark shadow: %w", err) - } - - // 6. 绑定分组。注意:create+bind 非单一 DB 事务(通用 Create 走 r.client、outbox 走 r.sql, - // 无现成共享事务路径),故绑组失败时做 best-effort 补偿删除刚建的影子,避免半成品影子(否则 - // 一母一影唯一索引会挡住重试)——外审 C/P1。补偿删除用 detached ctx,即便请求 ctx 已取消/超时 - // 仍能完成清理(外审第4轮);进程崩溃这种极端仍可能残留,属已知权衡。 - if len(groupIDs) > 0 { - if err := s.accountRepo.BindGroups(ctx, shadow.ID, groupIDs); err != nil { - if delErr := s.accountRepo.Delete(context.WithoutCancel(ctx), shadow.ID); delErr != nil { - slog.Error("spark_shadow_bind_groups_rollback_failed", - "shadow_id", shadow.ID, "parent_id", parentID, "delete_err", delErr) - } - return nil, fmt.Errorf("bind groups for spark shadow: %w", err) - } - shadow.GroupIDs = groupIDs - } - - return shadow, nil -} - -// propagateProxyToShadows syncs proxyID to all spark shadow accounts of parentID. -// It is called synchronously so that proxy changes are immediately consistent; -// accountRepo.Update triggers the scheduler outbox + cache propagation internally. -// Calling this for a non-parent account is a harmless no-op. -func (s *adminServiceImpl) propagateProxyToShadows(ctx context.Context, parentID int64, proxyID *int64) error { - return propagateAccountProxyToShadows(ctx, s.accountRepo, parentID, proxyID) -} - -// propagateAccountProxyToShadows 把母账号的 proxy 同步到其所有 spark 影子(影子 proxy 恒继承母账号)。 -// 供 AdminService 编辑路径与 CRS 同步路径共用——后者改动母账号 proxy 后必须同样传播,否则影子保留 -// 旧 proxy 出现出站漂移(外审第8轮)。 -func propagateAccountProxyToShadows(ctx context.Context, repo AccountRepository, parentID int64, proxyID *int64) error { - shadows, err := repo.ListShadowsByParent(ctx, parentID) - if err != nil { - return fmt.Errorf("list spark shadows for proxy propagation: %w", err) - } - for _, shadow := range shadows { - shadow.ProxyID = proxyID - if err := repo.Update(ctx, shadow); err != nil { - return fmt.Errorf("update spark shadow %d proxy: %w", shadow.ID, err) - } - } - return nil -} - -// Proxy management implementations -func (s *adminServiceImpl) ListProxies(ctx context.Context, page, pageSize int, protocol, status, search string, sortBy, sortOrder string) ([]Proxy, int64, error) { - params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} - proxies, result, err := s.proxyRepo.ListWithFilters(ctx, params, protocol, status, search) - if err != nil { - return nil, 0, err - } - return proxies, result.Total, nil -} - -func (s *adminServiceImpl) ListProxiesWithAccountCount(ctx context.Context, page, pageSize int, protocol, status, search string, sortBy, sortOrder string) ([]ProxyWithAccountCount, int64, error) { - params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} - proxies, result, err := s.proxyRepo.ListWithFiltersAndAccountCount(ctx, params, protocol, status, search) - if err != nil { - return nil, 0, err - } - s.attachProxyLatency(ctx, proxies) - return proxies, result.Total, nil -} - -func (s *adminServiceImpl) GetAllProxies(ctx context.Context) ([]Proxy, error) { - return s.proxyRepo.ListActive(ctx) -} - -func (s *adminServiceImpl) GetAllProxiesWithAccountCount(ctx context.Context) ([]ProxyWithAccountCount, error) { - proxies, err := s.proxyRepo.ListActiveWithAccountCount(ctx) - if err != nil { - return nil, err - } - s.attachProxyLatency(ctx, proxies) - return proxies, nil -} - -func (s *adminServiceImpl) GetProxy(ctx context.Context, id int64) (*Proxy, error) { - return s.proxyRepo.GetByID(ctx, id) -} - -func (s *adminServiceImpl) GetProxiesByIDs(ctx context.Context, ids []int64) ([]Proxy, error) { - return s.proxyRepo.ListByIDs(ctx, ids) -} - -func (s *adminServiceImpl) CreateProxy(ctx context.Context, input *CreateProxyInput) (*Proxy, error) { - // 规范化 fallback_mode - mode := input.FallbackMode - if mode == "" { - mode = FallbackModeNone - } - // 校验:mode=proxy 必须有 backup - if mode == FallbackModeProxy && input.BackupProxyID == nil { - return nil, infraerrors.BadRequest("PROXY_BACKUP_REQUIRED", "backup proxy required when fallback_mode=proxy") - } - if input.ExpiryWarnDays < 0 { - return nil, infraerrors.BadRequest("PROXY_WARN_DAYS_INVALID", "expiry_warn_days must be >= 0") - } - - proxy := &Proxy{ - Name: input.Name, - Protocol: input.Protocol, - Host: input.Host, - Port: input.Port, - Username: input.Username, - Password: input.Password, - Status: StatusActive, - ExpiresAt: input.ExpiresAt, - FallbackMode: mode, - BackupProxyID: input.BackupProxyID, - ExpiryWarnDays: input.ExpiryWarnDays, - } - if err := s.proxyRepo.Create(ctx, proxy); err != nil { - return nil, err - } - // Probe latency asynchronously so creation isn't blocked by network timeout. - go s.probeProxyLatency(context.Background(), proxy) - return proxy, nil -} - -func (s *adminServiceImpl) UpdateProxy(ctx context.Context, id int64, input *UpdateProxyInput) (*Proxy, error) { - // 校验:backup_proxy_id 不能是自身 - if input.BackupProxyID != nil && *input.BackupProxyID == id { - return nil, infraerrors.BadRequest("PROXY_BACKUP_SELF", "backup proxy cannot be itself") - } - // 规范化 fallback_mode - mode := input.FallbackMode - if mode == "" { - mode = FallbackModeNone - } - // 校验:mode=proxy 必须有 backup - if mode == FallbackModeProxy && input.BackupProxyID == nil { - return nil, infraerrors.BadRequest("PROXY_BACKUP_REQUIRED", "backup proxy required when fallback_mode=proxy") - } - if input.ExpiryWarnDays < 0 { - return nil, infraerrors.BadRequest("PROXY_WARN_DAYS_INVALID", "expiry_warn_days must be >= 0") - } - - proxy, err := s.proxyRepo.GetByID(ctx, id) - if err != nil { - return nil, err - } - - if input.Name != "" { - proxy.Name = input.Name - } - if input.Protocol != "" { - proxy.Protocol = input.Protocol - } - if input.Host != "" { - proxy.Host = input.Host - } - if input.Port != 0 { - proxy.Port = input.Port - } - if input.Username != "" { - proxy.Username = input.Username - } - if input.Password != "" { - proxy.Password = input.Password - } - if input.Status != "" { - proxy.Status = input.Status - } - // 透传有效期与回退字段 - proxy.ExpiresAt = input.ExpiresAt - proxy.FallbackMode = mode - proxy.BackupProxyID = input.BackupProxyID - proxy.ExpiryWarnDays = input.ExpiryWarnDays - - if err := s.proxyRepo.Update(ctx, proxy); err != nil { - return nil, err - } - return proxy, nil -} - -func (s *adminServiceImpl) DeleteProxy(ctx context.Context, id int64) error { - count, err := s.proxyRepo.CountAccountsByProxyID(ctx, id) - if err != nil { - return err - } - if count > 0 { - return ErrProxyInUse - } - return s.proxyRepo.Delete(ctx, id) -} - -func (s *adminServiceImpl) BatchDeleteProxies(ctx context.Context, ids []int64) (*ProxyBatchDeleteResult, error) { - result := &ProxyBatchDeleteResult{} - if len(ids) == 0 { - return result, nil - } - - for _, id := range ids { - count, err := s.proxyRepo.CountAccountsByProxyID(ctx, id) - if err != nil { - result.Skipped = append(result.Skipped, ProxyBatchDeleteSkipped{ - ID: id, - Reason: err.Error(), - }) - continue - } - if count > 0 { - result.Skipped = append(result.Skipped, ProxyBatchDeleteSkipped{ - ID: id, - Reason: ErrProxyInUse.Error(), - }) - continue - } - if err := s.proxyRepo.Delete(ctx, id); err != nil { - result.Skipped = append(result.Skipped, ProxyBatchDeleteSkipped{ - ID: id, - Reason: err.Error(), - }) - continue - } - result.DeletedIDs = append(result.DeletedIDs, id) - } - - return result, nil -} - -func (s *adminServiceImpl) GetProxyAccounts(ctx context.Context, proxyID int64) ([]ProxyAccountSummary, error) { - return s.proxyRepo.ListAccountSummariesByProxyID(ctx, proxyID) -} - -func (s *adminServiceImpl) CheckProxyExists(ctx context.Context, host string, port int, username, password string) (bool, error) { - return s.proxyRepo.ExistsByHostPortAuth(ctx, host, port, username, password) -} - -// Redeem code management implementations -func (s *adminServiceImpl) ListRedeemCodes(ctx context.Context, page, pageSize int, codeType, status, search string, sortBy, sortOrder string) ([]RedeemCode, int64, error) { - params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} - codes, result, err := s.redeemCodeRepo.ListWithFilters(ctx, params, codeType, status, search) - if err != nil { - return nil, 0, err - } - return codes, result.Total, nil -} - -func (s *adminServiceImpl) GetRedeemCode(ctx context.Context, id int64) (*RedeemCode, error) { - return s.redeemCodeRepo.GetByID(ctx, id) -} - -func (s *adminServiceImpl) GenerateRedeemCodes(ctx context.Context, input *GenerateRedeemCodesInput) ([]RedeemCode, error) { - if input.ExpiresAt != nil && !input.ExpiresAt.After(time.Now()) { - return nil, ErrRedeemCodeExpired - } - - // 如果是订阅类型,验证必须有 GroupID - if input.Type == RedeemTypeSubscription { - if input.GroupID == nil { - return nil, errors.New("group_id is required for subscription type") - } - // 验证分组存在且为订阅类型 - group, err := s.groupRepo.GetByID(ctx, *input.GroupID) - if err != nil { - return nil, fmt.Errorf("group not found: %w", err) - } - if !group.IsSubscriptionType() { - return nil, errors.New("group must be subscription type") - } - } - - codes := make([]RedeemCode, 0, input.Count) - for i := 0; i < input.Count; i++ { - codeValue, err := GenerateRedeemCode() - if err != nil { - return nil, err - } - code := RedeemCode{ - Code: codeValue, - Type: input.Type, - Value: input.Value, - Status: StatusUnused, - ExpiresAt: input.ExpiresAt, - } - // 订阅类型专用字段 - if input.Type == RedeemTypeSubscription { - code.GroupID = input.GroupID - code.ValidityDays = input.ValidityDays - if code.ValidityDays <= 0 { - code.ValidityDays = 30 // 默认30天 - } - } - if err := s.redeemCodeRepo.Create(ctx, &code); err != nil { - return nil, err - } - codes = append(codes, code) - } - return codes, nil -} - -func (s *adminServiceImpl) DeleteRedeemCode(ctx context.Context, id int64) error { - return s.redeemCodeRepo.Delete(ctx, id) -} - -func (s *adminServiceImpl) BatchDeleteRedeemCodes(ctx context.Context, ids []int64) (int64, error) { - var deleted int64 - for _, id := range ids { - if err := s.redeemCodeRepo.Delete(ctx, id); err == nil { - deleted++ - } - } - return deleted, nil -} - -func (s *adminServiceImpl) ExpireRedeemCode(ctx context.Context, id int64) (*RedeemCode, error) { - code, err := s.redeemCodeRepo.GetByID(ctx, id) - if err != nil { - return nil, err - } - code.Status = StatusExpired - if err := s.redeemCodeRepo.Update(ctx, code); err != nil { - return nil, err - } - return code, nil -} - -func (s *adminServiceImpl) TestProxy(ctx context.Context, id int64) (*ProxyTestResult, error) { - proxy, err := s.proxyRepo.GetByID(ctx, id) - if err != nil { - return nil, err - } - - proxyURL := proxy.URL() - exitInfo, latencyMs, err := s.proxyProber.ProbeProxy(ctx, proxyURL) - if err != nil { - s.saveProxyLatency(ctx, id, &ProxyLatencyInfo{ - Success: false, - Message: err.Error(), - UpdatedAt: time.Now(), - }) - return &ProxyTestResult{ - Success: false, - Message: err.Error(), - }, nil - } - - latency := latencyMs - s.saveProxyLatency(ctx, id, &ProxyLatencyInfo{ - Success: true, - LatencyMs: &latency, - Message: "Proxy is accessible", - IPAddress: exitInfo.IP, - Country: exitInfo.Country, - CountryCode: exitInfo.CountryCode, - Region: exitInfo.Region, - City: exitInfo.City, - UpdatedAt: time.Now(), - }) - return &ProxyTestResult{ - Success: true, - Message: "Proxy is accessible", - LatencyMs: latencyMs, - IPAddress: exitInfo.IP, - City: exitInfo.City, - Region: exitInfo.Region, - Country: exitInfo.Country, - CountryCode: exitInfo.CountryCode, - }, nil -} - -func (s *adminServiceImpl) CheckProxyQuality(ctx context.Context, id int64) (*ProxyQualityCheckResult, error) { - proxy, err := s.proxyRepo.GetByID(ctx, id) - if err != nil { - return nil, err - } - - result := &ProxyQualityCheckResult{ - ProxyID: id, - Score: 100, - Grade: "A", - CheckedAt: time.Now().Unix(), - Items: make([]ProxyQualityCheckItem, 0, len(proxyQualityTargets)+1), - } - - proxyURL := proxy.URL() - if s.proxyProber == nil { - result.Items = append(result.Items, ProxyQualityCheckItem{ - Target: "base_connectivity", - Status: "fail", - Message: "代理探测服务未配置", - }) - result.FailedCount++ - finalizeProxyQualityResult(result) - s.saveProxyQualitySnapshot(ctx, id, result, nil) - return result, nil - } - - exitInfo, latencyMs, err := s.proxyProber.ProbeProxy(ctx, proxyURL) - if err != nil { - result.Items = append(result.Items, ProxyQualityCheckItem{ - Target: "base_connectivity", - Status: "fail", - LatencyMs: latencyMs, - Message: err.Error(), - }) - result.FailedCount++ - finalizeProxyQualityResult(result) - s.saveProxyQualitySnapshot(ctx, id, result, nil) - return result, nil - } - - result.ExitIP = exitInfo.IP - result.Country = exitInfo.Country - result.CountryCode = exitInfo.CountryCode - result.BaseLatencyMs = latencyMs - result.Items = append(result.Items, ProxyQualityCheckItem{ - Target: "base_connectivity", - Status: "pass", - LatencyMs: latencyMs, - Message: "代理出口连通正常", - }) - result.PassedCount++ - - client, err := httpclient.GetClient(httpclient.Options{ - ProxyURL: proxyURL, - Timeout: proxyQualityRequestTimeout, - ResponseHeaderTimeout: proxyQualityResponseHeaderTimeout, - }) - if err != nil { - result.Items = append(result.Items, ProxyQualityCheckItem{ - Target: "http_client", - Status: "fail", - Message: fmt.Sprintf("创建检测客户端失败: %v", err), - }) - result.FailedCount++ - finalizeProxyQualityResult(result) - s.saveProxyQualitySnapshot(ctx, id, result, exitInfo) - return result, nil - } - - for _, target := range proxyQualityTargets { - item := runProxyQualityTarget(ctx, client, target) - result.Items = append(result.Items, item) - switch item.Status { - case "pass": - result.PassedCount++ - case "warn": - result.WarnCount++ - case "challenge": - result.ChallengeCount++ - default: - result.FailedCount++ - } - } - - finalizeProxyQualityResult(result) - s.saveProxyQualitySnapshot(ctx, id, result, exitInfo) - return result, nil -} - -func runProxyQualityTarget(ctx context.Context, client *http.Client, target proxyQualityTarget) ProxyQualityCheckItem { - item := ProxyQualityCheckItem{ - Target: target.Target, - } - - req, err := http.NewRequestWithContext(ctx, target.Method, target.URL, nil) - if err != nil { - item.Status = "fail" - item.Message = fmt.Sprintf("构建请求失败: %v", err) - return item - } - req.Header.Set("Accept", "application/json,text/html,*/*") - req.Header.Set("User-Agent", proxyQualityClientUserAgent) - - start := time.Now() - resp, err := client.Do(req) - if err != nil { - item.Status = "fail" - item.LatencyMs = time.Since(start).Milliseconds() - item.Message = fmt.Sprintf("请求失败: %v", err) - return item - } - defer func() { _ = resp.Body.Close() }() - item.LatencyMs = time.Since(start).Milliseconds() - item.HTTPStatus = resp.StatusCode - - body, readErr := io.ReadAll(io.LimitReader(resp.Body, proxyQualityMaxBodyBytes+1)) - if readErr != nil { - item.Status = "fail" - item.Message = fmt.Sprintf("读取响应失败: %v", readErr) - return item - } - if int64(len(body)) > proxyQualityMaxBodyBytes { - body = body[:proxyQualityMaxBodyBytes] - } - - // Cloudflare challenge 检测 - if httputil.IsCloudflareChallengeResponse(resp.StatusCode, resp.Header, body) { - item.Status = "challenge" - item.CFRay = httputil.ExtractCloudflareRayID(resp.Header, body) - item.Message = "命中 Cloudflare challenge" - return item - } - - if _, ok := target.AllowedStatuses[resp.StatusCode]; ok { - // 白名单内的状态码均代表目标可达:2xx 表示接口直接可用, - // 401/405 等是无鉴权探测的预期结果,同样视为连通正常,不再扣分。 - item.Status = "pass" - if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { - item.Message = fmt.Sprintf("HTTP %d", resp.StatusCode) - } else { - item.Message = fmt.Sprintf("HTTP %d(目标可达)", resp.StatusCode) - } - return item - } - - if resp.StatusCode == http.StatusTooManyRequests { - item.Status = "warn" - item.Message = "目标返回 429,可能存在频控" - return item - } - - item.Status = "fail" - item.Message = fmt.Sprintf("非预期状态码: %d", resp.StatusCode) - return item -} - -func finalizeProxyQualityResult(result *ProxyQualityCheckResult) { - if result == nil { - return - } - score := 100 - result.WarnCount*10 - result.FailedCount*22 - result.ChallengeCount*30 - if score < 0 { - score = 0 - } - result.Score = score - result.Grade = proxyQualityGrade(score) - result.Summary = fmt.Sprintf( - "通过 %d 项,告警 %d 项,失败 %d 项,挑战 %d 项", - result.PassedCount, - result.WarnCount, - result.FailedCount, - result.ChallengeCount, - ) -} - -func proxyQualityGrade(score int) string { - switch { - case score >= 90: - return "A" - case score >= 75: - return "B" - case score >= 60: - return "C" - case score >= 40: - return "D" - default: - return "F" - } -} - -func proxyQualityOverallStatus(result *ProxyQualityCheckResult) string { - if result == nil { - return "" - } - if result.ChallengeCount > 0 { - return "challenge" - } - if result.FailedCount > 0 { - return "failed" - } - if result.WarnCount > 0 { - return "warn" - } - if result.PassedCount > 0 { - return "healthy" - } - return "failed" -} - -func proxyQualityFirstCFRay(result *ProxyQualityCheckResult) string { - if result == nil { - return "" - } - for _, item := range result.Items { - if item.CFRay != "" { - return item.CFRay - } - } - return "" -} - -func proxyQualityBaseConnectivityPass(result *ProxyQualityCheckResult) bool { - if result == nil { - return false - } - for _, item := range result.Items { - if item.Target == "base_connectivity" { - return item.Status == "pass" - } - } - return false -} - -func (s *adminServiceImpl) saveProxyQualitySnapshot(ctx context.Context, proxyID int64, result *ProxyQualityCheckResult, exitInfo *ProxyExitInfo) { - if result == nil { - return - } - score := result.Score - checkedAt := result.CheckedAt - info := &ProxyLatencyInfo{ - Success: proxyQualityBaseConnectivityPass(result), - Message: result.Summary, - QualityStatus: proxyQualityOverallStatus(result), - QualityScore: &score, - QualityGrade: result.Grade, - QualitySummary: result.Summary, - QualityCheckedAt: &checkedAt, - QualityCFRay: proxyQualityFirstCFRay(result), - UpdatedAt: time.Now(), - } - if result.BaseLatencyMs > 0 { - latency := result.BaseLatencyMs - info.LatencyMs = &latency - } - if exitInfo != nil { - info.IPAddress = exitInfo.IP - info.Country = exitInfo.Country - info.CountryCode = exitInfo.CountryCode - info.Region = exitInfo.Region - info.City = exitInfo.City - } - s.saveProxyLatency(ctx, proxyID, info) -} - -func (s *adminServiceImpl) probeProxyLatency(ctx context.Context, proxy *Proxy) { - if s.proxyProber == nil || proxy == nil { - return - } - exitInfo, latencyMs, err := s.proxyProber.ProbeProxy(ctx, proxy.URL()) - if err != nil { - s.saveProxyLatency(ctx, proxy.ID, &ProxyLatencyInfo{ - Success: false, - Message: err.Error(), - UpdatedAt: time.Now(), - }) - return - } - - latency := latencyMs - s.saveProxyLatency(ctx, proxy.ID, &ProxyLatencyInfo{ - Success: true, - LatencyMs: &latency, - Message: "Proxy is accessible", - IPAddress: exitInfo.IP, - Country: exitInfo.Country, - CountryCode: exitInfo.CountryCode, - Region: exitInfo.Region, - City: exitInfo.City, - UpdatedAt: time.Now(), - }) -} - -// checkMixedChannelRisk 检查分组中是否存在混合渠道(Antigravity + Anthropic) -// 如果存在混合,返回错误提示用户确认 -func (s *adminServiceImpl) checkMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error { - // 判断当前账号的渠道类型(基于 platform 字段,而不是 type 字段) - currentPlatform := getAccountPlatform(currentAccountPlatform) - if currentPlatform == "" { - // 不是 Antigravity 或 Anthropic,无需检查 - return nil - } - - // 检查每个分组中的其他账号 - for _, groupID := range groupIDs { - accounts, err := s.accountRepo.ListByGroup(ctx, groupID) - if err != nil { - return fmt.Errorf("get accounts in group %d: %w", groupID, err) - } - - // 检查是否存在不同渠道的账号 - for _, account := range accounts { - if currentAccountID > 0 && account.ID == currentAccountID { - continue // 跳过当前账号 - } - - otherPlatform := getAccountPlatform(account.Platform) - if otherPlatform == "" { - continue // 不是 Antigravity 或 Anthropic,跳过 - } - - // 检测混合渠道 - if currentPlatform != otherPlatform { - group, _ := s.groupRepo.GetByID(ctx, groupID) - groupName := fmt.Sprintf("Group %d", groupID) - if group != nil { - groupName = group.Name - } - - return &MixedChannelError{ - GroupID: groupID, - GroupName: groupName, - CurrentPlatform: currentPlatform, - OtherPlatform: otherPlatform, - } - } - } - } - - return nil -} - -func (s *adminServiceImpl) validateGroupIDsExist(ctx context.Context, groupIDs []int64) error { - if len(groupIDs) == 0 { - return nil - } - if s.groupRepo == nil { - return errors.New("group repository not configured") - } - - if batchReader, ok := s.groupRepo.(groupExistenceBatchReader); ok { - existsByID, err := batchReader.ExistsByIDs(ctx, groupIDs) - if err != nil { - return fmt.Errorf("check groups exists: %w", err) - } - for _, groupID := range groupIDs { - if groupID <= 0 || !existsByID[groupID] { - return fmt.Errorf("get group: %w", ErrGroupNotFound) - } - } - return nil - } - - for _, groupID := range groupIDs { - if _, err := s.groupRepo.GetByID(ctx, groupID); err != nil { - return fmt.Errorf("get group: %w", err) - } - } - return nil -} - -// CheckMixedChannelRisk checks whether target groups contain mixed channels for the current account platform. -func (s *adminServiceImpl) CheckMixedChannelRisk(ctx context.Context, currentAccountID int64, currentAccountPlatform string, groupIDs []int64) error { - return s.checkMixedChannelRisk(ctx, currentAccountID, currentAccountPlatform, groupIDs) -} - -func (s *adminServiceImpl) attachProxyLatency(ctx context.Context, proxies []ProxyWithAccountCount) { - if s.proxyLatencyCache == nil || len(proxies) == 0 { - return - } - - ids := make([]int64, 0, len(proxies)) - for i := range proxies { - ids = append(ids, proxies[i].ID) - } - - latencies, err := s.proxyLatencyCache.GetProxyLatencies(ctx, ids) - if err != nil { - logger.LegacyPrintf("service.admin", "Warning: load proxy latency cache failed: %v", err) - return - } - - for i := range proxies { - info := latencies[proxies[i].ID] - if info == nil { - continue - } - if info.Success { - proxies[i].LatencyStatus = "success" - proxies[i].LatencyMs = info.LatencyMs - } else { - proxies[i].LatencyStatus = "failed" - } - proxies[i].LatencyMessage = info.Message - proxies[i].IPAddress = info.IPAddress - proxies[i].Country = info.Country - proxies[i].CountryCode = info.CountryCode - proxies[i].Region = info.Region - proxies[i].City = info.City - proxies[i].QualityStatus = info.QualityStatus - proxies[i].QualityScore = info.QualityScore - proxies[i].QualityGrade = info.QualityGrade - proxies[i].QualitySummary = info.QualitySummary - proxies[i].QualityChecked = info.QualityCheckedAt - } -} - -func (s *adminServiceImpl) saveProxyLatency(ctx context.Context, proxyID int64, info *ProxyLatencyInfo) { - if s.proxyLatencyCache == nil || info == nil { - return - } - - merged := *info - if latencies, err := s.proxyLatencyCache.GetProxyLatencies(ctx, []int64{proxyID}); err == nil { - if existing := latencies[proxyID]; existing != nil { - if merged.QualityCheckedAt == nil && - merged.QualityScore == nil && - merged.QualityGrade == "" && - merged.QualityStatus == "" && - merged.QualitySummary == "" && - merged.QualityCFRay == "" { - merged.QualityStatus = existing.QualityStatus - merged.QualityScore = existing.QualityScore - merged.QualityGrade = existing.QualityGrade - merged.QualitySummary = existing.QualitySummary - merged.QualityCheckedAt = existing.QualityCheckedAt - merged.QualityCFRay = existing.QualityCFRay - } - } - } - - if err := s.proxyLatencyCache.SetProxyLatency(ctx, proxyID, &merged); err != nil { - logger.LegacyPrintf("service.admin", "Warning: store proxy latency cache failed: %v", err) - } -} - -// getAccountPlatform 根据账号 platform 判断混合渠道检查用的平台标识 -func getAccountPlatform(accountPlatform string) string { - switch strings.ToLower(strings.TrimSpace(accountPlatform)) { - case PlatformAntigravity: - return "Antigravity" - case PlatformAnthropic, "claude": - return "Anthropic" - default: - return "" - } -} - -// MixedChannelError 混合渠道错误 -type MixedChannelError struct { - GroupID int64 - GroupName string - CurrentPlatform string - OtherPlatform string -} - -func (e *MixedChannelError) Error() string { - return fmt.Sprintf("mixed_channel_warning: Group '%s' contains both %s and %s accounts. Using mixed channels in the same context may cause thinking block signature validation issues, which will fallback to non-thinking mode for historical messages.", - e.GroupName, e.CurrentPlatform, e.OtherPlatform) -} - -func (s *adminServiceImpl) ResetAccountQuota(ctx context.Context, id int64) error { - account, err := s.accountRepo.GetByID(ctx, id) - if err != nil { - return err - } - // spark 影子账号不持自有配额(凭据透传母账号、spark 用量走独立 codex_* 维度由 QueryUsage 维护), - // 通用 quota 重置对其无意义且语义不一致——明确 400 拒绝(与 OpenAI reset-credit 对影子一致)(外审第7轮 P2)。 - if account.IsCredentialShadow() { - return infraerrors.New(http.StatusBadRequest, "SPARK_SHADOW_NO_QUOTA_RESET", - "cannot reset quota for a spark shadow account; manage it on the parent account") - } - return s.accountRepo.ResetQuotaUsed(ctx, id) -} - -// EnsureOpenAIPrivacy 检查 OpenAI OAuth 账号是否已设置 privacy_mode, -// 未设置则调用 disableOpenAITraining 并持久化到 Extra,返回设置的 mode 值。 -func (s *adminServiceImpl) EnsureOpenAIPrivacy(ctx context.Context, account *Account) string { - // 影子账号不持凭据,隐私设置由母账号管理,直接跳过。 - if account.IsCredentialShadow() { - return "" - } - if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth { - return "" - } - if s.privacyClientFactory == nil { - return "" - } - if shouldSkipOpenAIPrivacyEnsure(account.Extra) { - return "" - } - - token, _ := account.Credentials["access_token"].(string) - if token == "" { - return "" - } - - var proxyURL string - if account.ProxyID != nil { - if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil { - proxyURL = p.URL() - } - } - - mode := disableOpenAITraining(ctx, s.privacyClientFactory, token, proxyURL) - if mode == "" { - return "" - } - - _ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}) - return mode -} - -// ForceOpenAIPrivacy 强制重新设置 OpenAI OAuth 账号隐私,无论当前状态。 -func (s *adminServiceImpl) ForceOpenAIPrivacy(ctx context.Context, account *Account) string { - // 影子账号不持凭据,隐私由母账号管理,直接跳过(与 EnsureOpenAIPrivacy 一致——外审第4轮)。 - if account.IsCredentialShadow() { - return "" - } - if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth { - return "" - } - if s.privacyClientFactory == nil { - return "" - } - - token, _ := account.Credentials["access_token"].(string) - if token == "" { - return "" - } - - var proxyURL string - if account.ProxyID != nil { - if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil { - proxyURL = p.URL() - } - } - - mode := disableOpenAITraining(ctx, s.privacyClientFactory, token, proxyURL) - if mode == "" { - return "" - } - - if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil { - logger.LegacyPrintf("service.admin", "force_update_openai_privacy_mode_failed: account_id=%d err=%v", account.ID, err) - return mode - } - if account.Extra == nil { - account.Extra = make(map[string]any) - } - account.Extra["privacy_mode"] = mode - return mode -} - -// EnsureAntigravityPrivacy 检查 Antigravity OAuth 账号隐私状态。 -// 仅当 privacy_mode 已成功设置("privacy_set")时跳过; -// 未设置或之前失败("privacy_set_failed")均会重试。 -func (s *adminServiceImpl) EnsureAntigravityPrivacy(ctx context.Context, account *Account) string { - if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth { - return "" - } - if account.Extra != nil { - if existing, ok := account.Extra["privacy_mode"].(string); ok && existing == AntigravityPrivacySet { - return existing - } - } - - token, _ := account.Credentials["access_token"].(string) - if token == "" { - return "" - } - - projectID, _ := account.Credentials["project_id"].(string) - - var proxyURL string - if account.ProxyID != nil { - if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil { - proxyURL = p.URL() - } - } - - mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL) - if mode == "" { - return "" - } - - if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil { - logger.LegacyPrintf("service.admin", "update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err) - return mode - } - applyAntigravityPrivacyMode(account, mode) - return mode -} - -// ForceAntigravityPrivacy 强制重新设置 Antigravity OAuth 账号隐私,无论当前状态。 -func (s *adminServiceImpl) ForceAntigravityPrivacy(ctx context.Context, account *Account) string { - if account.Platform != PlatformAntigravity || account.Type != AccountTypeOAuth { - return "" - } - - token, _ := account.Credentials["access_token"].(string) - if token == "" { - return "" - } - - projectID, _ := account.Credentials["project_id"].(string) - - var proxyURL string - if account.ProxyID != nil { - if p, err := s.proxyRepo.GetByID(ctx, *account.ProxyID); err == nil && p != nil { - proxyURL = p.URL() - } - } - - mode := setAntigravityPrivacy(ctx, token, projectID, proxyURL) - if mode == "" { - return "" - } - - if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{"privacy_mode": mode}); err != nil { - logger.LegacyPrintf("service.admin", "force_update_antigravity_privacy_mode_failed: account_id=%d err=%v", account.ID, err) - return mode - } - applyAntigravityPrivacyMode(account, mode) - return mode -} diff --git a/backend/internal/service/admin_user.go b/backend/internal/service/admin_user.go new file mode 100644 index 0000000000..e95ed3d92e --- /dev/null +++ b/backend/internal/service/admin_user.go @@ -0,0 +1,1188 @@ +package service + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/Wei-Shaw/sub2api/ent/authidentity" + "github.com/Wei-Shaw/sub2api/ent/authidentitychannel" + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/Wei-Shaw/sub2api/internal/pkg/logger" + "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" +) + +// User management implementations +func (s *adminServiceImpl) ListUsers(ctx context.Context, page, pageSize int, filters UserListFilters, sortBy, sortOrder string) ([]User, int64, error) { + params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} + users, result, err := s.userRepo.ListWithFilters(ctx, params, filters) + if err != nil { + return nil, 0, err + } + if len(users) > 0 { + userIDs := make([]int64, 0, len(users)) + for i := range users { + userIDs = append(userIDs, users[i].ID) + } + lastUsedByUserID, latestErr := s.userRepo.GetLatestUsedAtByUserIDs(ctx, userIDs) + if latestErr != nil { + logger.LegacyPrintf("service.admin", "failed to load user last_used_at in batch: err=%v", latestErr) + } else { + for i := range users { + users[i].LastUsedAt = lastUsedByUserID[users[i].ID] + } + } + } + // 批量加载用户专属分组倍率 + if s.userGroupRateRepo != nil && len(users) > 0 { + if batchRepo, ok := s.userGroupRateRepo.(userGroupRateBatchReader); ok { + userIDs := make([]int64, 0, len(users)) + for i := range users { + userIDs = append(userIDs, users[i].ID) + } + ratesByUser, err := batchRepo.GetByUserIDs(ctx, userIDs) + if err != nil { + logger.LegacyPrintf("service.admin", "failed to load user group rates in batch: err=%v", err) + s.loadUserGroupRatesOneByOne(ctx, users) + } else { + for i := range users { + if rates, ok := ratesByUser[users[i].ID]; ok { + users[i].GroupRates = rates + } + } + } + } else { + s.loadUserGroupRatesOneByOne(ctx, users) + } + } + return users, result.Total, nil +} + +func (s *adminServiceImpl) loadUserGroupRatesOneByOne(ctx context.Context, users []User) { + if s.userGroupRateRepo == nil { + return + } + for i := range users { + rates, err := s.userGroupRateRepo.GetByUserID(ctx, users[i].ID) + if err != nil { + logger.LegacyPrintf("service.admin", "failed to load user group rates: user_id=%d err=%v", users[i].ID, err) + continue + } + users[i].GroupRates = rates + } +} + +func (s *adminServiceImpl) GetUser(ctx context.Context, id int64) (*User, error) { + user, err := s.userRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + lastUsedAt, latestErr := s.userRepo.GetLatestUsedAtByUserID(ctx, id) + if latestErr != nil { + logger.LegacyPrintf("service.admin", "failed to load user last_used_at: user_id=%d err=%v", id, latestErr) + } else { + user.LastUsedAt = lastUsedAt + } + // 加载用户专属分组倍率 + if s.userGroupRateRepo != nil { + rates, err := s.userGroupRateRepo.GetByUserID(ctx, id) + if err != nil { + logger.LegacyPrintf("service.admin", "failed to load user group rates: user_id=%d err=%v", id, err) + } else { + user.GroupRates = rates + } + } + return user, nil +} + +func (s *adminServiceImpl) GetUserIncludeDeleted(ctx context.Context, id int64) (*User, error) { + return s.userRepo.GetByIDIncludeDeleted(ctx, id) +} + +func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInput) (*User, error) { + balance := 0.0 + if input.Balance != nil { + balance = *input.Balance + } else if s.settingService != nil { + balance = s.settingService.GetDefaultBalance(ctx) + } + + user := &User{ + Email: input.Email, + Username: input.Username, + Notes: input.Notes, + Role: RoleUser, // Always create as regular user, never admin + Balance: balance, + Concurrency: input.Concurrency, + RPMLimit: input.RPMLimit, + Status: StatusActive, + AllowedGroups: input.AllowedGroups, + } + if err := user.SetPassword(input.Password); err != nil { + return nil, err + } + if err := s.userRepo.Create(ctx, user); err != nil { + return nil, err + } + s.assignDefaultSubscriptions(ctx, user.ID) + return user, nil +} + +func (s *adminServiceImpl) assignDefaultSubscriptions(ctx context.Context, userID int64) { + if s.settingService == nil || s.defaultSubAssigner == nil || userID <= 0 { + return + } + items := s.settingService.GetDefaultSubscriptions(ctx) + for _, item := range items { + if _, _, err := s.defaultSubAssigner.AssignOrExtendSubscription(ctx, &AssignSubscriptionInput{ + UserID: userID, + GroupID: item.GroupID, + ValidityDays: item.ValidityDays, + Notes: "auto assigned by default user subscriptions setting", + }); err != nil { + logger.LegacyPrintf("service.admin", "failed to assign default subscription: user_id=%d group_id=%d err=%v", userID, item.GroupID, err) + } + } +} + +func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *UpdateUserInput) (*User, error) { + // 校验用户专属分组倍率:必须 > 0(nil 合法,表示清除专属倍率) + if input.GroupRates != nil { + for groupID, rate := range input.GroupRates { + if rate != nil && *rate <= 0 { + return nil, fmt.Errorf("rate_multiplier must be > 0 (group_id=%d)", groupID) + } + } + } + + user, err := s.userRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + + // Protect admin users: cannot disable admin accounts + if user.Role == "admin" && input.Status == "disabled" { + return nil, errors.New("cannot disable admin user") + } + + oldConcurrency := user.Concurrency + oldStatus := user.Status + oldRole := user.Role + oldRPMLimit := user.RPMLimit + oldAllowedGroups := append([]int64(nil), user.AllowedGroups...) + + if input.Email != "" { + user.Email = input.Email + } + if input.Password != "" { + if err := user.SetPassword(input.Password); err != nil { + return nil, err + } + } + + if input.Username != nil { + user.Username = *input.Username + } + if input.Notes != nil { + user.Notes = *input.Notes + } + + if input.Status != "" { + user.Status = input.Status + } + + if input.Concurrency != nil { + user.Concurrency = *input.Concurrency + } + + if input.RPMLimit != nil { + user.RPMLimit = *input.RPMLimit + } + + if input.AllowedGroups != nil { + user.AllowedGroups = *input.AllowedGroups + } + + if err := s.userRepo.Update(ctx, user); err != nil { + return nil, err + } + + // 同步用户专属分组倍率 + if input.GroupRates != nil && s.userGroupRateRepo != nil { + if err := s.userGroupRateRepo.SyncUserGroupRates(ctx, user.ID, input.GroupRates); err != nil { + logger.LegacyPrintf("service.admin", "failed to sync user group rates: user_id=%d err=%v", user.ID, err) + } + } + + if s.authCacheInvalidator != nil { + // RPMLimit 直接参与 billing_cache_service.checkRPM 的三级级联, + // allowed_groups 参与 API Key 专属分组授权判断;不失效缓存会让修改在一个 L2 TTL 内失去效果。 + if user.Concurrency != oldConcurrency || user.Status != oldStatus || user.Role != oldRole || user.RPMLimit != oldRPMLimit || !sameInt64Set(user.AllowedGroups, oldAllowedGroups) { + s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, user.ID) + } + } + + concurrencyDiff := user.Concurrency - oldConcurrency + if concurrencyDiff != 0 { + code, err := GenerateRedeemCode() + if err != nil { + logger.LegacyPrintf("service.admin", "failed to generate adjustment redeem code: %v", err) + return user, nil + } + adjustmentRecord := &RedeemCode{ + Code: code, + Type: AdjustmentTypeAdminConcurrency, + Value: float64(concurrencyDiff), + Status: StatusUsed, + UsedBy: &user.ID, + } + now := time.Now() + adjustmentRecord.UsedAt = &now + if err := s.redeemCodeRepo.Create(ctx, adjustmentRecord); err != nil { + logger.LegacyPrintf("service.admin", "failed to create concurrency adjustment redeem code: %v", err) + } + } + + return user, nil +} + +func sameInt64Set(a, b []int64) bool { + if len(a) != len(b) { + return false + } + if len(a) == 0 { + return true + } + counts := make(map[int64]int, len(a)) + for _, v := range a { + counts[v]++ + } + for _, v := range b { + if counts[v] == 0 { + return false + } + counts[v]-- + } + return true +} + +func (s *adminServiceImpl) DeleteUser(ctx context.Context, id int64) error { + // Protect admin users: cannot delete admin accounts + user, err := s.userRepo.GetByID(ctx, id) + if err != nil { + return err + } + if user.Role == "admin" { + return errors.New("cannot delete admin user") + } + + apiKeys, err := s.listUserAPIKeysForDeletion(ctx, id) + if err != nil { + return err + } + + if s.entClient != nil { + tx, err := s.entClient.Tx(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + + opCtx := dbent.NewTxContext(ctx, tx) + if err := s.deleteUserWithAPIKeys(opCtx, id, apiKeys); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return err + } + } else { + if err := s.deleteUserWithAPIKeys(ctx, id, apiKeys); err != nil { + return err + } + } + + if s.authCacheInvalidator != nil { + for _, key := range apiKeys { + if keyValue := strings.TrimSpace(key.Key); keyValue != "" { + s.authCacheInvalidator.InvalidateAuthCacheByKey(ctx, keyValue) + } + } + s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, id) + } + return nil +} + +func (s *adminServiceImpl) listUserAPIKeysForDeletion(ctx context.Context, userID int64) ([]APIKey, error) { + if s.apiKeyRepo == nil { + return nil, nil + } + + const pageSize = 1000 + keys := make([]APIKey, 0) + for page := 1; ; page++ { + batch, result, err := s.apiKeyRepo.ListByUserID(ctx, userID, pagination.PaginationParams{ + Page: page, + PageSize: pageSize, + SortBy: "id", + SortOrder: pagination.SortOrderAsc, + }, APIKeyListFilters{}) + if err != nil { + return nil, fmt.Errorf("list user api keys: %w", err) + } + keys = append(keys, batch...) + if len(batch) == 0 || len(batch) < pageSize || result == nil || int64(len(keys)) >= result.Total { + break + } + } + return keys, nil +} + +func (s *adminServiceImpl) deleteUserWithAPIKeys(ctx context.Context, userID int64, apiKeys []APIKey) error { + if s.apiKeyRepo != nil { + for _, key := range apiKeys { + if key.ID <= 0 { + continue + } + if err := s.apiKeyRepo.DeleteWithAudit(ctx, key.ID); err != nil { + logger.LegacyPrintf("service.admin", "delete user api key failed: user_id=%d api_key_id=%d err=%v", userID, key.ID, err) + return fmt.Errorf("delete user api key %d: %w", key.ID, err) + } + } + } + + if err := s.userRepo.Delete(ctx, userID); err != nil { + logger.LegacyPrintf("service.admin", "delete user failed: user_id=%d err=%v", userID, err) + return err + } + return nil +} + +func (s *adminServiceImpl) BatchUpdateConcurrency(ctx context.Context, userIDs []int64, value int, mode string) (int, error) { + cleaned := make([]int64, 0, len(userIDs)) + for _, uid := range userIDs { + if uid > 0 { + cleaned = append(cleaned, uid) + } + } + if len(cleaned) == 0 { + return 0, nil + } + + var affected int + var err error + switch mode { + case "set": + affected, err = s.userRepo.BatchSetConcurrency(ctx, cleaned, value) + case "add": + affected, err = s.userRepo.BatchAddConcurrency(ctx, cleaned, value) + default: + return 0, errors.New("invalid mode: must be 'set' or 'add'") + } + if err != nil { + return 0, err + } + + if s.authCacheInvalidator != nil { + for _, uid := range cleaned { + s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, uid) + } + } + return affected, nil +} + +func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) { + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return nil, err + } + + oldBalance := user.Balance + + switch operation { + case "set": + user.Balance = balance + case "add": + user.Balance += balance + case "subtract": + user.Balance -= balance + } + + if user.Balance < 0 { + return nil, fmt.Errorf("balance cannot be negative, current balance: %.2f, requested operation would result in: %.2f", oldBalance, user.Balance) + } + + if err := s.userRepo.Update(ctx, user); err != nil { + return nil, err + } + balanceDiff := user.Balance - oldBalance + if s.authCacheInvalidator != nil && balanceDiff != 0 { + s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID) + } + + if s.billingCacheService != nil { + go func() { + cacheCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := s.billingCacheService.InvalidateUserBalance(cacheCtx, userID); err != nil { + logger.LegacyPrintf("service.admin", "invalidate user balance cache failed: user_id=%d err=%v", userID, err) + } + }() + } + + if balanceDiff != 0 { + code, err := GenerateRedeemCode() + if err != nil { + logger.LegacyPrintf("service.admin", "failed to generate adjustment redeem code: %v", err) + return user, nil + } + + adjustmentRecord := &RedeemCode{ + Code: code, + Type: AdjustmentTypeAdminBalance, + Value: balanceDiff, + Status: StatusUsed, + UsedBy: &user.ID, + Notes: notes, + } + now := time.Now() + adjustmentRecord.UsedAt = &now + + if err := s.redeemCodeRepo.Create(ctx, adjustmentRecord); err != nil { + logger.LegacyPrintf("service.admin", "failed to create balance adjustment redeem code: %v", err) + } + } + + return user, nil +} + +func (s *adminServiceImpl) GetUserAPIKeys(ctx context.Context, userID int64, page, pageSize int, sortBy, sortOrder string) ([]APIKey, int64, error) { + params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} + keys, result, err := s.apiKeyRepo.ListByUserID(ctx, userID, params, APIKeyListFilters{}) + if err != nil { + return nil, 0, err + } + return keys, result.Total, nil +} + +func (s *adminServiceImpl) GetUserRPMStatus(ctx context.Context, userID int64) (*UserRPMStatus, error) { + if s.userRPMCache == nil { + return nil, ErrRPMStatusUnavailable + } + + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return nil, err + } + + userRPMUsed, err := s.userRPMCache.GetUserRPM(ctx, userID) + if err != nil { + logger.LegacyPrintf("service.admin", "failed to get user rpm: user_id=%d err=%v", userID, err) + } + + keys, _, err := s.GetUserAPIKeys(ctx, userID, 1, 1000, "", "") + if err != nil { + return nil, err + } + + groupIDSet := make(map[int64]struct{}) + for _, key := range keys { + if key.GroupID != nil && *key.GroupID > 0 { + groupIDSet[*key.GroupID] = struct{}{} + } + } + + groupIDs := make([]int64, 0, len(groupIDSet)) + for groupID := range groupIDSet { + groupIDs = append(groupIDs, groupID) + } + sort.Slice(groupIDs, func(i, j int) bool { return groupIDs[i] < groupIDs[j] }) + + var perGroup []UserGroupRPMStatus + for _, groupID := range groupIDs { + used, getErr := s.userRPMCache.GetUserGroupRPM(ctx, userID, groupID) + if getErr != nil { + logger.LegacyPrintf("service.admin", "failed to get user group rpm: user_id=%d group_id=%d err=%v", userID, groupID, getErr) + } + + entry := UserGroupRPMStatus{ + GroupID: groupID, + Used: used, + } + + if s.groupRepo != nil { + if group, groupErr := s.groupRepo.GetByIDLite(ctx, groupID); groupErr == nil && group != nil { + entry.GroupName = group.Name + entry.Limit = group.RPMLimit + entry.Source = "group" + } else if groupErr != nil { + logger.LegacyPrintf("service.admin", "failed to get group rpm status metadata: group_id=%d err=%v", groupID, groupErr) + } + } + + if s.userGroupRateRepo != nil { + override, overrideErr := s.userGroupRateRepo.GetRPMOverrideByUserAndGroup(ctx, userID, groupID) + if overrideErr != nil { + logger.LegacyPrintf("service.admin", "failed to get rpm override: user_id=%d group_id=%d err=%v", userID, groupID, overrideErr) + } else if override != nil { + entry.Limit = *override + entry.Source = "override" + } + } + + perGroup = append(perGroup, entry) + } + + return &UserRPMStatus{ + UserRPMUsed: userRPMUsed, + UserRPMLimit: user.RPMLimit, + PerGroup: perGroup, + }, nil +} + +func (s *adminServiceImpl) GetUserUsageStats(ctx context.Context, userID int64, period string) (any, error) { + // Return mock data for now + return map[string]any{ + "period": period, + "total_requests": 0, + "total_cost": 0.0, + "total_tokens": 0, + "avg_duration_ms": 0, + }, nil +} + +// GetUserBalanceHistory returns paginated balance/concurrency change records for a user. +func (s *adminServiceImpl) GetUserBalanceHistory(ctx context.Context, userID int64, page, pageSize int, codeType string) ([]RedeemCode, int64, float64, error) { + params := pagination.PaginationParams{Page: page, PageSize: pageSize} + if codeType == RedeemTypeAffiliateBalance { + codes, total, err := s.listAffiliateBalanceHistory(ctx, userID, params) + if err != nil { + return nil, 0, 0, err + } + totalRecharged, err := s.redeemCodeRepo.SumPositiveBalanceByUser(ctx, userID) + if err != nil { + return nil, 0, 0, err + } + return codes, total, totalRecharged, nil + } + + if codeType == "" { + return s.getAllUserBalanceHistory(ctx, userID, params) + } + + codes, result, err := s.redeemCodeRepo.ListByUserPaginated(ctx, userID, params, codeType) + if err != nil { + return nil, 0, 0, err + } + total := result.Total + // Aggregate total recharged amount (only once, regardless of type filter) + totalRecharged, err := s.redeemCodeRepo.SumPositiveBalanceByUser(ctx, userID) + if err != nil { + return nil, 0, 0, err + } + return codes, total, totalRecharged, nil +} + +func (s *adminServiceImpl) getAllUserBalanceHistory(ctx context.Context, userID int64, params pagination.PaginationParams) ([]RedeemCode, int64, float64, error) { + needed := params.Offset() + params.Limit() + if needed < params.Limit() { + needed = params.Limit() + } + + redeemCodes, redeemTotal, err := s.listRedeemBalanceHistoryForMerge(ctx, userID, needed) + if err != nil { + return nil, 0, 0, err + } + affiliateCodes, affiliateTotal, err := s.listAffiliateBalanceHistoryForMerge(ctx, userID, needed) + if err != nil { + return nil, 0, 0, err + } + codes := mergeBalanceHistoryCodes(redeemCodes, affiliateCodes, params) + + totalRecharged, err := s.redeemCodeRepo.SumPositiveBalanceByUser(ctx, userID) + if err != nil { + return nil, 0, 0, err + } + return codes, redeemTotal + affiliateTotal, totalRecharged, nil +} + +func (s *adminServiceImpl) listRedeemBalanceHistoryForMerge(ctx context.Context, userID int64, needed int) ([]RedeemCode, int64, error) { + if needed <= 0 { + return nil, 0, nil + } + + var ( + out []RedeemCode + total int64 + ) + for page := 1; len(out) < needed; page++ { + params := pagination.PaginationParams{Page: page, PageSize: 1000} + codes, result, err := s.redeemCodeRepo.ListByUserPaginated(ctx, userID, params, "") + if err != nil { + return nil, 0, err + } + if result != nil { + total = result.Total + } + out = append(out, codes...) + if len(codes) < params.Limit() || int64(len(out)) >= total { + break + } + } + if len(out) > needed { + out = out[:needed] + } + return out, total, nil +} + +func (s *adminServiceImpl) listAffiliateBalanceHistoryForMerge(ctx context.Context, userID int64, needed int) ([]RedeemCode, int64, error) { + if needed <= 0 { + return nil, 0, nil + } + + var ( + out []RedeemCode + total int64 + ) + for page := 1; len(out) < needed; page++ { + params := pagination.PaginationParams{Page: page, PageSize: 1000} + codes, currentTotal, err := s.listAffiliateBalanceHistory(ctx, userID, params) + if err != nil { + return nil, 0, err + } + total = currentTotal + out = append(out, codes...) + if len(codes) < params.Limit() || int64(len(out)) >= total { + break + } + } + if len(out) > needed { + out = out[:needed] + } + return out, total, nil +} + +func (s *adminServiceImpl) listAffiliateBalanceHistory(ctx context.Context, userID int64, params pagination.PaginationParams) ([]RedeemCode, int64, error) { + if s == nil || s.entClient == nil || userID <= 0 { + return nil, 0, nil + } + + rows, err := s.entClient.QueryContext(ctx, ` +SELECT id, + amount::double precision, + created_at +FROM user_affiliate_ledger +WHERE user_id = $1 + AND action = 'transfer' +ORDER BY created_at DESC, id DESC +OFFSET $2 +LIMIT $3`, userID, params.Offset(), params.Limit()) + if err != nil { + return nil, 0, err + } + defer func() { _ = rows.Close() }() + + codes := make([]RedeemCode, 0, params.Limit()) + for rows.Next() { + var id int64 + var amount float64 + var createdAt time.Time + if err := rows.Scan(&id, &amount, &createdAt); err != nil { + return nil, 0, err + } + usedBy := userID + usedAt := createdAt + codes = append(codes, RedeemCode{ + ID: -id, + Code: fmt.Sprintf("AFF-%d", id), + Type: RedeemTypeAffiliateBalance, + Value: amount, + Status: StatusUsed, + UsedBy: &usedBy, + UsedAt: &usedAt, + CreatedAt: createdAt, + }) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + + total, err := countAffiliateBalanceHistory(ctx, s.entClient, userID) + if err != nil { + return nil, 0, err + } + return codes, total, nil +} + +func countAffiliateBalanceHistory(ctx context.Context, client *dbent.Client, userID int64) (int64, error) { + rows, err := client.QueryContext(ctx, ` +SELECT COUNT(*) +FROM user_affiliate_ledger +WHERE user_id = $1 + AND action = 'transfer'`, userID) + if err != nil { + return 0, err + } + defer func() { _ = rows.Close() }() + + var total sql.NullInt64 + if rows.Next() { + if err := rows.Scan(&total); err != nil { + return 0, err + } + } + if err := rows.Err(); err != nil { + return 0, err + } + if !total.Valid { + return 0, nil + } + return total.Int64, nil +} + +func mergeBalanceHistoryCodes(redeemCodes, affiliateCodes []RedeemCode, params pagination.PaginationParams) []RedeemCode { + combined := append(append([]RedeemCode{}, redeemCodes...), affiliateCodes...) + sort.SliceStable(combined, func(i, j int) bool { + return redeemCodeHistoryTime(combined[i]).After(redeemCodeHistoryTime(combined[j])) + }) + offset := params.Offset() + if offset >= len(combined) { + return []RedeemCode{} + } + end := offset + params.Limit() + if end > len(combined) { + end = len(combined) + } + return combined[offset:end] +} + +func redeemCodeHistoryTime(code RedeemCode) time.Time { + if code.UsedAt != nil { + return *code.UsedAt + } + return code.CreatedAt +} + +func (s *adminServiceImpl) BindUserAuthIdentity(ctx context.Context, userID int64, input AdminBindAuthIdentityInput) (*AdminBoundAuthIdentity, error) { + if userID <= 0 { + return nil, infraerrors.BadRequest("INVALID_INPUT", "user_id must be greater than 0") + } + if s == nil || s.entClient == nil || s.userRepo == nil { + return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_BIND_UNAVAILABLE", "auth identity binding service is unavailable") + } + if _, err := s.userRepo.GetByID(ctx, userID); err != nil { + return nil, err + } + + providerType := normalizeAdminAuthIdentityProviderType(input.ProviderType) + providerKey := strings.TrimSpace(input.ProviderKey) + providerSubject := strings.TrimSpace(input.ProviderSubject) + if providerType == "" { + return nil, infraerrors.BadRequest("INVALID_INPUT", "provider_type must be one of email, linuxdo, oidc, wechat, or dingtalk") + } + if providerKey == "" || providerSubject == "" { + return nil, infraerrors.BadRequest("INVALID_INPUT", "provider_type, provider_key, and provider_subject are required") + } + canonicalProviderKey := canonicalAdminAuthIdentityProviderKey(providerType, "", providerKey) + compatibleProviderKeys := compatibleAdminAuthIdentityProviderKeys(providerType, providerKey) + + var issuer *string + if input.Issuer != nil { + trimmed := strings.TrimSpace(*input.Issuer) + if trimmed != "" { + issuer = &trimmed + } + } + + channelInput := normalizeAdminBindChannelInput(input.Channel) + if input.Channel != nil && channelInput == nil { + return nil, infraerrors.BadRequest("INVALID_INPUT", "channel, channel_app_id, and channel_subject are required when channel binding is provided") + } + + verifiedAt := time.Now().UTC() + tx, err := s.entClient.Tx(ctx) + if err != nil { + return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_BIND_TX_FAILED", "failed to start auth identity bind transaction").WithCause(err) + } + defer func() { _ = tx.Rollback() }() + + identityRecords, err := tx.AuthIdentity.Query(). + Where( + authidentity.ProviderTypeEQ(providerType), + authidentity.ProviderKeyIn(compatibleProviderKeys...), + authidentity.ProviderSubjectEQ(providerSubject), + ). + All(ctx) + if err != nil { + return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_BIND_LOOKUP_FAILED", "failed to inspect auth identity ownership").WithCause(err) + } + if hasAdminAuthIdentityOwnershipConflict(identityRecords, userID) { + return nil, infraerrors.Conflict("AUTH_IDENTITY_OWNERSHIP_CONFLICT", "auth identity already belongs to another user") + } + identity := selectOwnedAdminAuthIdentity(identityRecords, userID) + + if identity == nil { + create := tx.AuthIdentity.Create(). + SetUserID(userID). + SetProviderType(providerType). + SetProviderKey(canonicalProviderKey). + SetProviderSubject(providerSubject). + SetVerifiedAt(verifiedAt) + if issuer != nil { + create = create.SetIssuer(*issuer) + } + if input.Metadata != nil { + create = create.SetMetadata(cloneAdminAuthIdentityMetadata(input.Metadata)) + } + identity, err = create.Save(ctx) + if err != nil { + return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_BIND_SAVE_FAILED", "failed to save auth identity").WithCause(err) + } + } else { + update := tx.AuthIdentity.UpdateOneID(identity.ID). + SetVerifiedAt(verifiedAt). + SetProviderKey(canonicalProviderKey) + if issuer != nil { + update = update.SetIssuer(*issuer) + } + if input.Metadata != nil { + update = update.SetMetadata(cloneAdminAuthIdentityMetadata(input.Metadata)) + } + identity, err = update.Save(ctx) + if err != nil { + return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_BIND_SAVE_FAILED", "failed to save auth identity").WithCause(err) + } + } + + var channel *dbent.AuthIdentityChannel + if channelInput != nil { + channelRecords, err := tx.AuthIdentityChannel.Query(). + Where( + authidentitychannel.ProviderTypeEQ(providerType), + authidentitychannel.ProviderKeyIn(compatibleProviderKeys...), + authidentitychannel.ChannelEQ(channelInput.Channel), + authidentitychannel.ChannelAppIDEQ(channelInput.ChannelAppID), + authidentitychannel.ChannelSubjectEQ(channelInput.ChannelSubject), + ). + WithIdentity(). + All(ctx) + if err != nil { + return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_CHANNEL_LOOKUP_FAILED", "failed to inspect auth identity channel ownership").WithCause(err) + } + if hasAdminAuthIdentityChannelOwnershipConflict(channelRecords, userID) { + return nil, infraerrors.Conflict("AUTH_IDENTITY_CHANNEL_OWNERSHIP_CONFLICT", "auth identity channel already belongs to another user") + } + channel = selectOwnedAdminAuthIdentityChannel(channelRecords, userID) + if channel == nil { + create := tx.AuthIdentityChannel.Create(). + SetIdentityID(identity.ID). + SetProviderType(providerType). + SetProviderKey(canonicalProviderKey). + SetChannel(channelInput.Channel). + SetChannelAppID(channelInput.ChannelAppID). + SetChannelSubject(channelInput.ChannelSubject) + if channelInput.Metadata != nil { + create = create.SetMetadata(cloneAdminAuthIdentityMetadata(channelInput.Metadata)) + } + channel, err = create.Save(ctx) + if err != nil { + return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_CHANNEL_SAVE_FAILED", "failed to save auth identity channel").WithCause(err) + } + } else { + update := tx.AuthIdentityChannel.UpdateOneID(channel.ID). + SetIdentityID(identity.ID). + SetProviderKey(canonicalProviderKey) + if channelInput.Metadata != nil { + update = update.SetMetadata(cloneAdminAuthIdentityMetadata(channelInput.Metadata)) + } + channel, err = update.Save(ctx) + if err != nil { + return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_CHANNEL_SAVE_FAILED", "failed to save auth identity channel").WithCause(err) + } + } + } + + if err := tx.Commit(); err != nil { + return nil, infraerrors.InternalServer("ADMIN_AUTH_IDENTITY_BIND_COMMIT_FAILED", "failed to commit auth identity bind").WithCause(err) + } + return buildAdminBoundAuthIdentity(identity, channel), nil +} + +func compatibleAdminAuthIdentityProviderKeys(providerType, providerKey string) []string { + providerType = strings.TrimSpace(strings.ToLower(providerType)) + providerKey = strings.TrimSpace(providerKey) + if providerKey == "" { + return []string{providerKey} + } + if providerType != "wechat" { + return []string{providerKey} + } + + keys := []string{providerKey} + if !strings.EqualFold(providerKey, "wechat-main") { + keys = append(keys, "wechat-main") + } + if !strings.EqualFold(providerKey, "wechat") { + keys = append(keys, "wechat") + } + return keys +} + +func canonicalAdminAuthIdentityProviderKey(providerType, existingKey, requestedKey string) string { + providerType = strings.TrimSpace(strings.ToLower(providerType)) + existingKey = strings.TrimSpace(existingKey) + requestedKey = strings.TrimSpace(requestedKey) + if providerType != "wechat" { + if requestedKey != "" { + return requestedKey + } + return existingKey + } + if strings.EqualFold(existingKey, "wechat") || strings.EqualFold(existingKey, "wechat-main") || strings.EqualFold(requestedKey, "wechat-main") { + return "wechat-main" + } + if requestedKey != "" { + return requestedKey + } + return existingKey +} + +func adminAuthIdentityProviderKeyRank(providerType, providerKey string) int { + providerType = strings.TrimSpace(strings.ToLower(providerType)) + providerKey = strings.TrimSpace(providerKey) + if providerType != "wechat" { + return 0 + } + switch { + case strings.EqualFold(providerKey, "wechat-main"): + return 0 + case strings.EqualFold(providerKey, "wechat"): + return 2 + default: + return 1 + } +} + +func selectOwnedAdminAuthIdentity(records []*dbent.AuthIdentity, userID int64) *dbent.AuthIdentity { + var selected *dbent.AuthIdentity + for _, record := range records { + if record.UserID != userID { + continue + } + if selected == nil || adminAuthIdentityProviderKeyRank(record.ProviderType, record.ProviderKey) < adminAuthIdentityProviderKeyRank(selected.ProviderType, selected.ProviderKey) { + selected = record + } + } + return selected +} + +func hasAdminAuthIdentityOwnershipConflict(records []*dbent.AuthIdentity, userID int64) bool { + for _, record := range records { + if record.UserID != userID { + return true + } + } + return false +} + +func selectOwnedAdminAuthIdentityChannel(records []*dbent.AuthIdentityChannel, userID int64) *dbent.AuthIdentityChannel { + var selected *dbent.AuthIdentityChannel + for _, record := range records { + if record.Edges.Identity == nil || record.Edges.Identity.UserID != userID { + continue + } + if selected == nil || adminAuthIdentityProviderKeyRank(record.ProviderType, record.ProviderKey) < adminAuthIdentityProviderKeyRank(selected.ProviderType, selected.ProviderKey) { + selected = record + } + } + return selected +} + +func hasAdminAuthIdentityChannelOwnershipConflict(records []*dbent.AuthIdentityChannel, userID int64) bool { + for _, record := range records { + if record.Edges.Identity != nil && record.Edges.Identity.UserID != userID { + return true + } + } + return false +} + +func normalizeAdminBindChannelInput(input *AdminBindAuthIdentityChannelInput) *AdminBindAuthIdentityChannelInput { + if input == nil { + return nil + } + channel := &AdminBindAuthIdentityChannelInput{ + Channel: strings.TrimSpace(input.Channel), + ChannelAppID: strings.TrimSpace(input.ChannelAppID), + ChannelSubject: strings.TrimSpace(input.ChannelSubject), + Metadata: cloneAdminAuthIdentityMetadata(input.Metadata), + } + if channel.Channel == "" || channel.ChannelAppID == "" || channel.ChannelSubject == "" { + return nil + } + return channel +} + +func normalizeAdminAuthIdentityProviderType(input string) string { + switch strings.ToLower(strings.TrimSpace(input)) { + case "email": + return "email" + case "linuxdo": + return "linuxdo" + case "oidc": + return "oidc" + case "wechat": + return "wechat" + case "dingtalk": + return "dingtalk" + default: + return "" + } +} + +func buildAdminBoundAuthIdentity(identity *dbent.AuthIdentity, channel *dbent.AuthIdentityChannel) *AdminBoundAuthIdentity { + if identity == nil { + return nil + } + result := &AdminBoundAuthIdentity{ + UserID: identity.UserID, + ProviderType: strings.TrimSpace(identity.ProviderType), + ProviderKey: strings.TrimSpace(identity.ProviderKey), + ProviderSubject: strings.TrimSpace(identity.ProviderSubject), + VerifiedAt: identity.VerifiedAt, + Issuer: identity.Issuer, + Metadata: cloneAdminAuthIdentityMetadata(identity.Metadata), + CreatedAt: identity.CreatedAt, + UpdatedAt: identity.UpdatedAt, + } + if channel != nil { + result.Channel = &AdminBoundAuthIdentityChannel{ + Channel: strings.TrimSpace(channel.Channel), + ChannelAppID: strings.TrimSpace(channel.ChannelAppID), + ChannelSubject: strings.TrimSpace(channel.ChannelSubject), + Metadata: cloneAdminAuthIdentityMetadata(channel.Metadata), + CreatedAt: channel.CreatedAt, + UpdatedAt: channel.UpdatedAt, + } + } + return result +} + +func cloneAdminAuthIdentityMetadata(input map[string]any) map[string]any { + if input == nil { + return nil + } + if len(input) == 0 { + return map[string]any{} + } + data, err := json.Marshal(input) + if err != nil { + out := make(map[string]any, len(input)) + for key, value := range input { + out[key] = value + } + return out + } + var out map[string]any + if err := json.Unmarshal(data, &out); err != nil { + out = make(map[string]any, len(input)) + for key, value := range input { + out[key] = value + } + } + return out +} + +// Redeem code management implementations +func (s *adminServiceImpl) ListRedeemCodes(ctx context.Context, page, pageSize int, codeType, status, search string, sortBy, sortOrder string) ([]RedeemCode, int64, error) { + params := pagination.PaginationParams{Page: page, PageSize: pageSize, SortBy: sortBy, SortOrder: sortOrder} + codes, result, err := s.redeemCodeRepo.ListWithFilters(ctx, params, codeType, status, search) + if err != nil { + return nil, 0, err + } + return codes, result.Total, nil +} + +func (s *adminServiceImpl) GetRedeemCode(ctx context.Context, id int64) (*RedeemCode, error) { + return s.redeemCodeRepo.GetByID(ctx, id) +} + +func (s *adminServiceImpl) GenerateRedeemCodes(ctx context.Context, input *GenerateRedeemCodesInput) ([]RedeemCode, error) { + if input.ExpiresAt != nil && !input.ExpiresAt.After(time.Now()) { + return nil, ErrRedeemCodeExpired + } + + // 如果是订阅类型,验证必须有 GroupID + if input.Type == RedeemTypeSubscription { + if input.GroupID == nil { + return nil, errors.New("group_id is required for subscription type") + } + // 验证分组存在且为订阅类型 + group, err := s.groupRepo.GetByID(ctx, *input.GroupID) + if err != nil { + return nil, fmt.Errorf("group not found: %w", err) + } + if !group.IsSubscriptionType() { + return nil, errors.New("group must be subscription type") + } + } + + codes := make([]RedeemCode, 0, input.Count) + for i := 0; i < input.Count; i++ { + codeValue, err := GenerateRedeemCode() + if err != nil { + return nil, err + } + code := RedeemCode{ + Code: codeValue, + Type: input.Type, + Value: input.Value, + Status: StatusUnused, + ExpiresAt: input.ExpiresAt, + } + // 订阅类型专用字段 + if input.Type == RedeemTypeSubscription { + code.GroupID = input.GroupID + code.ValidityDays = input.ValidityDays + if code.ValidityDays <= 0 { + code.ValidityDays = 30 // 默认30天 + } + } + if err := s.redeemCodeRepo.Create(ctx, &code); err != nil { + return nil, err + } + codes = append(codes, code) + } + return codes, nil +} + +func (s *adminServiceImpl) DeleteRedeemCode(ctx context.Context, id int64) error { + return s.redeemCodeRepo.Delete(ctx, id) +} + +func (s *adminServiceImpl) BatchDeleteRedeemCodes(ctx context.Context, ids []int64) (int64, error) { + var deleted int64 + for _, id := range ids { + if err := s.redeemCodeRepo.Delete(ctx, id); err == nil { + deleted++ + } + } + return deleted, nil +} + +func (s *adminServiceImpl) ExpireRedeemCode(ctx context.Context, id int64) (*RedeemCode, error) { + code, err := s.redeemCodeRepo.GetByID(ctx, id) + if err != nil { + return nil, err + } + code.Status = StatusExpired + if err := s.redeemCodeRepo.Update(ctx, code); err != nil { + return nil, err + } + return code, nil +}