mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30: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:
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user