Files
2026-05-12 16:35:07 +08:00

628 lines
23 KiB
Python

"""微信支付工具模块
支持微信支付 V3 API
文档参考: https://pay.weixin.qq.com/docs/v3/apis/index.html
使用方式:
from app.core.wechat_pay import wechat_pay, WechatPayClient
# 统一下单
result = wechat_pay.unified_order(out_trade_no, total_amount, description, notify_url)
# 申请退款
result = wechat_pay.refund(transaction_id, refund_amount, total_amount)
"""
import hashlib
import time
import random
import string
import json
import base64
import xml.etree.ElementTree as ET
from typing import Optional, Dict, Any
from datetime import datetime
from urllib.parse import urljoin
import ssl
import urllib.request
import urllib.error
from app.core.config import settings
class WechatPayClient:
"""微信支付客户端"""
def __init__(self):
self.mchid = settings.WXPAY_MCHID
self.appid = settings.WXPAY_APPID
self.api_key = settings.WXPAY_API_KEY
self.cert_path = settings.WXPAY_CERT_PATH
self.private_key_path = settings.WXPAY_PRIVATE_KEY_PATH
self.sandbox = settings.WXPAY_SANDBOX
# API 版本
self.api_base = "https://api.mchpay.qpic.cn/v3" if self.sandbox else "https://api.mchpay.qpic.cn/v3"
def _generate_nonce_str(self, length: int = 32) -> str:
"""生成随机字符串"""
return ''.join(random.choices(string.ascii_letters + string.digits, k=length))
def _generate_sign(self, data: Dict[str, Any]) -> str:
"""生成签名 (V2 API)"""
# V2 签名算法
sorted_keys = sorted(data.keys())
sign_str = "&".join([f"{k}={data[k]}" for k in sorted_keys if data[k]])
sign_str += f"&key={self.api_key}"
return hashlib.md5(sign_str.encode('utf-8')).hexdigest().upper()
def _generate_sign_v3(self, method: str, url: str, timestamp: str, nonce: str, body: str = "") -> str:
"""生成签名 V3 (SHA256 with RSA)"""
try:
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.backends import default_backend
from cryptography.x509 import load_pem_x509_certificate
with open(self.private_key_path, 'r') as f:
private_key = f.read()
# 构建签名串
sign_str = f"{method}\n{url}\n{timestamp}\n{nonce}\n{body}\n"
# 使用私钥签名
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
key = serialization.load_pem_private_key(
private_key.encode(),
password=None,
backend=default_backend()
)
signature = key.sign(
sign_str.encode('utf-8'),
padding.PKCS1v15(),
hashes.SHA256()
)
return base64.b64encode(signature).decode('utf-8')
except ImportError:
raise ImportError("请安装 cryptography: pip install cryptography")
def _make_request(self, url: str, method: str = "GET", data: Dict = None, need_cert: bool = False) -> Dict:
"""发送HTTP请求"""
headers = {
'Content-Type': 'application/json',
'User-Agent': 'SQXP/1.0'
}
body = json.dumps(data) if data else ""
try:
if need_cert and self.cert_path and self.private_key_path:
import ssl
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.load_cert_chain(self.cert_path, self.private_key_path)
req = urllib.request.Request(url, data=body.encode(), headers=headers, method=method)
with urllib.request.urlopen(req, context=context) as response:
return json.loads(response.read().decode())
else:
req = urllib.request.Request(url, data=body.encode(), headers=headers, method=method)
with urllib.request.urlopen(req) as response:
return json.loads(response.read().decode())
except urllib.error.HTTPError as e:
error_body = e.read().decode() if e.fp else ""
try:
return json.loads(error_body)
except:
return {"error": error_body, "code": e.code}
except Exception as e:
return {"error": str(e), "code": -1}
def unified_order(
self,
out_trade_no: str,
total_amount: int,
description: str,
notify_url: str,
openid: str = None
) -> Dict[str, Any]:
"""
统一下单接口 (V3)
Args:
out_trade_no: 商户订单号
total_amount: 订单金额(分)
description: 商品描述
notify_url: 回调通知地址
openid: 用户openid (JSAPI支付必需)
Returns:
返回支付参数,用于调起微信支付
"""
timestamp = str(int(time.time()))
nonce_str = self._generate_nonce_str()
url = f"{self.api_base}/transactions/jsapi"
data = {
"appid": self.appid,
"mchid": self.mchid,
"description": description,
"out_trade_no": out_trade_no,
"time_expire": datetime.now().strftime("%Y-%m-%dT%H:%M:%S+08:00"),
"attach": "sqxp_order",
"notify_url": notify_url,
"amount": {
"total": total_amount,
"currency": "CNY"
},
"payer": {
"openid": openid
} if openid else None
}
# 移除None值
data = {k: v for k, v in data.items() if v is not None}
body = json.dumps(data)
sign = self._generate_sign_v3("POST", f"/v3/transactions/jsapi", timestamp, nonce_str, body)
headers = {
"Authorization": f'WECHATPAY2-SHA256-RSA2048 mchid="{self.mchid}",nonce_str="{nonce_str}",signature="{sign}",timestamp="{timestamp}",serial_no="{settings.WXPAY_SERIAL_NO}"',
"Content-Type": "application/json",
"Accept": "application/json"
}
try:
import ssl
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
if self.cert_path and self.private_key_path:
context.load_cert_chain(self.cert_path, self.private_key_path)
req = urllib.request.Request(url, data=body.encode(), headers=headers, method="POST")
with urllib.request.urlopen(req, context=context, timeout=30) as response:
result = json.loads(response.read().decode())
if "prepay_id" in result:
# 返回调起支付的参数
pay_sign = self._generate_pay_sign(result["prepay_id"])
return {
"success": True,
"prepay_id": result["prepay_id"],
"pay_sign": pay_sign,
"timestamp": timestamp,
"nonce_str": nonce_str
}
else:
return {"success": False, "error": result.get("message", "下单失败")}
except Exception as e:
return {"success": False, "error": str(e)}
def _generate_pay_sign(self, prepay_id: str) -> str:
"""生成支付签名 (小程序调起支付用)"""
timestamp = str(int(time.time()))
nonce_str = self._generate_nonce_str()
sign_str = f"{self.appid}\n{timestamp}\n{nonce_str}\nprepay_id={prepay_id}\n"
return hashlib.sha256(sign_str.encode('utf-8')).hexdigest()
def query_order(self, out_trade_no: str = None, transaction_id: str = None) -> Dict:
"""查询订单"""
if not out_trade_no and not transaction_id:
return {"success": False, "error": "缺少订单号"}
timestamp = str(int(time.time()))
nonce_str = self._generate_nonce_str()
if out_trade_no:
url = f"{self.api_base}/v3/pay/transactions/out-trade-no/{out_trade_no}?mchid={self.mchid}"
else:
url = f"{self.api_base}/v3/pay/transactions/id/{transaction_id}?mchid={self.mchid}"
headers = {
"Authorization": f'WECHATPAY2-SHA256-RSA2048 mchid="{self.mchid}",nonce_str="{nonce_str}",signature="",timestamp="{timestamp}",serial_no=""',
"Accept": "application/json"
}
try:
import ssl
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(url, headers=headers, method="GET")
with urllib.request.urlopen(req, context=context, timeout=30) as response:
result = json.loads(response.read().decode())
return {"success": True, "data": result}
except urllib.error.HTTPError as e:
return {"success": False, "error": f"HTTP {e.code}: {e.read().decode()}"}
except Exception as e:
return {"success": False, "error": str(e)}
def refund(
self,
out_trade_no: str,
total_amount: int,
refund_amount: int,
reason: str = None
) -> Dict:
"""
申请退款
Args:
out_trade_no: 商户订单号
total_amount: 订单总金额(分)
refund_amount: 退款金额(分)
reason: 退款原因
Returns:
退款结果
"""
if refund_amount > total_amount:
return {"success": False, "error": "退款金额不能大于订单金额"}
timestamp = str(int(time.time()))
nonce_str = self._generate_nonce_str()
out_refund_no = f"REF{datetime.now().strftime('%Y%m%d%H%M%S')}{random.randint(1000, 9999)}"
url = f"{self.api_base}/v3/refund/domestic/refunds"
data = {
"out_trade_no": out_trade_no,
"out_refund_no": out_refund_no,
"reason": reason or "用户申请退款",
"notify_url": settings.WXPAY_REFUND_NOTIFY_URL,
"amount": {
"refund": refund_amount,
"total": total_amount,
"currency": "CNY"
}
}
body = json.dumps(data)
sign = self._generate_sign_v3("POST", "/v3/refund/domestic/refunds", timestamp, nonce_str, body)
headers = {
"Authorization": f'WECHATPAY2-SHA256-RSA2048 mchid="{self.mchid}",nonce_str="{nonce_str}",signature="{sign}",timestamp="{timestamp}",serial_no="{settings.WXPAY_SERIAL_NO}"',
"Content-Type": "application/json",
"Accept": "application/json"
}
try:
import ssl
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
if self.cert_path and self.private_key_path:
context.load_cert_chain(self.cert_path, self.private_key_path)
req = urllib.request.Request(url, data=body.encode(), headers=headers, method="POST")
with urllib.request.urlopen(req, context=context, timeout=30) as response:
result = json.loads(response.read().decode())
return {
"success": True,
"refund_id": result.get("refund_id"),
"out_refund_no": out_refund_no,
"refund_status": result.get("status")
}
except urllib.error.HTTPError as e:
error_body = e.read().decode()
return {"success": False, "error": f"HTTP {e.code}: {error_body}"}
except Exception as e:
return {"success": False, "error": str(e)}
def close_order(self, out_trade_no: str) -> Dict:
"""关闭订单"""
timestamp = str(int(time.time()))
nonce_str = self._generate_nonce_str()
url = f"{self.api_base}/v3/transactions/out-trade-no/{out_trade_no}/close"
data = {"mchid": self.mchid}
body = json.dumps(data)
sign = self._generate_sign_v3("POST", f"/v3/transactions/out-trade-no/{out_trade_no}/close", timestamp, nonce_str, body)
headers = {
"Authorization": f'WECHATPAY2-SHA256-RSA2048 mchid="{self.mchid}",nonce_str="{nonce_str}",signature="{sign}",timestamp="{timestamp}",serial_no="{settings.WXPAY_SERIAL_NO}"',
"Content-Type": "application/json"
}
try:
import ssl
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
if self.cert_path and self.private_key_path:
context.load_cert_chain(self.cert_path, self.private_key_path)
req = urllib.request.Request(url, data=body.encode(), headers=headers, method="POST")
with urllib.request.urlopen(req, context=context, timeout=30) as response:
return {"success": True}
except urllib.error.HTTPError as e:
return {"success": False, "error": f"HTTP {e.code}: {e.read().decode()}"}
except Exception as e:
return {"success": False, "error": str(e)}
def get_wechat_pay_client() -> WechatPayClient:
"""获取微信支付客户端实例"""
return WechatPayClient()
# 全局实例 (延迟初始化)
_wechat_pay_client: Optional[WechatPayClient] = None
def wechat_pay() -> WechatPayClient:
"""获取微信支付客户端"""
global _wechat_pay_client
if _wechat_pay_client is None:
_wechat_pay_client = WechatPayClient()
return _wechat_pay_client
class WechatPayV2Client:
"""微信支付 V2 客户端 (兼容旧版)"""
def __init__(self):
self.mchid = settings.WXPAY_MCHID
self.appid = settings.WXPAY_APPID
self.api_key = settings.WXPAY_API_KEY
self.appid_mini = settings.WXPAY_MINI_APPID
self.notify_url = settings.WXPAY_NOTIFY_URL
if settings.WXPAY_SANDBOX:
self.pay_url = "https://sandbox.mch.weixin.qq.com/pay/unifiedorder"
else:
self.pay_url = "https://api.mch.weixin.qq.com/pay/unifiedorder"
def _generate_sign(self, params: Dict) -> str:
"""生成签名"""
sorted_keys = sorted(params.keys())
sign_str = "&".join([f"{k}={params[k]}" for k in sorted_keys if params[k]])
sign_str += f"&key={self.api_key}"
return hashlib.md5(sign_str.encode('utf-8')).hexdigest().upper()
def _dict_to_xml(self, params: Dict) -> str:
"""字典转XML"""
xml_str = "<xml>"
for k, v in params.items():
xml_str += f"<{k}><![CDATA[{v}]]></{k}>"
xml_str += "</xml>"
return xml_str
def _xml_to_dict(self, xml_str: str) -> Dict:
"""XML转字典"""
try:
root = ET.fromstring(xml_str)
return {child.tag: child.text for child in root}
except:
return {}
def build_miniprogram_pay_params(self, prepay_id: str) -> Dict[str, str]:
"""小程序调起支付的二次签名参数(与公众号 JSAPI 规则一致)"""
appid = self.appid_mini or self.appid
time_stamp = str(int(time.time()))
nonce_str = ''.join(random.choices(string.ascii_letters + string.digits, k=32))
package = f"prepay_id={prepay_id}"
sign_type = "MD5"
pay_fields = {
"appId": appid,
"nonceStr": nonce_str,
"package": package,
"signType": sign_type,
"timeStamp": time_stamp,
}
sign_str = "&".join([f"{k}={pay_fields[k]}" for k in sorted(pay_fields.keys())])
sign_str += f"&key={self.api_key}"
pay_sign = hashlib.md5(sign_str.encode("utf-8")).hexdigest().upper()
return {
"appId": appid,
"timeStamp": time_stamp,
"nonceStr": nonce_str,
"package": package,
"signType": sign_type,
"paySign": pay_sign,
}
def unified_order(
self,
out_trade_no: str,
total_fee: int,
body: str,
openid: str = None,
trade_type: str = "JSAPI"
) -> Dict:
"""
统一下单 (V2 API)
Args:
out_trade_no: 商户订单号
total_fee: 订单金额(分)
body: 商品描述
openid: 用户openid
trade_type: 交易类型 JSAPI/NATIVE/APP
Returns:
返回支付参数
"""
params = {
"appid": self.appid,
"mch_id": self.mchid,
"nonce_str": ''.join(random.choices(string.ascii_letters + string.digits, k=32)),
"body": body,
"out_trade_no": out_trade_no,
"total_fee": total_fee,
"spbill_create_ip": "127.0.0.1",
"notify_url": self.notify_url,
"trade_type": trade_type
}
if openid:
params["openid"] = openid
# 小程序 JSAPI 必须使用小程序 AppID 与商户号绑定关系一致
if trade_type == "JSAPI" and self.appid_mini:
params["appid"] = self.appid_mini
# 生成签名
params["sign"] = self._generate_sign(params)
# 发送请求
try:
import ssl
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(
self.pay_url,
data=self._dict_to_xml(params).encode('utf-8'),
headers={"Content-Type": "text/xml"}
)
with urllib.request.urlopen(req, context=context, timeout=30) as response:
result = self._xml_to_dict(response.read().decode())
if result.get("return_code") == "SUCCESS" and result.get("result_code") == "SUCCESS":
if trade_type == "JSAPI":
prepay = result.get("prepay_id")
pay_extra = self.build_miniprogram_pay_params(prepay) if prepay else {}
return {
"success": True,
"prepay_id": prepay,
"package": pay_extra.get("package"),
"pay_params": pay_extra,
}
elif trade_type == "NATIVE":
return {
"success": True,
"code_url": result.get("code_url")
}
elif trade_type == "APP":
return {
"success": True,
"prepay_id": result.get("prepay_id")
}
else:
return {
"success": False,
"error": result.get("err_code_des", result.get("return_msg", "下单失败"))
}
except Exception as e:
return {"success": False, "error": str(e)}
def query_order(self, out_trade_no: str = None, transaction_id: str = None) -> Dict:
"""查询订单"""
if not out_trade_no and not transaction_id:
return {"success": False, "error": "缺少订单号"}
params = {
"appid": self.appid,
"mch_id": self.mchid,
"nonce_str": ''.join(random.choices(string.ascii_letters + string.digits, k=32))
}
if out_trade_no:
params["out_trade_no"] = out_trade_no
else:
params["transaction_id"] = transaction_id
params["sign"] = self._generate_sign(params)
query_url = "https://api.mch.weixin.qq.com/pay/orderquery"
try:
import ssl
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(
query_url,
data=self._dict_to_xml(params).encode('utf-8'),
headers={"Content-Type": "text/xml"}
)
with urllib.request.urlopen(req, context=context, timeout=30) as response:
result = self._xml_to_dict(response.read().decode())
return {"success": True, "data": result}
except Exception as e:
return {"success": False, "error": str(e)}
def refund(
self,
out_trade_no: str,
total_fee: int,
refund_fee: int,
refund_desc: str = None
) -> Dict:
"""申请退款 (V2)"""
out_refund_no = f"REF{datetime.now().strftime('%Y%m%d%H%M%S')}{random.randint(1000, 9999)}"
params = {
"appid": self.appid,
"mch_id": self.mchid,
"nonce_str": ''.join(random.choices(string.ascii_letters + string.digits, k=32)),
"out_trade_no": out_trade_no,
"out_refund_no": out_refund_no,
"total_fee": total_fee,
"refund_fee": refund_fee,
"refund_desc": refund_desc or "用户申请退款"
}
params["sign"] = self._generate_sign(params)
refund_url = "https://api.mch.weixin.qq.com/secapi/pay/refund"
try:
import ssl
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
# 需要双向证书
if settings.WXPAY_SSL_CERT and settings.WXPAY_SSL_KEY:
cert_file = settings.WXPAY_SSL_CERT
key_file = settings.WXPAY_SSL_KEY
req = urllib.request.Request(
refund_url,
data=self._dict_to_xml(params).encode('utf-8'),
headers={"Content-Type": "text/xml"}
)
with urllib.request.urlopen(req, context=context, timeout=30) as response:
result = self._xml_to_dict(response.read().decode())
if result.get("return_code") == "SUCCESS":
return {
"success": True,
"out_refund_no": out_refund_no,
"refund_id": result.get("refund_id")
}
return {"success": False, "error": result.get("return_msg", "退款失败")}
else:
return {"success": False, "error": "缺少SSL证书配置"}
except Exception as e:
return {"success": False, "error": str(e)}
def parse_notify(self, xml_data: str) -> Dict:
"""解析支付回调"""
result = self._xml_to_dict(xml_data)
if result.get("return_code") == "SUCCESS":
# 验证签名
sign = result.pop("sign", None)
if sign and sign == self._generate_sign(result):
return {"success": True, "data": result}
return {"success": False, "error": "签名验证失败"}
def get_wechat_pay_v2() -> WechatPayV2Client:
"""获取微信支付V2客户端"""
return WechatPayV2Client()