mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat(api-keys): 支持独立密钥额度重置(手动+定时自动)
- 新增手动重置接口 PATCH /api/admin/api-keys/{id}/reset-usage
- 前端 ApiKeys 页面增加重置按钮,支持确认后归零已使用额度
- 新增独立密钥额度定时自动重置任务,支持配置周期和执行时间
- 支持 all/selected 两种重置模式,selected 模式可指定密钥
- 移除已废弃的 CleanupScheduler 兼容别名
This commit is contained in:
@@ -518,6 +518,14 @@ export const adminApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 重置独立余额Key的已使用额度
|
||||
async resetApiKeyUsage(keyId: string): Promise<AdminApiKey & { message: string }> {
|
||||
const response = await apiClient.patch<AdminApiKey & { message: string }>(
|
||||
`/api/admin/api-keys/${keyId}/reset-usage`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 获取API密钥详情(可选包含完整密钥)
|
||||
async getApiKeyDetail(keyId: string, includeKey: boolean = false): Promise<AdminApiKey & { key?: string }> {
|
||||
const response = await apiClient.get<AdminApiKey & { key?: string }>(
|
||||
|
||||
@@ -279,6 +279,15 @@
|
||||
>
|
||||
<DollarSign class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
title="重置额度"
|
||||
@click="resetKeyUsage(apiKey)"
|
||||
>
|
||||
<RotateCcw class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -480,6 +489,15 @@
|
||||
<DollarSign class="h-3.5 w-3.5 mr-1.5" />
|
||||
调整
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="text-amber-600"
|
||||
@click="resetKeyUsage(apiKey)"
|
||||
>
|
||||
<RotateCcw class="h-3.5 w-3.5 mr-1.5" />
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -727,7 +745,8 @@ import {
|
||||
SquarePen,
|
||||
Search,
|
||||
Lock,
|
||||
LockOpen
|
||||
LockOpen,
|
||||
RotateCcw
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
import { StandaloneKeyFormDialog, type StandaloneKeyFormData } from '@/features/api-keys'
|
||||
@@ -971,6 +990,23 @@ async function handleAddBalance() {
|
||||
}
|
||||
}
|
||||
|
||||
async function resetKeyUsage(apiKey: AdminApiKey) {
|
||||
const confirmed = await confirmDanger(
|
||||
`确定要重置此 Key 的已使用额度吗?\n\n${apiKey.name || apiKey.key_display || 'sk-****'}\n\n已使用额度将归零,当前余额不变。`,
|
||||
'重置使用额度'
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
const response = await adminApi.resetApiKeyUsage(apiKey.id)
|
||||
await loadApiKeys()
|
||||
success(response.message)
|
||||
} catch (err: unknown) {
|
||||
log.error('重置使用额度失败:', err)
|
||||
error(parseApiError(err, '重置失败'))
|
||||
}
|
||||
}
|
||||
|
||||
function selectKey() {
|
||||
keyInput.value?.select()
|
||||
}
|
||||
|
||||
@@ -114,7 +114,14 @@
|
||||
id="section-scheduled"
|
||||
:scheduled-tasks="scheduledTasks"
|
||||
:quota-reset-interval-days="systemConfig.user_quota_reset_interval_days"
|
||||
:standalone-key-reset-interval-days="systemConfig.standalone_key_quota_reset_interval_days"
|
||||
:standalone-key-reset-mode="systemConfig.standalone_key_quota_reset_mode"
|
||||
:standalone-key-reset-key-ids="systemConfig.standalone_key_quota_reset_key_ids"
|
||||
:standalone-keys="standaloneKeys"
|
||||
@update:quota-reset-interval-days="systemConfig.user_quota_reset_interval_days = $event"
|
||||
@update:standalone-key-reset-interval-days="systemConfig.standalone_key_quota_reset_interval_days = $event"
|
||||
@update:standalone-key-reset-mode="handleStandaloneKeyResetModeChange"
|
||||
@toggle-standalone-key-reset-key-id="handleToggleStandaloneKeyResetKeyId"
|
||||
/>
|
||||
|
||||
<!-- 系统版本信息 -->
|
||||
@@ -194,6 +201,7 @@
|
||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { PageHeader, PageContainer } from '@/components/layout'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
import { adminApi } from '@/api/admin'
|
||||
|
||||
// Composables
|
||||
import { useSystemConfig } from './system-settings/composables/useSystemConfig'
|
||||
@@ -336,13 +344,50 @@ const {
|
||||
const {
|
||||
scheduledTasks,
|
||||
initPreviousValues,
|
||||
saveStandaloneKeyResetMode,
|
||||
saveStandaloneKeyResetKeyIds,
|
||||
} = useScheduledTasks(systemConfig)
|
||||
|
||||
// 独立密钥列表(用于定时任务配置中的密钥选择)
|
||||
const standaloneKeys = ref<Array<{ id: string; name?: string; key_display?: string; current_balance_usd?: number | null }>>([])
|
||||
|
||||
async function loadStandaloneKeys() {
|
||||
try {
|
||||
const result = await adminApi.getAllApiKeys({ limit: 2000 })
|
||||
standaloneKeys.value = result.api_keys.map((k) => ({
|
||||
id: k.id,
|
||||
name: k.name,
|
||||
key_display: k.key_display,
|
||||
current_balance_usd: k.current_balance_usd,
|
||||
}))
|
||||
} catch {
|
||||
// 加载失败不影响其他功能
|
||||
}
|
||||
}
|
||||
|
||||
function handleStandaloneKeyResetModeChange(mode: string) {
|
||||
systemConfig.value.standalone_key_quota_reset_mode = mode
|
||||
saveStandaloneKeyResetMode(mode)
|
||||
}
|
||||
|
||||
function handleToggleStandaloneKeyResetKeyId(keyId: string) {
|
||||
const ids = [...systemConfig.value.standalone_key_quota_reset_key_ids]
|
||||
const idx = ids.indexOf(keyId)
|
||||
if (idx >= 0) {
|
||||
ids.splice(idx, 1)
|
||||
} else {
|
||||
ids.push(keyId)
|
||||
}
|
||||
systemConfig.value.standalone_key_quota_reset_key_ids = ids
|
||||
saveStandaloneKeyResetKeyIds(ids)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadSystemConfig(),
|
||||
loadSystemVersion(),
|
||||
proxyNodesStore.ensureLoaded(),
|
||||
loadStandaloneKeys(),
|
||||
])
|
||||
// 配置加载完成后初始化定时任务的原始值
|
||||
initPreviousValues()
|
||||
|
||||
@@ -145,6 +145,96 @@
|
||||
滚动计算:距离上次成功执行满 N 天后再次执行
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 独立密钥额度重置额外配置 -->
|
||||
<div
|
||||
v-if="task.id === 'standalone-key-quota-reset' && task.enabled"
|
||||
class="px-4 pb-4 pt-0 space-y-3"
|
||||
>
|
||||
<!-- 重置周期 -->
|
||||
<div class="flex items-center gap-3 p-3 rounded-lg bg-muted/30 border border-border/50">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-muted-foreground">重置周期</span>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-muted-foreground">每</span>
|
||||
<Input
|
||||
:model-value="standaloneKeyResetIntervalDays"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
class="w-14 h-7 text-xs text-center px-2"
|
||||
@update:model-value="$emit('update:standaloneKeyResetIntervalDays', Number($event))"
|
||||
/>
|
||||
<span class="text-muted-foreground">天</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重置范围 -->
|
||||
<div class="p-3 rounded-lg bg-muted/30 border border-border/50 space-y-3">
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-muted-foreground">重置范围</span>
|
||||
<Select
|
||||
:model-value="standaloneKeyResetMode"
|
||||
@update:model-value="$emit('update:standaloneKeyResetMode', $event)"
|
||||
>
|
||||
<SelectTrigger class="w-32 h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
全部独立密钥
|
||||
</SelectItem>
|
||||
<SelectItem value="selected">
|
||||
指定密钥
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<!-- 密钥多选列表 -->
|
||||
<div
|
||||
v-if="standaloneKeyResetMode === 'selected'"
|
||||
class="space-y-2"
|
||||
>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
选择需要重置的密钥:
|
||||
</div>
|
||||
<div
|
||||
v-if="standaloneKeys.length === 0"
|
||||
class="text-xs text-muted-foreground/60 py-2"
|
||||
>
|
||||
暂无独立密钥
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="max-h-48 overflow-y-auto space-y-1"
|
||||
>
|
||||
<label
|
||||
v-for="key in standaloneKeys"
|
||||
:key="key.id"
|
||||
class="flex items-center gap-2 p-2 rounded hover:bg-muted/50 cursor-pointer text-xs"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="standaloneKeyResetKeyIds.includes(key.id)"
|
||||
@update:model-value="$emit('toggleStandaloneKeyResetKeyId', key.id)"
|
||||
/>
|
||||
<span class="truncate">{{ key.name || key.key_display || 'sk-****' }}</span>
|
||||
<span
|
||||
v-if="key.current_balance_usd != null"
|
||||
class="text-muted-foreground ml-auto shrink-0"
|
||||
>
|
||||
${{ key.current_balance_usd.toFixed(2) }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-[11px] text-muted-foreground ml-1">
|
||||
滚动计算:距离上次成功执行满 N 天后再次执行
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -156,6 +246,7 @@ import { Clock, Check, Loader2, X } from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Switch from '@/components/ui/switch.vue'
|
||||
import Checkbox from '@/components/ui/checkbox.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
@@ -181,12 +272,26 @@ interface ScheduledTask {
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
interface StandaloneKeyOption {
|
||||
id: string
|
||||
name?: string
|
||||
key_display?: string
|
||||
current_balance_usd?: number | null
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
scheduledTasks: ScheduledTask[]
|
||||
quotaResetIntervalDays: number
|
||||
standaloneKeyResetIntervalDays: number
|
||||
standaloneKeyResetMode: string
|
||||
standaloneKeyResetKeyIds: string[]
|
||||
standaloneKeys: StandaloneKeyOption[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'update:quotaResetIntervalDays': [value: number]
|
||||
'update:standaloneKeyResetIntervalDays': [value: number]
|
||||
'update:standaloneKeyResetMode': [value: string]
|
||||
'toggleStandaloneKeyResetKeyId': [keyId: string]
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ref, computed, type Ref } from 'vue'
|
||||
import { CalendarCheck, RotateCcw, RefreshCw } from 'lucide-vue-next'
|
||||
import { CalendarCheck, RotateCcw, RefreshCw, KeyRound } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { log } from '@/utils/logger'
|
||||
@@ -10,18 +10,24 @@ export function useScheduledTasks(systemConfig: Ref<SystemConfig>) {
|
||||
|
||||
const checkinConfigLoading = ref(false)
|
||||
const quotaResetConfigLoading = ref(false)
|
||||
const standaloneKeyResetConfigLoading = ref(false)
|
||||
|
||||
// 签到时间的原始值(用于回滚)
|
||||
const previousCheckinTime = ref('')
|
||||
// 用户配额重置时间的原始值
|
||||
const previousUserQuotaResetTime = ref('')
|
||||
const previousUserQuotaResetIntervalDays = ref(1)
|
||||
// 独立密钥额度重置的原始值
|
||||
const previousStandaloneKeyResetTime = ref('')
|
||||
const previousStandaloneKeyResetIntervalDays = ref(1)
|
||||
|
||||
// 初始化原始值(在配置加载完成后调用)
|
||||
function initPreviousValues() {
|
||||
previousCheckinTime.value = systemConfig.value.provider_checkin_time
|
||||
previousUserQuotaResetTime.value = systemConfig.value.user_quota_reset_time
|
||||
previousUserQuotaResetIntervalDays.value = systemConfig.value.user_quota_reset_interval_days
|
||||
previousStandaloneKeyResetTime.value = systemConfig.value.standalone_key_quota_reset_time
|
||||
previousStandaloneKeyResetIntervalDays.value = systemConfig.value.standalone_key_quota_reset_interval_days
|
||||
}
|
||||
|
||||
// 签到时间
|
||||
@@ -77,6 +83,38 @@ export function useScheduledTasks(systemConfig: Ref<SystemConfig>) {
|
||||
return hasUserQuotaResetTimeChanged.value || hasUserQuotaResetIntervalChanged.value
|
||||
})
|
||||
|
||||
// 独立密钥额度重置时间
|
||||
const standaloneKeyResetHour = computed(() => {
|
||||
const time = systemConfig.value.standalone_key_quota_reset_time
|
||||
if (!time || !time.includes(':')) return '05'
|
||||
return time.split(':')[0]
|
||||
})
|
||||
|
||||
const standaloneKeyResetMinute = computed(() => {
|
||||
const time = systemConfig.value.standalone_key_quota_reset_time
|
||||
if (!time || !time.includes(':')) return '00'
|
||||
return time.split(':')[1]
|
||||
})
|
||||
|
||||
function updateStandaloneKeyResetTime(hour: string, minute: string) {
|
||||
systemConfig.value.standalone_key_quota_reset_time = `${hour}:${minute}`
|
||||
}
|
||||
|
||||
const hasStandaloneKeyResetTimeChanged = computed(() => {
|
||||
return systemConfig.value.standalone_key_quota_reset_time !== previousStandaloneKeyResetTime.value
|
||||
})
|
||||
|
||||
const hasStandaloneKeyResetIntervalChanged = computed(() => {
|
||||
return (
|
||||
systemConfig.value.standalone_key_quota_reset_interval_days !==
|
||||
previousStandaloneKeyResetIntervalDays.value
|
||||
)
|
||||
})
|
||||
|
||||
const hasStandaloneKeyResetConfigChanged = computed(() => {
|
||||
return hasStandaloneKeyResetTimeChanged.value || hasStandaloneKeyResetIntervalChanged.value
|
||||
})
|
||||
|
||||
// Toggle handlers
|
||||
async function handleProviderCheckinToggle(enabled: boolean) {
|
||||
const previousValue = systemConfig.value.enable_provider_checkin
|
||||
@@ -129,6 +167,23 @@ export function useScheduledTasks(systemConfig: Ref<SystemConfig>) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStandaloneKeyResetToggle(enabled: boolean) {
|
||||
const previousValue = systemConfig.value.enable_standalone_key_quota_reset
|
||||
systemConfig.value.enable_standalone_key_quota_reset = enabled
|
||||
try {
|
||||
await adminApi.updateSystemConfig(
|
||||
'enable_standalone_key_quota_reset',
|
||||
enabled,
|
||||
'是否启用独立密钥额度自动重置任务'
|
||||
)
|
||||
success(enabled ? '已启用独立密钥额度自动重置' : '已禁用独立密钥额度自动重置')
|
||||
} catch (err) {
|
||||
error('保存配置失败')
|
||||
log.error('保存独立密钥额度自动重置配置失败:', err)
|
||||
systemConfig.value.enable_standalone_key_quota_reset = previousValue
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel handlers
|
||||
function handleCheckinTimeCancel() {
|
||||
systemConfig.value.provider_checkin_time = previousCheckinTime.value
|
||||
@@ -139,6 +194,11 @@ export function useScheduledTasks(systemConfig: Ref<SystemConfig>) {
|
||||
systemConfig.value.user_quota_reset_interval_days = previousUserQuotaResetIntervalDays.value
|
||||
}
|
||||
|
||||
function handleStandaloneKeyResetConfigCancel() {
|
||||
systemConfig.value.standalone_key_quota_reset_time = previousStandaloneKeyResetTime.value
|
||||
systemConfig.value.standalone_key_quota_reset_interval_days = previousStandaloneKeyResetIntervalDays.value
|
||||
}
|
||||
|
||||
// Save handlers
|
||||
async function handleCheckinTimeSave() {
|
||||
const newTime = systemConfig.value.provider_checkin_time
|
||||
@@ -232,6 +292,103 @@ export function useScheduledTasks(systemConfig: Ref<SystemConfig>) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStandaloneKeyResetConfigSave() {
|
||||
const configItems: Array<{
|
||||
key: string
|
||||
value: unknown
|
||||
description: string
|
||||
onSuccess: () => void
|
||||
}> = []
|
||||
|
||||
if (hasStandaloneKeyResetTimeChanged.value) {
|
||||
const newTime = systemConfig.value.standalone_key_quota_reset_time
|
||||
if (!newTime || !/^\d{2}:\d{2}$/.test(newTime)) {
|
||||
error('请输入有效的时间格式 (HH:MM)')
|
||||
return
|
||||
}
|
||||
configItems.push({
|
||||
key: 'standalone_key_quota_reset_time',
|
||||
value: newTime,
|
||||
description: '独立密钥额度自动重置执行时间(HH:MM 格式)',
|
||||
onSuccess: () => {
|
||||
previousStandaloneKeyResetTime.value = newTime
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (hasStandaloneKeyResetIntervalChanged.value) {
|
||||
let intervalDays = Number(systemConfig.value.standalone_key_quota_reset_interval_days)
|
||||
if (!Number.isFinite(intervalDays) || intervalDays < 1) intervalDays = 1
|
||||
intervalDays = Math.trunc(intervalDays)
|
||||
|
||||
systemConfig.value.standalone_key_quota_reset_interval_days = intervalDays
|
||||
|
||||
configItems.push({
|
||||
key: 'standalone_key_quota_reset_interval_days',
|
||||
value: intervalDays,
|
||||
description: '独立密钥额度重置周期(天数),滚动计算',
|
||||
onSuccess: () => {
|
||||
previousStandaloneKeyResetIntervalDays.value = intervalDays
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (configItems.length === 0) return
|
||||
|
||||
standaloneKeyResetConfigLoading.value = true
|
||||
const failedKeys: string[] = []
|
||||
|
||||
try {
|
||||
for (const item of configItems) {
|
||||
try {
|
||||
await adminApi.updateSystemConfig(item.key, item.value, item.description)
|
||||
item.onSuccess()
|
||||
} catch (err) {
|
||||
failedKeys.push(item.key)
|
||||
log.error(`保存独立密钥额度重置配置失败: ${item.key}`, err)
|
||||
}
|
||||
}
|
||||
|
||||
if (failedKeys.length > 0) {
|
||||
error(`部分配置保存失败: ${failedKeys.join(', ')}`)
|
||||
return
|
||||
}
|
||||
|
||||
success('独立密钥额度重置配置已保存')
|
||||
} finally {
|
||||
standaloneKeyResetConfigLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存独立密钥额度重置模式和选中密钥
|
||||
async function saveStandaloneKeyResetMode(mode: string) {
|
||||
try {
|
||||
await adminApi.updateSystemConfig(
|
||||
'standalone_key_quota_reset_mode',
|
||||
mode,
|
||||
'独立密钥额度重置模式'
|
||||
)
|
||||
success('重置模式已保存')
|
||||
} catch (err) {
|
||||
error('保存重置模式失败')
|
||||
log.error('保存独立密钥额度重置模式失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveStandaloneKeyResetKeyIds(keyIds: string[]) {
|
||||
try {
|
||||
await adminApi.updateSystemConfig(
|
||||
'standalone_key_quota_reset_key_ids',
|
||||
keyIds,
|
||||
'独立密钥额度重置指定的密钥 ID 列表'
|
||||
)
|
||||
success('已保存选中密钥')
|
||||
} catch (err) {
|
||||
error('保存选中密钥失败')
|
||||
log.error('保存独立密钥额度重置密钥列表失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 定时任务配置列表
|
||||
const scheduledTasks = computed(() => [
|
||||
{
|
||||
@@ -282,12 +439,31 @@ export function useScheduledTasks(systemConfig: Ref<SystemConfig>) {
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
},
|
||||
{
|
||||
id: 'standalone-key-quota-reset',
|
||||
icon: KeyRound,
|
||||
title: '独立密钥额度自动重置',
|
||||
description: '定时将独立密钥已使用额度重置为零',
|
||||
enabled: systemConfig.value.enable_standalone_key_quota_reset,
|
||||
hasTimeConfig: true,
|
||||
hour: standaloneKeyResetHour.value,
|
||||
minute: standaloneKeyResetMinute.value,
|
||||
updateTime: updateStandaloneKeyResetTime,
|
||||
hasChanges: hasStandaloneKeyResetConfigChanged.value,
|
||||
loading: standaloneKeyResetConfigLoading.value,
|
||||
onToggle: handleStandaloneKeyResetToggle,
|
||||
onSave: handleStandaloneKeyResetConfigSave,
|
||||
onCancel: handleStandaloneKeyResetConfigCancel,
|
||||
},
|
||||
])
|
||||
|
||||
return {
|
||||
checkinConfigLoading,
|
||||
quotaResetConfigLoading,
|
||||
standaloneKeyResetConfigLoading,
|
||||
scheduledTasks,
|
||||
initPreviousValues,
|
||||
saveStandaloneKeyResetMode,
|
||||
saveStandaloneKeyResetKeyIds,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,12 @@ export interface SystemConfig {
|
||||
user_quota_reset_time: string
|
||||
user_quota_reset_interval_days: number
|
||||
enable_oauth_token_refresh: boolean
|
||||
// 独立密钥额度重置
|
||||
enable_standalone_key_quota_reset: boolean
|
||||
standalone_key_quota_reset_time: string
|
||||
standalone_key_quota_reset_interval_days: number
|
||||
standalone_key_quota_reset_mode: string
|
||||
standalone_key_quota_reset_key_ids: string[]
|
||||
}
|
||||
|
||||
const CONFIG_KEYS = [
|
||||
@@ -74,6 +80,12 @@ const CONFIG_KEYS = [
|
||||
'user_quota_reset_time',
|
||||
'user_quota_reset_interval_days',
|
||||
'enable_oauth_token_refresh',
|
||||
// 独立密钥额度重置
|
||||
'enable_standalone_key_quota_reset',
|
||||
'standalone_key_quota_reset_time',
|
||||
'standalone_key_quota_reset_interval_days',
|
||||
'standalone_key_quota_reset_mode',
|
||||
'standalone_key_quota_reset_key_ids',
|
||||
]
|
||||
|
||||
function createDefaultConfig(): SystemConfig {
|
||||
@@ -111,6 +123,12 @@ function createDefaultConfig(): SystemConfig {
|
||||
user_quota_reset_time: '05:00',
|
||||
user_quota_reset_interval_days: 1,
|
||||
enable_oauth_token_refresh: true,
|
||||
// 独立密钥额度重置
|
||||
enable_standalone_key_quota_reset: false,
|
||||
standalone_key_quota_reset_time: '05:00',
|
||||
standalone_key_quota_reset_interval_days: 1,
|
||||
standalone_key_quota_reset_mode: 'all',
|
||||
standalone_key_quota_reset_key_ids: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -292,6 +292,26 @@ async def add_balance_to_key(
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.patch("/{key_id}/reset-usage")
|
||||
async def reset_api_key_usage(key_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
|
||||
"""
|
||||
重置独立余额 API Key 的已使用额度
|
||||
|
||||
将 balance_used_usd 重置为 0,不改变 current_balance_usd。
|
||||
|
||||
**路径参数**:
|
||||
- `key_id`: API Key ID
|
||||
|
||||
**返回字段**:
|
||||
- `id`: API Key ID
|
||||
- `current_balance_usd`: 当前余额
|
||||
- `balance_used_usd`: 已使用余额(重置后为 0)
|
||||
- `message`: 提示信息
|
||||
"""
|
||||
adapter = AdminResetKeyUsageAdapter(key_id=key_id)
|
||||
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
|
||||
|
||||
|
||||
@router.get("/{key_id}")
|
||||
async def get_api_key_detail(
|
||||
key_id: str,
|
||||
@@ -663,6 +683,48 @@ class AdminAddBalanceAdapter(AdminApiAdapter):
|
||||
}
|
||||
|
||||
|
||||
class AdminResetKeyUsageAdapter(AdminApiAdapter):
|
||||
"""重置独立余额Key的已使用额度"""
|
||||
|
||||
def __init__(self, key_id: str):
|
||||
self.key_id = key_id
|
||||
|
||||
async def handle(self, context: ApiRequestContext) -> Any: # type: ignore[override]
|
||||
db = context.db
|
||||
api_key = db.query(ApiKey).filter(ApiKey.id == self.key_id).first()
|
||||
if not api_key:
|
||||
raise NotFoundException("API密钥不存在", "api_key")
|
||||
|
||||
if not api_key.is_standalone:
|
||||
raise InvalidRequestException("只能重置独立余额Key的使用额度")
|
||||
|
||||
previous_used = float(api_key.balance_used_usd or 0)
|
||||
api_key.balance_used_usd = 0.0
|
||||
api_key.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(api_key)
|
||||
|
||||
logger.info(
|
||||
f"管理员重置独立余额Key使用额度: Key ID {self.key_id}, "
|
||||
f"重置前已使用 ${previous_used:.4f}"
|
||||
)
|
||||
|
||||
context.add_audit_metadata(
|
||||
action="reset_key_usage",
|
||||
key_id=self.key_id,
|
||||
current_balance_usd=api_key.current_balance_usd,
|
||||
previous_balance_used_usd=previous_used,
|
||||
)
|
||||
|
||||
return {
|
||||
"id": api_key.id,
|
||||
"name": api_key.name,
|
||||
"current_balance_usd": api_key.current_balance_usd,
|
||||
"balance_used_usd": 0.0,
|
||||
"message": "使用额度已重置",
|
||||
}
|
||||
|
||||
|
||||
class AdminGetFullKeyAdapter(AdminApiAdapter):
|
||||
"""获取完整的API密钥"""
|
||||
|
||||
|
||||
@@ -684,6 +684,16 @@ class AdminSetSystemConfigAdapter(AdminApiAdapter):
|
||||
except Exception as e:
|
||||
logger.warning(f"更新用户配额重置任务时间失败: {e}")
|
||||
|
||||
# 如果更新的是独立密钥额度重置任务时间,动态更新调度器
|
||||
if self.key == "standalone_key_quota_reset_time" and value:
|
||||
try:
|
||||
from src.services.system.maintenance_scheduler import get_maintenance_scheduler
|
||||
|
||||
scheduler = get_maintenance_scheduler()
|
||||
scheduler.update_standalone_key_quota_reset_time(value)
|
||||
except Exception as e:
|
||||
logger.warning(f"更新独立密钥额度重置任务时间失败: {e}")
|
||||
|
||||
# 如果更新的是调度模式或优先级模式,立即更新当前 Worker 的 Scheduler 单例
|
||||
if self.key in ("scheduling_mode", "provider_priority_mode"):
|
||||
try:
|
||||
@@ -2444,9 +2454,7 @@ class AdminPurgeUsersAdapter(AdminApiAdapter):
|
||||
|
||||
db = context.db
|
||||
|
||||
user_ids = [
|
||||
uid for (uid,) in db.query(User.id).filter(User.role != UserRole.ADMIN).all()
|
||||
]
|
||||
user_ids = [uid for (uid,) in db.query(User.id).filter(User.role != UserRole.ADMIN).all()]
|
||||
users_count = len(user_ids)
|
||||
|
||||
if user_ids:
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
from src.services.system.announcement import AnnouncementService
|
||||
from src.services.system.audit import AuditService
|
||||
from src.services.system.config import SystemConfigService
|
||||
from src.services.system.maintenance_scheduler import CleanupScheduler # 兼容旧名称
|
||||
from src.services.system.maintenance_scheduler import (
|
||||
MaintenanceScheduler,
|
||||
get_maintenance_scheduler,
|
||||
@@ -20,7 +19,6 @@ __all__ = [
|
||||
"AuditService",
|
||||
"AnnouncementService",
|
||||
"MaintenanceScheduler",
|
||||
"CleanupScheduler", # 兼容旧名称
|
||||
"get_maintenance_scheduler",
|
||||
"SyncStatsService",
|
||||
"TaskScheduler",
|
||||
|
||||
@@ -155,6 +155,26 @@ class SystemConfigService:
|
||||
"value": 1,
|
||||
"description": "用户配额重置周期(天数)",
|
||||
},
|
||||
"enable_standalone_key_quota_reset": {
|
||||
"value": False,
|
||||
"description": "是否启用独立密钥额度自动重置任务(按配置时间触发,按周期执行)",
|
||||
},
|
||||
"standalone_key_quota_reset_time": {
|
||||
"value": "05:00",
|
||||
"description": "独立密钥额度自动重置执行时间(HH:MM 格式,24小时制)",
|
||||
},
|
||||
"standalone_key_quota_reset_interval_days": {
|
||||
"value": 1,
|
||||
"description": "独立密钥额度重置周期(天数)",
|
||||
},
|
||||
"standalone_key_quota_reset_mode": {
|
||||
"value": "all",
|
||||
"description": "独立密钥额度重置模式:all(全部独立密钥) 或 selected(指定密钥)",
|
||||
},
|
||||
"standalone_key_quota_reset_key_ids": {
|
||||
"value": [],
|
||||
"description": "独立密钥额度重置指定的密钥 ID 列表(仅 mode=selected 时生效)",
|
||||
},
|
||||
"provider_priority_mode": {
|
||||
"value": "provider",
|
||||
"description": "优先级策略:provider(提供商优先模式) 或 global_key(全局Key优先模式)",
|
||||
|
||||
@@ -24,7 +24,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.database import create_session
|
||||
from src.models.database import AuditLog, Provider, Usage
|
||||
from src.models.database import ApiKey, AuditLog, Provider, Usage
|
||||
from src.services.provider_ops.service import ProviderOpsService
|
||||
from src.services.system.config import SystemConfigService
|
||||
from src.services.system.scheduler import get_scheduler
|
||||
@@ -40,6 +40,8 @@ class MaintenanceScheduler:
|
||||
CHECKIN_JOB_ID = "provider_checkin"
|
||||
# 用户配额重置任务的 job_id
|
||||
USER_QUOTA_RESET_JOB_ID = "user_quota_reset"
|
||||
# 独立密钥额度重置任务的 job_id
|
||||
STANDALONE_KEY_QUOTA_RESET_JOB_ID = "standalone_key_quota_reset"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.running = False
|
||||
@@ -161,6 +163,33 @@ class MaintenanceScheduler:
|
||||
|
||||
return success
|
||||
|
||||
def _get_standalone_key_quota_reset_time(self) -> tuple[int, int]:
|
||||
"""获取独立密钥额度重置任务的执行时间"""
|
||||
db = create_session()
|
||||
try:
|
||||
time_str = SystemConfigService.get_config(
|
||||
db, "standalone_key_quota_reset_time", "05:00"
|
||||
)
|
||||
return self._parse_user_quota_reset_time_string(time_str)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def update_standalone_key_quota_reset_time(self, time_str: str) -> bool:
|
||||
"""更新独立密钥额度重置任务的执行时间"""
|
||||
hour, minute = self._parse_user_quota_reset_time_string(time_str)
|
||||
|
||||
scheduler = get_scheduler()
|
||||
success = scheduler.reschedule_cron_job(
|
||||
self.STANDALONE_KEY_QUOTA_RESET_JOB_ID,
|
||||
hour=hour,
|
||||
minute=minute,
|
||||
)
|
||||
|
||||
if success:
|
||||
logger.info(f"独立密钥额度重置任务时间已更新为: {hour:02d}:{minute:02d}")
|
||||
|
||||
return success
|
||||
|
||||
def get_checkin_job_info(self) -> dict | None:
|
||||
"""获取签到任务的信息
|
||||
|
||||
@@ -279,6 +308,16 @@ class MaintenanceScheduler:
|
||||
name="用户配额自动重置",
|
||||
)
|
||||
|
||||
# 独立密钥额度重置任务 - 根据配置时间执行(按周期配置决定是否执行)
|
||||
sk_reset_hour, sk_reset_minute = self._get_standalone_key_quota_reset_time()
|
||||
scheduler.add_cron_job(
|
||||
self._scheduled_standalone_key_quota_reset,
|
||||
hour=sk_reset_hour,
|
||||
minute=sk_reset_minute,
|
||||
job_id=self.STANDALONE_KEY_QUOTA_RESET_JOB_ID,
|
||||
name="独立密钥额度自动重置",
|
||||
)
|
||||
|
||||
# 启动时执行一次初始化任务
|
||||
asyncio.create_task(self._run_startup_tasks())
|
||||
|
||||
@@ -371,6 +410,10 @@ class MaintenanceScheduler:
|
||||
"""用户配额重置任务(定时调用)"""
|
||||
await self._perform_user_quota_reset()
|
||||
|
||||
async def _scheduled_standalone_key_quota_reset(self) -> None:
|
||||
"""独立密钥额度重置任务(定时调用)"""
|
||||
await self._perform_standalone_key_quota_reset()
|
||||
|
||||
# ========== 实际任务实现 ==========
|
||||
|
||||
async def _perform_stats_aggregation(self, backfill: bool = False) -> None:
|
||||
@@ -876,6 +919,125 @@ class MaintenanceScheduler:
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def _perform_standalone_key_quota_reset(self) -> None:
|
||||
"""执行独立密钥额度自动重置任务
|
||||
|
||||
适用范围:
|
||||
- is_standalone=True 的密钥
|
||||
- current_balance_usd != NULL(有限额的密钥)
|
||||
- 支持 all(全部)和 selected(指定密钥)两种模式
|
||||
"""
|
||||
db = create_session()
|
||||
try:
|
||||
if not SystemConfigService.get_config(db, "enable_standalone_key_quota_reset", False):
|
||||
logger.info("独立密钥额度自动重置已禁用,跳过任务")
|
||||
return
|
||||
|
||||
# 重置周期
|
||||
interval_value = SystemConfigService.get_config(
|
||||
db, "standalone_key_quota_reset_interval_days", 1
|
||||
)
|
||||
try:
|
||||
interval_days = int(interval_value)
|
||||
except Exception:
|
||||
interval_days = 1
|
||||
if interval_days < 1:
|
||||
interval_days = 1
|
||||
|
||||
# 滚动计算
|
||||
last_reset_at = SystemConfigService.get_config(db, "standalone_key_quota_last_reset_at")
|
||||
|
||||
should_run = True
|
||||
if last_reset_at:
|
||||
last_dt: datetime | None = None
|
||||
try:
|
||||
if isinstance(last_reset_at, str):
|
||||
last_dt = datetime.fromisoformat(last_reset_at)
|
||||
except Exception:
|
||||
last_dt = None
|
||||
|
||||
if last_dt is None:
|
||||
logger.warning("standalone_key_quota_last_reset_at 格式无效,视为需要执行一次")
|
||||
else:
|
||||
if last_dt.tzinfo is None:
|
||||
last_dt = last_dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from src.services.system.scheduler import APP_TIMEZONE
|
||||
|
||||
tz = ZoneInfo(APP_TIMEZONE)
|
||||
now_local = datetime.now(tz)
|
||||
last_local_date = last_dt.astimezone(tz).date()
|
||||
days_since_reset = (now_local.date() - last_local_date).days
|
||||
|
||||
if days_since_reset < 0:
|
||||
logger.warning("standalone_key_quota_last_reset_at 在未来,跳过本次重置")
|
||||
should_run = False
|
||||
elif days_since_reset < interval_days:
|
||||
logger.info(
|
||||
f"独立密钥额度自动重置未到周期,跳过任务"
|
||||
f"({days_since_reset}/{interval_days}天)"
|
||||
)
|
||||
should_run = False
|
||||
|
||||
if not should_run:
|
||||
return
|
||||
|
||||
# 确定重置范围
|
||||
reset_mode = SystemConfigService.get_config(
|
||||
db, "standalone_key_quota_reset_mode", "all"
|
||||
)
|
||||
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
base_filter = [
|
||||
ApiKey.is_standalone.is_(True),
|
||||
ApiKey.current_balance_usd.isnot(None),
|
||||
]
|
||||
|
||||
if reset_mode == "selected":
|
||||
key_ids = SystemConfigService.get_config(
|
||||
db, "standalone_key_quota_reset_key_ids", []
|
||||
)
|
||||
if not key_ids:
|
||||
logger.info("独立密钥额度重置模式为 selected 但未选择任何密钥,跳过")
|
||||
return
|
||||
base_filter.append(ApiKey.id.in_(key_ids))
|
||||
|
||||
reset_count = (
|
||||
db.query(ApiKey)
|
||||
.filter(*base_filter)
|
||||
.update(
|
||||
{
|
||||
ApiKey.balance_used_usd: 0.0,
|
||||
ApiKey.updated_at: now_utc,
|
||||
},
|
||||
synchronize_session=False,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
SystemConfigService.set_config(
|
||||
db,
|
||||
"standalone_key_quota_last_reset_at",
|
||||
now_utc.isoformat(),
|
||||
"独立密钥额度自动重置的上次执行时间(UTC,内部使用)",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"独立密钥额度自动重置完成: mode={reset_mode}, "
|
||||
f"interval_days={interval_days}, 重置密钥数={reset_count}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"独立密钥额度自动重置任务执行失败: {e}")
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
async def _perform_cleanup(self) -> None:
|
||||
"""执行清理任务"""
|
||||
db = create_session()
|
||||
@@ -1276,12 +1438,3 @@ def get_maintenance_scheduler() -> MaintenanceScheduler:
|
||||
if _maintenance_scheduler is None:
|
||||
_maintenance_scheduler = MaintenanceScheduler()
|
||||
return _maintenance_scheduler
|
||||
|
||||
|
||||
# 兼容旧名称(deprecated)
|
||||
def get_cleanup_scheduler() -> MaintenanceScheduler:
|
||||
"""获取维护调度器单例(已废弃,请使用 get_maintenance_scheduler)"""
|
||||
return get_maintenance_scheduler()
|
||||
|
||||
|
||||
CleanupScheduler = MaintenanceScheduler # 兼容旧名称
|
||||
|
||||
Reference in New Issue
Block a user