mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 11:33:18 +08:00
fix(openai): refresh usage after quota reset
This commit is contained in:
@@ -27,6 +27,7 @@ type OpenAIOAuthHandler struct {
|
||||
type openAIQuotaService interface {
|
||||
QueryUsage(ctx context.Context, accountID int64) (*service.OpenAIQuotaUsage, error)
|
||||
CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *service.OpenAIRateLimitResetCredits) error
|
||||
CachePostResetSnapshot(ctx context.Context, accountID int64, usage *service.OpenAIQuotaUsage) error
|
||||
ResetCredit(ctx context.Context, accountID int64) (*service.OpenAIQuotaResetResult, error)
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,12 @@ func (s *openAIQuotaWorkflowStub) CacheResetCreditsSnapshot(ctx context.Context,
|
||||
return s.cacheErr
|
||||
}
|
||||
|
||||
func (s *openAIQuotaWorkflowStub) CachePostResetSnapshot(ctx context.Context, _ int64, _ *service.OpenAIQuotaUsage) error {
|
||||
s.cacheCalls++
|
||||
s.cacheCtxErr = ctx.Err()
|
||||
return s.cacheErr
|
||||
}
|
||||
|
||||
type openAIAccountStateRecovererStub struct {
|
||||
err error
|
||||
calls int
|
||||
|
||||
@@ -54,6 +54,7 @@ type OpenAIAutoResetCreditState struct {
|
||||
type openAIAutoResetQuota interface {
|
||||
QueryUsage(ctx context.Context, accountID int64) (*OpenAIQuotaUsage, error)
|
||||
CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *OpenAIRateLimitResetCredits) error
|
||||
CachePostResetSnapshot(ctx context.Context, accountID int64, usage *OpenAIQuotaUsage) error
|
||||
ResetCreditTargeted(ctx context.Context, accountID int64, creditID, redeemRequestID string) (*OpenAIQuotaResetResult, error)
|
||||
}
|
||||
|
||||
|
||||
@@ -192,6 +192,10 @@ func (q *autoResetTestQuota) CacheResetCreditsSnapshot(context.Context, int64, *
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *autoResetTestQuota) CachePostResetSnapshot(context.Context, int64, *OpenAIQuotaUsage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *autoResetTestQuota) ResetCreditTargeted(_ context.Context, _ int64, creditID, redeemRequestID string) (*OpenAIQuotaResetResult, error) {
|
||||
if creditID == "" || redeemRequestID == "" {
|
||||
panic("targeted reset identifiers must be present")
|
||||
|
||||
@@ -15,7 +15,7 @@ const (
|
||||
|
||||
type openAIQuotaResetWorkflowQuota interface {
|
||||
QueryUsage(ctx context.Context, accountID int64) (*OpenAIQuotaUsage, error)
|
||||
CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *OpenAIRateLimitResetCredits) error
|
||||
CachePostResetSnapshot(ctx context.Context, accountID int64, usage *OpenAIQuotaUsage) error
|
||||
}
|
||||
|
||||
type openAIQuotaResetWorkflowRecoverer interface {
|
||||
@@ -60,7 +60,7 @@ func RunOpenAIQuotaResetPostProcess(
|
||||
slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error_code", infraerrors.Reason(usageErr))
|
||||
result.WarningCode = OpenAIQuotaResetWarningCacheRefreshFailed
|
||||
default:
|
||||
if err := quota.CacheResetCreditsSnapshot(ctx, accountID, usage.RateLimitResetCredits); err != nil {
|
||||
if err := quota.CachePostResetSnapshot(ctx, accountID, usage); err != nil {
|
||||
slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error_code", infraerrors.Reason(err))
|
||||
result.WarningCode = OpenAIQuotaResetWarningCacheRefreshFailed
|
||||
} else {
|
||||
|
||||
@@ -225,6 +225,23 @@ 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 {
|
||||
return s.cacheResetCreditsSnapshot(ctx, accountID, credits, nil)
|
||||
}
|
||||
|
||||
// CachePostResetSnapshot persists the credits and usage windows observed after a reset.
|
||||
func (s *OpenAIQuotaService) CachePostResetSnapshot(ctx context.Context, accountID int64, usage *OpenAIQuotaUsage) error {
|
||||
if usage == nil {
|
||||
return s.cacheResetCreditsSnapshot(ctx, accountID, nil, nil)
|
||||
}
|
||||
return s.cacheResetCreditsSnapshot(
|
||||
ctx,
|
||||
accountID,
|
||||
usage.RateLimitResetCredits,
|
||||
buildOpenAIAutoResetUsageUpdates(usage, time.Now()),
|
||||
)
|
||||
}
|
||||
|
||||
func (s *OpenAIQuotaService) cacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *OpenAIRateLimitResetCredits, updates map[string]any) error {
|
||||
if credits == nil || (credits.AvailableCount > 0 && len(credits.Credits) == 0) {
|
||||
return infraerrors.New(
|
||||
http.StatusBadGateway,
|
||||
@@ -232,9 +249,11 @@ func (s *OpenAIQuotaService) CacheResetCreditsSnapshot(ctx context.Context, acco
|
||||
"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 {
|
||||
if updates == nil {
|
||||
updates = make(map[string]any, 1)
|
||||
}
|
||||
updates[openaiQuotaResetCreditsKey] = credits
|
||||
if err := s.accountRepo.UpdateExtra(ctx, accountID, updates); err != nil {
|
||||
return infraerrors.New(
|
||||
http.StatusInternalServerError,
|
||||
"OPENAI_QUOTA_CACHE_WRITE_FAILED",
|
||||
|
||||
@@ -27,9 +27,10 @@ import (
|
||||
// stubQuotaAccountRepo 是多账号 AccountRepository stub,仅实现 GetByID。
|
||||
type stubQuotaAccountRepo struct {
|
||||
AccountRepository
|
||||
accounts map[int64]*Account
|
||||
extraUpdates map[int64]map[string]any
|
||||
extraUpdateErr error
|
||||
accounts map[int64]*Account
|
||||
extraUpdates map[int64]map[string]any
|
||||
extraUpdateCalls int
|
||||
extraUpdateErr error
|
||||
}
|
||||
|
||||
func (r *stubQuotaAccountRepo) GetByID(_ context.Context, id int64) (*Account, error) {
|
||||
@@ -53,6 +54,7 @@ func (r *stubQuotaAccountRepo) UpdateExtra(_ context.Context, id int64, updates
|
||||
if r.extraUpdateErr != nil {
|
||||
return r.extraUpdateErr
|
||||
}
|
||||
r.extraUpdateCalls++
|
||||
if r.extraUpdates == nil {
|
||||
r.extraUpdates = make(map[int64]map[string]any)
|
||||
}
|
||||
@@ -694,6 +696,29 @@ func TestCacheResetCreditsSnapshot(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCachePostResetSnapshot(t *testing.T) {
|
||||
repo := &stubQuotaAccountRepo{}
|
||||
svc := &OpenAIQuotaService{accountRepo: repo}
|
||||
credits := &OpenAIRateLimitResetCredits{AvailableCount: 0}
|
||||
usage := &OpenAIQuotaUsage{
|
||||
RateLimitResetCredits: credits,
|
||||
RateLimit: &OpenAIRateLimit{
|
||||
PrimaryWindow: &OpenAIRateLimitWindow{
|
||||
UsedPercent: 0, LimitWindowSeconds: 5 * 60 * 60, ResetAfterSeconds: 5 * 60 * 60,
|
||||
},
|
||||
SecondaryWindow: &OpenAIRateLimitWindow{
|
||||
UsedPercent: 0, LimitWindowSeconds: 7 * 24 * 60 * 60, ResetAfterSeconds: 7 * 24 * 60 * 60,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, svc.CachePostResetSnapshot(context.Background(), 100, usage))
|
||||
require.Equal(t, 1, repo.extraUpdateCalls)
|
||||
require.Equal(t, credits, repo.extraUpdates[100][openaiQuotaResetCreditsKey])
|
||||
require.Equal(t, 0.0, repo.extraUpdates[100]["codex_5h_used_percent"])
|
||||
require.Equal(t, 0.0, repo.extraUpdates[100]["codex_7d_used_percent"])
|
||||
}
|
||||
|
||||
// TestResetCreditGetByIDError_FailsClosed 验证守卫「失败关闭」语义:
|
||||
// 当守卫的 GetByID 发生瞬时错误时,ResetCredit 必须立即返回该错误,
|
||||
// 不得旁路进入 prepareUpstreamCall(否则影子账号会借 resolve 路径操作母账号)。
|
||||
|
||||
@@ -656,8 +656,6 @@ import { cnQuotaCellVisible as cnQuotaCellVisibleFn, cnBalanceCellVisible as cnB
|
||||
// Module-level cache shared across all AccountUsageCell instances
|
||||
const _usageCache = new Map<number, { data: AccountUsageInfo; ts: number }>()
|
||||
const USAGE_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
|
||||
// How long a quota-reset response may suppress the row-patch usage refetch.
|
||||
const SUPPRESS_USAGE_REFRESH_WINDOW_MS = 5 * 1000
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -699,7 +697,6 @@ const usageInfo = ref<AccountUsageInfo | null>(null)
|
||||
watch(usageInfo, (usage) => {
|
||||
if (usage) emit('usage-loaded', usage)
|
||||
})
|
||||
const suppressOpenAIUsageRefreshUntil = ref(0)
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const isDesktopViewport = ref(
|
||||
typeof window === 'undefined' ? true : window.matchMedia(desktopViewportQuery).matches
|
||||
@@ -1525,11 +1522,6 @@ const quotaTotalBar = computed((): QuotaBarInfo | null => {
|
||||
})
|
||||
|
||||
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.
|
||||
// The suppression is time-boxed so an unhandled emit (parent that ignores
|
||||
// account-updated) cannot latch it and swallow a later, unrelated refresh.
|
||||
suppressOpenAIUsageRefreshUntil.value = Date.now() + SUPPRESS_USAGE_REFRESH_WINDOW_MS
|
||||
emit('account-updated', account)
|
||||
}
|
||||
|
||||
@@ -1619,10 +1611,6 @@ watch(
|
||||
watch(openAIUsageRefreshKey, (nextKey, prevKey) => {
|
||||
if (!prevKey || nextKey === prevKey) return
|
||||
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return
|
||||
if (Date.now() < suppressOpenAIUsageRefreshUntil.value) {
|
||||
suppressOpenAIUsageRefreshUntil.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
if (isBatchManaged.value) {
|
||||
requestParentBatchUsage({ force: true })
|
||||
|
||||
@@ -519,7 +519,7 @@ describe('AccountUsageCell', () => {
|
||||
expect(wrapper.text()).toContain('5h|0|200')
|
||||
})
|
||||
|
||||
it('OpenAI 重置响应更新账号行时不会额外拉取 usage', async () => {
|
||||
it('OpenAI 重置响应更新账号行后重新拉取 usage', async () => {
|
||||
getUsage.mockResolvedValue({
|
||||
five_hour: {
|
||||
utilization: 0,
|
||||
@@ -560,7 +560,7 @@ describe('AccountUsageCell', () => {
|
||||
await wrapper.setProps({ account: updatedAccount as Account })
|
||||
await flushPromises()
|
||||
|
||||
expect(getUsage).toHaveBeenCalledTimes(1)
|
||||
expect(getUsage).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('OpenAI OAuth 已限额时显示 /usage API 返回的限额数据', async () => {
|
||||
|
||||
Reference in New Issue
Block a user