From 9e8959c56da58a5eb812f382d9460d7393f4f84e Mon Sep 17 00:00:00 2001 From: erio Date: Sun, 15 Mar 2026 14:04:13 +0800 Subject: [PATCH] fix(billing): treat nil rate limit window as expired to prevent usage accumulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Redis cache is populated from DB with a NULL window_1d_start, the Lua increment script only updates usage counters without setting window timestamps. IsWindowExpired(nil) previously returned false, so the accumulated usage was never reset across time windows, effectively turning usage_1d into a lifetime counter. Once this exceeded rate_limit_1d the key was incorrectly blocked with "日限额已用完". Fixes Wei-Shaw/sub2api#1022 --- backend/internal/service/api_key.go | 3 ++- .../service/api_key_rate_limit_test.go | 22 +++++++++---------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/backend/internal/service/api_key.go b/backend/internal/service/api_key.go index eb9f2b15..ec20b0a9 100644 --- a/backend/internal/service/api_key.go +++ b/backend/internal/service/api_key.go @@ -22,8 +22,9 @@ const ( ) // IsWindowExpired returns true if the window starting at windowStart has exceeded the given duration. +// A nil windowStart is treated as expired — no initialized window means any accumulated usage is stale. func IsWindowExpired(windowStart *time.Time, duration time.Duration) bool { - return windowStart != nil && time.Since(*windowStart) >= duration + return windowStart == nil || time.Since(*windowStart) >= duration } type APIKey struct { diff --git a/backend/internal/service/api_key_rate_limit_test.go b/backend/internal/service/api_key_rate_limit_test.go index 7fadf270..4058ca4b 100644 --- a/backend/internal/service/api_key_rate_limit_test.go +++ b/backend/internal/service/api_key_rate_limit_test.go @@ -15,10 +15,10 @@ func TestIsWindowExpired(t *testing.T) { want bool }{ { - name: "nil window start", + name: "nil window start (treated as expired)", start: nil, duration: RateLimitWindow5h, - want: false, + want: true, }, { name: "active window (started 1h ago, 5h window)", @@ -113,7 +113,7 @@ func TestAPIKey_EffectiveUsage(t *testing.T) { want7d: 0, }, { - name: "nil window starts return raw usage", + name: "nil window starts return 0 (stale usage reset)", key: APIKey{ Usage5h: 5.0, Usage1d: 10.0, @@ -122,9 +122,9 @@ func TestAPIKey_EffectiveUsage(t *testing.T) { Window1dStart: nil, Window7dStart: nil, }, - want5h: 5.0, - want1d: 10.0, - want7d: 50.0, + want5h: 0, + want1d: 0, + want7d: 0, }, { name: "mixed: 5h expired, 1d active, 7d nil", @@ -138,7 +138,7 @@ func TestAPIKey_EffectiveUsage(t *testing.T) { }, want5h: 0, want1d: 10.0, - want7d: 50.0, + want7d: 0, }, { name: "zero usage with active windows", @@ -210,7 +210,7 @@ func TestAPIKeyRateLimitData_EffectiveUsage(t *testing.T) { want7d: 0, }, { - name: "nil window starts return raw usage", + name: "nil window starts return 0 (stale usage reset)", data: APIKeyRateLimitData{ Usage5h: 3.0, Usage1d: 8.0, @@ -219,9 +219,9 @@ func TestAPIKeyRateLimitData_EffectiveUsage(t *testing.T) { Window1dStart: nil, Window7dStart: nil, }, - want5h: 3.0, - want1d: 8.0, - want7d: 40.0, + want5h: 0, + want1d: 0, + want7d: 0, }, }