mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat: OAuth Token 自动刷新调度、OAuth 对话框优化及 OpenAI CLI normalizer 重构
- 系统设置新增 OAuth Token 自动刷新任务开关,支持动态调度 - OAuth 授权对话框简化步骤布局,移除编号圆圈样式 - 重构 OpenAI CLI normalizer:将 if-else 链替换为事件处理器映射表, 将 stream_event_from_internal 拆分为独立方法 - 维护调度器在禁用刷新时移除已调度的 job - .gitignore 新增 CLIProxyAPI/ 和 sub2api/ 排除项 - 补充 noop/unknown 事件处理器的测试覆盖
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,6 +1,9 @@
|
|||||||
# Created by https://www.toptal.com/developers/gitignore/api/python
|
# Created by https://www.toptal.com/developers/gitignore/api/python
|
||||||
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
||||||
|
|
||||||
|
CLIProxyAPI/
|
||||||
|
sub2api/
|
||||||
|
|
||||||
# AI Assistant Configuration
|
# AI Assistant Configuration
|
||||||
.claude/
|
.claude/
|
||||||
.serena/
|
.serena/
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
size="md"
|
size="md"
|
||||||
@update:model-value="handleDialogUpdate"
|
@update:model-value="handleDialogUpdate"
|
||||||
>
|
>
|
||||||
<div class="space-y-6">
|
<div class="space-y-5">
|
||||||
<!-- 加载中 -->
|
<!-- 加载中 -->
|
||||||
<div
|
<div
|
||||||
v-if="oauth.starting && !oauth.authorization_url"
|
v-if="oauth.starting && !oauth.authorization_url"
|
||||||
@@ -21,22 +21,14 @@
|
|||||||
<!-- 授权流程 -->
|
<!-- 授权流程 -->
|
||||||
<template v-else-if="oauth.authorization_url">
|
<template v-else-if="oauth.authorization_url">
|
||||||
<!-- 步骤 1: 打开授权链接 -->
|
<!-- 步骤 1: 打开授权链接 -->
|
||||||
<div class="space-y-3">
|
<div class="space-y-2">
|
||||||
<div class="flex items-center gap-2">
|
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
<div class="w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-medium shrink-0">
|
第一步 · 前往授权
|
||||||
1
|
</p>
|
||||||
</div>
|
<p class="text-xs text-muted-foreground">
|
||||||
<span class="text-sm font-medium">打开授权链接</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-muted-foreground pl-7">
|
|
||||||
点击下方按钮在浏览器中完成登录授权
|
点击下方按钮在浏览器中完成登录授权
|
||||||
</p>
|
</p>
|
||||||
<div class="ml-7 p-2.5 rounded-md bg-muted/50 border border-border/50">
|
<div class="flex gap-2 pt-1">
|
||||||
<p class="text-xs font-mono text-muted-foreground break-all line-clamp-3 leading-relaxed">
|
|
||||||
{{ oauth.authorization_url }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 pl-7">
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
:disabled="oauthBusy"
|
:disabled="oauthBusy"
|
||||||
@@ -57,28 +49,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
<!-- 步骤 2: 粘贴回调地址 -->
|
<!-- 步骤 2: 粘贴回调地址 -->
|
||||||
<div class="space-y-3">
|
<div class="space-y-2">
|
||||||
<div class="flex items-center gap-2">
|
<p class="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
<div class="w-5 h-5 rounded-full bg-muted text-muted-foreground flex items-center justify-center text-xs font-medium shrink-0">
|
第二步 · 粘贴回调
|
||||||
2
|
</p>
|
||||||
</div>
|
<p class="text-xs text-muted-foreground">
|
||||||
<span class="text-sm font-medium">粘贴回调地址</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-muted-foreground pl-7">
|
|
||||||
授权完成后,复制浏览器地址栏的完整 URL 并粘贴到下方
|
授权完成后,复制浏览器地址栏的完整 URL 并粘贴到下方
|
||||||
</p>
|
</p>
|
||||||
<div class="pl-7">
|
<div class="pt-1">
|
||||||
<Textarea
|
<Textarea
|
||||||
v-model="oauth.callback_url"
|
v-model="oauth.callback_url"
|
||||||
:disabled="oauthBusy"
|
:disabled="oauthBusy"
|
||||||
placeholder="http://localhost:xxx/callback?code=..."
|
placeholder="http://localhost:xxx/callback?code=..."
|
||||||
class="min-h-[80px] text-xs font-mono resize-none"
|
class="min-h-[80px] text-xs font-mono break-all !rounded-xl"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -93,7 +83,7 @@
|
|||||||
:disabled="!canCompleteOAuth"
|
:disabled="!canCompleteOAuth"
|
||||||
@click="handleCompleteOAuth"
|
@click="handleCompleteOAuth"
|
||||||
>
|
>
|
||||||
{{ oauth.completing ? '验证中...' : '完成授权' }}
|
{{ oauth.completing ? '验证中...' : '验证' }}
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -101,7 +91,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { Dialog, Button, Textarea } from '@/components/ui'
|
import { Dialog, Button, Textarea, Separator } from '@/components/ui'
|
||||||
import { UserPlus, Copy, ExternalLink } from 'lucide-vue-next'
|
import { UserPlus, Copy, ExternalLink } from 'lucide-vue-next'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
|
|||||||
@@ -534,7 +534,7 @@
|
|||||||
|
|
||||||
<!-- 右侧:时间选择器 + 保存按钮 -->
|
<!-- 右侧:时间选择器 + 保存按钮 -->
|
||||||
<div
|
<div
|
||||||
v-if="task.enabled"
|
v-if="task.enabled && task.hasTimeConfig"
|
||||||
class="flex items-center gap-2 shrink-0"
|
class="flex items-center gap-2 shrink-0"
|
||||||
>
|
>
|
||||||
<Clock class="w-4 h-4 text-muted-foreground" />
|
<Clock class="w-4 h-4 text-muted-foreground" />
|
||||||
@@ -995,7 +995,7 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { Download, Upload, CalendarCheck, RotateCcw, Clock, Check, Loader2 } from 'lucide-vue-next'
|
import { Download, Upload, CalendarCheck, RotateCcw, RefreshCw, Clock, Check, Loader2 } 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 Label from '@/components/ui/label.vue'
|
import Label from '@/components/ui/label.vue'
|
||||||
@@ -1044,6 +1044,7 @@ interface SystemConfig {
|
|||||||
enable_user_quota_reset: boolean
|
enable_user_quota_reset: boolean
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
const basicConfigLoading = ref(false)
|
const basicConfigLoading = ref(false)
|
||||||
@@ -1104,6 +1105,7 @@ const systemConfig = ref<SystemConfig>({
|
|||||||
enable_user_quota_reset: false,
|
enable_user_quota_reset: false,
|
||||||
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,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 原始配置值(用于检测变动)
|
// 原始配置值(用于检测变动)
|
||||||
@@ -1215,6 +1217,7 @@ async function loadSystemConfig() {
|
|||||||
'enable_user_quota_reset',
|
'enable_user_quota_reset',
|
||||||
'user_quota_reset_time',
|
'user_quota_reset_time',
|
||||||
'user_quota_reset_interval_days',
|
'user_quota_reset_interval_days',
|
||||||
|
'enable_oauth_token_refresh',
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const key of configs) {
|
for (const key of configs) {
|
||||||
@@ -1423,6 +1426,28 @@ async function handleUserQuotaResetToggle(enabled: boolean) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleOAuthTokenRefreshToggle(enabled: boolean) {
|
||||||
|
const previousValue = systemConfig.value.enable_oauth_token_refresh
|
||||||
|
systemConfig.value.enable_oauth_token_refresh = enabled
|
||||||
|
try {
|
||||||
|
await adminApi.updateSystemConfig(
|
||||||
|
'enable_oauth_token_refresh',
|
||||||
|
enabled,
|
||||||
|
'是否启用 OAuth Token 自动刷新任务'
|
||||||
|
)
|
||||||
|
success(enabled ? '已启用 OAuth Token 自动刷新' : '已禁用 OAuth Token 自动刷新')
|
||||||
|
} catch (err) {
|
||||||
|
error('保存配置失败')
|
||||||
|
log.error('保存 OAuth Token 自动刷新配置失败:', err)
|
||||||
|
// 回滚状态
|
||||||
|
systemConfig.value.enable_oauth_token_refresh = previousValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth Token 刷新(无时间配置,占位 ref)
|
||||||
|
const oauthRefreshHourSelectOpen = ref(false)
|
||||||
|
const oauthRefreshMinuteSelectOpen = ref(false)
|
||||||
|
|
||||||
// 用户配额重置时间相关
|
// 用户配额重置时间相关
|
||||||
const previousUserQuotaResetTime = ref('')
|
const previousUserQuotaResetTime = ref('')
|
||||||
const userQuotaResetHourSelectOpen = ref(false)
|
const userQuotaResetHourSelectOpen = ref(false)
|
||||||
@@ -1465,6 +1490,7 @@ const scheduledTasks = computed(() => [
|
|||||||
title: 'Provider 自动签到',
|
title: 'Provider 自动签到',
|
||||||
description: '自动执行已配置 Provider 的签到任务',
|
description: '自动执行已配置 Provider 的签到任务',
|
||||||
enabled: systemConfig.value.enable_provider_checkin,
|
enabled: systemConfig.value.enable_provider_checkin,
|
||||||
|
hasTimeConfig: true,
|
||||||
hour: checkinHour.value,
|
hour: checkinHour.value,
|
||||||
minute: checkinMinute.value,
|
minute: checkinMinute.value,
|
||||||
hourSelectOpen: checkinHourSelectOpen,
|
hourSelectOpen: checkinHourSelectOpen,
|
||||||
@@ -1481,6 +1507,7 @@ const scheduledTasks = computed(() => [
|
|||||||
title: '用户配额自动重置',
|
title: '用户配额自动重置',
|
||||||
description: '定时将用户已使用配额重置为零',
|
description: '定时将用户已使用配额重置为零',
|
||||||
enabled: systemConfig.value.enable_user_quota_reset,
|
enabled: systemConfig.value.enable_user_quota_reset,
|
||||||
|
hasTimeConfig: true,
|
||||||
hour: userQuotaResetHour.value,
|
hour: userQuotaResetHour.value,
|
||||||
minute: userQuotaResetMinute.value,
|
minute: userQuotaResetMinute.value,
|
||||||
hourSelectOpen: userQuotaResetHourSelectOpen,
|
hourSelectOpen: userQuotaResetHourSelectOpen,
|
||||||
@@ -1491,6 +1518,23 @@ const scheduledTasks = computed(() => [
|
|||||||
onToggle: handleUserQuotaResetToggle,
|
onToggle: handleUserQuotaResetToggle,
|
||||||
onSave: handleQuotaResetConfigSave,
|
onSave: handleQuotaResetConfigSave,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'oauth-token-refresh',
|
||||||
|
icon: RefreshCw,
|
||||||
|
title: 'OAuth Token 自动刷新',
|
||||||
|
description: '主动刷新即将过期的 OAuth Token(动态调度)',
|
||||||
|
enabled: systemConfig.value.enable_oauth_token_refresh,
|
||||||
|
hasTimeConfig: false,
|
||||||
|
hour: '',
|
||||||
|
minute: '',
|
||||||
|
hourSelectOpen: oauthRefreshHourSelectOpen,
|
||||||
|
minuteSelectOpen: oauthRefreshMinuteSelectOpen,
|
||||||
|
updateTime: () => {},
|
||||||
|
hasChanges: false,
|
||||||
|
loading: false,
|
||||||
|
onToggle: handleOAuthTokenRefreshToggle,
|
||||||
|
onSave: () => {},
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
// Provider 签到时间保存
|
// Provider 签到时间保存
|
||||||
|
|||||||
@@ -634,6 +634,15 @@ class AdminSetSystemConfigAdapter(AdminApiAdapter):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"更新用户配额重置任务时间失败: {e}")
|
logger.warning(f"更新用户配额重置任务时间失败: {e}")
|
||||||
|
|
||||||
|
# 如果更新的是 OAuth Token 自动刷新开关,触发调度器重新计算
|
||||||
|
if self.key == "enable_oauth_token_refresh":
|
||||||
|
try:
|
||||||
|
from src.services.system.maintenance_scheduler import get_maintenance_scheduler
|
||||||
|
|
||||||
|
get_maintenance_scheduler().trigger_oauth_refresh_check()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("触发 OAuth Token 刷新调度失败: {}", e)
|
||||||
|
|
||||||
# 返回时不暴露加密后的值
|
# 返回时不暴露加密后的值
|
||||||
display_value = "********" if self.key in self.ENCRYPTED_KEYS else config.value
|
display_value = "********" if self.key in self.ENCRYPTED_KEYS else config.value
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -396,6 +396,11 @@ class MaintenanceScheduler:
|
|||||||
# 检查配置开关
|
# 检查配置开关
|
||||||
if not SystemConfigService.get_config(db, "enable_oauth_token_refresh", True):
|
if not SystemConfigService.get_config(db, "enable_oauth_token_refresh", True):
|
||||||
logger.info("OAuth Token 自动刷新已禁用,不调度任务")
|
logger.info("OAuth Token 自动刷新已禁用,不调度任务")
|
||||||
|
# 移除已调度的 job(如果存在)
|
||||||
|
try:
|
||||||
|
scheduler.remove_job(job_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return
|
return
|
||||||
# 查找所有活跃的 OAuth 类型 Key
|
# 查找所有活跃的 OAuth 类型 Key
|
||||||
oauth_keys = (
|
oauth_keys = (
|
||||||
@@ -966,9 +971,7 @@ class MaintenanceScheduler:
|
|||||||
days_since_reset = (now_local.date() - last_local_date).days
|
days_since_reset = (now_local.date() - last_local_date).days
|
||||||
|
|
||||||
if days_since_reset < 0:
|
if days_since_reset < 0:
|
||||||
logger.warning(
|
logger.warning("user_quota_last_reset_at 在未来,跳过本次用户配额自动重置")
|
||||||
"user_quota_last_reset_at 在未来,跳过本次用户配额自动重置"
|
|
||||||
)
|
|
||||||
should_run = False
|
should_run = False
|
||||||
elif days_since_reset < interval_days:
|
elif days_since_reset < interval_days:
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -1006,7 +1009,9 @@ class MaintenanceScheduler:
|
|||||||
"用户配额自动重置的上次执行时间(UTC,内部使用)",
|
"用户配额自动重置的上次执行时间(UTC,内部使用)",
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"用户配额自动重置完成: interval_days={interval_days}, 重置用户数={reset_count}")
|
logger.info(
|
||||||
|
f"用户配额自动重置完成: interval_days={interval_days}, 重置用户数={reset_count}"
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception(f"用户配额自动重置任务执行失败: {e}")
|
logger.exception(f"用户配额自动重置任务执行失败: {e}")
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from src.core.api_format.conversion.normalizers.gemini_cli import GeminiCliNorma
|
|||||||
from src.core.api_format.conversion.normalizers.openai import OpenAINormalizer
|
from src.core.api_format.conversion.normalizers.openai import OpenAINormalizer
|
||||||
from src.core.api_format.conversion.normalizers.openai_cli import OpenAICliNormalizer
|
from src.core.api_format.conversion.normalizers.openai_cli import OpenAICliNormalizer
|
||||||
from src.core.api_format.conversion.registry import FormatConversionRegistry
|
from src.core.api_format.conversion.registry import FormatConversionRegistry
|
||||||
|
from src.core.api_format.conversion.stream_events import UnknownStreamEvent
|
||||||
from src.core.api_format.conversion.stream_state import StreamState
|
from src.core.api_format.conversion.stream_state import StreamState
|
||||||
|
|
||||||
|
|
||||||
@@ -309,6 +310,36 @@ def test_stream_openai_cli_in_progress_event() -> None:
|
|||||||
assert events2 == []
|
assert events2 == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_openai_cli_noop_and_unknown_events() -> None:
|
||||||
|
"""覆盖 OpenAI CLI noop/unknown 事件处理器"""
|
||||||
|
normalizer = OpenAICliNormalizer()
|
||||||
|
state = StreamState()
|
||||||
|
|
||||||
|
# 先触发 message_start 初始化
|
||||||
|
normalizer.stream_chunk_to_internal(
|
||||||
|
{"type": "response.created", "response": {"id": "resp_789", "model": "gpt-5"}}, state
|
||||||
|
)
|
||||||
|
|
||||||
|
# noop 事件不应产生内部事件
|
||||||
|
assert (
|
||||||
|
normalizer.stream_chunk_to_internal({"type": "response.function_call_arguments.done"}, state)
|
||||||
|
== []
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
normalizer.stream_chunk_to_internal({"type": "response.content_part.added"}, state) == []
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
normalizer.stream_chunk_to_internal({"type": "response.content_part.done"}, state) == []
|
||||||
|
)
|
||||||
|
|
||||||
|
# unknown 事件应返回 UnknownStreamEvent
|
||||||
|
events = normalizer.stream_chunk_to_internal(
|
||||||
|
{"type": "response.reasoning_summary_text.delta"}, state
|
||||||
|
)
|
||||||
|
assert len(events) == 1
|
||||||
|
assert isinstance(events[0], UnknownStreamEvent)
|
||||||
|
|
||||||
|
|
||||||
def test_stream_openai_cli_function_call_events() -> None:
|
def test_stream_openai_cli_function_call_events() -> None:
|
||||||
"""测试 OpenAI CLI 流式 function_call 相关事件"""
|
"""测试 OpenAI CLI 流式 function_call 相关事件"""
|
||||||
reg = _make_registry_with_cli()
|
reg = _make_registry_with_cli()
|
||||||
|
|||||||
Reference in New Issue
Block a user