feat(provider-ops): 新增 Sub2API 架构支持并优化余额分项显示

- 新增 Sub2ApiArchitecture 和 Sub2ApiBalanceAction,支持 Sub2API 风格中转站
- 前端 provider 列表支持 balance + points 分项显示
- 验证弹窗适配 Sub2API 余额和积分的分开展示
This commit is contained in:
fawney19
2026-02-13 21:03:21 +08:00
parent d6b39babf5
commit 941e599d36
7 changed files with 280 additions and 5 deletions

View File

@@ -493,7 +493,12 @@ async function handleVerify() {
verifyStatus.value = 'success' verifyStatus.value = 'success'
formChanged.value = false formChanged.value = false
const displayName = result.data?.display_name || result.data?.username 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 { } else {
verifyStatus.value = 'error' verifyStatus.value = 'error'

View File

@@ -216,10 +216,26 @@
v-else-if="provider.ops_configured && getProviderBalance(provider.id)" v-else-if="provider.ops_configured && getProviderBalance(provider.id)"
class="flex items-center gap-2 text-xs" class="flex items-center gap-2 text-xs"
> >
<!-- 余额文字 --> <!-- 余额文字balance + points 分开显示或普通余额 -->
<span class="font-semibold text-foreground/90 min-w-[4.5rem] tabular-nums"> <template v-for="bd in [getProviderBalanceBreakdown(provider.id)]" :key="'bd'">
{{ formatBalanceDisplay(getProviderBalance(provider.id)) }} <div
</span> 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 失效警告 --> <!-- 窗口限额 + 签到状态 + Cookie 失效警告 -->
<div <div
v-if="getProviderBalanceExtra(provider.id, provider.ops_architecture_id).length > 0 || getProviderCheckin(provider.id) || getProviderCookieExpired(provider.id)" 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 余额查询的错误状态 // 获取 provider 余额查询的错误状态
function getProviderBalanceError(providerId: string): { status: string; message: string } | null { function getProviderBalanceError(providerId: string): { status: string; message: string } | null {
const result = balanceCache.value[providerId] const result = balanceCache.value[providerId]

View File

@@ -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.cubence_balance import CubenceBalanceAction
from src.services.provider_ops.actions.nekocode_balance import NekoCodeBalanceAction 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.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 from src.services.provider_ops.actions.yescode_balance import YesCodeBalanceAction
__all__ = [ __all__ = [
@@ -19,5 +20,6 @@ __all__ = [
"AnyrouterBalanceAction", "AnyrouterBalanceAction",
"CubenceBalanceAction", "CubenceBalanceAction",
"NekoCodeBalanceAction", "NekoCodeBalanceAction",
"Sub2ApiBalanceAction",
"YesCodeBalanceAction", "YesCodeBalanceAction",
] ]

View 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,
},
)

View File

@@ -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.generic_api import GenericApiArchitecture
from src.services.provider_ops.architectures.nekocode import NekoCodeArchitecture 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.new_api import NewApiArchitecture
from src.services.provider_ops.architectures.sub2api import Sub2ApiArchitecture
from src.services.provider_ops.architectures.yescode import YesCodeArchitecture from src.services.provider_ops.architectures.yescode import YesCodeArchitecture
__all__ = [ __all__ = [
@@ -23,5 +24,6 @@ __all__ = [
"GenericApiArchitecture", "GenericApiArchitecture",
"NekoCodeArchitecture", "NekoCodeArchitecture",
"NewApiArchitecture", "NewApiArchitecture",
"Sub2ApiArchitecture",
"YesCodeArchitecture", "YesCodeArchitecture",
] ]

View 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"),
},
)

View File

@@ -16,6 +16,7 @@ from src.services.provider_ops.architectures import (
NekoCodeArchitecture, NekoCodeArchitecture,
NewApiArchitecture, NewApiArchitecture,
ProviderArchitecture, ProviderArchitecture,
Sub2ApiArchitecture,
YesCodeArchitecture, YesCodeArchitecture,
) )
@@ -57,6 +58,7 @@ class ArchitectureRegistry:
GenericApiArchitecture, GenericApiArchitecture,
NekoCodeArchitecture, NekoCodeArchitecture,
NewApiArchitecture, NewApiArchitecture,
Sub2ApiArchitecture,
YesCodeArchitecture, YesCodeArchitecture,
] ]