244 lines
9.5 KiB
Python
244 lines
9.5 KiB
Python
import json
|
||
import logging
|
||
import urllib.parse
|
||
import uuid
|
||
from datetime import datetime
|
||
|
||
import requests
|
||
|
||
from app.config import settings
|
||
from app.services.payment.base import PaymentGateway, PaymentOrder, PaymentCallback
|
||
from app.services.payment.mock_gateway import MockGateway
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
ALIPAY_GATEWAY_URL = "https://openapi.alipay.com/gateway.do"
|
||
|
||
|
||
class AlipayGateway(PaymentGateway):
|
||
def __init__(self):
|
||
self.app_id = settings.ALIPAY_APP_ID
|
||
self.private_key_path = settings.ALIPAY_PRIVATE_KEY_PATH
|
||
self.public_key_path = settings.ALIPAY_PUBLIC_KEY_PATH
|
||
self.notify_url = settings.ALIPAY_NOTIFY_URL
|
||
self._private_key = None
|
||
self._alipay_public_key = None
|
||
|
||
def _is_configured(self) -> bool:
|
||
return bool(self.app_id and self.private_key_path and self.public_key_path)
|
||
|
||
def _load_private_key(self):
|
||
if self._private_key is not None:
|
||
return self._private_key
|
||
try:
|
||
with open(self.private_key_path, "r") as f:
|
||
key_data = f.read()
|
||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||
from cryptography.hazmat.backends import default_backend
|
||
self._private_key = load_pem_private_key(
|
||
key_data.encode("utf-8"), password=None, backend=default_backend()
|
||
)
|
||
return self._private_key
|
||
except Exception as e:
|
||
logger.error("[Alipay] 加载私钥失败: %s", e)
|
||
return None
|
||
|
||
def _load_alipay_public_key(self):
|
||
if self._alipay_public_key is not None:
|
||
return self._alipay_public_key
|
||
try:
|
||
with open(self.public_key_path, "r") as f:
|
||
key_data = f.read()
|
||
from cryptography.hazmat.primitives.serialization import load_pem_public_key
|
||
from cryptography.hazmat.backends import default_backend
|
||
self._alipay_public_key = load_pem_public_key(
|
||
key_data.encode("utf-8"), backend=default_backend()
|
||
)
|
||
return self._alipay_public_key
|
||
except Exception as e:
|
||
logger.error("[Alipay] 加载支付宝公钥失败: %s", e)
|
||
return None
|
||
|
||
def _build_sign_content(self, params: dict) -> str:
|
||
sorted_params = sorted(params.items(), key=lambda x: x[0])
|
||
return "&".join(f"{k}={v}" for k, v in sorted_params if v)
|
||
|
||
def _rsa2_sign(self, sign_content: str) -> str:
|
||
private_key = self._load_private_key()
|
||
if private_key is None:
|
||
raise RuntimeError("Alipay private key not loaded")
|
||
from cryptography.hazmat.primitives import hashes, serialization
|
||
from cryptography.hazmat.primitives.asymmetric import padding
|
||
signature = private_key.sign(
|
||
sign_content.encode("utf-8"),
|
||
padding.PKCS1v15(),
|
||
hashes.SHA256(),
|
||
)
|
||
import base64
|
||
return base64.b64encode(signature).decode("utf-8")
|
||
|
||
def _rsa2_verify(self, sign_content: str, sign: str) -> bool:
|
||
public_key = self._load_alipay_public_key()
|
||
if public_key is None:
|
||
logger.warning("[Alipay] 支付宝公钥未加载,跳过签名验证")
|
||
return False
|
||
import base64
|
||
from cryptography.hazmat.primitives import hashes
|
||
from cryptography.hazmat.primitives.asymmetric import padding
|
||
try:
|
||
public_key.verify(
|
||
base64.b64decode(sign),
|
||
sign_content.encode("utf-8"),
|
||
padding.PKCS1v15(),
|
||
hashes.SHA256(),
|
||
)
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
def _build_common_params(self, method: str) -> dict:
|
||
return {
|
||
"app_id": self.app_id,
|
||
"method": method,
|
||
"charset": "utf-8",
|
||
"sign_type": "RSA2",
|
||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||
"version": "1.0",
|
||
"notify_url": self.notify_url,
|
||
}
|
||
|
||
async def create_order(
|
||
self, order_id: str, amount: float, description: str, user_id: str, plan: str
|
||
) -> PaymentOrder:
|
||
if not self._is_configured():
|
||
logger.info("[Alipay] 未配置应用信息,降级为Mock支付")
|
||
return await MockGateway().create_order(order_id, amount, description, user_id, plan)
|
||
|
||
biz_content = json.dumps({
|
||
"out_trade_no": order_id,
|
||
"total_amount": str(amount),
|
||
"subject": description,
|
||
"product_code": "QUICK_WAP_WAY",
|
||
})
|
||
|
||
params = self._build_common_params("alipay.trade.wap.pay")
|
||
params["biz_content"] = biz_content
|
||
|
||
sign_content = self._build_sign_content(params)
|
||
params["sign"] = self._rsa2_sign(sign_content)
|
||
|
||
try:
|
||
resp = requests.post(
|
||
ALIPAY_GATEWAY_URL,
|
||
data=params,
|
||
timeout=10,
|
||
)
|
||
resp.raise_for_status()
|
||
|
||
if resp.headers.get("content-type", "").startswith("text/html"):
|
||
pay_url = f"{ALIPAY_GATEWAY_URL}?{urllib.parse.urlencode(params)}"
|
||
else:
|
||
data = resp.json()
|
||
resp_data = data.get("alipay_trade_wap_pay_response", {})
|
||
pay_url = resp_data.get("pay_url", f"{ALIPAY_GATEWAY_URL}?{urllib.parse.urlencode(params)}")
|
||
|
||
return PaymentOrder(
|
||
order_id=order_id,
|
||
pay_url=pay_url,
|
||
amount=amount,
|
||
status="pending",
|
||
)
|
||
except requests.RequestException as e:
|
||
logger.error("[Alipay] 创建订单失败: %s", e)
|
||
raise RuntimeError(f"Alipay create_order failed: {e}") from e
|
||
|
||
async def verify_callback(self, request_data: dict) -> PaymentCallback:
|
||
if not self._is_configured():
|
||
return await MockGateway().verify_callback(request_data)
|
||
|
||
sign = request_data.get("sign", "")
|
||
sign_type = request_data.get("sign_type", "RSA2")
|
||
|
||
params = {k: v for k, v in request_data.items() if k not in ("sign", "sign_type") and v}
|
||
sign_content = self._build_sign_content(params)
|
||
|
||
if not self._rsa2_verify(sign_content, sign):
|
||
logger.warning("[Alipay] 回调签名验证失败: order_id=%s", request_data.get("out_trade_no"))
|
||
return PaymentCallback(
|
||
order_id=request_data.get("out_trade_no", ""),
|
||
payment_id=request_data.get("trade_no", ""),
|
||
amount=float(request_data.get("total_amount", 0)),
|
||
status="failed",
|
||
raw_data=request_data,
|
||
)
|
||
|
||
trade_status = request_data.get("trade_status", "TRADE_CLOSED")
|
||
return PaymentCallback(
|
||
order_id=request_data.get("out_trade_no", ""),
|
||
payment_id=request_data.get("trade_no", ""),
|
||
amount=float(request_data.get("total_amount", 0)),
|
||
status="success" if trade_status in ("TRADE_SUCCESS", "TRADE_FINISHED") else "failed",
|
||
raw_data=request_data,
|
||
)
|
||
|
||
async def query_order(self, order_id: str) -> PaymentOrder:
|
||
if not self._is_configured():
|
||
return await MockGateway().query_order(order_id)
|
||
|
||
biz_content = json.dumps({"out_trade_no": order_id})
|
||
params = self._build_common_params("alipay.trade.query")
|
||
params["biz_content"] = biz_content
|
||
|
||
sign_content = self._build_sign_content(params)
|
||
params["sign"] = self._rsa2_sign(sign_content)
|
||
|
||
try:
|
||
resp = requests.post(ALIPAY_GATEWAY_URL, data=params, timeout=10)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
resp_data = data.get("alipay_trade_query_response", {})
|
||
trade_status = resp_data.get("trade_status", "WAIT_BUYER_PAY")
|
||
total_amount = float(resp_data.get("total_amount", 0))
|
||
|
||
status_map = {
|
||
"TRADE_SUCCESS": "completed",
|
||
"TRADE_FINISHED": "completed",
|
||
"WAIT_BUYER_PAY": "pending",
|
||
"TRADE_CLOSED": "closed",
|
||
}
|
||
return PaymentOrder(
|
||
order_id=order_id,
|
||
pay_url="",
|
||
amount=total_amount,
|
||
status=status_map.get(trade_status, "pending"),
|
||
)
|
||
except requests.RequestException as e:
|
||
logger.error("[Alipay] 查询订单失败: %s", e)
|
||
raise RuntimeError(f"Alipay query_order failed: {e}") from e
|
||
|
||
async def refund(self, order_id: str, amount: float, reason: str = "") -> bool:
|
||
if not self._is_configured():
|
||
return await MockGateway().refund(order_id, amount, reason)
|
||
|
||
biz_content = json.dumps({
|
||
"out_trade_no": order_id,
|
||
"refund_amount": str(amount),
|
||
"refund_reason": reason or "用户申请退款",
|
||
"out_request_no": f"R_{order_id}_{int(datetime.now().timestamp())}",
|
||
})
|
||
params = self._build_common_params("alipay.trade.refund")
|
||
params["biz_content"] = biz_content
|
||
|
||
sign_content = self._build_sign_content(params)
|
||
params["sign"] = self._rsa2_sign(sign_content)
|
||
|
||
try:
|
||
resp = requests.post(ALIPAY_GATEWAY_URL, data=params, timeout=10)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
resp_data = data.get("alipay_trade_refund_response", {})
|
||
return resp_data.get("code") == "10000"
|
||
except requests.RequestException as e:
|
||
logger.error("[Alipay] 退款失败: %s", e)
|
||
return False
|