From 6a875bfced699a945fc36cf51064cd0ad8bead48 Mon Sep 17 00:00:00 2001 From: Jack <2091813161@qq.com> Date: Tue, 24 Mar 2026 09:37:33 +0800 Subject: [PATCH] Add default remote proxy support for Sub2API services. Let Sub2API service settings store and load a default remote proxy so uploads can reuse the target service's remote proxy bindings without forcing users to pick one each time. --- src/database/crud.py | 4 +- src/database/models.py | 1 + src/database/session.py | 1 + src/web/routes/accounts.py | 26 +++-- src/web/routes/registration.py | 7 +- src/web/routes/upload/sub2api_services.py | 63 ++++++++++- static/css/style.css | 21 ++++ static/js/accounts.js | 4 +- static/js/settings.js | 127 ++++++++++++++++++++-- templates/settings.html | 10 ++ 10 files changed, 240 insertions(+), 24 deletions(-) diff --git a/src/database/crud.py b/src/database/crud.py index 4750969..e14dd3c 100644 --- a/src/database/crud.py +++ b/src/database/crud.py @@ -596,13 +596,15 @@ def create_sub2api_service( api_url: str, api_key: str, enabled: bool = True, - priority: int = 0 + priority: int = 0, + default_remote_proxy_id: Optional[int] = None, ) -> Sub2ApiService: """创建 Sub2API 服务配置""" svc = Sub2ApiService( name=name, api_url=api_url, api_key=api_key, + default_remote_proxy_id=default_remote_proxy_id, enabled=enabled, priority=priority, ) diff --git a/src/database/models.py b/src/database/models.py index f662917..17880e0 100644 --- a/src/database/models.py +++ b/src/database/models.py @@ -152,6 +152,7 @@ class Sub2ApiService(Base): name = Column(String(100), nullable=False) # 服务名称 api_url = Column(String(500), nullable=False) # API URL (host) api_key = Column(Text, nullable=False) # x-api-key + default_remote_proxy_id = Column(Integer, nullable=True) # 默认远端代理 ID enabled = Column(Boolean, default=True) priority = Column(Integer, default=0) # 优先级 created_at = Column(DateTime, default=datetime.utcnow) diff --git a/src/database/session.py b/src/database/session.py index c16541b..4c1dd24 100644 --- a/src/database/session.py +++ b/src/database/session.py @@ -111,6 +111,7 @@ class DatabaseSessionManager: ("accounts", "subscription_at", "DATETIME"), ("accounts", "cookies", "TEXT"), ("proxies", "is_default", "BOOLEAN DEFAULT 0"), + ("sub2api_services", "default_remote_proxy_id", "INTEGER"), ] # 确保新表存在(create_tables 已处理,此处兜底) diff --git a/src/web/routes/accounts.py b/src/web/routes/accounts.py index d2e1df5..5f6c86f 100644 --- a/src/web/routes/accounts.py +++ b/src/web/routes/accounts.py @@ -819,6 +819,7 @@ async def batch_upload_accounts_to_sub2api(request: BatchSub2ApiUploadRequest): # 解析指定的 Sub2API 服务 api_url = None api_key = None + svc = None if request.service_id: with get_db() as db: svc = crud.get_sub2api_service_by_id(db, request.service_id) @@ -830,10 +831,11 @@ async def batch_upload_accounts_to_sub2api(request: BatchSub2ApiUploadRequest): with get_db() as db: svcs = crud.get_sub2api_services(db, enabled=True) if svcs: - api_url = svcs[0].api_url - api_key = svcs[0].api_key + svc = svcs[0] + api_url = svc.api_url + api_key = svc.api_key - if not api_url or not api_key: + if not api_url or not api_key or not svc: raise HTTPException(status_code=400, detail="未找到可用的 Sub2API 服务,请先在设置中配置") with get_db() as db: @@ -842,11 +844,13 @@ async def batch_upload_accounts_to_sub2api(request: BatchSub2ApiUploadRequest): request.status_filter, request.email_service_filter, request.search_filter ) + selected_proxy_id = request.proxy_id if "proxy_id" in request.model_fields_set else svc.default_remote_proxy_id + results = batch_upload_to_sub2api( ids, api_url, api_key, concurrency=request.concurrency, priority=request.priority, - proxy_id=request.proxy_id, + proxy_id=selected_proxy_id, ) return results @@ -858,10 +862,11 @@ 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 + proxy_id = request.proxy_id if request and "proxy_id" in request.model_fields_set else None api_url = None api_key = None + svc = None if service_id: with get_db() as db: svc = crud.get_sub2api_service_by_id(db, service_id) @@ -873,10 +878,11 @@ async def upload_account_to_sub2api(account_id: int, request: Optional[Sub2ApiUp with get_db() as db: svcs = crud.get_sub2api_services(db, enabled=True) if svcs: - api_url = svcs[0].api_url - api_key = svcs[0].api_key + svc = svcs[0] + api_url = svc.api_url + api_key = svc.api_key - if not api_url or not api_key: + if not api_url or not api_key or not svc: raise HTTPException(status_code=400, detail="未找到可用的 Sub2API 服务,请先在设置中配置") with get_db() as db: @@ -888,7 +894,9 @@ 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, proxy_id=proxy_id + concurrency=concurrency, + priority=priority, + proxy_id=proxy_id if request and "proxy_id" in request.model_fields_set else svc.default_remote_proxy_id, ) if success: return {"success": True, "message": message} diff --git a/src/web/routes/registration.py b/src/web/routes/registration.py index daa92e5..da386fe 100644 --- a/src/web/routes/registration.py +++ b/src/web/routes/registration.py @@ -462,7 +462,12 @@ def _run_sync_registration_task(task_uuid: str, email_service_type: str, proxy: if not _svc: continue log_callback(f"[Sub2API] 正在把账号发往服务站: {_svc.name}") - _ok, _msg = upload_to_sub2api([saved_account], _svc.api_url, _svc.api_key) + _ok, _msg = upload_to_sub2api( + [saved_account], + _svc.api_url, + _svc.api_key, + proxy_id=_svc.default_remote_proxy_id, + ) log_callback(f"[Sub2API] {'成功' if _ok else '失败'}({_svc.name}): {_msg}") except Exception as _e: log_callback(f"[Sub2API] 异常({_sid}): {_e}") diff --git a/src/web/routes/upload/sub2api_services.py b/src/web/routes/upload/sub2api_services.py index b15fa94..7843ea2 100644 --- a/src/web/routes/upload/sub2api_services.py +++ b/src/web/routes/upload/sub2api_services.py @@ -23,6 +23,7 @@ class Sub2ApiServiceCreate(BaseModel): name: str api_url: str api_key: str + default_remote_proxy_id: Optional[int] = None enabled: bool = True priority: int = 0 @@ -31,6 +32,7 @@ class Sub2ApiServiceUpdate(BaseModel): name: Optional[str] = None api_url: Optional[str] = None api_key: Optional[str] = None + default_remote_proxy_id: Optional[int] = None enabled: Optional[bool] = None priority: Optional[int] = None @@ -40,6 +42,7 @@ class Sub2ApiServiceResponse(BaseModel): name: str api_url: str has_key: bool + default_remote_proxy_id: Optional[int] = None enabled: bool priority: int created_at: Optional[str] = None @@ -50,8 +53,10 @@ class Sub2ApiServiceResponse(BaseModel): class Sub2ApiTestRequest(BaseModel): + service_id: Optional[int] = None api_url: Optional[str] = None api_key: Optional[str] = None + default_remote_proxy_id: Optional[int] = None class Sub2ApiUploadRequest(BaseModel): @@ -83,6 +88,7 @@ def _to_response(svc) -> Sub2ApiServiceResponse: name=svc.name, api_url=svc.api_url, has_key=bool(svc.api_key), + default_remote_proxy_id=svc.default_remote_proxy_id, enabled=svc.enabled, priority=svc.priority, created_at=svc.created_at.isoformat() if svc.created_at else None, @@ -119,6 +125,23 @@ def _to_remote_proxy_response(proxy: dict) -> Sub2ApiRemoteProxyResponse: ) +def _resolve_temp_sub2api_request(request: Sub2ApiTestRequest): + svc = None + if request.service_id is not None: + with get_db() as db: + svc = crud.get_sub2api_service_by_id(db, request.service_id) + if not svc: + raise HTTPException(status_code=404, detail="Sub2API 服务不存在") + + api_url = (request.api_url or (svc.api_url if svc else "")).strip() + api_key = (request.api_key or (svc.api_key if svc else "")).strip() + + if not api_url or not api_key: + raise HTTPException(status_code=400, detail="api_url 和 api_key 不能为空") + + return svc, api_url, api_key + + # ============== API Endpoints ============== @router.get("", response_model=List[Sub2ApiServiceResponse]) @@ -140,6 +163,7 @@ async def create_sub2api_service(request: Sub2ApiServiceCreate): api_key=request.api_key, enabled=request.enabled, priority=request.priority, + default_remote_proxy_id=request.default_remote_proxy_id, ) return _to_response(svc) @@ -161,6 +185,33 @@ async def list_remote_sub2api_proxies(service_id: Optional[int] = None): ) +@router.post("/remote-proxies", response_model=Sub2ApiRemoteProxyListResponse) +async def list_remote_sub2api_proxies_direct(request: Sub2ApiTestRequest): + """按临时 Sub2API 配置拉取远端代理列表(用于新增前选择默认代理)""" + svc, api_url, api_key = _resolve_temp_sub2api_request(request) + + try: + proxies = fetch_remote_sub2api_proxies(api_url, 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) if svc else Sub2ApiServiceResponse( + id=0, + name="未保存服务", + api_url=api_url, + has_key=bool(api_key), + default_remote_proxy_id=request.default_remote_proxy_id, + enabled=True, + priority=0, + created_at=None, + updated_at=None, + ), + proxies=proxy_items, + ) + + @router.get("/{service_id}", response_model=Sub2ApiServiceResponse) async def get_sub2api_service(service_id: int): """获取单个 Sub2API 服务详情""" @@ -183,6 +234,7 @@ async def get_sub2api_service_full(service_id: int): "name": svc.name, "api_url": svc.api_url, "api_key": svc.api_key, + "default_remote_proxy_id": svc.default_remote_proxy_id, "enabled": svc.enabled, "priority": svc.priority, } @@ -204,6 +256,8 @@ async def update_sub2api_service(service_id: int, request: Sub2ApiServiceUpdate) # api_key 留空则保持原值 if request.api_key: update_data["api_key"] = request.api_key + if "default_remote_proxy_id" in request.model_fields_set: + update_data["default_remote_proxy_id"] = request.default_remote_proxy_id if request.enabled is not None: update_data["enabled"] = request.enabled if request.priority is not None: @@ -238,9 +292,8 @@ async def test_sub2api_service(service_id: int): @router.post("/test-connection") async def test_sub2api_connection_direct(request: Sub2ApiTestRequest): """直接测试 Sub2API 连接(用于添加前验证)""" - if not request.api_url or not request.api_key: - raise HTTPException(status_code=400, detail="api_url 和 api_key 不能为空") - success, message = test_sub2api_connection(request.api_url, request.api_key) + _svc, api_url, api_key = _resolve_temp_sub2api_request(request) + success, message = test_sub2api_connection(api_url, api_key) return {"success": success, "message": message} @@ -263,12 +316,14 @@ async def upload_accounts_to_sub2api(request: Sub2ApiUploadRequest): api_url = svc.api_url api_key = svc.api_key + selected_proxy_id = request.proxy_id if "proxy_id" in request.model_fields_set else svc.default_remote_proxy_id + results = batch_upload_to_sub2api( request.account_ids, api_url, api_key, concurrency=request.concurrency, priority=request.priority, - proxy_id=request.proxy_id, + proxy_id=selected_proxy_id, ) return results diff --git a/static/css/style.css b/static/css/style.css index 4c40acc..6c2b748 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -357,6 +357,27 @@ body { border-top: 1px solid var(--border-light); } +.sub2api-remote-proxy-row { + display: flex; + align-items: stretch; + gap: var(--spacing-sm); +} + +.sub2api-remote-proxy-select { + flex: 1; +} + +.sub2api-remote-proxy-btn { + flex-shrink: 0; +} + +.sub2api-remote-proxy-hint { + margin-top: var(--spacing-xs); + font-size: 0.75rem; + color: var(--text-muted); + line-height: 1.5; +} + /* Checkbox 样式优化 */ .form-group input[type="checkbox"] { width: auto; diff --git a/static/js/accounts.js b/static/js/accounts.js index 303d1ce..60c86cc 100644 --- a/static/js/accounts.js +++ b/static/js/accounts.js @@ -1119,7 +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; + payload.proxy_id = proxyInput.proxy_id; const result = await api.post('/accounts/batch-upload-sub2api', payload); let message = `成功: ${result.success_count}`; @@ -1149,7 +1149,7 @@ async function uploadToSub2Api(id) { 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; + payload.proxy_id = proxyInput.proxy_id; const result = await api.post(`/accounts/${id}/upload-sub2api`, payload); if (result.success) { toast.success('上传成功'); diff --git a/static/js/settings.js b/static/js/settings.js index 595f038..1a1b62b 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -57,6 +57,7 @@ const elements = { sub2ApiServiceForm: document.getElementById('sub2api-service-form'), sub2ApiServiceModalTitle: document.getElementById('sub2api-service-modal-title'), testSub2ApiServiceBtn: document.getElementById('test-sub2api-service-btn'), + loadSub2ApiRemoteProxiesBtn: document.getElementById('load-sub2api-remote-proxies-btn'), // Team Manager 服务管理 addTmServiceBtn: document.getElementById('add-tm-service-btn'), tmServicesTable: document.getElementById('tm-services-table'), @@ -312,6 +313,9 @@ function initEventListeners() { if (elements.testSub2ApiServiceBtn) { elements.testSub2ApiServiceBtn.addEventListener('click', handleTestSub2ApiService); } + if (elements.loadSub2ApiRemoteProxiesBtn) { + elements.loadSub2ApiRemoteProxiesBtn.addEventListener('click', () => loadSub2ApiRemoteProxiesForForm()); + } } // 加载设置 @@ -1379,6 +1383,101 @@ async function handleTestCpaService() { let _sub2apiEditingId = null; +function getSub2ApiDefaultRemoteProxySelect() { + return document.getElementById('sub2api-default-remote-proxy-id'); +} + +function resetSub2ApiRemoteProxyOptions(selectedValue = '', firstOptionText = '不设置默认代理') { + const select = getSub2ApiDefaultRemoteProxySelect(); + if (!select) return; + + const normalizedValue = selectedValue == null ? '' : String(selectedValue); + const options = [``]; + if (normalizedValue) { + options.push(``); + } + + select.innerHTML = options.join(''); + select.value = normalizedValue; +} + +function renderSub2ApiRemoteProxyOptions(proxies, selectedValue = '') { + const select = getSub2ApiDefaultRemoteProxySelect(); + if (!select) return; + + const normalizedValue = selectedValue == null ? '' : String(selectedValue); + const hasSelectedProxy = normalizedValue !== '' && proxies.some(proxy => String(proxy.id) === normalizedValue); + const options = [ + `` + ]; + + if (normalizedValue && !hasSelectedProxy) { + options.push(``); + } + + options.push(...proxies.map(proxy => { + const protocol = String(proxy.protocol || '').trim().toUpperCase(); + const host = String(proxy.host || '').trim(); + const port = proxy.port != null ? `:${proxy.port}` : ''; + const location = host ? ` - ${host}${port}` : ''; + const status = String(proxy.status || '').trim() || 'inactive'; + const label = `#${proxy.id} ${proxy.name || `Proxy ${proxy.id}`}${protocol ? ` (${protocol})` : ''}${location} [${status}]`; + return ``; + })); + + select.innerHTML = options.join(''); + select.value = normalizedValue; +} + +function setSub2ApiRemoteProxyLoading(loading) { + if (!elements.loadSub2ApiRemoteProxiesBtn) return; + elements.loadSub2ApiRemoteProxiesBtn.disabled = loading; + elements.loadSub2ApiRemoteProxiesBtn.textContent = loading ? '加载中...' : '加载代理'; +} + +async function loadSub2ApiRemoteProxiesForForm({ silent = false } = {}) { + const id = document.getElementById('sub2api-service-id').value.trim(); + const apiUrl = document.getElementById('sub2api-service-url').value.trim(); + const apiKey = document.getElementById('sub2api-service-key').value.trim(); + const select = getSub2ApiDefaultRemoteProxySelect(); + const selectedValue = select ? select.value : ''; + + if (!apiUrl) { + if (!silent) toast.error('请先填写 API URL'); + return; + } + if (!id && !apiKey) { + if (!silent) toast.error('请先填写 API Key'); + return; + } + + const payload = { api_url: apiUrl }; + if (id) payload.service_id = parseInt(id, 10); + if (apiKey) payload.api_key = apiKey; + + setSub2ApiRemoteProxyLoading(true); + try { + const result = await api.post('/sub2api-services/remote-proxies', payload); + const proxies = result.proxies || []; + renderSub2ApiRemoteProxyOptions(proxies, selectedValue); + if (!silent) { + if (proxies.length > 0) { + toast.success(`已加载 ${proxies.length} 个远端代理`); + } else { + toast.info('当前服务下暂无可选远端代理'); + } + } + } catch (e) { + if (!silent) { + toast.error('加载远端代理失败: ' + e.message); + } else { + console.error('加载远端代理失败:', e); + } + } finally { + setSub2ApiRemoteProxyLoading(false); + } +} + async function loadSub2ApiServices() { try { const services = await api.get('/sub2api-services'); @@ -1416,19 +1515,31 @@ function openSub2ApiServiceModal(svc = null) { elements.sub2ApiServiceModalTitle.textContent = svc ? '编辑 Sub2API 服务' : '添加 Sub2API 服务'; elements.sub2ApiServiceForm.reset(); document.getElementById('sub2api-service-id').value = svc ? svc.id : ''; + + resetSub2ApiRemoteProxyOptions(svc ? svc.default_remote_proxy_id : ''); + if (svc) { document.getElementById('sub2api-service-name').value = svc.name || ''; document.getElementById('sub2api-service-url').value = svc.api_url || ''; document.getElementById('sub2api-service-priority').value = svc.priority ?? 0; document.getElementById('sub2api-service-enabled').checked = svc.enabled !== false; document.getElementById('sub2api-service-key').placeholder = svc.has_key ? '已配置,留空保持不变' : '请输入 API Key'; + } else { + document.getElementById('sub2api-service-key').placeholder = '请输入 API Key'; } + elements.sub2ApiServiceEditModal.classList.add('active'); + + if (svc) { + loadSub2ApiRemoteProxiesForForm({ silent: true }); + } } function closeSub2ApiServiceModal() { elements.sub2ApiServiceEditModal.classList.remove('active'); elements.sub2ApiServiceForm.reset(); + resetSub2ApiRemoteProxyOptions(); + setSub2ApiRemoteProxyLoading(false); _sub2apiEditingId = null; } @@ -1455,10 +1566,13 @@ async function deleteSub2ApiService(id, name) { async function handleSaveSub2ApiService(e) { e.preventDefault(); const id = document.getElementById('sub2api-service-id').value; + const defaultRemoteProxyValue = getSub2ApiDefaultRemoteProxySelect()?.value || ''; + const parsedDefaultRemoteProxyId = defaultRemoteProxyValue === '' ? null : parseInt(defaultRemoteProxyValue, 10); const data = { name: document.getElementById('sub2api-service-name').value, api_url: document.getElementById('sub2api-service-url').value, api_key: document.getElementById('sub2api-service-key').value || undefined, + default_remote_proxy_id: Number.isNaN(parsedDefaultRemoteProxyId) ? null : parsedDefaultRemoteProxyId, priority: parseInt(document.getElementById('sub2api-service-priority').value) || 0, enabled: document.getElementById('sub2api-service-enabled').checked, }; @@ -1499,7 +1613,7 @@ async function testSub2ApiServiceById(id) { async function handleTestSub2ApiService() { const apiUrl = document.getElementById('sub2api-service-url').value.trim(); const apiKey = document.getElementById('sub2api-service-key').value.trim(); - const id = document.getElementById('sub2api-service-id').value; + const id = document.getElementById('sub2api-service-id').value.trim(); if (!apiUrl) { toast.error('请先填写 API URL'); @@ -1514,12 +1628,11 @@ async function handleTestSub2ApiService() { elements.testSub2ApiServiceBtn.textContent = '测试中...'; try { - let result; - if (id && !apiKey) { - result = await api.post(`/sub2api-services/${id}/test`); - } else { - result = await api.post('/sub2api-services/test-connection', { api_url: apiUrl, api_key: apiKey }); - } + const payload = { api_url: apiUrl }; + if (id) payload.service_id = parseInt(id, 10); + if (apiKey) payload.api_key = apiKey; + + const result = await api.post('/sub2api-services/test-connection', payload); if (result.success) { toast.success(result.message); } else { diff --git a/templates/settings.html b/templates/settings.html index e925ce3..2838511 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -349,6 +349,16 @@ +
与账号页上传时选择的远端代理一致;仅在未显式选择代理时作为默认值。
+