diff --git a/backend/internal/handler/sora_client_handler_test.go b/backend/internal/handler/sora_client_handler_test.go
index 78931c88ca..c8b7a5ff6e 100644
--- a/backend/internal/handler/sora_client_handler_test.go
+++ b/backend/internal/handler/sora_client_handler_test.go
@@ -2132,7 +2132,7 @@ func (r *stubAccountRepoForHandler) BulkUpdate(context.Context, []int64, service
return 0, nil
}
-func (r *stubAccountRepoForHandler) IncrementQuotaUsed(context.Context, int64, float64, string) error {
+func (r *stubAccountRepoForHandler) IncrementQuotaUsed(context.Context, int64, float64) error {
return nil
}
diff --git a/backend/internal/handler/sora_gateway_handler_test.go b/backend/internal/handler/sora_gateway_handler_test.go
index 2d1fb0a97f..637462ad50 100644
--- a/backend/internal/handler/sora_gateway_handler_test.go
+++ b/backend/internal/handler/sora_gateway_handler_test.go
@@ -216,7 +216,7 @@ func (r *stubAccountRepo) BulkUpdate(ctx context.Context, ids []int64, updates s
return 0, nil
}
-func (r *stubAccountRepo) IncrementQuotaUsed(ctx context.Context, id int64, amount float64, _ string) error {
+func (r *stubAccountRepo) IncrementQuotaUsed(ctx context.Context, id int64, amount float64) error {
return nil
}
diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go
index 6fda0c182a..2e4c7ec9b6 100644
--- a/backend/internal/repository/account_repo.go
+++ b/backend/internal/repository/account_repo.go
@@ -1676,54 +1676,53 @@ func (r *accountRepository) FindByExtraField(ctx context.Context, key string, va
return r.accountsToService(ctx, accounts)
}
-// IncrementQuotaUsed 原子递增账号的 extra.quota_used 字段
-// period 为 "daily"/"weekly" 时,使用 CTE 原子重置+递增;为空时保持累计制。
-func (r *accountRepository) IncrementQuotaUsed(ctx context.Context, id int64, amount float64, period string) error {
- var rows *sql.Rows
- var err error
+// 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"')`
- interval := periodToInterval(period)
- if interval == "" {
- // 累计制:保持原有逻辑
- rows, err = r.sql.QueryContext(ctx,
- `UPDATE accounts SET extra = jsonb_set(
- COALESCE(extra, '{}'::jsonb),
- '{quota_used}',
- to_jsonb(COALESCE((extra->>'quota_used')::numeric, 0) + $1)
- ), updated_at = NOW()
- WHERE id = $2 AND deleted_at IS NULL
- RETURNING
- COALESCE((extra->>'quota_used')::numeric, 0),
- COALESCE((extra->>'quota_limit')::numeric, 0)`,
- amount, id)
- } else {
- // 周期制:单条 UPDATE 内联过期判断,避免 CTE snapshot 在 READ COMMITTED 下的 lost update
- rows, err = r.sql.QueryContext(ctx,
- `UPDATE accounts SET extra = jsonb_set(
- jsonb_set(
- COALESCE(extra, '{}'::jsonb),
- '{quota_used}',
- to_jsonb(
- CASE WHEN COALESCE((extra->>'quota_period_start')::timestamptz, '1970-01-01'::timestamptz)
- + $3::interval <= NOW()
- THEN $1
- ELSE COALESCE((extra->>'quota_used')::numeric, 0) + $1 END
- )
- ),
- '{quota_period_start}',
- to_jsonb(
- CASE WHEN COALESCE((extra->>'quota_period_start')::timestamptz, '1970-01-01'::timestamptz)
- + $3::interval <= NOW()
- THEN to_char(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')
- ELSE COALESCE(extra->>'quota_period_start', to_char(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')) END
+// IncrementQuotaUsed 原子递增账号的配额用量(总/日/周三个维度)
+// 日/周额度在周期过期时自动重置为 0 再递增。
+func (r *accountRepository) IncrementQuotaUsed(ctx context.Context, id int64, amount float64) error {
+ rows, err := r.sql.QueryContext(ctx,
+ `UPDATE accounts SET extra = (
+ COALESCE(extra, '{}'::jsonb)
+ -- 总额度:始终递增
+ || jsonb_build_object('quota_used', COALESCE((extra->>'quota_used')::numeric, 0) + $1)
+ -- 日额度:仅在 quota_daily_limit > 0 时处理
+ || 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()
+ 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()
+ THEN `+nowUTC+`
+ ELSE COALESCE(extra->>'quota_daily_start', `+nowUTC+`) END
)
- ), updated_at = NOW()
- WHERE id = $2 AND deleted_at IS NULL
- RETURNING
- COALESCE((extra->>'quota_used')::numeric, 0),
- COALESCE((extra->>'quota_limit')::numeric, 0)`,
- amount, id, interval)
- }
+ 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()
+ 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()
+ THEN `+nowUTC+`
+ ELSE COALESCE(extra->>'quota_weekly_start', `+nowUTC+`) END
+ )
+ ELSE '{}'::jsonb END
+ ), updated_at = NOW()
+ WHERE id = $2 AND deleted_at IS NULL
+ RETURNING
+ COALESCE((extra->>'quota_used')::numeric, 0),
+ COALESCE((extra->>'quota_limit')::numeric, 0)`,
+ amount, id)
if err != nil {
return err
}
@@ -1739,7 +1738,7 @@ func (r *accountRepository) IncrementQuotaUsed(ctx context.Context, id int64, am
return err
}
- // 配额刚超限时触发调度快照刷新,使账号及时从调度候选中移除
+ // 任一维度配额刚超限时触发调度快照刷新
if limit > 0 && newUsed >= limit && (newUsed-amount) < limit {
if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventAccountChanged, &id, nil, nil); err != nil {
logger.LegacyPrintf("repository.account", "[SchedulerOutbox] enqueue quota exceeded failed: account=%d err=%v", id, err)
@@ -1748,25 +1747,13 @@ func (r *accountRepository) IncrementQuotaUsed(ctx context.Context, id int64, am
return nil
}
-// periodToInterval 将周期类型转为 PostgreSQL interval 字符串
-func periodToInterval(period string) string {
- switch period {
- case "daily":
- return "24 hours"
- case "weekly":
- return "168 hours"
- default:
- return ""
- }
-}
-
-// ResetQuotaUsed 重置账号的 extra.quota_used 为 0,同时清除 quota_period_start 以开始新周期
+// ResetQuotaUsed 重置账号所有维度的配额用量为 0
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}'::jsonb
- ) - 'quota_period_start', updated_at = NOW()
+ || '{"quota_used": 0, "quota_daily_used": 0, "quota_weekly_used": 0}'::jsonb
+ ) - 'quota_daily_start' - 'quota_weekly_start', updated_at = NOW()
WHERE id = $1 AND deleted_at IS NULL`,
id)
if err != nil {
diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go
index e6b2399c85..7e7c898220 100644
--- a/backend/internal/server/api_contract_test.go
+++ b/backend/internal/server/api_contract_test.go
@@ -1096,7 +1096,7 @@ func (s *stubAccountRepo) UpdateExtra(ctx context.Context, id int64, updates map
return errors.New("not implemented")
}
-func (s *stubAccountRepo) IncrementQuotaUsed(ctx context.Context, id int64, amount float64, _ string) error {
+func (s *stubAccountRepo) IncrementQuotaUsed(ctx context.Context, id int64, amount float64) error {
return errors.New("not implemented")
}
diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go
index ce1b5c309d..4429107fe7 100644
--- a/backend/internal/service/account.go
+++ b/backend/internal/service/account.go
@@ -1152,45 +1152,51 @@ func (a *Account) GetCacheTTLOverrideTarget() string {
// GetQuotaLimit 获取 API Key 账号的配额限制(美元)
// 返回 0 表示未启用
func (a *Account) GetQuotaLimit() float64 {
- if a.Extra == nil {
- return 0
- }
- if v, ok := a.Extra["quota_limit"]; ok {
- return parseExtraFloat64(v)
- }
- return 0
+ return a.getExtraFloat64("quota_limit")
}
// GetQuotaUsed 获取 API Key 账号的已用配额(美元)
func (a *Account) GetQuotaUsed() float64 {
+ return a.getExtraFloat64("quota_used")
+}
+
+// GetQuotaDailyLimit 获取日额度限制(美元),0 表示未启用
+func (a *Account) GetQuotaDailyLimit() float64 {
+ return a.getExtraFloat64("quota_daily_limit")
+}
+
+// GetQuotaDailyUsed 获取当日已用额度(美元)
+func (a *Account) GetQuotaDailyUsed() float64 {
+ return a.getExtraFloat64("quota_daily_used")
+}
+
+// GetQuotaWeeklyLimit 获取周额度限制(美元),0 表示未启用
+func (a *Account) GetQuotaWeeklyLimit() float64 {
+ return a.getExtraFloat64("quota_weekly_limit")
+}
+
+// GetQuotaWeeklyUsed 获取本周已用额度(美元)
+func (a *Account) GetQuotaWeeklyUsed() float64 {
+ return a.getExtraFloat64("quota_weekly_used")
+}
+
+// getExtraFloat64 从 Extra 中读取指定 key 的 float64 值
+func (a *Account) getExtraFloat64(key string) float64 {
if a.Extra == nil {
return 0
}
- if v, ok := a.Extra["quota_used"]; ok {
+ if v, ok := a.Extra[key]; ok {
return parseExtraFloat64(v)
}
return 0
}
-// GetQuotaPeriod 获取配额周期类型("daily"/"weekly",空字符串表示累计制)
-func (a *Account) GetQuotaPeriod() string {
- if a.Extra == nil {
- return ""
- }
- if v, ok := a.Extra["quota_period"]; ok {
- if s, ok := v.(string); ok {
- return s
- }
- }
- return ""
-}
-
-// GetQuotaPeriodStart 获取当前周期的起始时间
-func (a *Account) GetQuotaPeriodStart() time.Time {
+// getExtraTime 从 Extra 中读取 RFC3339 时间戳
+func (a *Account) getExtraTime(key string) time.Time {
if a.Extra == nil {
return time.Time{}
}
- if v, ok := a.Extra["quota_period_start"]; ok {
+ if v, ok := a.Extra[key]; ok {
if s, ok := v.(string); ok {
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
return t
@@ -1203,38 +1209,40 @@ func (a *Account) GetQuotaPeriodStart() time.Time {
return time.Time{}
}
-// quotaPeriodDuration 返回周期长度
-func quotaPeriodDuration(period string) time.Duration {
- switch period {
- case "daily":
- return 24 * time.Hour
- case "weekly":
- return 7 * 24 * time.Hour
- default:
- return 0
- }
+// HasAnyQuotaLimit 检查是否配置了任一维度的配额限制
+func (a *Account) HasAnyQuotaLimit() bool {
+ return a.GetQuotaLimit() > 0 || a.GetQuotaDailyLimit() > 0 || a.GetQuotaWeeklyLimit() > 0
}
-// isQuotaPeriodExpired 检查配额周期是否已过期
-func isQuotaPeriodExpired(period string, periodStart time.Time) bool {
- dur := quotaPeriodDuration(period)
- if dur == 0 || periodStart.IsZero() {
- return false
+// isPeriodExpired 检查指定周期(自 periodStart 起经过 dur)是否已过期
+func isPeriodExpired(periodStart time.Time, dur time.Duration) bool {
+ if periodStart.IsZero() {
+ return true // 从未使用过,视为过期(下次 increment 会初始化)
}
return time.Since(periodStart) >= dur
}
-// IsQuotaExceeded 检查 API Key 账号配额是否已超限
+// IsQuotaExceeded 检查 API Key 账号配额是否已超限(任一维度超限即返回 true)
func (a *Account) IsQuotaExceeded() bool {
- limit := a.GetQuotaLimit()
- if limit <= 0 {
- return false
+ // 总额度
+ if limit := a.GetQuotaLimit(); limit > 0 && a.GetQuotaUsed() >= limit {
+ return true
}
- // 周期性配额:如果当前周期已过期,视为未超限(下次 increment 时会自动重置)
- if isQuotaPeriodExpired(a.GetQuotaPeriod(), a.GetQuotaPeriodStart()) {
- return false
+ // 日额度(周期过期视为未超限,下次 increment 会重置)
+ if limit := a.GetQuotaDailyLimit(); limit > 0 {
+ start := a.getExtraTime("quota_daily_start")
+ if !isPeriodExpired(start, 24*time.Hour) && a.GetQuotaDailyUsed() >= limit {
+ return true
+ }
}
- return a.GetQuotaUsed() >= limit
+ // 周额度
+ if limit := a.GetQuotaWeeklyLimit(); limit > 0 {
+ start := a.getExtraTime("quota_weekly_start")
+ if !isPeriodExpired(start, 7*24*time.Hour) && a.GetQuotaWeeklyUsed() >= limit {
+ return true
+ }
+ }
+ return false
}
// GetWindowCostLimit 获取 5h 窗口费用阈值(美元)
diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go
index 85c1398cf3..7ba4918961 100644
--- a/backend/internal/service/account_service.go
+++ b/backend/internal/service/account_service.go
@@ -68,8 +68,8 @@ type AccountRepository interface {
UpdateSessionWindow(ctx context.Context, id int64, start, end *time.Time, status string) 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 账号的配额用量
- IncrementQuotaUsed(ctx context.Context, id int64, amount float64, period string) error
+ // IncrementQuotaUsed 原子递增 API Key 账号的配额用量(总/日/周)
+ IncrementQuotaUsed(ctx context.Context, id int64, amount float64) error
// ResetQuotaUsed 重置 API Key 账号的配额用量为 0
ResetQuotaUsed(ctx context.Context, id int64) error
}
diff --git a/backend/internal/service/account_service_delete_test.go b/backend/internal/service/account_service_delete_test.go
index bfb57bdb44..c96b436f02 100644
--- a/backend/internal/service/account_service_delete_test.go
+++ b/backend/internal/service/account_service_delete_test.go
@@ -199,7 +199,7 @@ func (s *accountRepoStub) BulkUpdate(ctx context.Context, ids []int64, updates A
panic("unexpected BulkUpdate call")
}
-func (s *accountRepoStub) IncrementQuotaUsed(ctx context.Context, id int64, amount float64, _ string) error {
+func (s *accountRepoStub) IncrementQuotaUsed(ctx context.Context, id int64, amount float64) error {
return nil
}
diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go
index 6f800f3387..466ff294bb 100644
--- a/backend/internal/service/gateway_multiplatform_test.go
+++ b/backend/internal/service/gateway_multiplatform_test.go
@@ -187,7 +187,7 @@ func (m *mockAccountRepoForPlatform) BulkUpdate(ctx context.Context, ids []int64
return 0, nil
}
-func (m *mockAccountRepoForPlatform) IncrementQuotaUsed(ctx context.Context, id int64, amount float64, _ string) error {
+func (m *mockAccountRepoForPlatform) IncrementQuotaUsed(ctx context.Context, id int64, amount float64) error {
return nil
}
diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go
index 73b726c843..bf03b8a0ec 100644
--- a/backend/internal/service/gateway_service.go
+++ b/backend/internal/service/gateway_service.go
@@ -6583,10 +6583,9 @@ func postUsageBilling(ctx context.Context, p *postUsageBillingParams, deps *bill
}
// 4. 账号配额用量(账号口径:TotalCost × 账号计费倍率)
- if cost.TotalCost > 0 && p.Account.Type == AccountTypeAPIKey && p.Account.GetQuotaLimit() > 0 {
+ if cost.TotalCost > 0 && p.Account.Type == AccountTypeAPIKey && p.Account.HasAnyQuotaLimit() {
accountCost := cost.TotalCost * p.AccountRateMultiplier
- period := p.Account.GetQuotaPeriod()
- if err := deps.accountRepo.IncrementQuotaUsed(ctx, p.Account.ID, accountCost, period); err != nil {
+ if err := deps.accountRepo.IncrementQuotaUsed(ctx, p.Account.ID, accountCost); err != nil {
slog.Error("increment account quota used failed", "account_id", p.Account.ID, "cost", accountCost, "error", err)
}
}
diff --git a/backend/internal/service/gemini_multiplatform_test.go b/backend/internal/service/gemini_multiplatform_test.go
index 5a9dc69a14..56f94d5f1f 100644
--- a/backend/internal/service/gemini_multiplatform_test.go
+++ b/backend/internal/service/gemini_multiplatform_test.go
@@ -176,7 +176,7 @@ func (m *mockAccountRepoForGemini) BulkUpdate(ctx context.Context, ids []int64,
return 0, nil
}
-func (m *mockAccountRepoForGemini) IncrementQuotaUsed(ctx context.Context, id int64, amount float64, _ string) error {
+func (m *mockAccountRepoForGemini) IncrementQuotaUsed(ctx context.Context, id int64, amount float64) error {
return nil
}
diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue
index c50712ff10..5cbc9691df 100644
--- a/frontend/src/components/account/CreateAccountModal.vue
+++ b/frontend/src/components/account/CreateAccountModal.vue
@@ -1228,7 +1228,7 @@
-
{{ t('admin.accounts.quotaLimitAmountHint') }}
+{{ t('admin.accounts.quotaDailyLimitHint') }}
{{ t('admin.accounts.quotaPeriodHint') }}
+ +{{ t('admin.accounts.quotaWeeklyLimitHint') }}
+{{ t('admin.accounts.quotaTotalLimitHint') }}