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:
zqq61
2026-03-15 23:05:08 +08:00
commit 4f53889a8e
98 changed files with 10847 additions and 0 deletions

19
backend/.env.example Normal file
View File

@@ -0,0 +1,19 @@
# Admin credentials
ADMIN_USERNAME=admin
ADMIN_PASSWORD=admin123
# JWT settings
SECRET_KEY=your-secret-key-change-in-production
JWT_ALGORITHM=HS256
JWT_EXPIRE_MINUTES=480
# Database
DATABASE_URL=sqlite:///./data/airwallex.db
# Airwallex API credentials
AIRWALLEX_CLIENT_ID=your-client-id
AIRWALLEX_API_KEY=your-api-key
AIRWALLEX_BASE_URL=https://api.airwallex.com/
# HTTP proxy (optional)
# PROXY_URL=http://127.0.0.1:7890

27
backend/Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy and install the airwallex SDK first (changes less often)
COPY airwallex-sdk /opt/airwallex-sdk
RUN pip install --no-cache-dir /opt/airwallex-sdk
# Copy and install Python dependencies
COPY backend/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY backend/app ./app
COPY backend/.env* ./
# Create data directory for SQLite
RUN mkdir -p /app/data
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

0
backend/app/__init__.py Normal file
View File

74
backend/app/auth.py Normal file
View File

@@ -0,0 +1,74 @@
"""Authentication utilities: JWT tokens, password hashing, and dependencies."""
from datetime import datetime, timedelta, timezone
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError, jwt
from passlib.context import CryptContext
from pydantic import BaseModel
from .config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer()
class AdminUser(BaseModel):
"""Represents the authenticated admin user."""
username: str
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against its hash."""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash a password using bcrypt."""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
"""Create a JWT access token.
Args:
data: Payload data to encode in the token.
expires_delta: Optional custom expiration time.
Returns:
Encoded JWT string.
"""
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=settings.JWT_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> AdminUser:
"""FastAPI dependency that extracts and validates the current user from a JWT token.
Raises:
HTTPException: If the token is invalid or missing required claims.
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(
credentials.credentials,
settings.SECRET_KEY,
algorithms=[settings.JWT_ALGORITHM],
)
username: str | None = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
return AdminUser(username=username)

38
backend/app/config.py Normal file
View File

@@ -0,0 +1,38 @@
"""Application configuration using pydantic-settings."""
import secrets
from typing import Optional
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application settings loaded from environment variables and .env file."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
)
# Admin credentials
ADMIN_USERNAME: str = "admin"
ADMIN_PASSWORD: str = "admin123"
# JWT settings
SECRET_KEY: str = secrets.token_urlsafe(32)
JWT_ALGORITHM: str = "HS256"
JWT_EXPIRE_MINUTES: int = 480
# Database
DATABASE_URL: str = "sqlite:///./data/airwallex.db"
# Airwallex API
AIRWALLEX_CLIENT_ID: str = ""
AIRWALLEX_API_KEY: str = ""
AIRWALLEX_BASE_URL: str = "https://api.airwallex.com/"
# Proxy (optional)
PROXY_URL: Optional[str] = None
settings = Settings()

35
backend/app/database.py Normal file
View File

@@ -0,0 +1,35 @@
"""Database setup and session management."""
from typing import Generator
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from .config import settings
engine = create_engine(
settings.DATABASE_URL,
connect_args={"check_same_thread": False}, # SQLite specific
echo=False,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
"""Declarative base for all ORM models."""
pass
def get_db() -> Generator[Session, None, None]:
"""FastAPI dependency that provides a database session."""
db = SessionLocal()
try:
yield db
finally:
db.close()
def create_tables() -> None:
"""Create all database tables."""
Base.metadata.create_all(bind=engine)

105
backend/app/main.py Normal file
View File

@@ -0,0 +1,105 @@
"""FastAPI application entry point."""
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .auth import get_password_hash
from .config import settings
from .database import SessionLocal, create_tables
from .models.db_models import SystemSetting
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
def _init_admin_and_defaults() -> None:
"""Initialize admin password hash and default settings."""
db = SessionLocal()
try:
# Store admin password hash
existing = (
db.query(SystemSetting)
.filter(SystemSetting.key == "admin_password_hash")
.first()
)
if not existing:
hashed = get_password_hash(settings.ADMIN_PASSWORD)
db.add(SystemSetting(key="admin_password_hash", value=hashed, encrypted=True))
logger.info("Admin user initialized.")
# Store Airwallex credentials from env if provided
for key, env_val in [
("airwallex_client_id", settings.AIRWALLEX_CLIENT_ID),
("airwallex_api_key", settings.AIRWALLEX_API_KEY),
("airwallex_base_url", settings.AIRWALLEX_BASE_URL),
]:
if env_val:
s = db.query(SystemSetting).filter(SystemSetting.key == key).first()
if not s:
db.add(SystemSetting(key=key, value=env_val, encrypted=(key == "airwallex_api_key")))
# Default daily card limit
if not db.query(SystemSetting).filter(SystemSetting.key == "daily_card_limit").first():
db.add(SystemSetting(key="daily_card_limit", value="100"))
db.commit()
finally:
db.close()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup and shutdown logic."""
create_tables()
_init_admin_and_defaults()
logger.info("Application started.")
yield
logger.info("Application shutting down.")
app = FastAPI(
title="Airwallex Card Management",
version="1.0.0",
lifespan=lifespan,
)
# CORS - allow all origins for development
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- Mount routers (each router defines its own prefix) ---
from .routers import auth as auth_router
from .routers import cards as cards_router
from .routers import cardholders as cardholders_router
from .routers import dashboard as dashboard_router
from .routers import settings as settings_router
from .routers import logs as logs_router
from .routers import tokens as tokens_router
from .routers import transactions as transactions_router
from .routers import external_api as external_api_router
app.include_router(auth_router.router)
app.include_router(dashboard_router.router)
app.include_router(cards_router.router)
app.include_router(cardholders_router.router)
app.include_router(transactions_router.router)
app.include_router(settings_router.router)
app.include_router(tokens_router.router)
app.include_router(logs_router.router)
app.include_router(external_api_router.router)
# --- Health check ---
@app.get("/api/health")
async def health_check():
"""Simple health check endpoint."""
return {"status": "ok"}

View File

View File

@@ -0,0 +1,67 @@
"""SQLAlchemy ORM models for the application database."""
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column
from ..database import Base
class SystemSetting(Base):
"""Stores application-level configuration key-value pairs."""
__tablename__ = "system_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
key: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
value: Mapped[str] = mapped_column(Text, nullable=False)
encrypted: Mapped[bool] = mapped_column(Boolean, default=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=func.now(), onupdate=func.now()
)
class ApiToken(Base):
"""API tokens for programmatic access."""
__tablename__ = "api_tokens"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
token: Mapped[str] = mapped_column(String(512), unique=True, nullable=False, index=True)
permissions: Mapped[str] = mapped_column(Text, default="[]") # JSON string
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now())
expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
class CardLog(Base):
"""Logs for card-related operations."""
__tablename__ = "card_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
card_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
cardholder_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
action: Mapped[str] = mapped_column(String(100), nullable=False)
status: Mapped[str] = mapped_column(String(50), nullable=False)
operator: Mapped[str] = mapped_column(String(255), nullable=False)
request_data: Mapped[str | None] = mapped_column(Text, nullable=True)
response_data: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now())
class AuditLog(Base):
"""General audit trail for all administrative actions."""
__tablename__ = "audit_logs"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
action: Mapped[str] = mapped_column(String(100), nullable=False)
resource_type: Mapped[str] = mapped_column(String(100), nullable=False)
resource_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
operator: Mapped[str] = mapped_column(String(255), nullable=False)
ip_address: Mapped[str | None] = mapped_column(String(45), nullable=True)
details: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=func.now())

View File

@@ -0,0 +1,148 @@
"""Pydantic schemas for request/response validation."""
from datetime import datetime
from typing import Any, Optional
from pydantic import BaseModel, Field
# --- Auth ---
class LoginRequest(BaseModel):
username: str
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
# --- System Settings ---
class SystemSettingUpdate(BaseModel):
key: str
value: str
class SystemSettingResponse(BaseModel):
key: str
value: str
updated_at: datetime | None = None
model_config = {"from_attributes": True}
# --- API Tokens ---
class ApiTokenCreate(BaseModel):
name: str
permissions: list[str] = Field(default_factory=list)
expires_in_days: int | None = None
class ApiTokenResponse(BaseModel):
id: int
name: str
token: str | None = None # Only shown on create
permissions: list[str] = Field(default_factory=list)
is_active: bool
created_at: datetime | None = None
expires_at: datetime | None = None
model_config = {"from_attributes": True}
# --- Logs ---
class CardLogResponse(BaseModel):
id: int
card_id: str | None = None
cardholder_id: str | None = None
action: str
status: str
operator: str
request_data: str | None = None
response_data: str | None = None
created_at: datetime | None = None
model_config = {"from_attributes": True}
class AuditLogResponse(BaseModel):
id: int
action: str
resource_type: str
resource_id: str | None = None
operator: str
ip_address: str | None = None
details: str | None = None
created_at: datetime | None = None
model_config = {"from_attributes": True}
# --- Dashboard ---
class DashboardResponse(BaseModel):
total_cards: int = 0
active_cards: int = 0
today_card_count: int = 0
daily_card_limit: int = 0
account_balance: dict[str, Any] | None = None
# --- Cards ---
class CardCreateRequest(BaseModel):
cardholder_id: str
card_nickname: str | None = None
authorization_controls: dict[str, Any] | None = None
form_factor: str = "VIRTUAL"
purpose: str | None = None
class CardUpdateRequest(BaseModel):
card_nickname: str | None = None
authorization_controls: dict[str, Any] | None = None
status: str | None = None
# --- Cardholders ---
class CardholderCreateRequest(BaseModel):
email: str
type: str = "INDIVIDUAL"
individual: dict[str, Any] = Field(
...,
description="Individual details: first_name, last_name, date_of_birth, etc.",
)
address: dict[str, Any] = Field(
...,
description="Address: street_address, city, state, postcode, country_code.",
)
# --- Pagination ---
class PaginatedResponse(BaseModel):
items: list[Any]
page_num: int = 0
page_size: int = 20
total: int | None = None
has_more: bool = False
# --- External / Third-party ---
class ExternalCardCreateRequest(BaseModel):
cardholder_id: str
card_nickname: str | None = None
authorization_controls: dict[str, Any] | None = None
form_factor: str = "VIRTUAL"
purpose: str | None = None
# --- Balance ---
class BalanceResponse(BaseModel):
available: list[dict[str, Any]] = Field(default_factory=list)

View File

@@ -0,0 +1,51 @@
"""Airwallex client with proxy support."""
import httpx
from airwallex.client import AirwallexClient
class ProxiedAirwallexClient(AirwallexClient):
"""AirwallexClient that routes requests through an HTTP proxy."""
def __init__(self, proxy_url: str | None = None, **kwargs):
self._proxy_url = proxy_url
super().__init__(**kwargs)
# Replace the default httpx client with a proxied one
if proxy_url:
self._client.close()
self._client = httpx.Client(
base_url=self.base_url,
timeout=self.request_timeout,
proxy=proxy_url,
)
def authenticate(self) -> None:
"""Override authenticate to use proxy for auth requests too."""
from datetime import datetime, timezone
if self._token and self._token_expiry and datetime.now(timezone.utc) < self._token_expiry:
return
# Use a proxied client for authentication
auth_kwargs = {"timeout": self.request_timeout}
if self._proxy_url:
auth_kwargs["proxy"] = self._proxy_url
auth_client = httpx.Client(**auth_kwargs)
try:
response = auth_client.post(
self.auth_url,
headers={
"Content-Type": "application/json",
"x-client-id": self.client_id,
"x-api-key": self.api_key,
},
content="{}",
)
response.raise_for_status()
data = response.json()
self._token = data.get("token")
# Token valid for 30 minutes, refresh a bit early
from datetime import timedelta
self._token_expiry = datetime.now(timezone.utc) + timedelta(minutes=28)
finally:
auth_client.close()

View File

View File

@@ -0,0 +1,37 @@
"""Authentication router."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.auth import verify_password, create_access_token, get_current_user, AdminUser
from app.config import settings
from app.database import get_db
from app.models.db_models import SystemSetting
from app.models.schemas import LoginRequest, TokenResponse
from app.services.audit_log import create_audit_log
router = APIRouter(prefix="/api/auth", tags=["auth"])
@router.post("/login", response_model=TokenResponse)
def login(req: LoginRequest, db: Session = Depends(get_db)):
"""Admin login endpoint."""
if req.username != settings.ADMIN_USERNAME:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
# Get password hash from database
setting = db.query(SystemSetting).filter(SystemSetting.key == "admin_password_hash").first()
if not setting:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Admin not initialized")
if not verify_password(req.password, setting.value):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
token = create_access_token(data={"sub": req.username})
create_audit_log(db, action="login", resource_type="auth", operator=req.username)
return TokenResponse(access_token=token, token_type="bearer")
@router.get("/me")
def get_me(user: AdminUser = Depends(get_current_user)):
"""Get current authenticated user."""
return {"username": user.username}

View File

@@ -0,0 +1,47 @@
"""Cardholders management router."""
import json
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from app.auth import get_current_user, AdminUser
from app.database import get_db
from app.services import airwallex_service
from app.services.audit_log import create_audit_log
router = APIRouter(prefix="/api/cardholders", tags=["cardholders"])
@router.get("")
def list_cardholders(
page_num: int = Query(0, ge=0),
page_size: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
user: AdminUser = Depends(get_current_user),
):
"""List cardholders."""
return airwallex_service.list_cardholders(db, page_num, page_size)
@router.post("")
def create_cardholder(
cardholder_data: dict,
request: Request,
db: Session = Depends(get_db),
user: AdminUser = Depends(get_current_user),
):
"""Create a new cardholder."""
try:
result = airwallex_service.create_cardholder(db, cardholder_data)
create_audit_log(
db,
action="create_cardholder",
resource_type="cardholder",
resource_id=result.get("cardholder_id", result.get("id", "")),
operator=user.username,
ip_address=request.client.host if request.client else "",
details=f"Created cardholder {cardholder_data.get('email', '')}",
)
return result
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))

View File

@@ -0,0 +1,146 @@
"""Cards management router."""
import json
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.orm import Session
from sqlalchemy import func
from datetime import datetime, timezone
from app.auth import get_current_user, AdminUser
from app.database import get_db
from app.models.db_models import CardLog, SystemSetting
from app.services import airwallex_service
from app.services.audit_log import create_card_log, create_audit_log
router = APIRouter(prefix="/api/cards", tags=["cards"])
@router.get("")
def list_cards(
page_num: int = Query(0, ge=0),
page_size: int = Query(20, ge=1, le=100),
cardholder_id: Optional[str] = None,
status: Optional[str] = None,
db: Session = Depends(get_db),
user: AdminUser = Depends(get_current_user),
):
"""List cards with optional filters."""
return airwallex_service.list_cards(db, page_num, page_size, cardholder_id, status)
@router.post("")
def create_card(
request: Request,
card_data: dict,
db: Session = Depends(get_db),
user: AdminUser = Depends(get_current_user),
):
"""Create a new card."""
# Check daily limit
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
today_count = (
db.query(func.count(CardLog.id))
.filter(CardLog.action == "create_card", CardLog.status == "success", CardLog.created_at >= today_start)
.scalar()
) or 0
limit_setting = db.query(SystemSetting).filter(SystemSetting.key == "daily_card_limit").first()
daily_limit = int(limit_setting.value) if limit_setting else 100
if today_count >= daily_limit:
raise HTTPException(status_code=429, detail=f"Daily card creation limit ({daily_limit}) reached")
try:
result = airwallex_service.create_card(db, card_data)
create_card_log(
db,
action="create_card",
status="success",
operator=user.username,
card_id=result.get("card_id", result.get("id", "")),
cardholder_id=card_data.get("cardholder_id", ""),
request_data=json.dumps(card_data),
response_data=json.dumps(result, default=str),
)
create_audit_log(
db,
action="create_card",
resource_type="card",
resource_id=result.get("card_id", result.get("id", "")),
operator=user.username,
ip_address=request.client.host if request.client else "",
details=f"Created card for cardholder {card_data.get('cardholder_id', '')}",
)
return result
except Exception as e:
create_card_log(
db,
action="create_card",
status="failed",
operator=user.username,
cardholder_id=card_data.get("cardholder_id", ""),
request_data=json.dumps(card_data),
response_data=str(e),
)
raise HTTPException(status_code=400, detail=str(e))
@router.get("/{card_id}")
def get_card(
card_id: str,
db: Session = Depends(get_db),
user: AdminUser = Depends(get_current_user),
):
"""Get card by ID."""
try:
return airwallex_service.get_card(db, card_id)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))
@router.get("/{card_id}/details")
def get_card_details(
card_id: str,
request: Request,
db: Session = Depends(get_db),
user: AdminUser = Depends(get_current_user),
):
"""Get sensitive card details (card number, CVV, expiry)."""
create_audit_log(
db,
action="view_card_details",
resource_type="card",
resource_id=card_id,
operator=user.username,
ip_address=request.client.host if request.client else "",
)
try:
return airwallex_service.get_card_details(db, card_id)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))
@router.put("/{card_id}")
def update_card(
card_id: str,
update_data: dict,
request: Request,
db: Session = Depends(get_db),
user: AdminUser = Depends(get_current_user),
):
"""Update card attributes."""
try:
result = airwallex_service.update_card(db, card_id, update_data)
create_audit_log(
db,
action="update_card",
resource_type="card",
resource_id=card_id,
operator=user.username,
ip_address=request.client.host if request.client else "",
details=json.dumps(update_data),
)
return result
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))

View File

@@ -0,0 +1,57 @@
"""Dashboard router."""
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.auth import get_current_user, AdminUser
from app.database import get_db
from app.models.db_models import CardLog, SystemSetting
from app.services import airwallex_service
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
@router.get("")
def get_dashboard(
db: Session = Depends(get_db),
user: AdminUser = Depends(get_current_user),
):
"""Get dashboard summary data."""
# Today's card count from local logs
today_start = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0, microsecond=0)
today_card_count = (
db.query(func.count(CardLog.id))
.filter(CardLog.action == "create_card", CardLog.status == "success", CardLog.created_at >= today_start)
.scalar()
) or 0
# Daily limit from settings
limit_setting = db.query(SystemSetting).filter(SystemSetting.key == "daily_card_limit").first()
daily_card_limit = int(limit_setting.value) if limit_setting else 100
# Try to get live data from Airwallex
total_cards = 0
active_cards = 0
account_balance = None
try:
cards_data = airwallex_service.list_cards(db, page_num=0, page_size=1)
# We can't get total from a page_size=1 call easily, so we show what we have
total_cards = cards_data.get("total", 0)
except Exception:
pass
try:
account_balance = airwallex_service.get_balance(db)
except Exception:
pass
return {
"total_cards": total_cards,
"active_cards": active_cards,
"today_card_count": today_card_count,
"daily_card_limit": daily_card_limit,
"account_balance": account_balance,
}

View File

@@ -0,0 +1,152 @@
"""External API endpoints for third-party access via X-API-Key."""
import json
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Header, Query, Request
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.db_models import ApiToken
from app.services import airwallex_service
from app.services.audit_log import create_card_log, create_audit_log
router = APIRouter(prefix="/api/v1", tags=["external"])
def verify_api_key(
x_api_key: str = Header(..., alias="X-API-Key"),
db: Session = Depends(get_db),
) -> ApiToken:
"""Verify API key and return token record."""
token = db.query(ApiToken).filter(ApiToken.token == x_api_key, ApiToken.is_active == True).first()
if not token:
raise HTTPException(status_code=401, detail="Invalid or inactive API key")
# Check expiry
if token.expires_at and token.expires_at < datetime.now(timezone.utc):
raise HTTPException(status_code=401, detail="API key has expired")
# Update last used
token.last_used_at = datetime.now(timezone.utc)
db.commit()
return token
def check_permission(token: ApiToken, required: str):
"""Check if token has a required permission."""
permissions = json.loads(token.permissions) if token.permissions else []
if required not in permissions and "*" not in permissions:
raise HTTPException(status_code=403, detail=f"Token lacks permission: {required}")
@router.post("/cards/create")
def external_create_card(
card_data: dict,
request: Request,
db: Session = Depends(get_db),
token: ApiToken = Depends(verify_api_key),
):
"""Create a new card (external API)."""
check_permission(token, "create_cards")
try:
result = airwallex_service.create_card(db, card_data)
create_card_log(
db,
action="create_card",
status="success",
operator=f"api:{token.name}",
card_id=result.get("card_id", result.get("id", "")),
cardholder_id=card_data.get("cardholder_id", ""),
request_data=json.dumps(card_data),
response_data=json.dumps(result, default=str),
)
return result
except Exception as e:
create_card_log(
db,
action="create_card",
status="failed",
operator=f"api:{token.name}",
cardholder_id=card_data.get("cardholder_id", ""),
request_data=json.dumps(card_data),
response_data=str(e),
)
raise HTTPException(status_code=400, detail=str(e))
@router.get("/cards")
def external_list_cards(
page_num: int = Query(0, ge=0),
page_size: int = Query(20, ge=1, le=100),
status: Optional[str] = None,
db: Session = Depends(get_db),
token: ApiToken = Depends(verify_api_key),
):
"""List cards (external API)."""
check_permission(token, "read_cards")
return airwallex_service.list_cards(db, page_num, page_size, status=status)
@router.get("/cards/{card_id}")
def external_get_card(
card_id: str,
db: Session = Depends(get_db),
token: ApiToken = Depends(verify_api_key),
):
"""Get card details (external API)."""
check_permission(token, "read_cards")
try:
return airwallex_service.get_card(db, card_id)
except Exception as e:
raise HTTPException(status_code=404, detail=str(e))
@router.post("/cards/{card_id}/freeze")
def external_freeze_card(
card_id: str,
request: Request,
db: Session = Depends(get_db),
token: ApiToken = Depends(verify_api_key),
):
"""Freeze a card (external API)."""
check_permission(token, "create_cards")
try:
result = airwallex_service.update_card(db, card_id, {"status": "SUSPENDED"})
create_audit_log(
db,
action="freeze_card",
resource_type="card",
resource_id=card_id,
operator=f"api:{token.name}",
ip_address=request.client.host if request.client else "",
)
return result
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/transactions")
def external_list_transactions(
page_num: int = Query(0, ge=0),
page_size: int = Query(20, ge=1, le=100),
card_id: Optional[str] = None,
db: Session = Depends(get_db),
token: ApiToken = Depends(verify_api_key),
):
"""List transactions (external API)."""
check_permission(token, "read_transactions")
return airwallex_service.list_transactions(db, page_num, page_size, card_id)
@router.get("/balance")
def external_get_balance(
db: Session = Depends(get_db),
token: ApiToken = Depends(verify_api_key),
):
"""Get account balance (external API)."""
check_permission(token, "read_balance")
balance = airwallex_service.get_balance(db)
if balance is None:
raise HTTPException(status_code=503, detail="Unable to fetch balance")
return balance

View File

@@ -0,0 +1,98 @@
"""Card logs and audit logs router."""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.auth import get_current_user, AdminUser
from app.database import get_db
from app.models.db_models import CardLog, AuditLog
router = APIRouter(prefix="/api", tags=["logs"])
@router.get("/card-logs")
def list_card_logs(
page_num: int = Query(0, ge=0),
page_size: int = Query(20, ge=1, le=100),
card_id: Optional[str] = None,
action: Optional[str] = None,
db: Session = Depends(get_db),
user: AdminUser = Depends(get_current_user),
):
"""List card operation logs."""
query = db.query(CardLog).order_by(CardLog.created_at.desc())
if card_id:
query = query.filter(CardLog.card_id == card_id)
if action:
query = query.filter(CardLog.action == action)
total = query.count()
logs = query.offset(page_num * page_size).limit(page_size).all()
return {
"items": [
{
"id": log.id,
"card_id": log.card_id,
"cardholder_id": log.cardholder_id,
"action": log.action,
"status": log.status,
"operator": log.operator,
"request_data": log.request_data,
"response_data": log.response_data,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
for log in logs
],
"page_num": page_num,
"page_size": page_size,
"total": total,
"has_more": (page_num + 1) * page_size < total,
}
@router.get("/audit-logs")
def list_audit_logs(
page_num: int = Query(0, ge=0),
page_size: int = Query(20, ge=1, le=100),
action: Optional[str] = None,
resource_type: Optional[str] = None,
from_date: Optional[str] = None,
to_date: Optional[str] = None,
db: Session = Depends(get_db),
user: AdminUser = Depends(get_current_user),
):
"""List audit logs."""
query = db.query(AuditLog).order_by(AuditLog.created_at.desc())
if action:
query = query.filter(AuditLog.action == action)
if resource_type:
query = query.filter(AuditLog.resource_type == resource_type)
if from_date:
query = query.filter(AuditLog.created_at >= from_date)
if to_date:
query = query.filter(AuditLog.created_at <= to_date)
total = query.count()
logs = query.offset(page_num * page_size).limit(page_size).all()
return {
"items": [
{
"id": log.id,
"action": log.action,
"resource_type": log.resource_type,
"resource_id": log.resource_id,
"operator": log.operator,
"ip_address": log.ip_address,
"details": log.details,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
for log in logs
],
"page_num": page_num,
"page_size": page_size,
"total": total,
"has_more": (page_num + 1) * page_size < total,
}

View File

@@ -0,0 +1,85 @@
"""System settings router."""
from typing import List
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.auth import get_current_user, AdminUser
from app.database import get_db
from app.models.db_models import SystemSetting
from app.services import airwallex_service
from app.services.audit_log import create_audit_log
router = APIRouter(prefix="/api/settings", tags=["settings"])
# Keys that should have their values masked in responses
SENSITIVE_KEYS = {"airwallex_api_key", "proxy_password"}
@router.get("")
def get_settings(
db: Session = Depends(get_db),
user: AdminUser = Depends(get_current_user),
):
"""Get all system settings."""
settings = db.query(SystemSetting).all()
result = []
for s in settings:
value = "********" if s.key in SENSITIVE_KEYS and s.value else s.value
result.append({
"key": s.key,
"value": value,
"encrypted": s.encrypted,
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
})
return result
@router.put("")
def update_settings(
updates: List[dict],
request: Request,
db: Session = Depends(get_db),
user: AdminUser = Depends(get_current_user),
):
"""Update system settings. Accepts a list of {key, value} objects."""
for item in updates:
key = item.get("key")
value = item.get("value")
if not key:
continue
# Skip if masked value sent back unchanged
if value == "********":
continue
existing = db.query(SystemSetting).filter(SystemSetting.key == key).first()
if existing:
existing.value = str(value)
existing.encrypted = key in SENSITIVE_KEYS
else:
db.add(SystemSetting(
key=key,
value=str(value),
encrypted=key in SENSITIVE_KEYS,
))
db.commit()
create_audit_log(
db,
action="update_settings",
resource_type="settings",
operator=user.username,
ip_address=request.client.host if request.client else "",
details=f"Updated keys: {[item.get('key') for item in updates]}",
)
return {"message": "Settings updated"}
@router.post("/test-connection")
def test_connection(
db: Session = Depends(get_db),
user: AdminUser = Depends(get_current_user),
):
"""Test Airwallex API connection with current settings."""
return airwallex_service.test_connection(db)

View File

@@ -0,0 +1,114 @@
"""API token management router."""
import secrets
import json
from datetime import datetime, timezone, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from app.auth import get_current_user, AdminUser
from app.database import get_db
from app.models.db_models import ApiToken
from app.services.audit_log import create_audit_log
router = APIRouter(prefix="/api/tokens", tags=["tokens"])
@router.get("")
def list_tokens(
db: Session = Depends(get_db),
user: AdminUser = Depends(get_current_user),
):
"""List all API tokens."""
tokens = db.query(ApiToken).order_by(ApiToken.created_at.desc()).all()
return [
{
"id": t.id,
"name": t.name,
"token": t.token[:8] + "..." + t.token[-4:] if t.token else "",
"permissions": json.loads(t.permissions) if t.permissions else [],
"is_active": t.is_active,
"created_at": t.created_at.isoformat() if t.created_at else None,
"expires_at": t.expires_at.isoformat() if t.expires_at else None,
"last_used_at": t.last_used_at.isoformat() if t.last_used_at else None,
}
for t in tokens
]
@router.post("")
def create_token(
data: dict,
request: Request,
db: Session = Depends(get_db),
user: AdminUser = Depends(get_current_user),
):
"""Create a new API token."""
name = data.get("name", "Unnamed Token")
permissions = data.get("permissions", [])
expires_in_days = data.get("expires_in_days")
raw_token = secrets.token_urlsafe(32)
expires_at = None
if expires_in_days:
expires_at = datetime.now(timezone.utc) + timedelta(days=int(expires_in_days))
token = ApiToken(
name=name,
token=raw_token,
permissions=json.dumps(permissions),
is_active=True,
created_at=datetime.now(timezone.utc),
expires_at=expires_at,
)
db.add(token)
db.commit()
db.refresh(token)
create_audit_log(
db,
action="create_token",
resource_type="api_token",
resource_id=str(token.id),
operator=user.username,
ip_address=request.client.host if request.client else "",
details=f"Created token '{name}' with permissions {permissions}",
)
return {
"id": token.id,
"name": token.name,
"token": raw_token, # Only shown once on creation
"permissions": permissions,
"is_active": True,
"created_at": token.created_at.isoformat(),
"expires_at": token.expires_at.isoformat() if token.expires_at else None,
}
@router.delete("/{token_id}")
def delete_token(
token_id: int,
request: Request,
db: Session = Depends(get_db),
user: AdminUser = Depends(get_current_user),
):
"""Revoke an API token."""
token = db.query(ApiToken).filter(ApiToken.id == token_id).first()
if not token:
raise HTTPException(status_code=404, detail="Token not found")
token.is_active = False
db.commit()
create_audit_log(
db,
action="revoke_token",
resource_type="api_token",
resource_id=str(token_id),
operator=user.username,
ip_address=request.client.host if request.client else "",
)
return {"message": "Token revoked"}

View File

@@ -0,0 +1,44 @@
"""Transactions router."""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from app.auth import get_current_user, AdminUser
from app.database import get_db
from app.services import airwallex_service
router = APIRouter(prefix="/api", tags=["transactions"])
@router.get("/transactions")
def list_transactions(
page_num: int = Query(0, ge=0),
page_size: int = Query(20, ge=1, le=100),
card_id: Optional[str] = None,
from_created_at: Optional[str] = None,
to_created_at: Optional[str] = None,
db: Session = Depends(get_db),
user: AdminUser = Depends(get_current_user),
):
"""List transactions."""
return airwallex_service.list_transactions(
db, page_num, page_size, card_id, from_created_at, to_created_at
)
@router.get("/authorizations")
def list_authorizations(
page_num: int = Query(0, ge=0),
page_size: int = Query(20, ge=1, le=100),
card_id: Optional[str] = None,
status: Optional[str] = None,
from_created_at: Optional[str] = None,
to_created_at: Optional[str] = None,
db: Session = Depends(get_db),
user: AdminUser = Depends(get_current_user),
):
"""List authorizations."""
return airwallex_service.list_authorizations(
db, page_num, page_size, card_id, status, from_created_at, to_created_at
)

View File

View 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)}

View 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

11
backend/requirements.txt Normal file
View File

@@ -0,0 +1,11 @@
fastapi==0.115.0
uvicorn[standard]==0.30.0
sqlalchemy==2.0.35
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.1.3
python-dotenv==1.0.1
pydantic==2.11.3
pydantic-settings==2.6.0
httpx==0.28.1
cryptography==43.0.0