diff --git a/src/main/java/com/gxwebsoft/payment/service/WxTransferService.java b/src/main/java/com/gxwebsoft/payment/service/WxTransferService.java index 2515b44..2925e65 100644 --- a/src/main/java/com/gxwebsoft/payment/service/WxTransferService.java +++ b/src/main/java/com/gxwebsoft/payment/service/WxTransferService.java @@ -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 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 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 parseTransferSceneReportInfos() throws PaymentException { + if (StrUtil.isBlank(transferSceneReportInfosJson)) { + return null; + } + try { + Type t = new TypeToken>() {}.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 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; + } } diff --git a/src/main/java/com/gxwebsoft/shop/controller/ShopDealerWithdrawController.java b/src/main/java/com/gxwebsoft/shop/controller/ShopDealerWithdrawController.java index b8a9f54..3ae7905 100644 --- a/src/main/java/com/gxwebsoft/shop/controller/ShopDealerWithdrawController.java +++ b/src/main/java/com/gxwebsoft/shop/controller/ShopDealerWithdrawController.java @@ -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 );