From ee867f7231f92804bd59ce166d1fe78320990931 Mon Sep 17 00:00:00 2001 From: MiaoZang Date: Sun, 26 Apr 2026 21:55:21 +0800 Subject: [PATCH 1/7] feat: localize quota filters and add normal account filter --- backend/internal/repository/account_repo.go | 51 +++ .../internal/service/account_model_filter.go | 304 +++++++++++++++ .../service/account_model_filter_test.go | 159 ++++++++ .../admin/account/AccountTableFilters.vue | 11 +- frontend/src/i18n/locales/en.ts | 15 + frontend/src/i18n/locales/zh.ts | 15 + frontend/src/views/admin/AccountsView.vue | 40 ++ .../admin/__tests__/AccountsView.spec.ts | 356 ++++++++++++++++++ 8 files changed, 950 insertions(+), 1 deletion(-) create mode 100644 backend/internal/service/account_model_filter.go create mode 100644 backend/internal/service/account_model_filter_test.go create mode 100644 frontend/src/views/admin/__tests__/AccountsView.spec.ts diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 78f739ac205..3089d275c7c 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -487,6 +487,22 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati )) }), ) + case service.AccountStatusFilterActiveExcludingQuotaStopped: + q = q.Where( + dbaccount.StatusEQ(service.StatusActive), + dbaccount.SchedulableEQ(true), + dbaccount.Or( + dbaccount.RateLimitResetAtIsNil(), + dbaccount.RateLimitResetAtLTE(time.Now()), + ), + dbpredicate.Account(func(s *entsql.Selector) { + col := s.C("temp_unschedulable_until") + s.Where(entsql.Or( + entsql.IsNull(col), + entsql.LTE(col, entsql.Expr("NOW()")), + )) + }), + ) case "rate_limited": q = q.Where( dbaccount.StatusEQ(service.StatusActive), @@ -553,6 +569,41 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati })) } + normalizedStatus := strings.TrimSpace(status) + if normalizedStatus == service.AccountStatusFilterActiveExcludingQuotaStopped || strings.TrimSpace(model) != "" || strings.TrimSpace(quotaStrategy) != "" { + accountsQuery := q + for _, order := range accountListOrder(params) { + accountsQuery = accountsQuery.Order(order) + } + accounts, err := accountsQuery.All(ctx) + if err != nil { + return nil, nil, err + } + outAccounts, err := r.accountsToService(ctx, accounts) + if err != nil { + return nil, nil, err + } + filtered := make([]service.Account, 0, len(outAccounts)) + now := time.Now() + for i := range outAccounts { + if service.MatchesAccountListStatusFilter(&outAccounts[i], normalizedStatus, now) && + service.IsAccountSupportedForModelFilter(&outAccounts[i], model) && + service.MatchesOpenAIQuotaStrategyFilter(&outAccounts[i], quotaStrategy) { + filtered = append(filtered, outAccounts[i]) + } + } + total := int64(len(filtered)) + start := params.Offset() + if start >= len(filtered) { + return []service.Account{}, paginationResultFromTotal(total, params), nil + } + end := start + params.Limit() + if end > len(filtered) { + end = len(filtered) + } + return filtered[start:end], paginationResultFromTotal(total, params), nil + } + total, err := q.Count(ctx) if err != nil { return nil, nil, err diff --git a/backend/internal/service/account_model_filter.go b/backend/internal/service/account_model_filter.go new file mode 100644 index 00000000000..af67baad851 --- /dev/null +++ b/backend/internal/service/account_model_filter.go @@ -0,0 +1,304 @@ +package service + +import ( + "strings" + "time" + + "github.com/Wei-Shaw/sub2api/internal/pkg/antigravity" + "github.com/Wei-Shaw/sub2api/internal/pkg/claude" + "github.com/Wei-Shaw/sub2api/internal/pkg/geminicli" + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" +) + +type AccountModelFilterEntry struct { + Value string `json:"value"` + Label string `json:"label"` +} + +type AccountModelFilterGroup struct { + Platform string `json:"platform"` + Label string `json:"label"` + Models []AccountModelFilterEntry `json:"models"` +} + +const ( + AccountModelFilterLimited = "__limited__" + AccountModelFilterUnlimited = "__unlimited__" + AccountStatusFilterActiveExcludingQuotaStopped = "active_excluding_quota_stopped" +) + +func ListAccountModelFilterGroups() []AccountModelFilterGroup { + return []AccountModelFilterGroup{ + { + Platform: PlatformOpenAI, + Label: "OpenAI", + Models: buildOpenAIModelFilterEntries(), + }, + { + Platform: PlatformAnthropic, + Label: "Anthropic", + Models: buildClaudeModelFilterEntries(), + }, + { + Platform: PlatformGemini, + Label: "Gemini", + Models: buildGeminiModelFilterEntries(), + }, + { + Platform: PlatformAntigravity, + Label: "Antigravity", + Models: buildAntigravityModelFilterEntries(), + }, + } +} + +func FilterAccountModelGroupsByPlatform(groups []AccountModelFilterGroup, platform string) []AccountModelFilterGroup { + normalizedPlatform := strings.TrimSpace(platform) + if normalizedPlatform == "" { + return groups + } + + filtered := make([]AccountModelFilterGroup, 0, 1) + for _, group := range groups { + if group.Platform == normalizedPlatform { + filtered = append(filtered, group) + break + } + } + return filtered +} + +func IsAccountSupportedForModelFilter(account *Account, requestedModel string) bool { + if account == nil { + return false + } + + trimmed := strings.TrimSpace(requestedModel) + if trimmed == "" { + return false + } + + switch trimmed { + case AccountModelFilterLimited: + return hasExplicitModelRestriction(account) + case AccountModelFilterUnlimited: + return !hasExplicitModelRestriction(account) + } + + mapping := account.GetModelMapping() + if len(mapping) == 0 { + return true + } + + if account.IsAnthropicOAuthOrSetupToken() { + for _, alias := range buildAnthropicModelAliases(trimmed) { + if mappingSupportsRequestedModel(mapping, alias) { + return true + } + } + if mappingSupportsAnthropicModelAlias(mapping, trimmed) { + return true + } + } + + return account.IsModelSupported(trimmed) +} + +func hasExplicitModelRestriction(account *Account) bool { + if account == nil || account.Credentials == nil { + return false + } + rawMapping, ok := account.Credentials["model_mapping"] + if !ok || rawMapping == nil { + return false + } + switch mapping := rawMapping.(type) { + case map[string]any: + return len(mapping) > 0 + case map[string]string: + return len(mapping) > 0 + default: + return false + } +} + +func MatchesOpenAIQuotaStrategyFilter(account *Account, requestedStrategy string) bool { + if account == nil { + return false + } + + switch strings.TrimSpace(requestedStrategy) { + case "": + return true + case "prefer_5h": + return account.GetOpenAIQuotaStrategy() == "prefer_5h" + case "prefer_7d": + return account.GetOpenAIQuotaStrategy() == "prefer_7d" + case "enabled": + strategy := account.GetOpenAIQuotaStrategy() + return strategy == "prefer_5h" || strategy == "prefer_7d" + case "disabled": + return account.GetOpenAIQuotaStrategy() == "" + default: + return false + } +} + +func MatchesAccountListStatusFilter(account *Account, requestedStatus string, now time.Time) bool { + if account == nil { + return false + } + + switch strings.TrimSpace(requestedStatus) { + case "": + return true + case StatusActive: + return matchesActiveAccountListStatusFilter(account, now, false) + case AccountStatusFilterActiveExcludingQuotaStopped: + return matchesActiveAccountListStatusFilter(account, now, true) + case "rate_limited": + return account.Status == StatusActive && + isAccountRateLimitedAt(account, now) && + !isAccountTempUnschedulableAt(account, now) + case "temp_unschedulable": + return account.Status == StatusActive && isAccountTempUnschedulableAt(account, now) + case "unschedulable": + return account.Status == StatusActive && + !account.Schedulable && + !isAccountRateLimitedAt(account, now) && + !isAccountTempUnschedulableAt(account, now) + default: + return account.Status == requestedStatus + } +} + +func matchesActiveAccountListStatusFilter(account *Account, now time.Time, excludeQuotaStopped bool) bool { + if account == nil { + return false + } + if account.Status != StatusActive || !account.Schedulable { + return false + } + if isAccountRateLimitedAt(account, now) || isAccountTempUnschedulableAt(account, now) { + return false + } + if excludeQuotaStopped && !account.IsOpenAIQuotaStrategySchedulable() { + return false + } + return true +} + +func isAccountRateLimitedAt(account *Account, now time.Time) bool { + return account != nil && account.RateLimitResetAt != nil && account.RateLimitResetAt.After(now) +} + +func isAccountTempUnschedulableAt(account *Account, now time.Time) bool { + return account != nil && account.TempUnschedulableUntil != nil && account.TempUnschedulableUntil.After(now) +} + +func buildAnthropicModelAliases(requestedModel string) []string { + trimmed := strings.TrimSpace(requestedModel) + if trimmed == "" { + return nil + } + + aliases := make([]string, 0, 8) + seen := make(map[string]struct{}, 8) + add := func(value string) { + value = strings.TrimSpace(value) + if value == "" { + return + } + if _, ok := seen[value]; ok { + return + } + seen[value] = struct{}{} + aliases = append(aliases, value) + } + + add(trimmed) + add(claude.NormalizeModelID(trimmed)) + add(claude.DenormalizeModelID(trimmed)) + + if short := stripAnthropicDateSuffix(trimmed); short != trimmed { + add(short) + add(claude.NormalizeModelID(short)) + } + + for _, model := range claude.DefaultModels { + modelID := strings.TrimSpace(model.ID) + shortID := stripAnthropicDateSuffix(modelID) + if trimmed == modelID || trimmed == shortID { + add(modelID) + add(shortID) + } + } + + return aliases +} + +func mappingSupportsAnthropicModelAlias(mapping map[string]string, requestedModel string) bool { + normalizedRequested := stripAnthropicDateSuffix(requestedModel) + if normalizedRequested == "" { + return false + } + for key, value := range mapping { + if stripAnthropicDateSuffix(key) == normalizedRequested { + return true + } + if stripAnthropicDateSuffix(value) == normalizedRequested { + return true + } + } + return false +} + +func stripAnthropicDateSuffix(model string) string { + parts := strings.Split(strings.TrimSpace(model), "-") + if len(parts) < 2 { + return strings.TrimSpace(model) + } + last := parts[len(parts)-1] + if len(last) != 8 { + return strings.TrimSpace(model) + } + for _, ch := range last { + if ch < '0' || ch > '9' { + return strings.TrimSpace(model) + } + } + return strings.Join(parts[:len(parts)-1], "-") +} + +func buildOpenAIModelFilterEntries() []AccountModelFilterEntry { + entries := make([]AccountModelFilterEntry, 0, len(openai.DefaultModels)) + for _, model := range openai.DefaultModels { + entries = append(entries, AccountModelFilterEntry{Value: model.ID, Label: model.DisplayName}) + } + return entries +} + +func buildClaudeModelFilterEntries() []AccountModelFilterEntry { + entries := make([]AccountModelFilterEntry, 0, len(claude.DefaultModels)) + for _, model := range claude.DefaultModels { + entries = append(entries, AccountModelFilterEntry{Value: model.ID, Label: model.DisplayName}) + } + return entries +} + +func buildGeminiModelFilterEntries() []AccountModelFilterEntry { + entries := make([]AccountModelFilterEntry, 0, len(geminicli.DefaultModels)) + for _, model := range geminicli.DefaultModels { + entries = append(entries, AccountModelFilterEntry{Value: model.ID, Label: model.DisplayName}) + } + return entries +} + +func buildAntigravityModelFilterEntries() []AccountModelFilterEntry { + models := antigravity.DefaultModels() + entries := make([]AccountModelFilterEntry, 0, len(models)) + for _, model := range models { + entries = append(entries, AccountModelFilterEntry{Value: model.ID, Label: model.DisplayName}) + } + return entries +} diff --git a/backend/internal/service/account_model_filter_test.go b/backend/internal/service/account_model_filter_test.go new file mode 100644 index 00000000000..b6de551fe66 --- /dev/null +++ b/backend/internal/service/account_model_filter_test.go @@ -0,0 +1,159 @@ +package service + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestIsAccountSupportedForModelFilter(t *testing.T) { + t.Run("Anthropic OAuth 支持短 ID 命中带日期后缀模型", func(t *testing.T) { + account := &Account{ + Platform: PlatformAnthropic, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "claude-3-7-sonnet-20250219": "claude-3-7-sonnet-20250219", + }, + }, + } + + require.True(t, IsAccountSupportedForModelFilter(account, "claude-3-7-sonnet")) + }) +} + +func TestMatchesOpenAIQuotaStrategyFilter(t *testing.T) { + account5h := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{ + "openai_quota_strategy": "prefer_5h", + }, + } + account7d := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{ + "openai_quota_strategy": "prefer_7d", + }, + } + accountDisabled := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{}, + } + + tests := []struct { + name string + account *Account + filter string + expected bool + }{ + {name: "no_restriction", account: account5h, filter: "", expected: true}, + {name: "prefer_5h", account: account5h, filter: "prefer_5h", expected: true}, + {name: "prefer_5h_mismatch", account: account7d, filter: "prefer_5h", expected: false}, + {name: "prefer_7d", account: account7d, filter: "prefer_7d", expected: true}, + {name: "enabled_matches_5h", account: account5h, filter: "enabled", expected: true}, + {name: "enabled_matches_7d", account: account7d, filter: "enabled", expected: true}, + {name: "disabled_matches_empty", account: accountDisabled, filter: "disabled", expected: true}, + {name: "disabled_rejects_enabled", account: account5h, filter: "disabled", expected: false}, + {name: "unknown_filter", account: account5h, filter: "unknown", expected: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := MatchesOpenAIQuotaStrategyFilter(tt.account, tt.filter); got != tt.expected { + t.Fatalf("MatchesOpenAIQuotaStrategyFilter() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestMatchesModelRestrictionFilter(t *testing.T) { + accountLimited := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{ + "model_mapping": map[string]any{ + "gpt-5": "gpt-5", + }, + }, + } + accountUnlimited := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Credentials: map[string]any{}, + } + + tests := []struct { + name string + account *Account + filter string + expected bool + }{ + {name: "all_models_matches_everything", account: accountLimited, filter: "", expected: false}, + {name: "limited_matches_explicit_mapping", account: accountLimited, filter: AccountModelFilterLimited, expected: true}, + {name: "limited_rejects_missing_mapping", account: accountUnlimited, filter: AccountModelFilterLimited, expected: false}, + {name: "unlimited_matches_missing_mapping", account: accountUnlimited, filter: AccountModelFilterUnlimited, expected: true}, + {name: "unlimited_rejects_explicit_mapping", account: accountLimited, filter: AccountModelFilterUnlimited, expected: false}, + {name: "specific_model_still_uses_support_check", account: accountLimited, filter: "gpt-5", expected: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsAccountSupportedForModelFilter(tt.account, tt.filter); got != tt.expected { + t.Fatalf("IsAccountSupportedForModelFilter() = %v, want %v", got, tt.expected) + } + }) + } +} + +func TestMatchesAccountListStatusFilter(t *testing.T) { + now := time.Date(2026, 4, 26, 21, 0, 0, 0, time.UTC) + rateLimitedUntil := now.Add(5 * time.Minute) + tempUnschedUntil := now.Add(5 * time.Minute) + + activeQuotaOK := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + Extra: map[string]any{ + "openai_quota_strategy": "prefer_5h", + "openai_quota_stop_threshold_percent": 10, + "codex_5h_used_percent": 80, + }, + } + activeQuotaStopped := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Status: StatusActive, + Schedulable: true, + Extra: map[string]any{ + "openai_quota_strategy": "prefer_5h", + "openai_quota_stop_threshold_percent": 10, + "codex_5h_used_percent": 95, + }, + } + rateLimited := &Account{ + Status: StatusActive, + Schedulable: true, + RateLimitResetAt: &rateLimitedUntil, + } + tempUnsched := &Account{ + Status: StatusActive, + Schedulable: true, + TempUnschedulableUntil: &tempUnschedUntil, + } + unschedulable := &Account{ + Status: StatusActive, + Schedulable: false, + } + + require.True(t, MatchesAccountListStatusFilter(activeQuotaOK, AccountStatusFilterActiveExcludingQuotaStopped, now)) + require.False(t, MatchesAccountListStatusFilter(activeQuotaStopped, AccountStatusFilterActiveExcludingQuotaStopped, now)) + require.True(t, MatchesAccountListStatusFilter(rateLimited, "rate_limited", now)) + require.True(t, MatchesAccountListStatusFilter(tempUnsched, "temp_unschedulable", now)) + require.True(t, MatchesAccountListStatusFilter(unschedulable, "unschedulable", now)) +} diff --git a/frontend/src/components/admin/account/AccountTableFilters.vue b/frontend/src/components/admin/account/AccountTableFilters.vue index b33dad84e0e..cbefcac5e7a 100644 --- a/frontend/src/components/admin/account/AccountTableFilters.vue +++ b/frontend/src/components/admin/account/AccountTableFilters.vue @@ -27,7 +27,16 @@ const updatePrivacyMode = (value: string | number | boolean | null) => { emit('u const updateGroup = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, group: value }) } const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }]) const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }, { value: 'bedrock', label: 'AWS Bedrock' }]) -const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }, { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }, { value: 'temp_unschedulable', label: t('admin.accounts.status.tempUnschedulable') }, { value: 'unschedulable', label: t('admin.accounts.status.unschedulable') }]) +const sOpts = computed(() => [ + { value: '', label: t('admin.accounts.allStatus') }, + { value: 'active', label: t('admin.accounts.status.active') }, + { value: 'active_excluding_quota_stopped', label: t('admin.accounts.status.activeExcludingQuotaStopped') }, + { value: 'inactive', label: t('admin.accounts.status.inactive') }, + { value: 'error', label: t('admin.accounts.status.error') }, + { value: 'rate_limited', label: t('admin.accounts.status.rateLimited') }, + { value: 'temp_unschedulable', label: t('admin.accounts.status.tempUnschedulable') }, + { value: 'unschedulable', label: t('admin.accounts.status.unschedulable') } +]) const privacyOpts = computed(() => [ { value: '', label: t('admin.accounts.allPrivacyModes') }, { value: '__unset__', label: t('admin.accounts.privacyUnset') }, diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index d18a895c007..0497eae4007 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -2841,6 +2841,7 @@ export default { allPlatforms: 'All Platforms', allTypes: 'All Types', allStatus: 'All Status', + allQuotaStrategies: 'All Quota Strategies', allGroups: 'All Groups', ungroupedGroup: 'Ungrouped', oauthType: 'OAuth', @@ -2873,6 +2874,7 @@ export default { }, status: { active: 'Active', + activeExcludingQuotaStopped: 'Active (Exclude Quota-Stopped)', inactive: 'Inactive', error: 'Error', cooldown: 'Cooldown', @@ -3089,6 +3091,12 @@ export default { failedToDelete: 'Failed to delete account', failedToClearRateLimit: 'Failed to clear rate limit', deleteConfirm: "Are you sure you want to delete '{name}'? This action cannot be undone.", + quotaStrategy: { + prefer5h: 'Prefer 5H Window', + prefer7d: 'Prefer 7D Window', + enabled: 'Quota Strategy Enabled', + disabled: 'Quota Strategy Disabled' + }, // Create/Edit Account Modal platform: 'Platform', accountName: 'Account Name', @@ -3186,6 +3194,13 @@ export default { testMode: 'Test mode', testModeDefault: 'Default request', testModeCompact: 'Compact probe', + quotaStrategy: 'Quota Strategy', + quotaStrategyDesc: 'Decide whether this account continues to participate in scheduling based on remaining quota in the 5-hour or 7-day window.', + quotaStrategyMode: 'Quota Strategy Mode', + quotaStrategyPrefer5h: 'Use 5H Window', + quotaStrategyPrefer7d: 'Use 7D Window', + quotaStopThreshold: 'Stop Threshold (Remaining %)', + quotaStopThresholdDesc: 'When remaining quota falls below this percentage, the account is excluded from new request scheduling.', modelRestrictionDisabledByPassthrough: 'Automatic passthrough is enabled: model whitelist/mapping will not take effect.', }, anthropic: { diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 4f473f946b7..9e12bf23a4d 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -2920,6 +2920,7 @@ export default { allPlatforms: '全部平台', allTypes: '全部类型', allStatus: '全部状态', + allQuotaStrategies: '全部额度策略', allGroups: '全部分组', ungroupedGroup: '未分配分组', oauthType: 'OAuth', @@ -3038,6 +3039,12 @@ export default { apiKey: 'API Key', deleteConfirm: "确定要删除账号 '{name}' 吗?此操作无法撤销。", failedToClearRateLimit: '清除速率限制失败', + quotaStrategy: { + prefer5h: '5小时窗口优先', + prefer7d: '7天窗口优先', + enabled: '已启用额度策略', + disabled: '未启用额度策略' + }, platforms: { claude: 'Claude', openai: 'OpenAI', @@ -3060,6 +3067,7 @@ export default { }, status: { active: '正常', + activeExcludingQuotaStopped: '正常(排除超额度)', inactive: '停用', error: '错误', cooldown: '冷却中', @@ -3331,6 +3339,13 @@ export default { testMode: '测试模式', testModeDefault: '常规请求', testModeCompact: 'Compact 探测', + quotaStrategy: '额度策略', + quotaStrategyDesc: '根据 5 小时或 7 天窗口剩余额度决定该账号是否继续参与调度。', + quotaStrategyMode: '额度策略模式', + quotaStrategyPrefer5h: '按 5 小时窗口', + quotaStrategyPrefer7d: '按 7 天窗口', + quotaStopThreshold: '停止调度阈值(剩余额度 %)', + quotaStopThresholdDesc: '当剩余额度低于该百分比时,此账号不再参与新请求调度。', modelRestrictionDisabledByPassthrough: '已开启自动透传:模型白名单/映射不会生效。', }, anthropic: { diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index c2159f6f990..81db0882977 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -1405,6 +1405,16 @@ const accountMatchesCurrentFilters = (account: Account) => { const filters = buildAccountQueryFilters() if (filters.platform && account.platform !== filters.platform) return false if (filters.type && account.type !== filters.type) return false + const strategy = String(account.extra?.openai_quota_strategy || '').trim() + const thresholdRaw = Number(account.extra?.openai_quota_stop_threshold_percent ?? 10) + const threshold = Number.isFinite(thresholdRaw) && thresholdRaw > 0 ? Math.min(100, thresholdRaw) : 10 + const usedKey = strategy === 'prefer_5h' ? 'codex_5h_used_percent' : strategy === 'prefer_7d' ? 'codex_7d_used_percent' : '' + const usedRaw = usedKey ? Number(account.extra?.[usedKey] ?? Number.NaN) : Number.NaN + const usedPercent = Number.isFinite(usedRaw) ? Math.min(100, Math.max(0, usedRaw)) : Number.NaN + const remainingPercent = Number.isFinite(usedPercent) ? 100 - usedPercent : Number.NaN + const isQuotaStopped = (strategy === 'prefer_5h' || strategy === 'prefer_7d') && + Number.isFinite(remainingPercent) && + remainingPercent < threshold if (filters.status) { const now = Date.now() const rateLimitResetAt = account.rate_limit_reset_at ? new Date(account.rate_limit_reset_at).getTime() : Number.NaN @@ -1414,6 +1424,8 @@ const accountMatchesCurrentFilters = (account: Account) => { if (filters.status === 'active') { if (account.status !== 'active' || isRateLimited || isTempUnschedulable || !account.schedulable) return false + } else if (filters.status === 'active_excluding_quota_stopped') { + if (account.status !== 'active' || isRateLimited || isTempUnschedulable || !account.schedulable || isQuotaStopped) return false } else if (filters.status === 'rate_limited') { if (account.status !== 'active' || !isRateLimited || isTempUnschedulable) return false } else if (filters.status === 'temp_unschedulable') { @@ -1432,6 +1444,34 @@ const accountMatchesCurrentFilters = (account: Account) => { return false } } + if (filters.model) { + const credentials = account.credentials as Record | undefined + const geminiCredentials = credentials as { model_mapping?: Record } | undefined + const modelMapping = geminiCredentials?.model_mapping + if (account.platform === 'gemini' && modelMapping && !Object.values(modelMapping).includes(filters.model)) { + return false + } + } + if (filters.quota_strategy) { + if (filters.quota_strategy === 'enabled') { + if (strategy !== 'prefer_5h' && strategy !== 'prefer_7d') return false + } else if (filters.quota_strategy === 'disabled') { + if (strategy === 'prefer_5h' || strategy === 'prefer_7d') return false + } else if (strategy !== filters.quota_strategy) { + return false + } + } + if (filters.proxy_filter) { + const proxyID = account.proxy_id + if (filters.proxy_filter === 'configured') { + if (!proxyID) return false + } else if (filters.proxy_filter === 'unconfigured') { + if (proxyID) return false + } else if (String(filters.proxy_filter).startsWith('proxy:')) { + const expectedProxyID = String(filters.proxy_filter).slice('proxy:'.length) + if (String(proxyID || '') !== expectedProxyID) return false + } + } const privacyMode = typeof account.extra?.privacy_mode === 'string' ? account.extra.privacy_mode : '' if (filters.privacy_mode) { if (filters.privacy_mode === ACCOUNT_PRIVACY_MODE_UNSET_QUERY_VALUE) { diff --git a/frontend/src/views/admin/__tests__/AccountsView.spec.ts b/frontend/src/views/admin/__tests__/AccountsView.spec.ts new file mode 100644 index 00000000000..95586bd358d --- /dev/null +++ b/frontend/src/views/admin/__tests__/AccountsView.spec.ts @@ -0,0 +1,356 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { flushPromises, mount } from '@vue/test-utils' + +import type { Account, AdminGroup, PaginatedResponse, WindowStats } from '@/types' +import AccountsView from '../AccountsView.vue' + +const { + listAccounts, + listWithEtag, + getFilterModels, + getBatchTodayStats, + listProxies, + getAllProxies, + getAllGroups +} = vi.hoisted(() => ({ + listAccounts: vi.fn(), + listWithEtag: vi.fn(), + getFilterModels: vi.fn(), + getBatchTodayStats: vi.fn(), + listProxies: vi.fn(), + getAllProxies: vi.fn(), + getAllGroups: vi.fn() +})) + +vi.mock('@/api/admin', () => ({ + adminAPI: { + accounts: { + list: listAccounts, + listWithEtag, + getFilterModels, + getBatchTodayStats, + delete: vi.fn(), + batchClearError: vi.fn(), + batchRefresh: vi.fn(), + bulkUpdate: vi.fn(), + exportData: vi.fn(), + getAvailableModels: vi.fn(), + refreshCredentials: vi.fn(), + recoverState: vi.fn(), + resetAccountQuota: vi.fn(), + setPrivacy: vi.fn(), + setSchedulable: vi.fn() + }, + proxies: { + list: listProxies, + getAll: getAllProxies + }, + groups: { + getAll: getAllGroups + } + } +})) + +const showError = vi.fn() +const showSuccess = vi.fn() + +vi.mock('@/stores/app', () => ({ + useAppStore: () => ({ + showError, + showSuccess + }) +})) + +vi.mock('@/stores/auth', () => ({ + useAuthStore: () => ({ + isSimpleMode: false + }) +})) + +vi.mock('@/composables/useSwipeSelect', () => ({ + useSwipeSelect: vi.fn() +})) + +vi.mock('vue-i18n', async () => { + const actual = await vi.importActual('vue-i18n') + return { + ...actual, + useI18n: () => ({ + t: (key: string, params?: Record) => { + if (key === 'admin.accounts.autoRefreshCountdown' && params?.seconds != null) { + return `${key}:${params.seconds}` + } + return key + } + }) + } +}) + +const createAccount = (): Account => ({ + id: 1, + name: 'Claude Account', + platform: 'anthropic', + type: 'oauth', + credentials: {}, + extra: {}, + proxy_id: null, + concurrency: 1, + priority: 1, + status: 'active', + error_message: null, + last_used_at: null, + expires_at: null, + auto_pause_on_expired: false, + created_at: '2026-04-25T00:00:00Z', + updated_at: '2026-04-25T00:00:00Z', + groups: [], + group_ids: [], + schedulable: true, + rate_limited_at: null, + rate_limit_reset_at: null, + overload_until: null, + temp_unschedulable_until: null, + temp_unschedulable_reason: null, + session_window_start: null, + session_window_end: null, + session_window_status: null +}) + +const createListResponse = (): PaginatedResponse => ({ + items: [createAccount()], + total: 1, + page: 1, + page_size: 20, + pages: 1 +}) + +const anthropicGroups = [ + { + platform: 'anthropic', + label: 'Anthropic', + models: [{ value: 'claude-3-7-sonnet-20250219', label: 'claude-3-7-sonnet-20250219' }] + } +] + +const openaiGroups = [ + { + platform: 'openai', + label: 'OpenAI', + models: [{ value: 'gpt-4o', label: 'gpt-4o' }] + } +] + +const AccountTableFiltersStub = { + props: ['filters', 'groups', 'modelGroups', 'searchQuery'], + emits: ['update:filters', 'change', 'update:searchQuery'], + template: ` +
+
{{ filters.model || '' }}
+
{{ filters.quota_strategy || '' }}
+
{{ filters.proxy_filter || '' }}
+
{{ (modelGroups || []).map(group => group.platform).join(',') }}
+ + + + + +
+ ` +} + +const TablePageLayoutStub = { + template: '
' +} + +const AccountTableActionsStub = { + template: '
' +} + +const createWrapper = () => mount(AccountsView, { + global: { + stubs: { + AppLayout: { template: '
' }, + TablePageLayout: TablePageLayoutStub, + AccountTableFilters: AccountTableFiltersStub, + AccountTableActions: AccountTableActionsStub, + AccountBulkActionsBar: true, + DataTable: { template: '
' }, + Pagination: true, + ConfirmDialog: { template: '
' }, + CreateAccountModal: true, + EditAccountModal: true, + BulkEditAccountModal: true, + SyncFromCrsModal: true, + TempUnschedStatusModal: true, + ImportDataModal: true, + ReAuthAccountModal: true, + AccountTestModal: true, + AccountStatsModal: true, + ScheduledTestsPanel: true, + AccountActionMenu: true, + ErrorPassthroughRulesModal: true, + TLSFingerprintProfilesModal: true, + AccountStatusIndicator: true, + AccountUsageCell: true, + AccountTodayStatsCell: true, + AccountGroupsCell: true, + AccountCapacityCell: true, + PlatformTypeBadge: true, + Icon: true, + Teleport: true + } + } +}) + +describe('admin AccountsView', () => { + beforeEach(() => { + vi.useFakeTimers() + localStorage.clear() + + listAccounts.mockReset() + listWithEtag.mockReset() + getFilterModels.mockReset() + getBatchTodayStats.mockReset() + listProxies.mockReset() + getAllProxies.mockReset() + getAllGroups.mockReset() + showError.mockReset() + showSuccess.mockReset() + + listAccounts.mockResolvedValue(createListResponse()) + listWithEtag.mockResolvedValue({ notModified: true, etag: 'etag', data: null }) + getBatchTodayStats.mockResolvedValue({ stats: { '1': { requests: 0, tokens: 0, cost: 0, standard_cost: 0, user_cost: 0 } satisfies WindowStats } }) + listProxies.mockResolvedValue({ items: [], total: 0, page: 1, page_size: 500, pages: 1 }) + getAllProxies.mockResolvedValue([]) + getAllGroups.mockResolvedValue([] as AdminGroup[]) + getFilterModels.mockImplementation(async (platform?: string) => { + if (platform === 'openai') return openaiGroups + if (platform === 'anthropic') return anthropicGroups + return [...anthropicGroups, ...openaiGroups] + }) + }) + + it('首屏加载模型筛选项,并透传 model 参数请求列表', async () => { + const wrapper = createWrapper() + + await flushPromises() + + expect(getFilterModels).toHaveBeenCalledWith(undefined) + expect(wrapper.get('[data-test="model-groups"]').text()).toContain('anthropic') + + await wrapper.get('[data-test="set-model"]').trigger('click') + await vi.advanceTimersByTimeAsync(350) + await flushPromises() + + expect(listAccounts).toHaveBeenLastCalledWith( + 1, + 20, + expect.objectContaining({ + model: 'claude-3-7-sonnet-20250219' + }), + expect.any(Object) + ) + }) + + it('切换平台时会清空无效模型值并重新拉取平台模型', async () => { + const wrapper = createWrapper() + + await flushPromises() + + await wrapper.get('[data-test="set-model"]').trigger('click') + await vi.advanceTimersByTimeAsync(350) + await flushPromises() + expect(wrapper.get('[data-test="current-model"]').text()).toBe('claude-3-7-sonnet-20250219') + + await wrapper.get('[data-test="set-openai-platform"]').trigger('click') + await flushPromises() + await vi.advanceTimersByTimeAsync(350) + await flushPromises() + + expect(getFilterModels).toHaveBeenLastCalledWith('openai') + expect(wrapper.get('[data-test="current-model"]').text()).toBe('') + expect(listAccounts).toHaveBeenLastCalledWith( + 1, + 20, + expect.objectContaining({ + platform: 'openai', + model: '' + }), + expect.any(Object) + ) + }) + + it('额度策略筛选会透传到列表请求', async () => { + const wrapper = createWrapper() + + await flushPromises() + + await wrapper.get('[data-test="set-quota-strategy"]').trigger('click') + await vi.advanceTimersByTimeAsync(350) + await flushPromises() + + expect(wrapper.get('[data-test="current-quota-strategy"]').text()).toBe('enabled') + expect(listAccounts).toHaveBeenLastCalledWith( + 1, + 20, + expect.objectContaining({ + quota_strategy: 'enabled' + }), + expect.any(Object) + ) + }) + + it('代理筛选会透传到列表请求', async () => { + const wrapper = createWrapper() + + await flushPromises() + + await wrapper.get('[data-test="set-proxy-filter"]').trigger('click') + await vi.advanceTimersByTimeAsync(350) + await flushPromises() + + expect(wrapper.get('[data-test="current-proxy-filter"]').text()).toBe('configured') + expect(listAccounts).toHaveBeenLastCalledWith( + 1, + 20, + expect.objectContaining({ + proxy_filter: 'configured' + }), + expect.any(Object) + ) + }) + + it('新状态筛选会透传到列表请求', async () => { + const wrapper = createWrapper() + + await flushPromises() + + await wrapper.get('[data-test="set-status-filter"]').trigger('click') + await vi.advanceTimersByTimeAsync(350) + await flushPromises() + + expect(listAccounts).toHaveBeenLastCalledWith( + 1, + 20, + expect.objectContaining({ + status: 'active_excluding_quota_stopped' + }), + expect.any(Object) + ) + }) +}) From 4445b3e099d98176e44b6b61dcb2e51a822ec37a Mon Sep 17 00:00:00 2001 From: MiaoZang Date: Sun, 26 Apr 2026 23:28:03 +0800 Subject: [PATCH 2/7] fix: keep active quota filter when model is empty --- backend/internal/repository/account_repo.go | 3 +- .../account_repo_integration_test.go | 35 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 3089d275c7c..a262fc54292 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -586,8 +586,9 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati filtered := make([]service.Account, 0, len(outAccounts)) now := time.Now() for i := range outAccounts { + matchesModel := strings.TrimSpace(model) == "" || service.IsAccountSupportedForModelFilter(&outAccounts[i], model) if service.MatchesAccountListStatusFilter(&outAccounts[i], normalizedStatus, now) && - service.IsAccountSupportedForModelFilter(&outAccounts[i], model) && + matchesModel && service.MatchesOpenAIQuotaStrategyFilter(&outAccounts[i], quotaStrategy) { filtered = append(filtered, outAccounts[i]) } diff --git a/backend/internal/repository/account_repo_integration_test.go b/backend/internal/repository/account_repo_integration_test.go index d1cea9eb3b0..34ecb075fce 100644 --- a/backend/internal/repository/account_repo_integration_test.go +++ b/backend/internal/repository/account_repo_integration_test.go @@ -354,6 +354,41 @@ func (s *AccountRepoSuite) TestListWithFilters() { s.Require().Equal("active-temp-unsched", accounts[0].Name) }, }, + { + name: "filter_by_status_active_excluding_quota_stopped_with_empty_model_keeps_matching_accounts", + setup: func(client *dbent.Client) { + mustCreateAccount(s.T(), client, &service.Account{ + Name: "quota-ok", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + Schedulable: true, + Extra: map[string]any{ + "openai_quota_strategy": "prefer_7d", + "openai_quota_stop_threshold_percent": 10, + "codex_7d_used_percent": 23, + }, + }) + mustCreateAccount(s.T(), client, &service.Account{ + Name: "quota-stopped", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + Schedulable: true, + Extra: map[string]any{ + "openai_quota_strategy": "prefer_7d", + "openai_quota_stop_threshold_percent": 10, + "codex_7d_used_percent": 97, + }, + }) + }, + status: service.AccountStatusFilterActiveExcludingQuotaStopped, + model: "", + wantCount: 1, + validate: func(accounts []service.Account) { + s.Require().Equal("quota-ok", accounts[0].Name) + }, + }, { name: "filter_by_search", setup: func(client *dbent.Client) { From 14cf8fc537c07d2d89b02b4fb93c60e5e397530a Mon Sep 17 00:00:00 2001 From: MiaoZang Date: Fri, 1 May 2026 11:23:36 +0800 Subject: [PATCH 3/7] fix: align openai zero-usage filters with reset windows --- backend/internal/service/account.go | 66 +++++++++++++++++++ .../internal/service/account_model_filter.go | 16 +++++ .../service/account_model_filter_test.go | 35 ++++++++++ .../service/account_quota_schedulable_test.go | 18 +++++ .../internal/service/account_usage_service.go | 42 ++++++++++++ frontend/src/views/admin/AccountsView.vue | 39 ++++++++++- 6 files changed, 213 insertions(+), 3 deletions(-) diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index cd06ffa3c49..8fb83978959 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1046,6 +1046,72 @@ func (a *Account) GetOpenAISessionID() string { return strings.TrimSpace(a.GetExtraString("openai_session_id")) } +func (a *Account) GetOpenAIQuotaStrategy() string { + if !a.IsOpenAIOAuth() { + return "" + } + switch strings.TrimSpace(a.GetExtraString("openai_quota_strategy")) { + case "prefer_5h", "prefer_7d": + return strings.TrimSpace(a.GetExtraString("openai_quota_strategy")) + default: + return "" + } +} + +func (a *Account) GetOpenAIQuotaStopThresholdPercent() float64 { + if !a.IsOpenAIOAuth() { + return 0 + } + threshold := a.getExtraFloat64("openai_quota_stop_threshold_percent") + if threshold <= 0 { + return 10 + } + if threshold > 100 { + return 100 + } + return threshold +} + +func (a *Account) GetOpenAIQuotaRemainingPercentByStrategy() (float64, bool) { + if !a.IsOpenAIOAuth() || a.Extra == nil { + return 0, false + } + + var window string + switch a.GetOpenAIQuotaStrategy() { + case "prefer_5h": + window = "5h" + case "prefer_7d": + window = "7d" + default: + return 0, false + } + + progress := buildCodexUsageProgressFromExtra(a.Extra, window, time.Now()) + if progress == nil { + return 0, false + } + used := progress.Utilization + if used < 0 { + used = 0 + } + if used > 100 { + used = 100 + } + return 100 - used, true +} + +func (a *Account) IsOpenAIQuotaStrategySchedulable() bool { + if a.GetOpenAIQuotaStrategy() == "" { + return true + } + remaining, ok := a.GetOpenAIQuotaRemainingPercentByStrategy() + if !ok { + return true + } + return remaining >= a.GetOpenAIQuotaStopThresholdPercent() +} + func (a *Account) SupportsOpenAIImageCapability(capability OpenAIImagesCapability) bool { if !a.IsOpenAI() { return false diff --git a/backend/internal/service/account_model_filter.go b/backend/internal/service/account_model_filter.go index af67baad851..d9f532509aa 100644 --- a/backend/internal/service/account_model_filter.go +++ b/backend/internal/service/account_model_filter.go @@ -196,6 +196,22 @@ func isAccountTempUnschedulableAt(account *Account, now time.Time) bool { return account != nil && account.TempUnschedulableUntil != nil && account.TempUnschedulableUntil.After(now) } +func isOpenAIUsagePercentExactlyZero(account *Account, key string) bool { + if account == nil || account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth || account.Extra == nil { + return false + } + window := "" + switch key { + case "codex_5h_used_percent": + window = "5h" + case "codex_7d_used_percent": + window = "7d" + default: + return false + } + progress := buildCodexUsageProgressFromExtra(account.Extra, window, time.Now()) + return progress != nil && progress.Utilization == 0 +} func buildAnthropicModelAliases(requestedModel string) []string { trimmed := strings.TrimSpace(requestedModel) if trimmed == "" { diff --git a/backend/internal/service/account_model_filter_test.go b/backend/internal/service/account_model_filter_test.go index b6de551fe66..fba9ec73493 100644 --- a/backend/internal/service/account_model_filter_test.go +++ b/backend/internal/service/account_model_filter_test.go @@ -150,10 +150,45 @@ func TestMatchesAccountListStatusFilter(t *testing.T) { Status: StatusActive, Schedulable: false, } + openAI5HZero := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{ + "codex_5h_used_percent": 0.0, + }, + } + openAI7DZero := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{ + "codex_7d_used_percent": 0.0, + }, + } + openAINonZero := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{ + "codex_5h_used_percent": 12.0, + "codex_7d_used_percent": 8.0, + }, + } + expired7DWindow := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{ + "codex_7d_used_percent": 91.0, + "codex_7d_reset_at": now.Add(-1 * time.Hour).Format(time.RFC3339), + }, + } require.True(t, MatchesAccountListStatusFilter(activeQuotaOK, AccountStatusFilterActiveExcludingQuotaStopped, now)) require.False(t, MatchesAccountListStatusFilter(activeQuotaStopped, AccountStatusFilterActiveExcludingQuotaStopped, now)) require.True(t, MatchesAccountListStatusFilter(rateLimited, "rate_limited", now)) require.True(t, MatchesAccountListStatusFilter(tempUnsched, "temp_unschedulable", now)) require.True(t, MatchesAccountListStatusFilter(unschedulable, "unschedulable", now)) + require.True(t, MatchesAccountListStatusFilter(openAI5HZero, AccountStatusFilterOpenAI5HUsedZero, now)) + require.True(t, MatchesAccountListStatusFilter(openAI7DZero, AccountStatusFilterOpenAI7DUsedZero, now)) + require.True(t, MatchesAccountListStatusFilter(expired7DWindow, AccountStatusFilterOpenAI7DUsedZero, now)) + require.False(t, MatchesAccountListStatusFilter(openAINonZero, AccountStatusFilterOpenAI5HUsedZero, now)) + require.False(t, MatchesAccountListStatusFilter(openAINonZero, AccountStatusFilterOpenAI7DUsedZero, now)) } diff --git a/backend/internal/service/account_quota_schedulable_test.go b/backend/internal/service/account_quota_schedulable_test.go index 2895b34c889..ce616c6629a 100644 --- a/backend/internal/service/account_quota_schedulable_test.go +++ b/backend/internal/service/account_quota_schedulable_test.go @@ -121,3 +121,21 @@ func TestAccountIsSchedulable_QuotaExceeded(t *testing.T) { }) } } + +func TestOpenAIQuotaStrategySchedulable_ExpiredWindowTreatedAsReset(t *testing.T) { + account := &Account{ + Platform: PlatformOpenAI, + Type: AccountTypeOAuth, + Extra: map[string]any{ + "openai_quota_strategy": "prefer_7d", + "openai_quota_stop_threshold_percent": 10, + "codex_7d_used_percent": 91.0, + "codex_7d_reset_at": time.Now().Add(-1 * time.Hour).Format(time.RFC3339), + }, + } + + require.True(t, account.IsOpenAIQuotaStrategySchedulable()) + remaining, ok := account.GetOpenAIQuotaRemainingPercentByStrategy() + require.True(t, ok) + require.Equal(t, 100.0, remaining) +} diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 68ba8f8ce98..916fe7a6353 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -506,6 +506,16 @@ func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Accou if progress := buildCodexUsageProgressFromExtra(account.Extra, "7d", now); progress != nil { usage.SevenDay = progress } + if zeroUpdates := buildExpiredOpenAICodexZeroUpdates(account, now); len(zeroUpdates) > 0 { + mergeAccountExtra(account, zeroUpdates) + s.persistOpenAICodexProbeSnapshot(account.ID, zeroUpdates) + if progress := buildCodexUsageProgressFromExtra(account.Extra, "5h", now); progress != nil { + usage.FiveHour = progress + } + if progress := buildCodexUsageProgressFromExtra(account.Extra, "7d", now); progress != nil { + usage.SevenDay = progress + } + } if shouldRefreshOpenAICodexSnapshot(account, usage, now) && s.shouldProbeOpenAICodexSnapshot(account.ID, now) { if updates, err := s.probeOpenAICodexSnapshot(ctx, account); err == nil && len(updates) > 0 { @@ -672,6 +682,38 @@ func (s *AccountUsageService) persistOpenAICodexProbeSnapshot(accountID int64, u }() } +func buildExpiredOpenAICodexZeroUpdates(account *Account, now time.Time) map[string]any { + if account == nil || account.Extra == nil { + return nil + } + + type windowConfig struct { + window string + key string + } + configs := []windowConfig{ + {window: "5h", key: "codex_5h_used_percent"}, + {window: "7d", key: "codex_7d_used_percent"}, + } + + updates := make(map[string]any) + for _, config := range configs { + progress := buildCodexUsageProgressFromExtra(account.Extra, config.window, now) + if progress == nil || progress.ResetsAt == nil || now.Before(*progress.ResetsAt) { + continue + } + current := parseExtraFloat64(account.Extra[config.key]) + if current != 0 { + updates[config.key] = 0.0 + } + } + if len(updates) == 0 { + return nil + } + updates["codex_usage_updated_at"] = now.UTC().Truncate(time.Second).Format(time.RFC3339) + return updates +} + func extractOpenAICodexProbeUpdates(resp *http.Response) (map[string]any, error) { if resp == nil { return nil, nil diff --git a/frontend/src/views/admin/AccountsView.vue b/frontend/src/views/admin/AccountsView.vue index 81db0882977..62ac82fff20 100644 --- a/frontend/src/views/admin/AccountsView.vue +++ b/frontend/src/views/admin/AccountsView.vue @@ -1401,6 +1401,33 @@ const buildAccountQueryFilters = () => ({ sort_by: sortState.sort_by, sort_order: sortState.sort_order }) +const parseWindowUsagePercent = (account: Account, window: '5h' | '7d') => { + const usedKey = window === '5h' ? 'codex_5h_used_percent' : 'codex_7d_used_percent' + const resetAtKey = window === '5h' ? 'codex_5h_reset_at' : 'codex_7d_reset_at' + const resetAfterKey = window === '5h' ? 'codex_5h_reset_after_seconds' : 'codex_7d_reset_after_seconds' + const usedRaw = Number(account.extra?.[usedKey] ?? Number.NaN) + if (!Number.isFinite(usedRaw)) return Number.NaN + let usedPercent = Math.min(100, Math.max(0, usedRaw)) + const now = Date.now() + const resetAtValue = account.extra?.[resetAtKey] + const resetAt = typeof resetAtValue === 'string' ? new Date(resetAtValue).getTime() : Number.NaN + if (Number.isFinite(resetAt)) { + if (resetAt <= now) { + return 0 + } + return usedPercent + } + const resetAfterRaw = Number(account.extra?.[resetAfterKey] ?? Number.NaN) + if (Number.isFinite(resetAfterRaw) && resetAfterRaw > 0) { + const updatedAtValue = account.extra?.codex_usage_updated_at + const updatedAt = typeof updatedAtValue === 'string' ? new Date(updatedAtValue).getTime() : now + const derivedResetAt = updatedAt + resetAfterRaw * 1000 + if (Number.isFinite(derivedResetAt) && derivedResetAt <= now) { + return 0 + } + } + return usedPercent +} const accountMatchesCurrentFilters = (account: Account) => { const filters = buildAccountQueryFilters() if (filters.platform && account.platform !== filters.platform) return false @@ -1408,13 +1435,19 @@ const accountMatchesCurrentFilters = (account: Account) => { const strategy = String(account.extra?.openai_quota_strategy || '').trim() const thresholdRaw = Number(account.extra?.openai_quota_stop_threshold_percent ?? 10) const threshold = Number.isFinite(thresholdRaw) && thresholdRaw > 0 ? Math.min(100, thresholdRaw) : 10 - const usedKey = strategy === 'prefer_5h' ? 'codex_5h_used_percent' : strategy === 'prefer_7d' ? 'codex_7d_used_percent' : '' - const usedRaw = usedKey ? Number(account.extra?.[usedKey] ?? Number.NaN) : Number.NaN - const usedPercent = Number.isFinite(usedRaw) ? Math.min(100, Math.max(0, usedRaw)) : Number.NaN + const usedPercent = strategy === 'prefer_5h' + ? parseWindowUsagePercent(account, '5h') + : strategy === 'prefer_7d' + ? parseWindowUsagePercent(account, '7d') + : Number.NaN const remainingPercent = Number.isFinite(usedPercent) ? 100 - usedPercent : Number.NaN const isQuotaStopped = (strategy === 'prefer_5h' || strategy === 'prefer_7d') && Number.isFinite(remainingPercent) && remainingPercent < threshold + const openAI5HUsedPercent = parseWindowUsagePercent(account, '5h') + const openAI7DUsedPercent = parseWindowUsagePercent(account, '7d') + const isOpenAI5HUsedZero = account.platform === 'openai' && account.type === 'oauth' && Number.isFinite(openAI5HUsedPercent) && openAI5HUsedPercent === 0 + const isOpenAI7DUsedZero = account.platform === 'openai' && account.type === 'oauth' && Number.isFinite(openAI7DUsedPercent) && openAI7DUsedPercent === 0 if (filters.status) { const now = Date.now() const rateLimitResetAt = account.rate_limit_reset_at ? new Date(account.rate_limit_reset_at).getTime() : Number.NaN From 2c43954e4e97ab2c6ce6862397d7751a7348840b Mon Sep 17 00:00:00 2001 From: MiaoZang Date: Wed, 6 May 2026 02:12:06 +0800 Subject: [PATCH 4/7] fix: adapt replayed filters to v0.1.123 --- backend/internal/service/account_model_filter.go | 10 ++++++++-- backend/internal/service/admin_service.go | 3 +++ .../internal/service/admin_service_bulk_update_test.go | 3 ++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/backend/internal/service/account_model_filter.go b/backend/internal/service/account_model_filter.go index d9f532509aa..f4950ffe27c 100644 --- a/backend/internal/service/account_model_filter.go +++ b/backend/internal/service/account_model_filter.go @@ -22,9 +22,11 @@ type AccountModelFilterGroup struct { } const ( - AccountModelFilterLimited = "__limited__" - AccountModelFilterUnlimited = "__unlimited__" + AccountModelFilterLimited = "__limited__" + AccountModelFilterUnlimited = "__unlimited__" AccountStatusFilterActiveExcludingQuotaStopped = "active_excluding_quota_stopped" + AccountStatusFilterOpenAI5HUsedZero = "openai_5h_used_zero" + AccountStatusFilterOpenAI7DUsedZero = "openai_7d_used_zero" ) func ListAccountModelFilterGroups() []AccountModelFilterGroup { @@ -167,6 +169,10 @@ func MatchesAccountListStatusFilter(account *Account, requestedStatus string, no !account.Schedulable && !isAccountRateLimitedAt(account, now) && !isAccountTempUnschedulableAt(account, now) + case AccountStatusFilterOpenAI5HUsedZero: + return isOpenAIUsagePercentExactlyZero(account, "codex_5h_used_percent") + case AccountStatusFilterOpenAI7DUsedZero: + return isOpenAIUsagePercentExactlyZero(account, "codex_7d_used_percent") default: return account.Status == requestedStatus } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index eb5994d5498..072ab44abf2 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -2737,6 +2737,9 @@ func (s *adminServiceImpl) resolveBulkUpdateTargetIDs(ctx context.Context, filte filters.Status, filters.Search, groupID, + "", + "", + "", filters.PrivacyMode, "", "", diff --git a/backend/internal/service/admin_service_bulk_update_test.go b/backend/internal/service/admin_service_bulk_update_test.go index df415295b1b..455c8e79552 100644 --- a/backend/internal/service/admin_service_bulk_update_test.go +++ b/backend/internal/service/admin_service_bulk_update_test.go @@ -88,7 +88,7 @@ func (s *accountRepoStubForBulkUpdate) ListByGroup(_ context.Context, groupID in return nil, nil } -func (s *accountRepoStubForBulkUpdate) ListWithFilters(_ context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64, privacyMode string) ([]Account, *pagination.PaginationResult, error) { +func (s *accountRepoStubForBulkUpdate) ListWithFilters(_ context.Context, params pagination.PaginationParams, platform, accountType, status, search string, groupID int64, model, quotaStrategy, proxyFilter, privacyMode string) ([]Account, *pagination.PaginationResult, error) { s.listCalled = true s.lastListParams = params s.lastListFilters.platform = platform @@ -96,6 +96,7 @@ func (s *accountRepoStubForBulkUpdate) ListWithFilters(_ context.Context, params s.lastListFilters.status = status s.lastListFilters.search = search s.lastListFilters.groupID = groupID + s.lastListFilters.model = model s.lastListFilters.privacyMode = privacyMode if s.listErr != nil { return nil, nil, s.listErr From b872e29d4136a270173c42b5f45cb2da0ef5ae26 Mon Sep 17 00:00:00 2001 From: MiaoZang Date: Fri, 1 May 2026 10:00:55 +0800 Subject: [PATCH 5/7] feat: queue account tests after quota refresh --- .../internal/handler/admin/account_handler.go | 136 ++++++++++- .../admin/account_handler_reset_quota_test.go | 227 ++++++++++++++++++ .../handler/admin/account_test_queue.go | 42 ++++ .../handler/admin/admin_service_stub_test.go | 4 + backend/internal/repository/account_repo.go | 11 +- backend/internal/server/routes/admin.go | 1 + .../internal/service/account_model_filter.go | 2 + .../internal/service/gateway_request_test.go | 16 +- frontend/src/api/admin/accounts.ts | 10 + .../admin/account/AccountBulkActionsBar.vue | 3 +- .../admin/account/AccountTableFilters.vue | 2 + frontend/src/i18n/locales/en.ts | 5 + frontend/src/i18n/locales/zh.ts | 5 + frontend/src/views/admin/AccountsView.vue | 21 ++ .../admin/__tests__/AccountsView.spec.ts | 96 +++++++- 15 files changed, 562 insertions(+), 19 deletions(-) create mode 100644 backend/internal/handler/admin/account_handler_reset_quota_test.go create mode 100644 backend/internal/handler/admin/account_test_queue.go diff --git a/backend/internal/handler/admin/account_handler.go b/backend/internal/handler/admin/account_handler.go index ffab74d6a7a..c043424e952 100644 --- a/backend/internal/handler/admin/account_handler.go +++ b/backend/internal/handler/admin/account_handler.go @@ -11,6 +11,7 @@ import ( "log" "log/slog" "net/http" + "net/http/httptest" "strconv" "strings" "sync" @@ -58,6 +59,7 @@ type AccountHandler struct { sessionLimitCache service.SessionLimitCache rpmCache service.RPMCache tokenCacheInvalidator service.TokenCacheInvalidator + accountTestQueue *accountTestQueue } // NewAccountHandler creates a new admin account handler @@ -90,6 +92,7 @@ func NewAccountHandler( sessionLimitCache: sessionLimitCache, rpmCache: rpmCache, tokenCacheInvalidator: tokenCacheInvalidator, + accountTestQueue: newAccountTestQueue(3 * time.Second), } } @@ -730,17 +733,9 @@ func (h *AccountHandler) Test(c *gin.Context) { // Allow empty body, model_id is optional _ = c.ShouldBindJSON(&req) - // Use AccountTestService to test the account with SSE streaming - if err := h.accountTestService.TestAccountConnection(c, accountID, req.ModelID, req.Prompt, req.Mode); err != nil { - // Error already sent via SSE, just log + if err := h.runQueuedInteractiveAccountTest(c, accountID, req); err != nil { return } - - if h.rateLimitService != nil { - if _, err := h.rateLimitService.RecoverAccountAfterSuccessfulTest(c.Request.Context(), accountID); err != nil { - _ = c.Error(err) - } - } } // RecoverState handles unified recovery of recoverable account runtime state. @@ -1200,6 +1195,78 @@ func (h *AccountHandler) BatchRefresh(c *gin.Context) { }) } +// BatchTest handles batch testing account connectivity. +// POST /api/v1/admin/accounts/batch-test +func (h *AccountHandler) BatchTest(c *gin.Context) { + if h.accountTestService == nil { + response.Error(c, http.StatusServiceUnavailable, "Account test service unavailable") + return + } + + var req struct { + AccountIDs []int64 `json:"account_ids"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + if len(req.AccountIDs) == 0 { + response.BadRequest(c, "account_ids is required") + return + } + + ctx := c.Request.Context() + accounts, err := h.adminService.GetAccountsByIDs(ctx, req.AccountIDs) + if err != nil { + response.ErrorFrom(c, err) + return + } + + foundIDs := make(map[int64]bool, len(accounts)) + for _, acc := range accounts { + if acc != nil { + foundIDs[acc.ID] = true + } + } + + successCount := 0 + failedCount := 0 + errors := make([]gin.H, 0) + + for _, id := range req.AccountIDs { + if foundIDs[id] { + continue + } + failedCount++ + errors = append(errors, gin.H{ + "account_id": id, + "error": "account not found", + }) + } + + for _, account := range accounts { + if account == nil { + continue + } + if err := h.runQueuedBackgroundAccountTest(ctx, account.ID); err != nil { + failedCount++ + errors = append(errors, gin.H{ + "account_id": account.ID, + "error": err.Error(), + }) + continue + } + successCount++ + } + + response.Success(c, gin.H{ + "total": len(req.AccountIDs), + "success": successCount, + "failed": failedCount, + "errors": errors, + }) +} + // BatchCreate handles batch creating accounts // POST /api/v1/admin/accounts/batch func (h *AccountHandler) BatchCreate(c *gin.Context) { @@ -1711,9 +1778,60 @@ func (h *AccountHandler) ResetQuota(c *gin.Context) { return } + if account.Platform == service.PlatformOpenAI && h.accountTestService != nil { + if err := h.runQueuedBackgroundAccountTest(c.Request.Context(), accountID); err != nil { + log.Printf("[WARN] auto test after quota reset failed for account %d: %v", accountID, err) + } + account, err = h.adminService.GetAccount(c.Request.Context(), accountID) + if err != nil { + response.ErrorFrom(c, err) + return + } + } + response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account)) } +func (h *AccountHandler) runQueuedInteractiveAccountTest(c *gin.Context, accountID int64, req TestAccountRequest) error { + if h.accountTestService == nil { + response.Error(c, http.StatusServiceUnavailable, "Account test service unavailable") + return errors.New("account test service unavailable") + } + + return h.accountTestQueue.Run(c.Request.Context(), func() error { + if err := h.accountTestService.TestAccountConnection(c, accountID, req.ModelID, req.Prompt, req.Mode); err != nil { + return err + } + if h.rateLimitService != nil { + if _, err := h.rateLimitService.RecoverAccountAfterSuccessfulTest(c.Request.Context(), accountID); err != nil { + _ = c.Error(err) + } + } + return nil + }) +} + +func (h *AccountHandler) runQueuedBackgroundAccountTest(ctx context.Context, accountID int64) error { + if h.accountTestService == nil { + return errors.New("account test service unavailable") + } + + return h.accountTestQueue.Run(ctx, func() error { + recorder := httptest.NewRecorder() + testCtx, _ := gin.CreateTestContext(recorder) + testCtx.Request = httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/v1/admin/accounts/%d/test", accountID), nil).WithContext(ctx) + if err := h.accountTestService.TestAccountConnection(testCtx, accountID, "", "", ""); err != nil { + return err + } + if h.rateLimitService != nil { + if _, err := h.rateLimitService.RecoverAccountAfterSuccessfulTest(ctx, accountID); err != nil { + return err + } + } + return nil + }) +} + // GetTempUnschedulable handles getting temporary unschedulable status // GET /api/v1/admin/accounts/:id/temp-unschedulable func (h *AccountHandler) GetTempUnschedulable(c *gin.Context) { diff --git a/backend/internal/handler/admin/account_handler_reset_quota_test.go b/backend/internal/handler/admin/account_handler_reset_quota_test.go new file mode 100644 index 00000000000..d272ae6a477 --- /dev/null +++ b/backend/internal/handler/admin/account_handler_reset_quota_test.go @@ -0,0 +1,227 @@ +package admin + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/config" + "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint" + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type accountHandlerRateLimitRepoStub struct { + service.AccountRepository + accounts map[int64]*service.Account + getErr map[int64]error + clearErrs []int64 +} + +func (s *accountHandlerRateLimitRepoStub) GetByID(_ context.Context, id int64) (*service.Account, error) { + if err, ok := s.getErr[id]; ok { + return nil, err + } + if account, ok := s.accounts[id]; ok { + return account, nil + } + return nil, service.ErrAccountNotFound +} + +func (s *accountHandlerRateLimitRepoStub) ClearError(_ context.Context, id int64) error { + s.clearErrs = append(s.clearErrs, id) + if account, ok := s.accounts[id]; ok { + account.Status = service.StatusActive + account.ErrorMessage = "" + } + return nil +} + +func newAccountHandlerForResetQuotaTest(adminSvc service.AdminService, rateLimitSvc *service.RateLimitService) *AccountHandler { + return NewAccountHandler(adminSvc, nil, nil, nil, nil, rateLimitSvc, nil, nil, nil, nil, nil, nil, nil) +} + +type accountHandlerTestHTTPUpstream struct { + requestCount int +} + +func (s *accountHandlerTestHTTPUpstream) Do(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return nil, errors.New("unexpected Do call") +} + +func (s *accountHandlerTestHTTPUpstream) DoWithTLS(_ *http.Request, _ string, _ int64, _ int, _ *tlsfingerprint.Profile) (*http.Response, error) { + s.requestCount++ + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{ + "Content-Type": []string{"text/event-stream"}, + }, + Body: io.NopCloser(strings.NewReader( + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"hi\"}\n\n" + + "data: {\"type\":\"response.completed\",\"response\":{}}\n\n" + + "data: [DONE]\n\n", + )), + }, nil +} + +func TestAccountHandler_ResetQuota_RecoversAccountStateAndReturnsUpdatedAccount(t *testing.T) { + gin.SetMode(gin.TestMode) + + adminSvc := newStubAdminService() + adminSvc.accounts = []service.Account{{ID: 42, Name: "before", Status: service.StatusError}} + adminSvc.getAccountByID = map[int64]*service.Account{ + 42: {ID: 42, Name: "after", Status: service.StatusActive}, + } + + repo := &accountHandlerRateLimitRepoStub{ + accounts: map[int64]*service.Account{ + 42: { + ID: 42, + Name: "before", + Status: service.StatusError, + ErrorMessage: "401", + }, + }, + } + rateLimitSvc := service.NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + handler := newAccountHandlerForResetQuotaTest(adminSvc, rateLimitSvc) + + router := gin.New() + router.POST("/api/v1/admin/accounts/:id/reset-quota", handler.ResetQuota) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/reset-quota", nil) + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, []int64{42}, adminSvc.resetAccountQuotaIDs) + require.Equal(t, []int64{42}, repo.clearErrs) + + var payload struct { + Code int `json:"code"` + Data struct { + ID int64 `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) + require.Equal(t, 0, payload.Code) + require.Equal(t, int64(42), payload.Data.ID) + require.Equal(t, "after", payload.Data.Name) + require.Equal(t, service.StatusActive, payload.Data.Status) +} + +func TestAccountHandler_ResetQuota_OpenAIAccountTriggersAutoTest(t *testing.T) { + gin.SetMode(gin.TestMode) + + adminSvc := newStubAdminService() + adminSvc.getAccountByID = map[int64]*service.Account{ + 42: {ID: 42, Name: "openai", Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Status: service.StatusActive}, + } + + repo := &accountHandlerRateLimitRepoStub{ + accounts: map[int64]*service.Account{ + 42: { + ID: 42, + Name: "openai", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + Concurrency: 1, + Schedulable: true, + Credentials: map[string]any{"access_token": "token"}, + Extra: map[string]any{}, + ErrorMessage: "", + }, + }, + } + rateLimitSvc := service.NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + upstream := &accountHandlerTestHTTPUpstream{} + accountTestSvc := service.NewAccountTestService(repo, nil, nil, upstream, &config.Config{}, &service.TLSFingerprintProfileService{}) + + handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, rateLimitSvc, nil, accountTestSvc, nil, nil, nil, nil, nil) + + router := gin.New() + router.POST("/api/v1/admin/accounts/:id/reset-quota", handler.ResetQuota) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/42/reset-quota", nil) + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, 1, upstream.requestCount) +} + +func TestAccountHandler_BatchTest_ReturnsSummary(t *testing.T) { + gin.SetMode(gin.TestMode) + + adminSvc := newStubAdminService() + adminSvc.getAccountByID = map[int64]*service.Account{ + 42: {ID: 42, Name: "openai-a", Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Status: service.StatusActive}, + 43: {ID: 43, Name: "openai-b", Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth, Status: service.StatusActive}, + } + + repo := &accountHandlerRateLimitRepoStub{ + accounts: map[int64]*service.Account{ + 42: { + ID: 42, + Name: "openai-a", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + Concurrency: 1, + Schedulable: true, + Credentials: map[string]any{"access_token": "token-a"}, + Extra: map[string]any{}, + }, + 43: { + ID: 43, + Name: "openai-b", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + Concurrency: 1, + Schedulable: true, + Credentials: map[string]any{"access_token": "token-b"}, + Extra: map[string]any{}, + }, + }, + } + rateLimitSvc := service.NewRateLimitService(repo, nil, &config.Config{}, nil, nil) + upstream := &accountHandlerTestHTTPUpstream{} + accountTestSvc := service.NewAccountTestService(repo, nil, nil, upstream, &config.Config{}, &service.TLSFingerprintProfileService{}) + + handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, rateLimitSvc, nil, accountTestSvc, nil, nil, nil, nil, nil) + + router := gin.New() + router.POST("/api/v1/admin/accounts/batch-test", handler.BatchTest) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/batch-test", strings.NewReader(`{"account_ids":[42,43]}`)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, 2, upstream.requestCount) + + var payload struct { + Code int `json:"code"` + Data struct { + Total int `json:"total"` + Success int `json:"success"` + Failed int `json:"failed"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload)) + require.Equal(t, 0, payload.Code) + require.Equal(t, 2, payload.Data.Total) + require.Equal(t, 2, payload.Data.Success) + require.Equal(t, 0, payload.Data.Failed) +} diff --git a/backend/internal/handler/admin/account_test_queue.go b/backend/internal/handler/admin/account_test_queue.go new file mode 100644 index 00000000000..821ba1c3b29 --- /dev/null +++ b/backend/internal/handler/admin/account_test_queue.go @@ -0,0 +1,42 @@ +package admin + +import ( + "context" + "sync" + "time" +) + +type accountTestQueue struct { + mu sync.Mutex + nextAvailableAt time.Time + cooldown time.Duration +} + +func newAccountTestQueue(cooldown time.Duration) *accountTestQueue { + return &accountTestQueue{cooldown: cooldown} +} + +func (q *accountTestQueue) Run(ctx context.Context, fn func() error) error { + if q == nil { + return fn() + } + + q.mu.Lock() + defer q.mu.Unlock() + + if wait := time.Until(q.nextAvailableAt); wait > 0 { + timer := time.NewTimer(wait) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + } + } + + err := fn() + if q.cooldown > 0 { + q.nextAvailableAt = time.Now().Add(q.cooldown) + } + return err +} diff --git a/backend/internal/handler/admin/admin_service_stub_test.go b/backend/internal/handler/admin/admin_service_stub_test.go index 2fef94f1561..2a12f86518f 100644 --- a/backend/internal/handler/admin/admin_service_stub_test.go +++ b/backend/internal/handler/admin/admin_service_stub_test.go @@ -320,6 +320,10 @@ func (s *stubAdminService) GetAccount(ctx context.Context, id int64) (*service.A func (s *stubAdminService) GetAccountsByIDs(ctx context.Context, ids []int64) ([]*service.Account, error) { out := make([]*service.Account, 0, len(ids)) for _, id := range ids { + if account, ok := s.getAccountByID[id]; ok && account != nil { + out = append(out, account) + continue + } account := service.Account{ID: id, Name: "account", Status: service.StatusActive} out = append(out, &account) } diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index a262fc54292..75d35a41629 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -503,6 +503,11 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati )) }), ) + case service.AccountStatusFilterOpenAI5HUsedZero, service.AccountStatusFilterOpenAI7DUsedZero: + q = q.Where( + dbaccount.PlatformEQ(service.PlatformOpenAI), + dbaccount.TypeEQ(service.AccountTypeOAuth), + ) case "rate_limited": q = q.Where( dbaccount.StatusEQ(service.StatusActive), @@ -570,7 +575,11 @@ func (r *accountRepository) ListWithFilters(ctx context.Context, params paginati } normalizedStatus := strings.TrimSpace(status) - if normalizedStatus == service.AccountStatusFilterActiveExcludingQuotaStopped || strings.TrimSpace(model) != "" || strings.TrimSpace(quotaStrategy) != "" { + if normalizedStatus == service.AccountStatusFilterActiveExcludingQuotaStopped || + normalizedStatus == service.AccountStatusFilterOpenAI5HUsedZero || + normalizedStatus == service.AccountStatusFilterOpenAI7DUsedZero || + strings.TrimSpace(model) != "" || + strings.TrimSpace(quotaStrategy) != "" { accountsQuery := q for _, order := range accountListOrder(params) { accountsQuery = accountsQuery.Order(order) diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index 6e1059bc829..d6c1da803c2 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -311,6 +311,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers) { accounts.POST("/bulk-update", h.Admin.Account.BulkUpdate) accounts.POST("/batch-clear-error", h.Admin.Account.BatchClearError) accounts.POST("/batch-refresh", h.Admin.Account.BatchRefresh) + accounts.POST("/batch-test", h.Admin.Account.BatchTest) // Antigravity 默认模型映射 accounts.GET("/antigravity/default-model-mapping", h.Admin.Account.GetAntigravityDefaultModelMapping) diff --git a/backend/internal/service/account_model_filter.go b/backend/internal/service/account_model_filter.go index f4950ffe27c..b7bd147afa2 100644 --- a/backend/internal/service/account_model_filter.go +++ b/backend/internal/service/account_model_filter.go @@ -1,6 +1,7 @@ package service import ( + "strconv" "strings" "time" @@ -218,6 +219,7 @@ func isOpenAIUsagePercentExactlyZero(account *Account, key string) bool { progress := buildCodexUsageProgressFromExtra(account.Extra, window, time.Now()) return progress != nil && progress.Utilization == 0 } + func buildAnthropicModelAliases(requestedModel string) []string { trimmed := strings.TrimSpace(requestedModel) if trimmed == "" { diff --git a/backend/internal/service/gateway_request_test.go b/backend/internal/service/gateway_request_test.go index 40bd1186728..887ca4c49bf 100644 --- a/backend/internal/service/gateway_request_test.go +++ b/backend/internal/service/gateway_request_test.go @@ -5,6 +5,7 @@ package service import ( "encoding/json" "fmt" + "strconv" "strings" "testing" @@ -960,7 +961,7 @@ func TestParseGatewayRequest_MaxTokensBoundary(t *testing.T) { tests := []struct { name string body string - wantMaxTokens int + wantMaxTokens int64 wantErr bool }{ { @@ -979,9 +980,14 @@ func TestParseGatewayRequest_MaxTokensBoundary(t *testing.T) { wantMaxTokens: -1, }, { - name: "超大值不 panic", - body: `{"max_tokens":9999999999999999}`, - wantMaxTokens: 10000000000000000, // float64 精度导致 9999999999999999 → 1e16 + name: "超大值不 panic", + body: `{"max_tokens":9999999999999999}`, + wantMaxTokens: func() int64 { + if strconv.IntSize == 32 { + return 0 + } + return 10000000000000000 + }(), }, { name: "null 值被忽略", @@ -998,7 +1004,7 @@ func TestParseGatewayRequest_MaxTokensBoundary(t *testing.T) { return } require.NoError(t, err) - require.Equal(t, tt.wantMaxTokens, parsed.MaxTokens) + require.Equal(t, tt.wantMaxTokens, int64(parsed.MaxTokens)) }) } } diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index 00ed40878c3..0c95cf8a430 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -627,6 +627,15 @@ export async function batchRefresh(accountIds: number[]): Promise { + const { data } = await apiClient.post('/admin/accounts/batch-test', { + account_ids: accountIds, + }, { + timeout: 600000 + }) + return data +} + /** * Set privacy for an Antigravity OAuth account * @param id - Account ID @@ -674,6 +683,7 @@ export const accountsAPI = { getAntigravityDefaultModelMapping, batchClearError, batchRefresh, + batchTest, setPrivacy } diff --git a/frontend/src/components/admin/account/AccountBulkActionsBar.vue b/frontend/src/components/admin/account/AccountBulkActionsBar.vue index a632bdd4213..3275d304b8f 100644 --- a/frontend/src/components/admin/account/AccountBulkActionsBar.vue +++ b/frontend/src/components/admin/account/AccountBulkActionsBar.vue @@ -26,6 +26,7 @@