fix: zero expired codex windows in backend, use /usage API as single frontend data source

This commit is contained in:
Ethan0x0000
2026-03-16 19:46:07 +08:00
parent aa5846b282
commit 7fde9ebbc2
6 changed files with 61 additions and 394 deletions

View File

@@ -1042,6 +1042,11 @@ func buildCodexUsageProgressFromExtra(extra map[string]any, window string, now t
} }
} }
// 窗口已过期resetAt 在 now 之前)→ 额度已重置,归零
if progress.ResetsAt != nil && !now.Before(*progress.ResetsAt) {
progress.Utilization = 0
}
return progress return progress
} }

View File

@@ -148,3 +148,54 @@ func TestAccountUsageService_PersistOpenAICodexProbeSnapshotSetsRateLimit(t *tes
t.Fatal("waiting for codex probe rate limit persistence timed out") t.Fatal("waiting for codex probe rate limit persistence timed out")
} }
} }
func TestBuildCodexUsageProgressFromExtra_ZerosExpiredWindow(t *testing.T) {
t.Parallel()
now := time.Date(2026, 3, 16, 12, 0, 0, 0, time.UTC)
t.Run("expired 5h window zeroes utilization", func(t *testing.T) {
extra := map[string]any{
"codex_5h_used_percent": 42.0,
"codex_5h_reset_at": "2026-03-16T10:00:00Z", // 2h ago
}
progress := buildCodexUsageProgressFromExtra(extra, "5h", now)
if progress == nil {
t.Fatal("expected non-nil progress")
}
if progress.Utilization != 0 {
t.Fatalf("expected Utilization=0 for expired window, got %v", progress.Utilization)
}
if progress.RemainingSeconds != 0 {
t.Fatalf("expected RemainingSeconds=0, got %v", progress.RemainingSeconds)
}
})
t.Run("active 5h window keeps utilization", func(t *testing.T) {
resetAt := now.Add(2 * time.Hour).Format(time.RFC3339)
extra := map[string]any{
"codex_5h_used_percent": 42.0,
"codex_5h_reset_at": resetAt,
}
progress := buildCodexUsageProgressFromExtra(extra, "5h", now)
if progress == nil {
t.Fatal("expected non-nil progress")
}
if progress.Utilization != 42.0 {
t.Fatalf("expected Utilization=42, got %v", progress.Utilization)
}
})
t.Run("expired 7d window zeroes utilization", func(t *testing.T) {
extra := map[string]any{
"codex_7d_used_percent": 88.0,
"codex_7d_reset_at": "2026-03-15T00:00:00Z", // yesterday
}
progress := buildCodexUsageProgressFromExtra(extra, "7d", now)
if progress == nil {
t.Fatal("expected non-nil progress")
}
if progress.Utilization != 0 {
t.Fatalf("expected Utilization=0 for expired 7d window, got %v", progress.Utilization)
}
})
}

View File

@@ -73,7 +73,7 @@
<div v-else class="text-xs text-gray-400">-</div> <div v-else class="text-xs text-gray-400">-</div>
</template> </template>
<!-- OpenAI OAuth accounts: prefer fresh usage query for active rate-limited rows --> <!-- OpenAI OAuth accounts: single source from /usage API -->
<template v-else-if="account.platform === 'openai' && account.type === 'oauth'"> <template v-else-if="account.platform === 'openai' && account.type === 'oauth'">
<div v-if="hasOpenAIUsageFallback" class="space-y-1"> <div v-if="hasOpenAIUsageFallback" class="space-y-1">
<UsageProgressBar <UsageProgressBar
@@ -93,37 +93,6 @@
color="emerald" color="emerald"
/> />
</div> </div>
<div v-else-if="isActiveOpenAIRateLimited && loading" class="space-y-1.5">
<div class="flex items-center gap-1">
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
<div class="flex items-center gap-1">
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
</div>
<div v-else-if="hasCodexUsage" class="space-y-1">
<!-- 5h Window -->
<UsageProgressBar
v-if="codex5hUsedPercent !== null"
label="5h"
:utilization="codex5hUsedPercent"
:resets-at="codex5hResetAt"
color="indigo"
/>
<!-- 7d Window -->
<UsageProgressBar
v-if="codex7dUsedPercent !== null"
label="7d"
:utilization="codex7dUsedPercent"
:resets-at="codex7dResetAt"
color="emerald"
/>
</div>
<div v-else-if="loading" class="space-y-1.5"> <div v-else-if="loading" class="space-y-1.5">
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div> <div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
@@ -441,7 +410,6 @@ import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin' import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types' import type { Account, AccountUsageInfo, GeminiCredentials, WindowStats } from '@/types'
import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh' import { buildOpenAIUsageRefreshKey } from '@/utils/accountUsageRefresh'
import { resolveCodexUsageWindow } from '@/utils/codexUsage'
import { formatCompactNumber } from '@/utils/format' import { formatCompactNumber } from '@/utils/format'
import UsageProgressBar from './UsageProgressBar.vue' import UsageProgressBar from './UsageProgressBar.vue'
import AccountQuotaInfo from './AccountQuotaInfo.vue' import AccountQuotaInfo from './AccountQuotaInfo.vue'
@@ -500,37 +468,17 @@ const geminiUsageAvailable = computed(() => {
) )
}) })
const codex5hWindow = computed(() => resolveCodexUsageWindow(props.account.extra, '5h'))
const codex7dWindow = computed(() => resolveCodexUsageWindow(props.account.extra, '7d'))
// OpenAI Codex usage computed properties
const hasCodexUsage = computed(() => {
return codex5hWindow.value.usedPercent !== null || codex7dWindow.value.usedPercent !== null
})
const hasOpenAIUsageFallback = computed(() => { const hasOpenAIUsageFallback = computed(() => {
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return false if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return false
return !!usageInfo.value?.five_hour || !!usageInfo.value?.seven_day return !!usageInfo.value?.five_hour || !!usageInfo.value?.seven_day
}) })
const isActiveOpenAIRateLimited = computed(() => {
if (props.account.platform !== 'openai' || props.account.type !== 'oauth') return false
if (!props.account.rate_limit_reset_at) return false
const resetAt = Date.parse(props.account.rate_limit_reset_at)
return !Number.isNaN(resetAt) && resetAt > Date.now()
})
const openAIUsageRefreshKey = computed(() => buildOpenAIUsageRefreshKey(props.account)) const openAIUsageRefreshKey = computed(() => buildOpenAIUsageRefreshKey(props.account))
const shouldAutoLoadUsageOnMount = computed(() => { const shouldAutoLoadUsageOnMount = computed(() => {
return shouldFetchUsage.value return shouldFetchUsage.value
}) })
const codex5hUsedPercent = computed(() => codex5hWindow.value.usedPercent)
const codex5hResetAt = computed(() => codex5hWindow.value.resetAt)
const codex7dUsedPercent = computed(() => codex7dWindow.value.usedPercent)
const codex7dResetAt = computed(() => codex7dWindow.value.resetAt)
// Antigravity quota types (用于 API 返回的数据) // Antigravity quota types (用于 API 返回的数据)
interface AntigravityUsageResult { interface AntigravityUsageResult {
utilization: number utilization: number

View File

@@ -198,7 +198,7 @@ describe('AccountUsageCell', () => {
expect(wrapper.text()).toContain('7d|77|300') expect(wrapper.text()).toContain('7d|77|300')
}) })
it('OpenAI OAuth 有现成快照时首屏先显示快照再加载 usage 覆盖', async () => { it('OpenAI OAuth 有 codex 快照时仍然使用 /usage API 数据渲染', async () => {
getUsage.mockResolvedValue({ getUsage.mockResolvedValue({
five_hour: { five_hour: {
utilization: 18, utilization: 18,
@@ -254,8 +254,8 @@ describe('AccountUsageCell', () => {
await flushPromises() await flushPromises()
// 始终拉 usagefetched data 优先显示(包含 window_stats
expect(getUsage).toHaveBeenCalledWith(2001) expect(getUsage).toHaveBeenCalledWith(2001)
// 单一数据源:始终使用 /usage API 返回值,忽略 codex 快照
expect(wrapper.text()).toContain('5h|18|900') expect(wrapper.text()).toContain('5h|18|900')
expect(wrapper.text()).toContain('7d|36|900') expect(wrapper.text()).toContain('7d|36|900')
}) })
@@ -326,7 +326,7 @@ describe('AccountUsageCell', () => {
// 手动刷新再拉一次 // 手动刷新再拉一次
expect(getUsage).toHaveBeenCalledTimes(2) expect(getUsage).toHaveBeenCalledTimes(2)
expect(getUsage).toHaveBeenCalledWith(2010) expect(getUsage).toHaveBeenCalledWith(2010)
// fetched data 优先显示,包含 window_stats // 单一数据源:始终使用 /usage API 值
expect(wrapper.text()).toContain('5h|18|900') expect(wrapper.text()).toContain('5h|18|900')
}) })
@@ -458,7 +458,7 @@ describe('AccountUsageCell', () => {
expect(wrapper.text()).toContain('5h|0|200') expect(wrapper.text()).toContain('5h|0|200')
}) })
it('OpenAI OAuth 已限额时首屏优先展示重新查询后的 usage而不是旧 codex 快照', async () => { it('OpenAI OAuth 已限额时显示 /usage API 返回的限额数据', async () => {
getUsage.mockResolvedValue({ getUsage.mockResolvedValue({
five_hour: { five_hour: {
utilization: 100, utilization: 100,
@@ -515,7 +515,6 @@ describe('AccountUsageCell', () => {
expect(getUsage).toHaveBeenCalledWith(2004) expect(getUsage).toHaveBeenCalledWith(2004)
expect(wrapper.text()).toContain('5h|100|106540000') expect(wrapper.text()).toContain('5h|100|106540000')
expect(wrapper.text()).toContain('7d|100|106540000') expect(wrapper.text()).toContain('7d|100|106540000')
expect(wrapper.text()).not.toContain('5h|0|')
}) })
it('Key 账号会展示 today stats 徽章并带 A/U 提示', async () => { it('Key 账号会展示 today stats 徽章并带 A/U 提示', async () => {

View File

@@ -1,206 +0,0 @@
import { describe, expect, it } from 'vitest'
import { resolveCodexUsageWindow } from '@/utils/codexUsage'
describe('resolveCodexUsageWindow', () => {
it('快照为空时返回空窗口', () => {
const result = resolveCodexUsageWindow(null, '5h', new Date('2026-02-20T08:00:00Z'))
expect(result).toEqual({ usedPercent: null, resetAt: null })
})
it('优先使用后端提供的绝对重置时间', () => {
const now = new Date('2026-02-20T08:00:00Z')
const result = resolveCodexUsageWindow(
{
codex_5h_used_percent: 55,
codex_5h_reset_at: '2026-02-20T10:00:00Z',
codex_5h_reset_after_seconds: 1
},
'5h',
now
)
expect(result.usedPercent).toBe(55)
expect(result.resetAt).toBe('2026-02-20T10:00:00.000Z')
})
it('窗口已过期时自动归零', () => {
const now = new Date('2026-02-20T08:00:00Z')
const result = resolveCodexUsageWindow(
{
codex_7d_used_percent: 100,
codex_7d_reset_at: '2026-02-20T07:00:00Z'
},
'7d',
now
)
expect(result.usedPercent).toBe(0)
expect(result.resetAt).toBe('2026-02-20T07:00:00.000Z')
})
it('无绝对时间时使用 updated_at + seconds 回退计算', () => {
const now = new Date('2026-02-20T07:00:00Z')
const result = resolveCodexUsageWindow(
{
codex_5h_used_percent: 20,
codex_5h_reset_after_seconds: 3600,
codex_usage_updated_at: '2026-02-20T06:30:00Z'
},
'5h',
now
)
expect(result.usedPercent).toBe(20)
expect(result.resetAt).toBe('2026-02-20T07:30:00.000Z')
})
it('支持 legacy primary/secondary 字段映射', () => {
const now = new Date('2026-02-20T07:05:00Z')
const result5h = resolveCodexUsageWindow(
{
codex_primary_window_minutes: 10080,
codex_primary_used_percent: 70,
codex_primary_reset_after_seconds: 86400,
codex_secondary_window_minutes: 300,
codex_secondary_used_percent: 15,
codex_secondary_reset_after_seconds: 1200,
codex_usage_updated_at: '2026-02-20T07:00:00Z'
},
'5h',
now
)
const result7d = resolveCodexUsageWindow(
{
codex_primary_window_minutes: 10080,
codex_primary_used_percent: 70,
codex_primary_reset_after_seconds: 86400,
codex_secondary_window_minutes: 300,
codex_secondary_used_percent: 15,
codex_secondary_reset_after_seconds: 1200,
codex_usage_updated_at: '2026-02-20T07:00:00Z'
},
'7d',
now
)
expect(result5h.usedPercent).toBe(15)
expect(result5h.resetAt).toBe('2026-02-20T07:20:00.000Z')
expect(result7d.usedPercent).toBe(70)
expect(result7d.resetAt).toBe('2026-02-21T07:00:00.000Z')
})
it('legacy 5h 在 primary<=360 时优先 primary 并支持字符串数字', () => {
const result = resolveCodexUsageWindow(
{
codex_primary_window_minutes: '300',
codex_primary_used_percent: '21',
codex_primary_reset_after_seconds: '1800',
codex_secondary_window_minutes: '10080',
codex_secondary_used_percent: '99',
codex_secondary_reset_after_seconds: '99999',
codex_usage_updated_at: '2026-02-20T08:00:00Z'
},
'5h',
new Date('2026-02-20T08:10:00Z')
)
expect(result.usedPercent).toBe(21)
expect(result.resetAt).toBe('2026-02-20T08:30:00.000Z')
})
it('legacy 5h 在无窗口信息时回退 secondary', () => {
const result = resolveCodexUsageWindow(
{
codex_secondary_used_percent: 19,
codex_secondary_reset_after_seconds: 120,
codex_usage_updated_at: '2026-02-20T08:00:00Z'
},
'5h',
new Date('2026-02-20T08:00:01Z')
)
expect(result.usedPercent).toBe(19)
expect(result.resetAt).toBe('2026-02-20T08:02:00.000Z')
})
it('legacy 场景下 secondary 为 7d 时能正确识别', () => {
const now = new Date('2026-02-20T07:30:00Z')
const result = resolveCodexUsageWindow(
{
codex_primary_window_minutes: 300,
codex_primary_used_percent: 5,
codex_primary_reset_after_seconds: 600,
codex_secondary_window_minutes: 10080,
codex_secondary_used_percent: 66,
codex_secondary_reset_after_seconds: 7200,
codex_usage_updated_at: '2026-02-20T07:00:00Z'
},
'7d',
now
)
expect(result.usedPercent).toBe(66)
expect(result.resetAt).toBe('2026-02-20T09:00:00.000Z')
})
it('绝对时间非法时回退到 updated_at + seconds', () => {
const now = new Date('2026-02-20T07:40:00Z')
const result = resolveCodexUsageWindow(
{
codex_5h_used_percent: 33,
codex_5h_reset_at: 'not-a-date',
codex_5h_reset_after_seconds: 900,
codex_usage_updated_at: '2026-02-20T07:30:00Z'
},
'5h',
now
)
expect(result.usedPercent).toBe(33)
expect(result.resetAt).toBe('2026-02-20T07:45:00.000Z')
})
it('updated_at 非法且无绝对时间时 resetAt 返回 null', () => {
const result = resolveCodexUsageWindow(
{
codex_5h_used_percent: 10,
codex_5h_reset_after_seconds: 123,
codex_usage_updated_at: 'invalid-time'
},
'5h',
new Date('2026-02-20T08:00:00Z')
)
expect(result.usedPercent).toBe(10)
expect(result.resetAt).toBeNull()
})
it('reset_after_seconds 为负数时按 0 秒处理', () => {
const result = resolveCodexUsageWindow(
{
codex_5h_used_percent: 80,
codex_5h_reset_after_seconds: -30,
codex_usage_updated_at: '2026-02-20T08:00:00Z'
},
'5h',
new Date('2026-02-20T07:59:00Z')
)
expect(result.usedPercent).toBe(80)
expect(result.resetAt).toBe('2026-02-20T08:00:00.000Z')
})
it('百分比缺失时仍可计算 resetAt 供倒计时展示', () => {
const result = resolveCodexUsageWindow(
{
codex_7d_reset_after_seconds: 60,
codex_usage_updated_at: '2026-02-20T08:00:00Z'
},
'7d',
new Date('2026-02-20T08:00:01Z')
)
expect(result.usedPercent).toBeNull()
expect(result.resetAt).toBe('2026-02-20T08:01:00.000Z')
})
})

View File

@@ -1,130 +0,0 @@
import type { CodexUsageSnapshot } from '@/types'
export interface ResolvedCodexUsageWindow {
usedPercent: number | null
resetAt: string | null
}
type WindowKind = '5h' | '7d'
function asNumber(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string' && value.trim() !== '') {
const n = Number(value)
if (Number.isFinite(n)) return n
}
return null
}
function asString(value: unknown): string | null {
if (typeof value !== 'string') return null
const trimmed = value.trim()
return trimmed === '' ? null : trimmed
}
function asISOTime(value: unknown): string | null {
const raw = asString(value)
if (!raw) return null
const date = new Date(raw)
if (Number.isNaN(date.getTime())) return null
return date.toISOString()
}
function resolveLegacy5h(snapshot: Record<string, unknown>): { used: number | null; resetAfterSeconds: number | null } {
const primaryWindow = asNumber(snapshot.codex_primary_window_minutes)
const secondaryWindow = asNumber(snapshot.codex_secondary_window_minutes)
const primaryUsed = asNumber(snapshot.codex_primary_used_percent)
const secondaryUsed = asNumber(snapshot.codex_secondary_used_percent)
const primaryReset = asNumber(snapshot.codex_primary_reset_after_seconds)
const secondaryReset = asNumber(snapshot.codex_secondary_reset_after_seconds)
if (primaryWindow != null && primaryWindow <= 360) {
return { used: primaryUsed, resetAfterSeconds: primaryReset }
}
if (secondaryWindow != null && secondaryWindow <= 360) {
return { used: secondaryUsed, resetAfterSeconds: secondaryReset }
}
return { used: secondaryUsed, resetAfterSeconds: secondaryReset }
}
function resolveLegacy7d(snapshot: Record<string, unknown>): { used: number | null; resetAfterSeconds: number | null } {
const primaryWindow = asNumber(snapshot.codex_primary_window_minutes)
const secondaryWindow = asNumber(snapshot.codex_secondary_window_minutes)
const primaryUsed = asNumber(snapshot.codex_primary_used_percent)
const secondaryUsed = asNumber(snapshot.codex_secondary_used_percent)
const primaryReset = asNumber(snapshot.codex_primary_reset_after_seconds)
const secondaryReset = asNumber(snapshot.codex_secondary_reset_after_seconds)
if (primaryWindow != null && primaryWindow >= 10000) {
return { used: primaryUsed, resetAfterSeconds: primaryReset }
}
if (secondaryWindow != null && secondaryWindow >= 10000) {
return { used: secondaryUsed, resetAfterSeconds: secondaryReset }
}
return { used: primaryUsed, resetAfterSeconds: primaryReset }
}
function resolveFromSeconds(snapshot: Record<string, unknown>, resetAfterSeconds: number | null): string | null {
if (resetAfterSeconds == null) return null
const baseRaw = asString(snapshot.codex_usage_updated_at)
const base = baseRaw ? new Date(baseRaw) : new Date()
if (Number.isNaN(base.getTime())) {
return null
}
const sec = Math.max(0, resetAfterSeconds)
const resetAt = new Date(base.getTime() + sec * 1000)
return resetAt.toISOString()
}
function applyExpiredRule(window: ResolvedCodexUsageWindow, now: Date): ResolvedCodexUsageWindow {
if (window.usedPercent == null || !window.resetAt) return window
const resetDate = new Date(window.resetAt)
if (Number.isNaN(resetDate.getTime())) return window
if (resetDate.getTime() <= now.getTime()) {
return { usedPercent: 0, resetAt: resetDate.toISOString() }
}
return window
}
export function resolveCodexUsageWindow(
snapshot: (CodexUsageSnapshot & Record<string, unknown>) | null | undefined,
window: WindowKind,
now: Date = new Date()
): ResolvedCodexUsageWindow {
if (!snapshot) {
return { usedPercent: null, resetAt: null }
}
const typedSnapshot = snapshot as Record<string, unknown>
let usedPercent: number | null
let resetAfterSeconds: number | null
let resetAt: string | null
if (window === '5h') {
usedPercent = asNumber(typedSnapshot.codex_5h_used_percent)
resetAfterSeconds = asNumber(typedSnapshot.codex_5h_reset_after_seconds)
resetAt = asISOTime(typedSnapshot.codex_5h_reset_at)
if (usedPercent == null || (resetAfterSeconds == null && !resetAt)) {
const legacy = resolveLegacy5h(typedSnapshot)
if (usedPercent == null) usedPercent = legacy.used
if (resetAfterSeconds == null) resetAfterSeconds = legacy.resetAfterSeconds
}
} else {
usedPercent = asNumber(typedSnapshot.codex_7d_used_percent)
resetAfterSeconds = asNumber(typedSnapshot.codex_7d_reset_after_seconds)
resetAt = asISOTime(typedSnapshot.codex_7d_reset_at)
if (usedPercent == null || (resetAfterSeconds == null && !resetAt)) {
const legacy = resolveLegacy7d(typedSnapshot)
if (usedPercent == null) usedPercent = legacy.used
if (resetAfterSeconds == null) resetAfterSeconds = legacy.resetAfterSeconds
}
}
if (!resetAt) {
resetAt = resolveFromSeconds(typedSnapshot, resetAfterSeconds)
}
return applyExpiredRule({ usedPercent, resetAt }, now)
}