feat(api-keys): 支持独立密钥额度重置(手动+定时自动)

- 新增手动重置接口 PATCH /api/admin/api-keys/{id}/reset-usage
- 前端 ApiKeys 页面增加重置按钮,支持确认后归零已使用额度
- 新增独立密钥额度定时自动重置任务,支持配置周期和执行时间
- 支持 all/selected 两种重置模式,selected 模式可指定密钥
- 移除已废弃的 CleanupScheduler 兼容别名
This commit is contained in:
fawney19
2026-02-26 02:16:48 +08:00
parent 1a1bb0a99c
commit 0ecf8b703e
11 changed files with 646 additions and 17 deletions

View File

@@ -518,6 +518,14 @@ export const adminApi = {
return response.data 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密钥详情可选包含完整密钥 // 获取API密钥详情可选包含完整密钥
async getApiKeyDetail(keyId: string, includeKey: boolean = false): Promise<AdminApiKey & { key?: string }> { async getApiKeyDetail(keyId: string, includeKey: boolean = false): Promise<AdminApiKey & { key?: string }> {
const response = await apiClient.get<AdminApiKey & { key?: string }>( const response = await apiClient.get<AdminApiKey & { key?: string }>(

View File

@@ -279,6 +279,15 @@
> >
<DollarSign class="h-4 w-4" /> <DollarSign class="h-4 w-4" />
</Button> </Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
title="重置额度"
@click="resetKeyUsage(apiKey)"
>
<RotateCcw class="h-4 w-4" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -480,6 +489,15 @@
<DollarSign class="h-3.5 w-3.5 mr-1.5" /> <DollarSign class="h-3.5 w-3.5 mr-1.5" />
调整 调整
</Button> </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 <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -727,7 +745,8 @@ import {
SquarePen, SquarePen,
Search, Search,
Lock, Lock,
LockOpen LockOpen,
RotateCcw
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { StandaloneKeyFormDialog, type StandaloneKeyFormData } from '@/features/api-keys' 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() { function selectKey() {
keyInput.value?.select() keyInput.value?.select()
} }

View File

@@ -114,7 +114,14 @@
id="section-scheduled" id="section-scheduled"
:scheduled-tasks="scheduledTasks" :scheduled-tasks="scheduledTasks"
:quota-reset-interval-days="systemConfig.user_quota_reset_interval_days" :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: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 { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { PageHeader, PageContainer } from '@/components/layout' import { PageHeader, PageContainer } from '@/components/layout'
import { useProxyNodesStore } from '@/stores/proxy-nodes' import { useProxyNodesStore } from '@/stores/proxy-nodes'
import { adminApi } from '@/api/admin'
// Composables // Composables
import { useSystemConfig } from './system-settings/composables/useSystemConfig' import { useSystemConfig } from './system-settings/composables/useSystemConfig'
@@ -336,13 +344,50 @@ const {
const { const {
scheduledTasks, scheduledTasks,
initPreviousValues, initPreviousValues,
saveStandaloneKeyResetMode,
saveStandaloneKeyResetKeyIds,
} = useScheduledTasks(systemConfig) } = 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 () => { onMounted(async () => {
await Promise.all([ await Promise.all([
loadSystemConfig(), loadSystemConfig(),
loadSystemVersion(), loadSystemVersion(),
proxyNodesStore.ensureLoaded(), proxyNodesStore.ensureLoaded(),
loadStandaloneKeys(),
]) ])
// 配置加载完成后初始化定时任务的原始值 // 配置加载完成后初始化定时任务的原始值
initPreviousValues() initPreviousValues()

View File

@@ -145,6 +145,96 @@
滚动计算:距离上次成功执行满 N 天后再次执行 滚动计算:距离上次成功执行满 N 天后再次执行
</p> </p>
</div> </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> </div>
</template> </template>
</div> </div>
@@ -156,6 +246,7 @@ import { Clock, Check, Loader2, X } from 'lucide-vue-next'
import Button from '@/components/ui/button.vue' import Button from '@/components/ui/button.vue'
import Input from '@/components/ui/input.vue' import Input from '@/components/ui/input.vue'
import Switch from '@/components/ui/switch.vue' import Switch from '@/components/ui/switch.vue'
import Checkbox from '@/components/ui/checkbox.vue'
import Select from '@/components/ui/select.vue' import Select from '@/components/ui/select.vue'
import SelectTrigger from '@/components/ui/select-trigger.vue' import SelectTrigger from '@/components/ui/select-trigger.vue'
import SelectValue from '@/components/ui/select-value.vue' import SelectValue from '@/components/ui/select-value.vue'
@@ -181,12 +272,26 @@ interface ScheduledTask {
onCancel: () => void onCancel: () => void
} }
interface StandaloneKeyOption {
id: string
name?: string
key_display?: string
current_balance_usd?: number | null
}
defineProps<{ defineProps<{
scheduledTasks: ScheduledTask[] scheduledTasks: ScheduledTask[]
quotaResetIntervalDays: number quotaResetIntervalDays: number
standaloneKeyResetIntervalDays: number
standaloneKeyResetMode: string
standaloneKeyResetKeyIds: string[]
standaloneKeys: StandaloneKeyOption[]
}>() }>()
defineEmits<{ defineEmits<{
'update:quotaResetIntervalDays': [value: number] 'update:quotaResetIntervalDays': [value: number]
'update:standaloneKeyResetIntervalDays': [value: number]
'update:standaloneKeyResetMode': [value: string]
'toggleStandaloneKeyResetKeyId': [keyId: string]
}>() }>()
</script> </script>

View File

@@ -1,5 +1,5 @@
import { ref, computed, type Ref } from 'vue' 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 { useToast } from '@/composables/useToast'
import { adminApi } from '@/api/admin' import { adminApi } from '@/api/admin'
import { log } from '@/utils/logger' import { log } from '@/utils/logger'
@@ -10,18 +10,24 @@ export function useScheduledTasks(systemConfig: Ref<SystemConfig>) {
const checkinConfigLoading = ref(false) const checkinConfigLoading = ref(false)
const quotaResetConfigLoading = ref(false) const quotaResetConfigLoading = ref(false)
const standaloneKeyResetConfigLoading = ref(false)
// 签到时间的原始值(用于回滚) // 签到时间的原始值(用于回滚)
const previousCheckinTime = ref('') const previousCheckinTime = ref('')
// 用户配额重置时间的原始值 // 用户配额重置时间的原始值
const previousUserQuotaResetTime = ref('') const previousUserQuotaResetTime = ref('')
const previousUserQuotaResetIntervalDays = ref(1) const previousUserQuotaResetIntervalDays = ref(1)
// 独立密钥额度重置的原始值
const previousStandaloneKeyResetTime = ref('')
const previousStandaloneKeyResetIntervalDays = ref(1)
// 初始化原始值(在配置加载完成后调用) // 初始化原始值(在配置加载完成后调用)
function initPreviousValues() { function initPreviousValues() {
previousCheckinTime.value = systemConfig.value.provider_checkin_time previousCheckinTime.value = systemConfig.value.provider_checkin_time
previousUserQuotaResetTime.value = systemConfig.value.user_quota_reset_time previousUserQuotaResetTime.value = systemConfig.value.user_quota_reset_time
previousUserQuotaResetIntervalDays.value = systemConfig.value.user_quota_reset_interval_days 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 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 // Toggle handlers
async function handleProviderCheckinToggle(enabled: boolean) { async function handleProviderCheckinToggle(enabled: boolean) {
const previousValue = systemConfig.value.enable_provider_checkin 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 // Cancel handlers
function handleCheckinTimeCancel() { function handleCheckinTimeCancel() {
systemConfig.value.provider_checkin_time = previousCheckinTime.value 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 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 // Save handlers
async function handleCheckinTimeSave() { async function handleCheckinTimeSave() {
const newTime = systemConfig.value.provider_checkin_time 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(() => [ const scheduledTasks = computed(() => [
{ {
@@ -282,12 +439,31 @@ export function useScheduledTasks(systemConfig: Ref<SystemConfig>) {
onSave: () => {}, onSave: () => {},
onCancel: () => {}, 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 { return {
checkinConfigLoading, checkinConfigLoading,
quotaResetConfigLoading, quotaResetConfigLoading,
standaloneKeyResetConfigLoading,
scheduledTasks, scheduledTasks,
initPreviousValues, initPreviousValues,
saveStandaloneKeyResetMode,
saveStandaloneKeyResetKeyIds,
} }
} }

View File

@@ -38,6 +38,12 @@ export interface SystemConfig {
user_quota_reset_time: string user_quota_reset_time: string
user_quota_reset_interval_days: number user_quota_reset_interval_days: number
enable_oauth_token_refresh: boolean 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 = [ const CONFIG_KEYS = [
@@ -74,6 +80,12 @@ const CONFIG_KEYS = [
'user_quota_reset_time', 'user_quota_reset_time',
'user_quota_reset_interval_days', 'user_quota_reset_interval_days',
'enable_oauth_token_refresh', '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 { function createDefaultConfig(): SystemConfig {
@@ -111,6 +123,12 @@ function createDefaultConfig(): SystemConfig {
user_quota_reset_time: '05:00', user_quota_reset_time: '05:00',
user_quota_reset_interval_days: 1, user_quota_reset_interval_days: 1,
enable_oauth_token_refresh: true, 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: [],
} }
} }

View File

@@ -292,6 +292,26 @@ async def add_balance_to_key(
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode) 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}") @router.get("/{key_id}")
async def get_api_key_detail( async def get_api_key_detail(
key_id: str, 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): class AdminGetFullKeyAdapter(AdminApiAdapter):
"""获取完整的API密钥""" """获取完整的API密钥"""

View File

@@ -684,6 +684,16 @@ class AdminSetSystemConfigAdapter(AdminApiAdapter):
except Exception as e: except Exception as e:
logger.warning(f"更新用户配额重置任务时间失败: {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 单例 # 如果更新的是调度模式或优先级模式,立即更新当前 Worker 的 Scheduler 单例
if self.key in ("scheduling_mode", "provider_priority_mode"): if self.key in ("scheduling_mode", "provider_priority_mode"):
try: try:
@@ -2444,9 +2454,7 @@ class AdminPurgeUsersAdapter(AdminApiAdapter):
db = context.db db = context.db
user_ids = [ user_ids = [uid for (uid,) in db.query(User.id).filter(User.role != UserRole.ADMIN).all()]
uid for (uid,) in db.query(User.id).filter(User.role != UserRole.ADMIN).all()
]
users_count = len(user_ids) users_count = len(user_ids)
if user_ids: if user_ids:

View File

@@ -7,7 +7,6 @@
from src.services.system.announcement import AnnouncementService from src.services.system.announcement import AnnouncementService
from src.services.system.audit import AuditService from src.services.system.audit import AuditService
from src.services.system.config import SystemConfigService from src.services.system.config import SystemConfigService
from src.services.system.maintenance_scheduler import CleanupScheduler # 兼容旧名称
from src.services.system.maintenance_scheduler import ( from src.services.system.maintenance_scheduler import (
MaintenanceScheduler, MaintenanceScheduler,
get_maintenance_scheduler, get_maintenance_scheduler,
@@ -20,7 +19,6 @@ __all__ = [
"AuditService", "AuditService",
"AnnouncementService", "AnnouncementService",
"MaintenanceScheduler", "MaintenanceScheduler",
"CleanupScheduler", # 兼容旧名称
"get_maintenance_scheduler", "get_maintenance_scheduler",
"SyncStatsService", "SyncStatsService",
"TaskScheduler", "TaskScheduler",

View File

@@ -155,6 +155,26 @@ class SystemConfigService:
"value": 1, "value": 1,
"description": "用户配额重置周期(天数)", "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": { "provider_priority_mode": {
"value": "provider", "value": "provider",
"description": "优先级策略provider(提供商优先模式) 或 global_key(全局Key优先模式)", "description": "优先级策略provider(提供商优先模式) 或 global_key(全局Key优先模式)",

View File

@@ -24,7 +24,7 @@ from sqlalchemy.orm import Session
from src.core.logger import logger from src.core.logger import logger
from src.database import create_session 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.provider_ops.service import ProviderOpsService
from src.services.system.config import SystemConfigService from src.services.system.config import SystemConfigService
from src.services.system.scheduler import get_scheduler from src.services.system.scheduler import get_scheduler
@@ -40,6 +40,8 @@ class MaintenanceScheduler:
CHECKIN_JOB_ID = "provider_checkin" CHECKIN_JOB_ID = "provider_checkin"
# 用户配额重置任务的 job_id # 用户配额重置任务的 job_id
USER_QUOTA_RESET_JOB_ID = "user_quota_reset" USER_QUOTA_RESET_JOB_ID = "user_quota_reset"
# 独立密钥额度重置任务的 job_id
STANDALONE_KEY_QUOTA_RESET_JOB_ID = "standalone_key_quota_reset"
def __init__(self) -> None: def __init__(self) -> None:
self.running = False self.running = False
@@ -161,6 +163,33 @@ class MaintenanceScheduler:
return success 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: def get_checkin_job_info(self) -> dict | None:
"""获取签到任务的信息 """获取签到任务的信息
@@ -279,6 +308,16 @@ class MaintenanceScheduler:
name="用户配额自动重置", 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()) asyncio.create_task(self._run_startup_tasks())
@@ -371,6 +410,10 @@ class MaintenanceScheduler:
"""用户配额重置任务(定时调用)""" """用户配额重置任务(定时调用)"""
await self._perform_user_quota_reset() 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: async def _perform_stats_aggregation(self, backfill: bool = False) -> None:
@@ -876,6 +919,125 @@ class MaintenanceScheduler:
finally: finally:
db.close() 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: async def _perform_cleanup(self) -> None:
"""执行清理任务""" """执行清理任务"""
db = create_session() db = create_session()
@@ -1276,12 +1438,3 @@ def get_maintenance_scheduler() -> MaintenanceScheduler:
if _maintenance_scheduler is None: if _maintenance_scheduler is None:
_maintenance_scheduler = MaintenanceScheduler() _maintenance_scheduler = MaintenanceScheduler()
return _maintenance_scheduler return _maintenance_scheduler
# 兼容旧名称deprecated
def get_cleanup_scheduler() -> MaintenanceScheduler:
"""获取维护调度器单例(已废弃,请使用 get_maintenance_scheduler"""
return get_maintenance_scheduler()
CleanupScheduler = MaintenanceScheduler # 兼容旧名称