Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 27 additions & 5 deletions backend/internal/repository/account_usage_window_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"github.com/lib/pq"
)

var errAccountUsageWindowConflict = errors.New("account usage window state conflict")

type accountUsageWindowRepository struct {
sql sqlExecutor
db *sql.DB
Expand Down Expand Up @@ -42,15 +44,25 @@ func (r *accountUsageWindowRepository) UpsertOpen(ctx context.Context, row *usag
}

func (r *accountUsageWindowRepository) UpdateSample(ctx context.Context, id int64, peakPercent, lastPercent float64, sampledAt time.Time) error {
_, err := r.sql.ExecContext(ctx, `
result, err := r.sql.ExecContext(ctx, `
UPDATE account_usage_windows
SET peak_used_percent = GREATEST(peak_used_percent, $2),
last_used_percent = $3,
sampled_at = $4,
updated_at = NOW()
WHERE id = $1 AND status = 'open'
`, id, peakPercent, lastPercent, sampledAt)
return err
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return fmt.Errorf("%w: update window %d affected %d rows", errAccountUsageWindowConflict, id, affected)
}
return nil
}

func (r *accountUsageWindowRepository) CloseAndOpen(ctx context.Context, closed *usagestats.AccountUsageWindow, next *usagestats.AccountUsageWindow) error {
Expand Down Expand Up @@ -115,7 +127,7 @@ func (r *accountUsageWindowRepository) closeOne(ctx context.Context, exec sqlExe
if row.ClosedReason != "" {
reason = row.ClosedReason
}
_, err = exec.ExecContext(ctx, `
result, err := exec.ExecContext(ctx, `
UPDATE account_usage_windows
SET status = 'closed',
closed_reason = $2,
Expand All @@ -135,7 +147,17 @@ func (r *accountUsageWindowRepository) closeOne(ctx context.Context, exec sqlExe
`, row.ID, reason, row.PeakUsedPercent, row.LastUsedPercent,
row.LocalCost, row.StandardCost, row.UserCost, row.Requests, row.Tokens,
row.InferredLimitUSD, row.InferredConfidence, breakdown, row.SampledAt)
return err
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected != 1 {
return fmt.Errorf("%w: close window %d affected %d rows", errAccountUsageWindowConflict, row.ID, affected)
}
return nil
}

func upsertOpenWith(ctx context.Context, exec sqlExecutor, row *usagestats.AccountUsageWindow) error {
Expand Down Expand Up @@ -166,7 +188,7 @@ func upsertOpenWith(ctx context.Context, exec sqlExecutor, row *usagestats.Accou
`, []any{row.AccountID, row.WindowType, row.WindowStart, row.WindowEnd,
row.PeakUsedPercent, row.LastUsedPercent, breakdown, row.SampledAt}, &id)
if errors.Is(err, sql.ErrNoRows) {
return nil
return fmt.Errorf("%w: open window already exists as a closed row", errAccountUsageWindowConflict)
}
if err != nil {
return err
Expand Down
120 changes: 120 additions & 0 deletions backend/internal/repository/account_usage_window_repo_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
package repository

import (
"context"
"errors"
"testing"
"time"

"github.com/DATA-DOG/go-sqlmock"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
"github.com/stretchr/testify/require"
)

func newAccountUsageWindowRepoMock(t *testing.T) (*accountUsageWindowRepository, sqlmock.Sqlmock) {
t.Helper()
db, mock, err := sqlmock.New()
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
return &accountUsageWindowRepository{sql: db, db: db}, mock
}

func accountUsageWindowTestRows() (*usagestats.AccountUsageWindow, *usagestats.AccountUsageWindow) {
now := time.Date(2026, 8, 27, 11, 33, 43, 0, time.UTC)
closed := &usagestats.AccountUsageWindow{
ID: 75,
AccountID: 20,
WindowType: usagestats.AccountWindowType7d,
WindowStart: now.Add(-7 * 24 * time.Hour),
WindowEnd: now,
Status: usagestats.AccountWindowStatusOpen,
PeakUsedPercent: 6,
LastUsedPercent: 6,
InferredConfidence: usagestats.AccountWindowConfidenceLow,
ModelBreakdown: []usagestats.AccountWindowModelStat{},
SampledAt: now,
}
next := &usagestats.AccountUsageWindow{
AccountID: 20,
WindowType: usagestats.AccountWindowType7d,
WindowStart: now,
WindowEnd: now.Add(7 * 24 * time.Hour),
Status: usagestats.AccountWindowStatusOpen,
InferredConfidence: usagestats.AccountWindowConfidenceLow,
ModelBreakdown: []usagestats.AccountWindowModelStat{},
SampledAt: now,
}
return closed, next
}

func TestAccountUsageWindowUpdateSampleRejectsStaleWindow(t *testing.T) {
repo, mock := newAccountUsageWindowRepoMock(t)
now := time.Date(2026, 8, 22, 12, 0, 0, 0, time.UTC)

mock.ExpectExec(`(?s)UPDATE account_usage_windows.*WHERE id = \$1 AND status = 'open'`).
WillReturnResult(sqlmock.NewResult(0, 0))

err := repo.UpdateSample(context.Background(), 75, 6, 6, now)
require.ErrorIs(t, err, errAccountUsageWindowConflict)
require.NoError(t, mock.ExpectationsWereMet())
}

func TestAccountUsageWindowCloseAndOpenCommitsCompleteRoll(t *testing.T) {
repo, mock := newAccountUsageWindowRepoMock(t)
closed, next := accountUsageWindowTestRows()

mock.ExpectBegin()
mock.ExpectExec(`(?s)UPDATE account_usage_windows.*WHERE id = \$1 AND status = 'open'`).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery(`(?s)INSERT INTO account_usage_windows.*RETURNING id`).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(76)))
mock.ExpectCommit()

require.NoError(t, repo.CloseAndOpen(context.Background(), closed, next))
require.Equal(t, int64(76), next.ID)
require.NoError(t, mock.ExpectationsWereMet())
}

func TestAccountUsageWindowCloseAndOpenRollsBackStaleClose(t *testing.T) {
repo, mock := newAccountUsageWindowRepoMock(t)
closed, next := accountUsageWindowTestRows()

mock.ExpectBegin()
mock.ExpectExec(`(?s)UPDATE account_usage_windows.*WHERE id = \$1 AND status = 'open'`).
WillReturnResult(sqlmock.NewResult(0, 0))
mock.ExpectRollback()

err := repo.CloseAndOpen(context.Background(), closed, next)
require.ErrorIs(t, err, errAccountUsageWindowConflict)
require.NoError(t, mock.ExpectationsWereMet())
}

func TestAccountUsageWindowCloseAndOpenRollsBackClosedTargetConflict(t *testing.T) {
repo, mock := newAccountUsageWindowRepoMock(t)
closed, next := accountUsageWindowTestRows()

mock.ExpectBegin()
mock.ExpectExec(`(?s)UPDATE account_usage_windows.*WHERE id = \$1 AND status = 'open'`).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery(`(?s)INSERT INTO account_usage_windows.*RETURNING id`).
WillReturnRows(sqlmock.NewRows([]string{"id"}))
mock.ExpectRollback()

err := repo.CloseAndOpen(context.Background(), closed, next)
require.True(t, errors.Is(err, errAccountUsageWindowConflict))
require.Zero(t, next.ID)
require.NoError(t, mock.ExpectationsWereMet())
}

func TestAccountUsageWindowUpsertOpenRejectsClosedTargetConflict(t *testing.T) {
repo, mock := newAccountUsageWindowRepoMock(t)
_, next := accountUsageWindowTestRows()

mock.ExpectQuery(`(?s)INSERT INTO account_usage_windows.*RETURNING id`).
WillReturnRows(sqlmock.NewRows([]string{"id"}))

err := repo.UpsertOpen(context.Background(), next)
require.ErrorIs(t, err, errAccountUsageWindowConflict)
require.Zero(t, next.ID)
require.NoError(t, mock.ExpectationsWereMet())
}
90 changes: 57 additions & 33 deletions backend/internal/service/account_usage_window.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package service

import (
"context"
"math"
"time"

"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
Expand All @@ -12,10 +13,9 @@ const (
accountWindowRollSkew = 2 * time.Minute
// accountWindow7dRollSkew 7d 剩余秒数是 now+seconds 现算的,双源/取整常漂几分钟。
accountWindow7dRollSkew = 30 * time.Minute
// accountWindow7dRollMinJump 7d 占比几乎没掉时,reset_at 至少跳这么多才换窗。
accountWindow7dRollMinJump = 6 * time.Hour
// accountWindowResetPercentDrop 官方占比至少下降这么多,才认为窗口被重置。
accountWindowResetPercentDrop = 3.0
// 官方 7d 时长偶尔会有取整差异,允许 6d-8d,拒绝 0 等占位窗口。
accountWindow7dMinMinutes = 6 * 24 * 60
accountWindow7dMaxMinutes = 8 * 24 * 60
// accountWindowLowPercent 低于此峰值不反推额度。
accountWindowLowPercent = 8.0
// accountWindowMediumPercent 低于此峰值为中等置信。
Expand All @@ -29,14 +29,23 @@ const (

// CodexWindowSample 一次官方 5h/7d 采样。缺字段表示这次没看到该窗。
type CodexWindowSample struct {
Used5hPercent *float64
Reset5hAt *time.Time
Used7dPercent *float64
Reset7dAt *time.Time
ClosedReason string
Now time.Time
Used5hPercent *float64
Reset5hAt *time.Time
Used7dPercent *float64
Reset7dAt *time.Time
Window7dMinutes *int
ClosedReason string
Now time.Time
}

type accountWindowSampleAction uint8

const (
accountWindowSampleUpdate accountWindowSampleAction = iota
accountWindowSampleIgnore
accountWindowSampleRoll
)

// CodexWindowObserver 在 Extra 覆盖之前观察官方窗口,避免节流丢掉换窗。
type CodexWindowObserver interface {
Observe(ctx context.Context, accountID int64, sample CodexWindowSample)
Expand Down Expand Up @@ -107,44 +116,59 @@ func sameAccountWindowWithin(prevEnd, nextEnd time.Time, skew time.Duration) boo
return absDuration(nextEnd.Sub(prevEnd)) <= skew
}

func shouldStayOnOpenWindow(open *usagestats.AccountUsageWindow, percent float64, resetAt *time.Time) bool {
func classifyAccountWindowSample(open *usagestats.AccountUsageWindow, resetAt *time.Time, now time.Time) accountWindowSampleAction {
if open == nil {
return false
return accountWindowSampleRoll
}
if resetAt == nil {
return true
return accountWindowSampleUpdate
}
nextEnd := resetAt.UTC()
if open.WindowType != usagestats.AccountWindowType7d {
return sameAccountWindow(open.WindowEnd, nextEnd)
if sameAccountWindow(open.WindowEnd, nextEnd) {
return accountWindowSampleUpdate
}
return accountWindowSampleRoll
}
if sameAccountWindowWithin(open.WindowEnd, nextEnd, accountWindow7dRollSkew) {
return true
return accountWindowSampleUpdate
}
// 7d:占比没掉且 reset_at 没跳过数小时,当作剩余秒数抖动,挂在原窗。
return !usagePercentDropped(windowUsedPercent(open), percent) &&
!accountWindowResetJumped(open.WindowEnd, nextEnd, accountWindow7dRollMinJump)
}

func windowUsedPercent(open *usagestats.AccountUsageWindow) float64 {
if open == nil {
return 0
// 普通 probe 不能在官方窗口结束前提前换窗。主动 credit reset
// 走 CloseOpenWindows 的显式路径,不依赖这里的启发式判断。
if now.Before(open.WindowEnd) {
return accountWindowSampleIgnore
}
if open.PeakUsedPercent > open.LastUsedPercent {
return open.PeakUsedPercent
// 新 reset 必须明确落在当前窗口之后;旧值、倒退值都视为陈旧样本。
if !nextEnd.After(open.WindowEnd.Add(accountWindow7dRollSkew)) {
return accountWindowSampleIgnore
}
return open.LastUsedPercent
return accountWindowSampleRoll
}

func usagePercentDropped(prev, next float64) bool {
return prev-next >= accountWindowResetPercentDrop
}

func accountWindowResetJumped(prev, next time.Time, minJump time.Duration) bool {
if prev.IsZero() || next.IsZero() || minJump <= 0 {
func validAccountWindowSample(windowType string, used *float64, resetAt *time.Time, windowMinutes *int, now time.Time, opening bool) bool {
if used == nil {
return false
}
if math.IsNaN(*used) || math.IsInf(*used, 0) || *used < 0 || *used > 100 {
return false
}
return absDuration(next.Sub(prev)) >= minJump
if windowType == usagestats.AccountWindowType7d && windowMinutes != nil {
if *windowMinutes < accountWindow7dMinMinutes || *windowMinutes > accountWindow7dMaxMinutes {
return false
}
}
if resetAt != nil {
end := resetAt.UTC()
maxDuration := windowDuration(windowType)
if windowMinutes != nil {
maxDuration = time.Duration(*windowMinutes) * time.Minute
}
if !end.After(now) || (maxDuration > 0 && end.After(now.Add(maxDuration+accountWindow7dRollSkew))) {
return false
}
}
// 没有 reset_at 时,首次观测无法建立稳定的窗口身份。
return !opening || resetAt != nil
}

func absDuration(d time.Duration) time.Duration {
Expand Down
Loading