Node.js 接入

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

第一步:确认手里有这些

东西来源
wantu-merchant-sdk 安装包npm 包(tgz 文件),点此下载,零第三方依赖
merchant_id / api_secret顽兔发放的商户凭据
环境地址(base_url)https://pay-api.wantutech.com
开通的渠道开户时顽兔告知:支付宝、银联商务聚合码,或两条都开。第三步下单要传对应的 channel

安装(要求 Node.js ≥ 20):

curl -O https://docs.wantutech.com/downloads/wantu-merchant-sdk-0.4.0.tgz
npm install ./wantu-merchant-sdk-0.4.0.tgz

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

第二步:初始化

import { WantuPayClient } from 'wantu-merchant-sdk'

const pay = new WantuPayClient({
  base_url: process.env.WANTU_PAY_BASE_URL!,
  merchant_id: process.env.WANTU_MERCHANT_ID!,
  api_secret: process.env.WANTU_API_SECRET!,
  // webhook_secret: process.env.WANTU_WEBHOOK_SECRET,  // 配置了独立回调密钥才需要
})

方法名、参数名、返回字段都和 HTTP 报文、Python SDK 一字不差(snake_case),对着接口文档就能核对。

第三步:服务端三个路由

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

import express from 'express'

const app = express()

// 套餐表:金额永远查服务端的表,前端只传套餐名
const PLANS: Record<string, [string, string]> = {
  small: ['0.01', '积分充值·体验'],
  big: ['100.00', '积分充值100元'],
}

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

app.post('/recharge', express.json(), async (req, res) => {
  const plan = PLANS[req.body.plan]
  if (!plan) return res.status(400).send('未知套餐')
  const [amount, subject] = plan
  const out_trade_no = `R${Date.now()}`   // 服务端生成,保证唯一;建议换成库表序列
  const order = await pay.create_order({
    out_trade_no,
    amount,
    subject,
    channel: CHANNEL,
    pay_mode: 'qrcode',                   // 充值页内嵌二维码;整页跳转用 'redirect'
    attach: String(req.body.uid ?? ''),
  })
  // 此时应把 out_trade_no、金额、用户、状态=待支付 落到你的订单表
  res.json({ out_trade_no, channel: order.channel, pay_url: order.pay_url })
})

app.post('/wantu/webhook', express.raw({ type: '*/*' }), async (req, res) => {
  let event
  try {
    event = pay.verify_webhook(req.headers, req.body)   // req.body 是原文 Buffer,直接传
  } catch {
    return res.sendStatus(400)          // 验签失败:不处理,网关会重试
  }
  if (event.event_type === 'order.paid') {
    const data = event.data
    // 入账三件套:查到本地订单 → 金额一致 → 事务内幂等入账
    // UPDATE orders SET status='paid' WHERE out_trade_no=? AND status='created'
    // 更新行数为 1 才执行加积分;为 0 说明是重复回调,直接返回 200
  }
  res.sendStatus(200)
})

app.get('/orders/:out_trade_no', async (req, res) => {
  const order = await pay.query_order({ out_trade_no: req.params.out_trade_no })
  res.json({ status: order.status })
})

app.listen(3000)
Warning

不要全局挂 app.use(express.json())。webhook 的签名针对请求体原文,全局 JSON 中间件会把 body 提前消费掉,导致验签必然失败。像上面这样按路由挂中间件:业务路由用 express.json(),webhook 路由用 express.raw()。这是 Node 侧最常踩的一个坑。

其他框架取原文的方式:Koa 用 raw-body,Nest 开启 rawBody: true, Next.js App Router 用 await request.text()。

第四步:前端充值页

前端要做的只有两件事:展示 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>

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

银商聚合码的两点说明

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

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

第五步:上线前核对

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

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

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