diff --git a/.env.example b/.env.example index f833d04..286db19 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/__pycache__/config.cpython-312.pyc b/app/__pycache__/config.cpython-312.pyc index 57578c7..97f31b8 100644 Binary files a/app/__pycache__/config.cpython-312.pyc and b/app/__pycache__/config.cpython-312.pyc differ diff --git a/app/__pycache__/main.cpython-312.pyc b/app/__pycache__/main.cpython-312.pyc index 2de8ef6..d1f3593 100755 Binary files a/app/__pycache__/main.cpython-312.pyc and b/app/__pycache__/main.cpython-312.pyc differ diff --git a/app/config.py b/app/config.py index f6bda29..818d6c0 100644 --- a/app/config.py +++ b/app/config.py @@ -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") diff --git a/app/main.py b/app/main.py index 3d4ba9d..165eaec 100755 --- a/app/main.py +++ b/app/main.py @@ -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 跳转 payurl,H5/微信 跳转 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)""" diff --git a/app/payment/__pycache__/zpay.cpython-312.pyc b/app/payment/__pycache__/zpay.cpython-312.pyc index 9d9457c..916fc7c 100644 Binary files a/app/payment/__pycache__/zpay.cpython-312.pyc and b/app/payment/__pycache__/zpay.cpython-312.pyc differ diff --git a/app/payment/zpay.py b/app/payment/zpay.py index ce5f4b9..e19811b 100644 --- a/app/payment/zpay.py +++ b/app/payment/zpay.py @@ -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, diff --git a/payment_notify.log b/payment_notify.log index e69de29..048bd20 100644 --- a/payment_notify.log +++ b/payment_notify.log @@ -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'))