281 lines
10 KiB
Python
281 lines
10 KiB
Python
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
|