feat(payment): 升级微信商家转账接口为新版API

- 将批量转账接口替换为商家转账(升级版)接口 /v3/fund-app/mch-transfer/transfer-bills
- 新增 transfer_scene_id 和场景报备信息配置支持
- 参数从 outBatchNo/outDetailNo 统一为 outBillNo 单号
- 添加商户单号长度限制校验(5-32字符)
- 支持接口路径fallback机制,兼容不同环境差异
- 实现转账场景报备信息的JSON配置解析功能
- 更新日志记录格式以匹配新接口响应结构
```
This commit is contained in:
2026-01-29 02:39:24 +08:00
parent 89177db718
commit 4c290ea4fe
2 changed files with 181 additions and 65 deletions

View File

@@ -4,47 +4,80 @@ import cn.hutool.core.util.StrUtil;
import com.gxwebsoft.common.system.entity.Payment; import com.gxwebsoft.common.system.entity.Payment;
import com.gxwebsoft.payment.exception.PaymentException; import com.gxwebsoft.payment.exception.PaymentException;
import com.wechat.pay.java.core.Config; import com.wechat.pay.java.core.Config;
import com.wechat.pay.java.service.transferbatch.TransferBatchService; import com.wechat.pay.java.core.cipher.PrivacyEncryptor;
import com.wechat.pay.java.service.transferbatch.model.InitiateBatchTransferRequest; import com.wechat.pay.java.core.http.Constant;
import com.wechat.pay.java.service.transferbatch.model.InitiateBatchTransferResponse; import com.wechat.pay.java.core.http.DefaultHttpClientBuilder;
import com.wechat.pay.java.service.transferbatch.model.TransferDetailInput; import com.wechat.pay.java.core.http.HttpClient;
import com.wechat.pay.java.core.http.HttpHeaders;
import com.wechat.pay.java.core.http.HttpMethod;
import com.wechat.pay.java.core.http.HttpRequest;
import com.wechat.pay.java.core.http.HttpResponse;
import com.wechat.pay.java.core.http.JsonRequestBody;
import com.wechat.pay.java.core.http.MediaType;
import com.wechat.pay.java.core.exception.ServiceException;
import com.wechat.pay.java.core.util.GsonUtil;
import com.google.gson.reflect.TypeToken;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import javax.annotation.Resource; import javax.annotation.Resource;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
import java.util.Collections; import java.lang.reflect.Type;
import java.util.List;
/** /**
* 微信支付-商家转账到零钱(批量转账)封装 * 微信支付-商家转账到零钱封装
* 使用 wechatpay-java (APIv3) SDK 发起转账。 *
* 注意:部分商户号开通了“商家转账(升级版)”后,会被微信侧限制使用旧版“批量转账到零钱”接口(/v3/transfer/batches
* 需改用升级版接口(/v3/fund-app/mch-transfer/transfer-bills
*/ */
@Slf4j @Slf4j
@Service @Service
public class WxTransferService { public class WxTransferService {
// 商家转账(升级版)接口在 fund-app 域下
private static final String TRANSFER_BILLS_API =
"https://api.mch.weixin.qq.com/v3/fund-app/mch-transfer/transfer-bills";
// 兼容少数文档/环境差异:如 fund-app 路径不可用时,尝试该路径(仅在 404 时重试)
private static final String TRANSFER_BILLS_API_FALLBACK =
"https://api.mch.weixin.qq.com/v3/transfer/bills";
@Resource @Resource
private WxPayConfigService wxPayConfigService; private WxPayConfigService wxPayConfigService;
/** /**
* 发起单笔“商家转账到零钱”(使用批量转账接口,明细数=1 * 商家转账升级版场景ID。直连模式下在“产品中心-商家转账”配置后获得
*/
@Value("${wechatpay.transfer.scene-id:1005}")
private String transferSceneId;
/**
* 转账场景报备信息(升级版必填项之一,内容需与商户平台该 transfer_scene_id 的报备信息对应)。
*
* 配置示例YAML
* wechatpay:
* transfer:
* scene-report-infos-json: '[{"info_type":"...","info_content":"..."}]'
*/
@Value("${wechatpay.transfer.scene-report-infos-json:}")
private String transferSceneReportInfosJson;
/**
* 发起单笔“商家转账到零钱”(升级版接口 /v3/fund-app/mch-transfer/transfer-bills
* *
* @param tenantId 租户ID用于获取微信支付配置 * @param tenantId 租户ID用于获取微信支付配置
* @param openid 收款用户openid必须是该appid下的openid * @param openid 收款用户openid必须是该appid下的openid
* @param amountYuan 转账金额(单位:元) * @param amountYuan 转账金额(单位:元)
* @param outBatchNo 商家批次单号(字母/数字,商户内唯一) * @param outBillNo 商家单号(字母/数字,商户内唯一)
* @param outDetailNo 商家明细单号(字母/数字,批次内唯一 * @param remark 备注(<=32字符
* @param batchName 批次名称(<=32字符
* @param remark 备注(<=32字符既用于批次备注也用于单条明细备注
* @param userName 收款用户姓名(可选;金额>=2000元时强制要求 * @param userName 收款用户姓名(可选;金额>=2000元时强制要求
*/ */
public InitiateBatchTransferResponse initiateSingleTransfer(Integer tenantId, public TransferBillsResponse initiateSingleTransfer(Integer tenantId,
String openid, String openid,
BigDecimal amountYuan, BigDecimal amountYuan,
String outBatchNo, String outBillNo,
String outDetailNo,
String batchName,
String remark, String remark,
String userName) throws PaymentException { String userName) throws PaymentException {
@@ -57,18 +90,15 @@ public class WxTransferService {
if (amountYuan == null || amountYuan.compareTo(BigDecimal.ZERO) <= 0) { if (amountYuan == null || amountYuan.compareTo(BigDecimal.ZERO) <= 0) {
throw PaymentException.amountError("转账金额必须大于0"); throw PaymentException.amountError("转账金额必须大于0");
} }
if (StrUtil.isBlank(outBatchNo) || !outBatchNo.matches("^[0-9A-Za-z]+$")) { if (StrUtil.isBlank(transferSceneId)) {
throw PaymentException.paramError("outBatchNo不合法仅允许数字/大小写字母"); throw PaymentException.paramError("transfer_scene_id未配置无法发起商家转账升级版");
} }
// 微信接口限制:商家批次单号长度需在区间内(当前实测最小 5 if (StrUtil.isBlank(outBillNo) || !outBillNo.matches("^[0-9A-Za-z]+$")) {
if (outBatchNo.length() < 5 || outBatchNo.length() > 32) { throw PaymentException.paramError("outBillNo不合法仅允许数字/大小写字母)");
throw PaymentException.paramError("outBatchNo长度不合法要求 5-32");
} }
if (StrUtil.isBlank(outDetailNo) || !outDetailNo.matches("^[0-9A-Za-z]+$")) { // 保守校验:多数微信单号字段限制在 5-32你此前 out_batch_no 也是 5-32
throw PaymentException.paramError("outDetailNo不合法仅允许数字/大小写字母)"); if (outBillNo.length() < 5 || outBillNo.length() > 32) {
} throw PaymentException.paramError("outBillNo长度不合法要求 5-32");
if (outDetailNo.length() > 32) {
throw PaymentException.paramError("outDetailNo长度不合法最大 32");
} }
// 微信要求金额单位为“分”,必须为整数 // 微信要求金额单位为“分”,必须为整数
@@ -94,38 +124,72 @@ public class WxTransferService {
Payment paymentConfig = wxPayConfigService.getPaymentConfigForStrategy(tenantId); Payment paymentConfig = wxPayConfigService.getPaymentConfigForStrategy(tenantId);
Config wxPayConfig = wxPayConfigService.getWxPayConfig(tenantId); Config wxPayConfig = wxPayConfigService.getWxPayConfig(tenantId);
InitiateBatchTransferRequest request = new InitiateBatchTransferRequest(); try {
request.setAppid(paymentConfig.getAppId()); HttpClient httpClient = new DefaultHttpClientBuilder().config(wxPayConfig).build();
request.setOutBatchNo(outBatchNo); PrivacyEncryptor encryptor = wxPayConfig.createEncryptor();
request.setBatchName(limitLen(batchName, 32));
request.setBatchRemark(limitLen(remark, 32));
request.setTotalAmount(amountFen);
request.setTotalNum(1);
// 可选:复用支付通知地址(如配置为 https用于接收转账结果通知 TransferBillsRequest request = new TransferBillsRequest();
request.setAppid(paymentConfig.getAppId());
request.setOutBillNo(outBillNo);
request.setTransferSceneId(transferSceneId);
request.setOpenid(openid);
request.setTransferAmount(amountFen);
request.setTransferRemark(limitLen(remark, 32));
List<TransferSceneReportInfo> sceneReportInfos = parseTransferSceneReportInfos();
if (sceneReportInfos != null && !sceneReportInfos.isEmpty()) {
request.setTransferSceneReportInfos(sceneReportInfos);
}
// 可选:转账结果通知地址(必须 https
if (StrUtil.isNotBlank(paymentConfig.getNotifyUrl()) && paymentConfig.getNotifyUrl().startsWith("https://")) { if (StrUtil.isNotBlank(paymentConfig.getNotifyUrl()) && paymentConfig.getNotifyUrl().startsWith("https://")) {
request.setNotifyUrl(paymentConfig.getNotifyUrl()); request.setNotifyUrl(paymentConfig.getNotifyUrl());
} }
TransferDetailInput detail = new TransferDetailInput(); // 需要姓名时按平台证书加密,并带上 Wechatpay-Serial
detail.setOutDetailNo(outDetailNo);
detail.setTransferAmount(amountFen);
detail.setTransferRemark(limitLen(remark, 32));
detail.setOpenid(openid);
if (StrUtil.isNotBlank(userName)) { if (StrUtil.isNotBlank(userName)) {
detail.setUserName(userName); request.setUserName(encryptor.encrypt(userName));
} }
request.setTransferDetailList(Collections.singletonList(detail));
HttpHeaders headers = new HttpHeaders();
headers.addHeader(Constant.ACCEPT, MediaType.APPLICATION_JSON.getValue());
headers.addHeader(Constant.CONTENT_TYPE, MediaType.APPLICATION_JSON.getValue());
headers.addHeader(Constant.WECHAT_PAY_SERIAL, encryptor.getWechatpaySerial());
HttpRequest httpRequest = buildTransferBillsHttpRequest(TRANSFER_BILLS_API, headers, request);
HttpResponse<TransferBillsResponse> httpResponse;
try { try {
TransferBatchService service = new TransferBatchService.Builder().config(wxPayConfig).build(); httpResponse = httpClient.execute(httpRequest, TransferBillsResponse.class);
InitiateBatchTransferResponse response = service.initiateBatchTransfer(request); } catch (ServiceException se) {
log.info("微信商家转账已受理: tenantId={}, outBatchNo={}, batchId={}, batchStatus={}", // 404 且无 body 通常意味着接口路径不正确;做一次兜底重试。
tenantId, response.getOutBatchNo(), response.getBatchId(), response.getBatchStatus()); if (se.getHttpStatusCode() == 404) {
log.warn("商家转账接口返回404尝试fallback路径: tenantId={}, outBillNo={}, url={}",
tenantId, outBillNo, TRANSFER_BILLS_API_FALLBACK);
HttpRequest fallbackReq = buildTransferBillsHttpRequest(TRANSFER_BILLS_API_FALLBACK, headers, request);
httpResponse = httpClient.execute(fallbackReq, TransferBillsResponse.class);
} else if (se.getHttpStatusCode() == 400
&& "PARAM_ERROR".equals(se.getErrorCode())
&& se.getErrorMessage() != null
&& se.getErrorMessage().contains("转账场景报备信息")) {
// 常见:升级版商家转账需带 transfer_scene_report_infos且内容必须与商户平台场景报备一致
throw PaymentException.paramError(
"未传入完整且对应的转账场景报备信息:请在配置中设置 wechatpay.transfer.scene-report-infos-json需与 transfer_scene_id="
+ transferSceneId + " 的报备信息一致)");
} else {
throw se;
}
}
TransferBillsResponse response = httpResponse.getServiceResponse();
log.info("微信商家转账已受理(升级版): tenantId={}, outBillNo={}, transferBillNo={}, state={}",
tenantId, outBillNo,
response != null ? response.getTransferBillNo() : null,
response != null ? response.getState() : null);
return response; return response;
} catch (PaymentException e) {
// 业务/参数错误保持原样抛出,避免被包装成 systemError
throw e;
} catch (Exception e) { } catch (Exception e) {
log.error("微信商家转账失败: tenantId={}, outBatchNo={}, openid={}, amountFen={}, err={}", log.error("微信商家转账失败(升级版): tenantId={}, outBillNo={}, openid={}, amountFen={}, err={}",
tenantId, outBatchNo, openid, amountFen, e.getMessage(), e); tenantId, outBillNo, openid, amountFen, e.getMessage(), e);
throw PaymentException.systemError("微信商家转账失败: " + e.getMessage(), e); throw PaymentException.systemError("微信商家转账失败: " + e.getMessage(), e);
} }
} }
@@ -139,4 +203,59 @@ public class WxTransferService {
} }
return s.substring(0, maxLen); return s.substring(0, maxLen);
} }
private static HttpRequest buildTransferBillsHttpRequest(String url, HttpHeaders headers, TransferBillsRequest request) {
return new HttpRequest.Builder()
.httpMethod(HttpMethod.POST)
.url(url)
.headers(headers)
.body(new JsonRequestBody.Builder().body(GsonUtil.toJson(request)).build())
.build();
}
private List<TransferSceneReportInfo> parseTransferSceneReportInfos() throws PaymentException {
if (StrUtil.isBlank(transferSceneReportInfosJson)) {
return null;
}
try {
Type t = new TypeToken<List<TransferSceneReportInfo>>() {}.getType();
return GsonUtil.getGson().fromJson(transferSceneReportInfosJson, t);
} catch (Exception e) {
throw PaymentException.paramError("转账场景报备信息配置解析失败wechatpay.transfer.scene-report-infos-json");
}
}
/**
* 商家转账升级版请求体POST /v3/fund-app/mch-transfer/transfer-bills
* 字段命名使用 GsonUtil 的 lower_case_with_underscores 策略自动转换。
*/
@lombok.Data
private static class TransferBillsRequest {
private String appid;
private String outBillNo;
private String transferSceneId;
private String openid;
private Long transferAmount;
private String transferRemark;
private String userName;
private String notifyUrl;
private List<TransferSceneReportInfo> transferSceneReportInfos;
}
@lombok.Data
private static class TransferSceneReportInfo {
private String infoType;
private String infoContent;
}
/**
* 商家转账(升级版)响应体(按需字段)
*/
@lombok.Data
public static class TransferBillsResponse {
private String outBillNo;
private String transferBillNo;
private String createTime;
private String state;
}
} }

View File

@@ -138,10 +138,9 @@ public class ShopDealerWithdrawController extends BaseController {
return fail("用户openid为空无法发起微信转账"); return fail("用户openid为空无法发起微信转账");
} }
// 微信支付批量转账接口对 out_batch_no 有最小长度限制(当前为 >= 5 // 微信支付商家转账接口对商户单号通常有最小长度限制(当前实测为 >= 5
// 使用 0 填充,避免如 "WD59" 这种过短导致 PARAM_ERROR。 // 使用 0 填充,避免如 "WD59" 这种过短导致 PARAM_ERROR。
String outBatchNo = String.format("WD%03d", db.getId()); String outBillNo = String.format("WD%03d", db.getId());
String outDetailNo = outBatchNo + "D1";
String remark = "分销商提现"; String remark = "分销商提现";
String userName = db.getRealName(); String userName = db.getRealName();
@@ -150,9 +149,7 @@ public class ShopDealerWithdrawController extends BaseController {
tenantId, tenantId,
openid, openid,
db.getMoney(), db.getMoney(),
outBatchNo, outBillNo,
outDetailNo,
"分销商提现",
remark, remark,
userName userName
); );