fix(codex): 修复额度窗口过期展示并补齐高覆盖测试

- 后端新增绝对重置时间字段计算(codex_5h_reset_at/codex_7d_reset_at)

- 前端统一窗口解析逻辑:绝对时间优先,updated_at+seconds 回退,过期自动归零

- 新增后端与前端单元测试,覆盖关键边界与异常场景
This commit is contained in:
yangjianbo
2026-02-22 21:04:52 +08:00
parent c67f02eaf0
commit 10636d8a1f
5 changed files with 591 additions and 152 deletions

View File

@@ -2796,19 +2796,41 @@ func ParseCodexRateLimitHeaders(headers http.Header) *OpenAICodexUsageSnapshot {
return snapshot
}
// updateCodexUsageSnapshot saves the Codex usage snapshot to account's Extra field
func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, accountID int64, snapshot *OpenAICodexUsageSnapshot) {
func codexSnapshotBaseTime(snapshot *OpenAICodexUsageSnapshot, fallback time.Time) time.Time {
if snapshot == nil {
return
return fallback
}
if s == nil || s.accountRepo == nil {
return
if snapshot.UpdatedAt == "" {
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)
// Save raw primary/secondary fields for debugging/tracing
// 保存原始 primary/secondary 字段,便于排查问题
if snapshot.PrimaryUsedPercent != nil {
updates["codex_primary_used_percent"] = *snapshot.PrimaryUsedPercent
}
@@ -2830,9 +2852,9 @@ func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, acc
if snapshot.PrimaryOverSecondaryPercent != nil {
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.Used5hPercent != nil {
updates["codex_5h_used_percent"] = *normalized.Used5hPercent
@@ -2852,6 +2874,29 @@ func (s *OpenAIGatewayService) updateCodexUsageSnapshot(ctx context.Context, acc
if normalized.Window7dMinutes != nil {
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

View File

@@ -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"])
}
}