中国建设银行个人网银商户二维码接口含服务器回调(详细版)
1.背景情况概述
由于项目要上线扫码支付功能,支持支付宝和微信付款,对接建行实现聚合码功能。

网站扫码支付示例图
2.开发准备
首先建行人员开通一系列商户平台账号,允许ip配置等等(默认已开通),提供了接口文档。

动态二维码接口
3.动态二维码接口
3.1配置项 application.yml
ccb:
dyQrCodeUrl: https://ibsbjstar.ccb.com.cn/CCBIS/ccbMain?CCB_IBSVersion=V6
merchantId: 商户代码
posId: 商户柜台代码
branchId: 分行代码
curcode: '01' #人民币
txCode: 530550 #交易码
pub: 公钥后30位
allPub: 完整公钥3.2对应配置类
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "ccb")
public class CcbConfig {
/**
* 动态二维码url
*/
private String dyQrCodeUrl;
/**
* 商户代码
*/
private String merchantId;
/**
* 商户柜台代码
*/
private String posId;
/**
* 分行代码
*/
private String branchId;
/**
* 交易码
*/
private String txCode;
/**
* 01-人民币
*/
private String curcode;
/**
* 公钥后三十位
*/
private String pub;
/**
* 公钥
*/
private String allPub;
}3.3 请求对象
注意点:因为银行要保持大小写一致,所以用JSONField注解!
@JsonProperty也行
import com.alibaba.fastjson.annotation.JSONField;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class CcbReq {
/**
* 商户代码 CHAR(15) Y 由建行统一分配
*/
@JSONField(name = "MERCHANTID")
private String MERCHANTID;
/**
* 商户柜台代码 CHAR(9) Y 由建行统一分配
*/
@JSONField(name = "POSID")
private String POSID;
/**
* 分行代码 CHAR(9) Y 由建行统一指定
*/
@JSONField(name = "BRANCHID")
private String BRANCHID;
/**
* 定单号 CHAR(30) Y 由商户提供,最长30位。
* 建议按以下规则生成订单号:商户代码后9位+ 自定义字符串(不超21位)
* 字符串可包含数字、字母、下划线
* 商户需保证订单号唯一
*/
@JSONField(name = "ORDERID")
private String ORDERID;
/**
* 付款金额 NUMBER(16,2) Y 由商户提供,按实际金额给出
* 支付完成后,请商户与收到的商户通知中的付款金额比对,确认两者金额一致;
*/
@JSONField(name = "PAYMENT")
private String PAYMENT;
/**
* 币种 CHAR(2) Y 缺省为01-人民币
* (只支持人民币支付)
*/
@JSONField(name = "CURCODE")
private String CURCODE = "01";
/**
* 备注1 CHAR(30) N 一般作为商户自定义备注信息使用,可在对账单中显示。
* 中文需使用escape编码。
* <p>
* 集团主商户代理发起子商户交易时,需将集团主商户信息填入该字段,格式如下:
* JTSH:主商户号+主柜台
* 例如:
* JTSH:105000000000000123456789
* 其中:105000000000000为主商户号
* 123456789为主柜台
*/
@JSONField(name = "REMARK1")
private String REMARK1;
/**
* 备注2 CHAR(30) N 一般作为商户自定义备注信息使用,可在对账单中显示。
* 中文需使用escape编码。
*/
@JSONField(name = "REMARK2")
private String REMARK2;
/**
* 交易码 CHAR(6) Y 由建行统一分配为530550
*/
@JSONField(name = "TXCODE")
private String TXCODE;
/**
* 返回类型 CHAR(6) N
* 0或空:返回二维码页面,只支持建行龙支付;
* 1:返回JSON格式【二维码信息串】,只支持建行龙支付;
* 2:返回聚合扫码二维码页面;
* 3:返回聚合扫码JSON格式【二维码信息串】
* 聚合扫码只能上送2或3
* 4:返回聚合银联二维码信息串
*/
@JSONField(name = "RETURNTYPE")
private String RETURNTYPE;
/**
* 订单超时时间 CHAR(14) N 格式:
* YYYYMMDDHHMMSS如:20120214143005
* 银行系统时间> TIMEOUT时拒绝交易,若送空值则不判断超时。
*/
@JSONField(name = "TIMEOUT")
private String TIMEOUT;
/**
* 实名支付 VARCHAR(2000) N 实名支付功能,包含类型、
* 证件号、姓名三个子域(如果本字段出现,那么本字
* 段包含的三个子域均需出
* 现。详见下文说明USERPARAM字段说明)。
* 当该字段有值时参与MAC校验,否则不参与MAC校验。
*/
@JSONField(name = "USERPARAM")
private String USERPARAM;
/**
* 商品信息 VARCHAR(128) N 客户购买的商品,该字段将展示在客户微信/支付宝账单的商品名称字段。
* 中文需使用escape编码。
* 该字段不参与MAC校验。
* <p>
* 使用js的escape()方法对“客户注册信息”和“商品信息”进行转码,数字字母信息不需转码。
* 例:escape(小飞侠)= %u5C0F%u98DE%u4FA0
* escape(充值卡)= %u5145%u503C%u5361
*/
@JSONField(name = "PROINFO")
private String PROINFO;
/**
* 付款账户限制标识 VARCHAR(2000) N 该字段不参与MAC计算
* 1、针对微信,商户上送PAYLIMIT这个字段时,字段值填“PAYLIMIT=no_balance”;可限制用户使用微信“余额”和“零钱通”进行支付。
* 2、针对支付宝,商户上送PAYLIMIT这个字段时,字段值分别填写“PAYLIMIT=balance,moneyFund,pcredit”;可限用户使用支付宝“余额”、“余额宝”和“花呗”进行支付。
*/
@JSONField(name = "PAYLIMIT")
private String PAYLIMIT;
/**
* 支付方式位图 CHAR(10) N 10位位图,1为开,0为关
* 第四位:H5支付标识【0-SDK,1-H5,默认SDK】
* 其余位数预留。
* 例如H5上送0001000000
* 该字段不参与MAC计算
*/
@JSONField(name = "PAYMAP")
private String PAYMAP;
/**
* 银联预授权 CHAR(6) N 0:普通消费
* 1:银联预授权
* 对于不送该字段和送空或者0的商户,默认为普通消费。该字段不参与MAC校验
*/
@JSONField(name = "PrePay")
private String PrePay;
/**
* MAC校验域 CHAR(32) Y 采用标准MD5算法,由商户实现
*/
@JSONField(name = "MAC")
private String MAC;
/**
* 商户URL CHAR(1800) N 退出支付流程时返回商户 URL,此字段不参与MAC计算
*/
@JSONField(name = "Mrch_url")
private String Mrch_url;
/**
* 二维码类型 CHAR(10) N WXPAY:微信支付二维码
* ALIPAY:支付宝二维码
* 此字段不参与MAC计算
*/
@JSONField(name = "QRFLAG")
private String QRFLAG;
}3.4核心方法(动态二维码)
import cn.hutool.crypto.SecureUtil;
import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import com.alibaba.fastjson.JSONObject;
import com.sungrow.power.common.exception.BusinessException;
import com.sungrow.power.common.utils.StringUtils;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.MapUtils;
import java.net.URLDecoder;
import java.util.LinkedHashMap;
import java.util.Map;
@Slf4j
public class CCBQrCodeClient {
/**
* 完整的动态二维码请求方法(包含RETURNTYPE=3的处理)
*
* @param params 请求参数
* @param pubKeySuffix 公钥后缀
*/
public static String requestQrCode(Map<String, String> params, String pubKeySuffix, String dyQrCodeUrl) throws Exception {
String response = requestDynamicQrCode(params, pubKeySuffix, dyQrCodeUrl);
if ("3".equals(params.get("RETURNTYPE"))) {
return handleReturnType3Response(response);
}
return response;
}
/* 发送动态二维码请求
*/
private static String requestDynamicQrCode(Map<String, String> params, String pubKeySuffix, String dyQrCodeUrl) throws Exception {
Map<String, Object> requestParams = new LinkedHashMap<>();
requestParams.putAll(params);
String macValue = calculateMac(params, pubKeySuffix);
requestParams.put("MAC", macValue);
dyQrCodeUrl += "&" + calculateUrl(params) + "&MAC=" + macValue;
log.info("二维码请求dyQrCodeUrl:{}", dyQrCodeUrl);
return sendHttpPost(requestParams, dyQrCodeUrl);
}
/**
* 计算MAC校验值 (MD5加密)
*/
private static String calculateMac(Map<String, String> params, String pubKeySuffix) throws Exception {
// 按文档指定顺序拼接字段
StringBuilder sb = new StringBuilder();
appendMacParam(sb, "MERCHANTID", params.get("MERCHANTID"));
appendMacParam(sb, "POSID", params.get("POSID"));
appendMacParam(sb, "BRANCHID", params.get("BRANCHID"));
appendMacParam(sb, "ORDERID", params.get("ORDERID"));
appendMacParam(sb, "PAYMENT", params.get("PAYMENT"));
appendMacParam(sb, "CURCODE", params.get("CURCODE"));
appendMacParam(sb, "TXCODE", params.get("TXCODE"));
appendMacParam(sb, "REMARK1", params.get("REMARK1"));
appendMacParam(sb, "REMARK2", params.get("REMARK2"));
appendMacParam(sb, "RETURNTYPE", params.get("RETURNTYPE"));
appendMacParam(sb, "TIMEOUT", params.get("TIMEOUT"));
// 可选字段处理(有值才参与)
if (params.containsKey("SUB_APPID") && !params.get("SUB_APPID").isEmpty()) {
appendMacParam(sb, "SUB_APPID", params.get("SUB_APPID"));
}
if (params.containsKey("USERPARAM") && !params.get("USERPARAM").isEmpty()) {
appendMacParam(sb, "USERPARAM", params.get("USERPARAM"));
}
// 添加公钥后缀
if (sb.length() > 0) {
sb.append('&');
}
sb.append("PUB=").append(pubKeySuffix);
log.info("MAC:{}", sb.toString());
// 计算MD5
return SecureUtil.md5(sb.toString());
}
/**
* 拼接url参数
*/
private static String calculateUrl(Map<String, String> params) throws Exception {
// 按文档指定顺序拼接字段
StringBuilder sb = new StringBuilder();
appendMacParam(sb, "MERCHANTID", params.get("MERCHANTID"));
appendMacParam(sb, "POSID", params.get("POSID"));
appendMacParam(sb, "BRANCHID", params.get("BRANCHID"));
appendMacParam(sb, "ORDERID", params.get("ORDERID"));
appendMacParam(sb, "PAYMENT", params.get("PAYMENT"));
appendMacParam(sb, "CURCODE", params.get("CURCODE"));
appendMacParam(sb, "TXCODE", params.get("TXCODE"));
appendMacParam(sb, "REMARK1", params.get("REMARK1"));
appendMacParam(sb, "REMARK2", params.get("REMARK2"));
appendMacParam(sb, "RETURNTYPE", params.get("RETURNTYPE"));
appendMacParam(sb, "TIMEOUT", params.get("TIMEOUT"));
// 可选字段处理(有值才参与)
if (params.containsKey("SUB_APPID") && !params.get("SUB_APPID").isEmpty()) {
appendMacParam(sb, "SUB_APPID", params.get("SUB_APPID"));
}
if (params.containsKey("USERPARAM") && !params.get("USERPARAM").isEmpty()) {
appendMacParam(sb, "USERPARAM", params.get("USERPARAM"));
}
return sb.toString();
}
private static void appendMacParam(StringBuilder sb, String key, String value) {
if (value == null) value = "";
if (sb.length() > 0) {
sb.append('&');
}
sb.append(key).append('=').append(value);
}
/**
* Post请求
*/
private static String sendHttpPost(Map<String, Object> paramsMap, String url) {
JSONObject jsoBject = new JSONObject(paramsMap);
log.info("接口请求入参:{}", jsoBject.toJSONString());
String resultStr = null;
HttpResponse execute = null;
try {
execute = HttpUtil.createPost(url).body(jsoBject.toJSONString()).execute();
resultStr = execute.body();
log.info("接口请求返参:{}", resultStr);
} catch (Exception e) {
log.info("异常:", e);
} finally {
if (execute != null) {
execute.close();
}
}
return resultStr;
}
/**
* 处理RETURNTYPE=3的响应(两步获取二维码)
*/
private static String handleReturnType3Response(String step1Response) {
String step2Response = null;
// 解析第一步响应
Map<String, Object> map1 = com.alibaba.fastjson.JSON.parseObject(step1Response, Map.class);
if (MapUtils.isNotEmpty(map1) && map1.containsKey("SUCCESS") && map1.get("SUCCESS") != null && StringUtils.equalsIgnoreCase("TRUE", map1.get("SUCCESS").toString())) {
if (map1.containsKey("PAYURL") && map1.get("PAYURL") != null) {
// 发送第二步请求
step2Response = sendGetRequest(map1.get("PAYURL").toString());
if (StringUtils.isBlank(step2Response)) {
throw new BusinessException("第二步请求返回为空!");
}
Map<String, Object> map2 = com.alibaba.fastjson.JSON.parseObject(step2Response, Map.class);
if (MapUtils.isNotEmpty(map2) && map2.containsKey("SUCCESS") && map2.get("SUCCESS") != null && StringUtils.equalsIgnoreCase("TRUE", map2.get("SUCCESS").toString())) {
String qrurl = map2.get("QRURL").toString();
try {
String utf8String = URLDecoder.decode(qrurl, "UTF-8");
return utf8String;
} catch (Exception e) {
log.info("转码异常:{}", qrurl);
}
} else {
throw new BusinessException("第二步请求失败: {} " + map2);
}
}
} else {
throw new BusinessException("第一步请求失败: " + step1Response);
}
return step2Response;
}
/**
* 发送GET请求
*/
private static String sendGetRequest(String url) {
String result = null;
try {
result = HttpUtil.createGet(url).execute().body();
} catch (Exception e) {
throw new BusinessException("第二步请求失败: " + url);
}
return result;
}
}4.银行服务器反馈POST
在银行的商户服务平台配置网上银行服务器反馈地址(POST请求),网上银行页面反馈地址(GET)请求。后端服务接口要免登录放行。
反馈接口代码示例(Controller):
/**
* 服务器反馈 Post
*/
@PostMapping("/payCallBack")
public AjaxResult payCallBack(HttpServletRequest request) {
log.info("支付服务器回调,服务器反馈 start!");
Map<String, String> map = Maps.newHashMap();
Enumeration<String> parameterNames = request.getParameterNames();
// 通过request获取返回信息,构建map
while (parameterNames.hasMoreElements()) {
String name = parameterNames.nextElement();
String value = request.getParameter(name);
map.put(name, value);
}
log.info("request 回调信息=====》{}", map);
PayCallBackEntity payCallBackEntity = JSONObject.parseObject(JSONObject.toJSONString(map), PayCallBackEntity.class);
log.info("payCallBackEntity:{}",payCallBackEntity);
try {
return AjaxResult.success(Constants.SUCCESS_MSG, ccbService.payCallBack(payCallBackEntity));
} catch (Exception e) {
log.error("支付回调异常!", e);
return AjaxResult.error("支付回调异常!");
}
}核心实现方法
import CCBSign.RSASig;
import com.alibaba.fastjson.JSON;
import com.sungrow.power.common.conf.CcbConfig;
import com.sungrow.power.common.exception.BusinessException;
import com.sungrow.power.common.utils.StringUtils;
import com.sungrow.power.domain.pay.PayCallBackEntity;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
/**
* 银行商户接口完整回调实现(含RSA验签)
*/
@Slf4j
@Component
public class CcbCallbackClient {
@Autowired
private CcbConfig ccbConfig;
public Map<String, String> ccbCallBack(PayCallBackEntity payCallBackEntity) {
log.info("回调请求开始:{}", JSON.toJSONString(payCallBackEntity));
String orderNum = payCallBackEntity.getORDERID();
String src = getSb(payCallBackEntity);
RSASig rsaSig = new RSASig();
// 验签
rsaSig.setPublicKey(ccbConfig.getAllPub());
// 验签结果
log.info("验签SIGN:{},src:{}", payCallBackEntity.getSIGN(), src);
boolean signResult = rsaSig.verifySigature(payCallBackEntity.getSIGN(), src);
if (!signResult) {
throw new BusinessException("验签结果失败: " + signResult);
}
log.info("验签成功!");
String success = payCallBackEntity.getSUCCESS();
if (!StringUtils.equals(success, "Y")) {
throw new BusinessException("支付失败!");
}
Map<String, String> result = new HashMap<>();
String money = payCallBackEntity.getPAYMENT();
result.put("orderNum", orderNum);
result.put("money", money);
return result;
}
private String getSb(PayCallBackEntity payCallBackEntity) {
StringBuilder sb = new StringBuilder();
try {
appendMacParam(sb, "POSID", payCallBackEntity.getPOSID());
appendMacParam(sb, "BRANCHID", payCallBackEntity.getBRANCHID());
appendMacParam(sb, "ORDERID", payCallBackEntity.getORDERID());
appendMacParam(sb, "PAYMENT", payCallBackEntity.getPAYMENT());
appendMacParam(sb, "CURCODE", payCallBackEntity.getCURCODE());
appendMacParam(sb, "REMARK1", payCallBackEntity.getREMARK1());
appendMacParam(sb, "REMARK2", payCallBackEntity.getREMARK2());
// 可选字段处理(有值才参与)
if (StringUtils.isNotBlank(payCallBackEntity.getACC_TYPE())) {
appendMacParam(sb, "ACC_TYPE", payCallBackEntity.getACC_TYPE());
}
appendMacParam(sb, "SUCCESS", payCallBackEntity.getSUCCESS());
if (StringUtils.isNotBlank(payCallBackEntity.getTYPE())) {
appendMacParam(sb, "TYPE", payCallBackEntity.getTYPE());
}
// 可选字段处理(有值才参与)
if (StringUtils.isNotBlank(payCallBackEntity.getREFERER())) {
appendMacParam(sb, "REFERER", payCallBackEntity.getREFERER());
}
if (StringUtils.isNotBlank(payCallBackEntity.getCLIENTIP())) {
appendMacParam(sb, "CLIENTIP", payCallBackEntity.getCLIENTIP());
}
// 可选字段处理(有值才参与)
if (StringUtils.isNotBlank(payCallBackEntity.getACCDATE())) {
appendMacParam(sb, "ACCDATE", payCallBackEntity.getACCDATE());
}
// 可选字段处理(有值才参与)
if (StringUtils.isNotBlank(payCallBackEntity.getINSTALLNUM())) {
appendMacParam(sb, "INSTALLNUM", payCallBackEntity.getINSTALLNUM());
}
// 可选字段处理(有值才参与)
if (StringUtils.isNotBlank(payCallBackEntity.getERRMSG())) {
appendMacParam(sb, "ERRMSG", payCallBackEntity.getERRMSG());
}
// 可选字段处理(有值才参与)
if (StringUtils.isNotBlank(payCallBackEntity.getUSRINFO())) {
appendMacParam(sb, "USRINFO", payCallBackEntity.getUSRINFO());
}
} catch (Exception e) {
e.printStackTrace();
}
log.info("src:{}", sb.toString());
return sb.toString();
}
private static void appendMacParam(StringBuilder sb, String key, String value) {
if (value == null) value = "";
if (sb.length() > 0) {
sb.append('&');
}
sb.append(key).append('=').append(value);
}
}验签中发生的问题
1.验签需要银行提供的netpay.jar包

2.pom文件问题
<dependency>
<groupId>com.xxx</groupId>
<artifactId>netpay</artifactId>
<version>1.0.0</version>
<scope>system</scope>
<systemPath>${project.basedir}/src/main/resources/lib/netpay.jar</systemPath>
</dependency>中间发现打包的时候maven没有包含netpay.jar,缺少下面配置
<execution>
<id>copy-dependencies-system</id>
<goals>
<goal>copy-dependencies</goal>
</goals>
<configuration>
<includeScope>system</includeScope>
<excludeTransitive>true</excludeTransitive>
<outputDirectory>${project.build.directory}/lib</outputDirectory>
</configuration>
</execution>
<manifestEntries>
<Class-Path>./lib/netpay-1.0.0.jar</Class-Path>
</manifestEntries>后续
调试过程中发现银行部分支付没有反馈请求到我们服务器上,后续猜测可能是建行回调通知调用HTTPS接口使用的是TLS版本过低,后续把我们阿里云服务器TLS支持扩展到TLS1、TLS1.1、TLS1.2、TLS1.3、SSLv2和SSLv3,后面都能收到回调了!(大坑!!)