mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-19 16:17:56 +00:00
feat: cache OpenAI reset credit details
This commit is contained in:
@@ -206,7 +206,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
imageStorageSettingService := service.ProvideImageStorageSettingService(settingRepository, secretEncryptor, backupService, imageStorageFactory, configConfig)
|
||||
backupHandler := admin.NewBackupHandler(backupService, userService, imageStorageSettingService)
|
||||
oAuthHandler := admin.NewOAuthHandler(oAuthService)
|
||||
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService, openAIQuotaService)
|
||||
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService, openAIQuotaService, rateLimitService)
|
||||
geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService)
|
||||
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
|
||||
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI, openAIGatewayService)
|
||||
|
||||
@@ -144,7 +144,7 @@ func TestApplyOAuthCredentialsRejectsMalformedOpenAILongContextBillingBeforeMuta
|
||||
|
||||
func TestOpenAIOAuthCodexPATBoundaryRejectsMalformedOpenAILongContextBillingValueBeforeTokenValidation(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewOpenAIOAuthHandler(nil, newStubAdminService(), nil)
|
||||
handler := NewOpenAIOAuthHandler(nil, newStubAdminService(), nil, nil)
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery())
|
||||
router.POST("/openai/create-from-codex-pat", handler.CreateAccountFromCodexPAT)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -17,7 +20,33 @@ import (
|
||||
type OpenAIOAuthHandler struct {
|
||||
openaiOAuthService *service.OpenAIOAuthService
|
||||
adminService service.AdminService
|
||||
quotaService *service.OpenAIQuotaService
|
||||
quotaService openAIQuotaService
|
||||
rateLimitService openAIAccountStateRecoverer
|
||||
}
|
||||
|
||||
type openAIQuotaService interface {
|
||||
QueryUsage(ctx context.Context, accountID int64) (*service.OpenAIQuotaUsage, error)
|
||||
CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *service.OpenAIRateLimitResetCredits) error
|
||||
ResetCredit(ctx context.Context, accountID int64) (*service.OpenAIQuotaResetResult, error)
|
||||
}
|
||||
|
||||
type openAIAccountStateRecoverer interface {
|
||||
RecoverAccountState(ctx context.Context, accountID int64, options service.AccountRecoveryOptions) (*service.SuccessfulTestRecoveryResult, error)
|
||||
}
|
||||
|
||||
const (
|
||||
openAIQuotaResetWarningCacheRefreshFailed = "reset_credit_cache_refresh_failed"
|
||||
openAIQuotaResetWarningAccountRecoveryFailed = "account_state_recovery_failed"
|
||||
openAIQuotaResetWarningAccountRefreshFailed = "account_state_refresh_failed"
|
||||
)
|
||||
|
||||
type openAIQuotaResetResponse struct {
|
||||
service.OpenAIQuotaResetResult
|
||||
Quota *service.OpenAIQuotaUsage `json:"quota,omitempty"`
|
||||
Account *dto.Account `json:"account,omitempty"`
|
||||
CacheRefreshed bool `json:"cache_refreshed"`
|
||||
AccountStateRecovered bool `json:"account_state_recovered"`
|
||||
WarningCode string `json:"warning_code,omitempty"`
|
||||
}
|
||||
|
||||
func oauthPlatformFromPath(c *gin.Context) string {
|
||||
@@ -29,11 +58,13 @@ func NewOpenAIOAuthHandler(
|
||||
openaiOAuthService *service.OpenAIOAuthService,
|
||||
adminService service.AdminService,
|
||||
quotaService *service.OpenAIQuotaService,
|
||||
rateLimitService *service.RateLimitService,
|
||||
) *OpenAIOAuthHandler {
|
||||
return &OpenAIOAuthHandler{
|
||||
openaiOAuthService: openaiOAuthService,
|
||||
adminService: adminService,
|
||||
quotaService: quotaService,
|
||||
rateLimitService: rateLimitService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,7 +451,19 @@ func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) {
|
||||
response.BadRequest(c, "openai quota service is not enabled")
|
||||
return
|
||||
}
|
||||
persistResetCredits := false
|
||||
if raw, ok := c.GetQuery("persist_reset_credits"); ok {
|
||||
persistResetCredits, err = strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid persist_reset_credits value")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
usage, err := h.quotaService.QueryUsage(c.Request.Context(), accountID)
|
||||
if err == nil && persistResetCredits {
|
||||
err = h.quotaService.CacheResetCreditsSnapshot(c.Request.Context(), accountID, usage.RateLimitResetCredits)
|
||||
}
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
@@ -482,5 +525,50 @@ func (h *OpenAIOAuthHandler) ResetQuota(c *gin.Context) {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
if result == nil {
|
||||
response.Error(c, http.StatusInternalServerError, "openai quota reset returned an empty result")
|
||||
return
|
||||
}
|
||||
|
||||
resetResponse := openAIQuotaResetResponse{OpenAIQuotaResetResult: *result}
|
||||
usage, err := h.quotaService.QueryUsage(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", err)
|
||||
resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed
|
||||
response.Success(c, resetResponse)
|
||||
return
|
||||
}
|
||||
if err := h.quotaService.CacheResetCreditsSnapshot(c.Request.Context(), accountID, usage.RateLimitResetCredits); err != nil {
|
||||
slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", err)
|
||||
resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed
|
||||
response.Success(c, resetResponse)
|
||||
return
|
||||
}
|
||||
resetResponse.Quota = usage
|
||||
resetResponse.CacheRefreshed = true
|
||||
|
||||
if h.rateLimitService == nil {
|
||||
resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed
|
||||
response.Success(c, resetResponse)
|
||||
return
|
||||
}
|
||||
if _, err := h.rateLimitService.RecoverAccountState(c.Request.Context(), accountID, service.AccountRecoveryOptions{
|
||||
InvalidateToken: true,
|
||||
}); err != nil {
|
||||
slog.Warn("openai_quota_reset_account_recovery_failed", "account_id", accountID, "error", err)
|
||||
resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed
|
||||
response.Success(c, resetResponse)
|
||||
return
|
||||
}
|
||||
resetResponse.AccountStateRecovered = true
|
||||
|
||||
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
slog.Warn("openai_quota_reset_account_refresh_failed", "account_id", accountID, "error", err)
|
||||
resetResponse.WarningCode = openAIQuotaResetWarningAccountRefreshFailed
|
||||
response.Success(c, resetResponse)
|
||||
return
|
||||
}
|
||||
resetResponse.Account = dto.AccountFromService(account)
|
||||
response.Success(c, resetResponse)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
type openAIQuotaWorkflowStub struct {
|
||||
resetResult *service.OpenAIQuotaResetResult
|
||||
resetErr error
|
||||
queryResult *service.OpenAIQuotaUsage
|
||||
queryErr error
|
||||
cacheErr error
|
||||
|
||||
resetCalls int
|
||||
queryCalls int
|
||||
cacheCalls int
|
||||
}
|
||||
|
||||
func (s *openAIQuotaWorkflowStub) ResetCredit(context.Context, int64) (*service.OpenAIQuotaResetResult, error) {
|
||||
s.resetCalls++
|
||||
return s.resetResult, s.resetErr
|
||||
}
|
||||
|
||||
func (s *openAIQuotaWorkflowStub) QueryUsage(context.Context, int64) (*service.OpenAIQuotaUsage, error) {
|
||||
s.queryCalls++
|
||||
return s.queryResult, s.queryErr
|
||||
}
|
||||
|
||||
func (s *openAIQuotaWorkflowStub) CacheResetCreditsSnapshot(context.Context, int64, *service.OpenAIRateLimitResetCredits) error {
|
||||
s.cacheCalls++
|
||||
return s.cacheErr
|
||||
}
|
||||
|
||||
type openAIAccountStateRecovererStub struct {
|
||||
err error
|
||||
calls int
|
||||
accountID int64
|
||||
lastOptions service.AccountRecoveryOptions
|
||||
}
|
||||
|
||||
func (s *openAIAccountStateRecovererStub) RecoverAccountState(_ context.Context, accountID int64, options service.AccountRecoveryOptions) (*service.SuccessfulTestRecoveryResult, error) {
|
||||
s.calls++
|
||||
s.accountID = accountID
|
||||
s.lastOptions = options
|
||||
return &service.SuccessfulTestRecoveryResult{}, s.err
|
||||
}
|
||||
|
||||
type openAIResetAdminServiceStub struct {
|
||||
service.AdminService
|
||||
account *service.Account
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *openAIResetAdminServiceStub) GetAccount(context.Context, int64) (*service.Account, error) {
|
||||
s.calls++
|
||||
return s.account, s.err
|
||||
}
|
||||
|
||||
type openAIQuotaResetEnvelope struct {
|
||||
Code int `json:"code"`
|
||||
Data openAIQuotaResetResponse `json:"data"`
|
||||
}
|
||||
|
||||
func performOpenAIQuotaResetRequest(t *testing.T, handler *OpenAIOAuthHandler) (int, openAIQuotaResetEnvelope) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/openai/accounts/:id/reset-quota", handler.ResetQuota)
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/openai/accounts/42/reset-quota", nil)
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
var envelope openAIQuotaResetEnvelope
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &envelope))
|
||||
return recorder.Code, envelope
|
||||
}
|
||||
|
||||
func successfulOpenAIQuotaWorkflowStub() *openAIQuotaWorkflowStub {
|
||||
return &openAIQuotaWorkflowStub{
|
||||
resetResult: &service.OpenAIQuotaResetResult{
|
||||
Code: "success",
|
||||
WindowsReset: 1,
|
||||
},
|
||||
queryResult: &service.OpenAIQuotaUsage{
|
||||
FetchedAt: 123,
|
||||
RateLimitResetCredits: &service.OpenAIRateLimitResetCredits{
|
||||
AvailableCount: 0,
|
||||
Credits: []service.OpenAIRateLimitResetCreditDetail{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResetQuota_ResetFailureStopsWorkflow(t *testing.T) {
|
||||
quota := &openAIQuotaWorkflowStub{resetErr: errors.New("upstream reset failed")}
|
||||
recoverer := &openAIAccountStateRecovererStub{}
|
||||
handler := &OpenAIOAuthHandler{
|
||||
adminService: &openAIResetAdminServiceStub{},
|
||||
quotaService: quota,
|
||||
rateLimitService: recoverer,
|
||||
}
|
||||
|
||||
status, _ := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, status)
|
||||
require.Equal(t, 1, quota.resetCalls)
|
||||
require.Zero(t, quota.queryCalls)
|
||||
require.Zero(t, quota.cacheCalls)
|
||||
require.Zero(t, recoverer.calls)
|
||||
}
|
||||
|
||||
func TestOpenAIResetQuota_QueryFailureReturnsPartialSuccessAndStops(t *testing.T) {
|
||||
quota := successfulOpenAIQuotaWorkflowStub()
|
||||
quota.queryResult = nil
|
||||
quota.queryErr = errors.New("upstream query failed")
|
||||
recoverer := &openAIAccountStateRecovererStub{}
|
||||
handler := &OpenAIOAuthHandler{
|
||||
adminService: &openAIResetAdminServiceStub{},
|
||||
quotaService: quota,
|
||||
rateLimitService: recoverer,
|
||||
}
|
||||
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
|
||||
require.False(t, envelope.Data.CacheRefreshed)
|
||||
require.False(t, envelope.Data.AccountStateRecovered)
|
||||
require.Nil(t, envelope.Data.Quota)
|
||||
require.Equal(t, 1, quota.resetCalls)
|
||||
require.Equal(t, 1, quota.queryCalls)
|
||||
require.Zero(t, quota.cacheCalls)
|
||||
require.Zero(t, recoverer.calls)
|
||||
}
|
||||
|
||||
func TestOpenAIResetQuota_CacheFailureReturnsPartialSuccessAndStops(t *testing.T) {
|
||||
quota := successfulOpenAIQuotaWorkflowStub()
|
||||
quota.cacheErr = errors.New("cache write failed")
|
||||
recoverer := &openAIAccountStateRecovererStub{}
|
||||
handler := &OpenAIOAuthHandler{
|
||||
adminService: &openAIResetAdminServiceStub{},
|
||||
quotaService: quota,
|
||||
rateLimitService: recoverer,
|
||||
}
|
||||
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
|
||||
require.False(t, envelope.Data.CacheRefreshed)
|
||||
require.Nil(t, envelope.Data.Quota)
|
||||
require.Equal(t, 1, quota.resetCalls)
|
||||
require.Equal(t, 1, quota.queryCalls)
|
||||
require.Equal(t, 1, quota.cacheCalls)
|
||||
require.Zero(t, recoverer.calls)
|
||||
}
|
||||
|
||||
func TestOpenAIResetQuota_RecoveryFailureKeepsRefreshedQuota(t *testing.T) {
|
||||
quota := successfulOpenAIQuotaWorkflowStub()
|
||||
recoverer := &openAIAccountStateRecovererStub{err: errors.New("recovery failed")}
|
||||
adminService := &openAIResetAdminServiceStub{}
|
||||
handler := &OpenAIOAuthHandler{
|
||||
adminService: adminService,
|
||||
quotaService: quota,
|
||||
rateLimitService: recoverer,
|
||||
}
|
||||
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode)
|
||||
require.True(t, envelope.Data.CacheRefreshed)
|
||||
require.False(t, envelope.Data.AccountStateRecovered)
|
||||
require.NotNil(t, envelope.Data.Quota)
|
||||
require.Equal(t, 1, quota.resetCalls)
|
||||
require.Equal(t, 1, quota.queryCalls)
|
||||
require.Equal(t, 1, quota.cacheCalls)
|
||||
require.Equal(t, 1, recoverer.calls)
|
||||
require.Zero(t, adminService.calls)
|
||||
}
|
||||
|
||||
func TestOpenAIResetQuota_SuccessReturnsQuotaAndRecoveredAccount(t *testing.T) {
|
||||
quota := successfulOpenAIQuotaWorkflowStub()
|
||||
recoverer := &openAIAccountStateRecovererStub{}
|
||||
adminService := &openAIResetAdminServiceStub{account: &service.Account{
|
||||
ID: 42,
|
||||
Name: "recovered",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: false,
|
||||
}}
|
||||
handler := &OpenAIOAuthHandler{
|
||||
adminService: adminService,
|
||||
quotaService: quota,
|
||||
rateLimitService: recoverer,
|
||||
}
|
||||
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Empty(t, envelope.Data.WarningCode)
|
||||
require.True(t, envelope.Data.CacheRefreshed)
|
||||
require.True(t, envelope.Data.AccountStateRecovered)
|
||||
require.NotNil(t, envelope.Data.Quota)
|
||||
require.NotNil(t, envelope.Data.Account)
|
||||
require.Equal(t, int64(42), envelope.Data.Account.ID)
|
||||
require.False(t, envelope.Data.Account.Schedulable)
|
||||
require.Equal(t, int64(42), recoverer.accountID)
|
||||
require.True(t, recoverer.lastOptions.InvalidateToken)
|
||||
require.Equal(t, 1, quota.resetCalls)
|
||||
require.Equal(t, 1, quota.queryCalls)
|
||||
require.Equal(t, 1, quota.cacheCalls)
|
||||
require.Equal(t, 1, recoverer.calls)
|
||||
require.Equal(t, 1, adminService.calls)
|
||||
}
|
||||
|
||||
func TestOpenAIResetQuota_AccountRefreshFailureReportsRecoveredState(t *testing.T) {
|
||||
quota := successfulOpenAIQuotaWorkflowStub()
|
||||
recoverer := &openAIAccountStateRecovererStub{}
|
||||
adminService := &openAIResetAdminServiceStub{err: errors.New("account refresh failed")}
|
||||
handler := &OpenAIOAuthHandler{
|
||||
adminService: adminService,
|
||||
quotaService: quota,
|
||||
rateLimitService: recoverer,
|
||||
}
|
||||
|
||||
status, envelope := performOpenAIQuotaResetRequest(t, handler)
|
||||
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
require.Equal(t, openAIQuotaResetWarningAccountRefreshFailed, envelope.Data.WarningCode)
|
||||
require.True(t, envelope.Data.CacheRefreshed)
|
||||
require.True(t, envelope.Data.AccountStateRecovered)
|
||||
require.NotNil(t, envelope.Data.Quota)
|
||||
require.Nil(t, envelope.Data.Account)
|
||||
require.Equal(t, 1, quota.resetCalls)
|
||||
require.Equal(t, 1, quota.queryCalls)
|
||||
require.Equal(t, 1, quota.cacheCalls)
|
||||
require.Equal(t, 1, recoverer.calls)
|
||||
require.Equal(t, 1, adminService.calls)
|
||||
}
|
||||
@@ -20,7 +20,7 @@ func TestCreateShadow_ReturnsCreatedShadow(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
stub := &stubAdminService{}
|
||||
h := NewOpenAIOAuthHandler(nil, stub, nil)
|
||||
h := NewOpenAIOAuthHandler(nil, stub, nil, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
|
||||
@@ -54,7 +54,7 @@ func TestCreateShadow_ReturnsCreatedShadow(t *testing.T) {
|
||||
func TestCreateShadow_InvalidID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil)
|
||||
h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
|
||||
@@ -72,7 +72,7 @@ func TestCreateShadow_ServiceError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
stub := &stubAdminService{createSparkShadowErr: errors.New("database unavailable")}
|
||||
h := NewOpenAIOAuthHandler(nil, stub, nil)
|
||||
h := NewOpenAIOAuthHandler(nil, stub, nil, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
|
||||
@@ -91,7 +91,7 @@ func TestCreateShadow_ServiceError(t *testing.T) {
|
||||
func TestCreateShadow_BadBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil)
|
||||
h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil, nil)
|
||||
|
||||
router := gin.New()
|
||||
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
|
||||
|
||||
@@ -56,6 +56,7 @@ var schedulerNeutralExtraKeyPrefixes = []string{
|
||||
"codex_secondary_",
|
||||
"codex_5h_",
|
||||
"codex_7d_",
|
||||
"codex_reset_credit_",
|
||||
"passive_usage_",
|
||||
"upstream_billing_probe",
|
||||
"upstream_billing_rate_sync",
|
||||
|
||||
@@ -34,6 +34,7 @@ const (
|
||||
openaiQuotaSecFetchSite = "none"
|
||||
openaiQuotaSecFetchMode = "no-cors"
|
||||
openaiQuotaSecFetchDest = "empty"
|
||||
openaiQuotaResetCreditsKey = "codex_reset_credit_snapshot"
|
||||
)
|
||||
|
||||
// OpenAIRateLimitWindow describes a single rate-limit window returned by
|
||||
@@ -206,6 +207,28 @@ func (s *OpenAIQuotaService) QueryUsage(ctx context.Context, accountID int64) (*
|
||||
return &payload, nil
|
||||
}
|
||||
|
||||
// CacheResetCreditsSnapshot persists a complete reset-credit snapshot after an
|
||||
// explicit UI refresh. Missing expiration details leave the old cache intact.
|
||||
func (s *OpenAIQuotaService) CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *OpenAIRateLimitResetCredits) error {
|
||||
if credits == nil || (credits.AvailableCount > 0 && credits.Credits == nil) {
|
||||
return infraerrors.New(
|
||||
http.StatusBadGateway,
|
||||
"OPENAI_QUOTA_RESET_CREDITS_REFRESH_FAILED",
|
||||
"failed to refresh reset-credit expiration details; cached data was preserved",
|
||||
)
|
||||
}
|
||||
if err := s.accountRepo.UpdateExtra(ctx, accountID, map[string]any{
|
||||
openaiQuotaResetCreditsKey: credits,
|
||||
}); err != nil {
|
||||
return infraerrors.New(
|
||||
http.StatusInternalServerError,
|
||||
"OPENAI_QUOTA_CACHE_WRITE_FAILED",
|
||||
"failed to cache reset-credit details",
|
||||
).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *OpenAIQuotaService) queryResetCreditDetails(ctx context.Context, client *req.Client, accessToken, chatGPTAccountID string, fedRAMP bool, accountID int64) *openAIRateLimitResetCreditDetails {
|
||||
quotaHeaders, _, headerErr := s.buildCodexQuotaHeaders(ctx, accountID, accessToken, chatGPTAccountID, fedRAMP)
|
||||
if headerErr != nil {
|
||||
|
||||
@@ -27,7 +27,9 @@ import (
|
||||
// stubQuotaAccountRepo 是多账号 AccountRepository stub,仅实现 GetByID。
|
||||
type stubQuotaAccountRepo struct {
|
||||
AccountRepository
|
||||
accounts map[int64]*Account
|
||||
accounts map[int64]*Account
|
||||
extraUpdates map[int64]map[string]any
|
||||
extraUpdateErr error
|
||||
}
|
||||
|
||||
func (r *stubQuotaAccountRepo) GetByID(_ context.Context, id int64) (*Account, error) {
|
||||
@@ -47,6 +49,17 @@ func (r *stubQuotaAccountRepo) UpdateCredentials(_ context.Context, id int64, cr
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *stubQuotaAccountRepo) UpdateExtra(_ context.Context, id int64, updates map[string]any) error {
|
||||
if r.extraUpdateErr != nil {
|
||||
return r.extraUpdateErr
|
||||
}
|
||||
if r.extraUpdates == nil {
|
||||
r.extraUpdates = make(map[int64]map[string]any)
|
||||
}
|
||||
r.extraUpdates[id] = updates
|
||||
return nil
|
||||
}
|
||||
|
||||
// stubQuotaTokenCache 实现 OpenAITokenCache,返回预设静态 token。
|
||||
type stubQuotaTokenCache struct {
|
||||
tokens map[string]string
|
||||
@@ -535,6 +548,14 @@ func TestQueryUsageIncludesResetCreditExpirations_EndToEnd(t *testing.T) {
|
||||
{ExpiresAt: "2026-07-03T04:05:06Z"},
|
||||
{ExpiresAt: "2026-07-04T04:05:06Z"},
|
||||
}, usage.RateLimitResetCredits.Credits)
|
||||
require.NoError(t, svc.CacheResetCreditsSnapshot(ctx, 100, usage.RateLimitResetCredits))
|
||||
require.Equal(t, &OpenAIRateLimitResetCredits{
|
||||
AvailableCount: 2,
|
||||
Credits: []OpenAIRateLimitResetCreditDetail{
|
||||
{ExpiresAt: "2026-07-03T04:05:06Z"},
|
||||
{ExpiresAt: "2026-07-04T04:05:06Z"},
|
||||
},
|
||||
}, repo.extraUpdates[100][openaiQuotaResetCreditsKey])
|
||||
|
||||
encoded, err := json.Marshal(usage)
|
||||
require.NoError(t, err)
|
||||
@@ -584,6 +605,42 @@ func TestQueryUsageResetCreditDetails401NonFatal(t *testing.T) {
|
||||
require.Equal(t, 1, usage.RateLimitResetCredits.AvailableCount)
|
||||
require.Equal(t, 1, detailCalls)
|
||||
require.Empty(t, usage.RateLimitResetCredits.Credits)
|
||||
require.Empty(t, repo.extraUpdates)
|
||||
}
|
||||
|
||||
func TestCacheResetCreditsSnapshot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("zero count allows an empty expiration list", func(t *testing.T) {
|
||||
repo := &stubQuotaAccountRepo{}
|
||||
svc := &OpenAIQuotaService{accountRepo: repo}
|
||||
credits := &OpenAIRateLimitResetCredits{AvailableCount: 0}
|
||||
|
||||
require.NoError(t, svc.CacheResetCreditsSnapshot(ctx, 100, credits))
|
||||
require.Equal(t, credits, repo.extraUpdates[100][openaiQuotaResetCreditsKey])
|
||||
})
|
||||
|
||||
t.Run("missing expiration list preserves the cache", func(t *testing.T) {
|
||||
repo := &stubQuotaAccountRepo{}
|
||||
svc := &OpenAIQuotaService{accountRepo: repo}
|
||||
|
||||
err := svc.CacheResetCreditsSnapshot(ctx, 100, &OpenAIRateLimitResetCredits{AvailableCount: 1})
|
||||
|
||||
require.Error(t, err)
|
||||
require.Empty(t, repo.extraUpdates)
|
||||
})
|
||||
|
||||
t.Run("repository errors are returned", func(t *testing.T) {
|
||||
repo := &stubQuotaAccountRepo{extraUpdateErr: errors.New("database unavailable")}
|
||||
svc := &OpenAIQuotaService{accountRepo: repo}
|
||||
|
||||
err := svc.CacheResetCreditsSnapshot(ctx, 100, &OpenAIRateLimitResetCredits{
|
||||
AvailableCount: 1,
|
||||
Credits: []OpenAIRateLimitResetCreditDetail{{ExpiresAt: "2026-07-03T04:05:06Z"}},
|
||||
})
|
||||
|
||||
require.ErrorContains(t, err, "database unavailable")
|
||||
})
|
||||
}
|
||||
|
||||
// TestResetCreditGetByIDError_FailsClosed 验证守卫「失败关闭」语义:
|
||||
|
||||
@@ -834,13 +834,26 @@ export interface OpenAIQuotaResetResult {
|
||||
code: string
|
||||
credit?: OpenAIQuotaResetCredit | null
|
||||
windows_reset: number
|
||||
quota?: OpenAIQuotaUsage | null
|
||||
account?: Account | null
|
||||
cache_refreshed: boolean
|
||||
account_state_recovered: boolean
|
||||
warning_code?:
|
||||
| 'reset_credit_cache_refresh_failed'
|
||||
| 'account_state_recovery_failed'
|
||||
| 'account_state_refresh_failed'
|
||||
}
|
||||
|
||||
/**
|
||||
* Query OpenAI/Codex rate-limit usage for an OAuth account.
|
||||
*/
|
||||
export async function queryOpenAIQuota(id: number): Promise<OpenAIQuotaUsage> {
|
||||
const { data } = await apiClient.get<OpenAIQuotaUsage>(`/admin/openai/accounts/${id}/quota`)
|
||||
export async function queryOpenAIQuota(
|
||||
id: number,
|
||||
options?: { persistResetCredits?: boolean }
|
||||
): Promise<OpenAIQuotaUsage> {
|
||||
const { data } = await apiClient.get<OpenAIQuotaUsage>(`/admin/openai/accounts/${id}/quota`, {
|
||||
params: options?.persistResetCredits === true ? { persist_reset_credits: true } : undefined
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
refresh button is rendered via the pre-actions slot so the user sees a
|
||||
single row of related buttons instead of two stacked rows.
|
||||
-->
|
||||
<OpenAIQuotaResetCell :account="account">
|
||||
<OpenAIQuotaResetCell :account="account" @account-updated="handleQuotaResetAccountUpdated">
|
||||
<template #pre-actions>
|
||||
<button
|
||||
type="button"
|
||||
@@ -186,7 +186,11 @@
|
||||
<div v-else>
|
||||
<div class="text-xs text-gray-400">-</div>
|
||||
<!-- Always allow on-demand upstream quota query, even before local data exists. -->
|
||||
<OpenAIQuotaResetCell :account="account" class="mt-1" />
|
||||
<OpenAIQuotaResetCell
|
||||
:account="account"
|
||||
class="mt-1"
|
||||
@account-updated="handleQuotaResetAccountUpdated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -654,6 +658,10 @@ const props = withDefaults(
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'account-updated': [account: Account]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const desktopViewportQuery = '(min-width: 768px)'
|
||||
|
||||
@@ -664,6 +672,7 @@ const loading = ref(false)
|
||||
const activeQueryLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const usageInfo = ref<AccountUsageInfo | null>(null)
|
||||
const suppressNextOpenAIUsageRefresh = ref(false)
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const isDesktopViewport = ref(
|
||||
typeof window === 'undefined' ? true : window.matchMedia(desktopViewportQuery).matches
|
||||
@@ -1473,6 +1482,13 @@ const quotaTotalBar = computed((): QuotaBarInfo | null => {
|
||||
return makeQuotaBar(props.account.quota_used ?? 0, limit)
|
||||
})
|
||||
|
||||
const handleQuotaResetAccountUpdated = (account: Account) => {
|
||||
// The reset response already carries authoritative quota and account data.
|
||||
// Avoid turning the parent patch into a second automatic /usage request.
|
||||
suppressNextOpenAIUsageRefresh.value = true
|
||||
emit('account-updated', account)
|
||||
}
|
||||
|
||||
// ===== Key account today stats formatters =====
|
||||
|
||||
const formatKeyRequests = computed(() => {
|
||||
@@ -1517,6 +1533,10 @@ onMounted(() => {
|
||||
watch(openAIUsageRefreshKey, (nextKey, prevKey) => {
|
||||
if (!prevKey || nextKey === prevKey) return
|
||||
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return
|
||||
if (suppressNextOpenAIUsageRefresh.value) {
|
||||
suppressNextOpenAIUsageRefresh.value = false
|
||||
return
|
||||
}
|
||||
|
||||
_usageCache.delete(props.account.id)
|
||||
requestAutoLoad()
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
class="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-[10px] font-medium text-blue-600 transition-colors hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-50 dark:text-blue-400 dark:hover:bg-blue-900/30"
|
||||
:disabled="loading || resetting"
|
||||
:title="countButtonTitle"
|
||||
@click="handleQuery"
|
||||
@click="handleQuery({ persistResetCredits: true })"
|
||||
>
|
||||
<svg
|
||||
class="h-2.5 w-2.5"
|
||||
@@ -111,6 +111,12 @@
|
||||
>
|
||||
{{ truncatedError }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="resetWarning"
|
||||
class="text-[10px] text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
{{ resetWarning }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="resetMessage"
|
||||
class="text-[10px] text-emerald-600 dark:text-emerald-400"
|
||||
@@ -147,6 +153,10 @@ const props = defineProps<{
|
||||
account: Account
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'account-updated': [account: Account]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Visible only for OpenAI OAuth accounts.
|
||||
@@ -156,17 +166,51 @@ const loading = ref(false)
|
||||
const resetting = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const data = ref<OpenAIQuotaUsage | null>(null)
|
||||
const cachedData = ref<OpenAIQuotaUsage | null>(null)
|
||||
const resetMessage = ref<string | null>(null)
|
||||
const resetWarning = ref<string | null>(null)
|
||||
const showResetConfirm = ref(false)
|
||||
const showResetCreditDetails = ref(false)
|
||||
|
||||
const readCachedResetCredits = (account: Account): OpenAIQuotaUsage | null => {
|
||||
const cached = account.extra?.codex_reset_credit_snapshot
|
||||
if (!cached || typeof cached !== 'object' || Array.isArray(cached)) return null
|
||||
|
||||
const { available_count: count, credits: rawCredits } = cached as {
|
||||
available_count?: unknown
|
||||
credits?: unknown
|
||||
}
|
||||
if (typeof count !== 'number' || !Number.isFinite(count)) return null
|
||||
|
||||
const credits: { expires_at?: string }[] = []
|
||||
if (Array.isArray(rawCredits)) {
|
||||
for (const credit of rawCredits) {
|
||||
if (!credit || typeof credit !== 'object') continue
|
||||
const expiresAt = (credit as { expires_at?: unknown }).expires_at
|
||||
if (typeof expiresAt === 'string' && expiresAt.trim() !== '') {
|
||||
credits.push({ expires_at: expiresAt })
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
fetched_at: 0,
|
||||
rate_limit_reset_credits: {
|
||||
available_count: count,
|
||||
credits
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cachedData.value = readCachedResetCredits(props.account)
|
||||
data.value = cachedData.value
|
||||
|
||||
// 影子账号的额度查询会 resolve 到母账号,但影子本身不支持重置(后端返回 409);
|
||||
// 重置必须在母账号上进行。前端据此禁用影子的重置入口(外审 F6)。
|
||||
const isShadow = computed(() => props.account.parent_account_id != null)
|
||||
|
||||
const availableResetCount = computed(() => data.value?.rate_limit_reset_credits?.available_count ?? 0)
|
||||
const resetCreditExpirations = computed(() =>
|
||||
(data.value?.rate_limit_reset_credits?.credits ?? [])
|
||||
(cachedData.value?.rate_limit_reset_credits?.credits ?? [])
|
||||
.map((credit) => credit.expires_at?.trim() ?? '')
|
||||
.filter((expiresAt) => expiresAt.length > 0)
|
||||
.sort(compareResetCreditExpiry)
|
||||
@@ -260,14 +304,19 @@ const toggleResetCreditDetails = () => {
|
||||
showResetCreditDetails.value = !showResetCreditDetails.value
|
||||
}
|
||||
|
||||
const handleQuery = async () => {
|
||||
const handleQuery = async (options?: { persistResetCredits?: boolean }) => {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
resetMessage.value = null
|
||||
resetWarning.value = null
|
||||
showResetCreditDetails.value = false
|
||||
try {
|
||||
data.value = await queryOpenAIQuota(props.account.id)
|
||||
const result = options
|
||||
? await queryOpenAIQuota(props.account.id, options)
|
||||
: await queryOpenAIQuota(props.account.id)
|
||||
data.value = result
|
||||
if (options?.persistResetCredits) cachedData.value = result
|
||||
} catch (e) {
|
||||
error.value = extractErrorMessage(e)
|
||||
} finally {
|
||||
@@ -294,15 +343,30 @@ const confirmReset = async () => {
|
||||
resetting.value = true
|
||||
error.value = null
|
||||
resetMessage.value = null
|
||||
resetWarning.value = null
|
||||
try {
|
||||
const result: OpenAIQuotaResetResult = await resetOpenAIQuota(props.account.id)
|
||||
// Refresh the reset-credit count so the badge reflects the consumed credit.
|
||||
// handleQuery clears resetMessage on entry, so the success toast is set
|
||||
// AFTER it resolves.
|
||||
await handleQuery()
|
||||
resetMessage.value = t('admin.accounts.openaiQuotaReset.resetSuccess', {
|
||||
windows: result.windows_reset
|
||||
})
|
||||
if (result.cache_refreshed && result.quota) {
|
||||
data.value = result.quota
|
||||
cachedData.value = result.quota
|
||||
showResetCreditDetails.value = false
|
||||
}
|
||||
if (result.account) emit('account-updated', result.account)
|
||||
|
||||
if (result.warning_code === 'reset_credit_cache_refresh_failed') {
|
||||
// The persisted snapshot is intentionally preserved, but it is no longer
|
||||
// authoritative enough to allow another credit consumption.
|
||||
data.value = null
|
||||
resetWarning.value = t('admin.accounts.openaiQuotaReset.resetCacheRefreshFailed')
|
||||
} else if (result.warning_code === 'account_state_recovery_failed') {
|
||||
resetWarning.value = t('admin.accounts.openaiQuotaReset.resetAccountRecoveryFailed')
|
||||
} else if (result.warning_code === 'account_state_refresh_failed') {
|
||||
resetWarning.value = t('admin.accounts.openaiQuotaReset.resetAccountRefreshFailed')
|
||||
} else {
|
||||
resetMessage.value = t('admin.accounts.openaiQuotaReset.resetSuccess', {
|
||||
windows: result.windows_reset
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = extractErrorMessage(e)
|
||||
} finally {
|
||||
@@ -314,9 +378,11 @@ watch(
|
||||
() => props.account.id,
|
||||
() => {
|
||||
// Account row may be reused across paginated lists; reset local state.
|
||||
data.value = null
|
||||
cachedData.value = readCachedResetCredits(props.account)
|
||||
data.value = cachedData.value
|
||||
error.value = null
|
||||
resetMessage.value = null
|
||||
resetWarning.value = null
|
||||
loading.value = false
|
||||
resetting.value = false
|
||||
showResetConfirm.value = false
|
||||
|
||||
@@ -509,7 +509,51 @@ describe('AccountUsageCell', () => {
|
||||
|
||||
await flushPromises()
|
||||
expect(getUsage).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper.text()).toContain('5h|0|200')
|
||||
expect(wrapper.text()).toContain('5h|0|200')
|
||||
})
|
||||
|
||||
it('OpenAI 重置响应更新账号行时不会额外拉取 usage', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
five_hour: {
|
||||
utilization: 0,
|
||||
resets_at: null,
|
||||
remaining_seconds: 0
|
||||
},
|
||||
seven_day: null
|
||||
})
|
||||
const account = makeAccount({
|
||||
id: 2004,
|
||||
platform: 'openai',
|
||||
type: 'oauth',
|
||||
updated_at: '2026-03-07T10:00:00Z',
|
||||
extra: {}
|
||||
})
|
||||
const wrapper = mount(AccountUsageCell, {
|
||||
props: { account },
|
||||
global: {
|
||||
stubs: {
|
||||
UsageProgressBar: true,
|
||||
AccountQuotaInfo: true,
|
||||
OpenAIQuotaResetCell: {
|
||||
props: ['account'],
|
||||
emits: ['account-updated'],
|
||||
template: '<button data-test="quota-reset-result" @click="$emit(\'account-updated\', { ...account, updated_at: \'2026-03-07T10:01:00Z\' })" />'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
expect(getUsage).toHaveBeenCalledTimes(1)
|
||||
|
||||
await wrapper.get('[data-test="quota-reset-result"]').trigger('click')
|
||||
const updatedAccount = wrapper.emitted<Account[]>('account-updated')?.[0]?.[0]
|
||||
expect(updatedAccount?.updated_at).toBe('2026-03-07T10:01:00Z')
|
||||
|
||||
await wrapper.setProps({ account: updatedAccount as Account })
|
||||
await flushPromises()
|
||||
|
||||
expect(getUsage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('OpenAI OAuth 已限额时显示 /usage API 返回的限额数据', async () => {
|
||||
|
||||
+138
-2
@@ -1,8 +1,9 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import OpenAIQuotaResetCell from '../OpenAIQuotaResetCell.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import type { Account } from '@/types'
|
||||
import { queryOpenAIQuota } from '@/api/admin/accounts'
|
||||
import { queryOpenAIQuota, resetOpenAIQuota } from '@/api/admin/accounts'
|
||||
|
||||
vi.mock('@/api/admin/accounts', () => ({
|
||||
queryOpenAIQuota: vi.fn(),
|
||||
@@ -55,6 +56,7 @@ const resetButton = (wrapper: ReturnType<typeof mount>) =>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(queryOpenAIQuota).mockReset()
|
||||
vi.mocked(resetOpenAIQuota).mockReset()
|
||||
})
|
||||
|
||||
describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
|
||||
@@ -78,6 +80,29 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('从账号 extra 缓存恢复重置卡次数和到期时间', () => {
|
||||
const account = makeAccount({
|
||||
parent_account_id: null,
|
||||
extra: {
|
||||
codex_reset_credit_snapshot: {
|
||||
available_count: 2,
|
||||
credits: [
|
||||
{ expires_at: '2026-07-05T04:05:06Z' },
|
||||
{ expires_at: '2026-07-03T04:05:06Z' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
|
||||
|
||||
expect(queryOpenAIQuota).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.count')
|
||||
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:')
|
||||
expect(wrapper.text()).toContain('+1')
|
||||
expect(resetButton(wrapper).attributes('disabled')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('查询后默认折叠为最早到期时间,点击 +N 展开完整列表', async () => {
|
||||
vi.mocked(queryOpenAIQuota).mockResolvedValue({
|
||||
rate_limit_reset_credits: {
|
||||
@@ -97,7 +122,7 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
|
||||
await wrapper.findAll('button')[0].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(queryOpenAIQuota).toHaveBeenCalledWith(1)
|
||||
expect(queryOpenAIQuota).toHaveBeenCalledWith(1, { persistResetCredits: true })
|
||||
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:')
|
||||
expect(wrapper.text()).toContain('+2')
|
||||
expect(wrapper.text()).not.toContain('not-a-date')
|
||||
@@ -136,4 +161,115 @@ describe('OpenAIQuotaResetCell — 外审 F6:影子禁用重置', () => {
|
||||
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('重置成功后直接使用响应中的最新缓存并回传恢复后的账号', async () => {
|
||||
const recoveredAccount = makeAccount({
|
||||
parent_account_id: null,
|
||||
status: 'active',
|
||||
error_message: null,
|
||||
})
|
||||
vi.mocked(resetOpenAIQuota).mockResolvedValue({
|
||||
code: 'success',
|
||||
windows_reset: 1,
|
||||
cache_refreshed: true,
|
||||
account_state_recovered: true,
|
||||
quota: {
|
||||
rate_limit_reset_credits: {
|
||||
available_count: 0,
|
||||
credits: [],
|
||||
},
|
||||
fetched_at: 1770000000,
|
||||
},
|
||||
account: recoveredAccount,
|
||||
})
|
||||
const account = makeAccount({
|
||||
parent_account_id: null,
|
||||
extra: {
|
||||
codex_reset_credit_snapshot: {
|
||||
available_count: 1,
|
||||
credits: [{ expires_at: '2026-07-03T04:05:06Z' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
|
||||
|
||||
await resetButton(wrapper).trigger('click')
|
||||
wrapper.findComponent(ConfirmDialog).vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
|
||||
expect(resetOpenAIQuota).toHaveBeenCalledWith(1)
|
||||
expect(queryOpenAIQuota).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).not.toContain('admin.accounts.openaiQuotaReset.expiresAt:')
|
||||
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.resetSuccess')
|
||||
expect(wrapper.emitted('account-updated')).toEqual([[recoveredAccount]])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('缓存刷新失败时保留旧缓存并显示部分成功警告', async () => {
|
||||
vi.mocked(resetOpenAIQuota).mockResolvedValue({
|
||||
code: 'success',
|
||||
windows_reset: 1,
|
||||
cache_refreshed: false,
|
||||
account_state_recovered: false,
|
||||
warning_code: 'reset_credit_cache_refresh_failed',
|
||||
})
|
||||
const account = makeAccount({
|
||||
parent_account_id: null,
|
||||
extra: {
|
||||
codex_reset_credit_snapshot: {
|
||||
available_count: 1,
|
||||
credits: [{ expires_at: '2026-07-03T04:05:06Z' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
|
||||
|
||||
await resetButton(wrapper).trigger('click')
|
||||
wrapper.findComponent(ConfirmDialog).vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
|
||||
expect(queryOpenAIQuota).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.expiresAt:')
|
||||
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.resetCacheRefreshFailed')
|
||||
expect(resetButton(wrapper).attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.emitted('account-updated')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('账号恢复失败时使用已刷新的缓存并显示部分成功警告', async () => {
|
||||
vi.mocked(resetOpenAIQuota).mockResolvedValue({
|
||||
code: 'success',
|
||||
windows_reset: 1,
|
||||
cache_refreshed: true,
|
||||
account_state_recovered: false,
|
||||
warning_code: 'account_state_recovery_failed',
|
||||
quota: {
|
||||
rate_limit_reset_credits: {
|
||||
available_count: 0,
|
||||
credits: [],
|
||||
},
|
||||
fetched_at: 1770000000,
|
||||
},
|
||||
})
|
||||
const account = makeAccount({
|
||||
parent_account_id: null,
|
||||
extra: {
|
||||
codex_reset_credit_snapshot: {
|
||||
available_count: 1,
|
||||
credits: [{ expires_at: '2026-07-03T04:05:06Z' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
const wrapper = mount(OpenAIQuotaResetCell, { props: { account } })
|
||||
|
||||
await resetButton(wrapper).trigger('click')
|
||||
wrapper.findComponent(ConfirmDialog).vm.$emit('confirm')
|
||||
await flushPromises()
|
||||
|
||||
expect(queryOpenAIQuota).not.toHaveBeenCalled()
|
||||
expect(wrapper.text()).not.toContain('admin.accounts.openaiQuotaReset.expiresAt:')
|
||||
expect(wrapper.text()).toContain('admin.accounts.openaiQuotaReset.resetAccountRecoveryFailed')
|
||||
expect(wrapper.emitted('account-updated')).toBeUndefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1374,7 +1374,10 @@ export default {
|
||||
collapseExpirations: 'Collapse reset credit expirations',
|
||||
expirationDetails: 'Reset credit expiration details',
|
||||
noCreditsAvailable: 'No reset credits available',
|
||||
resetSuccess: 'Reset {windows} window(s)',
|
||||
resetSuccess: 'Reset {windows} window(s); credits and account state updated',
|
||||
resetCacheRefreshFailed: 'The window was reset, but the reset-credit cache refresh failed. Account state was not recovered.',
|
||||
resetAccountRecoveryFailed: 'The window and reset-credit cache were updated, but account state recovery failed.',
|
||||
resetAccountRefreshFailed: 'The window, reset-credit cache, and account state were updated, but the latest account display could not be loaded.',
|
||||
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?'
|
||||
},
|
||||
|
||||
@@ -429,7 +429,10 @@ export default {
|
||||
collapseExpirations: '收起重置次数到期时间',
|
||||
expirationDetails: '重置次数到期明细',
|
||||
noCreditsAvailable: '没有可用的重置次数',
|
||||
resetSuccess: '已重置 {windows} 个窗口',
|
||||
resetSuccess: '已重置 {windows} 个窗口,次数和账号状态已更新',
|
||||
resetCacheRefreshFailed: '窗口已重置,但重置次数缓存刷新失败,账号状态未恢复。',
|
||||
resetAccountRecoveryFailed: '窗口和重置次数缓存已更新,但账号状态恢复失败。',
|
||||
resetAccountRefreshFailed: '窗口、重置次数缓存和账号状态已更新,但无法加载最新账号显示。',
|
||||
confirmTitle: '确认重置周限',
|
||||
confirmMessage: '将消耗 1 次重置次数立即恢复当前窗口,剩余 {count} 次。此操作不可撤销,确定继续吗?'
|
||||
},
|
||||
|
||||
@@ -318,6 +318,7 @@
|
||||
:today-stats="todayStatsByAccountId[String(row.id)] ?? null"
|
||||
:today-stats-loading="todayStatsLoading"
|
||||
:manual-refresh-token="usageManualRefreshToken"
|
||||
@account-updated="patchAccountInList"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-proxy="{ row }">
|
||||
|
||||
Reference in New Issue
Block a user