mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
feat(pool,provider_ops): 扩展批量操作支持代理设置,优化签到缓存与余额刷新
- pool batch-action 新增 clear_proxy/set_proxy 操作,前端统一使用 batch-action API 替代逐个调用,批量上限从 100 提升至 2000 - batch-action 删除操作后执行 key 删除副作用(run_delete_key_side_effects) - BalanceAction 签到增加 6 小时缓存冷却,同一 host 避免重复签到 - 异步余额刷新增加 per-provider 防重入保护
This commit is contained in:
@@ -178,6 +178,9 @@ export interface PoolBatchAction {
|
|||||||
| 'clear_cooldown'
|
| 'clear_cooldown'
|
||||||
| 'reset_cost'
|
| 'reset_cost'
|
||||||
| 'regenerate_fingerprint'
|
| 'regenerate_fingerprint'
|
||||||
|
| 'clear_proxy'
|
||||||
|
| 'set_proxy'
|
||||||
|
payload?: Record<string, unknown> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPoolOverview(): Promise<PoolOverviewResponse> {
|
export async function getPoolOverview(): Promise<PoolOverviewResponse> {
|
||||||
|
|||||||
@@ -266,8 +266,8 @@ import { RefreshCw, Play, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight
|
|||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { parseApiError } from '@/utils/errorParser'
|
import { parseApiError } from '@/utils/errorParser'
|
||||||
import { listPoolKeys, type PoolKeyDetail } from '@/api/endpoints/pool'
|
import { listPoolKeys, batchActionPoolKeys, type PoolKeyDetail } from '@/api/endpoints/pool'
|
||||||
import { batchDeleteEndpointKeys, refreshProviderQuota, updateProviderKey } from '@/api/endpoints/keys'
|
import { refreshProviderQuota } from '@/api/endpoints/keys'
|
||||||
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
|
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
|
||||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||||
import { hasNoFiveHourLimit as hasNoFiveHourLimitByQuota, hasNoWeeklyLimit as hasNoWeeklyLimitByQuota } from '@/features/pool/utils/quota-selectors'
|
import { hasNoFiveHourLimit as hasNoFiveHourLimitByQuota, hasNoWeeklyLimit as hasNoWeeklyLimitByQuota } from '@/features/pool/utils/quota-selectors'
|
||||||
@@ -619,10 +619,10 @@ async function executeAction(): Promise<void> {
|
|||||||
|
|
||||||
progressDone.value = Math.min(i + BATCH_SIZE, targetIds.length)
|
progressDone.value = Math.min(i + BATCH_SIZE, targetIds.length)
|
||||||
}
|
}
|
||||||
} else if (selectedAction.value === 'delete') {
|
} else if (['delete', 'enable', 'disable', 'clear_proxy', 'set_proxy'].includes(selectedAction.value)) {
|
||||||
// 使用批量删除 API,按 100 个一批分批调用(后端限制 max_length=100)
|
// 使用 batch-action API,每批最多 2000 个
|
||||||
const targetIds = selectedKeys.map((key) => key.key_id)
|
const targetIds = selectedKeys.map((key) => key.key_id)
|
||||||
const BATCH_SIZE = 100
|
const BATCH_SIZE = 2000
|
||||||
const totalBatches = Math.ceil(targetIds.length / BATCH_SIZE)
|
const totalBatches = Math.ceil(targetIds.length / BATCH_SIZE)
|
||||||
|
|
||||||
for (let i = 0; i < targetIds.length; i += BATCH_SIZE) {
|
for (let i = 0; i < targetIds.length; i += BATCH_SIZE) {
|
||||||
@@ -632,10 +632,17 @@ async function executeAction(): Promise<void> {
|
|||||||
progressLabel.value = `正在${actionLabel}...(第 ${batchIndex}/${totalBatches} 批)`
|
progressLabel.value = `正在${actionLabel}...(第 ${batchIndex}/${totalBatches} 批)`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const payload = selectedAction.value === 'set_proxy'
|
||||||
|
? { node_id: proxyNodeIdForAction.value, enabled: true }
|
||||||
|
: undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await batchDeleteEndpointKeys(batch)
|
const result = await batchActionPoolKeys(props.providerId, {
|
||||||
successCount += result.success_count
|
key_ids: batch,
|
||||||
failedCount += result.failed_count
|
action: selectedAction.value,
|
||||||
|
...(payload ? { payload } : {}),
|
||||||
|
})
|
||||||
|
successCount += result.affected
|
||||||
} catch {
|
} catch {
|
||||||
failedCount += batch.length
|
failedCount += batch.length
|
||||||
}
|
}
|
||||||
@@ -643,41 +650,19 @@ async function executeAction(): Promise<void> {
|
|||||||
progressDone.value = Math.min(i + BATCH_SIZE, targetIds.length)
|
progressDone.value = Math.min(i + BATCH_SIZE, targetIds.length)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// refresh_oauth: 逐个并发调用(涉及外部 OAuth 令牌刷新)
|
||||||
const CONCURRENCY = props.batchConcurrency || 8
|
const CONCURRENCY = props.batchConcurrency || 8
|
||||||
const taskForKey = (key: PoolKeyDetail): (() => Promise<'success' | 'skip'>) | null => {
|
|
||||||
if (selectedAction.value === 'refresh_oauth') {
|
|
||||||
if (normalizeText(key.auth_type) !== 'oauth') return null
|
|
||||||
return () => refreshProviderOAuth(key.key_id).then(() => 'success' as const)
|
|
||||||
}
|
|
||||||
if (selectedAction.value === 'clear_proxy') {
|
|
||||||
return () => updateProviderKey(key.key_id, { proxy: null }).then(() => 'success' as const)
|
|
||||||
}
|
|
||||||
if (selectedAction.value === 'set_proxy') {
|
|
||||||
return () => updateProviderKey(key.key_id, {
|
|
||||||
proxy: { node_id: proxyNodeIdForAction.value, enabled: true },
|
|
||||||
}).then(() => 'success' as const)
|
|
||||||
}
|
|
||||||
if (selectedAction.value === 'enable') {
|
|
||||||
return () => updateProviderKey(key.key_id, { is_active: true }).then(() => 'success' as const)
|
|
||||||
}
|
|
||||||
if (selectedAction.value === 'disable') {
|
|
||||||
return () => updateProviderKey(key.key_id, { is_active: false }).then(() => 'success' as const)
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const tasks: Array<() => Promise<'success' | 'skip'>> = []
|
const tasks: Array<() => Promise<'success' | 'skip'>> = []
|
||||||
for (const key of selectedKeys) {
|
for (const key of selectedKeys) {
|
||||||
const task = taskForKey(key)
|
if (selectedAction.value === 'refresh_oauth' && normalizeText(key.auth_type) !== 'oauth') {
|
||||||
if (task) tasks.push(task)
|
|
||||||
else {
|
|
||||||
skippedCount += 1
|
skippedCount += 1
|
||||||
progressDone.value += 1
|
progressDone.value += 1
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
tasks.push(() => refreshProviderOAuth(key.key_id).then(() => 'success' as const))
|
||||||
}
|
}
|
||||||
progressTotal.value = selectedKeys.length
|
progressTotal.value = selectedKeys.length
|
||||||
|
|
||||||
// 并发执行,限制并发数
|
|
||||||
let cursor = 0
|
let cursor = 0
|
||||||
const runNext = async (): Promise<void> => {
|
const runNext = async (): Promise<void> => {
|
||||||
while (cursor < tasks.length) {
|
while (cursor < tasks.length) {
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ ALLOWED_ACTIONS = {
|
|||||||
"clear_cooldown",
|
"clear_cooldown",
|
||||||
"reset_cost",
|
"reset_cost",
|
||||||
"regenerate_fingerprint",
|
"regenerate_fingerprint",
|
||||||
|
"clear_proxy",
|
||||||
|
"set_proxy",
|
||||||
}
|
}
|
||||||
|
|
||||||
_COOLDOWN_REASON_LABELS: dict[str, str] = {
|
_COOLDOWN_REASON_LABELS: dict[str, str] = {
|
||||||
@@ -988,6 +990,13 @@ class AdminBatchActionKeysAdapter(AdminApiAdapter):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self.body.action == "set_proxy":
|
||||||
|
if not isinstance(self.body.payload, dict) or not self.body.payload:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="set_proxy action requires a non-empty payload with proxy config",
|
||||||
|
)
|
||||||
|
|
||||||
db = context.db
|
db = context.db
|
||||||
provider = db.query(Provider).filter(Provider.id == self.provider_id).first()
|
provider = db.query(Provider).filter(Provider.id == self.provider_id).first()
|
||||||
if not provider:
|
if not provider:
|
||||||
@@ -1028,11 +1037,26 @@ class AdminBatchActionKeysAdapter(AdminApiAdapter):
|
|||||||
await pool_redis.clear_cost(pid, kid)
|
await pool_redis.clear_cost(pid, kid)
|
||||||
affected += 1
|
affected += 1
|
||||||
|
|
||||||
|
elif self.body.action == "clear_proxy":
|
||||||
|
key.proxy = None
|
||||||
|
affected += 1
|
||||||
|
|
||||||
|
elif self.body.action == "set_proxy":
|
||||||
|
key.proxy = self.body.payload
|
||||||
|
affected += 1
|
||||||
|
|
||||||
elif self.body.action == "regenerate_fingerprint":
|
elif self.body.action == "regenerate_fingerprint":
|
||||||
key.fingerprint = generate_fingerprint(seed=None)
|
key.fingerprint = generate_fingerprint(seed=None)
|
||||||
affected += 1
|
affected += 1
|
||||||
|
|
||||||
if self.body.action in {"enable", "disable", "delete", "regenerate_fingerprint"}:
|
if self.body.action in {
|
||||||
|
"enable",
|
||||||
|
"disable",
|
||||||
|
"delete",
|
||||||
|
"regenerate_fingerprint",
|
||||||
|
"clear_proxy",
|
||||||
|
"set_proxy",
|
||||||
|
}:
|
||||||
try:
|
try:
|
||||||
db.commit()
|
db.commit()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -1040,6 +1064,18 @@ class AdminBatchActionKeysAdapter(AdminApiAdapter):
|
|||||||
logger.error("batch action commit failed: {}", exc)
|
logger.error("batch action commit failed: {}", exc)
|
||||||
return BatchActionResponse(affected=0, message=f"commit failed: {exc}")
|
return BatchActionResponse(affected=0, message=f"commit failed: {exc}")
|
||||||
|
|
||||||
|
if self.body.action == "delete" and affected > 0:
|
||||||
|
from src.services.provider_keys.key_side_effects import run_delete_key_side_effects
|
||||||
|
|
||||||
|
try:
|
||||||
|
await run_delete_key_side_effects(
|
||||||
|
db=db,
|
||||||
|
provider_id=pid,
|
||||||
|
deleted_key_allowed_models=None,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("batch delete side effects failed: {}", exc)
|
||||||
|
|
||||||
action_labels = {
|
action_labels = {
|
||||||
"enable": "enabled",
|
"enable": "enabled",
|
||||||
"disable": "disabled",
|
"disable": "disabled",
|
||||||
@@ -1047,6 +1083,8 @@ class AdminBatchActionKeysAdapter(AdminApiAdapter):
|
|||||||
"clear_cooldown": "cooldown cleared",
|
"clear_cooldown": "cooldown cleared",
|
||||||
"reset_cost": "cost reset",
|
"reset_cost": "cost reset",
|
||||||
"regenerate_fingerprint": "fingerprint regenerated",
|
"regenerate_fingerprint": "fingerprint regenerated",
|
||||||
|
"clear_proxy": "proxy cleared",
|
||||||
|
"set_proxy": "proxy set",
|
||||||
}
|
}
|
||||||
|
|
||||||
admin_name = context.user.username if context.user else "admin"
|
admin_name = context.user.username if context.user else "admin"
|
||||||
|
|||||||
@@ -164,8 +164,9 @@ class BatchImportResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class BatchActionRequest(BaseModel):
|
class BatchActionRequest(BaseModel):
|
||||||
key_ids: list[str] = Field(..., max_length=500)
|
key_ids: list[str] = Field(..., max_length=2000)
|
||||||
action: str # enable / disable / delete / clear_cooldown / reset_cost / regenerate_fingerprint
|
action: str # enable / disable / delete / clear_cooldown / reset_cost / regenerate_fingerprint / clear_proxy / set_proxy
|
||||||
|
payload: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
class BatchActionResponse(BaseModel):
|
class BatchActionResponse(BaseModel):
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ from typing import Any
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from src.core.cache_service import CacheService
|
||||||
|
from src.core.logger import logger as _logger
|
||||||
from src.services.provider_ops.actions.base import ProviderAction
|
from src.services.provider_ops.actions.base import ProviderAction
|
||||||
from src.services.provider_ops.types import (
|
from src.services.provider_ops.types import (
|
||||||
ActionResult,
|
ActionResult,
|
||||||
@@ -16,6 +18,9 @@ from src.services.provider_ops.types import (
|
|||||||
ProviderActionType,
|
ProviderActionType,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 签到缓存 TTL(6 小时)
|
||||||
|
_CHECKIN_CACHE_TTL = 6 * 3600
|
||||||
|
|
||||||
|
|
||||||
class BalanceAction(ProviderAction):
|
class BalanceAction(ProviderAction):
|
||||||
"""
|
"""
|
||||||
@@ -40,7 +45,7 @@ class BalanceAction(ProviderAction):
|
|||||||
"""
|
"""
|
||||||
执行余额查询(模板方法)
|
执行余额查询(模板方法)
|
||||||
|
|
||||||
1. 先尝试签到(如果子类实现了 _do_checkin)
|
1. 先尝试签到(如果子类实现了 _do_checkin),带缓存冷却
|
||||||
2. 执行余额查询
|
2. 执行余额查询
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -49,10 +54,8 @@ class BalanceAction(ProviderAction):
|
|||||||
Returns:
|
Returns:
|
||||||
ActionResult,其中 data 字段为 BalanceInfo
|
ActionResult,其中 data 字段为 BalanceInfo
|
||||||
"""
|
"""
|
||||||
from src.core.logger import logger
|
# 先尝试签到(带缓存冷却)
|
||||||
|
checkin_result = await self._get_or_do_checkin(client)
|
||||||
# 先尝试签到
|
|
||||||
checkin_result = await self._do_checkin(client)
|
|
||||||
|
|
||||||
# 执行余额查询
|
# 执行余额查询
|
||||||
result = await self._do_query_balance(client)
|
result = await self._do_query_balance(client)
|
||||||
@@ -66,11 +69,11 @@ class BalanceAction(ProviderAction):
|
|||||||
result.data.extra["cookie_expired"] = True
|
result.data.extra["cookie_expired"] = True
|
||||||
result.data.extra["cookie_expired_message"] = checkin_result.get("message", "")
|
result.data.extra["cookie_expired_message"] = checkin_result.get("message", "")
|
||||||
result.status = ActionStatus.AUTH_EXPIRED
|
result.status = ActionStatus.AUTH_EXPIRED
|
||||||
logger.warning(f"Cookie 已失效: {checkin_result}")
|
_logger.warning("Cookie 已失效: {}", checkin_result)
|
||||||
else:
|
else:
|
||||||
result.data.extra["checkin_success"] = checkin_result.get("success")
|
result.data.extra["checkin_success"] = checkin_result.get("success")
|
||||||
result.data.extra["checkin_message"] = checkin_result.get("message", "")
|
result.data.extra["checkin_message"] = checkin_result.get("message", "")
|
||||||
logger.debug(f"签到结果已附加到 extra: {checkin_result}")
|
_logger.debug("签到结果已附加到 extra: {}", checkin_result)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -191,6 +194,39 @@ class BalanceAction(ProviderAction):
|
|||||||
raw_response=raw_data,
|
raw_response=raw_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def _get_or_do_checkin(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
|
||||||
|
"""
|
||||||
|
带缓存冷却的签到:优先返回缓存结果,未命中才实际执行签到。
|
||||||
|
|
||||||
|
缓存 key 使用 host(同一站点多个 provider 只需签到一次),TTL 6 小时。
|
||||||
|
签到失败或 cookie_expired 不写入缓存,允许下次重试。
|
||||||
|
"""
|
||||||
|
host = client.base_url.host or client.base_url.netloc or str(client.base_url)
|
||||||
|
cache_key = f"provider_ops:checkin:{host}"
|
||||||
|
|
||||||
|
# 检查缓存
|
||||||
|
try:
|
||||||
|
cached = await CacheService.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
_logger.debug("[{}] 签到缓存命中,跳过签到: {}", host, cached)
|
||||||
|
return cached
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 缓存未命中,执行签到
|
||||||
|
result = await self._do_checkin(client)
|
||||||
|
|
||||||
|
# 签到成功或"已签到"时写入缓存;失败/cookie_expired 不缓存
|
||||||
|
if result is not None and not result.get("cookie_expired"):
|
||||||
|
success = result.get("success")
|
||||||
|
if success is True or success is None:
|
||||||
|
try:
|
||||||
|
await CacheService.set(cache_key, result, _CHECKIN_CACHE_TTL)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
async def _do_checkin(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
|
async def _do_checkin(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
|
||||||
"""
|
"""
|
||||||
执行签到(子类可选实现)
|
执行签到(子类可选实现)
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ AUTH_FAILED_CACHE_TTL = 60
|
|||||||
# 使用较小的值(3)确保不会对连接池造成过大压力
|
# 使用较小的值(3)确保不会对连接池造成过大压力
|
||||||
_balance_refresh_semaphore: asyncio.Semaphore | None = None
|
_balance_refresh_semaphore: asyncio.Semaphore | None = None
|
||||||
|
|
||||||
|
# 正在异步刷新余额的 provider 集合(per-provider 防重入)
|
||||||
|
_refreshing_providers: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
def _get_balance_refresh_semaphore() -> asyncio.Semaphore:
|
def _get_balance_refresh_semaphore() -> asyncio.Semaphore:
|
||||||
"""获取余额刷新信号量(延迟初始化)"""
|
"""获取余额刷新信号量(延迟初始化)"""
|
||||||
@@ -511,7 +514,14 @@ class ProviderOpsService:
|
|||||||
避免长时间占用连接池资源。
|
避免长时间占用连接池资源。
|
||||||
|
|
||||||
使用信号量限制并发数,避免启动时多个刷新任务同时运行导致连接池耗尽。
|
使用信号量限制并发数,避免启动时多个刷新任务同时运行导致连接池耗尽。
|
||||||
|
使用 _refreshing_providers 集合防止同一 provider 被并发刷新。
|
||||||
"""
|
"""
|
||||||
|
# per-provider 防重入
|
||||||
|
if provider_id in _refreshing_providers:
|
||||||
|
logger.debug("异步刷新余额跳过(已在刷新中): provider_id={}", provider_id)
|
||||||
|
return
|
||||||
|
_refreshing_providers.add(provider_id)
|
||||||
|
|
||||||
semaphore = _get_balance_refresh_semaphore()
|
semaphore = _get_balance_refresh_semaphore()
|
||||||
|
|
||||||
# 尝试获取信号量,如果无法立即获取则跳过本次刷新
|
# 尝试获取信号量,如果无法立即获取则跳过本次刷新
|
||||||
@@ -521,6 +531,7 @@ class ProviderOpsService:
|
|||||||
await asyncio.wait_for(semaphore.acquire(), timeout=5.0)
|
await asyncio.wait_for(semaphore.acquire(), timeout=5.0)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.debug("异步刷新余额跳过(并发限制): provider_id={}", provider_id)
|
logger.debug("异步刷新余额跳过(并发限制): provider_id={}", provider_id)
|
||||||
|
_refreshing_providers.discard(provider_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
db = None
|
db = None
|
||||||
@@ -538,8 +549,9 @@ class ProviderOpsService:
|
|||||||
db.close()
|
db.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# 释放信号量
|
# 释放信号量并移除防重入标记
|
||||||
semaphore.release()
|
semaphore.release()
|
||||||
|
_refreshing_providers.discard(provider_id)
|
||||||
|
|
||||||
async def _clear_balance_cache(self, provider_id: str) -> None:
|
async def _clear_balance_cache(self, provider_id: str) -> None:
|
||||||
"""清除余额缓存"""
|
"""清除余额缓存"""
|
||||||
|
|||||||
Reference in New Issue
Block a user