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:
fawney19
2026-04-03 16:26:16 +08:00
parent 8f26e1a31f
commit 1d9c77522a
868 changed files with 1735 additions and 2433 deletions

View File

@@ -0,0 +1,25 @@
"""
Provider 操作模块
"""
from src.services.provider_ops.actions.anyrouter_balance import AnyrouterBalanceAction
from src.services.provider_ops.actions.balance import BalanceAction
from src.services.provider_ops.actions.base import ProviderAction
from src.services.provider_ops.actions.checkin import CheckinAction
from src.services.provider_ops.actions.cubence_balance import CubenceBalanceAction
from src.services.provider_ops.actions.nekocode_balance import NekoCodeBalanceAction
from src.services.provider_ops.actions.new_api_balance import NewApiBalanceAction
from src.services.provider_ops.actions.sub2api_balance import Sub2ApiBalanceAction
from src.services.provider_ops.actions.yescode_balance import YesCodeBalanceAction
__all__ = [
"ProviderAction",
"BalanceAction",
"CheckinAction",
"NewApiBalanceAction",
"AnyrouterBalanceAction",
"CubenceBalanceAction",
"NekoCodeBalanceAction",
"Sub2ApiBalanceAction",
"YesCodeBalanceAction",
]

View File

@@ -0,0 +1,84 @@
"""
Anyrouter 余额查询操作(含自动签到)
"""
from typing import Any
import httpx
from src.core.logger import logger
from src.services.provider_ops.actions.balance import BalanceAction
from src.services.provider_ops.types import BalanceInfo
class AnyrouterBalanceAction(BalanceAction):
"""
Anyrouter 余额查询
特点:
- 查询余额前始终自动签到
- 签到端点为 /api/user/sign_in
- Cookie 失效时返回友好的错误提示
- quota 单位是 1/500000 美元(与 New API 相同)
"""
display_name = "查询余额(含自动签到)"
description = "查询账户余额,同时自动签到"
_cookie_auth = True
def _parse_balance(self, data: Any) -> BalanceInfo:
"""解析余额信息"""
user_data = data.get("data", {}) if isinstance(data, dict) else {}
quota_divisor = self.config.get("quota_divisor", 500000)
# 注意Anyrouter 中 quota 是剩余额度total_available不是总额度
raw_quota = self._to_float(user_data.get("quota"))
raw_used = self._to_float(user_data.get("used_quota"))
total_available = raw_quota / quota_divisor if raw_quota is not None else None
total_used = raw_used / quota_divisor if raw_used is not None else None
return self._create_balance_info(
total_available=total_available,
total_used=total_used,
currency=self.config.get("currency", "USD"),
)
async def _do_checkin(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
"""
执行自动签到(始终执行)
Returns:
签到结果字典,包含 success 和 message 字段
"""
checkin_endpoint = self.config.get("checkin_endpoint", "/api/user/sign_in")
site = client.base_url.host or str(client.base_url)
try:
response = await client.post(checkin_endpoint)
try:
data = response.json()
success = data.get("success", False)
message = data.get("message", "")
if success:
logger.debug(f"[{site}] 签到成功: {message}")
return {"success": True, "message": message or "签到成功"}
else:
already_indicators = ["已签到", "已签", "今日已", "already"]
is_already = any(ind in message.lower() for ind in already_indicators)
if is_already:
logger.debug(f"[{site}] 今日已签到: {message}")
return {"success": None, "message": message or "今日已签到"}
else:
logger.debug(f"[{site}] 签到失败: {message}")
return {"success": False, "message": message or "签到失败"}
except Exception as e:
logger.debug(f"[{site}] 签到响应解析失败: {e}")
return {"success": False, "message": "响应解析失败"}
except Exception as e:
logger.debug(f"[{site}] 签到异常: {e}")
return {"success": False, "message": str(e)}

View File

@@ -0,0 +1,307 @@
"""
余额查询操作抽象基类
"""
import time
from abc import abstractmethod
from typing import Any
import httpx
from src.core.cache_service import CacheService
from src.core.logger import logger as _logger
from src.services.provider_ops.actions.base import ProviderAction
from src.services.provider_ops.types import (
ActionResult,
ActionStatus,
BalanceInfo,
ProviderActionType,
)
# 签到缓存 TTL6 小时)
_CHECKIN_CACHE_TTL = 6 * 3600
class BalanceAction(ProviderAction):
"""
余额查询操作抽象基类
子类必须实现 _parse_balance() 方法来处理特定平台的余额解析逻辑。
子类可选重写 _do_query_balance() 或 _do_checkin() 进行自定义。
如果子类使用 Cookie 认证,设置 _cookie_auth = True 可让 401/403 错误
显示 "Cookie 已失效" 而非 "认证失败"
"""
action_type = ProviderActionType.QUERY_BALANCE
display_name = "查询余额"
description = "查询账户余额信息"
default_cache_ttl = 86400 # 24 小时
# 子类设为 True 即可在 401/403 时显示 "Cookie 已失效" 消息
_cookie_auth: bool = False
async def execute(self, client: httpx.AsyncClient) -> ActionResult:
"""
执行余额查询(模板方法)
1. 先尝试签到(如果子类实现了 _do_checkin带缓存冷却
2. 执行余额查询
Args:
client: 已认证的 HTTP 客户端
Returns:
ActionResult其中 data 字段为 BalanceInfo
"""
# 先尝试签到(带缓存冷却)
checkin_result = await self._get_or_do_checkin(client)
# 执行余额查询
result = await self._do_query_balance(client)
# 将签到结果附加到 extra 字段
if checkin_result and result.data and hasattr(result.data, "extra"):
if result.data.extra is None:
result.data.extra = {}
# 处理 cookie_expired 标记
if checkin_result.get("cookie_expired"):
result.data.extra["cookie_expired"] = True
result.data.extra["cookie_expired_message"] = checkin_result.get("message", "")
result.status = ActionStatus.AUTH_EXPIRED
_logger.warning("Cookie 已失效: {}", checkin_result)
else:
result.data.extra["checkin_success"] = checkin_result.get("success")
result.data.extra["checkin_message"] = checkin_result.get("message", "")
_logger.debug("签到结果已附加到 extra: {}", checkin_result)
return result
async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult:
"""
执行余额查询
默认实现处理通用的请求/响应/错误处理流程。
子类只需实现 _parse_balance() 即可。
如果查询逻辑不同(如并发调用多个接口),子类可重写此方法。
Args:
client: 已认证的 HTTP 客户端
Returns:
ActionResult其中 data 字段为 BalanceInfo
"""
endpoint = self.config.get("endpoint", "/api/user/self")
method = self.config.get("method", "GET")
start_time = time.time()
try:
response = await client.request(method, endpoint)
response_time_ms = int((time.time() - start_time) * 1000)
try:
data = response.json()
except Exception:
return self._make_error_result(
ActionStatus.PARSE_ERROR,
"响应不是有效的 JSON",
)
if response.status_code != 200:
return self._handle_http_error(response, data)
if data.get("success") is False:
message = data.get("message", "业务状态码表示失败")
return self._make_error_result(
ActionStatus.UNKNOWN_ERROR,
message,
raw_response=data,
)
balance = self._parse_balance(data)
return self._make_success_result(
data=balance,
response_time_ms=response_time_ms,
raw_response=data,
)
except httpx.TimeoutException:
return self._make_error_result(
ActionStatus.NETWORK_ERROR,
"请求超时",
retry_after_seconds=30,
)
except httpx.RequestError as e:
return self._make_error_result(
ActionStatus.NETWORK_ERROR,
f"网络错误: {str(e)}",
retry_after_seconds=30,
)
except Exception as e:
return self._make_error_result(
ActionStatus.UNKNOWN_ERROR,
f"未知错误: {str(e)}",
)
@abstractmethod
def _parse_balance(self, data: Any) -> BalanceInfo:
"""
解析余额数据(子类必须实现)
Args:
data: API 响应 JSON 数据
Returns:
BalanceInfo 对象
"""
pass
def _handle_http_error(
self, response: httpx.Response, raw_data: dict[str, Any] | None = None
) -> ActionResult:
"""
处理 HTTP 错误响应
Cookie 认证的子类设置 _cookie_auth = True 即可获得友好的错误提示,
无需再逐个重写此方法。
"""
status_code = response.status_code
if status_code == 401:
msg = "Cookie 已失效,请重新配置" if self._cookie_auth else "认证失败"
return self._make_error_result(ActionStatus.AUTH_FAILED, msg, raw_response=raw_data)
elif status_code == 403:
msg = "Cookie 已失效或无权限" if self._cookie_auth else "无权限访问"
return self._make_error_result(ActionStatus.AUTH_FAILED, msg, raw_response=raw_data)
elif status_code == 404:
return self._make_error_result(
ActionStatus.NOT_SUPPORTED, "功能未开放", raw_response=raw_data
)
elif status_code == 429:
retry_after = response.headers.get("Retry-After")
return self._make_error_result(
ActionStatus.RATE_LIMITED,
"请求频率限制",
retry_after_seconds=int(retry_after) if retry_after else 60,
raw_response=raw_data,
)
else:
return self._make_error_result(
ActionStatus.UNKNOWN_ERROR,
f"HTTP {status_code}: {response.reason_phrase}",
raw_response=raw_data,
)
async def _get_or_do_checkin(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
"""
带缓存冷却的签到:优先返回缓存结果,未命中才实际执行签到。
缓存 key 使用 host同一站点多个 provider 只需签到一次TTL 6 小时。
签到失败或 cookie_expired 不写入缓存,允许下次重试。
"""
host = str(client.base_url.host or client.base_url.netloc or client.base_url)
cache_key = f"provider_ops:checkin:{host}"
# 检查缓存
try:
cached = await CacheService.get(cache_key)
if cached is not None:
_logger.debug("[{}] 签到缓存命中,跳过签到: {}", host, cached)
return cached
except Exception:
pass
# 缓存未命中,执行签到
result = await self._do_checkin(client)
# 签到成功或"已签到"时写入缓存;失败/cookie_expired 不缓存
if result is not None and not result.get("cookie_expired"):
success = result.get("success")
if success is True or success is None:
try:
await CacheService.set(cache_key, result, _CHECKIN_CACHE_TTL)
except Exception:
pass
return result
async def _do_checkin(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
"""
执行签到(子类可选实现)
默认实现返回 None不签到
子类可重写此方法实现平台特定的签到逻辑。
Args:
client: 已认证的 HTTP 客户端
Returns:
签到结果字典 {"success": bool, "message": str},或 None 表示不签到
"""
return None
def _create_balance_info(
self,
total_granted: float | None = None,
total_used: float | None = None,
total_available: float | None = None,
currency: str = "USD",
extra: dict[str, Any] | None = None,
) -> BalanceInfo:
"""
创建余额信息对象
辅助方法,用于创建统一格式的 BalanceInfo。
如果只有部分数据,会尝试计算缺失的值。
Args:
total_granted: 总额度
total_used: 已用额度
total_available: 可用余额
currency: 货币单位
extra: 额外信息
Returns:
BalanceInfo 对象
"""
# 如果只有部分数据,尝试计算
if total_available is None and total_granted is not None and total_used is not None:
total_available = total_granted - total_used
if total_used is None and total_granted is not None and total_available is not None:
total_used = total_granted - total_available
if total_granted is None and total_used is not None and total_available is not None:
total_granted = total_used + total_available
return BalanceInfo(
total_granted=total_granted,
total_used=total_used,
total_available=total_available,
currency=currency,
extra=extra if extra is not None else {},
)
def _to_float(self, value: Any) -> float | None:
"""转换为浮点数"""
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
"""获取操作配置 schema子类可重写"""
return {
"type": "object",
"properties": {
"currency": {
"type": "string",
"title": "货币单位",
"default": "USD",
},
},
"required": [],
}

View File

@@ -0,0 +1,161 @@
"""
Provider 操作抽象基类
"""
from abc import ABC, abstractmethod
from typing import Any
import httpx
from src.services.provider_ops.types import (
ActionResult,
ActionStatus,
ProviderActionType,
)
class ProviderAction(ABC):
"""
提供商操作基类
定义具体的操作逻辑(如查询余额、签到等)。
"""
# 子类需要定义的类属性
action_type: ProviderActionType = ProviderActionType.CUSTOM
display_name: str = "Base Action"
description: str = ""
# 默认缓存时间(秒)
default_cache_ttl: int = 300
def __init__(self, config: dict[str, Any] | None = None):
"""
初始化操作
Args:
config: 操作配置
"""
self.config = config or {}
@abstractmethod
async def execute(self, client: httpx.AsyncClient) -> ActionResult:
"""
执行操作
Args:
client: 已认证的 HTTP 客户端
Returns:
操作结果
"""
pass
def _extract_field(self, data: Any, path: str | None) -> Any:
"""
从响应数据中提取字段
支持点号分隔的路径,如 "data.user.balance"
Args:
data: 响应数据
path: 字段路径
Returns:
提取的值,如果路径无效则返回 None
"""
if not path:
return None
current = data
for key in path.split("."):
if isinstance(current, dict):
current = current.get(key)
elif isinstance(current, list) and key.isdigit():
index = int(key)
current = current[index] if 0 <= index < len(current) else None
else:
return None
if current is None:
return None
return current
def _make_success_result(
self,
data: Any = None,
message: str | None = None,
response_time_ms: int | None = None,
raw_response: dict[str, Any] | None = None,
) -> ActionResult:
"""创建成功结果"""
return ActionResult(
status=ActionStatus.SUCCESS,
action_type=self.action_type,
data=data,
message=message,
response_time_ms=response_time_ms,
raw_response=raw_response,
cache_ttl_seconds=self.default_cache_ttl,
)
def _make_error_result(
self,
status: ActionStatus,
message: str | None = None,
retry_after_seconds: int | None = None,
raw_response: dict[str, Any] | None = None,
) -> ActionResult:
"""创建错误结果"""
return ActionResult(
status=status,
action_type=self.action_type,
message=message,
retry_after_seconds=retry_after_seconds,
raw_response=raw_response,
cache_ttl_seconds=0, # 错误不缓存
)
def _handle_http_error(
self, response: httpx.Response, raw_data: dict[str, Any] | None = None
) -> ActionResult:
"""处理 HTTP 错误响应"""
status_code = response.status_code
if status_code == 401:
return self._make_error_result(
ActionStatus.AUTH_FAILED, "认证失败", raw_response=raw_data
)
elif status_code == 403:
return self._make_error_result(
ActionStatus.AUTH_FAILED, "无权限访问", raw_response=raw_data
)
elif status_code == 404:
# 404 表示接口不存在,通常意味着该功能未开放
return self._make_error_result(
ActionStatus.NOT_SUPPORTED, "功能未开放", raw_response=raw_data
)
elif status_code == 429:
retry_after = response.headers.get("Retry-After")
return self._make_error_result(
ActionStatus.RATE_LIMITED,
"请求频率限制",
retry_after_seconds=int(retry_after) if retry_after else 60,
raw_response=raw_data,
)
else:
return self._make_error_result(
ActionStatus.UNKNOWN_ERROR,
f"HTTP {status_code}: {response.reason_phrase}",
raw_response=raw_data,
)
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
"""
获取操作配置 JSON Schema用于前端表单生成
子类应重写此方法
"""
return {"type": "object", "properties": {}, "required": []}

View File

@@ -0,0 +1,80 @@
"""
签到操作抽象基类
"""
from abc import abstractmethod
from typing import Any
import httpx
from src.services.provider_ops.actions.base import ProviderAction
from src.services.provider_ops.types import (
ActionResult,
CheckinInfo,
ProviderActionType,
)
class CheckinAction(ProviderAction):
"""
签到操作抽象基类
子类必须实现 execute() 方法来处理特定平台的签到逻辑。
"""
action_type = ProviderActionType.CHECKIN
display_name = "签到"
description = "每日签到领取额度"
default_cache_ttl = 3600 # 签到结果缓存 1 小时
@abstractmethod
async def execute(self, client: httpx.AsyncClient) -> ActionResult:
"""
执行签到
子类必须实现此方法。
Args:
client: 已认证的 HTTP 客户端
Returns:
ActionResult其中 data 字段为 CheckinInfo
"""
pass
def _create_checkin_info(
self,
reward: float | None = None,
streak_days: int | None = None,
message: str | None = None,
extra: dict[str, Any] | None = None,
) -> CheckinInfo:
"""
创建签到信息对象
辅助方法,用于创建统一格式的 CheckinInfo。
Args:
reward: 签到奖励额度
streak_days: 连续签到天数
message: 签到消息
extra: 额外信息
Returns:
CheckinInfo 对象
"""
return CheckinInfo(
reward=reward,
streak_days=streak_days,
message=message,
extra=extra if extra is not None else {},
)
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
"""获取操作配置 schema子类可重写"""
return {
"type": "object",
"properties": {},
"required": [],
}

View File

@@ -0,0 +1,74 @@
"""
Cubence 余额查询操作
"""
from typing import Any
from src.services.provider_ops.actions.balance import BalanceAction
from src.services.provider_ops.types import BalanceInfo
class CubenceBalanceAction(BalanceAction):
"""
Cubence 专用余额查询
特点:
- 余额单位直接是美元
- 支持窗口限额查询5小时/每周)
- Cookie 失效时返回友好的错误提示
"""
display_name = "查询余额(含窗口限额)"
description = "查询账户余额和窗口限额信息"
_cookie_auth = True
def _parse_balance(self, data: Any) -> BalanceInfo:
"""解析 Cubence 余额信息"""
# Cubence 响应格式data.balance 和 data.subscription_limits
response_data = data.get("data", {}) if isinstance(data, dict) else {}
balance_data = response_data.get("balance", {})
subscription_limits = response_data.get("subscription_limits", {})
# 余额信息(单位直接是美元)
total_available = balance_data.get("total_balance_dollar")
normal_balance = balance_data.get("normal_balance_dollar")
subscription_balance = balance_data.get("subscription_balance_dollar")
charity_balance = balance_data.get("charity_balance_dollar")
# 窗口限额信息
extra: dict[str, Any] = {}
# 5小时窗口限额
five_hour = subscription_limits.get("five_hour", {})
if five_hour:
extra["five_hour_limit"] = {
"limit": five_hour.get("limit"),
"used": five_hour.get("used"),
"remaining": five_hour.get("remaining"),
"resets_at": five_hour.get("resets_at"),
}
# 每周窗口限额
weekly = subscription_limits.get("weekly", {})
if weekly:
extra["weekly_limit"] = {
"limit": weekly.get("limit"),
"used": weekly.get("used"),
"remaining": weekly.get("remaining"),
"resets_at": weekly.get("resets_at"),
}
# 余额组成
if normal_balance is not None:
extra["normal_balance"] = normal_balance
if subscription_balance is not None:
extra["subscription_balance"] = subscription_balance
if charity_balance is not None:
extra["charity_balance"] = charity_balance
return self._create_balance_info(
total_available=total_available,
currency=self.config.get("currency", "USD"),
extra=extra if extra else None,
)

View File

@@ -0,0 +1,119 @@
"""
NekoCode 余额查询操作
"""
from datetime import datetime
from typing import Any
from src.core.logger import logger
from src.services.provider_ops.actions.balance import BalanceAction
from src.services.provider_ops.types import BalanceInfo
class NekoCodeBalanceAction(BalanceAction):
"""
NekoCode 余额查询
特点:
- 查询余额和订阅信息
- 显示每日配额限制和剩余
- 显示订阅状态和有效期
- 余额单位为积分
"""
display_name = "查询余额"
description = "查询 NekoCode 账户余额和订阅信息"
_cookie_auth = True
def _parse_balance(self, data: Any) -> BalanceInfo:
"""解析余额信息"""
response_data = data.get("data", {}) if isinstance(data, dict) else {}
subscription = response_data.get("subscription", {})
# 解析余额(积分)
balance = self._to_float(response_data.get("balance"))
# 解析每日配额
daily_quota_limit = self._to_float(subscription.get("daily_quota_limit"))
daily_remaining_quota = self._to_float(subscription.get("daily_remaining_quota"))
# 计算每日已用配额
daily_used = None
if daily_quota_limit is not None and daily_remaining_quota is not None:
daily_used = daily_quota_limit - daily_remaining_quota
# 解析订阅信息
plan_name = subscription.get("plan_name")
status = subscription.get("status")
effective_start_date = subscription.get("effective_start_date")
effective_end_date = subscription.get("effective_end_date")
# 解析日期
expires_at = None
refresh_at = None
if effective_end_date:
try:
expires_at = datetime.fromisoformat(effective_end_date)
except ValueError as e:
logger.debug(f"解析 effective_end_date 失败: {e}")
if effective_start_date:
try:
refresh_at = datetime.fromisoformat(effective_start_date)
except ValueError as e:
logger.debug(f"解析 effective_start_date 失败: {e}")
# 构建 extra 信息
extra: dict[str, Any] = {
"plan_name": plan_name,
"subscription_status": status,
"daily_quota_limit": daily_quota_limit,
"daily_remaining_quota": daily_remaining_quota,
"daily_used_quota": daily_used,
"effective_start_date": effective_start_date,
"effective_end_date": effective_end_date,
}
# 添加刷新时间信息
if refresh_at:
extra["refresh_at"] = refresh_at.isoformat()
extra["refresh_at_display"] = refresh_at.strftime("%Y-%m-%d %H:%M:%S")
# 添加月度统计
month_data = response_data.get("month", {})
if month_data:
extra["month_stats"] = {
"total_input_tokens": month_data.get("total_input_tokens"),
"total_output_tokens": month_data.get("total_output_tokens"),
"total_quota": month_data.get("total_quota"),
"total_requests": month_data.get("total_requests"),
}
# 添加今日统计
today_data = response_data.get("today", {})
if today_data:
extra["today_stats"] = today_data.get("stats", [])
return self._create_balance_info(
total_available=balance,
total_granted=daily_quota_limit, # 每日配额作为总额度
total_used=daily_used, # 每日已用
currency="USD", # NekoCode 使用美元单位
extra=extra,
)
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
"""获取操作配置 schema"""
return {
"type": "object",
"properties": {
"endpoint": {
"type": "string",
"title": "API 端点",
"default": "/api/usage/summary",
},
},
"required": [],
}

View File

@@ -0,0 +1,176 @@
"""
New API 余额查询操作
"""
from typing import Any
import httpx
from src.core.logger import logger
from src.services.provider_ops.actions.balance import BalanceAction
from src.services.provider_ops.types import BalanceInfo
class NewApiBalanceAction(BalanceAction):
"""
New API 风格的余额查询
特点:
- 使用 /api/user/self 端点
- quota 单位是 1/500000 美元
- 支持查询前自动签到(通过基类的模板方法)
"""
display_name = "查询余额"
description = "查询 New API 账户余额信息"
def _parse_balance(
self,
data: Any,
) -> BalanceInfo:
"""解析 New API 余额信息"""
# New API 响应格式: {"success": true, "data": {...}}
user_data = data.get("data", {}) if isinstance(data, dict) else {}
# 获取 quota 除数(默认 500000New API 的标准)
quota_divisor = self.config.get("quota_divisor", 500000)
# 提取原始值
# 注意New API 中 quota 是剩余额度total_available不是总额度
raw_quota = self._to_float(user_data.get("quota"))
raw_used = self._to_float(user_data.get("used_quota"))
# 转换为美元
total_available = raw_quota / quota_divisor if raw_quota is not None else None
total_used = raw_used / quota_divisor if raw_used is not None else None
return self._create_balance_info(
total_available=total_available,
total_used=total_used,
currency=self.config.get("currency", "USD"),
)
async def _do_checkin(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
"""
执行签到(静默,不抛出异常)
New API 签到通常需要认证Cookie 或 API Key + New-Api-User
失败时仅记录日志,不影响余额查询。
Returns:
签到结果字典,包含 success 和 message 字段;
如果功能未开放返回 None
如果 Cookie 失效返回 {"cookie_expired": True}
"""
site = client.base_url.host or str(client.base_url)
checkin_endpoint = self.config.get("checkin_endpoint", "/api/user/checkin")
# 检查是否配置了 Cookie通过 service 层注入的 _has_cookie 标志)
has_cookie = self.config.get("_has_cookie", False)
if not has_cookie:
logger.debug(f"[{site}] 未配置 Cookie尝试使用 API Key 认证签到")
try:
response = await client.post(checkin_endpoint)
# 404 表示签到功能未开放
if response.status_code == 404:
logger.debug(f"[{site}] 签到功能未开放")
return None
# 401/403 通常表示未授权Cookie 模式下大多意味着 Cookie 已失效
if response.status_code in (401, 403):
# 只有在明确配置了 Cookie 的情况下,才标记为 Cookie 失效。
# API Key 模式下的 401/403 更可能表示该站点不支持该认证方式签到。
if has_cookie:
logger.warning(f"[{site}] Cookie 已失效(签到返回 {response.status_code}")
return {"cookie_expired": True, "message": "Cookie 已失效"}
logger.debug(
f"[{site}] 签到认证失败({response.status_code}),跳过签到结果上报"
)
return None
try:
data = response.json()
message = data.get("message", "")
success = data.get("success", False)
message_lower = str(message).lower()
if success:
logger.debug(f"[{site}] 签到成功: {message}")
return {"success": True, "message": message or "签到成功"}
else:
# 检查是否是"已签到"的情况
already_indicators = ["already", "已签到", "已经签到", "今日已签", "重复签到"]
is_already = any(ind.lower() in message_lower for ind in already_indicators)
if is_already:
logger.debug(f"[{site}] 今日已签到: {message}")
return {"success": None, "message": message or "今日已签到"}
# 检查是否是认证失败(未登录、无权限、验证码等)
auth_fail_indicators = [
"未登录",
"请登录",
"login",
"unauthorized",
"无权限",
"权限不足",
"turnstile",
"captcha",
"验证码", # 需要人机验证
]
is_auth_fail = any(ind.lower() in message_lower for ind in auth_fail_indicators)
if is_auth_fail:
# Cookie 模式下,这类提示通常意味着 Cookie 已失效或需要重新登录。
if has_cookie:
logger.warning(f"[{site}] Cookie 已失效(签到认证失败): {message}")
return {"cookie_expired": True, "message": message or "Cookie 已失效"}
# API Key 模式下,站点可能不支持该认证方式签到;保持静默不影响余额查询。
logger.debug(f"[{site}] 签到认证失败API Key 模式),跳过签到: {message}")
return None
# 其他失败情况
logger.debug(f"[{site}] 签到失败: {message}")
return {"success": False, "message": message or "签到失败"}
except Exception as e:
logger.debug(f"[{site}] 签到响应解析失败: {e}")
return {"success": False, "message": "响应解析失败"}
except Exception as e:
# 签到失败不影响余额查询
logger.debug(f"[{site}] 签到请求失败(不影响余额查询): {e}")
return None
@classmethod
def get_config_schema(cls) -> dict[str, Any]:
"""获取操作配置 schema"""
return {
"type": "object",
"properties": {
"endpoint": {
"type": "string",
"title": "API 路径",
"description": "余额查询 API 路径",
"default": "/api/user/self",
},
"method": {
"type": "string",
"title": "请求方法",
"enum": ["GET", "POST"],
"default": "GET",
},
"quota_divisor": {
"type": "number",
"title": "额度除数",
"description": "将原始额度值转换为美元的除数",
"default": 500000,
},
"currency": {
"type": "string",
"title": "货币单位",
"default": "USD",
},
},
"required": [],
}

View File

@@ -0,0 +1,145 @@
"""
Sub2API 余额查询操作
"""
import asyncio
import time
from typing import Any, cast
import httpx
from src.services.provider_ops.actions.balance import BalanceAction
from src.services.provider_ops.types import ActionResult, ActionStatus, BalanceInfo
class Sub2ApiBalanceAction(BalanceAction):
"""
Sub2API 余额查询
特点:
- 并发调用 /api/v1/auth/me 和 /api/v1/subscriptions/summary
- auth/me 提供基础余额balance + points
- subscriptions/summary 提供订阅详情(各订阅的日/周/月用量和额度)
- 响应格式: {"code": 0, "message": "success", "data": {...}}
"""
display_name = "查询余额"
description = "查询 Sub2API 账户余额和订阅信息"
def _parse_balance(self, data: Any) -> BalanceInfo:
"""本类完全重写了 _do_query_balance绕过基类默认流程故此方法不会被调用"""
raise NotImplementedError(
"Sub2API 重写了 _do_query_balance不走基类的 _parse_balance 路径"
)
async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult:
"""并发查询 auth/me 和 subscriptions/summary"""
start_time = time.time()
me_endpoint = self.config.get("endpoint", "/api/v1/auth/me?timezone=Asia/Shanghai")
sub_endpoint = self.config.get("subscription_endpoint", "/api/v1/subscriptions/summary")
try:
me_resp, sub_resp = cast(
tuple[httpx.Response | BaseException, httpx.Response | BaseException],
await asyncio.gather(
client.get(me_endpoint),
client.get(sub_endpoint),
return_exceptions=True,
),
)
response_time_ms = int((time.time() - start_time) * 1000)
# 解析 auth/me
me_data: dict[str, Any] = {}
me_ok = False
if isinstance(me_resp, httpx.Response):
if me_resp.status_code in (401, 403):
return self._make_error_result(
ActionStatus.AUTH_FAILED, "认证失败,请检查凭据配置"
)
if me_resp.status_code == 200:
try:
me_json = me_resp.json()
if me_json.get("code") == 0:
me_data = me_json.get("data", {})
me_ok = True
except Exception:
pass
if not me_ok:
return self._make_error_result(ActionStatus.UNKNOWN_ERROR, "查询用户信息失败")
# 基础余额
balance = self._to_float(me_data.get("balance")) or 0.0
points = self._to_float(me_data.get("points")) or 0.0
total_available = balance + points
extra: dict[str, Any] = {
"balance": balance,
"points": points,
}
# 解析 subscriptions/summary可选失败不影响主流程
if isinstance(sub_resp, httpx.Response) and sub_resp.status_code == 200:
try:
sub_json = sub_resp.json()
if sub_json.get("code") == 0:
summary = sub_json.get("data", {})
extra["active_subscriptions"] = summary.get("active_count", 0)
extra["total_used_usd"] = summary.get("total_used_usd", 0)
extra["subscriptions"] = self._parse_subscriptions(
summary.get("subscriptions", [])
)
except Exception:
pass
balance_info = self._create_balance_info(
total_available=total_available,
currency="USD",
extra=extra,
)
return self._make_success_result(
data=balance_info,
response_time_ms=response_time_ms,
raw_response={"me": me_data},
)
except httpx.TimeoutException:
return self._make_error_result(
ActionStatus.NETWORK_ERROR, "请求超时", retry_after_seconds=30
)
except httpx.RequestError as e:
return self._make_error_result(
ActionStatus.NETWORK_ERROR, f"网络错误: {e}", retry_after_seconds=30
)
except Exception as e:
return self._make_error_result(ActionStatus.UNKNOWN_ERROR, f"未知错误: {e}")
@staticmethod
def _parse_subscriptions(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""将订阅列表精简为前端需要的字段"""
result = []
for item in items:
sub: dict[str, Any] = {
"group_name": item.get("group_name", ""),
"status": item.get("status", ""),
}
# 只保留非 None 的限额字段0 是有效值,表示未使用/无限额)
for field in (
"daily_used_usd",
"daily_limit_usd",
"weekly_used_usd",
"weekly_limit_usd",
"monthly_used_usd",
"monthly_limit_usd",
):
val = item.get(field)
if val is not None:
sub[field] = val
expires_at = item.get("expires_at")
if expires_at:
sub["expires_at"] = expires_at
result.append(sub)
return result

View File

@@ -0,0 +1,233 @@
"""
YesCode 余额查询操作
"""
import asyncio
from datetime import datetime, timedelta
from typing import Any, cast
import httpx
from src.services.provider_ops.actions.balance import BalanceAction
from src.services.provider_ops.types import ActionResult, ActionStatus, BalanceInfo
async def fetch_yescode_combined_data(
client: httpx.AsyncClient,
base_url: str,
) -> dict[str, Any]:
"""
获取 YesCode 合并数据balance + profile
使用传入的 client 并发调用两个接口:
- /api/v1/user/balance: 准确的 weekly_spent_balance
- /api/v1/auth/profile: 用户信息、重置时间
Args:
client: 已配置好认证的 HTTP 客户端
base_url: API 基础地址
Returns:
合并后的数据字典
"""
base_url = base_url.rstrip("/")
result: dict[str, Any] = {}
# 并发调用两个接口
balance_task = client.get(f"{base_url}/api/v1/user/balance")
profile_task = client.get(f"{base_url}/api/v1/auth/profile")
balance_resp, profile_resp = cast(
tuple[httpx.Response | BaseException, httpx.Response | BaseException],
await asyncio.gather(balance_task, profile_task, return_exceptions=True),
)
# 解析 balance 接口
balance_data: dict[str, Any] = {}
if isinstance(balance_resp, httpx.Response) and balance_resp.status_code == 200:
try:
balance_data = balance_resp.json()
result["_balance_data"] = balance_data
except Exception:
pass
# 解析 profile 接口
profile_data: dict[str, Any] = {}
if isinstance(profile_resp, httpx.Response) and profile_resp.status_code == 200:
try:
profile_data = profile_resp.json()
result["_profile_data"] = profile_data
except Exception:
pass
# 合并数据balance 接口优先(更准确的余额数据)
result["pay_as_you_go_balance"] = balance_data.get(
"pay_as_you_go_balance", profile_data.get("pay_as_you_go_balance", 0)
)
result["subscription_balance"] = balance_data.get(
"subscription_balance", profile_data.get("subscription_balance", 0)
)
result["weekly_limit"] = balance_data.get("weekly_limit") or (
profile_data.get("subscription_plan") or {}
).get("weekly_limit")
result["weekly_spent_balance"] = balance_data.get(
"weekly_spent_balance", profile_data.get("current_week_spend", 0)
)
# 用户信息(仅 profile 有)
result["username"] = profile_data.get("username")
result["email"] = profile_data.get("email")
# 重置时间(仅 profile 有)
result["last_week_reset"] = profile_data.get("last_week_reset")
result["last_daily_balance_add"] = profile_data.get("last_daily_balance_add")
# subscription_plan仅 profile 有)
result["subscription_plan"] = profile_data.get("subscription_plan")
return result
def parse_yescode_balance_extra(data: dict[str, Any]) -> dict[str, Any]:
"""
解析 YesCode 余额额外信息
Args:
data: 合并后的数据(来自 fetch_yescode_combined_data 或单独接口)
Returns:
统一格式的 extra 字典
"""
extra: dict[str, Any] = {}
pay_as_you_go = data.get("pay_as_you_go_balance", 0)
subscription = data.get("subscription_balance", 0)
extra["pay_as_you_go_balance"] = pay_as_you_go
# 每日额度上限
plan = data.get("subscription_plan") or {}
daily_balance = plan.get("daily_balance", subscription)
# 周限额
weekly_limit = data.get("weekly_limit") or plan.get("weekly_limit")
weekly_spent = data.get("weekly_spent_balance", 0)
# 映射为统一字段
extra["daily_limit"] = daily_balance
if weekly_limit is not None:
extra["weekly_limit"] = weekly_limit
extra["weekly_spent"] = weekly_spent
# 计算重置时间
last_week_reset = data.get("last_week_reset")
if last_week_reset:
try:
if isinstance(last_week_reset, str):
reset_dt = datetime.fromisoformat(last_week_reset.replace("Z", "+00:00"))
next_reset = reset_dt + timedelta(days=7)
extra["weekly_resets_at"] = int(next_reset.timestamp())
except Exception:
pass
last_daily_add = data.get("last_daily_balance_add")
if last_daily_add:
try:
if isinstance(last_daily_add, str):
add_dt = datetime.fromisoformat(last_daily_add.replace("Z", "+00:00"))
next_daily = add_dt + timedelta(days=1)
extra["daily_resets_at"] = int(next_daily.timestamp())
except Exception:
pass
# 计算实际可用余额
if weekly_limit is not None:
weekly_remaining = max(0, weekly_limit - weekly_spent)
subscription_available = min(subscription, weekly_remaining)
extra["daily_spent"] = daily_balance - min(daily_balance, subscription_available)
else:
subscription_available = subscription
extra["daily_spent"] = max(0, daily_balance - subscription)
extra["_subscription_available"] = subscription_available
extra["_total_available"] = pay_as_you_go + subscription_available
return extra
class YesCodeBalanceAction(BalanceAction):
"""
YesCode 专用余额查询
特点:
- 余额单位直接是美元
- 支持每周限额查询
- 同时调用 balance 和 profile 接口获取完整数据
- Cookie 失效时返回友好的错误提示
"""
display_name = "查询余额(含每周限额)"
description = "查询账户余额和每周限额信息"
_cookie_auth = True
def _parse_balance(self, data: Any) -> BalanceInfo:
"""YesCode 不使用基类的 _do_query_balance此方法不会被调用"""
raise NotImplementedError("YesCode 使用自定义 _do_query_balance")
async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult:
"""执行余额查询(实现抽象方法,复用 client 调用两个接口获取完整数据)"""
import time
start_time = time.time()
base_url = str(client.base_url).rstrip("/")
try:
# 复用传入的 client 获取合并数据
combined_data = await fetch_yescode_combined_data(client, base_url)
response_time_ms = int((time.time() - start_time) * 1000)
# 检查是否至少有一个接口成功
if "_balance_data" not in combined_data and "_profile_data" not in combined_data:
return self._make_error_result(
ActionStatus.AUTH_FAILED,
"Cookie 已失效,请重新配置",
)
# 使用公共函数解析余额
extra = parse_yescode_balance_extra(combined_data)
total_available = extra.pop("_total_available", 0)
extra.pop("_subscription_available", None)
balance = BalanceInfo(
total_granted=None,
total_used=None,
total_available=total_available,
currency=self.config.get("currency", "USD"),
extra=extra if extra else {},
)
return self._make_success_result(
data=balance,
response_time_ms=response_time_ms,
raw_response=combined_data,
)
except httpx.TimeoutException:
return self._make_error_result(
ActionStatus.NETWORK_ERROR,
"请求超时",
retry_after_seconds=30,
)
except httpx.RequestError as e:
return self._make_error_result(
ActionStatus.NETWORK_ERROR,
f"网络错误: {str(e)}",
retry_after_seconds=30,
)
except Exception as e:
return self._make_error_result(
ActionStatus.UNKNOWN_ERROR,
f"未知错误: {str(e)}",
)