阅读 304

spring boot webflux 集成 APP 微信支付 和 支付宝支付

前言

这周(在草稿箱中发现 2019-11-01 有一篇文章忘记发布了)负责在服务端集成 APP 支付功能,支付渠道分别是 微信 APP 支付 和 支付宝 APP 支付,后端采用 spring cloud 技术栈,启动容器采用 spring boot webflux 内置的 netty 容器。 写这篇文章主要是记录一下自己踩得坑,以后再次遇到就可以节省很多时间。

加签

加签逻辑是,在请求发往 支付宝 或 微信支付 服务器之前,使用 hash 算法 把请求体中的信息 进行一次 hash,得到一串 hash 值。这串 hash 值不可逆,但是相同的请求体的 hash 值是相同的,这样就可以避免请求体中的信息被恶意篡改,因为一旦被篡改,那么 hash 值前后肯定不一致。

验签

如果加签的目的 是为了防止请求 被恶意篡改,那么验签就是在确认 请求是否被恶意篡改。验签的逻辑跟加签基本上是一样的,只要验签得到的签名 和 加签得到的签名一致,就可以信任该请求。

一个好消息一个坏消息

好消息是,支付宝提供了服务端 SDK,大部分的细节已经被封装到 SDK 中。

坏消息是,微信支付目前不提供服务端 SDK,所有的加签验证、发请求、接收请求返回值等细节都需要服务端开发者承担。

支付宝

支付宝有非常完善的开发文档,还有集成例子,集成起来的体验是最好的,而且 支付宝还有真人在线技术咨询,真人!真人!真人!

由于边幅有限,创建应用上线应用签约功能 请读者自行阅览官方文档。下面讲一些跟开发比较息息相关的内容。

密钥配置

支付宝开放平台支持开发者使用 普通公钥、公钥证书 两种签名方式,两种任选其一就好,具体操作请看官方文档

接下来要讲的是公钥证书的加签方式,

重要配置

完成密钥配置的公钥证书方式,就可以得到开发所需要的重要配置

  • APPID(创建应用得到)
  • 应用私钥 (private key)
  • 应用公钥证书(public key cert)
  • 支付宝公钥(alipay public key)
  • 支付宝公钥证书(alipay public key cert)
  • 支付宝根证书(alipay root cert)

APP 支付流程

APP 支付官网有详细的文档,建议读者看官网的一手资料,这里笔者只会提到重要的集成和开发信息。

在集成之前,有必要了解接入到支付宝支付的方式和架构建议

可以看到,支付前需要“下单”,由商家后台 调用 支付宝后台 下单接口 生成支付订单信息;支付动作 则先由商家 APP 唤起 支付宝 APP,再使用支付宝 APP 进行支付的。支付完成后,支付宝支付后台 会把支付结果 通知(同步通知)给 APP,还会采用 异步通知的方式 发给 商家后台。

这是跨职能的系统交互流程图

下单

下单是 APP 支付的前提,下单除了业务上的含义之外,其主要解决的问题 是保障支付过程的安全性,方式就是加签,避免支付信息在支付过程中被偷偷篡改。

任务列表:

  1. 配置
    • 四个公钥证书(支付宝接口需要)
    • 应用私钥(加签验签需要)
    • 加签算法(RSA1 和 RSA2,建议 RSA2)
    • 支付结果异步通知公网的接口(notify_url
    • 其它配置请看官方文档
  2. 构建 AlipayClient
    • 构建支付宝证书请求 CertAlipayRequest.java
    • 构建默认的支付宝客户端 new DefaultAlipayClient(certAlipayRequest)
    • 构建下单接口的请求参数 AlipayTradeAppPayRequest.javaAlipayTradeAppPayModel.java,后者对应下单接口的 biz_content 参数,其中有必填(金额、订单号较为重要)和选填参数(passbackParams 需要关注一下),请参考官方文档。
    • 发起下单请求 alipayClient.sdkExecute(request)
    • 使用 AlipayTradeAppPayResponse.java 作为下单请求的返回值
  3. 商家业务逻辑代码。
  4. 编写一个测试,用于模拟 APP 下单

部分代码如下:

@Test
void should_return_alipay_place_order_info() throws Exception {
    // given
    PlaceOrderCommand command = PlaceOrderCommand.builder()
            .userId(1L)
            .userName("张三")
            .orderNo("123456789")
            .goodId(1L)
            .subject("外套")
            .totalAmount(100)
            .payChannel(Payment.PayChannel.ALIPAY)
            .build();
    // when
    WebTestClient.ResponseSpec responseSpec = this.post("/orders", command);
    // then
    responseSpec
            .expectStatus().isOk()
            .expectBody(new ParameterizedTypeReference<PlaceOrderResult<Object>>() {})
            .value(actualPlaceOrderInfo -> {
                assertThat(actualPlaceOrderInfo.getId()).isNotNull();
                assertThat(actualPlaceOrderInfo.getOrderInfo()).isNotBlank();
                assertThat(actualPlaceOrderInfo.getPayChannel()).isEqualTo(Payment.PayChannel.ALIPAY);
                // other assert ...
            })
            // 自动生成 API 文档
            .consumeWith(this.commonDocumentation());
}

/**
 * 下单(生成支付订单)
 *
 * @return
 */
@PostMapping("/orders")
public Mono<PlaceOrderResult> placeOrder(@RequestBody PlaceOrderCommand command) {
    command.verify();
    if (command.isAlipay()) {
        return Mono.just(this.alipayService.placeOrder(command));
    }
    throw new PayException("暂不开放该支付方式");
}

@Slf4j
@Component
public class AlipayClient {

    /**
     * 支付宝签名算法
     */
    private static final String SIGN_TYPE = "RSA2";
    /**
     * 支付宝 client
     */
    private final com.alipay.api.AlipayClient alipayClient;
    private final AlipayProperties alipayProperties;

    public AlipayClient(AlipayProperties alipayProperties) throws AlipayApiException {
        this.alipayProperties = alipayProperties;
        this.alipayClient = new DefaultAlipayClient(this.createCertAlipayRequest());
    }

    /**
     * 统一下单
     *
     * @param command
     * @param paymentId 支付信息ID
     * @return
     * @throws AlipayApiException
     */
    public AlipayTradeAppPayResponse placeOrder(PlaceOrderCommand command, Long paymentId) throws AlipayApiException {
        PassBackParam passBackParam = new PassBackParam();
        passBackParam.setPaymentId(paymentId);
        // 实例化具体API对应的request类,类名称和接口名称对应,当前调用接口名称:alipay.trade.app.pay
        AlipayTradeAppPayRequest request = new AlipayTradeAppPayRequest();
        // SDK已经封装掉了公共参数,这里只需要传入业务参数。以下方法为sdk的model入参方式(model和biz_content同时存在的情况下取biz_content)。
        AlipayTradeAppPayModel model = new AlipayTradeAppPayModel();
        model.setSubject(command.getSubject());
        model.setOutTradeNo(command.getOrderNo());
        // 设置失效时间
        model.setTimeoutExpress(this.alipayProperties.getTimeoutExpress());
        // 接口入参金额的单元为 分,支付宝统一下单接口的金额单位是元
        model.setTotalAmount(command.getTotalAmount().toString());
        model.setProductCode("QUICK_MSECURITY_PAY");
        model.setPassbackParams(JSON.toJSONString(passBackParam));
        request.setBizModel(model);
        request.setNotifyUrl(this.alipayProperties.getNotifyUrl());
        // 这里和普通的接口调用不同,使用的是sdkExecute
        return this.alipayClient.sdkExecute(request);
    }

    private CertAlipayRequest createCertAlipayRequest() {
        //构造client
        CertAlipayRequest certAlipayRequest = new CertAlipayRequest();
        // 设置网关地址
        certAlipayRequest.setServerUrl(this.alipayProperties.getServerUrl());
        // 设置应用Id
        certAlipayRequest.setAppId(this.alipayProperties.getAppId());
        // 设置应用私钥 -> 配置密钥
        certAlipayRequest.setPrivateKey(this.alipayProperties.getPrivateKey());
        // 设置请求格式,固定值json
        certAlipayRequest.setFormat("json");
        // 设置字符集
        certAlipayRequest.setCharset(StandardCharsets.UTF_8.name());
        // 设置签名类型
        certAlipayRequest.setSignType(SIGN_TYPE);
        // 设置应用公钥证书路径
        certAlipayRequest.setCertPath(this.alipayProperties.getAppCertPublicKey());
        // 设置支付宝公钥证书路径
        certAlipayRequest.setAlipayPublicCertPath(this.alipayProperties.getAlipayCertPublicKey_RSA2());
        // 设置支付宝根证书路径
        certAlipayRequest.setRootCertPath(this.alipayProperties.getAlipayRootCert());
        return certAlipayRequest;
    }
}

@Slf4j
@Setter
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Component
@ConfigurationProperties("pay.alipay")
public class AlipayProperties {

    /**
     * 支付宝地址
     */
    private String serverUrl;
    /**
     * 商户id
     */
    private String appId;
    /**
     * 应用私钥
     */
    private String privateKey;
    /**
     * 应用公钥证书 物理绝对路径
     */
    private String appCertPublicKey;
    /**
     * 支付宝公钥证书 物理绝对路径
     */
    private String alipayCertPublicKey_RSA2;
    /**
     * 支付宝根证书 物理绝对路径
     */
    private String alipayRootCert;
    /**
     * 支付回调通知 url
     */
    private String notifyUrl;
    /**
     * 支付订单失效时间
     */
    private String timeoutExpress;

    public String getAlipayCertPublicKey_RSA2() {
        return FileUtils.getAbsolutePath(this.alipayCertPublicKey_RSA2);
    }

    public String getAlipayRootCert() {
        return FileUtils.getAbsolutePath(this.alipayRootCert);
    }

    public String getAppCertPublicKey() {
        return FileUtils.getAbsolutePath(this.appCertPublicKey);
    }
}

@Slf4j
public class FileUtils {

    /**
     * 当文件的前缀包含 / 说明这个文件位于操作系统的文件系统中
     * 当文件的前缀不包含 / 说明这个文件位于 resources 目录中
     */
    private static final String PREFIX = "/";

    public static String getAbsolutePath(String filePath) {
        if (filePath.startsWith(PREFIX)) {
            return filePath;
        }
        try {
            return ResourceUtils.getFile("classpath:" + filePath).getAbsolutePath();
        } catch (Throwable e) {
            log.error("找不到文件 {}", filePath);
            log.error("找不到文件", e);
            return null;
        }
    }
}
pay:
  alipay:
    # 支付宝地址
    server-url: https://openapi.alipay.com/gateway.do
    # 应用ID
    app-id: 
    # 应用私钥
    private-key: 
    # 应用公钥证书 物理绝对路径
    app-cert-public-key: 
    # 支付宝公钥证书 物理绝对路径
    alipay-cert-public-key_RSA2: 
    # 支付宝根证书 物理绝对路径
    alipay-root-cert: 
    # 支付结果 异步通知 url
    notify-url: 
    # 支付订单失效时间
    timeout-express: 
复制代码

下单的逻辑是非常简单的,只要认真检查 4个公钥证书 和 应用私钥的配置,基本上就没什么问题。

如果使用公钥证书方式,在密钥配置阶段请特别注意一个细节,在官网下载的密钥生成工具,生成的 CSR 文件不是应用公钥!四个公钥证书都是通过平台下载,包括应用私钥也是。

支付结果回调通知

如果 APP 请求下单接口或,能够唤起支付宝 并 支付成功,那么说明 公钥证书 和 加签方式 是正确的,这个时候,商家 APP 可以同步获取支付结果,例如告诉用户支付成功。

通常为了安全,我们并不会采用客户端的支付结果,而是采用 支付宝后台 发起的支付结果通知的信息 作为支付的凭证。也就是上图的第 12、13。

根据官方文档的描述,该接口是 HTTP POST 请求,具体的参数请自行阅读官方文档。

任务列表:

  1. 开发一个接口用于接收支付宝后台的回调,该接口就是下单时填写的 notify_url。
  2. 验签
  3. 开发商家业务逻辑
  4. 接口返回 success(代表回调并验证成功) 或者 failure(代码验证不通过)。
  5. 编写一个测试,用于模拟支付宝回调
@Test
void should_received_payment_success_from_alipay() throws Exception {
    // given
    Payment payment = Payment.builder()
            .buyerId(1L)
            .buyerName("Tester")
            .state(Payment.State.WAIT_BUYER_PAY)
            .orderNo("6823789339978248")
            .payChannel(Payment.PayChannel.ALIPAY)
            .payMoney(100L)
            .build();
    this.paymentRepository.save(payment);

    MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
    body.add("app_id", "123123123123");
    body.add("subject", "外套");
    body.add("notify_type", "trade_status_sync");
    body.add("notify_id", "ac05099524730693a8b330c5ecf72da9786");
    body.add("charset", "utf-8");
    body.add("version", "1.0");
    body.add("sign_type", "RSA2");
    body.add("sign", UUID.randomUUID().toString().replaceAll("-", ""));
    body.add("trade_no", "2013112011001004330000121536");
    body.add("out_trade_no", "6823789339978248");
    body.add("trade_status", TradeStatus.TRADE_SUCCESS.toString());
    body.add("gmt_close", "2019-10-10 10:10:10");
    body.add("notify_time", "2019-10-10 10:10:10");
    body.add("gmt_payment", "2019-10-10 10:10:10");
    body.add("gmt_create", "2019-10-10 10:10:10");
    body.add("buyer_pay_amount", "0.01");
    body.add("amount", "0.01");
    body.add("receipt_amount", "0.01");
    body.add("total_amount", "0.01");
    body.add("fund_bill_list", "[{\"amount\":\"0.01\",\"fundChannel\":\"ALIPAYACCOUNT\"}]");
    body.add("passback_params", "{\"paymentId\":\"" + payment.getId() + "\"}");

    given(this.alipayNotifyHandler.verifySignature(anyMap())).willReturn(true);
    // when
    WebTestClient.ResponseSpec responseSpec = this.client.post()
            .uri("/alipay/notifications/payment")
            .body(BodyInserters.fromFormData(body))
            .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_FORM_URLENCODED_VALUE)
            .exchange();
    // then
    responseSpec
            .expectStatus().isOk()
            .expectBody();
    Payment actualPayment = this.paymentRepository.findById(payment.getId()).get();
    assertThat(actualPayment.getState()).isEqualTo(Payment.State.SUCCESS);
    AlipayTransaction alipayTransaction = this.alipayTransactionRepository.findByPaymentId(payment.getId()).get();
    assertThat(alipayTransaction.getTradeStatus()).isEqualTo(TradeStatus.TRADE_SUCCESS);
    verify(this.orderClient, times(1)).notifyPaid(any());
    // other assert
}
复制代码

这里需要注意,支付宝后台回调接口的 Content-Typeapplication/x-www-form-urlencoded

/**
 * 支付宝 支付结果变更 回调接口
 *
 * @param requestParams
 */
@PostMapping(value = "/alipay/notifications/payment")
public String receiveAlipayNotify(@RequestBody MultiValueMap<String, String> requestParams) {
    Map<String, String> params = this.asMap(requestParams);
    if (!this.alipayNotifyHandler.verifySignature(params)) {
        log.warn("支付宝支付结果签名验证失败 {}", JSON.toJSONString(params));
        return "failure";
    }
    ReceiveAlipayNotifyCommand command = this.parseToReceiveAlipayNotifyCommand(params);
    if (command.isWaitBuyerPay()) {
        return "failure";
    }
    this.alipayNotifyHandler.receiveNotify(command);
    return "success";
}

/**
 * 验证签名
 *
 * @param params
 * @return
 */
public boolean verifySignature(Map<String, String> params) {
    try {
        String alipayPublicCertPath = this.alipayProperties.getAlipayCertPublicKey_RSA2();
        return AlipaySignature.rsaCertCheckV1(params, alipayPublicCertPath, StandardCharsets.UTF_8.name(), "RSA2");
    } catch (AlipayApiException e) {
        log.error("支付宝支付结果回调 检查签名异常", e);
        return false;
    }
}

private ReceiveAlipayNotifyCommand parseToReceiveAlipayNotifyCommand(Map<String, String> params) {
    String passbackParams = params.remove("passback_params");
    String fundBillList = params.remove("fund_bill_list");

    ReceiveAlipayNotifyCommand command = JSON.parseObject(JSON.toJSONString(params), ReceiveAlipayNotifyCommand.class);
    command.setFundBillList(JSON.parseArray(fundBillList, FundBill.class));
    command.setPassBackParam(JSON.parseObject(passbackParams, PassBackParam.class));
    return command;
}

@Setter
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ReceiveAlipayNotifyCommand {

    /**
     * 通知时间	Date	是	通知的发送时间。格式为yyyy-MM-dd HH:mm:ss	2015-14-27 15:45:58
     */
    @JSONField(name = "notify_time")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime notifyTime;
    /**
     * 通知类型	String(64)	是	通知的类型	trade_status_sync
     */
    @JSONField(name = "notify_type")
    private String notifyType;
    /**
     * 通知校验ID	String(128)	是	通知校验ID	ac05099524730693a8b330c5ecf72da9786
     */
    @JSONField(name = "notify_id")
    private String notifyId;
    /**
     * 开发者的app_id	String(32)	是	支付宝分配给开发者的应用Id	2014072300007148
     */
    @JSONField(name = "app_id")
    private String appId;
    /**
     * 编码格式	String(10)	是	编码格式,如utf-8、gbk、gb2312等	utf-8
     */
    @JSONField(name = "charset")
    private String charset;
    /**
     * 接口版本	String(3)	是	调用的接口版本,固定为:1.0	1.0
     */
    @JSONField(name = "version")
    private String version;
    /**
     * 签名类型	String(10)	是	商户生成签名字符串所使用的签名算法类型,目前支持RSA2和RSA,推荐使用RSA2	RSA2
     */
    @JSONField(name = "sign_type")
    private String signType;
    /**
     * 签名	String(256)	是	请参考异步返回结果的验签	601510b7970e52cc63db0f44997cf70e
     */
    @JSONField(name = "sign")
    private String sign;
    /**
     * 支付宝交易号	String(64)	是	支付宝交易凭证号	2013112011001004330000121536
     */
    @JSONField(name = "trade_no")
    private String tradeNo;
    /**
     * 商户订单号	String(64)	是	原支付请求的商户订单号	6823789339978248
     */
    @JSONField(name = "out_trade_no")
    private String outTradeNo;
    /**
     * 商户业务号	String(64)	否	商户业务ID,主要是退款通知中返回退款申请的流水号	HZRF001
     */
    @JSONField(name = "out_biz_no")
    private String outBizNo;
    /**
     * 买家支付宝用户号	String(16)	否	买家支付宝账号对应的支付宝唯一用户号。以2088开头的纯16位数字	2088102122524333
     */
    @JSONField(name = "buyer_id")
    private String buyerId;
    /**
     * 买家支付宝账号	String(100)	否	买家支付宝账号	159﹡﹡﹡﹡﹡﹡20
     */
    @JSONField(name = "buyer_logon_id")
    private String buyerLogonId;
    /**
     * 卖家支付宝用户号	String(30)	否	卖家支付宝用户号	2088101106499364
     */
    @JSONField(name = "seller_id")
    private String sellerId;
    /**
     * 卖家支付宝账号	String(100)	否	卖家支付宝账号	zhu﹡﹡﹡@alitest.com
     */
    @JSONField(name = "seller_email")
    private String sellerEmail;
    /**
     * 交易状态	String(32)	否	交易目前所处的状态,见交易状态说明	TRADE_CLOSED
     */
    @JSONField(name = "trade_status")
    private TradeStatus tradeStatus;
    /**
     * 订单金额	Number(9,2)	否	本次交易支付的订单金额,单位为人民币(元)	20
     */
    @JSONField(name = "total_amount")
    private BigDecimal totalAmount;
    /**
     * 实收金额	Number(9,2)	否	商家在交易中实际收到的款项,单位为元	15
     */
    @JSONField(name = "receipt_amount")
    private BigDecimal receiptAmount;
    /**
     * 开票金额	Number(9,2)	否	用户在交易中支付的可开发票的金额	10.00
     */
    @JSONField(name = "invoice_amount")
    private BigDecimal invoiceAmount;
    /**
     * 付款金额	Number(9,2)	否	用户在交易中支付的金额	13.88
     */
    @JSONField(name = "buyer_pay_amount")
    private BigDecimal buyerPayAmount;
    /**
     * 集分宝金额	Number(9,2)	否	使用集分宝支付的金额	12.00
     */
    @JSONField(name = "point_amount")
    private BigDecimal pointAmount;
    /**
     * 总退款金额	Number(9,2)	否	退款通知中,返回总退款金额,单位为元,支持两位小数	2.58
     */
    @JSONField(name = "refund_fee")
    private BigDecimal refundFee;
    /**
     * 订单标题	String(256)	否	商品的标题/交易标题/订单标题/订单关键字等,是请求时对应的参数,原样通知回来	当面付交易
     */
    @JSONField(name = "subject")
    private String subject;
    /**
     * 商品描述	String(400)	否	该订单的备注、描述、明细等。对应请求时的body参数,原样通知回来	当面付交易内容
     */
    @JSONField(name = "body")
    private String body;
    /**
     * 交易创建时间	Date	否	该笔交易创建的时间。格式为yyyy-MM-dd HH:mm:ss	2015-04-27 15:45:57
     */
    @JSONField(name = "gmt_create")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime gmtCreate;
    /**
     * 交易付款时间	Date	否	该笔交易的买家付款时间。格式为yyyy-MM-dd HH:mm:ss	2015-04-27 15:45:57
     */
    @JSONField(name = "gmt_payment")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime gmtPayment;
    /**
     * 交易退款时间	Date	否	该笔交易的退款时间。格式为yyyy-MM-dd HH:mm:ss.S	2015-04-28 15:45:57.320
     */
    @JSONField(name = "gmt_refund")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime gmtRefund;
    /**
     * 交易结束时间	Date	否	该笔交易结束时间。格式为yyyy-MM-dd HH:mm:ss	2015-04-29 15:45:57
     */
    @JSONField(name = "gmt_close")
    @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime gmtClose;
    /**
     * 支付金额信息	String(512)	否	支付成功的各个渠道金额信息,详见资金明细信息说明	[{"amount":"15.00","fundChannel":"ALIPAYACCOUNT"}]
     */
    @JSONField(name = "fund_bill_list")
    private List<FundBill> fundBillList;
    /**
     * 回传参数	String(512)	否	公共回传参数,如果请求时传递了该参数,则返回给商户时会在异步通知时将该参数原样返回。本参数必须进行UrlEncode之后才可以发送给支付宝	merchantBizType%3d3C%26merchantBizNo%3d2016010101111
     */
    @JSONField(name = "passback_params")
    private PassBackParam passBackParam;
    /**
     * 优惠券信息	String	否	本交易支付时所使用的所有优惠券信息,详见优惠券信息说明	[{"amount":"0.20","merchantContribute":"0.00","name":"一键创建券模板的券名称","otherContribute":"0.20","type":"ALIPAY_DISCOUNT_VOUCHER","memo":"学生卡8折优惠"];
     */
    @JSONField(name = "voucher_detail_list")
    private String voucherDetailList;

    @JSONField(serialize = false)
    public boolean isTradeClosed() {
        return this.getTradeStatus() == TradeStatus.TRADE_CLOSED;
    }

    @JSONField(serialize = false)
    public boolean isTradeFinished() {
        return this.getTradeStatus() == TradeStatus.TRADE_FINISHED;
    }

    @JSONField(serialize = false)
    public boolean isTradeSuccess() {
        return this.getTradeStatus() == TradeStatus.TRADE_SUCCESS;
    }

    @JSONField(serialize = false)
    public boolean isWaitBuyerPay() {
        return this.getTradeStatus() == TradeStatus.WAIT_BUYER_PAY;
    }
}
复制代码

支付结果回调最重要的,就是搞清楚支付宝后台回调接口 的协议是什么,这样才能知道如何测试接口和定义接口,避免浪费时间。

这里我建议编写 API 测试来模拟支付宝后台的回调,这样的开发效率非常高,而不是部署到线上去测试。

退款

统一收单交易退款接口 提供了退款所需的信息,唯一遗憾的是,没有提供证书加签方式的例子,把我给误导了大半天,导致接口一直不通,浪费了很多时间。

任务列表:

  1. 拿到 商户订单号 或 支付宝交易号,任选其一。
  2. 拿到退款金额
  3. 构建退款接口请求 AlipayTradeRefundRequestAlipayTradeRefundModel,后者为接口的 biz_content 参数。
  4. 发起退款请求
  5. 开发业务逻辑
  6. 测试
@Test
void should_refund_from_alipay() throws Exception {
    // given
    Payment payment = Payment.builder()
            .state(Payment.State.SUCCESS)
            .orderNo("77718857416704")
            .payMoney(1L)
            .payChannel(Payment.PayChannel.ALIPAY)
            .build();
    payment.setId(77718947676160L);
    AlipayTransaction alipayTransaction = AlipayTransaction.builder()
            .payment(payment)
            .outTradeNo(payment.getOrderNo())
            .totalAmount(BigDecimal.valueOf(payment.getPayMoney()).divide(BigDecimal.valueOf(100)))
            .build();
    given(this.paymentRepository.findByIdAndOrderNo(anyLong(), anyString()))
            .willReturn(Optional.of(payment));
    given(this.alipayTransactionRepository.findByPaymentId(eq(payment.getId())))
            .willReturn(Optional.of(alipayTransaction));
    // when
    RefundCommand command = RefundCommand.builder()
            .refundAmount(payment.getPayMoney())
            .refundChannel(Payment.PayChannel.ALIPAY)
            .paymentId(payment.getId())
            .orderNo(payment.getOrderNo())
            .operatorId(1L)
            .build();
    WebTestClient.ResponseSpec responseSpec = this.post("/refunds", command);
    // then
    responseSpec
            .expectStatus().isOk()
            .expectBody(RefundResult.class)
            .value(value -> {
                assertThat(value.getOrderNo()).isEqualTo(command.getOrderNo());
                assertThat(value.getPayAmount()).isEqualTo(1);
                assertThat(value.getPaymentId()).isEqualTo(command.getPaymentId());
                assertThat(value.getRefundAmount()).isEqualTo(command.getRefundAmount());
                assertThat(value.getRefundChannel()).isEqualTo(command.getRefundChannel());
            })
            // 自动生成 API 文档
            .consumeWith(this.commonDocumentation());
    verify(this.orderClient, times(1)).notifyRefund(any());
}

/**
 * 退款
 *
 * @return
 */
@PostMapping("/refunds")
public Mono<RefundResult> refund(@RequestBody RefundCommand command) {
    command.verify();
    if (command.isAlipay()) {
        return Mono.just(this.alipayRefundService.refund(command));
    }
    throw new PayException("暂不支持该退款方式");
}

@Slf4j
@Component
public class AlipayClient {
    /**
     * 退款
     * 详细信息请看官文文档 https://docs.open.alipay.com/api_1/alipay.trade.refund/
     *
     * @param orderNo      订单编号
     * @param refundAmount 退款金额
     * @return
     * @throws AlipayApiException
     */
    public AlipayTradeRefundResponse refund(String orderNo, BigDecimal refundAmount) throws AlipayApiException {
        AlipayTradeRefundRequest request = new AlipayTradeRefundRequest();
        AlipayTradeRefundModel model = new AlipayTradeRefundModel();
        model.setOutTradeNo(orderNo);
        model.setRefundAmount(refundAmount.toString());
        request.setBizModel(model);
        return this.alipayClient.certificateExecute(request);
    }
}
复制代码

这里有两个地方需要注意:

  1. 公钥方式使用 alipayClient.certificateExecute(request) 发起退款请求,而不是 alipayClient.execute(request)**。
  2. 退款是同步请求,推荐不设置 notify_url, 当然这得根据实际情况来做决定。

微信支付

微信支付集成难度不高,也没那么多配置,但是文档写得不太用心,而且没有提供服务端 SDK,集成的各种问题由开发者自己承担。

微信:爱用不用 傲娇脸.gif。
开发者:不用也得用 委屈脸.gif。
复制代码

重要配置

  • APPID(应用ID,微信开放平台审核通过的应用APPID(请登录open.weixin.qq.com查看,注意与公众号的APPID不同))
  • mch-id(商户号ID,微信支付分配的商户号)
  • api-secret-key(API 密钥)
  • notify-url(支付结果 异步通知 url)
  • cert-file-path(微信支付接口中,涉及资金回滚的接口会使用到API证书,包括退款、撤销接口,详情请看 pay.weixin.qq.com/wiki/doc/ap…

APP 支付流程

微信APP支付流程在微信支付开发文档中有描述,流程上跟支付宝没多大区别,详情请自行参考官网的一手资料。

协议规则

其中要注意的是,微信支付的请求和返回数据都为 XML 格式,根节点名为 xml

验签工具

微信提供了一个图形界面的验签工具规则也很简单,请自行阅读官方文档。

统一下单

详情请款 统一下单

任务列表:

  1. 重要配置
  2. 构建 加签 工具类
  3. 构建 微信支付 工具类
  4. 构建 XML <=> 自定义对象 的装换工具类
  5. 定义下单接口
  6. 编写测试
@Test
void should_return_wechat_place_order_info() throws Exception {
    // given
    PlaceOrderCommand command = PlaceOrderCommand.builder()
            .userId(2L)
            .userName("Tester")
            .goodsId(1L)
            .orderNo(RandomStringUtils.randomNumeric(20))
            .subject("外套")
            .totalAmount(BigDecimal.ONE)
            .payChannel(PayChannel.WECHAT)
            .build();
    // when
    WebTestClient.ResponseSpec responseSpec = this.post("/orders", command);
    // then
    responseSpec
            .expectStatus().isOk()
            .expectBody(new ParameterizedTypeReference<PlaceOrderResult<WechatOrderInfo>>() {})
            .value(actualPaymentOrder -> {
                assertThat(actualPaymentOrder.getId()).isNotNull();
                assertThat(actualPaymentOrder.getPayChannel()).isEqualTo(Payment.PayChannel.WECHAT);
                assertThat(actualPaymentOrder.getOrderInfo()).isNotNull();
                assertThat(actualPaymentOrder.getOrderInfo().getAppid()).isNotBlank();
                assertThat(actualPaymentOrder.getOrderInfo().getNoncestr()).isNotBlank();
                assertThat(actualPaymentOrder.getOrderInfo().getSign()).isNotBlank();
                assertThat(actualPaymentOrder.getOrderInfo().getPartnerid()).isNotBlank();
                assertThat(actualPaymentOrder.getOrderInfo().getPrepayid()).isNotBlank();
                assertThat(actualPaymentOrder.getOrderInfo().getPackageValue()).isEqualTo("Sign=WXPay");
                assertThat(actualPaymentOrder.getOrderInfo().getTimestamp()).isNotNull();
                // other assert
            })
            // 自动生成 API 文档
            .consumeWith(this.commonDocumentation());
}

/**
 * 下单(生成支付订单)
 *
 * @return
 */
@PostMapping("/orders")
public Mono<PlaceOrderResult> placeOrder(@RequestBody PlaceOrderCommand command) {
    command.verify();
    if (command.isAlipay()) {
        return Mono.just(this.alipayService.placeOrder(command));
    }
    if (command.isWechat()) {
        return this.wechatPayService.placeOrder(command);
    }
    throw new PayException("暂不开放该支付方式");
}


public class WechatSigner {

    public static String sign(Map<String, Object> map, String apiSecretKey) {
        SortedSet<String> sortedSet = new TreeSet<>(map.keySet());
        StringBuilder urlParams = new StringBuilder();
        for (String key : sortedSet) {
            urlParams.append(key)
                    .append("=")
                    .append(map.get(key).toString())
                    .append("&");
        }
        urlParams.append("key=").append(apiSecretKey);
        return DigestUtils.md5Hex(urlParams.toString())
                .toUpperCase();
    }

    public static <T> String sign(T jsonObject, String apiSecretKey) {
        Map<String, Object> map = toMap(jsonObject);
        return sign(map, apiSecretKey);
    }

    private static <T> Map<String, Object> toMap(T jsonObject) {
        String jsonString = JSON.toJSONString(jsonObject);
        return JSON.parseObject(jsonString, new TypeReference<HashMap<String, Object>>() {}.getType());
    }
}

@Slf4j
public class XmlUtils {

    public static <T> T parse2Object(String xml, Class<T> claz) {
        try {
            return JAXB.unmarshal(CharSource.wrap(xml).openStream(), claz);
        } catch (IOException e) {
            log.error("解析 xml 发生异常", e);
            throw new ParseXmlException("解析 xml 发生异常");
        }
    }

    public static <T> String toXmlString(T jsonObject) {
        StringWriter xml = new StringWriter();
        JAXB.marshal(jsonObject, xml);
        return xml.toString();
    }
}


@Slf4j
@Component
public class WechatClient {

    private final HttpClient wechatPayHttpClient;
    private final WechatPayProperties wechatPayProperties;

    public WechatClient(WechatPayProperties wechatPayProperties) {
        this.wechatPayProperties = wechatPayProperties;
        this.wechatPayHttpClient = HttpClient.create(ConnectionProvider.fixed("wechat-unifiedorder", 10))
                .baseUrl("https://api.mch.weixin.qq.com");
    }

    public Mono<WechatUnifiedOrderResponse> postUnifiedOrder(PlaceOrderCommand command, Long paymentId) {
        String requestBody = this.generateUnifiedOrderBody(command, paymentId);
        return this.wechatPayHttpClient
                .post()
                .uri("/pay/unifiedorder")
                .send((req, out) -> out.sendString(Mono.just(requestBody)))
                .responseContent()
                .aggregate()
                .asString()
                .onErrorResume(ex -> Mono.just(ex.getMessage()))
                .flatMap(responseXml -> Mono.just(XmlUtils.parse2Object(responseXml, WechatUnifiedOrderResponse.class)));
    }

    private SslContext createSslContext() throws KeyStoreException, IOException, NoSuchAlgorithmException, CertificateException, UnrecoverableKeyException {
        KeyStore keyStore = KeyStore.getInstance("PKCS12");
        File apiCertFile = ResourceUtils.getFile(this.wechatPayProperties.getCertFilePath());
        keyStore.load(new FileInputStream(apiCertFile), this.wechatPayProperties.getMchId().toCharArray());
        // Set up key manager factory to use our key store
        KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
        keyManagerFactory.init(keyStore, this.wechatPayProperties.getMchId().toCharArray());
        return SslContextBuilder.forClient()
                .keyManager(keyManagerFactory)
                .build();
    }

    /**
     * 生成 调用 微信统一下单接口 request body
     *
     * @param command
     * @param paymentId
     * @return
     */
    private String generateUnifiedOrderBody(PlaceOrderCommand command, Long paymentId) {
        var wechatPlaceOrderRequest = WechatPlaceOrderRequest.builder()
                .appid(this.wechatPayProperties.getAppid())
                .mchId(this.wechatPayProperties.getMchId())
                .nonceStr(UUID.randomUUID().toString().replaceAll("-", ""))
                .body(command.getSubject())
                .outTradeNo(command.getOrderNo())
                .totalFee(command.getTotalAmount().longValue())
                .spbillCreateIp(this.getIpV4())
                .notifyUrl(this.wechatPayProperties.getNotifyUrl())
                .tradeType(this.wechatPayProperties.getTradeType())
                .attach(JSON.toJSONString(Attach.builder().paymentId(paymentId).build()))
                .build();
        wechatPlaceOrderRequest.setSign(WechatSigner.sign(wechatPlaceOrderRequest, this.wechatPayProperties.getApiSecretKey()));

        return XmlUtils.toXmlString(wechatPlaceOrderRequest);
    }

    private String getIpV4() {
        String ip = null;
        try {
            Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
            while (interfaces.hasMoreElements()) {
                NetworkInterface iface = interfaces.nextElement();
                // filters out 127.0.0.1 and inactive interfaces
                if (iface.isLoopback() || !iface.isUp()) {
                    continue;
                }
                Enumeration<InetAddress> addresses = iface.getInetAddresses();
                while (addresses.hasMoreElements()) {
                    InetAddress addr = addresses.nextElement();
                    if (addr instanceof Inet6Address) {
                        continue;
                    }
                    ip = addr.getHostAddress();
                }
            }
        } catch (SocketException e) {
            throw new RuntimeException(e);
        }
        return ip;
    }
}

@Setter
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@XmlAccessorType(XmlAccessType.FIELD)
@XmlRootElement(name = "xml")
public class WechatPlaceOrderRequest {

    /**
     * 应用ID
     * 微信开放平台审核通过的应用APPID(请登录open.weixin.qq.com查看,注意与公众号的APPID不同)
     */
    @NotBlank
    @XmlElement(name = "appid")
    private String appid;

    /**
     * 商户号
     * 微信支付分配的商户号
     */
    @NotBlank
    @XmlElement(name = "mch_id")
    @JSONField(name = "mch_id")
    private String mchId;

    /**
     * 随机字符串,不长于32位(https://pay.weixin.qq.com/wiki/doc/api/app/app.php?chapter=4_3)
     */
    @NotBlank
    @XmlElement(name = "nonce_str")
    @JSONField(name = "nonce_str")
    private String nonceStr;

    /**
     * 签名(https://pay.weixin.qq.com/wiki/doc/api/app/app.php?chapter=4_3)
     */
    @NotBlank
    @XmlElement(name = "sign")
    @JSONField(name = "sign")
    private String sign;

    /**
     * 商品描述
     * 商品描述交易字段格式根据不同的应用场景按照以下格式:
     * APP——需传入应用市场上的APP名字-实际商品名称,天天爱消除-游戏充值。
     */
    @NotBlank
    @XmlElement(name = "body")
    @JSONField(name = "body")
    private String body;

    /**
     * 商户订单号
     * 商户系统内部订单号,要求32个字符内,只能是数字、大小写字母_-|*且在同一个商户号下唯一。详见商户订单号
     */
    @NotBlank
    @XmlElement(name = "out_trade_no")
    @JSONField(name = "out_trade_no")
    private String outTradeNo;

    /**
     * 总金额
     * 订单总金额,单位:分
     * 详见支付金额(https://pay.weixin.qq.com/wiki/doc/api/app/app.php?chapter=4_2)
     */
    @NotBlank
    @XmlElement(name = "total_fee")
    @JSONField(name = "total_fee")
    private Long totalFee;

    /**
     * 终端IP
     * 支持IPV4和IPV6两种格式的IP地址。调用微信支付API的机器IP
     * 示例:123.12.12.123
     */
    @NotBlank
    @XmlElement(name = "spbill_create_ip")
    @JSONField(name = "spbill_create_ip")
    private String spbillCreateIp;

    /**
     * 通知地址
     * 接收微信支付异步通知回调地址,通知url必须为直接可访问的url,不能携带参数。
     */
    @NotBlank
    @XmlElement(name = "notify_url")
    @JSONField(name = "notify_url")
    private String notifyUrl;

    /**
     * 支付类型
     * 示例:APP
     */
    @NotBlank
    @XmlElement(name = "trade_type")
    @JSONField(name = "trade_type")
    private String tradeType;

    /**
     * 附加数据
     */
    @XmlElement(name = "attach")
    @JSONField(name = "attach")
    private String attach;

    public Attach getAttach() {
        return JSON.parseObject(this.attach, Attach.class);
    }
}

@Setter
@Getter
@XmlAccessorType(XmlAccessType.FIELD)
@XmlRootElement(name = "xml")
public class WechatUnifiedOrderResponse {

    /**
     * SUCCESS/FAIL
     * <p>
     * 此字段是通信标识,非交易标识,交易是否成功需要查看result_code来判断
     */
    @NotBlank
    @XmlElement(name = "return_code")
    private String returnCode;

    /**
     * 返回信息,如非空,为错误原因
     * <p>
     * 签名失败
     * <p>
     * 参数格式校验错误
     */
    @Nullable
    @XmlElement(name = "return_msg")
    private String returnMsg;
    /**
     * 应用APPID	是	String(32)	wx8888888888888888	调用接口提交的应用ID
     */
    @NotBlank
    private String appid;
    /**
     * 商户号	是	String(32)	1900000109	调用接口提交的商户号
     */
    @NotBlank
    @XmlElement(name = "mch_id")
    private String mchId;
    /**
     * 设备号	否	String(32)	013467007045764	调用接口提交的终端设备号,
     */
    @NotBlank
    @XmlElement(name = "device_info")
    private String deviceInfo;
    /**
     * 随机字符串	是	String(32)	5K8264ILTKCH16CQ2502SI8ZNMTM67VS	微信返回的随机字符串
     */
    @NotBlank
    @XmlElement(name = "nonce_str")
    private String nonceStr;
    /**
     * 签名	是	String(32)	C380BEC2BFD727A4B6845133519F3AD6	微信返回的签名,详见签名算法
     */
    @NotBlank
    private String sign;
    /**
     * 业务结果	是	String(16)	SUCCESS	SUCCESS/FAIL
     */
    @NotBlank
    @XmlElement(name = "result_code")
    private String resultCode;
    /**
     * 错误代码	否	String(32)	SYSTEMERROR	详细参见第6节错误列表
     */
    @NotBlank
    @XmlElement(name = "err_code")
    private String errCode;
    /**
     * 错误代码描述	否	String(128)	系统错误	错误返回的信息描述
     */
    @NotBlank
    @XmlElement(name = "err_code_des")
    private String errCodeDes;

    /**
     * 预支付交易会话ID String(32)	是	WX1217752501201407033233368018	微信返回的支付交易会话ID
     */
    @NotBlank
    @XmlElement(name = "prepay_id")
    private String prepayId;

    /**
     * 支付类型
     */
    @NotBlank
    @XmlElement(name = "trade_type")
    private String tradeType;
}

@Setter
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Component
@ConfigurationProperties("pay.wechat")
public class WechatPayProperties {

    /**
     * 应用ID
     * 微信开放平台审核通过的应用APPID(请登录open.weixin.qq.com查看,注意与公众号的APPID不同)
     */
    private String appid;

    /**
     * 商户号
     * 微信支付分配的商户号
     */
    private String mchId;

    /**
     * API 密钥
     */
    private String apiSecretKey;

    /**
     * 通知地址
     * 接收微信支付异步通知回调地址,通知url必须为直接可访问的url,不能携带参数。
     */
    private String notifyUrl;

    /**
     * 支付类型
     */
    private String tradeType;

    /**
     * 证书文件路径
     */
    private String certFilePath;

    public String getCertFilePath() {
        return FileUtils.getAbsolutePath(this.certFilePath);
    }
}

pay:
  wechat:
    # 应用ID,微信开放平台审核通过的应用APPID(请登录open.weixin.qq.com查看,注意与公众号的APPID不同)
    appid: 
    # 商户号ID,微信支付分配的商户号
    mch-id: 
    # API 密钥
    api-secret-key: 
    # 支付结果 异步通知 url
    notify-url: 
    # 支付类型
    trade-type: APP
    # 微信支付接口中,涉及资金回滚的接口会使用到API证书,包括退款、撤销接口,详情请看 https://pay.weixin.qq.com/wiki/doc/api/app/app.php?chapter=4_3
    cert-file-path: 
复制代码

这样下单就完成了,没什么难度,就是繁琐,需要自己封装 xml 的处理、加签 和 wechat client。如果使用 reactor netty http client,还需要了解 reactor netty 相关的 API。

支付结果通知

支付结果通知的文档已经描述大部分信息,读者请自定阅读。

任务列表:

  • 定义接收微信支付的回调接口
  • 定义接收微信支付的回调入参对象
  • 从请求中读取 xml 并转换为入参对象
  • 验签
  • 开发商家业务逻辑
  • 回调并验证通过,返回成功的 xml 格式给微信支付后台
  • 验证不通过,返回失败的 xml 格式给微信支付后台
  • 编写 API 测试 模拟支付结果通知回调

@Test
void should_response_failure_when_the_sign_is_incorrect() throws Exception {
    // given
    String xml = "<xml>" +
            "<appid><![CDATA[xxx]]></appid>\n" +
            "<attach><![CDATA[{\"paymentId\":75321692864512}]]></attach>\n" +
            "<bank_type><![CDATA[CFT]]></bank_type>\n" +
            "<cash_fee><![CDATA[1]]></cash_fee>\n" +
            "<fee_type><![CDATA[CNY]]></fee_type>\n" +
            "<is_subscribe><![CDATA[N]]></is_subscribe>\n" +
            "<mch_id><![CDATA[1532945971]]></mch_id>\n" +
            "<nonce_str><![CDATA[fdafasd0e037462a87bc2f646839d2be]]></nonce_str>\n" +
            "<openid><![CDATA[oDYbhwXD0iuRCffLETbrqknupCQQ]]></openid>\n" +
            "<out_trade_no><![CDATA[75321441763328]]></out_trade_no>\n" +
            "<result_code><![CDATA[SUCCESS]]></result_code>\n" +
            "<return_code><![CDATA[SUCCESS]]></return_code>\n" +
            "<sign><![CDATA[66C94B1E45BE3B61ED27428DE5F2C171]]></sign>\n" +
            "<time_end><![CDATA[20191024194552]]></time_end>\n" +
            "<total_fee>1</total_fee>\n" +
            "<trade_type><![CDATA[APP]]></trade_type>\n" +
            "<transaction_id><![CDATA[4200000409201910247411505829]]></transaction_id>\n" +
            "</xml>";
    // when
    WebTestClient.ResponseSpec responseSpec = this.client.post()
            .uri("/wechat/notifications/payment")
            .body(BodyInserters.fromObject(xml))
            .header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_XML_VALUE)
            .exchange();
    // then
    responseSpec
            .expectStatus().isOk()
            .expectBody(String.class)
            .isEqualTo("<xml>\n" +
                    "  <return_code><![CDATA[FAILURE]]></return_code>\n" +
                    "  <return_msg><![CDATA[FAILURE]]></return_msg>\n" +
                    "</xml>")
            // 自动生成 API 文档
            .consumeWith(this.commonDocumentation());
    // other assert
}


@Test
void should_response_success_when_the_sign_is_correct() throws Exception {
    // given
    Payment payment = Payment.builder()
            .buyerId(1L)
            .buyerName("Tester")
            .state(State.WAIT_BUYER_PAY)
            .orderNo("75321441763328")
            .payChannel(PayChannel.WECHAT)
            .payMoney(100)
            .build();
    payment.setId(75321692864512L);
    given(this.paymentRepository.findById(eq(payment.getId()))).willReturn(Optional.of(payment));
    doReturn(WechatTransaction.builder().payment(payment).build())
            .when(this.wechatTransactionRepository)
            .save(any());

    String xml = "<xml>" +
            "<appid><![CDATA[12343123123]]></appid>\n" +
            "<attach><![CDATA[{\"paymentId\":75321692864512}]]></attach>\n" +
            "<bank_type><![CDATA[CFT]]></bank_type>\n" +
            "<cash_fee><![CDATA[1]]></cash_fee>\n" +
            "<fee_type><![CDATA[CNY]]></fee_type>\n" +
            "<is_subscribe><![CDATA[N]]></is_subscribe>\n" +
            "<mch_id><![CDATA[1532945971]]></mch_id>\n" +
            "<nonce_str><![CDATA[84a58150e037462a87bc2f646839d2be]]></nonce_str>\n" +
            "<openid><![CDATA[oDYbhwXD0iuRCffLETbrqknupCQQ]]></openid>\n" +
            "<out_trade_no><![CDATA[75321441763328]]></out_trade_no>\n" +
            "<result_code><![CDATA[SUCCESS]]></result_code>\n" +
            "<return_code><![CDATA[SUCCESS]]></return_code>\n" +
            "<sign><![CDATA[66C94B1E45BE3B61ED27428DE5F2C171]]></sign>\n" +
            "<time_end><![CDATA[20191024194552]]></time_end>\n" +
            "<total_fee>1</total_fee>\n" +
            "<trade_type><![CDATA[APP]]></trade_type>\n" +
            "<transaction_id><![CDATA[4200000409201910247411505829]]></transaction_id>\n" +
            "</xml>";
    // when
    WebTestClient.ResponseSpec responseSpec = this.client.post()
            .uri("/wechat/notifications/payment")
            .body(BodyInserters.fromObject(xml))
            .header(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_XML_VALUE)
            .exchange();
    // then
    responseSpec
            .expectStatus().isOk()
            .expectBody(String.class)
            .isEqualTo("<xml>\n" +
                    "  <return_code><![CDATA[SUCCESS]]></return_code>\n" +
                    "  <return_msg><![CDATA[OK]]></return_msg>\n" +
                    "</xml>")
            // 自动生成 API 文档
            .consumeWith(this.commonDocumentation());
    Payment dbPayment = this.paymentRepository.findById(payment.getId()).get();
    assertThat(dbPayment.getState()).isEqualTo(Payment.State.SUCCESS);
    verify(this.orderClient, times(1)).notifyPaid(any());
    // other assert
}

/**
 * 微信支付 支付结果变更 回调接口
 *
 * @param request
 */
@PostMapping("/wechat/notifications/payment")
public Mono<String> receiveWechatNotify(ServerHttpRequest request) {
    return this.readXmlFrom(request)
            .map(xml -> {
                ReceiveWechatNotifyCommand command = XmlUtils.parse2Object(xml, ReceiveWechatNotifyCommand.class);
                if (!command.isSuccess()) {
                    log.error("微信支付回调通知报错 {} {}", command.getErrCodeDes(), JSON.toJSONString(command));
                    return this.sendWechatPayNotifyFailResponse();
                }
                if (!this.wechatNotifyHandler.verifySignature(command)) {
                    log.error("微信回调接口 签名验证失败 {}", JSON.toJSONString(command));
                    return this.sendWechatPayNotifyFailResponse();
                }
                this.wechatNotifyHandler.receiveNotify(command);
                return this.sendWechatPayNotifySuccessResponse();
            });
}

public boolean verifySignature(ReceiveWechatNotifyCommand command) {
    Map<String, Object> map = JSON.parseObject(JSON.toJSONString(command), new TypeReference<HashMap<String, Object>>() {}.getType());
    // sign 不参与签名
    map.remove("sign");
    String sign = WechatSigner.sign(map, this.wechatPayProperties.getApiSecretKey());
    return StringUtils.equals(command.getSign(), sign);
}

private Mono<String> readXmlFrom(ServerHttpRequest request) {
    return request.getBody()
            .flatMap(dataBuffer -> Mono.just(String.valueOf(StandardCharsets.UTF_8.decode(dataBuffer.asByteBuffer()))))
            .reduce(String::concat);
}

private String sendWechatPayNotifyFailResponse() {
    return "<xml>\n" +
            "  <return_code><![CDATA[FAILURE]]></return_code>\n" +
            "  <return_msg><![CDATA[FAILURE]]></return_msg>\n" +
            "</xml>";
}

private String sendWechatPayNotifySuccessResponse() {
    return "<xml>\n" +
            "  <return_code><![CDATA[SUCCESS]]></return_code>\n" +
            "  <return_msg><![CDATA[OK]]></return_msg>\n" +
            "</xml>";
}

@Setter
@Getter
@XmlAccessorType(XmlAccessType.FIELD)
@XmlRootElement(name = "xml")
public class ReceiveWechatNotifyCommand {

    /**
     * 返回状态码	是	String(16)	SUCCESS
     */
    @NotBlank
    @XmlElement(name = "return_code")
    @JSONField(name = "return_code")
    private String returnCode;

    /**
     * 返回信息	是	String(128)	OK
     */
    @NotBlank
    @XmlElement(name = "return_msg")
    @JSONField(name = "return_msg")
    private String returnMsg;

    /**
     * 应用ID	 String(32)	wx8888888888888888	微信开放平台审核通过的应用APPID
     */
    private String appid;
    /**
     * 商户号	 String(32)	1900000109	微信支付分配的商户号
     */
    @XmlElement(name = "mch_id")
    @JSONField(name = "mch_id")
    private String mchId;
    /**
     * 随机字符串	 String(32)	5K8264ILTKCH16CQ2502SI8ZNMTM67VS	随机字符串,不长于32位
     */
    @XmlElement(name = "nonce_str")
    @JSONField(name = "nonce_str")
    private String nonceStr;
    /**
     * 签名	 String(32)	C380BEC2BFD727A4B6845133519F3AD6	签名,详见签名算法
     */
    private String sign;
    /**
     * 业务结果	 String(16)	SUCCESS	SUCCESS/FAIL
     */
    @XmlElement(name = "result_code")
    @JSONField(name = "result_code")
    private String resultCode;
    /**
     * 错误代码	 String(32)	SYSTEMERROR	错误返回的信息描述
     */
    @XmlElement(name = "err_code")
    @JSONField(name = "err_code")
    private String errCode;
    /**
     * 错误代码描述	 String(128)	系统错误	错误返回的信息描述
     */
    @XmlElement(name = "err_code_des")
    @JSONField(name = "err_code_des")
    private String errCodeDes;
    /**
     * 用户标识	 String(128)	wxd930ea5d5a258f4f	用户在商户appid下的唯一标识
     */
    private String openid;
    /**
     * 是否关注公众账号	 String(1)	Y	用户是否关注公众账号,Y-关注,N-未关注
     */
    @XmlElement(name = "is_subscribe")
    @JSONField(name = "is_subscribe")
    private String isSubscribe;
    /**
     * 交易类型	 String(16)	APP	APP
     */
    @XmlElement(name = "trade_type")
    @JSONField(name = "trade_type")
    private String tradeType;
    /**
     * 付款银行	 String(16)	CMC	银行类型,采用字符串类型的银行标识,银行类型见银行列表
     */
    @XmlElement(name = "bank_type")
    @JSONField(name = "bank_type")
    private String bankType;
    /**
     * 总金额	 Int	100	订单总金额,单位为分
     */
    @XmlElement(name = "total_fee")
    @JSONField(name = "total_fee")
    private Long totalFee;
    /**
     * 货币种类	 String(8)	CNY	货币类型,符合ISO4217标准的三位字母代码,默认人民币:CNY,其他值列表详见货币类型
     */
    @XmlElement(name = "fee_type")
    @JSONField(name = "fee_type")
    private String feeType;
    /**
     * 现金支付金额	 Int	100	现金支付金额订单现金支付金额,详见支付金额
     */
    @XmlElement(name = "cash_fee")
    @JSONField(name = "cash_fee")
    private Long cashFee;
    /**
     * 现金支付货币类型	 String(16)	CNY	货币类型,符合ISO4217标准的三位字母代码,默认人民币:CNY,其他值列表详见货币类型
     */
    @XmlElement(name = "cash_fee_type")
    @JSONField(name = "cash_fee_type")
    private String cashFeeType;
    /**
     * 代金券金额	 Int	10	代金券或立减优惠金额<=订单总金额,订单总金额-代金券或立减优惠金额=现金支付金额,详见支付金额
     */
    @XmlElement(name = "coupon_fee")
    @JSONField(name = "coupon_fee")
    private Long couponFee;
    /**
     * 微信支付订单号	 String(32)	1217752501201407033233368018	微信支付订单号
     */
    @XmlElement(name = "transaction_id")
    @JSONField(name = "transaction_id")
    private String transactionId;
    /**
     * 商户订单号	 String(32)	1212321211201407033568112322	商户系统内部订单号,要求32个字符内,只能是数字、大小写字母_-|*@ ,且在同一个商户号下唯一。
     */
    @XmlElement(name = "out_trade_no")
    @JSONField(name = "out_trade_no")
    private String outTradeNo;
    /**
     * 商家数据包	 String(128)	123456	商家数据包,原样返回
     */
    private String attach;
    /**
     * 支付完成时间	 String(14)	20141030133525	支付完成时间,格式为yyyyMMddHHmmss,如2009年12月25日9点10分10秒表示为20091225091010。其他详见时间规则
     */
    @XmlElement(name = "time_end")
    @JSONField(name = "time_end")
    private String timeEnd;

    public WechatTransaction.Attach getAttach() {
        return JSON.parseObject(this.attach, WechatTransaction.Attach.class);
    }

    @JSONField(serialize = false)
    public boolean isSuccess() {
        return "SUCCESS".equals(this.getReturnCode())
                && "SUCCESS".equals(this.getResultCode());
    }
}
复制代码

这里要注意的地方是,API 层的入参需要使用 org.springframework.http.server.reactive.ServerHttpRequest request 把当前的请求注入进来,这样才能读到请求消息体并转换为 xml。

申请退款

申请退款的文档已经描述大部分信息,读者请自定阅读。

任务列表

  • 配置 API 证书
  • 定义微信退款接口 入参
  • 定义微信退款接口 返回值
  • 定义微信退款 API
  • 定义商家后端 API
  • 编写测试
@Test
void should_refund_from_wechat_pay() throws Exception {
    // given
    Payment payment = Payment.builder()
            .state(Payment.State.SUCCESS)
            .orderNo("77718857416704")
            .payMoney(1L)
            .payChannel(PayChannel.WECHAT)
            .build();
    payment.setId(77718947676160L);
    WechatTransaction wechatTransaction = WechatTransaction.builder()
            .payment(payment)
            .outTradeNo(payment.getOrderNo())
            .build();
    given(this.paymentRepository.findByIdAndOrderNo(anyLong(), anyString()))
            .willReturn(Optional.of(payment));
    given(this.wechatTransactionRepository.findByPaymentId(eq(payment.getId())))
            .willReturn(Optional.of(wechatTransaction));
    // when
    RefundCommand command = RefundCommand.builder()
            .operatorId(1L)
            .orderNo(payment.getOrderNo())
            .paymentId(payment.getId())
            .refundAmount(1L)
            .refundChannel(WECHAT)
            .build();
    doReturn(this.createWechatRefundSuccessResult(payment.getPayMoney(), command.getRefundAmount(), command.getOrderNo()))
            .when(this.wechatClient)
            .postRefund(any(), eq(payment.getPayMoney()));
    WebTestClient.ResponseSpec responseSpec = this.post("/refunds", command);
    // then
    responseSpec
            .expectStatus().isOk()
            .expectBody(RefundResult.class)
            .value(value -> {
                assertThat(value.getOrderNo()).isEqualTo(payment.getOrderNo());
                assertThat(value.getPayAmount()).isEqualTo(1);
                assertThat(value.getPaymentId()).isEqualTo(payment.getId());
                assertThat(value.getRefundAmount()).isEqualTo(payment.getRefundMoney());
                assertThat(value.getRefundChannel()).isEqualTo(payment.getPayChannel());
            })
            // 自动生成 API 文档
            .consumeWith(this.commonDocumentation());
    verify(this.orderClient, times(1)).notifyRefund(any());
    // other assert
}

/**
 * 退款
 *
 * @return
 */
@PostMapping("/refunds")
public Mono<RefundResult> refund(@RequestBody RefundCommand command) {
    command.verify();
    if (command.isWechat()) {
        return this.wechayRefundService.refund(command);
    }
    throw new PayException("暂不支持该退款方式");
}

@Slf4j
@Component
public class WechatClient {

    private final HttpClient secureHttpClient;
    private final WechatPayProperties wechatPayProperties;

    public WechatClient(WechatPayProperties wechatPayProperties) {
        this.wechatPayProperties = wechatPayProperties;
        this.secureHttpClient = HttpClient.create(ConnectionProvider.fixed("secure-http-client", 5))
                .secure(spec -> {
                    try {
                        spec.sslContext(this.createSslContext());
                    } catch (Exception e) {
                        log.warn("Unable to set SSL Context", e);
                    }
                })
                .baseUrl("https://api.mch.weixin.qq.com");
    }

    public Mono<WechatRefundResponse> postRefund(RefundCommand command, Long orderTotalFee) {
        String requestBody = this.generateRefundRequestBody(command, orderTotalFee);
        return this.secureHttpClient
                .post()
                .uri("/secapi/pay/refund")
                .send((req, out) -> out.sendString(Mono.just(requestBody)))
                .responseContent()
                .aggregate()
                .asString()
                .onErrorResume(ex -> Mono.just(ex.getMessage()))
                .flatMap(responseXml -> Mono.just(XmlUtils.parse2Object(responseXml, WechatRefundResponse.class)));
    }

    /**
     * 生成 调用 微信退款接口 request body
     *
     * @param command
     * @return
     */
    private String generateRefundRequestBody(RefundCommand command, Long orderTotalFee) {
        var wechatRefundRequest = WechatRefundRequest.builder()
                .appid(this.wechatPayProperties.getAppid())
                .mchId(this.wechatPayProperties.getMchId())
                .nonceStr(UUID.randomUUID().toString().replaceAll("-", ""))
                .outTradeNo(command.getOrderNo())
                .outRefundNo(command.getOrderNo())
                .refundFee(command.getRefundAmount())
                .totalFee(orderTotalFee)
                .build();
        wechatRefundRequest.setSign(WechatSigner.sign(wechatRefundRequest, this.wechatPayProperties.getApiSecretKey()));

        return XmlUtils.toXmlString(wechatRefundRequest);
    }
}

@Setter
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@XmlAccessorType(XmlAccessType.FIELD)
@XmlRootElement(name = "xml")
public class WechatRefundRequest {
    /**
     * 应用ID
     * 微信开放平台审核通过的应用APPID(请登录open.weixin.qq.com查看,注意与公众号的APPID不同)
     */
    @NotBlank
    @XmlElement(name = "appid")
    private String appid;

    /**
     * 商户号
     * 微信支付分配的商户号
     */
    @NotBlank
    @XmlElement(name = "mch_id")
    @JSONField(name = "mch_id")
    private String mchId;

    /**
     * 随机字符串,不长于32位(https://pay.weixin.qq.com/wiki/doc/api/app/app.php?chapter=4_3)
     */
    @NotBlank
    @XmlElement(name = "nonce_str")
    @JSONField(name = "nonce_str")
    private String nonceStr;

    /**
     * 签名(https://pay.weixin.qq.com/wiki/doc/api/app/app.php?chapter=4_3)
     */
    @NotBlank
    @XmlElement(name = "sign")
    @JSONField(name = "sign")
    private String sign;

    /**
     * 商户订单号
     * 商户系统内部订单号,要求32个字符内,只能是数字、大小写字母_-|*@ ,且在同一个商户号下唯一
     */
    @NotBlank
    @XmlElement(name = "out_trade_no")
    @JSONField(name = "out_trade_no")
    private String outTradeNo;

    /**
     * 商户退款单号
     * 商户系统内部的退款单号,商户系统内部唯一,只能是数字、大小写字母_-|*@ ,同一退款单号多次请求只退一笔。
     */
    @NotBlank
    @XmlElement(name = "out_refund_no")
    @JSONField(name = "out_refund_no")
    private String outRefundNo;

    /**
     * 总金额
     * 订单总金额,单位:分
     * 详见支付金额(https://pay.weixin.qq.com/wiki/doc/api/app/app.php?chapter=4_2)
     */
    @NotBlank
    @XmlElement(name = "total_fee")
    @JSONField(name = "total_fee")
    private Long totalFee;

    /**
     * 退款金额
     * 退款总金额,订单总金额,单位为分,只能为整数
     * 详见支付金额(https://pay.weixin.qq.com/wiki/doc/api/app/app.php?chapter=4_2)
     */
    @NotBlank
    @XmlElement(name = "refund_fee")
    @JSONField(name = "refund_fee")
    private Long refundFee;
}

@Setter
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
@XmlAccessorType(XmlAccessType.FIELD)
@XmlRootElement(name = "xml")
public class WechatRefundResponse {

    /**
     * 返回状态码
     * 是	String(16)	SUCCESS	SUCCESS/FAIL
     */
    @NotBlank
    @XmlElement(name = "return_code")
    private String returnCode;
    /**
     * 返回信息
     * 否	String(128)	签名失败 返回信息,如非空,为错误原因 签名失败 参数格式校验错误
     */
    @NotBlank
    @XmlElement(name = "return_msg")
    private String returnMsg;
    /**
     * 业务结果
     * 是	String(16)	SUCCESS SUCCESS/FAIL SUCCESS退款申请接收成功,结果通过退款查询接口查询FAIL 提交业务失败
     */
    @NotBlank
    @XmlElement(name = "result_code")
    private String resultCode;

    /**
     * 错误代码
     * 否	String(32)	SYSTEMERROR	列表详见错误码列表
     */
    @NotBlank
    @XmlElement(name = "err_code")
    private String errCode;

    /**
     * 错误代码描述
     * 否	String(128)	系统超时	结果信息描述
     */
    @XmlElement(name = "err_code_des")
    private String errCodeDes;

    /**
     * 公众账号ID
     * 是	String(32)	wx8888888888888888	微信分配的公众账号ID
     */
    @NotBlank
    @XmlElement(name = "appid")
    private String appid;

    /**
     * 商户号
     * 是	String(32)	1900000109	微信支付分配的商户号
     */
    @NotBlank
    @XmlElement(name = "mch_id")
    private String mchId;
    /**
     * 随机字符串
     * 是	String(32)	5K8264ILTKCH16CQ2502SI8ZNMTM67VS	随机字符串,不长于32位
     */
    @NotBlank
    @XmlElement(name = "nonce_str")
    private String nonceStr;
    /**
     * 签名
     * 是	String(32)	5K8264ILTKCH16CQ2502SI8ZNMTM67VS	签名,详见签名算法
     */
    @NotBlank
    @XmlElement(name = "sign")
    private String sign;
    /**
     * 微信订单号
     * 是	String(32)	4007752501201407033233368018	微信订单号
     */
    @NotBlank
    @XmlElement(name = "transaction_id")
    private String transactionId;
    /**
     * 商户订单号
     * 是	String(32)	33368018	商户系统内部订单号,要求32个字符内,只能是数字、大小写字母_-|*@ ,且在同一个商户号下唯一。
     */
    @NotBlank
    @XmlElement(name = "out_trade_no")
    private String outTradeNo;
    /**
     * 商户退款单号
     * 是	String(64)	121775250	商户系统内部的退款单号,商户系统内部唯一,只能是数字、大小写字母_-|*@ ,同一退款单号多次请求只退一笔。
     */
    @NotBlank
    @XmlElement(name = "out_refund_no")
    private String outRefundNo;
    /**
     * 微信退款单号
     * 是	String(32)	2007752501201407033233368018	微信退款单号
     */
    @NotBlank
    @XmlElement(name = "refund_id")
    private String refundId;
    /**
     * 退款金额
     * 是	Int	100	退款总金额,单位为分,可以做部分退款
     */
    @NotBlank
    @XmlElement(name = "refund_fee")
    private Long refundFee;
    /**
     * 应结退款金额
     * 否	Int	100	去掉非充值代金券退款金额后的退款金额,退款金额=申请退款金额-非充值代金券退款金额,退款金额<=申请退款金额
     */
    @XmlElement(name = "settlement_refund_fee")
    private String settlementRefundFee;
    /**
     * 标价金额
     * 是	Int	100	订单总金额,单位为分,只能为整数,详见支付金额
     */
    @NotBlank
    @XmlElement(name = "total_fee")
    private Long totalFee;
    /**
     * 应结订单金额
     * 否	Int	100	去掉非充值代金券金额后的订单总金额,应结订单金额=订单金额-非充值代金券金额,应结订单金额<=订单金额。
     */
    @XmlElement(name = "settlement_total_fee")
    private Long settlementTotalFee;
    /**
     * 现金支付金额
     * 是	Int	100	现金支付金额,单位为分,只能为整数,详见支付金额
     */
    @NotBlank
    @XmlElement(name = "cash_fee")
    private Long cashFee;
    /**
     * 现金支付币种
     * 否	String(16)	CNY	货币类型,符合ISO 4217标准的三位字母代码,默认人民币:CNY,其他值列表详见货币类型
     */
    @XmlElement(name = "cash_fee_type")
    private String cashFeeType;
    /**
     * 现金退款金额
     * 否	Int	100	现金退款金额,单位为分,只能为整数,详见支付金额
     */
    @XmlElement(name = "cash_refund_fee")
    private Long cashRefundFee;

    public boolean isPostSuccess() {
        return "SUCCESS".equals(this.getReturnCode());
    }

    public boolean isRefundSuccess() {
        return "SUCCESS".equals(this.getResultCode());
    }
}
复制代码

退款值得注意的是,需要使用 API 证书。

总结

文章总结了服务端接入支付宝支付 SDK 和微信支付 SDK 遇到的各种问题,同时也包括下单退款两个 case 的原理、配置和代码,希望对部分读者有帮助。

文章分类
后端