Add remote Sub2API proxy selection for account uploads.

Use the target Sub2API service's remote proxy list during account uploads so selected proxies are converted into proxy_key bindings the import API recognizes.
This commit is contained in:
Jack committed 2026-03-24 09:05:27 +08:00
1 parent b5ddbff081
commit dfebd9a780
5 files changed
+346 -9

No files matched your search

+167 -7
View File
@@ -3,25 +3,165 @@ Sub2API 账号上传功能
将账号以 sub2api-data 格式批量导入到 Sub2API 平台
"""
import json
import logging
from datetime import datetime, timezone
from typing import List, Tuple, Optional
from typing import Any, List, Optional, Tuple
from curl_cffi import requests as cffi_requests
from ...database.session import get_db
from ...database.models import Account
from ...database.session import get_db
logger = logging.getLogger(__name__)
def _extract_sub2api_data(response) -> Any:
try:
payload = response.json()
except Exception:
payload = None
if response.status_code >= 400:
if isinstance(payload, dict) and payload.get("message"):
raise ValueError(str(payload["message"]))
response_text = (response.text or "").strip()
raise ValueError(
f"HTTP {response.status_code}" + (f" - {response_text[:200]}" if response_text else "")
)
if isinstance(payload, dict):
if payload.get("code") not in (None, 0):
raise ValueError(str(payload.get("message") or "Sub2API 返回错误"))
return payload.get("data", payload)
if payload is None:
raise ValueError("Sub2API 返回了无法解析的响应")
return payload
def _normalize_remote_sub2api_proxy(proxy: dict) -> dict:
protocol = str(proxy.get("protocol") or "").strip()
host = str(proxy.get("host") or "").strip()
try:
port = int(proxy.get("port") or 0)
except (TypeError, ValueError):
port = 0
normalized = {
"id": proxy.get("id"),
"name": str(proxy.get("name") or "").strip(),
"protocol": protocol,
"host": host,
"port": port,
"username": str(proxy.get("username") or "").strip(),
"password": str(proxy.get("password") or "").strip(),
"status": str(proxy.get("status") or "inactive").strip() or "inactive",
}
if not normalized["name"]:
if normalized["id"] is not None:
normalized["name"] = f"Proxy {normalized['id']}"
else:
normalized["name"] = f"{protocol}://{host}:{port}"
return normalized
def _build_sub2api_proxy_key(proxy: dict) -> str:
normalized = _normalize_remote_sub2api_proxy(proxy)
return (
f"{normalized['protocol']}|{normalized['host']}|{normalized['port']}|"
f"{normalized['username']}|{normalized['password']}"
)
def _build_sub2api_proxy_payload(proxy: dict) -> dict:
normalized = _normalize_remote_sub2api_proxy(proxy)
if not normalized["protocol"] or not normalized["host"] or normalized["port"] <= 0:
raise ValueError(f"远端 Sub2API 代理 {normalized['id']} 配置不完整,无法生成上传数据")
return {
"proxy_key": _build_sub2api_proxy_key(normalized),
"name": normalized["name"],
"protocol": normalized["protocol"],
"host": normalized["host"],
"port": normalized["port"],
"username": normalized["username"],
"password": normalized["password"],
"status": normalized["status"],
}
def fetch_remote_sub2api_proxies(api_url: str, api_key: str) -> List[dict]:
if not api_url:
raise ValueError("Sub2API URL 未配置")
if not api_key:
raise ValueError("Sub2API API Key 未配置")
url = api_url.rstrip("/") + "/api/v1/admin/proxies/all"
try:
response = cffi_requests.get(
url,
headers={"x-api-key": api_key},
proxies=None,
timeout=15,
impersonate="chrome110",
)
data = _extract_sub2api_data(response)
if not isinstance(data, list):
raise ValueError("远端 Sub2API 代理列表格式异常")
return [_normalize_remote_sub2api_proxy(item) for item in data if isinstance(item, dict)]
except ValueError:
raise
except cffi_requests.exceptions.ConnectionError as e:
raise ValueError(f"无法连接到远端 Sub2API 服务: {e}") from e
except cffi_requests.exceptions.Timeout as e:
raise ValueError("拉取远端 Sub2API 代理列表超时") from e
except Exception as e:
logger.error(f"拉取远端 Sub2API 代理列表异常: {e}")
raise ValueError(f"拉取远端 Sub2API 代理列表失败: {e}") from e
def fetch_remote_sub2api_proxy(api_url: str, api_key: str, proxy_id: int) -> dict:
if proxy_id is None:
raise ValueError("远端 Sub2API 代理 ID 不能为空")
if not api_url:
raise ValueError("Sub2API URL 未配置")
if not api_key:
raise ValueError("Sub2API API Key 未配置")
url = api_url.rstrip("/") + f"/api/v1/admin/proxies/{proxy_id}"
try:
response = cffi_requests.get(
url,
headers={"x-api-key": api_key},
proxies=None,
timeout=15,
impersonate="chrome110",
)
data = _extract_sub2api_data(response)
if not isinstance(data, dict):
raise ValueError("远端 Sub2API 代理详情格式异常")
return _normalize_remote_sub2api_proxy(data)
except ValueError:
raise
except cffi_requests.exceptions.ConnectionError as e:
raise ValueError(f"无法连接到远端 Sub2API 服务: {e}") from e
except cffi_requests.exceptions.Timeout as e:
raise ValueError("拉取远端 Sub2API 代理详情超时") from e
except Exception as e:
logger.error(f"拉取远端 Sub2API 代理详情异常: {e}")
raise ValueError(f"拉取远端 Sub2API 代理详情失败: {e}") from e
def upload_to_sub2api(
accounts: List[Account],
api_url: str,
api_key: str,
concurrency: int = 3,
priority: int = 50,
proxy_id: Optional[int] = None,
) -> Tuple[bool, str]:
"""
上传账号列表到 Sub2API 平台(不走代理)
@@ -32,6 +172,7 @@ def upload_to_sub2api(
api_key: Admin API Key(x-api-key header)
concurrency: 账号并发数,默认 3
priority: 账号优先级,默认 50
proxy_id: 远端 Sub2API 代理 ID,用于生成 Sub2API 识别的 proxy_key(可选)
Returns:
(成功标志, 消息)
@@ -47,12 +188,20 @@ def upload_to_sub2api(
exported_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
proxy_payload = None
if proxy_id is not None:
try:
remote_proxy = fetch_remote_sub2api_proxy(api_url, api_key, proxy_id)
proxy_payload = _build_sub2api_proxy_payload(remote_proxy)
except ValueError as e:
return False, str(e)
account_items = []
for acc in accounts:
if not acc.access_token:
continue
expires_at = int(acc.expires_at.timestamp()) if acc.expires_at else 0
account_items.append({
account_item = {
"name": acc.email,
"platform": "openai",
"type": "oauth",
@@ -82,7 +231,10 @@ def upload_to_sub2api(
"priority": priority,
"rate_multiplier": 1,
"auto_pause_on_expired": True,
})
}
if proxy_payload is not None:
account_item["proxy_key"] = proxy_payload["proxy_key"]
account_items.append(account_item)
if not account_items:
return False, "所有账号均缺少 access_token,无法上传"
@@ -92,7 +244,7 @@ def upload_to_sub2api(
"type": "sub2api-data",
"version": 1,
"exported_at": exported_at,
"proxies": [],
"proxies": [proxy_payload] if proxy_payload is not None else [],
"accounts": account_items,
},
"skip_default_group_bind": True,
@@ -138,6 +290,7 @@ def batch_upload_to_sub2api(
api_key: str,
concurrency: int = 3,
priority: int = 50,
proxy_id: Optional[int] = None,
) -> dict:
"""
批量上传指定 ID 的账号到 Sub2API 平台
@@ -169,7 +322,14 @@ def batch_upload_to_sub2api(
if not accounts:
return results
success, message = upload_to_sub2api(accounts, api_url, api_key, concurrency, priority)
success, message = upload_to_sub2api(
accounts,
api_url,
api_key,
concurrency,
priority,
proxy_id=proxy_id,
)
if success:
for acc in accounts:
+5 -1
View File
@@ -796,6 +796,7 @@ class Sub2ApiUploadRequest(BaseModel):
service_id: Optional[int] = None
concurrency: int = 3
priority: int = 50
proxy_id: Optional[int] = None # 远端 Sub2API 代理 ID
class BatchSub2ApiUploadRequest(BaseModel):
@@ -808,6 +809,7 @@ class BatchSub2ApiUploadRequest(BaseModel):
service_id: Optional[int] = None # 指定 Sub2API 服务 ID,不传则使用第一个启用的
concurrency: int = 3
priority: int = 50
proxy_id: Optional[int] = None # 远端 Sub2API 代理 ID
@router.post("/batch-upload-sub2api")
@@ -844,6 +846,7 @@ async def batch_upload_accounts_to_sub2api(request: BatchSub2ApiUploadRequest):
ids, api_url, api_key,
concurrency=request.concurrency,
priority=request.priority,
proxy_id=request.proxy_id,
)
return results
@@ -855,6 +858,7 @@ async def upload_account_to_sub2api(account_id: int, request: Optional[Sub2ApiUp
service_id = request.service_id if request else None
concurrency = request.concurrency if request else 3
priority = request.priority if request else 50
proxy_id = request.proxy_id if request else None
api_url = None
api_key = None
@@ -884,7 +888,7 @@ async def upload_account_to_sub2api(account_id: int, request: Optional[Sub2ApiUp
success, message = upload_to_sub2api(
[account], api_url, api_key,
concurrency=concurrency, priority=priority
concurrency=concurrency, priority=priority, proxy_id=proxy_id
)
if success:
return {"success": True, "message": message}
+68 -1
View File
@@ -8,7 +8,11 @@ from pydantic import BaseModel
from ....database import crud
from ....database.session import get_db
from ....core.upload.sub2api_upload import test_sub2api_connection, batch_upload_to_sub2api
from ....core.upload.sub2api_upload import (
batch_upload_to_sub2api,
fetch_remote_sub2api_proxies,
test_sub2api_connection,
)
router = APIRouter()
@@ -55,6 +59,22 @@ class Sub2ApiUploadRequest(BaseModel):
service_id: Optional[int] = None
concurrency: int = 3
priority: int = 50
proxy_id: Optional[int] = None
class Sub2ApiRemoteProxyResponse(BaseModel):
id: int
name: str
protocol: str
host: str
port: int
username: str = ""
status: str
class Sub2ApiRemoteProxyListResponse(BaseModel):
service: Sub2ApiServiceResponse
proxies: List[Sub2ApiRemoteProxyResponse]
def _to_response(svc) -> Sub2ApiServiceResponse:
@@ -70,6 +90,35 @@ def _to_response(svc) -> Sub2ApiServiceResponse:
)
def _resolve_sub2api_service(service_id: Optional[int] = None):
with get_db() as db:
if service_id:
svc = crud.get_sub2api_service_by_id(db, service_id)
else:
services = crud.get_sub2api_services(db, enabled=True)
svc = services[0] if services else None
if not svc:
raise HTTPException(status_code=400, detail="未找到可用的 Sub2API 服务")
return svc
def _to_remote_proxy_response(proxy: dict) -> Sub2ApiRemoteProxyResponse:
proxy_id = proxy.get("id")
if proxy_id is None:
raise ValueError("远端 Sub2API 代理缺少 id")
return Sub2ApiRemoteProxyResponse(
id=int(proxy_id),
name=str(proxy.get("name") or "").strip() or f"Proxy {proxy_id}",
protocol=str(proxy.get("protocol") or "").strip(),
host=str(proxy.get("host") or "").strip(),
port=int(proxy.get("port") or 0),
username=str(proxy.get("username") or "").strip(),
status=str(proxy.get("status") or "inactive").strip() or "inactive",
)
# ============== API Endpoints ==============
@router.get("", response_model=List[Sub2ApiServiceResponse])
@@ -95,6 +144,23 @@ async def create_sub2api_service(request: Sub2ApiServiceCreate):
return _to_response(svc)
@router.get("/remote-proxies", response_model=Sub2ApiRemoteProxyListResponse)
async def list_remote_sub2api_proxies(service_id: Optional[int] = None):
"""拉取目标 Sub2API 服务中的远端代理列表"""
svc = _resolve_sub2api_service(service_id)
try:
proxies = fetch_remote_sub2api_proxies(svc.api_url, svc.api_key)
proxy_items = [_to_remote_proxy_response(proxy) for proxy in proxies]
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return Sub2ApiRemoteProxyListResponse(
service=_to_response(svc),
proxies=proxy_items,
)
@router.get("/{service_id}", response_model=Sub2ApiServiceResponse)
async def get_sub2api_service(service_id: int):
"""获取单个 Sub2API 服务详情"""
@@ -203,5 +269,6 @@ async def upload_accounts_to_sub2api(request: Sub2ApiUploadRequest):
api_key,
concurrency=request.concurrency,
priority=request.priority,
proxy_id=request.proxy_id,
)
return results
+86
View File
@@ -1022,6 +1022,83 @@ function selectSub2ApiService() {
});
}
function selectSub2ApiProxy(serviceChoice) {
return new Promise(async (resolve) => {
const modal = document.getElementById('sub2api-proxy-modal');
const listEl = document.getElementById('sub2api-proxy-list');
const closeBtn = document.getElementById('close-sub2api-proxy-modal');
const cancelBtn = document.getElementById('cancel-sub2api-proxy-modal-btn');
const noProxyBtn = document.getElementById('sub2api-no-proxy-btn');
listEl.innerHTML = '<div style="text-align:center;color:var(--text-muted)">加载中...</div>';
modal.classList.add('active');
let proxies = [];
try {
const query = serviceChoice && serviceChoice.service_id != null
? `?service_id=${encodeURIComponent(serviceChoice.service_id)}`
: '';
const result = await api.get(`/sub2api-services/remote-proxies${query}`);
proxies = result.proxies || [];
} catch (e) {
cleanup();
toast.error('加载远端 Sub2API 代理失败: ' + e.message);
resolve(null);
return;
}
if (proxies.length === 0) {
listEl.innerHTML = '<div style="text-align:center;color:var(--text-muted);padding:12px;">该 Sub2API 服务下暂无可选远端代理,可选择不使用代理继续上传</div>';
} else {
listEl.innerHTML = proxies.map(proxy => `
<div class="sub2api-service-item" data-id="${proxy.id}" style="
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
">
<div>
<div style="font-weight:500;display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
<span>#${proxy.id} ${escapeHtml(proxy.name)}</span>
<span class="badge">${escapeHtml(String(proxy.protocol || '').toUpperCase())}</span>
<span class="status-badge ${proxy.status === 'active' ? 'active' : ''}">${escapeHtml(proxy.status || 'inactive')}</span>
</div>
<div style="font-size:0.8rem;color:var(--text-muted);">${escapeHtml(proxy.host)}:${proxy.port}</div>
</div>
<span class="badge" style="background:var(--primary);color:#fff;font-size:0.7rem;padding:2px 8px;border-radius:10px;">选择</span>
</div>
`).join('');
listEl.querySelectorAll('.sub2api-service-item').forEach(item => {
item.addEventListener('mouseenter', () => item.style.background = 'var(--surface-hover)');
item.addEventListener('mouseleave', () => item.style.background = '');
item.addEventListener('click', () => {
cleanup();
resolve({ proxy_id: parseInt(item.dataset.id) });
});
});
}
function cleanup() {
modal.classList.remove('active');
closeBtn.removeEventListener('click', onCancel);
cancelBtn.removeEventListener('click', onCancel);
noProxyBtn.removeEventListener('click', onNoProxy);
}
function onCancel() { cleanup(); resolve(null); }
function onNoProxy() { cleanup(); resolve({ proxy_id: null }); }
closeBtn.addEventListener('click', onCancel);
cancelBtn.addEventListener('click', onCancel);
noProxyBtn.addEventListener('click', onNoProxy);
});
}
// 批量上传到 Sub2API
async function handleBatchUploadSub2Api() {
const count = getEffectiveCount();
@@ -1030,6 +1107,9 @@ async function handleBatchUploadSub2Api() {
const choice = await selectSub2ApiService();
if (choice === null) return; // 用户取消
const proxyInput = await selectSub2ApiProxy(choice);
if (proxyInput === null) return;
const confirmed = await confirm(`确定要将选中的 ${count} 个账号上传到 Sub2API 吗?`);
if (!confirmed) return;
@@ -1039,6 +1119,7 @@ async function handleBatchUploadSub2Api() {
try {
const payload = buildBatchPayload();
if (choice.service_id != null) payload.service_id = choice.service_id;
if (proxyInput.proxy_id != null) payload.proxy_id = proxyInput.proxy_id;
const result = await api.post('/accounts/batch-upload-sub2api', payload);
let message = `成功: ${result.success_count}`;
@@ -1060,10 +1141,15 @@ async function handleBatchUploadSub2Api() {
async function uploadToSub2Api(id) {
const choice = await selectSub2ApiService();
if (choice === null) return;
const proxyInput = await selectSub2ApiProxy(choice);
if (proxyInput === null) return;
try {
toast.info('正在上传到 Sub2API...');
const payload = {};
if (choice.service_id != null) payload.service_id = choice.service_id;
if (proxyInput.proxy_id != null) payload.proxy_id = proxyInput.proxy_id;
const result = await api.post(`/accounts/${id}/upload-sub2api`, payload);
if (result.success) {
toast.success('上传成功');
+20
View File
@@ -277,6 +277,26 @@
</div>
</div>
<!-- Sub2API 代理选择模态框 -->
<div class="modal" id="sub2api-proxy-modal">
<div class="modal-content" style="max-width: 520px;">
<div class="modal-header">
<h3>🌐 选择远端代理</h3>
<button class="modal-close" id="close-sub2api-proxy-modal">&times;</button>
</div>
<div class="modal-body">
<p style="color: var(--text-muted); margin-bottom: 12px; font-size: 0.9rem;">选择目标 Sub2API 服务中已存在的远端代理,或选择不使用代理。</p>
<div id="sub2api-proxy-list" style="display: flex; flex-direction: column; gap: 8px; max-height: 320px; overflow-y: auto;">
<div style="text-align: center; color: var(--text-muted);">加载中...</div>
</div>
</div>
<div class="modal-footer" style="padding: 12px 20px; border-top: 1px solid var(--border); display: flex; gap: 8px; justify-content: flex-end;">
<button class="btn btn-secondary" id="sub2api-no-proxy-btn">不使用代理</button>
<button class="btn btn-secondary" id="cancel-sub2api-proxy-modal-btn">取消</button>
</div>
</div>
</div>
<script src="/static/js/utils.js?v={{ static_version }}"></script>
<script src="/static/js/accounts.js?v={{ static_version }}"></script>
</body>