Files
Airwallex/backend/app/services/airwallex_service.py
zqq61 faba565c66 feat: Go 重写后端,替换 Python FastAPI
用 Go (Gin + GORM + SQLite) 重写整个后端:
- 单二进制部署,不依赖 Python/pip/SDK
- net/http 原生客户端,无 Cloudflare TLS 指纹问题
- 多阶段 Dockerfile:Node 构建前端 + Go 构建后端 + Alpine 运行
- 内存占用从 ~95MB 降至 ~3MB
- 完整保留所有 API 路由、JWT 认证、API Key 权限、审计日志
2026-03-16 02:11:48 +08:00

335 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 airwallex.exceptions import AirwallexAPIError
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").strip() or None
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