| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627 |
- """微信支付工具模块
- 支持微信支付 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()
|