Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,24 @@ public class TransferBillsRequest implements Serializable {
@SerializedName("transfer_scene_report_infos")
private List<TransferSceneReportInfo> transferSceneReportInfos;

/**
* 自动收款授权信息
*/
@SerializedName("authorization_info")
private AuthorizationInfo authorizationInfo;
Comment on lines +93 to +94

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route authorization-info calls to the pre-transfer API

When callers populate authorizationInfo, TransferServiceImpl.transferBills still serializes this request to /v3/fund-app/mch-transfer/transfer-bills. WeChat documents authorization_info only for POST /v3/fund-app/mch-transfer/transfer-bills/pre-transfer-with-authorization (https://pay.wechatpay.cn/doc/v3/merchant/4014399293); the regular transfer endpoint doesn't accept it, so this newly exposed field will produce an unsupported request instead of starting the transfer+authorization flow.

Useful? React with 👍 / 👎.


/**
* 微信免确认收款授权单号
*/
@SerializedName("authorization_id")
private String authorizationId;
Comment on lines +99 to +100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route authorized transfers to the transfer endpoint

When callers set authorizationId (or the adjacent outAuthorizationNo) to pay a user who already authorized免确认收款, this same request object is still sent by transferBills to /transfer-bills and also requires the normal openid. The WeChat docs put these fields on POST /v3/fund-app/mch-transfer/transfer-bills/transfer (https://pay.wechatpay.cn/doc/v3/merchant/4014399371), so authorized-transfer requests built with the new fields are sent to the wrong API and cannot succeed.

Useful? React with 👍 / 👎.


/**
* 商户侧授权单号
*/
@SerializedName("out_authorization_no")
private String outAuthorizationNo;
Comment on lines +90 to +106

/**
* 收款授权模式
* <pre>
Expand Down Expand Up @@ -125,4 +143,28 @@ public static class TransferSceneReportInfo {
@SerializedName("info_content")
private String infoContent;
}

@Data
@Builder(builderMethodName = "newBuilder")
@AllArgsConstructor
@NoArgsConstructor
public static class AuthorizationInfo {

@augmentcode augmentcode Bot Jul 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TransferBillsRequest.java:151TransferBillsRequest implements Serializable, but the newly added nested type AuthorizationInfo does not; serializing a TransferBillsRequest that has authorizationInfo set can throw NotSerializableException.

Severity: medium

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

/**
* 用户展示名称
*/
@SerializedName("user_display_name")
private String userDisplayName;

/**
* 商户侧授权单号
*/
@SerializedName("out_authorization_no")
private String outAuthorizationNo;

/**
* 自动收款授权结果通知地址
*/
@SerializedName("authorization_notify_url")
private String authorizationNotifyUrl;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
package com.github.binarywang.wxpay.service.impl;

import com.github.binarywang.wxpay.bean.transfer.ReservationTransferBatchRequest;
import com.github.binarywang.wxpay.bean.transfer.ReservationTransferBatchGetResult;
import com.github.binarywang.wxpay.bean.transfer.ReservationTransferBatchResult;
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Assert the documented authorization-result endpoint

For querying an免确认收款 authorization result, the official API is keyed by out_authorization_no at /v3/fund-app/mch-transfer/user-confirm-authorization/out-authorization-no/{out_authorization_no} with optional is_display_authorization (https://pay.wechatpay.cn/doc/v3/merchant/4014399423). This new compatibility test instead locks in an OpenID-based /authorization/openid/... URL, so the suite can pass while the library still cannot perform the documented query flow clients need after creating an authorization.

Useful? React with 👍 / 👎.

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);

@augmentcode augmentcode Bot Jul 2, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TransferAuthorizationApiCompatibilityTest.java:92 — This proxy-based test claims it can run without real credentials, but TransferServiceImpl.reservationTransferBatch() currently calls payService.getConfig().getVerifier().getValidCertificate() whenever transferDetailList is non-empty, so this invocation will likely NPE before lastPostUrl/lastPostBody are captured.

Severity: high

Fix This in Augment

🤖 Was this useful? React with 👍 or 👎, or 🚀 if it prevented an incident/outage.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stub payment config before invoking reservation transfer

In this test, reservationTransferBatch receives a non-empty detail list, and the implementation reads payService.getConfig().getVerifier().getValidCertificate() before checking each detail's userName. The dynamic proxy returns null for getConfig, so this call throws a NullPointerException and the new compatibility test never reaches the URL/body assertions unless the proxy stubs config/certificate or the request avoids the encryption branch.

Useful? React with 👍 / 👎.


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, "");
}

/**
* 通过动态代理拦截 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;
}
Comment on lines +313 to +315
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;
}
}
}
Loading