Files
Airwallex/backend/app/services/airwallex_service.py
zqq61 81d9c3a7e1 feat: 支持x-login-as三参数认证、auth_url跟随base_url
- 认证请求增加x-login-as header支持连接账户
- auth_url根据base_url动态构建,不再硬编码生产环境
- 默认Base URL改为demo环境
- 设置页面新增Account ID字段,带tooltip说明
2026-03-16 00:44:27 +08:00

334 lines
12 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. 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"{scheme}://{proxy_user}:{proxy_pass}@{proxy_ip}:{proxy_port}"
return f"{scheme}://{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-demo.airwallex.com/")
login_as = _get_setting(db, "airwallex_login_as")
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}:{login_as}:{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
# auth_url must match base_url domain
auth_url = base_url.rstrip("/") + "/api/v1/authentication/login"
_client_instance = ProxiedAirwallexClient(
proxy_url=proxy_url,
login_as=login_as or None,
client_id=client_id,
api_key=api_key,
base_url=base_url,
auth_url=auth_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)
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
# ─── 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 + country — 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:
# 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("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"
return result