refactor: 移除 Python 后端源码,全面迁移至 Rust gateway 架构

- 删除全部 Python 源码 (src/) 及 Alembic 迁移脚本,归档至 _deprecated_py_src/
- 重构 Rust gateway ai_pipeline: 拆分 planner/finalize 模块,新增 contracts/adaptation 层
- 重组 handlers 模块为 admin/public/proxy/internal/shared 子模块结构
- 新增 executor 模块,引入 Rust 原生数据库迁移 (aether-data/migrations)
- 简化 CI/Docker 构建流程,移除 base image 二级构建,统一为单一 app image
- 移除 Python 相关基础设施文件 (entrypoint.sh, gunicorn_conf.py, Dockerfile.base)
This commit is contained in:
fawney19
2026-04-03 16:26:16 +08:00
parent 8f26e1a31f
commit 1d9c77522a
868 changed files with 1735 additions and 2433 deletions

View File

@@ -0,0 +1,48 @@
"""Wallet API routes."""
from __future__ import annotations
from fastapi import APIRouter
from starlette.routing import BaseRoute
from .routes import router as wallet_router
_RUST_OWNED_WALLET_ROUTE_SIGNATURES = frozenset(
{
("GET", "/api/wallet/balance"),
("GET", "/api/wallet/transactions"),
("GET", "/api/wallet/flow"),
("GET", "/api/wallet/today-cost"),
("GET", "/api/wallet/recharge"),
("POST", "/api/wallet/recharge"),
("GET", "/api/wallet/recharge/{order_id}"),
("GET", "/api/wallet/refunds"),
("POST", "/api/wallet/refunds"),
("GET", "/api/wallet/refunds/{refund_id}"),
}
)
def _route_is_rust_owned(route: BaseRoute) -> bool:
path = getattr(route, "path", None)
methods = getattr(route, "methods", None)
if not isinstance(path, str) or not methods:
return False
return any(
(method, path) in _RUST_OWNED_WALLET_ROUTE_SIGNATURES
for method in methods
if method not in {"HEAD", "OPTIONS"}
)
def _build_python_wallet_router() -> APIRouter:
router = APIRouter()
router.include_router(wallet_router)
router.routes = [route for route in router.routes if not _route_is_rust_owned(route)]
return router
python_wallet_router = _build_python_wallet_router()
router = python_wallet_router
__all__ = ["python_wallet_router", "router"]

View File

@@ -0,0 +1,576 @@
"""用户钱包与退款接口。"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, datetime, timezone
from typing import Any
from uuid import uuid4
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, Query, Request
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from src.api.base.authenticated_adapter import AuthenticatedApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import get_pipeline
from src.api.serializers import (
safe_gateway_response,
serialize_payment_order,
serialize_wallet_daily_usage,
serialize_wallet_payload,
serialize_wallet_refund,
serialize_wallet_transaction,
)
from src.core.exceptions import InvalidRequestException, NotFoundException, translate_pydantic_error
from src.database import get_db, get_db_context
from src.models.database import (
PaymentOrder,
RefundRequest,
User,
Wallet,
WalletDailyUsageLedger,
WalletTransaction,
)
from src.services.payment import PaymentService
from src.services.wallet import WalletDailyUsageLedgerService, WalletService
router = APIRouter(prefix="/api/wallet", tags=["Wallet"])
pipeline = get_pipeline()
def _create_recharge_order_sync(user_id: str, req: CreateRechargePayload) -> dict[str, Any]:
with get_db_context() as db:
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise InvalidRequestException("未登录")
try:
order = PaymentService.create_recharge_order(
db,
user=user,
amount_usd=req.amount_usd,
payment_method=req.payment_method,
pay_amount=req.pay_amount,
pay_currency=req.pay_currency,
exchange_rate=req.exchange_rate,
)
except ValueError as exc:
raise InvalidRequestException(str(exc)) from exc
db.commit()
db.refresh(order)
return {
"order": serialize_payment_order(order, sanitize_gateway_response=True),
"payment_instructions": safe_gateway_response(order.gateway_response),
}
def _list_recharge_orders_sync(user_id: str, limit: int, offset: int) -> dict[str, Any]:
with get_db_context() as db:
items, total, _changed = PaymentService.list_user_orders(
db,
user_id=user_id,
limit=limit,
offset=offset,
)
return {
"items": [
serialize_payment_order(item, sanitize_gateway_response=True) for item in items
],
"total": total,
"limit": limit,
"offset": offset,
}
def _get_recharge_order_sync(user_id: str, order_id: str) -> dict[str, Any]:
with get_db_context() as db:
order = PaymentService.get_user_order(db, user_id=user_id, order_id=order_id)
if order is None:
raise NotFoundException("Payment order not found")
PaymentService.refresh_order_status(order)
return {"order": serialize_payment_order(order, sanitize_gateway_response=True)}
def _list_refunds_sync(user_id: str, limit: int, offset: int) -> dict[str, Any]:
with get_db_context() as db:
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise InvalidRequestException("未登录")
existing_wallet = WalletService.get_wallet(db, user_id=user.id)
wallet = existing_wallet or WalletService.get_or_create_wallet(db, user=user)
if wallet is None:
return {"items": [], "total": 0, "limit": limit, "offset": offset}
if existing_wallet is None:
db.commit()
db.refresh(wallet)
base_query = db.query(RefundRequest).filter(RefundRequest.wallet_id == wallet.id)
total = base_query.count()
items = (
base_query.order_by(RefundRequest.created_at.desc()).offset(offset).limit(limit).all()
)
return {
"items": [serialize_wallet_refund(item) for item in items],
"total": total,
"limit": limit,
"offset": offset,
}
def _create_refund_sync(user_id: str, req: CreateRefundPayload) -> dict[str, Any]:
with get_db_context() as db:
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise InvalidRequestException("未登录")
wallet = WalletService.get_or_create_wallet(db, user=user)
if wallet is None:
raise InvalidRequestException("当前账户尚未开通钱包,无法申请退款")
payment_order = None
source_type = req.source_type or "wallet_balance"
source_id = req.source_id
refund_mode = req.refund_mode or "offline_payout"
if req.payment_order_id:
payment_order = (
db.query(PaymentOrder)
.filter(
PaymentOrder.id == req.payment_order_id, PaymentOrder.wallet_id == wallet.id
)
.first()
)
if payment_order is None:
raise NotFoundException("Payment order not found")
source_type = "payment_order"
source_id = payment_order.id
refund_mode = req.refund_mode or _default_refund_mode_for_order(payment_order)
try:
refund = WalletService.create_refund_request(
db,
wallet=wallet,
user_id=user.id,
amount_usd=req.amount_usd,
refund_no=_build_refund_no(),
source_type=source_type,
source_id=source_id,
refund_mode=refund_mode,
payment_order=payment_order,
reason=req.reason,
requested_by=user.id,
idempotency_key=req.idempotency_key,
)
db.commit()
db.refresh(refund)
return serialize_wallet_refund(refund)
except ValueError as exc:
db.rollback()
raise InvalidRequestException(str(exc)) from exc
except IntegrityError:
db.rollback()
if req.idempotency_key:
existing = (
db.query(RefundRequest)
.filter(
RefundRequest.idempotency_key == req.idempotency_key,
RefundRequest.user_id == user.id,
)
.first()
)
if existing is not None:
return serialize_wallet_refund(existing)
raise InvalidRequestException("退款申请重复,请勿重复提交")
class CreateRefundPayload(BaseModel):
amount_usd: float = Field(..., gt=0, allow_inf_nan=False)
payment_order_id: str | None = None
source_type: str | None = Field(default=None, max_length=30)
source_id: str | None = Field(default=None, max_length=100)
refund_mode: str | None = Field(default=None, max_length=30)
reason: str | None = Field(default=None, max_length=500)
idempotency_key: str | None = Field(default=None, max_length=128)
class CreateRechargePayload(BaseModel):
amount_usd: float = Field(..., gt=0, allow_inf_nan=False)
payment_method: str = Field(..., min_length=1, max_length=30)
pay_amount: float | None = Field(default=None, gt=0, allow_inf_nan=False)
pay_currency: str | None = Field(default=None, min_length=3, max_length=3)
exchange_rate: float | None = Field(default=None, gt=0, allow_inf_nan=False)
def _default_refund_mode_for_order(order: PaymentOrder) -> str:
if order.payment_method in {"admin_manual", "card_recharge", "card_code", "gift_code"}:
return "offline_payout"
return "original_channel"
def _build_refund_no() -> str:
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f")
return f"rf_{ts}_{uuid4().hex[:8]}"
def _resolve_user_wallet(db: Session, user: Any) -> Wallet | None:
existing_wallet = WalletService.get_wallet(db, user_id=user.id)
wallet = existing_wallet or WalletService.get_or_create_wallet(db, user=user)
if wallet is not None and existing_wallet is None:
db.commit()
db.refresh(wallet)
return wallet
def _flow_sort_key(item: dict[str, Any], billing_tz: ZoneInfo) -> tuple[date, int, datetime]:
item_type = item.get("type")
data = item.get("data") or {}
if item_type == "daily_usage":
raw_date = data.get("date")
billing_date = (
date.fromisoformat(raw_date) if isinstance(raw_date, str) and raw_date else date.min
)
sort_dt = (
data.get("last_finalized_at")
or data.get("aggregated_at")
or datetime.min.replace(tzinfo=timezone.utc)
)
if isinstance(sort_dt, str):
sort_dt = datetime.fromisoformat(sort_dt.replace("Z", "+00:00"))
return billing_date, 1, sort_dt
created_at = data.get("created_at")
if isinstance(created_at, str):
created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00"))
if not isinstance(created_at, datetime):
created_at = datetime.min.replace(tzinfo=timezone.utc)
elif created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
local_date = created_at.astimezone(billing_tz).date()
return local_date, 0, created_at
@router.get("/balance")
async def get_wallet_balance(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = WalletBalanceAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/transactions")
async def list_wallet_transactions(
request: Request,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0, le=5000),
db: Session = Depends(get_db),
) -> Any:
adapter = WalletTransactionsAdapter(limit=limit, offset=offset)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/flow")
async def get_wallet_flow(
request: Request,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0, le=5000),
db: Session = Depends(get_db),
) -> Any:
adapter = WalletFlowAdapter(limit=limit, offset=offset)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/today-cost")
async def get_wallet_today_cost(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = WalletTodayCostAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/recharge")
async def create_recharge_order(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = WalletRechargeCreateAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/recharge")
async def list_recharge_orders(
request: Request,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0, le=5000),
db: Session = Depends(get_db),
) -> Any:
adapter = WalletRechargeListAdapter(limit=limit, offset=offset)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/recharge/{order_id}")
async def get_recharge_order(order_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
adapter = WalletRechargeDetailAdapter(order_id=order_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/refunds")
async def list_refunds(
request: Request,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0, le=5000),
db: Session = Depends(get_db),
) -> Any:
adapter = WalletRefundListAdapter(limit=limit, offset=offset)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.get("/refunds/{refund_id}")
async def get_refund_detail(refund_id: str, request: Request, db: Session = Depends(get_db)) -> Any:
adapter = WalletRefundDetailAdapter(refund_id=refund_id)
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@router.post("/refunds")
async def create_refund(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = WalletRefundCreateAdapter()
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
@dataclass
class WalletTransactionsAdapter(AuthenticatedApiAdapter):
limit: int
offset: int
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
db = context.db
user = context.user
if user is None:
raise InvalidRequestException("未登录")
wallet = _resolve_user_wallet(db, user)
if wallet is None:
return {
"items": [],
"total": 0,
"limit": self.limit,
"offset": self.offset,
**serialize_wallet_payload(None),
}
base_query = db.query(WalletTransaction).filter(WalletTransaction.wallet_id == wallet.id)
total = base_query.count()
items = (
base_query.order_by(WalletTransaction.created_at.desc())
.offset(self.offset)
.limit(self.limit)
.all()
)
return {
"items": [serialize_wallet_transaction(item) for item in items],
"total": total,
"limit": self.limit,
"offset": self.offset,
**serialize_wallet_payload(wallet),
}
@dataclass
class WalletFlowAdapter(AuthenticatedApiAdapter):
limit: int
offset: int
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
db = context.db
user = context.user
if user is None:
raise InvalidRequestException("未登录")
wallet = _resolve_user_wallet(db, user)
if wallet is None:
return {
"today_entry": None,
"items": [],
"total": 0,
"limit": self.limit,
"offset": self.offset,
**serialize_wallet_payload(None),
}
today_entry = WalletDailyUsageLedgerService.get_today_snapshot(db, wallet.id)
billing_tz = WalletDailyUsageLedgerService.get_timezone(today_entry.billing_timezone)
fetch_size = min(self.offset + self.limit, 5200)
tx_query = db.query(WalletTransaction).filter(WalletTransaction.wallet_id == wallet.id)
tx_total = tx_query.count()
tx_items = tx_query.order_by(WalletTransaction.created_at.desc()).limit(fetch_size).all()
daily_query = db.query(WalletDailyUsageLedger).filter(
WalletDailyUsageLedger.wallet_id == wallet.id,
WalletDailyUsageLedger.billing_timezone == today_entry.billing_timezone,
WalletDailyUsageLedger.billing_date < today_entry.billing_date,
)
daily_total = daily_query.count()
daily_items = (
daily_query.order_by(WalletDailyUsageLedger.billing_date.desc()).limit(fetch_size).all()
)
merged = [
{"type": "transaction", "data": serialize_wallet_transaction(item)} for item in tx_items
] + [
{"type": "daily_usage", "data": serialize_wallet_daily_usage(item)}
for item in daily_items
]
merged.sort(key=lambda item: _flow_sort_key(item, billing_tz), reverse=True)
paged = merged[self.offset : self.offset + self.limit]
return {
"today_entry": serialize_wallet_daily_usage(today_entry),
"items": paged,
"total": tx_total + daily_total,
"limit": self.limit,
"offset": self.offset,
**serialize_wallet_payload(wallet),
}
class WalletTodayCostAdapter(AuthenticatedApiAdapter):
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
db = context.db
user = context.user
if user is None:
raise InvalidRequestException("未登录")
wallet = _resolve_user_wallet(db, user)
snapshot = WalletDailyUsageLedgerService.get_today_snapshot(
db,
wallet.id if wallet is not None else None,
)
return serialize_wallet_daily_usage(snapshot)
class WalletBalanceAdapter(AuthenticatedApiAdapter):
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
db = context.db
user = context.user
if user is None:
raise InvalidRequestException("未登录")
wallet = _resolve_user_wallet(db, user)
if wallet is None:
return serialize_wallet_payload(None)
pending_refunds = (
db.query(RefundRequest)
.filter(
RefundRequest.wallet_id == wallet.id,
RefundRequest.status.in_(["pending_approval", "approved", "processing"]),
)
.count()
)
payload = serialize_wallet_payload(wallet)
payload["pending_refund_count"] = pending_refunds
return payload
class WalletRechargeCreateAdapter(AuthenticatedApiAdapter):
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
db = context.db
user = context.user
if user is None:
raise InvalidRequestException("未登录")
payload = context.ensure_json_body()
try:
req = CreateRechargePayload.model_validate(payload)
except ValidationError as exc:
errors = exc.errors()
if errors:
raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败")
return await run_in_threadpool(_create_recharge_order_sync, user.id, req)
@dataclass
class WalletRechargeListAdapter(AuthenticatedApiAdapter):
limit: int
offset: int
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
user = context.user
if user is None:
raise InvalidRequestException("未登录")
return await run_in_threadpool(
_list_recharge_orders_sync,
user.id,
self.limit,
self.offset,
)
@dataclass
class WalletRechargeDetailAdapter(AuthenticatedApiAdapter):
order_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
user = context.user
if user is None:
raise InvalidRequestException("未登录")
return await run_in_threadpool(_get_recharge_order_sync, user.id, self.order_id)
@dataclass
class WalletRefundListAdapter(AuthenticatedApiAdapter):
limit: int
offset: int
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
db = context.db
user = context.user
if user is None:
raise InvalidRequestException("未登录")
return await run_in_threadpool(_list_refunds_sync, user.id, self.limit, self.offset)
@dataclass
class WalletRefundDetailAdapter(AuthenticatedApiAdapter):
refund_id: str
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
db = context.db
user = context.user
if user is None:
raise InvalidRequestException("未登录")
refund = (
db.query(RefundRequest)
.filter(RefundRequest.id == self.refund_id, RefundRequest.user_id == user.id)
.first()
)
if refund is None:
raise NotFoundException("Refund request not found")
return serialize_wallet_refund(refund)
class WalletRefundCreateAdapter(AuthenticatedApiAdapter):
async def handle(self, context: ApiRequestContext) -> dict[str, Any]:
db = context.db
user = context.user
if user is None:
raise InvalidRequestException("未登录")
payload = context.ensure_json_body()
try:
req = CreateRefundPayload.model_validate(payload)
except ValidationError as exc:
errors = exc.errors()
if errors:
raise InvalidRequestException(translate_pydantic_error(errors[0]))
raise InvalidRequestException("请求数据验证失败")
return await run_in_threadpool(_create_refund_sync, user.id, req)