mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-21 14:19:18 +08:00
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:
@@ -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 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{
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -860,6 +860,8 @@ export default {
|
||||
accountCost: '成本',
|
||||
userBilled: '用户扣费',
|
||||
accountBilled: '账号计费',
|
||||
resetNow: '现在',
|
||||
resetPending: '待刷新',
|
||||
accountMultiplier: '账号倍率',
|
||||
avgDuration: '平均耗时',
|
||||
inSelectedRange: '所选范围内',
|
||||
|
||||
Reference in New Issue
Block a user