mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-01 17:00:21 +08:00
feat(provider-ops): 新增 Sub2API 架构支持并优化余额分项显示
- 新增 Sub2ApiArchitecture 和 Sub2ApiBalanceAction,支持 Sub2API 风格中转站 - 前端 provider 列表支持 balance + points 分项显示 - 验证弹窗适配 Sub2API 余额和积分的分开展示
This commit is contained in:
@@ -493,7 +493,12 @@ async function handleVerify() {
|
||||
verifyStatus.value = 'success'
|
||||
formChanged.value = false
|
||||
const displayName = result.data?.display_name || result.data?.username
|
||||
showSuccess(`用户: ${displayName} | 余额: ${formatQuota(quota)}`, '验证成功')
|
||||
const extra = result.data?.extra
|
||||
let balanceText = `余额: ${formatQuota(quota)}`
|
||||
if (extra && extra.balance !== undefined && extra.points !== undefined) {
|
||||
balanceText = `余额: ${formatQuota(extra.balance)} | 积分: ${formatQuota(extra.points)}`
|
||||
}
|
||||
showSuccess(`用户: ${displayName} | ${balanceText}`, '验证成功')
|
||||
}
|
||||
} else {
|
||||
verifyStatus.value = 'error'
|
||||
|
||||
@@ -216,10 +216,26 @@
|
||||
v-else-if="provider.ops_configured && getProviderBalance(provider.id)"
|
||||
class="flex items-center gap-2 text-xs"
|
||||
>
|
||||
<!-- 余额文字 -->
|
||||
<span class="font-semibold text-foreground/90 min-w-[4.5rem] tabular-nums">
|
||||
{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}
|
||||
</span>
|
||||
<!-- 余额文字:balance + points 分开显示,或普通余额 -->
|
||||
<template v-for="bd in [getProviderBalanceBreakdown(provider.id)]" :key="'bd'">
|
||||
<div
|
||||
v-if="bd"
|
||||
class="min-w-[4.5rem] tabular-nums leading-tight"
|
||||
>
|
||||
<div class="font-semibold text-foreground/90">
|
||||
${{ bd.balance.toFixed(2) }}
|
||||
</div>
|
||||
<div class="text-muted-foreground/70 text-[10px]">
|
||||
${{ bd.points.toFixed(2) }}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-else
|
||||
class="font-semibold text-foreground/90 min-w-[4.5rem] tabular-nums"
|
||||
>
|
||||
{{ formatBalanceDisplay(getProviderBalance(provider.id)) }}
|
||||
</span>
|
||||
</template>
|
||||
<!-- 窗口限额 + 签到状态 + Cookie 失效警告 -->
|
||||
<div
|
||||
v-if="getProviderBalanceExtra(provider.id, provider.ops_architecture_id).length > 0 || getProviderCheckin(provider.id) || getProviderCookieExpired(provider.id)"
|
||||
@@ -1016,6 +1032,24 @@ function getProviderBalance(providerId: string): { available: number | null; cur
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 provider 余额明细(balance + points 分开显示)
|
||||
function getProviderBalanceBreakdown(providerId: string): { balance: number; points: number; currency: string } | null {
|
||||
const result = balanceCache.value[providerId]
|
||||
if (!result || (result.status !== 'success' && result.status !== 'auth_expired') || !result.data) {
|
||||
return null
|
||||
}
|
||||
const data = result.data as Record<string, any>
|
||||
const extra = data.extra
|
||||
if (!extra || extra.balance === undefined || extra.points === undefined) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
balance: extra.balance,
|
||||
points: extra.points,
|
||||
currency: data.currency || 'USD',
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 provider 余额查询的错误状态
|
||||
function getProviderBalanceError(providerId: string): { status: string; message: string } | null {
|
||||
const result = balanceCache.value[providerId]
|
||||
|
||||
@@ -9,6 +9,7 @@ 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__ = [
|
||||
@@ -19,5 +20,6 @@ __all__ = [
|
||||
"AnyrouterBalanceAction",
|
||||
"CubenceBalanceAction",
|
||||
"NekoCodeBalanceAction",
|
||||
"Sub2ApiBalanceAction",
|
||||
"YesCodeBalanceAction",
|
||||
]
|
||||
|
||||
45
src/services/provider_ops/actions/sub2api_balance.py
Normal file
45
src/services/provider_ops/actions/sub2api_balance.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Sub2API 余额查询操作
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from src.services.provider_ops.actions.balance import BalanceAction
|
||||
from src.services.provider_ops.types import BalanceInfo
|
||||
|
||||
|
||||
class Sub2ApiBalanceAction(BalanceAction):
|
||||
"""
|
||||
Sub2API 余额查询
|
||||
|
||||
特点:
|
||||
- 使用 /api/v1/auth/me 端点
|
||||
- balance 为充值余额,points 为赠送余额,均以美元为单位
|
||||
- 响应格式: {"code": 0, "message": "success", "data": {...}}
|
||||
"""
|
||||
|
||||
display_name = "查询余额"
|
||||
description = "查询 Sub2API 账户余额信息"
|
||||
|
||||
def _parse_balance(self, data: Any) -> BalanceInfo:
|
||||
"""解析 Sub2API 余额信息"""
|
||||
# Sub2API 使用 {"code": 0, ...} 表示成功,非 0 表示业务错误
|
||||
if isinstance(data, dict) and data.get("code") is not None and data.get("code") != 0:
|
||||
message = data.get("message", "查询失败")
|
||||
raise ValueError(f"Sub2API 业务错误: {message}")
|
||||
|
||||
user_data = data.get("data", {}) if isinstance(data, dict) else {}
|
||||
|
||||
balance = self._to_float(user_data.get("balance")) or 0.0
|
||||
points = self._to_float(user_data.get("points")) or 0.0
|
||||
|
||||
total_available = balance + points
|
||||
|
||||
return self._create_balance_info(
|
||||
total_available=total_available,
|
||||
currency="USD",
|
||||
extra={
|
||||
"balance": balance,
|
||||
"points": points,
|
||||
},
|
||||
)
|
||||
@@ -12,6 +12,7 @@ from src.services.provider_ops.architectures.cubence import CubenceArchitecture
|
||||
from src.services.provider_ops.architectures.generic_api import GenericApiArchitecture
|
||||
from src.services.provider_ops.architectures.nekocode import NekoCodeArchitecture
|
||||
from src.services.provider_ops.architectures.new_api import NewApiArchitecture
|
||||
from src.services.provider_ops.architectures.sub2api import Sub2ApiArchitecture
|
||||
from src.services.provider_ops.architectures.yescode import YesCodeArchitecture
|
||||
|
||||
__all__ = [
|
||||
@@ -23,5 +24,6 @@ __all__ = [
|
||||
"GenericApiArchitecture",
|
||||
"NekoCodeArchitecture",
|
||||
"NewApiArchitecture",
|
||||
"Sub2ApiArchitecture",
|
||||
"YesCodeArchitecture",
|
||||
]
|
||||
|
||||
185
src/services/provider_ops/architectures/sub2api.py
Normal file
185
src/services/provider_ops/architectures/sub2api.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
Sub2API 架构
|
||||
|
||||
针对 Sub2API 风格的中转站优化的预设配置。
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from src.services.provider_ops.actions import ProviderAction
|
||||
from src.services.provider_ops.actions.sub2api_balance import Sub2ApiBalanceAction
|
||||
from src.services.provider_ops.architectures.base import (
|
||||
ProviderArchitecture,
|
||||
ProviderConnector,
|
||||
VerifyResult,
|
||||
)
|
||||
from src.services.provider_ops.types import ConnectorAuthType, ProviderActionType
|
||||
|
||||
|
||||
class Sub2ApiConnector(ProviderConnector):
|
||||
"""
|
||||
Sub2API 连接器
|
||||
|
||||
使用 Bearer Token 认证。
|
||||
"""
|
||||
|
||||
auth_type = ConnectorAuthType.API_KEY
|
||||
display_name = "Sub2API Key"
|
||||
|
||||
def __init__(self, base_url: str, config: dict[str, Any] | None = None):
|
||||
super().__init__(base_url, config)
|
||||
self._api_key: str | None = None
|
||||
|
||||
@staticmethod
|
||||
def _strip_bearer(value: str) -> str:
|
||||
"""去掉用户可能粘贴的 Bearer 前缀"""
|
||||
stripped = value.strip()
|
||||
if stripped.lower().startswith("bearer "):
|
||||
stripped = stripped[7:].strip()
|
||||
return stripped
|
||||
|
||||
async def connect(self, credentials: dict[str, Any]) -> bool:
|
||||
api_key = credentials.get("api_key")
|
||||
if not api_key:
|
||||
self._set_error("JWT Token 不能为空")
|
||||
return False
|
||||
self._api_key = self._strip_bearer(api_key)
|
||||
self._set_connected()
|
||||
return True
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self._api_key = None
|
||||
self._set_disconnected()
|
||||
|
||||
async def is_authenticated(self) -> bool:
|
||||
return self._api_key is not None
|
||||
|
||||
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
|
||||
if self._api_key:
|
||||
request.headers["Authorization"] = f"Bearer {self._api_key}"
|
||||
return request
|
||||
|
||||
@classmethod
|
||||
def get_credentials_schema(cls) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"base_url": {
|
||||
"type": "string",
|
||||
"title": "站点地址",
|
||||
"description": "API 基础地址",
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"title": "JWT Token",
|
||||
"description": "Sub2API 的访问令牌",
|
||||
"x-sensitive": True,
|
||||
"x-input-type": "password",
|
||||
},
|
||||
},
|
||||
"required": ["api_key"],
|
||||
"x-field-groups": [
|
||||
{"fields": ["base_url"]},
|
||||
{"fields": ["api_key"]},
|
||||
],
|
||||
"x-auth-type": "api_key",
|
||||
"x-auth-method": "bearer",
|
||||
"x-validation": [
|
||||
{
|
||||
"type": "required",
|
||||
"fields": ["api_key"],
|
||||
"message": "请填写 JWT Token",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class Sub2ApiArchitecture(ProviderArchitecture):
|
||||
"""
|
||||
Sub2API 架构
|
||||
|
||||
特点:
|
||||
- 使用 Bearer Token 认证
|
||||
- 验证端点: /api/v1/auth/me
|
||||
- balance 为充值余额,points 为赠送余额
|
||||
"""
|
||||
|
||||
architecture_id = "sub2api"
|
||||
display_name = "Sub2API"
|
||||
description = "Sub2API 风格中转站的预设配置"
|
||||
|
||||
supported_connectors: list[type[ProviderConnector]] = [Sub2ApiConnector]
|
||||
|
||||
supported_actions: list[type[ProviderAction]] = [Sub2ApiBalanceAction]
|
||||
|
||||
default_action_configs: dict[ProviderActionType, dict[str, Any]] = {
|
||||
ProviderActionType.QUERY_BALANCE: {
|
||||
"endpoint": "/api/v1/auth/me?timezone=Asia/Shanghai",
|
||||
"method": "GET",
|
||||
},
|
||||
}
|
||||
|
||||
def get_credentials_schema(self) -> dict[str, Any]:
|
||||
return Sub2ApiConnector.get_credentials_schema()
|
||||
|
||||
def get_verify_endpoint(self) -> str:
|
||||
return "/api/v1/auth/me?timezone=Asia/Shanghai"
|
||||
|
||||
def build_verify_headers(
|
||||
self,
|
||||
config: dict[str, Any],
|
||||
credentials: dict[str, Any],
|
||||
) -> dict[str, str]:
|
||||
headers: dict[str, str] = {}
|
||||
api_key = credentials.get("api_key", "")
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {Sub2ApiConnector._strip_bearer(api_key)}"
|
||||
return headers
|
||||
|
||||
def parse_verify_response(
|
||||
self,
|
||||
status_code: int,
|
||||
data: dict[str, Any],
|
||||
) -> VerifyResult:
|
||||
"""
|
||||
解析 Sub2API 验证响应
|
||||
|
||||
Sub2API 使用 {"code": 0, "message": "success", "data": {...}} 格式。
|
||||
"""
|
||||
if status_code == 401:
|
||||
return VerifyResult(success=False, message=self._auth_fail_message(401))
|
||||
if status_code == 403:
|
||||
return VerifyResult(success=False, message=self._auth_fail_message(403))
|
||||
if status_code != 200:
|
||||
return VerifyResult(success=False, message=f"验证失败:HTTP {status_code}")
|
||||
|
||||
code = data.get("code")
|
||||
if code != 0:
|
||||
message = data.get("message", "验证失败")
|
||||
return VerifyResult(success=False, message=message)
|
||||
|
||||
user_data = data.get("data", {})
|
||||
return self._build_verify_result(user_data, data)
|
||||
|
||||
def _build_verify_result(
|
||||
self, user_data: dict[str, Any], raw_data: dict[str, Any] | None = None
|
||||
) -> VerifyResult:
|
||||
# or 0 防御上游返回 None / "" 等 falsy 值
|
||||
balance = float(user_data.get("balance") or 0)
|
||||
points = float(user_data.get("points") or 0)
|
||||
|
||||
return VerifyResult(
|
||||
success=True,
|
||||
username=user_data.get("username") or user_data.get("email"),
|
||||
display_name=user_data.get("username") or user_data.get("email"),
|
||||
email=user_data.get("email"),
|
||||
quota=balance + points,
|
||||
extra={
|
||||
"balance": balance,
|
||||
"points": points,
|
||||
"status": user_data.get("status"),
|
||||
"concurrency": user_data.get("concurrency"),
|
||||
},
|
||||
)
|
||||
@@ -16,6 +16,7 @@ from src.services.provider_ops.architectures import (
|
||||
NekoCodeArchitecture,
|
||||
NewApiArchitecture,
|
||||
ProviderArchitecture,
|
||||
Sub2ApiArchitecture,
|
||||
YesCodeArchitecture,
|
||||
)
|
||||
|
||||
@@ -57,6 +58,7 @@ class ArchitectureRegistry:
|
||||
GenericApiArchitecture,
|
||||
NekoCodeArchitecture,
|
||||
NewApiArchitecture,
|
||||
Sub2ApiArchitecture,
|
||||
YesCodeArchitecture,
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user