- 前端所有页面显示后端真实错误信息,不再显示通用"失败" - 新增代理测试功能和落地IP查询 - 修复凭证未配置时返回500改为400+中文提示 - 修复Settings页面字段名与后端一致(proxy_ip) - 修复favicon 404、bcrypt版本兼容、tsconfig配置
308 lines
11 KiB
Python
308 lines
11 KiB
Python
"""Airwallex API service layer."""
|
|
import json
|
|
import logging
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from fastapi import HTTPException
|
|
from app.models.db_models import SystemSetting
|
|
from app.proxy_client import ProxiedAirwallexClient
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Cached client instance
|
|
_client_instance: Optional[ProxiedAirwallexClient] = None
|
|
_client_config_hash: Optional[str] = None
|
|
|
|
|
|
def _get_setting(db: Session, key: str, default: str = "") -> str:
|
|
"""Get a system setting value."""
|
|
setting = db.query(SystemSetting).filter(SystemSetting.key == key).first()
|
|
return setting.value if setting else default
|
|
|
|
|
|
def _build_proxy_url(db: Session) -> Optional[str]:
|
|
"""Build proxy URL from settings."""
|
|
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_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}"
|
|
|
|
|
|
def get_client(db: Session) -> ProxiedAirwallexClient:
|
|
"""Get or create an Airwallex client with current settings."""
|
|
global _client_instance, _client_config_hash
|
|
|
|
client_id = _get_setting(db, "airwallex_client_id")
|
|
api_key = _get_setting(db, "airwallex_api_key")
|
|
base_url = _get_setting(db, "airwallex_base_url", "https://api.airwallex.com/")
|
|
proxy_url = _build_proxy_url(db)
|
|
|
|
if not client_id or not api_key:
|
|
raise HTTPException(status_code=400, detail="Airwallex 凭证未配置,请在系统设置中填写 Client ID 和 API Key")
|
|
|
|
config_hash = f"{client_id}:{api_key}:{base_url}:{proxy_url}"
|
|
if _client_instance and _client_config_hash == config_hash:
|
|
return _client_instance
|
|
|
|
# Close old client
|
|
if _client_instance:
|
|
try:
|
|
_client_instance.close()
|
|
except Exception:
|
|
pass
|
|
|
|
_client_instance = ProxiedAirwallexClient(
|
|
proxy_url=proxy_url,
|
|
client_id=client_id,
|
|
api_key=api_key,
|
|
base_url=base_url,
|
|
)
|
|
_client_config_hash = config_hash
|
|
return _client_instance
|
|
|
|
|
|
def ensure_authenticated(db: Session) -> ProxiedAirwallexClient:
|
|
"""Get client and ensure it's authenticated."""
|
|
client = get_client(db)
|
|
client.authenticate()
|
|
return client
|
|
|
|
|
|
# ─── Card operations ────────────────────────────────────────────────
|
|
|
|
def list_cards(
|
|
db: Session,
|
|
page_num: int = 0,
|
|
page_size: int = 20,
|
|
cardholder_id: Optional[str] = None,
|
|
status: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
"""List cards with optional filters."""
|
|
client = ensure_authenticated(db)
|
|
params: Dict[str, Any] = {"page_num": page_num, "page_size": page_size}
|
|
if cardholder_id:
|
|
params["cardholder_id"] = cardholder_id
|
|
if status:
|
|
params["status"] = status
|
|
|
|
cards = client.issuing_card.list_with_filters(**params)
|
|
return {
|
|
"items": [c.to_dict() if hasattr(c, "to_dict") else c.__dict__ for c in cards],
|
|
"page_num": page_num,
|
|
"page_size": page_size,
|
|
"has_more": len(cards) == page_size,
|
|
}
|
|
|
|
|
|
def create_card(db: Session, card_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Create a new card."""
|
|
from airwallex.models.issuing_card import CardCreateRequest
|
|
|
|
client = ensure_authenticated(db)
|
|
request = CardCreateRequest(**card_data)
|
|
card = client.issuing_card.create_card(request)
|
|
return card.to_dict() if hasattr(card, "to_dict") else card.__dict__
|
|
|
|
|
|
def get_card(db: Session, card_id: str) -> Dict[str, Any]:
|
|
"""Get card by ID."""
|
|
client = ensure_authenticated(db)
|
|
card = client.issuing_card.get(card_id)
|
|
return card.to_dict() if hasattr(card, "to_dict") else card.__dict__
|
|
|
|
|
|
def get_card_details(db: Session, card_id: str) -> Dict[str, Any]:
|
|
"""Get sensitive card details (card number, CVV, expiry)."""
|
|
client = ensure_authenticated(db)
|
|
details = client.issuing_card.get_card_details(card_id)
|
|
return details.to_dict() if hasattr(details, "to_dict") else details.__dict__
|
|
|
|
|
|
def update_card(db: Session, card_id: str, update_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Update card attributes."""
|
|
from airwallex.models.issuing_card import CardUpdateRequest
|
|
|
|
client = ensure_authenticated(db)
|
|
request = CardUpdateRequest(**update_data)
|
|
card = client.issuing_card.update_card(card_id, request)
|
|
return card.to_dict() if hasattr(card, "to_dict") else card.__dict__
|
|
|
|
|
|
# ─── Cardholder operations ──────────────────────────────────────────
|
|
|
|
def list_cardholders(
|
|
db: Session,
|
|
page_num: int = 0,
|
|
page_size: int = 20,
|
|
) -> Dict[str, Any]:
|
|
"""List cardholders."""
|
|
client = ensure_authenticated(db)
|
|
cardholders = client.issuing_cardholder.list(page_num=page_num, page_size=page_size)
|
|
return {
|
|
"items": [c.to_dict() if hasattr(c, "to_dict") else c.__dict__ for c in cardholders],
|
|
"page_num": page_num,
|
|
"page_size": page_size,
|
|
"has_more": len(cardholders) == page_size,
|
|
}
|
|
|
|
|
|
def create_cardholder(db: Session, cardholder_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Create a new cardholder."""
|
|
from airwallex.models.issuing_cardholder import CardholderCreateRequest
|
|
|
|
client = ensure_authenticated(db)
|
|
request = CardholderCreateRequest(**cardholder_data)
|
|
cardholder = client.issuing_cardholder.create_cardholder(request)
|
|
return cardholder.to_dict() if hasattr(cardholder, "to_dict") else cardholder.__dict__
|
|
|
|
|
|
# ─── Transaction operations ─────────────────────────────────────────
|
|
|
|
def list_transactions(
|
|
db: Session,
|
|
page_num: int = 0,
|
|
page_size: int = 20,
|
|
card_id: Optional[str] = None,
|
|
from_created_at: Optional[str] = None,
|
|
to_created_at: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
"""List transactions."""
|
|
client = ensure_authenticated(db)
|
|
params: Dict[str, Any] = {"page_num": page_num, "page_size": page_size}
|
|
if card_id:
|
|
params["card_id"] = card_id
|
|
if from_created_at:
|
|
params["from_created_at"] = from_created_at
|
|
if to_created_at:
|
|
params["to_created_at"] = to_created_at
|
|
|
|
txns = client.issuing_transaction.list_with_filters(**params)
|
|
return {
|
|
"items": [t.to_dict() if hasattr(t, "to_dict") else t.__dict__ for t in txns],
|
|
"page_num": page_num,
|
|
"page_size": page_size,
|
|
"has_more": len(txns) == page_size,
|
|
}
|
|
|
|
|
|
def list_authorizations(
|
|
db: Session,
|
|
page_num: int = 0,
|
|
page_size: int = 20,
|
|
card_id: Optional[str] = None,
|
|
status: Optional[str] = None,
|
|
from_created_at: Optional[str] = None,
|
|
to_created_at: Optional[str] = None,
|
|
) -> Dict[str, Any]:
|
|
"""List authorizations."""
|
|
client = ensure_authenticated(db)
|
|
params: Dict[str, Any] = {"page_num": page_num, "page_size": page_size}
|
|
if card_id:
|
|
params["card_id"] = card_id
|
|
if status:
|
|
params["status"] = status
|
|
if from_created_at:
|
|
params["from_created_at"] = from_created_at
|
|
if to_created_at:
|
|
params["to_created_at"] = to_created_at
|
|
|
|
auths = client.issuing_authorization.list_with_filters(**params)
|
|
return {
|
|
"items": [a.to_dict() if hasattr(a, "to_dict") else a.__dict__ for a in auths],
|
|
"page_num": page_num,
|
|
"page_size": page_size,
|
|
"has_more": len(auths) == page_size,
|
|
}
|
|
|
|
|
|
# ─── Account / Balance ──────────────────────────────────────────────
|
|
|
|
def get_balance(db: Session) -> Optional[Dict[str, Any]]:
|
|
"""Get account balance. Returns None if not available."""
|
|
try:
|
|
client = ensure_authenticated(db)
|
|
# Try to get global account balance via the balances endpoint
|
|
response = client._request("GET", "api/v1/balances/current")
|
|
return response.json()
|
|
except Exception as e:
|
|
logger.warning("Failed to fetch balance: %s", e)
|
|
return None
|
|
|
|
|
|
# ─── Config ──────────────────────────────────────────────────────────
|
|
|
|
def get_issuing_config(db: Session) -> Optional[Dict[str, Any]]:
|
|
"""Get issuing configuration."""
|
|
try:
|
|
client = ensure_authenticated(db)
|
|
config = client.issuing_config.get_config()
|
|
return config.to_dict() if hasattr(config, "to_dict") else config.__dict__
|
|
except Exception as e:
|
|
logger.warning("Failed to fetch issuing config: %s", e)
|
|
return None
|
|
|
|
|
|
def test_connection(db: Session) -> Dict[str, Any]:
|
|
"""Test Airwallex API connection with detailed error info."""
|
|
try:
|
|
client = get_client(db)
|
|
client.authenticate()
|
|
return {"success": True, "message": "连接成功,认证通过"}
|
|
except HTTPException as e:
|
|
return {"success": False, "message": e.detail}
|
|
except Exception as 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
|