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

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
)