From a0802f00b63917555b0225093c281e8bbb4a9af1 Mon Sep 17 00:00:00 2001 From: rick147 Date: Wed, 29 Jul 2026 16:23:45 +0800 Subject: [PATCH 1/3] feat: cache OpenAI reset credit details --- backend/cmd/server/wire_gen.go | 2 +- ...count_handler_long_context_billing_test.go | 2 +- .../handler/admin/openai_oauth_handler.go | 92 ++++++- .../openai_oauth_handler_reset_quota_test.go | 255 ++++++++++++++++++ .../openai_oauth_handler_spark_shadow_test.go | 8 +- backend/internal/repository/account_repo.go | 1 + .../internal/service/openai_quota_service.go | 23 ++ .../service/openai_quota_spark_window_test.go | 59 +++- frontend/src/api/admin/accounts.ts | 17 +- .../components/account/AccountUsageCell.vue | 24 +- .../account/OpenAIQuotaResetCell.vue | 90 ++++++- .../__tests__/AccountUsageCell.spec.ts | 46 +++- .../OpenAIQuotaResetCell.spark_shadow.spec.ts | 140 +++++++++- .../src/i18n/locales/en/admin/accounts.ts | 5 +- .../src/i18n/locales/zh/admin/accounts.ts | 5 +- frontend/src/views/admin/AccountsView.vue | 1 + 16 files changed, 740 insertions(+), 30 deletions(-) create mode 100644 backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go diff --git a/backend/cmd/server/wire_gen.go b/backend/cmd/server/wire_gen.go index 92300337be..ec2db0e11c 100644 --- a/backend/cmd/server/wire_gen.go +++ b/backend/cmd/server/wire_gen.go @@ -206,7 +206,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { imageStorageSettingService := service.ProvideImageStorageSettingService(settingRepository, secretEncryptor, backupService, imageStorageFactory, configConfig) backupHandler := admin.NewBackupHandler(backupService, userService, imageStorageSettingService) oAuthHandler := admin.NewOAuthHandler(oAuthService) - openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService, openAIQuotaService) + openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService, openAIQuotaService, rateLimitService) geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService) antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService) tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI, openAIGatewayService) diff --git a/backend/internal/handler/admin/account_handler_long_context_billing_test.go b/backend/internal/handler/admin/account_handler_long_context_billing_test.go index b3d3db9667..4d40e93bf7 100644 --- a/backend/internal/handler/admin/account_handler_long_context_billing_test.go +++ b/backend/internal/handler/admin/account_handler_long_context_billing_test.go @@ -144,7 +144,7 @@ func TestApplyOAuthCredentialsRejectsMalformedOpenAILongContextBillingBeforeMuta func TestOpenAIOAuthCodexPATBoundaryRejectsMalformedOpenAILongContextBillingValueBeforeTokenValidation(t *testing.T) { gin.SetMode(gin.TestMode) - handler := NewOpenAIOAuthHandler(nil, newStubAdminService(), nil) + handler := NewOpenAIOAuthHandler(nil, newStubAdminService(), nil, nil) router := gin.New() router.Use(gin.Recovery()) router.POST("/openai/create-from-codex-pat", handler.CreateAccountFromCodexPAT) diff --git a/backend/internal/handler/admin/openai_oauth_handler.go b/backend/internal/handler/admin/openai_oauth_handler.go index 78d57299b6..657eac4695 100644 --- a/backend/internal/handler/admin/openai_oauth_handler.go +++ b/backend/internal/handler/admin/openai_oauth_handler.go @@ -1,6 +1,9 @@ package admin import ( + "context" + "log/slog" + "net/http" "strconv" "strings" "time" @@ -17,7 +20,33 @@ import ( type OpenAIOAuthHandler struct { openaiOAuthService *service.OpenAIOAuthService adminService service.AdminService - quotaService *service.OpenAIQuotaService + quotaService openAIQuotaService + rateLimitService openAIAccountStateRecoverer +} + +type openAIQuotaService interface { + QueryUsage(ctx context.Context, accountID int64) (*service.OpenAIQuotaUsage, error) + CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *service.OpenAIRateLimitResetCredits) error + ResetCredit(ctx context.Context, accountID int64) (*service.OpenAIQuotaResetResult, error) +} + +type openAIAccountStateRecoverer interface { + RecoverAccountState(ctx context.Context, accountID int64, options service.AccountRecoveryOptions) (*service.SuccessfulTestRecoveryResult, error) +} + +const ( + openAIQuotaResetWarningCacheRefreshFailed = "reset_credit_cache_refresh_failed" + openAIQuotaResetWarningAccountRecoveryFailed = "account_state_recovery_failed" + openAIQuotaResetWarningAccountRefreshFailed = "account_state_refresh_failed" +) + +type openAIQuotaResetResponse struct { + service.OpenAIQuotaResetResult + Quota *service.OpenAIQuotaUsage `json:"quota,omitempty"` + Account *dto.Account `json:"account,omitempty"` + CacheRefreshed bool `json:"cache_refreshed"` + AccountStateRecovered bool `json:"account_state_recovered"` + WarningCode string `json:"warning_code,omitempty"` } func oauthPlatformFromPath(c *gin.Context) string { @@ -29,11 +58,13 @@ func NewOpenAIOAuthHandler( openaiOAuthService *service.OpenAIOAuthService, adminService service.AdminService, quotaService *service.OpenAIQuotaService, + rateLimitService *service.RateLimitService, ) *OpenAIOAuthHandler { return &OpenAIOAuthHandler{ openaiOAuthService: openaiOAuthService, adminService: adminService, quotaService: quotaService, + rateLimitService: rateLimitService, } } @@ -420,7 +451,19 @@ func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) { response.BadRequest(c, "openai quota service is not enabled") return } + persistResetCredits := false + if raw, ok := c.GetQuery("persist_reset_credits"); ok { + persistResetCredits, err = strconv.ParseBool(raw) + if err != nil { + response.BadRequest(c, "Invalid persist_reset_credits value") + return + } + } + usage, err := h.quotaService.QueryUsage(c.Request.Context(), accountID) + if err == nil && persistResetCredits { + err = h.quotaService.CacheResetCreditsSnapshot(c.Request.Context(), accountID, usage.RateLimitResetCredits) + } if err != nil { response.ErrorFrom(c, err) return @@ -482,5 +525,50 @@ func (h *OpenAIOAuthHandler) ResetQuota(c *gin.Context) { response.ErrorFrom(c, err) return } - response.Success(c, result) + if result == nil { + response.Error(c, http.StatusInternalServerError, "openai quota reset returned an empty result") + return + } + + resetResponse := openAIQuotaResetResponse{OpenAIQuotaResetResult: *result} + usage, err := h.quotaService.QueryUsage(c.Request.Context(), accountID) + if err != nil { + slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", err) + resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed + response.Success(c, resetResponse) + return + } + if err := h.quotaService.CacheResetCreditsSnapshot(c.Request.Context(), accountID, usage.RateLimitResetCredits); err != nil { + slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", err) + resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed + response.Success(c, resetResponse) + return + } + resetResponse.Quota = usage + resetResponse.CacheRefreshed = true + + if h.rateLimitService == nil { + resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed + response.Success(c, resetResponse) + return + } + if _, err := h.rateLimitService.RecoverAccountState(c.Request.Context(), accountID, service.AccountRecoveryOptions{ + InvalidateToken: true, + }); err != nil { + slog.Warn("openai_quota_reset_account_recovery_failed", "account_id", accountID, "error", err) + resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed + response.Success(c, resetResponse) + return + } + resetResponse.AccountStateRecovered = true + + account, err := h.adminService.GetAccount(c.Request.Context(), accountID) + if err != nil { + slog.Warn("openai_quota_reset_account_refresh_failed", "account_id", accountID, "error", err) + resetResponse.WarningCode = openAIQuotaResetWarningAccountRefreshFailed + response.Success(c, resetResponse) + return + } + resetResponse.Account = dto.AccountFromService(account) + response.Success(c, resetResponse) } diff --git a/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go b/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go new file mode 100644 index 0000000000..e287b32deb --- /dev/null +++ b/backend/internal/handler/admin/openai_oauth_handler_reset_quota_test.go @@ -0,0 +1,255 @@ +//go:build unit + +package admin + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + + "github.com/Wei-Shaw/sub2api/internal/service" +) + +type openAIQuotaWorkflowStub struct { + resetResult *service.OpenAIQuotaResetResult + resetErr error + queryResult *service.OpenAIQuotaUsage + queryErr error + cacheErr error + + resetCalls int + queryCalls int + cacheCalls int +} + +func (s *openAIQuotaWorkflowStub) ResetCredit(context.Context, int64) (*service.OpenAIQuotaResetResult, error) { + s.resetCalls++ + return s.resetResult, s.resetErr +} + +func (s *openAIQuotaWorkflowStub) QueryUsage(context.Context, int64) (*service.OpenAIQuotaUsage, error) { + s.queryCalls++ + return s.queryResult, s.queryErr +} + +func (s *openAIQuotaWorkflowStub) CacheResetCreditsSnapshot(context.Context, int64, *service.OpenAIRateLimitResetCredits) error { + s.cacheCalls++ + return s.cacheErr +} + +type openAIAccountStateRecovererStub struct { + err error + calls int + accountID int64 + lastOptions service.AccountRecoveryOptions +} + +func (s *openAIAccountStateRecovererStub) RecoverAccountState(_ context.Context, accountID int64, options service.AccountRecoveryOptions) (*service.SuccessfulTestRecoveryResult, error) { + s.calls++ + s.accountID = accountID + s.lastOptions = options + return &service.SuccessfulTestRecoveryResult{}, s.err +} + +type openAIResetAdminServiceStub struct { + service.AdminService + account *service.Account + err error + calls int +} + +func (s *openAIResetAdminServiceStub) GetAccount(context.Context, int64) (*service.Account, error) { + s.calls++ + return s.account, s.err +} + +type openAIQuotaResetEnvelope struct { + Code int `json:"code"` + Data openAIQuotaResetResponse `json:"data"` +} + +func performOpenAIQuotaResetRequest(t *testing.T, handler *OpenAIOAuthHandler) (int, openAIQuotaResetEnvelope) { + t.Helper() + gin.SetMode(gin.TestMode) + + router := gin.New() + router.POST("/api/v1/admin/openai/accounts/:id/reset-quota", handler.ResetQuota) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/openai/accounts/42/reset-quota", nil) + router.ServeHTTP(recorder, request) + + var envelope openAIQuotaResetEnvelope + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &envelope)) + return recorder.Code, envelope +} + +func successfulOpenAIQuotaWorkflowStub() *openAIQuotaWorkflowStub { + return &openAIQuotaWorkflowStub{ + resetResult: &service.OpenAIQuotaResetResult{ + Code: "success", + WindowsReset: 1, + }, + queryResult: &service.OpenAIQuotaUsage{ + FetchedAt: 123, + RateLimitResetCredits: &service.OpenAIRateLimitResetCredits{ + AvailableCount: 0, + Credits: []service.OpenAIRateLimitResetCreditDetail{}, + }, + }, + } +} + +func TestOpenAIResetQuota_ResetFailureStopsWorkflow(t *testing.T) { + quota := &openAIQuotaWorkflowStub{resetErr: errors.New("upstream reset failed")} + recoverer := &openAIAccountStateRecovererStub{} + handler := &OpenAIOAuthHandler{ + adminService: &openAIResetAdminServiceStub{}, + quotaService: quota, + rateLimitService: recoverer, + } + + status, _ := performOpenAIQuotaResetRequest(t, handler) + + require.Equal(t, http.StatusInternalServerError, status) + require.Equal(t, 1, quota.resetCalls) + require.Zero(t, quota.queryCalls) + require.Zero(t, quota.cacheCalls) + require.Zero(t, recoverer.calls) +} + +func TestOpenAIResetQuota_QueryFailureReturnsPartialSuccessAndStops(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + quota.queryResult = nil + quota.queryErr = errors.New("upstream query failed") + recoverer := &openAIAccountStateRecovererStub{} + handler := &OpenAIOAuthHandler{ + adminService: &openAIResetAdminServiceStub{}, + quotaService: quota, + rateLimitService: recoverer, + } + + status, envelope := performOpenAIQuotaResetRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) + require.False(t, envelope.Data.CacheRefreshed) + require.False(t, envelope.Data.AccountStateRecovered) + require.Nil(t, envelope.Data.Quota) + require.Equal(t, 1, quota.resetCalls) + require.Equal(t, 1, quota.queryCalls) + require.Zero(t, quota.cacheCalls) + require.Zero(t, recoverer.calls) +} + +func TestOpenAIResetQuota_CacheFailureReturnsPartialSuccessAndStops(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + quota.cacheErr = errors.New("cache write failed") + recoverer := &openAIAccountStateRecovererStub{} + handler := &OpenAIOAuthHandler{ + adminService: &openAIResetAdminServiceStub{}, + quotaService: quota, + rateLimitService: recoverer, + } + + status, envelope := performOpenAIQuotaResetRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode) + require.False(t, envelope.Data.CacheRefreshed) + require.Nil(t, envelope.Data.Quota) + require.Equal(t, 1, quota.resetCalls) + require.Equal(t, 1, quota.queryCalls) + require.Equal(t, 1, quota.cacheCalls) + require.Zero(t, recoverer.calls) +} + +func TestOpenAIResetQuota_RecoveryFailureKeepsRefreshedQuota(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + recoverer := &openAIAccountStateRecovererStub{err: errors.New("recovery failed")} + adminService := &openAIResetAdminServiceStub{} + handler := &OpenAIOAuthHandler{ + adminService: adminService, + quotaService: quota, + rateLimitService: recoverer, + } + + status, envelope := performOpenAIQuotaResetRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.Equal(t, openAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode) + require.True(t, envelope.Data.CacheRefreshed) + require.False(t, envelope.Data.AccountStateRecovered) + require.NotNil(t, envelope.Data.Quota) + require.Equal(t, 1, quota.resetCalls) + require.Equal(t, 1, quota.queryCalls) + require.Equal(t, 1, quota.cacheCalls) + require.Equal(t, 1, recoverer.calls) + require.Zero(t, adminService.calls) +} + +func TestOpenAIResetQuota_SuccessReturnsQuotaAndRecoveredAccount(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + recoverer := &openAIAccountStateRecovererStub{} + adminService := &openAIResetAdminServiceStub{account: &service.Account{ + ID: 42, + Name: "recovered", + Platform: service.PlatformOpenAI, + Type: service.AccountTypeOAuth, + Status: service.StatusActive, + Schedulable: false, + }} + handler := &OpenAIOAuthHandler{ + adminService: adminService, + quotaService: quota, + rateLimitService: recoverer, + } + + status, envelope := performOpenAIQuotaResetRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.Empty(t, envelope.Data.WarningCode) + require.True(t, envelope.Data.CacheRefreshed) + require.True(t, envelope.Data.AccountStateRecovered) + require.NotNil(t, envelope.Data.Quota) + require.NotNil(t, envelope.Data.Account) + require.Equal(t, int64(42), envelope.Data.Account.ID) + require.False(t, envelope.Data.Account.Schedulable) + require.Equal(t, int64(42), recoverer.accountID) + require.True(t, recoverer.lastOptions.InvalidateToken) + require.Equal(t, 1, quota.resetCalls) + require.Equal(t, 1, quota.queryCalls) + require.Equal(t, 1, quota.cacheCalls) + require.Equal(t, 1, recoverer.calls) + require.Equal(t, 1, adminService.calls) +} + +func TestOpenAIResetQuota_AccountRefreshFailureReportsRecoveredState(t *testing.T) { + quota := successfulOpenAIQuotaWorkflowStub() + recoverer := &openAIAccountStateRecovererStub{} + adminService := &openAIResetAdminServiceStub{err: errors.New("account refresh failed")} + handler := &OpenAIOAuthHandler{ + adminService: adminService, + quotaService: quota, + rateLimitService: recoverer, + } + + status, envelope := performOpenAIQuotaResetRequest(t, handler) + + require.Equal(t, http.StatusOK, status) + require.Equal(t, openAIQuotaResetWarningAccountRefreshFailed, envelope.Data.WarningCode) + require.True(t, envelope.Data.CacheRefreshed) + require.True(t, envelope.Data.AccountStateRecovered) + require.NotNil(t, envelope.Data.Quota) + require.Nil(t, envelope.Data.Account) + require.Equal(t, 1, quota.resetCalls) + require.Equal(t, 1, quota.queryCalls) + require.Equal(t, 1, quota.cacheCalls) + require.Equal(t, 1, recoverer.calls) + require.Equal(t, 1, adminService.calls) +} diff --git a/backend/internal/handler/admin/openai_oauth_handler_spark_shadow_test.go b/backend/internal/handler/admin/openai_oauth_handler_spark_shadow_test.go index af3b245c4b..da856b3926 100644 --- a/backend/internal/handler/admin/openai_oauth_handler_spark_shadow_test.go +++ b/backend/internal/handler/admin/openai_oauth_handler_spark_shadow_test.go @@ -20,7 +20,7 @@ func TestCreateShadow_ReturnsCreatedShadow(t *testing.T) { gin.SetMode(gin.TestMode) stub := &stubAdminService{} - h := NewOpenAIOAuthHandler(nil, stub, nil) + h := NewOpenAIOAuthHandler(nil, stub, nil, nil) router := gin.New() router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow) @@ -54,7 +54,7 @@ func TestCreateShadow_ReturnsCreatedShadow(t *testing.T) { func TestCreateShadow_InvalidID(t *testing.T) { gin.SetMode(gin.TestMode) - h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil) + h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil, nil) router := gin.New() router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow) @@ -72,7 +72,7 @@ func TestCreateShadow_ServiceError(t *testing.T) { gin.SetMode(gin.TestMode) stub := &stubAdminService{createSparkShadowErr: errors.New("database unavailable")} - h := NewOpenAIOAuthHandler(nil, stub, nil) + h := NewOpenAIOAuthHandler(nil, stub, nil, nil) router := gin.New() router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow) @@ -91,7 +91,7 @@ func TestCreateShadow_ServiceError(t *testing.T) { func TestCreateShadow_BadBody(t *testing.T) { gin.SetMode(gin.TestMode) - h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil) + h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil, nil) router := gin.New() router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow) diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 92f098768c..010442eb6b 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -56,6 +56,7 @@ var schedulerNeutralExtraKeyPrefixes = []string{ "codex_secondary_", "codex_5h_", "codex_7d_", + "codex_reset_credit_", "passive_usage_", "upstream_billing_probe", "upstream_billing_rate_sync", diff --git a/backend/internal/service/openai_quota_service.go b/backend/internal/service/openai_quota_service.go index c4bad643e7..82759f7011 100644 --- a/backend/internal/service/openai_quota_service.go +++ b/backend/internal/service/openai_quota_service.go @@ -34,6 +34,7 @@ const ( openaiQuotaSecFetchSite = "none" openaiQuotaSecFetchMode = "no-cors" openaiQuotaSecFetchDest = "empty" + openaiQuotaResetCreditsKey = "codex_reset_credit_snapshot" ) // OpenAIRateLimitWindow describes a single rate-limit window returned by @@ -206,6 +207,28 @@ func (s *OpenAIQuotaService) QueryUsage(ctx context.Context, accountID int64) (* return &payload, nil } +// CacheResetCreditsSnapshot persists a complete reset-credit snapshot after an +// explicit UI refresh. Missing expiration details leave the old cache intact. +func (s *OpenAIQuotaService) CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *OpenAIRateLimitResetCredits) error { + if credits == nil || (credits.AvailableCount > 0 && credits.Credits == nil) { + return infraerrors.New( + http.StatusBadGateway, + "OPENAI_QUOTA_RESET_CREDITS_REFRESH_FAILED", + "failed to refresh reset-credit expiration details; cached data was preserved", + ) + } + if err := s.accountRepo.UpdateExtra(ctx, accountID, map[string]any{ + openaiQuotaResetCreditsKey: credits, + }); err != nil { + return infraerrors.New( + http.StatusInternalServerError, + "OPENAI_QUOTA_CACHE_WRITE_FAILED", + "failed to cache reset-credit details", + ).WithCause(err) + } + return nil +} + func (s *OpenAIQuotaService) queryResetCreditDetails(ctx context.Context, client *req.Client, accessToken, chatGPTAccountID string, fedRAMP bool, accountID int64) *openAIRateLimitResetCreditDetails { quotaHeaders, _, headerErr := s.buildCodexQuotaHeaders(ctx, accountID, accessToken, chatGPTAccountID, fedRAMP) if headerErr != nil { diff --git a/backend/internal/service/openai_quota_spark_window_test.go b/backend/internal/service/openai_quota_spark_window_test.go index ad871c3f2e..6182a04998 100644 --- a/backend/internal/service/openai_quota_spark_window_test.go +++ b/backend/internal/service/openai_quota_spark_window_test.go @@ -27,7 +27,9 @@ import ( // stubQuotaAccountRepo 是多账号 AccountRepository stub,仅实现 GetByID。 type stubQuotaAccountRepo struct { AccountRepository - accounts map[int64]*Account + accounts map[int64]*Account + extraUpdates map[int64]map[string]any + extraUpdateErr error } func (r *stubQuotaAccountRepo) GetByID(_ context.Context, id int64) (*Account, error) { @@ -47,6 +49,17 @@ func (r *stubQuotaAccountRepo) UpdateCredentials(_ context.Context, id int64, cr return nil } +func (r *stubQuotaAccountRepo) UpdateExtra(_ context.Context, id int64, updates map[string]any) error { + if r.extraUpdateErr != nil { + return r.extraUpdateErr + } + if r.extraUpdates == nil { + r.extraUpdates = make(map[int64]map[string]any) + } + r.extraUpdates[id] = updates + return nil +} + // stubQuotaTokenCache 实现 OpenAITokenCache,返回预设静态 token。 type stubQuotaTokenCache struct { tokens map[string]string @@ -535,6 +548,14 @@ func TestQueryUsageIncludesResetCreditExpirations_EndToEnd(t *testing.T) { {ExpiresAt: "2026-07-03T04:05:06Z"}, {ExpiresAt: "2026-07-04T04:05:06Z"}, }, usage.RateLimitResetCredits.Credits) + require.NoError(t, svc.CacheResetCreditsSnapshot(ctx, 100, usage.RateLimitResetCredits)) + require.Equal(t, &OpenAIRateLimitResetCredits{ + AvailableCount: 2, + Credits: []OpenAIRateLimitResetCreditDetail{ + {ExpiresAt: "2026-07-03T04:05:06Z"}, + {ExpiresAt: "2026-07-04T04:05:06Z"}, + }, + }, repo.extraUpdates[100][openaiQuotaResetCreditsKey]) encoded, err := json.Marshal(usage) require.NoError(t, err) @@ -584,6 +605,42 @@ func TestQueryUsageResetCreditDetails401NonFatal(t *testing.T) { require.Equal(t, 1, usage.RateLimitResetCredits.AvailableCount) require.Equal(t, 1, detailCalls) require.Empty(t, usage.RateLimitResetCredits.Credits) + require.Empty(t, repo.extraUpdates) +} + +func TestCacheResetCreditsSnapshot(t *testing.T) { + ctx := context.Background() + + t.Run("zero count allows an empty expiration list", func(t *testing.T) { + repo := &stubQuotaAccountRepo{} + svc := &OpenAIQuotaService{accountRepo: repo} + credits := &OpenAIRateLimitResetCredits{AvailableCount: 0} + + require.NoError(t, svc.CacheResetCreditsSnapshot(ctx, 100, credits)) + require.Equal(t, credits, repo.extraUpdates[100][openaiQuotaResetCreditsKey]) + }) + + t.Run("missing expiration list preserves the cache", func(t *testing.T) { + repo := &stubQuotaAccountRepo{} + svc := &OpenAIQuotaService{accountRepo: repo} + + err := svc.CacheResetCreditsSnapshot(ctx, 100, &OpenAIRateLimitResetCredits{AvailableCount: 1}) + + require.Error(t, err) + require.Empty(t, repo.extraUpdates) + }) + + t.Run("repository errors are returned", func(t *testing.T) { + repo := &stubQuotaAccountRepo{extraUpdateErr: errors.New("database unavailable")} + svc := &OpenAIQuotaService{accountRepo: repo} + + err := svc.CacheResetCreditsSnapshot(ctx, 100, &OpenAIRateLimitResetCredits{ + AvailableCount: 1, + Credits: []OpenAIRateLimitResetCreditDetail{{ExpiresAt: "2026-07-03T04:05:06Z"}}, + }) + + require.ErrorContains(t, err, "database unavailable") + }) } // TestResetCreditGetByIDError_FailsClosed 验证守卫「失败关闭」语义: diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index ea9b59b20c..bab9b0c49a 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -834,13 +834,26 @@ export interface OpenAIQuotaResetResult { code: string credit?: OpenAIQuotaResetCredit | null windows_reset: number + quota?: OpenAIQuotaUsage | null + account?: Account | null + cache_refreshed: boolean + account_state_recovered: boolean + warning_code?: + | 'reset_credit_cache_refresh_failed' + | 'account_state_recovery_failed' + | 'account_state_refresh_failed' } /** * Query OpenAI/Codex rate-limit usage for an OAuth account. */ -export async function queryOpenAIQuota(id: number): Promise { - const { data } = await apiClient.get(`/admin/openai/accounts/${id}/quota`) +export async function queryOpenAIQuota( + id: number, + options?: { persistResetCredits?: boolean } +): Promise { + const { data } = await apiClient.get(`/admin/openai/accounts/${id}/quota`, { + params: options?.persistResetCredits === true ? { persist_reset_credits: true } : undefined + }) return data } diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index b83e0308cd..8be4361a39 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -144,7 +144,7 @@ refresh button is rendered via the pre-actions slot so the user sees a single row of related buttons instead of two stacked rows. --> - + @@ -654,6 +658,10 @@ const props = withDefaults( } ) +const emit = defineEmits<{ + 'account-updated': [account: Account] +}>() + const { t } = useI18n() const desktopViewportQuery = '(min-width: 768px)' @@ -664,6 +672,7 @@ const loading = ref(false) const activeQueryLoading = ref(false) const error = ref(null) const usageInfo = ref(null) +const suppressNextOpenAIUsageRefresh = ref(false) const rootRef = ref(null) const isDesktopViewport = ref( typeof window === 'undefined' ? true : window.matchMedia(desktopViewportQuery).matches @@ -1473,6 +1482,13 @@ const quotaTotalBar = computed((): QuotaBarInfo | null => { return makeQuotaBar(props.account.quota_used ?? 0, limit) }) +const handleQuotaResetAccountUpdated = (account: Account) => { + // The reset response already carries authoritative quota and account data. + // Avoid turning the parent patch into a second automatic /usage request. + suppressNextOpenAIUsageRefresh.value = true + emit('account-updated', account) +} + // ===== Key account today stats formatters ===== const formatKeyRequests = computed(() => { @@ -1517,6 +1533,10 @@ onMounted(() => { watch(openAIUsageRefreshKey, (nextKey, prevKey) => { if (!prevKey || nextKey === prevKey) return if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return + if (suppressNextOpenAIUsageRefresh.value) { + suppressNextOpenAIUsageRefresh.value = false + return + } _usageCache.delete(props.account.id) requestAutoLoad() diff --git a/frontend/src/components/account/OpenAIQuotaResetCell.vue b/frontend/src/components/account/OpenAIQuotaResetCell.vue index c1b5e1eec3..dbaccd75cf 100644 --- a/frontend/src/components/account/OpenAIQuotaResetCell.vue +++ b/frontend/src/components/account/OpenAIQuotaResetCell.vue @@ -19,7 +19,7 @@ class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-blue-600 transition-colors hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-blue-400 dark:hover:bg-blue-900/30" :disabled="loading || resetting" :title="countButtonTitle" - @click="handleQuery" + @click="handleQuery({ persistResetCredits: true })" > {{ truncatedError }} +
+ {{ resetWarning }} +
() +const emit = defineEmits<{ + 'account-updated': [account: Account] +}>() + const { t } = useI18n() // Visible only for OpenAI OAuth accounts. @@ -156,17 +166,51 @@ const loading = ref(false) const resetting = ref(false) const error = ref(null) const data = ref(null) +const cachedData = ref(null) const resetMessage = ref(null) +const resetWarning = ref(null) const showResetConfirm = ref(false) const showResetCreditDetails = ref(false) +const readCachedResetCredits = (account: Account): OpenAIQuotaUsage | null => { + const cached = account.extra?.codex_reset_credit_snapshot + if (!cached || typeof cached !== 'object' || Array.isArray(cached)) return null + + const { available_count: count, credits: rawCredits } = cached as { + available_count?: unknown + credits?: unknown + } + if (typeof count !== 'number' || !Number.isFinite(count)) return null + + const credits: { expires_at?: string }[] = [] + if (Array.isArray(rawCredits)) { + for (const credit of rawCredits) { + if (!credit || typeof credit !== 'object') continue + const expiresAt = (credit as { expires_at?: unknown }).expires_at + if (typeof expiresAt === 'string' && expiresAt.trim() !== '') { + credits.push({ expires_at: expiresAt }) + } + } + } + return { + fetched_at: 0, + rate_limit_reset_credits: { + available_count: count, + credits + } + } +} + +cachedData.value = readCachedResetCredits(props.account) +data.value = cachedData.value + // 影子账号的额度查询会 resolve 到母账号,但影子本身不支持重置(后端返回 409); // 重置必须在母账号上进行。前端据此禁用影子的重置入口(外审 F6)。 const isShadow = computed(() => props.account.parent_account_id != null) const availableResetCount = computed(() => data.value?.rate_limit_reset_credits?.available_count ?? 0) const resetCreditExpirations = computed(() => - (data.value?.rate_limit_reset_credits?.credits ?? []) + (cachedData.value?.rate_limit_reset_credits?.credits ?? []) .map((credit) => credit.expires_at?.trim() ?? '') .filter((expiresAt) => expiresAt.length > 0) .sort(compareResetCreditExpiry) @@ -260,14 +304,19 @@ const toggleResetCreditDetails = () => { showResetCreditDetails.value = !showResetCreditDetails.value } -const handleQuery = async () => { +const handleQuery = async (options?: { persistResetCredits?: boolean }) => { if (loading.value) return loading.value = true error.value = null resetMessage.value = null + resetWarning.value = null showResetCreditDetails.value = false try { - data.value = await queryOpenAIQuota(props.account.id) + const result = options + ? await queryOpenAIQuota(props.account.id, options) + : await queryOpenAIQuota(props.account.id) + data.value = result + if (options?.persistResetCredits) cachedData.value = result } catch (e) { error.value = extractErrorMessage(e) } finally { @@ -294,15 +343,30 @@ const confirmReset = async () => { resetting.value = true error.value = null resetMessage.value = null + resetWarning.value = null try { const result: OpenAIQuotaResetResult = await resetOpenAIQuota(props.account.id) - // Refresh the reset-credit count so the badge reflects the consumed credit. - // handleQuery clears resetMessage on entry, so the success toast is set - // AFTER it resolves. - await handleQuery() - resetMessage.value = t('admin.accounts.openaiQuotaReset.resetSuccess', { - windows: result.windows_reset - }) + if (result.cache_refreshed && result.quota) { + data.value = result.quota + cachedData.value = result.quota + showResetCreditDetails.value = false + } + if (result.account) emit('account-updated', result.account) + + if (result.warning_code === 'reset_credit_cache_refresh_failed') { + // The persisted snapshot is intentionally preserved, but it is no longer + // authoritative enough to allow another credit consumption. + data.value = null + resetWarning.value = t('admin.accounts.openaiQuotaReset.resetCacheRefreshFailed') + } else if (result.warning_code === 'account_state_recovery_failed') { + resetWarning.value = t('admin.accounts.openaiQuotaReset.resetAccountRecoveryFailed') + } else if (result.warning_code === 'account_state_refresh_failed') { + resetWarning.value = t('admin.accounts.openaiQuotaReset.resetAccountRefreshFailed') + } else { + resetMessage.value = t('admin.accounts.openaiQuotaReset.resetSuccess', { + windows: result.windows_reset + }) + } } catch (e) { error.value = extractErrorMessage(e) } finally { @@ -314,9 +378,11 @@ watch( () => props.account.id, () => { // Account row may be reused across paginated lists; reset local state. - data.value = null + cachedData.value = readCachedResetCredits(props.account) + data.value = cachedData.value error.value = null resetMessage.value = null + resetWarning.value = null loading.value = false resetting.value = false showResetConfirm.value = false diff --git a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts index 70c32ee480..dd3e231879 100644 --- a/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts +++ b/frontend/src/components/account/__tests__/AccountUsageCell.spec.ts @@ -509,7 +509,51 @@ describe('AccountUsageCell', () => { await flushPromises() expect(getUsage).toHaveBeenCalledTimes(2) - expect(wrapper.text()).toContain('5h|0|200') + expect(wrapper.text()).toContain('5h|0|200') + }) + + it('OpenAI 重置响应更新账号行时不会额外拉取 usage', async () => { + getUsage.mockResolvedValue({ + five_hour: { + utilization: 0, + resets_at: null, + remaining_seconds: 0 + }, + seven_day: null + }) + const account = makeAccount({ + id: 2004, + platform: 'openai', + type: 'oauth', + updated_at: '2026-03-07T10:00:00Z', + extra: {} + }) + const wrapper = mount(AccountUsageCell, { + props: { account }, + global: { + stubs: { + UsageProgressBar: true, + AccountQuotaInfo: true, + OpenAIQuotaResetCell: { + props: ['account'], + emits: ['account-updated'], + template: '