Go 接入

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

Go SDK 目前是 beta:接口形态可能还会调整,也还没经过真实商户的生产验证。生产接入前请与顽兔确认。 网关本身和 Node / Python 商户用的是同一套,已在生产稳定运行。

第一步:确认手里有这些

东西来源
wantupay.go单文件 SDK,点此下载后复制进你的项目(仅 Go 标准库,1.21+),包名可按你的项目习惯改
merchant_id / api_secret顽兔发放的商户凭据
环境地址(BaseURL)https://pay-api.wantutech.com
开通的渠道开户时顽兔告知:支付宝、银联商务聚合码,或两条都开。第三步下单要传对应的 Channel

不需要 go get 任何东西,也不用配私有仓库,把文件放进项目里就能编译。 api_secret 放环境变量或配置中心,不要写进代码,不要提交到代码仓库。

第二步:初始化

pay, err := wantupay.NewClient(wantupay.Config{
	BaseURL:    os.Getenv("WANTU_PAY_BASE_URL"),
	MerchantID: os.Getenv("WANTU_MERCHANT_ID"),
	APISecret:  os.Getenv("WANTU_API_SECRET"),
	// WebhookSecret: os.Getenv("WANTU_WEBHOOK_SECRET"),  // 配置了独立回调密钥才需要
})
if err != nil {
	log.Fatal(err)
}

客户端并发安全,建一个全局复用即可。字段名是 Go 惯例的导出名(首字母大写是语言硬性要求), 对应的报文字段名写在结构体的 json 标签里,与接口文档一字不差;方法名与 Python / Node 版一一对应。

第三步:服务端三个路由

充值功能在服务端就是三个路由:下单、收回调、给前端查状态。以下代码用标准库 net/http,可直接粘贴:

package main

import (
	"encoding/json"
	"io"
	"log"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"

	"yourproject/wantupay"
)

var pay *wantupay.Client

// 套餐表:金额永远查服务端的表,前端只传套餐名
var plans = map[string][2]string{
	"small": {"0.01", "积分充值·体验"},
	"big":   {"100.00", "积分充值100元"},
}

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

func main() {
	var err error
	pay, err = wantupay.NewClient(wantupay.Config{
		BaseURL:    os.Getenv("WANTU_PAY_BASE_URL"),
		MerchantID: os.Getenv("WANTU_MERCHANT_ID"),
		APISecret:  os.Getenv("WANTU_API_SECRET"),
	})
	if err != nil {
		log.Fatal(err)
	}
	http.HandleFunc("/recharge", recharge)
	http.HandleFunc("/wantu/webhook", webhook)
	http.HandleFunc("/orders/", queryOrder)
	log.Fatal(http.ListenAndServe(":3000", nil))
}

func recharge(w http.ResponseWriter, r *http.Request) {
	var body struct {
		Plan string `json:"plan"`
		UID  int    `json:"uid"`
	}
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		http.Error(w, "参数错误", http.StatusBadRequest)
		return
	}
	plan, ok := plans[body.Plan]
	if !ok {
		http.Error(w, "未知套餐", http.StatusBadRequest)
		return
	}
	outTradeNo := "R" + strconv.FormatInt(time.Now().UnixNano(), 10) // 服务端生成,保证唯一;建议换成库表序列
	order, err := pay.CreateOrder(r.Context(), wantupay.CreateOrderParams{
		OutTradeNo: outTradeNo,
		Amount:     plan[0],
		Subject:    plan[1],
		Channel:    channel,
		PayMode:    "qrcode", // 充值页内嵌二维码;整页跳转用 "redirect"
		Attach:     "uid=" + strconv.Itoa(body.UID),
	})
	if err != nil {
		log.Println("下单失败:", err)
		http.Error(w, "下单失败", http.StatusBadGateway)
		return
	}
	// 此时应把 outTradeNo、金额、用户、状态=待支付 落到你的订单表
	writeJSON(w, map[string]string{
		"out_trade_no": outTradeNo,
		"channel":      order.Channel,
		"pay_url":      order.PayURL,
	})
}

func webhook(w http.ResponseWriter, r *http.Request) {
	raw, err := io.ReadAll(r.Body) // 必须是请求体原文,验签要在任何 JSON 解析之前
	if err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	event, err := pay.VerifyWebhook(r.Header, raw)
	if err != nil {
		w.WriteHeader(http.StatusBadRequest) // 验签失败:不处理业务,网关会重试
		return
	}
	if event.EventType == wantupay.EventOrderPaid {
		// 入账三件套:查到本地订单 → 金额一致 → 事务内幂等入账
		// UPDATE orders SET status='paid' WHERE out_trade_no=$1 AND status='created'
		// 更新行数为 1 才执行加积分;为 0 说明是重复回调,直接返回 200
		log.Printf("收到支付成功: %s 金额 %s", event.Data.OutTradeNo, event.Data.Amount)
	}
	w.WriteHeader(http.StatusOK) // 不认识的事件类型也回 200,不要报错
}

func queryOrder(w http.ResponseWriter, r *http.Request) {
	outTradeNo := strings.TrimPrefix(r.URL.Path, "/orders/")
	order, err := pay.QueryOrder(r.Context(), wantupay.OrderRef{OutTradeNo: outTradeNo})
	if err != nil {
		http.Error(w, "查询失败", http.StatusBadGateway)
		return
	}
	writeJSON(w, map[string]string{"status": order.Status})
}

func writeJSON(w http.ResponseWriter, v any) {
	w.Header().Set("Content-Type", "application/json")
	_ = json.NewEncoder(w).Encode(v)
}
Warning

webhook 的签名针对请求体原文,必须先 io.ReadAll(r.Body) 拿到字节再验签。 任何 json.Decode(r.Body) 都会把 body 消费掉,之后再验签必然失败。 用 Gin 的话别用 c.ShouldBindJSON,改用 io.ReadAll(c.Request.Body); Echo 用 io.ReadAll(c.Request().Body)。

第四步:前端充值页

前端要做的只有两件事:展示 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 决定。回调、查单、退款和 支付宝渠道完全一致,服务端不用做任何额外处理。

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

错误处理

四类错误用 errors.As 判别:

order, err := pay.CreateOrder(ctx, params)
if err != nil {
	var apiErr *wantupay.APIError
	var httpErr *wantupay.HTTPError
	switch {
	case errors.As(err, &apiErr):
		// 网关业务失败:看 apiErr.Code(40901 换单号、40902 关单撞上已支付、42201 看 ChannelCode 排障等)
		log.Printf("下单被拒 code=%d msg=%s", apiErr.Code, apiErr.Message)
	case errors.As(err, &httpErr):
		// 网络问题:查询类可以直接重试;下单用原单号重试不会产生重复订单
		log.Printf("网络异常 status=%d", httpErr.Status)
	default:
		log.Println("入参有问题:", err) // *ConfigError,请求根本没发出去
	}
}

错误码含义见错误处理。

第五步:上线前核对

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

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

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