diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index fb95201f20..de7d97f884 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -2036,6 +2036,60 @@ func ComputeQuotaResetAt(extra map[string]any) { } } +// NormalizeFixedQuotaWindows aligns preserved quota usage with the active fixed reset window. +// +// Editing an existing account can switch a daily/weekly quota from rolling to fixed reset +// while preserving quota_*_used and quota_*_start. If the preserved start belongs to the +// old rolling window, response mapping treats the usage as expired and the dashboard shows +// 0 until the next reset. Normalize those stale starts before persisting the edited account. +func NormalizeFixedQuotaWindows(extra map[string]any) { + if extra == nil { + return + } + now := time.Now() + tzName, _ := extra["quota_reset_timezone"].(string) + if tzName == "" { + tzName = "UTC" + } + tz, err := time.LoadLocation(tzName) + if err != nil { + tz = time.UTC + } + + if mode, _ := extra["quota_daily_reset_mode"].(string); mode == "fixed" && parseExtraFloat64(extra["quota_daily_limit"]) > 0 { + hour := int(parseExtraFloat64(extra["quota_daily_reset_hour"])) + if hour < 0 || hour > 23 { + hour = 0 + } + lastReset := lastFixedDailyReset(hour, tz, now) + start := parseExtraTime(extra["quota_daily_start"]) + if start.IsZero() || start.Before(lastReset) { + extra["quota_daily_used"] = 0.0 + extra["quota_daily_start"] = lastReset.UTC().Format(time.RFC3339) + } + } + + if mode, _ := extra["quota_weekly_reset_mode"].(string); mode == "fixed" && parseExtraFloat64(extra["quota_weekly_limit"]) > 0 { + day := 1 + if rawDay, ok := extra["quota_weekly_reset_day"]; ok { + day = int(parseExtraFloat64(rawDay)) + } + if day < 0 || day > 6 { + day = 1 + } + hour := int(parseExtraFloat64(extra["quota_weekly_reset_hour"])) + if hour < 0 || hour > 23 { + hour = 0 + } + lastReset := lastFixedWeeklyReset(day, hour, tz, now) + start := parseExtraTime(extra["quota_weekly_start"]) + if start.IsZero() || start.Before(lastReset) { + extra["quota_weekly_used"] = 0.0 + extra["quota_weekly_start"] = lastReset.UTC().Format(time.RFC3339) + } + } +} + // ValidateQuotaResetConfig 校验配额固定重置时间配置的合法性 func ValidateQuotaResetConfig(extra map[string]any) error { if extra == nil { @@ -2364,6 +2418,18 @@ func parseExtraFloat64(value any) float64 { return 0 } +func parseExtraTime(value any) time.Time { + if s, ok := value.(string); ok { + if t, err := time.Parse(time.RFC3339Nano, s); err == nil { + return t + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t + } + } + return time.Time{} +} + // parseExtraInt 从 extra 字段解析 int 值 // ParseExtraInt 从 extra 字段的 any 值解析为 int。 // 支持 int, int64, float64, json.Number, string 类型,无法解析时返回 0。 diff --git a/backend/internal/service/account_quota_reset_test.go b/backend/internal/service/account_quota_reset_test.go index 45a4bad6e9..fe0900c04e 100644 --- a/backend/internal/service/account_quota_reset_test.go +++ b/backend/internal/service/account_quota_reset_test.go @@ -398,6 +398,51 @@ func TestValidateQuotaResetConfig_BoundaryValues(t *testing.T) { assert.NoError(t, ValidateQuotaResetConfig(extra2)) } +// --------------------------------------------------------------------------- +// NormalizeFixedQuotaWindows +// --------------------------------------------------------------------------- + +func TestNormalizeFixedQuotaWindows_ClearsExpiredWeeklyWindow(t *testing.T) { + now := time.Now().UTC() + daysSinceMonday := (int(now.Weekday()) + 6) % 7 + currentWeekStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, -daysSinceMonday) + staleStart := currentWeekStart.Add(-24 * time.Hour) + extra := map[string]any{ + "quota_weekly_limit": 500.0, + "quota_weekly_used": 76.0, + "quota_weekly_start": staleStart.Format(time.RFC3339), + "quota_weekly_reset_mode": "fixed", + "quota_weekly_reset_day": float64(1), + "quota_weekly_reset_hour": float64(0), + "quota_reset_timezone": "UTC", + } + + NormalizeFixedQuotaWindows(extra) + + assert.Equal(t, 0.0, extra["quota_weekly_used"]) + assert.Equal(t, currentWeekStart.Format(time.RFC3339), extra["quota_weekly_start"]) +} + +func TestNormalizeFixedQuotaWindows_KeepsActiveWeeklyWindow(t *testing.T) { + now := time.Now().UTC() + daysSinceMonday := (int(now.Weekday()) + 6) % 7 + currentWeekStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, -daysSinceMonday) + extra := map[string]any{ + "quota_weekly_limit": 500.0, + "quota_weekly_used": 76.0, + "quota_weekly_start": currentWeekStart.Format(time.RFC3339), + "quota_weekly_reset_mode": "fixed", + "quota_weekly_reset_day": float64(1), + "quota_weekly_reset_hour": float64(0), + "quota_reset_timezone": "UTC", + } + + NormalizeFixedQuotaWindows(extra) + + assert.Equal(t, 76.0, extra["quota_weekly_used"]) + assert.Equal(t, currentWeekStart.Format(time.RFC3339), extra["quota_weekly_start"]) +} + // --------------------------------------------------------------------------- // ComputeQuotaResetAt // --------------------------------------------------------------------------- diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 00205d1f79..562298a1cc 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -2498,6 +2498,7 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou return nil, err } ComputeQuotaResetAt(account.Extra) + NormalizeFixedQuotaWindows(account.Extra) } if input.ExpiresAt != nil && *input.ExpiresAt > 0 { expiresAt := time.Unix(*input.ExpiresAt, 0) @@ -2606,6 +2607,7 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U return nil, err } ComputeQuotaResetAt(account.Extra) + NormalizeFixedQuotaWindows(account.Extra) } if input.ProxyID != nil { // 0 表示清除代理(前端发送 0 而不是 null 来表达清除意图) diff --git a/backend/internal/service/admin_service_overages_test.go b/backend/internal/service/admin_service_overages_test.go index d6380f4dcd..211ad4cd7a 100644 --- a/backend/internal/service/admin_service_overages_test.go +++ b/backend/internal/service/admin_service_overages_test.go @@ -153,3 +153,42 @@ func TestUpdateAccount_EmptyExtraPayloadCanClearQuotaLimits(t *testing.T) { require.NotContains(t, repo.account.Extra, "quota_weekly_limit") require.Len(t, repo.account.Extra, 0) } + +func TestUpdateAccount_FixedWeeklyResetClearsLegacyRollingUsage(t *testing.T) { + now := time.Now().UTC() + daysSinceMonday := (int(now.Weekday()) + 6) % 7 + currentWeekStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, -daysSinceMonday) + legacyRollingStart := currentWeekStart.Add(-24 * time.Hour) + accountID := int64(104) + repo := &updateAccountOveragesRepoStub{ + account: &Account{ + ID: accountID, + Platform: PlatformAnthropic, + Type: AccountTypeAPIKey, + Status: StatusActive, + Extra: map[string]any{ + "quota_weekly_limit": 40.0, + "quota_weekly_used": 12.5, + "quota_weekly_start": legacyRollingStart.Format(time.RFC3339), + }, + }, + } + + svc := &adminServiceImpl{accountRepo: repo} + updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{ + Extra: map[string]any{ + "quota_weekly_limit": 40.0, + "quota_weekly_reset_mode": "fixed", + "quota_weekly_reset_day": float64(1), + "quota_weekly_reset_hour": float64(0), + "quota_reset_timezone": "UTC", + }, + }) + + require.NoError(t, err) + require.NotNil(t, updated) + require.Equal(t, 1, repo.updateCalls) + require.InDelta(t, 0.0, updated.GetQuotaWeeklyUsed(), 1e-9) + require.Equal(t, currentWeekStart.Format(time.RFC3339), updated.Extra["quota_weekly_start"]) + require.False(t, updated.IsWeeklyQuotaPeriodExpired()) +}