mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-03 06:52:13 +08:00
fix: zero expired codex windows in backend, use /usage API as single frontend data source
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
// 始终拉 usage,fetched 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 () => {
|
||||||
|
|||||||
@@ -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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user