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,49 +4,82 @@ import cn.hutool.core.util.StrUtil;
import com.gxwebsoft.common.system.entity.Payment;
import com.gxwebsoft.payment.exception.PaymentException;
import com.wechat.pay.java.core.Config;
import com.wechat.pay.java.service.transferbatch.TransferBatchService;
import com.wechat.pay.java.service.transferbatch.model.InitiateBatchTransferRequest;
import com.wechat.pay.java.service.transferbatch.model.InitiateBatchTransferResponse;
import com.wechat.pay.java.service.transferbatch.model.TransferDetailInput;
import com.wechat.pay.java.core.cipher.PrivacyEncryptor;
import com.wechat.pay.java.core.http.Constant;
import com.wechat.pay.java.core.http.DefaultHttpClientBuilder;
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 org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.math.BigDecimal;
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
@Service
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
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 openid 收款用户openid必须是该appid下的openid
* @param amountYuan 转账金额(单位:元)
* @param outBatchNo 商家批次单号(字母/数字,商户内唯一)
* @param outDetailNo 商家明细单号(字母/数字,批次内唯一
* @param batchName 批次名称(<=32字符
* @param remark 备注(<=32字符既用于批次备注也用于单条明细备注
* @param outBillNo 商家单号(字母/数字,商户内唯一)
* @param remark 备注(<=32字符
* @param userName 收款用户姓名(可选;金额>=2000元时强制要求
*/
public InitiateBatchTransferResponse initiateSingleTransfer(Integer tenantId,
String openid,
BigDecimal amountYuan,
String outBatchNo,
String outDetailNo,
String batchName,
String remark,
String userName) throws PaymentException {
public TransferBillsResponse initiateSingleTransfer(Integer tenantId,
String openid,
BigDecimal amountYuan,
String outBillNo,
String remark,
String userName) throws PaymentException {
if (tenantId == null) {
throw PaymentException.paramError("租户ID不能为空");
@@ -57,18 +90,15 @@ public class WxTransferService {
if (amountYuan == null || amountYuan.compareTo(BigDecimal.ZERO) <= 0) {
throw PaymentException.amountError("转账金额必须大于0");
}
if (StrUtil.isBlank(outBatchNo) || !outBatchNo.matches("^[0-9A-Za-z]+$")) {
throw PaymentException.paramError("outBatchNo不合法仅允许数字/大小写字母");
if (StrUtil.isBlank(transferSceneId)) {
throw PaymentException.paramError("transfer_scene_id未配置无法发起商家转账升级版");
}
// 微信接口限制:商家批次单号长度需在区间内(当前实测最小 5
if (outBatchNo.length() < 5 || outBatchNo.length() > 32) {
throw PaymentException.paramError("outBatchNo长度不合法要求 5-32");
if (StrUtil.isBlank(outBillNo) || !outBillNo.matches("^[0-9A-Za-z]+$")) {
throw PaymentException.paramError("outBillNo不合法仅允许数字/大小写字母)");
}
if (StrUtil.isBlank(outDetailNo) || !outDetailNo.matches("^[0-9A-Za-z]+$")) {
throw PaymentException.paramError("outDetailNo不合法仅允许数字/大小写字母)");
}
if (outDetailNo.length() > 32) {
throw PaymentException.paramError("outDetailNo长度不合法最大 32");
// 保守校验:多数微信单号字段限制在 5-32你此前 out_batch_no 也是 5-32
if (outBillNo.length() < 5 || outBillNo.length() > 32) {
throw PaymentException.paramError("outBillNo长度不合法要求 5-32");
}
// 微信要求金额单位为“分”,必须为整数
@@ -94,38 +124,72 @@ public class WxTransferService {
Payment paymentConfig = wxPayConfigService.getPaymentConfigForStrategy(tenantId);
Config wxPayConfig = wxPayConfigService.getWxPayConfig(tenantId);
InitiateBatchTransferRequest request = new InitiateBatchTransferRequest();
request.setAppid(paymentConfig.getAppId());
request.setOutBatchNo(outBatchNo);
request.setBatchName(limitLen(batchName, 32));
request.setBatchRemark(limitLen(remark, 32));
request.setTotalAmount(amountFen);
request.setTotalNum(1);
// 可选:复用支付通知地址(如配置为 https用于接收转账结果通知
if (StrUtil.isNotBlank(paymentConfig.getNotifyUrl()) && paymentConfig.getNotifyUrl().startsWith("https://")) {
request.setNotifyUrl(paymentConfig.getNotifyUrl());
}
TransferDetailInput detail = new TransferDetailInput();
detail.setOutDetailNo(outDetailNo);
detail.setTransferAmount(amountFen);
detail.setTransferRemark(limitLen(remark, 32));
detail.setOpenid(openid);
if (StrUtil.isNotBlank(userName)) {
detail.setUserName(userName);
}
request.setTransferDetailList(Collections.singletonList(detail));
try {
TransferBatchService service = new TransferBatchService.Builder().config(wxPayConfig).build();
InitiateBatchTransferResponse response = service.initiateBatchTransfer(request);
log.info("微信商家转账已受理: tenantId={}, outBatchNo={}, batchId={}, batchStatus={}",
tenantId, response.getOutBatchNo(), response.getBatchId(), response.getBatchStatus());
HttpClient httpClient = new DefaultHttpClientBuilder().config(wxPayConfig).build();
PrivacyEncryptor encryptor = wxPayConfig.createEncryptor();
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://")) {
request.setNotifyUrl(paymentConfig.getNotifyUrl());
}
// 需要姓名时按平台证书加密,并带上 Wechatpay-Serial
if (StrUtil.isNotBlank(userName)) {
request.setUserName(encryptor.encrypt(userName));
}
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 {
httpResponse = httpClient.execute(httpRequest, TransferBillsResponse.class);
} catch (ServiceException se) {
// 404 且无 body 通常意味着接口路径不正确;做一次兜底重试。
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;
} catch (PaymentException e) {
// 业务/参数错误保持原样抛出,避免被包装成 systemError
throw e;
} catch (Exception e) {
log.error("微信商家转账失败: tenantId={}, outBatchNo={}, openid={}, amountFen={}, err={}",
tenantId, outBatchNo, openid, amountFen, e.getMessage(), e);
log.error("微信商家转账失败(升级版): tenantId={}, outBillNo={}, openid={}, amountFen={}, err={}",
tenantId, outBillNo, openid, amountFen, e.getMessage(), e);
throw PaymentException.systemError("微信商家转账失败: " + e.getMessage(), e);
}
}
@@ -139,4 +203,59 @@ public class WxTransferService {
}
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为空无法发起微信转账");
}
// 微信支付批量转账接口对 out_batch_no 有最小长度限制(当前为 >= 5
// 微信支付商家转账接口对商户单号通常有最小长度限制(当前实测为 >= 5
// 使用 0 填充,避免如 "WD59" 这种过短导致 PARAM_ERROR。
String outBatchNo = String.format("WD%03d", db.getId());
String outDetailNo = outBatchNo + "D1";
String outBillNo = String.format("WD%03d", db.getId());
String remark = "分销商提现";
String userName = db.getRealName();
@@ -150,9 +149,7 @@ public class ShopDealerWithdrawController extends BaseController {
tenantId,
openid,
db.getMoney(),
outBatchNo,
outDetailNo,
"分销商提现",
outBillNo,
remark,
userName
);