mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +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'
|
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'
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
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.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",
|
||||||
]
|
]
|
||||||
|
|||||||
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,
|
NekoCodeArchitecture,
|
||||||
NewApiArchitecture,
|
NewApiArchitecture,
|
||||||
ProviderArchitecture,
|
ProviderArchitecture,
|
||||||
|
Sub2ApiArchitecture,
|
||||||
YesCodeArchitecture,
|
YesCodeArchitecture,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ class ArchitectureRegistry:
|
|||||||
GenericApiArchitecture,
|
GenericApiArchitecture,
|
||||||
NekoCodeArchitecture,
|
NekoCodeArchitecture,
|
||||||
NewApiArchitecture,
|
NewApiArchitecture,
|
||||||
|
Sub2ApiArchitecture,
|
||||||
YesCodeArchitecture,
|
YesCodeArchitecture,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user