diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index f149116b3e..1c9b2dad82 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -1273,6 +1273,20 @@ func (r *accountRepository) UpdateSessionWindow(ctx context.Context, id int64, s return nil } +func (r *accountRepository) UpdateSessionWindowEnd(ctx context.Context, id int64, end time.Time) error { + _, err := r.client.Account.Update(). + Where(dbaccount.IDEQ(id)). + SetSessionWindowEnd(end). + Save(ctx) + if err != nil { + return err + } + if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventAccountChanged, &id, nil, nil); err != nil { + logger.LegacyPrintf("repository.account", "[SchedulerOutbox] enqueue session window end update failed: account=%d err=%v", id, err) + } + return nil +} + func (r *accountRepository) SetSchedulable(ctx context.Context, id int64, schedulable bool) error { _, err := r.client.Account.Update(). Where(dbaccount.IDEQ(id)). diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 54719cda2e..555f314c29 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -1775,6 +1775,10 @@ func (s *stubAccountRepo) UpdateSessionWindow(ctx context.Context, id int64, sta return errors.New("not implemented") } +func (s *stubAccountRepo) UpdateSessionWindowEnd(ctx context.Context, id int64, end time.Time) error { + return errors.New("not implemented") +} + func (s *stubAccountRepo) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error { return errors.New("not implemented") } diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index 748840b75d..345241db79 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -68,6 +68,9 @@ type AccountRepository interface { ClearAntigravityQuotaScopes(ctx context.Context, id int64) error ClearModelRateLimits(ctx context.Context, id int64) error UpdateSessionWindow(ctx context.Context, id int64, start, end *time.Time, status string) error + // UpdateSessionWindowEnd 仅更新 5h 窗口的结束时间,不动 start / status。 + // 用于 active poll 拿到新 ResetsAt 后回写,避免覆盖请求路径上记录的 status。 + UpdateSessionWindowEnd(ctx context.Context, id int64, end time.Time) error UpdateExtra(ctx context.Context, id int64, updates map[string]any) error BulkUpdate(ctx context.Context, ids []int64, updates AccountBulkUpdate) (int64, error) // IncrementQuotaUsed 原子递增 API Key 账号的配额用量(总/日/周) diff --git a/backend/internal/service/account_service_delete_test.go b/backend/internal/service/account_service_delete_test.go index d72554ce1f..4986bb83d0 100644 --- a/backend/internal/service/account_service_delete_test.go +++ b/backend/internal/service/account_service_delete_test.go @@ -191,6 +191,10 @@ func (s *accountRepoStub) UpdateSessionWindow(ctx context.Context, id int64, sta panic("unexpected UpdateSessionWindow call") } +func (s *accountRepoStub) UpdateSessionWindowEnd(ctx context.Context, id int64, end time.Time) error { + panic("unexpected UpdateSessionWindowEnd call") +} + func (s *accountRepoStub) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error { panic("unexpected UpdateExtra call") } diff --git a/backend/internal/service/account_usage_service.go b/backend/internal/service/account_usage_service.go index 1467bf7c1b..416bf09181 100644 --- a/backend/internal/service/account_usage_service.go +++ b/backend/internal/service/account_usage_service.go @@ -492,6 +492,14 @@ func (s *AccountUsageService) syncActiveToPassive(ctx context.Context, accountID slog.Warn("sync_active_to_passive_failed", "account_id", accountID, "error", err) } } + + // 5h ResetsAt 必须回写到 SessionWindowEnd column,estimateSetupTokenUsage + // 读这个字段作为窗口结束时间;只塞 Extra 会让 UI 一直拿到上个窗口的过期时间。 + if usage.FiveHour != nil && usage.FiveHour.ResetsAt != nil { + if err := s.accountRepo.UpdateSessionWindowEnd(ctx, accountID, *usage.FiveHour.ResetsAt); err != nil { + slog.Warn("sync_active_to_passive_session_window_end_failed", "account_id", accountID, "error", err) + } + } } func (s *AccountUsageService) getOpenAIUsage(ctx context.Context, account *Account, force bool) (*UsageInfo, error) { @@ -1305,6 +1313,15 @@ func (s *AccountUsageService) estimateSetupTokenUsage(account *Account) *UsageIn ResetsAt: account.SessionWindowEnd, RemainingSeconds: remaining, } + + // 窗口已过期(resetAt 在 now 之前)→ 额度已重置,归零; + // 与 Codex 分支 buildCodexUsageProgressFromExtra 保持一致,避免 + // UI 在 active poll 没回写 SessionWindowEnd 时渲染矛盾状态。 + if info.FiveHour.ResetsAt != nil && !time.Now().Before(*info.FiveHour.ResetsAt) { + info.FiveHour.Utilization = 0 + info.FiveHour.ResetsAt = nil + info.FiveHour.RemainingSeconds = 0 + } } else { // 没有窗口信息,返回空数据 info.FiveHour = &UsageProgress{ diff --git a/backend/internal/service/account_usage_session_window_test.go b/backend/internal/service/account_usage_session_window_test.go new file mode 100644 index 0000000000..779a5974fd --- /dev/null +++ b/backend/internal/service/account_usage_session_window_test.go @@ -0,0 +1,135 @@ +package service + +import ( + "context" + "sync" + "testing" + "time" +) + +// sessionWindowSyncRepo 记录 syncActiveToPassive 触发的所有写操作。 +type sessionWindowSyncRepo struct { + AccountRepository + + mu sync.Mutex + extraUpdates []map[string]any + sessionWindowEnds []sessionWindowEndCall +} + +type sessionWindowEndCall struct { + AccountID int64 + End time.Time +} + +func (r *sessionWindowSyncRepo) UpdateExtra(_ context.Context, _ int64, updates map[string]any) error { + r.mu.Lock() + defer r.mu.Unlock() + copied := make(map[string]any, len(updates)) + for k, v := range updates { + copied[k] = v + } + r.extraUpdates = append(r.extraUpdates, copied) + return nil +} + +func (r *sessionWindowSyncRepo) UpdateSessionWindowEnd(_ context.Context, id int64, end time.Time) error { + r.mu.Lock() + defer r.mu.Unlock() + r.sessionWindowEnds = append(r.sessionWindowEnds, sessionWindowEndCall{AccountID: id, End: end}) + return nil +} + +func TestEstimateSetupTokenUsage_ExpiredWindowZeroes(t *testing.T) { + t.Parallel() + + past := time.Now().Add(-2 * time.Hour) + svc := &AccountUsageService{} + info := svc.estimateSetupTokenUsage(&Account{ + SessionWindowEnd: &past, + Extra: map[string]any{ + "session_window_utilization": 0.53, + }, + }) + + if info.FiveHour == nil { + t.Fatal("expected non-nil FiveHour info") + } + if info.FiveHour.Utilization != 0 { + t.Fatalf("expected Utilization=0 for expired window, got %v", info.FiveHour.Utilization) + } + if info.FiveHour.ResetsAt != nil { + t.Fatalf("expected ResetsAt=nil for expired window, got %v", info.FiveHour.ResetsAt) + } + if info.FiveHour.RemainingSeconds != 0 { + t.Fatalf("expected RemainingSeconds=0 for expired window, got %v", info.FiveHour.RemainingSeconds) + } +} + +func TestEstimateSetupTokenUsage_ActiveWindowPreservesUtilization(t *testing.T) { + t.Parallel() + + future := time.Now().Add(3 * time.Hour) + svc := &AccountUsageService{} + info := svc.estimateSetupTokenUsage(&Account{ + SessionWindowEnd: &future, + Extra: map[string]any{ + "session_window_utilization": 0.53, + }, + }) + + if info.FiveHour == nil { + t.Fatal("expected non-nil FiveHour info") + } + if info.FiveHour.Utilization != 53 { + t.Fatalf("expected Utilization=53, got %v", info.FiveHour.Utilization) + } + if info.FiveHour.ResetsAt == nil || !info.FiveHour.ResetsAt.Equal(future) { + t.Fatalf("expected ResetsAt=%v, got %v", future, info.FiveHour.ResetsAt) + } + if info.FiveHour.RemainingSeconds <= 0 { + t.Fatalf("expected positive RemainingSeconds, got %v", info.FiveHour.RemainingSeconds) + } +} + +func TestSyncActiveToPassive_WritesFiveHourSessionWindowEnd(t *testing.T) { + t.Parallel() + + repo := &sessionWindowSyncRepo{} + svc := &AccountUsageService{accountRepo: repo} + resetsAt := time.Now().Add(3 * time.Hour).UTC().Truncate(time.Second) + svc.syncActiveToPassive(context.Background(), 42, &UsageInfo{ + FiveHour: &UsageProgress{ + Utilization: 53, + ResetsAt: &resetsAt, + }, + }) + + repo.mu.Lock() + defer repo.mu.Unlock() + if len(repo.sessionWindowEnds) != 1 { + t.Fatalf("expected 1 UpdateSessionWindowEnd call, got %d", len(repo.sessionWindowEnds)) + } + call := repo.sessionWindowEnds[0] + if call.AccountID != 42 { + t.Fatalf("expected AccountID=42, got %d", call.AccountID) + } + if !call.End.Equal(resetsAt) { + t.Fatalf("expected End=%v, got %v", resetsAt, call.End) + } +} + +func TestSyncActiveToPassive_SkipsSessionWindowEndWhenResetMissing(t *testing.T) { + t.Parallel() + + repo := &sessionWindowSyncRepo{} + svc := &AccountUsageService{accountRepo: repo} + svc.syncActiveToPassive(context.Background(), 99, &UsageInfo{ + FiveHour: &UsageProgress{Utilization: 10}, + }) + + repo.mu.Lock() + defer repo.mu.Unlock() + if len(repo.sessionWindowEnds) != 0 { + t.Fatalf("expected no UpdateSessionWindowEnd calls when ResetsAt is nil, got %d", len(repo.sessionWindowEnds)) + } +} diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index 7a6acaaca8..19b0005e57 100644 --- a/backend/internal/service/gateway_multiplatform_test.go +++ b/backend/internal/service/gateway_multiplatform_test.go @@ -180,6 +180,9 @@ func (m *mockAccountRepoForPlatform) ClearModelRateLimits(ctx context.Context, i func (m *mockAccountRepoForPlatform) UpdateSessionWindow(ctx context.Context, id int64, start, end *time.Time, status string) error { return nil } +func (m *mockAccountRepoForPlatform) UpdateSessionWindowEnd(ctx context.Context, id int64, end time.Time) error { + return nil +} func (m *mockAccountRepoForPlatform) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error { return nil } diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go index 8f879b0238..e696465f3b 100644 --- a/backend/internal/service/gemini_multiplatform_test.go +++ b/backend/internal/service/gemini_multiplatform_test.go @@ -169,6 +169,9 @@ func (m *mockAccountRepoForGemini) ClearModelRateLimits(ctx context.Context, id func (m *mockAccountRepoForGemini) UpdateSessionWindow(ctx context.Context, id int64, start, end *time.Time, status string) error { return nil } +func (m *mockAccountRepoForGemini) UpdateSessionWindowEnd(ctx context.Context, id int64, end time.Time) error { + return nil +} func (m *mockAccountRepoForGemini) UpdateExtra(ctx context.Context, id int64, updates map[string]any) error { return nil } diff --git a/backend/internal/service/ratelimit_session_window_test.go b/backend/internal/service/ratelimit_session_window_test.go index be6cb30975..52b31779d5 100644 --- a/backend/internal/service/ratelimit_session_window_test.go +++ b/backend/internal/service/ratelimit_session_window_test.go @@ -37,6 +37,9 @@ func (m *sessionWindowMockRepo) UpdateSessionWindow(_ context.Context, id int64, m.sessionWindowCalls = append(m.sessionWindowCalls, swCall{ID: id, Start: start, End: end, Status: status}) return nil } +func (m *sessionWindowMockRepo) UpdateSessionWindowEnd(_ context.Context, _ int64, _ time.Time) error { + return nil +} func (m *sessionWindowMockRepo) UpdateExtra(_ context.Context, id int64, updates map[string]any) error { m.updateExtraCalls = append(m.updateExtraCalls, ueCall{ID: id, Updates: updates}) return nil diff --git a/frontend/src/components/account/UsageProgressBar.vue b/frontend/src/components/account/UsageProgressBar.vue index 52f0ecbbe7..6a69357318 100644 --- a/frontend/src/components/account/UsageProgressBar.vue +++ b/frontend/src/components/account/UsageProgressBar.vue @@ -149,7 +149,7 @@ const shouldShowResetTime = computed(() => { const formatResetTime = computed(() => { // For rolling windows, when utilization is 0%, treat as immediately available. if (props.showNowWhenIdle && props.utilization <= 0) { - return '现在' + return t('usage.resetNow') } if (!props.resetsAt) return '-' @@ -157,7 +157,11 @@ const formatResetTime = computed(() => { const date = new Date(props.resetsAt) const diffMs = date.getTime() - now.value.getTime() - if (diffMs <= 0) return '现在' + // resetsAt 已过期:utilization>0 说明后端窗口数据还没刷新(active poll 没回写), + // 显示「待刷新」以区别于真正可用的「现在」。 + if (diffMs <= 0) { + return props.utilization > 0 ? t('usage.resetPending') : t('usage.resetNow') + } const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)) diff --git a/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts b/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts index 9def052c2e..6fa6575f54 100644 --- a/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts +++ b/frontend/src/components/account/__tests__/UsageProgressBar.spec.ts @@ -33,7 +33,7 @@ describe('UsageProgressBar', () => { } }) - expect(wrapper.text()).toContain('现在') + expect(wrapper.text()).toContain('usage.resetNow') expect(wrapper.text()).not.toContain('2h 30m') }) @@ -49,7 +49,8 @@ describe('UsageProgressBar', () => { }) expect(wrapper.text()).toContain('2h 30m') - expect(wrapper.text()).not.toContain('现在') + expect(wrapper.text()).not.toContain('usage.resetNow') + expect(wrapper.text()).not.toContain('usage.resetPending') }) it('showNowWhenIdle=false 时保持原有倒计时行为', () => { @@ -64,6 +65,35 @@ describe('UsageProgressBar', () => { }) expect(wrapper.text()).toContain('2h 30m') - expect(wrapper.text()).not.toContain('现在') + expect(wrapper.text()).not.toContain('usage.resetNow') + }) + + it('resetsAt 已过期且利用率大于 0 时显示「待刷新」', () => { + const wrapper = mount(UsageProgressBar, { + props: { + label: '5h', + utilization: 53, + // 早于 fake system time 2026-03-17T00:00:00Z + resetsAt: '2026-03-16T22:00:00Z', + color: 'indigo' + } + }) + + expect(wrapper.text()).toContain('usage.resetPending') + expect(wrapper.text()).not.toContain('usage.resetNow') + }) + + it('resetsAt 已过期且利用率为 0 时仍显示「现在」', () => { + const wrapper = mount(UsageProgressBar, { + props: { + label: '5h', + utilization: 0, + resetsAt: '2026-03-16T22:00:00Z', + color: 'indigo' + } + }) + + expect(wrapper.text()).toContain('usage.resetNow') + expect(wrapper.text()).not.toContain('usage.resetPending') }) }) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 1ad34b0e65..614bb70fae 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -856,6 +856,8 @@ export default { accountCost: 'Cost', userBilled: 'User billed', accountBilled: 'Account billed', + resetNow: 'Now', + resetPending: 'Pending refresh', accountMultiplier: 'Account rate', avgDuration: 'Avg Duration', inSelectedRange: 'in selected range', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 2868e5d598..411292143c 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -860,6 +860,8 @@ export default { accountCost: '成本', userBilled: '用户扣费', accountBilled: '账号计费', + resetNow: '现在', + resetPending: '待刷新', accountMultiplier: '账号倍率', avgDuration: '平均耗时', inSelectedRange: '所选范围内',