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