This commit is contained in:
eric
2026-03-08 06:43:29 -05:00
parent f725104c8a
commit d77f90cb8c
8 changed files with 155 additions and 13 deletions

View File

@@ -1,7 +1,16 @@
# 支付渠道xorpay | zpay
PAYMENT_PROVIDER=xorpay
# ========== 本地调试 ==========
# BASE_URL 需为 ZPAY 可访问的回调地址。本地调试时 ZPAY 无法回调 localhost
# 可用 ngrok 等隧道BASE_URL=https://xxx.ngrok.io
# 或直接使用生产地址测试回调(需 payjsapi 已部署到公网)
# XorPay默认
# ========== 生产部署 ==========
# BASE_URL 必须为 payjsapi 实际公网地址ZPAY 异步通知会请求此地址
BASE_URL=https://api.hackrobot.cn
# 支付渠道xorpay | zpay默认 zpay
PAYMENT_PROVIDER=zpay
# XorPay
XORPAY_AID=8220
XORPAY_SECRET=your_secret
@@ -9,9 +18,6 @@ XORPAY_SECRET=your_secret
ZPAY_PID=2025121809351743
ZPAY_KEY=tpEi7wWIWI2kXiYVTpIG6j7it0mjVW89
# 回调根地址
BASE_URL=https://api.hackrobot.cn
# PocketBase
PB_URL=https://pocketbase.hackrobot.cn
PB_ADMIN_EMAIL=your@email.com

Binary file not shown.

View File

@@ -7,8 +7,8 @@ from typing import Literal
PaymentChannel = Literal["xorpay", "zpay"]
# 当前使用的支付渠道
PAYMENT_PROVIDER: PaymentChannel = os.getenv("PAYMENT_PROVIDER", "xorpay").lower()
# 当前使用的支付渠道(默认 zpay与 /join 报名支付流程一致)
PAYMENT_PROVIDER: PaymentChannel = os.getenv("PAYMENT_PROVIDER", "zpay").lower()
# XorPay 配置
XORPAY_AID = os.getenv("XORPAY_AID", "8220")

View File

@@ -15,13 +15,14 @@ from pydantic import BaseModel
from pocketbase import PocketBase
from .config import (
PAYMENT_PROVIDER,
BASE_URL,
PB_URL,
PB_ADMIN_EMAIL,
PB_ADMIN_PASSWORD,
MEMOS_API,
MEMOS_TOKEN,
ZPAY_PID,
ZPAY_KEY,
)
from .payment import get_payment_provider
@@ -220,15 +221,139 @@ async def payh5(item: dict):
"xorpay_url": result.get("pay_url", ""),
"provider": provider.name,
}
# ZPAY确保所有参数为字符串避免前端传参问题
params = result.get("params", {})
params_str = {k: str(v) for k, v in params.items() if v is not None and str(v).strip() != ""}
return {
"status": "ok",
"params": result.get("params", {}),
"params": params_str,
"pay_url": result.get("pay_url", ""),
"provider": provider.name,
**{k: v for k, v in result.items() if k not in ("status", "params", "pay_url", "provider")},
"submit_method": "POST",
}
@app.post("/payh5/redirect")
async def payh5_redirect(item: dict, request: Request):
"""
创建支付订单,使用 ZPAY API接口支付mapi.php返回 302 重定向。
根据 device 区分PC 跳转 payurlH5/微信 跳转 payurl 或 payurl2微信H5
文档https://z-pay.cn/doc.html#d3
"""
from fastapi.responses import RedirectResponse
provider = get_payment_provider()
if provider.name != "zpay":
raise HTTPException(status_code=400, detail="payh5/redirect 仅支持 zpay 渠道")
total_fee = float(item.get("total_fee", 80))
total_fee_yuan = f"{total_fee / 100:.2f}"
user_id = item.get("user_id", "unknown")
pay_type = item.get("type", "unknown").lower()
channel = item.get("channel", "alipay")
return_url = item.get("return_url")
device = item.get("device", "pc") # pc | h5 | wechat微信内传 wechat 走收银台
client_ip = item.get("client_ip") or request.client.host if request.client else "127.0.0.1"
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
client_ip = forwarded.split(",")[0].strip()
random_suffix = "".join(random.choices(string.hexdigits.lower(), k=8))
timestamp = int(time.time())
order_id = f"{user_id}_{pay_type}_order_{timestamp}_{random_suffix}"
type_map = {
"book": "购买书籍",
"meetup": "数字游民社区报名",
"video": "视频解锁",
"vip": "VIP会员充值",
}
name = type_map.get(pay_type, "商品支付")
notify_url = f"{BASE_URL.rstrip('/')}/zpay_notify"
from .payment.zpay import ZPayProvider
if not isinstance(provider, ZPayProvider):
raise HTTPException(status_code=400, detail="payh5/redirect 仅支持 zpay")
result = provider.create_order_api(
order_id=order_id,
name=name,
total_fee_yuan=total_fee_yuan,
pay_type=channel,
notify_url=notify_url,
return_url=return_url,
client_ip=client_ip,
device=device,
)
if result.get("status") != "ok":
raise HTTPException(status_code=500, detail=result.get("msg", "ZPAY mapi 创建订单失败"))
payurl = result.get("payurl")
payurl2 = result.get("payurl2")
img = result.get("img")
# PC 端payurl 为 wap 链接,在 PC 浏览器会显示错误页面。支付宝/微信均需展示二维码
if device == "pc" and img:
from fastapi.responses import JSONResponse
return JSONResponse(
status_code=200,
content={
"status": "ok",
"qrcode_mode": True,
"order_id": order_id,
"img": img,
"payurl": payurl,
"return_url": return_url,
"channel": channel,
},
)
# 微信内payurl2 为 checkmweb会提示「请外微信外打开」。改用收银台表单submit.php由 Z-Pay 识别 UA
if device == "wechat" and channel.lower() in ("wxpay", "wx", "wechat"):
from fastapi.responses import JSONResponse
return JSONResponse(status_code=200, content={"use_form": True, "order_id": order_id})
# 返回 302 时附带 order_id供 digital 写入 cookie 用于轮询检测支付状态(微信 H5 可能不跳转)
headers = {"X-Pay-Order-Id": order_id}
if device == "h5" and channel.lower() in ("wxpay", "wx", "wechat") and payurl2:
return RedirectResponse(url=payurl2, status_code=302, headers=headers)
if payurl:
return RedirectResponse(url=payurl, status_code=302, headers=headers)
raise HTTPException(status_code=500, detail="ZPAY 未返回支付链接")
@app.get("/zpay_order_status")
async def zpay_order_status(out_trade_no: str):
"""
查询 ZPAY 订单支付状态,用于 PC 二维码页轮询。
文档https://z-pay.cn/doc.html 查询单个订单
"""
provider = get_payment_provider()
if provider.name != "zpay":
raise HTTPException(status_code=400, detail="仅 zpay 渠道支持")
url = f"https://zpayz.cn/api.php?act=order&pid={ZPAY_PID}&key={ZPAY_KEY}&out_trade_no={out_trade_no}"
try:
resp = requests.get(url, timeout=10)
result = resp.json() if resp.ok else {}
except Exception as e:
logging.warning(f"ZPAY 订单查询失败: {e}")
return {"paid": False, "error": str(e)}
# ZPAY 错误时可能返回 code='error',需安全解析
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}
@app.post("/xorpay_notify")
async def xorpay_notify(request: Request):
"""XorPay 异步通知POST form"""

View File

@@ -115,8 +115,13 @@ class ZPayProvider(PaymentProvider):
params["sign"] = self._md5_sign(params)
resp = requests.post(self.MAPI_URL, data=params, timeout=15)
result = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
if isinstance(result, dict) and result.get("code") == 1:
try:
result = resp.json()
except Exception:
result = {}
if not isinstance(result, dict):
result = {}
if int(result.get("code", 0)) == 1:
return {
"status": "ok",
"provider": self.name,

View File

@@ -0,0 +1,6 @@
2026-03-08 03:04:04,172 - ZPAY 땐데꿴璂呵겨: HTTPSConnectionPool(host='zpayz.cn', port=443): Read timed out. (read timeout=10)
2026-03-08 06:25:51,919 - ZPAY 땐데꿴璂呵겨: HTTPSConnectionPool(host='zpayz.cn', port=443): Read timed out. (read timeout=10)
2026-03-08 06:28:42,212 - ZPAY 땐데꿴璂呵겨: HTTPSConnectionPool(host='zpayz.cn', port=443): Read timed out. (read timeout=10)
2026-03-08 06:31:18,405 - ZPAY 땐데꿴璂呵겨: HTTPSConnectionPool(host='zpayz.cn', port=443): Read timed out. (read timeout=10)
2026-03-08 06:39:41,805 - ZPAY 땐데꿴璂呵겨: HTTPSConnectionPool(host='zpayz.cn', port=443): Read timed out. (read timeout=10)
2026-03-08 06:40:22,362 - ZPAY 땐데꿴璂呵겨: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))