feat: SOCKS5代理支持、落地IP国家查询、设置页优化

- 代理支持SOCKS5和HTTP两种类型切换
- 落地IP查询显示国家、城市、ISP信息
- 设置页面不再隐藏已配置的值
- Airwallex API异常统一返回400+详细错误信息
This commit is contained in:
zqq61
2026-03-16 00:02:59 +08:00
parent c28090e75d
commit 01773500af
5 changed files with 107 additions and 45 deletions

View File

@@ -1,10 +1,14 @@
"""Airwallex client with proxy support."""
"""Airwallex client with proxy support (HTTP and SOCKS5)."""
import logging
import httpx
from datetime import datetime, timezone, timedelta
from airwallex.client import AirwallexClient
logger = logging.getLogger(__name__)
class ProxiedAirwallexClient(AirwallexClient):
"""AirwallexClient that routes requests through an HTTP proxy."""
"""AirwallexClient that routes requests through an HTTP or SOCKS5 proxy."""
def __init__(self, proxy_url: str | None = None, **kwargs):
self._proxy_url = proxy_url
@@ -17,16 +21,20 @@ class ProxiedAirwallexClient(AirwallexClient):
timeout=self.request_timeout,
proxy=proxy_url,
)
safe_url = proxy_url.split("@")[-1] if "@" in proxy_url else proxy_url
logger.info("Airwallex client initialized with proxy: %s", safe_url)
else:
logger.info("Airwallex client initialized without proxy")
def authenticate(self) -> None:
"""Override authenticate to use proxy for auth requests too."""
from datetime import datetime, timezone
if self._token and self._token_expiry and datetime.now(timezone.utc) < self._token_expiry:
return
# Use a proxied client for authentication
auth_kwargs = {"timeout": self.request_timeout}
logger.info("Authenticating with Airwallex API at %s (proxy: %s)",
self.auth_url, bool(self._proxy_url))
auth_kwargs: dict = {"timeout": self.request_timeout}
if self._proxy_url:
auth_kwargs["proxy"] = self._proxy_url
@@ -41,11 +49,15 @@ class ProxiedAirwallexClient(AirwallexClient):
},
content="{}",
)
response.raise_for_status()
if response.status_code != 200:
body = response.text[:300]
logger.error("Auth failed: HTTP %d, body: %s", response.status_code, body)
response.raise_for_status()
data = response.json()
self._token = data.get("token")
# Token valid for 30 minutes, refresh a bit early
from datetime import timedelta
self._token_expiry = datetime.now(timezone.utc) + timedelta(minutes=28)
logger.info("Authentication successful, token expires in 28 minutes")
finally:
auth_client.close()

View File

@@ -25,10 +25,9 @@ def get_settings(
settings = db.query(SystemSetting).all()
result = []
for s in settings:
value = "********" if s.key in SENSITIVE_KEYS and s.value else s.value
result.append({
"key": s.key,
"value": value,
"value": s.value,
"encrypted": s.encrypted,
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
})

View File

@@ -23,16 +23,18 @@ def _get_setting(db: Session, key: str, default: str = "") -> str:
def _build_proxy_url(db: Session) -> Optional[str]:
"""Build proxy URL from settings."""
"""Build proxy URL from settings. Supports http and socks5."""
proxy_ip = _get_setting(db, "proxy_ip")
proxy_port = _get_setting(db, "proxy_port")
if not proxy_ip or not proxy_port:
return None
proxy_type = _get_setting(db, "proxy_type", "socks5") # default socks5
scheme = "socks5" if proxy_type == "socks5" else "http"
proxy_user = _get_setting(db, "proxy_username")
proxy_pass = _get_setting(db, "proxy_password")
if proxy_user and proxy_pass:
return f"http://{proxy_user}:{proxy_pass}@{proxy_ip}:{proxy_port}"
return f"http://{proxy_ip}:{proxy_port}"
return f"{scheme}://{proxy_user}:{proxy_pass}@{proxy_ip}:{proxy_port}"
return f"{scheme}://{proxy_ip}:{proxy_port}"
def get_client(db: Session) -> ProxiedAirwallexClient:
@@ -71,7 +73,19 @@ def get_client(db: Session) -> ProxiedAirwallexClient:
def ensure_authenticated(db: Session) -> ProxiedAirwallexClient:
"""Get client and ensure it's authenticated."""
client = get_client(db)
client.authenticate()
try:
client.authenticate()
except HTTPException:
raise
except Exception as e:
error_msg = str(e)
if hasattr(e, "response"):
try:
detail = e.response.json()
error_msg = f"Airwallex API 错误 ({e.response.status_code}): {detail.get('message', detail)}"
except Exception:
error_msg = f"Airwallex API 错误 ({e.response.status_code}): {e.response.text[:200]}"
raise HTTPException(status_code=400, detail=error_msg)
return client
@@ -286,7 +300,7 @@ def test_proxy(db: Session) -> Dict[str, Any]:
masked = f"{scheme_user}:****@{rest}"
result["proxy_url"] = masked
# Query outbound IP — with proxy if configured, otherwise direct
# Query outbound IP + country — with proxy if configured, otherwise direct
for label, use_proxy in [("proxy", True), ("direct", False)]:
if label == "proxy" and not proxy_url:
continue
@@ -295,12 +309,18 @@ def test_proxy(db: Session) -> Dict[str, Any]:
if use_proxy and proxy_url:
client_kwargs["proxy"] = proxy_url
with httpx.Client(**client_kwargs) as client:
resp = client.get("https://api.ipify.org?format=json")
# Use ip-api.com for IP + country info
resp = client.get("http://ip-api.com/json/?fields=query,country,countryCode,city,isp")
ip_data = resp.json()
result[f"{label}_ip"] = ip_data.get("ip", "unknown")
result[f"{label}_ip"] = ip_data.get("query", "unknown")
result[f"{label}_country"] = ip_data.get("country", "unknown")
result[f"{label}_country_code"] = ip_data.get("countryCode", "")
result[f"{label}_city"] = ip_data.get("city", "")
result[f"{label}_isp"] = ip_data.get("isp", "")
result[f"{label}_status"] = "ok"
except Exception as e:
result[f"{label}_ip"] = None
result[f"{label}_country"] = None
result[f"{label}_status"] = f"failed: {str(e)[:150]}"
result["success"] = result.get("proxy_status", result.get("direct_status")) == "ok"

View File

@@ -7,6 +7,6 @@ bcrypt==4.1.3
python-dotenv==1.0.1
pydantic==2.11.3
pydantic-settings==2.6.0
httpx==0.28.1
httpx[socks]==0.28.1
cryptography==43.0.0
email-validator==2.1.1