diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go index 83bf9f38..d6fe81ba 100644 --- a/backend/internal/handler/dto/mappers.go +++ b/backend/internal/handler/dto/mappers.go @@ -71,7 +71,7 @@ func APIKeyFromService(k *service.APIKey) *APIKey { if k == nil { return nil } - return &APIKey{ + out := &APIKey{ ID: k.ID, UserID: k.UserID, Key: k.Key, @@ -98,6 +98,19 @@ func APIKeyFromService(k *service.APIKey) *APIKey { User: UserFromServiceShallow(k.User), Group: GroupFromServiceShallow(k.Group), } + if k.Window5hStart != nil && !service.IsWindowExpired(k.Window5hStart, service.RateLimitWindow5h) { + t := k.Window5hStart.Add(service.RateLimitWindow5h) + out.Reset5hAt = &t + } + if k.Window1dStart != nil && !service.IsWindowExpired(k.Window1dStart, service.RateLimitWindow1d) { + t := k.Window1dStart.Add(service.RateLimitWindow1d) + out.Reset1dAt = &t + } + if k.Window7dStart != nil && !service.IsWindowExpired(k.Window7dStart, service.RateLimitWindow7d) { + t := k.Window7dStart.Add(service.RateLimitWindow7d) + out.Reset7dAt = &t + } + return out } func GroupFromServiceShallow(g *service.Group) *Group { diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go index a11ea390..828b24f9 100644 --- a/backend/internal/handler/dto/types.go +++ b/backend/internal/handler/dto/types.go @@ -57,6 +57,9 @@ type APIKey struct { Window5hStart *time.Time `json:"window_5h_start"` Window1dStart *time.Time `json:"window_1d_start"` Window7dStart *time.Time `json:"window_7d_start"` + Reset5hAt *time.Time `json:"reset_5h_at,omitempty"` + Reset1dAt *time.Time `json:"reset_1d_at,omitempty"` + Reset7dAt *time.Time `json:"reset_7d_at,omitempty"` User *User `json:"user,omitempty"` Group *Group `json:"group,omitempty"` diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go index 148d83e9..88502f77 100644 --- a/backend/internal/handler/gateway_handler.go +++ b/backend/internal/handler/gateway_handler.go @@ -972,33 +972,45 @@ func (h *GatewayHandler) usageQuotaLimited(c *gin.Context, ctx context.Context, var rateLimits []gin.H if apiKey.RateLimit5h > 0 { used := rateLimitData.EffectiveUsage5h() - rateLimits = append(rateLimits, gin.H{ + entry := gin.H{ "window": "5h", "limit": apiKey.RateLimit5h, "used": used, "remaining": max(0, apiKey.RateLimit5h-used), "window_start": rateLimitData.Window5hStart, - }) + } + if rateLimitData.Window5hStart != nil && !service.IsWindowExpired(rateLimitData.Window5hStart, service.RateLimitWindow5h) { + entry["reset_at"] = rateLimitData.Window5hStart.Add(service.RateLimitWindow5h) + } + rateLimits = append(rateLimits, entry) } if apiKey.RateLimit1d > 0 { used := rateLimitData.EffectiveUsage1d() - rateLimits = append(rateLimits, gin.H{ + entry := gin.H{ "window": "1d", "limit": apiKey.RateLimit1d, "used": used, "remaining": max(0, apiKey.RateLimit1d-used), "window_start": rateLimitData.Window1dStart, - }) + } + if rateLimitData.Window1dStart != nil && !service.IsWindowExpired(rateLimitData.Window1dStart, service.RateLimitWindow1d) { + entry["reset_at"] = rateLimitData.Window1dStart.Add(service.RateLimitWindow1d) + } + rateLimits = append(rateLimits, entry) } if apiKey.RateLimit7d > 0 { used := rateLimitData.EffectiveUsage7d() - rateLimits = append(rateLimits, gin.H{ + entry := gin.H{ "window": "7d", "limit": apiKey.RateLimit7d, "used": used, "remaining": max(0, apiKey.RateLimit7d-used), "window_start": rateLimitData.Window7dStart, - }) + } + if rateLimitData.Window7dStart != nil && !service.IsWindowExpired(rateLimitData.Window7dStart, service.RateLimitWindow7d) { + entry["reset_at"] = rateLimitData.Window7dStart.Add(service.RateLimitWindow7d) + } + rateLimits = append(rateLimits, entry) } if len(rateLimits) > 0 { resp["rate_limits"] = rateLimits diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index 451c61e5..95db1819 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -476,8 +476,8 @@ func (r *apiKeyRepository) IncrementRateLimitUsage(ctx context.Context, id int64 usage_1d = CASE WHEN window_1d_start IS NOT NULL AND window_1d_start + INTERVAL '24 hours' <= NOW() THEN $1 ELSE usage_1d + $1 END, usage_7d = CASE WHEN window_7d_start IS NOT NULL AND window_7d_start + INTERVAL '7 days' <= NOW() THEN $1 ELSE usage_7d + $1 END, window_5h_start = CASE WHEN window_5h_start IS NULL OR window_5h_start + INTERVAL '5 hours' <= NOW() THEN NOW() ELSE window_5h_start END, - window_1d_start = CASE WHEN window_1d_start IS NULL OR window_1d_start + INTERVAL '24 hours' <= NOW() THEN NOW() ELSE window_1d_start END, - window_7d_start = CASE WHEN window_7d_start IS NULL OR window_7d_start + INTERVAL '7 days' <= NOW() THEN NOW() ELSE window_7d_start END, + window_1d_start = CASE WHEN window_1d_start IS NULL OR window_1d_start + INTERVAL '24 hours' <= NOW() THEN date_trunc('day', NOW()) ELSE window_1d_start END, + window_7d_start = CASE WHEN window_7d_start IS NULL OR window_7d_start + INTERVAL '7 days' <= NOW() THEN date_trunc('day', NOW()) ELSE window_7d_start END, updated_at = NOW() WHERE id = $2 AND deleted_at IS NULL`, cost, id) @@ -491,9 +491,9 @@ func (r *apiKeyRepository) ResetRateLimitWindows(ctx context.Context, id int64) usage_5h = CASE WHEN window_5h_start IS NOT NULL AND window_5h_start + INTERVAL '5 hours' <= NOW() THEN 0 ELSE usage_5h END, window_5h_start = CASE WHEN window_5h_start IS NOT NULL AND window_5h_start + INTERVAL '5 hours' <= NOW() THEN NOW() ELSE window_5h_start END, usage_1d = CASE WHEN window_1d_start IS NOT NULL AND window_1d_start + INTERVAL '24 hours' <= NOW() THEN 0 ELSE usage_1d END, - window_1d_start = CASE WHEN window_1d_start IS NOT NULL AND window_1d_start + INTERVAL '24 hours' <= NOW() THEN NOW() ELSE window_1d_start END, + window_1d_start = CASE WHEN window_1d_start IS NOT NULL AND window_1d_start + INTERVAL '24 hours' <= NOW() THEN date_trunc('day', NOW()) ELSE window_1d_start END, usage_7d = CASE WHEN window_7d_start IS NOT NULL AND window_7d_start + INTERVAL '7 days' <= NOW() THEN 0 ELSE usage_7d END, - window_7d_start = CASE WHEN window_7d_start IS NOT NULL AND window_7d_start + INTERVAL '7 days' <= NOW() THEN NOW() ELSE window_7d_start END, + window_7d_start = CASE WHEN window_7d_start IS NOT NULL AND window_7d_start + INTERVAL '7 days' <= NOW() THEN date_trunc('day', NOW()) ELSE window_7d_start END, updated_at = NOW() WHERE id = $1 AND deleted_at IS NULL`, id) diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index ecd13940..b16b2f22 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -153,6 +153,7 @@ export default { todayExpires: '(expires today)', daysLeft: '({days} days)', usedQuota: 'Used Quota', + resetNow: 'Resetting soon', subscriptionType: 'Subscription Type', subscriptionExpires: 'Subscription Expires', // Usage stat cells @@ -660,6 +661,7 @@ export default { resetRateLimitConfirmMessage: 'Are you sure you want to reset the rate limit usage for key "{name}"? All time window usage will be reset to zero. This action cannot be undone.', rateLimitResetSuccess: 'Rate limit usage reset successfully', failedToResetRateLimit: 'Failed to reset rate limit usage', + resetNow: 'Resetting soon', expiration: 'Expiration', expiresInDays: '{days} days', extendDays: '+{days} days', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 0238595e..39064c4b 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -153,6 +153,7 @@ export default { todayExpires: '(今日到期)', daysLeft: '({days} 天)', usedQuota: '已用额度', + resetNow: '即将重置', subscriptionType: '订阅类型', subscriptionExpires: '订阅到期', // Usage stat cells @@ -665,6 +666,7 @@ export default { resetRateLimitConfirmMessage: '确定要重置密钥 "{name}" 的速率限制用量吗?所有时间窗口的已用额度将归零。此操作不可撤销。', rateLimitResetSuccess: '速率限制已重置', failedToResetRateLimit: '重置速率限制失败', + resetNow: '即将重置', expiration: '密钥有效期', expiresInDays: '{days} 天', extendDays: '+{days} 天', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 249ae652..6f1987c2 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -441,6 +441,9 @@ export interface ApiKey { window_5h_start: string | null window_1d_start: string | null window_7d_start: string | null + reset_5h_at: string | null + reset_1d_at: string | null + reset_7d_at: string | null } export interface CreateApiKeyRequest { diff --git a/frontend/src/views/KeyUsageView.vue b/frontend/src/views/KeyUsageView.vue index 755f1966..21a35340 100644 --- a/frontend/src/views/KeyUsageView.vue +++ b/frontend/src/views/KeyUsageView.vue @@ -226,6 +226,9 @@ class="text-sm font-semibold mt-1 tabular-nums" :style="{ color: RING_GRADIENTS[i % 4].from }" >{{ ring.amount }} +
+ ⟳ {{ formatResetTime(ring.resetAt) }} +
@@ -358,7 +361,7 @@ diff --git a/frontend/src/views/user/KeysView.vue b/frontend/src/views/user/KeysView.vue index 8ffd6522..3068cb7f 100644 --- a/frontend/src/views/user/KeysView.vue +++ b/frontend/src/views/user/KeysView.vue @@ -187,6 +187,9 @@ :style="{ width: Math.min((row.usage_5h / row.rate_limit_5h) * 100, 100) + '%' }" /> +