中国建设银行个人网银商户二维码接口含服务器回调(详细版)

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,后面都能收到回调了!(大坑!!)

举报