Commit ·
c0ec55b
1
Parent(s): 54a879d
feat(proxy): 支持为 chatgpt.com 上游请求配置代理
Browse files- 新增 proxy_service 持久化 data/proxy_config.json,支持环境变量 CHATGPT2API_PROXY 初始化
- account_service / image_service 的 Session 在启用时注入 http/socks 代理
- 新增 /api/proxy GET/POST 和 /api/proxy/test 路由,URL 回显时遮蔽密码
- 设置页新增「上游代理配置」卡片,支持启用开关、地址编辑、连通测试
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- .gitignore +3 -1
- services/account_service.py +2 -0
- services/api.py +48 -0
- services/image_service.py +2 -0
- services/proxy_service.py +172 -0
- web/src/app/settings/components/proxy-settings.tsx +214 -0
- web/src/app/settings/page.tsx +3 -0
- web/src/lib/api.ts +32 -0
.gitignore
CHANGED
|
@@ -14,4 +14,6 @@ config.json
|
|
| 14 |
edit
|
| 15 |
test
|
| 16 |
|
| 17 |
-
docker-compose-local.yml
|
|
|
|
|
|
|
|
|
| 14 |
edit
|
| 15 |
test
|
| 16 |
|
| 17 |
+
docker-compose-local.yml
|
| 18 |
+
|
| 19 |
+
.claude/settings.local.json
|
services/account_service.py
CHANGED
|
@@ -12,6 +12,7 @@ from datetime import datetime
|
|
| 12 |
from curl_cffi.requests import Session
|
| 13 |
|
| 14 |
from services.config import config
|
|
|
|
| 15 |
|
| 16 |
|
| 17 |
class AccountService:
|
|
@@ -399,6 +400,7 @@ class AccountService:
|
|
| 399 |
headers, impersonate = self._build_remote_headers(access_token)
|
| 400 |
print(f"[account-refresh] start {access_token[:12]}...")
|
| 401 |
session = Session(impersonate=impersonate, verify=True)
|
|
|
|
| 402 |
session.headers.update(headers)
|
| 403 |
try:
|
| 404 |
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
|
|
| 12 |
from curl_cffi.requests import Session
|
| 13 |
|
| 14 |
from services.config import config
|
| 15 |
+
from services.proxy_service import apply_proxy_to_session
|
| 16 |
|
| 17 |
|
| 18 |
class AccountService:
|
|
|
|
| 400 |
headers, impersonate = self._build_remote_headers(access_token)
|
| 401 |
print(f"[account-refresh] start {access_token[:12]}...")
|
| 402 |
session = Session(impersonate=impersonate, verify=True)
|
| 403 |
+
apply_proxy_to_session(session)
|
| 404 |
session.headers.update(headers)
|
| 405 |
try:
|
| 406 |
with ThreadPoolExecutor(max_workers=2) as executor:
|
services/api.py
CHANGED
|
@@ -14,6 +14,7 @@ from services.account_service import account_service
|
|
| 14 |
from services.chatgpt_service import ChatGPTService
|
| 15 |
from services.config import config
|
| 16 |
from services.cpa_service import cpa_config, cpa_import_service, list_remote_files
|
|
|
|
| 17 |
from services.sub2api_service import (
|
| 18 |
list_remote_accounts as sub2api_list_remote_accounts,
|
| 19 |
sub2api_config,
|
|
@@ -111,6 +112,15 @@ class Sub2APIImportRequest(BaseModel):
|
|
| 111 |
account_ids: list[str] = Field(default_factory=list)
|
| 112 |
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
def build_model_item(model_id: str) -> dict[str, object]:
|
| 115 |
return {
|
| 116 |
"id": model_id,
|
|
@@ -541,6 +551,44 @@ def create_app() -> FastAPI:
|
|
| 541 |
raise HTTPException(status_code=404, detail={"error": "server not found"})
|
| 542 |
return {"import_job": server.get("import_job")}
|
| 543 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 544 |
app.include_router(router)
|
| 545 |
|
| 546 |
@app.get("/{full_path:path}", include_in_schema=False)
|
|
|
|
| 14 |
from services.chatgpt_service import ChatGPTService
|
| 15 |
from services.config import config
|
| 16 |
from services.cpa_service import cpa_config, cpa_import_service, list_remote_files
|
| 17 |
+
from services.proxy_service import proxy_config, test_proxy
|
| 18 |
from services.sub2api_service import (
|
| 19 |
list_remote_accounts as sub2api_list_remote_accounts,
|
| 20 |
sub2api_config,
|
|
|
|
| 112 |
account_ids: list[str] = Field(default_factory=list)
|
| 113 |
|
| 114 |
|
| 115 |
+
class ProxyUpdateRequest(BaseModel):
|
| 116 |
+
enabled: bool | None = None
|
| 117 |
+
url: str | None = None
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class ProxyTestRequest(BaseModel):
|
| 121 |
+
url: str = ""
|
| 122 |
+
|
| 123 |
+
|
| 124 |
def build_model_item(model_id: str) -> dict[str, object]:
|
| 125 |
return {
|
| 126 |
"id": model_id,
|
|
|
|
| 551 |
raise HTTPException(status_code=404, detail={"error": "server not found"})
|
| 552 |
return {"import_job": server.get("import_job")}
|
| 553 |
|
| 554 |
+
# ── Upstream proxy endpoints ─────────────────────────────────────
|
| 555 |
+
|
| 556 |
+
@router.get("/api/proxy")
|
| 557 |
+
async def get_proxy(authorization: str | None = Header(default=None)):
|
| 558 |
+
require_auth_key(authorization)
|
| 559 |
+
return {"proxy": proxy_config.get_public()}
|
| 560 |
+
|
| 561 |
+
@router.post("/api/proxy")
|
| 562 |
+
async def update_proxy(body: ProxyUpdateRequest, authorization: str | None = Header(default=None)):
|
| 563 |
+
require_auth_key(authorization)
|
| 564 |
+
|
| 565 |
+
# If the client echoes back the masked form (contains `***@`), treat it as "no change"
|
| 566 |
+
# so the stored password is preserved.
|
| 567 |
+
url = body.url
|
| 568 |
+
if url is not None and "***@" in url:
|
| 569 |
+
url = None
|
| 570 |
+
|
| 571 |
+
try:
|
| 572 |
+
proxy_config.update(enabled=body.enabled, url=url)
|
| 573 |
+
except ValueError as exc:
|
| 574 |
+
raise HTTPException(status_code=400, detail={"error": str(exc)}) from exc
|
| 575 |
+
return {"proxy": proxy_config.get_public()}
|
| 576 |
+
|
| 577 |
+
@router.post("/api/proxy/test")
|
| 578 |
+
async def test_proxy_endpoint(
|
| 579 |
+
body: ProxyTestRequest,
|
| 580 |
+
authorization: str | None = Header(default=None),
|
| 581 |
+
):
|
| 582 |
+
require_auth_key(authorization)
|
| 583 |
+
candidate = (body.url or "").strip()
|
| 584 |
+
if not candidate or "***@" in candidate:
|
| 585 |
+
# Fall back to the stored url when caller omits it or sent the masked form.
|
| 586 |
+
candidate = (proxy_config.get().get("url") or "").strip()
|
| 587 |
+
if not candidate:
|
| 588 |
+
raise HTTPException(status_code=400, detail={"error": "proxy url is required"})
|
| 589 |
+
result = await run_in_threadpool(test_proxy, candidate)
|
| 590 |
+
return {"result": result}
|
| 591 |
+
|
| 592 |
app.include_router(router)
|
| 593 |
|
| 594 |
@app.get("/{full_path:path}", include_in_schema=False)
|
services/image_service.py
CHANGED
|
@@ -13,6 +13,7 @@ from curl_cffi.requests import Session
|
|
| 13 |
|
| 14 |
from services.account_service import account_service
|
| 15 |
from services import proof_of_work
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
BASE_URL = "https://chatgpt.com"
|
|
@@ -100,6 +101,7 @@ def _new_session(access_token: str) -> tuple[Session, dict]:
|
|
| 100 |
impersonate=fp.get("impersonate") or "edge101",
|
| 101 |
verify=True,
|
| 102 |
)
|
|
|
|
| 103 |
session.headers.update(
|
| 104 |
{
|
| 105 |
"user-agent": fp.get("user-agent") or USER_AGENT,
|
|
|
|
| 13 |
|
| 14 |
from services.account_service import account_service
|
| 15 |
from services import proof_of_work
|
| 16 |
+
from services.proxy_service import apply_proxy_to_session
|
| 17 |
|
| 18 |
|
| 19 |
BASE_URL = "https://chatgpt.com"
|
|
|
|
| 101 |
impersonate=fp.get("impersonate") or "edge101",
|
| 102 |
verify=True,
|
| 103 |
)
|
| 104 |
+
apply_proxy_to_session(session)
|
| 105 |
session.headers.update(
|
| 106 |
{
|
| 107 |
"user-agent": fp.get("user-agent") or USER_AGENT,
|
services/proxy_service.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Upstream proxy configuration for requests against chatgpt.com.
|
| 2 |
+
|
| 3 |
+
Only chatgpt.com traffic goes through this proxy. Internal services like
|
| 4 |
+
sub2api / CPA pools always call their own base_url directly.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
import time
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from threading import Lock
|
| 14 |
+
from urllib.parse import quote, urlparse, urlunparse
|
| 15 |
+
|
| 16 |
+
from curl_cffi.requests import Session
|
| 17 |
+
|
| 18 |
+
from services.config import DATA_DIR
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
PROXY_CONFIG_FILE = DATA_DIR / "proxy_config.json"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _clean(value: object) -> str:
|
| 25 |
+
return str(value or "").strip()
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _is_valid_proxy_url(url: str) -> bool:
|
| 29 |
+
if not url:
|
| 30 |
+
return False
|
| 31 |
+
try:
|
| 32 |
+
parsed = urlparse(url)
|
| 33 |
+
except Exception:
|
| 34 |
+
return False
|
| 35 |
+
if parsed.scheme not in {"http", "https", "socks4", "socks5", "socks5h"}:
|
| 36 |
+
return False
|
| 37 |
+
return bool(parsed.hostname)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _mask_proxy_url(url: str) -> str:
|
| 41 |
+
"""Return a display-safe version of the proxy URL where password is hidden."""
|
| 42 |
+
if not url:
|
| 43 |
+
return ""
|
| 44 |
+
try:
|
| 45 |
+
parsed = urlparse(url)
|
| 46 |
+
except Exception:
|
| 47 |
+
return url
|
| 48 |
+
if not parsed.password:
|
| 49 |
+
return url
|
| 50 |
+
user = parsed.username or ""
|
| 51 |
+
host = parsed.hostname or ""
|
| 52 |
+
port = f":{parsed.port}" if parsed.port else ""
|
| 53 |
+
netloc = f"{quote(user, safe='')}:***@{host}{port}"
|
| 54 |
+
return urlunparse(parsed._replace(netloc=netloc))
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class ProxyConfig:
|
| 58 |
+
def __init__(self, store_file: Path):
|
| 59 |
+
self._store_file = store_file
|
| 60 |
+
self._lock = Lock()
|
| 61 |
+
self._state = self._load()
|
| 62 |
+
|
| 63 |
+
def _default_state(self) -> dict:
|
| 64 |
+
env_url = _clean(os.getenv("CHATGPT2API_PROXY"))
|
| 65 |
+
if env_url and _is_valid_proxy_url(env_url):
|
| 66 |
+
return {"enabled": True, "url": env_url}
|
| 67 |
+
return {"enabled": False, "url": ""}
|
| 68 |
+
|
| 69 |
+
def _load(self) -> dict:
|
| 70 |
+
if not self._store_file.exists():
|
| 71 |
+
return self._default_state()
|
| 72 |
+
try:
|
| 73 |
+
raw = json.loads(self._store_file.read_text(encoding="utf-8"))
|
| 74 |
+
except Exception:
|
| 75 |
+
return self._default_state()
|
| 76 |
+
if not isinstance(raw, dict):
|
| 77 |
+
return self._default_state()
|
| 78 |
+
return {
|
| 79 |
+
"enabled": bool(raw.get("enabled")),
|
| 80 |
+
"url": _clean(raw.get("url")),
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
def _save(self) -> None:
|
| 84 |
+
self._store_file.parent.mkdir(parents=True, exist_ok=True)
|
| 85 |
+
self._store_file.write_text(
|
| 86 |
+
json.dumps(self._state, ensure_ascii=False, indent=2) + "\n",
|
| 87 |
+
encoding="utf-8",
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
def get(self) -> dict:
|
| 91 |
+
with self._lock:
|
| 92 |
+
return dict(self._state)
|
| 93 |
+
|
| 94 |
+
def get_public(self) -> dict:
|
| 95 |
+
"""Return a masked representation safe to expose over the API."""
|
| 96 |
+
state = self.get()
|
| 97 |
+
return {
|
| 98 |
+
"enabled": bool(state.get("enabled")),
|
| 99 |
+
"url": _mask_proxy_url(_clean(state.get("url"))),
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
def update(self, *, enabled: bool | None, url: str | None) -> dict:
|
| 103 |
+
with self._lock:
|
| 104 |
+
next_state = dict(self._state)
|
| 105 |
+
if enabled is not None:
|
| 106 |
+
next_state["enabled"] = bool(enabled)
|
| 107 |
+
if url is not None:
|
| 108 |
+
new_url = _clean(url)
|
| 109 |
+
if new_url and not _is_valid_proxy_url(new_url):
|
| 110 |
+
raise ValueError("invalid proxy url")
|
| 111 |
+
next_state["url"] = new_url
|
| 112 |
+
if next_state.get("enabled") and not next_state.get("url"):
|
| 113 |
+
raise ValueError("proxy url is required when enabled")
|
| 114 |
+
self._state = next_state
|
| 115 |
+
self._save()
|
| 116 |
+
return dict(self._state)
|
| 117 |
+
|
| 118 |
+
def get_proxies(self) -> dict[str, str] | None:
|
| 119 |
+
"""Return a dict suitable for curl_cffi's `proxies=` kwarg, or None."""
|
| 120 |
+
state = self.get()
|
| 121 |
+
if not state.get("enabled"):
|
| 122 |
+
return None
|
| 123 |
+
url = _clean(state.get("url"))
|
| 124 |
+
if not url:
|
| 125 |
+
return None
|
| 126 |
+
return {"http": url, "https": url}
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
proxy_config = ProxyConfig(PROXY_CONFIG_FILE)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def apply_proxy_to_session(session: Session) -> None:
|
| 133 |
+
"""Attach the current upstream proxy to a curl_cffi Session, if configured."""
|
| 134 |
+
proxies = proxy_config.get_proxies()
|
| 135 |
+
if proxies:
|
| 136 |
+
session.proxies.update(proxies)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def test_proxy(url: str, *, timeout: float = 15.0) -> dict:
|
| 140 |
+
"""Probe chatgpt.com through the given proxy URL. Returns {ok, status, latency_ms, error}."""
|
| 141 |
+
candidate = _clean(url)
|
| 142 |
+
if not candidate:
|
| 143 |
+
return {"ok": False, "status": 0, "latency_ms": 0, "error": "proxy url is required"}
|
| 144 |
+
if not _is_valid_proxy_url(candidate):
|
| 145 |
+
return {"ok": False, "status": 0, "latency_ms": 0, "error": "invalid proxy url"}
|
| 146 |
+
|
| 147 |
+
session = Session(impersonate="edge101", verify=True)
|
| 148 |
+
session.proxies.update({"http": candidate, "https": candidate})
|
| 149 |
+
started = time.perf_counter()
|
| 150 |
+
try:
|
| 151 |
+
response = session.get(
|
| 152 |
+
"https://chatgpt.com/api/auth/csrf",
|
| 153 |
+
headers={"user-agent": "Mozilla/5.0 (chatgpt2api proxy test)"},
|
| 154 |
+
timeout=timeout,
|
| 155 |
+
)
|
| 156 |
+
latency_ms = int((time.perf_counter() - started) * 1000)
|
| 157 |
+
return {
|
| 158 |
+
"ok": response.status_code < 500,
|
| 159 |
+
"status": int(response.status_code),
|
| 160 |
+
"latency_ms": latency_ms,
|
| 161 |
+
"error": None if response.status_code < 500 else f"HTTP {response.status_code}",
|
| 162 |
+
}
|
| 163 |
+
except Exception as exc:
|
| 164 |
+
latency_ms = int((time.perf_counter() - started) * 1000)
|
| 165 |
+
return {
|
| 166 |
+
"ok": False,
|
| 167 |
+
"status": 0,
|
| 168 |
+
"latency_ms": latency_ms,
|
| 169 |
+
"error": str(exc) or exc.__class__.__name__,
|
| 170 |
+
}
|
| 171 |
+
finally:
|
| 172 |
+
session.close()
|
web/src/app/settings/components/proxy-settings.tsx
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useRef, useState } from "react";
|
| 4 |
+
import {
|
| 5 |
+
Eye,
|
| 6 |
+
EyeOff,
|
| 7 |
+
LoaderCircle,
|
| 8 |
+
PlugZap,
|
| 9 |
+
Save,
|
| 10 |
+
Wifi,
|
| 11 |
+
} from "lucide-react";
|
| 12 |
+
import { toast } from "sonner";
|
| 13 |
+
|
| 14 |
+
import { Button } from "@/components/ui/button";
|
| 15 |
+
import { Card, CardContent } from "@/components/ui/card";
|
| 16 |
+
import { Input } from "@/components/ui/input";
|
| 17 |
+
import {
|
| 18 |
+
fetchProxy,
|
| 19 |
+
testProxy,
|
| 20 |
+
updateProxy,
|
| 21 |
+
type ProxySettings,
|
| 22 |
+
type ProxyTestResult,
|
| 23 |
+
} from "@/lib/api";
|
| 24 |
+
|
| 25 |
+
export function ProxySettingsCard() {
|
| 26 |
+
const didLoadRef = useRef(false);
|
| 27 |
+
const [settings, setSettings] = useState<ProxySettings>({ enabled: false, url: "" });
|
| 28 |
+
const [formUrl, setFormUrl] = useState("");
|
| 29 |
+
const [formEnabled, setFormEnabled] = useState(false);
|
| 30 |
+
const [showUrl, setShowUrl] = useState(false);
|
| 31 |
+
const [isLoading, setIsLoading] = useState(true);
|
| 32 |
+
const [isSaving, setIsSaving] = useState(false);
|
| 33 |
+
const [isTesting, setIsTesting] = useState(false);
|
| 34 |
+
const [testResult, setTestResult] = useState<ProxyTestResult | null>(null);
|
| 35 |
+
|
| 36 |
+
const load = async () => {
|
| 37 |
+
setIsLoading(true);
|
| 38 |
+
try {
|
| 39 |
+
const data = await fetchProxy();
|
| 40 |
+
setSettings(data.proxy);
|
| 41 |
+
setFormUrl(data.proxy.url);
|
| 42 |
+
setFormEnabled(data.proxy.enabled);
|
| 43 |
+
} catch (error) {
|
| 44 |
+
toast.error(error instanceof Error ? error.message : "加载代理配置失败");
|
| 45 |
+
} finally {
|
| 46 |
+
setIsLoading(false);
|
| 47 |
+
}
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
useEffect(() => {
|
| 51 |
+
if (didLoadRef.current) {
|
| 52 |
+
return;
|
| 53 |
+
}
|
| 54 |
+
didLoadRef.current = true;
|
| 55 |
+
void load();
|
| 56 |
+
}, []);
|
| 57 |
+
|
| 58 |
+
const urlChanged = formUrl.trim() !== settings.url;
|
| 59 |
+
const enabledChanged = formEnabled !== settings.enabled;
|
| 60 |
+
const dirty = urlChanged || enabledChanged;
|
| 61 |
+
|
| 62 |
+
const handleSave = async () => {
|
| 63 |
+
if (formEnabled && !formUrl.trim()) {
|
| 64 |
+
toast.error("启用代理时必须填写代理地址");
|
| 65 |
+
return;
|
| 66 |
+
}
|
| 67 |
+
setIsSaving(true);
|
| 68 |
+
try {
|
| 69 |
+
const payload: { enabled?: boolean; url?: string } = {};
|
| 70 |
+
if (enabledChanged) payload.enabled = formEnabled;
|
| 71 |
+
if (urlChanged) payload.url = formUrl.trim();
|
| 72 |
+
const data = await updateProxy(payload);
|
| 73 |
+
setSettings(data.proxy);
|
| 74 |
+
setFormUrl(data.proxy.url);
|
| 75 |
+
setFormEnabled(data.proxy.enabled);
|
| 76 |
+
toast.success("代理配置已保存");
|
| 77 |
+
} catch (error) {
|
| 78 |
+
toast.error(error instanceof Error ? error.message : "保存失败");
|
| 79 |
+
} finally {
|
| 80 |
+
setIsSaving(false);
|
| 81 |
+
}
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
+
const handleTest = async () => {
|
| 85 |
+
const candidate = formUrl.trim();
|
| 86 |
+
if (!candidate) {
|
| 87 |
+
toast.error("请先填写代理地址");
|
| 88 |
+
return;
|
| 89 |
+
}
|
| 90 |
+
setIsTesting(true);
|
| 91 |
+
setTestResult(null);
|
| 92 |
+
try {
|
| 93 |
+
const data = await testProxy(candidate);
|
| 94 |
+
setTestResult(data.result);
|
| 95 |
+
if (data.result.ok) {
|
| 96 |
+
toast.success(`代理可用(${data.result.latency_ms} ms,HTTP ${data.result.status})`);
|
| 97 |
+
} else {
|
| 98 |
+
toast.error(`代理不可用:${data.result.error ?? "未知错误"}`);
|
| 99 |
+
}
|
| 100 |
+
} catch (error) {
|
| 101 |
+
toast.error(error instanceof Error ? error.message : "测试代理失败");
|
| 102 |
+
} finally {
|
| 103 |
+
setIsTesting(false);
|
| 104 |
+
}
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
return (
|
| 108 |
+
<Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
|
| 109 |
+
<CardContent className="space-y-6 p-6">
|
| 110 |
+
<div className="flex items-start justify-between">
|
| 111 |
+
<div className="flex items-center gap-3">
|
| 112 |
+
<div className="flex size-10 items-center justify-center rounded-xl bg-stone-100">
|
| 113 |
+
<Wifi className="size-5 text-stone-600" />
|
| 114 |
+
</div>
|
| 115 |
+
<div>
|
| 116 |
+
<h2 className="text-lg font-semibold tracking-tight">上游代理配置</h2>
|
| 117 |
+
<p className="text-sm text-stone-500">
|
| 118 |
+
为 chatgpt.com 的请求配置出网代理,适合国内服务器部署;Sub2API / CPA 请求不受影响。
|
| 119 |
+
</p>
|
| 120 |
+
</div>
|
| 121 |
+
</div>
|
| 122 |
+
</div>
|
| 123 |
+
|
| 124 |
+
{isLoading ? (
|
| 125 |
+
<div className="flex items-center justify-center py-10">
|
| 126 |
+
<LoaderCircle className="size-5 animate-spin text-stone-400" />
|
| 127 |
+
</div>
|
| 128 |
+
) : (
|
| 129 |
+
<div className="space-y-4">
|
| 130 |
+
<label className="flex cursor-pointer items-start gap-3 rounded-xl border border-stone-200 bg-white px-4 py-3">
|
| 131 |
+
<input
|
| 132 |
+
type="checkbox"
|
| 133 |
+
className="mt-1 size-4 rounded border-stone-300 text-stone-900 focus:ring-stone-900"
|
| 134 |
+
checked={formEnabled}
|
| 135 |
+
onChange={(event) => setFormEnabled(event.target.checked)}
|
| 136 |
+
/>
|
| 137 |
+
<div className="space-y-0.5">
|
| 138 |
+
<div className="text-sm font-medium text-stone-800">启用代理</div>
|
| 139 |
+
<div className="text-sm text-stone-500">
|
| 140 |
+
关闭后 chatgpt.com 请求会直连。保存后立即生效,无需重启。
|
| 141 |
+
</div>
|
| 142 |
+
</div>
|
| 143 |
+
</label>
|
| 144 |
+
|
| 145 |
+
<div className="space-y-2">
|
| 146 |
+
<label className="flex items-center gap-1.5 text-sm font-medium text-stone-700">
|
| 147 |
+
<PlugZap className="size-3.5" />
|
| 148 |
+
代理地址
|
| 149 |
+
</label>
|
| 150 |
+
<div className="relative">
|
| 151 |
+
<Input
|
| 152 |
+
type={showUrl ? "text" : "password"}
|
| 153 |
+
value={formUrl}
|
| 154 |
+
onChange={(event) => setFormUrl(event.target.value)}
|
| 155 |
+
placeholder="http://user:pass@host:port 或 socks5://host:port"
|
| 156 |
+
className="h-11 rounded-xl border-stone-200 bg-white pr-10 font-mono text-xs"
|
| 157 |
+
/>
|
| 158 |
+
<button
|
| 159 |
+
type="button"
|
| 160 |
+
className="absolute top-1/2 right-3 -translate-y-1/2 text-stone-400 transition hover:text-stone-600"
|
| 161 |
+
onClick={() => setShowUrl((prev) => !prev)}
|
| 162 |
+
>
|
| 163 |
+
{showUrl ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
| 164 |
+
</button>
|
| 165 |
+
</div>
|
| 166 |
+
<div className="text-xs text-stone-400">
|
| 167 |
+
支持 <code className="font-mono">http / https / socks4 / socks5 / socks5h</code>。
|
| 168 |
+
密码部分展示时会被遮蔽(<code className="font-mono">***</code>),回传时保持原样即可保留密码。
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
|
| 172 |
+
{testResult ? (
|
| 173 |
+
<div
|
| 174 |
+
className={`rounded-xl border px-4 py-3 text-sm leading-6 ${
|
| 175 |
+
testResult.ok
|
| 176 |
+
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
| 177 |
+
: "border-rose-200 bg-rose-50 text-rose-800"
|
| 178 |
+
}`}
|
| 179 |
+
>
|
| 180 |
+
{testResult.ok ? (
|
| 181 |
+
<>
|
| 182 |
+
代理可用:HTTP {testResult.status},用时 {testResult.latency_ms} ms
|
| 183 |
+
</>
|
| 184 |
+
) : (
|
| 185 |
+
<>代理不可用:{testResult.error ?? "未知错误"}(用时 {testResult.latency_ms} ms)</>
|
| 186 |
+
)}
|
| 187 |
+
</div>
|
| 188 |
+
) : null}
|
| 189 |
+
|
| 190 |
+
<div className="flex items-center gap-2">
|
| 191 |
+
<Button
|
| 192 |
+
className="h-10 rounded-xl bg-stone-950 px-5 text-white hover:bg-stone-800"
|
| 193 |
+
onClick={() => void handleSave()}
|
| 194 |
+
disabled={isSaving || !dirty}
|
| 195 |
+
>
|
| 196 |
+
{isSaving ? <LoaderCircle className="size-4 animate-spin" /> : <Save className="size-4" />}
|
| 197 |
+
保存
|
| 198 |
+
</Button>
|
| 199 |
+
<Button
|
| 200 |
+
variant="outline"
|
| 201 |
+
className="h-10 rounded-xl border-stone-200 bg-white px-5 text-stone-700"
|
| 202 |
+
onClick={() => void handleTest()}
|
| 203 |
+
disabled={isTesting}
|
| 204 |
+
>
|
| 205 |
+
{isTesting ? <LoaderCircle className="size-4 animate-spin" /> : <PlugZap className="size-4" />}
|
| 206 |
+
测试连通
|
| 207 |
+
</Button>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
)}
|
| 211 |
+
</CardContent>
|
| 212 |
+
</Card>
|
| 213 |
+
);
|
| 214 |
+
}
|
web/src/app/settings/page.tsx
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
| 47 |
type CPAPool,
|
| 48 |
type CPARemoteFile,
|
| 49 |
} from "@/lib/api";
|
|
|
|
| 50 |
import { Sub2APIConnections } from "@/app/settings/components/sub2api-connections";
|
| 51 |
|
| 52 |
const PAGE_SIZE_OPTIONS = ["50", "100", "200"] as const;
|
|
@@ -305,6 +306,8 @@ export default function SettingsPage() {
|
|
| 305 |
</section>
|
| 306 |
|
| 307 |
<section className="space-y-6">
|
|
|
|
|
|
|
| 308 |
<Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
|
| 309 |
<CardContent className="space-y-6 p-6">
|
| 310 |
<div className="flex items-start justify-between">
|
|
|
|
| 47 |
type CPAPool,
|
| 48 |
type CPARemoteFile,
|
| 49 |
} from "@/lib/api";
|
| 50 |
+
import { ProxySettingsCard } from "@/app/settings/components/proxy-settings";
|
| 51 |
import { Sub2APIConnections } from "@/app/settings/components/sub2api-connections";
|
| 52 |
|
| 53 |
const PAGE_SIZE_OPTIONS = ["50", "100", "200"] as const;
|
|
|
|
| 306 |
</section>
|
| 307 |
|
| 308 |
<section className="space-y-6">
|
| 309 |
+
<ProxySettingsCard />
|
| 310 |
+
|
| 311 |
<Card className="rounded-2xl border-white/80 bg-white/90 shadow-sm">
|
| 312 |
<CardContent className="space-y-6 p-6">
|
| 313 |
<div className="flex items-start justify-between">
|
web/src/lib/api.ts
CHANGED
|
@@ -277,3 +277,35 @@ export async function startSub2APIImport(serverId: string, accountIds: string[])
|
|
| 277 |
export async function fetchSub2APIImportJob(serverId: string) {
|
| 278 |
return httpRequest<{ import_job: CPAImportJob | null }>(`/api/sub2api/servers/${serverId}/import`);
|
| 279 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
export async function fetchSub2APIImportJob(serverId: string) {
|
| 278 |
return httpRequest<{ import_job: CPAImportJob | null }>(`/api/sub2api/servers/${serverId}/import`);
|
| 279 |
}
|
| 280 |
+
|
| 281 |
+
// ── Upstream proxy ────────────────────────────────────────────────
|
| 282 |
+
|
| 283 |
+
export type ProxySettings = {
|
| 284 |
+
enabled: boolean;
|
| 285 |
+
url: string;
|
| 286 |
+
};
|
| 287 |
+
|
| 288 |
+
export type ProxyTestResult = {
|
| 289 |
+
ok: boolean;
|
| 290 |
+
status: number;
|
| 291 |
+
latency_ms: number;
|
| 292 |
+
error: string | null;
|
| 293 |
+
};
|
| 294 |
+
|
| 295 |
+
export async function fetchProxy() {
|
| 296 |
+
return httpRequest<{ proxy: ProxySettings }>("/api/proxy");
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
export async function updateProxy(updates: { enabled?: boolean; url?: string }) {
|
| 300 |
+
return httpRequest<{ proxy: ProxySettings }>("/api/proxy", {
|
| 301 |
+
method: "POST",
|
| 302 |
+
body: updates,
|
| 303 |
+
});
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
export async function testProxy(url?: string) {
|
| 307 |
+
return httpRequest<{ result: ProxyTestResult }>("/api/proxy/test", {
|
| 308 |
+
method: "POST",
|
| 309 |
+
body: { url: url ?? "" },
|
| 310 |
+
});
|
| 311 |
+
}
|