2026-01-17 19:50:35 +08:00
|
|
|
|
"""
|
2026-01-20 00:55:52 +08:00
|
|
|
|
余额查询操作抽象基类
|
2026-01-17 19:50:35 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
2026-02-13 16:41:30 +08:00
|
|
|
|
import time
|
2026-01-20 00:55:52 +08:00
|
|
|
|
from abc import abstractmethod
|
2026-01-30 03:10:21 +08:00
|
|
|
|
from typing import Any
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
|
|
|
|
|
import httpx
|
|
|
|
|
|
|
2026-03-08 02:29:51 +08:00
|
|
|
|
from src.core.cache_service import CacheService
|
|
|
|
|
|
from src.core.logger import logger as _logger
|
2026-01-17 19:50:35 +08:00
|
|
|
|
from src.services.provider_ops.actions.base import ProviderAction
|
|
|
|
|
|
from src.services.provider_ops.types import (
|
|
|
|
|
|
ActionResult,
|
2026-01-20 02:44:22 +08:00
|
|
|
|
ActionStatus,
|
2026-01-17 19:50:35 +08:00
|
|
|
|
BalanceInfo,
|
|
|
|
|
|
ProviderActionType,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-08 02:29:51 +08:00
|
|
|
|
# 签到缓存 TTL(6 小时)
|
|
|
|
|
|
_CHECKIN_CACHE_TTL = 6 * 3600
|
|
|
|
|
|
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
|
|
|
|
|
class BalanceAction(ProviderAction):
|
|
|
|
|
|
"""
|
2026-01-20 00:55:52 +08:00
|
|
|
|
余额查询操作抽象基类
|
2026-01-17 19:50:35 +08:00
|
|
|
|
|
2026-02-13 16:41:30 +08:00
|
|
|
|
子类必须实现 _parse_balance() 方法来处理特定平台的余额解析逻辑。
|
|
|
|
|
|
子类可选重写 _do_query_balance() 或 _do_checkin() 进行自定义。
|
|
|
|
|
|
|
|
|
|
|
|
如果子类使用 Cookie 认证,设置 _cookie_auth = True 可让 401/403 错误
|
|
|
|
|
|
显示 "Cookie 已失效" 而非 "认证失败"。
|
2026-01-17 19:50:35 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
action_type = ProviderActionType.QUERY_BALANCE
|
|
|
|
|
|
display_name = "查询余额"
|
|
|
|
|
|
description = "查询账户余额信息"
|
|
|
|
|
|
default_cache_ttl = 86400 # 24 小时
|
|
|
|
|
|
|
2026-02-13 16:41:30 +08:00
|
|
|
|
# 子类设为 True 即可在 401/403 时显示 "Cookie 已失效" 消息
|
|
|
|
|
|
_cookie_auth: bool = False
|
|
|
|
|
|
|
2026-01-17 19:50:35 +08:00
|
|
|
|
async def execute(self, client: httpx.AsyncClient) -> ActionResult:
|
2026-01-20 00:55:52 +08:00
|
|
|
|
"""
|
|
|
|
|
|
执行余额查询(模板方法)
|
|
|
|
|
|
|
2026-03-08 02:29:51 +08:00
|
|
|
|
1. 先尝试签到(如果子类实现了 _do_checkin),带缓存冷却
|
2026-01-20 00:55:52 +08:00
|
|
|
|
2. 执行余额查询
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
client: 已认证的 HTTP 客户端
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
ActionResult,其中 data 字段为 BalanceInfo
|
|
|
|
|
|
"""
|
2026-03-08 02:29:51 +08:00
|
|
|
|
# 先尝试签到(带缓存冷却)
|
|
|
|
|
|
checkin_result = await self._get_or_do_checkin(client)
|
2026-01-20 00:55:52 +08:00
|
|
|
|
|
|
|
|
|
|
# 执行余额查询
|
|
|
|
|
|
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 = {}
|
2026-01-20 02:44:22 +08:00
|
|
|
|
# 处理 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
|
2026-03-08 02:29:51 +08:00
|
|
|
|
_logger.warning("Cookie 已失效: {}", checkin_result)
|
2026-01-20 02:44:22 +08:00
|
|
|
|
else:
|
|
|
|
|
|
result.data.extra["checkin_success"] = checkin_result.get("success")
|
|
|
|
|
|
result.data.extra["checkin_message"] = checkin_result.get("message", "")
|
2026-03-08 02:29:51 +08:00
|
|
|
|
_logger.debug("签到结果已附加到 extra: {}", checkin_result)
|
2026-01-20 00:55:52 +08:00
|
|
|
|
|
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
async def _do_query_balance(self, client: httpx.AsyncClient) -> ActionResult:
|
|
|
|
|
|
"""
|
2026-02-13 16:41:30 +08:00
|
|
|
|
执行余额查询
|
|
|
|
|
|
|
|
|
|
|
|
默认实现处理通用的请求/响应/错误处理流程。
|
|
|
|
|
|
子类只需实现 _parse_balance() 即可。
|
|
|
|
|
|
如果查询逻辑不同(如并发调用多个接口),子类可重写此方法。
|
2026-01-20 00:55:52 +08:00
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
client: 已认证的 HTTP 客户端
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
ActionResult,其中 data 字段为 BalanceInfo
|
|
|
|
|
|
"""
|
2026-02-13 16:41:30 +08:00
|
|
|
|
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 对象
|
|
|
|
|
|
"""
|
2026-01-20 00:55:52 +08:00
|
|
|
|
pass
|
|
|
|
|
|
|
2026-02-13 16:41:30 +08:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-08 02:29:51 +08:00
|
|
|
|
async def _get_or_do_checkin(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
|
|
|
|
|
|
"""
|
|
|
|
|
|
带缓存冷却的签到:优先返回缓存结果,未命中才实际执行签到。
|
|
|
|
|
|
|
|
|
|
|
|
缓存 key 使用 host(同一站点多个 provider 只需签到一次),TTL 6 小时。
|
|
|
|
|
|
签到失败或 cookie_expired 不写入缓存,允许下次重试。
|
|
|
|
|
|
"""
|
2026-03-09 18:26:53 +08:00
|
|
|
|
host = str(client.base_url.host or client.base_url.netloc or client.base_url)
|
2026-03-08 02:29:51 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-01-30 03:10:21 +08:00
|
|
|
|
async def _do_checkin(self, client: httpx.AsyncClient) -> dict[str, Any] | None:
|
2026-01-20 00:55:52 +08:00
|
|
|
|
"""
|
|
|
|
|
|
执行签到(子类可选实现)
|
|
|
|
|
|
|
|
|
|
|
|
默认实现返回 None(不签到)。
|
|
|
|
|
|
子类可重写此方法实现平台特定的签到逻辑。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
client: 已认证的 HTTP 客户端
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
签到结果字典 {"success": bool, "message": str},或 None 表示不签到
|
|
|
|
|
|
"""
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def _create_balance_info(
|
|
|
|
|
|
self,
|
2026-01-30 03:10:21 +08:00
|
|
|
|
total_granted: float | None = None,
|
|
|
|
|
|
total_used: float | None = None,
|
|
|
|
|
|
total_available: float | None = None,
|
2026-01-20 00:55:52 +08:00
|
|
|
|
currency: str = "USD",
|
2026-01-30 03:10:21 +08:00
|
|
|
|
extra: dict[str, Any] | None = None,
|
2026-01-20 00:55:52 +08:00
|
|
|
|
) -> BalanceInfo:
|
|
|
|
|
|
"""
|
|
|
|
|
|
创建余额信息对象
|
|
|
|
|
|
|
|
|
|
|
|
辅助方法,用于创建统一格式的 BalanceInfo。
|
|
|
|
|
|
如果只有部分数据,会尝试计算缺失的值。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
total_granted: 总额度
|
|
|
|
|
|
total_used: 已用额度
|
|
|
|
|
|
total_available: 可用余额
|
|
|
|
|
|
currency: 货币单位
|
|
|
|
|
|
extra: 额外信息
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
BalanceInfo 对象
|
|
|
|
|
|
"""
|
2026-01-17 19:50:35 +08:00
|
|
|
|
# 如果只有部分数据,尝试计算
|
|
|
|
|
|
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,
|
2026-01-20 00:55:52 +08:00
|
|
|
|
currency=currency,
|
|
|
|
|
|
extra=extra if extra is not None else {},
|
2026-01-17 19:50:35 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def _to_float(self, value: Any) -> float | None:
|
2026-01-17 19:50:35 +08:00
|
|
|
|
"""转换为浮点数"""
|
|
|
|
|
|
if value is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
return float(value)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
@classmethod
|
2026-01-30 03:10:21 +08:00
|
|
|
|
def get_config_schema(cls) -> dict[str, Any]:
|
2026-01-20 00:55:52 +08:00
|
|
|
|
"""获取操作配置 schema(子类可重写)"""
|
2026-01-17 19:50:35 +08:00
|
|
|
|
return {
|
|
|
|
|
|
"type": "object",
|
|
|
|
|
|
"properties": {
|
|
|
|
|
|
"currency": {
|
|
|
|
|
|
"type": "string",
|
|
|
|
|
|
"title": "货币单位",
|
|
|
|
|
|
"default": "USD",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-01-20 00:55:52 +08:00
|
|
|
|
"required": [],
|
2026-01-17 19:50:35 +08:00
|
|
|
|
}
|