251 lines
9.1 KiB
Python
251 lines
9.1 KiB
Python
"""
|
||
ZPAY 支付渠道实现
|
||
文档: https://z-pay.cn/doc.html
|
||
支持:页面跳转支付、API 接口支付、异步通知验签
|
||
"""
|
||
import hashlib
|
||
import json
|
||
import logging
|
||
from typing import Any, Dict, Optional
|
||
|
||
import requests
|
||
|
||
from .base import PaymentProvider
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _is_internal_ip(ip: Optional[str]) -> bool:
|
||
"""判断是否为内网 IP,ZPAY 可能拒收导致 Ubuntu 部署失败"""
|
||
if not ip or not ip.strip():
|
||
return True
|
||
ip = ip.strip()
|
||
if ip in ("127.0.0.1", "localhost", "::1"):
|
||
return True
|
||
if ip.startswith("10.") or ip.startswith("192.168.") or ip.startswith("172.16."):
|
||
return True
|
||
return False
|
||
|
||
|
||
class ZPayProvider(PaymentProvider):
|
||
"""ZPAY 支付(易支付兼容接口)"""
|
||
|
||
# API 地址
|
||
SUBMIT_URL = "https://zpayz.cn/submit.php" # 页面跳转
|
||
MAPI_URL = "https://zpayz.cn/mapi.php" # API 接口
|
||
|
||
def __init__(self, pid: str, key: str):
|
||
self.pid = pid
|
||
self.key = key
|
||
|
||
@property
|
||
def name(self) -> str:
|
||
return "zpay"
|
||
|
||
def _md5_sign(self, params: Dict[str, Any], exclude: Optional[set] = None) -> str:
|
||
"""
|
||
ZPAY MD5 签名算法:
|
||
1. 参数按 ASCII 排序,sign/sign_type/空值不参与
|
||
2. 拼接为 a=b&c=d
|
||
3. sign = md5(拼接串 + KEY),小写
|
||
"""
|
||
exclude = exclude or {"sign", "sign_type"}
|
||
filtered = {k: v for k, v in params.items() if k not in exclude and v is not None and v != ""}
|
||
sorted_keys = sorted(filtered.keys())
|
||
parts = [f"{k}={filtered[k]}" for k in sorted_keys]
|
||
raw = "&".join(parts) + self.key
|
||
return hashlib.md5(raw.encode("utf-8")).hexdigest().lower()
|
||
|
||
def create_order(
|
||
self,
|
||
*,
|
||
order_id: str,
|
||
name: str,
|
||
total_fee_yuan: str,
|
||
pay_type: str,
|
||
notify_url: str,
|
||
return_url: Optional[str] = None,
|
||
client_ip: Optional[str] = None,
|
||
extra: Optional[Dict[str, Any]] = None,
|
||
) -> Dict[str, Any]:
|
||
# ZPAY: alipay / wxpay(pay_type 可为业务类型或支付方式,此处按支付方式解析)
|
||
channel_type = "wxpay" if str(pay_type).lower() in ("wx", "wechat", "wxpay") else "alipay"
|
||
params = {
|
||
"pid": self.pid,
|
||
"type": channel_type,
|
||
"out_trade_no": order_id,
|
||
"notify_url": notify_url,
|
||
"name": name,
|
||
"money": total_fee_yuan,
|
||
"return_url": return_url or notify_url,
|
||
}
|
||
if client_ip and not _is_internal_ip(client_ip):
|
||
params["clientip"] = client_ip
|
||
elif client_ip and _is_internal_ip(client_ip):
|
||
logger.warning("ZPAY create_order 跳过内网 client_ip=%s", client_ip)
|
||
if extra and extra.get("param"):
|
||
params["param"] = str(extra["param"])
|
||
params["sign_type"] = "MD5"
|
||
params["sign"] = self._md5_sign(params)
|
||
|
||
# 页面跳转方式:返回 form 提交 URL 和参数
|
||
return {
|
||
"status": "ok",
|
||
"provider": self.name,
|
||
"params": params,
|
||
"pay_url": self.SUBMIT_URL,
|
||
"submit_method": "POST",
|
||
}
|
||
|
||
def create_order_api(
|
||
self,
|
||
*,
|
||
order_id: str,
|
||
name: str,
|
||
total_fee_yuan: str,
|
||
pay_type: str,
|
||
notify_url: str,
|
||
return_url: Optional[str] = None,
|
||
client_ip: str = "127.0.0.1",
|
||
device: str = "pc",
|
||
extra: Optional[Dict[str, Any]] = None,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
ZPAY API 接口支付(返回 payurl/qrcode 等)
|
||
适用于需要二维码或 H5 跳转的场景
|
||
"""
|
||
channel_type = "wxpay" if pay_type.lower() in ("wx", "wechat", "wxpay") else "alipay"
|
||
effective_ip = (client_ip or "").strip() or "127.0.0.1"
|
||
# 内网 IP(127.0.0.1/10.x/192.168.x)可能导致 ZPAY 风控拒单,Ubuntu 部署时常见
|
||
if _is_internal_ip(effective_ip):
|
||
logger.warning(
|
||
"ZPAY create_order_api client_ip=%s 为内网地址,已省略 clientip 参数(避免风控拒单)",
|
||
effective_ip,
|
||
)
|
||
effective_ip = "" # 不传 clientip,由 ZPAY 从连接获取
|
||
params = {
|
||
"pid": self.pid,
|
||
"type": channel_type,
|
||
"out_trade_no": order_id,
|
||
"notify_url": notify_url,
|
||
"name": name,
|
||
"money": total_fee_yuan,
|
||
"device": device,
|
||
}
|
||
if effective_ip:
|
||
params["clientip"] = effective_ip
|
||
if return_url:
|
||
params["return_url"] = return_url
|
||
if extra and extra.get("param"):
|
||
params["param"] = str(extra["param"])
|
||
params["sign_type"] = "MD5"
|
||
params["sign"] = self._md5_sign(params)
|
||
|
||
try:
|
||
resp = requests.post(self.MAPI_URL, data=params, timeout=15)
|
||
except requests.exceptions.SSLError as e:
|
||
logger.error("ZPAY SSL 错误(Ubuntu 可能 CA 证书不全): %s", e, exc_info=True)
|
||
return {
|
||
"status": "error",
|
||
"provider": self.name,
|
||
"msg": f"连接 zpayz.cn SSL 失败: {e}。Ubuntu 请安装 ca-certificates: apt install ca-certificates",
|
||
}
|
||
except requests.RequestException as e:
|
||
logger.error("ZPAY 连接失败: %s", e, exc_info=True)
|
||
return {
|
||
"status": "error",
|
||
"provider": self.name,
|
||
"msg": f"连接 zpayz.cn 失败: {e}。请检查服务器网络、防火墙或 DNS。",
|
||
}
|
||
|
||
try:
|
||
result = resp.json()
|
||
except Exception:
|
||
result = {}
|
||
# ZPAY 可能返回二次编码的 JSON(body 为 JSON 字符串),需再次解析
|
||
if isinstance(result, str):
|
||
try:
|
||
result = json.loads(result)
|
||
except Exception:
|
||
result = {}
|
||
if not isinstance(result, dict):
|
||
result = {}
|
||
# 部分环境下 ZPAY 将成功数据放在 msg 中(msg 为 JSON 字符串)
|
||
msg_val = result.get("msg")
|
||
if isinstance(msg_val, str) and msg_val.strip().startswith("{"):
|
||
try:
|
||
inner = json.loads(msg_val)
|
||
if isinstance(inner, dict) and int(float(inner.get("code", 0))) == 1:
|
||
result = inner
|
||
except Exception:
|
||
pass
|
||
try:
|
||
code = int(float(result.get("code", 0)))
|
||
except (TypeError, ValueError):
|
||
code = 0
|
||
if code == 1:
|
||
return {
|
||
"status": "ok",
|
||
"provider": self.name,
|
||
"payurl": result.get("payurl"),
|
||
"payurl2": result.get("payurl2"), # 微信 H5,手机浏览器打开可唤醒微信
|
||
"qrcode": result.get("qrcode"),
|
||
"img": result.get("img"),
|
||
"O_id": result.get("O_id"),
|
||
"trade_no": result.get("trade_no"),
|
||
}
|
||
err_msg = result.get("msg", resp.text)
|
||
logger.error(
|
||
"ZPAY create_order_api 失败 status=%s code=%s msg=%s resp_text=%s",
|
||
resp.status_code,
|
||
code,
|
||
err_msg,
|
||
resp.text[:500] if resp.text else "",
|
||
)
|
||
return {
|
||
"status": "error",
|
||
"provider": self.name,
|
||
"msg": err_msg,
|
||
}
|
||
|
||
def verify_notify(self, data: Dict[str, Any]) -> bool:
|
||
received = data.get("sign", "")
|
||
calculated = self._md5_sign(data)
|
||
return calculated == received
|
||
|
||
def parse_notify(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||
# ZPAY 通知:trade_status 为 TRADE_SUCCESS 表示成功
|
||
status = "success" if data.get("trade_status") == "TRADE_SUCCESS" else "pending"
|
||
return {
|
||
"order_id": data.get("out_trade_no", ""),
|
||
"trade_no": data.get("trade_no", ""),
|
||
"pay_price": data.get("money", ""),
|
||
"pay_time": "", # ZPAY 通知未明确返回
|
||
"trade_status": status,
|
||
}
|
||
|
||
def success_response(self) -> str:
|
||
return "success"
|
||
|
||
def query_order_status(self, out_trade_no: str, timeout: int = 15) -> Dict[str, Any]:
|
||
"""查询 ZPAY 订单支付状态(供 nomadvip/digital/cnomadcna/nomadlms 共用)"""
|
||
url = f"https://zpayz.cn/api.php?act=order&pid={self.pid}&key={self.key}&out_trade_no={out_trade_no}"
|
||
try:
|
||
resp = requests.get(url, timeout=timeout)
|
||
result = resp.json() if resp.ok else {}
|
||
except Exception as e:
|
||
return {"paid": False, "error": str(e)}
|
||
try:
|
||
code = result.get("code", 0)
|
||
code = int(code) if isinstance(code, (int, float)) or str(code).isdigit() else 0
|
||
except (ValueError, TypeError):
|
||
code = 0
|
||
if code != 1:
|
||
return {"paid": False, "msg": result.get("msg", "查询失败")}
|
||
try:
|
||
status = int(result.get("status", 0))
|
||
except (ValueError, TypeError):
|
||
status = 0
|
||
money = result.get("money") or result.get("pay_price") or ""
|
||
return {"paid": status == 1, "status": status, "pay_price": str(money) if money else ""}
|