feat(gateway): 添加流超时处理机制

- 添加 StreamTimeoutSettings 配置结构体和系统设置
- 实现 TimeoutCounterCache Redis 计数器用于累计超时次数
- 在 RateLimitService 添加 HandleStreamTimeout 方法
- 在 gateway_service、openai_gateway_service、antigravity_gateway_service 中调用超时处理
- 添加后端 API 端点 GET/PUT /admin/settings/stream-timeout
- 添加前端配置界面到系统设置页面
- 支持配置:启用开关、超时阈值、处理方式、暂停时长、触发阈值、阈值窗口

默认配置:
- 启用:true
- 超时阈值:60秒
- 处理方式:临时不可调度
- 暂停时长:5分钟
- 触发阈值:3次
- 阈值窗口:10分钟
This commit is contained in:
ianshaw
2026-01-11 21:54:52 -08:00
parent cc4910dd30
commit 2a0758bdfe
19 changed files with 770 additions and 10 deletions

View File

@@ -201,6 +201,42 @@ export async function deleteAdminApiKey(): Promise<{ message: string }> {
return data
}
/**
* Stream timeout settings interface
*/
export interface StreamTimeoutSettings {
enabled: boolean
timeout_seconds: number
action: 'temp_unsched' | 'error' | 'none'
temp_unsched_minutes: number
threshold_count: number
threshold_window_minutes: number
}
/**
* Get stream timeout settings
* @returns Stream timeout settings
*/
export async function getStreamTimeoutSettings(): Promise<StreamTimeoutSettings> {
const { data } = await apiClient.get<StreamTimeoutSettings>('/admin/settings/stream-timeout')
return data
}
/**
* Update stream timeout settings
* @param settings - Stream timeout settings to update
* @returns Updated settings
*/
export async function updateStreamTimeoutSettings(
settings: StreamTimeoutSettings
): Promise<StreamTimeoutSettings> {
const { data } = await apiClient.put<StreamTimeoutSettings>(
'/admin/settings/stream-timeout',
settings
)
return data
}
export const settingsAPI = {
getSettings,
updateSettings,
@@ -208,7 +244,9 @@ export const settingsAPI = {
sendTestEmail,
getAdminApiKey,
regenerateAdminApiKey,
deleteAdminApiKey
deleteAdminApiKey,
getStreamTimeoutSettings,
updateStreamTimeoutSettings
}
export default settingsAPI

View File

@@ -2512,6 +2512,27 @@ export default {
securityWarning: 'Warning: This key provides full admin access. Keep it secure.',
usage: 'Usage: Add to request header - x-api-key: <your-admin-api-key>'
},
streamTimeout: {
title: 'Stream Timeout Handling',
description: 'Configure account handling strategy when upstream response times out',
enabled: 'Enable Stream Timeout Handling',
enabledHint: 'Automatically handle problematic accounts when upstream times out',
timeoutSeconds: 'Timeout Threshold (seconds)',
timeoutSecondsHint: 'Stream data interval exceeding this time is considered timeout (30-300s)',
action: 'Action',
actionTempUnsched: 'Temporarily Unschedulable',
actionError: 'Mark as Error',
actionNone: 'No Action',
actionHint: 'Action to take on the account after timeout',
tempUnschedMinutes: 'Pause Duration (minutes)',
tempUnschedMinutesHint: 'Duration of temporary unschedulable state (1-60 minutes)',
thresholdCount: 'Trigger Threshold (count)',
thresholdCountHint: 'Number of timeouts before triggering action (1-10)',
thresholdWindowMinutes: 'Threshold Window (minutes)',
thresholdWindowMinutesHint: 'Time window for counting timeouts (1-60 minutes)',
saved: 'Stream timeout settings saved',
saveFailed: 'Failed to save stream timeout settings'
},
saveSettings: 'Save Settings',
saving: 'Saving...',
settingsSaved: 'Settings saved successfully',

View File

@@ -2696,6 +2696,27 @@ export default {
securityWarning: '警告:此密钥拥有完整的管理员权限,请妥善保管。',
usage: '使用方法:在请求头中添加 x-api-key: <your-admin-api-key>'
},
streamTimeout: {
title: '流超时处理',
description: '配置上游响应超时时的账户处理策略,避免问题账户持续被选中',
enabled: '启用流超时处理',
enabledHint: '当上游响应超时时,自动处理问题账户',
timeoutSeconds: '超时阈值(秒)',
timeoutSecondsHint: '流数据间隔超过此时间视为超时30-300秒',
action: '处理方式',
actionTempUnsched: '临时不可调度',
actionError: '标记为错误状态',
actionNone: '不处理',
actionHint: '超时后对账户执行的操作',
tempUnschedMinutes: '暂停时长(分钟)',
tempUnschedMinutesHint: '临时不可调度的持续时间1-60分钟',
thresholdCount: '触发阈值(次数)',
thresholdCountHint: '累计超时多少次后触发处理1-10次',
thresholdWindowMinutes: '阈值窗口(分钟)',
thresholdWindowMinutesHint: '超时计数的时间窗口1-60分钟',
saved: '流超时设置保存成功',
saveFailed: '保存流超时设置失败'
},
saveSettings: '保存设置',
saving: '保存中...',
settingsSaved: '设置保存成功',

View File

@@ -147,6 +147,161 @@
</div>
</div>
<!-- Stream Timeout Settings -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('admin.settings.streamTimeout.title') }}
</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.streamTimeout.description') }}
</p>
</div>
<div class="space-y-5 p-6">
<!-- Loading State -->
<div v-if="streamTimeoutLoading" class="flex items-center gap-2 text-gray-500">
<div class="h-4 w-4 animate-spin rounded-full border-b-2 border-primary-600"></div>
{{ t('common.loading') }}
</div>
<template v-else>
<!-- Enable Stream Timeout -->
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t('admin.settings.streamTimeout.enabled')
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.settings.streamTimeout.enabledHint') }}
</p>
</div>
<Toggle v-model="streamTimeoutForm.enabled" />
</div>
<!-- Settings - Only show when enabled -->
<div
v-if="streamTimeoutForm.enabled"
class="space-y-4 border-t border-gray-100 pt-4 dark:border-dark-700"
>
<!-- Timeout Seconds -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.streamTimeout.timeoutSeconds') }}
</label>
<input
v-model.number="streamTimeoutForm.timeout_seconds"
type="number"
min="30"
max="300"
class="input w-32"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.streamTimeout.timeoutSecondsHint') }}
</p>
</div>
<!-- Action -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.streamTimeout.action') }}
</label>
<select v-model="streamTimeoutForm.action" class="input w-64">
<option value="temp_unsched">{{ t('admin.settings.streamTimeout.actionTempUnsched') }}</option>
<option value="error">{{ t('admin.settings.streamTimeout.actionError') }}</option>
<option value="none">{{ t('admin.settings.streamTimeout.actionNone') }}</option>
</select>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.streamTimeout.actionHint') }}
</p>
</div>
<!-- Temp Unsched Minutes (only show when action is temp_unsched) -->
<div v-if="streamTimeoutForm.action === 'temp_unsched'">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.streamTimeout.tempUnschedMinutes') }}
</label>
<input
v-model.number="streamTimeoutForm.temp_unsched_minutes"
type="number"
min="1"
max="60"
class="input w-32"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.streamTimeout.tempUnschedMinutesHint') }}
</p>
</div>
<!-- Threshold Count -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.streamTimeout.thresholdCount') }}
</label>
<input
v-model.number="streamTimeoutForm.threshold_count"
type="number"
min="1"
max="10"
class="input w-32"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.streamTimeout.thresholdCountHint') }}
</p>
</div>
<!-- Threshold Window Minutes -->
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.settings.streamTimeout.thresholdWindowMinutes') }}
</label>
<input
v-model.number="streamTimeoutForm.threshold_window_minutes"
type="number"
min="1"
max="60"
class="input w-32"
/>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.streamTimeout.thresholdWindowMinutesHint') }}
</p>
</div>
</div>
<!-- Save Button -->
<div class="flex justify-end border-t border-gray-100 pt-4 dark:border-dark-700">
<button
type="button"
@click="saveStreamTimeoutSettings"
:disabled="streamTimeoutSaving"
class="btn btn-primary btn-sm"
>
<svg
v-if="streamTimeoutSaving"
class="mr-1 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{ streamTimeoutSaving ? t('common.saving') : t('common.save') }}
</button>
</div>
</template>
</div>
</div>
<!-- Registration Settings -->
<div class="card">
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
@@ -840,6 +995,18 @@ const adminApiKeyMasked = ref('')
const adminApiKeyOperating = ref(false)
const newAdminApiKey = ref('')
// Stream Timeout 状态
const streamTimeoutLoading = ref(true)
const streamTimeoutSaving = ref(false)
const streamTimeoutForm = reactive({
enabled: true,
timeout_seconds: 60,
action: 'temp_unsched' as 'temp_unsched' | 'error' | 'none',
temp_unsched_minutes: 5,
threshold_count: 3,
threshold_window_minutes: 10
})
type SettingsForm = SystemSettings & {
smtp_password: string
turnstile_secret_key: string
@@ -1129,8 +1296,44 @@ function copyNewKey() {
})
}
// Stream Timeout 方法
async function loadStreamTimeoutSettings() {
streamTimeoutLoading.value = true
try {
const settings = await adminAPI.settings.getStreamTimeoutSettings()
Object.assign(streamTimeoutForm, settings)
} catch (error: any) {
console.error('Failed to load stream timeout settings:', error)
} finally {
streamTimeoutLoading.value = false
}
}
async function saveStreamTimeoutSettings() {
streamTimeoutSaving.value = true
try {
const updated = await adminAPI.settings.updateStreamTimeoutSettings({
enabled: streamTimeoutForm.enabled,
timeout_seconds: streamTimeoutForm.timeout_seconds,
action: streamTimeoutForm.action,
temp_unsched_minutes: streamTimeoutForm.temp_unsched_minutes,
threshold_count: streamTimeoutForm.threshold_count,
threshold_window_minutes: streamTimeoutForm.threshold_window_minutes
})
Object.assign(streamTimeoutForm, updated)
appStore.showSuccess(t('admin.settings.streamTimeout.saved'))
} catch (error: any) {
appStore.showError(
t('admin.settings.streamTimeout.saveFailed') + ': ' + (error.message || t('common.unknownError'))
)
} finally {
streamTimeoutSaving.value = false
}
}
onMounted(() => {
loadSettings()
loadAdminApiKey()
loadStreamTimeoutSettings()
})
</script>