mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 17:30:23 +08:00
- 删除全部 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)
479 lines
17 KiB
Python
479 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from datetime import datetime, timedelta, timezone
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from src.models.database import PaymentCallback, PaymentOrder, User, Wallet
|
|
from src.services.billing.precision import to_money_decimal
|
|
from src.services.payment.gateway import get_payment_gateway
|
|
from src.services.wallet import WalletService
|
|
|
|
|
|
class PaymentService:
|
|
"""支付订单与回调处理服务。
|
|
|
|
当前实现目标:
|
|
- 打通充值订单创建
|
|
- 打通支付回调幂等到账
|
|
- 真实网关签名/SDK 留给后续渠道适配层
|
|
"""
|
|
|
|
@staticmethod
|
|
def _build_order_no() -> str:
|
|
ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f")
|
|
return f"po_{ts}_{uuid4().hex[:12]}"
|
|
|
|
@staticmethod
|
|
def _build_payload_hash(payload: dict[str, Any] | None) -> str | None:
|
|
if payload is None:
|
|
return None
|
|
encoded = json.dumps(payload, sort_keys=True, ensure_ascii=False, default=str).encode(
|
|
"utf-8"
|
|
)
|
|
return hashlib.sha256(encoded).hexdigest()
|
|
|
|
@classmethod
|
|
def create_recharge_order(
|
|
cls,
|
|
db: Session,
|
|
*,
|
|
user: User,
|
|
amount_usd: Decimal | float | int | str,
|
|
payment_method: str,
|
|
pay_amount: Decimal | float | int | str | None = None,
|
|
pay_currency: str | None = None,
|
|
exchange_rate: Decimal | float | int | str | None = None,
|
|
expires_in_minutes: int = 30,
|
|
gateway_order_id: str | None = None,
|
|
gateway_response: dict[str, Any] | None = None,
|
|
) -> PaymentOrder:
|
|
amount = to_money_decimal(amount_usd)
|
|
if amount <= Decimal("0"):
|
|
raise ValueError("recharge amount must be positive")
|
|
if not payment_method:
|
|
raise ValueError("payment_method is required")
|
|
if payment_method == "admin_manual":
|
|
raise ValueError("admin_manual is reserved for admin recharge")
|
|
gateway = get_payment_gateway(payment_method)
|
|
|
|
wallet = WalletService.get_or_create_wallet(db, user=user)
|
|
if wallet is None:
|
|
raise ValueError("wallet not available")
|
|
if wallet.status != "active":
|
|
raise ValueError("wallet is not active")
|
|
|
|
now = datetime.now(timezone.utc)
|
|
order = PaymentOrder(
|
|
order_no=cls._build_order_no(),
|
|
wallet_id=wallet.id,
|
|
user_id=user.id,
|
|
amount_usd=amount,
|
|
pay_amount=to_money_decimal(pay_amount) if pay_amount is not None else None,
|
|
pay_currency=pay_currency,
|
|
exchange_rate=to_money_decimal(exchange_rate) if exchange_rate is not None else None,
|
|
refunded_amount_usd=Decimal("0"),
|
|
refundable_amount_usd=Decimal("0"),
|
|
payment_method=payment_method,
|
|
gateway_order_id=gateway_order_id,
|
|
gateway_response=gateway_response,
|
|
status="pending",
|
|
expires_at=now + timedelta(minutes=max(expires_in_minutes, 1)),
|
|
)
|
|
db.add(order)
|
|
db.flush()
|
|
checkout = gateway.create_checkout_payload(order=order)
|
|
order.gateway_order_id = order.gateway_order_id or checkout.get("gateway_order_id")
|
|
order.gateway_response = gateway_response if gateway_response is not None else checkout
|
|
return order
|
|
|
|
@classmethod
|
|
def refresh_order_status(cls, order: PaymentOrder | None) -> bool:
|
|
if order is None:
|
|
return False
|
|
if order.status != "pending":
|
|
return False
|
|
now = datetime.now(timezone.utc)
|
|
if order.expires_at is not None and order.expires_at < now:
|
|
order.status = "expired"
|
|
return True
|
|
return False
|
|
|
|
@staticmethod
|
|
def get_order(
|
|
db: Session,
|
|
*,
|
|
order_id: str | None = None,
|
|
order_no: str | None = None,
|
|
gateway_order_id: str | None = None,
|
|
) -> PaymentOrder | None:
|
|
if order_id:
|
|
return db.query(PaymentOrder).filter(PaymentOrder.id == order_id).first()
|
|
if order_no:
|
|
return db.query(PaymentOrder).filter(PaymentOrder.order_no == order_no).first()
|
|
if gateway_order_id:
|
|
return (
|
|
db.query(PaymentOrder)
|
|
.filter(PaymentOrder.gateway_order_id == gateway_order_id)
|
|
.first()
|
|
)
|
|
return None
|
|
|
|
@classmethod
|
|
def list_user_orders(
|
|
cls,
|
|
db: Session,
|
|
*,
|
|
user_id: str,
|
|
limit: int,
|
|
offset: int,
|
|
) -> tuple[list[PaymentOrder], int, bool]:
|
|
expired_count = cls.expire_overdue_pending_orders(db, user_id=user_id)
|
|
q = db.query(PaymentOrder).filter(PaymentOrder.user_id == user_id)
|
|
total = q.count()
|
|
items = q.order_by(PaymentOrder.created_at.desc()).offset(offset).limit(limit).all()
|
|
return items, total, expired_count > 0
|
|
|
|
@classmethod
|
|
def list_orders(
|
|
cls,
|
|
db: Session,
|
|
*,
|
|
status: str | None = None,
|
|
payment_method: str | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> tuple[list[PaymentOrder], int, bool]:
|
|
expired_count = 0
|
|
if status in {None, "pending", "expired"}:
|
|
expired_count = cls.expire_overdue_pending_orders(
|
|
db,
|
|
payment_method=payment_method,
|
|
)
|
|
|
|
q = db.query(PaymentOrder)
|
|
if status:
|
|
q = q.filter(PaymentOrder.status == status)
|
|
if payment_method:
|
|
q = q.filter(PaymentOrder.payment_method == payment_method)
|
|
total = q.count()
|
|
items = q.order_by(PaymentOrder.created_at.desc()).offset(offset).limit(limit).all()
|
|
return items, total, expired_count > 0
|
|
|
|
@staticmethod
|
|
def expire_overdue_pending_orders(
|
|
db: Session,
|
|
*,
|
|
user_id: str | None = None,
|
|
payment_method: str | None = None,
|
|
) -> int:
|
|
now = datetime.now(timezone.utc)
|
|
q = db.query(PaymentOrder).filter(
|
|
PaymentOrder.status == "pending",
|
|
PaymentOrder.expires_at.isnot(None),
|
|
PaymentOrder.expires_at < now,
|
|
)
|
|
if user_id:
|
|
q = q.filter(PaymentOrder.user_id == user_id)
|
|
if payment_method:
|
|
q = q.filter(PaymentOrder.payment_method == payment_method)
|
|
return int(q.update({PaymentOrder.status: "expired"}, synchronize_session=False) or 0)
|
|
|
|
@staticmethod
|
|
def list_callbacks(
|
|
db: Session,
|
|
*,
|
|
payment_method: str | None = None,
|
|
limit: int = 50,
|
|
offset: int = 0,
|
|
) -> tuple[list[PaymentCallback], int]:
|
|
q = db.query(PaymentCallback)
|
|
if payment_method:
|
|
q = q.filter(PaymentCallback.payment_method == payment_method)
|
|
total = q.count()
|
|
items = q.order_by(PaymentCallback.created_at.desc()).offset(offset).limit(limit).all()
|
|
return items, total
|
|
|
|
@staticmethod
|
|
def get_user_order(
|
|
db: Session,
|
|
*,
|
|
user_id: str,
|
|
order_id: str,
|
|
) -> PaymentOrder | None:
|
|
return (
|
|
db.query(PaymentOrder)
|
|
.filter(PaymentOrder.id == order_id, PaymentOrder.user_id == user_id)
|
|
.first()
|
|
)
|
|
|
|
@classmethod
|
|
def fail_order(
|
|
cls,
|
|
db: Session,
|
|
*,
|
|
order: PaymentOrder,
|
|
reason: str | None = None,
|
|
) -> PaymentOrder:
|
|
locked_order = (
|
|
db.query(PaymentOrder)
|
|
.filter(PaymentOrder.id == order.id)
|
|
.with_for_update()
|
|
.one_or_none()
|
|
)
|
|
if locked_order is None:
|
|
raise ValueError("payment order not found")
|
|
if locked_order.status == "credited":
|
|
raise ValueError("credited order cannot be failed")
|
|
locked_order.status = "failed"
|
|
payload = dict(locked_order.gateway_response or {})
|
|
if reason:
|
|
payload["failure_reason"] = reason
|
|
payload["failed_at"] = datetime.now(timezone.utc).isoformat()
|
|
locked_order.gateway_response = payload
|
|
return locked_order
|
|
|
|
@classmethod
|
|
def expire_order(
|
|
cls,
|
|
db: Session,
|
|
*,
|
|
order: PaymentOrder,
|
|
reason: str | None = None,
|
|
) -> tuple[PaymentOrder, bool]:
|
|
locked_order = (
|
|
db.query(PaymentOrder)
|
|
.filter(PaymentOrder.id == order.id)
|
|
.with_for_update()
|
|
.one_or_none()
|
|
)
|
|
if locked_order is None:
|
|
raise ValueError("payment order not found")
|
|
if locked_order.status == "credited":
|
|
raise ValueError("credited order cannot be expired")
|
|
if locked_order.status == "expired":
|
|
return locked_order, False
|
|
if locked_order.status != "pending":
|
|
raise ValueError(f"only pending order can be expired: {locked_order.status}")
|
|
|
|
locked_order.status = "expired"
|
|
payload = dict(locked_order.gateway_response or {})
|
|
if reason:
|
|
payload["expire_reason"] = reason
|
|
payload["expired_at"] = datetime.now(timezone.utc).isoformat()
|
|
locked_order.gateway_response = payload
|
|
return locked_order, True
|
|
|
|
@classmethod
|
|
def log_callback(
|
|
cls,
|
|
db: Session,
|
|
*,
|
|
payment_method: str,
|
|
callback_key: str,
|
|
order_no: str | None = None,
|
|
gateway_order_id: str | None = None,
|
|
payload: dict[str, Any] | None = None,
|
|
signature_valid: bool = False,
|
|
status: str = "received",
|
|
payment_order: PaymentOrder | None = None,
|
|
error_message: str | None = None,
|
|
) -> tuple[PaymentCallback, bool]:
|
|
existing = (
|
|
db.query(PaymentCallback).filter(PaymentCallback.callback_key == callback_key).first()
|
|
)
|
|
if existing is not None:
|
|
return existing, False
|
|
|
|
callback = PaymentCallback(
|
|
payment_order_id=payment_order.id if payment_order else None,
|
|
payment_method=payment_method,
|
|
callback_key=callback_key,
|
|
order_no=order_no,
|
|
gateway_order_id=gateway_order_id,
|
|
payload_hash=cls._build_payload_hash(payload),
|
|
signature_valid=signature_valid,
|
|
status=status,
|
|
payload=payload,
|
|
error_message=error_message,
|
|
)
|
|
db.add(callback)
|
|
db.flush()
|
|
return callback, True
|
|
|
|
@classmethod
|
|
def credit_order(
|
|
cls,
|
|
db: Session,
|
|
*,
|
|
order: PaymentOrder,
|
|
gateway_order_id: str | None = None,
|
|
gateway_response: dict[str, Any] | None = None,
|
|
pay_amount: Decimal | float | int | str | None = None,
|
|
pay_currency: str | None = None,
|
|
exchange_rate: Decimal | float | int | str | None = None,
|
|
) -> tuple[PaymentOrder, bool]:
|
|
locked_order = (
|
|
db.query(PaymentOrder)
|
|
.filter(PaymentOrder.id == order.id)
|
|
.with_for_update()
|
|
.one_or_none()
|
|
)
|
|
if locked_order is None:
|
|
raise ValueError("payment order not found")
|
|
|
|
if locked_order.status == "credited":
|
|
return locked_order, False
|
|
if locked_order.status in {"failed", "expired", "refunded"}:
|
|
raise ValueError(f"payment order is not creditable: {locked_order.status}")
|
|
|
|
now = datetime.now(timezone.utc)
|
|
if locked_order.expires_at is not None and locked_order.expires_at < now:
|
|
locked_order.status = "expired"
|
|
raise ValueError("payment order expired")
|
|
|
|
wallet = db.query(Wallet).filter(Wallet.id == locked_order.wallet_id).first()
|
|
if wallet is None:
|
|
raise ValueError("wallet not found")
|
|
if wallet.status != "active":
|
|
raise ValueError("wallet is not active")
|
|
|
|
if gateway_order_id:
|
|
locked_order.gateway_order_id = gateway_order_id
|
|
if gateway_response is not None:
|
|
locked_order.gateway_response = gateway_response
|
|
if pay_amount is not None:
|
|
locked_order.pay_amount = to_money_decimal(pay_amount)
|
|
if pay_currency is not None:
|
|
locked_order.pay_currency = pay_currency
|
|
if exchange_rate is not None:
|
|
locked_order.exchange_rate = to_money_decimal(exchange_rate)
|
|
|
|
locked_order.status = "paid"
|
|
locked_order.paid_at = locked_order.paid_at or now
|
|
locked_order.refundable_amount_usd = to_money_decimal(locked_order.amount_usd)
|
|
|
|
WalletService.create_wallet_transaction(
|
|
db,
|
|
wallet=wallet,
|
|
category="recharge",
|
|
reason_code="topup_gateway",
|
|
amount=locked_order.amount_usd,
|
|
balance_type="recharge",
|
|
link_type="payment_order",
|
|
link_id=locked_order.id,
|
|
description=f"充值到账({locked_order.payment_method})",
|
|
)
|
|
|
|
locked_order.status = "credited"
|
|
locked_order.credited_at = now
|
|
return locked_order, True
|
|
|
|
@classmethod
|
|
def handle_callback(
|
|
cls,
|
|
db: Session,
|
|
*,
|
|
payment_method: str,
|
|
callback_key: str,
|
|
payload: dict[str, Any] | None,
|
|
callback_signature: str | None,
|
|
callback_secret: str | None,
|
|
order_no: str | None = None,
|
|
gateway_order_id: str | None = None,
|
|
amount_usd: Decimal | float | int | str | None = None,
|
|
pay_amount: Decimal | float | int | str | None = None,
|
|
pay_currency: str | None = None,
|
|
exchange_rate: Decimal | float | int | str | None = None,
|
|
) -> dict[str, Any]:
|
|
gateway = get_payment_gateway(payment_method)
|
|
verified = gateway.verify_callback_payload(
|
|
payload=payload,
|
|
callback_signature=callback_signature,
|
|
callback_secret=callback_secret,
|
|
)
|
|
callback, created = cls.log_callback(
|
|
db,
|
|
payment_method=payment_method,
|
|
callback_key=callback_key,
|
|
order_no=order_no,
|
|
gateway_order_id=gateway_order_id,
|
|
payload=payload,
|
|
signature_valid=verified,
|
|
)
|
|
if not created and callback.status == "processed":
|
|
return {
|
|
"ok": True,
|
|
"duplicate": True,
|
|
"credited": False,
|
|
"order_id": callback.payment_order_id,
|
|
}
|
|
if not verified:
|
|
callback.status = "failed"
|
|
callback.error_message = "invalid callback signature"
|
|
callback.processed_at = datetime.now(timezone.utc)
|
|
return {"ok": False, "duplicate": not created, "error": callback.error_message}
|
|
|
|
order = cls.get_order(
|
|
db,
|
|
order_no=order_no or callback.order_no,
|
|
gateway_order_id=gateway_order_id or callback.gateway_order_id,
|
|
)
|
|
if order is None:
|
|
callback.status = "failed"
|
|
callback.error_message = "payment order not found"
|
|
callback.processed_at = datetime.now(timezone.utc)
|
|
return {"ok": False, "duplicate": not created, "error": callback.error_message}
|
|
|
|
callback.payment_order_id = order.id
|
|
callback.order_no = order.order_no
|
|
callback.gateway_order_id = gateway_order_id or order.gateway_order_id
|
|
|
|
if amount_usd is None:
|
|
callback.status = "failed"
|
|
callback.error_message = "callback amount is required"
|
|
callback.processed_at = datetime.now(timezone.utc)
|
|
return {"ok": False, "duplicate": not created, "error": callback.error_message}
|
|
|
|
expected = to_money_decimal(order.amount_usd)
|
|
actual = to_money_decimal(amount_usd)
|
|
if actual != expected:
|
|
callback.status = "failed"
|
|
callback.error_message = "callback amount mismatch"
|
|
callback.processed_at = datetime.now(timezone.utc)
|
|
return {"ok": False, "duplicate": not created, "error": callback.error_message}
|
|
|
|
try:
|
|
updated_order, credited = cls.credit_order(
|
|
db,
|
|
order=order,
|
|
gateway_order_id=gateway_order_id,
|
|
gateway_response=payload,
|
|
pay_amount=pay_amount,
|
|
pay_currency=pay_currency,
|
|
exchange_rate=exchange_rate,
|
|
)
|
|
except ValueError as exc:
|
|
callback.status = "failed"
|
|
callback.error_message = str(exc)
|
|
callback.processed_at = datetime.now(timezone.utc)
|
|
return {"ok": False, "duplicate": not created, "error": callback.error_message}
|
|
|
|
callback.status = "processed"
|
|
callback.error_message = None
|
|
callback.processed_at = datetime.now(timezone.utc)
|
|
return {
|
|
"ok": True,
|
|
"duplicate": not created,
|
|
"credited": credited,
|
|
"order_id": updated_order.id,
|
|
"order_no": updated_order.order_no,
|
|
"status": updated_order.status,
|
|
"wallet_id": updated_order.wallet_id,
|
|
}
|