mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(proxy,oauth,pool): H2 头过滤、OAuth 过期分级标记与批量操作进度条
- proxy: 屏蔽 host/content-length 头转发,避免 H2 PROTOCOL_ERROR - oauth: 区分 [REFRESH_FAILED] 与 [OAUTH_EXPIRED] 标记,token 过期 自动阻止调度但不停用账号,便于管理员恢复 - pool/account_state: 识别新增的 OAUTH_EXPIRED/REFRESH_FAILED 前缀 - 前端: 批量操作显示实时进度条,倍率编辑 Escape/blur 竞态修复, OAuth 刷新失败后自动刷新列表
This commit is contained in:
2
aether-proxy/Cargo.lock
generated
2
aether-proxy/Cargo.lock
generated
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aether-proxy"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
@@ -34,8 +34,18 @@ const MIN_TIMEOUT_SECS: u64 = 5;
|
||||
const MAX_TIMEOUT_SECS: u64 = 300;
|
||||
|
||||
/// Headers that must not be forwarded to upstream (hop-by-hop or security-sensitive).
|
||||
///
|
||||
/// `host` and `content-length` are managed by the HTTP client (reqwest/hyper):
|
||||
/// - `host` → translated to `:authority` pseudo-header in HTTP/2; forwarding
|
||||
/// the original `host` alongside `:authority` triggers PROTOCOL_ERROR on
|
||||
/// strict H2 implementations (e.g. Google APIs).
|
||||
/// - `content-length` → recalculated by hyper from the actual body; a stale
|
||||
/// value from the tunnel (body may have been re-compressed) causes H2
|
||||
/// PROTOCOL_ERROR when it mismatches the real frame length.
|
||||
const BLOCKED_HEADERS: &[&str] = &[
|
||||
"connection",
|
||||
"content-length",
|
||||
"host",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
|
||||
@@ -223,7 +223,22 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="lastResultMessage"
|
||||
v-if="executing && progressTotal > 0"
|
||||
class="space-y-1"
|
||||
>
|
||||
<div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>{{ progressLabel }}</span>
|
||||
<span>{{ progressDone }} / {{ progressTotal }}</span>
|
||||
</div>
|
||||
<div class="h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full bg-primary transition-all duration-150"
|
||||
:style="{ width: `${Math.round((progressDone / progressTotal) * 100)}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="lastResultMessage"
|
||||
class="rounded-md border bg-background px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
{{ lastResultMessage }}
|
||||
@@ -322,6 +337,9 @@ const searchText = ref('')
|
||||
const selectedAction = ref<BatchActionValue>('delete')
|
||||
const proxyNodeIdForAction = ref('')
|
||||
const lastResultMessage = ref('')
|
||||
const progressTotal = ref(0)
|
||||
const progressDone = ref(0)
|
||||
const progressLabel = ref('')
|
||||
const activeQuickSelectors = ref<QuickSelectorValue[]>([])
|
||||
const currentPage = ref(1)
|
||||
const PAGE_SIZE = 50
|
||||
@@ -571,6 +589,12 @@ async function executeAction(): Promise<void> {
|
||||
let failedCount = 0
|
||||
let skippedCount = 0
|
||||
|
||||
const actionLabel = ACTION_OPTIONS.find((a) => a.value === selectedAction.value)?.label || '执行'
|
||||
progressDone.value = 0
|
||||
progressTotal.value = selectedKeys.length
|
||||
progressLabel.value = `正在${actionLabel}...`
|
||||
lastResultMessage.value = ''
|
||||
|
||||
try {
|
||||
if (selectedAction.value === 'refresh_quota') {
|
||||
const targetIds = selectedKeys.map((key) => key.key_id)
|
||||
@@ -578,6 +602,7 @@ async function executeAction(): Promise<void> {
|
||||
successCount = Number(result.success || 0)
|
||||
failedCount = Number(result.failed || 0)
|
||||
skippedCount = Math.max(0, targetIds.length - Number(result.total || 0))
|
||||
progressDone.value = targetIds.length
|
||||
} else {
|
||||
const CONCURRENCY = props.batchConcurrency || 8
|
||||
const taskForKey = (key: PoolKeyDetail): (() => Promise<'success' | 'skip'>) | null => {
|
||||
@@ -609,8 +634,12 @@ async function executeAction(): Promise<void> {
|
||||
for (const key of selectedKeys) {
|
||||
const task = taskForKey(key)
|
||||
if (task) tasks.push(task)
|
||||
else skippedCount += 1
|
||||
else {
|
||||
skippedCount += 1
|
||||
progressDone.value += 1
|
||||
}
|
||||
}
|
||||
progressTotal.value = selectedKeys.length
|
||||
|
||||
// 并发执行,限制并发数
|
||||
let cursor = 0
|
||||
@@ -623,6 +652,7 @@ async function executeAction(): Promise<void> {
|
||||
} catch {
|
||||
failedCount += 1
|
||||
}
|
||||
progressDone.value += 1
|
||||
}
|
||||
}
|
||||
const workers = Array.from({ length: Math.min(CONCURRENCY, tasks.length) }, () => runNext())
|
||||
@@ -647,6 +677,9 @@ async function executeAction(): Promise<void> {
|
||||
showError(parseApiError(err, '批量操作失败'))
|
||||
} finally {
|
||||
executing.value = false
|
||||
progressTotal.value = 0
|
||||
progressDone.value = 0
|
||||
progressLabel.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2059,19 +2059,16 @@ function startEditMultiplier(key: EndpointAPIKey, format: string) {
|
||||
function cancelEditMultiplier() {
|
||||
editingMultiplierKey.value = null
|
||||
editingMultiplierFormat.value = null
|
||||
multiplierSaving.value = false
|
||||
}
|
||||
|
||||
function handleMultiplierKeydown(e: KeyboardEvent, key: EndpointAPIKey, format: string) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!multiplierSaving.value) {
|
||||
multiplierSaving.value = true
|
||||
saveMultiplier(key, format)
|
||||
}
|
||||
saveMultiplier(key, format)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
multiplierSaving.value = true // 阻止 blur 触发保存
|
||||
cancelEditMultiplier()
|
||||
}
|
||||
}
|
||||
@@ -2082,7 +2079,7 @@ function handleMultiplierBlur(key: EndpointAPIKey, format: string) {
|
||||
}
|
||||
|
||||
async function saveMultiplier(key: EndpointAPIKey, format: string) {
|
||||
// 防止重复调用
|
||||
// 防止重复调用(Enter 触发后阻止 blur 再次进入)
|
||||
if (multiplierSaving.value) return
|
||||
multiplierSaving.value = true
|
||||
|
||||
@@ -2093,6 +2090,7 @@ async function saveMultiplier(key: EndpointAPIKey, format: string) {
|
||||
if (!keyId || isNaN(newMultiplier)) {
|
||||
showError('请输入有效的倍率值')
|
||||
cancelEditMultiplier()
|
||||
multiplierSaving.value = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2100,6 +2098,7 @@ async function saveMultiplier(key: EndpointAPIKey, format: string) {
|
||||
if (newMultiplier <= 0 || newMultiplier > 100) {
|
||||
showError('倍率必须在 0.01 到 100 之间')
|
||||
cancelEditMultiplier()
|
||||
multiplierSaving.value = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2107,6 +2106,7 @@ async function saveMultiplier(key: EndpointAPIKey, format: string) {
|
||||
const currentMultiplier = getKeyRateMultiplier(key, format)
|
||||
if (Math.abs(currentMultiplier - newMultiplier) < 0.0001) {
|
||||
cancelEditMultiplier()
|
||||
multiplierSaving.value = false
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -44,11 +44,13 @@ export function isAccountLevelBlockReason(reason: string | null | undefined): bo
|
||||
const text = reason.trim()
|
||||
if (!text) return false
|
||||
if (text.startsWith('[ACCOUNT_BLOCK]')) return true
|
||||
if (text.startsWith('[OAUTH_EXPIRED]')) return true
|
||||
const lowered = text.toLowerCase()
|
||||
return ACCOUNT_BLOCK_REASON_KEYWORDS.some(keyword => lowered.includes(keyword))
|
||||
}
|
||||
|
||||
export function classifyAccountBlockLabel(reason: string): string {
|
||||
if (reason.trim().startsWith('[OAUTH_EXPIRED]')) return 'Token 失效'
|
||||
const lowered = reason.toLowerCase()
|
||||
if (KEYWORDS_VERIFICATION.some(kw => lowered.includes(kw))) return '需要验证'
|
||||
if (KEYWORDS_DISABLED.some(kw => lowered.includes(kw))) return '账号停用'
|
||||
@@ -57,5 +59,15 @@ export function classifyAccountBlockLabel(reason: string): string {
|
||||
}
|
||||
|
||||
export function cleanAccountBlockReason(reason: string): string {
|
||||
return reason.replace(/^\[ACCOUNT_BLOCK\]\s*/i, '').trim()
|
||||
return reason.replace(/^\[(ACCOUNT_BLOCK|OAUTH_EXPIRED)\]\s*/i, '').trim()
|
||||
}
|
||||
|
||||
export function isRefreshFailedReason(reason: string | null | undefined): boolean {
|
||||
if (!reason) return false
|
||||
return reason.trim().startsWith('[REFRESH_FAILED]')
|
||||
}
|
||||
|
||||
export function isOAuthExpiredReason(reason: string | null | undefined): boolean {
|
||||
if (!reason) return false
|
||||
return reason.trim().startsWith('[OAUTH_EXPIRED]')
|
||||
}
|
||||
|
||||
@@ -1963,6 +1963,7 @@ async function handleRefreshOAuth(key: PoolKeyDetail) {
|
||||
await loadKeys()
|
||||
} catch (err) {
|
||||
showError(parseApiError(err, 'Token 刷新失败'))
|
||||
await loadKeys()
|
||||
} finally {
|
||||
refreshingOAuthKeyId.value = null
|
||||
}
|
||||
|
||||
@@ -906,10 +906,9 @@ async def refresh_oauth(
|
||||
except Exception as e:
|
||||
# 标记为失效
|
||||
key.oauth_invalid_at = datetime.now(timezone.utc)
|
||||
key.oauth_invalid_reason = str(e)
|
||||
key.is_active = False
|
||||
key.oauth_invalid_reason = f"[REFRESH_FAILED] Token 续期失败: {e}"
|
||||
db.commit()
|
||||
logger.warning("Kiro Key {} token 刷新失败,已标记为失效并自动停用: {}", key_id, e)
|
||||
logger.warning("Kiro Key {} token 刷新失败,已标记为刷新失效: {}", key_id, e)
|
||||
raise InvalidRequestException("Kiro token refresh 失败,请检查凭据是否有效")
|
||||
|
||||
# 更新 key
|
||||
@@ -1000,11 +999,12 @@ async def refresh_oauth(
|
||||
from datetime import datetime, timezone
|
||||
|
||||
key.oauth_invalid_at = datetime.now(timezone.utc)
|
||||
key.oauth_invalid_reason = error_reason
|
||||
key.is_active = False
|
||||
key.oauth_invalid_reason = (
|
||||
f"[REFRESH_FAILED] Token 续期失败 ({resp.status_code}): {error_reason}"
|
||||
)
|
||||
db.commit()
|
||||
logger.warning(
|
||||
"Key {} OAuth token 刷新失败,已标记为失效并自动停用: {}", key_id, error_reason
|
||||
"Key {} OAuth token 刷新失败,已标记为刷新失效: {}", key_id, error_reason
|
||||
)
|
||||
|
||||
raise InvalidRequestException(f"token refresh 失败: {error_reason}")
|
||||
|
||||
@@ -141,6 +141,42 @@ def _mark_refresh_token_invalid(
|
||||
)
|
||||
|
||||
|
||||
def _mark_oauth_token_expired(key: Any, expires_at: Any) -> None:
|
||||
"""标记 OAuth key 为 Token 已过期且无法续期,阻止后续调度。
|
||||
|
||||
当 refresh token 已失效且 access token 也已过期时调用。
|
||||
使用 [OAUTH_EXPIRED] 前缀,account_state 会将其判定为 blocked。
|
||||
不设置 is_active = False(管理员可通过重新导入凭据恢复)。
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# 如果已经有更严重的标记([ACCOUNT_BLOCK]),不降级
|
||||
existing = str(getattr(key, "oauth_invalid_reason", None) or "")
|
||||
if existing.startswith("[ACCOUNT_BLOCK]"):
|
||||
return
|
||||
|
||||
reason = f"[OAUTH_EXPIRED] Token 已过期且续期失败 (expired_at={expires_at})"
|
||||
|
||||
try:
|
||||
key.oauth_invalid_at = datetime.now(timezone.utc)
|
||||
key.oauth_invalid_reason = reason
|
||||
|
||||
sess = object_session(key)
|
||||
if sess is not None:
|
||||
sess.add(key)
|
||||
sess.commit()
|
||||
logger.info(
|
||||
"[OAUTH_EXPIRED] key {} token expired and refresh failed, blocking scheduling",
|
||||
str(getattr(key, "id", "?"))[:8],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"[OAUTH_EXPIRED] failed to mark key {} as expired: {}",
|
||||
str(getattr(key, "id", "?"))[:8],
|
||||
str(exc),
|
||||
)
|
||||
|
||||
|
||||
def _get_proxy_config(key: Any, endpoint: Any = None) -> Any:
|
||||
"""获取有效代理配置(Key 级别优先于 Provider 级别)。"""
|
||||
try:
|
||||
@@ -414,6 +450,7 @@ async def get_provider_auth(
|
||||
should_refresh = True
|
||||
|
||||
_refreshed = False
|
||||
_lost_lock = False # 其他实例持有刷新锁,不应标记过期
|
||||
if should_refresh and refresh_token and provider_type:
|
||||
try:
|
||||
from src.core.provider_templates.fixed_providers import FIXED_PROVIDERS
|
||||
@@ -437,10 +474,22 @@ async def get_provider_auth(
|
||||
finally:
|
||||
if got_lock:
|
||||
await _release_refresh_lock(redis, key.id)
|
||||
else:
|
||||
_lost_lock = True
|
||||
except Exception:
|
||||
# 刷新失败不阻断请求;后续由上游返回 401 再触发管理端处理
|
||||
pass
|
||||
|
||||
# Refresh 失败(非锁竞争)且 access token 已过期 → 升级标记为 [OAUTH_EXPIRED]
|
||||
# 注意:未获取到锁说明其他实例正在刷新,不应在此标记为过期
|
||||
if should_refresh and not _refreshed and not _lost_lock and expires_at is not None:
|
||||
try:
|
||||
token_truly_expired = int(time.time()) >= int(expires_at)
|
||||
except Exception:
|
||||
token_truly_expired = False
|
||||
if token_truly_expired:
|
||||
_mark_oauth_token_expired(key, expires_at)
|
||||
|
||||
# 获取最终使用的 access_token
|
||||
# Kiro 优先使用 token_meta 中缓存的 access_token(刷新后会更新到 token_meta)
|
||||
if provider_type == "kiro":
|
||||
|
||||
@@ -10,6 +10,8 @@ from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
OAUTH_ACCOUNT_BLOCK_PREFIX = "[ACCOUNT_BLOCK] "
|
||||
OAUTH_REFRESH_FAILED_PREFIX = "[REFRESH_FAILED] "
|
||||
OAUTH_EXPIRED_PREFIX = "[OAUTH_EXPIRED] "
|
||||
|
||||
# -- 按原因细分的关键词组 --
|
||||
# 封禁类 (suspended / banned)
|
||||
@@ -184,6 +186,15 @@ def _resolve_from_oauth_invalid_reason(reason: str | None) -> PoolAccountState |
|
||||
reason=cleaned or "账号异常",
|
||||
)
|
||||
|
||||
if text.startswith(OAUTH_EXPIRED_PREFIX):
|
||||
cleaned = text[len(OAUTH_EXPIRED_PREFIX) :].strip()
|
||||
return PoolAccountState(
|
||||
blocked=True,
|
||||
code="oauth_expired",
|
||||
label="Token 失效",
|
||||
reason=cleaned or "OAuth Token 已过期且无法续期",
|
||||
)
|
||||
|
||||
lowered = text.lower()
|
||||
if any(keyword in lowered for keyword in ACCOUNT_BLOCK_REASON_KEYWORDS):
|
||||
code, label = _classify_block_reason(text)
|
||||
@@ -219,6 +230,8 @@ def resolve_pool_account_state(
|
||||
__all__ = [
|
||||
"ACCOUNT_BLOCK_REASON_KEYWORDS",
|
||||
"OAUTH_ACCOUNT_BLOCK_PREFIX",
|
||||
"OAUTH_EXPIRED_PREFIX",
|
||||
"OAUTH_REFRESH_FAILED_PREFIX",
|
||||
"PoolAccountState",
|
||||
"resolve_pool_account_state",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user