feat(proxy): 优化代理配置管理功能

- 更新 proxy_url 属性为 get_proxy_url 方法,支持多级代理优先级
- 实现代理获取三路优先级:动态代理 > 代理池 > 静态代理
- 添加取消默认代理功能和 unset_proxy_default 接口
- 实现批量导入代理功能,支持多种格式解析
- 在前端界面添加批量导入代理按钮和模态框
- 重构代理设置页面的默认代理切换交互
- 更新支付流程中的代理获取方式
- 添加 UUID 依赖并优化支付请求头配置
This commit is contained in:
cnlimiter
2026-03-27 15:03:11 +08:00
parent ae089ee707
commit 2e47834152
10 changed files with 276 additions and 42 deletions

View File

@@ -638,25 +638,40 @@ class Settings(BaseModel):
proxy_dynamic_api_key_header: str = "X-API-Key"
proxy_dynamic_result_field: str = ""
@property
def proxy_url(self) -> Optional[str]:
"""获取完整的代理 URL"""
if not self.proxy_enabled:
return None
def get_proxy_url(self, db=None) -> Optional[str]:
"""获取当前可用的代理 URL三路优先级
if self.proxy_type == "http":
scheme = "http"
elif self.proxy_type == "socks5":
scheme = "socks5"
else:
return None
优先级:动态代理 > 代理池(默认/随机)> 静态代理 > None
auth = ""
if self.proxy_username and self.proxy_password:
auth = f"{self.proxy_username}:{self.proxy_password.get_secret_value()}@"
Args:
db: 可选的数据库 session传入时检查代理池不传则跳过代理池
"""
# 1. 动态代理
if self.proxy_dynamic_enabled and self.proxy_dynamic_api_url:
return self.proxy_dynamic_api_url
return f"{scheme}://{auth}{self.proxy_host}:{self.proxy_port}"
# 2 & 3. 代理池(优先 is_default否则随机
if db is not None:
from src.database import crud
proxy = crud.get_random_proxy(db)
if proxy is not None:
return proxy.proxy_url
# 4. 静态代理
if self.proxy_enabled:
if self.proxy_type == "http":
scheme = "http"
elif self.proxy_type == "socks5":
scheme = "socks5"
else:
return None
auth = ""
if self.proxy_username and self.proxy_password:
auth = f"{self.proxy_username}:{self.proxy_password.get_secret_value()}@"
return f"{scheme}://{auth}{self.proxy_host}:{self.proxy_port}"
# 5. 无可用代理
return None
# 注册配置
registration_max_retries: int = 3
registration_timeout: int = 120

View File

@@ -115,4 +115,4 @@ def get_proxy_url_for_task() -> Optional[str]:
logger.warning("动态代理获取失败,回退到静态代理")
# 使用静态代理
return settings.proxy_url
return settings.get_proxy_url()

View File

@@ -5,6 +5,7 @@
import logging
import subprocess
import sys
import uuid
from typing import Optional
from curl_cffi import requests as cffi_requests
@@ -154,12 +155,8 @@ def generate_team_link(
"Authorization": f"Bearer {account.access_token}",
"Content-Type": "application/json",
"oai-language": "zh-CN",
"oai-device-id": str(uuid.uuid4()),
}
if account.cookies:
headers["cookie"] = account.cookies
oai_did = _extract_oai_did(account.cookies)
if oai_did:
headers["oai-device-id"] = oai_did
payload = {
"plan_name": "chatgptteamplan",
@@ -171,7 +168,7 @@ def generate_team_link(
"billing_details": {"country": country, "currency": currency},
"promo_campaign": {
"promo_campaign_id": "team-1-month-free",
"is_coupon_from_query_param": True,
"is_coupon_from_query_param": False,
},
"cancel_url": "https://chatgpt.com/#pricing",
"checkout_ui_mode": "custom",
@@ -187,9 +184,35 @@ def generate_team_link(
)
resp.raise_for_status()
data = resp.json()
if "checkout_session_id" in data:
return TEAM_CHECKOUT_BASE_URL + data["checkout_session_id"]
raise ValueError(data.get("detail", "API 未返回 checkout_session_id"))
resp2 = cffi_requests.post(
"https://api.stripe.com/v1/payment_pages/" + data["checkout_session_id"],
headers={
"Content-Type": "application/x-www-form-urlencoded",
"accept": "application/json",
"referer": "https://js.stripe.com/"
},
data=f"tax_region[country]={country}"
"&elements_session_client[client_betas][0]=custom_checkout_server_updates_1"
"&elements_session_client[client_betas][1]=custom_checkout_manual_approval_1"
"&elements_session_client[elements_init_source]=custom_checkout"
"&elements_session_client[referrer_host]=chatgpt.com"
"&elements_session_client[session_id]=elements_session_1rr8sS4PKIY"
"&elements_session_client[stripe_js_id]=72d6a553-c2fb-4f85-941e-8022c8335a85"
"&elements_session_client[locale]=zh"
"&elements_session_client[is_aggregation_expected]=false"
"&client_attribution_metadata[merchant_integration_additional_elements][0]=payment"
"&client_attribution_metadata[merchant_integration_additional_elements][1]=address"
f"&key={data["publishable_key"]}"
,
proxies=_build_proxies(proxy),
timeout=30,
impersonate="chrome110",
)
resp2.raise_for_status()
data2 = resp2.json()
if "stripe_hosted_url" in data2:
return data2["stripe_hosted_url"]
raise ValueError(data.get("detail", "API 未返回 stripe_hosted_url"))
def open_url_incognito(url: str, cookies_str: Optional[str] = None) -> bool:

View File

@@ -579,6 +579,16 @@ def set_proxy_default(db: Session, proxy_id: int) -> Optional[Proxy]:
return proxy
def unset_proxy_default(db: Session, proxy_id: int) -> Optional[Proxy]:
"""取消指定代理的默认标记"""
proxy = db.query(Proxy).filter(Proxy.id == proxy_id).first()
if proxy:
proxy.is_default = False
db.commit()
db.refresh(proxy)
return proxy
def get_proxies_count(db: Session, enabled: Optional[bool] = None) -> int:
"""获取代理数量"""
query = db.query(func.count(Proxy.id))

View File

@@ -135,7 +135,7 @@ def _get_proxy(request_proxy: Optional[str] = None) -> Optional[str]:
proxy_url = get_proxy_url_for_task()
if proxy_url:
return proxy_url
return get_settings().proxy_url
return get_settings().get_proxy_url()
# ============== Pydantic Models ==============

View File

@@ -66,7 +66,7 @@ def generate_payment_link(request: GenerateLinkRequest):
if not account:
raise HTTPException(status_code=404, detail="账号不存在")
proxy = request.proxy or get_settings().proxy_url
proxy = request.proxy or get_settings().get_proxy_url(db=db)
try:
if request.plan_type == "plus":
@@ -125,11 +125,10 @@ def open_browser_incognito(request: OpenIncognitoRequest):
@router.post("/accounts/batch-check-subscription")
def batch_check_subscription(request: BatchCheckSubscriptionRequest):
"""批量检测账号订阅状态"""
proxy = request.proxy or get_settings().proxy_url
results = {"success_count": 0, "failed_count": 0, "details": []}
with get_db() as db:
proxy = request.proxy or get_settings().get_proxy_url(db=db)
ids = resolve_account_ids(
db, request.ids, request.select_all,
request.status_filter, request.email_service_filter, request.search_filter

View File

@@ -53,25 +53,31 @@ def get_proxy_for_registration(
获取用于注册的代理
策略:
1. 优先从代理列表中随机选择一个启用的代理
2. 如果代理列表为空且启用了动态代理,调用动态代理 API 获取
3. 否则使用系统设置中的静态默认代理
1. 优先使用动态代理(若已启用)
2. 动态代理不可用时,使用代理池(有默认代理则走默认,否则随机轮询)
3. 代理池为空时,使用系统静态代理配置兜底
Returns:
Tuple[proxy_url, proxy_id]: 代理 URL 和代理 ID如果来自代理列表
"""
# 先尝试从代理列表中获取
from ...core.dynamic_proxy import get_proxy_url_for_task
settings = get_settings()
# 1. 优先动态代理
if settings.proxy_dynamic_enabled and settings.proxy_dynamic_api_url:
proxy_url = get_proxy_url_for_task()
if proxy_url:
return proxy_url, None
logger.warning("动态代理获取失败,回退到代理池")
# 2. 代理池(内部已实现:有默认走默认,无默认随机轮询)
proxy = crud.get_random_proxy(db, exclude_ids=exclude_proxy_ids)
if proxy:
return proxy.proxy_url, proxy.id
# 代理列表为空,尝试动态代理或静态代理
from ...core.dynamic_proxy import get_proxy_url_for_task
proxy_url = get_proxy_url_for_task()
if proxy_url:
return proxy_url, None
return None, None
# 3. 静态代理兜底
return settings.get_proxy_url(), None
def update_proxy_usage(db, proxy_id: Optional[int]):

View File

@@ -608,6 +608,94 @@ async def set_proxy_default(proxy_id: int):
return {"success": True, "proxy": proxy.to_dict()}
@router.post("/proxies/{proxy_id}/unset-default")
async def unset_proxy_default(proxy_id: int):
"""取消指定代理的默认标记"""
with get_db() as db:
proxy = crud.unset_proxy_default(db, proxy_id)
if not proxy:
raise HTTPException(status_code=404, detail="代理不存在")
return {"success": True, "proxy": proxy.to_dict()}
class ProxyBatchImportRequest(BaseModel):
"""批量导入代理请求"""
lines: str
def _parse_proxy_line(line: str):
"""
解析单行代理字符串,支持格式:
- host:port
- type://host:port
- type://user:pass@host:port
- 名称|type://user:pass@host:port
"""
from urllib.parse import urlparse
name = None
# 解析可选名称前缀(竖线分隔)
if '|' in line:
name, line = line.split('|', 1)
name = name.strip()
line = line.strip()
# 若没有协议头,默认补 http://
if '://' not in line:
line = 'http://' + line
parsed = urlparse(line)
proxy_type = (parsed.scheme or 'http').lower()
if proxy_type not in ('http', 'https', 'socks5', 'socks4'):
proxy_type = 'http'
host = parsed.hostname
port = parsed.port
username = parsed.username or None
password = parsed.password or None
if not host or not port:
raise ValueError(f"无法解析 host/port")
if not name:
name = f"{proxy_type}://{host}:{port}"
return name, proxy_type, host, port, username, password
@router.post("/proxies/batch-import")
async def batch_import_proxies(request: ProxyBatchImportRequest):
"""
批量导入代理,每行格式支持:
- host:port
- type://host:port
- type://user:pass@host:port
- 名称|type://user:pass@host:port
"""
results = {"success": 0, "failed": 0, "errors": []}
with get_db() as db:
for raw_line in request.lines.splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
try:
name, proxy_type, host, port, username, password = _parse_proxy_line(line)
crud.create_proxy(
db,
name=name,
type=proxy_type,
host=host,
port=port,
username=username,
password=password,
)
results["success"] += 1
except Exception as e:
results["failed"] += 1
results["errors"].append(f"{raw_line}: {e}")
return results
@router.post("/proxies/{proxy_id}/test")
async def test_proxy_item(proxy_id: int):
"""测试单个代理"""

View File

@@ -32,6 +32,11 @@ const elements = {
addProxyBtn: document.getElementById('add-proxy-btn'),
testAllProxiesBtn: document.getElementById('test-all-proxies-btn'),
deleteDisabledProxiesBtn: document.getElementById('delete-disabled-proxies-btn'),
batchImportProxyBtn: document.getElementById('batch-import-proxy-btn'),
batchImportProxyModal: document.getElementById('batch-import-proxy-modal'),
closeBatchImportProxyModal: document.getElementById('close-batch-import-proxy-modal'),
cancelBatchImportProxyBtn: document.getElementById('cancel-batch-import-proxy-btn'),
confirmBatchImportProxyBtn: document.getElementById('confirm-batch-import-proxy-btn'),
addProxyModal: document.getElementById('add-proxy-modal'),
proxyItemForm: document.getElementById('proxy-item-form'),
closeProxyModal: document.getElementById('close-proxy-modal'),
@@ -211,6 +216,38 @@ function initEventListeners() {
elements.addProxyBtn.addEventListener('click', () => openProxyModal());
}
if (elements.batchImportProxyBtn) {
elements.batchImportProxyBtn.addEventListener('click', () => {
document.getElementById('batch-import-proxy-data').value = '';
document.getElementById('batch-import-proxy-result').innerHTML = '';
elements.batchImportProxyModal.classList.add('active');
});
}
if (elements.closeBatchImportProxyModal) {
elements.closeBatchImportProxyModal.addEventListener('click', () => {
elements.batchImportProxyModal.classList.remove('active');
});
}
if (elements.cancelBatchImportProxyBtn) {
elements.cancelBatchImportProxyBtn.addEventListener('click', () => {
elements.batchImportProxyModal.classList.remove('active');
});
}
if (elements.confirmBatchImportProxyBtn) {
elements.confirmBatchImportProxyBtn.addEventListener('click', handleBatchImportProxies);
}
if (elements.batchImportProxyModal) {
elements.batchImportProxyModal.addEventListener('click', (e) => {
if (e.target === elements.batchImportProxyModal) {
elements.batchImportProxyModal.classList.remove('active');
}
});
}
if (elements.testAllProxiesBtn) {
elements.testAllProxiesBtn.addEventListener('click', handleTestAllProxies);
}
@@ -833,7 +870,7 @@ function renderProxies(proxies) {
<td><code>${escapeHtml(proxy.host)}:${proxy.port}</code></td>
<td>
${proxy.is_default
? '<span class="status-badge active">默认</span>'
? `<button class="btn btn-ghost btn-sm" onclick="handleUnsetProxyDefault(${proxy.id})" title="取消默认">取消默认</button>`
: `<button class="btn btn-ghost btn-sm" onclick="handleSetProxyDefault(${proxy.id})" title="设为默认">设默认</button>`
}
</td>
@@ -847,7 +884,10 @@ function renderProxies(proxies) {
<div class="dropdown-menu" style="min-width:80px;">
<a href="#" class="dropdown-item" onclick="event.preventDefault();closeSettingsMoreMenu(this);testProxyItem(${proxy.id})">测试</a>
<a href="#" class="dropdown-item" onclick="event.preventDefault();closeSettingsMoreMenu(this);toggleProxyItem(${proxy.id}, ${!proxy.enabled})">${proxy.enabled ? '禁用' : '启用'}</a>
${!proxy.is_default ? `<a href="#" class="dropdown-item" onclick="event.preventDefault();closeSettingsMoreMenu(this);handleSetProxyDefault(${proxy.id})">设为默认</a>` : ''}
${proxy.is_default
? `<a href="#" class="dropdown-item" onclick="event.preventDefault();closeSettingsMoreMenu(this);handleUnsetProxyDefault(${proxy.id})">取消默认</a>`
: `<a href="#" class="dropdown-item" onclick="event.preventDefault();closeSettingsMoreMenu(this);handleSetProxyDefault(${proxy.id})">设为默认</a>`
}
</div>
</div>
<button class="btn btn-danger btn-sm" onclick="deleteProxyItem(${proxy.id})">删除</button>
@@ -880,6 +920,36 @@ async function handleSetProxyDefault(id) {
}
}
// 取消默认代理
async function handleUnsetProxyDefault(id) {
try {
await api.post(`/settings/proxies/${id}/unset-default`);
toast.success('已取消默认代理');
loadProxies();
} catch (error) {
toast.error('操作失败: ' + error.message);
}
}
// 批量导入代理
async function handleBatchImportProxies() {
const lines = document.getElementById('batch-import-proxy-data').value;
const resultEl = document.getElementById('batch-import-proxy-result');
if (!lines.trim()) {
resultEl.innerHTML = '<span style="color:var(--color-warning)">请输入代理数据</span>';
return;
}
try {
const result = await api.post('/settings/proxies/batch-import', { lines });
const color = result.failed > 0 ? 'var(--color-warning)' : 'var(--color-success)';
resultEl.innerHTML = `<span style="color:${color}">导入成功 ${result.success} 条,失败 ${result.failed} 条。</span>`
+ (result.errors.length ? '<br><pre style="font-size:0.8rem;margin-top:4px;">' + result.errors.join('\n') + '</pre>' : '');
if (result.success > 0) await loadProxies();
} catch (error) {
resultEl.innerHTML = `<span style="color:var(--color-danger)">导入失败: ${error.message}</span>`;
}
}
// 打开代理模态框
function openProxyModal(proxy = null) {
elements.proxyModalTitle.textContent = proxy ? '编辑代理' : '添加代理';

View File

@@ -96,6 +96,7 @@
<div style="display: flex; gap: var(--spacing-sm);">
<button class="btn btn-secondary btn-sm" id="test-all-proxies-btn">🔌 测试全部</button>
<button class="btn btn-danger btn-sm" id="delete-disabled-proxies-btn" disabled>🧹 删除禁用项</button>
<button class="btn btn-secondary btn-sm" id="batch-import-proxy-btn">📋 批量导入</button>
<button class="btn btn-primary btn-sm" id="add-proxy-btn"> 添加代理</button>
</div>
</div>
@@ -152,6 +153,28 @@
</div>
</div>
<!-- 批量导入代理模态框 -->
<div class="modal" id="batch-import-proxy-modal">
<div class="modal-content">
<div class="modal-header">
<h3>批量导入代理</h3>
<button class="modal-close" id="close-batch-import-proxy-modal">&times;</button>
</div>
<div class="modal-body">
<p class="text-muted" style="margin-bottom: var(--spacing-sm); font-size: 0.875rem;">每行一条,支持格式:<code>host:port</code><code>type://host:port</code><code>type://user:pass@host:port</code><code>名称|type://user:pass@host:port</code></p>
<textarea id="batch-import-proxy-data" rows="10" style="width:100%;font-family:monospace;font-size:0.85rem;" placeholder="127.0.0.1:7890
socks5://127.0.0.1:1080
http://user:pass@host:port
MyProxy|socks5://user:pass@host:port"></textarea>
<div id="batch-import-proxy-result" style="margin-top: var(--spacing-sm); font-size: 0.875rem;"></div>
</div>
<div class="modal-footer">
<button class="btn btn-ghost" id="cancel-batch-import-proxy-btn">取消</button>
<button class="btn btn-primary" id="confirm-batch-import-proxy-btn">导入</button>
</div>
</div>
</div>
<!-- 添加代理模态框 -->
<div class="modal" id="add-proxy-modal">
<div class="modal-content">