mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
Merge pull request #3097 from NeckBozia/fix/5h-resets-at-stale
fix(usage): sync 5h ResetsAt to SessionWindowEnd and zero expired window
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")
|
||||
}
|
||||
|
||||
@@ -69,6 +69,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
|
||||
|
||||
Reference in New Issue
Block a user