背景
项目中本来是使用微信和支付宝分开的收款码收款,现在需要实现一张二维码图片,微信/支付宝 都能扫码付款
这里可以先看看自己支付宝或者微信的费率,我们公司直接对接支付宝和微信的费率分别的0.6%和0.9%,后来了解到一些聚合支付的通道能给到0.38%,真香。
开始选型,要求是文档清晰,长期稳定,费率低,安全可靠。
这里选的是【计全付】,开始找了一些其它的要么费率高,不靠谱,弄得我想自己写个聚合支付了,在gitee找代码时看到的,star还挺高的就看了下
计全付码云仓库
SDK地址
jeepay-plus文档
文档还算详尽,这里做个记录
准备工作,注册获取开发参数
注册地址
注册–>登录
目前商家推荐使用盛付通
获取商户号
获取APPID
获取私钥
开发
引入SDK
<dependency>
<groupId>com.jeequan</groupId>
<artifactId>jeepay-sdk-java</artifactId>
<version>1.5.0</version>
</dependency>
为了方便统一管理,添加配置文件
回调地址是自己本地的,后面会用到
调用SDK每次都需要new一个JeepayClient,并赋值,这里为了方便,写在配置文件里面
import com.jeequan.jeepay.Jeepay;
import com.jeequan.jeepay.JeepayClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.PropertySource;
import org.springframework.core.env.Environment;
/**
* @author:xianyu
* @createDate:2022/8/5
* @description:
*/
@Configuration
@PropertySource("classpath:jee-pay.properties")
public class JeePayClientConfig {
@Autowired
private Environment config;
@Bean
public JeepayClient jeePayConfig(){
Jeepay.setApiBase(config.getProperty("api-base"));
Jeepay.apiKey = config.getProperty("api-key");
Jeepay.mchNo = config.getProperty("mch-no");
Jeepay.appId = config.getProperty("app-id");
JeepayClient jeepayClient = JeepayClient.getInstance(Jeepay.appId, Jeepay.apiKey, Jeepay.getApiBase());
return jeepayClient;
}
}
注意classpath:后面接自己配置文件的名称,按住ctrl能跳转就说明没问题
下单接口
编写扫码下单接口,这里实体类就不贴出了,可参考开发文档里面自定义参数即可
controller
import com.jeequan.jeepay.exception.JeepayException;
import com.mcsgis.saas.common.data.R;
import com.mcsgis.saas.system.api.domain.dto.OrderInfoDto;
import com.mcsgis.saas.yun.service.JeePayService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* @author:xianyu
* @createDate:2022/8/5
* @description:
*/
@Api(tags = "计全聚合支付")
@RestController()
public class JeePayController {
@Autowired
private JeePayService jeePayService;
/**
* 扫码下单
*
* @param orderInfoDto
* @return
* @throws JeepayException
*/
@PostMapping("/scanPay")
public String scanPay(@RequestBody OrderInfoDto orderInfoDto) throws JeepayException {
return jeePayService.scanPay(orderInfoDto);
}
}
service
/**
* 统一生成订单接口,返回二维码
*
* @param orderInfoDto
* @return
* @throws JeepayException
*/
String scanPay(OrderInfoDto orderInfoDto) throws JeepayException;
serviceImpl
import cn.hutool.http.HttpRequest;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.jeequan.jeepay.Jeepay;
import com.jeequan.jeepay.JeepayClient;
import com.jeequan.jeepay.exception.JeepayException;
import com.jeequan.jeepay.model.PayOrderCreateReqModel;
import com.jeequan.jeepay.request.PayOrderCreateRequest;
import com.jeequan.jeepay.response.PayOrderCreateResponse;
import com.jeequan.jeepay.util.JeepayKit;
import com.mcsgis.saas.common.constant.JeePayConstants;
import com.mcsgis.saas.common.util.JsonUtil;
import com.mcsgis.saas.system.api.domain.dto.OrderInfoDto;
import com.mcsgis.saas.yun.configuration.JeePayClientConfig;
import com.mcsgis.saas.yun.service.JeePayService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Service;
import sun.security.krb5.internal.APRep;
import java.math.BigDecimal;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
/**
* @author:xianyu
* @createDate:2022/8/5
* @description:
*/
@Slf4j
@Service
public class JeePayServiceImpl implements JeePayService {
@Autowired
private JeepayClient jeepayClient;
@Autowired
private Environment config;
@Override
public String scanPay(OrderInfoDto orderInfoDto) {
// 构建请求数据
PayOrderCreateRequest request = new PayOrderCreateRequest();
PayOrderCreateReqModel model = new PayOrderCreateReqModel();
// 商户号
model.setMchNo(Jeepay.mchNo);
// 应用ID
model.setAppId(Jeepay.appId);
// 商户订单号
model.setMchOrderNo(orderInfoDto.getOutTradeNo());
// 支付方式
model.setWayCode(JeePayConstants.QR_CASHIER);
// 金额,单位分
long amount = orderInfoDto.getTotalAmount().multiply(new BigDecimal(100)).longValue();
model.setAmount(amount);
// 币种,目前只支持cny
model.setCurrency("CNY");
// 发起支付请求客户端的IP地址
model.setClientIp(config.getProperty("ip-address"));
// 商品标题
model.setSubject(orderInfoDto.getSubject());
// 商品描述
model.setBody("商品描述");
// 异步通知地址
model.setNotifyUrl(config.getProperty("notify"));
// 前端跳转地址
model.setReturnUrl("");
// 渠道扩展参数
model.setChannelExtra(channelExtra(JeePayConstants.QR_CASHIER));
// 商户扩展参数,回调时原样返回
model.setExtParam("");
request.setBizModel(model);
log.info("jeepay下单参数处理完毕,参数:[{}]", JSON.toJSONString(request));
String result = "failure";
try {
PayOrderCreateResponse response = jeepayClient.execute(request);
// 下单成功
if (response.isSuccess(Jeepay.apiKey)) {
result = response.getData().getString("payData");
} else {
log.warn("下单失败:{}", orderInfoDto.getOutTradeNo());
}
} catch (JeepayException e) {
log.error(e.getMessage());
}
return result;
}
String channelExtra(String wayCode) {
if ("WX_LITE".equals(wayCode)) return wxJsapiExtra();
if ("WX_JSAPI".equals(wayCode)) return wxJsapiExtra();
if ("WX_BAR".equals(wayCode)) return wxBarExtra();
if ("ALI_BAR".equals(wayCode)) return aliBarExtra();
if ("YSF_BAR".equals(wayCode)) return ysfBarExtra();
if ("UPACP_BAR".equals(wayCode)) return upacpBarExtra();
if ("QR_CASHIER".equals(wayCode)) return qrCashierExtra();
if ("AUTO_BAR".equals(wayCode)) return autoBarExtra();
if ("PP_PC".equals(wayCode)) return ppExtra();
if ("SAND_H5".equals(wayCode)) return sandH5Extra();
return "";
}
private String wxJsapiExtra() {
JSONObject obj = new JSONObject();
obj.put("openId", "134756231107811344");
return obj.toString();
}
private String wxBarExtra() {
JSONObject obj = new JSONObject();
obj.put("authCode", "134675721924600802");
return obj.toString();
}
private String aliBarExtra() {
JSONObject obj = new JSONObject();
obj.put("authCode", "1180812820366966512");
return obj.toString();
}
private String ysfBarExtra() {
JSONObject obj = new JSONObject();
obj.put("authCode", "6223194037624963090");
return obj.toString();
}
private String upacpBarExtra() {
JSONObject obj = new JSONObject();
obj.put("authCode", "6227662446181058584");
return obj.toString();
}
private String qrCashierExtra() {
JSONObject obj = new JSONObject();
obj.put("payDataType", "codeImgUrl");
return obj.toString();
}
private String autoBarExtra() {
JSONObject obj = new JSONObject();
obj.put("authCode", "134753177301492386");
return obj.toString();
}
private String ppExtra() {
JSONObject obj = new JSONObject();
obj.put("cancelUrl", "http://baidu.com");
return obj.toString();
}
private String sandH5Extra() {
JSONObject obj = new JSONObject();
JSONObject payExtra = new JSONObject();
// 聚合码
obj.put("productCode", "02000001");
obj.put("payExtra", "");
obj.put("metaOption", "[{\"s\":\"Pc\",\"n\":\"支付\"}]");
// 微信公众号
/*obj.put("productCode", "02010002");
payExtra = new JSONObject();
payExtra.put("mer_app_id", "");
payExtra.put("openid", "");
obj.put("payExtra", payExtra.toString());
obj.put("metaOption", "");
// 微信小程序(云函数sdk)
obj.put("productCode", "02010007");
payExtra = new JSONObject();
payExtra.put("wx_app_id", ""); // 移动应用Appid(微信开放平台获取,wx开头)
payExtra.put("gh_ori_id", ""); // 小程序原始id(微信公众平台获取,gh_开头)
payExtra.put("path_url", ""); // 拉起小程序页面的可带参路径,不填默认拉起小程序首页
payExtra.put("miniProgramType", "0"); // 开发时根据小程序是开发版、体验版或正式版自行选择。正式版:0; 开发版:1; 体验版:2
obj.put("payExtra", payExtra.toString());
obj.put("metaOption", "");
// 支付宝生活号
obj.put("productCode", "02010002");
payExtra = new JSONObject();
payExtra.put("buyer_id", ""); // 支付宝生活号所需参数(支付宝H5建议不传)
obj.put("payExtra", payExtra.toString());
obj.put("metaOption", "");*/
return obj.toString();
}
}
在配置文件中给 JeepayClient 初始值了,这里直接用@Autowired引入即可,不需要额外配置
测试下单接口,返回值
{
“status”: 200,
“msg”: “操作成功”,
“data”: “https://pay.jeepay.vip/api/scan/imgs/a034b9a3cc3e89d53d936c196d5a48c219c1f01a8623733b004429d057af027e69b4220ccdc8ad29375295f30ccb3e1cf130261adeb28c64f27dc682305ec082062889a910c12dba1317fa3f69c29eb0c2c83f71516c305b25830bf4a83e21abb235934310960a27aef6f6868c4a1481c52f213b693c30c0c391ecd371e17e0bfbc0f77203beabcb7549c44df66de99d06ad9d731e2ebac14a43effcec80aace.png”
}
返回的是一个图片地址,复制到浏览器中
微信扫码测试
支付宝扫码测试
回调接口
业务系统处理后同步返回给支付中心,返回字符串 success 则表示成功,返回非success则表示处理失败,支付中心会再次通知业务系统。(通知频率为0/30/60/90/120/150,单位:秒)
支付完成之后,我们的业务系统遇到知道用户已经付款,这里就有一个要使用回调,第四方商家通知我们的业务系统,我们开发普遍都在内网,这里就需要使用一个内网穿透,我用的是 ngrok
回调接口
@ApiOperation("支付回调")
@PostMapping("/tradeNotify")
public String tradeNotify(HttpServletRequest req) {
return orderInfoService.tradeNotify(req);
}
@Override
public String tradeNotify(HttpServletRequest req) {
String result = "failure";
try {
Map<String, Object> map = getParamsMap(req);
log.info("支付回调参数处理完毕,参数:[{}]", JSON.toJSONString(map));
//获取私钥
String s = redisTemplate.opsForValue().get(SysConfigConstants.JEE_PAY);
JSONObject configObj = new JSONObject();
String apikey;
if (StrUtil.isNotBlank(s)) {
configObj = JSONObject.parseObject(s);
apikey = configObj.get("api-key").toString();
} else {
SysConfiguration configuration = sysConfigurationMapper.findByKey(SysConfigConstants.JEE_PAY);
Object config = configuration.getConfig();
redisTemplate.opsForValue().set(SysConfigConstants.JEE_PAY, config.toString(), SMS_EXPIRES, TimeUnit.MILLISECONDS);
configObj = JSONObject.parseObject(config.toString());
apikey = configObj.get("api-key").toString();
}
//验签
if (chackSgin(map, apikey)) {
return result;
}
log.info("回调验签处理完成,开始处理业务逻辑,参数:[{}]", JSON.toJSONString(map));
OrderInfo info = new OrderInfo();
info.setOutTradeNo(map.get("mchOrderNo").toString());
info.setIsPay(true);
updateByPrimaryKeySelective(info);
result = "success";
} catch (Exception e) {
e.printStackTrace();
}
return result;
}
我这里的私钥放在数据库中,放在配置文件的不用可以直接取,不用查库或者redis
验签
/**
* 回调验签
*
* @param result
* @param map
* @param apikey
* @return
*/
private Boolean chackSgin(Map<String, Object> map, String apikey) {
Object sign = map.remove("sign");
String reSign = JeepayKit.getSign(map, apikey);
log.info("调用SDK加签,返回参数:[{}]", reSign);
if (!Objects.equals(reSign, sign)) {
log.error("支付成功,异步通知验签失败!");
return true;
}
log.info("支付成功,异步通知验签成功!");
//TODO 验签成功后,按照支付结果异步通知中的描述,对支付结果中的业务内容进行二次校验
//1.验证mchOrderNo 是否为商家系统中创建的订单号
List<OrderInfo> orderInfos = orderInfoMapper.selectByOutTradeNo(map.get("mchOrderNo").toString());
log.info("支付成功回调,查询订单,[{}]", JSON.toJSONString(orderInfos));
if (CollectionUtil.isEmpty(orderInfos)) {
log.error("支付成功,回调通知,mchOrderNo不是本系统生成的订单号!!");
return true;
}
//2.判断 amountt 是否确实为该订单的实际金额
//订单金额单位转换为分
BigDecimal reduce = orderInfos.stream().map(e -> e.getPrice()).reduce(new BigDecimal("0"), BigDecimal::add).multiply(new BigDecimal(100));
BigDecimal amount = new BigDecimal(map.get("amount").toString());
if (reduce.compareTo(amount) != 0) {
log.error("支付成功,回调通知,订单金额与实际金额不符!!");
return true;
}
//todo 后续添加 2022-08-16
//3.校验通知中的 mchNo 是否是本条
//4.验证 app_id 是否为该商家本身
return false;
}
private Map<String, Object> getParamsMap(HttpServletRequest req) {
Map<String, String[]> requestMap = req.getParameterMap();
Map<String, Object> paramsMap = new HashMap<>();
requestMap.forEach((key, values) -> {
String strs = "";
for (String value : values) {
strs = strs + value;
}
paramsMap.put(key, strs);
});
return paramsMap;
}
这里要说下验签,文档中给出的签名算法如下
这里只做了支付平台的比对,支付宝的文档中还需要与本地系统对比做二次校验
验签成功后,按照支付结果异步通知中的描述,对支付结果中的业务内容进行二次校验
1.验证mchOrderNo 是否为商家系统中创建的订单号
2.判断 amountt 是否确实为该订单的实际金额
3.校验通知中的 mchNo 是否是本条
4.验证 app_id 是否为该商家本身
只要这4条有一条不对就不能返回success
查询订单接口
/**
* 查询订单状态
*
* @param outTradeNo
* @return 0-订单生成 1-支付中 2-支付成功 3-支付失败 4-已撤销 5-已退款 6-订单关闭
* @throws Exception
*/
@ApiOperation("查询订单状态")
@GetMapping("/query")
public R query(@RequestParam("outTradeNo") @ApiParam("订单编号(唯一)") String outTradeNo){
Map map = jeePayService.query(outTradeNo);
return R.success(map);
}
@Override
public Map query(String outTradeNo) {
log.info("查询订单状态,outTradeNo:{}",outTradeNo);
Map<String,Object> parMap =new HashMap<>();
parMap.put("mchNo",config.getProperty("mch-no"));
parMap.put("appId",config.getProperty("app-id"));
parMap.put("mchOrderNo",outTradeNo);
parMap.put("reqTime",new Date().getTime());
parMap.put("version","1.0");
parMap.put("signType","MD5");
String sign = JeepayKit.getSign(parMap, config.getProperty("api-key"));
parMap.put("sign",sign);
log.info("查询订单状态,参数处理完毕,参数:[{}]",JSON.toJSONString(parMap));
String body = HttpRequest.post(config.getProperty("query-url"))
.body(JSON.toJSONString(parMap))
.execute()
.body();
Map parse = JsonUtil.parse(body, Map.class);
Map dataMap =(Map) parse.get("data");
log.info("查询订单状态,完成,返回结果:[{}]",JSON.toJSONString(dataMap));
return dataMap;
}
这个接口比较简单,不过有个BUG,截止我写完博客也没解决,返回值没有4-已撤销,支付的时候取消了,返回值还是1-支付中,计全付商家给出的解释是支付状态是上游返回,他们也只是原样返回给我这边,不过我自己对接支付宝或者微信的时候没有出现这种情况