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:
19
backend/.env.example
Normal file
19
backend/.env.example
Normal 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
27
backend/Dockerfile
Normal 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
0
backend/app/__init__.py
Normal file
74
backend/app/auth.py
Normal file
74
backend/app/auth.py
Normal 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
38
backend/app/config.py
Normal 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
35
backend/app/database.py
Normal 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
105
backend/app/main.py
Normal 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"}
|
||||
0
backend/app/models/__init__.py
Normal file
0
backend/app/models/__init__.py
Normal file
67
backend/app/models/db_models.py
Normal file
67
backend/app/models/db_models.py
Normal 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())
|
||||
148
backend/app/models/schemas.py
Normal file
148
backend/app/models/schemas.py
Normal 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)
|
||||
51
backend/app/proxy_client.py
Normal file
51
backend/app/proxy_client.py
Normal 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()
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
37
backend/app/routers/auth.py
Normal file
37
backend/app/routers/auth.py
Normal 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}
|
||||
47
backend/app/routers/cardholders.py
Normal file
47
backend/app/routers/cardholders.py
Normal 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))
|
||||
146
backend/app/routers/cards.py
Normal file
146
backend/app/routers/cards.py
Normal 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))
|
||||
57
backend/app/routers/dashboard.py
Normal file
57
backend/app/routers/dashboard.py
Normal 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,
|
||||
}
|
||||
152
backend/app/routers/external_api.py
Normal file
152
backend/app/routers/external_api.py
Normal 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
|
||||
98
backend/app/routers/logs.py
Normal file
98
backend/app/routers/logs.py
Normal 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,
|
||||
}
|
||||
85
backend/app/routers/settings.py
Normal file
85
backend/app/routers/settings.py
Normal 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)
|
||||
114
backend/app/routers/tokens.py
Normal file
114
backend/app/routers/tokens.py
Normal 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"}
|
||||
44
backend/app/routers/transactions.py
Normal file
44
backend/app/routers/transactions.py
Normal 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
|
||||
)
|
||||
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
|
||||
11
backend/requirements.txt
Normal file
11
backend/requirements.txt
Normal 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
|
||||
Reference in New Issue
Block a user