fix: 改进错误提示、代理测试和落地IP查询

- 前端所有页面显示后端真实错误信息,不再显示通用"失败"
- 新增代理测试功能和落地IP查询
- 修复凭证未配置时返回500改为400+中文提示
- 修复Settings页面字段名与后端一致(proxy_ip)
- 修复favicon 404、bcrypt版本兼容、tsconfig配置
This commit is contained in:
zqq61
2026-03-15 23:39:02 +08:00
parent 4f53889a8e
commit 1b8b2c0bd6
14 changed files with 3299 additions and 70 deletions

View File

@@ -83,3 +83,12 @@ def test_connection(
): ):
"""Test Airwallex API connection with current settings.""" """Test Airwallex API connection with current settings."""
return airwallex_service.test_connection(db) return airwallex_service.test_connection(db)
@router.post("/test-proxy")
def test_proxy(
db: Session = Depends(get_db),
user: AdminUser = Depends(get_current_user),
):
"""Test proxy connectivity and query outbound IP."""
return airwallex_service.test_proxy(db)

View File

@@ -5,6 +5,7 @@ from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from fastapi import HTTPException
from app.models.db_models import SystemSetting from app.models.db_models import SystemSetting
from app.proxy_client import ProxiedAirwallexClient from app.proxy_client import ProxiedAirwallexClient
@@ -44,7 +45,7 @@ def get_client(db: Session) -> ProxiedAirwallexClient:
proxy_url = _build_proxy_url(db) proxy_url = _build_proxy_url(db)
if not client_id or not api_key: if not client_id or not api_key:
raise ValueError("Airwallex credentials not configured. Please set client_id and api_key in Settings.") raise HTTPException(status_code=400, detail="Airwallex 凭证未配置,请在系统设置中填写 Client ID 和 API Key")
config_hash = f"{client_id}:{api_key}:{base_url}:{proxy_url}" config_hash = f"{client_id}:{api_key}:{base_url}:{proxy_url}"
if _client_instance and _client_config_hash == config_hash: if _client_instance and _client_config_hash == config_hash:
@@ -249,10 +250,58 @@ def get_issuing_config(db: Session) -> Optional[Dict[str, Any]]:
def test_connection(db: Session) -> Dict[str, Any]: def test_connection(db: Session) -> Dict[str, Any]:
"""Test Airwallex API connection.""" """Test Airwallex API connection with detailed error info."""
try: try:
client = get_client(db) client = get_client(db)
client.authenticate() client.authenticate()
return {"success": True, "message": "Connection successful"} return {"success": True, "message": "连接成功,认证通过"}
except HTTPException as e:
return {"success": False, "message": e.detail}
except Exception as e: except Exception as e:
return {"success": False, "message": str(e)} error_msg = str(e)
# Try to extract more detail from httpx response errors
if hasattr(e, "response"):
try:
detail = e.response.json()
error_msg = f"HTTP {e.response.status_code}: {detail.get('message', detail)}"
except Exception:
error_msg = f"HTTP {e.response.status_code}: {e.response.text[:200]}"
return {"success": False, "message": error_msg}
def test_proxy(db: Session) -> Dict[str, Any]:
"""Test proxy connectivity and return the outbound IP address."""
import httpx
proxy_url = _build_proxy_url(db)
result: Dict[str, Any] = {"proxy_configured": bool(proxy_url), "proxy_url": None}
if proxy_url:
# Mask password in display
masked = proxy_url
if "@" in proxy_url:
prefix, rest = proxy_url.rsplit("@", 1)
if ":" in prefix:
scheme_user = prefix.rsplit(":", 1)[0]
masked = f"{scheme_user}:****@{rest}"
result["proxy_url"] = masked
# Query outbound IP — with proxy if configured, otherwise direct
for label, use_proxy in [("proxy", True), ("direct", False)]:
if label == "proxy" and not proxy_url:
continue
try:
client_kwargs: Dict[str, Any] = {"timeout": 10}
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")
ip_data = resp.json()
result[f"{label}_ip"] = ip_data.get("ip", "unknown")
result[f"{label}_status"] = "ok"
except Exception as e:
result[f"{label}_ip"] = None
result[f"{label}_status"] = f"failed: {str(e)[:150]}"
result["success"] = result.get("proxy_status", result.get("direct_status")) == "ok"
return result

3111
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Modal, Form, Input, Select, InputNumber, Divider, message } from 'antd' import { Modal, Form, Input, Select, InputNumber, Divider, message } from 'antd'
import { cardsApi, cardholdersApi } from '@/services/api' import { cardsApi, cardholdersApi, getErrorMsg } from '@/services/api'
interface Props { interface Props {
open: boolean open: boolean
@@ -29,8 +29,8 @@ export default function CreateCardModal({ open, onClose, onSuccess }: Props) {
.then((res) => { .then((res) => {
setCardholders(res.data.items || res.data || []) setCardholders(res.data.items || res.data || [])
}) })
.catch(() => { .catch((err) => {
message.error('获取持卡人列表失败') message.error(getErrorMsg(err, '获取持卡人列表失败'))
}) })
.finally(() => setChLoading(false)) .finally(() => setChLoading(false))
} }
@@ -43,9 +43,8 @@ export default function CreateCardModal({ open, onClose, onSuccess }: Props) {
message.success('卡片创建成功') message.success('卡片创建成功')
form.resetFields() form.resetFields()
onSuccess() onSuccess()
} catch (err: unknown) { } catch (err) {
const error = err as { response?: { data?: { detail?: string } } } message.error(getErrorMsg(err, '创建卡片失败'))
message.error(error.response?.data?.detail || '创建卡片失败')
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { Modal, Form, Input, DatePicker, Select, Row, Col, message } from 'antd' import { Modal, Form, Input, DatePicker, Select, Row, Col, message } from 'antd'
import { cardholdersApi } from '@/services/api' import { cardholdersApi, getErrorMsg } from '@/services/api'
interface Props { interface Props {
open: boolean open: boolean
@@ -36,9 +36,8 @@ export default function CreateCardholderModal({ open, onClose, onSuccess }: Prop
message.success('持卡人创建成功') message.success('持卡人创建成功')
form.resetFields() form.resetFields()
onSuccess() onSuccess()
} catch (err: unknown) { } catch (err) {
const error = err as { response?: { data?: { detail?: string } } } message.error(getErrorMsg(err, '创建持卡人失败'))
message.error(error.response?.data?.detail || '创建持卡人失败')
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -16,7 +16,7 @@ import {
import { PlusOutlined, DeleteOutlined, CopyOutlined } from '@ant-design/icons' import { PlusOutlined, DeleteOutlined, CopyOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table' import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { tokensApi } from '@/services/api' import { tokensApi, getErrorMsg } from '@/services/api'
const { Paragraph } = Typography const { Paragraph } = Typography
@@ -49,8 +49,8 @@ export default function ApiTokens() {
try { try {
const res = await tokensApi.getTokens() const res = await tokensApi.getTokens()
setData(res.data.items || res.data || []) setData(res.data.items || res.data || [])
} catch { } catch (err) {
message.error('获取令牌列表失败') message.error(getErrorMsg(err, '获取令牌列表失败'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -71,8 +71,8 @@ export default function ApiTokens() {
setModalOpen(false) setModalOpen(false)
form.resetFields() form.resetFields()
fetchData() fetchData()
} catch { } catch (err) {
message.error('创建令牌失败') message.error(getErrorMsg(err, '创建令牌失败'))
} }
} }
@@ -81,8 +81,8 @@ export default function ApiTokens() {
await tokensApi.deleteToken(id) await tokensApi.deleteToken(id)
message.success('令牌已删除') message.success('令牌已删除')
fetchData() fetchData()
} catch { } catch (err) {
message.error('删除令牌失败') message.error(getErrorMsg(err, '删除令牌失败'))
} }
} }

View File

@@ -3,7 +3,7 @@ import { Table, DatePicker, message } from 'antd'
import type { ColumnsType } from 'antd/es/table' import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { Dayjs } from 'dayjs' import type { Dayjs } from 'dayjs'
import { auditLogsApi } from '@/services/api' import { auditLogsApi, getErrorMsg } from '@/services/api'
const { RangePicker } = DatePicker const { RangePicker } = DatePicker
@@ -37,8 +37,8 @@ export default function AuditLog() {
const res = await auditLogsApi.getAuditLogs(params) const res = await auditLogsApi.getAuditLogs(params)
setData(res.data.items || res.data || []) setData(res.data.items || res.data || [])
setTotal(res.data.total || 0) setTotal(res.data.total || 0)
} catch { } catch (err) {
message.error('获取操作日志失败') message.error(getErrorMsg(err, '获取操作日志失败'))
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -22,7 +22,7 @@ import {
} from '@ant-design/icons' } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table' import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { cardsApi, transactionsApi } from '@/services/api' import { cardsApi, transactionsApi, getErrorMsg } from '@/services/api'
interface CardInfo { interface CardInfo {
card_id: string card_id: string
@@ -75,8 +75,8 @@ export default function CardDetail() {
try { try {
const res = await cardsApi.getCard(id) const res = await cardsApi.getCard(id)
setCard(res.data) setCard(res.data)
} catch { } catch (err) {
message.error('获取卡片信息失败') message.error(getErrorMsg(err, '获取卡片信息失败'))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -111,8 +111,8 @@ export default function CardDetail() {
const res = await cardsApi.getCardDetails(id) const res = await cardsApi.getCardDetails(id)
setCard((prev) => (prev ? { ...prev, ...res.data } : prev)) setCard((prev) => (prev ? { ...prev, ...res.data } : prev))
setShowSensitive(true) setShowSensitive(true)
} catch { } catch (err) {
message.error('获取敏感信息失败') message.error(getErrorMsg(err, '获取敏感信息失败'))
} finally { } finally {
setSensitiveLoading(false) setSensitiveLoading(false)
} }
@@ -126,8 +126,8 @@ export default function CardDetail() {
}) })
message.success(action === 'activate' ? '卡片已激活' : '卡片已冻结') message.success(action === 'activate' ? '卡片已激活' : '卡片已冻结')
fetchCard() fetchCard()
} catch { } catch (err) {
message.error('操作失败') message.error(getErrorMsg(err, '操作失败'))
} }
} }
@@ -138,8 +138,8 @@ export default function CardDetail() {
message.success('限额更新成功') message.success('限额更新成功')
setLimitModalOpen(false) setLimitModalOpen(false)
fetchCard() fetchCard()
} catch { } catch (err) {
message.error('更新限额失败') message.error(getErrorMsg(err, '更新限额失败'))
} }
} }

View File

@@ -3,7 +3,7 @@ import { Table, Button, Tag, message } from 'antd'
import { PlusOutlined } from '@ant-design/icons' import { PlusOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table' import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { cardholdersApi } from '@/services/api' import { cardholdersApi, getErrorMsg } from '@/services/api'
import CreateCardholderModal from '@/components/CreateCardholderModal' import CreateCardholderModal from '@/components/CreateCardholderModal'
interface Cardholder { interface Cardholder {
@@ -29,8 +29,8 @@ export default function Cardholders() {
const res = await cardholdersApi.getCardholders({ page, page_size: pageSize }) const res = await cardholdersApi.getCardholders({ page, page_size: pageSize })
setData(res.data.items || res.data) setData(res.data.items || res.data)
setTotal(res.data.total || 0) setTotal(res.data.total || 0)
} catch { } catch (err) {
message.error('获取持卡人列表失败') message.error(getErrorMsg(err, '获取持卡人列表失败'))
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -4,7 +4,7 @@ import { Table, Button, Select, Input, Tag, Space, message } from 'antd'
import { PlusOutlined, SearchOutlined } from '@ant-design/icons' import { PlusOutlined, SearchOutlined } from '@ant-design/icons'
import type { ColumnsType } from 'antd/es/table' import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { cardsApi } from '@/services/api' import { cardsApi, getErrorMsg } from '@/services/api'
import CreateCardModal from '@/components/CreateCardModal' import CreateCardModal from '@/components/CreateCardModal'
interface CardRecord { interface CardRecord {
@@ -43,8 +43,8 @@ export default function Cards() {
const res = await cardsApi.getCards(params) const res = await cardsApi.getCards(params)
setData(res.data.items || res.data) setData(res.data.items || res.data)
setTotal(res.data.total || 0) setTotal(res.data.total || 0)
} catch { } catch (err) {
message.error('获取卡片列表失败') message.error(getErrorMsg(err, '获取卡片列表失败'))
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -7,7 +7,7 @@ import {
PlusCircleOutlined, PlusCircleOutlined,
ReloadOutlined, ReloadOutlined,
} from '@ant-design/icons' } from '@ant-design/icons'
import { dashboardApi } from '@/services/api' import { dashboardApi, getErrorMsg } from '@/services/api'
interface DashboardData { interface DashboardData {
account_balance: number account_balance: number
@@ -26,8 +26,8 @@ export default function Dashboard() {
try { try {
const res = await dashboardApi.getDashboard() const res = await dashboardApi.getDashboard()
setData(res.data) setData(res.data)
} catch { } catch (err) {
message.error('获取仪表板数据失败') message.error(getErrorMsg(err, '获取仪表板数据失败'))
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -1,18 +1,16 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Card, Form, Input, InputNumber, Button, Divider, message, Space } from 'antd' import { Card, Form, Input, InputNumber, Button, Divider, message, Space, Descriptions, Tag } from 'antd'
import { SaveOutlined, ApiOutlined } from '@ant-design/icons' import { SaveOutlined, ApiOutlined, GlobalOutlined } from '@ant-design/icons'
import { settingsApi } from '@/services/api' import { settingsApi } from '@/services/api'
import api from '@/services/api'
interface SettingsData { interface ProxyTestResult {
airwallex_client_id: string success: boolean
airwallex_api_key: string proxy_configured: boolean
airwallex_base_url: string proxy_url: string | null
proxy_host: string proxy_ip: string | null
proxy_port: number | null proxy_status: string | null
proxy_username: string direct_ip: string | null
proxy_password: string direct_status: string | null
daily_card_limit: number
} }
export default function Settings() { export default function Settings() {
@@ -20,15 +18,18 @@ export default function Settings() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false) const [testing, setTesting] = useState(false)
const [testingProxy, setTestingProxy] = useState(false)
const [proxyResult, setProxyResult] = useState<ProxyTestResult | null>(null)
const fetchSettings = async () => { const fetchSettings = async () => {
setLoading(true) setLoading(true)
try { try {
const res = await settingsApi.getSettings() const res = await settingsApi.getSettings()
// Backend returns a list of {key, value} — convert to object for form
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) { 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
} }
} }
@@ -45,12 +46,12 @@ export default function Settings() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
const handleSave = async (values: SettingsData) => { const handleSave = async () => {
setSaving(true) setSaving(true)
try { try {
// Convert form object to list of {key, value} for backend const values = await form.validateFields()
const updates = Object.entries(values) const updates = Object.entries(values)
.filter(([, v]) => v !== undefined && v !== null) .filter(([, v]) => v !== undefined && v !== null && v !== '')
.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('设置已保存')
@@ -64,9 +65,9 @@ export default function Settings() {
const handleTestConnection = async () => { const handleTestConnection = async () => {
setTesting(true) setTesting(true)
try { try {
const res = await api.post('/settings/test-connection') const res = await settingsApi.testConnection()
if (res.data.success) { if (res.data.success) {
message.success('连接测试成功') message.success(res.data.message || '连接测试成功')
} else { } else {
message.error(res.data.message || '连接测试失败') message.error(res.data.message || '连接测试失败')
} }
@@ -77,12 +78,30 @@ export default function Settings() {
} }
} }
const handleTestProxy = async () => {
setTestingProxy(true)
setProxyResult(null)
try {
const res = await settingsApi.testProxy()
setProxyResult(res.data)
if (res.data.success) {
message.success('代理测试成功')
} else {
message.warning('代理测试失败,请检查配置')
}
} catch {
message.error('代理测试请求失败')
} finally {
setTestingProxy(false)
}
}
return ( return (
<div> <div>
<div className="page-header"> <div className="page-header">
<h2></h2> <h2></h2>
</div> </div>
<Form form={form} layout="vertical" onFinish={handleSave} disabled={loading}> <Form form={form} layout="vertical" disabled={loading}>
<Card title="Airwallex 凭证" style={{ marginBottom: 16 }}> <Card title="Airwallex 凭证" style={{ marginBottom: 16 }}>
<Form.Item <Form.Item
label="Client ID" label="Client ID"
@@ -96,10 +115,10 @@ 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/" />
</Form.Item> </Form.Item>
<Button <Button
icon={<ApiOutlined />} icon={<ApiOutlined />}
@@ -111,7 +130,7 @@ export default function Settings() {
</Card> </Card>
<Card title="代理设置" style={{ marginBottom: 16 }}> <Card title="代理设置" style={{ marginBottom: 16 }}>
<Form.Item label="代理 IP" name="proxy_host"> <Form.Item label="代理 IP" name="proxy_ip">
<Input placeholder="例如: 127.0.0.1" /> <Input placeholder="例如: 127.0.0.1" />
</Form.Item> </Form.Item>
<Form.Item label="代理端口" name="proxy_port"> <Form.Item label="代理端口" name="proxy_port">
@@ -121,8 +140,38 @@ 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>
<Button
icon={<GlobalOutlined />}
onClick={handleTestProxy}
loading={testingProxy}
>
/ IP
</Button>
</Space>
{proxyResult && (
<Descriptions bordered size="small" column={1} style={{ marginTop: 16 }}>
<Descriptions.Item label="代理配置">
{proxyResult.proxy_configured
? <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>
</Descriptions>
)}
</Card> </Card>
<Card title="卡片限制" style={{ marginBottom: 16 }}> <Card title="卡片限制" style={{ marginBottom: 16 }}>
@@ -133,7 +182,7 @@ export default function Settings() {
<Divider /> <Divider />
<Space> <Space>
<Button type="primary" htmlType="submit" icon={<SaveOutlined />} loading={saving}> <Button type="primary" onClick={handleSave} icon={<SaveOutlined />} loading={saving}>
</Button> </Button>
</Space> </Space>

View File

@@ -3,7 +3,7 @@ import { Table, Tag, DatePicker, Input, Select, Tabs, message } from 'antd'
import type { ColumnsType } from 'antd/es/table' import type { ColumnsType } from 'antd/es/table'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { Dayjs } from 'dayjs' import type { Dayjs } from 'dayjs'
import { transactionsApi, authorizationsApi } from '@/services/api' import { transactionsApi, authorizationsApi, getErrorMsg } from '@/services/api'
const { RangePicker } = DatePicker const { RangePicker } = DatePicker
@@ -68,8 +68,8 @@ export default function Transactions() {
setAuthData(res.data.items || res.data) setAuthData(res.data.items || res.data)
setTotal(res.data.total || 0) setTotal(res.data.total || 0)
} }
} catch { } catch (err) {
message.error('获取数据失败') message.error(getErrorMsg(err, '获取数据失败'))
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -25,6 +25,18 @@ api.interceptors.response.use(
} }
) )
/** Extract readable error message from axios error */
export function getErrorMsg(err: unknown, fallback = '操作失败'): string {
if (axios.isAxiosError(err)) {
const detail = err.response?.data?.detail
if (typeof detail === 'string') return detail
if (typeof detail === 'object' && detail?.msg) return detail.msg
if (err.response?.data?.message) return err.response.data.message
if (err.response?.status) return `请求失败 (HTTP ${err.response.status})`
}
return fallback
}
export const authApi = { export const authApi = {
login: (username: string, password: string) => login: (username: string, password: string) =>
api.post('/auth/login', { username, password }), api.post('/auth/login', { username, password }),
@@ -71,6 +83,7 @@ export const settingsApi = {
getSettings: () => api.get('/settings'), getSettings: () => api.get('/settings'),
updateSettings: (data: { key: string; value: string }[]) => api.put('/settings', data), updateSettings: (data: { key: string; value: string }[]) => api.put('/settings', data),
testConnection: () => api.post('/settings/test-connection'), testConnection: () => api.post('/settings/test-connection'),
testProxy: () => api.post('/settings/test-proxy'),
} }
export const cardLogsApi = { export const cardLogsApi = {