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();
}
最后小结
我感觉其实没有很难,就是出了问题也不知道是谁的问题哈哈哈。
有什么问题欢迎留意交流。