mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构
- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/ - 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层 - 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构 - 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations) - 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image - 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
This commit is contained in:
39
_deprecated_py_src/services/provider_ops/__init__.py
Normal file
39
_deprecated_py_src/services/provider_ops/__init__.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
Provider 操作模块
|
||||
|
||||
提供对提供商的扩展操作支持:
|
||||
- 多种鉴权方式(API Key、登录、Cookie)
|
||||
- 可扩展的操作类型(余额查询、签到等)
|
||||
"""
|
||||
|
||||
from src.services.provider_ops.registry import ArchitectureRegistry, get_registry
|
||||
from src.services.provider_ops.service import ProviderOpsService
|
||||
from src.services.provider_ops.types import (
|
||||
ActionResult,
|
||||
ActionStatus,
|
||||
BalanceInfo,
|
||||
CheckinInfo,
|
||||
ConnectorAuthType,
|
||||
ConnectorState,
|
||||
ConnectorStatus,
|
||||
ProviderActionType,
|
||||
ProviderOpsConfig,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# 服务
|
||||
"ProviderOpsService",
|
||||
# 注册表
|
||||
"ArchitectureRegistry",
|
||||
"get_registry",
|
||||
# 类型
|
||||
"ActionResult",
|
||||
"ActionStatus",
|
||||
"BalanceInfo",
|
||||
"CheckinInfo",
|
||||
"ConnectorAuthType",
|
||||
"ConnectorState",
|
||||
"ConnectorStatus",
|
||||
"ProviderActionType",
|
||||
"ProviderOpsConfig",
|
||||
]
|
||||
25
_deprecated_py_src/services/provider_ops/actions/__init__.py
Normal file
25
_deprecated_py_src/services/provider_ops/actions/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
Provider 操作模块
|
||||
"""
|
||||
|
||||
from src.services.provider_ops.actions.anyrouter_balance import AnyrouterBalanceAction
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.actions.base import ProviderAction
|
||||
from src.services.provider_ops.actions.checkin import CheckinAction
|
||||
from src.services.provider_ops.actions.cubence_balance import CubenceBalanceAction
|
||||
from src.services.provider_ops.actions.nekocode_balance import NekoCodeBalanceAction
|
||||
from src.services.provider_ops.actions.new_api_balance import NewApiBalanceAction
|
||||
from src.services.provider_ops.actions.sub2api_balance import Sub2ApiBalanceAction
|
||||
from src.services.provider_ops.actions.yescode_balance import YesCodeBalanceAction
|
||||
|
||||
__all__ = [
|
||||
"ProviderAction",
|
||||
"BalanceAction",
|
||||
"CheckinAction",
|
||||
"NewApiBalanceAction",
|
||||
"AnyrouterBalanceAction",
|
||||
"CubenceBalanceAction",
|
||||
"NekoCodeBalanceAction",
|
||||
"Sub2ApiBalanceAction",
|
||||
"YesCodeBalanceAction",
|
||||
]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Anyrouter 余额查询操作(含自动签到)
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.types import BalanceInfo
|
||||
|
||||
|
||||
class AnyrouterBalanceAction(BalanceAction):
|
||||
"""
|
||||
Anyrouter 余额查询
|
||||
|
||||
特点:
|
||||
- 查询余额前始终自动签到
|
||||
- 签到端点为 /api/user/sign_in
|
||||
- Cookie 失效时返回友好的错误提示
|
||||
- quota 单位是 1/500000 美元(与 New API 相同)
|
||||
"""
|
||||
|
||||
display_name = "查询余额(含自动签到)"
|
||||
description = "查询账户余额,同时自动签到"
|
||||
|
||||
_cookie_auth = True
|
||||
|
||||
def _parse_balance(self, data: Any) -> BalanceInfo:
|
||||
"""解析余额信息"""
|
||||
user_data = data.get("data", {}) if isinstance(data, dict) else {}
|
||||
quota_divisor = self.config.get("quota_divisor", 500000)
|
||||
|
||||
# 注意:Anyrouter 中 quota 是剩余额度(total_available),不是总额度
|
||||
raw_quota = self._to_float(user_data.get("quota"))
|
||||
raw_used = self._to_float(user_data.get("used_quota"))
|
||||
|
||||
total_available = raw_quota / quota_divisor if raw_quota is not None else None
|
||||
total_used = raw_used / quota_divisor if raw_used is not None else None
|
||||
|
||||
return self._create_balance_info(
|
||||
total_available=total_available,
|
||||
total_used=total_used,
|
||||
currency=self.config.get("currency", "USD"),
|
||||
)
|
||||
|
||||
async def _do_checkin(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
|
||||
"""
|
||||
执行自动签到(始终执行)
|
||||
|
||||
Returns:
|
||||
签到结果字典,包含 success 和 message 字段
|
||||
"""
|
||||
checkin_endpoint = self.config.get("checkin_endpoint", "/api/user/sign_in")
|
||||
site = client.base_url.host or str(client.base_url)
|
||||
|
||||
try:
|
||||
response = await client.post(checkin_endpoint)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
success = data.get("success", False)
|
||||
message = data.get("message", "")
|
||||
|
||||
if success:
|
||||
logger.debug(f"[{site}] 签到成功: {message}")
|
||||
return {"success": True, "message": message or "签到成功"}
|
||||
else:
|
||||
already_indicators = ["已签到", "已签", "今日已", "already"]
|
||||
is_already = any(ind in message.lower() for ind in already_indicators)
|
||||
if is_already:
|
||||
logger.debug(f"[{site}] 今日已签到: {message}")
|
||||
return {"success": None, "message": message or "今日已签到"}
|
||||
else:
|
||||
logger.debug(f"[{site}] 签到失败: {message}")
|
||||
return {"success": False, "message": message or "签到失败"}
|
||||
except Exception as e:
|
||||
logger.debug(f"[{site}] 签到响应解析失败: {e}")
|
||||
return {"success": False, "message": "响应解析失败"}
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"[{site}] 签到异常: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
307
_deprecated_py_src/services/provider_ops/actions/balance.py
Normal file
307
_deprecated_py_src/services/provider_ops/actions/balance.py
Normal file
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
余额查询操作抽象基类
|
||||
"""
|
||||
|
||||
import time
|
||||
from abc import abstractmethod
|
||||
from typing import Any
|
||||
|
||||
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.types import (
|
||||
ActionResult,
|
||||
ActionStatus,
|
||||
BalanceInfo,
|
||||
ProviderActionType,
|
||||
)
|
||||
|
||||
# 签到缓存 TTL(6 小时)
|
||||
_CHECKIN_CACHE_TTL = 6 * 3600
|
||||
|
||||
|
||||
class BalanceAction(ProviderAction):
|
||||
"""
|
||||
余额查询操作抽象基类
|
||||
|
||||
子类必须实现 _parse_balance() 方法来处理特定平台的余额解析逻辑。
|
||||
子类可选重写 _do_query_balance() 或 _do_checkin() 进行自定义。
|
||||
|
||||
如果子类使用 Cookie 认证,设置 _cookie_auth = True 可让 401/403 错误
|
||||
显示 "Cookie 已失效" 而非 "认证失败"。
|
||||
"""
|
||||
|
||||
action_type = ProviderActionType.QUERY_BALANCE
|
||||
display_name = "查询余额"
|
||||
description = "查询账户余额信息"
|
||||
default_cache_ttl = 86400 # 24 小时
|
||||
|
||||
# 子类设为 True 即可在 401/403 时显示 "Cookie 已失效" 消息
|
||||
_cookie_auth: bool = False
|
||||
|
||||
async def execute(self, client: httpx.AsyncClient) -> ActionResult:
|
||||
"""
|
||||
执行余额查询(模板方法)
|
||||
|
||||
1. 先尝试签到(如果子类实现了 _do_checkin),带缓存冷却
|
||||
2. 执行余额查询
|
||||
|
||||
Args:
|
||||
client: 已认证的 HTTP 客户端
|
||||
|
||||
Returns:
|
||||
ActionResult,其中 data 字段为 BalanceInfo
|
||||
"""
|
||||
# 先尝试签到(带缓存冷却)
|
||||
checkin_result = await self._get_or_do_checkin(client)
|
||||
|
||||
# 执行余额查询
|
||||
result = await self._do_query_balance(client)
|
||||
|
||||
# 将签到结果附加到 extra 字段
|
||||
if checkin_result and result.data and hasattr(result.data, "extra"):
|
||||
if result.data.extra is None:
|
||||
result.data.extra = {}
|
||||
# 处理 cookie_expired 标记
|
||||
if checkin_result.get("cookie_expired"):
|
||||
result.data.extra["cookie_expired"] = True
|
||||
result.data.extra["cookie_expired_message"] = checkin_result.get("message", "")
|
||||
result.status = ActionStatus.AUTH_EXPIRED
|
||||
_logger.warning("Cookie 已失效: {}", checkin_result)
|
||||
else:
|
||||
result.data.extra["checkin_success"] = checkin_result.get("success")
|
||||
result.data.extra["checkin_message"] = checkin_result.get("message", "")
|
||||
_logger.debug("签到结果已附加到 extra: {}", checkin_result)
|
||||
|
||||
return result
|
||||
|
||||
async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult:
|
||||
"""
|
||||
执行余额查询
|
||||
|
||||
默认实现处理通用的请求/响应/错误处理流程。
|
||||
子类只需实现 _parse_balance() 即可。
|
||||
如果查询逻辑不同(如并发调用多个接口),子类可重写此方法。
|
||||
|
||||
Args:
|
||||
client: 已认证的 HTTP 客户端
|
||||
|
||||
Returns:
|
||||
ActionResult,其中 data 字段为 BalanceInfo
|
||||
"""
|
||||
endpoint = self.config.get("endpoint", "/api/user/self")
|
||||
method = self.config.get("method", "GET")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
response = await client.request(method, endpoint)
|
||||
response_time_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except Exception:
|
||||
return self._make_error_result(
|
||||
ActionStatus.PARSE_ERROR,
|
||||
"响应不是有效的 JSON",
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return self._handle_http_error(response, data)
|
||||
|
||||
if data.get("success") is False:
|
||||
message = data.get("message", "业务状态码表示失败")
|
||||
return self._make_error_result(
|
||||
ActionStatus.UNKNOWN_ERROR,
|
||||
message,
|
||||
raw_response=data,
|
||||
)
|
||||
|
||||
balance = self._parse_balance(data)
|
||||
|
||||
return self._make_success_result(
|
||||
data=balance,
|
||||
response_time_ms=response_time_ms,
|
||||
raw_response=data,
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
return self._make_error_result(
|
||||
ActionStatus.NETWORK_ERROR,
|
||||
"请求超时",
|
||||
retry_after_seconds=30,
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
return self._make_error_result(
|
||||
ActionStatus.NETWORK_ERROR,
|
||||
f"网络错误: {str(e)}",
|
||||
retry_after_seconds=30,
|
||||
)
|
||||
except Exception as e:
|
||||
return self._make_error_result(
|
||||
ActionStatus.UNKNOWN_ERROR,
|
||||
f"未知错误: {str(e)}",
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def _parse_balance(self, data: Any) -> BalanceInfo:
|
||||
"""
|
||||
解析余额数据(子类必须实现)
|
||||
|
||||
Args:
|
||||
data: API 响应 JSON 数据
|
||||
|
||||
Returns:
|
||||
BalanceInfo 对象
|
||||
"""
|
||||
pass
|
||||
|
||||
def _handle_http_error(
|
||||
self, response: httpx.Response, raw_data: dict[str, Any] | None = None
|
||||
) -> ActionResult:
|
||||
"""
|
||||
处理 HTTP 错误响应
|
||||
|
||||
Cookie 认证的子类设置 _cookie_auth = True 即可获得友好的错误提示,
|
||||
无需再逐个重写此方法。
|
||||
"""
|
||||
status_code = response.status_code
|
||||
|
||||
if status_code == 401:
|
||||
msg = "Cookie 已失效,请重新配置" if self._cookie_auth else "认证失败"
|
||||
return self._make_error_result(ActionStatus.AUTH_FAILED, msg, raw_response=raw_data)
|
||||
elif status_code == 403:
|
||||
msg = "Cookie 已失效或无权限" if self._cookie_auth else "无权限访问"
|
||||
return self._make_error_result(ActionStatus.AUTH_FAILED, msg, raw_response=raw_data)
|
||||
elif status_code == 404:
|
||||
return self._make_error_result(
|
||||
ActionStatus.NOT_SUPPORTED, "功能未开放", raw_response=raw_data
|
||||
)
|
||||
elif status_code == 429:
|
||||
retry_after = response.headers.get("Retry-After")
|
||||
return self._make_error_result(
|
||||
ActionStatus.RATE_LIMITED,
|
||||
"请求频率限制",
|
||||
retry_after_seconds=int(retry_after) if retry_after else 60,
|
||||
raw_response=raw_data,
|
||||
)
|
||||
else:
|
||||
return self._make_error_result(
|
||||
ActionStatus.UNKNOWN_ERROR,
|
||||
f"HTTP {status_code}: {response.reason_phrase}",
|
||||
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 = str(client.base_url.host or client.base_url.netloc or 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:
|
||||
"""
|
||||
执行签到(子类可选实现)
|
||||
|
||||
默认实现返回 None(不签到)。
|
||||
子类可重写此方法实现平台特定的签到逻辑。
|
||||
|
||||
Args:
|
||||
client: 已认证的 HTTP 客户端
|
||||
|
||||
Returns:
|
||||
签到结果字典 {"success": bool, "message": str},或 None 表示不签到
|
||||
"""
|
||||
return None
|
||||
|
||||
def _create_balance_info(
|
||||
self,
|
||||
total_granted: float | None = None,
|
||||
total_used: float | None = None,
|
||||
total_available: float | None = None,
|
||||
currency: str = "USD",
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> BalanceInfo:
|
||||
"""
|
||||
创建余额信息对象
|
||||
|
||||
辅助方法,用于创建统一格式的 BalanceInfo。
|
||||
如果只有部分数据,会尝试计算缺失的值。
|
||||
|
||||
Args:
|
||||
total_granted: 总额度
|
||||
total_used: 已用额度
|
||||
total_available: 可用余额
|
||||
currency: 货币单位
|
||||
extra: 额外信息
|
||||
|
||||
Returns:
|
||||
BalanceInfo 对象
|
||||
"""
|
||||
# 如果只有部分数据,尝试计算
|
||||
if total_available is None and total_granted is not None and total_used is not None:
|
||||
total_available = total_granted - total_used
|
||||
if total_used is None and total_granted is not None and total_available is not None:
|
||||
total_used = total_granted - total_available
|
||||
if total_granted is None and total_used is not None and total_available is not None:
|
||||
total_granted = total_used + total_available
|
||||
|
||||
return BalanceInfo(
|
||||
total_granted=total_granted,
|
||||
total_used=total_used,
|
||||
total_available=total_available,
|
||||
currency=currency,
|
||||
extra=extra if extra is not None else {},
|
||||
)
|
||||
|
||||
def _to_float(self, value: Any) -> float | None:
|
||||
"""转换为浮点数"""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> dict[str, Any]:
|
||||
"""获取操作配置 schema(子类可重写)"""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"title": "货币单位",
|
||||
"default": "USD",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
161
_deprecated_py_src/services/provider_ops/actions/base.py
Normal file
161
_deprecated_py_src/services/provider_ops/actions/base.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Provider 操作抽象基类
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.services.provider_ops.types import (
|
||||
ActionResult,
|
||||
ActionStatus,
|
||||
ProviderActionType,
|
||||
)
|
||||
|
||||
|
||||
class ProviderAction(ABC):
|
||||
"""
|
||||
提供商操作基类
|
||||
|
||||
定义具体的操作逻辑(如查询余额、签到等)。
|
||||
"""
|
||||
|
||||
# 子类需要定义的类属性
|
||||
action_type: ProviderActionType = ProviderActionType.CUSTOM
|
||||
display_name: str = "Base Action"
|
||||
description: str = ""
|
||||
|
||||
# 默认缓存时间(秒)
|
||||
default_cache_ttl: int = 300
|
||||
|
||||
def __init__(self, config: dict[str, Any] | None = None):
|
||||
"""
|
||||
初始化操作
|
||||
|
||||
Args:
|
||||
config: 操作配置
|
||||
"""
|
||||
self.config = config or {}
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, client: httpx.AsyncClient) -> ActionResult:
|
||||
"""
|
||||
执行操作
|
||||
|
||||
Args:
|
||||
client: 已认证的 HTTP 客户端
|
||||
|
||||
Returns:
|
||||
操作结果
|
||||
"""
|
||||
pass
|
||||
|
||||
def _extract_field(self, data: Any, path: str | None) -> Any:
|
||||
"""
|
||||
从响应数据中提取字段
|
||||
|
||||
支持点号分隔的路径,如 "data.user.balance"
|
||||
|
||||
Args:
|
||||
data: 响应数据
|
||||
path: 字段路径
|
||||
|
||||
Returns:
|
||||
提取的值,如果路径无效则返回 None
|
||||
"""
|
||||
if not path:
|
||||
return None
|
||||
|
||||
current = data
|
||||
for key in path.split("."):
|
||||
if isinstance(current, dict):
|
||||
current = current.get(key)
|
||||
elif isinstance(current, list) and key.isdigit():
|
||||
index = int(key)
|
||||
current = current[index] if 0 <= index < len(current) else None
|
||||
else:
|
||||
return None
|
||||
|
||||
if current is None:
|
||||
return None
|
||||
|
||||
return current
|
||||
|
||||
def _make_success_result(
|
||||
self,
|
||||
data: Any = None,
|
||||
message: str | None = None,
|
||||
response_time_ms: int | None = None,
|
||||
raw_response: dict[str, Any] | None = None,
|
||||
) -> ActionResult:
|
||||
"""创建成功结果"""
|
||||
return ActionResult(
|
||||
status=ActionStatus.SUCCESS,
|
||||
action_type=self.action_type,
|
||||
data=data,
|
||||
message=message,
|
||||
response_time_ms=response_time_ms,
|
||||
raw_response=raw_response,
|
||||
cache_ttl_seconds=self.default_cache_ttl,
|
||||
)
|
||||
|
||||
def _make_error_result(
|
||||
self,
|
||||
status: ActionStatus,
|
||||
message: str | None = None,
|
||||
retry_after_seconds: int | None = None,
|
||||
raw_response: dict[str, Any] | None = None,
|
||||
) -> ActionResult:
|
||||
"""创建错误结果"""
|
||||
return ActionResult(
|
||||
status=status,
|
||||
action_type=self.action_type,
|
||||
message=message,
|
||||
retry_after_seconds=retry_after_seconds,
|
||||
raw_response=raw_response,
|
||||
cache_ttl_seconds=0, # 错误不缓存
|
||||
)
|
||||
|
||||
def _handle_http_error(
|
||||
self, response: httpx.Response, raw_data: dict[str, Any] | None = None
|
||||
) -> ActionResult:
|
||||
"""处理 HTTP 错误响应"""
|
||||
status_code = response.status_code
|
||||
|
||||
if status_code == 401:
|
||||
return self._make_error_result(
|
||||
ActionStatus.AUTH_FAILED, "认证失败", raw_response=raw_data
|
||||
)
|
||||
elif status_code == 403:
|
||||
return self._make_error_result(
|
||||
ActionStatus.AUTH_FAILED, "无权限访问", raw_response=raw_data
|
||||
)
|
||||
elif status_code == 404:
|
||||
# 404 表示接口不存在,通常意味着该功能未开放
|
||||
return self._make_error_result(
|
||||
ActionStatus.NOT_SUPPORTED, "功能未开放", raw_response=raw_data
|
||||
)
|
||||
elif status_code == 429:
|
||||
retry_after = response.headers.get("Retry-After")
|
||||
return self._make_error_result(
|
||||
ActionStatus.RATE_LIMITED,
|
||||
"请求频率限制",
|
||||
retry_after_seconds=int(retry_after) if retry_after else 60,
|
||||
raw_response=raw_data,
|
||||
)
|
||||
else:
|
||||
return self._make_error_result(
|
||||
ActionStatus.UNKNOWN_ERROR,
|
||||
f"HTTP {status_code}: {response.reason_phrase}",
|
||||
raw_response=raw_data,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> dict[str, Any]:
|
||||
"""
|
||||
获取操作配置 JSON Schema(用于前端表单生成)
|
||||
|
||||
子类应重写此方法
|
||||
"""
|
||||
return {"type": "object", "properties": {}, "required": []}
|
||||
80
_deprecated_py_src/services/provider_ops/actions/checkin.py
Normal file
80
_deprecated_py_src/services/provider_ops/actions/checkin.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
签到操作抽象基类
|
||||
"""
|
||||
|
||||
from abc import abstractmethod
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.services.provider_ops.actions.base import ProviderAction
|
||||
from src.services.provider_ops.types import (
|
||||
ActionResult,
|
||||
CheckinInfo,
|
||||
ProviderActionType,
|
||||
)
|
||||
|
||||
|
||||
class CheckinAction(ProviderAction):
|
||||
"""
|
||||
签到操作抽象基类
|
||||
|
||||
子类必须实现 execute() 方法来处理特定平台的签到逻辑。
|
||||
"""
|
||||
|
||||
action_type = ProviderActionType.CHECKIN
|
||||
display_name = "签到"
|
||||
description = "每日签到领取额度"
|
||||
default_cache_ttl = 3600 # 签到结果缓存 1 小时
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, client: httpx.AsyncClient) -> ActionResult:
|
||||
"""
|
||||
执行签到
|
||||
|
||||
子类必须实现此方法。
|
||||
|
||||
Args:
|
||||
client: 已认证的 HTTP 客户端
|
||||
|
||||
Returns:
|
||||
ActionResult,其中 data 字段为 CheckinInfo
|
||||
"""
|
||||
pass
|
||||
|
||||
def _create_checkin_info(
|
||||
self,
|
||||
reward: float | None = None,
|
||||
streak_days: int | None = None,
|
||||
message: str | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> CheckinInfo:
|
||||
"""
|
||||
创建签到信息对象
|
||||
|
||||
辅助方法,用于创建统一格式的 CheckinInfo。
|
||||
|
||||
Args:
|
||||
reward: 签到奖励额度
|
||||
streak_days: 连续签到天数
|
||||
message: 签到消息
|
||||
extra: 额外信息
|
||||
|
||||
Returns:
|
||||
CheckinInfo 对象
|
||||
"""
|
||||
return CheckinInfo(
|
||||
reward=reward,
|
||||
streak_days=streak_days,
|
||||
message=message,
|
||||
extra=extra if extra is not None else {},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> dict[str, Any]:
|
||||
"""获取操作配置 schema(子类可重写)"""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Cubence 余额查询操作
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.types import BalanceInfo
|
||||
|
||||
|
||||
class CubenceBalanceAction(BalanceAction):
|
||||
"""
|
||||
Cubence 专用余额查询
|
||||
|
||||
特点:
|
||||
- 余额单位直接是美元
|
||||
- 支持窗口限额查询(5小时/每周)
|
||||
- Cookie 失效时返回友好的错误提示
|
||||
"""
|
||||
|
||||
display_name = "查询余额(含窗口限额)"
|
||||
description = "查询账户余额和窗口限额信息"
|
||||
|
||||
_cookie_auth = True
|
||||
|
||||
def _parse_balance(self, data: Any) -> BalanceInfo:
|
||||
"""解析 Cubence 余额信息"""
|
||||
# Cubence 响应格式:data.balance 和 data.subscription_limits
|
||||
response_data = data.get("data", {}) if isinstance(data, dict) else {}
|
||||
balance_data = response_data.get("balance", {})
|
||||
subscription_limits = response_data.get("subscription_limits", {})
|
||||
|
||||
# 余额信息(单位直接是美元)
|
||||
total_available = balance_data.get("total_balance_dollar")
|
||||
normal_balance = balance_data.get("normal_balance_dollar")
|
||||
subscription_balance = balance_data.get("subscription_balance_dollar")
|
||||
charity_balance = balance_data.get("charity_balance_dollar")
|
||||
|
||||
# 窗口限额信息
|
||||
extra: dict[str, Any] = {}
|
||||
|
||||
# 5小时窗口限额
|
||||
five_hour = subscription_limits.get("five_hour", {})
|
||||
if five_hour:
|
||||
extra["five_hour_limit"] = {
|
||||
"limit": five_hour.get("limit"),
|
||||
"used": five_hour.get("used"),
|
||||
"remaining": five_hour.get("remaining"),
|
||||
"resets_at": five_hour.get("resets_at"),
|
||||
}
|
||||
|
||||
# 每周窗口限额
|
||||
weekly = subscription_limits.get("weekly", {})
|
||||
if weekly:
|
||||
extra["weekly_limit"] = {
|
||||
"limit": weekly.get("limit"),
|
||||
"used": weekly.get("used"),
|
||||
"remaining": weekly.get("remaining"),
|
||||
"resets_at": weekly.get("resets_at"),
|
||||
}
|
||||
|
||||
# 余额组成
|
||||
if normal_balance is not None:
|
||||
extra["normal_balance"] = normal_balance
|
||||
if subscription_balance is not None:
|
||||
extra["subscription_balance"] = subscription_balance
|
||||
if charity_balance is not None:
|
||||
extra["charity_balance"] = charity_balance
|
||||
|
||||
return self._create_balance_info(
|
||||
total_available=total_available,
|
||||
currency=self.config.get("currency", "USD"),
|
||||
extra=extra if extra else None,
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
NekoCode 余额查询操作
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.types import BalanceInfo
|
||||
|
||||
|
||||
class NekoCodeBalanceAction(BalanceAction):
|
||||
"""
|
||||
NekoCode 余额查询
|
||||
|
||||
特点:
|
||||
- 查询余额和订阅信息
|
||||
- 显示每日配额限制和剩余
|
||||
- 显示订阅状态和有效期
|
||||
- 余额单位为积分
|
||||
"""
|
||||
|
||||
display_name = "查询余额"
|
||||
description = "查询 NekoCode 账户余额和订阅信息"
|
||||
|
||||
_cookie_auth = True
|
||||
|
||||
def _parse_balance(self, data: Any) -> BalanceInfo:
|
||||
"""解析余额信息"""
|
||||
response_data = data.get("data", {}) if isinstance(data, dict) else {}
|
||||
subscription = response_data.get("subscription", {})
|
||||
|
||||
# 解析余额(积分)
|
||||
balance = self._to_float(response_data.get("balance"))
|
||||
|
||||
# 解析每日配额
|
||||
daily_quota_limit = self._to_float(subscription.get("daily_quota_limit"))
|
||||
daily_remaining_quota = self._to_float(subscription.get("daily_remaining_quota"))
|
||||
|
||||
# 计算每日已用配额
|
||||
daily_used = None
|
||||
if daily_quota_limit is not None and daily_remaining_quota is not None:
|
||||
daily_used = daily_quota_limit - daily_remaining_quota
|
||||
|
||||
# 解析订阅信息
|
||||
plan_name = subscription.get("plan_name")
|
||||
status = subscription.get("status")
|
||||
effective_start_date = subscription.get("effective_start_date")
|
||||
effective_end_date = subscription.get("effective_end_date")
|
||||
|
||||
# 解析日期
|
||||
expires_at = None
|
||||
refresh_at = None
|
||||
if effective_end_date:
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(effective_end_date)
|
||||
except ValueError as e:
|
||||
logger.debug(f"解析 effective_end_date 失败: {e}")
|
||||
|
||||
if effective_start_date:
|
||||
try:
|
||||
refresh_at = datetime.fromisoformat(effective_start_date)
|
||||
except ValueError as e:
|
||||
logger.debug(f"解析 effective_start_date 失败: {e}")
|
||||
|
||||
# 构建 extra 信息
|
||||
extra: dict[str, Any] = {
|
||||
"plan_name": plan_name,
|
||||
"subscription_status": status,
|
||||
"daily_quota_limit": daily_quota_limit,
|
||||
"daily_remaining_quota": daily_remaining_quota,
|
||||
"daily_used_quota": daily_used,
|
||||
"effective_start_date": effective_start_date,
|
||||
"effective_end_date": effective_end_date,
|
||||
}
|
||||
|
||||
# 添加刷新时间信息
|
||||
if refresh_at:
|
||||
extra["refresh_at"] = refresh_at.isoformat()
|
||||
extra["refresh_at_display"] = refresh_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# 添加月度统计
|
||||
month_data = response_data.get("month", {})
|
||||
if month_data:
|
||||
extra["month_stats"] = {
|
||||
"total_input_tokens": month_data.get("total_input_tokens"),
|
||||
"total_output_tokens": month_data.get("total_output_tokens"),
|
||||
"total_quota": month_data.get("total_quota"),
|
||||
"total_requests": month_data.get("total_requests"),
|
||||
}
|
||||
|
||||
# 添加今日统计
|
||||
today_data = response_data.get("today", {})
|
||||
if today_data:
|
||||
extra["today_stats"] = today_data.get("stats", [])
|
||||
|
||||
return self._create_balance_info(
|
||||
total_available=balance,
|
||||
total_granted=daily_quota_limit, # 每日配额作为总额度
|
||||
total_used=daily_used, # 每日已用
|
||||
currency="USD", # NekoCode 使用美元单位
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> dict[str, Any]:
|
||||
"""获取操作配置 schema"""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"endpoint": {
|
||||
"type": "string",
|
||||
"title": "API 端点",
|
||||
"default": "/api/usage/summary",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
New API 余额查询操作
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.types import BalanceInfo
|
||||
|
||||
|
||||
class NewApiBalanceAction(BalanceAction):
|
||||
"""
|
||||
New API 风格的余额查询
|
||||
|
||||
特点:
|
||||
- 使用 /api/user/self 端点
|
||||
- quota 单位是 1/500000 美元
|
||||
- 支持查询前自动签到(通过基类的模板方法)
|
||||
"""
|
||||
|
||||
display_name = "查询余额"
|
||||
description = "查询 New API 账户余额信息"
|
||||
|
||||
def _parse_balance(
|
||||
self,
|
||||
data: Any,
|
||||
) -> BalanceInfo:
|
||||
"""解析 New API 余额信息"""
|
||||
# New API 响应格式: {"success": true, "data": {...}}
|
||||
user_data = data.get("data", {}) if isinstance(data, dict) else {}
|
||||
|
||||
# 获取 quota 除数(默认 500000,New API 的标准)
|
||||
quota_divisor = self.config.get("quota_divisor", 500000)
|
||||
|
||||
# 提取原始值
|
||||
# 注意:New API 中 quota 是剩余额度(total_available),不是总额度
|
||||
raw_quota = self._to_float(user_data.get("quota"))
|
||||
raw_used = self._to_float(user_data.get("used_quota"))
|
||||
|
||||
# 转换为美元
|
||||
total_available = raw_quota / quota_divisor if raw_quota is not None else None
|
||||
total_used = raw_used / quota_divisor if raw_used is not None else None
|
||||
|
||||
return self._create_balance_info(
|
||||
total_available=total_available,
|
||||
total_used=total_used,
|
||||
currency=self.config.get("currency", "USD"),
|
||||
)
|
||||
|
||||
async def _do_checkin(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
|
||||
"""
|
||||
执行签到(静默,不抛出异常)
|
||||
|
||||
New API 签到通常需要认证(Cookie 或 API Key + New-Api-User)。
|
||||
失败时仅记录日志,不影响余额查询。
|
||||
|
||||
Returns:
|
||||
签到结果字典,包含 success 和 message 字段;
|
||||
如果功能未开放返回 None;
|
||||
如果 Cookie 失效返回 {"cookie_expired": True}
|
||||
"""
|
||||
site = client.base_url.host or str(client.base_url)
|
||||
checkin_endpoint = self.config.get("checkin_endpoint", "/api/user/checkin")
|
||||
|
||||
# 检查是否配置了 Cookie(通过 service 层注入的 _has_cookie 标志)
|
||||
has_cookie = self.config.get("_has_cookie", False)
|
||||
if not has_cookie:
|
||||
logger.debug(f"[{site}] 未配置 Cookie,尝试使用 API Key 认证签到")
|
||||
|
||||
try:
|
||||
response = await client.post(checkin_endpoint)
|
||||
|
||||
# 404 表示签到功能未开放
|
||||
if response.status_code == 404:
|
||||
logger.debug(f"[{site}] 签到功能未开放")
|
||||
return None
|
||||
|
||||
# 401/403 通常表示未授权;Cookie 模式下大多意味着 Cookie 已失效
|
||||
if response.status_code in (401, 403):
|
||||
# 只有在明确配置了 Cookie 的情况下,才标记为 Cookie 失效。
|
||||
# API Key 模式下的 401/403 更可能表示该站点不支持该认证方式签到。
|
||||
if has_cookie:
|
||||
logger.warning(f"[{site}] Cookie 已失效(签到返回 {response.status_code})")
|
||||
return {"cookie_expired": True, "message": "Cookie 已失效"}
|
||||
logger.debug(
|
||||
f"[{site}] 签到认证失败({response.status_code}),跳过签到结果上报"
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
message = data.get("message", "")
|
||||
success = data.get("success", False)
|
||||
message_lower = str(message).lower()
|
||||
|
||||
if success:
|
||||
logger.debug(f"[{site}] 签到成功: {message}")
|
||||
return {"success": True, "message": message or "签到成功"}
|
||||
else:
|
||||
# 检查是否是"已签到"的情况
|
||||
already_indicators = ["already", "已签到", "已经签到", "今日已签", "重复签到"]
|
||||
is_already = any(ind.lower() in message_lower for ind in already_indicators)
|
||||
if is_already:
|
||||
logger.debug(f"[{site}] 今日已签到: {message}")
|
||||
return {"success": None, "message": message or "今日已签到"}
|
||||
|
||||
# 检查是否是认证失败(未登录、无权限、验证码等)
|
||||
auth_fail_indicators = [
|
||||
"未登录",
|
||||
"请登录",
|
||||
"login",
|
||||
"unauthorized",
|
||||
"无权限",
|
||||
"权限不足",
|
||||
"turnstile",
|
||||
"captcha",
|
||||
"验证码", # 需要人机验证
|
||||
]
|
||||
is_auth_fail = any(ind.lower() in message_lower for ind in auth_fail_indicators)
|
||||
if is_auth_fail:
|
||||
# Cookie 模式下,这类提示通常意味着 Cookie 已失效或需要重新登录。
|
||||
if has_cookie:
|
||||
logger.warning(f"[{site}] Cookie 已失效(签到认证失败): {message}")
|
||||
return {"cookie_expired": True, "message": message or "Cookie 已失效"}
|
||||
|
||||
# API Key 模式下,站点可能不支持该认证方式签到;保持静默不影响余额查询。
|
||||
logger.debug(f"[{site}] 签到认证失败(API Key 模式),跳过签到: {message}")
|
||||
return None
|
||||
|
||||
# 其他失败情况
|
||||
logger.debug(f"[{site}] 签到失败: {message}")
|
||||
return {"success": False, "message": message or "签到失败"}
|
||||
except Exception as e:
|
||||
logger.debug(f"[{site}] 签到响应解析失败: {e}")
|
||||
return {"success": False, "message": "响应解析失败"}
|
||||
|
||||
except Exception as e:
|
||||
# 签到失败不影响余额查询
|
||||
logger.debug(f"[{site}] 签到请求失败(不影响余额查询): {e}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> dict[str, Any]:
|
||||
"""获取操作配置 schema"""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"endpoint": {
|
||||
"type": "string",
|
||||
"title": "API 路径",
|
||||
"description": "余额查询 API 路径",
|
||||
"default": "/api/user/self",
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"title": "请求方法",
|
||||
"enum": ["GET", "POST"],
|
||||
"default": "GET",
|
||||
},
|
||||
"quota_divisor": {
|
||||
"type": "number",
|
||||
"title": "额度除数",
|
||||
"description": "将原始额度值转换为美元的除数",
|
||||
"default": 500000,
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"title": "货币单位",
|
||||
"default": "USD",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Sub2API 余额查询操作
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.types import ActionResult, ActionStatus, BalanceInfo
|
||||
|
||||
|
||||
class Sub2ApiBalanceAction(BalanceAction):
|
||||
"""
|
||||
Sub2API 余额查询
|
||||
|
||||
特点:
|
||||
- 并发调用 /api/v1/auth/me 和 /api/v1/subscriptions/summary
|
||||
- auth/me 提供基础余额(balance + points)
|
||||
- subscriptions/summary 提供订阅详情(各订阅的日/周/月用量和额度)
|
||||
- 响应格式: {"code": 0, "message": "success", "data": {...}}
|
||||
"""
|
||||
|
||||
display_name = "查询余额"
|
||||
description = "查询 Sub2API 账户余额和订阅信息"
|
||||
|
||||
def _parse_balance(self, data: Any) -> BalanceInfo:
|
||||
"""本类完全重写了 _do_query_balance,绕过基类默认流程,故此方法不会被调用"""
|
||||
raise NotImplementedError(
|
||||
"Sub2API 重写了 _do_query_balance,不走基类的 _parse_balance 路径"
|
||||
)
|
||||
|
||||
async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult:
|
||||
"""并发查询 auth/me 和 subscriptions/summary"""
|
||||
start_time = time.time()
|
||||
|
||||
me_endpoint = self.config.get("endpoint", "/api/v1/auth/me?timezone=Asia/Shanghai")
|
||||
sub_endpoint = self.config.get("subscription_endpoint", "/api/v1/subscriptions/summary")
|
||||
|
||||
try:
|
||||
me_resp, sub_resp = cast(
|
||||
tuple[httpx.Response | BaseException, httpx.Response | BaseException],
|
||||
await asyncio.gather(
|
||||
client.get(me_endpoint),
|
||||
client.get(sub_endpoint),
|
||||
return_exceptions=True,
|
||||
),
|
||||
)
|
||||
response_time_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
# 解析 auth/me
|
||||
me_data: dict[str, Any] = {}
|
||||
me_ok = False
|
||||
if isinstance(me_resp, httpx.Response):
|
||||
if me_resp.status_code in (401, 403):
|
||||
return self._make_error_result(
|
||||
ActionStatus.AUTH_FAILED, "认证失败,请检查凭据配置"
|
||||
)
|
||||
if me_resp.status_code == 200:
|
||||
try:
|
||||
me_json = me_resp.json()
|
||||
if me_json.get("code") == 0:
|
||||
me_data = me_json.get("data", {})
|
||||
me_ok = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not me_ok:
|
||||
return self._make_error_result(ActionStatus.UNKNOWN_ERROR, "查询用户信息失败")
|
||||
|
||||
# 基础余额
|
||||
balance = self._to_float(me_data.get("balance")) or 0.0
|
||||
points = self._to_float(me_data.get("points")) or 0.0
|
||||
total_available = balance + points
|
||||
|
||||
extra: dict[str, Any] = {
|
||||
"balance": balance,
|
||||
"points": points,
|
||||
}
|
||||
|
||||
# 解析 subscriptions/summary(可选,失败不影响主流程)
|
||||
if isinstance(sub_resp, httpx.Response) and sub_resp.status_code == 200:
|
||||
try:
|
||||
sub_json = sub_resp.json()
|
||||
if sub_json.get("code") == 0:
|
||||
summary = sub_json.get("data", {})
|
||||
extra["active_subscriptions"] = summary.get("active_count", 0)
|
||||
extra["total_used_usd"] = summary.get("total_used_usd", 0)
|
||||
extra["subscriptions"] = self._parse_subscriptions(
|
||||
summary.get("subscriptions", [])
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
balance_info = self._create_balance_info(
|
||||
total_available=total_available,
|
||||
currency="USD",
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
return self._make_success_result(
|
||||
data=balance_info,
|
||||
response_time_ms=response_time_ms,
|
||||
raw_response={"me": me_data},
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
return self._make_error_result(
|
||||
ActionStatus.NETWORK_ERROR, "请求超时", retry_after_seconds=30
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
return self._make_error_result(
|
||||
ActionStatus.NETWORK_ERROR, f"网络错误: {e}", retry_after_seconds=30
|
||||
)
|
||||
except Exception as e:
|
||||
return self._make_error_result(ActionStatus.UNKNOWN_ERROR, f"未知错误: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _parse_subscriptions(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""将订阅列表精简为前端需要的字段"""
|
||||
result = []
|
||||
for item in items:
|
||||
sub: dict[str, Any] = {
|
||||
"group_name": item.get("group_name", ""),
|
||||
"status": item.get("status", ""),
|
||||
}
|
||||
# 只保留非 None 的限额字段(0 是有效值,表示未使用/无限额)
|
||||
for field in (
|
||||
"daily_used_usd",
|
||||
"daily_limit_usd",
|
||||
"weekly_used_usd",
|
||||
"weekly_limit_usd",
|
||||
"monthly_used_usd",
|
||||
"monthly_limit_usd",
|
||||
):
|
||||
val = item.get(field)
|
||||
if val is not None:
|
||||
sub[field] = val
|
||||
expires_at = item.get("expires_at")
|
||||
if expires_at:
|
||||
sub["expires_at"] = expires_at
|
||||
result.append(sub)
|
||||
return result
|
||||
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
YesCode 余额查询操作
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.types import ActionResult, ActionStatus, BalanceInfo
|
||||
|
||||
|
||||
async def fetch_yescode_combined_data(
|
||||
client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
获取 YesCode 合并数据(balance + profile)
|
||||
|
||||
使用传入的 client 并发调用两个接口:
|
||||
- /api/v1/user/balance: 准确的 weekly_spent_balance
|
||||
- /api/v1/auth/profile: 用户信息、重置时间
|
||||
|
||||
Args:
|
||||
client: 已配置好认证的 HTTP 客户端
|
||||
base_url: API 基础地址
|
||||
|
||||
Returns:
|
||||
合并后的数据字典
|
||||
"""
|
||||
base_url = base_url.rstrip("/")
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
# 并发调用两个接口
|
||||
balance_task = client.get(f"{base_url}/api/v1/user/balance")
|
||||
profile_task = client.get(f"{base_url}/api/v1/auth/profile")
|
||||
|
||||
balance_resp, profile_resp = cast(
|
||||
tuple[httpx.Response | BaseException, httpx.Response | BaseException],
|
||||
await asyncio.gather(balance_task, profile_task, return_exceptions=True),
|
||||
)
|
||||
|
||||
# 解析 balance 接口
|
||||
balance_data: dict[str, Any] = {}
|
||||
if isinstance(balance_resp, httpx.Response) and balance_resp.status_code == 200:
|
||||
try:
|
||||
balance_data = balance_resp.json()
|
||||
result["_balance_data"] = balance_data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 解析 profile 接口
|
||||
profile_data: dict[str, Any] = {}
|
||||
if isinstance(profile_resp, httpx.Response) and profile_resp.status_code == 200:
|
||||
try:
|
||||
profile_data = profile_resp.json()
|
||||
result["_profile_data"] = profile_data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 合并数据:balance 接口优先(更准确的余额数据)
|
||||
result["pay_as_you_go_balance"] = balance_data.get(
|
||||
"pay_as_you_go_balance", profile_data.get("pay_as_you_go_balance", 0)
|
||||
)
|
||||
result["subscription_balance"] = balance_data.get(
|
||||
"subscription_balance", profile_data.get("subscription_balance", 0)
|
||||
)
|
||||
result["weekly_limit"] = balance_data.get("weekly_limit") or (
|
||||
profile_data.get("subscription_plan") or {}
|
||||
).get("weekly_limit")
|
||||
result["weekly_spent_balance"] = balance_data.get(
|
||||
"weekly_spent_balance", profile_data.get("current_week_spend", 0)
|
||||
)
|
||||
|
||||
# 用户信息(仅 profile 有)
|
||||
result["username"] = profile_data.get("username")
|
||||
result["email"] = profile_data.get("email")
|
||||
|
||||
# 重置时间(仅 profile 有)
|
||||
result["last_week_reset"] = profile_data.get("last_week_reset")
|
||||
result["last_daily_balance_add"] = profile_data.get("last_daily_balance_add")
|
||||
|
||||
# subscription_plan(仅 profile 有)
|
||||
result["subscription_plan"] = profile_data.get("subscription_plan")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_yescode_balance_extra(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
解析 YesCode 余额额外信息
|
||||
|
||||
Args:
|
||||
data: 合并后的数据(来自 fetch_yescode_combined_data 或单独接口)
|
||||
|
||||
Returns:
|
||||
统一格式的 extra 字典
|
||||
"""
|
||||
extra: dict[str, Any] = {}
|
||||
|
||||
pay_as_you_go = data.get("pay_as_you_go_balance", 0)
|
||||
subscription = data.get("subscription_balance", 0)
|
||||
|
||||
extra["pay_as_you_go_balance"] = pay_as_you_go
|
||||
|
||||
# 每日额度上限
|
||||
plan = data.get("subscription_plan") or {}
|
||||
daily_balance = plan.get("daily_balance", subscription)
|
||||
|
||||
# 周限额
|
||||
weekly_limit = data.get("weekly_limit") or plan.get("weekly_limit")
|
||||
weekly_spent = data.get("weekly_spent_balance", 0)
|
||||
|
||||
# 映射为统一字段
|
||||
extra["daily_limit"] = daily_balance
|
||||
if weekly_limit is not None:
|
||||
extra["weekly_limit"] = weekly_limit
|
||||
extra["weekly_spent"] = weekly_spent
|
||||
|
||||
# 计算重置时间
|
||||
last_week_reset = data.get("last_week_reset")
|
||||
if last_week_reset:
|
||||
try:
|
||||
if isinstance(last_week_reset, str):
|
||||
reset_dt = datetime.fromisoformat(last_week_reset.replace("Z", "+00:00"))
|
||||
next_reset = reset_dt + timedelta(days=7)
|
||||
extra["weekly_resets_at"] = int(next_reset.timestamp())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
last_daily_add = data.get("last_daily_balance_add")
|
||||
if last_daily_add:
|
||||
try:
|
||||
if isinstance(last_daily_add, str):
|
||||
add_dt = datetime.fromisoformat(last_daily_add.replace("Z", "+00:00"))
|
||||
next_daily = add_dt + timedelta(days=1)
|
||||
extra["daily_resets_at"] = int(next_daily.timestamp())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 计算实际可用余额
|
||||
if weekly_limit is not None:
|
||||
weekly_remaining = max(0, weekly_limit - weekly_spent)
|
||||
subscription_available = min(subscription, weekly_remaining)
|
||||
extra["daily_spent"] = daily_balance - min(daily_balance, subscription_available)
|
||||
else:
|
||||
subscription_available = subscription
|
||||
extra["daily_spent"] = max(0, daily_balance - subscription)
|
||||
|
||||
extra["_subscription_available"] = subscription_available
|
||||
extra["_total_available"] = pay_as_you_go + subscription_available
|
||||
|
||||
return extra
|
||||
|
||||
|
||||
class YesCodeBalanceAction(BalanceAction):
|
||||
"""
|
||||
YesCode 专用余额查询
|
||||
|
||||
特点:
|
||||
- 余额单位直接是美元
|
||||
- 支持每周限额查询
|
||||
- 同时调用 balance 和 profile 接口获取完整数据
|
||||
- Cookie 失效时返回友好的错误提示
|
||||
"""
|
||||
|
||||
display_name = "查询余额(含每周限额)"
|
||||
description = "查询账户余额和每周限额信息"
|
||||
|
||||
_cookie_auth = True
|
||||
|
||||
def _parse_balance(self, data: Any) -> BalanceInfo:
|
||||
"""YesCode 不使用基类的 _do_query_balance,此方法不会被调用"""
|
||||
raise NotImplementedError("YesCode 使用自定义 _do_query_balance")
|
||||
|
||||
async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult:
|
||||
"""执行余额查询(实现抽象方法,复用 client 调用两个接口获取完整数据)"""
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
base_url = str(client.base_url).rstrip("/")
|
||||
|
||||
try:
|
||||
# 复用传入的 client 获取合并数据
|
||||
combined_data = await fetch_yescode_combined_data(client, base_url)
|
||||
response_time_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
# 检查是否至少有一个接口成功
|
||||
if "_balance_data" not in combined_data and "_profile_data" not in combined_data:
|
||||
return self._make_error_result(
|
||||
ActionStatus.AUTH_FAILED,
|
||||
"Cookie 已失效,请重新配置",
|
||||
)
|
||||
|
||||
# 使用公共函数解析余额
|
||||
extra = parse_yescode_balance_extra(combined_data)
|
||||
|
||||
total_available = extra.pop("_total_available", 0)
|
||||
extra.pop("_subscription_available", None)
|
||||
|
||||
balance = BalanceInfo(
|
||||
total_granted=None,
|
||||
total_used=None,
|
||||
total_available=total_available,
|
||||
currency=self.config.get("currency", "USD"),
|
||||
extra=extra if extra else {},
|
||||
)
|
||||
|
||||
return self._make_success_result(
|
||||
data=balance,
|
||||
response_time_ms=response_time_ms,
|
||||
raw_response=combined_data,
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
return self._make_error_result(
|
||||
ActionStatus.NETWORK_ERROR,
|
||||
"请求超时",
|
||||
retry_after_seconds=30,
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
return self._make_error_result(
|
||||
ActionStatus.NETWORK_ERROR,
|
||||
f"网络错误: {str(e)}",
|
||||
retry_after_seconds=30,
|
||||
)
|
||||
except Exception as e:
|
||||
return self._make_error_result(
|
||||
ActionStatus.UNKNOWN_ERROR,
|
||||
f"未知错误: {str(e)}",
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Provider 架构模块
|
||||
"""
|
||||
|
||||
from src.services.provider_ops.architectures.anyrouter import AnyrouterArchitecture
|
||||
from src.services.provider_ops.architectures.base import (
|
||||
ProviderArchitecture,
|
||||
ProviderConnector,
|
||||
VerifyResult,
|
||||
)
|
||||
from src.services.provider_ops.architectures.cubence import CubenceArchitecture
|
||||
from src.services.provider_ops.architectures.generic_api import GenericApiArchitecture
|
||||
from src.services.provider_ops.architectures.nekocode import NekoCodeArchitecture
|
||||
from src.services.provider_ops.architectures.new_api import NewApiArchitecture
|
||||
from src.services.provider_ops.architectures.sub2api import Sub2ApiArchitecture
|
||||
from src.services.provider_ops.architectures.yescode import YesCodeArchitecture
|
||||
|
||||
__all__ = [
|
||||
"ProviderArchitecture",
|
||||
"ProviderConnector",
|
||||
"VerifyResult",
|
||||
"AnyrouterArchitecture",
|
||||
"CubenceArchitecture",
|
||||
"GenericApiArchitecture",
|
||||
"NekoCodeArchitecture",
|
||||
"NewApiArchitecture",
|
||||
"Sub2ApiArchitecture",
|
||||
"YesCodeArchitecture",
|
||||
]
|
||||
@@ -0,0 +1,469 @@
|
||||
"""
|
||||
Anyrouter 架构
|
||||
|
||||
针对 Anyrouter 中转站的预设配置,自动处理 acw_sc__v2 反爬 Cookie。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.services.provider_ops.actions import (
|
||||
AnyrouterBalanceAction,
|
||||
ProviderAction,
|
||||
)
|
||||
from src.services.provider_ops.architectures.base import (
|
||||
ProviderArchitecture,
|
||||
ProviderConnector,
|
||||
)
|
||||
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
|
||||
from src.services.provider_ops.utils import extract_cookie_value
|
||||
from src.utils.ssl_utils import get_ssl_context
|
||||
|
||||
# acw_sc__v2 算法常量
|
||||
_XOR_KEY = "3000176000856006061501533003690027800375"
|
||||
_UNSBOX_TABLE = [
|
||||
0xF,
|
||||
0x23,
|
||||
0x1D,
|
||||
0x18,
|
||||
0x21,
|
||||
0x10,
|
||||
0x1,
|
||||
0x26,
|
||||
0xA,
|
||||
0x9,
|
||||
0x13,
|
||||
0x1F,
|
||||
0x28,
|
||||
0x1B,
|
||||
0x16,
|
||||
0x17,
|
||||
0x19,
|
||||
0xD,
|
||||
0x6,
|
||||
0xB,
|
||||
0x27,
|
||||
0x12,
|
||||
0x14,
|
||||
0x8,
|
||||
0xE,
|
||||
0x15,
|
||||
0x20,
|
||||
0x1A,
|
||||
0x2,
|
||||
0x1E,
|
||||
0x7,
|
||||
0x4,
|
||||
0x11,
|
||||
0x5,
|
||||
0x3,
|
||||
0x1C,
|
||||
0x22,
|
||||
0x25,
|
||||
0xC,
|
||||
0x24,
|
||||
]
|
||||
|
||||
|
||||
def _compute_acw_sc_v2(arg1: str) -> str:
|
||||
"""
|
||||
计算 acw_sc__v2 Cookie 值
|
||||
|
||||
Args:
|
||||
arg1: 从 HTML 中提取的 40 位十六进制字符串
|
||||
|
||||
Returns:
|
||||
计算后的 Cookie 值
|
||||
"""
|
||||
# Step 1: unsbox - 根据置换表重排字符
|
||||
unsboxed = "".join(arg1[i - 1] for i in _UNSBOX_TABLE)
|
||||
|
||||
# Step 2: hexXor - 与密钥逐字节异或
|
||||
result = ""
|
||||
for i in range(0, 40, 2):
|
||||
a = int(unsboxed[i : i + 2], 16)
|
||||
b = int(_XOR_KEY[i : i + 2], 16)
|
||||
xored = format(a ^ b, "02x")
|
||||
result += xored
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _parse_session_user_id(cookie_input: str) -> tuple[str | None, str | None]:
|
||||
"""
|
||||
从 session cookie 中解析用户 ID 和用户名
|
||||
|
||||
Anyrouter 的 session cookie 结构:
|
||||
base64(timestamp|gob_base64|signature)
|
||||
|
||||
gob 数据中包含:
|
||||
- id: 内部数字 ID (gob 编码的整数)
|
||||
- username: 用户名
|
||||
- role, status, group 等
|
||||
|
||||
Args:
|
||||
cookie_input: Cookie 字符串或 session 值
|
||||
|
||||
Returns:
|
||||
(user_id, username) 元组,解析失败则返回 (None, None)
|
||||
"""
|
||||
try:
|
||||
# 先提取 session 值
|
||||
session_cookie = extract_cookie_value(cookie_input, "session")
|
||||
# 1. URL-safe base64 解码外层
|
||||
padding = 4 - len(session_cookie) % 4
|
||||
if padding != 4:
|
||||
session_cookie += "=" * padding
|
||||
|
||||
decoded = base64.urlsafe_b64decode(session_cookie)
|
||||
text = decoded.decode("utf-8", errors="replace")
|
||||
|
||||
# 2. 分割: timestamp|gob_base64|signature
|
||||
parts = text.split("|")
|
||||
if len(parts) < 2:
|
||||
return None, None
|
||||
|
||||
# 3. 解码 gob 数据 (第二层 base64)
|
||||
gob_b64 = parts[1]
|
||||
padding2 = 4 - len(gob_b64) % 4
|
||||
if padding2 != 4:
|
||||
gob_b64 += "=" * padding2
|
||||
|
||||
gob_data = base64.urlsafe_b64decode(gob_b64)
|
||||
|
||||
# 4. 从 gob 数据中解析 id 字段
|
||||
# 查找 "\x02id\x03int" 模式,后面跟着 gob 编码的整数
|
||||
id_pattern = b"\x02id\x03int"
|
||||
id_idx = gob_data.find(id_pattern)
|
||||
user_id = None
|
||||
if id_idx != -1:
|
||||
# 跳过 "\x02id\x03int" (7字节) 和类型标记 (2字节)
|
||||
value_start = id_idx + 7 + 2
|
||||
if value_start < len(gob_data):
|
||||
# 读取第一个字节,检查是否是 00(正数标记)
|
||||
first_byte = gob_data[value_start]
|
||||
if first_byte == 0:
|
||||
# 下一个字节是长度标记
|
||||
marker = gob_data[value_start + 1]
|
||||
if marker >= 0x80:
|
||||
# 负的表示长度: 256 - marker = 字节数
|
||||
length = 256 - marker
|
||||
if value_start + 2 + length <= len(gob_data):
|
||||
# 读取 length 字节,大端序转整数
|
||||
val = int.from_bytes(
|
||||
gob_data[value_start + 2 : value_start + 2 + length],
|
||||
"big",
|
||||
)
|
||||
# gob zigzag 解码:正整数用 2*n 编码
|
||||
user_id = str(val >> 1)
|
||||
|
||||
# 5. 从 gob 数据中提取用户名
|
||||
username = None
|
||||
|
||||
# 查找 username 字段后的值
|
||||
# 格式: \x08username\x06string\x0c\x10\x00\x0elinuxdo_129083
|
||||
# 其中 \x0e (14) 是用户名的长度
|
||||
username_pattern = b"\x08username\x06string"
|
||||
username_idx = gob_data.find(username_pattern)
|
||||
if username_idx != -1:
|
||||
# 跳过模式本身 (17字节) 和 \x0c\x10\x00 (3字节)
|
||||
# 第 4 个字节是长度
|
||||
length_pos = username_idx + len(username_pattern) + 3
|
||||
if length_pos < len(gob_data):
|
||||
length_byte = gob_data[length_pos]
|
||||
value_start = length_pos + 1
|
||||
if length_byte < 128 and value_start + length_byte <= len(gob_data):
|
||||
username = gob_data[value_start : value_start + length_byte].decode(
|
||||
"utf-8", errors="ignore"
|
||||
)
|
||||
|
||||
return user_id, username
|
||||
except Exception as e:
|
||||
logger.debug(f"解析 Anyrouter session cookie 失败: {e}")
|
||||
return None, None
|
||||
|
||||
|
||||
async def _get_acw_cookie(
|
||||
base_url: str,
|
||||
timeout: float = 10,
|
||||
proxy: str | httpx.Proxy | None = None,
|
||||
tunnel_node_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""
|
||||
获取 acw_sc__v2 Cookie
|
||||
|
||||
首先请求目标 URL,如果返回包含 arg1 的反爬页面,则计算 Cookie 值。
|
||||
|
||||
Args:
|
||||
base_url: 目标站点 URL
|
||||
timeout: 请求超时时间
|
||||
proxy: 代理地址
|
||||
tunnel_node_id: tunnel 模式节点 ID(优先于 proxy)
|
||||
|
||||
Returns:
|
||||
Cookie 字符串 (acw_sc__v2=xxx),如果不需要或获取失败则返回 None
|
||||
"""
|
||||
try:
|
||||
# 构建 client 参数
|
||||
client_kwargs: dict[str, Any] = {
|
||||
"timeout": timeout,
|
||||
"verify": get_ssl_context(),
|
||||
}
|
||||
if tunnel_node_id:
|
||||
from src.services.proxy_node.tunnel_transport import create_tunnel_transport
|
||||
|
||||
client_kwargs["transport"] = create_tunnel_transport(tunnel_node_id, timeout=timeout)
|
||||
elif proxy:
|
||||
client_kwargs["proxy"] = proxy
|
||||
logger.debug(f"获取 acw_sc__v2 Cookie 使用代理: {proxy}")
|
||||
|
||||
async with httpx.AsyncClient(**client_kwargs) as client:
|
||||
resp = await client.get(
|
||||
base_url,
|
||||
headers={
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/120.0.0.0 Safari/537.36"
|
||||
),
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
|
||||
# 尝试从响应中提取 arg1
|
||||
match = re.search(r"var\s+arg1\s*=\s*'([0-9a-fA-F]{40})'", resp.text)
|
||||
if not match:
|
||||
# 没有反爬页面,不需要 Cookie
|
||||
return None
|
||||
|
||||
cookie_value = _compute_acw_sc_v2(match.group(1))
|
||||
return f"acw_sc__v2={cookie_value}"
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"获取 acw_sc__v2 Cookie 失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
class AnyrouterConnector(ProviderConnector):
|
||||
"""
|
||||
Anyrouter 专用连接器
|
||||
|
||||
特点:
|
||||
- 使用 Cookie 认证(session)
|
||||
- 自动补充 acw_sc__v2 反爬 Cookie
|
||||
- 自动解析 user_id 用于 New-Api-User header
|
||||
"""
|
||||
|
||||
auth_type = ConnectorAuthType.COOKIE
|
||||
display_name = "Anyrouter Cookie"
|
||||
|
||||
def __init__(self, base_url: str, config: dict[str, Any] | None = None):
|
||||
super().__init__(base_url, config)
|
||||
self._session_cookie: str | None = None
|
||||
self._acw_cookie: str | None = None
|
||||
self._user_id: str | None = None
|
||||
|
||||
async def connect(self, credentials: dict[str, Any]) -> bool:
|
||||
"""建立连接"""
|
||||
session_cookie = credentials.get("session_cookie")
|
||||
if not session_cookie:
|
||||
self._set_error("Session Cookie 不能为空")
|
||||
return False
|
||||
|
||||
# 提取纯 session 值(支持完整 Cookie 字符串或仅 session 值)
|
||||
self._session_cookie = extract_cookie_value(session_cookie, "session")
|
||||
|
||||
# 解析 user_id
|
||||
self._user_id, _ = _parse_session_user_id(session_cookie)
|
||||
|
||||
# 尝试获取反爬 Cookie(使用配置中的代理)
|
||||
self._acw_cookie = await _get_acw_cookie(
|
||||
self.base_url, proxy=self._proxy, tunnel_node_id=self._tunnel_node_id
|
||||
)
|
||||
|
||||
self._set_connected()
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""断开连接"""
|
||||
self._session_cookie = None
|
||||
self._acw_cookie = None
|
||||
self._user_id = None
|
||||
self._set_disconnected()
|
||||
|
||||
async def is_authenticated(self) -> bool:
|
||||
"""检查是否已认证"""
|
||||
return self._session_cookie is not None
|
||||
|
||||
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
|
||||
"""为请求应用认证信息"""
|
||||
cookies = []
|
||||
|
||||
# 添加反爬 Cookie
|
||||
if self._acw_cookie:
|
||||
cookies.append(self._acw_cookie)
|
||||
|
||||
# 添加 session Cookie
|
||||
if self._session_cookie:
|
||||
cookies.append(f"session={self._session_cookie}")
|
||||
|
||||
if cookies:
|
||||
request.headers["Cookie"] = "; ".join(cookies)
|
||||
|
||||
# 添加 New-Api-User header
|
||||
if self._user_id:
|
||||
request.headers["New-Api-User"] = self._user_id
|
||||
|
||||
return request
|
||||
|
||||
@classmethod
|
||||
def get_credentials_schema(cls) -> dict[str, Any]:
|
||||
"""获取凭据配置 schema"""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"title": "站点地址",
|
||||
"description": "API 基础地址",
|
||||
"x-default-value": "https://anyrouter.top",
|
||||
},
|
||||
"session_cookie": {
|
||||
"type": "string",
|
||||
"title": "Session Cookie",
|
||||
"description": "从浏览器复制的 session Cookie 值",
|
||||
"x-sensitive": True,
|
||||
"x-input-type": "password",
|
||||
},
|
||||
},
|
||||
"required": ["session_cookie"],
|
||||
"x-field-groups": [
|
||||
{"fields": ["base_url"]},
|
||||
{"fields": ["session_cookie"]},
|
||||
],
|
||||
"x-auth-type": "cookie",
|
||||
"x-default-base-url": "https://anyrouter.top",
|
||||
"x-validation": [
|
||||
{
|
||||
"type": "required",
|
||||
"fields": ["session_cookie"],
|
||||
"message": "请填写 Session Cookie",
|
||||
},
|
||||
],
|
||||
"x-quota-divisor": 500000,
|
||||
"x-currency": "USD",
|
||||
}
|
||||
|
||||
|
||||
class AnyrouterArchitecture(ProviderArchitecture):
|
||||
"""
|
||||
Anyrouter 架构预设
|
||||
|
||||
针对 Anyrouter 中转站优化的预设配置。
|
||||
|
||||
特点:
|
||||
- 使用 Cookie 认证(session)
|
||||
- 自动处理 acw_sc__v2 反爬 Cookie
|
||||
- 验证端点: /api/user/self
|
||||
- quota 单位是 1/500000 美元
|
||||
"""
|
||||
|
||||
architecture_id = "anyrouter"
|
||||
display_name = "Anyrouter"
|
||||
description = "Anyrouter 中转站预设配置,使用 Cookie 认证"
|
||||
|
||||
supported_connectors: list[type[ProviderConnector]] = [
|
||||
AnyrouterConnector,
|
||||
]
|
||||
|
||||
supported_actions: list[type[ProviderAction]] = [AnyrouterBalanceAction]
|
||||
|
||||
default_action_configs: dict[ProviderActionType, dict[str, Any]] = {
|
||||
ProviderActionType.QUERY_BALANCE: {
|
||||
"endpoint": "/api/user/self",
|
||||
"method": "GET",
|
||||
"quota_divisor": 500000, # 与 New API 相同
|
||||
"checkin_endpoint": "/api/user/sign_in", # 自动签到端点
|
||||
},
|
||||
}
|
||||
|
||||
def get_credentials_schema(self) -> dict[str, Any]:
|
||||
"""Anyrouter 使用 session_cookie 认证"""
|
||||
return AnyrouterConnector.get_credentials_schema()
|
||||
|
||||
def get_verify_endpoint(self) -> str:
|
||||
"""验证端点"""
|
||||
return "/api/user/self"
|
||||
|
||||
async def prepare_verify_config(
|
||||
self,
|
||||
base_url: str,
|
||||
config: dict[str, Any],
|
||||
credentials: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
验证前获取 acw_sc__v2 Cookie
|
||||
|
||||
Args:
|
||||
base_url: API 基础地址
|
||||
config: 连接器配置
|
||||
credentials: 凭据信息
|
||||
|
||||
Returns:
|
||||
包含 acw_cookie 的配置
|
||||
"""
|
||||
# 从 config 获取代理配置(支持 proxy_node_id、tunnel 和旧的 proxy URL)
|
||||
from src.services.proxy_node.resolver import resolve_ops_proxy_config_async
|
||||
|
||||
proxy, tunnel_node_id = await resolve_ops_proxy_config_async(config)
|
||||
acw_cookie = await _get_acw_cookie(base_url, proxy=proxy, tunnel_node_id=tunnel_node_id)
|
||||
if acw_cookie:
|
||||
return {"acw_cookie": acw_cookie}
|
||||
return {}
|
||||
|
||||
def build_verify_headers(
|
||||
self,
|
||||
config: dict[str, Any],
|
||||
credentials: dict[str, Any],
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
构建 Anyrouter 的验证请求 Headers
|
||||
|
||||
使用 Cookie 认证,不使用 Authorization。
|
||||
同时添加 New-Api-User header。
|
||||
"""
|
||||
headers: dict[str, str] = {}
|
||||
|
||||
cookies = []
|
||||
|
||||
# 添加反爬 Cookie
|
||||
acw_cookie = config.get("acw_cookie")
|
||||
if acw_cookie:
|
||||
cookies.append(acw_cookie)
|
||||
|
||||
# 添加 session Cookie
|
||||
cookie_input = credentials.get("session_cookie")
|
||||
if cookie_input:
|
||||
# 提取 session 值(支持完整 Cookie 字符串或仅 session 值)
|
||||
session_value = extract_cookie_value(cookie_input, "session")
|
||||
cookies.append(f"session={session_value}")
|
||||
|
||||
# 从 session 解析 user_id 并添加 New-Api-User header
|
||||
user_id, _ = _parse_session_user_id(cookie_input)
|
||||
if user_id:
|
||||
headers["New-Api-User"] = user_id
|
||||
|
||||
if cookies:
|
||||
headers["Cookie"] = "; ".join(cookies)
|
||||
|
||||
return headers
|
||||
|
||||
def _auth_fail_message(self, status_code: int) -> str:
|
||||
"""Cookie 认证的错误消息"""
|
||||
if status_code == 401:
|
||||
return "Cookie 已失效,请重新配置"
|
||||
return "Cookie 已失效或无权限"
|
||||
550
_deprecated_py_src/services/provider_ops/architectures/base.py
Normal file
550
_deprecated_py_src/services/provider_ops/architectures/base.py
Normal file
@@ -0,0 +1,550 @@
|
||||
"""
|
||||
Provider 架构抽象基类
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.services.provider_ops.actions.base import ProviderAction
|
||||
from src.services.provider_ops.types import (
|
||||
ConnectorAuthType,
|
||||
ConnectorState,
|
||||
ConnectorStatus,
|
||||
ProviderActionType,
|
||||
)
|
||||
from src.utils.ssl_utils import get_ssl_context
|
||||
|
||||
# ==================== 连接器基类 ====================
|
||||
|
||||
|
||||
class ProviderConnector(ABC):
|
||||
"""
|
||||
提供商连接器基类
|
||||
|
||||
负责建立与提供商的认证连接,管理凭据状态。
|
||||
每个架构应在自己的文件中实现对应的连接器子类。
|
||||
"""
|
||||
|
||||
# 子类需要定义的类属性
|
||||
auth_type: ConnectorAuthType = ConnectorAuthType.NONE
|
||||
display_name: str = "Base Connector"
|
||||
|
||||
def __init__(self, base_url: str, config: dict[str, Any] | None = None):
|
||||
"""
|
||||
初始化连接器
|
||||
|
||||
Args:
|
||||
base_url: 提供商 API 基础 URL
|
||||
config: 连接器配置
|
||||
"""
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.config = config or {}
|
||||
self._status = ConnectorStatus.DISCONNECTED
|
||||
self._connected_at: datetime | None = None
|
||||
self._expires_at: datetime | None = None
|
||||
self._last_error: str | None = None
|
||||
|
||||
# 代理配置(支持 proxy_node_id、tunnel 和旧的 proxy URL)
|
||||
from src.services.proxy_node.resolver import resolve_ops_proxy_config
|
||||
|
||||
self._proxy: str | httpx.Proxy | None
|
||||
self._tunnel_node_id: str | None
|
||||
self._proxy, self._tunnel_node_id = resolve_ops_proxy_config(self.config)
|
||||
|
||||
# HTTP 客户端配置
|
||||
self._timeout = self.config.get("timeout", 30)
|
||||
self._headers: dict[str, str] = {}
|
||||
|
||||
# 凭据更新回调(Token Rotation 等场景需要持久化新凭据)
|
||||
self._on_credentials_updated: Callable[[dict[str, Any]], None] | None = None
|
||||
|
||||
@abstractmethod
|
||||
async def connect(self, credentials: dict[str, Any]) -> bool:
|
||||
"""
|
||||
建立认证连接
|
||||
|
||||
Args:
|
||||
credentials: 凭据信息(如用户名密码、API Key 等)
|
||||
|
||||
Returns:
|
||||
是否连接成功
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def disconnect(self) -> None:
|
||||
"""断开连接,清理状态"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def is_authenticated(self) -> bool:
|
||||
"""检查当前是否已认证"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
|
||||
"""
|
||||
为请求应用认证信息
|
||||
|
||||
Args:
|
||||
request: 原始请求
|
||||
|
||||
Returns:
|
||||
添加认证信息后的请求
|
||||
"""
|
||||
pass
|
||||
|
||||
async def refresh_auth(self, credentials: dict[str, Any]) -> bool:
|
||||
"""
|
||||
刷新认证(如 Token 过期)
|
||||
|
||||
默认实现:重新连接
|
||||
|
||||
Args:
|
||||
credentials: 凭据信息
|
||||
|
||||
Returns:
|
||||
是否刷新成功
|
||||
"""
|
||||
return await self.connect(credentials)
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_client(self) -> AsyncIterator[httpx.AsyncClient]:
|
||||
"""
|
||||
获取已认证的 HTTP 客户端
|
||||
|
||||
使用 context manager 确保资源正确释放。
|
||||
tunnel 模式下使用 TunnelTransport 替代 proxy transport。
|
||||
|
||||
Yields:
|
||||
已配置认证信息的 AsyncClient
|
||||
"""
|
||||
transport = None
|
||||
if self._tunnel_node_id:
|
||||
from src.services.proxy_node.tunnel_transport import create_tunnel_transport
|
||||
|
||||
transport = create_tunnel_transport(self._tunnel_node_id, timeout=self._timeout)
|
||||
elif self._proxy:
|
||||
transport = httpx.AsyncHTTPTransport(proxy=self._proxy)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
timeout=self._timeout,
|
||||
transport=transport,
|
||||
event_hooks={"request": [self._auth_hook]},
|
||||
verify=get_ssl_context(),
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
async def _auth_hook(self, request: httpx.Request) -> None:
|
||||
"""请求钩子:应用认证信息"""
|
||||
self._apply_auth(request)
|
||||
|
||||
def get_state(self) -> ConnectorState:
|
||||
"""获取连接器当前状态"""
|
||||
return ConnectorState(
|
||||
status=self._status,
|
||||
auth_type=self.auth_type,
|
||||
connected_at=self._connected_at,
|
||||
expires_at=self._expires_at,
|
||||
last_error=self._last_error,
|
||||
)
|
||||
|
||||
def _set_connected(self, expires_at: datetime | None = None) -> None:
|
||||
"""设置为已连接状态"""
|
||||
self._status = ConnectorStatus.CONNECTED
|
||||
self._connected_at = datetime.now(timezone.utc)
|
||||
self._expires_at = expires_at
|
||||
self._last_error = None
|
||||
|
||||
def _set_error(self, error: str) -> None:
|
||||
"""设置错误状态"""
|
||||
self._status = ConnectorStatus.ERROR
|
||||
self._last_error = error
|
||||
|
||||
def _set_disconnected(self) -> None:
|
||||
"""设置为断开状态"""
|
||||
self._status = ConnectorStatus.DISCONNECTED
|
||||
self._connected_at = None
|
||||
self._expires_at = None
|
||||
|
||||
@classmethod
|
||||
def get_credentials_schema(cls) -> dict[str, Any]:
|
||||
"""
|
||||
获取凭据配置 JSON Schema(用于前端表单生成)
|
||||
|
||||
子类应重写此方法
|
||||
"""
|
||||
return {"type": "object", "properties": {}, "required": []}
|
||||
|
||||
|
||||
# ==================== 验证结果 ====================
|
||||
|
||||
|
||||
@dataclass
|
||||
class VerifyResult:
|
||||
"""认证验证结果"""
|
||||
|
||||
success: bool
|
||||
message: str | None = None
|
||||
username: str | None = None
|
||||
display_name: str | None = None
|
||||
email: str | None = None
|
||||
quota: float | None = None
|
||||
used_quota: float | None = None
|
||||
request_count: int | None = None
|
||||
extra: dict[str, Any] | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
if not self.success:
|
||||
return {"success": False, "message": self.message}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"data": {
|
||||
"username": self.username,
|
||||
"display_name": self.display_name or self.username,
|
||||
"email": self.email,
|
||||
"quota": self.quota,
|
||||
"used_quota": self.used_quota,
|
||||
"request_count": self.request_count,
|
||||
"extra": self.extra or {},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ==================== 架构基类 ====================
|
||||
|
||||
|
||||
class ProviderArchitecture(ABC):
|
||||
"""
|
||||
提供商架构抽象基类
|
||||
|
||||
架构 = Connector(鉴权方式) + Actions(支持的操作)
|
||||
|
||||
一个架构可以被多个 Provider 复用。
|
||||
例如:generic_api 架构可用于各种中转站。
|
||||
|
||||
## 添加新认证模板的步骤
|
||||
|
||||
1. 在 architectures/ 目录创建新文件
|
||||
2. 继承 ProviderArchitecture 和 ProviderConnector
|
||||
3. 定义类属性:architecture_id, display_name, description
|
||||
4. 实现连接器子类和架构类
|
||||
5. 实现认证相关的抽象方法:
|
||||
- get_credentials_schema(): 返回凭据字段定义
|
||||
- get_verify_endpoint(): 返回验证端点
|
||||
- build_verify_headers(): 构建验证请求 headers
|
||||
- parse_verify_response(): 解析验证响应
|
||||
6. 在 registry.py 的 _register_builtin_architectures() 中注册
|
||||
"""
|
||||
|
||||
# 子类需要定义的类属性
|
||||
architecture_id: str = ""
|
||||
display_name: str = ""
|
||||
description: str = ""
|
||||
|
||||
# 设为 True 时不在架构列表 API 中返回(内部使用的架构)
|
||||
hidden: bool = False
|
||||
|
||||
# 支持的 Connector 类型列表(按优先级排序)
|
||||
supported_connectors: list[type[ProviderConnector]] = []
|
||||
|
||||
# 支持的 Action 类型列表
|
||||
supported_actions: list[type[ProviderAction]] = []
|
||||
|
||||
# 默认操作配置
|
||||
default_action_configs: dict[ProviderActionType, dict[str, Any]] = {}
|
||||
|
||||
def __init__(self, config: dict[str, Any] | None = None):
|
||||
"""
|
||||
初始化架构
|
||||
|
||||
Args:
|
||||
config: 架构配置
|
||||
"""
|
||||
self.config = config or {}
|
||||
|
||||
# ==================== 认证验证相关方法 ====================
|
||||
|
||||
@abstractmethod
|
||||
def get_credentials_schema(self) -> dict[str, Any]:
|
||||
"""
|
||||
获取凭据字段定义(JSON Schema 格式)
|
||||
|
||||
子类必须实现此方法定义需要的凭据字段。
|
||||
|
||||
Returns:
|
||||
JSON Schema 格式的字段定义
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_verify_endpoint(self) -> str:
|
||||
"""
|
||||
获取认证验证端点
|
||||
|
||||
子类必须实现此方法返回验证端点。
|
||||
|
||||
Returns:
|
||||
验证端点路径(如 /api/user/self, /api/v1/auth/profile)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def build_verify_headers(
|
||||
self,
|
||||
config: dict[str, Any],
|
||||
credentials: dict[str, Any],
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
构建认证验证请求的 Headers
|
||||
|
||||
子类必须实现此方法构建认证 Headers。
|
||||
|
||||
Args:
|
||||
config: 连接器配置(可能包含 prepare_verify_config 返回的额外配置)
|
||||
credentials: 凭据信息
|
||||
|
||||
Returns:
|
||||
Headers 字典
|
||||
"""
|
||||
pass
|
||||
|
||||
def parse_verify_response(
|
||||
self,
|
||||
status_code: int,
|
||||
data: dict[str, Any],
|
||||
) -> VerifyResult:
|
||||
"""
|
||||
解析认证验证响应
|
||||
|
||||
默认实现处理通用的 {"success": bool, "data": {...}} 格式。
|
||||
子类可重写 _auth_fail_message() 和 _build_verify_result() 进行自定义。
|
||||
|
||||
Args:
|
||||
status_code: HTTP 状态码
|
||||
data: 响应 JSON 数据
|
||||
|
||||
Returns:
|
||||
验证结果
|
||||
"""
|
||||
if status_code == 401:
|
||||
return VerifyResult(success=False, message=self._auth_fail_message(401))
|
||||
if status_code == 403:
|
||||
return VerifyResult(success=False, message=self._auth_fail_message(403))
|
||||
if status_code != 200:
|
||||
return VerifyResult(success=False, message=f"验证失败:HTTP {status_code}")
|
||||
|
||||
# 解析通用响应格式
|
||||
if data.get("success") is True and "data" in data:
|
||||
user_data = data["data"]
|
||||
elif data.get("success") is False:
|
||||
message = data.get("message", "验证失败")
|
||||
return VerifyResult(success=False, message=message)
|
||||
else:
|
||||
user_data = data
|
||||
|
||||
return self._build_verify_result(user_data, data)
|
||||
|
||||
def _auth_fail_message(self, status_code: int) -> str:
|
||||
"""
|
||||
获取认证失败消息
|
||||
|
||||
子类可重写以提供自定义消息(如 Cookie 认证场景)。
|
||||
"""
|
||||
if status_code == 401:
|
||||
return "认证失败:无效的凭据"
|
||||
return "认证失败:权限不足"
|
||||
|
||||
def _build_verify_result(
|
||||
self, user_data: dict[str, Any], raw_data: dict[str, Any] | None = None
|
||||
) -> VerifyResult:
|
||||
"""
|
||||
从用户数据构建验证结果
|
||||
|
||||
默认实现提取 username, display_name, email, quota, used_quota, request_count。
|
||||
子类可重写以自定义字段提取。
|
||||
"""
|
||||
known_fields = (
|
||||
"username",
|
||||
"display_name",
|
||||
"email",
|
||||
"quota",
|
||||
"used_quota",
|
||||
"request_count",
|
||||
)
|
||||
return VerifyResult(
|
||||
success=True,
|
||||
username=user_data.get("username"),
|
||||
display_name=user_data.get("display_name") or user_data.get("username"),
|
||||
email=user_data.get("email"),
|
||||
quota=user_data.get("quota"),
|
||||
used_quota=user_data.get("used_quota"),
|
||||
request_count=user_data.get("request_count"),
|
||||
extra={k: v for k, v in user_data.items() if k not in known_fields},
|
||||
)
|
||||
|
||||
# ==================== 可选的钩子方法 ====================
|
||||
|
||||
async def prepare_verify_config(
|
||||
self,
|
||||
base_url: str,
|
||||
config: dict[str, Any],
|
||||
credentials: dict[str, Any],
|
||||
) -> dict[str, Any] | tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""
|
||||
验证前的异步预处理(可选)
|
||||
|
||||
子类可重写以执行异步操作(如获取动态 Cookie、登录获取 Token)。
|
||||
|
||||
Args:
|
||||
base_url: API 基础地址
|
||||
config: 连接器配置
|
||||
credentials: 凭据信息
|
||||
|
||||
Returns:
|
||||
- dict: 额外配置(会与原 config 合并传递给 build_verify_headers)
|
||||
- tuple[dict, dict]: (额外配置, 需持久化的凭据更新)
|
||||
当预处理过程中凭据发生变更时(如 Token Rotation),
|
||||
通过第二个 dict 显式返回需要持久化的字段。
|
||||
"""
|
||||
return {}
|
||||
|
||||
# ==================== 连接器和操作相关方法 ====================
|
||||
|
||||
def get_connector(
|
||||
self,
|
||||
base_url: str,
|
||||
auth_type: ConnectorAuthType | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
) -> ProviderConnector:
|
||||
"""
|
||||
获取连接器实例
|
||||
|
||||
Args:
|
||||
base_url: 提供商 API 基础 URL
|
||||
auth_type: 指定的认证类型,None 则使用默认
|
||||
config: 连接器配置
|
||||
|
||||
Returns:
|
||||
连接器实例
|
||||
|
||||
Raises:
|
||||
ValueError: 不支持的认证类型
|
||||
"""
|
||||
if not self.supported_connectors:
|
||||
raise ValueError(f"架构 {self.architecture_id} 未配置支持的连接器")
|
||||
|
||||
# 查找匹配的连接器
|
||||
connector_cls: type[ProviderConnector] | None = None
|
||||
|
||||
if auth_type:
|
||||
for cls in self.supported_connectors:
|
||||
if cls.auth_type == auth_type:
|
||||
connector_cls = cls
|
||||
break
|
||||
|
||||
if not connector_cls:
|
||||
supported = [c.auth_type.value for c in self.supported_connectors]
|
||||
raise ValueError(
|
||||
f"架构 {self.architecture_id} 不支持 {auth_type.value} 认证,"
|
||||
f"支持的类型: {supported}"
|
||||
)
|
||||
else:
|
||||
# 使用第一个(默认)连接器
|
||||
connector_cls = self.supported_connectors[0]
|
||||
|
||||
return connector_cls(base_url, config)
|
||||
|
||||
def get_action(
|
||||
self,
|
||||
action_type: ProviderActionType,
|
||||
config: dict[str, Any] | None = None,
|
||||
) -> ProviderAction:
|
||||
"""
|
||||
获取操作实例
|
||||
|
||||
Args:
|
||||
action_type: 操作类型
|
||||
config: 操作配置(会与默认配置合并)
|
||||
|
||||
Returns:
|
||||
操作实例
|
||||
|
||||
Raises:
|
||||
ValueError: 不支持的操作类型
|
||||
"""
|
||||
action_cls: type[ProviderAction] | None = None
|
||||
|
||||
for cls in self.supported_actions:
|
||||
if cls.action_type == action_type:
|
||||
action_cls = cls
|
||||
break
|
||||
|
||||
if not action_cls:
|
||||
supported = [a.action_type.value for a in self.supported_actions]
|
||||
raise ValueError(
|
||||
f"架构 {self.architecture_id} 不支持 {action_type.value} 操作,"
|
||||
f"支持的操作: {supported}"
|
||||
)
|
||||
|
||||
# 合并默认配置和用户配置
|
||||
merged_config = dict(self.default_action_configs.get(action_type, {}))
|
||||
if config:
|
||||
merged_config.update(config)
|
||||
|
||||
return action_cls(merged_config)
|
||||
|
||||
def supports_action(self, action_type: ProviderActionType) -> bool:
|
||||
"""检查是否支持指定操作"""
|
||||
return any(a.action_type == action_type for a in self.supported_actions)
|
||||
|
||||
def supports_auth_type(self, auth_type: ConnectorAuthType) -> bool:
|
||||
"""检查是否支持指定认证类型"""
|
||||
return any(c.auth_type == auth_type for c in self.supported_connectors)
|
||||
|
||||
def get_supported_auth_types(self) -> list[ConnectorAuthType]:
|
||||
"""获取支持的认证类型列表"""
|
||||
return [c.auth_type for c in self.supported_connectors]
|
||||
|
||||
def get_supported_action_types(self) -> list[ProviderActionType]:
|
||||
"""获取支持的操作类型列表"""
|
||||
return [a.action_type for a in self.supported_actions]
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""转换为字典(用于 API 响应)"""
|
||||
return {
|
||||
"architecture_id": self.architecture_id,
|
||||
"display_name": self.display_name,
|
||||
"description": self.description,
|
||||
"credentials_schema": self.get_credentials_schema(),
|
||||
"verify_endpoint": self.get_verify_endpoint(),
|
||||
"supported_auth_types": [
|
||||
{
|
||||
"type": c.auth_type.value,
|
||||
"display_name": c.display_name,
|
||||
"credentials_schema": c.get_credentials_schema(),
|
||||
}
|
||||
for c in self.supported_connectors
|
||||
],
|
||||
"supported_actions": [
|
||||
{
|
||||
"type": a.action_type.value,
|
||||
"display_name": a.display_name,
|
||||
"description": a.description,
|
||||
"config_schema": a.get_config_schema(),
|
||||
}
|
||||
for a in self.supported_actions
|
||||
],
|
||||
"default_connector": (
|
||||
self.supported_connectors[0].auth_type.value if self.supported_connectors else None
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
Cubence 架构
|
||||
|
||||
针对 Cubence 中转站的预设配置。
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.services.provider_ops.actions import ProviderAction
|
||||
from src.services.provider_ops.actions.cubence_balance import CubenceBalanceAction
|
||||
from src.services.provider_ops.architectures.base import (
|
||||
ProviderArchitecture,
|
||||
ProviderConnector,
|
||||
VerifyResult,
|
||||
)
|
||||
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
|
||||
from src.services.provider_ops.utils import extract_cookie_value
|
||||
|
||||
|
||||
class CubenceConnector(ProviderConnector):
|
||||
"""
|
||||
Cubence 专用连接器
|
||||
|
||||
特点:
|
||||
- 使用 Cookie 认证(token JWT)
|
||||
"""
|
||||
|
||||
auth_type = ConnectorAuthType.COOKIE
|
||||
display_name = "Cubence Cookie"
|
||||
|
||||
def __init__(self, base_url: str, config: dict[str, Any] | None = None):
|
||||
super().__init__(base_url, config)
|
||||
self._token_cookie: str | None = None
|
||||
|
||||
async def connect(self, credentials: dict[str, Any]) -> bool:
|
||||
"""建立连接"""
|
||||
token_cookie = credentials.get("token_cookie")
|
||||
if not token_cookie:
|
||||
self._set_error("Token Cookie 不能为空")
|
||||
return False
|
||||
|
||||
# 提取纯 token 值
|
||||
self._token_cookie = extract_cookie_value(token_cookie, "token")
|
||||
|
||||
self._set_connected()
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""断开连接"""
|
||||
self._token_cookie = None
|
||||
self._set_disconnected()
|
||||
|
||||
async def is_authenticated(self) -> bool:
|
||||
"""检查是否已认证"""
|
||||
return self._token_cookie is not None
|
||||
|
||||
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
|
||||
"""为请求应用认证信息"""
|
||||
if self._token_cookie:
|
||||
request.headers["Cookie"] = f"token={self._token_cookie}"
|
||||
|
||||
return request
|
||||
|
||||
@classmethod
|
||||
def get_credentials_schema(cls) -> dict[str, Any]:
|
||||
"""获取凭据配置 schema"""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"title": "站点地址",
|
||||
"description": "API 基础地址",
|
||||
"x-default-value": "https://cubence.com",
|
||||
},
|
||||
"token_cookie": {
|
||||
"type": "string",
|
||||
"title": "Token Cookie",
|
||||
"description": "从浏览器复制的 token Cookie 值(JWT 格式)",
|
||||
"x-sensitive": True,
|
||||
"x-input-type": "password",
|
||||
},
|
||||
},
|
||||
"required": ["token_cookie"],
|
||||
"x-field-groups": [
|
||||
{"fields": ["base_url"]},
|
||||
{"fields": ["token_cookie"]},
|
||||
],
|
||||
"x-auth-type": "cookie",
|
||||
"x-default-base-url": "https://cubence.com",
|
||||
"x-validation": [
|
||||
{
|
||||
"type": "required",
|
||||
"fields": ["token_cookie"],
|
||||
"message": "请填写 Token Cookie",
|
||||
},
|
||||
],
|
||||
"x-quota-divisor": None,
|
||||
"x-currency": "USD",
|
||||
"x-balance-extra-format": [
|
||||
{
|
||||
"label": "5h",
|
||||
"type": "window_limit",
|
||||
"source": "five_hour_limit",
|
||||
"unit_divisor": 1000000,
|
||||
},
|
||||
{
|
||||
"label": "周",
|
||||
"type": "window_limit",
|
||||
"source": "weekly_limit",
|
||||
"unit_divisor": 1000000,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class CubenceArchitecture(ProviderArchitecture):
|
||||
"""
|
||||
Cubence 架构预设
|
||||
|
||||
针对 Cubence 中转站优化的预设配置。
|
||||
|
||||
特点:
|
||||
- 使用 Cookie 认证(token JWT)
|
||||
- 验证端点: /api/v1/dashboard/overview
|
||||
- 余额单位直接是美元
|
||||
- 支持窗口限额(5小时/每周)
|
||||
"""
|
||||
|
||||
architecture_id = "cubence"
|
||||
display_name = "Cubence"
|
||||
description = "Cubence 中转站预设配置,使用 Cookie 认证"
|
||||
|
||||
supported_connectors: list[type[ProviderConnector]] = [
|
||||
CubenceConnector,
|
||||
]
|
||||
|
||||
supported_actions: list[type[ProviderAction]] = [
|
||||
CubenceBalanceAction,
|
||||
]
|
||||
|
||||
default_action_configs: dict[ProviderActionType, dict[str, Any]] = {
|
||||
ProviderActionType.QUERY_BALANCE: {
|
||||
"endpoint": "/api/v1/dashboard/overview",
|
||||
"method": "GET",
|
||||
"response_mapping": {
|
||||
"total_available": "data.balance.total_balance_dollar",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def get_credentials_schema(self) -> dict[str, Any]:
|
||||
"""Cubence 使用 token_cookie 认证"""
|
||||
return CubenceConnector.get_credentials_schema()
|
||||
|
||||
def get_verify_endpoint(self) -> str:
|
||||
"""验证端点"""
|
||||
return "/api/v1/dashboard/overview"
|
||||
|
||||
def build_verify_headers(
|
||||
self,
|
||||
config: dict[str, Any],
|
||||
credentials: dict[str, Any],
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
构建 Cubence 的验证请求 Headers
|
||||
|
||||
使用 Cookie 认证,不使用 Authorization。
|
||||
"""
|
||||
headers: dict[str, str] = {}
|
||||
|
||||
# 添加 token Cookie
|
||||
cookie_input = credentials.get("token_cookie")
|
||||
if cookie_input:
|
||||
token_value = extract_cookie_value(cookie_input, "token")
|
||||
headers["Cookie"] = f"token={token_value}"
|
||||
|
||||
return headers
|
||||
|
||||
def _auth_fail_message(self, status_code: int) -> str:
|
||||
"""Cookie 认证的错误消息"""
|
||||
if status_code == 401:
|
||||
return "Cookie 已失效,请重新配置"
|
||||
return "Cookie 已失效或无权限"
|
||||
|
||||
def _build_verify_result(
|
||||
self, user_data: dict[str, Any], raw_data: dict[str, Any] | None = None
|
||||
) -> VerifyResult:
|
||||
"""Cubence 自定义字段提取(user/balance/subscription_limits)"""
|
||||
user_info = user_data.get("user", {})
|
||||
balance_info = user_data.get("balance", {})
|
||||
subscription_limits = user_data.get("subscription_limits", {})
|
||||
|
||||
# 构建 extra 信息,包含窗口限额
|
||||
extra: dict[str, Any] = {
|
||||
"role": user_info.get("role"),
|
||||
"invite_code": user_info.get("invite_code"),
|
||||
}
|
||||
|
||||
# 5小时窗口限额
|
||||
five_hour = subscription_limits.get("five_hour", {})
|
||||
if five_hour:
|
||||
extra["five_hour_limit"] = {
|
||||
"limit": five_hour.get("limit"),
|
||||
"used": five_hour.get("used"),
|
||||
"remaining": five_hour.get("remaining"),
|
||||
"resets_at": five_hour.get("resets_at"),
|
||||
}
|
||||
|
||||
# 每周窗口限额
|
||||
weekly = subscription_limits.get("weekly", {})
|
||||
if weekly:
|
||||
extra["weekly_limit"] = {
|
||||
"limit": weekly.get("limit"),
|
||||
"used": weekly.get("used"),
|
||||
"remaining": weekly.get("remaining"),
|
||||
"resets_at": weekly.get("resets_at"),
|
||||
}
|
||||
|
||||
return VerifyResult(
|
||||
success=True,
|
||||
username=user_info.get("username"),
|
||||
display_name=user_info.get("username"),
|
||||
quota=balance_info.get("total_balance_dollar"),
|
||||
extra=extra,
|
||||
)
|
||||
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
通用 API 架构
|
||||
|
||||
支持各种中转站的可配置架构。
|
||||
|
||||
## 添加新认证模板示例
|
||||
|
||||
如需添加新的中转站模板(如 MyApi),参考以下步骤:
|
||||
|
||||
1. 在 architectures/ 目录创建新文件,如 my_api.py:
|
||||
|
||||
from src.services.provider_ops.architectures.base import ProviderArchitecture
|
||||
from src.services.provider_ops.connectors.base import ProviderConnector
|
||||
|
||||
class MyApiConnector(ProviderConnector):
|
||||
# 实现自己的连接器
|
||||
pass
|
||||
|
||||
class MyApiArchitecture(ProviderArchitecture):
|
||||
architecture_id = "my_api"
|
||||
display_name = "My API"
|
||||
description = "My API 风格中转站"
|
||||
|
||||
supported_connectors = [MyApiConnector]
|
||||
supported_actions = [BalanceAction]
|
||||
|
||||
# 如果需要特殊的认证 headers,重写此方法
|
||||
def build_verify_headers(self, config, credentials):
|
||||
headers = super().build_verify_headers(config, credentials)
|
||||
if "custom_field" in credentials:
|
||||
headers["X-Custom-Header"] = credentials["custom_field"]
|
||||
return headers
|
||||
|
||||
2. 在 registry.py 的 _register_builtin_architectures() 中注册:
|
||||
|
||||
from .my_api import MyApiArchitecture
|
||||
builtin = [..., MyApiArchitecture]
|
||||
|
||||
3. 在前端 auth-templates/ 添加对应的模板定义
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.services.provider_ops.actions import (
|
||||
NewApiBalanceAction,
|
||||
ProviderAction,
|
||||
)
|
||||
from src.services.provider_ops.architectures.base import (
|
||||
ProviderArchitecture,
|
||||
ProviderConnector,
|
||||
)
|
||||
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
|
||||
|
||||
|
||||
class GenericApiKeyConnector(ProviderConnector):
|
||||
"""
|
||||
通用 API Key 连接器
|
||||
|
||||
支持多种 API Key 传递方式:
|
||||
- Bearer Token (Authorization: Bearer xxx)
|
||||
- Custom Header (X-API-Key: xxx)
|
||||
"""
|
||||
|
||||
auth_type = ConnectorAuthType.API_KEY
|
||||
display_name = "API Key"
|
||||
|
||||
def __init__(self, base_url: str, config: dict[str, Any] | None = None):
|
||||
super().__init__(base_url, config)
|
||||
self._api_key: str | None = None
|
||||
# 支持配置认证方式
|
||||
self._auth_method = self.config.get("auth_method", "bearer")
|
||||
self._header_name = self.config.get("header_name", "Authorization")
|
||||
|
||||
async def connect(self, credentials: dict[str, Any]) -> bool:
|
||||
"""建立连接"""
|
||||
api_key = credentials.get("api_key")
|
||||
if not api_key:
|
||||
self._set_error("API Key 不能为空")
|
||||
return False
|
||||
|
||||
self._api_key = api_key
|
||||
self._set_connected()
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""断开连接"""
|
||||
self._api_key = None
|
||||
self._set_disconnected()
|
||||
|
||||
async def is_authenticated(self) -> bool:
|
||||
"""检查是否已认证"""
|
||||
return self._api_key is not None
|
||||
|
||||
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
|
||||
"""为请求应用认证信息"""
|
||||
if not self._api_key:
|
||||
return request
|
||||
|
||||
if self._auth_method == "bearer":
|
||||
request.headers["Authorization"] = f"Bearer {self._api_key}"
|
||||
elif self._auth_method == "header":
|
||||
request.headers[self._header_name] = self._api_key
|
||||
|
||||
return request
|
||||
|
||||
@classmethod
|
||||
def get_credentials_schema(cls) -> dict[str, Any]:
|
||||
"""获取凭据配置 schema"""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"title": "站点地址",
|
||||
"description": "API 基础地址",
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"title": "API Key",
|
||||
"description": "提供商的 API Key",
|
||||
"x-sensitive": True,
|
||||
"x-input-type": "password",
|
||||
},
|
||||
},
|
||||
"required": ["api_key"],
|
||||
"x-field-groups": [
|
||||
{"fields": ["base_url"]},
|
||||
{"fields": ["api_key"]},
|
||||
],
|
||||
"x-auth-type": "api_key",
|
||||
"x-auth-method": "bearer",
|
||||
"x-validation": [
|
||||
{
|
||||
"type": "required",
|
||||
"fields": ["api_key"],
|
||||
"message": "请填写 API Key",
|
||||
},
|
||||
],
|
||||
"x-quota-divisor": 500000,
|
||||
"x-currency": "USD",
|
||||
}
|
||||
|
||||
|
||||
class GenericApiArchitecture(ProviderArchitecture):
|
||||
"""
|
||||
通用 API 架构
|
||||
|
||||
适用于各种中转站,支持所有认证方式和操作类型。
|
||||
用户可以完全自定义 endpoint 和响应映射。
|
||||
|
||||
这是"自定义"模板对应的后端架构。
|
||||
"""
|
||||
|
||||
architecture_id = "generic_api"
|
||||
display_name = "通用 API"
|
||||
description = "可配置的通用 API 架构,适用于各种中转站"
|
||||
hidden = True
|
||||
|
||||
supported_connectors: list[type[ProviderConnector]] = [
|
||||
GenericApiKeyConnector,
|
||||
]
|
||||
|
||||
supported_actions: list[type[ProviderAction]] = [NewApiBalanceAction]
|
||||
|
||||
# 默认操作配置(可被用户配置覆盖)
|
||||
default_action_configs: dict[ProviderActionType, dict[str, Any]] = {
|
||||
ProviderActionType.QUERY_BALANCE: {
|
||||
"endpoint": "/api/user/balance",
|
||||
"method": "GET",
|
||||
},
|
||||
ProviderActionType.CHECKIN: {
|
||||
"endpoint": "/api/user/checkin",
|
||||
"method": "POST",
|
||||
},
|
||||
}
|
||||
|
||||
def get_credentials_schema(self) -> dict[str, Any]:
|
||||
"""通用架构只需要 api_key"""
|
||||
return GenericApiKeyConnector.get_credentials_schema()
|
||||
|
||||
def get_verify_endpoint(self) -> str:
|
||||
"""通用架构验证端点"""
|
||||
return "/api/user/self"
|
||||
|
||||
def build_verify_headers(
|
||||
self,
|
||||
config: dict[str, Any],
|
||||
credentials: dict[str, Any],
|
||||
) -> dict[str, str]:
|
||||
"""构建通用 API 的验证请求 Headers"""
|
||||
headers: dict[str, str] = {}
|
||||
|
||||
api_key = credentials.get("api_key", "")
|
||||
if api_key:
|
||||
auth_method = config.get("auth_method", "bearer")
|
||||
if auth_method == "bearer":
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
elif auth_method == "header":
|
||||
header_name = config.get("header_name", "X-API-Key")
|
||||
headers[header_name] = api_key
|
||||
|
||||
return headers
|
||||
@@ -0,0 +1,289 @@
|
||||
"""
|
||||
NekoCode 架构
|
||||
|
||||
针对 NekoCode 中转站的预设配置,使用 Cookie 认证。
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.services.provider_ops.actions import ProviderAction
|
||||
from src.services.provider_ops.actions.nekocode_balance import NekoCodeBalanceAction
|
||||
from src.services.provider_ops.architectures.base import (
|
||||
ProviderArchitecture,
|
||||
ProviderConnector,
|
||||
VerifyResult,
|
||||
)
|
||||
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
|
||||
from src.services.provider_ops.utils import extract_cookie_value
|
||||
from src.utils.ssl_utils import get_ssl_context
|
||||
|
||||
|
||||
class NekoCodeConnector(ProviderConnector):
|
||||
"""
|
||||
NekoCode 专用连接器
|
||||
|
||||
特点:
|
||||
- 使用 Cookie 认证(session)
|
||||
"""
|
||||
|
||||
auth_type = ConnectorAuthType.COOKIE
|
||||
display_name = "NekoCode Cookie"
|
||||
|
||||
def __init__(self, base_url: str, config: dict[str, Any] | None = None):
|
||||
super().__init__(base_url, config)
|
||||
self._session_cookie: str | None = None
|
||||
|
||||
async def connect(self, credentials: dict[str, Any]) -> bool:
|
||||
"""建立连接"""
|
||||
session_cookie = credentials.get("session_cookie")
|
||||
if not session_cookie:
|
||||
self._set_error("Session Cookie 不能为空")
|
||||
return False
|
||||
|
||||
# 提取纯 session 值(支持完整 Cookie 字符串或仅 session 值)
|
||||
self._session_cookie = extract_cookie_value(session_cookie, "session")
|
||||
|
||||
self._set_connected()
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""断开连接"""
|
||||
self._session_cookie = None
|
||||
self._set_disconnected()
|
||||
|
||||
async def is_authenticated(self) -> bool:
|
||||
"""检查是否已认证"""
|
||||
return self._session_cookie is not None
|
||||
|
||||
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
|
||||
"""为请求应用认证信息"""
|
||||
if self._session_cookie:
|
||||
request.headers["Cookie"] = f"session={self._session_cookie}"
|
||||
|
||||
return request
|
||||
|
||||
@classmethod
|
||||
def get_credentials_schema(cls) -> dict[str, Any]:
|
||||
"""获取凭据配置 schema"""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"title": "站点地址",
|
||||
"description": "API 基础地址",
|
||||
"x-default-value": "https://nekocode.ai",
|
||||
},
|
||||
"session_cookie": {
|
||||
"type": "string",
|
||||
"title": "Session Cookie",
|
||||
"description": "从浏览器复制的 session Cookie 值",
|
||||
"x-sensitive": True,
|
||||
"x-input-type": "password",
|
||||
},
|
||||
},
|
||||
"required": ["session_cookie"],
|
||||
"x-field-groups": [
|
||||
{"fields": ["base_url"]},
|
||||
{"fields": ["session_cookie"]},
|
||||
],
|
||||
"x-auth-type": "cookie",
|
||||
"x-default-base-url": "https://nekocode.ai",
|
||||
"x-validation": [
|
||||
{
|
||||
"type": "required",
|
||||
"fields": ["session_cookie"],
|
||||
"message": "请填写 Session Cookie",
|
||||
},
|
||||
],
|
||||
"x-quota-divisor": None,
|
||||
"x-currency": "USD",
|
||||
"x-balance-extra-format": [
|
||||
{
|
||||
"label": "天",
|
||||
"type": "daily_quota",
|
||||
"source_limit": "daily_quota_limit",
|
||||
"source_remaining": "daily_remaining_quota",
|
||||
"source_start_date": "effective_start_date",
|
||||
},
|
||||
{
|
||||
"label": "月",
|
||||
"type": "monthly_expiry",
|
||||
"source_end_date": "effective_end_date",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class NekoCodeArchitecture(ProviderArchitecture):
|
||||
"""
|
||||
NekoCode 架构预设
|
||||
|
||||
针对 NekoCode 中转站优化的预设配置。
|
||||
|
||||
特点:
|
||||
- 使用 Cookie 认证(session)
|
||||
- 验证端点: /api/usage/summary
|
||||
- 显示余额、每日配额、订阅状态
|
||||
"""
|
||||
|
||||
architecture_id = "nekocode"
|
||||
display_name = "NekoCode"
|
||||
description = "NekoCode 中转站预设配置,使用 Cookie 认证"
|
||||
|
||||
supported_connectors: list[type[ProviderConnector]] = [
|
||||
NekoCodeConnector,
|
||||
]
|
||||
|
||||
supported_actions: list[type[ProviderAction]] = [NekoCodeBalanceAction]
|
||||
|
||||
default_action_configs: dict[ProviderActionType, dict[str, Any]] = {
|
||||
ProviderActionType.QUERY_BALANCE: {
|
||||
"endpoint": "/api/usage/summary",
|
||||
"method": "GET",
|
||||
},
|
||||
}
|
||||
|
||||
def get_credentials_schema(self) -> dict[str, Any]:
|
||||
"""NekoCode 使用 session_cookie 认证"""
|
||||
return NekoCodeConnector.get_credentials_schema()
|
||||
|
||||
def get_verify_endpoint(self) -> str:
|
||||
"""验证端点"""
|
||||
return "/api/user/self"
|
||||
|
||||
async def prepare_verify_config(
|
||||
self,
|
||||
base_url: str,
|
||||
config: dict[str, Any],
|
||||
credentials: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
验证前获取 /api/usage/summary 数据(用于显示天卡信息)
|
||||
|
||||
Args:
|
||||
base_url: API 基础地址
|
||||
config: 连接器配置
|
||||
credentials: 凭据信息
|
||||
|
||||
Returns:
|
||||
包含 _usage_summary 的配置(会被合并到验证响应中)
|
||||
"""
|
||||
try:
|
||||
# 构建请求头
|
||||
headers: dict[str, str] = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/120.0.0.0 Safari/537.36"
|
||||
),
|
||||
}
|
||||
|
||||
# 添加 Cookie
|
||||
cookie_input = credentials.get("session_cookie")
|
||||
if cookie_input:
|
||||
session_value = extract_cookie_value(cookie_input, "session")
|
||||
headers["Cookie"] = f"session={session_value}"
|
||||
|
||||
# 构建 client 参数
|
||||
client_kwargs: dict[str, Any] = {
|
||||
"timeout": 10,
|
||||
"verify": get_ssl_context(),
|
||||
}
|
||||
from src.services.proxy_node.resolver import resolve_ops_proxy_config_async
|
||||
|
||||
proxy, tunnel_node_id = await resolve_ops_proxy_config_async(config)
|
||||
if tunnel_node_id:
|
||||
from src.services.proxy_node.tunnel_transport import create_tunnel_transport
|
||||
|
||||
client_kwargs["transport"] = create_tunnel_transport(tunnel_node_id, timeout=10.0)
|
||||
elif proxy:
|
||||
client_kwargs["proxy"] = proxy
|
||||
|
||||
async with httpx.AsyncClient(**client_kwargs) as client:
|
||||
resp = await client.get(
|
||||
f"{base_url.rstrip('/')}/api/usage/summary",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if data.get("success"):
|
||||
return {"_usage_summary": data.get("data", {})}
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("获取 NekoCode usage summary 失败: {}", e)
|
||||
|
||||
return {}
|
||||
|
||||
def build_verify_headers(
|
||||
self,
|
||||
config: dict[str, Any],
|
||||
credentials: dict[str, Any],
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
构建 NekoCode 的验证请求 Headers
|
||||
|
||||
使用 Cookie 认证,不使用 Authorization。
|
||||
"""
|
||||
headers: dict[str, str] = {}
|
||||
|
||||
# 添加 session Cookie
|
||||
cookie_input = credentials.get("session_cookie")
|
||||
if cookie_input:
|
||||
# 提取 session 值(支持完整 Cookie 字符串或仅 session 值)
|
||||
session_value = extract_cookie_value(cookie_input, "session")
|
||||
headers["Cookie"] = f"session={session_value}"
|
||||
|
||||
return headers
|
||||
|
||||
def _auth_fail_message(self, status_code: int) -> str:
|
||||
"""Cookie 认证的错误消息"""
|
||||
if status_code == 401:
|
||||
return "Cookie 已失效,请重新配置"
|
||||
return "Cookie 已失效或无权限"
|
||||
|
||||
def _build_verify_result(
|
||||
self, user_data: dict[str, Any], raw_data: dict[str, Any] | None = None
|
||||
) -> VerifyResult:
|
||||
"""NekoCode 自定义字段提取(合并 _usage_summary 天卡数据)"""
|
||||
# 转换余额字符串为数字
|
||||
balance = user_data.get("balance")
|
||||
try:
|
||||
quota = float(balance) if balance else None
|
||||
except (TypeError, ValueError):
|
||||
quota = None
|
||||
|
||||
# 从 prepare_verify_config 获取的 _usage_summary 数据(天卡信息)
|
||||
extra: dict[str, Any] = {}
|
||||
usage_summary = (raw_data or {}).get("_usage_summary", {})
|
||||
subscription = usage_summary.get("subscription", {})
|
||||
|
||||
if subscription:
|
||||
daily_limit = subscription.get("daily_quota_limit")
|
||||
daily_remaining = subscription.get("daily_remaining_quota")
|
||||
try:
|
||||
extra["daily_quota_limit"] = float(daily_limit) if daily_limit else None
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
extra["daily_remaining_quota"] = float(daily_remaining) if daily_remaining else None
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
extra["plan_name"] = subscription.get("plan_name")
|
||||
extra["subscription_status"] = subscription.get("status")
|
||||
extra["effective_start_date"] = subscription.get("effective_start_date")
|
||||
extra["effective_end_date"] = subscription.get("effective_end_date")
|
||||
|
||||
return VerifyResult(
|
||||
success=True,
|
||||
username=user_data.get("username"),
|
||||
display_name=user_data.get("display_name") or user_data.get("username"),
|
||||
email=user_data.get("email"),
|
||||
quota=quota,
|
||||
extra=extra if extra else None,
|
||||
)
|
||||
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
New API 架构
|
||||
|
||||
针对 New API 风格的中转站优化的预设配置。
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.api_format.headers import BROWSER_FINGERPRINT_HEADERS
|
||||
from src.services.provider_ops.actions import (
|
||||
NewApiBalanceAction,
|
||||
ProviderAction,
|
||||
)
|
||||
from src.services.provider_ops.architectures.base import (
|
||||
ProviderArchitecture,
|
||||
ProviderConnector,
|
||||
)
|
||||
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
|
||||
|
||||
|
||||
class NewApiConnector(ProviderConnector):
|
||||
"""
|
||||
New API 专用连接器
|
||||
|
||||
特点:
|
||||
- 使用 Bearer Token 认证
|
||||
- 需要 New-Api-User Header 传递用户 ID
|
||||
"""
|
||||
|
||||
auth_type = ConnectorAuthType.API_KEY
|
||||
display_name = "New API Key"
|
||||
|
||||
def __init__(self, base_url: str, config: dict[str, Any] | None = None):
|
||||
super().__init__(base_url, config)
|
||||
self._api_key: str | None = None
|
||||
self._user_id: str | None = None
|
||||
self._cookie: str | None = None
|
||||
|
||||
async def connect(self, credentials: dict[str, Any]) -> bool:
|
||||
"""建立连接"""
|
||||
api_key = credentials.get("api_key")
|
||||
cookie = credentials.get("cookie")
|
||||
user_id = credentials.get("user_id")
|
||||
|
||||
# api_key 和 cookie 至少需要一个
|
||||
if not api_key and not cookie:
|
||||
self._set_error("访问令牌和 Cookie 至少需要填写一个")
|
||||
return False
|
||||
|
||||
# 使用 api_key 时必须提供 user_id,使用 cookie 时 user_id 可选
|
||||
if api_key and not cookie and not user_id:
|
||||
self._set_error("使用访问令牌时,用户 ID 不能为空")
|
||||
return False
|
||||
|
||||
self._api_key = api_key
|
||||
self._user_id = str(user_id) if user_id else None
|
||||
self._cookie = cookie
|
||||
self._set_connected()
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""断开连接"""
|
||||
self._api_key = None
|
||||
self._user_id = None
|
||||
self._cookie = None
|
||||
self._set_disconnected()
|
||||
|
||||
async def is_authenticated(self) -> bool:
|
||||
"""检查是否已认证"""
|
||||
# 有 cookie 就行,或者有 api_key + user_id
|
||||
if self._cookie:
|
||||
return True
|
||||
return self._api_key is not None and self._user_id is not None
|
||||
|
||||
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
|
||||
"""为请求应用认证信息"""
|
||||
# 添加浏览器指纹 Headers 以绕过 Cloudflare 等防护
|
||||
for key, value in BROWSER_FINGERPRINT_HEADERS.items():
|
||||
request.headers.setdefault(key, value)
|
||||
|
||||
if self._api_key:
|
||||
request.headers["Authorization"] = f"Bearer {self._api_key}"
|
||||
if self._user_id:
|
||||
request.headers["New-Api-User"] = self._user_id
|
||||
if self._cookie:
|
||||
request.headers["Cookie"] = self._cookie
|
||||
return request
|
||||
|
||||
@classmethod
|
||||
def get_credentials_schema(cls) -> dict[str, Any]:
|
||||
"""获取凭据配置 schema"""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"title": "站点地址",
|
||||
"description": "API 基础地址",
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"title": "访问令牌 (API Key)",
|
||||
"description": "New API 的访问令牌,与 Cookie 二选一",
|
||||
"x-sensitive": True,
|
||||
"x-input-type": "password",
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string",
|
||||
"title": "用户 ID",
|
||||
"description": "使用访问令牌时必填,使用 Cookie 时可选",
|
||||
},
|
||||
"cookie": {
|
||||
"type": "string",
|
||||
"title": "Cookie",
|
||||
"description": "用于 Cookie 认证,与访问令牌二选一",
|
||||
"x-sensitive": True,
|
||||
"x-input-type": "password",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
"x-field-groups": [
|
||||
{"fields": ["base_url"]},
|
||||
{
|
||||
"fields": ["cookie"],
|
||||
"x-help": "从浏览器开发者工具复制完整 Cookie",
|
||||
},
|
||||
{
|
||||
"layout": "inline",
|
||||
"fields": ["api_key", "user_id"],
|
||||
"x-flex": {"api_key": 3, "user_id": 1},
|
||||
},
|
||||
],
|
||||
"x-auth-type": "api_key",
|
||||
"x-auth-method": "bearer",
|
||||
"x-validation": [
|
||||
{
|
||||
"type": "any_required",
|
||||
"fields": ["api_key", "cookie"],
|
||||
"message": "访问令牌和 Cookie 至少需要填写一个",
|
||||
},
|
||||
{
|
||||
"type": "conditional_required",
|
||||
"if": "api_key",
|
||||
"unless": "cookie",
|
||||
"then": ["user_id"],
|
||||
"message": "使用访问令牌时,用户 ID 不能为空",
|
||||
},
|
||||
],
|
||||
"x-quota-divisor": 500000,
|
||||
"x-currency": "USD",
|
||||
"x-field-hooks": {
|
||||
"cookie": {
|
||||
"action": "parse_new_api_user_id",
|
||||
"target": "user_id",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class NewApiArchitecture(ProviderArchitecture):
|
||||
"""
|
||||
New API 架构预设
|
||||
|
||||
针对 New API 风格的中转站优化的预设配置。
|
||||
|
||||
特点:
|
||||
- 使用 Bearer Token 认证
|
||||
- 需要 New-Api-User Header 传递用户 ID
|
||||
- 验证端点: /api/user/self
|
||||
- quota 单位通常是 1/500000 美元
|
||||
"""
|
||||
|
||||
architecture_id = "new_api"
|
||||
display_name = "New API"
|
||||
description = "New API 风格中转站的预设配置"
|
||||
|
||||
supported_connectors: list[type[ProviderConnector]] = [
|
||||
NewApiConnector,
|
||||
]
|
||||
|
||||
supported_actions: list[type[ProviderAction]] = [
|
||||
NewApiBalanceAction,
|
||||
]
|
||||
|
||||
default_action_configs: dict[ProviderActionType, dict[str, Any]] = {
|
||||
ProviderActionType.QUERY_BALANCE: {
|
||||
"endpoint": "/api/user/self",
|
||||
"method": "GET",
|
||||
"quota_divisor": 500000, # New API 的 quota 单位是 1/500000 美元
|
||||
"checkin_endpoint": "/api/user/checkin", # 签到端点
|
||||
},
|
||||
}
|
||||
|
||||
def get_credentials_schema(self) -> dict[str, Any]:
|
||||
"""New API 需要 api_key 和 user_id"""
|
||||
return NewApiConnector.get_credentials_schema()
|
||||
|
||||
def get_verify_endpoint(self) -> str:
|
||||
"""New API 验证端点"""
|
||||
return "/api/user/self"
|
||||
|
||||
def build_verify_headers(
|
||||
self,
|
||||
config: dict[str, Any],
|
||||
credentials: dict[str, Any],
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
构建 New API 的验证请求 Headers
|
||||
|
||||
New API 特有:需要 New-Api-User Header 传递用户 ID
|
||||
"""
|
||||
# 以浏览器指纹 Headers 为基础,绕过 Cloudflare 等防护
|
||||
headers: dict[str, str] = {**BROWSER_FINGERPRINT_HEADERS}
|
||||
|
||||
# Bearer Token 认证
|
||||
api_key = credentials.get("api_key", "")
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
# New API 特有的 header
|
||||
user_id = credentials.get("user_id", "")
|
||||
if user_id:
|
||||
headers["New-Api-User"] = str(user_id)
|
||||
|
||||
# 可选的 Cookie
|
||||
cookie = credentials.get("cookie", "")
|
||||
if cookie:
|
||||
headers["Cookie"] = cookie
|
||||
|
||||
return headers
|
||||
@@ -0,0 +1,565 @@
|
||||
"""
|
||||
Sub2API 架构
|
||||
|
||||
针对 Sub2API 风格的中转站优化的预设配置。
|
||||
支持两种认证方式:
|
||||
1. 账号密码登录(自动获取 JWT,过期自动刷新,refresh 失败自动重新登录)
|
||||
2. Refresh Token(从浏览器 localStorage 获取,自动续期,适合 OAuth 用户)
|
||||
"""
|
||||
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.services.provider_ops.actions import ProviderAction
|
||||
from src.services.provider_ops.actions.sub2api_balance import Sub2ApiBalanceAction
|
||||
from src.services.provider_ops.architectures.base import (
|
||||
ProviderArchitecture,
|
||||
ProviderConnector,
|
||||
VerifyResult,
|
||||
)
|
||||
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
|
||||
from src.utils.ssl_utils import get_ssl_context
|
||||
|
||||
|
||||
def _calc_expires_at(token_data: dict[str, Any]) -> float:
|
||||
"""从 token 响应数据计算过期时间(秒级时间戳,提前 60s)"""
|
||||
token_expires_at = token_data.get("token_expires_at")
|
||||
if token_expires_at is not None:
|
||||
# Sub2API 返回毫秒级绝对时间戳
|
||||
return token_expires_at / 1000 - 60
|
||||
expires_in = token_data.get("expires_in", 900)
|
||||
return time.time() + expires_in - 60
|
||||
|
||||
|
||||
async def _do_login(
|
||||
client: httpx.AsyncClient,
|
||||
email: str,
|
||||
password: str,
|
||||
) -> dict[str, Any]:
|
||||
"""调用 Sub2API 登录接口,返回 token_data。失败抛 ValueError。"""
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": email, "password": password},
|
||||
)
|
||||
data = resp.json()
|
||||
if resp.status_code != 200 or data.get("code", -1) != 0:
|
||||
raise ValueError(data.get("message", f"登录失败 (HTTP {resp.status_code})"))
|
||||
return data.get("data", {})
|
||||
|
||||
|
||||
async def _do_refresh(
|
||||
client: httpx.AsyncClient,
|
||||
refresh_token: str,
|
||||
) -> dict[str, Any]:
|
||||
"""调用 Sub2API refresh 接口,返回 token_data。失败抛 ValueError。"""
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
data = resp.json()
|
||||
if resp.status_code != 200 or data.get("code", -1) != 0:
|
||||
raise ValueError(data.get("message", "Refresh Token 无效或已过期"))
|
||||
return data.get("data", {})
|
||||
|
||||
|
||||
def _collect_updated_credentials(
|
||||
token_data: dict[str, Any],
|
||||
old_refresh_token: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""从 token 响应中提取需要持久化的凭据变更"""
|
||||
updated: dict[str, Any] = {}
|
||||
new_refresh_token = token_data.get("refresh_token")
|
||||
if new_refresh_token and new_refresh_token != old_refresh_token:
|
||||
updated["refresh_token"] = new_refresh_token
|
||||
access_token = token_data.get("access_token")
|
||||
if access_token:
|
||||
updated["_cached_access_token"] = access_token
|
||||
updated["_cached_token_expires_at"] = _calc_expires_at(token_data)
|
||||
return updated
|
||||
|
||||
|
||||
class _Sub2ApiTokenMixin:
|
||||
"""Sub2API JWT token 管理公共逻辑
|
||||
|
||||
与 ProviderConnector 配合使用(MRO 中由 ProviderConnector 提供实际属性初始化)。
|
||||
以下类型注解声明 mixin 依赖的协议属性,不会创建新的实例属性。
|
||||
"""
|
||||
|
||||
# Mixin 自身管理的 token 状态(提供默认值防止子类遗漏初始化)
|
||||
_access_token: str | None = None
|
||||
_refresh_token: str | None = None
|
||||
_token_expires_at: float = 0
|
||||
# 以下属性由 ProviderConnector.__init__ 初始化,仅作协议声明
|
||||
_on_credentials_updated: Callable[[dict[str, Any]], None] | None
|
||||
base_url: str
|
||||
_timeout: int | float
|
||||
_proxy: str | httpx.Proxy | None
|
||||
_tunnel_node_id: str | None
|
||||
|
||||
@asynccontextmanager
|
||||
async def _get_raw_client(self) -> AsyncIterator[httpx.AsyncClient]:
|
||||
"""获取不带 auth hook 的裸 HTTP 客户端(用于登录/刷新 token)"""
|
||||
transport = None
|
||||
if self._tunnel_node_id:
|
||||
from src.services.proxy_node.tunnel_transport import create_tunnel_transport
|
||||
|
||||
transport = create_tunnel_transport(self._tunnel_node_id, timeout=self._timeout)
|
||||
elif self._proxy:
|
||||
transport = httpx.AsyncHTTPTransport(proxy=self._proxy)
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
timeout=self._timeout,
|
||||
transport=transport,
|
||||
verify=get_ssl_context(),
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
def _update_tokens(self, token_data: dict[str, Any]) -> None:
|
||||
"""更新实例 token 状态并通过回调持久化变更"""
|
||||
old_refresh_token = self._refresh_token
|
||||
self._access_token = token_data.get("access_token")
|
||||
self._refresh_token = token_data.get("refresh_token", self._refresh_token)
|
||||
self._token_expires_at = _calc_expires_at(token_data)
|
||||
|
||||
if self._on_credentials_updated:
|
||||
updated = _collect_updated_credentials(token_data, old_refresh_token)
|
||||
if updated:
|
||||
self._on_credentials_updated(updated)
|
||||
|
||||
async def _refresh(self) -> bool:
|
||||
"""使用 refresh_token 续期"""
|
||||
if not self._refresh_token:
|
||||
return False
|
||||
|
||||
try:
|
||||
async with self._get_raw_client() as client:
|
||||
token_data = await _do_refresh(client, self._refresh_token)
|
||||
self._update_tokens(token_data)
|
||||
logger.debug("Sub2API token 续期成功")
|
||||
return True
|
||||
except ValueError as e:
|
||||
logger.warning("Sub2API refresh_token 续期失败: {}", e)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning("Sub2API refresh_token 续期异常: {}", e)
|
||||
return False
|
||||
|
||||
|
||||
class Sub2ApiConnector(_Sub2ApiTokenMixin, ProviderConnector):
|
||||
"""
|
||||
Sub2API 连接器(账号密码模式)
|
||||
|
||||
使用 email + password 登录获取 JWT Token,支持自动刷新:
|
||||
- 登录后获取 access_token + refresh_token
|
||||
- access_token 过期前自动使用 refresh_token 续期
|
||||
- refresh_token 也过期时自动重新登录
|
||||
"""
|
||||
|
||||
auth_type = ConnectorAuthType.SESSION_LOGIN
|
||||
display_name = "账号密码"
|
||||
|
||||
def __init__(self, base_url: str, config: dict[str, Any] | None = None):
|
||||
super().__init__(base_url, config)
|
||||
self._access_token: str | None = None
|
||||
self._refresh_token: str | None = None
|
||||
self._token_expires_at: float = 0
|
||||
self._email: str | None = None
|
||||
self._password: str | None = None
|
||||
|
||||
async def connect(self, credentials: dict[str, Any]) -> bool:
|
||||
email = credentials.get("email", "").strip()
|
||||
password = credentials.get("password", "").strip()
|
||||
if not email or not password:
|
||||
self._set_error("邮箱和密码不能为空")
|
||||
return False
|
||||
|
||||
self._email = email
|
||||
self._password = password
|
||||
|
||||
# 如果有缓存的 access_token 且未过期,直接复用,避免不必要的登录
|
||||
cached_access_token = credentials.get("_cached_access_token", "")
|
||||
cached_expires_at = credentials.get("_cached_token_expires_at", 0)
|
||||
if cached_access_token and time.time() < cached_expires_at:
|
||||
self._access_token = cached_access_token
|
||||
self._token_expires_at = cached_expires_at
|
||||
# 恢复 refresh_token 以便 access_token 过期后可刷新而非重新登录
|
||||
self._refresh_token = credentials.get("refresh_token", "").strip() or None
|
||||
self._set_connected()
|
||||
return True
|
||||
|
||||
return await self._login()
|
||||
|
||||
async def _login(self) -> bool:
|
||||
"""使用 email + password 登录获取 token pair"""
|
||||
try:
|
||||
async with self._get_raw_client() as client:
|
||||
token_data = await _do_login(client, self._email or "", self._password or "")
|
||||
self._update_tokens(token_data)
|
||||
self._set_connected()
|
||||
logger.debug(
|
||||
"Sub2API 登录成功: {}", self._email[:3] + "***" if self._email else "N/A"
|
||||
)
|
||||
return True
|
||||
|
||||
except ValueError as e:
|
||||
self._set_error(str(e))
|
||||
return False
|
||||
except httpx.TimeoutException:
|
||||
self._set_error("登录请求超时")
|
||||
return False
|
||||
except httpx.RequestError as e:
|
||||
self._set_error(f"登录网络错误: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self._set_error(f"登录失败: {e}")
|
||||
return False
|
||||
|
||||
async def _ensure_token(self) -> None:
|
||||
"""确保 access_token 有效,过期则自动刷新或重新登录"""
|
||||
if self._access_token and time.time() < self._token_expires_at:
|
||||
return
|
||||
|
||||
if self._refresh_token and await self._refresh():
|
||||
return
|
||||
|
||||
logger.info("Sub2API token 已过期,尝试重新登录")
|
||||
if not await self._login():
|
||||
logger.error("Sub2API 重新登录失败: {}", self._last_error)
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self._access_token = None
|
||||
self._refresh_token = None
|
||||
self._token_expires_at = 0
|
||||
self._email = None
|
||||
self._password = None
|
||||
self._set_disconnected()
|
||||
|
||||
async def is_authenticated(self) -> bool:
|
||||
if not self._access_token:
|
||||
return False
|
||||
return self._refresh_token is not None or self._email is not None
|
||||
|
||||
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
|
||||
if self._access_token:
|
||||
request.headers["Authorization"] = f"Bearer {self._access_token}"
|
||||
return request
|
||||
|
||||
async def _auth_hook(self, request: httpx.Request) -> None:
|
||||
await self._ensure_token()
|
||||
self._apply_auth(request)
|
||||
|
||||
async def refresh_auth(self, credentials: dict[str, Any]) -> bool:
|
||||
if await self._refresh():
|
||||
return True
|
||||
self._email = credentials.get("email", self._email)
|
||||
self._password = credentials.get("password", self._password)
|
||||
return await self._login()
|
||||
|
||||
@classmethod
|
||||
def get_credentials_schema(cls) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"title": "站点地址",
|
||||
"description": "API 基础地址",
|
||||
},
|
||||
"email": {
|
||||
"type": "string",
|
||||
"title": "邮箱",
|
||||
"description": "Sub2API 登录邮箱",
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"title": "密码",
|
||||
"description": "Sub2API 登录密码",
|
||||
"x-sensitive": True,
|
||||
"x-input-type": "password",
|
||||
},
|
||||
},
|
||||
"required": ["email", "password"],
|
||||
"x-field-groups": [
|
||||
{"fields": ["base_url"]},
|
||||
{"fields": ["email"]},
|
||||
{"fields": ["password"]},
|
||||
],
|
||||
"x-auth-type": "session_login",
|
||||
"x-auth-method": "jwt",
|
||||
"x-validation": [
|
||||
{
|
||||
"type": "required",
|
||||
"fields": ["email", "password"],
|
||||
"message": "请填写邮箱和密码",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class Sub2ApiRefreshTokenConnector(_Sub2ApiTokenMixin, ProviderConnector):
|
||||
"""
|
||||
Sub2API 连接器(Refresh Token 模式)
|
||||
|
||||
适合 OAuth 登录用户(如 LinuxDo),从浏览器 localStorage 获取 refresh_token。
|
||||
- 首次连接时用 refresh_token 换取 access_token
|
||||
- access_token 过期前自动续期
|
||||
- refresh_token 过期后需手动更新(无法自动重新登录)
|
||||
"""
|
||||
|
||||
auth_type = ConnectorAuthType.API_KEY
|
||||
display_name = "Refresh Token"
|
||||
|
||||
def __init__(self, base_url: str, config: dict[str, Any] | None = None):
|
||||
super().__init__(base_url, config)
|
||||
self._access_token: str | None = None
|
||||
self._refresh_token: str | None = None
|
||||
self._token_expires_at: float = 0
|
||||
|
||||
async def connect(self, credentials: dict[str, Any]) -> bool:
|
||||
refresh_token = credentials.get("refresh_token", "").strip()
|
||||
|
||||
if not refresh_token:
|
||||
self._set_error("请填写 Refresh Token")
|
||||
return False
|
||||
|
||||
self._refresh_token = refresh_token
|
||||
|
||||
# 如果有缓存的 access_token 且未过期,直接复用,不消耗 refresh_token
|
||||
cached_access_token = credentials.get("_cached_access_token", "")
|
||||
cached_expires_at = credentials.get("_cached_token_expires_at", 0)
|
||||
if cached_access_token and time.time() < cached_expires_at:
|
||||
self._access_token = cached_access_token
|
||||
self._token_expires_at = cached_expires_at
|
||||
self._set_connected()
|
||||
return True
|
||||
|
||||
# 首次连接或 access_token 已过期,用 refresh_token 换取
|
||||
if not await self._refresh():
|
||||
self._refresh_token = None # 清理,避免残留无效状态
|
||||
self._set_error("Refresh Token 无效或已过期")
|
||||
return False
|
||||
|
||||
self._set_connected()
|
||||
return True
|
||||
|
||||
async def _ensure_token(self) -> None:
|
||||
"""确保 access_token 有效,有 refresh_token 时自动续期"""
|
||||
if self._access_token and time.time() < self._token_expires_at:
|
||||
return
|
||||
if self._refresh_token:
|
||||
await self._refresh()
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self._access_token = None
|
||||
self._refresh_token = None
|
||||
self._token_expires_at = 0
|
||||
self._set_disconnected()
|
||||
|
||||
async def is_authenticated(self) -> bool:
|
||||
return self._refresh_token is not None
|
||||
|
||||
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
|
||||
if self._access_token:
|
||||
request.headers["Authorization"] = f"Bearer {self._access_token}"
|
||||
return request
|
||||
|
||||
async def _auth_hook(self, request: httpx.Request) -> None:
|
||||
await self._ensure_token()
|
||||
self._apply_auth(request)
|
||||
|
||||
async def refresh_auth(self, credentials: dict[str, Any]) -> bool:
|
||||
if self._refresh_token and await self._refresh():
|
||||
return True
|
||||
return await self.connect(credentials)
|
||||
|
||||
@classmethod
|
||||
def get_credentials_schema(cls) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"title": "站点地址",
|
||||
"description": "API 基础地址",
|
||||
},
|
||||
"refresh_token": {
|
||||
"type": "string",
|
||||
"title": "Refresh Token",
|
||||
"description": ("从浏览器 F12 > Application > Local Storage 获取"),
|
||||
"x-sensitive": True,
|
||||
"x-input-type": "password",
|
||||
"x-help": "浏览器控制台执行 localStorage.getItem('refresh_token') 获取",
|
||||
},
|
||||
},
|
||||
"required": ["refresh_token"],
|
||||
"x-field-groups": [
|
||||
{"fields": ["base_url"]},
|
||||
{"fields": ["refresh_token"]},
|
||||
],
|
||||
"x-auth-type": "api_key",
|
||||
"x-auth-method": "bearer",
|
||||
"x-validation": [
|
||||
{
|
||||
"type": "required",
|
||||
"fields": ["refresh_token"],
|
||||
"message": "请填写 Refresh Token",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class Sub2ApiArchitecture(ProviderArchitecture):
|
||||
"""
|
||||
Sub2API 架构
|
||||
|
||||
特点:
|
||||
- 支持两种认证方式:账号密码 / Refresh Token
|
||||
- 验证端点: /api/v1/auth/me
|
||||
- balance 为充值余额,points 为赠送余额
|
||||
- 余额查询同时获取订阅概览信息
|
||||
"""
|
||||
|
||||
architecture_id = "sub2api"
|
||||
display_name = "Sub2API"
|
||||
description = "Sub2API 风格中转站的预设配置"
|
||||
|
||||
supported_connectors: list[type[ProviderConnector]] = [
|
||||
Sub2ApiConnector,
|
||||
Sub2ApiRefreshTokenConnector,
|
||||
]
|
||||
|
||||
supported_actions: list[type[ProviderAction]] = [Sub2ApiBalanceAction]
|
||||
|
||||
default_action_configs: dict[ProviderActionType, dict[str, Any]] = {
|
||||
ProviderActionType.QUERY_BALANCE: {
|
||||
"endpoint": "/api/v1/auth/me?timezone=Asia/Shanghai",
|
||||
"subscription_endpoint": "/api/v1/subscriptions/summary",
|
||||
"method": "GET",
|
||||
},
|
||||
}
|
||||
|
||||
def get_credentials_schema(self) -> dict[str, Any]:
|
||||
return Sub2ApiConnector.get_credentials_schema()
|
||||
|
||||
def get_verify_endpoint(self) -> str:
|
||||
return "/api/v1/auth/me?timezone=Asia/Shanghai"
|
||||
|
||||
def build_verify_headers(
|
||||
self,
|
||||
config: dict[str, Any],
|
||||
credentials: dict[str, Any],
|
||||
) -> dict[str, str]:
|
||||
headers: dict[str, str] = {}
|
||||
access_token = credentials.get("_access_token", "")
|
||||
if access_token:
|
||||
headers["Authorization"] = f"Bearer {access_token}"
|
||||
return headers
|
||||
|
||||
async def prepare_verify_config(
|
||||
self,
|
||||
base_url: str,
|
||||
config: dict[str, Any],
|
||||
credentials: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""
|
||||
验证前预处理:根据凭据类型选择登录方式
|
||||
|
||||
- 有 email + password -> 账号密码登录
|
||||
- 有 refresh_token -> 用 refresh_token 换 access_token
|
||||
|
||||
Returns:
|
||||
(extra_config, updated_credentials):
|
||||
extra_config 为空;updated_credentials 包含需持久化的凭据变更
|
||||
(Token Rotation 后的新 refresh_token、缓存的 access_token 等)
|
||||
"""
|
||||
base_url = base_url.rstrip("/")
|
||||
|
||||
from src.services.proxy_node.resolver import resolve_ops_proxy_config_async
|
||||
|
||||
proxy, tunnel_node_id = await resolve_ops_proxy_config_async(config)
|
||||
client_kwargs: dict[str, Any] = {
|
||||
"base_url": base_url,
|
||||
"timeout": 30.0,
|
||||
"verify": get_ssl_context(),
|
||||
}
|
||||
if tunnel_node_id:
|
||||
from src.services.proxy_node.tunnel_transport import create_tunnel_transport
|
||||
|
||||
client_kwargs["transport"] = create_tunnel_transport(tunnel_node_id, timeout=30.0)
|
||||
elif proxy:
|
||||
client_kwargs["proxy"] = proxy
|
||||
|
||||
email = credentials.get("email", "").strip()
|
||||
password = credentials.get("password", "").strip()
|
||||
refresh_token = credentials.get("refresh_token", "").strip()
|
||||
|
||||
try:
|
||||
if email and password:
|
||||
async with httpx.AsyncClient(**client_kwargs) as client:
|
||||
token_data = await _do_login(client, email, password)
|
||||
|
||||
elif refresh_token:
|
||||
async with httpx.AsyncClient(**client_kwargs) as client:
|
||||
token_data = await _do_refresh(client, refresh_token)
|
||||
|
||||
else:
|
||||
raise ValueError("请填写账号密码或 Refresh Token")
|
||||
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ValueError(f"验证失败: {e}") from e
|
||||
|
||||
access_token = token_data.get("access_token", "")
|
||||
credentials["_access_token"] = access_token
|
||||
|
||||
updated_credentials = _collect_updated_credentials(
|
||||
token_data, old_refresh_token=refresh_token or None
|
||||
)
|
||||
|
||||
return {}, updated_credentials
|
||||
|
||||
def parse_verify_response(
|
||||
self,
|
||||
status_code: int,
|
||||
data: dict[str, Any],
|
||||
) -> VerifyResult:
|
||||
if status_code == 401:
|
||||
return VerifyResult(success=False, message=self._auth_fail_message(401))
|
||||
if status_code == 403:
|
||||
return VerifyResult(success=False, message=self._auth_fail_message(403))
|
||||
if status_code != 200:
|
||||
return VerifyResult(success=False, message=f"验证失败:HTTP {status_code}")
|
||||
|
||||
code = data.get("code")
|
||||
if code != 0:
|
||||
message = data.get("message", "验证失败")
|
||||
return VerifyResult(success=False, message=message)
|
||||
|
||||
user_data = data.get("data", {})
|
||||
return self._build_verify_result(user_data, data)
|
||||
|
||||
def _build_verify_result(
|
||||
self, user_data: dict[str, Any], raw_data: dict[str, Any] | None = None
|
||||
) -> VerifyResult:
|
||||
balance = float(user_data.get("balance") or 0)
|
||||
points = float(user_data.get("points") or 0)
|
||||
|
||||
return VerifyResult(
|
||||
success=True,
|
||||
username=user_data.get("username") or user_data.get("email"),
|
||||
display_name=user_data.get("username") or user_data.get("email"),
|
||||
email=user_data.get("email"),
|
||||
quota=balance + points,
|
||||
extra={
|
||||
"balance": balance,
|
||||
"points": points,
|
||||
"status": user_data.get("status"),
|
||||
"concurrency": user_data.get("concurrency"),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,344 @@
|
||||
"""
|
||||
YesCode 架构
|
||||
|
||||
针对 YesCode 中转站的预设配置。
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.services.provider_ops.actions import ProviderAction
|
||||
from src.services.provider_ops.actions.yescode_balance import (
|
||||
YesCodeBalanceAction,
|
||||
fetch_yescode_combined_data,
|
||||
parse_yescode_balance_extra,
|
||||
)
|
||||
from src.services.provider_ops.architectures.base import (
|
||||
ProviderArchitecture,
|
||||
ProviderConnector,
|
||||
VerifyResult,
|
||||
)
|
||||
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
|
||||
from src.utils.ssl_utils import get_ssl_context
|
||||
|
||||
|
||||
def _extract_cookies(cookie_string: str) -> dict[str, str]:
|
||||
"""
|
||||
从完整的 Cookie 字符串中提取 yescode_auth 和 yescode_csrf
|
||||
|
||||
Args:
|
||||
cookie_string: Cookie 字符串
|
||||
|
||||
Returns:
|
||||
包含 yescode_auth 和 yescode_csrf 的字典
|
||||
"""
|
||||
result: dict[str, str] = {}
|
||||
for part in cookie_string.split(";"):
|
||||
part = part.strip()
|
||||
if "=" in part:
|
||||
key, value = part.split("=", 1)
|
||||
key = key.strip()
|
||||
if key in ("yescode_auth", "yescode_csrf"):
|
||||
result[key] = value.strip()
|
||||
return result
|
||||
|
||||
|
||||
def _build_cookie_header(cookie_string: str) -> str:
|
||||
"""
|
||||
从输入的 Cookie 字符串构建请求用的 Cookie header
|
||||
|
||||
支持两种输入格式:
|
||||
1. 完整 Cookie: "yescode_auth=xxx; yescode_csrf=yyy"
|
||||
2. 仅 auth 值: "eyJhbGciOiJI..."
|
||||
|
||||
Args:
|
||||
cookie_string: Cookie 字符串或 auth 值
|
||||
|
||||
Returns:
|
||||
Cookie header 值
|
||||
"""
|
||||
# 如果包含 "yescode_auth=",说明是完整 Cookie 字符串
|
||||
if "yescode_auth=" in cookie_string:
|
||||
cookies = _extract_cookies(cookie_string)
|
||||
parts = []
|
||||
if "yescode_auth" in cookies:
|
||||
parts.append(f"yescode_auth={cookies['yescode_auth']}")
|
||||
if "yescode_csrf" in cookies:
|
||||
parts.append(f"yescode_csrf={cookies['yescode_csrf']}")
|
||||
return "; ".join(parts)
|
||||
# 否则认为直接是 auth 值
|
||||
return f"yescode_auth={cookie_string.strip()}"
|
||||
|
||||
|
||||
class YesCodeConnector(ProviderConnector):
|
||||
"""
|
||||
YesCode 专用连接器
|
||||
|
||||
特点:
|
||||
- 使用 Cookie 认证(yescode_auth JWT + yescode_csrf)
|
||||
"""
|
||||
|
||||
auth_type = ConnectorAuthType.COOKIE
|
||||
display_name = "YesCode Cookie"
|
||||
|
||||
def __init__(self, base_url: str, config: dict[str, Any] | None = None):
|
||||
super().__init__(base_url, config)
|
||||
self._auth_cookie: str | None = None
|
||||
|
||||
async def connect(self, credentials: dict[str, Any]) -> bool:
|
||||
"""建立连接"""
|
||||
auth_cookie = credentials.get("auth_cookie")
|
||||
if not auth_cookie:
|
||||
self._set_error("Auth Cookie 不能为空")
|
||||
return False
|
||||
|
||||
# 构建 Cookie header
|
||||
self._auth_cookie = _build_cookie_header(auth_cookie)
|
||||
|
||||
self._set_connected()
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
"""断开连接"""
|
||||
self._auth_cookie = None
|
||||
self._set_disconnected()
|
||||
|
||||
async def is_authenticated(self) -> bool:
|
||||
"""检查是否已认证"""
|
||||
return self._auth_cookie is not None
|
||||
|
||||
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
|
||||
"""为请求应用认证信息"""
|
||||
if self._auth_cookie:
|
||||
request.headers["Cookie"] = self._auth_cookie
|
||||
|
||||
return request
|
||||
|
||||
@classmethod
|
||||
def get_credentials_schema(cls) -> dict[str, Any]:
|
||||
"""获取凭据配置 schema"""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"title": "站点地址",
|
||||
"description": "API 基础地址",
|
||||
"x-default-value": "https://co.yes.vg",
|
||||
},
|
||||
"auth_cookie": {
|
||||
"type": "string",
|
||||
"title": "Auth Cookie",
|
||||
"description": "从浏览器复制的 Cookie(包含 yescode_auth 和 yescode_csrf)",
|
||||
"x-sensitive": True,
|
||||
"x-input-type": "password",
|
||||
},
|
||||
},
|
||||
"required": ["auth_cookie"],
|
||||
"x-field-groups": [
|
||||
{"fields": ["base_url"]},
|
||||
{"fields": ["auth_cookie"]},
|
||||
],
|
||||
"x-auth-type": "cookie",
|
||||
"x-default-base-url": "https://co.yes.vg",
|
||||
"x-validation": [
|
||||
{
|
||||
"type": "required",
|
||||
"fields": ["auth_cookie"],
|
||||
"message": "请填写 Auth Cookie",
|
||||
},
|
||||
],
|
||||
"x-quota-divisor": None,
|
||||
"x-currency": "USD",
|
||||
"x-balance-extra-format": [
|
||||
{
|
||||
"label": "天",
|
||||
"type": "weekly_spent",
|
||||
"source_limit": "daily_limit",
|
||||
"source_spent": "daily_spent",
|
||||
"source_resets_at": "daily_resets_at",
|
||||
},
|
||||
{
|
||||
"label": "周",
|
||||
"type": "weekly_spent",
|
||||
"source_limit": "weekly_limit",
|
||||
"source_spent": "weekly_spent",
|
||||
"source_resets_at": "weekly_resets_at",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class YesCodeArchitecture(ProviderArchitecture):
|
||||
"""
|
||||
YesCode 架构预设
|
||||
|
||||
针对 YesCode 中转站优化的预设配置。
|
||||
|
||||
特点:
|
||||
- 使用 Cookie 认证(yescode_auth JWT + yescode_csrf)
|
||||
- 验证端点: /api/v1/user/balance
|
||||
- 余额单位直接是美元
|
||||
- 支持每周限额查询
|
||||
"""
|
||||
|
||||
architecture_id = "yescode"
|
||||
display_name = "YesCode"
|
||||
description = "YesCode 中转站预设配置,使用 Cookie 认证"
|
||||
|
||||
supported_connectors: list[type[ProviderConnector]] = [
|
||||
YesCodeConnector,
|
||||
]
|
||||
|
||||
supported_actions: list[type[ProviderAction]] = [
|
||||
YesCodeBalanceAction,
|
||||
]
|
||||
|
||||
default_action_configs: dict[ProviderActionType, dict[str, Any]] = {
|
||||
ProviderActionType.QUERY_BALANCE: {
|
||||
"endpoint": "/api/v1/user/balance",
|
||||
"method": "GET",
|
||||
"response_mapping": {
|
||||
"total_available": "total_balance",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def get_credentials_schema(self) -> dict[str, Any]:
|
||||
"""YesCode 使用 auth_cookie 认证"""
|
||||
return YesCodeConnector.get_credentials_schema()
|
||||
|
||||
def get_verify_endpoint(self) -> str:
|
||||
"""验证端点 - 使用 profile 接口获取完整信息"""
|
||||
return "/api/v1/auth/profile"
|
||||
|
||||
async def prepare_verify_config(
|
||||
self,
|
||||
base_url: str,
|
||||
config: dict[str, Any],
|
||||
credentials: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
预获取合并数据(balance + profile)
|
||||
|
||||
验证时并发调用两个接口获取完整数据。
|
||||
"""
|
||||
extra_config: dict[str, Any] = {}
|
||||
|
||||
cookie_input = credentials.get("auth_cookie")
|
||||
if not cookie_input:
|
||||
return extra_config
|
||||
|
||||
cookie_header = _build_cookie_header(cookie_input)
|
||||
|
||||
# 获取代理配置(支持 proxy_node_id、tunnel 和旧的 proxy URL)
|
||||
from src.services.proxy_node.resolver import resolve_ops_proxy_config_async
|
||||
|
||||
proxy, tunnel_node_id = await resolve_ops_proxy_config_async(config)
|
||||
|
||||
try:
|
||||
# 构建 client 参数
|
||||
client_kwargs: dict[str, Any] = {
|
||||
"headers": {"Cookie": cookie_header},
|
||||
"timeout": 10.0,
|
||||
"verify": get_ssl_context(),
|
||||
}
|
||||
if tunnel_node_id:
|
||||
from src.services.proxy_node.tunnel_transport import create_tunnel_transport
|
||||
|
||||
client_kwargs["transport"] = create_tunnel_transport(tunnel_node_id, timeout=10.0)
|
||||
elif proxy:
|
||||
client_kwargs["proxy"] = proxy
|
||||
|
||||
# 创建临时 client 获取合并数据
|
||||
async with httpx.AsyncClient(**client_kwargs) as client:
|
||||
combined_data = await fetch_yescode_combined_data(client, base_url)
|
||||
extra_config["_combined_data"] = combined_data
|
||||
except Exception:
|
||||
# 如果调用失败,不影响验证流程(会回退到单独调用 profile)
|
||||
pass
|
||||
|
||||
return extra_config
|
||||
|
||||
def build_verify_headers(
|
||||
self,
|
||||
config: dict[str, Any],
|
||||
credentials: dict[str, Any],
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
构建 YesCode 的验证请求 Headers
|
||||
|
||||
使用 Cookie 认证,不使用 Authorization。
|
||||
"""
|
||||
headers: dict[str, str] = {}
|
||||
|
||||
# 添加 Cookie
|
||||
cookie_input = credentials.get("auth_cookie")
|
||||
if cookie_input:
|
||||
headers["Cookie"] = _build_cookie_header(cookie_input)
|
||||
|
||||
return headers
|
||||
|
||||
def parse_verify_response(
|
||||
self,
|
||||
status_code: int,
|
||||
data: dict[str, Any],
|
||||
) -> VerifyResult:
|
||||
"""解析 YesCode 验证响应(使用预获取的合并数据)"""
|
||||
if status_code == 401:
|
||||
return VerifyResult(success=False, message="Cookie 已失效,请重新配置")
|
||||
if status_code == 403:
|
||||
return VerifyResult(success=False, message="Cookie 已失效或无权限")
|
||||
if status_code != 200:
|
||||
return VerifyResult(success=False, message=f"验证失败:HTTP {status_code}")
|
||||
|
||||
# 优先使用预获取的合并数据(包含 balance + profile)
|
||||
combined_data = data.get("_combined_data")
|
||||
if combined_data:
|
||||
# 检查是否有有效数据
|
||||
if "_profile_data" not in combined_data and "_balance_data" not in combined_data:
|
||||
return VerifyResult(success=False, message="Cookie 已失效,请重新配置")
|
||||
|
||||
# 使用公共函数解析余额
|
||||
extra = parse_yescode_balance_extra(combined_data)
|
||||
|
||||
total_available = extra.pop("_total_available", 0)
|
||||
extra.pop("_subscription_available", None)
|
||||
|
||||
return VerifyResult(
|
||||
success=True,
|
||||
username=combined_data.get("username"),
|
||||
display_name=combined_data.get("username"),
|
||||
email=combined_data.get("email"),
|
||||
quota=total_available,
|
||||
extra=extra if extra else None,
|
||||
)
|
||||
|
||||
# 回退:仅使用 profile 数据(旧逻辑,当 prepare_verify_config 失败时)
|
||||
if "username" not in data:
|
||||
return VerifyResult(success=False, message="响应格式无效")
|
||||
|
||||
# 构造兼容格式供公共函数使用
|
||||
compat_data = {
|
||||
"pay_as_you_go_balance": data.get("pay_as_you_go_balance", 0),
|
||||
"subscription_balance": data.get("subscription_balance", 0),
|
||||
"weekly_spent_balance": data.get("current_week_spend", 0),
|
||||
"subscription_plan": data.get("subscription_plan"),
|
||||
"last_week_reset": data.get("last_week_reset"),
|
||||
"last_daily_balance_add": data.get("last_daily_balance_add"),
|
||||
}
|
||||
|
||||
extra = parse_yescode_balance_extra(compat_data)
|
||||
|
||||
total_available = extra.pop("_total_available", 0)
|
||||
extra.pop("_subscription_available", None)
|
||||
|
||||
return VerifyResult(
|
||||
success=True,
|
||||
username=data.get("username"),
|
||||
display_name=data.get("username"),
|
||||
email=data.get("email"),
|
||||
quota=total_available,
|
||||
extra=extra if extra else None,
|
||||
)
|
||||
149
_deprecated_py_src/services/provider_ops/registry.py
Normal file
149
_deprecated_py_src/services/provider_ops/registry.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""
|
||||
架构注册表
|
||||
|
||||
管理所有可用的 Provider 架构。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.services.provider_ops.architectures import (
|
||||
AnyrouterArchitecture,
|
||||
CubenceArchitecture,
|
||||
GenericApiArchitecture,
|
||||
NekoCodeArchitecture,
|
||||
NewApiArchitecture,
|
||||
ProviderArchitecture,
|
||||
Sub2ApiArchitecture,
|
||||
YesCodeArchitecture,
|
||||
)
|
||||
|
||||
|
||||
class ArchitectureRegistry:
|
||||
"""
|
||||
架构注册表
|
||||
|
||||
单例模式,管理所有可用的 Provider 架构。
|
||||
"""
|
||||
|
||||
_instance: ArchitectureRegistry | None = None
|
||||
_lock = threading.Lock()
|
||||
_initialized: bool = False
|
||||
|
||||
def __new__(cls) -> ArchitectureRegistry:
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance._initialized = False
|
||||
return cls._instance
|
||||
|
||||
def __init__(self) -> None:
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._architectures: dict[str, ProviderArchitecture] = {}
|
||||
self._initialized = True
|
||||
|
||||
# 注册内置架构
|
||||
self._register_builtin_architectures()
|
||||
|
||||
def _register_builtin_architectures(self) -> None:
|
||||
"""注册内置架构"""
|
||||
builtin: list[type[ProviderArchitecture]] = [
|
||||
AnyrouterArchitecture,
|
||||
CubenceArchitecture,
|
||||
GenericApiArchitecture,
|
||||
NekoCodeArchitecture,
|
||||
NewApiArchitecture,
|
||||
Sub2ApiArchitecture,
|
||||
YesCodeArchitecture,
|
||||
]
|
||||
|
||||
for arch_cls in builtin:
|
||||
self.register(arch_cls(), _quiet=True)
|
||||
logger.debug(f"内置架构注册完成: {', '.join(self._architectures.keys())}")
|
||||
|
||||
def register(self, architecture: ProviderArchitecture, *, _quiet: bool = False) -> None:
|
||||
"""
|
||||
注册架构
|
||||
|
||||
Args:
|
||||
architecture: 架构实例
|
||||
_quiet: 内部参数,批量注册时抑制逐条日志
|
||||
"""
|
||||
if architecture.architecture_id in self._architectures:
|
||||
logger.warning(f"架构 {architecture.architecture_id} 已存在,将被覆盖")
|
||||
|
||||
self._architectures[architecture.architecture_id] = architecture
|
||||
if not _quiet:
|
||||
logger.debug(f"注册架构: {architecture.architecture_id}")
|
||||
|
||||
def unregister(self, architecture_id: str) -> bool:
|
||||
"""
|
||||
注销架构
|
||||
|
||||
Args:
|
||||
architecture_id: 架构 ID
|
||||
|
||||
Returns:
|
||||
是否成功注销
|
||||
"""
|
||||
if architecture_id in self._architectures:
|
||||
del self._architectures[architecture_id]
|
||||
return True
|
||||
return False
|
||||
|
||||
def get(self, architecture_id: str) -> ProviderArchitecture | None:
|
||||
"""
|
||||
获取架构
|
||||
|
||||
Args:
|
||||
architecture_id: 架构 ID
|
||||
|
||||
Returns:
|
||||
架构实例,不存在则返回 None
|
||||
"""
|
||||
return self._architectures.get(architecture_id)
|
||||
|
||||
def get_or_default(self, architecture_id: str | None = None) -> ProviderArchitecture:
|
||||
"""
|
||||
获取架构,如果不存在则返回默认架构
|
||||
|
||||
Args:
|
||||
architecture_id: 架构 ID
|
||||
|
||||
Returns:
|
||||
架构实例
|
||||
"""
|
||||
if architecture_id and architecture_id in self._architectures:
|
||||
return self._architectures[architecture_id]
|
||||
|
||||
# 返回默认架构(generic_api)
|
||||
return self._architectures.get("generic_api", GenericApiArchitecture())
|
||||
|
||||
def list_all(self) -> list[ProviderArchitecture]:
|
||||
"""获取所有已注册的架构"""
|
||||
return list(self._architectures.values())
|
||||
|
||||
def list_ids(self) -> list[str]:
|
||||
"""获取所有已注册的架构 ID"""
|
||||
return list(self._architectures.keys())
|
||||
|
||||
def to_dict_list(self) -> list[dict]:
|
||||
"""获取所有架构的字典表示(用于 API 响应,隐藏 hidden 架构)"""
|
||||
return [arch.to_dict() for arch in self._architectures.values() if not arch.hidden]
|
||||
|
||||
|
||||
# 全局注册表实例
|
||||
_registry: ArchitectureRegistry | None = None
|
||||
|
||||
|
||||
def get_registry() -> ArchitectureRegistry:
|
||||
"""获取全局注册表实例"""
|
||||
global _registry
|
||||
if _registry is None:
|
||||
_registry = ArchitectureRegistry()
|
||||
return _registry
|
||||
1164
_deprecated_py_src/services/provider_ops/service.py
Normal file
1164
_deprecated_py_src/services/provider_ops/service.py
Normal file
File diff suppressed because it is too large
Load Diff
177
_deprecated_py_src/services/provider_ops/types.py
Normal file
177
_deprecated_py_src/services/provider_ops/types.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Provider 操作模块类型定义
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
SENSITIVE_CREDENTIAL_FIELDS = frozenset(
|
||||
{
|
||||
"api_key",
|
||||
"password",
|
||||
"refresh_token",
|
||||
"session_token",
|
||||
"session_cookie",
|
||||
"token_cookie",
|
||||
"auth_cookie",
|
||||
"cookie_string",
|
||||
"cookie",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ConnectorAuthType(str, Enum):
|
||||
"""连接器认证类型"""
|
||||
|
||||
API_KEY = "api_key" # API Key 直接认证
|
||||
SESSION_LOGIN = "session_login" # 用户名密码登录获取 Session
|
||||
OAUTH = "oauth" # OAuth 流程
|
||||
COOKIE = "cookie" # 直接使用 Cookie
|
||||
NONE = "none" # 无需认证
|
||||
|
||||
|
||||
class ProviderActionType(str, Enum):
|
||||
"""提供商操作类型"""
|
||||
|
||||
QUERY_BALANCE = "query_balance" # 查询余额
|
||||
CHECKIN = "checkin" # 签到
|
||||
CLAIM_QUOTA = "claim_quota" # 领取额度
|
||||
REFRESH_TOKEN = "refresh_token" # 刷新 Token
|
||||
GET_USAGE = "get_usage" # 获取使用记录
|
||||
GET_MODELS = "get_models" # 获取可用模型列表
|
||||
CUSTOM = "custom" # 自定义操作
|
||||
|
||||
|
||||
class ActionStatus(str, Enum):
|
||||
"""操作执行状态"""
|
||||
|
||||
SUCCESS = "success" # 成功
|
||||
PENDING = "pending" # 处理中(异步任务已触发,尚未完成)
|
||||
AUTH_FAILED = "auth_failed" # 认证失败
|
||||
AUTH_EXPIRED = "auth_expired" # 认证过期
|
||||
RATE_LIMITED = "rate_limited" # 频率限制
|
||||
NETWORK_ERROR = "network_error" # 网络错误
|
||||
PARSE_ERROR = "parse_error" # 响应解析错误
|
||||
NOT_CONFIGURED = "not_configured" # 未配置
|
||||
NOT_SUPPORTED = "not_supported" # 不支持
|
||||
ALREADY_DONE = "already_done" # 已完成(如今日已签到)
|
||||
UNKNOWN_ERROR = "unknown_error" # 未知错误
|
||||
|
||||
|
||||
class ConnectorStatus(str, Enum):
|
||||
"""连接器状态"""
|
||||
|
||||
DISCONNECTED = "disconnected" # 未连接
|
||||
CONNECTING = "connecting" # 连接中
|
||||
CONNECTED = "connected" # 已连接
|
||||
EXPIRED = "expired" # 已过期
|
||||
ERROR = "error" # 错误
|
||||
|
||||
|
||||
@dataclass
|
||||
class BalanceInfo:
|
||||
"""余额信息"""
|
||||
|
||||
total_granted: float | None = None # 总授予额度
|
||||
total_used: float | None = None # 已使用额度
|
||||
total_available: float | None = None # 可用余额
|
||||
expires_at: datetime | None = None # 过期时间
|
||||
currency: str = "USD" # 货币单位
|
||||
extra: dict[str, Any] = field(default_factory=dict) # 额外信息
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckinInfo:
|
||||
"""签到信息"""
|
||||
|
||||
reward: float | None = None # 奖励额度
|
||||
streak_days: int | None = None # 连续签到天数
|
||||
next_reward: float | None = None # 下次奖励
|
||||
message: str | None = None # 签到消息
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionResult:
|
||||
"""操作执行结果"""
|
||||
|
||||
status: ActionStatus
|
||||
action_type: ProviderActionType
|
||||
data: Any | None = None # 操作返回的数据(如 BalanceInfo, CheckinInfo)
|
||||
message: str | None = None # 消息
|
||||
executed_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
response_time_ms: int | None = None # 响应时间(毫秒)
|
||||
raw_response: dict[str, Any] | None = None # 原始响应(调试用)
|
||||
cache_ttl_seconds: int = 300 # 建议缓存时间
|
||||
retry_after_seconds: int | None = None # 失败后重试间隔
|
||||
|
||||
@property
|
||||
def is_success(self) -> bool:
|
||||
return self.status == ActionStatus.SUCCESS
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectorState:
|
||||
"""连接器状态信息"""
|
||||
|
||||
status: ConnectorStatus
|
||||
auth_type: ConnectorAuthType
|
||||
connected_at: datetime | None = None
|
||||
expires_at: datetime | None = None
|
||||
last_error: str | None = None
|
||||
extra: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderOpsConfig:
|
||||
"""Provider 操作配置(存储在 Provider.config['provider_ops'] 中)"""
|
||||
|
||||
architecture_id: str = "generic_api"
|
||||
base_url: str | None = None # API 基础地址
|
||||
|
||||
# 连接器配置
|
||||
connector_auth_type: ConnectorAuthType = ConnectorAuthType.API_KEY
|
||||
connector_config: dict[str, Any] = field(default_factory=dict)
|
||||
connector_credentials: dict[str, Any] = field(default_factory=dict) # 加密存储
|
||||
|
||||
# 操作配置
|
||||
actions: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
# 定时任务配置
|
||||
schedule: dict[str, str] = field(default_factory=dict) # {action_type: cron_expression}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any] | None) -> ProviderOpsConfig:
|
||||
"""从字典创建配置"""
|
||||
if not data:
|
||||
return cls()
|
||||
|
||||
return cls(
|
||||
architecture_id=data.get("architecture_id", "generic_api"),
|
||||
base_url=data.get("base_url"),
|
||||
connector_auth_type=ConnectorAuthType(
|
||||
data.get("connector", {}).get("auth_type", "api_key")
|
||||
),
|
||||
connector_config=data.get("connector", {}).get("config", {}),
|
||||
connector_credentials=data.get("connector", {}).get("credentials", {}),
|
||||
actions=data.get("actions", {}),
|
||||
schedule=data.get("schedule", {}),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""转换为字典(用于存储)"""
|
||||
return {
|
||||
"architecture_id": self.architecture_id,
|
||||
"base_url": self.base_url,
|
||||
"connector": {
|
||||
"auth_type": self.connector_auth_type.value,
|
||||
"config": self.connector_config,
|
||||
"credentials": self.connector_credentials,
|
||||
},
|
||||
"actions": self.actions,
|
||||
"schedule": self.schedule,
|
||||
}
|
||||
26
_deprecated_py_src/services/provider_ops/utils.py
Normal file
26
_deprecated_py_src/services/provider_ops/utils.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Provider Ops 通用工具函数
|
||||
"""
|
||||
|
||||
|
||||
def extract_cookie_value(cookie_string: str, key: str) -> str:
|
||||
"""
|
||||
从 Cookie 字符串中提取指定 key 的值
|
||||
|
||||
支持两种输入格式:
|
||||
1. 完整 Cookie: "key=xxx; other=yyy; ..."
|
||||
2. 仅值: "MTc2ODc4..."
|
||||
|
||||
Args:
|
||||
cookie_string: Cookie 字符串或直接的值
|
||||
key: 要提取的 Cookie key
|
||||
|
||||
Returns:
|
||||
对应的值
|
||||
"""
|
||||
if f"{key}=" in cookie_string:
|
||||
for part in cookie_string.split(";"):
|
||||
part = part.strip()
|
||||
if part.startswith(f"{key}="):
|
||||
return part[len(key) + 1 :]
|
||||
return cookie_string.strip()
|
||||
Reference in New Issue
Block a user