mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构
- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/ - 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层 - 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构 - 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations) - 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image - 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
This commit is contained in:
25
_deprecated_py_src/services/provider_ops/actions/__init__.py
Normal file
25
_deprecated_py_src/services/provider_ops/actions/__init__.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
Provider 操作模块
|
||||
"""
|
||||
|
||||
from src.services.provider_ops.actions.anyrouter_balance import AnyrouterBalanceAction
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.actions.base import ProviderAction
|
||||
from src.services.provider_ops.actions.checkin import CheckinAction
|
||||
from src.services.provider_ops.actions.cubence_balance import CubenceBalanceAction
|
||||
from src.services.provider_ops.actions.nekocode_balance import NekoCodeBalanceAction
|
||||
from src.services.provider_ops.actions.new_api_balance import NewApiBalanceAction
|
||||
from src.services.provider_ops.actions.sub2api_balance import Sub2ApiBalanceAction
|
||||
from src.services.provider_ops.actions.yescode_balance import YesCodeBalanceAction
|
||||
|
||||
__all__ = [
|
||||
"ProviderAction",
|
||||
"BalanceAction",
|
||||
"CheckinAction",
|
||||
"NewApiBalanceAction",
|
||||
"AnyrouterBalanceAction",
|
||||
"CubenceBalanceAction",
|
||||
"NekoCodeBalanceAction",
|
||||
"Sub2ApiBalanceAction",
|
||||
"YesCodeBalanceAction",
|
||||
]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Anyrouter 余额查询操作(含自动签到)
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.types import BalanceInfo
|
||||
|
||||
|
||||
class AnyrouterBalanceAction(BalanceAction):
|
||||
"""
|
||||
Anyrouter 余额查询
|
||||
|
||||
特点:
|
||||
- 查询余额前始终自动签到
|
||||
- 签到端点为 /api/user/sign_in
|
||||
- Cookie 失效时返回友好的错误提示
|
||||
- quota 单位是 1/500000 美元(与 New API 相同)
|
||||
"""
|
||||
|
||||
display_name = "查询余额(含自动签到)"
|
||||
description = "查询账户余额,同时自动签到"
|
||||
|
||||
_cookie_auth = True
|
||||
|
||||
def _parse_balance(self, data: Any) -> BalanceInfo:
|
||||
"""解析余额信息"""
|
||||
user_data = data.get("data", {}) if isinstance(data, dict) else {}
|
||||
quota_divisor = self.config.get("quota_divisor", 500000)
|
||||
|
||||
# 注意:Anyrouter 中 quota 是剩余额度(total_available),不是总额度
|
||||
raw_quota = self._to_float(user_data.get("quota"))
|
||||
raw_used = self._to_float(user_data.get("used_quota"))
|
||||
|
||||
total_available = raw_quota / quota_divisor if raw_quota is not None else None
|
||||
total_used = raw_used / quota_divisor if raw_used is not None else None
|
||||
|
||||
return self._create_balance_info(
|
||||
total_available=total_available,
|
||||
total_used=total_used,
|
||||
currency=self.config.get("currency", "USD"),
|
||||
)
|
||||
|
||||
async def _do_checkin(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
|
||||
"""
|
||||
执行自动签到(始终执行)
|
||||
|
||||
Returns:
|
||||
签到结果字典,包含 success 和 message 字段
|
||||
"""
|
||||
checkin_endpoint = self.config.get("checkin_endpoint", "/api/user/sign_in")
|
||||
site = client.base_url.host or str(client.base_url)
|
||||
|
||||
try:
|
||||
response = await client.post(checkin_endpoint)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
success = data.get("success", False)
|
||||
message = data.get("message", "")
|
||||
|
||||
if success:
|
||||
logger.debug(f"[{site}] 签到成功: {message}")
|
||||
return {"success": True, "message": message or "签到成功"}
|
||||
else:
|
||||
already_indicators = ["已签到", "已签", "今日已", "already"]
|
||||
is_already = any(ind in message.lower() for ind in already_indicators)
|
||||
if is_already:
|
||||
logger.debug(f"[{site}] 今日已签到: {message}")
|
||||
return {"success": None, "message": message or "今日已签到"}
|
||||
else:
|
||||
logger.debug(f"[{site}] 签到失败: {message}")
|
||||
return {"success": False, "message": message or "签到失败"}
|
||||
except Exception as e:
|
||||
logger.debug(f"[{site}] 签到响应解析失败: {e}")
|
||||
return {"success": False, "message": "响应解析失败"}
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"[{site}] 签到异常: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
307
_deprecated_py_src/services/provider_ops/actions/balance.py
Normal file
307
_deprecated_py_src/services/provider_ops/actions/balance.py
Normal file
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
余额查询操作抽象基类
|
||||
"""
|
||||
|
||||
import time
|
||||
from abc import abstractmethod
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.cache_service import CacheService
|
||||
from src.core.logger import logger as _logger
|
||||
from src.services.provider_ops.actions.base import ProviderAction
|
||||
from src.services.provider_ops.types import (
|
||||
ActionResult,
|
||||
ActionStatus,
|
||||
BalanceInfo,
|
||||
ProviderActionType,
|
||||
)
|
||||
|
||||
# 签到缓存 TTL(6 小时)
|
||||
_CHECKIN_CACHE_TTL = 6 * 3600
|
||||
|
||||
|
||||
class BalanceAction(ProviderAction):
|
||||
"""
|
||||
余额查询操作抽象基类
|
||||
|
||||
子类必须实现 _parse_balance() 方法来处理特定平台的余额解析逻辑。
|
||||
子类可选重写 _do_query_balance() 或 _do_checkin() 进行自定义。
|
||||
|
||||
如果子类使用 Cookie 认证,设置 _cookie_auth = True 可让 401/403 错误
|
||||
显示 "Cookie 已失效" 而非 "认证失败"。
|
||||
"""
|
||||
|
||||
action_type = ProviderActionType.QUERY_BALANCE
|
||||
display_name = "查询余额"
|
||||
description = "查询账户余额信息"
|
||||
default_cache_ttl = 86400 # 24 小时
|
||||
|
||||
# 子类设为 True 即可在 401/403 时显示 "Cookie 已失效" 消息
|
||||
_cookie_auth: bool = False
|
||||
|
||||
async def execute(self, client: httpx.AsyncClient) -> ActionResult:
|
||||
"""
|
||||
执行余额查询(模板方法)
|
||||
|
||||
1. 先尝试签到(如果子类实现了 _do_checkin),带缓存冷却
|
||||
2. 执行余额查询
|
||||
|
||||
Args:
|
||||
client: 已认证的 HTTP 客户端
|
||||
|
||||
Returns:
|
||||
ActionResult,其中 data 字段为 BalanceInfo
|
||||
"""
|
||||
# 先尝试签到(带缓存冷却)
|
||||
checkin_result = await self._get_or_do_checkin(client)
|
||||
|
||||
# 执行余额查询
|
||||
result = await self._do_query_balance(client)
|
||||
|
||||
# 将签到结果附加到 extra 字段
|
||||
if checkin_result and result.data and hasattr(result.data, "extra"):
|
||||
if result.data.extra is None:
|
||||
result.data.extra = {}
|
||||
# 处理 cookie_expired 标记
|
||||
if checkin_result.get("cookie_expired"):
|
||||
result.data.extra["cookie_expired"] = True
|
||||
result.data.extra["cookie_expired_message"] = checkin_result.get("message", "")
|
||||
result.status = ActionStatus.AUTH_EXPIRED
|
||||
_logger.warning("Cookie 已失效: {}", checkin_result)
|
||||
else:
|
||||
result.data.extra["checkin_success"] = checkin_result.get("success")
|
||||
result.data.extra["checkin_message"] = checkin_result.get("message", "")
|
||||
_logger.debug("签到结果已附加到 extra: {}", checkin_result)
|
||||
|
||||
return result
|
||||
|
||||
async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult:
|
||||
"""
|
||||
执行余额查询
|
||||
|
||||
默认实现处理通用的请求/响应/错误处理流程。
|
||||
子类只需实现 _parse_balance() 即可。
|
||||
如果查询逻辑不同(如并发调用多个接口),子类可重写此方法。
|
||||
|
||||
Args:
|
||||
client: 已认证的 HTTP 客户端
|
||||
|
||||
Returns:
|
||||
ActionResult,其中 data 字段为 BalanceInfo
|
||||
"""
|
||||
endpoint = self.config.get("endpoint", "/api/user/self")
|
||||
method = self.config.get("method", "GET")
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
response = await client.request(method, endpoint)
|
||||
response_time_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
except Exception:
|
||||
return self._make_error_result(
|
||||
ActionStatus.PARSE_ERROR,
|
||||
"响应不是有效的 JSON",
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
return self._handle_http_error(response, data)
|
||||
|
||||
if data.get("success") is False:
|
||||
message = data.get("message", "业务状态码表示失败")
|
||||
return self._make_error_result(
|
||||
ActionStatus.UNKNOWN_ERROR,
|
||||
message,
|
||||
raw_response=data,
|
||||
)
|
||||
|
||||
balance = self._parse_balance(data)
|
||||
|
||||
return self._make_success_result(
|
||||
data=balance,
|
||||
response_time_ms=response_time_ms,
|
||||
raw_response=data,
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
return self._make_error_result(
|
||||
ActionStatus.NETWORK_ERROR,
|
||||
"请求超时",
|
||||
retry_after_seconds=30,
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
return self._make_error_result(
|
||||
ActionStatus.NETWORK_ERROR,
|
||||
f"网络错误: {str(e)}",
|
||||
retry_after_seconds=30,
|
||||
)
|
||||
except Exception as e:
|
||||
return self._make_error_result(
|
||||
ActionStatus.UNKNOWN_ERROR,
|
||||
f"未知错误: {str(e)}",
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
def _parse_balance(self, data: Any) -> BalanceInfo:
|
||||
"""
|
||||
解析余额数据(子类必须实现)
|
||||
|
||||
Args:
|
||||
data: API 响应 JSON 数据
|
||||
|
||||
Returns:
|
||||
BalanceInfo 对象
|
||||
"""
|
||||
pass
|
||||
|
||||
def _handle_http_error(
|
||||
self, response: httpx.Response, raw_data: dict[str, Any] | None = None
|
||||
) -> ActionResult:
|
||||
"""
|
||||
处理 HTTP 错误响应
|
||||
|
||||
Cookie 认证的子类设置 _cookie_auth = True 即可获得友好的错误提示,
|
||||
无需再逐个重写此方法。
|
||||
"""
|
||||
status_code = response.status_code
|
||||
|
||||
if status_code == 401:
|
||||
msg = "Cookie 已失效,请重新配置" if self._cookie_auth else "认证失败"
|
||||
return self._make_error_result(ActionStatus.AUTH_FAILED, msg, raw_response=raw_data)
|
||||
elif status_code == 403:
|
||||
msg = "Cookie 已失效或无权限" if self._cookie_auth else "无权限访问"
|
||||
return self._make_error_result(ActionStatus.AUTH_FAILED, msg, raw_response=raw_data)
|
||||
elif status_code == 404:
|
||||
return self._make_error_result(
|
||||
ActionStatus.NOT_SUPPORTED, "功能未开放", raw_response=raw_data
|
||||
)
|
||||
elif status_code == 429:
|
||||
retry_after = response.headers.get("Retry-After")
|
||||
return self._make_error_result(
|
||||
ActionStatus.RATE_LIMITED,
|
||||
"请求频率限制",
|
||||
retry_after_seconds=int(retry_after) if retry_after else 60,
|
||||
raw_response=raw_data,
|
||||
)
|
||||
else:
|
||||
return self._make_error_result(
|
||||
ActionStatus.UNKNOWN_ERROR,
|
||||
f"HTTP {status_code}: {response.reason_phrase}",
|
||||
raw_response=raw_data,
|
||||
)
|
||||
|
||||
async def _get_or_do_checkin(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
|
||||
"""
|
||||
带缓存冷却的签到:优先返回缓存结果,未命中才实际执行签到。
|
||||
|
||||
缓存 key 使用 host(同一站点多个 provider 只需签到一次),TTL 6 小时。
|
||||
签到失败或 cookie_expired 不写入缓存,允许下次重试。
|
||||
"""
|
||||
host = str(client.base_url.host or client.base_url.netloc or client.base_url)
|
||||
cache_key = f"provider_ops:checkin:{host}"
|
||||
|
||||
# 检查缓存
|
||||
try:
|
||||
cached = await CacheService.get(cache_key)
|
||||
if cached is not None:
|
||||
_logger.debug("[{}] 签到缓存命中,跳过签到: {}", host, cached)
|
||||
return cached
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 缓存未命中,执行签到
|
||||
result = await self._do_checkin(client)
|
||||
|
||||
# 签到成功或"已签到"时写入缓存;失败/cookie_expired 不缓存
|
||||
if result is not None and not result.get("cookie_expired"):
|
||||
success = result.get("success")
|
||||
if success is True or success is None:
|
||||
try:
|
||||
await CacheService.set(cache_key, result, _CHECKIN_CACHE_TTL)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
async def _do_checkin(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
|
||||
"""
|
||||
执行签到(子类可选实现)
|
||||
|
||||
默认实现返回 None(不签到)。
|
||||
子类可重写此方法实现平台特定的签到逻辑。
|
||||
|
||||
Args:
|
||||
client: 已认证的 HTTP 客户端
|
||||
|
||||
Returns:
|
||||
签到结果字典 {"success": bool, "message": str},或 None 表示不签到
|
||||
"""
|
||||
return None
|
||||
|
||||
def _create_balance_info(
|
||||
self,
|
||||
total_granted: float | None = None,
|
||||
total_used: float | None = None,
|
||||
total_available: float | None = None,
|
||||
currency: str = "USD",
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> BalanceInfo:
|
||||
"""
|
||||
创建余额信息对象
|
||||
|
||||
辅助方法,用于创建统一格式的 BalanceInfo。
|
||||
如果只有部分数据,会尝试计算缺失的值。
|
||||
|
||||
Args:
|
||||
total_granted: 总额度
|
||||
total_used: 已用额度
|
||||
total_available: 可用余额
|
||||
currency: 货币单位
|
||||
extra: 额外信息
|
||||
|
||||
Returns:
|
||||
BalanceInfo 对象
|
||||
"""
|
||||
# 如果只有部分数据,尝试计算
|
||||
if total_available is None and total_granted is not None and total_used is not None:
|
||||
total_available = total_granted - total_used
|
||||
if total_used is None and total_granted is not None and total_available is not None:
|
||||
total_used = total_granted - total_available
|
||||
if total_granted is None and total_used is not None and total_available is not None:
|
||||
total_granted = total_used + total_available
|
||||
|
||||
return BalanceInfo(
|
||||
total_granted=total_granted,
|
||||
total_used=total_used,
|
||||
total_available=total_available,
|
||||
currency=currency,
|
||||
extra=extra if extra is not None else {},
|
||||
)
|
||||
|
||||
def _to_float(self, value: Any) -> float | None:
|
||||
"""转换为浮点数"""
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> dict[str, Any]:
|
||||
"""获取操作配置 schema(子类可重写)"""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"title": "货币单位",
|
||||
"default": "USD",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
161
_deprecated_py_src/services/provider_ops/actions/base.py
Normal file
161
_deprecated_py_src/services/provider_ops/actions/base.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Provider 操作抽象基类
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.services.provider_ops.types import (
|
||||
ActionResult,
|
||||
ActionStatus,
|
||||
ProviderActionType,
|
||||
)
|
||||
|
||||
|
||||
class ProviderAction(ABC):
|
||||
"""
|
||||
提供商操作基类
|
||||
|
||||
定义具体的操作逻辑(如查询余额、签到等)。
|
||||
"""
|
||||
|
||||
# 子类需要定义的类属性
|
||||
action_type: ProviderActionType = ProviderActionType.CUSTOM
|
||||
display_name: str = "Base Action"
|
||||
description: str = ""
|
||||
|
||||
# 默认缓存时间(秒)
|
||||
default_cache_ttl: int = 300
|
||||
|
||||
def __init__(self, config: dict[str, Any] | None = None):
|
||||
"""
|
||||
初始化操作
|
||||
|
||||
Args:
|
||||
config: 操作配置
|
||||
"""
|
||||
self.config = config or {}
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, client: httpx.AsyncClient) -> ActionResult:
|
||||
"""
|
||||
执行操作
|
||||
|
||||
Args:
|
||||
client: 已认证的 HTTP 客户端
|
||||
|
||||
Returns:
|
||||
操作结果
|
||||
"""
|
||||
pass
|
||||
|
||||
def _extract_field(self, data: Any, path: str | None) -> Any:
|
||||
"""
|
||||
从响应数据中提取字段
|
||||
|
||||
支持点号分隔的路径,如 "data.user.balance"
|
||||
|
||||
Args:
|
||||
data: 响应数据
|
||||
path: 字段路径
|
||||
|
||||
Returns:
|
||||
提取的值,如果路径无效则返回 None
|
||||
"""
|
||||
if not path:
|
||||
return None
|
||||
|
||||
current = data
|
||||
for key in path.split("."):
|
||||
if isinstance(current, dict):
|
||||
current = current.get(key)
|
||||
elif isinstance(current, list) and key.isdigit():
|
||||
index = int(key)
|
||||
current = current[index] if 0 <= index < len(current) else None
|
||||
else:
|
||||
return None
|
||||
|
||||
if current is None:
|
||||
return None
|
||||
|
||||
return current
|
||||
|
||||
def _make_success_result(
|
||||
self,
|
||||
data: Any = None,
|
||||
message: str | None = None,
|
||||
response_time_ms: int | None = None,
|
||||
raw_response: dict[str, Any] | None = None,
|
||||
) -> ActionResult:
|
||||
"""创建成功结果"""
|
||||
return ActionResult(
|
||||
status=ActionStatus.SUCCESS,
|
||||
action_type=self.action_type,
|
||||
data=data,
|
||||
message=message,
|
||||
response_time_ms=response_time_ms,
|
||||
raw_response=raw_response,
|
||||
cache_ttl_seconds=self.default_cache_ttl,
|
||||
)
|
||||
|
||||
def _make_error_result(
|
||||
self,
|
||||
status: ActionStatus,
|
||||
message: str | None = None,
|
||||
retry_after_seconds: int | None = None,
|
||||
raw_response: dict[str, Any] | None = None,
|
||||
) -> ActionResult:
|
||||
"""创建错误结果"""
|
||||
return ActionResult(
|
||||
status=status,
|
||||
action_type=self.action_type,
|
||||
message=message,
|
||||
retry_after_seconds=retry_after_seconds,
|
||||
raw_response=raw_response,
|
||||
cache_ttl_seconds=0, # 错误不缓存
|
||||
)
|
||||
|
||||
def _handle_http_error(
|
||||
self, response: httpx.Response, raw_data: dict[str, Any] | None = None
|
||||
) -> ActionResult:
|
||||
"""处理 HTTP 错误响应"""
|
||||
status_code = response.status_code
|
||||
|
||||
if status_code == 401:
|
||||
return self._make_error_result(
|
||||
ActionStatus.AUTH_FAILED, "认证失败", raw_response=raw_data
|
||||
)
|
||||
elif status_code == 403:
|
||||
return self._make_error_result(
|
||||
ActionStatus.AUTH_FAILED, "无权限访问", raw_response=raw_data
|
||||
)
|
||||
elif status_code == 404:
|
||||
# 404 表示接口不存在,通常意味着该功能未开放
|
||||
return self._make_error_result(
|
||||
ActionStatus.NOT_SUPPORTED, "功能未开放", raw_response=raw_data
|
||||
)
|
||||
elif status_code == 429:
|
||||
retry_after = response.headers.get("Retry-After")
|
||||
return self._make_error_result(
|
||||
ActionStatus.RATE_LIMITED,
|
||||
"请求频率限制",
|
||||
retry_after_seconds=int(retry_after) if retry_after else 60,
|
||||
raw_response=raw_data,
|
||||
)
|
||||
else:
|
||||
return self._make_error_result(
|
||||
ActionStatus.UNKNOWN_ERROR,
|
||||
f"HTTP {status_code}: {response.reason_phrase}",
|
||||
raw_response=raw_data,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> dict[str, Any]:
|
||||
"""
|
||||
获取操作配置 JSON Schema(用于前端表单生成)
|
||||
|
||||
子类应重写此方法
|
||||
"""
|
||||
return {"type": "object", "properties": {}, "required": []}
|
||||
80
_deprecated_py_src/services/provider_ops/actions/checkin.py
Normal file
80
_deprecated_py_src/services/provider_ops/actions/checkin.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
签到操作抽象基类
|
||||
"""
|
||||
|
||||
from abc import abstractmethod
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.services.provider_ops.actions.base import ProviderAction
|
||||
from src.services.provider_ops.types import (
|
||||
ActionResult,
|
||||
CheckinInfo,
|
||||
ProviderActionType,
|
||||
)
|
||||
|
||||
|
||||
class CheckinAction(ProviderAction):
|
||||
"""
|
||||
签到操作抽象基类
|
||||
|
||||
子类必须实现 execute() 方法来处理特定平台的签到逻辑。
|
||||
"""
|
||||
|
||||
action_type = ProviderActionType.CHECKIN
|
||||
display_name = "签到"
|
||||
description = "每日签到领取额度"
|
||||
default_cache_ttl = 3600 # 签到结果缓存 1 小时
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, client: httpx.AsyncClient) -> ActionResult:
|
||||
"""
|
||||
执行签到
|
||||
|
||||
子类必须实现此方法。
|
||||
|
||||
Args:
|
||||
client: 已认证的 HTTP 客户端
|
||||
|
||||
Returns:
|
||||
ActionResult,其中 data 字段为 CheckinInfo
|
||||
"""
|
||||
pass
|
||||
|
||||
def _create_checkin_info(
|
||||
self,
|
||||
reward: float | None = None,
|
||||
streak_days: int | None = None,
|
||||
message: str | None = None,
|
||||
extra: dict[str, Any] | None = None,
|
||||
) -> CheckinInfo:
|
||||
"""
|
||||
创建签到信息对象
|
||||
|
||||
辅助方法,用于创建统一格式的 CheckinInfo。
|
||||
|
||||
Args:
|
||||
reward: 签到奖励额度
|
||||
streak_days: 连续签到天数
|
||||
message: 签到消息
|
||||
extra: 额外信息
|
||||
|
||||
Returns:
|
||||
CheckinInfo 对象
|
||||
"""
|
||||
return CheckinInfo(
|
||||
reward=reward,
|
||||
streak_days=streak_days,
|
||||
message=message,
|
||||
extra=extra if extra is not None else {},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> dict[str, Any]:
|
||||
"""获取操作配置 schema(子类可重写)"""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Cubence 余额查询操作
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.types import BalanceInfo
|
||||
|
||||
|
||||
class CubenceBalanceAction(BalanceAction):
|
||||
"""
|
||||
Cubence 专用余额查询
|
||||
|
||||
特点:
|
||||
- 余额单位直接是美元
|
||||
- 支持窗口限额查询(5小时/每周)
|
||||
- Cookie 失效时返回友好的错误提示
|
||||
"""
|
||||
|
||||
display_name = "查询余额(含窗口限额)"
|
||||
description = "查询账户余额和窗口限额信息"
|
||||
|
||||
_cookie_auth = True
|
||||
|
||||
def _parse_balance(self, data: Any) -> BalanceInfo:
|
||||
"""解析 Cubence 余额信息"""
|
||||
# Cubence 响应格式:data.balance 和 data.subscription_limits
|
||||
response_data = data.get("data", {}) if isinstance(data, dict) else {}
|
||||
balance_data = response_data.get("balance", {})
|
||||
subscription_limits = response_data.get("subscription_limits", {})
|
||||
|
||||
# 余额信息(单位直接是美元)
|
||||
total_available = balance_data.get("total_balance_dollar")
|
||||
normal_balance = balance_data.get("normal_balance_dollar")
|
||||
subscription_balance = balance_data.get("subscription_balance_dollar")
|
||||
charity_balance = balance_data.get("charity_balance_dollar")
|
||||
|
||||
# 窗口限额信息
|
||||
extra: dict[str, Any] = {}
|
||||
|
||||
# 5小时窗口限额
|
||||
five_hour = subscription_limits.get("five_hour", {})
|
||||
if five_hour:
|
||||
extra["five_hour_limit"] = {
|
||||
"limit": five_hour.get("limit"),
|
||||
"used": five_hour.get("used"),
|
||||
"remaining": five_hour.get("remaining"),
|
||||
"resets_at": five_hour.get("resets_at"),
|
||||
}
|
||||
|
||||
# 每周窗口限额
|
||||
weekly = subscription_limits.get("weekly", {})
|
||||
if weekly:
|
||||
extra["weekly_limit"] = {
|
||||
"limit": weekly.get("limit"),
|
||||
"used": weekly.get("used"),
|
||||
"remaining": weekly.get("remaining"),
|
||||
"resets_at": weekly.get("resets_at"),
|
||||
}
|
||||
|
||||
# 余额组成
|
||||
if normal_balance is not None:
|
||||
extra["normal_balance"] = normal_balance
|
||||
if subscription_balance is not None:
|
||||
extra["subscription_balance"] = subscription_balance
|
||||
if charity_balance is not None:
|
||||
extra["charity_balance"] = charity_balance
|
||||
|
||||
return self._create_balance_info(
|
||||
total_available=total_available,
|
||||
currency=self.config.get("currency", "USD"),
|
||||
extra=extra if extra else None,
|
||||
)
|
||||
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
NekoCode 余额查询操作
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.types import BalanceInfo
|
||||
|
||||
|
||||
class NekoCodeBalanceAction(BalanceAction):
|
||||
"""
|
||||
NekoCode 余额查询
|
||||
|
||||
特点:
|
||||
- 查询余额和订阅信息
|
||||
- 显示每日配额限制和剩余
|
||||
- 显示订阅状态和有效期
|
||||
- 余额单位为积分
|
||||
"""
|
||||
|
||||
display_name = "查询余额"
|
||||
description = "查询 NekoCode 账户余额和订阅信息"
|
||||
|
||||
_cookie_auth = True
|
||||
|
||||
def _parse_balance(self, data: Any) -> BalanceInfo:
|
||||
"""解析余额信息"""
|
||||
response_data = data.get("data", {}) if isinstance(data, dict) else {}
|
||||
subscription = response_data.get("subscription", {})
|
||||
|
||||
# 解析余额(积分)
|
||||
balance = self._to_float(response_data.get("balance"))
|
||||
|
||||
# 解析每日配额
|
||||
daily_quota_limit = self._to_float(subscription.get("daily_quota_limit"))
|
||||
daily_remaining_quota = self._to_float(subscription.get("daily_remaining_quota"))
|
||||
|
||||
# 计算每日已用配额
|
||||
daily_used = None
|
||||
if daily_quota_limit is not None and daily_remaining_quota is not None:
|
||||
daily_used = daily_quota_limit - daily_remaining_quota
|
||||
|
||||
# 解析订阅信息
|
||||
plan_name = subscription.get("plan_name")
|
||||
status = subscription.get("status")
|
||||
effective_start_date = subscription.get("effective_start_date")
|
||||
effective_end_date = subscription.get("effective_end_date")
|
||||
|
||||
# 解析日期
|
||||
expires_at = None
|
||||
refresh_at = None
|
||||
if effective_end_date:
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(effective_end_date)
|
||||
except ValueError as e:
|
||||
logger.debug(f"解析 effective_end_date 失败: {e}")
|
||||
|
||||
if effective_start_date:
|
||||
try:
|
||||
refresh_at = datetime.fromisoformat(effective_start_date)
|
||||
except ValueError as e:
|
||||
logger.debug(f"解析 effective_start_date 失败: {e}")
|
||||
|
||||
# 构建 extra 信息
|
||||
extra: dict[str, Any] = {
|
||||
"plan_name": plan_name,
|
||||
"subscription_status": status,
|
||||
"daily_quota_limit": daily_quota_limit,
|
||||
"daily_remaining_quota": daily_remaining_quota,
|
||||
"daily_used_quota": daily_used,
|
||||
"effective_start_date": effective_start_date,
|
||||
"effective_end_date": effective_end_date,
|
||||
}
|
||||
|
||||
# 添加刷新时间信息
|
||||
if refresh_at:
|
||||
extra["refresh_at"] = refresh_at.isoformat()
|
||||
extra["refresh_at_display"] = refresh_at.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# 添加月度统计
|
||||
month_data = response_data.get("month", {})
|
||||
if month_data:
|
||||
extra["month_stats"] = {
|
||||
"total_input_tokens": month_data.get("total_input_tokens"),
|
||||
"total_output_tokens": month_data.get("total_output_tokens"),
|
||||
"total_quota": month_data.get("total_quota"),
|
||||
"total_requests": month_data.get("total_requests"),
|
||||
}
|
||||
|
||||
# 添加今日统计
|
||||
today_data = response_data.get("today", {})
|
||||
if today_data:
|
||||
extra["today_stats"] = today_data.get("stats", [])
|
||||
|
||||
return self._create_balance_info(
|
||||
total_available=balance,
|
||||
total_granted=daily_quota_limit, # 每日配额作为总额度
|
||||
total_used=daily_used, # 每日已用
|
||||
currency="USD", # NekoCode 使用美元单位
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> dict[str, Any]:
|
||||
"""获取操作配置 schema"""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"endpoint": {
|
||||
"type": "string",
|
||||
"title": "API 端点",
|
||||
"default": "/api/usage/summary",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
New API 余额查询操作
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.core.logger import logger
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.types import BalanceInfo
|
||||
|
||||
|
||||
class NewApiBalanceAction(BalanceAction):
|
||||
"""
|
||||
New API 风格的余额查询
|
||||
|
||||
特点:
|
||||
- 使用 /api/user/self 端点
|
||||
- quota 单位是 1/500000 美元
|
||||
- 支持查询前自动签到(通过基类的模板方法)
|
||||
"""
|
||||
|
||||
display_name = "查询余额"
|
||||
description = "查询 New API 账户余额信息"
|
||||
|
||||
def _parse_balance(
|
||||
self,
|
||||
data: Any,
|
||||
) -> BalanceInfo:
|
||||
"""解析 New API 余额信息"""
|
||||
# New API 响应格式: {"success": true, "data": {...}}
|
||||
user_data = data.get("data", {}) if isinstance(data, dict) else {}
|
||||
|
||||
# 获取 quota 除数(默认 500000,New API 的标准)
|
||||
quota_divisor = self.config.get("quota_divisor", 500000)
|
||||
|
||||
# 提取原始值
|
||||
# 注意:New API 中 quota 是剩余额度(total_available),不是总额度
|
||||
raw_quota = self._to_float(user_data.get("quota"))
|
||||
raw_used = self._to_float(user_data.get("used_quota"))
|
||||
|
||||
# 转换为美元
|
||||
total_available = raw_quota / quota_divisor if raw_quota is not None else None
|
||||
total_used = raw_used / quota_divisor if raw_used is not None else None
|
||||
|
||||
return self._create_balance_info(
|
||||
total_available=total_available,
|
||||
total_used=total_used,
|
||||
currency=self.config.get("currency", "USD"),
|
||||
)
|
||||
|
||||
async def _do_checkin(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
|
||||
"""
|
||||
执行签到(静默,不抛出异常)
|
||||
|
||||
New API 签到通常需要认证(Cookie 或 API Key + New-Api-User)。
|
||||
失败时仅记录日志,不影响余额查询。
|
||||
|
||||
Returns:
|
||||
签到结果字典,包含 success 和 message 字段;
|
||||
如果功能未开放返回 None;
|
||||
如果 Cookie 失效返回 {"cookie_expired": True}
|
||||
"""
|
||||
site = client.base_url.host or str(client.base_url)
|
||||
checkin_endpoint = self.config.get("checkin_endpoint", "/api/user/checkin")
|
||||
|
||||
# 检查是否配置了 Cookie(通过 service 层注入的 _has_cookie 标志)
|
||||
has_cookie = self.config.get("_has_cookie", False)
|
||||
if not has_cookie:
|
||||
logger.debug(f"[{site}] 未配置 Cookie,尝试使用 API Key 认证签到")
|
||||
|
||||
try:
|
||||
response = await client.post(checkin_endpoint)
|
||||
|
||||
# 404 表示签到功能未开放
|
||||
if response.status_code == 404:
|
||||
logger.debug(f"[{site}] 签到功能未开放")
|
||||
return None
|
||||
|
||||
# 401/403 通常表示未授权;Cookie 模式下大多意味着 Cookie 已失效
|
||||
if response.status_code in (401, 403):
|
||||
# 只有在明确配置了 Cookie 的情况下,才标记为 Cookie 失效。
|
||||
# API Key 模式下的 401/403 更可能表示该站点不支持该认证方式签到。
|
||||
if has_cookie:
|
||||
logger.warning(f"[{site}] Cookie 已失效(签到返回 {response.status_code})")
|
||||
return {"cookie_expired": True, "message": "Cookie 已失效"}
|
||||
logger.debug(
|
||||
f"[{site}] 签到认证失败({response.status_code}),跳过签到结果上报"
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
data = response.json()
|
||||
message = data.get("message", "")
|
||||
success = data.get("success", False)
|
||||
message_lower = str(message).lower()
|
||||
|
||||
if success:
|
||||
logger.debug(f"[{site}] 签到成功: {message}")
|
||||
return {"success": True, "message": message or "签到成功"}
|
||||
else:
|
||||
# 检查是否是"已签到"的情况
|
||||
already_indicators = ["already", "已签到", "已经签到", "今日已签", "重复签到"]
|
||||
is_already = any(ind.lower() in message_lower for ind in already_indicators)
|
||||
if is_already:
|
||||
logger.debug(f"[{site}] 今日已签到: {message}")
|
||||
return {"success": None, "message": message or "今日已签到"}
|
||||
|
||||
# 检查是否是认证失败(未登录、无权限、验证码等)
|
||||
auth_fail_indicators = [
|
||||
"未登录",
|
||||
"请登录",
|
||||
"login",
|
||||
"unauthorized",
|
||||
"无权限",
|
||||
"权限不足",
|
||||
"turnstile",
|
||||
"captcha",
|
||||
"验证码", # 需要人机验证
|
||||
]
|
||||
is_auth_fail = any(ind.lower() in message_lower for ind in auth_fail_indicators)
|
||||
if is_auth_fail:
|
||||
# Cookie 模式下,这类提示通常意味着 Cookie 已失效或需要重新登录。
|
||||
if has_cookie:
|
||||
logger.warning(f"[{site}] Cookie 已失效(签到认证失败): {message}")
|
||||
return {"cookie_expired": True, "message": message or "Cookie 已失效"}
|
||||
|
||||
# API Key 模式下,站点可能不支持该认证方式签到;保持静默不影响余额查询。
|
||||
logger.debug(f"[{site}] 签到认证失败(API Key 模式),跳过签到: {message}")
|
||||
return None
|
||||
|
||||
# 其他失败情况
|
||||
logger.debug(f"[{site}] 签到失败: {message}")
|
||||
return {"success": False, "message": message or "签到失败"}
|
||||
except Exception as e:
|
||||
logger.debug(f"[{site}] 签到响应解析失败: {e}")
|
||||
return {"success": False, "message": "响应解析失败"}
|
||||
|
||||
except Exception as e:
|
||||
# 签到失败不影响余额查询
|
||||
logger.debug(f"[{site}] 签到请求失败(不影响余额查询): {e}")
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> dict[str, Any]:
|
||||
"""获取操作配置 schema"""
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"endpoint": {
|
||||
"type": "string",
|
||||
"title": "API 路径",
|
||||
"description": "余额查询 API 路径",
|
||||
"default": "/api/user/self",
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"title": "请求方法",
|
||||
"enum": ["GET", "POST"],
|
||||
"default": "GET",
|
||||
},
|
||||
"quota_divisor": {
|
||||
"type": "number",
|
||||
"title": "额度除数",
|
||||
"description": "将原始额度值转换为美元的除数",
|
||||
"default": 500000,
|
||||
},
|
||||
"currency": {
|
||||
"type": "string",
|
||||
"title": "货币单位",
|
||||
"default": "USD",
|
||||
},
|
||||
},
|
||||
"required": [],
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Sub2API 余额查询操作
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.types import ActionResult, ActionStatus, BalanceInfo
|
||||
|
||||
|
||||
class Sub2ApiBalanceAction(BalanceAction):
|
||||
"""
|
||||
Sub2API 余额查询
|
||||
|
||||
特点:
|
||||
- 并发调用 /api/v1/auth/me 和 /api/v1/subscriptions/summary
|
||||
- auth/me 提供基础余额(balance + points)
|
||||
- subscriptions/summary 提供订阅详情(各订阅的日/周/月用量和额度)
|
||||
- 响应格式: {"code": 0, "message": "success", "data": {...}}
|
||||
"""
|
||||
|
||||
display_name = "查询余额"
|
||||
description = "查询 Sub2API 账户余额和订阅信息"
|
||||
|
||||
def _parse_balance(self, data: Any) -> BalanceInfo:
|
||||
"""本类完全重写了 _do_query_balance,绕过基类默认流程,故此方法不会被调用"""
|
||||
raise NotImplementedError(
|
||||
"Sub2API 重写了 _do_query_balance,不走基类的 _parse_balance 路径"
|
||||
)
|
||||
|
||||
async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult:
|
||||
"""并发查询 auth/me 和 subscriptions/summary"""
|
||||
start_time = time.time()
|
||||
|
||||
me_endpoint = self.config.get("endpoint", "/api/v1/auth/me?timezone=Asia/Shanghai")
|
||||
sub_endpoint = self.config.get("subscription_endpoint", "/api/v1/subscriptions/summary")
|
||||
|
||||
try:
|
||||
me_resp, sub_resp = cast(
|
||||
tuple[httpx.Response | BaseException, httpx.Response | BaseException],
|
||||
await asyncio.gather(
|
||||
client.get(me_endpoint),
|
||||
client.get(sub_endpoint),
|
||||
return_exceptions=True,
|
||||
),
|
||||
)
|
||||
response_time_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
# 解析 auth/me
|
||||
me_data: dict[str, Any] = {}
|
||||
me_ok = False
|
||||
if isinstance(me_resp, httpx.Response):
|
||||
if me_resp.status_code in (401, 403):
|
||||
return self._make_error_result(
|
||||
ActionStatus.AUTH_FAILED, "认证失败,请检查凭据配置"
|
||||
)
|
||||
if me_resp.status_code == 200:
|
||||
try:
|
||||
me_json = me_resp.json()
|
||||
if me_json.get("code") == 0:
|
||||
me_data = me_json.get("data", {})
|
||||
me_ok = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not me_ok:
|
||||
return self._make_error_result(ActionStatus.UNKNOWN_ERROR, "查询用户信息失败")
|
||||
|
||||
# 基础余额
|
||||
balance = self._to_float(me_data.get("balance")) or 0.0
|
||||
points = self._to_float(me_data.get("points")) or 0.0
|
||||
total_available = balance + points
|
||||
|
||||
extra: dict[str, Any] = {
|
||||
"balance": balance,
|
||||
"points": points,
|
||||
}
|
||||
|
||||
# 解析 subscriptions/summary(可选,失败不影响主流程)
|
||||
if isinstance(sub_resp, httpx.Response) and sub_resp.status_code == 200:
|
||||
try:
|
||||
sub_json = sub_resp.json()
|
||||
if sub_json.get("code") == 0:
|
||||
summary = sub_json.get("data", {})
|
||||
extra["active_subscriptions"] = summary.get("active_count", 0)
|
||||
extra["total_used_usd"] = summary.get("total_used_usd", 0)
|
||||
extra["subscriptions"] = self._parse_subscriptions(
|
||||
summary.get("subscriptions", [])
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
balance_info = self._create_balance_info(
|
||||
total_available=total_available,
|
||||
currency="USD",
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
return self._make_success_result(
|
||||
data=balance_info,
|
||||
response_time_ms=response_time_ms,
|
||||
raw_response={"me": me_data},
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
return self._make_error_result(
|
||||
ActionStatus.NETWORK_ERROR, "请求超时", retry_after_seconds=30
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
return self._make_error_result(
|
||||
ActionStatus.NETWORK_ERROR, f"网络错误: {e}", retry_after_seconds=30
|
||||
)
|
||||
except Exception as e:
|
||||
return self._make_error_result(ActionStatus.UNKNOWN_ERROR, f"未知错误: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _parse_subscriptions(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""将订阅列表精简为前端需要的字段"""
|
||||
result = []
|
||||
for item in items:
|
||||
sub: dict[str, Any] = {
|
||||
"group_name": item.get("group_name", ""),
|
||||
"status": item.get("status", ""),
|
||||
}
|
||||
# 只保留非 None 的限额字段(0 是有效值,表示未使用/无限额)
|
||||
for field in (
|
||||
"daily_used_usd",
|
||||
"daily_limit_usd",
|
||||
"weekly_used_usd",
|
||||
"weekly_limit_usd",
|
||||
"monthly_used_usd",
|
||||
"monthly_limit_usd",
|
||||
):
|
||||
val = item.get(field)
|
||||
if val is not None:
|
||||
sub[field] = val
|
||||
expires_at = item.get("expires_at")
|
||||
if expires_at:
|
||||
sub["expires_at"] = expires_at
|
||||
result.append(sub)
|
||||
return result
|
||||
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
YesCode 余额查询操作
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, cast
|
||||
|
||||
import httpx
|
||||
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.types import ActionResult, ActionStatus, BalanceInfo
|
||||
|
||||
|
||||
async def fetch_yescode_combined_data(
|
||||
client: httpx.AsyncClient,
|
||||
base_url: str,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
获取 YesCode 合并数据(balance + profile)
|
||||
|
||||
使用传入的 client 并发调用两个接口:
|
||||
- /api/v1/user/balance: 准确的 weekly_spent_balance
|
||||
- /api/v1/auth/profile: 用户信息、重置时间
|
||||
|
||||
Args:
|
||||
client: 已配置好认证的 HTTP 客户端
|
||||
base_url: API 基础地址
|
||||
|
||||
Returns:
|
||||
合并后的数据字典
|
||||
"""
|
||||
base_url = base_url.rstrip("/")
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
# 并发调用两个接口
|
||||
balance_task = client.get(f"{base_url}/api/v1/user/balance")
|
||||
profile_task = client.get(f"{base_url}/api/v1/auth/profile")
|
||||
|
||||
balance_resp, profile_resp = cast(
|
||||
tuple[httpx.Response | BaseException, httpx.Response | BaseException],
|
||||
await asyncio.gather(balance_task, profile_task, return_exceptions=True),
|
||||
)
|
||||
|
||||
# 解析 balance 接口
|
||||
balance_data: dict[str, Any] = {}
|
||||
if isinstance(balance_resp, httpx.Response) and balance_resp.status_code == 200:
|
||||
try:
|
||||
balance_data = balance_resp.json()
|
||||
result["_balance_data"] = balance_data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 解析 profile 接口
|
||||
profile_data: dict[str, Any] = {}
|
||||
if isinstance(profile_resp, httpx.Response) and profile_resp.status_code == 200:
|
||||
try:
|
||||
profile_data = profile_resp.json()
|
||||
result["_profile_data"] = profile_data
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 合并数据:balance 接口优先(更准确的余额数据)
|
||||
result["pay_as_you_go_balance"] = balance_data.get(
|
||||
"pay_as_you_go_balance", profile_data.get("pay_as_you_go_balance", 0)
|
||||
)
|
||||
result["subscription_balance"] = balance_data.get(
|
||||
"subscription_balance", profile_data.get("subscription_balance", 0)
|
||||
)
|
||||
result["weekly_limit"] = balance_data.get("weekly_limit") or (
|
||||
profile_data.get("subscription_plan") or {}
|
||||
).get("weekly_limit")
|
||||
result["weekly_spent_balance"] = balance_data.get(
|
||||
"weekly_spent_balance", profile_data.get("current_week_spend", 0)
|
||||
)
|
||||
|
||||
# 用户信息(仅 profile 有)
|
||||
result["username"] = profile_data.get("username")
|
||||
result["email"] = profile_data.get("email")
|
||||
|
||||
# 重置时间(仅 profile 有)
|
||||
result["last_week_reset"] = profile_data.get("last_week_reset")
|
||||
result["last_daily_balance_add"] = profile_data.get("last_daily_balance_add")
|
||||
|
||||
# subscription_plan(仅 profile 有)
|
||||
result["subscription_plan"] = profile_data.get("subscription_plan")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_yescode_balance_extra(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
解析 YesCode 余额额外信息
|
||||
|
||||
Args:
|
||||
data: 合并后的数据(来自 fetch_yescode_combined_data 或单独接口)
|
||||
|
||||
Returns:
|
||||
统一格式的 extra 字典
|
||||
"""
|
||||
extra: dict[str, Any] = {}
|
||||
|
||||
pay_as_you_go = data.get("pay_as_you_go_balance", 0)
|
||||
subscription = data.get("subscription_balance", 0)
|
||||
|
||||
extra["pay_as_you_go_balance"] = pay_as_you_go
|
||||
|
||||
# 每日额度上限
|
||||
plan = data.get("subscription_plan") or {}
|
||||
daily_balance = plan.get("daily_balance", subscription)
|
||||
|
||||
# 周限额
|
||||
weekly_limit = data.get("weekly_limit") or plan.get("weekly_limit")
|
||||
weekly_spent = data.get("weekly_spent_balance", 0)
|
||||
|
||||
# 映射为统一字段
|
||||
extra["daily_limit"] = daily_balance
|
||||
if weekly_limit is not None:
|
||||
extra["weekly_limit"] = weekly_limit
|
||||
extra["weekly_spent"] = weekly_spent
|
||||
|
||||
# 计算重置时间
|
||||
last_week_reset = data.get("last_week_reset")
|
||||
if last_week_reset:
|
||||
try:
|
||||
if isinstance(last_week_reset, str):
|
||||
reset_dt = datetime.fromisoformat(last_week_reset.replace("Z", "+00:00"))
|
||||
next_reset = reset_dt + timedelta(days=7)
|
||||
extra["weekly_resets_at"] = int(next_reset.timestamp())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
last_daily_add = data.get("last_daily_balance_add")
|
||||
if last_daily_add:
|
||||
try:
|
||||
if isinstance(last_daily_add, str):
|
||||
add_dt = datetime.fromisoformat(last_daily_add.replace("Z", "+00:00"))
|
||||
next_daily = add_dt + timedelta(days=1)
|
||||
extra["daily_resets_at"] = int(next_daily.timestamp())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 计算实际可用余额
|
||||
if weekly_limit is not None:
|
||||
weekly_remaining = max(0, weekly_limit - weekly_spent)
|
||||
subscription_available = min(subscription, weekly_remaining)
|
||||
extra["daily_spent"] = daily_balance - min(daily_balance, subscription_available)
|
||||
else:
|
||||
subscription_available = subscription
|
||||
extra["daily_spent"] = max(0, daily_balance - subscription)
|
||||
|
||||
extra["_subscription_available"] = subscription_available
|
||||
extra["_total_available"] = pay_as_you_go + subscription_available
|
||||
|
||||
return extra
|
||||
|
||||
|
||||
class YesCodeBalanceAction(BalanceAction):
|
||||
"""
|
||||
YesCode 专用余额查询
|
||||
|
||||
特点:
|
||||
- 余额单位直接是美元
|
||||
- 支持每周限额查询
|
||||
- 同时调用 balance 和 profile 接口获取完整数据
|
||||
- Cookie 失效时返回友好的错误提示
|
||||
"""
|
||||
|
||||
display_name = "查询余额(含每周限额)"
|
||||
description = "查询账户余额和每周限额信息"
|
||||
|
||||
_cookie_auth = True
|
||||
|
||||
def _parse_balance(self, data: Any) -> BalanceInfo:
|
||||
"""YesCode 不使用基类的 _do_query_balance,此方法不会被调用"""
|
||||
raise NotImplementedError("YesCode 使用自定义 _do_query_balance")
|
||||
|
||||
async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult:
|
||||
"""执行余额查询(实现抽象方法,复用 client 调用两个接口获取完整数据)"""
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
base_url = str(client.base_url).rstrip("/")
|
||||
|
||||
try:
|
||||
# 复用传入的 client 获取合并数据
|
||||
combined_data = await fetch_yescode_combined_data(client, base_url)
|
||||
response_time_ms = int((time.time() - start_time) * 1000)
|
||||
|
||||
# 检查是否至少有一个接口成功
|
||||
if "_balance_data" not in combined_data and "_profile_data" not in combined_data:
|
||||
return self._make_error_result(
|
||||
ActionStatus.AUTH_FAILED,
|
||||
"Cookie 已失效,请重新配置",
|
||||
)
|
||||
|
||||
# 使用公共函数解析余额
|
||||
extra = parse_yescode_balance_extra(combined_data)
|
||||
|
||||
total_available = extra.pop("_total_available", 0)
|
||||
extra.pop("_subscription_available", None)
|
||||
|
||||
balance = BalanceInfo(
|
||||
total_granted=None,
|
||||
total_used=None,
|
||||
total_available=total_available,
|
||||
currency=self.config.get("currency", "USD"),
|
||||
extra=extra if extra else {},
|
||||
)
|
||||
|
||||
return self._make_success_result(
|
||||
data=balance,
|
||||
response_time_ms=response_time_ms,
|
||||
raw_response=combined_data,
|
||||
)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
return self._make_error_result(
|
||||
ActionStatus.NETWORK_ERROR,
|
||||
"请求超时",
|
||||
retry_after_seconds=30,
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
return self._make_error_result(
|
||||
ActionStatus.NETWORK_ERROR,
|
||||
f"网络错误: {str(e)}",
|
||||
retry_after_seconds=30,
|
||||
)
|
||||
except Exception as e:
|
||||
return self._make_error_result(
|
||||
ActionStatus.UNKNOWN_ERROR,
|
||||
f"未知错误: {str(e)}",
|
||||
)
|
||||
Reference in New Issue
Block a user