mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1798,6 +1798,11 @@ export default {
|
||||
quotaLimitToggleHint: '开启后,当账号用量达到设定额度时自动暂停调度',
|
||||
quotaLimitAmount: '限额金额',
|
||||
quotaLimitAmountHint: '账号最大可用额度(美元),达到后自动暂停。修改限额不会重置已用额度。',
|
||||
quotaPeriod: '重置周期',
|
||||
quotaPeriodNone: '不重置(累计)',
|
||||
quotaPeriodDaily: '每日(24小时周期)',
|
||||
quotaPeriodWeekly: '每周(7天周期)',
|
||||
quotaPeriodHint: '选择用量计数器自动重置的频率。周期从首次使用时开始计算。',
|
||||
testConnection: '测试连接',
|
||||
reAuthorize: '重新授权',
|
||||
refreshToken: '刷新令牌',
|
||||
|
||||
Reference in New Issue
Block a user