geo/backend/app/services/payment/wechat_pay.py

281 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import base64
import hashlib
import hmac
import json
import logging
import time
import uuid
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__)
WECHAT_PAY_BASE_URL = "https://api.mch.weixin.qq.com"
class WeChatPayGateway(PaymentGateway):
def __init__(self):
self.mch_id = settings.WECHAT_PAY_MCH_ID
self.api_key = settings.WECHAT_PAY_API_KEY
self.app_id = settings.WECHAT_PAY_APP_ID
self.cert_path = settings.WECHAT_PAY_CERT_PATH
self.notify_url = settings.WECHAT_PAY_NOTIFY_URL
self._serial_no = ""
def _is_configured(self) -> bool:
return bool(self.mch_id and self.api_key and self.app_id)
def _get_gateway(self) -> PaymentGateway:
if not self._is_configured():
return MockGateway()
return self
def _generate_nonce(self) -> str:
return uuid.uuid4().hex[:32]
def _build_auth_header(self, method: str, url_path: str, body: str = "") -> str:
timestamp = str(int(time.time()))
nonce_str = self._generate_nonce()
sign_message = f"{self.mch_id}\n{timestamp}\n{nonce_str}\n{body}\n"
signature = hmac.new(
self.api_key.encode("utf-8"),
sign_message.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return (
f'WECHATPAY2-SHA256-RSA2048 '
f'mchid="{self.mch_id}",'
f'nonce_str="{nonce_str}",'
f'timestamp="{timestamp}",'
f'serial_no="{self._serial_no}",'
f'signature="{signature}"'
)
def _build_signature(self, method: str, url_path: str, timestamp: str, nonce_str: str, body: str = "") -> str:
sign_message = f"{method}\n{url_path}\n{timestamp}\n{nonce_str}\n{body}\n"
return hmac.new(
self.api_key.encode("utf-8"),
sign_message.encode("utf-8"),
hashlib.sha256,
).hexdigest()
def _verify_v3_signature(self, timestamp: str, nonce: str, body: str, signature: str) -> bool:
sign_message = f"{timestamp}\n{nonce}\n{body}\n"
expected = hmac.new(
self.api_key.encode("utf-8"),
sign_message.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)
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("[WeChatPay] 未配置商户信息降级为Mock支付")
return await MockGateway().create_order(order_id, amount, description, user_id, plan)
url_path = "/v3/pay/transactions/native"
url = f"{WECHAT_PAY_BASE_URL}{url_path}"
body = json.dumps({
"appid": self.app_id,
"mchid": self.mch_id,
"description": description,
"out_trade_no": order_id,
"notify_url": self.notify_url,
"amount": {
"total": int(amount * 100),
"currency": "CNY",
},
})
timestamp = str(int(time.time()))
nonce_str = self._generate_nonce()
signature = self._build_signature("POST", url_path, timestamp, nonce_str, body)
auth_header = (
f'WECHATPAY2-SHA256-RSA2048 '
f'mchid="{self.mch_id}",'
f'nonce_str="{nonce_str}",'
f'timestamp="{timestamp}",'
f'derial_no="{self._serial_no}",'
f'signature="{signature}"'
)
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": auth_header,
}
try:
resp = requests.post(url, data=body.encode("utf-8"), headers=headers, timeout=10)
resp.raise_for_status()
data = resp.json()
code_url = data.get("code_url", "")
return PaymentOrder(
order_id=order_id,
pay_url=code_url,
amount=amount,
status="pending",
)
except requests.RequestException as e:
logger.error("[WeChatPay] 创建订单失败: %s", e)
raise RuntimeError(f"WeChat Pay 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)
timestamp = request_data.get("timestamp", "")
nonce = request_data.get("nonce", "")
body = request_data.get("body", "")
signature = request_data.get("signature", "")
if not self._verify_v3_signature(timestamp, nonce, body, signature):
logger.warning("[WeChatPay] V3回调签名验证失败")
return PaymentCallback(
order_id="",
payment_id="",
amount=0,
status="failed",
raw_data=request_data,
)
try:
resource = json.loads(body).get("resource", {}) if body.startswith("{") else {}
if not resource and "resource" in request_data:
resource = request_data["resource"]
ciphertext = resource.get("ciphertext", "")
nonce_val = resource.get("nonce", "")
associated_data = resource.get("associated_data", "")
decrypted = self._decrypt_resource(ciphertext, nonce_val, associated_data)
order_id = decrypted.get("out_trade_no", "")
transaction_id = decrypted.get("transaction_id", "")
total = decrypted.get("amount", {}).get("total", 0)
trade_state = decrypted.get("trade_state", "")
return PaymentCallback(
order_id=order_id,
payment_id=transaction_id,
amount=total / 100,
status="success" if trade_state == "SUCCESS" else "failed",
raw_data=request_data,
)
except Exception as e:
logger.error("[WeChatPay] 回调解密失败: %s", e)
return PaymentCallback(
order_id="",
payment_id="",
amount=0,
status="failed",
raw_data=request_data,
)
def _decrypt_resource(self, ciphertext_b64: str, nonce: str, associated_data: str) -> dict:
key = self.api_key.encode("utf-8")
ct = base64.b64decode(ciphertext_b64)
tag = ct[-16:]
ct_bytes = ct[:-16]
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
aesgcm = AESGCM(key)
plaintext = aesgcm.decrypt(nonce.encode("utf-8"), ct_bytes + tag, associated_data.encode("utf-8"))
return json.loads(plaintext.decode("utf-8"))
async def query_order(self, order_id: str) -> PaymentOrder:
if not self._is_configured():
return await MockGateway().query_order(order_id)
url_path = f"/v3/pay/transactions/out-trade-no/{order_id}?mchid={self.mch_id}"
url = f"{WECHAT_PAY_BASE_URL}{url_path}"
timestamp = str(int(time.time()))
nonce_str = self._generate_nonce()
signature = self._build_signature("GET", url_path, timestamp, nonce_str, "")
auth_header = (
f'WECHATPAY2-SHA256-RSA2048 '
f'mchid="{self.mch_id}",'
f'nonce_str="{nonce_str}",'
f'timestamp="{timestamp}",'
f'derial_no="{self._serial_no}",'
f'signature="{signature}"'
)
headers = {
"Accept": "application/json",
"Authorization": auth_header,
}
try:
resp = requests.get(url, headers=headers, timeout=10)
resp.raise_for_status()
data = resp.json()
trade_state = data.get("trade_state", "NOTPAY")
total = data.get("amount", {}).get("total", 0)
status_map = {
"SUCCESS": "completed",
"NOTPAY": "pending",
"CLOSED": "closed",
"REFUND": "refunded",
}
return PaymentOrder(
order_id=order_id,
pay_url="",
amount=total / 100,
status=status_map.get(trade_state, "pending"),
)
except requests.RequestException as e:
logger.error("[WeChatPay] 查询订单失败: %s", e)
raise RuntimeError(f"WeChat Pay 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)
url_path = "/v3/refund/domestic/refunds"
url = f"{WECHAT_PAY_BASE_URL}{url_path}"
refund_id = f"R_{order_id}_{int(time.time())}"
body = json.dumps({
"out_trade_no": order_id,
"out_refund_no": refund_id,
"reason": reason or "用户申请退款",
"amount": {
"refund": int(amount * 100),
"total": int(amount * 100),
"currency": "CNY",
},
})
timestamp = str(int(time.time()))
nonce_str = self._generate_nonce()
signature = self._build_signature("POST", url_path, timestamp, nonce_str, body)
auth_header = (
f'WECHATPAY2-SHA256-RSA2048 '
f'mchid="{self.mch_id}",'
f'nonce_str="{nonce_str}",'
f'timestamp="{timestamp}",'
f'derial_no="{self._serial_no}",'
f'signature="{signature}"'
)
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"Authorization": auth_header,
}
try:
resp = requests.post(url, data=body.encode("utf-8"), headers=headers, timeout=10)
resp.raise_for_status()
data = resp.json()
return data.get("status") in ("PROCESSING", "SUCCESS")
except requests.RequestException as e:
logger.error("[WeChatPay] 退款失败: %s", e)
return False