This commit is contained in:
eric
2026-03-15 19:12:12 -05:00
parent 7d57cdc503
commit d44417d10a
42 changed files with 3953 additions and 3 deletions

25
app/payment/__init__.py Normal file
View File

@@ -0,0 +1,25 @@
"""
支付模块
支持多支付渠道,可通过配置切换
"""
from .base import PaymentProvider
from .xorpay import XorPayProvider
from .zpay import ZPayProvider
from .factory import (
get_payment_provider,
get_payment_provider_by_name,
reset_provider,
resolve_provider,
resolve_channel,
)
__all__ = [
"PaymentProvider",
"XorPayProvider",
"ZPayProvider",
"get_payment_provider",
"get_payment_provider_by_name",
"reset_provider",
"resolve_provider",
"resolve_channel",
]

59
app/payment/base.py Normal file
View File

@@ -0,0 +1,59 @@
"""
支付渠道抽象基类
定义统一的支付接口便于切换不同支付渠道XorPay、ZPAY 等)
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional
class PaymentProvider(ABC):
"""支付渠道抽象基类"""
@property
@abstractmethod
def name(self) -> str:
"""支付渠道名称"""
pass
@abstractmethod
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]:
"""
创建支付订单
:return: 包含支付跳转信息等,格式由各渠道自定义
"""
pass
@abstractmethod
def verify_notify(self, data: Dict[str, Any]) -> bool:
"""验证异步通知签名"""
pass
@abstractmethod
def parse_notify(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""
解析异步通知,提取统一字段
:return: {
"order_id": str, # 商户订单号
"trade_no": str, # 渠道订单号
"pay_price": str, # 实付金额(元)
"pay_time": str, # 支付时间
"trade_status": str, # 支付状态
}
"""
pass
@abstractmethod
def success_response(self) -> str:
"""异步通知成功时需返回的字符串(如 'ok''success'"""
pass

79
app/payment/factory.py Normal file
View File

@@ -0,0 +1,79 @@
"""
支付渠道工厂
根据配置返回对应的支付 Provider
默认使用 zpay 替代 xorpay
"""
from ..config import (
PAYMENT_PROVIDER,
XORPAY_AID,
XORPAY_SECRET,
XORPAY_CASHIER_URL,
ZPAY_PID,
ZPAY_KEY,
)
from .base import PaymentProvider
from .xorpay import XorPayProvider
from .zpay import ZPayProvider
_provider_instance: PaymentProvider | None = None
def get_payment_provider() -> PaymentProvider:
"""获取当前配置的支付渠道实例(单例)"""
global _provider_instance
if _provider_instance is not None:
return _provider_instance
if PAYMENT_PROVIDER == "zpay":
_provider_instance = ZPayProvider(pid=ZPAY_PID, key=ZPAY_KEY)
else:
_provider_instance = XorPayProvider(
aid=XORPAY_AID,
secret=XORPAY_SECRET,
cashier_url=XORPAY_CASHIER_URL,
)
return _provider_instance
def get_payment_provider_by_name(name: str) -> PaymentProvider:
"""按名称获取 Provider用于 per-request 覆盖)"""
if name == "xorpay":
return XorPayProvider(
aid=XORPAY_AID,
secret=XORPAY_SECRET,
cashier_url=XORPAY_CASHIER_URL,
)
return ZPayProvider(pid=ZPAY_PID, key=ZPAY_KEY)
def reset_provider():
"""重置 Provider用于测试或切换渠道"""
global _provider_instance
_provider_instance = None
def resolve_provider(
device: str | None = None,
req_provider: str | None = None,
user_agent: str | None = None,
) -> PaymentProvider:
"""
根据设备解析支付渠道:
- 微信内(device=wechat 或 UA 含 MicroMessenger) → xorpay
- PC/手机浏览器 → zpay
显式传入的 provider 优先。
"""
req = (req_provider or "").strip().lower()
if req in ("xorpay", "zpay"):
return get_payment_provider_by_name(req)
dev = (device or "pc").lower()
if dev == "wechat":
return get_payment_provider_by_name("xorpay")
ua = (user_agent or "").lower()
if "micromessenger" in ua:
return get_payment_provider_by_name("xorpay")
return get_payment_provider_by_name("zpay")
def resolve_channel(item_channel: str | None) -> str:
"""支付方式固定为微信支付,支付宝暂不提供"""
return "wxpay"

108
app/payment/xorpay.py Normal file
View File

@@ -0,0 +1,108 @@
"""
XorPay 支付渠道实现
文档: https://xorpay.com
"""
import hashlib
from typing import Any, Dict, Optional
import requests
from .base import PaymentProvider
class XorPayProvider(PaymentProvider):
"""XorPay 支付"""
def __init__(self, aid: str, secret: str, cashier_url: str = "https://xorpay.com/api/cashier"):
self.aid = aid
self.secret = secret
self.cashier_url = cashier_url.rstrip("/")
@property
def name(self) -> str:
return "xorpay"
def _sign(self, name: str, pay_type: str, price: str, order_id: str, notify_url: str) -> str:
raw = name + pay_type + price + order_id + notify_url + self.secret
return hashlib.md5(raw.encode("utf-8")).hexdigest().lower()
def _query2_sign(self, order_id: str) -> str:
raw = order_id + self.secret
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]:
# XorPay 使用 jsapi 表示 H5/JSAPI 支付
channel_pay_type = "jsapi"
params = {
"name": name,
"pay_type": channel_pay_type,
"price": total_fee_yuan,
"order_id": order_id,
"notify_url": notify_url,
}
params["sign"] = self._sign(
params["name"],
params["pay_type"],
params["price"],
params["order_id"],
params["notify_url"],
)
return {
"status": "ok",
"provider": self.name,
"params": params,
"pay_url": f"{self.cashier_url}/{self.aid}",
}
def verify_notify(self, data: Dict[str, Any]) -> bool:
received_sign = data.get("sign", "")
aoid = data.get("aoid", "")
order_id = data.get("order_id", "")
pay_price = data.get("pay_price", "")
pay_time = data.get("pay_time", "")
raw = aoid + order_id + pay_price + pay_time + self.secret
calculated = hashlib.md5(raw.encode("utf-8")).hexdigest().lower()
return calculated == received_sign
def parse_notify(self, data: Dict[str, Any]) -> Dict[str, Any]:
return {
"order_id": data.get("order_id", ""),
"trade_no": data.get("aoid", ""),
"pay_price": data.get("pay_price", ""),
"pay_time": data.get("pay_time", ""),
"trade_status": "success", # XorPay 验签通过即视为成功
}
def success_response(self) -> str:
return "ok"
def query_order_status(self, order_id: str, timeout: int = 10) -> Dict[str, Any]:
url = f"https://xorpay.com/api/query2/{self.aid}"
params = {
"order_id": order_id,
"sign": self._query2_sign(order_id),
}
try:
response = requests.get(url, params=params, timeout=timeout)
response.raise_for_status()
data = response.json() or {}
status = str(data.get("status", "")).strip().lower()
return {
"paid": status in ("payed", "success"),
"status": status,
"pay_price": data.get("pay_price") or data.get("price") or "",
"raw": data,
}
except Exception as error:
return {"paid": False, "error": str(error)}

249
app/payment/zpay.py Normal file
View File

@@ -0,0 +1,249 @@
"""
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:
"""判断是否为内网 IPZPAY 可能拒收导致 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 / wxpaypay_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"
# 内网 IP127.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 可能返回二次编码的 JSONbody 为 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
return {"paid": status == 1, "status": status}