diff --git a/backend/internal/handler/sora_client_handler_test.go b/backend/internal/handler/sora_client_handler_test.go index d2a849b1cb..248df208aa 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) error { +func (r *stubAccountRepoForHandler) IncrementQuotaUsed(context.Context, int64, float64, string) error { return nil } diff --git a/backend/internal/handler/sora_gateway_handler_test.go b/backend/internal/handler/sora_gateway_handler_test.go index 637462ad50..2d1fb0a97f 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) error { +func (r *stubAccountRepo) IncrementQuotaUsed(ctx context.Context, id int64, amount float64, _ string) error { return nil } diff --git a/backend/internal/repository/account_repo.go b/backend/internal/repository/account_repo.go index ffbfd46655..fdda231a87 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -1677,18 +1677,57 @@ func (r *accountRepository) FindByExtraField(ctx context.Context, key string, va } // IncrementQuotaUsed 原子递增账号的 extra.quota_used 字段 -func (r *accountRepository) IncrementQuotaUsed(ctx context.Context, id int64, amount float64) error { - 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) +// 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 + + 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 { + // 周期制:CTE 原子检查过期 → 重置 → 递增 + rows, err = r.sql.QueryContext(ctx, + `WITH reset_check AS ( + SELECT id, extra, + CASE WHEN COALESCE((extra->>'quota_period_start')::timestamptz, '1970-01-01'::timestamptz) + + $3::interval <= NOW() + THEN true ELSE false END AS needs_reset + FROM accounts WHERE id = $2 AND deleted_at IS NULL + ) + UPDATE accounts SET extra = jsonb_set( + jsonb_set( + COALESCE(extra, '{}'::jsonb), + '{quota_used}', + to_jsonb( + CASE WHEN (SELECT needs_reset FROM reset_check) THEN $1 + ELSE COALESCE((extra->>'quota_used')::numeric, 0) + $1 END + ) + ), + '{quota_period_start}', + to_jsonb( + CASE WHEN (SELECT needs_reset FROM reset_check) + THEN to_char(NOW(), 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') + ELSE COALESCE(extra->>'quota_period_start', to_char(NOW(), 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')) 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) + } if err != nil { return err } @@ -1713,6 +1752,18 @@ 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 func (r *accountRepository) ResetQuotaUsed(ctx context.Context, id int64) error { _, err := r.sql.ExecContext(ctx, diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index aafbbe21cb..b6915943fc 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) error { +func (s *stubAccountRepo) IncrementQuotaUsed(ctx context.Context, id int64, amount float64, _ string) error { return errors.New("not implemented") } diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 07459d8246..ce1b5c309d 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1172,12 +1172,68 @@ func (a *Account) GetQuotaUsed() float64 { 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 { + if a.Extra == nil { + return time.Time{} + } + if v, ok := a.Extra["quota_period_start"]; ok { + if s, ok := v.(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{} +} + +// 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 + } +} + +// isQuotaPeriodExpired 检查配额周期是否已过期 +func isQuotaPeriodExpired(period string, periodStart time.Time) bool { + dur := quotaPeriodDuration(period) + if dur == 0 || periodStart.IsZero() { + return false + } + return time.Since(periodStart) >= dur +} + // IsQuotaExceeded 检查 API Key 账号配额是否已超限 func (a *Account) IsQuotaExceeded() bool { limit := a.GetQuotaLimit() if limit <= 0 { return false } + // 周期性配额:如果当前周期已过期,视为未超限(下次 increment 时会自动重置) + if isQuotaPeriodExpired(a.GetQuotaPeriod(), a.GetQuotaPeriodStart()) { + return false + } return a.GetQuotaUsed() >= limit } diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index 26c0b1c29a..85c1398cf3 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -69,7 +69,7 @@ type AccountRepository interface { 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) error + IncrementQuotaUsed(ctx context.Context, id int64, amount float64, period string) 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 c96b436f02..bfb57bdb44 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) error { +func (s *accountRepoStub) IncrementQuotaUsed(ctx context.Context, id int64, amount float64, _ string) error { return nil } diff --git a/backend/internal/service/gateway_multiplatform_test.go b/backend/internal/service/gateway_multiplatform_test.go index 466ff294bb..6f800f3387 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) error { +func (m *mockAccountRepoForPlatform) IncrementQuotaUsed(ctx context.Context, id int64, amount float64, _ string) error { return nil } diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 0fe2545ec0..73b726c843 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -6585,7 +6585,8 @@ func postUsageBilling(ctx context.Context, p *postUsageBillingParams, deps *bill // 4. 账号配额用量(账号口径:TotalCost × 账号计费倍率) if cost.TotalCost > 0 && p.Account.Type == AccountTypeAPIKey && p.Account.GetQuotaLimit() > 0 { accountCost := cost.TotalCost * p.AccountRateMultiplier - if err := deps.accountRepo.IncrementQuotaUsed(ctx, p.Account.ID, accountCost); err != nil { + period := p.Account.GetQuotaPeriod() + if err := deps.accountRepo.IncrementQuotaUsed(ctx, p.Account.ID, accountCost, period); 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 56f94d5f1f..5a9dc69a14 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) error { +func (m *mockAccountRepoForGemini) IncrementQuotaUsed(ctx context.Context, id int64, amount float64, _ string) error { return nil } diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 92725c0788..c50712ff10 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -1228,7 +1228,7 @@ - +
@@ -2473,6 +2473,7 @@ const addMethod = ref('oauth') // For oauth-based: 'oauth' or 'setup- const apiKeyBaseUrl = ref('https://api.anthropic.com') const apiKeyValue = ref('') const editQuotaLimit = ref(null) +const editQuotaPeriod = ref('') const modelMappings = ref([]) const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist') const allowedModels = ref([]) @@ -3136,6 +3137,7 @@ const resetForm = () => { apiKeyBaseUrl.value = 'https://api.anthropic.com' apiKeyValue.value = '' editQuotaLimit.value = null + editQuotaPeriod.value = '' modelMappings.value = [] modelRestrictionMode.value = 'whitelist' allowedModels.value = [...claudeModels] // Default fill related models @@ -3554,6 +3556,9 @@ const createAccountAndFinish = async ( let finalExtra = extra if (type === 'apikey' && editQuotaLimit.value != null && editQuotaLimit.value > 0) { finalExtra = { ...(extra || {}), quota_limit: editQuotaLimit.value } + if (editQuotaPeriod.value) { + finalExtra.quota_period = editQuotaPeriod.value + } } await doCreateAccount({ name: form.name, diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue index bd91081b7b..826caf6496 100644 --- a/frontend/src/components/account/EditAccountModal.vue +++ b/frontend/src/components/account/EditAccountModal.vue @@ -798,7 +798,7 @@
- +
(OPENAI_WS_MODE_OF const codexCLIOnlyEnabled = ref(false) const anthropicPassthroughEnabled = ref(false) const editQuotaLimit = ref(null) +const editQuotaPeriod = ref('') const openAIWSModeOptions = computed(() => [ { value: OPENAI_WS_MODE_OFF, label: t('admin.accounts.openai.wsModeOff') }, // TODO: ctx_pool 选项暂时隐藏,待测试完成后恢复 @@ -1593,8 +1594,10 @@ watch( if (newAccount.type === 'apikey') { const quotaVal = extra?.quota_limit as number | undefined editQuotaLimit.value = (quotaVal && quotaVal > 0) ? quotaVal : null + editQuotaPeriod.value = (extra?.quota_period as string) || '' } else { editQuotaLimit.value = null + editQuotaPeriod.value = '' } // Load antigravity model mapping (Antigravity 只支持映射模式) @@ -2394,8 +2397,15 @@ const handleSubmit = async () => { const newExtra: Record = { ...currentExtra } if (editQuotaLimit.value != null && editQuotaLimit.value > 0) { newExtra.quota_limit = editQuotaLimit.value + if (editQuotaPeriod.value) { + newExtra.quota_period = editQuotaPeriod.value + } else { + delete newExtra.quota_period + } } else { delete newExtra.quota_limit + delete newExtra.quota_period + delete newExtra.quota_period_start } updatePayload.extra = newExtra } diff --git a/frontend/src/components/account/QuotaLimitCard.vue b/frontend/src/components/account/QuotaLimitCard.vue index 1be73a257b..a3c38cebbb 100644 --- a/frontend/src/components/account/QuotaLimitCard.vue +++ b/frontend/src/components/account/QuotaLimitCard.vue @@ -6,10 +6,12 @@ const { t } = useI18n() const props = defineProps<{ modelValue: number | null + period: string }>() const emit = defineEmits<{ 'update:modelValue': [value: number | null] + 'update:period': [value: string] }>() const enabled = ref(props.modelValue != null && props.modelValue > 0) @@ -26,6 +28,7 @@ watch( watch(enabled, (val) => { if (!val) { emit('update:modelValue', null) + emit('update:period', '') } }) @@ -86,6 +89,20 @@ const onInput = (e: Event) => {

{{ t('admin.accounts.quotaLimitAmountHint') }}

+ +
+ + +

{{ t('admin.accounts.quotaPeriodHint') }}

+
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 16b0bcd594..f77cebcf64 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -1799,6 +1799,11 @@ export default { quotaLimitToggleHint: 'When enabled, account will be paused when usage reaches the set limit', quotaLimitAmount: 'Limit Amount', quotaLimitAmountHint: 'Maximum spending limit (USD). Account will be auto-paused when reached. Changing limit won\'t reset usage.', + quotaPeriod: 'Reset Period', + quotaPeriodNone: 'Never (cumulative)', + quotaPeriodDaily: 'Daily (every 24 hours)', + quotaPeriodWeekly: 'Weekly (every 7 days)', + quotaPeriodHint: 'Choose how often the usage counter resets. Period starts from first usage.', testConnection: 'Test Connection', reAuthorize: 'Re-Authorize', refreshToken: 'Refresh Token', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 5ddef97620..4cc7191b5d 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -1798,6 +1798,11 @@ export default { quotaLimitToggleHint: '开启后,当账号用量达到设定额度时自动暂停调度', quotaLimitAmount: '限额金额', quotaLimitAmountHint: '账号最大可用额度(美元),达到后自动暂停。修改限额不会重置已用额度。', + quotaPeriod: '重置周期', + quotaPeriodNone: '不重置(累计)', + quotaPeriodDaily: '每日(24小时周期)', + quotaPeriodWeekly: '每周(7天周期)', + quotaPeriodHint: '选择用量计数器自动重置的频率。周期从首次使用时开始计算。', testConnection: '测试连接', reAuthorize: '重新授权', refreshToken: '刷新令牌',