feat: add periodic quota reset for API Key accounts

Support daily/weekly automatic quota reset using a rolling period
anchored to first usage. Implements lazy reset via atomic CTE SQL
in IncrementQuotaUsed - no cron job needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
erio
2026-03-06 19:51:41 +08:00
co-authored by Claude Opus 4.6
parent da99de82e9
commit e976409ba1
15 changed files with 172 additions and 22 deletions
@@ -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
}
@@ -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
}
+63 -12
View File
@@ -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,
+1 -1
View File
@@ -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")
}
+56
View File
@@ -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
}
+1 -1
View File
@@ -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
}
@@ -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
}
@@ -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
}
+2 -1
View File
@@ -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)
}
}
@@ -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
}
@@ -1228,7 +1228,7 @@
</div>
<!-- API Key 账号配额限制 -->
<QuotaLimitCard v-if="form.type === 'apikey'" v-model="editQuotaLimit" />
<QuotaLimitCard v-if="form.type === 'apikey'" v-model="editQuotaLimit" :period="editQuotaPeriod" @update:period="editQuotaPeriod = $event" />
<!-- Temp Unschedulable Rules -->
<div class="border-t border-gray-200 pt-4 dark:border-dark-600 space-y-4">
@@ -2473,6 +2473,7 @@ const addMethod = ref<AddMethod>('oauth') // For oauth-based: 'oauth' or 'setup-
const apiKeyBaseUrl = ref('https://api.anthropic.com')
const apiKeyValue = ref('')
const editQuotaLimit = ref<number | null>(null)
const editQuotaPeriod = ref<string>('')
const modelMappings = ref<ModelMapping[]>([])
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
const allowedModels = ref<string[]>([])
@@ -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,
@@ -798,7 +798,7 @@
</div>
<!-- API Key 账号配额限制 -->
<QuotaLimitCard v-if="account?.type === 'apikey'" v-model="editQuotaLimit" />
<QuotaLimitCard v-if="account?.type === 'apikey'" v-model="editQuotaLimit" :period="editQuotaPeriod" @update:period="editQuotaPeriod = $event" />
<!-- OpenAI OAuth Codex 官方客户端限制开关 -->
<div
@@ -1430,6 +1430,7 @@ const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OF
const codexCLIOnlyEnabled = ref(false)
const anthropicPassthroughEnabled = ref(false)
const editQuotaLimit = ref<number | null>(null)
const editQuotaPeriod = ref<string>('')
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<string, unknown> = { ...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
}
@@ -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) => {
</div>
<p class="input-hint">{{ t('admin.accounts.quotaLimitAmountHint') }}</p>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.quotaPeriod') }}</label>
<select
:value="period"
@change="$emit('update:period', ($event.target as HTMLSelectElement).value)"
class="input"
>
<option value="">{{ t('admin.accounts.quotaPeriodNone') }}</option>
<option value="daily">{{ t('admin.accounts.quotaPeriodDaily') }}</option>
<option value="weekly">{{ t('admin.accounts.quotaPeriodWeekly') }}</option>
</select>
<p class="input-hint">{{ t('admin.accounts.quotaPeriodHint') }}</p>
</div>
</div>
</div>
</div>
+5
View File
@@ -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',
+5
View File
@@ -1798,6 +1798,11 @@ export default {
quotaLimitToggleHint: '开启后,当账号用量达到设定额度时自动暂停调度',
quotaLimitAmount: '限额金额',
quotaLimitAmountHint: '账号最大可用额度(美元),达到后自动暂停。修改限额不会重置已用额度。',
quotaPeriod: '重置周期',
quotaPeriodNone: '不重置(累计)',
quotaPeriodDaily: '每日(24小时周期)',
quotaPeriodWeekly: '每周(7天周期)',
quotaPeriodHint: '选择用量计数器自动重置的频率。周期从首次使用时开始计算。',
testConnection: '测试连接',
reAuthorize: '重新授权',
refreshToken: '刷新令牌',