fix(usage): sync 5h ResetsAt to SessionWindowEnd and zero expired window

active poll 拿到新 5h ResetsAt 时只回写了 Extra.session_window_utilization,
没回写 SessionWindowEnd column;estimateSetupTokenUsage 读这个 column 作为 5h
窗口结束时间,导致被动采样模式下 dashboard 显示 utilization > 0 但 reset
时间渲染为「现在」。

- syncActiveToPassive 增加 UpdateSessionWindowEnd 回写
- estimateSetupTokenUsage 抄齐 Codex 分支的过期归零 guard,避免 active
  poll 没回写时 UI 渲染矛盾
- UsageProgressBar 区分「待刷新」/「现在」语义;i18n key 中英补齐
This commit is contained in:
NeckBozia
2026-06-07 15:23:45 +08:00
parent f868f7cb4b
commit 16bc876935
13 changed files with 229 additions and 5 deletions
@@ -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)).
@@ -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")
}
@@ -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 账号的配额用量(总/日/周)
@@ -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")
}
@@ -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 columnestimateSetupTokenUsage
// 读这个字段作为窗口结束时间;只塞 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{
@@ -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))
}
}
@@ -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
}
@@ -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
}
@@ -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
@@ -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))
@@ -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')
})
})
+2
View File
@@ -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',
+2
View File
@@ -860,6 +860,8 @@ export default {
accountCost: '成本',
userBilled: '用户扣费',
accountBilled: '账号计费',
resetNow: '现在',
resetPending: '待刷新',
accountMultiplier: '账号倍率',
avgDuration: '平均耗时',
inSelectedRange: '所选范围内',