1
0
mirror of synced 2026-10-13 10:36:46 +08:00

🆕 #4044 【微信支付】新增商家转账用户授权免确认相关接口

This commit is contained in:
Copilot
2026-07-10 22:21:12 +08:00
committed by GitHub
parent 8c29b1ac0f
commit c34ac4d326
4 changed files with 458 additions and 1 deletions

View File

@@ -87,6 +87,24 @@ public class TransferBillsRequest implements Serializable {
@SerializedName("transfer_scene_report_infos")
private List<TransferSceneReportInfo> transferSceneReportInfos;
/**
* 自动收款授权信息
*/
@SerializedName("authorization_info")
private AuthorizationInfo authorizationInfo;
/**
* 微信免确认收款授权单号
*/
@SerializedName("authorization_id")
private String authorizationId;
/**
* 商户侧授权单号
*/
@SerializedName("out_authorization_no")
private String outAuthorizationNo;
/**
* 收款授权模式
* <pre>
@@ -125,4 +143,28 @@ public class TransferBillsRequest implements Serializable {
@SerializedName("info_content")
private String infoContent;
}
@Data
@Builder(builderMethodName = "newBuilder")
@AllArgsConstructor
@NoArgsConstructor
public static class AuthorizationInfo {
/**
* 用户展示名称
*/
@SerializedName("user_display_name")
private String userDisplayName;
/**
* 商户侧授权单号
*/
@SerializedName("out_authorization_no")
private String outAuthorizationNo;
/**
* 自动收款授权结果通知地址
*/
@SerializedName("authorization_notify_url")
private String authorizationNotifyUrl;
}
}

View File

@@ -239,9 +239,12 @@ public class TransferServiceImpl implements TransferService {
String url = String.format("%s/v3/fund-app/mch-transfer/reservation/transfer-batches", this.payService.getPayBaseUrl());
List<ReservationTransferBatchRequest.TransferDetail> transferDetailList = request.getTransferDetailList();
if (transferDetailList != null && !transferDetailList.isEmpty()) {
X509Certificate validCertificate = this.payService.getConfig().getVerifier().getValidCertificate();
X509Certificate validCertificate = null;
for (ReservationTransferBatchRequest.TransferDetail detail : transferDetailList) {
if (detail.getUserName() != null && !detail.getUserName().isEmpty()) {
if (validCertificate == null) {
validCertificate = this.payService.getConfig().getVerifier().getValidCertificate();
}
RsaCryptoUtil.encryptFields(detail, validCertificate);
}
}

View File

@@ -0,0 +1,361 @@
package com.github.binarywang.wxpay.service.impl;
import com.github.binarywang.wxpay.bean.transfer.ReservationTransferBatchGetResult;
import com.github.binarywang.wxpay.bean.transfer.ReservationTransferBatchRequest;
import com.github.binarywang.wxpay.bean.transfer.ReservationTransferBatchResult;
import com.github.binarywang.wxpay.bean.transfer.TransferBillsRequest;
import com.github.binarywang.wxpay.bean.transfer.UserAuthorizationStatusResult;
import com.github.binarywang.wxpay.exception.WxPayException;
import com.github.binarywang.wxpay.service.WxPayService;
import com.google.gson.Gson;
import org.testng.Assert;
import org.testng.annotations.Test;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.Collections;
/**
* 用户授权免确认相关接口 API 路径兼容性测试
* <p>
* 通过动态代理拦截 WxPayService 调用,验证各接口的请求路径和参数是否符合官方文档要求,
* 无需真实微信 API 凭据即可运行。
* </p>
*
* @author GitHub Copilot
*/
@Test
public class TransferAuthorizationApiCompatibilityTest {
private static final String BASE_URL = "https://api.mch.weixin.qq.com";
/**
* 验证查询用户授权状态接口使用正确的 API 路径和查询参数。
*/
public void shouldGetUserAuthorizationStatusWithCorrectPath() throws WxPayException {
RequestCaptureHandler handler = new RequestCaptureHandler();
WxPayService wxPayService = handler.createWxPayService();
TransferServiceImpl transferService = new TransferServiceImpl(wxPayService);
transferService.getUserAuthorizationStatus("oX_7Jzr9gSZz4X_Xc9-_7HGf8XzI", "1005");
Assert.assertEquals(handler.lastGetUrl,
BASE_URL + "/v3/fund-app/mch-transfer/authorization/openid/oX_7Jzr9gSZz4X_Xc9-_7HGf8XzI?transfer_scene_id=1005");
}
/**
* 验证查询用户授权状态接口返回结果可正确反序列化。
*/
public void shouldDeserializeUserAuthorizationStatusResult() {
Gson gson = new Gson();
String json = "{\"appid\":\"wxf636efh5xxxxx\",\"mch_id\":\"1900000109\","
+ "\"openid\":\"oX_7Jzr9gSZz4X_Xc9-_7HGf8XzI\","
+ "\"authorization_state\":\"AUTHORIZED\","
+ "\"authorize_time\":\"2024-01-01T10:00:00+08:00\","
+ "\"deauthorize_time\":null}";
UserAuthorizationStatusResult result = gson.fromJson(json, UserAuthorizationStatusResult.class);
Assert.assertEquals(result.getAppid(), "wxf636efh5xxxxx");
Assert.assertEquals(result.getMchId(), "1900000109");
Assert.assertEquals(result.getOpenid(), "oX_7Jzr9gSZz4X_Xc9-_7HGf8XzI");
Assert.assertEquals(result.getAuthorizationState(), "AUTHORIZED");
Assert.assertEquals(result.getAuthorizeTime(), "2024-01-01T10:00:00+08:00");
}
/**
* 验证批量预约商家转账接口使用正确的 API 路径。
*/
public void shouldReservationTransferBatchUseCorrectPath() throws WxPayException {
RequestCaptureHandler handler = new RequestCaptureHandler();
WxPayService wxPayService = handler.createWxPayService();
TransferServiceImpl transferService = new TransferServiceImpl(wxPayService);
ReservationTransferBatchRequest request = ReservationTransferBatchRequest.newBuilder()
.appid("wxf636efh5xxxxx")
.outBatchNo("BATCH20240101001")
.transferSceneId("1005")
.batchRemark("测试批量预约转账")
.totalAmount(1000)
.totalNum(1)
.transferDetailList(Collections.singletonList(
ReservationTransferBatchRequest.TransferDetail.newBuilder()
.outDetailNo("detail001")
.transferAmount(1000)
.transferRemark("测试转账")
.openid("oX_7Jzr9gSZz4X_Xc9-_7HGf8XzI")
// 不设置 userName,避免触发证书加密流程
.build()
))
.notifyUrl("https://example.com/notify")
.build();
transferService.reservationTransferBatch(request);
Assert.assertEquals(handler.lastPostUrl,
BASE_URL + "/v3/fund-app/mch-transfer/reservation/transfer-batches");
Assert.assertTrue(handler.lastPostBody.contains("\"out_batch_no\""));
Assert.assertTrue(handler.lastPostBody.contains("BATCH20240101001"));
Assert.assertTrue(handler.lastPostBody.contains("\"transfer_detail_list\""));
}
/**
* 验证批量预约商家转账响应结果可正确反序列化。
*/
public void shouldDeserializeReservationTransferBatchResult() {
Gson gson = new Gson();
String json = "{\"out_batch_no\":\"BATCH20240101001\","
+ "\"reservation_batch_no\":\"1030000071100999991182020050700019480001\","
+ "\"create_time\":\"2024-01-01T10:00:00+08:00\","
+ "\"batch_state\":\"ACCEPTED\"}";
ReservationTransferBatchResult result = gson.fromJson(json, ReservationTransferBatchResult.class);
Assert.assertEquals(result.getOutBatchNo(), "BATCH20240101001");
Assert.assertEquals(result.getReservationBatchNo(), "1030000071100999991182020050700019480001");
Assert.assertEquals(result.getBatchState(), "ACCEPTED");
Assert.assertEquals(result.getCreateTime(), "2024-01-01T10:00:00+08:00");
}
/**
* 验证商户预约批次单号查询接口使用正确的 API 路径和查询参数。
*/
public void shouldGetReservationBatchByOutBatchNoWithCorrectPath() throws WxPayException {
RequestCaptureHandler handler = new RequestCaptureHandler();
WxPayService wxPayService = handler.createWxPayService();
TransferServiceImpl transferService = new TransferServiceImpl(wxPayService);
transferService.getReservationTransferBatchByOutBatchNo(
"BATCH20240101001", true, 0, 20, "SUCCESS");
Assert.assertTrue(handler.lastGetUrl.contains(
"/v3/fund-app/mch-transfer/reservation/transfer-batches/out-batch-no/BATCH20240101001"));
Assert.assertTrue(handler.lastGetUrl.contains("need_query_detail=true"));
Assert.assertTrue(handler.lastGetUrl.contains("offset=0"));
Assert.assertTrue(handler.lastGetUrl.contains("limit=20"));
Assert.assertTrue(handler.lastGetUrl.contains("detail_state=SUCCESS"));
}
/**
* 验证不携带可选参数时商户预约批次单号查询接口仍能正确构造 URL。
*/
public void shouldGetReservationBatchByOutBatchNoWithoutOptionalParams() throws WxPayException {
RequestCaptureHandler handler = new RequestCaptureHandler();
WxPayService wxPayService = handler.createWxPayService();
TransferServiceImpl transferService = new TransferServiceImpl(wxPayService);
transferService.getReservationTransferBatchByOutBatchNo(
"BATCH20240101001", false, null, null, null);
Assert.assertTrue(handler.lastGetUrl.contains(
"/v3/fund-app/mch-transfer/reservation/transfer-batches/out-batch-no/BATCH20240101001"));
Assert.assertTrue(handler.lastGetUrl.contains("need_query_detail=false"));
Assert.assertFalse(handler.lastGetUrl.contains("offset="));
Assert.assertFalse(handler.lastGetUrl.contains("limit="));
Assert.assertFalse(handler.lastGetUrl.contains("detail_state="));
}
/**
* 验证微信预约批次单号查询接口使用正确的 API 路径和查询参数。
*/
public void shouldGetReservationBatchByReservationBatchNoWithCorrectPath() throws WxPayException {
RequestCaptureHandler handler = new RequestCaptureHandler();
WxPayService wxPayService = handler.createWxPayService();
TransferServiceImpl transferService = new TransferServiceImpl(wxPayService);
transferService.getReservationTransferBatchByReservationBatchNo(
"1030000071100999991182020050700019480001", true, 0, 20, "PROCESSING");
Assert.assertTrue(handler.lastGetUrl.contains(
"/v3/fund-app/mch-transfer/reservation/transfer-batches/reservation-batch-no/"
+ "1030000071100999991182020050700019480001"));
Assert.assertTrue(handler.lastGetUrl.contains("need_query_detail=true"));
Assert.assertTrue(handler.lastGetUrl.contains("offset=0"));
Assert.assertTrue(handler.lastGetUrl.contains("limit=20"));
Assert.assertTrue(handler.lastGetUrl.contains("detail_state=PROCESSING"));
}
/**
* 验证批次查询结果可正确反序列化(包括明细列表)。
*/
public void shouldDeserializeReservationBatchGetResult() {
Gson gson = new Gson();
String json = "{\"mch_id\":\"1900000109\","
+ "\"out_batch_no\":\"BATCH20240101001\","
+ "\"reservation_batch_no\":\"1030000071100999991182020050700019480001\","
+ "\"appid\":\"wxf636efh5xxxxx\","
+ "\"batch_state\":\"FINISHED\","
+ "\"total_amount\":1000,"
+ "\"total_num\":1,"
+ "\"success_amount\":1000,"
+ "\"success_num\":1,"
+ "\"fail_amount\":0,"
+ "\"fail_num\":0,"
+ "\"transfer_detail_list\":["
+ "{\"out_detail_no\":\"detail001\","
+ "\"transfer_bill_no\":\"bill001\","
+ "\"detail_state\":\"SUCCESS\"}"
+ "]}";
ReservationTransferBatchGetResult result = gson.fromJson(json, ReservationTransferBatchGetResult.class);
Assert.assertEquals(result.getMchId(), "1900000109");
Assert.assertEquals(result.getBatchState(), "FINISHED");
Assert.assertEquals(result.getTotalAmount(), Integer.valueOf(1000));
Assert.assertEquals(result.getSuccessNum(), Integer.valueOf(1));
Assert.assertNotNull(result.getTransferDetailList());
Assert.assertEquals(result.getTransferDetailList().size(), 1);
Assert.assertEquals(result.getTransferDetailList().get(0).getDetailState(), "SUCCESS");
}
/**
* 验证关闭预约商家转账批次接口使用正确的 API 路径。
*/
public void shouldCloseReservationTransferBatchWithCorrectPath() throws WxPayException {
RequestCaptureHandler handler = new RequestCaptureHandler();
WxPayService wxPayService = handler.createWxPayService();
TransferServiceImpl transferService = new TransferServiceImpl(wxPayService);
transferService.closeReservationTransferBatch("BATCH20240101001");
Assert.assertEquals(handler.lastPostUrl,
BASE_URL + "/v3/fund-app/mch-transfer/reservation/transfer-batches/out-batch-no/BATCH20240101001/close");
Assert.assertEquals(handler.lastPostBody, "");
}
/**
* 验证 TransferBillsRequest 中授权相关字段可正确序列化为 JSON(含 @SerializedName 映射)。
*/
public void shouldSerializeTransferBillsRequestAuthorizationFields() {
Gson gson = new Gson();
TransferBillsRequest request = TransferBillsRequest.newBuilder()
.appid("wxf636efh5xxxxx")
.outBillNo("OUT_BILL_001")
.transferSceneId("1005")
.openid("oX_7Jzr9gSZz4X_Xc9-_7HGf8XzI")
.transferAmount(100)
.transferRemark("测试转账")
.receiptAuthorizationMode("NO_CONFIRM_RECEIPT_AUTHORIZATION")
.authorizationInfo(TransferBillsRequest.AuthorizationInfo.newBuilder()
.userDisplayName("张三")
.outAuthorizationNo("OUT_AUTH_001")
.authorizationNotifyUrl("https://example.com/auth/notify")
.build())
.build();
String json = gson.toJson(request);
Assert.assertTrue(json.contains("\"receipt_authorization_mode\""),
"JSON 应包含 receipt_authorization_mode 字段");
Assert.assertTrue(json.contains("NO_CONFIRM_RECEIPT_AUTHORIZATION"),
"JSON 应包含 receipt_authorization_mode 的值");
Assert.assertTrue(json.contains("\"authorization_info\""),
"JSON 应包含 authorization_info 嵌套字段");
Assert.assertTrue(json.contains("\"user_display_name\""),
"JSON 应包含 user_display_name 字段");
Assert.assertTrue(json.contains("张三"),
"JSON 应包含 user_display_name 的值");
Assert.assertTrue(json.contains("\"out_authorization_no\""),
"JSON 应包含 out_authorization_no 字段");
Assert.assertTrue(json.contains("OUT_AUTH_001"),
"JSON 应包含 out_authorization_no 的值");
Assert.assertTrue(json.contains("\"authorization_notify_url\""),
"JSON 应包含 authorization_notify_url 字段");
Assert.assertTrue(json.contains("https://example.com/auth/notify"),
"JSON 应包含 authorization_notify_url 的值");
}
/**
* 验证通过 authorization_id 和 out_authorization_no 顶层字段发起免确认转账时序列化正确。
*/
public void shouldSerializeTransferBillsRequestWithTopLevelAuthFields() {
Gson gson = new Gson();
TransferBillsRequest request = TransferBillsRequest.newBuilder()
.appid("wxf636efh5xxxxx")
.outBillNo("OUT_BILL_002")
.transferSceneId("1005")
.openid("oX_7Jzr9gSZz4X_Xc9-_7HGf8XzI")
.transferAmount(200)
.transferRemark("免确认转账")
.receiptAuthorizationMode("NO_CONFIRM_RECEIPT_AUTHORIZATION")
.authorizationId("AUTH_ID_001")
.outAuthorizationNo("OUT_AUTH_002")
.build();
String json = gson.toJson(request);
Assert.assertTrue(json.contains("\"authorization_id\""),
"JSON 应包含 authorization_id 字段");
Assert.assertTrue(json.contains("AUTH_ID_001"),
"JSON 应包含 authorization_id 的值");
Assert.assertTrue(json.contains("\"out_authorization_no\""),
"JSON 应包含 out_authorization_no 字段");
Assert.assertTrue(json.contains("OUT_AUTH_002"),
"JSON 应包含 out_authorization_no 的值");
}
/**
* 通过动态代理拦截 WxPayService 请求并记录 URL/请求体,便于断言接口路径和参数。
*/
private static class RequestCaptureHandler implements InvocationHandler {
private String lastPostUrl;
private String lastPostBody;
private String lastGetUrl;
private WxPayService createWxPayService() {
return (WxPayService) Proxy.newProxyInstance(
WxPayService.class.getClassLoader(),
new Class<?>[]{WxPayService.class},
this
);
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) {
if ("getPayBaseUrl".equals(method.getName())) {
return BASE_URL;
}
if ("postV3".equals(method.getName())) {
this.lastPostUrl = (String) args[0];
this.lastPostBody = (String) args[1];
return "{}";
}
if ("postV3WithWechatpaySerial".equals(method.getName())) {
this.lastPostUrl = (String) args[0];
this.lastPostBody = (String) args[1];
return "{}";
}
if ("getV3".equals(method.getName())) {
this.lastGetUrl = (String) args[0];
return "{}";
}
if ("toString".equals(method.getName())) {
return "MockWxPayService";
}
Class<?> returnType = method.getReturnType();
if (boolean.class.equals(returnType)) {
return false;
}
if (int.class.equals(returnType)) {
return 0;
}
if (long.class.equals(returnType)) {
return 0L;
}
if (double.class.equals(returnType)) {
return 0D;
}
if (float.class.equals(returnType)) {
return 0F;
}
if (short.class.equals(returnType)) {
return (short) 0;
}
if (byte.class.equals(returnType)) {
return (byte) 0;
}
if (char.class.equals(returnType)) {
return (char) 0;
}
return null;
}
}
}

View File

@@ -1,6 +1,7 @@
package com.github.binarywang.wxpay.service.impl;
import com.github.binarywang.wxpay.bean.transfer.QueryTransferBatchesRequest;
import com.github.binarywang.wxpay.bean.transfer.ReservationTransferBatchRequest;
import com.github.binarywang.wxpay.bean.transfer.TransferBatchesRequest;
import com.github.binarywang.wxpay.bean.transfer.TransferBillsRequest;
import com.github.binarywang.wxpay.exception.WxPayException;
@@ -12,6 +13,7 @@ import org.testng.annotations.Guice;
import org.testng.annotations.Test;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
@@ -102,4 +104,53 @@ public class TransferServiceImplTest {
public void testGetBillsByTransferBillNo() throws WxPayException {
log.info("微信单号查询转账单:{}", this.payService.getTransferService().getBillsByTransferBillNo("123456"));
}
@Test
public void testGetUserAuthorizationStatus() throws WxPayException {
log.info("查询用户授权状态:{}",
this.payService.getTransferService().getUserAuthorizationStatus("oX_7Jzr9gSZz4X_Xc9-_7HGf8XzI", "1005"));
}
@Test
public void testReservationTransferBatch() throws WxPayException {
ReservationTransferBatchRequest request = ReservationTransferBatchRequest.newBuilder()
.appid("wxf636efh5xxxxx")
.outBatchNo("BATCH20240101001")
.transferSceneId("1005")
.batchRemark("测试批量预约转账")
.totalAmount(1000)
.totalNum(1)
.transferDetailList(Collections.singletonList(
ReservationTransferBatchRequest.TransferDetail.newBuilder()
.outDetailNo("detail001")
.transferAmount(1000)
.transferRemark("测试转账备注")
.openid("oX_7Jzr9gSZz4X_Xc9-_7HGf8XzI")
.build()
))
.notifyUrl("https://example.com/notify")
.build();
log.info("批量预约商家转账:{}", this.payService.getTransferService().reservationTransferBatch(request));
}
@Test
public void testGetReservationTransferBatchByOutBatchNo() throws WxPayException {
log.info("商户预约批次单号查询批次单:{}",
this.payService.getTransferService()
.getReservationTransferBatchByOutBatchNo("BATCH20240101001", true, 0, 20, "SUCCESS"));
}
@Test
public void testGetReservationTransferBatchByReservationBatchNo() throws WxPayException {
log.info("微信预约批次单号查询批次单:{}",
this.payService.getTransferService()
.getReservationTransferBatchByReservationBatchNo(
"1030000071100999991182020050700019480001", false, null, null, null));
}
@Test
public void testCloseReservationTransferBatch() throws WxPayException {
this.payService.getTransferService().closeReservationTransferBatch("BATCH20240101001");
log.info("关闭预约商家转账批次成功");
}
}