diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 972c3e5e..3706f725 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -281,6 +281,31 @@ func AccountFromServiceShallow(a *service.Account) *Account { used := a.GetQuotaWeeklyUsed() out.QuotaWeeklyUsed = &used } + // 固定时间重置配置 + if mode := a.GetQuotaDailyResetMode(); mode == "fixed" { + out.QuotaDailyResetMode = &mode + hour := a.GetQuotaDailyResetHour() + out.QuotaDailyResetHour = &hour + } + if mode := a.GetQuotaWeeklyResetMode(); mode == "fixed" { + out.QuotaWeeklyResetMode = &mode + day := a.GetQuotaWeeklyResetDay() + out.QuotaWeeklyResetDay = &day + hour := a.GetQuotaWeeklyResetHour() + out.QuotaWeeklyResetHour = &hour + } + if a.GetQuotaDailyResetMode() == "fixed" || a.GetQuotaWeeklyResetMode() == "fixed" { + tz := a.GetQuotaResetTimezone() + out.QuotaResetTimezone = &tz + } + if a.Extra != nil { + if v, ok := a.Extra["quota_daily_reset_at"].(string); ok && v != "" { + out.QuotaDailyResetAt = &v + } + if v, ok := a.Extra["quota_weekly_reset_at"].(string); ok && v != "" { + out.QuotaWeeklyResetAt = &v + } + } } return out diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index d9ccda2d..3708eed5 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -203,6 +203,16 @@ type Account struct { QuotaWeeklyLimit *float64 `json:"quota_weekly_limit,omitempty"` QuotaWeeklyUsed *float64 `json:"quota_weekly_used,omitempty"` + // 配额固定时间重置配置 + QuotaDailyResetMode *string `json:"quota_daily_reset_mode,omitempty"` + QuotaDailyResetHour *int `json:"quota_daily_reset_hour,omitempty"` + QuotaWeeklyResetMode *string `json:"quota_weekly_reset_mode,omitempty"` + QuotaWeeklyResetDay *int `json:"quota_weekly_reset_day,omitempty"` + QuotaWeeklyResetHour *int `json:"quota_weekly_reset_hour,omitempty"` + QuotaResetTimezone *string `json:"quota_reset_timezone,omitempty"` + QuotaDailyResetAt *string `json:"quota_daily_reset_at,omitempty"` + QuotaWeeklyResetAt *string `json:"quota_weekly_reset_at,omitempty"` + Proxy *Proxy `json:"proxy,omitempty"` AccountGroups []AccountGroup `json:"account_groups,omitempty"` diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index a0e17469..20ff7373 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -1727,8 +1727,96 @@ func (r *accountRepository) FindByExtraField(ctx context.Context, key string, va // nowUTC is a SQL expression to generate a UTC RFC3339 timestamp string. const nowUTC = `to_char(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')` +// dailyExpiredExpr is a SQL expression that evaluates to TRUE when daily quota period has expired. +// Supports both rolling (24h from start) and fixed (pre-computed reset_at) modes. +const dailyExpiredExpr = `( + CASE WHEN COALESCE(extra->>'quota_daily_reset_mode', 'rolling') = 'fixed' + THEN NOW() >= COALESCE((extra->>'quota_daily_reset_at')::timestamptz, '1970-01-01'::timestamptz) + ELSE COALESCE((extra->>'quota_daily_start')::timestamptz, '1970-01-01'::timestamptz) + + '24 hours'::interval <= NOW() + END +)` + +// weeklyExpiredExpr is a SQL expression that evaluates to TRUE when weekly quota period has expired. +const weeklyExpiredExpr = `( + CASE WHEN COALESCE(extra->>'quota_weekly_reset_mode', 'rolling') = 'fixed' + THEN NOW() >= COALESCE((extra->>'quota_weekly_reset_at')::timestamptz, '1970-01-01'::timestamptz) + ELSE COALESCE((extra->>'quota_weekly_start')::timestamptz, '1970-01-01'::timestamptz) + + '168 hours'::interval <= NOW() + END +)` + +// nextDailyResetAtExpr is a SQL expression to compute the next daily reset_at when a reset occurs. +// For fixed mode: computes the next future reset time based on NOW(), timezone, and configured hour. +// This correctly handles long-inactive accounts by jumping directly to the next valid reset point. +const nextDailyResetAtExpr = `( + CASE WHEN COALESCE(extra->>'quota_daily_reset_mode', 'rolling') = 'fixed' + THEN to_char(( + -- Compute today's reset point in the configured timezone, then pick next future one + CASE WHEN NOW() >= ( + date_trunc('day', NOW() AT TIME ZONE COALESCE(extra->>'quota_reset_timezone', 'UTC')) + + (COALESCE((extra->>'quota_daily_reset_hour')::int, 0) || ' hours')::interval + ) AT TIME ZONE COALESCE(extra->>'quota_reset_timezone', 'UTC') + -- NOW() is at or past today's reset point → next reset is tomorrow + THEN ( + date_trunc('day', NOW() AT TIME ZONE COALESCE(extra->>'quota_reset_timezone', 'UTC')) + + (COALESCE((extra->>'quota_daily_reset_hour')::int, 0) || ' hours')::interval + + '1 day'::interval + ) AT TIME ZONE COALESCE(extra->>'quota_reset_timezone', 'UTC') + -- NOW() is before today's reset point → next reset is today + ELSE ( + date_trunc('day', NOW() AT TIME ZONE COALESCE(extra->>'quota_reset_timezone', 'UTC')) + + (COALESCE((extra->>'quota_daily_reset_hour')::int, 0) || ' hours')::interval + ) AT TIME ZONE COALESCE(extra->>'quota_reset_timezone', 'UTC') + END + ) AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') + ELSE NULL END +)` + +// nextWeeklyResetAtExpr is a SQL expression to compute the next weekly reset_at when a reset occurs. +// For fixed mode: computes the next future reset time based on NOW(), timezone, configured day and hour. +// This correctly handles long-inactive accounts by jumping directly to the next valid reset point. +const nextWeeklyResetAtExpr = `( + CASE WHEN COALESCE(extra->>'quota_weekly_reset_mode', 'rolling') = 'fixed' + THEN to_char(( + -- Compute this week's reset point in the configured timezone + -- Step 1: get today's date at reset hour in configured tz + -- Step 2: compute days forward to target weekday + -- Step 3: if same day but past reset hour, advance 7 days + CASE + WHEN ( + -- days_forward = (target_day - current_day + 7) % 7 + (COALESCE((extra->>'quota_weekly_reset_day')::int, 1) + - EXTRACT(DOW FROM NOW() AT TIME ZONE COALESCE(extra->>'quota_reset_timezone', 'UTC'))::int + + 7) % 7 + ) = 0 AND NOW() >= ( + date_trunc('day', NOW() AT TIME ZONE COALESCE(extra->>'quota_reset_timezone', 'UTC')) + + (COALESCE((extra->>'quota_weekly_reset_hour')::int, 0) || ' hours')::interval + ) AT TIME ZONE COALESCE(extra->>'quota_reset_timezone', 'UTC') + -- Same weekday and past reset hour → next week + THEN ( + date_trunc('day', NOW() AT TIME ZONE COALESCE(extra->>'quota_reset_timezone', 'UTC')) + + (COALESCE((extra->>'quota_weekly_reset_hour')::int, 0) || ' hours')::interval + + '7 days'::interval + ) AT TIME ZONE COALESCE(extra->>'quota_reset_timezone', 'UTC') + ELSE ( + -- Advance to target weekday this week (or next if days_forward > 0) + date_trunc('day', NOW() AT TIME ZONE COALESCE(extra->>'quota_reset_timezone', 'UTC')) + + (COALESCE((extra->>'quota_weekly_reset_hour')::int, 0) || ' hours')::interval + + (( + (COALESCE((extra->>'quota_weekly_reset_day')::int, 1) + - EXTRACT(DOW FROM NOW() AT TIME ZONE COALESCE(extra->>'quota_reset_timezone', 'UTC'))::int + + 7) % 7 + ) || ' days')::interval + ) AT TIME ZONE COALESCE(extra->>'quota_reset_timezone', 'UTC') + END + ) AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') + ELSE NULL END +)` + // IncrementQuotaUsed 原子递增账号的配额用量(总/日/周三个维度) // 日/周额度在周期过期时自动重置为 0 再递增。 +// 支持滚动窗口(rolling)和固定时间(fixed)两种重置模式。 func (r *accountRepository) IncrementQuotaUsed(ctx context.Context, id int64, amount float64) error { rows, err := r.sql.QueryContext(ctx, `UPDATE accounts SET extra = ( @@ -1739,31 +1827,35 @@ func (r *accountRepository) IncrementQuotaUsed(ctx context.Context, id int64, am || CASE WHEN COALESCE((extra->>'quota_daily_limit')::numeric, 0) > 0 THEN jsonb_build_object( 'quota_daily_used', - CASE WHEN COALESCE((extra->>'quota_daily_start')::timestamptz, '1970-01-01'::timestamptz) - + '24 hours'::interval <= NOW() + CASE WHEN `+dailyExpiredExpr+` THEN $1 ELSE COALESCE((extra->>'quota_daily_used')::numeric, 0) + $1 END, 'quota_daily_start', - CASE WHEN COALESCE((extra->>'quota_daily_start')::timestamptz, '1970-01-01'::timestamptz) - + '24 hours'::interval <= NOW() + CASE WHEN `+dailyExpiredExpr+` THEN `+nowUTC+` ELSE COALESCE(extra->>'quota_daily_start', `+nowUTC+`) END ) + -- 固定模式重置时更新下次重置时间 + || CASE WHEN `+dailyExpiredExpr+` AND `+nextDailyResetAtExpr+` IS NOT NULL + THEN jsonb_build_object('quota_daily_reset_at', `+nextDailyResetAtExpr+`) + ELSE '{}'::jsonb END ELSE '{}'::jsonb END -- 周额度:仅在 quota_weekly_limit > 0 时处理 || CASE WHEN COALESCE((extra->>'quota_weekly_limit')::numeric, 0) > 0 THEN jsonb_build_object( 'quota_weekly_used', - CASE WHEN COALESCE((extra->>'quota_weekly_start')::timestamptz, '1970-01-01'::timestamptz) - + '168 hours'::interval <= NOW() + CASE WHEN `+weeklyExpiredExpr+` THEN $1 ELSE COALESCE((extra->>'quota_weekly_used')::numeric, 0) + $1 END, 'quota_weekly_start', - CASE WHEN COALESCE((extra->>'quota_weekly_start')::timestamptz, '1970-01-01'::timestamptz) - + '168 hours'::interval <= NOW() + CASE WHEN `+weeklyExpiredExpr+` THEN `+nowUTC+` ELSE COALESCE(extra->>'quota_weekly_start', `+nowUTC+`) END ) + -- 固定模式重置时更新下次重置时间 + || CASE WHEN `+weeklyExpiredExpr+` AND `+nextWeeklyResetAtExpr+` IS NOT NULL + THEN jsonb_build_object('quota_weekly_reset_at', `+nextWeeklyResetAtExpr+`) + ELSE '{}'::jsonb END ELSE '{}'::jsonb END ), updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL @@ -1796,12 +1888,13 @@ func (r *accountRepository) IncrementQuotaUsed(ctx context.Context, id int64, am } // ResetQuotaUsed 重置账号所有维度的配额用量为 0 +// 保留固定重置模式的配置字段(quota_daily_reset_mode 等),仅清零用量和窗口起始时间 func (r *accountRepository) ResetQuotaUsed(ctx context.Context, id int64) error { _, err := r.sql.ExecContext(ctx, `UPDATE accounts SET extra = ( COALESCE(extra, '{}'::jsonb) || '{"quota_used": 0, "quota_daily_used": 0, "quota_weekly_used": 0}'::jsonb - ) - 'quota_daily_start' - 'quota_weekly_start', updated_at = NOW() + ) - 'quota_daily_start' - 'quota_weekly_start' - 'quota_daily_reset_at' - 'quota_weekly_reset_at', updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL`, id) if err != nil { diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index cd8b9378..578d1da3 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -3,6 +3,7 @@ package service import ( "encoding/json" + "errors" "hash/fnv" "reflect" "sort" @@ -1279,6 +1280,240 @@ func (a *Account) getExtraTime(key string) time.Time { return time.Time{} } +// getExtraString 从 Extra 中读取指定 key 的字符串值 +func (a *Account) getExtraString(key string) string { + if a.Extra == nil { + return "" + } + if v, ok := a.Extra[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// getExtraInt 从 Extra 中读取指定 key 的 int 值 +func (a *Account) getExtraInt(key string) int { + if a.Extra == nil { + return 0 + } + if v, ok := a.Extra[key]; ok { + return int(parseExtraFloat64(v)) + } + return 0 +} + +// GetQuotaDailyResetMode 获取日额度重置模式:"rolling"(默认)或 "fixed" +func (a *Account) GetQuotaDailyResetMode() string { + if m := a.getExtraString("quota_daily_reset_mode"); m == "fixed" { + return "fixed" + } + return "rolling" +} + +// GetQuotaDailyResetHour 获取固定重置的小时(0-23),默认 0 +func (a *Account) GetQuotaDailyResetHour() int { + return a.getExtraInt("quota_daily_reset_hour") +} + +// GetQuotaWeeklyResetMode 获取周额度重置模式:"rolling"(默认)或 "fixed" +func (a *Account) GetQuotaWeeklyResetMode() string { + if m := a.getExtraString("quota_weekly_reset_mode"); m == "fixed" { + return "fixed" + } + return "rolling" +} + +// GetQuotaWeeklyResetDay 获取固定重置的星期几(0=周日, 1=周一, ..., 6=周六),默认 1(周一) +func (a *Account) GetQuotaWeeklyResetDay() int { + if a.Extra == nil { + return 1 + } + if _, ok := a.Extra["quota_weekly_reset_day"]; !ok { + return 1 + } + return a.getExtraInt("quota_weekly_reset_day") +} + +// GetQuotaWeeklyResetHour 获取周配额固定重置的小时(0-23),默认 0 +func (a *Account) GetQuotaWeeklyResetHour() int { + return a.getExtraInt("quota_weekly_reset_hour") +} + +// GetQuotaResetTimezone 获取固定重置的时区名(IANA),默认 "UTC" +func (a *Account) GetQuotaResetTimezone() string { + if tz := a.getExtraString("quota_reset_timezone"); tz != "" { + return tz + } + return "UTC" +} + +// nextFixedDailyReset 计算在 after 之后的下一个每日固定重置时间点 +func nextFixedDailyReset(hour int, tz *time.Location, after time.Time) time.Time { + t := after.In(tz) + today := time.Date(t.Year(), t.Month(), t.Day(), hour, 0, 0, 0, tz) + if !after.Before(today) { + return today.AddDate(0, 0, 1) + } + return today +} + +// lastFixedDailyReset 计算 now 之前最近一次的每日固定重置时间点 +func lastFixedDailyReset(hour int, tz *time.Location, now time.Time) time.Time { + t := now.In(tz) + today := time.Date(t.Year(), t.Month(), t.Day(), hour, 0, 0, 0, tz) + if now.Before(today) { + return today.AddDate(0, 0, -1) + } + return today +} + +// nextFixedWeeklyReset 计算在 after 之后的下一个每周固定重置时间点 +// day: 0=Sunday, 1=Monday, ..., 6=Saturday +func nextFixedWeeklyReset(day, hour int, tz *time.Location, after time.Time) time.Time { + t := after.In(tz) + todayReset := time.Date(t.Year(), t.Month(), t.Day(), hour, 0, 0, 0, tz) + currentDay := int(todayReset.Weekday()) + + daysForward := (day - currentDay + 7) % 7 + if daysForward == 0 && !after.Before(todayReset) { + daysForward = 7 + } + return todayReset.AddDate(0, 0, daysForward) +} + +// lastFixedWeeklyReset 计算 now 之前最近一次的每周固定重置时间点 +func lastFixedWeeklyReset(day, hour int, tz *time.Location, now time.Time) time.Time { + t := now.In(tz) + todayReset := time.Date(t.Year(), t.Month(), t.Day(), hour, 0, 0, 0, tz) + currentDay := int(todayReset.Weekday()) + + daysBack := (currentDay - day + 7) % 7 + if daysBack == 0 && now.Before(todayReset) { + daysBack = 7 + } + return todayReset.AddDate(0, 0, -daysBack) +} + +// isFixedDailyPeriodExpired 检查日配额是否在固定时间模式下已过期 +func (a *Account) isFixedDailyPeriodExpired(periodStart time.Time) bool { + if periodStart.IsZero() { + return true + } + tz, err := time.LoadLocation(a.GetQuotaResetTimezone()) + if err != nil { + tz = time.UTC + } + lastReset := lastFixedDailyReset(a.GetQuotaDailyResetHour(), tz, time.Now()) + return periodStart.Before(lastReset) +} + +// isFixedWeeklyPeriodExpired 检查周配额是否在固定时间模式下已过期 +func (a *Account) isFixedWeeklyPeriodExpired(periodStart time.Time) bool { + if periodStart.IsZero() { + return true + } + tz, err := time.LoadLocation(a.GetQuotaResetTimezone()) + if err != nil { + tz = time.UTC + } + lastReset := lastFixedWeeklyReset(a.GetQuotaWeeklyResetDay(), a.GetQuotaWeeklyResetHour(), tz, time.Now()) + return periodStart.Before(lastReset) +} + +// ComputeQuotaResetAt 根据当前配置计算并填充 extra 中的 quota_daily_reset_at / quota_weekly_reset_at +// 在保存账号配置时调用 +func ComputeQuotaResetAt(extra map[string]any) { + 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" { + hour := int(parseExtraFloat64(extra["quota_daily_reset_hour"])) + if hour < 0 || hour > 23 { + hour = 0 + } + resetAt := nextFixedDailyReset(hour, tz, now) + extra["quota_daily_reset_at"] = resetAt.UTC().Format(time.RFC3339) + } else { + delete(extra, "quota_daily_reset_at") + } + + // 周配额固定重置时间 + if mode, _ := extra["quota_weekly_reset_mode"].(string); mode == "fixed" { + day := 1 // 默认周一 + if d, ok := extra["quota_weekly_reset_day"]; ok { + day = int(parseExtraFloat64(d)) + } + if day < 0 || day > 6 { + day = 1 + } + hour := int(parseExtraFloat64(extra["quota_weekly_reset_hour"])) + if hour < 0 || hour > 23 { + hour = 0 + } + resetAt := nextFixedWeeklyReset(day, hour, tz, now) + extra["quota_weekly_reset_at"] = resetAt.UTC().Format(time.RFC3339) + } else { + delete(extra, "quota_weekly_reset_at") + } +} + +// ValidateQuotaResetConfig 校验配额固定重置时间配置的合法性 +func ValidateQuotaResetConfig(extra map[string]any) error { + if extra == nil { + return nil + } + // 校验时区 + if tz, ok := extra["quota_reset_timezone"].(string); ok && tz != "" { + if _, err := time.LoadLocation(tz); err != nil { + return errors.New("invalid quota_reset_timezone: must be a valid IANA timezone name") + } + } + // 日配额重置模式 + if mode, ok := extra["quota_daily_reset_mode"].(string); ok { + if mode != "rolling" && mode != "fixed" { + return errors.New("quota_daily_reset_mode must be 'rolling' or 'fixed'") + } + } + // 日配额重置小时 + if v, ok := extra["quota_daily_reset_hour"]; ok { + hour := int(parseExtraFloat64(v)) + if hour < 0 || hour > 23 { + return errors.New("quota_daily_reset_hour must be between 0 and 23") + } + } + // 周配额重置模式 + if mode, ok := extra["quota_weekly_reset_mode"].(string); ok { + if mode != "rolling" && mode != "fixed" { + return errors.New("quota_weekly_reset_mode must be 'rolling' or 'fixed'") + } + } + // 周配额重置星期几 + if v, ok := extra["quota_weekly_reset_day"]; ok { + day := int(parseExtraFloat64(v)) + if day < 0 || day > 6 { + return errors.New("quota_weekly_reset_day must be between 0 (Sunday) and 6 (Saturday)") + } + } + // 周配额重置小时 + if v, ok := extra["quota_weekly_reset_hour"]; ok { + hour := int(parseExtraFloat64(v)) + if hour < 0 || hour > 23 { + return errors.New("quota_weekly_reset_hour must be between 0 and 23") + } + } + return nil +} + // HasAnyQuotaLimit 检查是否配置了任一维度的配额限制 func (a *Account) HasAnyQuotaLimit() bool { return a.GetQuotaLimit() > 0 || a.GetQuotaDailyLimit() > 0 || a.GetQuotaWeeklyLimit() > 0 @@ -1301,14 +1536,26 @@ func (a *Account) IsQuotaExceeded() bool { // 日额度(周期过期视为未超限,下次 increment 会重置) if limit := a.GetQuotaDailyLimit(); limit > 0 { start := a.getExtraTime("quota_daily_start") - if !isPeriodExpired(start, 24*time.Hour) && a.GetQuotaDailyUsed() >= limit { + var expired bool + if a.GetQuotaDailyResetMode() == "fixed" { + expired = a.isFixedDailyPeriodExpired(start) + } else { + expired = isPeriodExpired(start, 24*time.Hour) + } + if !expired && a.GetQuotaDailyUsed() >= limit { return true } } // 周额度 if limit := a.GetQuotaWeeklyLimit(); limit > 0 { start := a.getExtraTime("quota_weekly_start") - if !isPeriodExpired(start, 7*24*time.Hour) && a.GetQuotaWeeklyUsed() >= limit { + var expired bool + if a.GetQuotaWeeklyResetMode() == "fixed" { + expired = a.isFixedWeeklyPeriodExpired(start) + } else { + expired = isPeriodExpired(start, 7*24*time.Hour) + } + if !expired && a.GetQuotaWeeklyUsed() >= limit { return true } } diff --git a/backend/internal/service/account_quota_reset_test.go b/backend/internal/service/account_quota_reset_test.go new file mode 100644 index 00000000..45a4bad6 --- /dev/null +++ b/backend/internal/service/account_quota_reset_test.go @@ -0,0 +1,516 @@ +//go:build unit + +package service + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// nextFixedDailyReset +// --------------------------------------------------------------------------- + +func TestNextFixedDailyReset_BeforeResetHour(t *testing.T) { + tz := time.UTC + // 2026-03-14 06:00 UTC, reset hour = 9 + after := time.Date(2026, 3, 14, 6, 0, 0, 0, tz) + got := nextFixedDailyReset(9, tz, after) + want := time.Date(2026, 3, 14, 9, 0, 0, 0, tz) + assert.Equal(t, want, got) +} + +func TestNextFixedDailyReset_AtResetHour(t *testing.T) { + tz := time.UTC + // Exactly at reset hour → should return tomorrow + after := time.Date(2026, 3, 14, 9, 0, 0, 0, tz) + got := nextFixedDailyReset(9, tz, after) + want := time.Date(2026, 3, 15, 9, 0, 0, 0, tz) + assert.Equal(t, want, got) +} + +func TestNextFixedDailyReset_AfterResetHour(t *testing.T) { + tz := time.UTC + // After reset hour → should return tomorrow + after := time.Date(2026, 3, 14, 15, 30, 0, 0, tz) + got := nextFixedDailyReset(9, tz, after) + want := time.Date(2026, 3, 15, 9, 0, 0, 0, tz) + assert.Equal(t, want, got) +} + +func TestNextFixedDailyReset_MidnightReset(t *testing.T) { + tz := time.UTC + // Reset at hour 0 (midnight), currently 23:59 + after := time.Date(2026, 3, 14, 23, 59, 0, 0, tz) + got := nextFixedDailyReset(0, tz, after) + want := time.Date(2026, 3, 15, 0, 0, 0, 0, tz) + assert.Equal(t, want, got) +} + +func TestNextFixedDailyReset_NonUTCTimezone(t *testing.T) { + tz, err := time.LoadLocation("Asia/Shanghai") + require.NoError(t, err) + + // 2026-03-14 07:00 UTC = 2026-03-14 15:00 CST, reset hour = 9 (CST) + after := time.Date(2026, 3, 14, 7, 0, 0, 0, time.UTC) + got := nextFixedDailyReset(9, tz, after) + // Already past 9:00 CST today → tomorrow 9:00 CST = 2026-03-15 01:00 UTC + want := time.Date(2026, 3, 15, 9, 0, 0, 0, tz) + assert.Equal(t, want, got) +} + +// --------------------------------------------------------------------------- +// lastFixedDailyReset +// --------------------------------------------------------------------------- + +func TestLastFixedDailyReset_BeforeResetHour(t *testing.T) { + tz := time.UTC + now := time.Date(2026, 3, 14, 6, 0, 0, 0, tz) + got := lastFixedDailyReset(9, tz, now) + // Before today's 9:00 → yesterday 9:00 + want := time.Date(2026, 3, 13, 9, 0, 0, 0, tz) + assert.Equal(t, want, got) +} + +func TestLastFixedDailyReset_AtResetHour(t *testing.T) { + tz := time.UTC + now := time.Date(2026, 3, 14, 9, 0, 0, 0, tz) + got := lastFixedDailyReset(9, tz, now) + // At exactly 9:00 → today 9:00 + want := time.Date(2026, 3, 14, 9, 0, 0, 0, tz) + assert.Equal(t, want, got) +} + +func TestLastFixedDailyReset_AfterResetHour(t *testing.T) { + tz := time.UTC + now := time.Date(2026, 3, 14, 15, 0, 0, 0, tz) + got := lastFixedDailyReset(9, tz, now) + // After 9:00 → today 9:00 + want := time.Date(2026, 3, 14, 9, 0, 0, 0, tz) + assert.Equal(t, want, got) +} + +// --------------------------------------------------------------------------- +// nextFixedWeeklyReset +// --------------------------------------------------------------------------- + +func TestNextFixedWeeklyReset_TargetDayAhead(t *testing.T) { + tz := time.UTC + // 2026-03-14 is Saturday (day=6), target = Monday (day=1), hour = 9 + after := time.Date(2026, 3, 14, 10, 0, 0, 0, tz) + got := nextFixedWeeklyReset(1, 9, tz, after) + // Next Monday = 2026-03-16 + want := time.Date(2026, 3, 16, 9, 0, 0, 0, tz) + assert.Equal(t, want, got) +} + +func TestNextFixedWeeklyReset_TargetDayToday_BeforeHour(t *testing.T) { + tz := time.UTC + // 2026-03-16 is Monday (day=1), target = Monday, hour = 9, before 9:00 + after := time.Date(2026, 3, 16, 6, 0, 0, 0, tz) + got := nextFixedWeeklyReset(1, 9, tz, after) + // Today at 9:00 + want := time.Date(2026, 3, 16, 9, 0, 0, 0, tz) + assert.Equal(t, want, got) +} + +func TestNextFixedWeeklyReset_TargetDayToday_AtHour(t *testing.T) { + tz := time.UTC + // 2026-03-16 is Monday, target = Monday, hour = 9, exactly at 9:00 + after := time.Date(2026, 3, 16, 9, 0, 0, 0, tz) + got := nextFixedWeeklyReset(1, 9, tz, after) + // Next Monday at 9:00 + want := time.Date(2026, 3, 23, 9, 0, 0, 0, tz) + assert.Equal(t, want, got) +} + +func TestNextFixedWeeklyReset_TargetDayToday_AfterHour(t *testing.T) { + tz := time.UTC + // 2026-03-16 is Monday, target = Monday, hour = 9, after 9:00 + after := time.Date(2026, 3, 16, 15, 0, 0, 0, tz) + got := nextFixedWeeklyReset(1, 9, tz, after) + // Next Monday at 9:00 + want := time.Date(2026, 3, 23, 9, 0, 0, 0, tz) + assert.Equal(t, want, got) +} + +func TestNextFixedWeeklyReset_TargetDayPast(t *testing.T) { + tz := time.UTC + // 2026-03-18 is Wednesday (day=3), target = Monday (day=1) + after := time.Date(2026, 3, 18, 10, 0, 0, 0, tz) + got := nextFixedWeeklyReset(1, 9, tz, after) + // Next Monday = 2026-03-23 + want := time.Date(2026, 3, 23, 9, 0, 0, 0, tz) + assert.Equal(t, want, got) +} + +func TestNextFixedWeeklyReset_Sunday(t *testing.T) { + tz := time.UTC + // 2026-03-14 is Saturday (day=6), target = Sunday (day=0) + after := time.Date(2026, 3, 14, 10, 0, 0, 0, tz) + got := nextFixedWeeklyReset(0, 0, tz, after) + // Next Sunday = 2026-03-15 + want := time.Date(2026, 3, 15, 0, 0, 0, 0, tz) + assert.Equal(t, want, got) +} + +// --------------------------------------------------------------------------- +// lastFixedWeeklyReset +// --------------------------------------------------------------------------- + +func TestLastFixedWeeklyReset_SameDay_AfterHour(t *testing.T) { + tz := time.UTC + // 2026-03-16 is Monday (day=1), target = Monday, hour = 9, now = 15:00 + now := time.Date(2026, 3, 16, 15, 0, 0, 0, tz) + got := lastFixedWeeklyReset(1, 9, tz, now) + // Today at 9:00 + want := time.Date(2026, 3, 16, 9, 0, 0, 0, tz) + assert.Equal(t, want, got) +} + +func TestLastFixedWeeklyReset_SameDay_BeforeHour(t *testing.T) { + tz := time.UTC + // 2026-03-16 is Monday, target = Monday, hour = 9, now = 06:00 + now := time.Date(2026, 3, 16, 6, 0, 0, 0, tz) + got := lastFixedWeeklyReset(1, 9, tz, now) + // Last Monday at 9:00 = 2026-03-09 + want := time.Date(2026, 3, 9, 9, 0, 0, 0, tz) + assert.Equal(t, want, got) +} + +func TestLastFixedWeeklyReset_DifferentDay(t *testing.T) { + tz := time.UTC + // 2026-03-18 is Wednesday (day=3), target = Monday (day=1) + now := time.Date(2026, 3, 18, 10, 0, 0, 0, tz) + got := lastFixedWeeklyReset(1, 9, tz, now) + // Last Monday = 2026-03-16 + want := time.Date(2026, 3, 16, 9, 0, 0, 0, tz) + assert.Equal(t, want, got) +} + +// --------------------------------------------------------------------------- +// isFixedDailyPeriodExpired +// --------------------------------------------------------------------------- + +func TestIsFixedDailyPeriodExpired_ZeroPeriodStart(t *testing.T) { + a := &Account{Extra: map[string]any{ + "quota_daily_reset_mode": "fixed", + "quota_daily_reset_hour": float64(9), + "quota_reset_timezone": "UTC", + }} + assert.True(t, a.isFixedDailyPeriodExpired(time.Time{})) +} + +func TestIsFixedDailyPeriodExpired_NotExpired(t *testing.T) { + a := &Account{Extra: map[string]any{ + "quota_daily_reset_mode": "fixed", + "quota_daily_reset_hour": float64(9), + "quota_reset_timezone": "UTC", + }} + // Period started after the most recent reset → not expired + // (This test uses a time very close to "now", which is after the last reset) + periodStart := time.Now().Add(-1 * time.Minute) + assert.False(t, a.isFixedDailyPeriodExpired(periodStart)) +} + +func TestIsFixedDailyPeriodExpired_Expired(t *testing.T) { + a := &Account{Extra: map[string]any{ + "quota_daily_reset_mode": "fixed", + "quota_daily_reset_hour": float64(9), + "quota_reset_timezone": "UTC", + }} + // Period started 3 days ago → definitely expired + periodStart := time.Now().Add(-72 * time.Hour) + assert.True(t, a.isFixedDailyPeriodExpired(periodStart)) +} + +func TestIsFixedDailyPeriodExpired_InvalidTimezone(t *testing.T) { + a := &Account{Extra: map[string]any{ + "quota_daily_reset_mode": "fixed", + "quota_daily_reset_hour": float64(9), + "quota_reset_timezone": "Invalid/Timezone", + }} + // Invalid timezone falls back to UTC + periodStart := time.Now().Add(-72 * time.Hour) + assert.True(t, a.isFixedDailyPeriodExpired(periodStart)) +} + +// --------------------------------------------------------------------------- +// isFixedWeeklyPeriodExpired +// --------------------------------------------------------------------------- + +func TestIsFixedWeeklyPeriodExpired_ZeroPeriodStart(t *testing.T) { + a := &Account{Extra: map[string]any{ + "quota_weekly_reset_mode": "fixed", + "quota_weekly_reset_day": float64(1), + "quota_weekly_reset_hour": float64(9), + "quota_reset_timezone": "UTC", + }} + assert.True(t, a.isFixedWeeklyPeriodExpired(time.Time{})) +} + +func TestIsFixedWeeklyPeriodExpired_NotExpired(t *testing.T) { + a := &Account{Extra: map[string]any{ + "quota_weekly_reset_mode": "fixed", + "quota_weekly_reset_day": float64(1), + "quota_weekly_reset_hour": float64(9), + "quota_reset_timezone": "UTC", + }} + // Period started 1 minute ago → not expired + periodStart := time.Now().Add(-1 * time.Minute) + assert.False(t, a.isFixedWeeklyPeriodExpired(periodStart)) +} + +func TestIsFixedWeeklyPeriodExpired_Expired(t *testing.T) { + a := &Account{Extra: map[string]any{ + "quota_weekly_reset_mode": "fixed", + "quota_weekly_reset_day": float64(1), + "quota_weekly_reset_hour": float64(9), + "quota_reset_timezone": "UTC", + }} + // Period started 10 days ago → definitely expired + periodStart := time.Now().Add(-240 * time.Hour) + assert.True(t, a.isFixedWeeklyPeriodExpired(periodStart)) +} + +// --------------------------------------------------------------------------- +// ValidateQuotaResetConfig +// --------------------------------------------------------------------------- + +func TestValidateQuotaResetConfig_NilExtra(t *testing.T) { + assert.NoError(t, ValidateQuotaResetConfig(nil)) +} + +func TestValidateQuotaResetConfig_EmptyExtra(t *testing.T) { + assert.NoError(t, ValidateQuotaResetConfig(map[string]any{})) +} + +func TestValidateQuotaResetConfig_ValidFixed(t *testing.T) { + extra := map[string]any{ + "quota_daily_reset_mode": "fixed", + "quota_daily_reset_hour": float64(9), + "quota_weekly_reset_mode": "fixed", + "quota_weekly_reset_day": float64(1), + "quota_weekly_reset_hour": float64(0), + "quota_reset_timezone": "Asia/Shanghai", + } + assert.NoError(t, ValidateQuotaResetConfig(extra)) +} + +func TestValidateQuotaResetConfig_ValidRolling(t *testing.T) { + extra := map[string]any{ + "quota_daily_reset_mode": "rolling", + "quota_weekly_reset_mode": "rolling", + } + assert.NoError(t, ValidateQuotaResetConfig(extra)) +} + +func TestValidateQuotaResetConfig_InvalidTimezone(t *testing.T) { + extra := map[string]any{ + "quota_reset_timezone": "Not/A/Timezone", + } + err := ValidateQuotaResetConfig(extra) + require.Error(t, err) + assert.Contains(t, err.Error(), "quota_reset_timezone") +} + +func TestValidateQuotaResetConfig_InvalidDailyMode(t *testing.T) { + extra := map[string]any{ + "quota_daily_reset_mode": "invalid", + } + err := ValidateQuotaResetConfig(extra) + require.Error(t, err) + assert.Contains(t, err.Error(), "quota_daily_reset_mode") +} + +func TestValidateQuotaResetConfig_InvalidDailyHour_TooHigh(t *testing.T) { + extra := map[string]any{ + "quota_daily_reset_hour": float64(24), + } + err := ValidateQuotaResetConfig(extra) + require.Error(t, err) + assert.Contains(t, err.Error(), "quota_daily_reset_hour") +} + +func TestValidateQuotaResetConfig_InvalidDailyHour_Negative(t *testing.T) { + extra := map[string]any{ + "quota_daily_reset_hour": float64(-1), + } + err := ValidateQuotaResetConfig(extra) + require.Error(t, err) + assert.Contains(t, err.Error(), "quota_daily_reset_hour") +} + +func TestValidateQuotaResetConfig_InvalidWeeklyMode(t *testing.T) { + extra := map[string]any{ + "quota_weekly_reset_mode": "unknown", + } + err := ValidateQuotaResetConfig(extra) + require.Error(t, err) + assert.Contains(t, err.Error(), "quota_weekly_reset_mode") +} + +func TestValidateQuotaResetConfig_InvalidWeeklyDay_TooHigh(t *testing.T) { + extra := map[string]any{ + "quota_weekly_reset_day": float64(7), + } + err := ValidateQuotaResetConfig(extra) + require.Error(t, err) + assert.Contains(t, err.Error(), "quota_weekly_reset_day") +} + +func TestValidateQuotaResetConfig_InvalidWeeklyDay_Negative(t *testing.T) { + extra := map[string]any{ + "quota_weekly_reset_day": float64(-1), + } + err := ValidateQuotaResetConfig(extra) + require.Error(t, err) + assert.Contains(t, err.Error(), "quota_weekly_reset_day") +} + +func TestValidateQuotaResetConfig_InvalidWeeklyHour(t *testing.T) { + extra := map[string]any{ + "quota_weekly_reset_hour": float64(25), + } + err := ValidateQuotaResetConfig(extra) + require.Error(t, err) + assert.Contains(t, err.Error(), "quota_weekly_reset_hour") +} + +func TestValidateQuotaResetConfig_BoundaryValues(t *testing.T) { + // All boundary values should be valid + extra := map[string]any{ + "quota_daily_reset_hour": float64(23), + "quota_weekly_reset_day": float64(0), // Sunday + "quota_weekly_reset_hour": float64(0), + "quota_reset_timezone": "UTC", + } + assert.NoError(t, ValidateQuotaResetConfig(extra)) + + extra2 := map[string]any{ + "quota_daily_reset_hour": float64(0), + "quota_weekly_reset_day": float64(6), // Saturday + "quota_weekly_reset_hour": float64(23), + } + assert.NoError(t, ValidateQuotaResetConfig(extra2)) +} + +// --------------------------------------------------------------------------- +// ComputeQuotaResetAt +// --------------------------------------------------------------------------- + +func TestComputeQuotaResetAt_RollingMode_NoResetAt(t *testing.T) { + extra := map[string]any{ + "quota_daily_reset_mode": "rolling", + "quota_weekly_reset_mode": "rolling", + } + ComputeQuotaResetAt(extra) + _, hasDailyResetAt := extra["quota_daily_reset_at"] + _, hasWeeklyResetAt := extra["quota_weekly_reset_at"] + assert.False(t, hasDailyResetAt, "rolling mode should not set quota_daily_reset_at") + assert.False(t, hasWeeklyResetAt, "rolling mode should not set quota_weekly_reset_at") +} + +func TestComputeQuotaResetAt_RollingMode_ClearsExistingResetAt(t *testing.T) { + extra := map[string]any{ + "quota_daily_reset_mode": "rolling", + "quota_weekly_reset_mode": "rolling", + "quota_daily_reset_at": "2026-03-14T09:00:00Z", + "quota_weekly_reset_at": "2026-03-16T09:00:00Z", + } + ComputeQuotaResetAt(extra) + _, hasDailyResetAt := extra["quota_daily_reset_at"] + _, hasWeeklyResetAt := extra["quota_weekly_reset_at"] + assert.False(t, hasDailyResetAt, "rolling mode should remove quota_daily_reset_at") + assert.False(t, hasWeeklyResetAt, "rolling mode should remove quota_weekly_reset_at") +} + +func TestComputeQuotaResetAt_FixedDaily_SetsResetAt(t *testing.T) { + extra := map[string]any{ + "quota_daily_reset_mode": "fixed", + "quota_daily_reset_hour": float64(9), + "quota_reset_timezone": "UTC", + } + ComputeQuotaResetAt(extra) + resetAtStr, ok := extra["quota_daily_reset_at"].(string) + require.True(t, ok, "quota_daily_reset_at should be set") + + resetAt, err := time.Parse(time.RFC3339, resetAtStr) + require.NoError(t, err) + // Reset time should be in the future + assert.True(t, resetAt.After(time.Now()), "reset_at should be in the future") + // Reset hour should be 9 UTC + assert.Equal(t, 9, resetAt.UTC().Hour()) +} + +func TestComputeQuotaResetAt_FixedWeekly_SetsResetAt(t *testing.T) { + extra := map[string]any{ + "quota_weekly_reset_mode": "fixed", + "quota_weekly_reset_day": float64(1), // Monday + "quota_weekly_reset_hour": float64(0), + "quota_reset_timezone": "UTC", + } + ComputeQuotaResetAt(extra) + resetAtStr, ok := extra["quota_weekly_reset_at"].(string) + require.True(t, ok, "quota_weekly_reset_at should be set") + + resetAt, err := time.Parse(time.RFC3339, resetAtStr) + require.NoError(t, err) + // Reset time should be in the future + assert.True(t, resetAt.After(time.Now()), "reset_at should be in the future") + // Reset day should be Monday + assert.Equal(t, time.Monday, resetAt.UTC().Weekday()) +} + +func TestComputeQuotaResetAt_FixedDaily_WithTimezone(t *testing.T) { + tz, err := time.LoadLocation("Asia/Shanghai") + require.NoError(t, err) + + extra := map[string]any{ + "quota_daily_reset_mode": "fixed", + "quota_daily_reset_hour": float64(9), + "quota_reset_timezone": "Asia/Shanghai", + } + ComputeQuotaResetAt(extra) + resetAtStr, ok := extra["quota_daily_reset_at"].(string) + require.True(t, ok) + + resetAt, err := time.Parse(time.RFC3339, resetAtStr) + require.NoError(t, err) + // In Shanghai timezone, the hour should be 9 + assert.Equal(t, 9, resetAt.In(tz).Hour()) +} + +func TestComputeQuotaResetAt_DefaultTimezone(t *testing.T) { + extra := map[string]any{ + "quota_daily_reset_mode": "fixed", + "quota_daily_reset_hour": float64(12), + } + ComputeQuotaResetAt(extra) + resetAtStr, ok := extra["quota_daily_reset_at"].(string) + require.True(t, ok) + + resetAt, err := time.Parse(time.RFC3339, resetAtStr) + require.NoError(t, err) + // Default timezone is UTC + assert.Equal(t, 12, resetAt.UTC().Hour()) +} + +func TestComputeQuotaResetAt_InvalidHour_ClampedToZero(t *testing.T) { + extra := map[string]any{ + "quota_daily_reset_mode": "fixed", + "quota_daily_reset_hour": float64(99), + "quota_reset_timezone": "UTC", + } + ComputeQuotaResetAt(extra) + resetAtStr, ok := extra["quota_daily_reset_at"].(string) + require.True(t, ok) + + resetAt, err := time.Parse(time.RFC3339, resetAtStr) + require.NoError(t, err) + // Invalid hour → clamped to 0 + assert.Equal(t, 0, resetAt.UTC().Hour()) +} diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 10d67518..86824b6f 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -1462,6 +1462,13 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou Status: StatusActive, Schedulable: true, } + // 预计算固定时间重置的下次重置时间 + if account.Extra != nil { + if err := ValidateQuotaResetConfig(account.Extra); err != nil { + return nil, err + } + ComputeQuotaResetAt(account.Extra) + } if input.ExpiresAt != nil && *input.ExpiresAt > 0 { expiresAt := time.Unix(*input.ExpiresAt, 0) account.ExpiresAt = &expiresAt @@ -1535,6 +1542,11 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U } } account.Extra = input.Extra + // 校验并预计算固定时间重置的下次重置时间 + if err := ValidateQuotaResetConfig(account.Extra); err != nil { + return nil, err + } + ComputeQuotaResetAt(account.Extra) } if input.ProxyID != nil { // 0 表示清除代理(前端发送 0 而不是 null 来表达清除意图) diff --git a/frontend/src/components/account/AccountUsageCell.vue b/frontend/src/components/account/AccountUsageCell.vue index ee13beb7..fb145f98 100644 --- a/frontend/src/components/account/AccountUsageCell.vue +++ b/frontend/src/components/account/AccountUsageCell.vue @@ -942,11 +942,23 @@ const makeQuotaBar = ( let resetsAt: string | null = null if (startKey) { const extra = props.account.extra as Record | undefined - const startStr = extra?.[startKey] as string | undefined - if (startStr) { - const startDate = new Date(startStr) - const periodMs = startKey.includes('daily') ? 24 * 60 * 60 * 1000 : 7 * 24 * 60 * 60 * 1000 - resetsAt = new Date(startDate.getTime() + periodMs).toISOString() + const isDaily = startKey.includes('daily') + const mode = isDaily + ? (extra?.quota_daily_reset_mode as string) || 'rolling' + : (extra?.quota_weekly_reset_mode as string) || 'rolling' + + if (mode === 'fixed') { + // Use pre-computed next reset time for fixed mode + const resetAtKey = isDaily ? 'quota_daily_reset_at' : 'quota_weekly_reset_at' + resetsAt = (extra?.[resetAtKey] as string) || null + } else { + // Rolling mode: compute from start + period + const startStr = extra?.[startKey] as string | undefined + if (startStr) { + const startDate = new Date(startStr) + const periodMs = isDaily ? 24 * 60 * 60 * 1000 : 7 * 24 * 60 * 60 * 1000 + resetsAt = new Date(startDate.getTime() + periodMs).toISOString() + } } } return { utilization, resetsAt } diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 9002f0aa..a492f6a3 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -1568,9 +1568,21 @@ :totalLimit="editQuotaLimit" :dailyLimit="editQuotaDailyLimit" :weeklyLimit="editQuotaWeeklyLimit" + :dailyResetMode="editDailyResetMode" + :dailyResetHour="editDailyResetHour" + :weeklyResetMode="editWeeklyResetMode" + :weeklyResetDay="editWeeklyResetDay" + :weeklyResetHour="editWeeklyResetHour" + :resetTimezone="editResetTimezone" @update:totalLimit="editQuotaLimit = $event" @update:dailyLimit="editQuotaDailyLimit = $event" @update:weeklyLimit="editQuotaWeeklyLimit = $event" + @update:dailyResetMode="editDailyResetMode = $event" + @update:dailyResetHour="editDailyResetHour = $event" + @update:weeklyResetMode="editWeeklyResetMode = $event" + @update:weeklyResetDay="editWeeklyResetDay = $event" + @update:weeklyResetHour="editWeeklyResetHour = $event" + @update:resetTimezone="editResetTimezone = $event" /> @@ -2955,6 +2967,12 @@ const apiKeyValue = ref('') const editQuotaLimit = ref(null) const editQuotaDailyLimit = ref(null) const editQuotaWeeklyLimit = ref(null) +const editDailyResetMode = ref<'rolling' | 'fixed' | null>(null) +const editDailyResetHour = ref(null) +const editWeeklyResetMode = ref<'rolling' | 'fixed' | null>(null) +const editWeeklyResetDay = ref(null) +const editWeeklyResetHour = ref(null) +const editResetTimezone = ref(null) const modelMappings = ref([]) const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist') const allowedModels = ref([]) @@ -3651,6 +3669,12 @@ const resetForm = () => { editQuotaLimit.value = null editQuotaDailyLimit.value = null editQuotaWeeklyLimit.value = null + editDailyResetMode.value = null + editDailyResetHour.value = null + editWeeklyResetMode.value = null + editWeeklyResetDay.value = null + editWeeklyResetHour.value = null + editResetTimezone.value = null modelMappings.value = [] modelRestrictionMode.value = 'whitelist' allowedModels.value = [...claudeModels] // Default fill related models @@ -4158,6 +4182,19 @@ const createAccountAndFinish = async ( if (editQuotaWeeklyLimit.value != null && editQuotaWeeklyLimit.value > 0) { quotaExtra.quota_weekly_limit = editQuotaWeeklyLimit.value } + // Quota reset mode config + if (editDailyResetMode.value === 'fixed') { + quotaExtra.quota_daily_reset_mode = 'fixed' + quotaExtra.quota_daily_reset_hour = editDailyResetHour.value ?? 0 + } + if (editWeeklyResetMode.value === 'fixed') { + quotaExtra.quota_weekly_reset_mode = 'fixed' + quotaExtra.quota_weekly_reset_day = editWeeklyResetDay.value ?? 1 + quotaExtra.quota_weekly_reset_hour = editWeeklyResetHour.value ?? 0 + } + if (editDailyResetMode.value === 'fixed' || editWeeklyResetMode.value === 'fixed') { + quotaExtra.quota_reset_timezone = editResetTimezone.value || 'UTC' + } if (Object.keys(quotaExtra).length > 0) { finalExtra = quotaExtra } diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index dd496223..77ead160 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -1161,9 +1161,21 @@ :totalLimit="editQuotaLimit" :dailyLimit="editQuotaDailyLimit" :weeklyLimit="editQuotaWeeklyLimit" + :dailyResetMode="editDailyResetMode" + :dailyResetHour="editDailyResetHour" + :weeklyResetMode="editWeeklyResetMode" + :weeklyResetDay="editWeeklyResetDay" + :weeklyResetHour="editWeeklyResetHour" + :resetTimezone="editResetTimezone" @update:totalLimit="editQuotaLimit = $event" @update:dailyLimit="editQuotaDailyLimit = $event" @update:weeklyLimit="editQuotaWeeklyLimit = $event" + @update:dailyResetMode="editDailyResetMode = $event" + @update:dailyResetHour="editDailyResetHour = $event" + @update:weeklyResetMode="editWeeklyResetMode = $event" + @update:weeklyResetDay="editWeeklyResetDay = $event" + @update:weeklyResetHour="editWeeklyResetHour = $event" + @update:resetTimezone="editResetTimezone = $event" /> @@ -1814,6 +1826,12 @@ const anthropicPassthroughEnabled = ref(false) const editQuotaLimit = ref(null) const editQuotaDailyLimit = ref(null) const editQuotaWeeklyLimit = ref(null) +const editDailyResetMode = ref<'rolling' | 'fixed' | null>(null) +const editDailyResetHour = ref(null) +const editWeeklyResetMode = ref<'rolling' | 'fixed' | null>(null) +const editWeeklyResetDay = ref(null) +const editWeeklyResetHour = ref(null) +const editResetTimezone = ref(null) const openAIWSModeOptions = computed(() => [ { value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') }, // TODO: ctx_pool 选项暂时隐藏,待测试完成后恢复 @@ -2001,10 +2019,23 @@ watch( editQuotaDailyLimit.value = (dailyVal && dailyVal > 0) ? dailyVal : null const weeklyVal = extra?.quota_weekly_limit as number | undefined editQuotaWeeklyLimit.value = (weeklyVal && weeklyVal > 0) ? weeklyVal : null + // Load quota reset mode config + editDailyResetMode.value = (extra?.quota_daily_reset_mode as 'rolling' | 'fixed') || null + editDailyResetHour.value = (extra?.quota_daily_reset_hour as number) ?? null + editWeeklyResetMode.value = (extra?.quota_weekly_reset_mode as 'rolling' | 'fixed') || null + editWeeklyResetDay.value = (extra?.quota_weekly_reset_day as number) ?? null + editWeeklyResetHour.value = (extra?.quota_weekly_reset_hour as number) ?? null + editResetTimezone.value = (extra?.quota_reset_timezone as string) || null } else { editQuotaLimit.value = null editQuotaDailyLimit.value = null editQuotaWeeklyLimit.value = null + editDailyResetMode.value = null + editDailyResetHour.value = null + editWeeklyResetMode.value = null + editWeeklyResetDay.value = null + editWeeklyResetHour.value = null + editResetTimezone.value = null } // Load antigravity model mapping (Antigravity 只支持映射模式) @@ -2945,6 +2976,28 @@ const handleSubmit = async () => { } else { delete newExtra.quota_weekly_limit } + // Quota reset mode config + if (editDailyResetMode.value === 'fixed') { + newExtra.quota_daily_reset_mode = 'fixed' + newExtra.quota_daily_reset_hour = editDailyResetHour.value ?? 0 + } else { + delete newExtra.quota_daily_reset_mode + delete newExtra.quota_daily_reset_hour + } + if (editWeeklyResetMode.value === 'fixed') { + newExtra.quota_weekly_reset_mode = 'fixed' + newExtra.quota_weekly_reset_day = editWeeklyResetDay.value ?? 1 + newExtra.quota_weekly_reset_hour = editWeeklyResetHour.value ?? 0 + } else { + delete newExtra.quota_weekly_reset_mode + delete newExtra.quota_weekly_reset_day + delete newExtra.quota_weekly_reset_hour + } + if (editDailyResetMode.value === 'fixed' || editWeeklyResetMode.value === 'fixed') { + newExtra.quota_reset_timezone = editResetTimezone.value || 'UTC' + } else { + delete newExtra.quota_reset_timezone + } updatePayload.extra = newExtra } diff --git a/frontend/src/components/account/QuotaLimitCard.vue b/frontend/src/components/account/QuotaLimitCard.vue index 505118ba..fdc19ad9 100644 --- a/frontend/src/components/account/QuotaLimitCard.vue +++ b/frontend/src/components/account/QuotaLimitCard.vue @@ -8,12 +8,24 @@ const props = defineProps<{ totalLimit: number | null dailyLimit: number | null weeklyLimit: number | null + dailyResetMode: 'rolling' | 'fixed' | null + dailyResetHour: number | null + weeklyResetMode: 'rolling' | 'fixed' | null + weeklyResetDay: number | null + weeklyResetHour: number | null + resetTimezone: string | null }>() const emit = defineEmits<{ 'update:totalLimit': [value: number | null] 'update:dailyLimit': [value: number | null] 'update:weeklyLimit': [value: number | null] + 'update:dailyResetMode': [value: 'rolling' | 'fixed' | null] + 'update:dailyResetHour': [value: number | null] + 'update:weeklyResetMode': [value: 'rolling' | 'fixed' | null] + 'update:weeklyResetDay': [value: number | null] + 'update:weeklyResetHour': [value: number | null] + 'update:resetTimezone': [value: string | null] }>() const enabled = computed(() => @@ -35,9 +47,56 @@ watch(localEnabled, (val) => { emit('update:totalLimit', null) emit('update:dailyLimit', null) emit('update:weeklyLimit', null) + emit('update:dailyResetMode', null) + emit('update:dailyResetHour', null) + emit('update:weeklyResetMode', null) + emit('update:weeklyResetDay', null) + emit('update:weeklyResetHour', null) + emit('update:resetTimezone', null) } }) +// Whether any fixed mode is active (to show timezone selector) +const hasFixedMode = computed(() => + props.dailyResetMode === 'fixed' || props.weeklyResetMode === 'fixed' +) + +// Common timezone options +const timezoneOptions = [ + 'UTC', + 'Asia/Shanghai', + 'Asia/Tokyo', + 'Asia/Seoul', + 'Asia/Singapore', + 'Asia/Kolkata', + 'Asia/Dubai', + 'Europe/London', + 'Europe/Paris', + 'Europe/Berlin', + 'Europe/Moscow', + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'America/Sao_Paulo', + 'Australia/Sydney', + 'Pacific/Auckland', +] + +// Hours for dropdown (0-23) +const hourOptions = Array.from({ length: 24 }, (_, i) => i) + +// Day of week options +const dayOptions = [ + { value: 1, key: 'monday' }, + { value: 2, key: 'tuesday' }, + { value: 3, key: 'wednesday' }, + { value: 4, key: 'thursday' }, + { value: 5, key: 'friday' }, + { value: 6, key: 'saturday' }, + { value: 0, key: 'sunday' }, +] + const onTotalInput = (e: Event) => { const raw = (e.target as HTMLInputElement).valueAsNumber emit('update:totalLimit', Number.isNaN(raw) ? null : raw) @@ -50,6 +109,25 @@ const onWeeklyInput = (e: Event) => { const raw = (e.target as HTMLInputElement).valueAsNumber emit('update:weeklyLimit', Number.isNaN(raw) ? null : raw) } + +const onDailyModeChange = (e: Event) => { + const val = (e.target as HTMLSelectElement).value as 'rolling' | 'fixed' + emit('update:dailyResetMode', val) + if (val === 'fixed') { + if (props.dailyResetHour == null) emit('update:dailyResetHour', 0) + if (!props.resetTimezone) emit('update:resetTimezone', 'UTC') + } +} + +const onWeeklyModeChange = (e: Event) => { + const val = (e.target as HTMLSelectElement).value as 'rolling' | 'fixed' + emit('update:weeklyResetMode', val) + if (val === 'fixed') { + if (props.weeklyResetDay == null) emit('update:weeklyResetDay', 1) + if (props.weeklyResetHour == null) emit('update:weeklyResetHour', 0) + if (!props.resetTimezone) emit('update:resetTimezone', 'UTC') + } +}