""" 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", "") return {"paid": status == 1, "status": status, "money": money}