feat: apikey限额支持查询重置时间

This commit is contained in:
shaw
2026-03-09 10:22:24 +08:00
parent 440d2e28ed
commit c7fcb7a84b
9 changed files with 102 additions and 13 deletions

View File

@@ -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 {

View File

@@ -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"`

View File

@@ -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

View File

@@ -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)

View File

@@ -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',

View File

@@ -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} 天',

View File

@@ -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 {

View File

@@ -226,6 +226,9 @@
class="text-sm font-semibold mt-1 tabular-nums"
:style="{ color: RING_GRADIENTS[i % 4].from }"
>{{ ring.amount }}</span>
<p v-if="ring.resetAt && formatResetTime(ring.resetAt)" class="text-xs text-gray-400 dark:text-gray-500 mt-0.5 tabular-nums">
{{ formatResetTime(ring.resetAt) }}
</p>
</template>
</div>
</div>
@@ -358,7 +361,7 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores'
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
@@ -396,6 +399,8 @@ const showLoading = ref(false)
const showDatePicker = ref(false)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resultData = ref<any>(null)
const now = ref(new Date())
let resetTimer: ReturnType<typeof setInterval> | null = null
// ==================== Date Range State ====================
@@ -461,6 +466,7 @@ interface RingItem {
amount: string
isBalance?: boolean
iconType: 'clock' | 'calendar' | 'dollar'
resetAt?: string | null
}
function getRingOffset(ring: RingItem): number {
@@ -544,6 +550,7 @@ const ringItems = computed<RingItem[]>(() => {
pct,
amount: `${usd(rl.used)} / ${usd(rl.limit)}`,
iconType: windowIcons[rl.window] || 'clock',
resetAt: rl.reset_at,
})
}
}
@@ -627,10 +634,15 @@ const detailRows = computed<DetailRow[]>(() => {
const windowMap: Record<string, string> = { '5h': '5H', '1d': locale.value === 'zh' ? '日' : 'D', '7d': '7D' }
for (const rl of data.rate_limits) {
const pct = rl.limit > 0 ? (rl.used / rl.limit) * 100 : 0
let valueStr = `${usd(rl.used)} / ${usd(rl.limit)}`
const resetStr = formatResetTime(rl.reset_at)
if (resetStr) {
valueStr += ` (⟳ ${resetStr})`
}
rows.push({
iconBg: 'bg-primary-500/10', iconColor: 'text-primary-500', iconSvg: ICON_DOLLAR,
label: `${t('keyUsage.usedQuota')} (${windowMap[rl.window] || rl.window})`,
value: `${usd(rl.used)} / ${usd(rl.limit)}`,
value: valueStr,
valueClass: getUsageColor(pct),
})
}
@@ -798,11 +810,28 @@ function initTheme() {
}
}
function formatResetTime(resetAt: string | null | undefined): string {
if (!resetAt) return ''
const diff = new Date(resetAt).getTime() - now.value.getTime()
if (diff <= 0) return t('keyUsage.resetNow')
const days = Math.floor(diff / 86400000)
const hours = Math.floor((diff % 86400000) / 3600000)
const mins = Math.floor((diff % 3600000) / 60000)
if (days > 0) return `${days}d ${hours}h`
if (hours > 0) return `${hours}h ${mins}m`
return `${mins}m`
}
onMounted(() => {
initTheme()
if (!appStore.publicSettingsLoaded) {
appStore.fetchPublicSettings()
}
resetTimer = setInterval(() => { now.value = new Date() }, 60000)
})
onUnmounted(() => {
if (resetTimer) clearInterval(resetTimer)
})
</script>

View File

@@ -187,6 +187,9 @@
:style="{ width: Math.min((row.usage_5h / row.rate_limit_5h) * 100, 100) + '%' }"
/>
</div>
<div v-if="row.reset_5h_at && formatResetTime(row.reset_5h_at)" class="text-[10px] text-gray-400 dark:text-gray-500 tabular-nums">
{{ formatResetTime(row.reset_5h_at) }}
</div>
</div>
<!-- 1d window -->
<div v-if="row.rate_limit_1d > 0">
@@ -212,6 +215,9 @@
:style="{ width: Math.min((row.usage_1d / row.rate_limit_1d) * 100, 100) + '%' }"
/>
</div>
<div v-if="row.reset_1d_at && formatResetTime(row.reset_1d_at)" class="text-[10px] text-gray-400 dark:text-gray-500 tabular-nums">
{{ formatResetTime(row.reset_1d_at) }}
</div>
</div>
<!-- 7d window -->
<div v-if="row.rate_limit_7d > 0">
@@ -237,6 +243,9 @@
:style="{ width: Math.min((row.usage_7d / row.rate_limit_7d) * 100, 100) + '%' }"
/>
</div>
<div v-if="row.reset_7d_at && formatResetTime(row.reset_7d_at)" class="text-[10px] text-gray-400 dark:text-gray-500 tabular-nums">
{{ formatResetTime(row.reset_7d_at) }}
</div>
</div>
<!-- Reset button -->
<button
@@ -1085,6 +1094,8 @@ const apiKeys = ref<ApiKey[]>([])
const groups = ref<Group[]>([])
const loading = ref(false)
const submitting = ref(false)
const now = ref(new Date())
let resetTimer: ReturnType<typeof setInterval> | null = null
const usageStats = ref<Record<string, BatchApiKeyUsageStats>>({})
const userGroupRates = ref<Record<number, number>>({})
@@ -1745,15 +1756,29 @@ const closeCcsClientSelect = () => {
pendingCcsRow.value = null
}
function formatResetTime(resetAt: string | null): string {
if (!resetAt) return ''
const diff = new Date(resetAt).getTime() - now.value.getTime()
if (diff <= 0) return t('keys.resetNow')
const days = Math.floor(diff / 86400000)
const hours = Math.floor((diff % 86400000) / 3600000)
const mins = Math.floor((diff % 3600000) / 60000)
if (days > 0) return `${days}d ${hours}h`
if (hours > 0) return `${hours}h ${mins}m`
return `${mins}m`
}
onMounted(() => {
loadApiKeys()
loadGroups()
loadUserGroupRates()
loadPublicSettings()
document.addEventListener('click', closeGroupSelector)
resetTimer = setInterval(() => { now.value = new Date() }, 60000)
})
onUnmounted(() => {
document.removeEventListener('click', closeGroupSelector)
if (resetTimer) clearInterval(resetTimer)
})
</script>