fix(usage): use real reset header for session window instead of prediction

The 5h window reset time displayed for Setup Token accounts was inaccurate
because UpdateSessionWindow predicted the window end as "current hour + 5h"
instead of reading the actual `anthropic-ratelimit-unified-5h-reset` response
header. This caused the countdown to differ from the official Claude page.

Backend: parse the reset header (Unix timestamp) and use it as the real
window end, falling back to the hour-truncated prediction only when the
header is absent. Also correct stale predictions when a subsequent request
provides the real reset time.

Frontend: add a reactive 60s timer so the reset countdown in
UsageProgressBar ticks down in real-time instead of freezing at the
initial value.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Elysia
2026-03-17 00:13:45 +08:00
parent f42c8f2abe
commit 668e164793
3 changed files with 395 additions and 7 deletions

View File

@@ -1051,16 +1051,32 @@ func (s *RateLimitService) UpdateSessionWindow(ctx context.Context, account *Acc
var windowStart, windowEnd *time.Time
needInitWindow := account.SessionWindowEnd == nil || time.Now().After(*account.SessionWindowEnd)
if needInitWindow && (status == "allowed" || status == "allowed_warning") {
// 预测时间窗口:从当前时间的整点开始,+5小时为结束
// 例如:现在是 14:30窗口为 14:00 ~ 19:00
// 优先使用响应头中的真实重置时间(比预测更准确)
if resetStr := headers.Get("anthropic-ratelimit-unified-5h-reset"); resetStr != "" {
if ts, err := strconv.ParseInt(resetStr, 10, 64); err == nil {
end := time.Unix(ts, 0)
// 窗口需要初始化,或者真实重置时间与已存储的不同,则更新
if needInitWindow || account.SessionWindowEnd == nil || !end.Equal(*account.SessionWindowEnd) {
start := end.Add(-5 * time.Hour)
windowStart = &start
windowEnd = &end
slog.Info("account_session_window_from_header", "account_id", account.ID, "window_start", start, "window_end", end, "status", status)
}
}
}
// 回退:如果没有真实重置时间且需要初始化窗口,使用预测
if windowEnd == nil && needInitWindow && (status == "allowed" || status == "allowed_warning") {
now := time.Now()
start := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
end := start.Add(5 * time.Hour)
windowStart = &start
windowEnd = &end
slog.Info("account_session_window_initialized", "account_id", account.ID, "window_start", start, "window_end", end, "status", status)
// 窗口重置时清除旧的 utilization避免残留上个窗口的数据
}
// 窗口重置时清除旧的 utilization避免残留上个窗口的数据
if windowEnd != nil && needInitWindow {
_ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{
"session_window_utilization": nil,
})

View File

@@ -0,0 +1,366 @@
package service
import (
"context"
"fmt"
"net/http"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
)
// sessionWindowMockRepo is a minimal AccountRepository mock that records calls
// made by UpdateSessionWindow. Unrelated methods panic if invoked.
type sessionWindowMockRepo struct {
// captured calls
sessionWindowCalls []swCall
updateExtraCalls []ueCall
clearRateLimitIDs []int64
}
type swCall struct {
ID int64
Start *time.Time
End *time.Time
Status string
}
type ueCall struct {
ID int64
Updates map[string]any
}
func (m *sessionWindowMockRepo) UpdateSessionWindow(_ context.Context, id int64, start, end *time.Time, status string) error {
m.sessionWindowCalls = append(m.sessionWindowCalls, swCall{ID: id, Start: start, End: end, Status: status})
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
}
func (m *sessionWindowMockRepo) ClearRateLimit(_ context.Context, id int64) error {
m.clearRateLimitIDs = append(m.clearRateLimitIDs, id)
return nil
}
func (m *sessionWindowMockRepo) ClearAntigravityQuotaScopes(_ context.Context, _ int64) error {
return nil
}
func (m *sessionWindowMockRepo) ClearModelRateLimits(_ context.Context, _ int64) error {
return nil
}
func (m *sessionWindowMockRepo) ClearTempUnschedulable(_ context.Context, _ int64) error {
return nil
}
// --- Unused interface methods (panic on unexpected call) ---
func (m *sessionWindowMockRepo) Create(context.Context, *Account) error { panic("unexpected") }
func (m *sessionWindowMockRepo) GetByID(context.Context, int64) (*Account, error) {
panic("unexpected")
}
func (m *sessionWindowMockRepo) GetByIDs(context.Context, []int64) ([]*Account, error) {
panic("unexpected")
}
func (m *sessionWindowMockRepo) ExistsByID(context.Context, int64) (bool, error) {
panic("unexpected")
}
func (m *sessionWindowMockRepo) GetByCRSAccountID(context.Context, string) (*Account, error) {
panic("unexpected")
}
func (m *sessionWindowMockRepo) FindByExtraField(context.Context, string, any) ([]Account, error) {
panic("unexpected")
}
func (m *sessionWindowMockRepo) ListCRSAccountIDs(context.Context) (map[string]int64, error) {
panic("unexpected")
}
func (m *sessionWindowMockRepo) Update(context.Context, *Account) error { panic("unexpected") }
func (m *sessionWindowMockRepo) Delete(context.Context, int64) error { panic("unexpected") }
func (m *sessionWindowMockRepo) List(context.Context, pagination.PaginationParams) ([]Account, *pagination.PaginationResult, error) {
panic("unexpected")
}
func (m *sessionWindowMockRepo) ListWithFilters(context.Context, pagination.PaginationParams, string, string, string, string, int64) ([]Account, *pagination.PaginationResult, error) {
panic("unexpected")
}
func (m *sessionWindowMockRepo) ListByGroup(context.Context, int64) ([]Account, error) {
panic("unexpected")
}
func (m *sessionWindowMockRepo) ListActive(context.Context) ([]Account, error) {
panic("unexpected")
}
func (m *sessionWindowMockRepo) ListByPlatform(context.Context, string) ([]Account, error) {
panic("unexpected")
}
func (m *sessionWindowMockRepo) UpdateLastUsed(context.Context, int64) error { panic("unexpected") }
func (m *sessionWindowMockRepo) BatchUpdateLastUsed(context.Context, map[int64]time.Time) error {
panic("unexpected")
}
func (m *sessionWindowMockRepo) SetError(context.Context, int64, string) error {
panic("unexpected")
}
func (m *sessionWindowMockRepo) ClearError(context.Context, int64) error { panic("unexpected") }
func (m *sessionWindowMockRepo) SetSchedulable(context.Context, int64, bool) error {
panic("unexpected")
}
func (m *sessionWindowMockRepo) AutoPauseExpiredAccounts(context.Context, time.Time) (int64, error) {
panic("unexpected")
}
func (m *sessionWindowMockRepo) BindGroups(context.Context, int64, []int64) error {
panic("unexpected")
}
func (m *sessionWindowMockRepo) ListSchedulable(context.Context) ([]Account, error) {
panic("unexpected")
}
func (m *sessionWindowMockRepo) ListSchedulableByGroupID(context.Context, int64) ([]Account, error) {
panic("unexpected")
}
func (m *sessionWindowMockRepo) ListSchedulableByPlatform(context.Context, string) ([]Account, error) {
panic("unexpected")
}
func (m *sessionWindowMockRepo) ListSchedulableByGroupIDAndPlatform(context.Context, int64, string) ([]Account, error) {
panic("unexpected")
}
func (m *sessionWindowMockRepo) ListSchedulableByPlatforms(context.Context, []string) ([]Account, error) {
panic("unexpected")
}
func (m *sessionWindowMockRepo) ListSchedulableByGroupIDAndPlatforms(context.Context, int64, []string) ([]Account, error) {
panic("unexpected")
}
func (m *sessionWindowMockRepo) ListSchedulableUngroupedByPlatform(context.Context, string) ([]Account, error) {
panic("unexpected")
}
func (m *sessionWindowMockRepo) ListSchedulableUngroupedByPlatforms(context.Context, []string) ([]Account, error) {
panic("unexpected")
}
func (m *sessionWindowMockRepo) SetRateLimited(context.Context, int64, time.Time) error {
panic("unexpected")
}
func (m *sessionWindowMockRepo) SetModelRateLimit(context.Context, int64, string, time.Time) error {
panic("unexpected")
}
func (m *sessionWindowMockRepo) SetOverloaded(context.Context, int64, time.Time) error {
panic("unexpected")
}
func (m *sessionWindowMockRepo) SetTempUnschedulable(context.Context, int64, time.Time, string) error {
panic("unexpected")
}
func (m *sessionWindowMockRepo) BulkUpdate(context.Context, []int64, AccountBulkUpdate) (int64, error) {
panic("unexpected")
}
func (m *sessionWindowMockRepo) IncrementQuotaUsed(context.Context, int64, float64) error {
panic("unexpected")
}
func (m *sessionWindowMockRepo) ResetQuotaUsed(context.Context, int64) error { panic("unexpected") }
// newRateLimitServiceForTest creates a RateLimitService with the given mock repo.
func newRateLimitServiceForTest(repo AccountRepository) *RateLimitService {
return &RateLimitService{accountRepo: repo}
}
func TestUpdateSessionWindow_UsesResetHeader(t *testing.T) {
// The reset header provides the real window end as a Unix timestamp.
// UpdateSessionWindow should use it instead of the hour-truncated prediction.
resetUnix := int64(1771020000) // 2026-02-14T10:00:00Z
wantEnd := time.Unix(resetUnix, 0)
wantStart := wantEnd.Add(-5 * time.Hour)
repo := &sessionWindowMockRepo{}
svc := newRateLimitServiceForTest(repo)
account := &Account{ID: 42} // no existing window → needInitWindow=true
headers := http.Header{}
headers.Set("anthropic-ratelimit-unified-5h-status", "allowed")
headers.Set("anthropic-ratelimit-unified-5h-reset", fmt.Sprintf("%d", resetUnix))
svc.UpdateSessionWindow(context.Background(), account, headers)
if len(repo.sessionWindowCalls) != 1 {
t.Fatalf("expected 1 UpdateSessionWindow call, got %d", len(repo.sessionWindowCalls))
}
call := repo.sessionWindowCalls[0]
if call.ID != 42 {
t.Errorf("expected account ID 42, got %d", call.ID)
}
if call.End == nil || !call.End.Equal(wantEnd) {
t.Errorf("expected window end %v, got %v", wantEnd, call.End)
}
if call.Start == nil || !call.Start.Equal(wantStart) {
t.Errorf("expected window start %v, got %v", wantStart, call.Start)
}
if call.Status != "allowed" {
t.Errorf("expected status 'allowed', got %q", call.Status)
}
}
func TestUpdateSessionWindow_FallbackPredictionWhenNoResetHeader(t *testing.T) {
// When the reset header is absent, should fall back to hour-truncated prediction.
repo := &sessionWindowMockRepo{}
svc := newRateLimitServiceForTest(repo)
account := &Account{ID: 10} // no existing window
headers := http.Header{}
headers.Set("anthropic-ratelimit-unified-5h-status", "allowed_warning")
// No anthropic-ratelimit-unified-5h-reset header
svc.UpdateSessionWindow(context.Background(), account, headers)
if len(repo.sessionWindowCalls) != 1 {
t.Fatalf("expected 1 UpdateSessionWindow call, got %d", len(repo.sessionWindowCalls))
}
call := repo.sessionWindowCalls[0]
if call.End == nil {
t.Fatal("expected window end to be set (fallback prediction)")
}
// Fallback: start = current hour truncated, end = start + 5h
now := time.Now()
expectedStart := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location())
expectedEnd := expectedStart.Add(5 * time.Hour)
if !call.End.Equal(expectedEnd) {
t.Errorf("expected fallback end %v, got %v", expectedEnd, *call.End)
}
if call.Start == nil || !call.Start.Equal(expectedStart) {
t.Errorf("expected fallback start %v, got %v", expectedStart, call.Start)
}
}
func TestUpdateSessionWindow_CorrectsStalePrediction(t *testing.T) {
// When the stored SessionWindowEnd is wrong (from a previous prediction),
// and the reset header provides the real time, it should update the window.
staleEnd := time.Now().Add(2 * time.Hour) // existing prediction: 2h from now
realResetUnix := time.Now().Add(4 * time.Hour).Unix() // real reset: 4h from now
wantEnd := time.Unix(realResetUnix, 0)
repo := &sessionWindowMockRepo{}
svc := newRateLimitServiceForTest(repo)
account := &Account{
ID: 55,
SessionWindowEnd: &staleEnd,
}
headers := http.Header{}
headers.Set("anthropic-ratelimit-unified-5h-status", "allowed")
headers.Set("anthropic-ratelimit-unified-5h-reset", fmt.Sprintf("%d", realResetUnix))
svc.UpdateSessionWindow(context.Background(), account, headers)
if len(repo.sessionWindowCalls) != 1 {
t.Fatalf("expected 1 UpdateSessionWindow call, got %d", len(repo.sessionWindowCalls))
}
call := repo.sessionWindowCalls[0]
if call.End == nil || !call.End.Equal(wantEnd) {
t.Errorf("expected corrected end %v, got %v", wantEnd, call.End)
}
}
func TestUpdateSessionWindow_NoUpdateWhenHeaderMatchesStored(t *testing.T) {
// If the reset header matches the stored SessionWindowEnd, no window update needed.
futureUnix := time.Now().Add(3 * time.Hour).Unix()
existingEnd := time.Unix(futureUnix, 0)
repo := &sessionWindowMockRepo{}
svc := newRateLimitServiceForTest(repo)
account := &Account{
ID: 77,
SessionWindowEnd: &existingEnd,
}
headers := http.Header{}
headers.Set("anthropic-ratelimit-unified-5h-status", "allowed")
headers.Set("anthropic-ratelimit-unified-5h-reset", fmt.Sprintf("%d", futureUnix)) // same as stored
svc.UpdateSessionWindow(context.Background(), account, headers)
if len(repo.sessionWindowCalls) != 1 {
t.Fatalf("expected 1 UpdateSessionWindow call, got %d", len(repo.sessionWindowCalls))
}
call := repo.sessionWindowCalls[0]
// windowStart and windowEnd should be nil (no update needed)
if call.Start != nil || call.End != nil {
t.Errorf("expected nil start/end (no window change needed), got start=%v end=%v", call.Start, call.End)
}
// Status is still updated
if call.Status != "allowed" {
t.Errorf("expected status 'allowed', got %q", call.Status)
}
}
func TestUpdateSessionWindow_ClearsUtilizationOnWindowReset(t *testing.T) {
// When needInitWindow=true and window is set, utilization should be cleared.
resetUnix := int64(1771020000)
repo := &sessionWindowMockRepo{}
svc := newRateLimitServiceForTest(repo)
account := &Account{ID: 33} // no existing window → needInitWindow=true
headers := http.Header{}
headers.Set("anthropic-ratelimit-unified-5h-status", "allowed")
headers.Set("anthropic-ratelimit-unified-5h-reset", fmt.Sprintf("%d", resetUnix))
headers.Set("anthropic-ratelimit-unified-5h-utilization", "0.15")
svc.UpdateSessionWindow(context.Background(), account, headers)
// Should have 2 UpdateExtra calls: one to clear utilization, one to store new utilization
if len(repo.updateExtraCalls) != 2 {
t.Fatalf("expected 2 UpdateExtra calls, got %d", len(repo.updateExtraCalls))
}
// First call: clear utilization (nil value)
clearCall := repo.updateExtraCalls[0]
if clearCall.Updates["session_window_utilization"] != nil {
t.Errorf("expected utilization cleared to nil, got %v", clearCall.Updates["session_window_utilization"])
}
// Second call: store new utilization
storeCall := repo.updateExtraCalls[1]
if val, ok := storeCall.Updates["session_window_utilization"].(float64); !ok || val != 0.15 {
t.Errorf("expected utilization stored as 0.15, got %v", storeCall.Updates["session_window_utilization"])
}
}
func TestUpdateSessionWindow_NoClearUtilizationOnCorrection(t *testing.T) {
// When correcting a stale prediction (needInitWindow=false), utilization should NOT be cleared.
staleEnd := time.Now().Add(2 * time.Hour)
realResetUnix := time.Now().Add(4 * time.Hour).Unix()
repo := &sessionWindowMockRepo{}
svc := newRateLimitServiceForTest(repo)
account := &Account{
ID: 66,
SessionWindowEnd: &staleEnd,
}
headers := http.Header{}
headers.Set("anthropic-ratelimit-unified-5h-status", "allowed")
headers.Set("anthropic-ratelimit-unified-5h-reset", fmt.Sprintf("%d", realResetUnix))
headers.Set("anthropic-ratelimit-unified-5h-utilization", "0.30")
svc.UpdateSessionWindow(context.Background(), account, headers)
// Only 1 UpdateExtra call (store utilization), no clear call
if len(repo.updateExtraCalls) != 1 {
t.Fatalf("expected 1 UpdateExtra call (no clear), got %d", len(repo.updateExtraCalls))
}
if val, ok := repo.updateExtraCalls[0].Updates["session_window_utilization"].(float64); !ok || val != 0.30 {
t.Errorf("expected utilization 0.30, got %v", repo.updateExtraCalls[0].Updates["session_window_utilization"])
}
}
func TestUpdateSessionWindow_NoStatusHeader(t *testing.T) {
// Should return immediately if no status header.
repo := &sessionWindowMockRepo{}
svc := newRateLimitServiceForTest(repo)
account := &Account{ID: 1}
svc.UpdateSessionWindow(context.Background(), account, http.Header{})
if len(repo.sessionWindowCalls) != 0 {
t.Errorf("expected no calls when status header absent, got %d", len(repo.sessionWindowCalls))
}
}

View File

@@ -56,7 +56,8 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { useIntervalFn } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import type { WindowStats } from '@/types'
import { formatCompactNumber } from '@/utils/format'
@@ -71,6 +72,12 @@ const props = defineProps<{
const { t } = useI18n()
// Reactive clock for countdown (updates every 60s)
const now = ref(new Date())
useIntervalFn(() => {
now.value = new Date()
}, 60_000)
// Label background colors
const labelClass = computed(() => {
const colors = {
@@ -119,8 +126,7 @@ const displayPercent = computed(() => {
const formatResetTime = computed(() => {
if (!props.resetsAt) return '-'
const date = new Date(props.resetsAt)
const now = new Date()
const diffMs = date.getTime() - now.getTime()
const diffMs = date.getTime() - now.value.getTime()
if (diffMs <= 0) return '现在'