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.
This commit is contained in:
Jack committed 2026-03-24 09:37:33 +08:00
1 parent dfebd9a780
commit 6a875bfced
10 files changed
+240 -24

No files matched your search

+3 -1
View File
@@ -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,
)
+1
View File
@@ -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)
+1
View File
@@ -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 已处理,此处兜底)
+17 -9
View File
@@ -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}
+6 -1
View File
@@ -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}")
+59 -4
View File
@@ -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
+21
View File
@@ -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;
+2 -2
View File
@@ -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('上传成功');
+120 -7
View File
@@ -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 = [`<option value="">${escapeHtml(firstOptionText)}</option>`];
if (normalizedValue) {
options.push(`<option value="${escapeHtml(normalizedValue)}">当前默认代理 #${escapeHtml(normalizedValue)}(点击加载详情)</option>`);
}
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 = [
`<option value="">${escapeHtml(proxies.length === 0 ? '不设置默认代理(当前暂无远端代理)' : '不设置默认代理')}</option>`
];
if (normalizedValue && !hasSelectedProxy) {
options.push(`<option value="${escapeHtml(normalizedValue)}">当前默认代理 #${escapeHtml(normalizedValue)}(远端已不存在)</option>`);
}
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 `<option value="${proxy.id}">${escapeHtml(label)}</option>`;
}));
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 {
+10
View File
@@ -349,6 +349,16 @@
<label for="sub2api-service-key">API Key</label>
<input type="password" id="sub2api-service-key" placeholder="编辑时留空则保持原值" autocomplete="new-password">
</div>
<div class="form-group">
<label for="sub2api-default-remote-proxy-id">默认远端代理</label>
<div class="sub2api-remote-proxy-row">
<select id="sub2api-default-remote-proxy-id" class="sub2api-remote-proxy-select">
<option value="">不设置默认代理</option>
</select>
<button type="button" class="btn btn-secondary sub2api-remote-proxy-btn" id="load-sub2api-remote-proxies-btn">加载代理</button>
</div>
<p class="sub2api-remote-proxy-hint">与账号页上传时选择的远端代理一致;仅在未显式选择代理时作为默认值。</p>
</div>
<div class="form-row">
<div class="form-group">
<label for="sub2api-service-priority">优先级</label>