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 import httpx
from datetime import datetime, timezone, timedelta
from airwallex.client import AirwallexClient from airwallex.client import AirwallexClient
logger = logging.getLogger(__name__)
class ProxiedAirwallexClient(AirwallexClient): 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): def __init__(self, proxy_url: str | None = None, **kwargs):
self._proxy_url = proxy_url self._proxy_url = proxy_url
@@ -17,16 +21,20 @@ class ProxiedAirwallexClient(AirwallexClient):
timeout=self.request_timeout, timeout=self.request_timeout,
proxy=proxy_url, 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: def authenticate(self) -> None:
"""Override authenticate to use proxy for auth requests too.""" """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: if self._token and self._token_expiry and datetime.now(timezone.utc) < self._token_expiry:
return return
# Use a proxied client for authentication logger.info("Authenticating with Airwallex API at %s (proxy: %s)",
auth_kwargs = {"timeout": self.request_timeout} self.auth_url, bool(self._proxy_url))
auth_kwargs: dict = {"timeout": self.request_timeout}
if self._proxy_url: if self._proxy_url:
auth_kwargs["proxy"] = self._proxy_url auth_kwargs["proxy"] = self._proxy_url
@@ -41,11 +49,15 @@ class ProxiedAirwallexClient(AirwallexClient):
}, },
content="{}", 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() data = response.json()
self._token = data.get("token") 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) self._token_expiry = datetime.now(timezone.utc) + timedelta(minutes=28)
logger.info("Authentication successful, token expires in 28 minutes")
finally: finally:
auth_client.close() auth_client.close()

View File

@@ -25,10 +25,9 @@ def get_settings(
settings = db.query(SystemSetting).all() settings = db.query(SystemSetting).all()
result = [] result = []
for s in settings: for s in settings:
value = "********" if s.key in SENSITIVE_KEYS and s.value else s.value
result.append({ result.append({
"key": s.key, "key": s.key,
"value": value, "value": s.value,
"encrypted": s.encrypted, "encrypted": s.encrypted,
"updated_at": s.updated_at.isoformat() if s.updated_at else None, "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]: 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_ip = _get_setting(db, "proxy_ip")
proxy_port = _get_setting(db, "proxy_port") proxy_port = _get_setting(db, "proxy_port")
if not proxy_ip or not proxy_port: if not proxy_ip or not proxy_port:
return None 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_user = _get_setting(db, "proxy_username")
proxy_pass = _get_setting(db, "proxy_password") proxy_pass = _get_setting(db, "proxy_password")
if proxy_user and proxy_pass: if proxy_user and proxy_pass:
return f"http://{proxy_user}:{proxy_pass}@{proxy_ip}:{proxy_port}" return f"{scheme}://{proxy_user}:{proxy_pass}@{proxy_ip}:{proxy_port}"
return f"http://{proxy_ip}:{proxy_port}" return f"{scheme}://{proxy_ip}:{proxy_port}"
def get_client(db: Session) -> ProxiedAirwallexClient: def get_client(db: Session) -> ProxiedAirwallexClient:
@@ -71,7 +73,19 @@ def get_client(db: Session) -> ProxiedAirwallexClient:
def ensure_authenticated(db: Session) -> ProxiedAirwallexClient: def ensure_authenticated(db: Session) -> ProxiedAirwallexClient:
"""Get client and ensure it's authenticated.""" """Get client and ensure it's authenticated."""
client = get_client(db) 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 return client
@@ -286,7 +300,7 @@ def test_proxy(db: Session) -> Dict[str, Any]:
masked = f"{scheme_user}:****@{rest}" masked = f"{scheme_user}:****@{rest}"
result["proxy_url"] = masked 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)]: for label, use_proxy in [("proxy", True), ("direct", False)]:
if label == "proxy" and not proxy_url: if label == "proxy" and not proxy_url:
continue continue
@@ -295,12 +309,18 @@ def test_proxy(db: Session) -> Dict[str, Any]:
if use_proxy and proxy_url: if use_proxy and proxy_url:
client_kwargs["proxy"] = proxy_url client_kwargs["proxy"] = proxy_url
with httpx.Client(**client_kwargs) as client: 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() 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" result[f"{label}_status"] = "ok"
except Exception as e: except Exception as e:
result[f"{label}_ip"] = None result[f"{label}_ip"] = None
result[f"{label}_country"] = None
result[f"{label}_status"] = f"failed: {str(e)[:150]}" result[f"{label}_status"] = f"failed: {str(e)[:150]}"
result["success"] = result.get("proxy_status", result.get("direct_status")) == "ok" 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 python-dotenv==1.0.1
pydantic==2.11.3 pydantic==2.11.3
pydantic-settings==2.6.0 pydantic-settings==2.6.0
httpx==0.28.1 httpx[socks]==0.28.1
cryptography==43.0.0 cryptography==43.0.0
email-validator==2.1.1 email-validator==2.1.1

View File

@@ -1,15 +1,23 @@
import { useState, useEffect } from 'react' 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 { SaveOutlined, ApiOutlined, GlobalOutlined } from '@ant-design/icons'
import { settingsApi } from '@/services/api' import { settingsApi, getErrorMsg } from '@/services/api'
interface ProxyTestResult { interface ProxyTestResult {
success: boolean success: boolean
proxy_configured: boolean proxy_configured: boolean
proxy_url: string | null proxy_url: string | null
proxy_ip: 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 proxy_status: string | null
direct_ip: 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 direct_status: string | null
} }
@@ -28,14 +36,12 @@ export default function Settings() {
const data: Record<string, string> = {} const data: Record<string, string> = {}
if (Array.isArray(res.data)) { if (Array.isArray(res.data)) {
for (const item of res.data as { key: string; value: string }[]) { 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 data[item.key] = item.value
} }
} }
form.setFieldsValue(data) form.setFieldsValue(data)
} catch { } catch (err) {
message.error('获取设置失败') message.error(getErrorMsg(err, '获取设置失败'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -55,8 +61,8 @@ export default function Settings() {
.map(([key, value]) => ({ key, value: String(value) })) .map(([key, value]) => ({ key, value: String(value) }))
await settingsApi.updateSettings(updates) await settingsApi.updateSettings(updates)
message.success('设置已保存') message.success('设置已保存')
} catch { } catch (err) {
message.error('保存设置失败') message.error(getErrorMsg(err, '保存设置失败'))
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -71,8 +77,8 @@ export default function Settings() {
} else { } else {
message.error(res.data.message || '连接测试失败') message.error(res.data.message || '连接测试失败')
} }
} catch { } catch (err) {
message.error('连接测试失败') message.error(getErrorMsg(err, '连接测试失败'))
} finally { } finally {
setTesting(false) setTesting(false)
} }
@@ -89,13 +95,42 @@ export default function Settings() {
} else { } else {
message.warning('代理测试失败,请检查配置') message.warning('代理测试失败,请检查配置')
} }
} catch { } catch (err) {
message.error('代理测试请求失败') message.error(getErrorMsg(err, '代理测试请求失败'))
} finally { } finally {
setTestingProxy(false) 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 ( return (
<div> <div>
<div className="page-header"> <div className="page-header">
@@ -115,7 +150,7 @@ export default function Settings() {
name="airwallex_api_key" name="airwallex_api_key"
rules={[{ required: true, message: '请输入 API Key' }]} rules={[{ required: true, message: '请输入 API Key' }]}
> >
<Input.Password placeholder="请输入 API Key(留空则保持不变)" /> <Input.Password placeholder="请输入 API Key" />
</Form.Item> </Form.Item>
<Form.Item label="Base URL" name="airwallex_base_url"> <Form.Item label="Base URL" name="airwallex_base_url">
<Input placeholder="https://api-demo.airwallex.com/" /> <Input placeholder="https://api-demo.airwallex.com/" />
@@ -130,6 +165,12 @@ export default function Settings() {
</Card> </Card>
<Card title="代理设置" style={{ marginBottom: 16 }}> <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"> <Form.Item label="代理 IP" name="proxy_ip">
<Input placeholder="例如: 127.0.0.1" /> <Input placeholder="例如: 127.0.0.1" />
</Form.Item> </Form.Item>
@@ -140,7 +181,7 @@ export default function Settings() {
<Input placeholder="可选" /> <Input placeholder="可选" />
</Form.Item> </Form.Item>
<Form.Item label="代理密码" name="proxy_password"> <Form.Item label="代理密码" name="proxy_password">
<Input.Password placeholder="可选(留空则保持不变)" /> <Input.Password placeholder="可选" />
</Form.Item> </Form.Item>
<Space> <Space>
<Button <Button
@@ -158,18 +199,8 @@ export default function Settings() {
? <Tag color="blue">{proxyResult.proxy_url}</Tag> ? <Tag color="blue">{proxyResult.proxy_url}</Tag>
: <Tag color="default"></Tag>} : <Tag color="default"></Tag>}
</Descriptions.Item> </Descriptions.Item>
{proxyResult.proxy_ip !== undefined && ( {proxyResult.proxy_ip !== undefined && renderIpInfo('代理落地', 'proxy')}
<Descriptions.Item label="代理落地 IP"> {renderIpInfo('服务器直连', 'direct')}
{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>
</Descriptions> </Descriptions>
)} )}
</Card> </Card>