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:
fawney19
2026-03-06 01:47:09 +08:00
parent fa71cddb60
commit e9c3ac94c6
9 changed files with 134 additions and 16 deletions

View File

@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aether-proxy"
version = "0.2.1"
version = "0.2.2"
dependencies = [
"anyhow",
"arc-swap",

View File

@@ -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",

View File

@@ -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 = ''
}
}

View File

@@ -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
}

View File

@@ -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]')
}

View File

@@ -1963,6 +1963,7 @@ async function handleRefreshOAuth(key: PoolKeyDetail) {
await loadKeys()
} catch (err) {
showError(parseApiError(err, 'Token 刷新失败'))
await loadKeys()
} finally {
refreshingOAuthKeyId.value = null
}

View File

@@ -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}")

View File

@@ -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":

View File

@@ -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",
]