fix(codex): 修复额度窗口过期展示并补齐高覆盖测试
- 后端新增绝对重置时间字段计算(codex_5h_reset_at/codex_7d_reset_at) - 前端统一窗口解析逻辑:绝对时间优先,updated_at+seconds 回退,过期自动归零 - 新增后端与前端单元测试,覆盖关键边界与异常场景
This commit is contained in:
@@ -2796,19 +2796,41 @@ func ParseCodexRateLimitHeaders(headers http.Header) *OpenAICodexUsageSnapshot {
|
|||||||
return snapshot
|
return snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateCodexUsageSnapshot saves the Codex usage snapshot to account's Extra field
|
func codexSnapshotBaseTime(snapshot *OpenAICodexUsageSnapshot, fallback time.Time) time.Time {
|
||||||
func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, accountID int64, snapshot *OpenAICodexUsageSnapshot) {
|
|
||||||
if snapshot == nil {
|
if snapshot == nil {
|
||||||
return
|
return fallback
|
||||||
}
|
}
|
||||||
if s == nil || s.accountRepo == nil {
|
if snapshot.UpdatedAt == "" {
|
||||||
return
|
return fallback
|
||||||
|
}
|
||||||
|
base, err := time.Parse(time.RFC3339, snapshot.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
func codexResetAtRFC3339(base time.Time, resetAfterSeconds *int) *string {
|
||||||
|
if resetAfterSeconds == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sec := *resetAfterSeconds
|
||||||
|
if sec < 0 {
|
||||||
|
sec = 0
|
||||||
|
}
|
||||||
|
resetAt := base.Add(time.Duration(sec) * time.Second).Format(time.RFC3339)
|
||||||
|
return &resetAt
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCodexUsageExtraUpdates(snapshot *OpenAICodexUsageSnapshot, fallbackNow time.Time) map[string]any {
|
||||||
|
if snapshot == nil {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert snapshot to map for merging into Extra
|
baseTime := codexSnapshotBaseTime(snapshot, fallbackNow)
|
||||||
updates := make(map[string]any)
|
updates := make(map[string]any)
|
||||||
|
|
||||||
// Save raw primary/secondary fields for debugging/tracing
|
// 保存原始 primary/secondary 字段,便于排查问题
|
||||||
if snapshot.PrimaryUsedPercent != nil {
|
if snapshot.PrimaryUsedPercent != nil {
|
||||||
updates["codex_primary_used_percent"] = *snapshot.PrimaryUsedPercent
|
updates["codex_primary_used_percent"] = *snapshot.PrimaryUsedPercent
|
||||||
}
|
}
|
||||||
@@ -2830,9 +2852,9 @@ func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, acc
|
|||||||
if snapshot.PrimaryOverSecondaryPercent != nil {
|
if snapshot.PrimaryOverSecondaryPercent != nil {
|
||||||
updates["codex_primary_over_secondary_percent"] = *snapshot.PrimaryOverSecondaryPercent
|
updates["codex_primary_over_secondary_percent"] = *snapshot.PrimaryOverSecondaryPercent
|
||||||
}
|
}
|
||||||
updates["codex_usage_updated_at"] = snapshot.UpdatedAt
|
updates["codex_usage_updated_at"] = baseTime.Format(time.RFC3339)
|
||||||
|
|
||||||
// Normalize to canonical 5h/7d fields
|
// 归一化到 5h/7d 规范字段
|
||||||
if normalized := snapshot.Normalize(); normalized != nil {
|
if normalized := snapshot.Normalize(); normalized != nil {
|
||||||
if normalized.Used5hPercent != nil {
|
if normalized.Used5hPercent != nil {
|
||||||
updates["codex_5h_used_percent"] = *normalized.Used5hPercent
|
updates["codex_5h_used_percent"] = *normalized.Used5hPercent
|
||||||
@@ -2852,6 +2874,29 @@ func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, acc
|
|||||||
if normalized.Window7dMinutes != nil {
|
if normalized.Window7dMinutes != nil {
|
||||||
updates["codex_7d_window_minutes"] = *normalized.Window7dMinutes
|
updates["codex_7d_window_minutes"] = *normalized.Window7dMinutes
|
||||||
}
|
}
|
||||||
|
if reset5hAt := codexResetAtRFC3339(baseTime, normalized.Reset5hSeconds); reset5hAt != nil {
|
||||||
|
updates["codex_5h_reset_at"] = *reset5hAt
|
||||||
|
}
|
||||||
|
if reset7dAt := codexResetAtRFC3339(baseTime, normalized.Reset7dSeconds); reset7dAt != nil {
|
||||||
|
updates["codex_7d_reset_at"] = *reset7dAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updates
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateCodexUsageSnapshot saves the Codex usage snapshot to account's Extra field
|
||||||
|
func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, accountID int64, snapshot *OpenAICodexUsageSnapshot) {
|
||||||
|
if snapshot == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s == nil || s.accountRepo == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := buildCodexUsageExtraUpdates(snapshot, time.Now())
|
||||||
|
if len(updates) == 0 {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update account's Extra field asynchronously
|
// Update account's Extra field asynchronously
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCodexSnapshotBaseTime(t *testing.T) {
|
||||||
|
fallback := time.Date(2026, 2, 20, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
t.Run("nil snapshot uses fallback", func(t *testing.T) {
|
||||||
|
got := codexSnapshotBaseTime(nil, fallback)
|
||||||
|
if !got.Equal(fallback) {
|
||||||
|
t.Fatalf("got %v, want fallback %v", got, fallback)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty updatedAt uses fallback", func(t *testing.T) {
|
||||||
|
got := codexSnapshotBaseTime(&OpenAICodexUsageSnapshot{}, fallback)
|
||||||
|
if !got.Equal(fallback) {
|
||||||
|
t.Fatalf("got %v, want fallback %v", got, fallback)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid updatedAt wins", func(t *testing.T) {
|
||||||
|
got := codexSnapshotBaseTime(&OpenAICodexUsageSnapshot{UpdatedAt: "2026-02-16T10:00:00Z"}, fallback)
|
||||||
|
want := time.Date(2026, 2, 16, 10, 0, 0, 0, time.UTC)
|
||||||
|
if !got.Equal(want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid updatedAt uses fallback", func(t *testing.T) {
|
||||||
|
got := codexSnapshotBaseTime(&OpenAICodexUsageSnapshot{UpdatedAt: "invalid"}, fallback)
|
||||||
|
if !got.Equal(fallback) {
|
||||||
|
t.Fatalf("got %v, want fallback %v", got, fallback)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCodexResetAtRFC3339(t *testing.T) {
|
||||||
|
base := time.Date(2026, 2, 16, 10, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
t.Run("nil reset returns nil", func(t *testing.T) {
|
||||||
|
if got := codexResetAtRFC3339(base, nil); got != nil {
|
||||||
|
t.Fatalf("expected nil, got %v", *got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("positive seconds", func(t *testing.T) {
|
||||||
|
sec := 90
|
||||||
|
got := codexResetAtRFC3339(base, &sec)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expected non-nil")
|
||||||
|
}
|
||||||
|
if *got != "2026-02-16T10:01:30Z" {
|
||||||
|
t.Fatalf("got %s, want %s", *got, "2026-02-16T10:01:30Z")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("negative seconds clamp to base", func(t *testing.T) {
|
||||||
|
sec := -3
|
||||||
|
got := codexResetAtRFC3339(base, &sec)
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("expected non-nil")
|
||||||
|
}
|
||||||
|
if *got != "2026-02-16T10:00:00Z" {
|
||||||
|
t.Fatalf("got %s, want %s", *got, "2026-02-16T10:00:00Z")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCodexUsageExtraUpdates_UsesSnapshotUpdatedAt(t *testing.T) {
|
||||||
|
primaryUsed := 88.0
|
||||||
|
primaryReset := 86400
|
||||||
|
primaryWindow := 10080
|
||||||
|
secondaryUsed := 12.0
|
||||||
|
secondaryReset := 3600
|
||||||
|
secondaryWindow := 300
|
||||||
|
|
||||||
|
snapshot := &OpenAICodexUsageSnapshot{
|
||||||
|
PrimaryUsedPercent: &primaryUsed,
|
||||||
|
PrimaryResetAfterSeconds: &primaryReset,
|
||||||
|
PrimaryWindowMinutes: &primaryWindow,
|
||||||
|
SecondaryUsedPercent: &secondaryUsed,
|
||||||
|
SecondaryResetAfterSeconds: &secondaryReset,
|
||||||
|
SecondaryWindowMinutes: &secondaryWindow,
|
||||||
|
UpdatedAt: "2026-02-16T10:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := buildCodexUsageExtraUpdates(snapshot, time.Date(2026, 2, 20, 8, 0, 0, 0, time.UTC))
|
||||||
|
if updates == nil {
|
||||||
|
t.Fatal("expected non-nil updates")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := updates["codex_usage_updated_at"]; got != "2026-02-16T10:00:00Z" {
|
||||||
|
t.Fatalf("codex_usage_updated_at = %v, want %s", got, "2026-02-16T10:00:00Z")
|
||||||
|
}
|
||||||
|
if got := updates["codex_5h_reset_at"]; got != "2026-02-16T11:00:00Z" {
|
||||||
|
t.Fatalf("codex_5h_reset_at = %v, want %s", got, "2026-02-16T11:00:00Z")
|
||||||
|
}
|
||||||
|
if got := updates["codex_7d_reset_at"]; got != "2026-02-17T10:00:00Z" {
|
||||||
|
t.Fatalf("codex_7d_reset_at = %v, want %s", got, "2026-02-17T10:00:00Z")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCodexUsageExtraUpdates_FallbackToNowWhenUpdatedAtInvalid(t *testing.T) {
|
||||||
|
primaryUsed := 15.0
|
||||||
|
primaryReset := 30
|
||||||
|
primaryWindow := 300
|
||||||
|
|
||||||
|
fallbackNow := time.Date(2026, 2, 20, 8, 30, 0, 0, time.UTC)
|
||||||
|
snapshot := &OpenAICodexUsageSnapshot{
|
||||||
|
PrimaryUsedPercent: &primaryUsed,
|
||||||
|
PrimaryResetAfterSeconds: &primaryReset,
|
||||||
|
PrimaryWindowMinutes: &primaryWindow,
|
||||||
|
UpdatedAt: "invalid-time",
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := buildCodexUsageExtraUpdates(snapshot, fallbackNow)
|
||||||
|
if updates == nil {
|
||||||
|
t.Fatal("expected non-nil updates")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := updates["codex_usage_updated_at"]; got != "2026-02-20T08:30:00Z" {
|
||||||
|
t.Fatalf("codex_usage_updated_at = %v, want %s", got, "2026-02-20T08:30:00Z")
|
||||||
|
}
|
||||||
|
if got := updates["codex_5h_reset_at"]; got != "2026-02-20T08:30:30Z" {
|
||||||
|
t.Fatalf("codex_5h_reset_at = %v, want %s", got, "2026-02-20T08:30:30Z")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCodexUsageExtraUpdates_ClampNegativeResetSeconds(t *testing.T) {
|
||||||
|
primaryUsed := 90.0
|
||||||
|
primaryReset := 7200
|
||||||
|
primaryWindow := 10080
|
||||||
|
secondaryUsed := 100.0
|
||||||
|
secondaryReset := -15
|
||||||
|
secondaryWindow := 300
|
||||||
|
|
||||||
|
snapshot := &OpenAICodexUsageSnapshot{
|
||||||
|
PrimaryUsedPercent: &primaryUsed,
|
||||||
|
PrimaryResetAfterSeconds: &primaryReset,
|
||||||
|
PrimaryWindowMinutes: &primaryWindow,
|
||||||
|
SecondaryUsedPercent: &secondaryUsed,
|
||||||
|
SecondaryResetAfterSeconds: &secondaryReset,
|
||||||
|
SecondaryWindowMinutes: &secondaryWindow,
|
||||||
|
UpdatedAt: "2026-02-16T10:00:00Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := buildCodexUsageExtraUpdates(snapshot, time.Time{})
|
||||||
|
if updates == nil {
|
||||||
|
t.Fatal("expected non-nil updates")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := updates["codex_5h_reset_after_seconds"]; got != -15 {
|
||||||
|
t.Fatalf("codex_5h_reset_after_seconds = %v, want %d", got, -15)
|
||||||
|
}
|
||||||
|
if got := updates["codex_5h_reset_at"]; got != "2026-02-16T10:00:00Z" {
|
||||||
|
t.Fatalf("codex_5h_reset_at = %v, want %s", got, "2026-02-16T10:00:00Z")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCodexUsageExtraUpdates_NilSnapshot(t *testing.T) {
|
||||||
|
if got := buildCodexUsageExtraUpdates(nil, time.Now()); got != nil {
|
||||||
|
t.Fatalf("expected nil updates, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCodexUsageExtraUpdates_WithoutNormalizedWindowFields(t *testing.T) {
|
||||||
|
primaryUsed := 42.0
|
||||||
|
fallbackNow := time.Date(2026, 2, 20, 9, 15, 0, 0, time.UTC)
|
||||||
|
snapshot := &OpenAICodexUsageSnapshot{
|
||||||
|
PrimaryUsedPercent: &primaryUsed,
|
||||||
|
UpdatedAt: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := buildCodexUsageExtraUpdates(snapshot, fallbackNow)
|
||||||
|
if updates == nil {
|
||||||
|
t.Fatal("expected non-nil updates")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := updates["codex_usage_updated_at"]; got != "2026-02-20T09:15:00Z" {
|
||||||
|
t.Fatalf("codex_usage_updated_at = %v, want %s", got, "2026-02-20T09:15:00Z")
|
||||||
|
}
|
||||||
|
if _, ok := updates["codex_5h_reset_at"]; ok {
|
||||||
|
t.Fatalf("did not expect codex_5h_reset_at in updates: %v", updates["codex_5h_reset_at"])
|
||||||
|
}
|
||||||
|
if _, ok := updates["codex_7d_reset_at"]; ok {
|
||||||
|
t.Fatalf("did not expect codex_7d_reset_at in updates: %v", updates["codex_7d_reset_at"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -282,6 +282,7 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
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 { resolveCodexUsageWindow } from '@/utils/codexUsage'
|
||||||
import UsageProgressBar from './UsageProgressBar.vue'
|
import UsageProgressBar from './UsageProgressBar.vue'
|
||||||
import AccountQuotaInfo from './AccountQuotaInfo.vue'
|
import AccountQuotaInfo from './AccountQuotaInfo.vue'
|
||||||
|
|
||||||
@@ -326,153 +327,18 @@ const geminiUsageAvailable = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const codex5hWindow = computed(() => resolveCodexUsageWindow(props.account.extra, '5h'))
|
||||||
|
const codex7dWindow = computed(() => resolveCodexUsageWindow(props.account.extra, '7d'))
|
||||||
|
|
||||||
// OpenAI Codex usage computed properties
|
// OpenAI Codex usage computed properties
|
||||||
const hasCodexUsage = computed(() => {
|
const hasCodexUsage = computed(() => {
|
||||||
const extra = props.account.extra
|
return codex5hWindow.value.usedPercent !== null || codex7dWindow.value.usedPercent !== null
|
||||||
return (
|
|
||||||
extra &&
|
|
||||||
// Check for new canonical fields first
|
|
||||||
(extra.codex_5h_used_percent !== undefined ||
|
|
||||||
extra.codex_7d_used_percent !== undefined ||
|
|
||||||
// Fallback to legacy fields
|
|
||||||
extra.codex_primary_used_percent !== undefined ||
|
|
||||||
extra.codex_secondary_used_percent !== undefined)
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 5h window usage (prefer canonical field)
|
const codex5hUsedPercent = computed(() => codex5hWindow.value.usedPercent)
|
||||||
const codex5hUsedPercent = computed(() => {
|
const codex5hResetAt = computed(() => codex5hWindow.value.resetAt)
|
||||||
const extra = props.account.extra
|
const codex7dUsedPercent = computed(() => codex7dWindow.value.usedPercent)
|
||||||
if (!extra) return null
|
const codex7dResetAt = computed(() => codex7dWindow.value.resetAt)
|
||||||
|
|
||||||
// Prefer canonical field
|
|
||||||
if (extra.codex_5h_used_percent !== undefined) {
|
|
||||||
return extra.codex_5h_used_percent
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: detect from legacy fields using window_minutes
|
|
||||||
if (
|
|
||||||
extra.codex_primary_window_minutes !== undefined &&
|
|
||||||
extra.codex_primary_window_minutes <= 360
|
|
||||||
) {
|
|
||||||
return extra.codex_primary_used_percent ?? null
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
extra.codex_secondary_window_minutes !== undefined &&
|
|
||||||
extra.codex_secondary_window_minutes <= 360
|
|
||||||
) {
|
|
||||||
return extra.codex_secondary_used_percent ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy assumption: secondary = 5h (may be incorrect)
|
|
||||||
return extra.codex_secondary_used_percent ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
const codex5hResetAt = computed(() => {
|
|
||||||
const extra = props.account.extra
|
|
||||||
if (!extra) return null
|
|
||||||
|
|
||||||
// Prefer canonical field
|
|
||||||
if (extra.codex_5h_reset_after_seconds !== undefined) {
|
|
||||||
const resetTime = new Date(Date.now() + extra.codex_5h_reset_after_seconds * 1000)
|
|
||||||
return resetTime.toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: detect from legacy fields using window_minutes
|
|
||||||
if (
|
|
||||||
extra.codex_primary_window_minutes !== undefined &&
|
|
||||||
extra.codex_primary_window_minutes <= 360
|
|
||||||
) {
|
|
||||||
if (extra.codex_primary_reset_after_seconds !== undefined) {
|
|
||||||
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
|
|
||||||
return resetTime.toISOString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
extra.codex_secondary_window_minutes !== undefined &&
|
|
||||||
extra.codex_secondary_window_minutes <= 360
|
|
||||||
) {
|
|
||||||
if (extra.codex_secondary_reset_after_seconds !== undefined) {
|
|
||||||
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
|
|
||||||
return resetTime.toISOString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy assumption: secondary = 5h
|
|
||||||
if (extra.codex_secondary_reset_after_seconds !== undefined) {
|
|
||||||
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
|
|
||||||
return resetTime.toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
|
|
||||||
// 7d window usage (prefer canonical field)
|
|
||||||
const codex7dUsedPercent = computed(() => {
|
|
||||||
const extra = props.account.extra
|
|
||||||
if (!extra) return null
|
|
||||||
|
|
||||||
// Prefer canonical field
|
|
||||||
if (extra.codex_7d_used_percent !== undefined) {
|
|
||||||
return extra.codex_7d_used_percent
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: detect from legacy fields using window_minutes
|
|
||||||
if (
|
|
||||||
extra.codex_primary_window_minutes !== undefined &&
|
|
||||||
extra.codex_primary_window_minutes >= 10000
|
|
||||||
) {
|
|
||||||
return extra.codex_primary_used_percent ?? null
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
extra.codex_secondary_window_minutes !== undefined &&
|
|
||||||
extra.codex_secondary_window_minutes >= 10000
|
|
||||||
) {
|
|
||||||
return extra.codex_secondary_used_percent ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy assumption: primary = 7d (may be incorrect)
|
|
||||||
return extra.codex_primary_used_percent ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
const codex7dResetAt = computed(() => {
|
|
||||||
const extra = props.account.extra
|
|
||||||
if (!extra) return null
|
|
||||||
|
|
||||||
// Prefer canonical field
|
|
||||||
if (extra.codex_7d_reset_after_seconds !== undefined) {
|
|
||||||
const resetTime = new Date(Date.now() + extra.codex_7d_reset_after_seconds * 1000)
|
|
||||||
return resetTime.toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: detect from legacy fields using window_minutes
|
|
||||||
if (
|
|
||||||
extra.codex_primary_window_minutes !== undefined &&
|
|
||||||
extra.codex_primary_window_minutes >= 10000
|
|
||||||
) {
|
|
||||||
if (extra.codex_primary_reset_after_seconds !== undefined) {
|
|
||||||
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
|
|
||||||
return resetTime.toISOString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
extra.codex_secondary_window_minutes !== undefined &&
|
|
||||||
extra.codex_secondary_window_minutes >= 10000
|
|
||||||
) {
|
|
||||||
if (extra.codex_secondary_reset_after_seconds !== undefined) {
|
|
||||||
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
|
|
||||||
return resetTime.toISOString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Legacy assumption: primary = 7d
|
|
||||||
if (extra.codex_primary_reset_after_seconds !== undefined) {
|
|
||||||
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
|
|
||||||
return resetTime.toISOString()
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
|
|
||||||
// Antigravity quota types (用于 API 返回的数据)
|
// Antigravity quota types (用于 API 返回的数据)
|
||||||
interface AntigravityUsageResult {
|
interface AntigravityUsageResult {
|
||||||
|
|||||||
206
frontend/src/utils/__tests__/codexUsage.spec.ts
Normal file
206
frontend/src/utils/__tests__/codexUsage.spec.ts
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
130
frontend/src/utils/codexUsage.ts
Normal file
130
frontend/src/utils/codexUsage.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
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