Skip to content

Commit 2e47834

Browse files
committed
feat(proxy): 优化代理配置管理功能
- 更新 proxy_url 属性为 get_proxy_url 方法,支持多级代理优先级 - 实现代理获取三路优先级:动态代理 > 代理池 > 静态代理 - 添加取消默认代理功能和 unset_proxy_default 接口 - 实现批量导入代理功能,支持多种格式解析 - 在前端界面添加批量导入代理按钮和模态框 - 重构代理设置页面的默认代理切换交互 - 更新支付流程中的代理获取方式 - 添加 UUID 依赖并优化支付请求头配置
1 parent ae089ee commit 2e47834

10 files changed

Lines changed: 280 additions & 46 deletions

File tree

src/config/settings.py

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -638,25 +638,40 @@ def validate_database_url(cls, v):
638638
proxy_dynamic_api_key_header: str = "X-API-Key"
639639
proxy_dynamic_result_field: str = ""
640640

641-
@property
642-
def proxy_url(self) -> Optional[str]:
643-
"""获取完整的代理 URL"""
644-
if not self.proxy_enabled:
645-
return None
646-
647-
if self.proxy_type == "http":
648-
scheme = "http"
649-
elif self.proxy_type == "socks5":
650-
scheme = "socks5"
651-
else:
652-
return None
653-
654-
auth = ""
655-
if self.proxy_username and self.proxy_password:
656-
auth = f"{self.proxy_username}:{self.proxy_password.get_secret_value()}@"
657-
658-
return f"{scheme}://{auth}{self.proxy_host}:{self.proxy_port}"
659-
641+
def get_proxy_url(self, db=None) -> Optional[str]:
642+
"""获取当前可用的代理 URL(三路优先级)
643+
644+
优先级:动态代理 > 代理池(默认/随机)> 静态代理 > None
645+
646+
Args:
647+
db: 可选的数据库 session,传入时检查代理池;不传则跳过代理池
648+
"""
649+
# 1. 动态代理
650+
if self.proxy_dynamic_enabled and self.proxy_dynamic_api_url:
651+
return self.proxy_dynamic_api_url
652+
653+
# 2 & 3. 代理池(优先 is_default,否则随机)
654+
if db is not None:
655+
from src.database import crud
656+
proxy = crud.get_random_proxy(db)
657+
if proxy is not None:
658+
return proxy.proxy_url
659+
660+
# 4. 静态代理
661+
if self.proxy_enabled:
662+
if self.proxy_type == "http":
663+
scheme = "http"
664+
elif self.proxy_type == "socks5":
665+
scheme = "socks5"
666+
else:
667+
return None
668+
auth = ""
669+
if self.proxy_username and self.proxy_password:
670+
auth = f"{self.proxy_username}:{self.proxy_password.get_secret_value()}@"
671+
return f"{scheme}://{auth}{self.proxy_host}:{self.proxy_port}"
672+
673+
# 5. 无可用代理
674+
return None
660675
# 注册配置
661676
registration_max_retries: int = 3
662677
registration_timeout: int = 120

src/core/dynamic_proxy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,4 @@ def get_proxy_url_for_task() -> Optional[str]:
115115
logger.warning("动态代理获取失败,回退到静态代理")
116116

117117
# 使用静态代理
118-
return settings.proxy_url
118+
return settings.get_proxy_url()

src/core/openai/payment.py

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
import subprocess
77
import sys
8+
import uuid
89
from typing import Optional
910

1011
from curl_cffi import requests as cffi_requests
@@ -154,12 +155,8 @@ def generate_team_link(
154155
"Authorization": f"Bearer {account.access_token}",
155156
"Content-Type": "application/json",
156157
"oai-language": "zh-CN",
158+
"oai-device-id": str(uuid.uuid4()),
157159
}
158-
if account.cookies:
159-
headers["cookie"] = account.cookies
160-
oai_did = _extract_oai_did(account.cookies)
161-
if oai_did:
162-
headers["oai-device-id"] = oai_did
163160

164161
payload = {
165162
"plan_name": "chatgptteamplan",
@@ -171,7 +168,7 @@ def generate_team_link(
171168
"billing_details": {"country": country, "currency": currency},
172169
"promo_campaign": {
173170
"promo_campaign_id": "team-1-month-free",
174-
"is_coupon_from_query_param": True,
171+
"is_coupon_from_query_param": False,
175172
},
176173
"cancel_url": "https://chatgpt.com/#pricing",
177174
"checkout_ui_mode": "custom",
@@ -187,9 +184,35 @@ def generate_team_link(
187184
)
188185
resp.raise_for_status()
189186
data = resp.json()
190-
if "checkout_session_id" in data:
191-
return TEAM_CHECKOUT_BASE_URL + data["checkout_session_id"]
192-
raise ValueError(data.get("detail", "API 未返回 checkout_session_id"))
187+
resp2 = cffi_requests.post(
188+
"https://api.stripe.com/v1/payment_pages/" + data["checkout_session_id"],
189+
headers={
190+
"Content-Type": "application/x-www-form-urlencoded",
191+
"accept": "application/json",
192+
"referer": "https://js.stripe.com/"
193+
},
194+
data=f"tax_region[country]={country}"
195+
"&elements_session_client[client_betas][0]=custom_checkout_server_updates_1"
196+
"&elements_session_client[client_betas][1]=custom_checkout_manual_approval_1"
197+
"&elements_session_client[elements_init_source]=custom_checkout"
198+
"&elements_session_client[referrer_host]=chatgpt.com"
199+
"&elements_session_client[session_id]=elements_session_1rr8sS4PKIY"
200+
"&elements_session_client[stripe_js_id]=72d6a553-c2fb-4f85-941e-8022c8335a85"
201+
"&elements_session_client[locale]=zh"
202+
"&elements_session_client[is_aggregation_expected]=false"
203+
"&client_attribution_metadata[merchant_integration_additional_elements][0]=payment"
204+
"&client_attribution_metadata[merchant_integration_additional_elements][1]=address"
205+
f"&key={data["publishable_key"]}"
206+
,
207+
proxies=_build_proxies(proxy),
208+
timeout=30,
209+
impersonate="chrome110",
210+
)
211+
resp2.raise_for_status()
212+
data2 = resp2.json()
213+
if "stripe_hosted_url" in data2:
214+
return data2["stripe_hosted_url"]
215+
raise ValueError(data.get("detail", "API 未返回 stripe_hosted_url"))
193216

194217

195218
def open_url_incognito(url: str, cookies_str: Optional[str] = None) -> bool:

src/database/crud.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -579,6 +579,16 @@ def set_proxy_default(db: Session, proxy_id: int) -> Optional[Proxy]:
579579
return proxy
580580

581581

582+
def unset_proxy_default(db: Session, proxy_id: int) -> Optional[Proxy]:
583+
"""取消指定代理的默认标记"""
584+
proxy = db.query(Proxy).filter(Proxy.id == proxy_id).first()
585+
if proxy:
586+
proxy.is_default = False
587+
db.commit()
588+
db.refresh(proxy)
589+
return proxy
590+
591+
582592
def get_proxies_count(db: Session, enabled: Optional[bool] = None) -> int:
583593
"""获取代理数量"""
584594
query = db.query(func.count(Proxy.id))

src/web/routes/accounts.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ def _get_proxy(request_proxy: Optional[str] = None) -> Optional[str]:
135135
proxy_url = get_proxy_url_for_task()
136136
if proxy_url:
137137
return proxy_url
138-
return get_settings().proxy_url
138+
return get_settings().get_proxy_url()
139139

140140

141141
# ============== Pydantic Models ==============

src/web/routes/payment.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ def generate_payment_link(request: GenerateLinkRequest):
6666
if not account:
6767
raise HTTPException(status_code=404, detail="账号不存在")
6868

69-
proxy = request.proxy or get_settings().proxy_url
69+
proxy = request.proxy or get_settings().get_proxy_url(db=db)
7070

7171
try:
7272
if request.plan_type == "plus":
@@ -125,11 +125,10 @@ def open_browser_incognito(request: OpenIncognitoRequest):
125125
@router.post("/accounts/batch-check-subscription")
126126
def batch_check_subscription(request: BatchCheckSubscriptionRequest):
127127
"""批量检测账号订阅状态"""
128-
proxy = request.proxy or get_settings().proxy_url
129-
130128
results = {"success_count": 0, "failed_count": 0, "details": []}
131129

132130
with get_db() as db:
131+
proxy = request.proxy or get_settings().get_proxy_url(db=db)
133132
ids = resolve_account_ids(
134133
db, request.ids, request.select_all,
135134
request.status_filter, request.email_service_filter, request.search_filter

src/web/routes/registration.py

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -53,25 +53,31 @@ def get_proxy_for_registration(
5353
获取用于注册的代理
5454
5555
策略:
56-
1. 优先从代理列表中随机选择一个启用的代理
57-
2. 如果代理列表为空且启用了动态代理,调用动态代理 API 获取
58-
3. 否则使用系统设置中的静态默认代理
56+
1. 优先使用动态代理(若已启用)
57+
2. 动态代理不可用时,使用代理池(有默认代理则走默认,否则随机轮询)
58+
3. 代理池为空时,使用系统静态代理配置兜底
5959
6060
Returns:
6161
Tuple[proxy_url, proxy_id]: 代理 URL 和代理 ID(如果来自代理列表)
6262
"""
63-
# 先尝试从代理列表中获取
63+
from ...core.dynamic_proxy import get_proxy_url_for_task
64+
65+
settings = get_settings()
66+
67+
# 1. 优先动态代理
68+
if settings.proxy_dynamic_enabled and settings.proxy_dynamic_api_url:
69+
proxy_url = get_proxy_url_for_task()
70+
if proxy_url:
71+
return proxy_url, None
72+
logger.warning("动态代理获取失败,回退到代理池")
73+
74+
# 2. 代理池(内部已实现:有默认走默认,无默认随机轮询)
6475
proxy = crud.get_random_proxy(db, exclude_ids=exclude_proxy_ids)
6576
if proxy:
6677
return proxy.proxy_url, proxy.id
6778

68-
# 代理列表为空,尝试动态代理或静态代理
69-
from ...core.dynamic_proxy import get_proxy_url_for_task
70-
proxy_url = get_proxy_url_for_task()
71-
if proxy_url:
72-
return proxy_url, None
73-
74-
return None, None
79+
# 3. 静态代理兜底
80+
return settings.get_proxy_url(), None
7581

7682

7783
def update_proxy_usage(db, proxy_id: Optional[int]):

src/web/routes/settings.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,94 @@ async def set_proxy_default(proxy_id: int):
608608
return {"success": True, "proxy": proxy.to_dict()}
609609

610610

611+
@router.post("/proxies/{proxy_id}/unset-default")
612+
async def unset_proxy_default(proxy_id: int):
613+
"""取消指定代理的默认标记"""
614+
with get_db() as db:
615+
proxy = crud.unset_proxy_default(db, proxy_id)
616+
if not proxy:
617+
raise HTTPException(status_code=404, detail="代理不存在")
618+
return {"success": True, "proxy": proxy.to_dict()}
619+
620+
621+
class ProxyBatchImportRequest(BaseModel):
622+
"""批量导入代理请求"""
623+
lines: str
624+
625+
626+
def _parse_proxy_line(line: str):
627+
"""
628+
解析单行代理字符串,支持格式:
629+
- host:port
630+
- type://host:port
631+
- type://user:pass@host:port
632+
- 名称|type://user:pass@host:port
633+
"""
634+
from urllib.parse import urlparse
635+
636+
name = None
637+
# 解析可选名称前缀(竖线分隔)
638+
if '|' in line:
639+
name, line = line.split('|', 1)
640+
name = name.strip()
641+
line = line.strip()
642+
643+
# 若没有协议头,默认补 http://
644+
if '://' not in line:
645+
line = 'http://' + line
646+
647+
parsed = urlparse(line)
648+
proxy_type = (parsed.scheme or 'http').lower()
649+
if proxy_type not in ('http', 'https', 'socks5', 'socks4'):
650+
proxy_type = 'http'
651+
652+
host = parsed.hostname
653+
port = parsed.port
654+
username = parsed.username or None
655+
password = parsed.password or None
656+
657+
if not host or not port:
658+
raise ValueError(f"无法解析 host/port")
659+
660+
if not name:
661+
name = f"{proxy_type}://{host}:{port}"
662+
663+
return name, proxy_type, host, port, username, password
664+
665+
666+
@router.post("/proxies/batch-import")
667+
async def batch_import_proxies(request: ProxyBatchImportRequest):
668+
"""
669+
批量导入代理,每行格式支持:
670+
- host:port
671+
- type://host:port
672+
- type://user:pass@host:port
673+
- 名称|type://user:pass@host:port
674+
"""
675+
results = {"success": 0, "failed": 0, "errors": []}
676+
with get_db() as db:
677+
for raw_line in request.lines.splitlines():
678+
line = raw_line.strip()
679+
if not line or line.startswith("#"):
680+
continue
681+
try:
682+
name, proxy_type, host, port, username, password = _parse_proxy_line(line)
683+
crud.create_proxy(
684+
db,
685+
name=name,
686+
type=proxy_type,
687+
host=host,
688+
port=port,
689+
username=username,
690+
password=password,
691+
)
692+
results["success"] += 1
693+
except Exception as e:
694+
results["failed"] += 1
695+
results["errors"].append(f"{raw_line}: {e}")
696+
return results
697+
698+
611699
@router.post("/proxies/{proxy_id}/test")
612700
async def test_proxy_item(proxy_id: int):
613701
"""测试单个代理"""

0 commit comments

Comments
 (0)