diff --git a/backend/internal/handler/admin/openai_oauth_handler.go b/backend/internal/handler/admin/openai_oauth_handler.go index 779e4455cd29..431495980233 100644 --- a/backend/internal/handler/admin/openai_oauth_handler.go +++ b/backend/internal/handler/admin/openai_oauth_handler.go @@ -18,10 +18,11 @@ import ( // OpenAIOAuthHandler handles OpenAI OAuth-related operations type OpenAIOAuthHandler struct { - openaiOAuthService *service.OpenAIOAuthService - adminService service.AdminService - quotaService openAIQuotaService - rateLimitService openAIAccountStateRecoverer + openaiOAuthService *service.OpenAIOAuthService + adminService service.AdminService + quotaService openAIQuotaService + expiryTargetService openAIResetCreditExpiryTargetService + rateLimitService openAIAccountStateRecoverer } type openAIQuotaService interface { @@ -30,6 +31,11 @@ type openAIQuotaService interface { ResetCredit(ctx context.Context, accountID int64) (*service.OpenAIQuotaResetResult, error) } +type openAIResetCreditExpiryTargetService interface { + SetResetCreditExpiryTarget(ctx context.Context, accountID int64, creditID string, leadTimeMinutes int) (*service.Account, error) + CancelResetCreditExpiryTarget(ctx context.Context, accountID int64) (*service.Account, error) +} + type openAIAccountStateRecoverer interface { RecoverAccountState(ctx context.Context, accountID int64, options service.AccountRecoveryOptions) (*service.SuccessfulTestRecoveryResult, error) } @@ -91,6 +97,7 @@ func NewOpenAIOAuthHandler( // `== nil` capability guards below and panic instead of returning 400. if quotaService != nil { h.quotaService = quotaService + h.expiryTargetService = quotaService } if rateLimitService != nil { h.rateLimitService = rateLimitService @@ -98,6 +105,58 @@ func NewOpenAIOAuthHandler( return h } +// SetResetCreditExpiryTarget creates or updates the single-card expiry plan. +// PUT /api/v1/admin/openai/accounts/:id/reset-credit-expiry-target +func (h *OpenAIOAuthHandler) SetResetCreditExpiryTarget(c *gin.Context) { + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid account ID") + return + } + if h.expiryTargetService == nil { + response.BadRequest(c, "openai quota service is not enabled") + return + } + var req struct { + CreditID string `json:"credit_id"` + LeadTimeMinutes *int `json:"lead_time_minutes"` + } + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, "Invalid request: "+err.Error()) + return + } + leadTimeMinutes := service.OpenAIResetCreditExpiryTargetDefaultLeadTimeMinutes + if req.LeadTimeMinutes != nil { + leadTimeMinutes = *req.LeadTimeMinutes + } + account, err := h.expiryTargetService.SetResetCreditExpiryTarget(c.Request.Context(), accountID, req.CreditID, leadTimeMinutes) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, dto.AccountFromService(account)) +} + +// CancelResetCreditExpiryTarget cancels the current single-card expiry plan. +// DELETE /api/v1/admin/openai/accounts/:id/reset-credit-expiry-target +func (h *OpenAIOAuthHandler) CancelResetCreditExpiryTarget(c *gin.Context) { + accountID, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil { + response.BadRequest(c, "Invalid account ID") + return + } + if h.expiryTargetService == nil { + response.BadRequest(c, "openai quota service is not enabled") + return + } + account, err := h.expiryTargetService.CancelResetCreditExpiryTarget(c.Request.Context(), accountID) + if err != nil { + response.ErrorFrom(c, err) + return + } + response.Success(c, dto.AccountFromService(account)) +} + // OpenAIGenerateAuthURLRequest represents the request for generating OpenAI auth URL type OpenAIGenerateAuthURLRequest struct { ProxyID *int64 `json:"proxy_id"` diff --git a/backend/internal/handler/admin/openai_oauth_handler_expiry_target_test.go b/backend/internal/handler/admin/openai_oauth_handler_expiry_target_test.go new file mode 100644 index 000000000000..1303d58e8cc4 --- /dev/null +++ b/backend/internal/handler/admin/openai_oauth_handler_expiry_target_test.go @@ -0,0 +1,64 @@ +package admin + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Wei-Shaw/sub2api/internal/service" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +type expiryTargetHandlerService struct { + account *service.Account + creditID string + leadTime int + setCalls int + cancelCalls int +} + +func (s *expiryTargetHandlerService) SetResetCreditExpiryTarget(_ context.Context, _ int64, creditID string, leadTimeMinutes int) (*service.Account, error) { + s.setCalls++ + s.creditID = creditID + s.leadTime = leadTimeMinutes + return s.account, nil +} + +func (s *expiryTargetHandlerService) CancelResetCreditExpiryTarget(context.Context, int64) (*service.Account, error) { + s.cancelCalls++ + return s.account, nil +} + +func TestOpenAIResetCreditExpiryTargetHandlers(t *testing.T) { + gin.SetMode(gin.TestMode) + stub := &expiryTargetHandlerService{account: &service.Account{ID: 42, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth}} + handler := &OpenAIOAuthHandler{expiryTargetService: stub} + router := gin.New() + router.PUT("/openai/accounts/:id/reset-credit-expiry-target", handler.SetResetCreditExpiryTarget) + router.DELETE("/openai/accounts/:id/reset-credit-expiry-target", handler.CancelResetCreditExpiryTarget) + + for index, test := range []struct { + body, creditID string + leadTime int + }{ + {`{"credit_id":"credit-one"}`, "credit-one", service.OpenAIResetCreditExpiryTargetDefaultLeadTimeMinutes}, + {`{"credit_id":"credit-two","lead_time_minutes":30}`, "credit-two", 30}, + } { + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPut, "/openai/accounts/42/reset-credit-expiry-target", bytes.NewBufferString(test.body)) + request.Header.Set("content-type", "application/json") + router.ServeHTTP(recorder, request) + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, index+1, stub.setCalls) + require.Equal(t, test.creditID, stub.creditID) + require.Equal(t, test.leadTime, stub.leadTime) + } + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, httptest.NewRequest(http.MethodDelete, "/openai/accounts/42/reset-credit-expiry-target", nil)) + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, 1, stub.cancelCalls) +} diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index 664a8454608c..7ad3e4247533 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -2629,6 +2629,50 @@ func (r *accountRepository) UpdateExtra(ctx context.Context, id int64, updates m return nil } +func (r *accountRepository) CompareAndSwapExtra(ctx context.Context, id int64, key string, expected any, updates map[string]any) (bool, error) { + updates = stripCodexFingerprintSeedFromExtraUpdate(updates) + if strings.TrimSpace(key) == "" || len(updates) == 0 { + return false, nil + } + payload, err := json.Marshal(updates) + if err != nil { + return false, err + } + expectedPayload, err := json.Marshal(expected) + if err != nil { + return false, err + } + result, err := r.sql.ExecContext(ctx, ` + UPDATE accounts + SET extra = COALESCE(extra, '{}'::jsonb) || $1::jsonb, + updated_at = NOW() + WHERE id = $2 + AND deleted_at IS NULL + AND COALESCE(extra -> $3::text, 'null'::jsonb) = $4::jsonb + `, string(payload), id, key, string(expectedPayload)) + if err != nil { + return false, err + } + affected, err := result.RowsAffected() + if err != nil { + return false, err + } + if affected == 0 { + exists, err := r.ExistsByID(ctx, id) + if err != nil { + return false, err + } + if !exists { + return false, service.ErrAccountNotFound + } + return false, nil + } + if dbent.TxFromContext(ctx) == nil { + r.syncSchedulerAccountSnapshot(ctx, id) + } + return true, nil +} + // UpdateUpstreamBillingProbeSnapshot stores a probe result only while the // network identity used by that probe is still current. func (r *accountRepository) UpdateUpstreamBillingProbeSnapshot( diff --git a/backend/internal/repository/account_repo_extra_cas_test.go b/backend/internal/repository/account_repo_extra_cas_test.go new file mode 100644 index 000000000000..a79235c869a4 --- /dev/null +++ b/backend/internal/repository/account_repo_extra_cas_test.go @@ -0,0 +1,58 @@ +package repository + +import ( + "context" + "regexp" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + dbent "github.com/Wei-Shaw/sub2api/ent" + "github.com/stretchr/testify/require" + + "entgo.io/ent/dialect" + entsql "entgo.io/ent/dialect/sql" +) + +func TestCompareAndSwapExtraUsesExpectedJSONValue(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + t.Cleanup(func() { _ = client.Close() }) + + mock.ExpectExec(`(?s)`+regexp.QuoteMeta("UPDATE accounts")+`.*`+regexp.QuoteMeta("COALESCE(extra -> $3::text, 'null'::jsonb) = $4::jsonb")). + WithArgs(`{"plan":null,"state":{"status":"done"}}`, int64(27), "plan", `{"plan_id":"old"}`). + WillReturnResult(sqlmock.NewResult(0, 1)) + repo := newAccountRepositoryWithSQL(client, db, nil) + + swapped, err := repo.CompareAndSwapExtra(context.Background(), 27, "plan", map[string]any{"plan_id": "old"}, map[string]any{ + "plan": nil, + "state": map[string]any{"status": "done"}, + }) + + require.NoError(t, err) + require.True(t, swapped) + require.NoError(t, mock.ExpectationsWereMet()) +} + +func TestCompareAndSwapExtraReportsConflictWithoutOverwriting(t *testing.T) { + db, mock, err := sqlmock.New() + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db))) + t.Cleanup(func() { _ = client.Close() }) + + mock.ExpectExec(`(?s)`+regexp.QuoteMeta("UPDATE accounts")+`.*`+regexp.QuoteMeta("COALESCE(extra -> $3::text, 'null'::jsonb) = $4::jsonb")). + WithArgs(`{"plan":null}`, int64(27), "plan", `{"plan_id":"old"}`). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectQuery(`(?s)SELECT .*accounts.*id.*FROM .*accounts.*WHERE .*accounts.*id.*LIMIT 1`). + WithArgs(int64(27)). + WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(27))) + repo := newAccountRepositoryWithSQL(client, db, nil) + + swapped, err := repo.CompareAndSwapExtra(context.Background(), 27, "plan", map[string]any{"plan_id": "old"}, map[string]any{"plan": nil}) + + require.NoError(t, err) + require.False(t, swapped) + require.NoError(t, mock.ExpectationsWereMet()) +} diff --git a/backend/internal/server/routes/admin.go b/backend/internal/server/routes/admin.go index d618fb45e718..9580429fe980 100644 --- a/backend/internal/server/routes/admin.go +++ b/backend/internal/server/routes/admin.go @@ -451,6 +451,8 @@ func registerOpenAIOAuthRoutes(admin *gin.RouterGroup, h *handler.Handlers) { openai.GET("/accounts/:id/quota", h.Admin.OpenAIOAuth.QueryQuota) openai.POST("/accounts/:id/quota/refresh", h.Admin.OpenAIOAuth.RefreshQuota) openai.POST("/accounts/:id/reset-quota", h.Admin.OpenAIOAuth.ResetQuota) + openai.PUT("/accounts/:id/reset-credit-expiry-target", h.Admin.OpenAIOAuth.SetResetCreditExpiryTarget) + openai.DELETE("/accounts/:id/reset-credit-expiry-target", h.Admin.OpenAIOAuth.CancelResetCreditExpiryTarget) } } diff --git a/backend/internal/service/admin_account.go b/backend/internal/service/admin_account.go index ae194f61e1e7..10a31a970b60 100644 --- a/backend/internal/service/admin_account.go +++ b/backend/internal/service/admin_account.go @@ -662,6 +662,7 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U OllamaCloudUsageAutoRefreshExtraKey, OllamaCloudUsageSnapshotExtraKey, OpenAIAutoResetCreditStateExtraKey, + OpenAIAutoResetCreditExpiryTargetExtraKey, } { if v, ok := account.Extra[key]; ok { normalizedExtra[key] = v diff --git a/backend/internal/service/audit_log.go b/backend/internal/service/audit_log.go index 46a59013e00e..856c5d0ab35f 100644 --- a/backend/internal/service/audit_log.go +++ b/backend/internal/service/audit_log.go @@ -133,6 +133,8 @@ var auditBodySensitiveExactKeys = func() map[string]struct{} { // custom_key 为用户自设的平台 API Key 明文, // session 为 Ollama Cloud 用量的浏览器会话 Cookie 明文。 "proxy_key", "custom_key", "session", + // 上游资源 ID 可用于计划和缓存,但不复制到操作审计正文。 + "credit_id", } set := make(map[string]struct{}, len(builtin)+len(SensitiveCredentialKeys)+16) for _, k := range builtin { diff --git a/backend/internal/service/audit_log_test.go b/backend/internal/service/audit_log_test.go index 0045c936f2ea..0c7fd8f8a5da 100644 --- a/backend/internal/service/audit_log_test.go +++ b/backend/internal/service/audit_log_test.go @@ -38,6 +38,7 @@ func TestRedactAuditBody_JSONRedactsSecrets(t *testing.T) { "credentials": {"api_key": "sk-secret-123", "base_url": "https://evil.example.com"}, "new_password": "hunter2", "totp_code": "123456", + "credit_id": "credit-resource-123", "nested": [{"access_token": "tok_abc"}] }`) out := RedactAuditBody(raw, "application/json") @@ -48,7 +49,7 @@ func TestRedactAuditBody_JSONRedactsSecrets(t *testing.T) { } // 敏感字段被擦除。 - for _, secret := range []string{"sk-secret-123", "hunter2", "123456", "tok_abc"} { + for _, secret := range []string{"sk-secret-123", "hunter2", "123456", "credit-resource-123", "tok_abc"} { if strings.Contains(out, secret) { t.Fatalf("redacted body still contains secret %q: %s", secret, out) } diff --git a/backend/internal/service/openai_quota_auto_reset.go b/backend/internal/service/openai_quota_auto_reset.go index aafc5991d78a..5b42b6d2ae80 100644 --- a/backend/internal/service/openai_quota_auto_reset.go +++ b/backend/internal/service/openai_quota_auto_reset.go @@ -36,12 +36,19 @@ const ( OpenAIAutoResetStatusSuccess = "success" OpenAIAutoResetStatusNoCredit = "no_credit" OpenAIAutoResetStatusFailed = "failed" + + OpenAIAutoResetTriggerReasonUsageThreshold = "usage_threshold" + OpenAIAutoResetTriggerReasonExpiryTarget = "expiry_target" + + openAIAutoResetReplayConflictCode = "OPENAI_AUTO_RESET_REPLAY_CONFLICT" + openAIAutoResetNoEffectCode = "OPENAI_AUTO_RESET_NO_EFFECT" ) // OpenAIAutoResetCreditState 是可返回管理端的脱敏运行态。Attempt* 仅保存不可逆 // 指纹,用于重启后拒绝切换到另一张卡;不会保存卡 ID 或兑换 ID。 type OpenAIAutoResetCreditState struct { Status string `json:"status"` + TriggerReason string `json:"trigger_reason,omitempty"` TriggerWindow string `json:"trigger_window,omitempty"` AvailableCount int `json:"available_count"` CheckedAt string `json:"checked_at,omitempty"` @@ -219,14 +226,15 @@ func (s *OpenAIQuotaAutoResetService) scanEnabledAccounts(ctx context.Context) { for page := 1; ; page++ { accounts, pageInfo, err := s.accountRepo.ListWithFilters(ctx, pagination.PaginationParams{ Page: page, PageSize: openAIAutoResetBatchSize, - }, PlatformOpenAI, AccountTypeOAuth, StatusActive, "", 0, "") + }, PlatformOpenAI, AccountTypeOAuth, "", "", 0, "") if err != nil { slog.Warn("openai_auto_reset_scan_failed", "page", page, "error", err) return } for i := range accounts { account := &accounts[i] - if account.Schedulable && ResolveOpenAIAutoResetCreditConfig(account).Enabled { + if ResolveOpenAIResetCreditExpiryTarget(account) != nil || + (account.IsActive() && account.Schedulable && ResolveOpenAIAutoResetCreditConfig(account).Enabled) { s.Notify(account.ID) } } @@ -258,6 +266,7 @@ func (s *OpenAIQuotaAutoResetService) tryAcquireScanLock(ctx context.Context) (f } type openAIAutoResetAssessment struct { + triggerReason string triggerWindow string resetReached bool pauseReached bool @@ -280,20 +289,32 @@ func (s *OpenAIQuotaAutoResetService) evaluateAccount(ctx context.Context, accou return nil } config := ResolveOpenAIAutoResetCreditConfig(account) - if !config.Enabled || !account.IsActive() || !account.Schedulable { + target := ResolveOpenAIResetCreditExpiryTarget(account) + state := openAIAutoResetStateFromExtra(account.Extra) + now := time.Now().UTC() + if target != nil && openAIAutoResetExpiryTargetExpired(target, now) { + return s.finishOpenAIAutoResetExpiryTarget(ctx, accountID, target, state, "OPENAI_RESET_CREDIT_EXPIRED_UNUSED", now) + } + if target != nil && openAIAutoResetExpiryTargetInWindow(target, now) { + if !account.IsActive() || !account.Schedulable { + return s.finishOpenAIAutoResetExpiryTarget(ctx, accountID, target, state, "OPENAI_RESET_CREDIT_ACCOUNT_UNAVAILABLE", now) + } + return s.consumeOpenAIAutoResetCredit(ctx, accountID, target.CreditID, OpenAIAutoResetTriggerReasonExpiryTarget, target, openAIAutoResetAssessment{}, stateAvailableCount(state), "") + } + if !account.IsActive() || !account.Schedulable || !config.Enabled { return nil } - now := time.Now() assessment := s.assessExtra(account, config, now) - state := openAIAutoResetStateFromExtra(account.Extra) - needsQuery := openAIAutoResetSnapshotStale(account.Extra, now) || assessment.resetReached + snapshotStale := openAIAutoResetSnapshotStale(account.Extra, now) + needsQuery := snapshotStale || assessment.resetReached if assessment.pauseReached && !assessment.resetReached { needsQuery = needsQuery || state == nil || state.Status == OpenAIAutoResetStatusChecking || state.Status == OpenAIAutoResetStatusFailed || openAIAutoResetStateStale(state, now) } if !needsQuery { if !assessment.pauseReached && state != nil && state.TriggerWindow != "" { state.TriggerWindow = "" + state.TriggerReason = "" state.ErrorCode = "" state.CheckedAt = now.UTC().Format(time.RFC3339) if state.AvailableCount > 0 { @@ -308,11 +329,16 @@ func (s *OpenAIQuotaAutoResetService) evaluateAccount(ctx context.Context, accou checking := &OpenAIAutoResetCreditState{ Status: OpenAIAutoResetStatusChecking, + TriggerReason: OpenAIAutoResetTriggerReasonUsageThreshold, TriggerWindow: assessment.triggerWindow, AvailableCount: stateAvailableCount(state), CheckedAt: now.UTC().Format(time.RFC3339), } copyOpenAIAutoResetAttempt(checking, state) + if isOpenAIAutoResetTerminalAttemptFailure(state) { + checking.ErrorCode = state.ErrorCode + checking.LastResultAt = state.LastResultAt + } if err := s.persistState(ctx, accountID, checking); err != nil { return err } @@ -321,12 +347,12 @@ func (s *OpenAIQuotaAutoResetService) evaluateAccount(ctx context.Context, accou if err != nil || usage == nil { return s.failState(ctx, accountID, checking, "RESET_CREDIT_QUERY_FAILED", err) } - if err := s.persistFreshUsage(ctx, accountID, usage, now); err != nil { - return s.failState(ctx, accountID, checking, "USAGE_SNAPSHOT_WRITE_FAILED", err) - } if usage.RateLimitResetCredits == nil { return s.failState(ctx, accountID, checking, "RESET_CREDIT_DETAILS_UNAVAILABLE", nil) } + if err := s.persistFreshUsage(ctx, accountID, usage, now); err != nil { + return s.failState(ctx, accountID, checking, "USAGE_SNAPSHOT_WRITE_FAILED", err) + } // 查询期间管理员可能关闭开关;消费前重新读取账号,确保尚未发出的任务可取消。 account, err = s.accountRepo.GetByID(ctx, accountID) @@ -334,7 +360,19 @@ func (s *OpenAIQuotaAutoResetService) evaluateAccount(ctx context.Context, accou return err } config = ResolveOpenAIAutoResetCreditConfig(account) - if !config.Enabled { + target = ResolveOpenAIResetCreditExpiryTarget(account) + state = openAIAutoResetStateFromExtra(account.Extra) + now = time.Now().UTC() + if target != nil && openAIAutoResetExpiryTargetExpired(target, now) { + return s.finishOpenAIAutoResetExpiryTarget(ctx, accountID, target, state, "OPENAI_RESET_CREDIT_EXPIRED_UNUSED", now) + } + if target != nil && openAIAutoResetExpiryTargetInWindow(target, now) { + if !account.IsActive() || !account.Schedulable { + return s.finishOpenAIAutoResetExpiryTarget(ctx, accountID, target, state, "OPENAI_RESET_CREDIT_ACCOUNT_UNAVAILABLE", now) + } + return s.consumeOpenAIAutoResetCredit(ctx, accountID, target.CreditID, OpenAIAutoResetTriggerReasonExpiryTarget, target, openAIAutoResetAssessment{}, usage.RateLimitResetCredits.AvailableCount, "") + } + if !account.IsActive() || !account.Schedulable || !config.Enabled { return nil } assessment = s.assessUsage(usage, account, config, now) @@ -348,64 +386,107 @@ func (s *OpenAIQuotaAutoResetService) evaluateAccount(ctx context.Context, accou Status: status, TriggerWindow: assessment.triggerWindow, AvailableCount: available, - CheckedAt: now.UTC().Format(time.RFC3339), + CheckedAt: now.Format(time.RFC3339), }) } if available <= 0 { return s.persistState(ctx, accountID, &OpenAIAutoResetCreditState{ Status: OpenAIAutoResetStatusNoCredit, + TriggerReason: OpenAIAutoResetTriggerReasonUsageThreshold, TriggerWindow: assessment.triggerWindow, AvailableCount: 0, - CheckedAt: now.UTC().Format(time.RFC3339), - LastResultAt: now.UTC().Format(time.RFC3339), + CheckedAt: now.Format(time.RFC3339), + LastResultAt: now.Format(time.RFC3339), ErrorCode: "NO_RESET_CREDIT", }) } - cycleSeed := openAIAutoResetCycleSeed(usage) - cycleHash := shortOpenAIAutoResetHash(cycleSeed) - candidate, selectErr := selectOpenAIAutoResetCandidate(usage.autoResetCandidates, available, state, cycleHash) + cycleHash := shortOpenAIAutoResetHash(openAIAutoResetCycleSeed(usage)) + if state != nil && state.AttemptCycleHash == cycleHash && isOpenAIAutoResetTerminalAttemptFailure(state) { + state.Status = OpenAIAutoResetStatusFailed + state.AvailableCount = available + state.CheckedAt = now.Format(time.RFC3339) + return s.persistState(ctx, accountID, state) + } + candidate, selectErr := selectOpenAIAutoResetCandidate(usage.RateLimitResetCredits.Credits, available, state, cycleHash) if selectErr != nil { - failed := checking + failed := ensureOpenAIAutoResetState(state) failed.AvailableCount = available + failed.TriggerReason = OpenAIAutoResetTriggerReasonUsageThreshold failed.TriggerWindow = assessment.triggerWindow failed.AttemptCycleHash = cycleHash return s.failState(ctx, accountID, failed, infraerrors.Reason(selectErr), selectErr) } - creditHash := shortOpenAIAutoResetHash(candidate.ID) - stableKey := fmt.Sprintf("oarc:%d:%s:%s", accountID, creditHash, cycleHash) - redeemRequestID := uuid.NewSHA1(uuid.NameSpaceURL, []byte(stableKey)).String() + return s.consumeOpenAIAutoResetCredit(ctx, accountID, candidate.ID, OpenAIAutoResetTriggerReasonUsageThreshold, nil, assessment, available, cycleHash) +} + +func (s *OpenAIQuotaAutoResetService) consumeOpenAIAutoResetCredit( + ctx context.Context, + accountID int64, + creditID string, + reason string, + target *OpenAIResetCreditExpiryTarget, + assessment openAIAutoResetAssessment, + available int, + cycleHash string, +) error { + now := time.Now().UTC() + assessment.triggerReason = reason + creditHash := shortOpenAIAutoResetHash(creditID) + operationID := openAIAutoResetOperationID(accountID, creditID) + claimKey := "oarc:" + operationID resetting := &OpenAIAutoResetCreditState{ Status: OpenAIAutoResetStatusResetting, + TriggerReason: reason, TriggerWindow: assessment.triggerWindow, AvailableCount: available, CheckedAt: now.UTC().Format(time.RFC3339), AttemptCycleHash: cycleHash, AttemptCreditHash: creditHash, } - if err := s.persistState(ctx, accountID, resetting); err != nil { + if reason == OpenAIAutoResetTriggerReasonExpiryTarget { + swapped, err := compareAndSwapAccountExtra(ctx, s.accountRepo, accountID, OpenAIAutoResetCreditExpiryTargetExtraKey, target, map[string]any{ + OpenAIAutoResetCreditStateExtraKey: resetting, + }) + if err != nil || !swapped { + return err + } + } else if err := s.persistState(ctx, accountID, resetting); err != nil { return err } - account, err = s.accountRepo.GetByID(ctx, accountID) - if err != nil || account == nil || !ResolveOpenAIAutoResetCreditConfig(account).Enabled { + account, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil || account == nil { return err } + if reason == OpenAIAutoResetTriggerReasonExpiryTarget { + currentTarget := ResolveOpenAIResetCreditExpiryTarget(account) + if !sameOpenAIResetCreditExpiryTarget(currentTarget, target) { + return nil + } + if expiredAt := time.Now().UTC(); openAIAutoResetExpiryTargetExpired(currentTarget, expiredAt) { + return s.finishOpenAIAutoResetExpiryTarget(ctx, accountID, currentTarget, resetting, "OPENAI_RESET_CREDIT_EXPIRED_UNUSED", expiredAt) + } + if !account.IsActive() || !account.Schedulable { + return s.finishOpenAIAutoResetExpiryTarget(ctx, accountID, currentTarget, resetting, "OPENAI_RESET_CREDIT_ACCOUNT_UNAVAILABLE", time.Now().UTC()) + } + } else if !account.IsActive() || !account.Schedulable || !ResolveOpenAIAutoResetCreditConfig(account).Enabled { + return nil + } result, err := s.idempotency.Execute(ctx, IdempotencyExecuteOptions{ Scope: "openai_auto_reset_credit", ActorScope: fmt.Sprintf("account:%d", accountID), Method: http.MethodPost, Route: "/system/openai/reset-credit/auto", - IdempotencyKey: stableKey, + IdempotencyKey: claimKey, Payload: map[string]any{ "account_id": accountID, "credit_hash": creditHash, - "cycle_hash": cycleHash, }, TTL: openAIAutoResetAttemptTTL, RequireKey: true, }, func(execCtx context.Context) (any, error) { - resetResult, resetErr := s.quota.ResetCreditTargeted(execCtx, accountID, candidate.ID, redeemRequestID) + resetResult, resetErr := s.quota.ResetCreditTargeted(execCtx, accountID, creditID, operationID) if resetErr != nil { return nil, resetErr } @@ -416,7 +497,7 @@ func (s *OpenAIQuotaAutoResetService) evaluateAccount(ctx context.Context, accou return openAIAutoResetConsumeResult{Code: resetResult.Code, WindowsReset: resetResult.WindowsReset}, nil }) if err != nil { - // 另一个实例已持有同一周期的兑换时保持 resetting,等待下一轮读取同一 + // 另一个实例已持有同一次兑换时保持 resetting,等待下一轮读取同一 // 幂等结果;不能把并发冲突误报成上游消费失败,更不能改选下一张卡。 reason := infraerrors.Reason(err) if reason == infraerrors.Reason(ErrIdempotencyInProgress) || reason == infraerrors.Reason(ErrIdempotencyRetryBackoff) { @@ -427,10 +508,52 @@ func (s *OpenAIQuotaAutoResetService) evaluateAccount(ctx context.Context, accou } consumeResult := decodeOpenAIAutoResetConsumeResult(result.Data) - if strings.EqualFold(strings.TrimSpace(consumeResult.Code), "no_credit") { + noCreditResult := strings.EqualFold(strings.TrimSpace(consumeResult.Code), "no_credit") + if result.Replayed || (!noCreditResult && consumeResult.WindowsReset <= 0) { + verifyCtx, cancelVerify := context.WithTimeout(context.WithoutCancel(ctx), 8*time.Second) + credits, creditPresent, verifyErr := s.queryOpenAIAutoResetCreditPresence(verifyCtx, accountID, creditID) + cancelVerify() + if verifyErr != nil { + code := infraerrors.Reason(verifyErr) + if credits != nil { + resetting.AvailableCount = credits.AvailableCount + } + s.recordAudit(accountID, assessment, resetting.AvailableCount, "failed", consumeResult.WindowsReset, code) + finalizeCtx, cancelFinalize := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second) + defer cancelFinalize() + return s.failStateKeepingMatchingExpiryTarget(finalizeCtx, accountID, target, resetting, code, verifyErr) + } + + failureCode := "" + switch { + case result.Replayed && creditPresent: + failureCode = openAIAutoResetReplayConflictCode + case consumeResult.WindowsReset <= 0 && creditPresent: + failureCode = openAIAutoResetNoEffectCode + } + if failureCode != "" { + failedAt := time.Now().UTC().Format(time.RFC3339) + failed := *resetting + failed.Status = OpenAIAutoResetStatusFailed + failed.AvailableCount = credits.AvailableCount + failed.CheckedAt = failedAt + failed.LastResultAt = failedAt + failed.ErrorCode = failureCode + finalizeCtx, cancelFinalize := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second) + defer cancelFinalize() + if err := s.persistStateClearingMatchingExpiryTarget(finalizeCtx, accountID, &failed, creditID, target); err != nil { + return err + } + s.recordAudit(accountID, assessment, credits.AvailableCount, "failed", consumeResult.WindowsReset, failureCode) + logOpenAIAutoResetFailure(accountID, &failed, failureCode) + return nil + } + } + if noCreditResult { noCreditAt := time.Now().UTC().Format(time.RFC3339) noCredit := &OpenAIAutoResetCreditState{ Status: OpenAIAutoResetStatusNoCredit, + TriggerReason: reason, TriggerWindow: assessment.triggerWindow, AvailableCount: 0, CheckedAt: noCreditAt, @@ -440,23 +563,31 @@ func (s *OpenAIQuotaAutoResetService) evaluateAccount(ctx context.Context, accou AttemptCreditHash: creditHash, } s.recordAudit(accountID, assessment, available, "no_credit", 0, noCredit.ErrorCode) - return s.persistState(ctx, accountID, noCredit) + finalizeCtx, cancelFinalize := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second) + defer cancelFinalize() + return s.persistStateClearingMatchingExpiryTarget(finalizeCtx, accountID, noCredit, creditID, target) } postCtx, cancelPost := context.WithTimeout(context.WithoutCancel(ctx), 8*time.Second) post := RunOpenAIQuotaResetPostProcess(postCtx, accountID, s.quota, s.recoverer, s.accountRepo.GetByID) cancelPost() + finalizeCtx, cancelFinalize := context.WithTimeout(context.WithoutCancel(ctx), 3*time.Second) + defer cancelFinalize() if !post.AccountStateRecovered || post.WarningCode != "" { code := post.WarningCode if code == "" { code = OpenAIQuotaResetWarningAccountRecoveryFailed } s.recordAudit(accountID, assessment, available, "recovery_failed", consumeResult.WindowsReset, code) - return s.failState(ctx, accountID, resetting, code, nil) + resetting.Status = OpenAIAutoResetStatusFailed + resetting.ErrorCode = code + resetting.LastResultAt = time.Now().UTC().Format(time.RFC3339) + return s.persistStateClearingMatchingExpiryTarget(finalizeCtx, accountID, resetting, creditID, target) } successAt := time.Now().UTC().Format(time.RFC3339) success := &OpenAIAutoResetCreditState{ Status: OpenAIAutoResetStatusSuccess, + TriggerReason: reason, TriggerWindow: assessment.triggerWindow, AvailableCount: max(0, available-1), CheckedAt: successAt, @@ -467,12 +598,13 @@ func (s *OpenAIQuotaAutoResetService) evaluateAccount(ctx context.Context, accou if post.Quota != nil && post.Quota.RateLimitResetCredits != nil { success.AvailableCount = post.Quota.RateLimitResetCredits.AvailableCount } - if err := s.persistState(ctx, accountID, success); err != nil { + if err := s.persistStateClearingMatchingExpiryTarget(finalizeCtx, accountID, success, creditID, target); err != nil { return err } s.recordAudit(accountID, assessment, available, "success", consumeResult.WindowsReset, "") slog.Info("openai_auto_reset_credit_success", "account_id", accountID, + "trigger_reason", reason, "trigger_window", assessment.triggerWindow, "threshold_5h", assessment.threshold5h, "threshold_7d", assessment.threshold7d, @@ -483,6 +615,33 @@ func (s *OpenAIQuotaAutoResetService) evaluateAccount(ctx context.Context, accou return nil } +func openAIAutoResetOperationID(accountID int64, creditID string) string { + return uuid.NewSHA1(uuid.NameSpaceURL, []byte(fmt.Sprintf("oarc:%d:%s", accountID, creditID))).String() +} + +func isOpenAIAutoResetTerminalAttemptFailure(state *OpenAIAutoResetCreditState) bool { + return state != nil && (state.ErrorCode == openAIAutoResetReplayConflictCode || state.ErrorCode == openAIAutoResetNoEffectCode) +} + +func (s *OpenAIQuotaAutoResetService) queryOpenAIAutoResetCreditPresence(ctx context.Context, accountID int64, creditID string) (*OpenAIRateLimitResetCredits, bool, error) { + usage, err := s.quota.QueryUsage(ctx, accountID) + if err != nil { + return nil, false, infraerrors.New(http.StatusBadGateway, "RESET_CREDIT_QUERY_FAILED", "failed to verify reset credit inventory").WithCause(err) + } + if usage == nil { + return nil, false, infraerrors.New(http.StatusBadGateway, "RESET_CREDIT_QUERY_FAILED", "reset credit inventory query returned no result") + } + credits := usage.RateLimitResetCredits + if credits == nil { + return nil, false, infraerrors.New(http.StatusBadGateway, "RESET_CREDIT_DETAILS_UNAVAILABLE", "reset credit details are unavailable") + } + if !completeOpenAIResetCreditSnapshot(credits) { + return credits, false, infraerrors.New(http.StatusBadGateway, "OPENAI_AUTO_RESET_CREDIT_DETAILS_INCOMPLETE", "reset credit details are incomplete") + } + _, present := findOpenAIResetCreditByID(credits, creditID) + return credits, present, nil +} + type openAIAutoResetConsumeResult struct { Code string `json:"code"` WindowsReset int `json:"windows_reset"` @@ -552,6 +711,36 @@ func joinOpenAIAutoResetWindows(fiveHour, sevenDay bool) string { } } +func sameOpenAIResetCreditExpiryTarget(left, right *OpenAIResetCreditExpiryTarget) bool { + return left != nil && right != nil && left.PlanID == right.PlanID +} + +func openAIAutoResetExpiryTargetExpired(target *OpenAIResetCreditExpiryTarget, now time.Time) bool { + if target == nil { + return false + } + expiresAt, err := time.Parse(time.RFC3339, target.ExpiresAt) + return err != nil || !expiresAt.After(now) +} + +func openAIAutoResetExpiryTargetInWindow(target *OpenAIResetCreditExpiryTarget, now time.Time) bool { + if target == nil { + return false + } + expiresAt, err := time.Parse(time.RFC3339, target.ExpiresAt) + if err != nil || !expiresAt.After(now) { + return false + } + return !now.Before(expiresAt.Add(-time.Duration(target.LeadTimeMinutes) * time.Minute)) +} + +func ensureOpenAIAutoResetState(state *OpenAIAutoResetCreditState) *OpenAIAutoResetCreditState { + if state == nil { + return &OpenAIAutoResetCreditState{} + } + return state +} + func buildOpenAIAutoResetUsageUpdates(usage *OpenAIQuotaUsage, now time.Time) map[string]any { if usage == nil || usage.RateLimit == nil { return nil @@ -590,19 +779,19 @@ func (s *OpenAIQuotaAutoResetService) persistFreshUsage(ctx context.Context, acc return s.quota.CacheResetCreditsSnapshot(ctx, accountID, usage.RateLimitResetCredits) } -func selectOpenAIAutoResetCandidate(candidates []openAIAutoResetCreditCandidate, available int, previous *OpenAIAutoResetCreditState, cycleHash string) (openAIAutoResetCreditCandidate, error) { +func selectOpenAIAutoResetCandidate(candidates []OpenAIRateLimitResetCreditDetail, available int, previous *OpenAIAutoResetCreditState, cycleHash string) (OpenAIRateLimitResetCreditDetail, error) { if available <= 0 { - return openAIAutoResetCreditCandidate{}, infraerrors.Conflict("OPENAI_AUTO_RESET_NO_CREDIT", "no reset credit is available") + return OpenAIRateLimitResetCreditDetail{}, infraerrors.Conflict("OPENAI_AUTO_RESET_NO_CREDIT", "no reset credit is available") } if len(candidates) < available { - return openAIAutoResetCreditCandidate{}, infraerrors.Conflict("OPENAI_AUTO_RESET_CREDIT_DETAILS_INCOMPLETE", "reset credit details are incomplete") + return OpenAIRateLimitResetCreditDetail{}, infraerrors.Conflict("OPENAI_AUTO_RESET_CREDIT_DETAILS_INCOMPLETE", "reset credit details are incomplete") } for _, candidate := range candidates { if _, err := time.Parse(time.RFC3339, candidate.ExpiresAt); err != nil { - return openAIAutoResetCreditCandidate{}, infraerrors.Conflict("OPENAI_AUTO_RESET_CREDIT_EXPIRY_INVALID", "reset credit expiration is invalid") + return OpenAIRateLimitResetCreditDetail{}, infraerrors.Conflict("OPENAI_AUTO_RESET_CREDIT_EXPIRY_INVALID", "reset credit expiration is invalid") } } - sorted := append([]openAIAutoResetCreditCandidate(nil), candidates...) + sorted := append([]OpenAIRateLimitResetCreditDetail(nil), candidates...) sort.SliceStable(sorted, func(i, j int) bool { left, leftErr := time.Parse(time.RFC3339, sorted[i].ExpiresAt) right, rightErr := time.Parse(time.RFC3339, sorted[j].ExpiresAt) @@ -623,10 +812,10 @@ func selectOpenAIAutoResetCandidate(candidates []openAIAutoResetCreditCandidate, return candidate, nil } } - return openAIAutoResetCreditCandidate{}, infraerrors.Conflict("OPENAI_AUTO_RESET_ORIGINAL_CREDIT_UNAVAILABLE", "the original reset credit cannot be confirmed; refusing to switch credits") + return OpenAIRateLimitResetCreditDetail{}, infraerrors.Conflict("OPENAI_AUTO_RESET_ORIGINAL_CREDIT_UNAVAILABLE", "the original reset credit cannot be confirmed; refusing to switch credits") } if len(sorted) == 0 || strings.TrimSpace(sorted[0].ID) == "" { - return openAIAutoResetCreditCandidate{}, infraerrors.Conflict("OPENAI_AUTO_RESET_CREDIT_ID_MISSING", "the earliest reset credit has no official id") + return OpenAIRateLimitResetCreditDetail{}, infraerrors.Conflict("OPENAI_AUTO_RESET_CREDIT_ID_MISSING", "the earliest reset credit has no official id") } return sorted[0], nil } @@ -719,6 +908,56 @@ func (s *OpenAIQuotaAutoResetService) persistState(ctx context.Context, accountI return s.accountRepo.UpdateExtra(ctx, accountID, map[string]any{OpenAIAutoResetCreditStateExtraKey: state}) } +func (s *OpenAIQuotaAutoResetService) finishOpenAIAutoResetExpiryTarget(ctx context.Context, accountID int64, target *OpenAIResetCreditExpiryTarget, state *OpenAIAutoResetCreditState, code string, now time.Time) error { + if target == nil { + return nil + } + state = ensureOpenAIAutoResetState(state) + state.Status = OpenAIAutoResetStatusFailed + state.TriggerReason = OpenAIAutoResetTriggerReasonExpiryTarget + state.TriggerWindow = "" + state.CheckedAt = now.UTC().Format(time.RFC3339) + state.LastResultAt = state.CheckedAt + state.ErrorCode = code + state.AttemptCycleHash = "" + state.AttemptCreditHash = "" + _, err := compareAndSwapAccountExtra(ctx, s.accountRepo, accountID, OpenAIAutoResetCreditExpiryTargetExtraKey, target, map[string]any{ + OpenAIAutoResetCreditExpiryTargetExtraKey: nil, + OpenAIAutoResetCreditStateExtraKey: state, + }) + return err +} + +func (s *OpenAIQuotaAutoResetService) persistStateClearingMatchingExpiryTarget( + ctx context.Context, + accountID int64, + state *OpenAIAutoResetCreditState, + creditID string, + target *OpenAIResetCreditExpiryTarget, +) error { + if state == nil { + return nil + } + if target == nil && strings.TrimSpace(creditID) != "" { + account, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil || account == nil { + return err + } + current := ResolveOpenAIResetCreditExpiryTarget(account) + if current != nil && current.CreditID == creditID { + target = current + } + } + if target == nil { + return s.persistState(ctx, accountID, state) + } + _, err := compareAndSwapAccountExtra(ctx, s.accountRepo, accountID, OpenAIAutoResetCreditExpiryTargetExtraKey, target, map[string]any{ + OpenAIAutoResetCreditExpiryTargetExtraKey: nil, + OpenAIAutoResetCreditStateExtraKey: state, + }) + return err +} + func (s *OpenAIQuotaAutoResetService) failState(ctx context.Context, accountID int64, state *OpenAIAutoResetCreditState, code string, cause error) error { if state == nil { state = &OpenAIAutoResetCreditState{} @@ -732,16 +971,50 @@ func (s *OpenAIQuotaAutoResetService) failState(ctx context.Context, accountID i if err := s.persistState(ctx, accountID, state); err != nil { return err } + logOpenAIAutoResetFailure(accountID, state, code) + if cause != nil { + return cause + } + return infraerrors.Conflict(code, "automatic reset credit operation failed") +} + +func (s *OpenAIQuotaAutoResetService) failStateKeepingMatchingExpiryTarget(ctx context.Context, accountID int64, target *OpenAIResetCreditExpiryTarget, state *OpenAIAutoResetCreditState, code string, cause error) error { + if target == nil { + return s.failState(ctx, accountID, state, code, cause) + } + if state == nil { + state = &OpenAIAutoResetCreditState{} + } + if strings.TrimSpace(code) == "" { + code = "OPENAI_AUTO_RESET_FAILED" + } + state.Status = OpenAIAutoResetStatusFailed + state.ErrorCode = code + state.LastResultAt = time.Now().UTC().Format(time.RFC3339) + swapped, err := compareAndSwapAccountExtra(ctx, s.accountRepo, accountID, OpenAIAutoResetCreditExpiryTargetExtraKey, target, map[string]any{ + OpenAIAutoResetCreditStateExtraKey: state, + }) + if err != nil { + return err + } + if !swapped { + return nil + } + logOpenAIAutoResetFailure(accountID, state, code) + if cause != nil { + return cause + } + return infraerrors.Conflict(code, "automatic reset credit operation failed") +} + +func logOpenAIAutoResetFailure(accountID int64, state *OpenAIAutoResetCreditState, code string) { slog.Warn("openai_auto_reset_credit_failed", "account_id", accountID, + "trigger_reason", state.TriggerReason, "trigger_window", state.TriggerWindow, "available_count", state.AvailableCount, "error_code", code, ) - if cause != nil { - return cause - } - return infraerrors.Conflict(code, "automatic reset credit operation failed") } func (s *OpenAIQuotaAutoResetService) recordAudit(accountID int64, assessment openAIAutoResetAssessment, available int, resultCode string, windowsReset int, errorCode string) { @@ -762,6 +1035,7 @@ func (s *OpenAIQuotaAutoResetService) recordAudit(accountID int64, assessment op StatusCode: statusCode, Extra: map[string]any{ "account_id": accountID, + "trigger_reason": assessment.triggerReason, "trigger_window": assessment.triggerWindow, "threshold_5h": assessment.threshold5h, "threshold_7d": assessment.threshold7d, diff --git a/backend/internal/service/openai_quota_auto_reset_config.go b/backend/internal/service/openai_quota_auto_reset_config.go index e89104979546..0f08e95d6350 100644 --- a/backend/internal/service/openai_quota_auto_reset_config.go +++ b/backend/internal/service/openai_quota_auto_reset_config.go @@ -11,10 +11,11 @@ import ( ) const ( - OpenAIAutoResetCreditEnabledExtraKey = "auto_reset_credit_enabled" - OpenAIAutoResetCredit5hThresholdExtraKey = "auto_reset_credit_5h_threshold" - OpenAIAutoResetCredit7dThresholdExtraKey = "auto_reset_credit_7d_threshold" - OpenAIAutoResetCreditStateExtraKey = "codex_auto_reset_credit_state" + OpenAIAutoResetCreditEnabledExtraKey = "auto_reset_credit_enabled" + OpenAIAutoResetCredit5hThresholdExtraKey = "auto_reset_credit_5h_threshold" + OpenAIAutoResetCredit7dThresholdExtraKey = "auto_reset_credit_7d_threshold" + OpenAIAutoResetCreditStateExtraKey = "codex_auto_reset_credit_state" + OpenAIAutoResetCreditExpiryTargetExtraKey = "auto_reset_credit_expiry_target" openAIAutoResetCreditDefaultThreshold = 1.0 openAIAutoResetCreditMinimumThreshold = 0.001 @@ -60,6 +61,7 @@ func normalizeOpenAIAutoResetCreditExtra(platform, accountType string, isShadow } normalized := cloneOpenAIAutoResetExtra(extra) delete(normalized, OpenAIAutoResetCreditStateExtraKey) + delete(normalized, OpenAIAutoResetCreditExpiryTargetExtraKey) _, hasEnabled := normalized[OpenAIAutoResetCreditEnabledExtraKey] _, has5h := normalized[OpenAIAutoResetCredit5hThresholdExtraKey] @@ -103,6 +105,7 @@ func stripOpenAIAutoResetCreditManagedExtra(extra map[string]any, stripConfig bo return nil } delete(extra, OpenAIAutoResetCreditStateExtraKey) + delete(extra, OpenAIAutoResetCreditExpiryTargetExtraKey) if stripConfig { delete(extra, OpenAIAutoResetCreditEnabledExtraKey) delete(extra, OpenAIAutoResetCredit5hThresholdExtraKey) diff --git a/backend/internal/service/openai_quota_auto_reset_test.go b/backend/internal/service/openai_quota_auto_reset_test.go index ad2dc965aa86..9b1dbe0ca513 100644 --- a/backend/internal/service/openai_quota_auto_reset_test.go +++ b/backend/internal/service/openai_quota_auto_reset_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/google/uuid" "github.com/stretchr/testify/require" ) @@ -24,11 +25,15 @@ func TestNormalizeOpenAIAutoResetCreditExtra(t *testing.T) { extra, err := normalizeOpenAIAutoResetCreditExtra(PlatformOpenAI, AccountTypeOAuth, false, map[string]any{ OpenAIAutoResetCreditEnabledExtraKey: true, OpenAIAutoResetCreditStateExtraKey: map[string]any{"status": "success"}, + OpenAIAutoResetCreditExpiryTargetExtraKey: map[string]any{ + "credit_id": "forged", + }, }) require.NoError(t, err) require.Equal(t, 1.0, extra[OpenAIAutoResetCredit5hThresholdExtraKey]) require.Equal(t, 1.0, extra[OpenAIAutoResetCredit7dThresholdExtraKey]) require.NotContains(t, extra, OpenAIAutoResetCreditStateExtraKey) + require.NotContains(t, extra, OpenAIAutoResetCreditExpiryTargetExtraKey) }) t.Run("阈值和账号类型严格校验", func(t *testing.T) { @@ -101,7 +106,7 @@ func TestShouldAutoPauseOpenAIAccountByQuota_AutoResetCreditStates(t *testing.T) } func TestSelectOpenAIAutoResetCandidate_FailsClosed(t *testing.T) { - candidates := []openAIAutoResetCreditCandidate{ + candidates := []OpenAIRateLimitResetCreditDetail{ {ID: "later", ExpiresAt: "2026-09-02T00:00:00Z"}, {ID: "earlier", ExpiresAt: "2026-09-01T00:00:00Z"}, } @@ -109,7 +114,7 @@ func TestSelectOpenAIAutoResetCandidate_FailsClosed(t *testing.T) { require.NoError(t, err) require.Equal(t, "earlier", selected.ID) - _, err = selectOpenAIAutoResetCandidate([]openAIAutoResetCreditCandidate{ + _, err = selectOpenAIAutoResetCandidate([]OpenAIRateLimitResetCreditDetail{ {ExpiresAt: "2026-09-01T00:00:00Z"}, }, 1, nil, "cycle-a") require.Error(t, err) @@ -172,23 +177,67 @@ func (r *autoResetTestAccountRepo) UpdateExtra(_ context.Context, id int64, upda return nil } +func (r *autoResetTestAccountRepo) CompareAndSwapExtra(_ context.Context, _ int64, key string, expected any, updates map[string]any) (bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + var current any + if r.account.Extra != nil { + current = r.account.Extra[key] + } + currentJSON, _ := json.Marshal(current) + expectedJSON, _ := json.Marshal(expected) + if string(currentJSON) != string(expectedJSON) { + return false, nil + } + if r.account.Extra == nil { + r.account.Extra = make(map[string]any) + } + for updateKey, value := range updates { + r.account.Extra[updateKey] = value + } + return true, nil +} + type autoResetTestQuota struct { - usage *OpenAIQuotaUsage - resetCalls atomic.Int32 - resetEntered chan struct{} - releaseReset chan struct{} - enterOnce sync.Once - mu sync.Mutex - resetArgs [][2]string - failFirst bool + usage *OpenAIQuotaUsage + usageErr error + resetResult *OpenAIQuotaResetResult + usageQueryCalls atomic.Int32 + cacheCalls atomic.Int32 + resetCalls atomic.Int32 + resetEntered chan struct{} + releaseReset chan struct{} + enterOnce sync.Once + mu sync.Mutex + resetArgs [][2]string + failFirst bool } func (q *autoResetTestQuota) QueryUsage(context.Context, int64) (*OpenAIQuotaUsage, error) { + q.usageQueryCalls.Add(1) + return q.queryResult() +} + +func (q *autoResetTestQuota) queryResult() (*OpenAIQuotaUsage, error) { + q.mu.Lock() + defer q.mu.Unlock() + if q.usageErr != nil { + return nil, q.usageErr + } + if q.usage == nil { + return nil, nil + } copy := *q.usage + if q.usage.RateLimitResetCredits != nil { + credits := *q.usage.RateLimitResetCredits + credits.Credits = append([]OpenAIRateLimitResetCreditDetail(nil), credits.Credits...) + copy.RateLimitResetCredits = &credits + } return ©, nil } func (q *autoResetTestQuota) CacheResetCreditsSnapshot(context.Context, int64, *OpenAIRateLimitResetCredits) error { + q.cacheCalls.Add(1) return nil } @@ -199,6 +248,7 @@ func (q *autoResetTestQuota) ResetCreditTargeted(_ context.Context, _ int64, cre call := q.resetCalls.Add(1) q.mu.Lock() q.resetArgs = append(q.resetArgs, [2]string{creditID, redeemRequestID}) + resetResult := q.resetResult q.mu.Unlock() if q.failFirst && call == 1 { return nil, context.DeadlineExceeded @@ -209,6 +259,10 @@ func (q *autoResetTestQuota) ResetCreditTargeted(_ context.Context, _ int64, cre if q.releaseReset != nil { <-q.releaseReset } + if resetResult != nil { + copy := *resetResult + return ©, nil + } return &OpenAIQuotaResetResult{Code: "ok", WindowsReset: 2}, nil } @@ -218,6 +272,60 @@ func (autoResetTestRecoverer) RecoverAccountState(context.Context, int64, Accoun return &SuccessfulTestRecoveryResult{ClearedRateLimit: true}, nil } +func newAutoResetTestService(repo AccountRepository, quota openAIAutoResetQuota) *OpenAIQuotaAutoResetService { + config := DefaultIdempotencyConfig() + config.ObserveOnly = false + return NewOpenAIQuotaAutoResetService(repo, quota, autoResetTestRecoverer{}, NewIdempotencyCoordinator(newInMemoryIdempotencyRepo(), config), nil, nil, nil) +} + +func newExpiryTargetTestAccount(id int64, creditID, expiresAt string, leadTimeMinutes int, now time.Time) *Account { + return &Account{ + ID: id, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Schedulable: true, + Extra: map[string]any{ + OpenAIAutoResetCreditExpiryTargetExtraKey: OpenAIResetCreditExpiryTarget{ + PlanID: uuid.NewString(), CreditID: creditID, ExpiresAt: expiresAt, LeadTimeMinutes: leadTimeMinutes, + }, + "codex_usage_updated_at": now.Add(-openAIAutoResetSnapshotTTL - time.Minute).Format(time.RFC3339), + }, + } +} + +func newExpiryTargetTestUsage(now time.Time, usedPercent float64, credits ...OpenAIRateLimitResetCreditDetail) *OpenAIQuotaUsage { + return &OpenAIQuotaUsage{ + FetchedAt: now.Unix(), + RateLimit: &OpenAIRateLimit{PrimaryWindow: &OpenAIRateLimitWindow{ + UsedPercent: usedPercent, LimitWindowSeconds: 5 * 60 * 60, + ResetAfterSeconds: 3600, ResetAt: now.Add(time.Hour).Unix(), + }}, + RateLimitResetCredits: &OpenAIRateLimitResetCredits{AvailableCount: len(credits), Credits: credits}, + } +} + +func readAutoResetTestState(repo *autoResetTestAccountRepo) (*OpenAIAutoResetCreditState, *OpenAIResetCreditExpiryTarget) { + repo.mu.Lock() + defer repo.mu.Unlock() + return openAIAutoResetStateFromExtra(repo.account.Extra), ResolveOpenAIResetCreditExpiryTarget(repo.account) +} + +func newExpiryTargetTestFixture(account *Account, usage *OpenAIQuotaUsage) (*autoResetTestAccountRepo, *autoResetTestQuota, *OpenAIQuotaAutoResetService) { + repo := &autoResetTestAccountRepo{account: account} + quota := &autoResetTestQuota{usage: usage} + return repo, quota, newAutoResetTestService(repo, quota) +} + +func enableAutoResetThreshold(account *Account) { + account.Extra[OpenAIAutoResetCreditEnabledExtraKey] = true + account.Extra[OpenAIAutoResetCredit5hThresholdExtraKey] = 0.5 + account.Extra[OpenAIAutoResetCredit7dThresholdExtraKey] = 1.0 +} + +func autoResetTestResetArgs(quota *autoResetTestQuota) [][2]string { + quota.mu.Lock() + defer quota.mu.Unlock() + return append([][2]string(nil), quota.resetArgs...) +} + func TestOpenAIQuotaAutoResetService_ConcurrentInstancesConsumeOnce(t *testing.T) { now := time.Now().UTC() account := &Account{ @@ -243,9 +351,8 @@ func TestOpenAIQuotaAutoResetService_ConcurrentInstancesConsumeOnce(t *testing.T }, RateLimitResetCredits: &OpenAIRateLimitResetCredits{ AvailableCount: 1, - Credits: []OpenAIRateLimitResetCreditDetail{{ExpiresAt: now.Add(48 * time.Hour).Format(time.RFC3339)}}, + Credits: []OpenAIRateLimitResetCreditDetail{{ID: "credit-sensitive-id", ExpiresAt: now.Add(48 * time.Hour).Format(time.RFC3339)}}, }, - autoResetCandidates: []openAIAutoResetCreditCandidate{{ID: "credit-sensitive-id", ExpiresAt: now.Add(48 * time.Hour).Format(time.RFC3339)}}, } quota := &autoResetTestQuota{usage: usage, resetEntered: make(chan struct{}), releaseReset: make(chan struct{})} idempotencyRepo := newInMemoryIdempotencyRepo() @@ -306,9 +413,8 @@ func TestOpenAIQuotaAutoResetService_TimeoutRetryReusesRequestBody(t *testing.T) }, RateLimitResetCredits: &OpenAIRateLimitResetCredits{ AvailableCount: 1, - Credits: []OpenAIRateLimitResetCreditDetail{{ExpiresAt: expiresAt}}, + Credits: []OpenAIRateLimitResetCreditDetail{{ID: "retry-credit", ExpiresAt: expiresAt}}, }, - autoResetCandidates: []openAIAutoResetCreditCandidate{{ID: "retry-credit", ExpiresAt: expiresAt}}, }, } idempotencyConfig := DefaultIdempotencyConfig() @@ -330,3 +436,411 @@ func TestOpenAIQuotaAutoResetService_TimeoutRetryReusesRequestBody(t *testing.T) require.Len(t, args, 2) require.Equal(t, args[0], args[1], "超时重试必须复用相同 credit_id 与 redeem_request_id") } + +func TestOpenAIQuotaAutoResetService_MissingCreditDetailsAreNotReportedAsWriteFailure(t *testing.T) { + now := time.Now().UTC() + account := &Account{ + ID: 126, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Schedulable: true, + Extra: map[string]any{ + OpenAIAutoResetCreditEnabledExtraKey: true, + OpenAIAutoResetCredit5hThresholdExtraKey: 0.5, + OpenAIAutoResetCredit7dThresholdExtraKey: 1.0, + "codex_5h_used_percent": 75.0, + "codex_usage_updated_at": now.Format(time.RFC3339), + }, + } + repo := &autoResetTestAccountRepo{account: account} + quota := &autoResetTestQuota{usage: &OpenAIQuotaUsage{ + FetchedAt: now.Unix(), + RateLimit: &OpenAIRateLimit{PrimaryWindow: &OpenAIRateLimitWindow{ + UsedPercent: 75, LimitWindowSeconds: 5 * 60 * 60, ResetAt: now.Add(time.Hour).Unix(), + }}, + }} + + require.Error(t, newAutoResetTestService(repo, quota).evaluateAccount(context.Background(), account.ID)) + state, _ := readAutoResetTestState(repo) + require.Equal(t, "RESET_CREDIT_DETAILS_UNAVAILABLE", state.ErrorCode) + require.Zero(t, quota.cacheCalls.Load()) + require.Zero(t, quota.resetCalls.Load()) +} + +func TestOpenAIQuotaAutoResetService_ExpiryTargetWaitsUntilScheduledTime(t *testing.T) { + now := time.Now().UTC() + expiresAt := now.Add(2 * time.Hour).Format(time.RFC3339) + creditID := "future-expiry-target" + account := newExpiryTargetTestAccount(121, creditID, expiresAt, 60, now) + repo := &autoResetTestAccountRepo{account: account} + quota := &autoResetTestQuota{} + svc := newAutoResetTestService(repo, quota) + + require.NoError(t, svc.evaluateAccount(context.Background(), account.ID)) + require.Zero(t, quota.usageQueryCalls.Load()) + require.Zero(t, quota.resetCalls.Load()) + _, target := readAutoResetTestState(repo) + require.NotNil(t, target) +} + +func TestOpenAIQuotaAutoResetService_ExpiryTargetWinsWhenThresholdAlsoTriggers(t *testing.T) { + now := time.Now().UTC() + targetExpiry := now.Add(48 * time.Hour).Format(time.RFC3339) + earlierExpiry := now.Add(24 * time.Hour).Format(time.RFC3339) + targetID := "explicit-expiry-target" + account := newExpiryTargetTestAccount(106, targetID, targetExpiry, 3*24*60, now) + enableAutoResetThreshold(account) + repo := &autoResetTestAccountRepo{account: account} + quota := &autoResetTestQuota{usage: newExpiryTargetTestUsage(now, 75, + OpenAIRateLimitResetCreditDetail{ID: "earlier-threshold-credit", ExpiresAt: earlierExpiry}, + OpenAIRateLimitResetCreditDetail{ID: targetID, ExpiresAt: targetExpiry}, + )} + svc := newAutoResetTestService(repo, quota) + + require.NoError(t, svc.evaluateAccount(context.Background(), account.ID)) + require.Equal(t, int32(1), quota.usageQueryCalls.Load(), "临期计划直接执行,只在消费后公共收尾回读一次") + require.Equal(t, targetID, autoResetTestResetArgs(quota)[0][0]) +} + +func TestOpenAIQuotaAutoResetService_ThresholdCanConsumePlannedCreditBeforeExpiryWindow(t *testing.T) { + now := time.Now().UTC() + targetID := "planned-credit" + targetExpiry := now.Add(72 * time.Hour).Format(time.RFC3339) + account := newExpiryTargetTestAccount(110, targetID, targetExpiry, 60, now) + enableAutoResetThreshold(account) + repo, quota, svc := newExpiryTargetTestFixture(account, newExpiryTargetTestUsage(now, 75, + OpenAIRateLimitResetCreditDetail{ID: targetID, ExpiresAt: targetExpiry}, + OpenAIRateLimitResetCreditDetail{ID: "later-credit", ExpiresAt: now.Add(96 * time.Hour).Format(time.RFC3339)}, + )) + + require.NoError(t, svc.evaluateAccount(context.Background(), account.ID)) + require.Equal(t, targetID, autoResetTestResetArgs(quota)[0][0]) + _, target := readAutoResetTestState(repo) + require.Nil(t, target) +} + +func TestOpenAIQuotaAutoResetService_ExpiryTargetConsumesExactCreditWithoutUsageDependency(t *testing.T) { + now := time.Now().UTC() + expiresAt := now.Add(30 * time.Minute).Format(time.RFC3339) + creditID := "unused-expiry-target" + account := newExpiryTargetTestAccount(102, creditID, expiresAt, OpenAIResetCreditExpiryTargetDefaultLeadTimeMinutes, now) + account.Extra["codex_usage_updated_at"] = now.Format(time.RFC3339) + account.Extra["codex_5h_used_percent"] = 0.0 + repo, quota, svc := newExpiryTargetTestFixture(account, newExpiryTargetTestUsage(now, 0, + OpenAIRateLimitResetCreditDetail{ID: "other-credit", ExpiresAt: now.Add(10 * time.Minute).Format(time.RFC3339)}, + OpenAIRateLimitResetCreditDetail{ID: creditID, ExpiresAt: expiresAt}, + )) + + require.NoError(t, svc.evaluateAccount(context.Background(), account.ID)) + require.Equal(t, int32(1), quota.usageQueryCalls.Load(), "纯定时执行前不得请求完整用量,消费后公共收尾仍回读一次") + require.Equal(t, int32(1), quota.cacheCalls.Load(), "执行前不写快照,消费后公共收尾回写一次") + require.Equal(t, int32(1), quota.resetCalls.Load()) + require.Equal(t, creditID, autoResetTestResetArgs(quota)[0][0]) + state, target := readAutoResetTestState(repo) + require.Nil(t, target) + require.Equal(t, OpenAIAutoResetStatusSuccess, state.Status) + require.Equal(t, OpenAIAutoResetTriggerReasonExpiryTarget, state.TriggerReason) + require.Empty(t, state.ErrorCode) +} + +func TestOpenAIQuotaAutoResetService_ReplayedCreditStillAvailableFails(t *testing.T) { + now := time.Now().UTC() + expiresAt := now.Add(30 * time.Minute).Format(time.RFC3339) + creditID := "replayed-still-available" + account := newExpiryTargetTestAccount(128, creditID, expiresAt, OpenAIResetCreditExpiryTargetDefaultLeadTimeMinutes, now) + repo, quota, svc := newExpiryTargetTestFixture(account, newExpiryTargetTestUsage(now, 0, + OpenAIRateLimitResetCreditDetail{ID: creditID, ExpiresAt: expiresAt}, + )) + + require.NoError(t, svc.evaluateAccount(context.Background(), account.ID)) + require.NoError(t, repo.UpdateExtra(context.Background(), account.ID, map[string]any{ + OpenAIAutoResetCreditExpiryTargetExtraKey: OpenAIResetCreditExpiryTarget{ + PlanID: uuid.NewString(), CreditID: creditID, ExpiresAt: expiresAt, + LeadTimeMinutes: OpenAIResetCreditExpiryTargetDefaultLeadTimeMinutes, + }, + })) + + require.NoError(t, svc.evaluateAccount(context.Background(), account.ID)) + state, target := readAutoResetTestState(repo) + require.Nil(t, target) + require.Equal(t, OpenAIAutoResetStatusFailed, state.Status) + require.Equal(t, openAIAutoResetReplayConflictCode, state.ErrorCode) + require.Equal(t, 1, state.AvailableCount) + require.Equal(t, int32(1), quota.resetCalls.Load(), "历史回放不得再次调用上游") + require.Equal(t, int32(2), quota.usageQueryCalls.Load(), "回放必须先额外核验实时库存") +} + +func TestOpenAIQuotaAutoResetService_ReplayedMissingCreditCompletesFinalization(t *testing.T) { + now := time.Now().UTC() + expiresAt := now.Add(30 * time.Minute).Format(time.RFC3339) + creditID := "replayed-consumed-credit" + account := newExpiryTargetTestAccount(129, creditID, expiresAt, OpenAIResetCreditExpiryTargetDefaultLeadTimeMinutes, now) + repo, quota, svc := newExpiryTargetTestFixture(account, newExpiryTargetTestUsage(now, 0, + OpenAIRateLimitResetCreditDetail{ID: creditID, ExpiresAt: expiresAt}, + )) + + require.NoError(t, svc.evaluateAccount(context.Background(), account.ID)) + quota.mu.Lock() + quota.usage = newExpiryTargetTestUsage(now, 0) + quota.mu.Unlock() + require.NoError(t, repo.UpdateExtra(context.Background(), account.ID, map[string]any{ + OpenAIAutoResetCreditExpiryTargetExtraKey: OpenAIResetCreditExpiryTarget{ + PlanID: uuid.NewString(), CreditID: creditID, ExpiresAt: expiresAt, + LeadTimeMinutes: OpenAIResetCreditExpiryTargetDefaultLeadTimeMinutes, + }, + })) + + require.NoError(t, svc.evaluateAccount(context.Background(), account.ID)) + state, target := readAutoResetTestState(repo) + require.Nil(t, target) + require.Equal(t, OpenAIAutoResetStatusSuccess, state.Status) + require.Empty(t, state.ErrorCode) + require.Zero(t, state.AvailableCount) + require.Equal(t, int32(1), quota.resetCalls.Load()) + require.Equal(t, int32(3), quota.usageQueryCalls.Load(), "回放核验后仍执行公共收尾") +} + +func TestOpenAIQuotaAutoResetService_ReplayVerificationFailureKeepsPlan(t *testing.T) { + now := time.Now().UTC() + expiresAt := now.Add(30 * time.Minute).Format(time.RFC3339) + creditID := "replay-unverified-credit" + account := newExpiryTargetTestAccount(130, creditID, expiresAt, OpenAIResetCreditExpiryTargetDefaultLeadTimeMinutes, now) + repo, quota, svc := newExpiryTargetTestFixture(account, newExpiryTargetTestUsage(now, 0, + OpenAIRateLimitResetCreditDetail{ID: creditID, ExpiresAt: expiresAt}, + )) + + require.NoError(t, svc.evaluateAccount(context.Background(), account.ID)) + replacement := OpenAIResetCreditExpiryTarget{ + PlanID: uuid.NewString(), CreditID: creditID, ExpiresAt: expiresAt, + LeadTimeMinutes: OpenAIResetCreditExpiryTargetDefaultLeadTimeMinutes, + } + require.NoError(t, repo.UpdateExtra(context.Background(), account.ID, map[string]any{ + OpenAIAutoResetCreditExpiryTargetExtraKey: replacement, + })) + quota.mu.Lock() + quota.usage = &OpenAIQuotaUsage{ + FetchedAt: now.Unix(), + RateLimitResetCredits: &OpenAIRateLimitResetCredits{AvailableCount: 1}, + } + quota.mu.Unlock() + + require.Error(t, svc.evaluateAccount(context.Background(), account.ID)) + state, target := readAutoResetTestState(repo) + require.Equal(t, &replacement, target) + require.Equal(t, OpenAIAutoResetStatusFailed, state.Status) + require.Equal(t, "OPENAI_AUTO_RESET_CREDIT_DETAILS_INCOMPLETE", state.ErrorCode) + require.Equal(t, int32(1), quota.resetCalls.Load()) +} + +func TestOpenAIQuotaAutoResetService_ReplayVerificationQueryFailureKeepsPlan(t *testing.T) { + now := time.Now().UTC() + expiresAt := now.Add(30 * time.Minute).Format(time.RFC3339) + creditID := "replay-query-failed-credit" + account := newExpiryTargetTestAccount(133, creditID, expiresAt, OpenAIResetCreditExpiryTargetDefaultLeadTimeMinutes, now) + repo, quota, svc := newExpiryTargetTestFixture(account, newExpiryTargetTestUsage(now, 0, + OpenAIRateLimitResetCreditDetail{ID: creditID, ExpiresAt: expiresAt}, + )) + + require.NoError(t, svc.evaluateAccount(context.Background(), account.ID)) + replacement := OpenAIResetCreditExpiryTarget{ + PlanID: uuid.NewString(), CreditID: creditID, ExpiresAt: expiresAt, + LeadTimeMinutes: OpenAIResetCreditExpiryTargetDefaultLeadTimeMinutes, + } + require.NoError(t, repo.UpdateExtra(context.Background(), account.ID, map[string]any{ + OpenAIAutoResetCreditExpiryTargetExtraKey: replacement, + })) + quota.mu.Lock() + quota.usageErr = context.DeadlineExceeded + quota.mu.Unlock() + + require.Error(t, svc.evaluateAccount(context.Background(), account.ID)) + state, target := readAutoResetTestState(repo) + require.Equal(t, &replacement, target) + require.Equal(t, OpenAIAutoResetStatusFailed, state.Status) + require.Equal(t, "RESET_CREDIT_QUERY_FAILED", state.ErrorCode) + require.Equal(t, int32(1), quota.resetCalls.Load()) +} + +func TestOpenAIQuotaAutoResetService_ZeroWindowResultUsesInventory(t *testing.T) { + now := time.Now().UTC() + expiresAt := now.Add(30 * time.Minute).Format(time.RFC3339) + + t.Run("卡仍存在时不得成功", func(t *testing.T) { + creditID := "zero-window-still-present" + account := newExpiryTargetTestAccount(131, creditID, expiresAt, OpenAIResetCreditExpiryTargetDefaultLeadTimeMinutes, now) + repo, quota, svc := newExpiryTargetTestFixture(account, newExpiryTargetTestUsage(now, 0, + OpenAIRateLimitResetCreditDetail{ID: creditID, ExpiresAt: expiresAt}, + )) + quota.resetResult = &OpenAIQuotaResetResult{Code: "ok", WindowsReset: 0} + + require.NoError(t, svc.evaluateAccount(context.Background(), account.ID)) + state, target := readAutoResetTestState(repo) + require.Nil(t, target) + require.Equal(t, OpenAIAutoResetStatusFailed, state.Status) + require.Equal(t, openAIAutoResetNoEffectCode, state.ErrorCode) + require.Equal(t, 1, state.AvailableCount) + }) + + t.Run("卡已消失时允许成功", func(t *testing.T) { + creditID := "zero-window-consumed" + account := newExpiryTargetTestAccount(132, creditID, expiresAt, OpenAIResetCreditExpiryTargetDefaultLeadTimeMinutes, now) + repo, quota, svc := newExpiryTargetTestFixture(account, newExpiryTargetTestUsage(now, 0)) + quota.resetResult = &OpenAIQuotaResetResult{Code: "ok", WindowsReset: 0} + + require.NoError(t, svc.evaluateAccount(context.Background(), account.ID)) + state, target := readAutoResetTestState(repo) + require.Nil(t, target) + require.Equal(t, OpenAIAutoResetStatusSuccess, state.Status) + require.Empty(t, state.ErrorCode) + require.Zero(t, state.AvailableCount) + require.Equal(t, int32(2), quota.usageQueryCalls.Load()) + }) +} + +func TestOpenAIQuotaAutoResetService_TerminalReplayFailureDoesNotRepeatWithinCycle(t *testing.T) { + now := time.Now().UTC() + creditID := "threshold-replay-conflict" + expiresAt := now.Add(24 * time.Hour).Format(time.RFC3339) + account := &Account{ + ID: 134, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Schedulable: true, + Extra: map[string]any{ + OpenAIAutoResetCreditEnabledExtraKey: true, + OpenAIAutoResetCredit5hThresholdExtraKey: 0.5, + OpenAIAutoResetCredit7dThresholdExtraKey: 1.0, + "codex_5h_used_percent": 75.0, + "codex_usage_updated_at": now.Format(time.RFC3339), + "codex_5h_reset_at": now.Add(time.Hour).Format(time.RFC3339), + }, + } + repo, quota, svc := newExpiryTargetTestFixture(account, newExpiryTargetTestUsage(now, 75, + OpenAIRateLimitResetCreditDetail{ID: creditID, ExpiresAt: expiresAt}, + )) + + require.NoError(t, svc.evaluateAccount(context.Background(), account.ID)) + require.NoError(t, svc.evaluateAccount(context.Background(), account.ID)) + state, _ := readAutoResetTestState(repo) + require.Equal(t, OpenAIAutoResetStatusFailed, state.Status) + require.Equal(t, openAIAutoResetReplayConflictCode, state.ErrorCode) + require.Equal(t, int32(1), quota.resetCalls.Load()) + require.Equal(t, int32(4), quota.usageQueryCalls.Load()) + + require.NoError(t, svc.evaluateAccount(context.Background(), account.ID)) + state, _ = readAutoResetTestState(repo) + require.Equal(t, OpenAIAutoResetStatusFailed, state.Status) + require.Equal(t, openAIAutoResetReplayConflictCode, state.ErrorCode) + require.Equal(t, int32(1), quota.resetCalls.Load()) + require.Equal(t, int32(5), quota.usageQueryCalls.Load(), "同一周期只刷新一次库存,不再重复应用历史结果") +} + +func TestOpenAIQuotaAutoResetService_StaleExpiryWorkerCannotOverwriteReplacementState(t *testing.T) { + now := time.Now().UTC() + oldTarget := &OpenAIResetCreditExpiryTarget{ + PlanID: uuid.NewString(), CreditID: "old-credit", + ExpiresAt: now.Add(time.Hour).Format(time.RFC3339), LeadTimeMinutes: 60, + } + replacement := OpenAIResetCreditExpiryTarget{ + PlanID: uuid.NewString(), CreditID: "replacement-credit", + ExpiresAt: now.Add(2 * time.Hour).Format(time.RFC3339), LeadTimeMinutes: 60, + } + existingState := OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusAvailable, AvailableCount: 2, + CheckedAt: now.Format(time.RFC3339), + } + account := &Account{ + ID: 127, Platform: PlatformOpenAI, Type: AccountTypeOAuth, + Status: StatusActive, Schedulable: true, + Extra: map[string]any{ + OpenAIAutoResetCreditExpiryTargetExtraKey: replacement, + OpenAIAutoResetCreditStateExtraKey: existingState, + }, + } + repo, quota, svc := newExpiryTargetTestFixture(account, nil) + + require.NoError(t, svc.consumeOpenAIAutoResetCredit( + context.Background(), account.ID, oldTarget.CreditID, + OpenAIAutoResetTriggerReasonExpiryTarget, oldTarget, + openAIAutoResetAssessment{}, existingState.AvailableCount, "", + )) + + state, target := readAutoResetTestState(repo) + require.Equal(t, &replacement, target) + require.Equal(t, &existingState, state) + require.Zero(t, quota.resetCalls.Load()) +} + +func TestOpenAIQuotaAutoResetService_ExpiryTargetRetryKeepsRedeemIDAcrossQuotaCycles(t *testing.T) { + now := time.Now().UTC() + expiresAt := now.Add(30 * time.Minute).Format(time.RFC3339) + creditID := "expiry-retry-target" + account := newExpiryTargetTestAccount(123, creditID, expiresAt, OpenAIResetCreditExpiryTargetDefaultLeadTimeMinutes, now) + repo := &autoResetTestAccountRepo{account: account} + quota := &autoResetTestQuota{ + failFirst: true, + usage: newExpiryTargetTestUsage(now, 0, OpenAIRateLimitResetCreditDetail{ID: creditID, ExpiresAt: expiresAt}), + } + idempotencyConfig := DefaultIdempotencyConfig() + idempotencyConfig.ObserveOnly = false + idempotencyConfig.FailedRetryBackoff = 0 + svc := NewOpenAIQuotaAutoResetService( + repo, + quota, + autoResetTestRecoverer{}, + NewIdempotencyCoordinator(newInMemoryIdempotencyRepo(), idempotencyConfig), + nil, nil, nil, + ) + + require.ErrorIs(t, svc.evaluateAccount(context.Background(), account.ID), context.DeadlineExceeded) + quota.usage.RateLimit.PrimaryWindow.ResetAt = now.Add(2 * time.Hour).Unix() + require.NoError(t, svc.evaluateAccount(context.Background(), account.ID)) + args := autoResetTestResetArgs(quota) + require.Len(t, args, 2) + require.Equal(t, args[0], args[1], "定时计划跨配额周期重试仍须复用相同 redeem_request_id") +} + +func TestOpenAIQuotaAutoResetService_ExpiryTargetEndsExpiredPlan(t *testing.T) { + now := time.Now().UTC() + account := newExpiryTargetTestAccount(104, "expired-credit", now.Add(-time.Minute).Format(time.RFC3339), OpenAIResetCreditExpiryTargetDefaultLeadTimeMinutes, now) + repo, quota, svc := newExpiryTargetTestFixture(account, nil) + + require.NoError(t, svc.evaluateAccount(context.Background(), account.ID)) + require.Zero(t, quota.usageQueryCalls.Load()) + require.Zero(t, quota.resetCalls.Load()) + state, target := readAutoResetTestState(repo) + require.Nil(t, target) + require.Equal(t, OpenAIAutoResetStatusFailed, state.Status) + require.Equal(t, OpenAIAutoResetTriggerReasonExpiryTarget, state.TriggerReason) + require.Equal(t, "OPENAI_RESET_CREDIT_EXPIRED_UNUSED", state.ErrorCode) + require.NotEmpty(t, state.LastResultAt) +} + +func TestOpenAIQuotaAutoResetService_ExpiryTargetUsesIndependentClaimAfterThreshold(t *testing.T) { + now := time.Now().UTC() + targetID := "independent-planned-credit" + otherID := "independent-threshold-credit" + targetExpiry := now.Add(72 * time.Hour).Format(time.RFC3339) + otherExpiry := now.Add(24 * time.Hour).Format(time.RFC3339) + account := newExpiryTargetTestAccount(125, targetID, targetExpiry, 60, now) + enableAutoResetThreshold(account) + repo := &autoResetTestAccountRepo{account: account} + quota := &autoResetTestQuota{usage: newExpiryTargetTestUsage(now, 75, + OpenAIRateLimitResetCreditDetail{ID: otherID, ExpiresAt: otherExpiry}, + OpenAIRateLimitResetCreditDetail{ID: targetID, ExpiresAt: targetExpiry}, + )} + svc := newAutoResetTestService(repo, quota) + + require.NoError(t, svc.evaluateAccount(context.Background(), account.ID)) + repo.mu.Lock() + target := ResolveOpenAIResetCreditExpiryTarget(repo.account) + repo.mu.Unlock() + require.NotNil(t, target) + target.LeadTimeMinutes = OpenAIResetCreditExpiryTargetMaxLeadTimeMinutes + repo.mu.Lock() + repo.account.Extra[OpenAIAutoResetCreditExpiryTargetExtraKey] = target + repo.mu.Unlock() + quota.usage = newExpiryTargetTestUsage(now, 0, OpenAIRateLimitResetCreditDetail{ID: targetID, ExpiresAt: targetExpiry}) + + require.NoError(t, svc.evaluateAccount(context.Background(), account.ID)) + args := autoResetTestResetArgs(quota) + require.Len(t, args, 2) + require.Equal(t, otherID, args[0][0]) + require.Equal(t, targetID, args[1][0]) +} diff --git a/backend/internal/service/openai_quota_expiry_target.go b/backend/internal/service/openai_quota_expiry_target.go new file mode 100644 index 000000000000..c4fab8082b2d --- /dev/null +++ b/backend/internal/service/openai_quota_expiry_target.go @@ -0,0 +1,215 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/google/uuid" +) + +const ( + OpenAIResetCreditExpiryTargetDefaultLeadTimeMinutes = 60 + OpenAIResetCreditExpiryTargetMinLeadTimeMinutes = 5 + OpenAIResetCreditExpiryTargetMaxLeadTimeMinutes = 7 * 24 * 60 +) + +type accountExtraCompareAndSwapper interface { + CompareAndSwapExtra(ctx context.Context, id int64, key string, expected any, updates map[string]any) (bool, error) +} + +func compareAndSwapAccountExtra(ctx context.Context, repo AccountRepository, id int64, key string, expected any, updates map[string]any) (bool, error) { + cas, ok := repo.(accountExtraCompareAndSwapper) + if !ok { + return false, infraerrors.New(http.StatusInternalServerError, "ACCOUNT_EXTRA_CAS_UNAVAILABLE", "account repository does not support conditional extra updates") + } + return cas.CompareAndSwapExtra(ctx, id, key, expected, updates) +} + +// OpenAIResetCreditExpiryTarget is one versioned authorization to consume a +// reset credit. PlanID prevents an older worker from clearing a replacement. +type OpenAIResetCreditExpiryTarget struct { + PlanID string `json:"plan_id"` + CreditID string `json:"credit_id"` + ExpiresAt string `json:"expires_at"` + LeadTimeMinutes int `json:"lead_time_minutes"` +} + +func ResolveOpenAIResetCreditExpiryTarget(account *Account) *OpenAIResetCreditExpiryTarget { + if !isOpenAIAutoResetCreditAccount(account) || len(account.Extra) == 0 { + return nil + } + raw, ok := account.Extra[OpenAIAutoResetCreditExpiryTargetExtraKey] + if !ok || raw == nil { + return nil + } + encoded, err := json.Marshal(raw) + if err != nil { + return nil + } + var target OpenAIResetCreditExpiryTarget + if err := json.Unmarshal(encoded, &target); err != nil || !validOpenAIResetCreditExpiryTarget(&target) { + return nil + } + return &target +} + +func (s *OpenAIQuotaService) SetResetCreditExpiryTarget(ctx context.Context, accountID int64, creditID string, leadTimeMinutes int) (*Account, error) { + creditID = strings.TrimSpace(creditID) + if creditID == "" { + return nil, infraerrors.BadRequest("OPENAI_RESET_CREDIT_ID_INVALID", "credit_id is required") + } + if leadTimeMinutes < OpenAIResetCreditExpiryTargetMinLeadTimeMinutes || leadTimeMinutes > OpenAIResetCreditExpiryTargetMaxLeadTimeMinutes { + return nil, infraerrors.BadRequest("OPENAI_RESET_CREDIT_LEAD_TIME_INVALID", "lead_time_minutes must be between 5 and 10080") + } + account, err := s.loadResetCreditExpiryTargetAccount(ctx, accountID) + if err != nil { + return nil, err + } + current := ResolveOpenAIResetCreditExpiryTarget(account) + if current != nil && current.CreditID != creditID { + return nil, infraerrors.Conflict("OPENAI_RESET_CREDIT_EXPIRY_TARGET_EXISTS", "another reset credit already has a scheduled-use plan; cancel it before scheduling a different credit") + } + snapshot := resolveOpenAIResetCreditSnapshot(account) + candidate, ok := findOpenAIResetCreditByID(snapshot, creditID) + if !ok { + return nil, infraerrors.Conflict("OPENAI_RESET_CREDIT_TARGET_UNAVAILABLE", "the selected reset credit is not present in the latest complete snapshot; refresh the account quota") + } + expiresAt, err := time.Parse(time.RFC3339, candidate.ExpiresAt) + if err != nil { + return nil, infraerrors.Conflict("OPENAI_RESET_CREDIT_EXPIRY_INVALID", "the selected reset credit has an invalid expiration") + } + now := time.Now().UTC() + if !expiresAt.After(now) { + return nil, infraerrors.Conflict("OPENAI_RESET_CREDIT_EXPIRED", "the selected reset credit has expired") + } + + target := &OpenAIResetCreditExpiryTarget{ + PlanID: uuid.NewString(), + CreditID: creditID, + ExpiresAt: expiresAt.UTC().Format(time.RFC3339Nano), + LeadTimeMinutes: leadTimeMinutes, + } + updates := map[string]any{OpenAIAutoResetCreditExpiryTargetExtraKey: target} + if state := openAIAutoResetStateFromExtra(account.Extra); state != nil && resetOpenAIAutoResetExpiryState(state, snapshot.AvailableCount, now) { + updates[OpenAIAutoResetCreditStateExtraKey] = state + } + var expected any + if current != nil { + expected = current + } + swapped, err := compareAndSwapAccountExtra(ctx, s.accountRepo, accountID, OpenAIAutoResetCreditExpiryTargetExtraKey, expected, updates) + if err != nil { + return nil, err + } + if !swapped { + return nil, infraerrors.Conflict("OPENAI_RESET_CREDIT_EXPIRY_TARGET_CHANGED", "the scheduled-use plan changed; refresh and try again") + } + notifyOpenAIAutoReset(accountID) + return s.accountRepo.GetByID(ctx, accountID) +} + +func (s *OpenAIQuotaService) CancelResetCreditExpiryTarget(ctx context.Context, accountID int64) (*Account, error) { + account, err := s.loadResetCreditExpiryTargetAccount(ctx, accountID) + if err != nil { + return nil, err + } + current := ResolveOpenAIResetCreditExpiryTarget(account) + if current == nil { + return account, nil + } + updates := map[string]any{OpenAIAutoResetCreditExpiryTargetExtraKey: nil} + if state := openAIAutoResetStateFromExtra(account.Extra); state != nil && resetOpenAIAutoResetExpiryState(state, state.AvailableCount, time.Now().UTC()) { + updates[OpenAIAutoResetCreditStateExtraKey] = state + } + swapped, err := compareAndSwapAccountExtra(ctx, s.accountRepo, accountID, OpenAIAutoResetCreditExpiryTargetExtraKey, current, updates) + if err != nil { + return nil, err + } + if !swapped { + return nil, infraerrors.Conflict("OPENAI_RESET_CREDIT_EXPIRY_TARGET_CHANGED", "the scheduled-use plan changed; refresh and try again") + } + notifyOpenAIAutoReset(accountID) + return s.accountRepo.GetByID(ctx, accountID) +} + +func (s *OpenAIQuotaService) loadResetCreditExpiryTargetAccount(ctx context.Context, accountID int64) (*Account, error) { + if s == nil || s.accountRepo == nil { + return nil, infraerrors.New(http.StatusInternalServerError, "OPENAI_QUOTA_NOT_CONFIGURED", "openai quota service is not configured") + } + account, err := s.accountRepo.GetByID(ctx, accountID) + if err != nil || account == nil { + return nil, infraerrors.New(http.StatusNotFound, "OPENAI_QUOTA_ACCOUNT_NOT_FOUND", "account not found").WithCause(err) + } + if !isOpenAIAutoResetCreditAccount(account) { + return nil, infraerrors.BadRequest("OPENAI_RESET_CREDIT_EXPIRY_TARGET_ACCOUNT_INVALID", "expiry targeting is only supported for OpenAI OAuth parent accounts") + } + return account, nil +} + +func resolveOpenAIResetCreditSnapshot(account *Account) *OpenAIRateLimitResetCredits { + if account == nil || len(account.Extra) == 0 { + return nil + } + raw, ok := account.Extra[openaiQuotaResetCreditsKey] + if !ok || raw == nil { + return nil + } + encoded, err := json.Marshal(raw) + if err != nil { + return nil + } + var snapshot OpenAIRateLimitResetCredits + if err := json.Unmarshal(encoded, &snapshot); err != nil || !completeOpenAIResetCreditSnapshot(&snapshot) { + return nil + } + return &snapshot +} + +func findOpenAIResetCreditByID(snapshot *OpenAIRateLimitResetCredits, creditID string) (OpenAIRateLimitResetCreditDetail, bool) { + if snapshot == nil { + return OpenAIRateLimitResetCreditDetail{}, false + } + for _, credit := range snapshot.Credits { + if credit.ID == creditID { + return credit, true + } + } + return OpenAIRateLimitResetCreditDetail{}, false +} + +func validOpenAIResetCreditExpiryTarget(target *OpenAIResetCreditExpiryTarget) bool { + if target == nil || strings.TrimSpace(target.CreditID) == "" { + return false + } + if _, err := uuid.Parse(target.PlanID); err != nil { + return false + } + if target.LeadTimeMinutes < OpenAIResetCreditExpiryTargetMinLeadTimeMinutes || target.LeadTimeMinutes > OpenAIResetCreditExpiryTargetMaxLeadTimeMinutes { + return false + } + _, err := time.Parse(time.RFC3339, target.ExpiresAt) + return err == nil +} + +func resetOpenAIAutoResetExpiryState(state *OpenAIAutoResetCreditState, available int, now time.Time) bool { + if state == nil || state.TriggerReason != OpenAIAutoResetTriggerReasonExpiryTarget { + return false + } + state.Status = OpenAIAutoResetStatusNoCredit + if available > 0 { + state.Status = OpenAIAutoResetStatusAvailable + } + state.TriggerReason = "" + state.TriggerWindow = "" + state.AvailableCount = available + state.CheckedAt = now.UTC().Format(time.RFC3339) + state.LastResultAt = "" + state.ErrorCode = "" + state.AttemptCycleHash = "" + state.AttemptCreditHash = "" + return true +} diff --git a/backend/internal/service/openai_quota_expiry_target_test.go b/backend/internal/service/openai_quota_expiry_target_test.go new file mode 100644 index 000000000000..f720ac110c6d --- /dev/null +++ b/backend/internal/service/openai_quota_expiry_target_test.go @@ -0,0 +1,204 @@ +package service + +import ( + "context" + "encoding/json" + "net/http" + "sync" + "testing" + "time" + + infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +type expiryTargetAccountRepo struct { + AccountRepository + mu sync.Mutex + account *Account +} + +func (r *expiryTargetAccountRepo) GetByID(context.Context, int64) (*Account, error) { + r.mu.Lock() + defer r.mu.Unlock() + copy := *r.account + copy.Extra = cloneOpenAIAutoResetExtra(r.account.Extra) + return ©, nil +} + +func (r *expiryTargetAccountRepo) UpdateExtra(_ context.Context, _ int64, updates map[string]any) error { + r.mu.Lock() + defer r.mu.Unlock() + r.applyExtra(updates) + return nil +} + +func (r *expiryTargetAccountRepo) CompareAndSwapExtra(_ context.Context, _ int64, key string, expected any, updates map[string]any) (bool, error) { + r.mu.Lock() + defer r.mu.Unlock() + var current any + if r.account.Extra != nil { + current = r.account.Extra[key] + } + currentJSON, _ := json.Marshal(current) + expectedJSON, _ := json.Marshal(expected) + if string(currentJSON) != string(expectedJSON) { + return false, nil + } + r.applyExtra(updates) + return true, nil +} + +func (r *expiryTargetAccountRepo) Update(_ context.Context, account *Account) error { + r.mu.Lock() + defer r.mu.Unlock() + copy := *account + copy.Extra = cloneOpenAIAutoResetExtra(account.Extra) + r.account = © + return nil +} + +func (r *expiryTargetAccountRepo) applyExtra(updates map[string]any) { + if r.account.Extra == nil { + r.account.Extra = make(map[string]any) + } + for key, value := range updates { + r.account.Extra[key] = value + } +} + +func TestOpenAIQuotaService_SetAndCancelResetCreditExpiryTarget(t *testing.T) { + expiresAt := time.Date(2099, time.July, 3, 4, 5, 6, 123456789, time.UTC).Format(time.RFC3339Nano) + creditID := "upstream-credit-id" + otherCreditID := "another-upstream-credit-id" + account := &Account{ + ID: 201, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, + Extra: map[string]any{ + openaiQuotaResetCreditsKey: OpenAIRateLimitResetCredits{ + AvailableCount: 2, + Credits: []OpenAIRateLimitResetCreditDetail{ + {ID: creditID, ExpiresAt: expiresAt}, + {ID: otherCreditID, ExpiresAt: expiresAt}, + }, + }, + }, + } + repo := &expiryTargetAccountRepo{account: account} + svc := &OpenAIQuotaService{accountRepo: repo} + + for _, leadTime := range []int{ + OpenAIResetCreditExpiryTargetMinLeadTimeMinutes - 1, + OpenAIResetCreditExpiryTargetMaxLeadTimeMinutes + 1, + } { + _, err := svc.SetResetCreditExpiryTarget(context.Background(), account.ID, creditID, leadTime) + require.Equal(t, "OPENAI_RESET_CREDIT_LEAD_TIME_INVALID", infraerrors.Reason(err)) + } + + _, err := svc.SetResetCreditExpiryTarget(context.Background(), account.ID, "missing-credit", 30) + require.Equal(t, "OPENAI_RESET_CREDIT_TARGET_UNAVAILABLE", infraerrors.Reason(err)) + + updated, err := svc.SetResetCreditExpiryTarget(context.Background(), account.ID, creditID, 30) + require.NoError(t, err) + target := ResolveOpenAIResetCreditExpiryTarget(updated) + require.NotNil(t, target) + require.NotEmpty(t, target.PlanID) + require.NoError(t, uuid.Validate(target.PlanID)) + require.Equal(t, creditID, target.CreditID) + require.Equal(t, expiresAt, target.ExpiresAt) + require.Equal(t, 30, target.LeadTimeMinutes) + encoded, err := json.Marshal(updated.Extra) + require.NoError(t, err) + require.Contains(t, string(encoded), creditID) + + firstPlanID := target.PlanID + updated, err = svc.SetResetCreditExpiryTarget(context.Background(), account.ID, creditID, 60) + require.NoError(t, err) + target = ResolveOpenAIResetCreditExpiryTarget(updated) + require.Equal(t, 60, target.LeadTimeMinutes) + require.NotEqual(t, firstPlanID, target.PlanID) + + _, err = svc.SetResetCreditExpiryTarget(context.Background(), account.ID, otherCreditID, 30) + require.Equal(t, http.StatusConflict, infraerrors.Code(err)) + require.Equal(t, "OPENAI_RESET_CREDIT_EXPIRY_TARGET_EXISTS", infraerrors.Reason(err)) + stored, err := repo.GetByID(context.Background(), account.ID) + require.NoError(t, err) + require.Equal(t, creditID, ResolveOpenAIResetCreditExpiryTarget(stored).CreditID) + + require.NoError(t, repo.UpdateExtra(context.Background(), account.ID, map[string]any{ + OpenAIAutoResetCreditStateExtraKey: OpenAIAutoResetCreditState{ + Status: OpenAIAutoResetStatusResetting, TriggerReason: OpenAIAutoResetTriggerReasonExpiryTarget, + TriggerWindow: "5h", AvailableCount: 1, ErrorCode: "pending", + AttemptCycleHash: "cycle", AttemptCreditHash: "credit", + }, + })) + + canceled, err := svc.CancelResetCreditExpiryTarget(context.Background(), account.ID) + require.NoError(t, err) + require.Nil(t, ResolveOpenAIResetCreditExpiryTarget(canceled)) + state := openAIAutoResetStateFromExtra(canceled.Extra) + require.NotNil(t, state) + require.Equal(t, OpenAIAutoResetStatusAvailable, state.Status) + require.Empty(t, state.TriggerReason) + require.Empty(t, state.TriggerWindow) + require.Empty(t, state.ErrorCode) + require.Empty(t, state.AttemptCycleHash) + require.Empty(t, state.AttemptCreditHash) +} + +func TestOpenAIQuotaAutoResetService_OldPlanCannotClearReplacement(t *testing.T) { + now := time.Now().UTC() + oldTarget := &OpenAIResetCreditExpiryTarget{ + PlanID: uuid.NewString(), CreditID: "old-credit", + ExpiresAt: now.Add(time.Hour).Format(time.RFC3339), LeadTimeMinutes: 30, + } + replacement := OpenAIResetCreditExpiryTarget{ + PlanID: uuid.NewString(), CreditID: "replacement-credit", + ExpiresAt: now.Add(2 * time.Hour).Format(time.RFC3339), LeadTimeMinutes: 30, + } + existingState := OpenAIAutoResetCreditState{Status: OpenAIAutoResetStatusAvailable, AvailableCount: 2} + repo := &expiryTargetAccountRepo{account: &Account{ + ID: 202, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, + Extra: map[string]any{ + OpenAIAutoResetCreditExpiryTargetExtraKey: replacement, + OpenAIAutoResetCreditStateExtraKey: existingState, + }, + }} + svc := &OpenAIQuotaAutoResetService{accountRepo: repo} + + err := svc.finishOpenAIAutoResetExpiryTarget(context.Background(), 202, oldTarget, nil, "STALE_WORKER", now) + require.NoError(t, err) + stored, err := repo.GetByID(context.Background(), 202) + require.NoError(t, err) + require.Equal(t, replacement.PlanID, ResolveOpenAIResetCreditExpiryTarget(stored).PlanID) + require.Equal(t, &existingState, openAIAutoResetStateFromExtra(stored.Extra)) +} + +func TestAdminAccountUpdatesPreserveManagedExpiryTarget(t *testing.T) { + current := OpenAIResetCreditExpiryTarget{ + PlanID: uuid.NewString(), CreditID: "current-credit", + ExpiresAt: time.Now().UTC().Add(time.Hour).Format(time.RFC3339), LeadTimeMinutes: 30, + } + repo := &expiryTargetAccountRepo{account: &Account{ + ID: 203, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, + Extra: map[string]any{OpenAIAutoResetCreditExpiryTargetExtraKey: current, "custom": "old"}, + }} + svc := &adminServiceImpl{accountRepo: repo} + + updated, err := svc.UpdateAccount(context.Background(), 203, &UpdateAccountInput{Extra: map[string]any{ + OpenAIAutoResetCreditExpiryTargetExtraKey: map[string]any{"credit_id": "forged"}, + "custom": "new", + }}) + require.NoError(t, err) + require.Equal(t, "new", updated.Extra["custom"]) + require.Equal(t, ¤t, ResolveOpenAIResetCreditExpiryTarget(updated)) + + require.NoError(t, svc.UpdateAccountExtra(context.Background(), 203, map[string]any{ + OpenAIAutoResetCreditExpiryTargetExtraKey: map[string]any{"credit_id": "forged-again"}, + "another": "value", + })) + stored, err := repo.GetByID(context.Background(), 203) + require.NoError(t, err) + require.Equal(t, ¤t, ResolveOpenAIResetCreditExpiryTarget(stored)) + require.Equal(t, "value", stored.Extra["another"]) +} diff --git a/backend/internal/service/openai_quota_reset_credits.go b/backend/internal/service/openai_quota_reset_credits.go index b87e3fd0d1e2..6a5f4be4b32c 100644 --- a/backend/internal/service/openai_quota_reset_credits.go +++ b/backend/internal/service/openai_quota_reset_credits.go @@ -32,14 +32,6 @@ type openAIRateLimitResetCreditDetails struct { AvailableCreditCount int CreditListPresent bool Credits []OpenAIRateLimitResetCreditDetail - AutoResetCandidates []openAIAutoResetCreditCandidate -} - -// openAIAutoResetCreditCandidate 仅在服务内部流转。上游卡 ID 不进入 API DTO、 -// 账号 extra 或日志,避免管理端响应扩大敏感标识暴露面。 -type openAIAutoResetCreditCandidate struct { - ID string - ExpiresAt string } func parseOpenAIRateLimitResetCreditDetails(body []byte) (openAIRateLimitResetCreditDetails, error) { @@ -75,7 +67,6 @@ func parseOpenAIRateLimitResetCreditDetails(body []byte) (openAIRateLimitResetCr } credits := make([]OpenAIRateLimitResetCreditDetail, 0, len(rawCredits)) - autoResetCandidates := make([]openAIAutoResetCreditCandidate, 0, len(rawCredits)) availableCreditCount := 0 for _, raw := range rawCredits { if raw == nil { @@ -99,7 +90,6 @@ func parseOpenAIRateLimitResetCreditDetails(body []byte) (openAIRateLimitResetCr if expiresAt == "" { continue } - credits = append(credits, OpenAIRateLimitResetCreditDetail{ExpiresAt: expiresAt}) creditID := strings.TrimSpace(raw.ID) if creditID == "" { creditID = strings.TrimSpace(raw.CreditID) @@ -107,17 +97,13 @@ func parseOpenAIRateLimitResetCreditDetails(body []byte) (openAIRateLimitResetCr if creditID == "" { creditID = strings.TrimSpace(raw.CreditIDCamel) } - autoResetCandidates = append(autoResetCandidates, openAIAutoResetCreditCandidate{ - ID: creditID, - ExpiresAt: expiresAt, - }) + credits = append(credits, OpenAIRateLimitResetCreditDetail{ID: creditID, ExpiresAt: expiresAt}) } return openAIRateLimitResetCreditDetails{ AvailableCount: availableCount, AvailableCreditCount: availableCreditCount, CreditListPresent: creditListPresent, Credits: credits, - AutoResetCandidates: autoResetCandidates, }, nil } diff --git a/backend/internal/service/openai_quota_reset_credits_test.go b/backend/internal/service/openai_quota_reset_credits_test.go index 2deb01ed8915..bdb0efc74dd8 100644 --- a/backend/internal/service/openai_quota_reset_credits_test.go +++ b/backend/internal/service/openai_quota_reset_credits_test.go @@ -25,13 +25,9 @@ func TestParseOpenAIRateLimitResetCreditDetails_PreservesAvailableCreditOrder(t require.NotNil(t, details.AvailableCount) require.Equal(t, 2, *details.AvailableCount) require.Equal(t, []OpenAIRateLimitResetCreditDetail{ - {ExpiresAt: "2026-07-04T04:05:06Z"}, - {ExpiresAt: "2026-07-03T04:05:06Z"}, - }, details.Credits) - require.Equal(t, []openAIAutoResetCreditCandidate{ {ID: "credit-later", ExpiresAt: "2026-07-04T04:05:06Z"}, {ID: "credit-earlier", ExpiresAt: "2026-07-03T04:05:06Z"}, - }, details.AutoResetCandidates) + }, details.Credits) } func TestQueryUsageResetCreditCountPrecedence(t *testing.T) { diff --git a/backend/internal/service/openai_quota_service.go b/backend/internal/service/openai_quota_service.go index 6e40c497fccd..6f03d452a10e 100644 --- a/backend/internal/service/openai_quota_service.go +++ b/backend/internal/service/openai_quota_service.go @@ -62,9 +62,10 @@ type OpenAIAdditionalRateLimit struct { RateLimit *OpenAIRateLimit `json:"rate_limit,omitempty"` } -// OpenAIRateLimitResetCreditDetail is the sanitized metadata surfaced for one -// available reset credit. Do not add upstream ids or tokens here. +// OpenAIRateLimitResetCreditDetail contains the resource identifier and expiry +// needed by admin reset-credit workflows. The ID is not an authentication token. type OpenAIRateLimitResetCreditDetail struct { + ID string `json:"id,omitempty"` ExpiresAt string `json:"expires_at,omitempty"` } @@ -87,7 +88,6 @@ type OpenAIQuotaUsage struct { AdditionalRateLimits []OpenAIAdditionalRateLimit `json:"additional_rate_limits,omitempty"` RateLimitResetCredits *OpenAIRateLimitResetCredits `json:"rate_limit_reset_credits,omitempty"` FetchedAt int64 `json:"fetched_at"` - autoResetCandidates []openAIAutoResetCreditCandidate } // OpenAIQuotaResetCredit captures the redeemed credit metadata returned by the @@ -195,18 +195,15 @@ func (s *OpenAIQuotaService) QueryUsage(ctx context.Context, accountID int64) (* payload.FetchedAt = time.Now().Unix() details := s.queryResetCreditDetails(callCtx, client, accessToken, chatGPTAccountID, fedRAMP, accountID) if details != nil { - payload.autoResetCandidates = details.AutoResetCandidates - hasDetailCount := details.AvailableCount != nil if payload.RateLimitResetCredits == nil { payload.RateLimitResetCredits = &OpenAIRateLimitResetCredits{} } if details.CreditListPresent { payload.RateLimitResetCredits.Credits = details.Credits } - switch { - case hasDetailCount: + if details.AvailableCount != nil { payload.RateLimitResetCredits.AvailableCount = *details.AvailableCount - case details.CreditListPresent: + } else if details.CreditListPresent { payload.RateLimitResetCredits.AvailableCount = details.AvailableCreditCount } } @@ -225,7 +222,7 @@ func (s *OpenAIQuotaService) QueryUsage(ctx context.Context, accountID int64) (* // consume) credits that already expired. Callers must treat this rejection as a // partial success — the upstream read itself is still valid. func (s *OpenAIQuotaService) CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *OpenAIRateLimitResetCredits) error { - if credits == nil || (credits.AvailableCount > 0 && len(credits.Credits) == 0) { + if !completeOpenAIResetCreditSnapshot(credits) { return infraerrors.New( http.StatusBadGateway, "OPENAI_QUOTA_RESET_CREDITS_REFRESH_FAILED", @@ -244,6 +241,21 @@ func (s *OpenAIQuotaService) CacheResetCreditsSnapshot(ctx context.Context, acco return nil } +func completeOpenAIResetCreditSnapshot(credits *OpenAIRateLimitResetCredits) bool { + if credits == nil || credits.AvailableCount < 0 || credits.AvailableCount != len(credits.Credits) { + return false + } + for _, credit := range credits.Credits { + if strings.TrimSpace(credit.ID) == "" || strings.TrimSpace(credit.ExpiresAt) == "" { + return false + } + if _, err := time.Parse(time.RFC3339, credit.ExpiresAt); err != nil { + return false + } + } + return true +} + 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 { @@ -284,7 +296,7 @@ func (s *OpenAIQuotaService) ResetCredit(ctx context.Context, accountID int64) ( if err != nil { return nil, infraerrors.Newf(http.StatusInternalServerError, "OPENAI_QUOTA_REDEEM_ID_FAILED", "failed to generate redeem id: %v", err) } - return s.resetCredit(ctx, accountID, "", redeemRequestID, false) + return s.resetCredit(ctx, accountID, "", redeemRequestID) } // ResetCreditTargeted 使用固定卡 ID 与兑换 ID执行自动消费。调用方必须在重试时 @@ -295,10 +307,10 @@ func (s *OpenAIQuotaService) ResetCreditTargeted(ctx context.Context, accountID if creditID == "" || redeemRequestID == "" { return nil, infraerrors.New(http.StatusBadRequest, "OPENAI_QUOTA_TARGETED_RESET_INVALID", "credit_id and redeem_request_id are required") } - return s.resetCredit(ctx, accountID, creditID, redeemRequestID, true) + return s.resetCredit(ctx, accountID, creditID, redeemRequestID) } -func (s *OpenAIQuotaService) resetCredit(ctx context.Context, accountID int64, creditID, redeemRequestID string, targeted bool) (*OpenAIQuotaResetResult, error) { +func (s *OpenAIQuotaService) resetCredit(ctx context.Context, accountID int64, creditID, redeemRequestID string) (*OpenAIQuotaResetResult, error) { // Shadow guard: resetting credits via a shadow account would silently // operate on the parent's quota; that is surprising and unwanted. Callers // must reset the parent account directly. @@ -339,7 +351,7 @@ func (s *OpenAIQuotaService) resetCredit(ctx context.Context, accountID int64, c } headers["content-type"] = "application/json" body := map[string]string{"redeem_request_id": redeemRequestID} - if targeted { + if creditID != "" { body["credit_id"] = creditID } resp, err := client.R(). @@ -360,7 +372,7 @@ func (s *OpenAIQuotaService) resetCredit(ctx context.Context, accountID int64, c continue } status := resp.StatusCode - if targeted { + if creditID != "" { slog.Warn("openai_quota_targeted_reset_failed", "account_id", accountID, "status", status) return nil, infraerrors.Newf(mapUpstreamStatus(status), "OPENAI_QUOTA_RESET_UPSTREAM_ERROR", "upstream returned %d", status) } diff --git a/backend/internal/service/openai_quota_spark_window_test.go b/backend/internal/service/openai_quota_spark_window_test.go index cb47a0c0c5ae..7358542e7408 100644 --- a/backend/internal/service/openai_quota_spark_window_test.go +++ b/backend/internal/service/openai_quota_spark_window_test.go @@ -479,14 +479,16 @@ func TestQueryUsageAgentIdentityRecoversInvalidTaskOnce(t *testing.T) { func TestParseOpenAIRateLimitResetCreditDetails_CompatibleContainers(t *testing.T) { tests := []struct { - name string - body string - want []string + name string + body string + want []string + wantID string }{ { - name: "credits", - body: `{"credits":[{"id":"secret-id","expires_at":"2026-07-03T04:05:06Z"}]}`, - want: []string{"2026-07-03T04:05:06Z"}, + name: "credits", + body: `{"credits":[{"id":"credit-id","expires_at":"2026-07-03T04:05:06Z"}]}`, + want: []string{"2026-07-03T04:05:06Z"}, + wantID: "credit-id", }, { name: "rate limit reset credits", @@ -518,9 +520,7 @@ func TestParseOpenAIRateLimitResetCreditDetails_CompatibleContainers(t *testing. for i := range tt.want { require.Equal(t, tt.want[i], got.Credits[i].ExpiresAt) } - encoded, err := json.Marshal(got.Credits) - require.NoError(t, err) - require.NotContains(t, string(encoded), "secret-id") + require.Equal(t, tt.wantID, got.Credits[0].ID) }) } } @@ -555,7 +555,7 @@ func TestQueryUsageIncludesResetCreditExpirations_EndToEnd(t *testing.T) { detailCalls++ capturedBeta = r.Header.Get("OpenAI-Beta") require.Equal(t, "org-parent123", r.Header.Get("ChatGPT-Account-ID")) - _, _ = w.Write([]byte(`{"credits":[{"id":"secret-credit-id","expires_at":"2026-07-03T04:05:06Z"},{"expiresAt":"2026-07-04T04:05:06Z"}]}`)) + _, _ = w.Write([]byte(`{"credits":[{"id":"credit-one","expires_at":"2026-07-03T04:05:06Z"},{"credit_id":"credit-two","expiresAt":"2026-07-04T04:05:06Z"}]}`)) default: http.NotFound(w, r) } @@ -571,21 +571,22 @@ func TestQueryUsageIncludesResetCreditExpirations_EndToEnd(t *testing.T) { require.Equal(t, 1, detailCalls) require.Equal(t, openaiQuotaCodexBeta, capturedBeta) require.Equal(t, []OpenAIRateLimitResetCreditDetail{ - {ExpiresAt: "2026-07-03T04:05:06Z"}, - {ExpiresAt: "2026-07-04T04:05:06Z"}, + {ID: "credit-one", ExpiresAt: "2026-07-03T04:05:06Z"}, + {ID: "credit-two", 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"}, + {ID: "credit-one", ExpiresAt: "2026-07-03T04:05:06Z"}, + {ID: "credit-two", ExpiresAt: "2026-07-04T04:05:06Z"}, }, }, repo.extraUpdates[100][openaiQuotaResetCreditsKey]) encoded, err := json.Marshal(usage) require.NoError(t, err) - require.NotContains(t, string(encoded), "secret-credit-id") + require.Contains(t, string(encoded), "credit-one") + require.Contains(t, string(encoded), "credit-two") } func TestQueryUsageResetCreditDetails401NonFatal(t *testing.T) { @@ -641,6 +642,20 @@ func TestQueryUsageResetCreditDetails401NonFatal(t *testing.T) { func TestCacheResetCreditsSnapshot(t *testing.T) { ctx := context.Background() + t.Run("complete cards are persisted with their ids", func(t *testing.T) { + repo := &stubQuotaAccountRepo{} + svc := &OpenAIQuotaService{accountRepo: repo} + credits := &OpenAIRateLimitResetCredits{ + AvailableCount: 1, + Credits: []OpenAIRateLimitResetCreditDetail{ + {ID: "credit-one", ExpiresAt: "2026-07-03T04:05:06Z"}, + }, + } + + require.NoError(t, svc.CacheResetCreditsSnapshot(ctx, 100, credits)) + require.Equal(t, credits, repo.extraUpdates[100][openaiQuotaResetCreditsKey]) + }) + t.Run("zero count allows an empty expiration list", func(t *testing.T) { repo := &stubQuotaAccountRepo{} svc := &OpenAIQuotaService{accountRepo: repo} @@ -687,7 +702,7 @@ func TestCacheResetCreditsSnapshot(t *testing.T) { err := svc.CacheResetCreditsSnapshot(ctx, 100, &OpenAIRateLimitResetCredits{ AvailableCount: 1, - Credits: []OpenAIRateLimitResetCreditDetail{{ExpiresAt: "2026-07-03T04:05:06Z"}}, + Credits: []OpenAIRateLimitResetCreditDetail{{ID: "credit-one", ExpiresAt: "2026-07-03T04:05:06Z"}}, }) require.ErrorContains(t, err, "database unavailable") diff --git a/frontend/src/api/admin/accounts.ts b/frontend/src/api/admin/accounts.ts index ee053ad9ecf0..258e66dd5ced 100644 --- a/frontend/src/api/admin/accounts.ts +++ b/frontend/src/api/admin/accounts.ts @@ -836,6 +836,7 @@ export interface OpenAIAdditionalRateLimit { } export interface OpenAIRateLimitResetCreditDetail { + id?: string expires_at?: string } @@ -917,6 +918,29 @@ export async function resetOpenAIQuota(id: number): Promise { + const { data } = await apiClient.put( + `/admin/openai/accounts/${id}/reset-credit-expiry-target`, + payload + ) + return data +} + +export async function cancelOpenAIResetCreditExpiryTarget(id: number): Promise { + const { data } = await apiClient.delete( + `/admin/openai/accounts/${id}/reset-credit-expiry-target` + ) + return data +} + export interface SparkShadowCreatePayload { name?: string priority?: number diff --git a/frontend/src/components/account/OpenAIQuotaResetCell.vue b/frontend/src/components/account/OpenAIQuotaResetCell.vue index 6a3225b8bf59..5371d3def916 100644 --- a/frontend/src/components/account/OpenAIQuotaResetCell.vue +++ b/frontend/src/components/account/OpenAIQuotaResetCell.vue @@ -89,14 +89,56 @@ -
+
+ + + {{ t('admin.accounts.openaiQuotaReset.expiryTarget.scheduledAt', { time: formatResetCreditExpiry(expiryTargetExecutionAt, 'short') }) }} + + +
+ +
- {{ t('admin.accounts.openaiQuotaReset.expiresAt', { time: formatResetCreditExpiry(primaryResetCreditExpiry, 'short') }) }} + {{ t('admin.accounts.openaiQuotaReset.expiresAt', { time: formatResetCreditExpiry(primaryResetCredit.expires_at, 'short') }) }} +
@@ -160,6 +215,68 @@ @confirm="confirmReset" @cancel="showResetConfirm = false" /> + + +
+
+
+ {{ t('admin.accounts.openaiQuotaReset.expiryTarget.creditExpiresAt') }} +
+
+ {{ selectedResetCredit ? formatResetCreditExpiry(selectedResetCredit.expires_at, 'full') : '-' }} +
+
+
+ + +
+
+
+ + {{ t('admin.accounts.openaiQuotaReset.expiryTarget.plannedExecution') }} + + + {{ selectedExpiryTargetExecutionAt ? formatResetCreditExpiry(selectedExpiryTargetExecutionAt, 'full') : '-' }} + +
+
+

+ + {{ t('admin.accounts.openaiQuotaReset.expiryTarget.executeImmediately') }} +

+
+ + +
@@ -168,12 +285,17 @@ import { ref, computed, watch } from 'vue' import { useI18n } from 'vue-i18n' import type { Account } from '@/types' import { + cancelOpenAIResetCreditExpiryTarget, refreshOpenAIQuota, resetOpenAIQuota, + setOpenAIResetCreditExpiryTarget, + type OpenAIRateLimitResetCreditDetail, type OpenAIQuotaUsage, type OpenAIQuotaResetResult } from '@/api/admin/accounts' +import BaseDialog from '@/components/common/BaseDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue' +import Icon from '@/components/icons/Icon.vue' const props = defineProps<{ account: Account @@ -197,13 +319,20 @@ const resetMessage = ref(null) const resetWarning = ref(null) const showResetConfirm = ref(false) const showResetCreditDetails = ref(false) +const showExpiryTargetDialog = ref(false) +const expiryTargetMutating = ref(false) +const selectedResetCredit = ref(null) +const expiryTargetDefaultLeadTimeMinutes = 60 +const expiryTargetMinLeadTimeMinutes = 5 +const expiryTargetMaxLeadTimeMinutes = 10080 +const expiryTargetLeadTimeMinutes = ref(expiryTargetDefaultLeadTimeMinutes) type AutoResetCreditState = NonNullable['codex_auto_reset_credit_state']> const validAutoResetStatuses = new Set(['checking', 'available', 'resetting', 'success', 'no_credit', 'failed']) const autoResetState = computed(() => { - if (props.account.extra?.auto_reset_credit_enabled !== true) return null const state = props.account.extra?.codex_auto_reset_credit_state if (!state || typeof state !== 'object' || !validAutoResetStatuses.has(String(state.status))) return null + if (props.account.extra?.auto_reset_credit_enabled !== true && state.trigger_reason !== 'expiry_target') return null return state }) const autoResetStateLabel = computed(() => { @@ -234,6 +363,21 @@ const autoResetStateClass = computed(() => { } }) +type ResetCreditExpiryTarget = NonNullable['auto_reset_credit_expiry_target']> +const expiryTarget = computed(() => { + const target = props.account.extra?.auto_reset_credit_expiry_target + if (!target || typeof target !== 'object') return null + if ( + typeof target.plan_id !== 'string' || + typeof target.credit_id !== 'string' || + typeof target.expires_at !== 'string' || + !Number.isInteger(target.lead_time_minutes) || + target.lead_time_minutes < expiryTargetMinLeadTimeMinutes || + target.lead_time_minutes > expiryTargetMaxLeadTimeMinutes + ) return null + return target +}) + // Rehydrate the card from the persisted snapshot. Credits that already expired // are dropped and the count is clamped to what remains: the snapshot has no // freshness signal, so an unfiltered read would offer to consume credits that no @@ -250,7 +394,7 @@ const readCachedResetCredits = (account: Account): OpenAIQuotaUsage | null => { if (typeof count !== 'number' || !Number.isFinite(count)) return null const now = Date.now() - const credits: { expires_at?: string }[] = [] + const credits: OpenAIRateLimitResetCreditDetail[] = [] if (Array.isArray(rawCredits)) { for (const credit of rawCredits) { if (!credit || typeof credit !== 'object') continue @@ -260,7 +404,11 @@ const readCachedResetCredits = (account: Account): OpenAIQuotaUsage | null => { // Unparsable timestamps are kept: they are already rendered verbatim and // dropping them would silently understate the available count. if (!Number.isNaN(expiryTime) && expiryTime <= now) continue - credits.push({ expires_at: expiresAt }) + const id = (credit as { id?: unknown }).id + credits.push({ + expires_at: expiresAt, + ...(typeof id === 'string' && id.trim() !== '' ? { id: id.trim() } : {}) + }) } } const availableCount = Math.min(Math.max(count, 0), credits.length) @@ -287,14 +435,24 @@ const availableResetCount = computed(() => data.value?.rate_limit_reset_credits? // Prefer the live payload and fall back to the persisted snapshot only when the // live state is unknown, so the count and the expirations never come from two // different generations of the same data. -const resetCreditExpirations = computed(() => +type SelectableResetCredit = Required> & OpenAIRateLimitResetCreditDetail +const resetCredits = computed(() => ((data.value ?? cachedData.value)?.rate_limit_reset_credits?.credits ?? []) - .map((credit) => credit.expires_at?.trim() ?? '') - .filter((expiresAt) => expiresAt.length > 0) - .sort(compareResetCreditExpiry) + .map((credit): SelectableResetCredit => { + const id = credit.id?.trim() + return { + ...credit, + expires_at: credit.expires_at?.trim() ?? '', + ...(id ? { id } : {}) + } + }) + .filter((credit) => credit.expires_at.length > 0) + .sort((a, b) => compareResetCreditExpiry(a.expires_at, b.expires_at)) ) -const primaryResetCreditExpiry = computed(() => resetCreditExpirations.value[0] ?? '') -const hiddenResetCreditCount = computed(() => Math.max(resetCreditExpirations.value.length - 1, 0)) +const resetCreditExpirations = computed(() => resetCredits.value.map((credit) => credit.expires_at)) +const primaryResetCredit = computed(() => resetCredits.value[0] ?? null) +const additionalResetCredits = computed(() => resetCredits.value.slice(1)) +const hiddenResetCreditCount = computed(() => additionalResetCredits.value.length) const canReset = computed(() => availableResetCount.value > 0 && !isShadow.value) const resetCreditDetailsTitle = computed(() => @@ -357,6 +515,43 @@ const formatResetCreditExpiry = (value: string, style: 'short' | 'full'): string return new Intl.DateTimeFormat(undefined, options).format(date) } +const canConfigureExpiryTarget = (credit: SelectableResetCredit): boolean => { + if (isShadow.value || typeof credit.id !== 'string' || credit.id.trim() === '') return false + const expiresAt = new Date(credit.expires_at).getTime() + return !Number.isNaN(expiresAt) && expiresAt > Date.now() +} + +const isExpiryTargetCredit = (credit: SelectableResetCredit): boolean => + Boolean(credit.id && expiryTarget.value?.credit_id === credit.id) + +const calculateExpiryTargetExecutionAt = (expiresAt: string, leadTimeMinutes: number): string => { + const expiry = new Date(expiresAt).getTime() + if (Number.isNaN(expiry)) return '' + return new Date(expiry - leadTimeMinutes * 60_000).toISOString() +} + +const formatExpiryTargetLeadTime = (minutes: number): string => { + if (minutes % 60 === 0) { + return t('admin.accounts.openaiQuotaReset.expiryTarget.durationHours', { count: minutes / 60 }) + } + return t('admin.accounts.openaiQuotaReset.expiryTarget.durationMinutes', { count: minutes }) +} + +const expiryTargetExecutionAt = computed(() => { + if (!expiryTarget.value) return '' + return calculateExpiryTargetExecutionAt(expiryTarget.value.expires_at, expiryTarget.value.lead_time_minutes) +}) + +const selectedExpiryTargetExecutionAt = computed(() => { + if (!selectedResetCredit.value) return '' + return calculateExpiryTargetExecutionAt(selectedResetCredit.value.expires_at, expiryTargetLeadTimeMinutes.value) +}) + +const expiryTargetRunsImmediately = computed(() => { + if (!selectedExpiryTargetExecutionAt.value) return false + return new Date(selectedExpiryTargetExecutionAt.value).getTime() <= Date.now() +}) + const extractErrorMessage = (e: unknown): string => { // The project's axios response interceptor (api/client.ts) flattens server // errors into { status, code, message, reason, ... } and re-rejects them, so @@ -382,6 +577,78 @@ const toggleResetCreditDetails = () => { showResetCreditDetails.value = !showResetCreditDetails.value } +const openExpiryTargetDialog = (credit: SelectableResetCredit) => { + if (expiryTargetMutating.value || !canConfigureExpiryTarget(credit)) return + selectedResetCredit.value = { ...credit } + expiryTargetLeadTimeMinutes.value = isExpiryTargetCredit(credit) + ? expiryTarget.value?.lead_time_minutes ?? expiryTargetDefaultLeadTimeMinutes + : expiryTargetDefaultLeadTimeMinutes + error.value = null + resetMessage.value = null + resetWarning.value = null + showExpiryTargetDialog.value = true +} + +const closeExpiryTargetDialog = () => { + if (expiryTargetMutating.value) return + showExpiryTargetDialog.value = false +} + +const validExpiryTargetInput = (): boolean => { + if (!selectedResetCredit.value || !canConfigureExpiryTarget(selectedResetCredit.value)) { + error.value = t('admin.accounts.openaiQuotaReset.expiryTarget.creditUnavailable') + return false + } + if ( + !Number.isInteger(expiryTargetLeadTimeMinutes.value) || + expiryTargetLeadTimeMinutes.value < expiryTargetMinLeadTimeMinutes || + expiryTargetLeadTimeMinutes.value > expiryTargetMaxLeadTimeMinutes + ) { + error.value = t('admin.accounts.openaiQuotaReset.expiryTarget.leadTimeInvalid') + return false + } + return true +} + +const saveExpiryTarget = async () => { + const credit = selectedResetCredit.value + if (!credit?.id || expiryTargetMutating.value || !validExpiryTargetInput()) return + expiryTargetMutating.value = true + showExpiryTargetDialog.value = false + error.value = null + resetMessage.value = null + resetWarning.value = null + try { + const account = await setOpenAIResetCreditExpiryTarget(props.account.id, { + credit_id: credit.id, + lead_time_minutes: expiryTargetLeadTimeMinutes.value + }) + emit('account-updated', account) + resetMessage.value = t('admin.accounts.openaiQuotaReset.expiryTarget.planSaved') + } catch (e) { + error.value = extractErrorMessage(e) + } finally { + expiryTargetMutating.value = false + } +} + +const cancelExpiryTarget = async () => { + if (expiryTargetMutating.value || !expiryTarget.value) return + expiryTargetMutating.value = true + error.value = null + resetMessage.value = null + resetWarning.value = null + try { + const account = await cancelOpenAIResetCreditExpiryTarget(props.account.id) + emit('account-updated', account) + resetMessage.value = t('admin.accounts.openaiQuotaReset.expiryTarget.planCanceled') + } catch (e) { + error.value = extractErrorMessage(e) + } finally { + expiryTargetMutating.value = false + } +} + const handleQuery = async () => { if (loading.value) return loading.value = true @@ -472,6 +739,10 @@ watch( resetting.value = false showResetConfirm.value = false showResetCreditDetails.value = false + showExpiryTargetDialog.value = false + expiryTargetMutating.value = false + selectedResetCredit.value = null + expiryTargetLeadTimeMinutes.value = expiryTargetDefaultLeadTimeMinutes } ) diff --git a/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts b/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts index 280725bc5b65..87a1476a5b75 100644 --- a/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts +++ b/frontend/src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts @@ -1,13 +1,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' import OpenAIQuotaResetCell from '../OpenAIQuotaResetCell.vue' +import BaseDialog from '@/components/common/BaseDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import type { Account } from '@/types' -import { refreshOpenAIQuota, resetOpenAIQuota } from '@/api/admin/accounts' +import { + cancelOpenAIResetCreditExpiryTarget, + refreshOpenAIQuota, + resetOpenAIQuota, + setOpenAIResetCreditExpiryTarget, + type OpenAIRateLimitResetCreditDetail, +} from '@/api/admin/accounts' vi.mock('@/api/admin/accounts', () => ({ + cancelOpenAIResetCreditExpiryTarget: vi.fn(), refreshOpenAIQuota: vi.fn(), resetOpenAIQuota: vi.fn(), + setOpenAIResetCreditExpiryTarget: vi.fn(), })) vi.mock('vue-i18n', async () => { @@ -60,8 +69,10 @@ const resetButton = (wrapper: ReturnType) => wrapper.findAll('button')[1] beforeEach(() => { + vi.mocked(cancelOpenAIResetCreditExpiryTarget).mockReset() vi.mocked(refreshOpenAIQuota).mockReset() vi.mocked(resetOpenAIQuota).mockReset() + vi.mocked(setOpenAIResetCreditExpiryTarget).mockReset() }) describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => { @@ -386,3 +397,107 @@ describe('OpenAIQuotaResetCell 自动用卡运行态', () => { wrapper.unmount() }) }) + +describe('OpenAIQuotaResetCell 单卡定时使用', () => { + const CREDIT_ID = 'credit-one' + type ExpiryTarget = NonNullable['auto_reset_credit_expiry_target']> + + const makeResetCreditAccount = ( + credits: OpenAIRateLimitResetCreditDetail[], + target?: ExpiryTarget, + ) => makeAccount({ + extra: { + codex_reset_credit_snapshot: { available_count: credits.length, credits }, + ...(target ? { auto_reset_credit_expiry_target: target } : {}), + }, + }) + + const visibleExpiryTargetDialog = (wrapper: ReturnType) => { + const dialog = wrapper.findAllComponents(BaseDialog).find(item => item.props('show') === true) + expect(dialog).toBeDefined() + return dialog! + } + + it('只为带 ID 的未过期卡显示时钟入口,旧缓存和影子账号只能展示', () => { + const account = makeResetCreditAccount([ + { id: CREDIT_ID, expires_at: FUTURE_EXPIRY_EARLY }, + { expires_at: FUTURE_EXPIRY_LATE }, + ]) + const wrapper = mount(OpenAIQuotaResetCell, { props: { account } }) + + expect(wrapper.find(`[data-testid="reset-credit-expiry-target-${CREDIT_ID}"]`).exists()).toBe(true) + expect(wrapper.findAll('[data-testid^="reset-credit-expiry-target-"]')).toHaveLength(1) + + const shadow = mount(OpenAIQuotaResetCell, { + props: { account: { ...account, parent_account_id: 9 } }, + }) + expect(shadow.findAll('[data-testid^="reset-credit-expiry-target-"]')).toHaveLength(0) + }) + + it('保存具体卡的 credit_id 和提前分钟数', async () => { + const credits = [{ id: CREDIT_ID, expires_at: FUTURE_EXPIRY_EARLY }] + const account = makeResetCreditAccount(credits) + const updated = makeResetCreditAccount(credits, { + plan_id: '11111111-1111-4111-8111-111111111111', + credit_id: CREDIT_ID, + expires_at: FUTURE_EXPIRY_EARLY, + lead_time_minutes: 30, + }) + vi.mocked(setOpenAIResetCreditExpiryTarget).mockResolvedValue(updated) + const wrapper = mount(OpenAIQuotaResetCell, { + props: { account }, + global: { stubs: { teleport: true } }, + }) + + await wrapper.get(`[data-testid="reset-credit-expiry-target-${CREDIT_ID}"]`).trigger('click') + const dialog = visibleExpiryTargetDialog(wrapper) + await dialog.get('[data-testid="reset-credit-expiry-lead-minutes"]').setValue('30') + expect(dialog.text()).toContain('admin.accounts.openaiQuotaReset.expiryTarget.plannedExecution') + await dialog.find('.btn-primary').trigger('click') + await flushPromises() + + expect(setOpenAIResetCreditExpiryTarget).toHaveBeenCalledWith(1, { + credit_id: CREDIT_ID, + lead_time_minutes: 30, + }) + expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiryTarget.planSaved') + expect(wrapper.emitted('account-updated')).toEqual([[updated]]) + }) + + it('取消当前计划并回传更新后的账号', async () => { + const credits = [{ id: CREDIT_ID, expires_at: FUTURE_EXPIRY_LATE }] + const account = makeResetCreditAccount(credits, { + plan_id: '22222222-2222-4222-8222-222222222222', + credit_id: CREDIT_ID, + expires_at: FUTURE_EXPIRY_LATE, + lead_time_minutes: 60, + }) + const canceled = makeResetCreditAccount(credits) + vi.mocked(cancelOpenAIResetCreditExpiryTarget).mockResolvedValue(canceled) + const wrapper = mount(OpenAIQuotaResetCell, { props: { account } }) + + expect(wrapper.get('[data-testid="reset-credit-expiry-target-state"]').text()).toContain( + 'admin.accounts.openaiQuotaReset.expiryTarget.scheduledAt:', + ) + await wrapper.get('[data-testid="reset-credit-expiry-target-cancel"]').trigger('click') + await flushPromises() + + expect(cancelOpenAIResetCreditExpiryTarget).toHaveBeenCalledWith(1) + expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiryTarget.planCanceled') + expect(wrapper.emitted('account-updated')).toEqual([[canceled]]) + }) + + it('计划时间已过时提示保存后立即执行', async () => { + const expiresAt = new Date(Date.now() + 30 * 60_000).toISOString() + const account = makeResetCreditAccount([{ id: CREDIT_ID, expires_at: expiresAt }]) + const wrapper = mount(OpenAIQuotaResetCell, { + props: { account }, + global: { stubs: { teleport: true } }, + }) + + await wrapper.get(`[data-testid="reset-credit-expiry-target-${CREDIT_ID}"]`).trigger('click') + expect(visibleExpiryTargetDialog(wrapper).get('[data-testid="reset-credit-expiry-immediate-warning"]').text()).toContain( + 'admin.accounts.openaiQuotaReset.expiryTarget.executeImmediately', + ) + }) +}) diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts index ae0867b9f1d9..6b8b667fdf07 100644 --- a/frontend/src/i18n/locales/en/admin/accounts.ts +++ b/frontend/src/i18n/locales/en/admin/accounts.ts @@ -1533,6 +1533,24 @@ export default { noCredit: 'No credit', failed: 'Auto-reset failed' }, + expiryTarget: { + scheduleTooltip: 'Schedule this reset credit', + dialogTitle: 'Schedule reset credit', + creditExpiresAt: 'Credit expires', + plannedExecution: 'Planned execution', + leadTime: 'Use before expiry (minutes)', + durationMinutes: '{count} minutes', + durationHours: '{count} hours', + executeImmediately: 'The planned time has passed. Saving will run the plan immediately.', + savePlan: 'Save plan', + cancelPlan: 'Cancel plan', + planSaved: 'Scheduled-use plan saved', + planCanceled: 'Scheduled-use plan canceled', + scheduledAt: 'Scheduled {time}', + summary: 'Scheduled for {execution}; credit expires {expiry}; lead time {lead}', + creditUnavailable: 'This reset credit cannot be scheduled', + leadTimeInvalid: 'Lead time must be an integer from 5 to 10080 minutes' + }, confirmTitle: 'Confirm Weekly Limit Reset', confirmMessage: 'This will consume 1 reset credit to immediately restore the current window ({count} remaining). This action cannot be undone. Continue?' }, diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts index c277eb7c2fd8..582bf0882cd1 100644 --- a/frontend/src/i18n/locales/zh/admin/accounts.ts +++ b/frontend/src/i18n/locales/zh/admin/accounts.ts @@ -494,6 +494,24 @@ export default { noCredit: '无卡', failed: '自动重置失败' }, + expiryTarget: { + scheduleTooltip: '为这张重置卡设置定时使用', + dialogTitle: '设置重置卡定时使用', + creditExpiresAt: '重置卡到期', + plannedExecution: '计划执行', + leadTime: '到期前使用(分钟)', + durationMinutes: '{count} 分钟', + durationHours: '{count} 小时', + executeImmediately: '计划执行时间已过,保存后将立即执行。', + savePlan: '保存计划', + cancelPlan: '取消计划', + planSaved: '定时使用计划已保存', + planCanceled: '定时使用计划已取消', + scheduledAt: '定时 {time}', + summary: '计划执行 {execution};重置卡到期 {expiry};提前 {lead}', + creditUnavailable: '这张重置卡无法设置计划', + leadTimeInvalid: '提前时间必须是 5 到 10080 之间的整数分钟' + }, confirmTitle: '确认重置周限', confirmMessage: '将消耗 1 次重置次数立即恢复当前窗口,剩余 {count} 次。此操作不可撤销,确定继续吗?' }, diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 10a72a0a1745..65f2b964b21f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1132,8 +1132,14 @@ export interface Account { upstream_billing_probe?: UpstreamBillingProbeSnapshot codex_reset_credit_snapshot?: { available_count?: number - credits?: { expires_at?: string }[] + credits?: { id?: string; expires_at?: string }[] } + auto_reset_credit_expiry_target?: { + plan_id: string + credit_id: string + expires_at: string + lead_time_minutes: number + } | null auto_reset_credit_enabled?: boolean auto_reset_credit_5h_threshold?: number auto_reset_credit_7d_threshold?: number @@ -1144,6 +1150,7 @@ export interface Account { checked_at?: string last_result_at?: string error_code?: string + trigger_reason?: 'usage_threshold' | 'expiry_target' } } & Record) proxy_id: number | null