Java 接入

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

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

第一步:确认手里有这些

东西来源
WantuPay.java单文件 SDK,点此下载后复制进你的项目(仅 JDK 标准库,Java 8+),包名默认 tech.wantu.pay,改第一行即可
merchant_id / api_secret顽兔发放的商户凭据
环境地址(baseUrl)https://pay-api.wantutech.com
开通的渠道开户时顽兔告知:支付宝、银联商务聚合码,或两条都开。第三步下单要传对应的 channel

不需要往 Maven / Gradle 里加任何依赖,也不用配私有仓库,把文件放进 src/main/java/tech/wantu/pay/ 就能编译。SDK 自带一个极简 JSON 解析器,所以不引 Jackson、Gson 也能跑。 api_secret 放环境变量或配置中心,不要写进代码,不要提交到代码仓库。

第二步:初始化

WantuPay.Client pay = new WantuPay.Client(new WantuPay.Config()
        .baseUrl(System.getenv("WANTU_PAY_BASE_URL"))
        .merchantId(System.getenv("WANTU_MERCHANT_ID"))
        .apiSecret(System.getenv("WANTU_API_SECRET")));
        // .webhookSecret(System.getenv("WANTU_WEBHOOK_SECRET"))  // 配置了独立回调密钥才需要

客户端线程安全,注册成一个 Bean 全局复用即可。返回的 Map 的键就是报文字段名,与接口文档 一字不差;方法名按 Java 惯例用 camelCase,与 Python / Node 版一一对应。

第三步:服务端三个路由

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

package com.example.recharge;

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import tech.wantu.pay.WantuPay;

@RestController
public class RechargeController {

    // 套餐表:金额永远查服务端的表,前端只传套餐名
    private static final Map<String, String[]> PLANS = new HashMap<>();

    static {
        PLANS.put("small", new String[] {"0.01", "积分充值·体验"});
        PLANS.put("big", new String[] {"100.00", "积分充值100元"});
    }

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

    private final WantuPay.Client pay = new WantuPay.Client(new WantuPay.Config()
            .baseUrl(System.getenv("WANTU_PAY_BASE_URL"))
            .merchantId(System.getenv("WANTU_MERCHANT_ID"))
            .apiSecret(System.getenv("WANTU_API_SECRET")));

    @PostMapping("/recharge")
    public ResponseEntity<Map<String, String>> recharge(@RequestBody Map<String, Object> body) {
        String[] plan = PLANS.get(String.valueOf(body.get("plan")));
        if (plan == null) {
            return ResponseEntity.badRequest().build();
        }
        String outTradeNo = "R" + System.currentTimeMillis();  // 服务端生成,保证唯一;建议换成库表序列
        Map<String, Object> order = pay.createOrder(new WantuPay.CreateOrderParams()
                .outTradeNo(outTradeNo)
                .amount(plan[0])
                .subject(plan[1])
                .channel(CHANNEL)
                .payMode("qrcode")                     // 充值页内嵌二维码;整页跳转用 "redirect"
                .attach("uid=" + body.get("uid")));
        // 此时应把 outTradeNo、金额、用户、状态=待支付 落到你的订单表
        Map<String, String> result = new HashMap<>();
        result.put("out_trade_no", outTradeNo);
        result.put("channel", (String) order.get("channel"));
        result.put("pay_url", (String) order.get("pay_url"));
        return ResponseEntity.ok(result);
    }

    @PostMapping("/wantu/webhook")
    public ResponseEntity<String> webhook(@RequestHeader Map<String, String> headers,
                                          @RequestBody byte[] rawBody) {   // 必须是请求体原文字节
        Map<String, Object> event;
        try {
            event = pay.verifyWebhook(headers, rawBody);
        } catch (WantuPay.WebhookVerifyException e) {
            return ResponseEntity.badRequest().build();   // 验签失败:不处理业务,网关会重试
        }
        if ("order.paid".equals(event.get("event_type"))) {
            // 入账三件套:查到本地订单 → 金额一致 → 事务内幂等入账
            // UPDATE orders SET status='paid' WHERE out_trade_no=? AND status='created'
            // 更新行数为 1 才执行加积分;为 0 说明是重复回调,直接返回 200
        }
        return ResponseEntity.ok("ok");                   // 不认识的事件类型也回 2xx,不要报错
    }

    @GetMapping("/orders/{outTradeNo}")
    public Map<String, Object> queryOrder(@PathVariable String outTradeNo) {
        Map<String, Object> order = pay.queryOrder(WantuPay.OrderRef.outTradeNo(outTradeNo));
        return Collections.singletonMap("status", order.get("status"));
    }
}
Warning

webhook 的签名针对请求体原文,参数必须声明成 @RequestBody byte[]。声明成 Map 或自定义 DTO 时 Spring 会先把 JSON 解析掉,拿到的就不是原始字节,验签必然失败。 Servlet 里用 request.getInputStream() 读原始字节,也不要先调 getParameter。

第四步:前端充值页

前端要做的只有两件事:展示 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(...)),拿到的 pay_url 直接让前端整页跳过去, 银商收银台会唤起微信支付,你不需要有自己的公众号。 参数细节与微信内完整示例见发起支付。

错误处理

四类异常都是非受检的,基类 WantuPay.PayException:

try {
    pay.createOrder(params);
} catch (WantuPay.ApiException e) {
    // 网关业务失败:看 e.code(40901 换单号、40902 关单撞上已支付、42201 看 e.channelCode 排障等)
    log.error("下单被拒 code={} msg={}", e.code, e.getMessage());
} catch (WantuPay.HttpException e) {
    // 网络问题:查询类可以直接重试;下单用原单号重试不会产生重复订单
    log.error("网络异常 status={}", e.status);
} catch (WantuPay.ConfigException e) {
    // 入参有问题,请求根本没发出去
    log.error("入参错误", e);
}

错误码含义见错误处理。

第五步:上线前核对

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

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

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