This commit is contained in:
eric
2026-03-13 10:59:27 -05:00
parent 4634b16cf8
commit 0326ab2875
6 changed files with 115 additions and 11 deletions

23
.env Normal file
View File

@@ -0,0 +1,23 @@
# ========== 生产环境配置 ==========
BASE_URL=https://api.hackrobot.cn
PAYMENT_PROVIDER=zpay
# ZPAY请填入实际值
ZPAY_PID=2025121809351743
ZPAY_KEY=tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89
# PocketBasecheck-user、ensure-user 必需,否则报「服务配置错误」)
PB_URL=https://pocketbase.hackrobot.cn
PB_ADMIN_EMAIL=xiaoshuang.eric@gmail.com
PB_ADMIN_PASSWORD=Xiao4669805@
# Memosnomadvip 支付成功后创建账号)
MEMOS_API=https://qun.hackrobot.cn/api/v1/users
MEMOS_TOKEN=eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiaGFja3JvYm90IiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImlhdCI6MTc1NzQwNTE0OX0.Idn7kBlxE-CoSOXwWuZ1tHGIRKHAIeDyXSafGS5OHsg
# MinIO可选
MINIO_ENDPOINT=minioweb.hackrobot.cn
MINIO_PORT=443
MINIO_BUCKET=hackrobot
MINIO_UPLOAD_PREFIX=joins

17
.env.local Normal file
View File

@@ -0,0 +1,17 @@
# 本地调试 - 端口 8007、zpay 渠道
PORT=8007
PAYMENT_PROVIDER=zpay
BASE_URL=https://api.hackrobot.cn
# PocketBase支付回调、nomadvip/digital/cnomadcna 共用)
PB_URL=https://pocketbase.hackrobot.cn
PB_ADMIN_EMAIL=xiaoshuang.eric@gmail.com
PB_ADMIN_PASSWORD=Xiao4669805@
# Memosnomadvip 支付成功后创建账号)
MEMOS_API=https://qun.hackrobot.cn/api/v1/users
MEMOS_TOKEN=eyJhbGciOiJIUzI1NiIsImtpZCI6InYxIiwidHlwIjoiSldUIn0.eyJuYW1lIjoiaGFja3JvYm90IiwiaXNzIjoibWVtb3MiLCJzdWIiOiIxIiwiYXVkIjpbInVzZXIuYWNjZXNzLXRva2VuIl0sImlhdCI6MTc1NzQwNTE0OX0.Idn7kBlxE-CoSOXwWuZ1tHGIRKHAIeDyXSafGS5OHsg
# ZPAY
ZPAY_PID=2025121809351743
ZPAY_KEY=tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89

4
.gitignore vendored
View File

@@ -9,5 +9,5 @@ __pycache__/
*.py[cod] *.py[cod]
venv/ venv/
.venv/ .venv/
.env # .env
.env.local # .env.local

View File

@@ -84,13 +84,19 @@ PAYMENT_PROVIDER=zpay
## 线上「支付失败」排查 ## 线上「支付失败」排查
本地正常、Ubuntu 部署后显示支付失败,常见原因: 本地正常、Ubuntu 部署后 api.hackrobot.cn 调用支付失败,常见原因:
1. **BASE_URL 配置错误**:必须为 ZPAY 可公网访问的地址。错误示例:`http://127.0.0.1:8700``http://localhost`。正确:`https://api.hackrobot.cn`域名需解析到本机nginx 反向代理 8700 端口)。 1. **BASE_URL 配置错误**:必须为 ZPAY 可公网访问的地址。错误示例:`http://127.0.0.1:8700``http://localhost`。正确:`https://api.hackrobot.cn`域名需解析到本机nginx 反向代理对应端口)。
2. **服务器无法访问 zpayz.cn**防火墙、DNS 或网络限制。访问 `https://你的域名/health?check_zpay=1` 自检,若 `zpayz_reachable: false` 则需开放出站或检查网络。 2. **服务器无法访问 zpayz.cn**防火墙、DNS 或网络限制。访问 `https://api.hackrobot.cn/health?check_zpay=1` 自检
- `zpayz_reachable: false` → 开放出站或检查网络
- `zpayz_error` 含 "SSL" → 执行 `apt install ca-certificates` 后重启
3. **启动时控制台有 BASE_URL 警告**:说明配置了内网地址,需修改 `.env` 中的 `BASE_URL` 为公网地址后重启 3. **client_ip 内网导致 ZPAY 风控拒单**前端digital/cnomadcna/nomadvip通过 nginx 代理时,需配置 `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for`,否则 payjsapi 收到 127.0.0.1 会触发风控。已做兼容:内网 IP 时自动省略 clientip 参数
4. **启动时控制台有 BASE_URL 警告**:说明配置了内网地址,需修改 `.env` 中的 `BASE_URL` 为公网地址后重启。
5. **查看详细错误**支付失败时payjsapi 会输出 `ZPAY create_order_api 失败` 日志,含 status/code/msg便于定位。
## Getting started ## Getting started

View File

@@ -118,15 +118,31 @@ async def startup_event():
@app.get("/health") @app.get("/health")
async def health(check_zpay: str = ""): async def health(check_zpay: str = ""):
"""健康检查。check_zpay=1 时额外检测 zpayz.cn 连通性""" """健康检查。check_zpay=1 时额外检测 zpayz.cn 连通性(含 SSL、mapi 接口)"""
out = {"status": "ok", "service": "payjsapi", "base_url": BASE_URL} out = {"status": "ok", "service": "payjsapi", "base_url": BASE_URL}
if str(check_zpay).lower() in ("1", "true", "yes"): if str(check_zpay).lower() in ("1", "true", "yes"):
# 1) 检测 zpayz.cn 首页
try: try:
r = requests.get("https://zpayz.cn", timeout=5) r = requests.get("https://zpayz.cn", timeout=5)
out["zpayz_reachable"] = r.status_code in (200, 301, 302, 404) out["zpayz_reachable"] = r.status_code in (200, 301, 302, 404)
except Exception as e: except requests.exceptions.SSLError as e:
out["zpayz_reachable"] = False
out["zpayz_error"] = f"SSL 错误: {e}Ubuntu 请执行: apt install ca-certificates"
except requests.RequestException as e:
out["zpayz_reachable"] = False out["zpayz_reachable"] = False
out["zpayz_error"] = str(e) out["zpayz_error"] = str(e)
# 2) 检测 mapi 接口(实际支付调用地址)
if out.get("zpayz_reachable"):
try:
r2 = requests.post("https://zpayz.cn/mapi.php", data={"pid": "test"}, timeout=5)
out["zpayz_mapi_reachable"] = True
out["zpayz_mapi_status"] = r2.status_code
except requests.exceptions.SSLError as e:
out["zpayz_mapi_reachable"] = False
out["zpayz_mapi_error"] = f"SSL: {e}"
except requests.RequestException as e:
out["zpayz_mapi_reachable"] = False
out["zpayz_mapi_error"] = str(e)
return out return out

View File

@@ -5,12 +5,27 @@ ZPAY 支付渠道实现
""" """
import hashlib import hashlib
import json import json
import logging
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import requests import requests
from .base import PaymentProvider 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): class ZPayProvider(PaymentProvider):
"""ZPAY 支付(易支付兼容接口)""" """ZPAY 支付(易支付兼容接口)"""
@@ -64,8 +79,10 @@ class ZPayProvider(PaymentProvider):
"money": total_fee_yuan, "money": total_fee_yuan,
"return_url": return_url or notify_url, "return_url": return_url or notify_url,
} }
if client_ip: if client_ip and not _is_internal_ip(client_ip):
params["clientip"] = 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"): if extra and extra.get("param"):
params["param"] = str(extra["param"]) params["param"] = str(extra["param"])
params["sign_type"] = "MD5" params["sign_type"] = "MD5"
@@ -98,6 +115,14 @@ class ZPayProvider(PaymentProvider):
适用于需要二维码或 H5 跳转的场景 适用于需要二维码或 H5 跳转的场景
""" """
channel_type = "wxpay" if pay_type.lower() in ("wx", "wechat", "wxpay") else "alipay" 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 = { params = {
"pid": self.pid, "pid": self.pid,
"type": channel_type, "type": channel_type,
@@ -105,9 +130,10 @@ class ZPayProvider(PaymentProvider):
"notify_url": notify_url, "notify_url": notify_url,
"name": name, "name": name,
"money": total_fee_yuan, "money": total_fee_yuan,
"clientip": client_ip or "127.0.0.1",
"device": device, "device": device,
} }
if effective_ip:
params["clientip"] = effective_ip
if return_url: if return_url:
params["return_url"] = return_url params["return_url"] = return_url
if extra and extra.get("param"): if extra and extra.get("param"):
@@ -117,7 +143,15 @@ class ZPayProvider(PaymentProvider):
try: try:
resp = requests.post(self.MAPI_URL, data=params, timeout=15) 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: except requests.RequestException as e:
logger.error("ZPAY 连接失败: %s", e, exc_info=True)
return { return {
"status": "error", "status": "error",
"provider": self.name, "provider": self.name,
@@ -160,10 +194,18 @@ class ZPayProvider(PaymentProvider):
"O_id": result.get("O_id"), "O_id": result.get("O_id"),
"trade_no": result.get("trade_no"), "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 { return {
"status": "error", "status": "error",
"provider": self.name, "provider": self.name,
"msg": result.get("msg", resp.text), "msg": err_msg,
} }
def verify_notify(self, data: Dict[str, Any]) -> bool: def verify_notify(self, data: Dict[str, Any]) -> bool: