前言
这周(在草稿箱中发现 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 支付的前提,下单除了业务上的含义之外,其主要解决的问题 是保障支付过程的安全性,方式就是加签,避免支付信息在支付过程中被偷偷篡改。
任务列表:
- 配置
- 四个公钥证书(支付宝接口需要)
- 应用私钥(加签验签需要)
- 加签算法(RSA1 和 RSA2,建议 RSA2)
- 支付结果异步通知公网的接口(
notify_url
) - 其它配置请看官方文档
- 构建 AlipayClient
- 构建支付宝证书请求
CertAlipayRequest.java
- 构建默认的支付宝客户端
new DefaultAlipayClient(certAlipayRequest)
- 构建下单接口的请求参数
AlipayTradeAppPayRequest.java
和AlipayTradeAppPayModel.java
,后者对应下单接口的 biz_content 参数,其中有必填(金额、订单号较为重要)和选填参数(passbackParams 需要关注一下),请参考官方文档。 - 发起下单请求
alipayClient.sdkExecute(request)
- 使用
AlipayTradeAppPayResponse.java
作为下单请求的返回值
- 构建支付宝证书请求
- 商家业务逻辑代码。
- 编写一个测试,用于模拟 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 请求,具体的参数请自行阅读官方文档。
任务列表:
- 开发一个接口用于接收支付宝后台的回调,该接口就是下单时填写的 notify_url。
- 验签
- 开发商家业务逻辑
- 接口返回 success(代表回调并验证成功) 或者 failure(代码验证不通过)。
- 编写一个测试,用于模拟支付宝回调
@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-Type
为 application/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 测试来模拟支付宝后台的回调,这样的开发效率非常高,而不是部署到线上去测试。
退款
统一收单交易退款接口 提供了退款所需的信息,唯一遗憾的是,没有提供证书加签方式的例子,把我给误导了大半天,导致接口一直不通,浪费了很多时间。
任务列表:
- 拿到 商户订单号 或 支付宝交易号,任选其一。
- 拿到退款金额
- 构建退款接口请求
AlipayTradeRefundRequest
和AlipayTradeRefundModel
,后者为接口的biz_content
参数。 - 发起退款请求
- 开发业务逻辑
- 测试
@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);
}
}
复制代码
这里有两个地方需要注意:
- 公钥方式使用
alipayClient.certificateExecute(request)
发起退款请求,而不是alipayClient.execute(request)
**。 - 退款是同步请求,推荐不设置
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
验签工具
微信提供了一个图形界面的验签工具 ,规则也很简单,请自行阅读官方文档。
统一下单
详情请款 统一下单 。
任务列表:
- 重要配置
- 构建 加签 工具类
- 构建 微信支付 工具类
- 构建 XML <=> 自定义对象 的装换工具类
- 定义下单接口
- 编写测试
@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 的原理、配置和代码,希望对部分读者有帮助。