|
|
import base64
|
|
|
import json
|
|
|
import uuid
|
|
|
from abc import ABC, abstractmethod
|
|
|
from dataclasses import dataclass, field
|
|
|
from datetime import datetime
|
|
|
from typing import Any
|
|
|
from urllib.parse import urlencode
|
|
|
|
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
|
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
|
|
|
|
|
from app.config.setting import settings
|
|
|
from app.core.logger import logger
|
|
|
|
|
|
# ── dataclass ──
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
class PaymentInfo:
|
|
|
pay_url: str | None = None
|
|
|
qr_code_url: str | None = None
|
|
|
trade_no: str = ""
|
|
|
raw: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
class CallbackResult:
|
|
|
verified: bool = False
|
|
|
transaction_id: str | None = None
|
|
|
order_id: int | None = None
|
|
|
amount: int = 0
|
|
|
raw: dict[str, Any] = field(default_factory=dict)
|
|
|
|
|
|
|
|
|
# ── 抽象基类 ──
|
|
|
|
|
|
|
|
|
class BasePaymentGateway(ABC):
|
|
|
|
|
|
@abstractmethod
|
|
|
async def create_payment(self, order_no: str, amount: int, subject: str, notify_url: str) -> PaymentInfo:
|
|
|
...
|
|
|
|
|
|
@abstractmethod
|
|
|
async def verify_callback(self, data: dict[str, Any]) -> CallbackResult:
|
|
|
...
|
|
|
|
|
|
|
|
|
# ── 支付宝 ──
|
|
|
|
|
|
|
|
|
class AlipayGateway(BasePaymentGateway):
|
|
|
GATEWAY_URL = "https://openapi.alipay.com/gateway.do"
|
|
|
DEV_GATEWAY_URL = "https://openapi-sandbox.dl.alipaydev.com/gateway.do"
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
self.app_id = settings.PAYMENT_ALIPAY_APP_ID or ""
|
|
|
self._private_key = (settings.PAYMENT_ALIPAY_PRIVATE_KEY or "").encode()
|
|
|
self._alipay_public_key = (settings.PAYMENT_ALIPAY_PUBLIC_KEY or "").encode()
|
|
|
self.is_sandbox = settings.PAYMENT_ALIPAY_SANDBOX
|
|
|
self._gateway = self.DEV_GATEWAY_URL if self.is_sandbox else self.GATEWAY_URL
|
|
|
|
|
|
def _sign(self, params: dict[str, Any]) -> str:
|
|
|
sorted_params = sorted((k, v) for k, v in params.items() if v != "" and v is not None)
|
|
|
sign_str = "&".join(f"{k}={v}" for k, v in sorted_params)
|
|
|
private_key_obj = serialization.load_pem_private_key(self._private_key, password=None)
|
|
|
if not isinstance(private_key_obj, rsa.RSAPrivateKey):
|
|
|
raise TypeError("私钥类型不是 RSA")
|
|
|
signature = private_key_obj.sign(sign_str.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256())
|
|
|
return base64.b64encode(signature).decode("utf-8")
|
|
|
|
|
|
def _verify(self, params: dict[str, Any], signature: str) -> bool:
|
|
|
sorted_params = sorted(
|
|
|
(k, v) for k, v in params.items()
|
|
|
if k != "sign" and k != "sign_type" and v != "" and v is not None
|
|
|
)
|
|
|
sign_str = "&".join(f"{k}={v}" for k, v in sorted_params)
|
|
|
try:
|
|
|
public_key_obj = serialization.load_pem_public_key(self._alipay_public_key)
|
|
|
if not isinstance(public_key_obj, rsa.RSAPublicKey):
|
|
|
return False
|
|
|
public_key_obj.verify(base64.b64decode(signature), sign_str.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256())
|
|
|
return True
|
|
|
except Exception:
|
|
|
return False
|
|
|
|
|
|
async def create_payment(self, order_no: str, amount: int, subject: str, notify_url: str) -> PaymentInfo:
|
|
|
biz_content = json.dumps({
|
|
|
"out_trade_no": order_no,
|
|
|
"total_amount": f"{amount / 100:.2f}",
|
|
|
"subject": subject,
|
|
|
"product_code": "FAST_INSTANT_TRADE_PAY",
|
|
|
}, ensure_ascii=False)
|
|
|
params = {
|
|
|
"app_id": self.app_id,
|
|
|
"method": "alipay.trade.page.pay",
|
|
|
"format": "JSON",
|
|
|
"charset": "utf-8",
|
|
|
"sign_type": "RSA2",
|
|
|
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
|
"version": "1.0",
|
|
|
"notify_url": notify_url,
|
|
|
"biz_content": biz_content,
|
|
|
}
|
|
|
params["sign"] = self._sign(params)
|
|
|
pay_url = f"{self._gateway}?{urlencode(params)}"
|
|
|
return PaymentInfo(pay_url=pay_url, trade_no="", raw=params)
|
|
|
|
|
|
async def verify_callback(self, data: dict[str, Any]) -> CallbackResult:
|
|
|
sign = data.pop("sign", "")
|
|
|
_ = data.pop("sign_type", "")
|
|
|
verified = self._verify(data, sign) if sign else False
|
|
|
if verified and data.get("trade_status") in ("TRADE_SUCCESS", "TRADE_FINISHED"):
|
|
|
return CallbackResult(
|
|
|
verified=True,
|
|
|
transaction_id=data.get("trade_no"),
|
|
|
amount=int(float(data.get("total_amount", 0)) * 100),
|
|
|
raw=data,
|
|
|
)
|
|
|
return CallbackResult(verified=False, raw=data)
|
|
|
|
|
|
|
|
|
# ── Mock ──
|
|
|
|
|
|
|
|
|
class MockPaymentGateway(BasePaymentGateway):
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
self._pending_orders: dict[str, dict] = {}
|
|
|
|
|
|
async def create_payment(self, order_no: str, amount: int, subject: str, notify_url: str) -> PaymentInfo:
|
|
|
trade_no = f"MOCK{uuid.uuid4().hex[:16].upper()}"
|
|
|
self._pending_orders[order_no] = {"trade_no": trade_no, "amount": amount, "subject": subject}
|
|
|
pay_url = f"/mock-pay?order_no={order_no}&amount={amount}"
|
|
|
qr_url = f"https://api.qrserver.com/v1/create-qr-code/?size=200x200&data={pay_url}"
|
|
|
return PaymentInfo(pay_url=pay_url, qr_code_url=qr_url, trade_no=trade_no, raw={"order_no": order_no, "amount": amount, "is_mock": True})
|
|
|
|
|
|
async def verify_callback(self, data: dict[str, Any]) -> CallbackResult:
|
|
|
order_no = data.get("order_no", "")
|
|
|
pending = self._pending_orders.pop(order_no, None)
|
|
|
if pending:
|
|
|
return CallbackResult(verified=True, transaction_id=pending["trade_no"], order_id=data.get("order_id"), amount=pending["amount"], raw={**data, **pending})
|
|
|
return CallbackResult(verified=False, raw=data)
|
|
|
|
|
|
def get_mock_callback_data(self, order_id: int, order_no: str) -> dict:
|
|
|
pending = self._pending_orders.get(order_no, {})
|
|
|
return {
|
|
|
"order_id": order_id,
|
|
|
"order_no": order_no,
|
|
|
"trade_no": pending.get("trade_no", f"MOCK{uuid.uuid4().hex[:16].upper()}"),
|
|
|
"total_amount": str(pending.get("amount", 0) / 100),
|
|
|
"trade_status": "TRADE_SUCCESS",
|
|
|
}
|
|
|
|
|
|
|
|
|
# ── 工厂 ──
|
|
|
|
|
|
_mock_gateway: MockPaymentGateway | None = None
|
|
|
|
|
|
|
|
|
def create_payment_gateway(method: str = "") -> BasePaymentGateway:
|
|
|
if method == "alipay":
|
|
|
logger.info("🔔 使用支付宝支付网关")
|
|
|
return AlipayGateway()
|
|
|
if method == "wxpay":
|
|
|
logger.info("🔔 使用微信支付网关(未实现,回退 Mock)")
|
|
|
return MockPaymentGateway()
|
|
|
if settings.PAYMENT_ALIPAY_APP_ID and settings.PAYMENT_ALIPAY_PRIVATE_KEY:
|
|
|
logger.info("🔔 自动选择支付宝支付网关")
|
|
|
return AlipayGateway()
|
|
|
logger.info("🔔 使用 Mock 支付网关(开发模式)")
|
|
|
return MockPaymentGateway()
|
|
|
|
|
|
|
|
|
def get_mock_gateway() -> MockPaymentGateway:
|
|
|
global _mock_gateway
|
|
|
if _mock_gateway is None:
|
|
|
_mock_gateway = MockPaymentGateway()
|
|
|
return _mock_gateway
|
|
|
|
|
|
|
|
|
__all__ = [
|
|
|
"BasePaymentGateway",
|
|
|
"PaymentInfo",
|
|
|
"CallbackResult",
|
|
|
"create_payment_gateway",
|
|
|
"get_mock_gateway",
|
|
|
]
|