feat: Airwallex 发卡管理后台完整实现
- 后端: FastAPI + SQLAlchemy + SQLite, JWT认证, 代理支持的AirwallexClient - 前端: React 18 + Vite + Ant Design 5, 中文界面 - 功能: 卡片管理, 持卡人管理, 交易记录, API令牌, 系统设置, 审计日志 - 第三方API: X-API-Key认证, 权限控制 - Docker部署: docker-compose编排前后端
This commit is contained in:
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
258
backend/app/services/airwallex_service.py
Normal file
258
backend/app/services/airwallex_service.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""Airwallex API service layer."""
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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 ValueError("Airwallex credentials not configured. Please set client_id and api_key in Settings.")
|
||||
|
||||
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."""
|
||||
try:
|
||||
client = get_client(db)
|
||||
client.authenticate()
|
||||
return {"success": True, "message": "Connection successful"}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": str(e)}
|
||||
56
backend/app/services/audit_log.py
Normal file
56
backend/app/services/audit_log.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Audit logging service."""
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session
|
||||
from app.models.db_models import AuditLog, CardLog
|
||||
|
||||
|
||||
def create_audit_log(
|
||||
db: Session,
|
||||
action: str,
|
||||
resource_type: str,
|
||||
operator: str,
|
||||
resource_id: str = "",
|
||||
ip_address: str = "",
|
||||
details: str = "",
|
||||
) -> AuditLog:
|
||||
"""Create an audit log entry."""
|
||||
log = AuditLog(
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
operator=operator,
|
||||
ip_address=ip_address,
|
||||
details=details,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
db.refresh(log)
|
||||
return log
|
||||
|
||||
|
||||
def create_card_log(
|
||||
db: Session,
|
||||
action: str,
|
||||
status: str,
|
||||
operator: str,
|
||||
card_id: str = "",
|
||||
cardholder_id: str = "",
|
||||
request_data: str = "",
|
||||
response_data: str = "",
|
||||
) -> CardLog:
|
||||
"""Create a card operation log entry."""
|
||||
log = CardLog(
|
||||
card_id=card_id,
|
||||
cardholder_id=cardholder_id,
|
||||
action=action,
|
||||
status=status,
|
||||
operator=operator,
|
||||
request_data=request_data,
|
||||
response_data=response_data,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(log)
|
||||
db.commit()
|
||||
db.refresh(log)
|
||||
return log
|
||||
Reference in New Issue
Block a user