feat: 新增 Anyrouter/Cubence/YesCode 架构及余额监控增强

- 新增三个 provider 架构:Anyrouter、Cubence、YesCode
- 前端新增对应的认证配置模板
- 余额监控增强:支持窗口限额显示(进度条+倒计时)、签到状态、错误信息展示
- 架构基类新增 prepare_verify_config 异步预处理方法
- API 返回 ops_architecture_id 字段用于前端展示
This commit is contained in:
fawney19
2026-01-19 17:18:03 +08:00
parent 3d88dfd98a
commit 6bc9cdc69d
25 changed files with 2057 additions and 20 deletions

View File

@@ -2,12 +2,18 @@
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.yescode_balance import YesCodeBalanceAction
__all__ = [
"ProviderAction",
"BalanceAction",
"CheckinAction",
"AnyrouterBalanceAction",
"CubenceBalanceAction",
"YesCodeBalanceAction",
]

View File

@@ -0,0 +1,107 @@
"""
Anyrouter 余额查询操作(含自动签到)
"""
from typing import Any, Dict, Optional, Tuple
import httpx
from src.core.logger import logger
from src.services.provider_ops.actions.balance import BalanceAction
from src.services.provider_ops.types import ActionResult, ActionStatus
class AnyrouterBalanceAction(BalanceAction):
"""
Anyrouter 专用余额查询
特点:
- 查询余额前自动触发签到
- 签到结果附加到余额信息的 extra 字段
- Cookie 失效时返回友好的错误提示
"""
display_name = "查询余额(含自动签到)"
description = "查询账户余额,同时自动签到"
def _handle_http_error(
self, response: httpx.Response, raw_data: Optional[Dict[str, Any]] = None
) -> ActionResult:
"""处理 HTTP 错误响应Anyrouter 专用)"""
status_code = response.status_code
# Anyrouter 使用 Cookie 认证,提供更友好的错误提示
if status_code == 401:
return self._make_error_result(
ActionStatus.AUTH_FAILED, "Cookie 已失效,请重新配置", raw_response=raw_data
)
elif status_code == 403:
return self._make_error_result(
ActionStatus.AUTH_FAILED, "Cookie 已失效或无权限", raw_response=raw_data
)
# 其他错误使用基类处理
return super()._handle_http_error(response, raw_data)
async def execute(self, client) -> ActionResult:
"""执行余额查询(含自动签到)"""
# 先尝试签到
checkin_success, checkin_message = await self._auto_checkin(client)
# 执行余额查询
result = await super().execute(client)
# 将签到结果附加到 extra 字段
if result.data and hasattr(result.data, "extra"):
if result.data.extra is None:
result.data.extra = {}
result.data.extra["checkin_success"] = checkin_success
result.data.extra["checkin_message"] = checkin_message
return result
async def _auto_checkin(self, client) -> Tuple[Optional[bool], str]:
"""
自动签到
Returns:
(success, message) 元组:
- success: True=签到成功, False=签到失败, None=已签到/跳过
- message: 签到消息
"""
checkin_endpoint = self.config.get("checkin_endpoint", "/api/user/sign_in")
try:
response = await client.post(checkin_endpoint)
if response.status_code == 200:
try:
data = response.json()
success = data.get("success", False)
message = data.get("message", "")
if success:
logger.debug(f"Anyrouter 自动签到成功: {message}")
return True, message or "签到成功"
else:
# 检查是否是"已签到"
is_already = (
any(ind in message for ind in ["已签到", "已签", "今日已"])
or "already" in message.lower()
)
if is_already:
logger.debug(f"Anyrouter 今日已签到: {message}")
return None, message or "今日已签到"
else:
logger.debug(f"Anyrouter 签到失败: {message}")
return False, message or "签到失败"
except Exception as e:
logger.debug(f"Anyrouter 签到响应解析失败: {e}")
return False, "响应解析失败"
else:
logger.debug(f"Anyrouter 签到请求失败: HTTP {response.status_code}")
return False, f"HTTP {response.status_code}"
except Exception as e:
logger.debug(f"Anyrouter 自动签到异常: {e}")
return False, str(e)

View File

@@ -0,0 +1,95 @@
"""
Cubence 余额查询操作
"""
from typing import Any, Dict, Optional
import httpx
from src.services.provider_ops.actions.balance import BalanceAction
from src.services.provider_ops.types import ActionResult, ActionStatus, BalanceInfo
class CubenceBalanceAction(BalanceAction):
"""
Cubence 专用余额查询
特点:
- 余额单位直接是美元
- 支持窗口限额查询5小时/每周)
- Cookie 失效时返回友好的错误提示
"""
display_name = "查询余额(含窗口限额)"
description = "查询账户余额和窗口限额信息"
def _handle_http_error(
self, response: httpx.Response, raw_data: Optional[Dict[str, Any]] = None
) -> ActionResult:
"""处理 HTTP 错误响应Cubence 专用)"""
status_code = response.status_code
# Cubence 使用 Cookie 认证,提供更友好的错误提示
if status_code == 401:
return self._make_error_result(
ActionStatus.AUTH_FAILED, "Cookie 已失效,请重新配置", raw_response=raw_data
)
elif status_code == 403:
return self._make_error_result(
ActionStatus.AUTH_FAILED, "Cookie 已失效或无权限", raw_response=raw_data
)
# 其他错误使用基类处理
return super()._handle_http_error(response, raw_data)
def _parse_balance(self, data: Any, mapping: Dict[str, str]) -> 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 BalanceInfo(
total_granted=None, # Cubence 不提供总额度
total_used=None,
total_available=total_available,
currency=self.config.get("currency", "USD"),
extra=extra if extra else None,
)

View File

@@ -0,0 +1,226 @@
"""
YesCode 余额查询操作
"""
import asyncio
from datetime import datetime, timedelta
from typing import Any, Dict
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 = 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 = "查询账户余额和每周限额信息"
async def execute(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 None,
)
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)}",
)

View File

@@ -7,15 +7,21 @@ from src.services.provider_ops.architectures.base import (
ProviderConnector,
VerifyResult,
)
from src.services.provider_ops.architectures.anyrouter import AnyrouterArchitecture
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.new_api import NewApiArchitecture
from src.services.provider_ops.architectures.one_api import OneApiArchitecture
from src.services.provider_ops.architectures.yescode import YesCodeArchitecture
__all__ = [
"ProviderArchitecture",
"ProviderConnector",
"VerifyResult",
"AnyrouterArchitecture",
"CubenceArchitecture",
"GenericApiArchitecture",
"NewApiArchitecture",
"OneApiArchitecture",
"YesCodeArchitecture",
]

View File

@@ -0,0 +1,444 @@
"""
Anyrouter 架构
针对 Anyrouter 中转站的预设配置,自动处理 acw_sc__v2 反爬 Cookie。
"""
import base64
import re
from typing import Any, Dict, List, Optional, Tuple, Type
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,
VerifyResult,
)
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
# 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 _extract_session_from_cookie(cookie_string: str) -> str:
"""
从完整的 Cookie 字符串中提取 session 值
支持两种输入格式:
1. 完整 Cookie: "session=xxx; acw_tc=xxx; ..."
2. 仅 session 值: "MTc2ODc4..."
Args:
cookie_string: Cookie 字符串或 session 值
Returns:
session cookie 的值
"""
# 如果包含 "session=",说明是完整 Cookie 字符串
if "session=" in cookie_string:
# 解析 Cookie 字符串
for part in cookie_string.split(";"):
part = part.strip()
if part.startswith("session="):
return part[8:] # 去掉 "session=" 前缀
# 否则认为直接是 session 值
return cookie_string.strip()
def _parse_session_user_id(cookie_input: str) -> Tuple[Optional[str], Optional[str]]:
"""
从 session cookie 中解析用户 ID 和用户名
Anyrouter 的 session cookie 结构:
base64(timestamp|gob_base64|signature)
gob 数据中包含:
- id: 内部数字 ID
- username: 用户名 (如 linuxdo_129083)
- role, status, group 等
Args:
cookie_input: Cookie 字符串或 session 值
Returns:
(user_id, username) 元组,解析失败则返回 (None, None)
"""
try:
# 先提取 session 值
session_cookie = _extract_session_from_cookie(cookie_input)
# 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 数据中提取用户名
gob_text = gob_data.decode("utf-8", errors="ignore")
# 查找 linuxdo_xxx 模式 (LinuxDo OAuth)
linuxdo_match = re.search(r"linuxdo_(\d+)", gob_text)
if linuxdo_match:
user_id = linuxdo_match.group(1)
username = linuxdo_match.group(0)
return user_id, username
# 查找其他 OAuth 格式 (github_xxx, google_xxx 等)
oauth_match = re.search(r"(github|google|discord|twitter)_(\d+)", gob_text, re.IGNORECASE)
if oauth_match:
user_id = oauth_match.group(2)
username = oauth_match.group(0)
return user_id, username
return None, None
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) -> Optional[str]:
"""
获取 acw_sc__v2 Cookie
首先请求目标 URL如果返回包含 arg1 的反爬页面,则计算 Cookie 值。
Args:
base_url: 目标站点 URL
timeout: 请求超时时间
Returns:
Cookie 字符串 (acw_sc__v2=xxx),如果不需要或获取失败则返回 None
"""
try:
async with httpx.AsyncClient(timeout=timeout) 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: Optional[Dict[str, Any]] = None):
super().__init__(base_url, config)
self._session_cookie: Optional[str] = None
self._acw_cookie: Optional[str] = None
self._user_id: Optional[str] = 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_session_from_cookie(session_cookie)
# 解析 user_id
self._user_id, _ = _parse_session_user_id(session_cookie)
# 尝试获取反爬 Cookie
self._acw_cookie = await _get_acw_cookie(self.base_url)
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": {
"session_cookie": {
"type": "string",
"title": "Session Cookie",
"description": "从浏览器复制的 session Cookie 值",
},
},
"required": ["session_cookie"],
}
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", # 自动签到端点
"response_mapping": {
"total_granted": "data.quota",
"total_used": "data.used_quota",
"total_available": "data.quota",
},
},
}
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 的配置
"""
acw_cookie = await _get_acw_cookie(base_url)
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_session_from_cookie(cookie_input)
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 parse_verify_response(
self,
status_code: int,
data: Dict[str, Any],
) -> VerifyResult:
"""解析 Anyrouter 验证响应"""
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}")
# Anyrouter 响应格式: {"success": true, "data": {...}}
if not data.get("success"):
message = data.get("message", "验证失败")
return VerifyResult(success=False, message=message)
user_data = data.get("data", {})
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=None,
)

View File

@@ -310,6 +310,28 @@ class ProviderArchitecture(ABC):
"""
return "/api/user/self"
async def prepare_verify_config(
self,
base_url: str,
config: Dict[str, Any],
credentials: Dict[str, Any],
) -> Dict[str, Any]:
"""
验证前的异步预处理
子类可重写以执行异步操作(如获取动态 Cookie
返回的配置会传递给 build_verify_headers。
Args:
base_url: API 基础地址
config: 连接器配置
credentials: 凭据信息
Returns:
处理后的配置(会与原 config 合并)
"""
return {}
def build_verify_headers(
self,
config: Dict[str, Any],

View File

@@ -0,0 +1,224 @@
"""
Cubence 架构
针对 Cubence 中转站的预设配置。
"""
from typing import Any, Dict, List, Optional, Type
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
def _extract_token_from_cookie(cookie_string: str) -> str:
"""
从完整的 Cookie 字符串中提取 token 值
支持两种输入格式:
1. 完整 Cookie: "token=xxx; other=yyy; ..."
2. 仅 token 值: "eyJhbGciOiJI..."
Args:
cookie_string: Cookie 字符串或 token 值
Returns:
token cookie 的值
"""
# 如果包含 "token=",说明是完整 Cookie 字符串
if "token=" in cookie_string:
# 解析 Cookie 字符串
for part in cookie_string.split(";"):
part = part.strip()
if part.startswith("token="):
return part[6:] # 去掉 "token=" 前缀
# 否则认为直接是 token 值
return cookie_string.strip()
class CubenceConnector(ProviderConnector):
"""
Cubence 专用连接器
特点:
- 使用 Cookie 认证token JWT
"""
auth_type = ConnectorAuthType.COOKIE
display_name = "Cubence Cookie"
def __init__(self, base_url: str, config: Optional[Dict[str, Any]] = None):
super().__init__(base_url, config)
self._token_cookie: Optional[str] = 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_token_from_cookie(token_cookie)
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": {
"token_cookie": {
"type": "string",
"title": "Token Cookie",
"description": "从浏览器复制的 token Cookie 值JWT 格式)",
},
},
"required": ["token_cookie"],
}
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_token_from_cookie(cookie_input)
headers["Cookie"] = f"token={token_value}"
return headers
def parse_verify_response(
self,
status_code: int,
data: Dict[str, Any],
) -> VerifyResult:
"""解析 Cubence 验证响应"""
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}")
# Cubence 响应格式: {"success": true, "data": {...}}
if not data.get("success"):
message = data.get("message", "验证失败")
return VerifyResult(success=False, message=message)
user_data = data.get("data", {})
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,
)

View File

@@ -0,0 +1,289 @@
"""
YesCode 架构
针对 YesCode 中转站的预设配置。
"""
from typing import Any, Dict, List, Optional, Type
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
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: Optional[Dict[str, Any]] = None):
super().__init__(base_url, config)
self._auth_cookie: Optional[str] = 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": {
"auth_cookie": {
"type": "string",
"title": "Auth Cookie",
"description": "从浏览器复制的 Cookie包含 yescode_auth 和 yescode_csrf",
},
},
"required": ["auth_cookie"],
}
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)
try:
# 创建临时 client 获取合并数据
async with httpx.AsyncClient(
headers={"Cookie": cookie_header},
timeout=10.0,
) 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,
)

View File

@@ -9,10 +9,13 @@ from typing import Dict, List, Optional, Type
from src.core.logger import logger
from src.services.provider_ops.architectures import (
AnyrouterArchitecture,
CubenceArchitecture,
GenericApiArchitecture,
NewApiArchitecture,
OneApiArchitecture,
ProviderArchitecture,
YesCodeArchitecture,
)
@@ -47,9 +50,12 @@ class ArchitectureRegistry:
def _register_builtin_architectures(self) -> None:
"""注册内置架构"""
builtin = [
AnyrouterArchitecture,
CubenceArchitecture,
GenericApiArchitecture,
NewApiArchitecture,
OneApiArchitecture,
YesCodeArchitecture,
]
for arch_cls in builtin:

View File

@@ -45,7 +45,7 @@ class ProviderOpsService:
"""
# 凭据中需要加密的字段
SENSITIVE_FIELDS = {"api_key", "password", "session_token", "cookie_string", "cookies"}
SENSITIVE_FIELDS = {"api_key", "password", "session_token", "session_cookie", "token_cookie", "auth_cookie", "cookie_string", "cookies"}
def __init__(self, db: Session):
self.db = db
@@ -412,13 +412,19 @@ class ProviderOpsService:
await CacheService.set(cache_key, cache_data, BALANCE_CACHE_TTL)
async def _cache_balance_from_verify(self, provider_id: str, quota_usd: float) -> None:
async def _cache_balance_from_verify(
self,
provider_id: str,
quota_usd: float,
extra: Optional[Dict[str, Any]] = None,
) -> None:
"""
从验证结果缓存余额
Args:
provider_id: Provider ID
quota_usd: 已转换为美元的余额值
extra: 额外信息(如窗口限额)
"""
cache_key = f"provider_ops:balance:{provider_id}"
@@ -431,7 +437,7 @@ class ProviderOpsService:
"total_used": None,
"total_available": quota_usd,
"currency": "USD",
"extra": {},
"extra": extra or {},
},
"executed_at": datetime.now(timezone.utc).isoformat(),
"response_time_ms": None,
@@ -609,7 +615,10 @@ class ProviderOpsService:
if saved_config:
saved_credentials = self._decrypt_credentials(saved_config.connector_credentials)
sensitive_fields = ["api_key", "password", "session_token", "cookie_string", "cookies"]
sensitive_fields = [
"api_key", "password", "session_token", "cookie_string", "cookies",
"token_cookie", "auth_cookie", # Cookie 认证字段
]
for field in sensitive_fields:
# 如果请求中该字段为空或只包含星号(脱敏值),使用已保存的值
@@ -704,7 +713,12 @@ class ProviderOpsService:
# 使用架构的方法构建请求
verify_endpoint = f"{base_url}{architecture.get_verify_endpoint()}"
headers = architecture.build_verify_headers(config, credentials)
# 执行异步预处理(如获取动态 Cookie
extra_config = await architecture.prepare_verify_config(base_url, config, credentials)
merged_config = {**config, **extra_config}
headers = architecture.build_verify_headers(merged_config, credentials)
logger.debug(
f"验证认证: architecture={architecture_id}, "
@@ -721,6 +735,12 @@ class ProviderOpsService:
except Exception:
data = {}
# 将预处理获取的额外数据合并到响应中
if "_combined_data" in merged_config:
data["_combined_data"] = merged_config["_combined_data"]
elif "_balance_data" in merged_config:
data["_balance_data"] = merged_config["_balance_data"]
# 使用架构的方法解析响应
result = architecture.parse_verify_response(response.status_code, data)
result_dict = result.to_dict()
@@ -734,7 +754,8 @@ class ProviderOpsService:
quota_divisor = balance_config.get("quota_divisor", 1)
# 转换为美元值后缓存
quota_usd = result.quota / quota_divisor
await self._cache_balance_from_verify(provider_id, quota_usd)
# 传入 extra 信息(如窗口限额)
await self._cache_balance_from_verify(provider_id, quota_usd, result.extra)
return result_dict