Python 接入

本页是 Python(FastAPI)商户的完整接入教程,五步走完就能收款,代码都可以整段照抄。 读完本页就能完成接入,其他页面当参考手册,用到再翻。

第一步:确认手里有这些

东西来源
wantu_pay.py单文件 SDK,点此下载后复制进你的项目(仅 Python 标准库,3.9+)
wantu-python-demo.zip完整示例工程,点此下载,解压即跑:SDK + FastAPI 后端 + 演示充值页
merchant_id / api_secret顽兔发放的商户凭据
环境地址(base_url)https://pay-api.wantutech.com
开通的渠道开户时顽兔告知:支付宝、银联商务聚合码,或两条都开。第三步下单要传对应的 channel

api_secret 放环境变量或配置中心,不要写进代码,不要提交到代码仓库。

第二步:初始化

import os
from wantu_pay import WantuPayClient

pay = WantuPayClient(
    base_url=os.environ["WANTU_PAY_BASE_URL"],
    merchant_id=os.environ["WANTU_MERCHANT_ID"],
    api_secret=os.environ["WANTU_API_SECRET"],
)

第三步:服务端三个路由

充值功能在服务端就是三个路由:下单、收回调、给前端查状态。以下可直接粘贴:

import time
from fastapi import FastAPI, Request, Response
from wantu_pay import WantuPayApiError, WantuWebhookVerifyError

app = FastAPI()

# 套餐表:金额永远查服务端的表,前端只传套餐名
PLANS = {"small": ("0.01", "积分充值·体验"), "big": ("100.00", "积分充值100元")}

# 支付渠道:填顽兔开户时给你开的那条。"alipay" 支付宝;"chinaums" 银联商务聚合码
#(一张码微信/支付宝/云闪付都能扫)。两条都开的可由前端传上来;传了没开通的渠道会报 42201
CHANNEL = "alipay"


@app.post("/recharge")
async def create_recharge(request: Request):
    body = await request.json()
    plan = PLANS.get(body.get("plan"))
    if plan is None:
        return Response(status_code=400, content="未知套餐")
    amount, subject = plan
    out_trade_no = f"R{int(time.time() * 1000)}"   # 服务端生成,保证唯一;建议换成库表序列
    order = pay.create_order(
        out_trade_no=out_trade_no,
        amount=amount,
        subject=subject,
        channel=CHANNEL,
        pay_mode="qrcode",                          # 充值页内嵌二维码;整页跳转用 "redirect"
        attach=str(body.get("uid", "")),
    )
    # 此时应把 out_trade_no、金额、用户、状态=待支付 落到你的订单表
    return {"out_trade_no": out_trade_no, "channel": order["channel"], "pay_url": order["pay_url"]}


@app.post("/wantu/webhook")
async def wantu_webhook(request: Request):
    raw_body = await request.body()                 # 必须是原文,不要先 request.json()
    try:
        event = pay.verify_webhook(request.headers, raw_body)
    except WantuWebhookVerifyError:
        return Response(status_code=400)            # 验签失败:不处理,网关会重试

    if event["event_type"] == "order.paid":
        data = event["data"]
        # 入账三件套:查到本地订单 → 金额一致 → 事务内幂等入账
        # UPDATE orders SET status='paid' WHERE out_trade_no=%s AND status='created'
        # 更新行数为 1 才执行加积分;为 0 说明是重复回调,直接返回 200
        ...
    return {"ok": True}


@app.get("/orders/{out_trade_no}")
async def order_status(out_trade_no: str):
    order = pay.query_order(out_trade_no=out_trade_no)
    return {"status": order["status"]}
Warning

webhook 路由的第一行必须是 await request.body() 拿原文。签名针对请求体原始字节, 任何先做 JSON 解析的写法都会导致验签失败。这是接入中最常踩的一个坑。

第四步:前端充值页

前端要做的只有两件事:展示 pay_url、轮询自家后端的订单状态。两条渠道的 pay_url 用法不同: 支付宝给的是一个可嵌入的网页,放 iframe 就行;银商聚合码给的是二维码内容,要用二维码库 渲染成码图(放 iframe 会白屏,电脑浏览器直接打开会提示不支持)。按后端返回的 channel 分支即可:

<div id="payArea"></div>
<div id="status"></div>
<!-- 二维码库,只开支付宝的可以不引。演示图省事走 CDN,生产建议打进自己的前端构建 -->
<script src="https://cdn.bootcdn.net/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script>
async function recharge() {
  const res = await fetch('/recharge', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ plan: 'small', uid: 42 }),
  })
  const order = await res.json()

  // 展示支付码,用户手机扫码,不离开你的页面
  const area = document.getElementById('payArea')
  if (order.channel === 'chinaums') {
    new QRCode(area, { text: order.pay_url, width: 220, height: 220 })
  } else {
    area.innerHTML = '<iframe src="' + order.pay_url + '" width="240" height="240" frameborder="0"></iframe>'
  }

  // 轮询自家后端,到账后刷新页面状态
  const timer = setInterval(async () => {
    const s = await (await fetch('/orders/' + order.out_trade_no)).json()
    if (s.status === 'paid') { clearInterval(timer); document.getElementById('status').textContent = '已到账' }
    if (s.status === 'closed') { clearInterval(timer); document.getElementById('status').textContent = '已超时,请重新下单' }
  }, 3000)
}
</script>

完整写法见 demo 压缩包里的 demo.html,三种支付方式各一个按钮。

要点:支付完成时 PC 页面不会收到任何事件,靠的就是这个轮询;到账的依据是你后端的 订单状态(由 webhook 驱动),不是支付页的跳转。

银商聚合码的两点说明

一张码微信、支付宝、云闪付都能扫,用哪个钱包由用户扫码的 App 决定。回调、查单、退款和 支付宝渠道完全一致,服务端不用做任何额外处理。

页面在微信里打开(公众号菜单、聊天链接)时展示二维码是没法付的——改传 pay_mode="redirect"(可带 return_url),拿到的 pay_url 直接整页跳转过去, 银商收银台会唤起微信支付,你不需要有自己的公众号。 参数细节与微信内完整示例见发起支付。

第五步:上线前核对

按 联调与上线 的用例走一遍(真实支付 0.01 元,验完退款收回),每步预期如下:

动作预期
调 /recharge返回 pay_url,服务端日志无异常
打开充值页,扫码付 0.01 元支付 App 显示付款成功
看你的服务日志出现 webhook 入账日志(几秒内)
调 /orders/{单号}status 为 paid
重启服务后网关重推回调不会重复入账(幂等生效)
调 pay.create_refund(...)受理应答 processing,到商户后台批准后收到 refund.succeeded 回调

本地开发收不到回调时:回调地址需公网可达(内网穿透),排查前先用查单接口确认支付 本身是否成功。遇到任何报错,直接查常见问题排查,按症状对号入座。