百度小程序收银台支付
最近需要实现百度小程序支付,自己记录下以后用。
百度小程序支付开通指引请参考官方文档:https://smartprogram.baidu.com/docs/introduction/pay/
开发准备
RSA公钥私钥生成工具,推荐:支付宝RAS密钥生成器。配置支付回调地址,退款审核地址,退款回调地址。
如果回调接口部署在阿里云或有网关准入限制,请参考文档阿里云安全组设置中的IP地址设置白名单
接入百度收银台
官方文档:http://dianshang.baidu.com/platform/doclist/index.html#!/doc/nuomiplus_1_guide/mini_program_cashier/standard_interface/push_notice.md
签名工具类
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.net.URLEncoder;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.*;
import com.nuomi.common.NuomiApiException;
import com.nuomi.common.NuomiConstants;
import com.nuomi.util.StreamUtil;
import com.nuomi.util.codec.Base64;
import org.apache.commons.lang.StringUtils;
/**
* 签名工具类
* 目前只支持rsa方式 不支持rsa2
*/
public class NuomiSignature {
public static final String appKey = "";
public static final String dealId = "";
public static final String PUB_KEY = "";
public static final String PRIVATE_KEY = "";
public static String genSignWithRsa(String totalAmount, String tpOrderId) throws NuomiApiException {
HashMap apiParams = new HashMap();
apiParams.put("appKey", appKey);
apiParams.put("dealId", dealId);
apiParams.put("totalAmount", totalAmount);
apiParams.put("tpOrderId", tpOrderId);
return NuomiSignature.genSignWithRsa(apiParams, PRIVATE_KEY);
}
public static Boolean checkSignWithRsa(String totalAmount, String tpOrderId, String rsaSign) throws NuomiApiException {
HashMap apiParams = new HashMap();
apiParams.put("appKey", appKey);
apiParams.put("dealId", dealId);
apiParams.put("totalAmount", totalAmount);
apiParams.put("tpOrderId", tpOrderId);
return NuomiSignature.checkSignWithRsa(apiParams, PUB_KEY, rsaSign);
}
/**
* 获取签名
*
* @param sortedParams 排序后的参数
* @param privateKey 私钥
* @return 返回签名后的字符串
* @throws NuomiApiException
*/
public static String genSignWithRsa(Map<String, String> sortedParams, String privateKey) throws NuomiApiException {
String sortedParamsContent = getSignContent(sortedParams);
return rsaSign(sortedParamsContent, privateKey, NuomiConstants.CHARSET_UTF8);
}
/**
* 签名验证
*
* @param sortedParams
* @param pubKey
* @param sign
* @return
* @throws NuomiApiException
*/
public static boolean checkSignWithRsa(Map<String, String> sortedParams, String pubKey, String sign) throws NuomiApiException {
String sortedParamsContent = getSignContent(sortedParams);
return doCheck(sortedParamsContent, sign, pubKey, NuomiConstants.CHARSET_UTF8);
}
/**
* @param sortedParams 已经排序的字符串
* @return 返回签名后的字符串
*/
public static String getSignContent(Map<String, String> sortedParams) {
StringBuffer content = new StringBuffer();
List<String> keys = new ArrayList<String>(sortedParams.keySet());
Collections.sort(keys);
int index = 0;
for (int i = 0; i < keys.size(); i++) {
String key = keys.get(i);
String value = sortedParams.get(key);
content.append((index == 0 ? "" : "&") + key + "=" + value);
index++;
}
return content.toString();
}
/**
* sha1WithRsa 加签
*
* @param content 需要加密的字符串
* @param privateKey 私钥
* @param charset 字符编码类型 如:utf8
* @return
* @throws NuomiApiException
*/
public static String rsaSign(String content, String privateKey, String charset) throws NuomiApiException {
try {
PrivateKey priKey = getPrivateKeyFromPKCS8(NuomiConstants.SIGN_TYPE_RSA,
new ByteArrayInputStream(privateKey.getBytes()));
java.security.Signature signature = java.security.Signature
.getInstance(NuomiConstants.SIGN_ALGORITHMS);
signature.initSign(priKey);
if (StringUtils.isEmpty(charset)) {
signature.update(content.getBytes());
} else {
signature.update(content.getBytes(charset));
}
byte[] signed = signature.sign();
return new String(Base64.encodeBase64(signed));
} catch (InvalidKeySpecException ie) {
throw new NuomiApiException("RSA私钥格式不正确,请检查是否正确配置了PKCS8格式的私钥", ie);
} catch (Exception e) {
throw new NuomiApiException("RSAcontent = " + content + "; charset = " + charset, e);
}
}
public static PrivateKey getPrivateKeyFromPKCS8(String algorithm, InputStream ins) throws Exception {
if (ins == null || StringUtils.isEmpty(algorithm)) {
return null;
}
KeyFactory keyFactory = KeyFactory.getInstance(algorithm);
byte[] encodedKey = StreamUtil.readText(ins).getBytes();
encodedKey = Base64.decodeBase64(encodedKey);
return keyFactory.generatePrivate(new PKCS8EncodedKeySpec(encodedKey));
}
/**
* RSA验签名检查
*
* @param content 待签名数据
* @param sign 签名值
* @param publicKey 分配给开发商公钥
* @param encode 字符集编码
* @return 布尔值
* @throws NuomiApiException
*/
private static boolean doCheck(String content, String sign, String publicKey, String encode) throws NuomiApiException {
try {
KeyFactory keyFactory = KeyFactory.getInstance(NuomiConstants.SIGN_TYPE_RSA);
byte[] bytes = publicKey.getBytes();
byte[] encodedKey = Base64.decodeBase64(bytes);
PublicKey pubKey = keyFactory.generatePublic(new X509EncodedKeySpec(encodedKey));
java.security.Signature signature = java.security.Signature.getInstance(NuomiConstants.SIGN_ALGORITHMS);
signature.initVerify(pubKey);
signature.update(content.getBytes(encode));
boolean bverify = signature.verify(Base64.decodeBase64(sign.getBytes()));
return bverify;
} catch (Exception e) {
throw new NuomiApiException("RSA私钥格式不正确,请检查是否正确配置了PKCS8格式的私钥", e);
}
}
}
【支付 核销 退款工具类】
代码如下(示例):
import cn.hutool.core.util.NumberUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.google.common.collect.Maps;
import com.nuomi.common.NuomiApiException;
import org.springframework.web.client.RestTemplate;
import java.net.URLEncoder;
import java.util.HashMap;
import java.util.Map;
/**
* @Description: 百度收银台工具类
* @Author: zhaobin
* @Date: 2020/6/10
*/
public class BaiduUtil {
public static final String appKey = "";
public static final String dealId = "";
public static final String PUB_KEY = "";
public static final String PRIVATE_KEY = "";
private static String url = "https://nop.nuomi.com/nop/server/rest";
/**
* 百度收银台 - 退款接口
*
* @param orderId
* @param userId
* @param orderNo
* @param amount
* @return
* @throws NuomiApiException
*/
public static JSONObject bdRefund(String orderId, String userId, String orderNo, Double amount, String bizRefundBatchId, String refundReason) throws Exception {
String totalAmountStr = NumberUtil.toStr(amount * 100);
Map<String, String> param = new HashMap<>();
param.put("method", "nuomi.cashier.applyorderrefund");
param.put("orderId", orderId);
param.put("userId", userId);
param.put("refundType", "1");
param.put("refundReason", refundReason);
param.put("tpOrderId", orderNo);
param.put("appKey", appKey);
param.put("applyRefundMoney", totalAmountStr); //退款金额,单位:分
param.put("bizRefundBatchId", bizRefundBatchId); //业务方退款批次id,退款业务流水唯一编号
String sign = NuomiSignature.genSignWithRsa(param, NuomiSignature.PRIVATE_KEY);
String refundApplyUrl = url + "?method=nuomi.cashier.applyorderrefund&orderId=" + orderId + "&userId=" + userId + "&refundType=1&refundReason=" + refundReason +
"&tpOrderId=" + orderNo + "&appKey=" + appKey + "&applyRefundMoney=" + totalAmountStr + "&bizRefundBatchId=" + bizRefundBatchId + "&rsaSign=" + sign;
JSONObject forObject = new RestTemplate().getForObject(refundApplyUrl, JSONObject.class);
return forObject;
}
public static JSONObject cancelHx(String orderId, String userId) throws Exception {
Map<String, String> param = new HashMap<>();
param.put("method", "nuomi.cashier.syncorderstatus");
param.put("orderId", orderId);
param.put("userId", userId);
param.put("type", "3");
param.put("appKey", appKey);
String sign = NuomiSignature.genSignWithRsa(param, NuomiSignature.PRIVATE_KEY);
String finalUrl = url + "?method=nuomi.cashier.syncorderstatus&orderId=" + orderId + "&userId=" + userId + "&type=3&appKey=MMUBBH&rsaSign=" + sign;
JSONObject forObject = new RestTemplate().getForObject(finalUrl, JSONObject.class);
return forObject;
}
}
【接口通用响应实体】
@NoArgsConstructor
@Data
@Accessors(chain=true)
public class BdXCXResDto<T> {
private T data;
private Integer errno;
private String msg;
public BdXCXResDto(Integer errno, String msg) {
this.errno = errno;
this.msg = msg;
}
}
【调起百度收银台】
百度收银台没有预支付,直接由小程序端调起百度收银台发起支付,不过他们需要一个参数 “rsaSign” 需要调后台接口返回:
@PostMapping("/rsaSign")
public BaseDataResDto<String> rsaSign (@RequestBody BaseDataReqDto<BaiduXCXReqData> baseDataReqDto) {
Double totalAmount = baseDataReqDto.getData().getTotalAmount();
String tpOrderId = baseDataReqDto.getData().getTpOrderId();
String totalAmountStr = NumberUtil.toStr(totalAmount * 100);
try {
String sign = NuomiSignature.genSignWithRsa(totalAmountStr, tpOrderId);
} catch (NuomiApiException e) {
e.printStackTrace();
return new BaseDataResDto<>(Status.ERROR);
}
return new BaseDataResDto<String>(Status.SUCCESS).setData(sign);
}
【通知支付状态】
百度收银台主动发起通知,该方式才会被启用
业务方智能小程序跳转至百度收银台,输入正确的交易密码之后,即订单支付成功后,百度收银台会主动调用业务方的的支付回调地址(开发者平台注册的支付回调地址)通知业务方该订单支付成功。
/**
* 实际开发可以直接用 @RequestBody Map<String, String> param 接收
* 因为我在SpringCloud做了一层拦截,所以该博客直接用@RequestParam一个个接收
*/
@PostMapping("bdXCXCallBack")
public BdXCXResDto<Map<String, Object>> bdXCXCallBack(@RequestParam Integer totalMoney, @RequestParam String tpOrderId,
@RequestParam String rsaSign, @RequestParam Long orderId, @RequestParam Long userId) {
return capitalMainLogService.bdXCXCallBack(totalMoney,tpOrderId,rsaSign,orderId,userId);
}
@Override
public BdXCXResDto<Map<String, Object>> bdXCXCallBack(Integer totalMoney, String tpOrderId, String rsaSign, Long orderId, Long userId) {
BdXCXResDto<Map<String, Object>> bdXCXResDto = new BdXCXResDto<>(0, "success");
Map<String, Object> map = Maps.newHashMap();
map.put("isConsumed", 2);
try {
// 根据订单号查看订单信息
ShopingOrder shopingOrder = shopingOrderService.selectOne(new EntityWrapper<ShopingOrder>().eq("order_no", tpOrderId));
if (BooleanUtils.isFalse(NuomiSignature.checkSignWithRsa(totalMoney.toString(), tpOrderId, NuomiSignature.genSignWithRsa( NumberUtil.toStr(shopingOrder.getAmount() * 100), tpOrderId)))) {
logger.info("bdXCXCallBack ===>> 签名验证失败");
map.put("isErrorOrder", 1);
return bdXCXResDto.setData(map);
}
// 已经付款
if (Arrays.asList(1, 2, 3, 5, 6).contains(shopingOrder.getStatus())) return bdXCXResDto.setData(map);
shopingOrder.setStatus(1).setPayType(5).setPayId(userId + "").setPayDate(new Date()).setPrepayId(orderId + "");
// 修改支付状态为成功
shopingOrderMapper.updateById(shopingOrder);
logger.info("百度收银台已支付");
// 其他业务逻辑...
} catch (Exception e) {
e.printStackTrace();
map.put("isErrorOrder", 1);
return bdXCXResDto.setData(map);
}
return bdXCXResDto.setData(map);
}
注意: isConsumed重要性:为必传参数(不传会触发异常退款),用来标记该订单是否已消费。 小程序接入为支付成功即消费场景,该字段需设置为2。(字段不设置为2订单同样会变更为“已消费”)如isConsumed值不返回2,“已消费”状态的订单金额不能顺利进入企业余额。
小程序场景isConsumed返回值一定要为2,(字段不设置为2订单不会变更为“已消费”)不按照要求值返回参数,用户已付款金额不能顺利进入企业余额。
【申请退款】
业务方可以通过该接口申请订单退款,仅限在百度电商开放平台支付的订单。
◆ 特别说明:
防止资金池金额小于退款金额时退款失败的情况,建议根据业务退款情况,在“管理中心——支付服务设置——我的服务——服务——财务设置”设置“每日退款上限(元)”和“打款预留(元)”。
每日退款上限(元) :设置每日退款上限。当日退款达到设置金额时,当日新发起的退款都会失败。
打款预留(元):结款日资金池预留的不打款的金额,保证资金池有金额退款。
发起部分退款时,订单必须是核销状态。
@PostMapping("executeRefund")
public BaseResDto executeRefund(@RequestBody BaseDataReqDto<String> baseDataReqDto) {
String orderRefundId = baseDataReqDto.getData();
if (StringUtil.isBlank(orderRefundId))
return new BaseResDto(Status.PARAMETERERROR);
return capitalMainLogService.executeRefund(orderRefundId);
}
@Override
public BaseResDto executeRefund(String orderRefundId) {
ShoppingOrderRefundEntity shoppingOrderRefund = shopingOrderRefundService
.selectById(orderRefundId).setRefundMiddleTime(new Date()).setStatus(3);//退款中
String orderId = shoppingOrderRefund.getOrderId();
ShopingOrder shopingOrder = shopingOrderMapper.selectById(orderId) .setRefundStatus(3);//退款中
Double refundMoney = shoppingOrderRefund.getRefundMoney();
Double amount = shopingOrder.getAmount();
if (refundManey > amount)
return BaseResDto.baseResDto(Status.ERROR, "退款金额错误!");
try{
// 先取消核销 (官方客服回复:新服务不需要关注取消核销接口,请直接调用申请退款接口。)
JSONObject hx = BaiduUtil.cancelHx(shopingOrder.getPrepayId(), shopingOrder.getPayId());
if((Integer)hx.get("errno") != 0)
BaseResDto.baseResDto(Status.ERROR, "百度收银台退款失败,错误码:" + hx.get("errno"));
// 调用百度API申请退款
JSONObject refundApply = BaiduUtil.bdRefund(shopingOrder.getPrepayId(), shopingOrder.getPayId(),
shopingOrder.getOrderNo(), refundManey, shoppingOrderRefund.getUuId(), shoppingOrderRefund.getRefundReason());
if ((Integer)refundApply.get("errno") != 0)
return BaseResDto.baseResDto(Status.ERROR, "百度收银台退款失败");
}
} catch (Exception e) {
e.printStackTrace();
return BaseResDto.baseResDto(Status.ERROR, "百度收银台退款失败异常!");
}
if (!shopingOrderService.updateById(shopingOrder))
return BaseResDto.baseResDto(Status.ERROR, "修改订单退款状态失败!");
if (!shopingOrderRefundService.updateById(shoppingOrderRefund))
return BaseResDto.baseResDto(Status.ERROR, "修改退款记录退款状态失败!");
return new BaseResDto(Status.SUCCESS);
}
返回示例
{
"errno": 0,
"msg": "success",
"data": {
"refundBatchId": "152713835",//平台退款批次号
"refundPayMoney": "9800" //平台可退退款金额【分为单位】
}
}
【请求业务方退款审核】
使用场景:
◆ 当用户的某个订单申请了退款后,百度收银台会主动调用业务方的退款审核地址(开发者平台注册的退款审核地址)询问订单是否可退
◆ 用户支付成功后,百度收银台通过通知业务方支付成功接口通知业务方,业务方反馈给百度收银台的字符不是合法json或解析出来的errno不为0时,系统会自动发起退款,此时百度收银台也会调用业务方的退款审核接口询问业务方订单是否可以退款
/** 百度小程序退款审核地址 */
@PostMapping("bdRefund")
public BdXCXResDto<Map<String, Object>> bdRefund(@RequestParam String tpOrderId) {
return capitalMainLogService.bdRefund(tpOrderId);
}
@Override
public BdXCXResDto<Map<String, Object>> bdRefund(String tpOrderId) {
BdXCXResDto<Map<String, Object>> bdXCXResDto = new BdXCXResDto<>(0, "success");
Map<String, Object> map = Maps.newHashMap();
try {
ShopingOrder shopingOrder = shopingOrderService.selectOne(new EntityWrapper<ShopingOrder>().eq("order_no", tpOrderId));
if (shopingOrder == null) {
// 订单不存在
map.put("auditStatus", 2); // 审核不通过,不能退款
map.put("calculateRes", new JSONObject().put("refundPayMoney", 0));
return bdXCXResDto.setData(map);
}
// if (BooleanUtils.isFalse(NuomiSignature.checkSignWithRsa(shopingOrder.getAmount() + "", tpOrderId, rsaSign))) {
// logger.info("CapitalMainLogServiceImpl.bdRefund ===>> 签名验证失败"); //如果金额不对,则会导致验签失败,所以后续不用判断金额是否匹配
// map.put("auditStatus", 2); // 审核不通过,不能退款
// map.put("calculateRes", new JSONObject().put("refundPayMoney", 0));
// return bdXCXResDto.setData(map);
// }
map.put("auditStatus", 1); // 审核通过可退款
map.put("calculateRes", new JSONObject().put("refundPayMoney",
shopingOrderMapper.getRefundMoneyByOrderId(shopingOrder.getUuId())));
} catch (Exception e) {
e.printStackTrace();
map.put("auditStatus", 2); // 审核不通过,不能退款
map.put("calculateRes", new JSONObject().put("refundPayMoney", 0));
return bdXCXResDto.setData(map);
}
return bdXCXResDto.setData(map);
}
返回(响应)DEMO:
{"errno":0,"msg":"success","data":{"auditStatus":1, "calculateRes":{"refundPayMoney":100}}}
refundPayMoney的值是以分为单位的整数,如不严格按照文档提示操作,会导致退款审核失败。
【通知退款状态】
百度收银台调用业务方的退款审核接口成功,且业务方返回允许退款后,平台会去做退款操作,当订单退款成功后,百度收银台会主动调用业务方的退款回调地址(开发者平台注册的退款回调地址)通知业务方该订单退款成功。
通知触发条件:退款成功后,平台会调用该接口,将退款成功消息通知到业务方。
/** 百度小程序退款回调地址 */
@PostMapping("bdRefundCallBack")
public BdXCXResDto<Map<String, Object>> bdRefundCallBack(@RequestParam String tpOrderId) {
return capitalMainLogService.bdRefundCallBack(tpOrderId);
}
@Override
public BdXCXResDto<Map<String, Object>> bdRefundCallBack(String tpOrderId) {
BdXCXResDto<Map<String, Object>> bdXCXResDto = new BdXCXResDto<>(0, "success");
try {
// String tpOrderId = param.get("tpOrderId"); //订单号
// String rsaSign = param.get("rsaSign");
ShopingOrder shopingOrder = shopingOrderService.selectOne(new EntityWrapper<ShopingOrder>().eq("order_no", tpOrderId));
if (shopingOrder == null) {
// 订单不存在
return bdXCXResDto.setData(new JSONObject());
}
// if (BooleanUtils.isFalse(NuomiSignature.checkSignWithRsa(shopingOrder.getAmount() + "", tpOrderId, rsaSign))) {
// return bdXCXResDto.setData(new JSONObject()); //验签失败
// }
// 已经退款
if (shopingOrder.getStatus() == 4)
return bdXCXResDto.setData(new JSONObject());
// 修改订单退款状态
shopingOrder.setRefundStatus(4);
shopingOrderService.updateById(shopingOrder)
// 修改退款状态
EntityWrapper<ShoppingOrderRefundEntity> entityWrapper = new EntityWrapper<>();
entityWrapper.eq("order_id", shopingOrder.getUuId());
ShoppingOrderRefundEntity shoppingOrderRefund = shopingOrderRefundService.selectOne(entityWrapper);
shoppingOrderRefund.setRefundFinishTime(new Date()).setStatus(4);
shopingOrderRefundService.updateById(shoppingOrderRefund)
// 其他业务逻辑...
} catch (Exception e) {
e.printStackTrace();
return bdXCXResDto.setData(new JSONObject()); //失败(官网也没说失败如何返回,干脆来个空json)
}
return bdXCXResDto.setData(new JSONObject()); //成功返回
}
返回(响应)DEMO:
{"errno":0,"msg":"success","data":{}}