全网首篇!微信委托代扣的接入记录

449 阅读11分钟

1.准备工作

需要先到官网去申请模板、权限之类的东西,听说这个东西现在申请起来很麻烦,因此公司是找了第三方公司去申请,具体怎么申请我就不在这里阐述了,有需要可以自行通过官方文档去查看流程。

微信委托代扣接入文档

2.进入开发

开始正题之前,不得不吐槽一下,微信的开发文档是真的糙啊,开这个文档开发,真的让人抓狂。 废话不多说,直接上代码说明。

另外,此次记录都是针对APP的接入。

多说一句,此次我并没有引入相关依赖,都是直接进行http请求的。

还有一个注意点,微信委托代扣只有v2版本,v3版本有些功能不全。现在微信纯支付基本都使用v3版本了。

有几个参数需要用到的,特意拿出来说一下

appId(应用Id)、mchId(商户Id)、planId(模板Id)、apiV2Key(加密需要用到)

2.1 纯签约

纯签约其实就是先调用微信的接口,获取一个预签约Id,然后返回给客户端那边拉起签约,签约成功后会根据你配置的notify_url进行回调,不能有参数,通过Request获取对应的返回结果。

注意:只有签约成功才会有回调。


public static String preSign(WxDeductionDTO wxDeductionDTO, String realName, String contractCode) {

    //wxDeductionDTO:appId、mchId、planId、apiV2Key
 
    // 构造请求参数
    Map<String, Object> params = new HashMap<>();
    //应用id
    params.put("appid", wxDeductionDTO.getAppId());
    //商户号
    params.put("mch_id", wxDeductionDTO.getMchId());
    //模板id
    params.put("plan_id", wxDeductionDTO.getPlanId());
    //商户侧的签约协议号,由商户生成,只能是数字、大小写字母的描述。(后续解约可以通过协议号加模板id解约)
    params.put("contract_code", contractCode);
    //商户请求签约时的序列号,要求唯一性。禁止使用0开头,序列号主要用于排序,不作为查询条件,纯数字,范围不能超过int64的范围(9223372036854775807)。示例值:1000
    params.put("request_serial", generateRandomNumber());
    //签约用户的名称,用于页面展示,参数值不支持UTF8非3字节编码的字符,例如表情符号,所以请勿传微信昵称到该字段
    //示例值:微信代扣
    params.put("contract_display_account", realName);
    //用于接收签约成功消息的回调通知地址,以http或https开头,通知url必须为外网可访问的url,不能携带参数。(目前是预签约,不是签约)
    params.put("notify_url", SIGN_SUCCESS_NOTIFY_URL);
    params.put("version", "1.0");
    //MD5或者HMAC-SHA256,不填默认MD5
    //注意:app预签约的默认签名方式是(sign_type为空或无该字段):MD5
    params.put("sign_type", "MD5");
    //系统当前时间,10位
    params.put("timestamp", System.currentTimeMillis() / 1000 + "");
    //用来控制签约页面结束后的返回路径(不传此参数,则签约完成后停留在微信内)。
    //Y表示返回app, 不填则不返回 注:签约参数appid必须为发起签约的app所有,且在微信开放平台注册过。
    //ios和android用户点完成按钮才返回原APP,直接右划关闭页面或左上角不支持返回APP
    //示例值:Y
    params.put("return_app", "Y");
    params.put("sign", generateMD5Sign(params, wxDeductionDTO.getApiKeyV2()));
    
    //v2支付都是使用的xml的传参格式,所以需要根据参数构建一个xml数据
    String xmlParam = buildXML(params);
    String urlString = "https://api.mch.weixin.qq.com/papay/preentrustweb";
    //此处就是发起http请求了,会返回xml数据,我将他转成了json格式。
    JSONObject respJson = sendPostRequest(xmlParam, urlString);

2.2 支付中签约

这个我们才是我们日常见到最多的,连续包月用的(ps:专门靠遗忘赚米)

支付中签约的步骤:

a.获取预支付Id
b.组装客户端需要的参数,签名返回,客户端拉起支付。

这里面有两个回调需要设置,一个是支付成功的回调、一个是签约成功的回调,他们是相互独立的。 开发的时候,要注意返回结果,写的时候可以去文档看看注意点,我就不在这里啰嗦了。

吐槽一句: 一定一定要确认好appId有没有配置好,我就是不知道,搞了好久,每次都能拿到预支付Id,客户端拉起支付就报错 -1,说什么签约、appid之类的错误。我的签名跟微信生成工具的一模一样。搞了一百年都不行,没办法只能找申请公司的人看看是什么问题了,也没说什么,下午突然就可以了。什么都没改的情况下,你说是谁的问题,不用我说了吧哈哈哈。

public static String payingSign(WxDeductionDTO wxDeductionDTO, String tradeNo,String contractCode, Long userId, String realName ,Integer totalFee, String ipv4){
        // 构造请求参数
        Map<String, Object> params = new HashMap<>();
        //应用id
        params.put("appid", wxDeductionDTO.getAppId());
        //商户号
        params.put("mch_id", wxDeductionDTO.getMchId());
        //模板id
        params.put("plan_id", wxDeductionDTO.getPlanId());
        //签约商户号,必须与mch_id一致
        params.put("contract_mchid", wxDeductionDTO.getMchId());
        //签约公众号,必须与appid一致
        params.put("contract_appid", wxDeductionDTO.getAppId());
        //商户系统内部的订单号,32个字符内、可包含字母
        params.put("out_trade_no", tradeNo);
        //终端设备号(门店号或收银设备ID),注意:PC网页或公众号内支付请传"WEB",先不传
//        params.put("device_info", "");
        //随机字符串,不长于32位.
        params.put("nonce_str", RandomStringUtils.random16());
        //商品或支付单简要描述
        params.put("body", "超级vip");
        //商品名称明细列表,非必传,暂时先不传
//        params.put("detail", "");
        //附加数据,在查询API和支付通知中原样返回,该字段主要用于商户携带订单的自定义数据,非必传,暂时先不传
        params.put("attach", userId.toString());
        //支付回调通知地址,以http或https开头,通知url必须为外网可访问的url,不能携带参数。
        params.put("notify_url", PAY_SUCCESS_NOTIFY_URL);
        //总金额,单位分
        params.put("total_fee", totalFee.toString());
        //用户的客户端ip,仅支持ipv4
        params.put("spbill_create_ip", ipv4);
        //订单生成时间,订单有效时间
        params.put("time_start", LocalDateTimeUtil.format(LocalDateTime.now(), "yyyyMMddHHmmss"));
        params.put("time_expire", LocalDateTimeUtil.format(LocalDateTime.now().plusHours(2), "yyyyMMddHHmmss"));
        //交易类型 JSAPI,NATIVE,APP,MWEB
        params.put("trade_type", "APP");
        //商品ID product_id,trade_type=NATIVE,此参数必传. 此id为二维码中包含的商品ID,商户自行定义.
        //用户标识 openid,trade_type=JSAPI,此参数必传,用户在商户appid下的唯一标识.
//        params.put("openid", "");
        //商户侧的签约协议号,由商户生成,只能是数字、大小写字母的描述。(后续解约可以通过协议号加模板id解约)
        params.put("contract_code", contractCode);
        //商户请求签约时的序列号,要求唯一性。禁止使用0开头,序列号主要用于排序,不作为查询条件,纯数字,范围不能超过int64的范围(9223372036854775807)。示例值:1000
        params.put("request_serial", generateRandomNumber());
        //签约用户的名称,用于页面展示,参数值不支持UTF8非3字节编码的字符,例如表情符号,所以请勿传微信昵称到该字段
        //示例值:微信代扣
        params.put("contract_display_account", realName);
        //签约信息回调通知的url,以http或https开头,通知url必须为外网可访问的url,不能携带参数。
        params.put("contract_notify_url", SIGN_SUCCESS_NOTIFY_URL);
        //签名
        params.put("sign", generateMD5Sign(params, wxDeductionDTO.getApiKeyV2()));


        JSONObject respJson = sendPostRequest(buildXML(params), 
        
    }

2.3 申请扣款

申请扣款的话,我感觉也没啥问题,一般就过期前一天发起申请吧。

签约12小时内发起的话,会马上扣款,超过的话,会发通知,然后再扣款

这里看似可以自定义扣款金额,实际上是有约束的,申请模板的时候,允许第一个月可以优惠点,第二个月基本都是要根据模板配置的金额来,不然后续会有风控的问题。

public static JSONObject applyPay(WxDeductionDTO wxDeductionDTO, String tradeNo, Long userId, String contractId ,Integer totalFee) {
        // 构造请求参数
        Map<String, Object> params = new HashMap<>();
        //应用id
        params.put("appid", wxDeductionDTO.getAppId());
        //商户号
        params.put("mch_id", wxDeductionDTO.getMchId());

        //随机字符串,不长于32位.
        params.put("nonce_str", RandomStringUtils.random16());
        //商品或支付单简要描述
        params.put("body", "超级vip");
        //商品名称明细列表,非必传,暂时先不传
//        params.put("detail", "");
        //附加数据,在查询API和支付通知中原样返回,该字段主要用于商户携带订单的自定义数据,非必传,暂时先不传
        params.put("attach", userId.toString());
        //商户系统内部订单号,要求32个字符内,只能是数字、大小写字母_-|* 且在同一个商户号下唯一
        params.put("out_trade_no", tradeNo);
        //总金额,单位分
        params.put("total_fee", totalFee.toString());
        //货币类型
        params.put("fee_type", "CNY");
        //用户的客户端ip,仅支持ipv4,非必传
//        params.put("spbill_create_ip", ipv4);
        //回调通知(使用支付成功回调,后续有问题再修改)
        params.put("notify_url", PAY_SUCCESS_NOTIFY_URL);
        //交易类型 JSAPI,NATIVE,APP,MWEB
        params.put("trade_type", "APP");
        //委托代购协议id,签约成功后微信回调会返回
        params.put("contract_id", contractId);
        //签名
        params.put("sign", generateMD5Sign(params, wxDeductionDTO.getApiKeyV2()));

        //150qps,即每秒钟正常的申请扣款请求次数不超过150次。通过定时器处理,按照150进行分组,每组睡眠1秒
        JSONObject respJson = sendPostRequest(buildXML(params), "https://api.mch.weixin.qq.com/pay/pappayapply");

2.4 申请解约

没啥好说的

public static Boolean deleteContract(WxDeductionDTO wxDeductionDTO, String contractId){
    // 构造请求参数
    Map<String, Object> params = new HashMap<>();
    //应用id
    params.put("appid", wxDeductionDTO.getAppId());
    //商户号
    params.put("mch_id", wxDeductionDTO.getMchId());
    //协议Id
    params.put("contract_id", contractId);
    //解约备注
    params.put("contract_termination_remark", "用户主动解约");
    //版本号
    params.put("version", "1.0");
    params.put("sign", generateMD5Sign(params, wxDeductionDTO.getApiKeyV2()));

    JSONObject dataJson = sendPostRequest(buildXML(params), "https://api.mch.weixin.qq.com/papay/deletecontract");

2.5 申请退款

没用上

public static JSONObject applyRefund(WxDeductionDTO wxDeductionDTO,String transactionId, String tradeNo, String refundNo,Integer totalFee, Integer refundFee, String refundReason) {
    // 构造请求参数
    Map<String, Object> params = new HashMap<>();
    //应用id
    params.put("appid", wxDeductionDTO.getAppId());
    //商户号
    params.put("mch_id", wxDeductionDTO.getMchId());

    //随机字符串,不长于32位.
    params.put("nonce_str", RandomStringUtils.random16());
    //微信生成的订单号,在支付通知中有返回
    //商户系统内部订单号,transaction_id、out_trade_no二选一,如果同时存在优先级:transaction_id> out_trade_no
    if (StrUtil.isNotBlank(transactionId)){
        params.put("transaction_id", transactionId);
    }else {
        if (StrUtil.isNotBlank(tradeNo)) {
            params.put("out_trade_no", tradeNo);
        }else {
            return null;
        }
    }
    //商户系统内部的退款单号,商户系统内部唯一,只能是数字、大小写字母_-|*@ ,同一退款单号多次请求只退一笔。
    params.put("out_refund_no", refundNo);
    //订单总金额,单位为分,只能为整数
    params.put("total_fee", totalFee.toString());
    //退款金额,单位为分,只能为整数,必须小于等于订单金额
    params.put("refund_fee", refundFee.toString());
    //非必填:
    // refund_fee_type;退款货币类型,需与支付一致,或者不填。符合ISO 4217标准的三位字母代码,默认人民币:CNY
    // refund_desc:退款原因,若商户传入,会在下发给用户的退款消息中体现退款原因,注意:若订单退款金额≤1元,且属于部分退款,则不会在退款消息中体现退款原因
    // refund_account:退款资金来源仅针,对老资金流商户使用,REFUND_SOURCE_UNSETTLED_FUNDS:未结算资金退款(默认使用未结算资金退款)、REFUND_SOURCE_RECHARGE_FUNDS:可用余额退款
    // notify_url:退款结果通知url,异步接收微信支付退款结果通知的回调地址,通知URL必须为外网可访问的url,不允许带参数
    // 注意:如果参数中传了notify_url,则商户平台上配置的回调地址将不会生效。
    params.put("refund_desc", refundReason);
    params.put("notify_url", REFUND_SUCCESS_NOTIFY_URL);
    params.put("sign",generateMD5Sign(params, wxDeductionDTO.getApiKeyV2()));
    return sendPostRequest(buildXML(params),"https://api.mch.weixin.qq.com/secapi/pay/refund");

2.6 查询退款结果

public static JSONObject queryRefundResult(WxDeductionDTO wxDeductionDTO, String refundTradeNo) {
    // 构造请求参数
    Map<String, Object> params = new HashMap<>();
    //应用id
    params.put("appid", wxDeductionDTO.getAppId());
    //商户号
    params.put("mch_id", wxDeductionDTO.getMchId());
    //随机字符串
    params.put("nonce_str", RandomStringUtils.random16());
    //退款订单号,可在transaction_id、out_trade_no、out_refund_no、refund_id中选择一个作为查询条件,优先级为:transaction_id> out_trade_no> out_refund_no> refund_id
    params.put("out_refund_no", refundTradeNo);
    //签名
    params.put("sign", generateMD5Sign(params, wxDeductionDTO.getApiKeyV2()));
    return sendPostRequest(buildXML(params), "https://api.mch.weixin.qq.com/pay/refundquery");
}

2.7 其他方法

/**
     * 构建xml字符串请求参数
     * @param xmlMap
     * @return
     */
    public static String buildXML(Map<String, Object> xmlMap) {
        StringBuilder xmlBuilder = new StringBuilder();
        xmlBuilder.append("<xml>");
        for (Map.Entry<String, Object> entry : xmlMap.entrySet()) {
            xmlBuilder.append("<").append(entry.getKey()).append(">").append(entry.getValue().toString()).append("</").append(entry.getKey()).append(">");
        }
        xmlBuilder.append("</xml>");
        return xmlBuilder.toString();
    }

    /**
     * 构建签名
     * @param params
     * @return
     */
    public static String generateMD5Sign(Map<String, Object> params, String apiKeyV2) {
        TreeMap<String, Object> sortedParams = new TreeMap<>(params);
        StringBuilder sb = new StringBuilder();
        for (Map.Entry<String, Object> entry : sortedParams.entrySet()) {
            if (!"sign".equals(entry.getKey()) && entry.getValue() != null && !entry.getValue().toString().isEmpty()) {
                sb.append(entry.getKey()).append("=").append(entry.getValue().toString()).append("&");
            }
        }
        //打印签名参数,方便比对
        log.info("key=value的格式:{}" , sb);
        sb.append("key=").append(apiKeyV2);
        log.info("连接密钥key:{}" , sb);

        String md5Hex = DigestUtil.md5Hex(sb.toString()).toUpperCase();
        log.info("MD5加密结果:{}" , md5Hex);

        return md5Hex;
    }



    public static JSONObject sendPostRequest(String xmlData, String urlString) {
        try {
            // 使用HttpUtil.post发送请求
            HttpResponse response = HttpUtil.createPost(urlString)
                    .header("Content-Type", "text/xml; charset=UTF-8")
                    .body(xmlData, "UTF-8")
                    .execute();

            int responseCode = response.getStatus();
            log.info("Response Code: {}", responseCode);

            if (responseCode == 200) {
                String responseBody = response.body();
                JSONObject jsonObject = parseJSONFromXml(responseBody);
                log.info("响应数据: {}", jsonObject);
                return jsonObject;
            } else {
                log.error("请求失败,状态码: {}", responseCode);
                log.error("错误信息: {}", response.body());
                return null;
            }
        } catch (Exception e) {
            log.error("请求异常,原因:{}", e.getMessage(), e);
            return null;
        }
    }

    /**
     * 解析xml数据
     * @param xmlData
     * @return
     */
    public static JSONObject parseJSONFromXml(String xmlData) {
        return JSONUtil.parseFromXml(xmlData.replace("<xml>", "").replace("</xml>", ""));
    }


    /**
     * 签名检验
     * @param jsonData
     * @return
     */
    public static Boolean signatureValid(JSONObject jsonData, String apiV2Key) {
        log.info("回调json数据:{}, 加密key:{}", jsonData, apiV2Key);
        Map<String, Object> paramMap = JSONUtil.toBean(jsonData, Map.class);
        try {
            String md5Sign = generateMD5Sign(paramMap, apiV2Key);
            if (md5Sign.equals(paramMap.get("sign"))) {
                return Boolean.TRUE;
            }
            return Boolean.FALSE;
        }catch (Exception e) {
            log.error("签名验证异常,原因:", e.getMessage());
            return Boolean.FALSE;
        }

    }

    /**
     * 生成随机数纯数字,首位不能为0
     * @return
     */
    public static String generateRandomNumber() {
        SecureRandom random = new SecureRandom();
        StringBuilder sb = new StringBuilder(DEFAULT_LENGTH);

        // 首位不能为0,随机选择一个1-9的数字
        sb.append(random.nextInt(9) + 1);

        // 生成剩余的length - 1位数字
        for (int i = 1; i < DEFAULT_LENGTH; i++) {
            sb.append(random.nextInt(10));
        }

        return sb.toString();
    }

最后小结

我感觉其实没有很难,就是出了问题也不知道是谁的问题哈哈哈。

有什么问题欢迎留意交流。