feat: SOCKS5代理支持、落地IP国家查询、设置页优化
- 代理支持SOCKS5和HTTP两种类型切换 - 落地IP查询显示国家、城市、ISP信息 - 设置页面不再隐藏已配置的值 - Airwallex API异常统一返回400+详细错误信息
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card, Form, Input, InputNumber, Button, Divider, message, Space, Descriptions, Tag } from 'antd'
|
||||
import { Card, Form, Input, InputNumber, Button, Divider, message, Space, Descriptions, Tag, Select } from 'antd'
|
||||
import { SaveOutlined, ApiOutlined, GlobalOutlined } from '@ant-design/icons'
|
||||
import { settingsApi } from '@/services/api'
|
||||
import { settingsApi, getErrorMsg } from '@/services/api'
|
||||
|
||||
interface ProxyTestResult {
|
||||
success: boolean
|
||||
proxy_configured: boolean
|
||||
proxy_url: string | null
|
||||
proxy_ip: string | null
|
||||
proxy_country: string | null
|
||||
proxy_country_code: string | null
|
||||
proxy_city: string | null
|
||||
proxy_isp: string | null
|
||||
proxy_status: string | null
|
||||
direct_ip: string | null
|
||||
direct_country: string | null
|
||||
direct_country_code: string | null
|
||||
direct_city: string | null
|
||||
direct_isp: string | null
|
||||
direct_status: string | null
|
||||
}
|
||||
|
||||
@@ -28,14 +36,12 @@ export default function Settings() {
|
||||
const data: Record<string, string> = {}
|
||||
if (Array.isArray(res.data)) {
|
||||
for (const item of res.data as { key: string; value: string }[]) {
|
||||
// Don't fill masked values into password fields
|
||||
if (item.value === '********') continue
|
||||
data[item.key] = item.value
|
||||
}
|
||||
}
|
||||
form.setFieldsValue(data)
|
||||
} catch {
|
||||
message.error('获取设置失败')
|
||||
} catch (err) {
|
||||
message.error(getErrorMsg(err, '获取设置失败'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -55,8 +61,8 @@ export default function Settings() {
|
||||
.map(([key, value]) => ({ key, value: String(value) }))
|
||||
await settingsApi.updateSettings(updates)
|
||||
message.success('设置已保存')
|
||||
} catch {
|
||||
message.error('保存设置失败')
|
||||
} catch (err) {
|
||||
message.error(getErrorMsg(err, '保存设置失败'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
@@ -71,8 +77,8 @@ export default function Settings() {
|
||||
} else {
|
||||
message.error(res.data.message || '连接测试失败')
|
||||
}
|
||||
} catch {
|
||||
message.error('连接测试失败')
|
||||
} catch (err) {
|
||||
message.error(getErrorMsg(err, '连接测试失败'))
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
@@ -89,13 +95,42 @@ export default function Settings() {
|
||||
} else {
|
||||
message.warning('代理测试失败,请检查配置')
|
||||
}
|
||||
} catch {
|
||||
message.error('代理测试请求失败')
|
||||
} catch (err) {
|
||||
message.error(getErrorMsg(err, '代理测试请求失败'))
|
||||
} finally {
|
||||
setTestingProxy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderIpInfo = (label: string, prefix: string) => {
|
||||
if (!proxyResult) return null
|
||||
const ip = proxyResult[`${prefix}_ip` as keyof ProxyTestResult] as string | null
|
||||
const country = proxyResult[`${prefix}_country` as keyof ProxyTestResult] as string | null
|
||||
const countryCode = proxyResult[`${prefix}_country_code` as keyof ProxyTestResult] as string | null
|
||||
const city = proxyResult[`${prefix}_city` as keyof ProxyTestResult] as string | null
|
||||
const isp = proxyResult[`${prefix}_isp` as keyof ProxyTestResult] as string | null
|
||||
const status = proxyResult[`${prefix}_status` as keyof ProxyTestResult] as string | null
|
||||
|
||||
if (status !== 'ok') {
|
||||
return (
|
||||
<Descriptions.Item label={label}>
|
||||
<Tag color="red">{status}</Tag>
|
||||
</Descriptions.Item>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Descriptions.Item label={label}>
|
||||
<Space direction="vertical" size={2}>
|
||||
<span><strong>IP:</strong> <Tag color="green">{ip}</Tag></span>
|
||||
<span><strong>国家:</strong> {country} ({countryCode})</span>
|
||||
{city && <span><strong>城市:</strong> {city}</span>}
|
||||
{isp && <span><strong>ISP:</strong> {isp}</span>}
|
||||
</Space>
|
||||
</Descriptions.Item>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
@@ -115,7 +150,7 @@ export default function Settings() {
|
||||
name="airwallex_api_key"
|
||||
rules={[{ required: true, message: '请输入 API Key' }]}
|
||||
>
|
||||
<Input.Password placeholder="请输入 API Key(留空则保持不变)" />
|
||||
<Input.Password placeholder="请输入 API Key" />
|
||||
</Form.Item>
|
||||
<Form.Item label="Base URL" name="airwallex_base_url">
|
||||
<Input placeholder="https://api-demo.airwallex.com/" />
|
||||
@@ -130,6 +165,12 @@ export default function Settings() {
|
||||
</Card>
|
||||
|
||||
<Card title="代理设置" style={{ marginBottom: 16 }}>
|
||||
<Form.Item label="代理类型" name="proxy_type" initialValue="socks5">
|
||||
<Select>
|
||||
<Select.Option value="socks5">SOCKS5</Select.Option>
|
||||
<Select.Option value="http">HTTP</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="代理 IP" name="proxy_ip">
|
||||
<Input placeholder="例如: 127.0.0.1" />
|
||||
</Form.Item>
|
||||
@@ -140,7 +181,7 @@ export default function Settings() {
|
||||
<Input placeholder="可选" />
|
||||
</Form.Item>
|
||||
<Form.Item label="代理密码" name="proxy_password">
|
||||
<Input.Password placeholder="可选(留空则保持不变)" />
|
||||
<Input.Password placeholder="可选" />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
@@ -158,18 +199,8 @@ export default function Settings() {
|
||||
? <Tag color="blue">{proxyResult.proxy_url}</Tag>
|
||||
: <Tag color="default">未配置</Tag>}
|
||||
</Descriptions.Item>
|
||||
{proxyResult.proxy_ip !== undefined && (
|
||||
<Descriptions.Item label="代理落地 IP">
|
||||
{proxyResult.proxy_status === 'ok'
|
||||
? <Tag color="green">{proxyResult.proxy_ip}</Tag>
|
||||
: <Tag color="red">{proxyResult.proxy_status}</Tag>}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="服务器直连 IP">
|
||||
{proxyResult.direct_status === 'ok'
|
||||
? <Tag color="green">{proxyResult.direct_ip}</Tag>
|
||||
: <Tag color="red">{proxyResult.direct_status}</Tag>}
|
||||
</Descriptions.Item>
|
||||
{proxyResult.proxy_ip !== undefined && renderIpInfo('代理落地', 'proxy')}
|
||||
{renderIpInfo('服务器直连', 'direct')}
|
||||
</Descriptions>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user