Node.js 接入

本页是 Node.js(Express)商户的完整接入教程,从零到收款五步,代码都可以整段复制。 读完本页即可完成接入,其他页面作为字典备查。

第一步:确认手里有这些

东西来源
wantu-merchant-sdk 安装包顽兔交付的 npm 包(tgz 文件),零第三方依赖
merchant_id / api_secret顽兔发放的商户凭据
环境地址(baseUrl)联调、生产各一个

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

npm install ./wantu-merchant-sdk-0.1.0.tgz

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

第二步:初始化

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

const pay = createWantuPayClient({
  baseUrl: process.env.WANTU_PAY_BASE_URL!,
  merchantId: process.env.WANTU_MERCHANT_ID!,
  apiSecret: process.env.WANTU_API_SECRET!,
  // webhookSecret: process.env.WANTU_WEBHOOK_SECRET,  // 配置了独立回调密钥才需要
})

第三步:服务端三个路由

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

import express from 'express'

const app = express()

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

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 outTradeNo = `R${Date.now()}`   // 服务端生成,保证唯一;建议换成库表序列
  const order = await pay.orders.create({
    outTradeNo,
    amount,
    subject,
    payMode: 'qrcode',                  // 充值页内嵌二维码;整页跳转用 'redirect'
    attach: String(req.body.uid ?? ''),
  })
  // 此时应把 outTradeNo、金额、用户、状态=待支付 落到你的订单表
  res.json({ out_trade_no: outTradeNo, pay_url: order.payUrl })
})

app.post('/wantu/webhook', express.raw({ type: '*/*' }), async (req, res) => {
  let event
  try {
    event = pay.webhook.verify({ headers: req.headers, rawBody: req.body.toString() })
  } catch {
    return res.sendStatus(400)          // 验签失败:不处理,网关会重试
  }
  if (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/:outTradeNo', async (req, res) => {
  const order = await pay.orders.query({ outTradeNo: req.params.outTradeNo })
  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、轮询自家后端的订单状态。可直接用的最小实现:

<div id="payArea"></div>
<div id="status"></div>
<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()

  // 内嵌二维码(用户手机支付宝扫码,不离开你的页面)
  document.getElementById('payArea').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 驱动),不是支付页的跳转。

可选:银联商务聚合码(一张码微信/支付宝/云闪付都能扫)

顽兔侧开通银商渠道后,下单多传一个 channel 就能出聚合码,回调、查单、退款完全不变:

const order = await pay.orders.create({
  outTradeNo,
  amount,
  subject,
  channel: 'chinaums',   // 银联商务聚合码;不传就是支付宝
  payMode: 'qrcode',     // PC 出码场景用 qrcode
  attach: String(req.body.uid ?? ''),
})

前端有一处不同:这条渠道的 payUrl二维码内容(不能 iframe、浏览器打不开), 要用二维码库渲染成码图:

<script src="https://cdn.bootcdn.net/ajax/libs/qrcodejs/1.0.0/qrcode.min.js"></script>
<script>
  // 替换第四步里的 iframe 那行:
  new QRCode(document.getElementById('payArea'), { text: order.pay_url, width: 220, height: 220 })
</script>

按下单返回的 channel 字段分支两种渲染即可。

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

第五步:联调核对

联调与上线 的用例走,每步预期如下:

动作预期
/recharge返回 pay_url,服务端日志无异常
打开支付页,用联调买家账号付款 0.01支付宝页面显示付款成功
看你的服务日志出现 webhook 入账日志(几秒内)
/orders/{单号}statuspaid
重启服务后网关重推回调不会重复入账(幂等生效)
pay.refunds.create(...)返回 succeeded,随后收到 refund.succeeded 回调

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