diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 2cae9817..83bf9f38 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -125,9 +125,9 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup { Group: groupFromServiceBase(g), ModelRouting: g.ModelRouting, ModelRoutingEnabled: g.ModelRoutingEnabled, - MCPXMLInject: g.MCPXMLInject, - DefaultMappedModel: g.DefaultMappedModel, - SupportedModelScopes: g.SupportedModelScopes, + MCPXMLInject: g.MCPXMLInject, + DefaultMappedModel: g.DefaultMappedModel, + SupportedModelScopes: g.SupportedModelScopes, AccountCount: g.AccountCount, SortOrder: g.SortOrder, } @@ -255,11 +255,19 @@ func AccountFromServiceShallow(a *service.Account) *Account { if a.Type == service.AccountTypeAPIKey { if limit := a.GetQuotaLimit(); limit > 0 { out.QuotaLimit = &limit - } - used := a.GetQuotaUsed() - if out.QuotaLimit != nil { + used := a.GetQuotaUsed() out.QuotaUsed = &used } + if limit := a.GetQuotaDailyLimit(); limit > 0 { + out.QuotaDailyLimit = &limit + used := a.GetQuotaDailyUsed() + out.QuotaDailyUsed = &used + } + if limit := a.GetQuotaWeeklyLimit(); limit > 0 { + out.QuotaWeeklyLimit = &limit + used := a.GetQuotaWeeklyUsed() + out.QuotaWeeklyUsed = &used + } } return out diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index 1c68f429..a11ea390 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -193,8 +193,12 @@ type Account struct { CacheTTLOverrideTarget *string `json:"cache_ttl_override_target,omitempty"` // API Key 账号配额限制 - QuotaLimit *float64 `json:"quota_limit,omitempty"` - QuotaUsed *float64 `json:"quota_used,omitempty"` + QuotaLimit *float64 `json:"quota_limit,omitempty"` + QuotaUsed *float64 `json:"quota_used,omitempty"` + QuotaDailyLimit *float64 `json:"quota_daily_limit,omitempty"` + QuotaDailyUsed *float64 `json:"quota_daily_used,omitempty"` + QuotaWeeklyLimit *float64 `json:"quota_weekly_limit,omitempty"` + QuotaWeeklyUsed *float64 `json:"quota_weekly_used,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 ffbfd466..2e4c7ec9 100644 --- a/backend/internal/repository/account_repo.go +++ b/backend/internal/repository/account_repo.go @@ -1676,13 +1676,47 @@ func (r *accountRepository) FindByExtraField(ctx context.Context, key string, va return r.accountsToService(ctx, accounts) } -// IncrementQuotaUsed 原子递增账号的 extra.quota_used 字段 +// 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"')` + +// IncrementQuotaUsed 原子递增账号的配额用量(总/日/周三个维度) +// 日/周额度在周期过期时自动重置为 0 再递增。 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) + `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 + ) + 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 @@ -1704,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) @@ -1713,14 +1747,13 @@ func (r *accountRepository) IncrementQuotaUsed(ctx context.Context, id int64, am return nil } -// ResetQuotaUsed 重置账号的 extra.quota_used 为 0 +// ResetQuotaUsed 重置账号所有维度的配额用量为 0 func (r *accountRepository) ResetQuotaUsed(ctx context.Context, id int64) error { _, err := r.sql.ExecContext(ctx, - `UPDATE accounts SET extra = jsonb_set( - COALESCE(extra, '{}'::jsonb), - '{quota_used}', - '0'::jsonb - ), updated_at = NOW() + `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() 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 8eb3748c..fdef2f6c 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1134,33 +1134,97 @@ 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 } -// IsQuotaExceeded 检查 API Key 账号配额是否已超限 -func (a *Account) IsQuotaExceeded() bool { - limit := a.GetQuotaLimit() - if limit <= 0 { - return false +// getExtraTime 从 Extra 中读取 RFC3339 时间戳 +func (a *Account) getExtraTime(key string) time.Time { + if a.Extra == nil { + return time.Time{} } - return a.GetQuotaUsed() >= limit + 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 + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t + } + } + } + return time.Time{} +} + +// HasAnyQuotaLimit 检查是否配置了任一维度的配额限制 +func (a *Account) HasAnyQuotaLimit() bool { + return a.GetQuotaLimit() > 0 || a.GetQuotaDailyLimit() > 0 || a.GetQuotaWeeklyLimit() > 0 +} + +// 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 账号配额是否已超限(任一维度超限即返回 true) +func (a *Account) IsQuotaExceeded() bool { + // 总额度 + if limit := a.GetQuotaLimit(); limit > 0 && a.GetQuotaUsed() >= limit { + return true + } + // 日额度(周期过期视为未超限,下次 increment 会重置) + if limit := a.GetQuotaDailyLimit(); limit > 0 { + start := a.getExtraTime("quota_daily_start") + if !isPeriodExpired(start, 24*time.Hour) && 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 { + return true + } + } + return false } // GetWindowCostLimit 获取 5h 窗口费用阈值(美元) diff --git a/backend/internal/service/account_service.go b/backend/internal/service/account_service.go index 26c0b1c2..a06d8048 100644 --- a/backend/internal/service/account_service.go +++ b/backend/internal/service/account_service.go @@ -68,9 +68,9 @@ 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 原子递增 API Key 账号的配额用量(总/日/周) IncrementQuotaUsed(ctx context.Context, id int64, amount float64) error - // ResetQuotaUsed 重置 API Key 账号的配额用量为 0 + // ResetQuotaUsed 重置 API Key 账号所有维度的配额用量为 0 ResetQuotaUsed(ctx context.Context, id int64) error } diff --git a/backend/internal/service/admin_service.go b/backend/internal/service/admin_service.go index 680268e0..a3ed4233 100644 --- a/backend/internal/service/admin_service.go +++ b/backend/internal/service/admin_service.go @@ -1484,9 +1484,11 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U account.Credentials = input.Credentials } if len(input.Extra) > 0 { - // 保留 quota_used,防止编辑账号时意外重置配额用量 - if oldQuotaUsed, ok := account.Extra["quota_used"]; ok { - input.Extra["quota_used"] = oldQuotaUsed + // 保留配额用量字段,防止编辑账号时意外重置 + for _, key := range []string{"quota_used", "quota_daily_used", "quota_daily_start", "quota_weekly_used", "quota_weekly_start"} { + if v, ok := account.Extra[key]; ok { + input.Extra[key] = v + } } account.Extra = input.Extra } diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 3a6003fc..83e4c8ee 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -6437,7 +6437,7 @@ 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 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/frontend/src/components/account/AccountCapacityCell.vue b/frontend/src/components/account/AccountCapacityCell.vue index 2001b185..301277ec 100644 --- a/frontend/src/components/account/AccountCapacityCell.vue +++ b/frontend/src/components/account/AccountCapacityCell.vue @@ -73,21 +73,10 @@ -
+ {{ t('admin.accounts.quotaLimitHint') }} +
++ {{ t('admin.accounts.quotaLimitHint') }} +
+- {{ t('admin.accounts.quotaLimitHint') }} -
-{{ t('admin.accounts.quotaLimitAmountHint') }}
+{{ t('admin.accounts.quotaDailyLimitHint') }}
+{{ t('admin.accounts.quotaWeeklyLimitHint') }}
+{{ t('admin.accounts.quotaTotalLimitHint') }}