2026-01-17 19:50:35 +08:00
|
|
|
|
"""
|
|
|
|
|
|
Provider 操作服务
|
|
|
|
|
|
|
|
|
|
|
|
提供操作执行、凭据管理、缓存等业务逻辑。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
2026-01-30 14:30:57 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-01-17 19:50:35 +08:00
|
|
|
|
import asyncio
|
2026-01-28 01:01:05 +08:00
|
|
|
|
import os
|
2026-01-17 19:50:35 +08:00
|
|
|
|
from dataclasses import asdict
|
|
|
|
|
|
from datetime import datetime, timezone
|
2026-01-30 03:10:21 +08:00
|
|
|
|
from typing import Any
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
|
|
|
|
|
from sqlalchemy.orm import Session
|
2026-02-15 16:32:23 +08:00
|
|
|
|
from sqlalchemy.orm.attributes import flag_modified
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
2026-01-28 01:01:05 +08:00
|
|
|
|
from src.config import config
|
2026-01-17 19:50:35 +08:00
|
|
|
|
from src.core.cache_service import CacheService
|
|
|
|
|
|
from src.core.crypto import CryptoService
|
|
|
|
|
|
from src.core.logger import logger
|
|
|
|
|
|
from src.database import create_session
|
|
|
|
|
|
from src.models.database import Provider
|
2026-01-30 03:10:21 +08:00
|
|
|
|
from src.services.provider_ops.architectures import ProviderConnector
|
2026-01-17 19:50:35 +08:00
|
|
|
|
from src.services.provider_ops.registry import get_registry
|
|
|
|
|
|
from src.services.provider_ops.types import (
|
2026-03-06 21:06:45 +08:00
|
|
|
|
SENSITIVE_CREDENTIAL_FIELDS,
|
2026-01-17 19:50:35 +08:00
|
|
|
|
ActionResult,
|
|
|
|
|
|
ActionStatus,
|
|
|
|
|
|
BalanceInfo,
|
|
|
|
|
|
ConnectorAuthType,
|
|
|
|
|
|
ConnectorState,
|
|
|
|
|
|
ConnectorStatus,
|
|
|
|
|
|
ProviderActionType,
|
|
|
|
|
|
ProviderOpsConfig,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 余额缓存 TTL(24 小时)
|
|
|
|
|
|
BALANCE_CACHE_TTL = 86400
|
2026-01-28 23:07:58 +08:00
|
|
|
|
# 认证失败缓存 TTL(60 秒,避免频繁重试但允许用户修正后快速重试)
|
|
|
|
|
|
AUTH_FAILED_CACHE_TTL = 60
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
# 后台余额刷新并发限制(避免启动时耗尽连接池)
|
|
|
|
|
|
# 使用较小的值(3)确保不会对连接池造成过大压力
|
|
|
|
|
|
_balance_refresh_semaphore: asyncio.Semaphore | None = None
|
|
|
|
|
|
|
2026-03-08 02:29:51 +08:00
|
|
|
|
# 正在异步刷新余额的 provider 集合(per-provider 防重入)
|
|
|
|
|
|
_refreshing_providers: set[str] = set()
|
|
|
|
|
|
|
2026-03-31 19:19:04 +08:00
|
|
|
|
PROVIDER_OPS_CONNECT_RUST_ONLY_MESSAGE = "Provider 连接仅支持 Rust executor"
|
|
|
|
|
|
PROVIDER_OPS_ACTION_RUST_ONLY_MESSAGE = "Provider 操作仅支持 Rust executor"
|
|
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
|
|
|
|
|
|
def _get_balance_refresh_semaphore() -> asyncio.Semaphore:
|
|
|
|
|
|
"""获取余额刷新信号量(延迟初始化)"""
|
|
|
|
|
|
global _balance_refresh_semaphore
|
|
|
|
|
|
if _balance_refresh_semaphore is None:
|
|
|
|
|
|
# 限制为 3 个并发,确保后台任务不会占用太多连接
|
|
|
|
|
|
_balance_refresh_semaphore = asyncio.Semaphore(3)
|
|
|
|
|
|
return _balance_refresh_semaphore
|
|
|
|
|
|
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
2026-01-28 01:01:05 +08:00
|
|
|
|
def _get_batch_balance_concurrency() -> int:
|
|
|
|
|
|
"""
|
|
|
|
|
|
动态计算批量余额查询的并发限制
|
|
|
|
|
|
|
|
|
|
|
|
计算逻辑:
|
|
|
|
|
|
1. 优先使用环境变量 BATCH_BALANCE_CONCURRENCY
|
|
|
|
|
|
2. 否则根据连接池大小自动计算(取 40% 的连接池容量)
|
|
|
|
|
|
3. 限制在 [3, 15] 范围内
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
并发限制数
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 优先使用环境变量
|
|
|
|
|
|
env_value = os.getenv("BATCH_BALANCE_CONCURRENCY")
|
|
|
|
|
|
if env_value:
|
|
|
|
|
|
try:
|
|
|
|
|
|
return max(1, int(env_value))
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
# 根据连接池大小自动计算
|
|
|
|
|
|
# 连接池容量 = pool_size + max_overflow
|
|
|
|
|
|
pool_capacity = config.db_pool_size + config.db_max_overflow
|
|
|
|
|
|
|
|
|
|
|
|
# 取 40% 的连接池容量,保留 60% 给其他请求
|
|
|
|
|
|
# 最小 3(保证基本并发),最大 15(避免过度并发)
|
|
|
|
|
|
calculated = int(pool_capacity * 0.4)
|
|
|
|
|
|
return max(3, min(calculated, 15))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-01-17 19:50:35 +08:00
|
|
|
|
class ProviderOpsService:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Provider 操作服务
|
|
|
|
|
|
|
|
|
|
|
|
提供:
|
|
|
|
|
|
- 凭据管理(加密存储、读取)
|
|
|
|
|
|
- 连接管理(建立、断开、状态检查)
|
|
|
|
|
|
- 操作执行(余额查询、签到等)
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
# 凭据中需要加密的字段
|
2026-03-06 21:06:45 +08:00
|
|
|
|
SENSITIVE_FIELDS = SENSITIVE_CREDENTIAL_FIELDS
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
|
|
|
|
|
def __init__(self, db: Session):
|
|
|
|
|
|
self.db = db
|
|
|
|
|
|
self.crypto = CryptoService()
|
|
|
|
|
|
|
|
|
|
|
|
# 连接器缓存 {provider_id: ProviderConnector}
|
2026-01-30 03:10:21 +08:00
|
|
|
|
self._connectors: dict[str, ProviderConnector] = {}
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
def _release_db_connection_before_await(self) -> None:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Release pooled DB connection before long awaits (network/Redis).
|
|
|
|
|
|
|
|
|
|
|
|
SQLAlchemy Session will keep a connection checked out while a transaction is open,
|
|
|
|
|
|
even for read-only queries. In async code, this can exhaust the pool if we `await`
|
|
|
|
|
|
network I/O while holding that transaction.
|
|
|
|
|
|
|
|
|
|
|
|
Safety:
|
|
|
|
|
|
- Only commits when the session has no pending changes (new/dirty/deleted).
|
|
|
|
|
|
- Temporarily disables expire_on_commit to avoid unexpected lazy reloads.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
has_pending_changes = bool(self.db.new) or bool(self.db.dirty) or bool(self.db.deleted)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
has_pending_changes = False
|
|
|
|
|
|
|
|
|
|
|
|
if has_pending_changes:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
if not self.db.in_transaction():
|
|
|
|
|
|
return
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
original_expire_on_commit = getattr(self.db, "expire_on_commit", True)
|
|
|
|
|
|
self.db.expire_on_commit = False
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.db.commit()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.db.rollback()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
finally:
|
|
|
|
|
|
self.db.expire_on_commit = original_expire_on_commit
|
|
|
|
|
|
|
2026-03-31 19:19:04 +08:00
|
|
|
|
def _provider_ops_rust_only_connect_message(self) -> str:
|
|
|
|
|
|
return PROVIDER_OPS_CONNECT_RUST_ONLY_MESSAGE
|
|
|
|
|
|
|
|
|
|
|
|
def _provider_ops_rust_only_action_result(
|
|
|
|
|
|
self,
|
|
|
|
|
|
action_type: ProviderActionType,
|
|
|
|
|
|
) -> ActionResult:
|
|
|
|
|
|
return ActionResult(
|
|
|
|
|
|
status=ActionStatus.NOT_SUPPORTED,
|
|
|
|
|
|
action_type=action_type,
|
|
|
|
|
|
message=PROVIDER_OPS_ACTION_RUST_ONLY_MESSAGE,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-21 12:57:09 +08:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _build_ops_proxy_snapshot(
|
|
|
|
|
|
proxy: Any,
|
|
|
|
|
|
tunnel_node_id: str | None,
|
|
|
|
|
|
) -> Any:
|
2026-04-03 14:59:58 +08:00
|
|
|
|
from src.services.request.execution_runtime_plan import ExecutionProxySnapshot
|
2026-03-21 12:57:09 +08:00
|
|
|
|
|
|
|
|
|
|
if tunnel_node_id:
|
|
|
|
|
|
return ExecutionProxySnapshot(
|
|
|
|
|
|
enabled=True,
|
|
|
|
|
|
mode="tunnel",
|
|
|
|
|
|
node_id=str(tunnel_node_id).strip() or None,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if proxy is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
proxy_url = getattr(proxy, "url", None)
|
|
|
|
|
|
if proxy_url is not None:
|
|
|
|
|
|
proxy_url = str(proxy_url).strip()
|
|
|
|
|
|
else:
|
|
|
|
|
|
proxy_url = str(proxy).strip()
|
|
|
|
|
|
if not proxy_url:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
mode = proxy_url.split("://", 1)[0].strip().lower() or None
|
|
|
|
|
|
return ExecutionProxySnapshot(
|
|
|
|
|
|
enabled=True,
|
|
|
|
|
|
mode=mode,
|
|
|
|
|
|
url=proxy_url,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
async def _try_rust_verify_response(
|
|
|
|
|
|
self,
|
|
|
|
|
|
*,
|
|
|
|
|
|
architecture_id: str,
|
|
|
|
|
|
verify_endpoint: str,
|
|
|
|
|
|
headers: dict[str, str],
|
|
|
|
|
|
proxy: Any,
|
|
|
|
|
|
tunnel_node_id: str | None,
|
|
|
|
|
|
) -> Any:
|
|
|
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
2026-04-03 14:59:58 +08:00
|
|
|
|
from src.services.request.execution_runtime_plan import (
|
2026-03-21 12:57:09 +08:00
|
|
|
|
ExecutionPlan,
|
|
|
|
|
|
ExecutionPlanBody,
|
|
|
|
|
|
ExecutionPlanTimeouts,
|
|
|
|
|
|
)
|
2026-04-03 14:59:58 +08:00
|
|
|
|
from src.services.request.execution_runtime_client import (
|
|
|
|
|
|
ExecutionRuntimeClient,
|
|
|
|
|
|
ExecutionRuntimeClientError,
|
2026-03-21 12:57:09 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-04-03 14:59:58 +08:00
|
|
|
|
if config.execution_runtime_backend != "rust":
|
2026-03-21 12:57:09 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2026-04-03 14:59:58 +08:00
|
|
|
|
result = await ExecutionRuntimeClient().execute_sync_json(
|
2026-03-21 12:57:09 +08:00
|
|
|
|
ExecutionPlan(
|
|
|
|
|
|
request_id=f"provider-ops-verify:{architecture_id}",
|
|
|
|
|
|
candidate_id=None,
|
|
|
|
|
|
provider_name=architecture_id,
|
|
|
|
|
|
provider_id="",
|
|
|
|
|
|
endpoint_id="",
|
|
|
|
|
|
key_id="",
|
|
|
|
|
|
method="GET",
|
|
|
|
|
|
url=verify_endpoint,
|
|
|
|
|
|
headers=dict(headers),
|
|
|
|
|
|
body=ExecutionPlanBody(),
|
|
|
|
|
|
stream=False,
|
|
|
|
|
|
provider_api_format="provider_ops:verify",
|
|
|
|
|
|
client_api_format="provider_ops:verify",
|
|
|
|
|
|
model_name="verify-auth",
|
|
|
|
|
|
proxy=self._build_ops_proxy_snapshot(proxy, tunnel_node_id),
|
|
|
|
|
|
timeouts=ExecutionPlanTimeouts(
|
|
|
|
|
|
connect_ms=30_000,
|
|
|
|
|
|
read_ms=30_000,
|
|
|
|
|
|
write_ms=30_000,
|
|
|
|
|
|
pool_ms=30_000,
|
|
|
|
|
|
total_ms=30_000,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
except (
|
2026-04-03 14:59:58 +08:00
|
|
|
|
ExecutionRuntimeClientError,
|
2026-03-21 12:57:09 +08:00
|
|
|
|
httpx.HTTPError,
|
|
|
|
|
|
json.JSONDecodeError,
|
|
|
|
|
|
ValueError,
|
|
|
|
|
|
AttributeError,
|
|
|
|
|
|
TypeError,
|
|
|
|
|
|
) as exc:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"Provider ops Rust verify fallback architecture={} endpoint={}: {}",
|
|
|
|
|
|
architecture_id,
|
|
|
|
|
|
verify_endpoint,
|
|
|
|
|
|
exc,
|
|
|
|
|
|
)
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
response_headers = dict(result.headers)
|
|
|
|
|
|
if result.response_json is not None:
|
|
|
|
|
|
response_headers.setdefault("content-type", "application/json")
|
|
|
|
|
|
response_body = json.dumps(result.response_json, ensure_ascii=False).encode("utf-8")
|
|
|
|
|
|
elif result.response_body_bytes is not None:
|
|
|
|
|
|
response_body = result.response_body_bytes
|
|
|
|
|
|
else:
|
|
|
|
|
|
response_body = b""
|
|
|
|
|
|
|
|
|
|
|
|
return httpx.Response(
|
|
|
|
|
|
status_code=result.status_code,
|
|
|
|
|
|
request=httpx.Request("GET", verify_endpoint, headers=headers),
|
|
|
|
|
|
headers=response_headers,
|
|
|
|
|
|
content=response_body,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-17 19:50:35 +08:00
|
|
|
|
# ==================== 配置管理 ====================
|
|
|
|
|
|
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def get_config(self, provider_id: str) -> ProviderOpsConfig | None:
|
2026-01-17 19:50:35 +08:00
|
|
|
|
"""
|
|
|
|
|
|
获取 Provider 的操作配置
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
provider_id: Provider ID
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
配置对象,未配置则返回 None
|
|
|
|
|
|
"""
|
|
|
|
|
|
provider = self._get_provider(provider_id)
|
|
|
|
|
|
if not provider:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
config_data = (provider.config or {}).get("provider_ops")
|
|
|
|
|
|
if not config_data:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
return ProviderOpsConfig.from_dict(config_data)
|
|
|
|
|
|
|
|
|
|
|
|
def save_config(
|
|
|
|
|
|
self,
|
|
|
|
|
|
provider_id: str,
|
|
|
|
|
|
config: ProviderOpsConfig,
|
|
|
|
|
|
) -> bool:
|
|
|
|
|
|
"""
|
|
|
|
|
|
保存 Provider 的操作配置
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
provider_id: Provider ID
|
|
|
|
|
|
config: 配置对象
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
是否保存成功
|
|
|
|
|
|
"""
|
|
|
|
|
|
provider = self._get_provider(provider_id)
|
|
|
|
|
|
if not provider:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# 加密敏感凭据
|
|
|
|
|
|
encrypted_credentials = self._encrypt_credentials(config.connector_credentials)
|
|
|
|
|
|
logger.debug(
|
2026-02-15 16:32:23 +08:00
|
|
|
|
"加密凭据: provider_id={}, input_keys={}, output_keys={}, has_api_key={}",
|
|
|
|
|
|
provider_id,
|
|
|
|
|
|
list(config.connector_credentials.keys()),
|
|
|
|
|
|
list(encrypted_credentials.keys()),
|
|
|
|
|
|
bool(config.connector_credentials.get("api_key")),
|
2026-01-17 19:50:35 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 构建配置
|
|
|
|
|
|
config_dict = config.to_dict()
|
|
|
|
|
|
config_dict["connector"]["credentials"] = encrypted_credentials
|
|
|
|
|
|
|
|
|
|
|
|
# 更新 Provider 配置
|
|
|
|
|
|
provider_config = dict(provider.config or {})
|
|
|
|
|
|
provider_config["provider_ops"] = config_dict
|
|
|
|
|
|
provider.config = provider_config
|
|
|
|
|
|
|
|
|
|
|
|
self.db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# 清除连接器缓存
|
|
|
|
|
|
if provider_id in self._connectors:
|
|
|
|
|
|
del self._connectors[provider_id]
|
|
|
|
|
|
|
2026-02-15 16:32:23 +08:00
|
|
|
|
logger.info("保存 Provider 操作配置: provider_id={}", provider_id)
|
2026-01-17 19:50:35 +08:00
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
def delete_config(self, provider_id: str) -> bool:
|
|
|
|
|
|
"""
|
|
|
|
|
|
删除 Provider 的操作配置
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
provider_id: Provider ID
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
是否删除成功
|
|
|
|
|
|
"""
|
|
|
|
|
|
provider = self._get_provider(provider_id)
|
|
|
|
|
|
if not provider:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
provider_config = dict(provider.config or {})
|
|
|
|
|
|
if "provider_ops" in provider_config:
|
|
|
|
|
|
del provider_config["provider_ops"]
|
|
|
|
|
|
provider.config = provider_config
|
|
|
|
|
|
self.db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
# 清除连接器缓存
|
|
|
|
|
|
if provider_id in self._connectors:
|
|
|
|
|
|
del self._connectors[provider_id]
|
|
|
|
|
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
# ==================== 连接管理 ====================
|
|
|
|
|
|
|
|
|
|
|
|
async def connect(
|
|
|
|
|
|
self,
|
|
|
|
|
|
provider_id: str,
|
2026-01-30 03:10:21 +08:00
|
|
|
|
credentials: dict[str, Any] | None = None,
|
2026-01-17 19:50:35 +08:00
|
|
|
|
) -> tuple[bool, str]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
建立与 Provider 的连接
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
provider_id: Provider ID
|
|
|
|
|
|
credentials: 凭据(如果为 None 则使用已保存的凭据)
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
(是否成功, 消息)
|
|
|
|
|
|
"""
|
|
|
|
|
|
provider = self._get_provider(provider_id)
|
|
|
|
|
|
if not provider:
|
|
|
|
|
|
return False, "Provider 不存在"
|
|
|
|
|
|
|
|
|
|
|
|
config = self.get_config(provider_id)
|
|
|
|
|
|
if not config:
|
|
|
|
|
|
return False, "未配置操作设置"
|
|
|
|
|
|
|
2026-01-18 13:20:14 +08:00
|
|
|
|
# 获取 base_url:优先从 config 读取
|
|
|
|
|
|
base_url = config.base_url or self._get_provider_base_url(provider)
|
2026-01-17 19:50:35 +08:00
|
|
|
|
if not base_url:
|
|
|
|
|
|
return False, "Provider 未配置 base_url"
|
|
|
|
|
|
|
|
|
|
|
|
# 使用提供的凭据或已保存的凭据
|
|
|
|
|
|
if credentials:
|
|
|
|
|
|
actual_credentials = credentials
|
|
|
|
|
|
else:
|
|
|
|
|
|
actual_credentials = self._decrypt_credentials(config.connector_credentials)
|
|
|
|
|
|
|
|
|
|
|
|
if not actual_credentials:
|
|
|
|
|
|
return False, "未提供凭据"
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(
|
2026-03-31 19:19:04 +08:00
|
|
|
|
"Provider ops connect blocked pending Rust cutover: provider_id={}, credentials_keys={}",
|
2026-02-15 16:32:23 +08:00
|
|
|
|
provider_id,
|
|
|
|
|
|
list(actual_credentials.keys()),
|
|
|
|
|
|
)
|
2026-03-31 19:19:04 +08:00
|
|
|
|
self._connectors.pop(provider_id, None)
|
|
|
|
|
|
return False, self._provider_ops_rust_only_connect_message()
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
|
|
|
|
|
async def disconnect(self, provider_id: str) -> bool:
|
|
|
|
|
|
"""
|
|
|
|
|
|
断开与 Provider 的连接
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
provider_id: Provider ID
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
是否成功
|
|
|
|
|
|
"""
|
|
|
|
|
|
connector = self._connectors.get(provider_id)
|
|
|
|
|
|
if connector:
|
|
|
|
|
|
await connector.disconnect()
|
|
|
|
|
|
del self._connectors[provider_id]
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
def get_connection_status(self, provider_id: str) -> ConnectorState:
|
|
|
|
|
|
"""
|
|
|
|
|
|
获取连接状态
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
provider_id: Provider ID
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
连接器状态
|
|
|
|
|
|
"""
|
|
|
|
|
|
connector = self._connectors.get(provider_id)
|
|
|
|
|
|
if connector:
|
|
|
|
|
|
return connector.get_state()
|
|
|
|
|
|
|
|
|
|
|
|
# 未连接
|
|
|
|
|
|
config = self.get_config(provider_id)
|
|
|
|
|
|
return ConnectorState(
|
|
|
|
|
|
status=ConnectorStatus.DISCONNECTED,
|
|
|
|
|
|
auth_type=config.connector_auth_type if config else ConnectorAuthType.NONE,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# ==================== 操作执行 ====================
|
|
|
|
|
|
|
|
|
|
|
|
async def execute_action(
|
|
|
|
|
|
self,
|
|
|
|
|
|
provider_id: str,
|
|
|
|
|
|
action_type: ProviderActionType,
|
2026-01-30 03:10:21 +08:00
|
|
|
|
action_config: dict[str, Any] | None = None,
|
2026-01-17 19:50:35 +08:00
|
|
|
|
) -> ActionResult:
|
|
|
|
|
|
"""
|
|
|
|
|
|
执行操作
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
provider_id: Provider ID
|
|
|
|
|
|
action_type: 操作类型
|
|
|
|
|
|
action_config: 操作配置(覆盖默认配置)
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
操作结果
|
|
|
|
|
|
"""
|
|
|
|
|
|
# 获取配置
|
|
|
|
|
|
config = self.get_config(provider_id)
|
|
|
|
|
|
if not config:
|
|
|
|
|
|
return ActionResult(
|
|
|
|
|
|
status=ActionStatus.NOT_CONFIGURED,
|
|
|
|
|
|
action_type=action_type,
|
|
|
|
|
|
message="未配置操作设置",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-31 19:19:04 +08:00
|
|
|
|
logger.info(
|
|
|
|
|
|
"Provider ops action blocked pending Rust cutover: provider_id={}, action_type={}",
|
|
|
|
|
|
provider_id,
|
|
|
|
|
|
action_type.value,
|
|
|
|
|
|
)
|
|
|
|
|
|
return self._provider_ops_rust_only_action_result(action_type)
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
|
|
|
|
|
async def query_balance(
|
|
|
|
|
|
self,
|
|
|
|
|
|
provider_id: str,
|
2026-01-30 03:10:21 +08:00
|
|
|
|
config: dict[str, Any] | None = None,
|
2026-01-17 19:50:35 +08:00
|
|
|
|
) -> ActionResult:
|
|
|
|
|
|
"""
|
|
|
|
|
|
查询余额(快捷方法)
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
provider_id: Provider ID
|
|
|
|
|
|
config: 操作配置
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
操作结果
|
|
|
|
|
|
"""
|
2026-02-01 17:28:00 +08:00
|
|
|
|
result = await self.execute_action(provider_id, ProviderActionType.QUERY_BALANCE, config)
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
2026-01-20 02:44:22 +08:00
|
|
|
|
# 成功或 auth_expired 时缓存(auth_expired 带有 cookie_expired 信息供前端显示警告)
|
|
|
|
|
|
if result.status in (ActionStatus.SUCCESS, ActionStatus.AUTH_EXPIRED) and result.data:
|
2026-01-17 19:50:35 +08:00
|
|
|
|
await self._cache_balance(provider_id, result)
|
2026-01-28 23:07:58 +08:00
|
|
|
|
# auth_failed 时也缓存(使用较短 TTL),避免前端无限显示"加载中..."
|
2026-01-20 02:44:22 +08:00
|
|
|
|
elif result.status == ActionStatus.AUTH_FAILED:
|
2026-01-28 23:07:58 +08:00
|
|
|
|
await self._cache_auth_failed(provider_id, result)
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
async def query_balance_with_cache(
|
|
|
|
|
|
self,
|
|
|
|
|
|
provider_id: str,
|
|
|
|
|
|
trigger_refresh: bool = True,
|
2026-01-28 01:01:05 +08:00
|
|
|
|
allow_sync_query: bool = True,
|
2026-01-17 19:50:35 +08:00
|
|
|
|
) -> ActionResult:
|
|
|
|
|
|
"""
|
|
|
|
|
|
查询余额(优先返回缓存,可触发异步刷新)
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
provider_id: Provider ID
|
|
|
|
|
|
trigger_refresh: 是否触发后台异步刷新
|
2026-01-28 01:01:05 +08:00
|
|
|
|
allow_sync_query: 缓存未命中时是否允许同步查询(False 时仅返回缓存或触发异步刷新)
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
操作结果(可能是缓存的)
|
|
|
|
|
|
"""
|
2026-02-02 03:16:52 +08:00
|
|
|
|
# Avoid holding a DB connection while awaiting Redis/cache I/O.
|
|
|
|
|
|
self._release_db_connection_before_await()
|
|
|
|
|
|
|
2026-01-17 19:50:35 +08:00
|
|
|
|
# 尝试从缓存获取
|
|
|
|
|
|
cached = await self._get_cached_balance(provider_id)
|
|
|
|
|
|
|
|
|
|
|
|
if cached:
|
|
|
|
|
|
# 有缓存,可选触发后台刷新
|
|
|
|
|
|
if trigger_refresh:
|
|
|
|
|
|
# 后台任务内部已处理异常并记录日志,无需额外回调
|
2026-03-10 14:42:33 +08:00
|
|
|
|
from src.utils.async_utils import safe_create_task
|
|
|
|
|
|
|
|
|
|
|
|
safe_create_task(self._refresh_balance_async(provider_id))
|
2026-01-17 19:50:35 +08:00
|
|
|
|
return cached
|
|
|
|
|
|
|
2026-01-28 01:01:05 +08:00
|
|
|
|
# 没有缓存
|
|
|
|
|
|
if allow_sync_query:
|
|
|
|
|
|
# 同步查询一次(首次访问)
|
2026-02-15 16:32:23 +08:00
|
|
|
|
logger.info("余额缓存未命中,同步查询: provider_id={}", provider_id)
|
2026-01-28 01:01:05 +08:00
|
|
|
|
return await self.query_balance(provider_id)
|
|
|
|
|
|
else:
|
|
|
|
|
|
# 仅触发异步刷新,立即返回
|
2026-02-15 16:32:23 +08:00
|
|
|
|
logger.debug("余额缓存未命中,触发异步刷新: provider_id={}", provider_id)
|
2026-03-10 14:42:33 +08:00
|
|
|
|
from src.utils.async_utils import safe_create_task
|
|
|
|
|
|
|
|
|
|
|
|
safe_create_task(self._refresh_balance_async(provider_id))
|
2026-01-28 01:01:05 +08:00
|
|
|
|
return ActionResult(
|
|
|
|
|
|
status=ActionStatus.PENDING,
|
|
|
|
|
|
action_type=ProviderActionType.QUERY_BALANCE,
|
|
|
|
|
|
message="余额数据加载中,请稍后刷新",
|
|
|
|
|
|
)
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
|
|
|
|
|
async def _refresh_balance_async(self, provider_id: str) -> None:
|
2026-01-28 01:01:05 +08:00
|
|
|
|
"""
|
|
|
|
|
|
后台异步刷新余额(使用独立的数据库 session)
|
|
|
|
|
|
|
|
|
|
|
|
注意:这是一个后台任务,使用独立的短生命周期 session,
|
|
|
|
|
|
避免长时间占用连接池资源。
|
2026-02-02 03:16:52 +08:00
|
|
|
|
|
|
|
|
|
|
使用信号量限制并发数,避免启动时多个刷新任务同时运行导致连接池耗尽。
|
2026-03-08 02:29:51 +08:00
|
|
|
|
使用 _refreshing_providers 集合防止同一 provider 被并发刷新。
|
2026-01-28 01:01:05 +08:00
|
|
|
|
"""
|
2026-03-08 02:29:51 +08:00
|
|
|
|
# per-provider 防重入
|
|
|
|
|
|
if provider_id in _refreshing_providers:
|
|
|
|
|
|
logger.debug("异步刷新余额跳过(已在刷新中): provider_id={}", provider_id)
|
|
|
|
|
|
return
|
|
|
|
|
|
_refreshing_providers.add(provider_id)
|
|
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
semaphore = _get_balance_refresh_semaphore()
|
|
|
|
|
|
|
|
|
|
|
|
# 尝试获取信号量,如果无法立即获取则跳过本次刷新
|
|
|
|
|
|
# 这样可以避免在连接池紧张时阻塞
|
|
|
|
|
|
try:
|
|
|
|
|
|
# 使用 wait_for 设置超时,避免无限等待
|
|
|
|
|
|
await asyncio.wait_for(semaphore.acquire(), timeout=5.0)
|
|
|
|
|
|
except asyncio.TimeoutError:
|
2026-02-15 16:32:23 +08:00
|
|
|
|
logger.debug("异步刷新余额跳过(并发限制): provider_id={}", provider_id)
|
2026-03-08 02:29:51 +08:00
|
|
|
|
_refreshing_providers.discard(provider_id)
|
2026-02-02 03:16:52 +08:00
|
|
|
|
return
|
|
|
|
|
|
|
2026-01-28 01:01:05 +08:00
|
|
|
|
db = None
|
2026-01-17 19:50:35 +08:00
|
|
|
|
try:
|
|
|
|
|
|
# 后台任务需要创建独立的 session,因为原请求的 session 可能已关闭
|
2026-01-28 01:01:05 +08:00
|
|
|
|
db = create_session()
|
|
|
|
|
|
service = ProviderOpsService(db)
|
|
|
|
|
|
await service.query_balance(provider_id)
|
2026-01-17 19:50:35 +08:00
|
|
|
|
except Exception as e:
|
2026-02-15 16:32:23 +08:00
|
|
|
|
logger.warning("异步刷新余额失败: provider_id={}, error={}", provider_id, e)
|
2026-01-28 01:01:05 +08:00
|
|
|
|
finally:
|
|
|
|
|
|
# 确保 session 被关闭,归还连接到连接池
|
|
|
|
|
|
if db is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
db.close()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2026-03-08 02:29:51 +08:00
|
|
|
|
# 释放信号量并移除防重入标记
|
2026-02-02 03:16:52 +08:00
|
|
|
|
semaphore.release()
|
2026-03-08 02:29:51 +08:00
|
|
|
|
_refreshing_providers.discard(provider_id)
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
2026-01-20 02:44:22 +08:00
|
|
|
|
async def _clear_balance_cache(self, provider_id: str) -> None:
|
2026-01-28 23:07:58 +08:00
|
|
|
|
"""清除余额缓存"""
|
2026-01-20 02:44:22 +08:00
|
|
|
|
cache_key = f"provider_ops:balance:{provider_id}"
|
|
|
|
|
|
await CacheService.delete(cache_key)
|
2026-02-15 16:32:23 +08:00
|
|
|
|
logger.info("余额缓存已清除: provider_id={}", provider_id)
|
2026-01-20 02:44:22 +08:00
|
|
|
|
|
2026-01-28 23:07:58 +08:00
|
|
|
|
async def _cache_auth_failed(self, provider_id: str, result: ActionResult) -> None:
|
|
|
|
|
|
"""
|
|
|
|
|
|
缓存认证失败结果(使用较短 TTL)
|
|
|
|
|
|
|
|
|
|
|
|
这样前端可以立即显示错误信息,而不是无限显示"加载中..."。
|
|
|
|
|
|
用户修正配置后,等待 60 秒或手动刷新即可重试。
|
|
|
|
|
|
"""
|
|
|
|
|
|
cache_key = f"provider_ops:balance:{provider_id}"
|
|
|
|
|
|
cache_data = {
|
|
|
|
|
|
"status": result.status.value,
|
|
|
|
|
|
"data": None,
|
|
|
|
|
|
"message": result.message,
|
|
|
|
|
|
"executed_at": result.executed_at.isoformat() if result.executed_at else None,
|
|
|
|
|
|
"response_time_ms": result.response_time_ms,
|
|
|
|
|
|
}
|
|
|
|
|
|
await CacheService.set(cache_key, cache_data, AUTH_FAILED_CACHE_TTL)
|
2026-02-01 17:28:00 +08:00
|
|
|
|
logger.info(
|
2026-02-15 16:32:23 +08:00
|
|
|
|
"余额缓存已写入(认证失败): provider_id={}, message={}",
|
|
|
|
|
|
provider_id,
|
|
|
|
|
|
result.message,
|
2026-02-01 17:28:00 +08:00
|
|
|
|
)
|
2026-01-28 23:07:58 +08:00
|
|
|
|
|
2026-01-17 19:50:35 +08:00
|
|
|
|
async def _cache_balance(self, provider_id: str, result: ActionResult) -> None:
|
|
|
|
|
|
"""缓存余额结果"""
|
|
|
|
|
|
cache_key = f"provider_ops:balance:{provider_id}"
|
|
|
|
|
|
|
|
|
|
|
|
# 序列化 BalanceInfo
|
|
|
|
|
|
data = result.data
|
|
|
|
|
|
if isinstance(data, BalanceInfo):
|
|
|
|
|
|
data = asdict(data)
|
|
|
|
|
|
|
|
|
|
|
|
cache_data = {
|
|
|
|
|
|
"status": result.status.value,
|
|
|
|
|
|
"data": data,
|
|
|
|
|
|
"executed_at": result.executed_at.isoformat(),
|
|
|
|
|
|
"response_time_ms": result.response_time_ms,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await CacheService.set(cache_key, cache_data, BALANCE_CACHE_TTL)
|
|
|
|
|
|
|
2026-01-19 17:18:03 +08:00
|
|
|
|
async def _cache_balance_from_verify(
|
|
|
|
|
|
self,
|
|
|
|
|
|
provider_id: str,
|
|
|
|
|
|
quota_usd: float,
|
2026-01-30 03:10:21 +08:00
|
|
|
|
extra: dict[str, Any] | None = None,
|
2026-01-19 17:18:03 +08:00
|
|
|
|
) -> None:
|
2026-01-18 13:00:11 +08:00
|
|
|
|
"""
|
|
|
|
|
|
从验证结果缓存余额
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
provider_id: Provider ID
|
|
|
|
|
|
quota_usd: 已转换为美元的余额值
|
2026-01-19 17:18:03 +08:00
|
|
|
|
extra: 额外信息(如窗口限额)
|
2026-01-18 13:00:11 +08:00
|
|
|
|
"""
|
|
|
|
|
|
cache_key = f"provider_ops:balance:{provider_id}"
|
|
|
|
|
|
|
|
|
|
|
|
# 构建与 BalanceAction 兼容的缓存数据
|
|
|
|
|
|
# 注意:验证接口只返回 quota,没有 total_granted/total_used
|
|
|
|
|
|
cache_data = {
|
|
|
|
|
|
"status": "success",
|
|
|
|
|
|
"data": {
|
|
|
|
|
|
"total_granted": None,
|
|
|
|
|
|
"total_used": None,
|
|
|
|
|
|
"total_available": quota_usd,
|
|
|
|
|
|
"currency": "USD",
|
2026-01-19 17:18:03 +08:00
|
|
|
|
"extra": extra or {},
|
2026-01-18 13:00:11 +08:00
|
|
|
|
},
|
|
|
|
|
|
"executed_at": datetime.now(timezone.utc).isoformat(),
|
|
|
|
|
|
"response_time_ms": None,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await CacheService.set(cache_key, cache_data, BALANCE_CACHE_TTL)
|
2026-02-15 16:32:23 +08:00
|
|
|
|
logger.debug("验证成功,缓存余额: provider_id={}, quota_usd={}", provider_id, quota_usd)
|
2026-01-18 13:00:11 +08:00
|
|
|
|
|
2026-01-30 03:10:21 +08:00
|
|
|
|
async def _get_cached_balance(self, provider_id: str) -> ActionResult | None:
|
2026-01-17 19:50:35 +08:00
|
|
|
|
"""获取缓存的余额"""
|
|
|
|
|
|
cache_key = f"provider_ops:balance:{provider_id}"
|
|
|
|
|
|
cached = await CacheService.get(cache_key)
|
|
|
|
|
|
|
|
|
|
|
|
if not cached:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
# 反序列化
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = cached.get("data")
|
|
|
|
|
|
if data and isinstance(data, dict):
|
|
|
|
|
|
# 转回 BalanceInfo
|
|
|
|
|
|
data = BalanceInfo(
|
|
|
|
|
|
total_granted=data.get("total_granted"),
|
|
|
|
|
|
total_used=data.get("total_used"),
|
|
|
|
|
|
total_available=data.get("total_available"),
|
|
|
|
|
|
currency=data.get("currency", "USD"),
|
|
|
|
|
|
extra=data.get("extra", {}),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
executed_at_str = cached.get("executed_at")
|
|
|
|
|
|
executed_at = (
|
|
|
|
|
|
datetime.fromisoformat(executed_at_str)
|
|
|
|
|
|
if executed_at_str
|
|
|
|
|
|
else datetime.now(timezone.utc)
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-28 23:07:58 +08:00
|
|
|
|
status = ActionStatus(cached.get("status", "success"))
|
|
|
|
|
|
# 认证失败使用较短的缓存 TTL
|
|
|
|
|
|
ttl = AUTH_FAILED_CACHE_TTL if status == ActionStatus.AUTH_FAILED else BALANCE_CACHE_TTL
|
2026-01-17 19:50:35 +08:00
|
|
|
|
return ActionResult(
|
2026-01-28 23:07:58 +08:00
|
|
|
|
status=status,
|
2026-01-17 19:50:35 +08:00
|
|
|
|
action_type=ProviderActionType.QUERY_BALANCE,
|
|
|
|
|
|
data=data,
|
2026-01-28 23:07:58 +08:00
|
|
|
|
message=cached.get("message"),
|
2026-01-17 19:50:35 +08:00
|
|
|
|
executed_at=executed_at,
|
|
|
|
|
|
response_time_ms=cached.get("response_time_ms"),
|
2026-01-28 23:07:58 +08:00
|
|
|
|
cache_ttl_seconds=ttl,
|
2026-01-17 19:50:35 +08:00
|
|
|
|
)
|
|
|
|
|
|
except Exception as e:
|
2026-02-15 16:32:23 +08:00
|
|
|
|
logger.warning("解析缓存余额失败: provider_id={}, error={}", provider_id, e)
|
2026-01-17 19:50:35 +08:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
async def checkin(
|
|
|
|
|
|
self,
|
|
|
|
|
|
provider_id: str,
|
2026-01-30 03:10:21 +08:00
|
|
|
|
config: dict[str, Any] | None = None,
|
2026-01-17 19:50:35 +08:00
|
|
|
|
) -> ActionResult:
|
|
|
|
|
|
"""
|
|
|
|
|
|
签到(快捷方法)
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
provider_id: Provider ID
|
|
|
|
|
|
config: 操作配置
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
操作结果
|
|
|
|
|
|
"""
|
|
|
|
|
|
return await self.execute_action(provider_id, ProviderActionType.CHECKIN, config)
|
|
|
|
|
|
|
|
|
|
|
|
# ==================== 辅助方法 ====================
|
|
|
|
|
|
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def _get_provider(self, provider_id: str) -> Provider | None:
|
2026-01-17 19:50:35 +08:00
|
|
|
|
"""获取 Provider"""
|
|
|
|
|
|
return self.db.query(Provider).filter(Provider.id == provider_id).first()
|
|
|
|
|
|
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def _get_provider_base_url(self, provider: Provider) -> str | None:
|
2026-01-17 19:50:35 +08:00
|
|
|
|
"""从 Provider 获取 base_url"""
|
|
|
|
|
|
# 优先从第一个 endpoint 获取
|
|
|
|
|
|
if provider.endpoints:
|
|
|
|
|
|
for endpoint in provider.endpoints:
|
|
|
|
|
|
if endpoint.base_url:
|
|
|
|
|
|
return endpoint.base_url
|
|
|
|
|
|
|
|
|
|
|
|
# 从 config 获取
|
|
|
|
|
|
config = provider.config or {}
|
|
|
|
|
|
if "base_url" in config:
|
|
|
|
|
|
return config["base_url"]
|
|
|
|
|
|
|
|
|
|
|
|
# 从 website 获取
|
|
|
|
|
|
if provider.website:
|
|
|
|
|
|
return provider.website
|
|
|
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2026-02-15 16:32:23 +08:00
|
|
|
|
def _persist_updated_credentials(self, provider_id: str, updated: dict[str, Any]) -> None:
|
|
|
|
|
|
"""
|
|
|
|
|
|
持久化连接器运行时更新的凭据(如 Token Rotation 后的新 refresh_token)
|
|
|
|
|
|
|
|
|
|
|
|
通过 run_in_executor 将同步 DB 操作 offload 到线程池,避免在异步事件循环中阻塞。
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
loop = asyncio.get_running_loop()
|
|
|
|
|
|
future = loop.run_in_executor(
|
|
|
|
|
|
None, self._persist_updated_credentials_sync, provider_id, updated
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _log_persist_error(f: asyncio.Future) -> None: # type: ignore[type-arg]
|
|
|
|
|
|
if not f.cancelled() and f.exception():
|
|
|
|
|
|
logger.warning("异步持久化凭据失败: {}", f.exception())
|
|
|
|
|
|
|
|
|
|
|
|
future.add_done_callback(_log_persist_error)
|
|
|
|
|
|
except RuntimeError:
|
|
|
|
|
|
# 没有运行中的事件循环(测试等场景),直接同步执行
|
|
|
|
|
|
self._persist_updated_credentials_sync(provider_id, updated)
|
|
|
|
|
|
|
|
|
|
|
|
def _persist_updated_credentials_sync(self, provider_id: str, updated: dict[str, Any]) -> None:
|
|
|
|
|
|
"""同步执行凭据持久化(在线程池中运行)"""
|
|
|
|
|
|
import copy
|
|
|
|
|
|
|
|
|
|
|
|
db = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
db = create_session()
|
|
|
|
|
|
provider = db.query(Provider).filter(Provider.id == provider_id).first()
|
|
|
|
|
|
if not provider:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
# 深拷贝整个 config,避免原地修改导致 SQLAlchemy 变更检测失败
|
|
|
|
|
|
provider_config = copy.deepcopy(dict(provider.config or {}))
|
|
|
|
|
|
|
|
|
|
|
|
config_data = provider_config.get("provider_ops")
|
|
|
|
|
|
if not config_data:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
credentials = config_data.get("connector", {}).get("credentials", {})
|
|
|
|
|
|
|
|
|
|
|
|
# 更新凭据(敏感字段加密)
|
|
|
|
|
|
for key, value in updated.items():
|
|
|
|
|
|
if key in self.SENSITIVE_FIELDS and isinstance(value, str) and value:
|
|
|
|
|
|
credentials[key] = self.crypto.encrypt(value)
|
|
|
|
|
|
else:
|
|
|
|
|
|
credentials[key] = value
|
|
|
|
|
|
|
|
|
|
|
|
config_data["connector"]["credentials"] = credentials
|
|
|
|
|
|
provider.config = provider_config
|
|
|
|
|
|
flag_modified(provider, "config")
|
|
|
|
|
|
db.commit()
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"凭据已持久化更新: provider_id={}, updated_keys={}",
|
|
|
|
|
|
provider_id,
|
|
|
|
|
|
list(updated.keys()),
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.warning("持久化凭据更新失败: provider_id={}, error={}", provider_id, e)
|
|
|
|
|
|
if db:
|
|
|
|
|
|
try:
|
|
|
|
|
|
db.rollback()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
finally:
|
|
|
|
|
|
if db:
|
|
|
|
|
|
try:
|
|
|
|
|
|
db.close()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def _encrypt_credentials(self, credentials: dict[str, Any]) -> dict[str, Any]:
|
2026-01-17 19:50:35 +08:00
|
|
|
|
"""加密凭据中的敏感字段"""
|
|
|
|
|
|
encrypted = {}
|
|
|
|
|
|
for key, value in credentials.items():
|
|
|
|
|
|
if key in self.SENSITIVE_FIELDS and isinstance(value, str):
|
|
|
|
|
|
if value: # 只加密非空值
|
|
|
|
|
|
encrypted[key] = self.crypto.encrypt(value)
|
2026-02-01 17:28:00 +08:00
|
|
|
|
logger.debug(
|
2026-02-15 16:32:23 +08:00
|
|
|
|
"加密字段 {}: 原始长度={}, 加密后长度={}",
|
|
|
|
|
|
key,
|
|
|
|
|
|
len(value),
|
|
|
|
|
|
len(encrypted[key]),
|
2026-02-01 17:28:00 +08:00
|
|
|
|
)
|
2026-01-17 19:50:35 +08:00
|
|
|
|
else:
|
2026-02-15 16:32:23 +08:00
|
|
|
|
logger.warning("跳过空值字段 {}", key)
|
2026-01-17 19:50:35 +08:00
|
|
|
|
encrypted[key] = value
|
|
|
|
|
|
else:
|
|
|
|
|
|
encrypted[key] = value
|
|
|
|
|
|
return encrypted
|
|
|
|
|
|
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def _decrypt_credentials(self, credentials: dict[str, Any]) -> dict[str, Any]:
|
2026-01-17 19:50:35 +08:00
|
|
|
|
"""解密凭据中的敏感字段"""
|
|
|
|
|
|
decrypted = {}
|
|
|
|
|
|
for key, value in credentials.items():
|
|
|
|
|
|
if key in self.SENSITIVE_FIELDS and isinstance(value, str):
|
|
|
|
|
|
try:
|
|
|
|
|
|
decrypted[key] = self.crypto.decrypt(value)
|
|
|
|
|
|
except Exception as e:
|
2026-02-15 16:32:23 +08:00
|
|
|
|
logger.warning("解密字段 {} 失败: {}", key, e)
|
2026-01-17 19:50:35 +08:00
|
|
|
|
decrypted[key] = value # 解密失败则保持原值
|
|
|
|
|
|
else:
|
|
|
|
|
|
decrypted[key] = value
|
|
|
|
|
|
return decrypted
|
|
|
|
|
|
|
2026-02-15 16:32:23 +08:00
|
|
|
|
# 密码类字段:脱敏时全部遮盖,不显示任何明文字符
|
|
|
|
|
|
FULLY_MASKED_FIELDS = {"password"}
|
|
|
|
|
|
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def get_masked_credentials(self, credentials: dict[str, Any]) -> dict[str, Any]:
|
2026-01-17 19:50:35 +08:00
|
|
|
|
"""
|
|
|
|
|
|
获取脱敏后的凭据
|
|
|
|
|
|
|
2026-02-15 16:32:23 +08:00
|
|
|
|
解密凭据并对敏感字段进行脱敏处理。
|
|
|
|
|
|
- 密码类字段:全部遮盖为 ********
|
|
|
|
|
|
- 其他敏感字段:显示部分字符(如 sk-x****a12k)
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
credentials: 加密的凭据
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
脱敏后的凭据
|
|
|
|
|
|
"""
|
|
|
|
|
|
decrypted = self._decrypt_credentials(credentials)
|
|
|
|
|
|
|
|
|
|
|
|
for field in self.SENSITIVE_FIELDS:
|
|
|
|
|
|
if field in decrypted and decrypted[field]:
|
|
|
|
|
|
value = str(decrypted[field])
|
2026-02-15 16:32:23 +08:00
|
|
|
|
if field in self.FULLY_MASKED_FIELDS:
|
|
|
|
|
|
decrypted[field] = "********"
|
|
|
|
|
|
elif len(value) > 12:
|
|
|
|
|
|
# 显示前4位和后4位,中间固定4个 *(如 sk-x****a12k)
|
2026-01-17 19:50:35 +08:00
|
|
|
|
decrypted[field] = value[:4] + "****" + value[-4:]
|
|
|
|
|
|
elif len(value) > 8:
|
|
|
|
|
|
decrypted[field] = value[:2] + "****" + value[-2:]
|
|
|
|
|
|
else:
|
|
|
|
|
|
decrypted[field] = "*" * len(value)
|
|
|
|
|
|
|
|
|
|
|
|
return decrypted
|
|
|
|
|
|
|
|
|
|
|
|
def merge_credentials_with_saved(
|
|
|
|
|
|
self,
|
|
|
|
|
|
provider_id: str,
|
2026-01-30 03:10:21 +08:00
|
|
|
|
credentials: dict[str, Any],
|
|
|
|
|
|
) -> dict[str, Any]:
|
2026-01-17 19:50:35 +08:00
|
|
|
|
"""
|
|
|
|
|
|
合并凭据:如果请求中的敏感字段为空,使用已保存的凭据
|
|
|
|
|
|
|
|
|
|
|
|
用于验证和保存配置时,当用户未重新输入敏感字段时保留原有值。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
provider_id: Provider ID
|
|
|
|
|
|
credentials: 请求中的凭据
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
合并后的凭据
|
|
|
|
|
|
"""
|
|
|
|
|
|
merged = dict(credentials)
|
|
|
|
|
|
saved_config = self.get_config(provider_id)
|
|
|
|
|
|
|
|
|
|
|
|
if saved_config:
|
|
|
|
|
|
saved_credentials = self._decrypt_credentials(saved_config.connector_credentials)
|
2026-01-19 17:18:03 +08:00
|
|
|
|
sensitive_fields = [
|
2026-02-01 17:28:00 +08:00
|
|
|
|
"api_key",
|
|
|
|
|
|
"password",
|
2026-02-15 16:32:23 +08:00
|
|
|
|
"refresh_token",
|
2026-02-01 17:28:00 +08:00
|
|
|
|
"session_token",
|
|
|
|
|
|
"cookie_string",
|
|
|
|
|
|
"cookie",
|
|
|
|
|
|
"token_cookie",
|
|
|
|
|
|
"auth_cookie",
|
|
|
|
|
|
"session_cookie", # Cookie 认证字段
|
2026-01-19 17:18:03 +08:00
|
|
|
|
]
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
|
|
|
|
|
for field in sensitive_fields:
|
|
|
|
|
|
# 如果请求中该字段为空或只包含星号(脱敏值),使用已保存的值
|
|
|
|
|
|
req_value = merged.get(field, "")
|
|
|
|
|
|
if not req_value or (isinstance(req_value, str) and set(req_value) <= {"*"}):
|
|
|
|
|
|
if field in saved_credentials:
|
|
|
|
|
|
merged[field] = saved_credentials[field]
|
2026-02-15 16:32:23 +08:00
|
|
|
|
logger.debug("合并凭据 - 使用已保存的 {}", field)
|
|
|
|
|
|
|
|
|
|
|
|
# 保留内部缓存字段(如 _cached_access_token),前端不感知这些字段
|
|
|
|
|
|
for key, value in saved_credentials.items():
|
|
|
|
|
|
if key.startswith("_") and key not in merged:
|
|
|
|
|
|
merged[key] = value
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
|
|
|
|
|
return merged
|
|
|
|
|
|
|
|
|
|
|
|
# ==================== 批量操作 ====================
|
|
|
|
|
|
|
|
|
|
|
|
async def batch_query_balance(
|
2026-01-30 03:10:21 +08:00
|
|
|
|
self, provider_ids: list[str] | None = None
|
|
|
|
|
|
) -> dict[str, ActionResult]:
|
2026-01-17 19:50:35 +08:00
|
|
|
|
"""
|
2026-01-17 23:44:53 +08:00
|
|
|
|
批量查询余额(优先返回缓存,后台异步刷新)
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
2026-01-28 01:01:05 +08:00
|
|
|
|
使用信号量限制并发数,避免数据库连接池耗尽。
|
|
|
|
|
|
|
2026-01-17 19:50:35 +08:00
|
|
|
|
Args:
|
|
|
|
|
|
provider_ids: Provider ID 列表,None 表示查询所有已配置的
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
{provider_id: result}
|
|
|
|
|
|
"""
|
|
|
|
|
|
if provider_ids is None:
|
|
|
|
|
|
# 查询所有已配置的 Provider
|
|
|
|
|
|
providers = self.db.query(Provider).filter(Provider.is_active.is_(True)).all()
|
2026-02-01 17:28:00 +08:00
|
|
|
|
provider_ids = [p.id for p in providers if p.config and p.config.get("provider_ops")]
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
2026-01-28 01:01:05 +08:00
|
|
|
|
if not provider_ids:
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
# Release the DB connection before awaiting many async cache refreshes.
|
|
|
|
|
|
self._release_db_connection_before_await()
|
|
|
|
|
|
|
2026-01-28 01:01:05 +08:00
|
|
|
|
# 使用信号量限制并发数,避免同时发起过多请求耗尽连接池
|
|
|
|
|
|
concurrency = _get_batch_balance_concurrency()
|
|
|
|
|
|
semaphore = asyncio.Semaphore(concurrency)
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
2026-01-28 01:01:05 +08:00
|
|
|
|
async def _query_with_limit(provider_id: str) -> tuple[str, ActionResult]:
|
|
|
|
|
|
async with semaphore:
|
|
|
|
|
|
try:
|
|
|
|
|
|
# 批量查询时禁用同步查询,避免阻塞请求
|
|
|
|
|
|
# 缓存未命中时会触发异步刷新,前端可稍后重试
|
|
|
|
|
|
result = await self.query_balance_with_cache(
|
|
|
|
|
|
provider_id, trigger_refresh=True, allow_sync_query=False
|
|
|
|
|
|
)
|
|
|
|
|
|
return provider_id, result
|
|
|
|
|
|
except Exception as e:
|
2026-02-15 16:32:23 +08:00
|
|
|
|
logger.warning("查询余额失败: provider_id={}, error={}", provider_id, e)
|
2026-01-28 01:01:05 +08:00
|
|
|
|
return provider_id, ActionResult(
|
|
|
|
|
|
status=ActionStatus.UNKNOWN_ERROR,
|
|
|
|
|
|
action_type=ProviderActionType.QUERY_BALANCE,
|
|
|
|
|
|
message=str(e),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 并行查询,但受信号量限制
|
|
|
|
|
|
tasks = [_query_with_limit(provider_id) for provider_id in provider_ids]
|
|
|
|
|
|
results_list = await asyncio.gather(*tasks)
|
|
|
|
|
|
|
|
|
|
|
|
return dict(results_list)
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
|
|
|
|
|
# ==================== 认证验证 ====================
|
|
|
|
|
|
|
|
|
|
|
|
async def verify_auth(
|
|
|
|
|
|
self,
|
|
|
|
|
|
base_url: str,
|
|
|
|
|
|
architecture_id: str,
|
|
|
|
|
|
auth_type: ConnectorAuthType,
|
2026-01-30 03:10:21 +08:00
|
|
|
|
config: dict[str, Any],
|
|
|
|
|
|
credentials: dict[str, Any],
|
|
|
|
|
|
provider_id: str | None = None,
|
|
|
|
|
|
) -> dict[str, Any]:
|
2026-01-17 19:50:35 +08:00
|
|
|
|
"""
|
|
|
|
|
|
验证认证配置
|
|
|
|
|
|
|
|
|
|
|
|
在保存前测试认证是否有效。
|
|
|
|
|
|
认证逻辑委托给对应的 Architecture 实现。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
base_url: API 基础地址
|
|
|
|
|
|
architecture_id: 架构 ID
|
|
|
|
|
|
auth_type: 认证类型
|
|
|
|
|
|
config: 连接器配置
|
|
|
|
|
|
credentials: 凭据
|
2026-01-18 13:00:11 +08:00
|
|
|
|
provider_id: Provider ID(可选,用于缓存余额)
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
验证结果
|
|
|
|
|
|
"""
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
2026-02-02 03:16:52 +08:00
|
|
|
|
# Avoid holding a DB connection while awaiting verify pre-processing / network.
|
|
|
|
|
|
self._release_db_connection_before_await()
|
|
|
|
|
|
|
2026-01-17 19:50:35 +08:00
|
|
|
|
# 移除 base_url 末尾的斜杠
|
|
|
|
|
|
base_url = base_url.rstrip("/")
|
|
|
|
|
|
|
|
|
|
|
|
# 获取架构实例
|
|
|
|
|
|
registry = get_registry()
|
|
|
|
|
|
architecture = registry.get_or_default(architecture_id)
|
|
|
|
|
|
|
|
|
|
|
|
# 使用架构的方法构建请求
|
|
|
|
|
|
verify_endpoint = f"{base_url}{architecture.get_verify_endpoint()}"
|
2026-01-19 17:18:03 +08:00
|
|
|
|
|
2026-03-20 01:22:07 +08:00
|
|
|
|
try:
|
|
|
|
|
|
# 执行异步预处理(如获取动态 Cookie、登录获取 Token)
|
|
|
|
|
|
# 返回值可以是 dict(仅额外配置)或 tuple[dict, dict](额外配置 + 凭据更新)
|
|
|
|
|
|
prepare_result = await architecture.prepare_verify_config(base_url, config, credentials)
|
|
|
|
|
|
if isinstance(prepare_result, tuple):
|
|
|
|
|
|
extra_config, updated_creds = prepare_result
|
|
|
|
|
|
else:
|
|
|
|
|
|
extra_config = prepare_result
|
|
|
|
|
|
updated_creds = {}
|
|
|
|
|
|
merged_config = {**config, **extra_config}
|
|
|
|
|
|
|
|
|
|
|
|
# Token Rotation: prepare_verify_config 可能已消耗旧 refresh_token 并获取新值,
|
|
|
|
|
|
# 无论后续验证是否成功都需要立即持久化,否则旧 token 已失效但数据库未更新。
|
|
|
|
|
|
if updated_creds and provider_id:
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"验证过程检测到凭据变更: provider_id={}, updated_keys={}",
|
|
|
|
|
|
provider_id,
|
|
|
|
|
|
list(updated_creds.keys()),
|
|
|
|
|
|
)
|
|
|
|
|
|
self._persist_updated_credentials(provider_id, updated_creds)
|
2026-02-15 16:32:23 +08:00
|
|
|
|
|
2026-03-20 01:22:07 +08:00
|
|
|
|
headers = architecture.build_verify_headers(merged_config, credentials)
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
2026-03-20 01:22:07 +08:00
|
|
|
|
logger.debug(
|
|
|
|
|
|
"验证认证: architecture={}, endpoint={}, headers={}",
|
|
|
|
|
|
architecture_id,
|
|
|
|
|
|
verify_endpoint,
|
|
|
|
|
|
list(headers.keys()),
|
|
|
|
|
|
)
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
2026-03-20 01:22:07 +08:00
|
|
|
|
# 获取代理配置(支持 proxy_node_id、tunnel 模式和旧的 proxy URL)
|
|
|
|
|
|
from src.services.proxy_node.resolver import resolve_ops_proxy_config_async
|
2026-02-07 14:30:46 +08:00
|
|
|
|
|
2026-03-20 01:22:07 +08:00
|
|
|
|
proxy, tunnel_node_id = await resolve_ops_proxy_config_async(config)
|
2026-01-19 18:30:52 +08:00
|
|
|
|
|
2026-03-21 12:57:09 +08:00
|
|
|
|
response = await self._try_rust_verify_response(
|
|
|
|
|
|
architecture_id=architecture_id,
|
|
|
|
|
|
verify_endpoint=verify_endpoint,
|
|
|
|
|
|
headers=headers,
|
|
|
|
|
|
proxy=proxy,
|
|
|
|
|
|
tunnel_node_id=tunnel_node_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
if response is None:
|
2026-03-31 19:19:04 +08:00
|
|
|
|
return {"success": False, "message": "认证验证仅支持 Rust executor"}
|
2026-03-21 12:57:09 +08:00
|
|
|
|
|
|
|
|
|
|
logger.debug(
|
|
|
|
|
|
"验证响应: status={}, content_type={}",
|
|
|
|
|
|
response.status_code,
|
|
|
|
|
|
response.headers.get("content-type"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# 尝试解析 JSON
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = response.json()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
data = {}
|
|
|
|
|
|
|
|
|
|
|
|
# 将预处理获取的额外数据合并到响应中
|
|
|
|
|
|
if "_combined_data" in merged_config:
|
|
|
|
|
|
data["_combined_data"] = merged_config["_combined_data"]
|
|
|
|
|
|
elif "_balance_data" in merged_config:
|
|
|
|
|
|
data["_balance_data"] = merged_config["_balance_data"]
|
|
|
|
|
|
|
|
|
|
|
|
# 使用架构的方法解析响应
|
|
|
|
|
|
result = architecture.parse_verify_response(response.status_code, data)
|
|
|
|
|
|
result_dict = result.to_dict()
|
|
|
|
|
|
|
|
|
|
|
|
# 将凭据更新信息附加到响应,供前端同步更新表单
|
|
|
|
|
|
# 过滤掉内部缓存字段(以 _ 开头),前端不需要这些
|
|
|
|
|
|
frontend_creds = {k: v for k, v in updated_creds.items() if not k.startswith("_")}
|
|
|
|
|
|
if frontend_creds:
|
|
|
|
|
|
result_dict["updated_credentials"] = frontend_creds
|
|
|
|
|
|
|
|
|
|
|
|
# 验证成功且有 provider_id 时,缓存余额
|
|
|
|
|
|
if result.success and provider_id and result.quota is not None:
|
|
|
|
|
|
# 从架构的默认配置获取 quota_divisor
|
|
|
|
|
|
balance_config = architecture.default_action_configs.get(
|
|
|
|
|
|
ProviderActionType.QUERY_BALANCE, {}
|
|
|
|
|
|
)
|
|
|
|
|
|
quota_divisor = balance_config.get("quota_divisor", 1)
|
|
|
|
|
|
# 转换为美元值后缓存
|
|
|
|
|
|
quota_usd = result.quota / quota_divisor
|
|
|
|
|
|
# 传入 extra 信息(如窗口限额)
|
|
|
|
|
|
await self._cache_balance_from_verify(provider_id, quota_usd, result.extra)
|
2026-01-18 13:00:11 +08:00
|
|
|
|
|
2026-03-21 12:57:09 +08:00
|
|
|
|
return result_dict
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
2026-03-20 01:22:07 +08:00
|
|
|
|
except ValueError as e:
|
|
|
|
|
|
return {"success": False, "message": str(e)}
|
2026-01-17 19:50:35 +08:00
|
|
|
|
except httpx.TimeoutException:
|
|
|
|
|
|
return {"success": False, "message": "连接超时"}
|
|
|
|
|
|
except httpx.ConnectError as e:
|
|
|
|
|
|
return {"success": False, "message": f"连接失败: {str(e)}"}
|
|
|
|
|
|
except Exception as e:
|
2026-02-15 16:32:23 +08:00
|
|
|
|
logger.error("验证认证失败: {}", e)
|
2026-01-17 19:50:35 +08:00
|
|
|
|
return {"success": False, "message": f"验证失败: {str(e)}"}
|