Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -5,15 +5,20 @@
import cn.binarywang.wx.miniapp.config.WxMaConfig;
import cn.binarywang.wx.miniapp.executor.ApiSignaturePostRequestExecutor;
import com.google.gson.JsonObject;
import java.io.IOException;
import java.util.Map;
import java.util.function.Function;
import me.chanjar.weixin.common.enums.WxType;
import me.chanjar.weixin.common.error.WxErrorException;
import me.chanjar.weixin.common.error.WxRuntimeException;
import me.chanjar.weixin.common.service.WxImgProcService;
import me.chanjar.weixin.common.service.WxOcrService;
import me.chanjar.weixin.common.service.WxService;
import me.chanjar.weixin.common.util.http.MediaUploadRequestExecutor;
import me.chanjar.weixin.common.util.http.RequestExecutor;
import me.chanjar.weixin.common.util.http.RequestHttp;
import me.chanjar.weixin.common.util.http.SimpleGetRequestExecutor;
import me.chanjar.weixin.common.util.http.SimplePostRequestExecutor;

/**
* 微信小程序主服务接口,定义了所有小程序相关的核心操作方法。
Expand All @@ -37,7 +42,7 @@ public interface WxMaService extends WxService {
String SET_DYNAMIC_DATA_URL = "https://api.weixin.qq.com/wxa/setdynamicdata";

/**
* 获取登录后的 session 信息。
* 获取登录后的 session 信息,不获取或携带 access_token。
* 登录参数按原始值传入,无需 URL 编码。为防止泄露凭证,失败异常保留错误码和栈帧,
* 不包含原始请求、响应、cause 或 suppressed 异常;其他运行时异常可能转换为 WxRuntimeException。
*
Expand Down Expand Up @@ -124,6 +129,54 @@ String getPaidUnionId(String openid, String transactionId, String mchId, String
*/
<T, E> T execute(RequestExecutor<T, E> executor, String uri, E data) throws WxErrorException;

/**
* 执行无需自动获取或携带 access_token 的自定义请求。
* 默认实现直接执行请求;SDK 内置实现同时支持 API 主机配置和系统繁忙重试,
* 不因 token 错误刷新配置中的 access_token。
*
* @param executor 请求执行器
* @param uri 接口地址
* @param data 请求数据
* @param <T> 返回类型
* @param <E> 请求数据类型
* @return 接口响应
* @throws WxErrorException 微信接口调用异常
*/
default <T, E> T executeWithoutToken(RequestExecutor<T, E> executor, String uri, E data)
throws WxErrorException {
try {
return executor.execute(uri, data, WxType.MiniApp);
} catch (IOException e) {
throw new WxRuntimeException(e);
}
}

/**
* 发起无需自动获取或携带 access_token 的 GET 请求。
*
* @param url 接口地址
* @param queryParam 已编码的查询参数,可为 null
* @return 接口响应
* @throws WxErrorException 微信接口调用异常
*/
default String getWithoutToken(String url, String queryParam) throws WxErrorException {
return executeWithoutToken(SimpleGetRequestExecutor.create(getRequestHttp()),
url, queryParam);
}

/**
* 发起无需自动获取或携带 access_token 的 POST 请求,不自动启用接口签名。
*
* @param url 接口地址
* @param postData 请求体
* @return 接口响应
* @throws WxErrorException 微信接口调用异常
*/
default String postWithoutToken(String url, String postData) throws WxErrorException {
return executeWithoutToken(SimplePostRequestExecutor.create(getRequestHttp()),
url, postData);
}

/**
* 执行带有签名的微信API请求。
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ public WxMaJscode2SessionResult jsCode2SessionInfo(String jsCode) throws WxError
params.put("grant_type", "authorization_code");

String result =
get(JSCODE_TO_SESSION_URL, Joiner.on("&").withKeyValueSeparator("=").join(params));
getWithoutToken(JSCODE_TO_SESSION_URL, Joiner.on("&").withKeyValueSeparator("=").join(params));
return WxMaJscode2SessionResult.fromJson(result);
} catch (WxErrorException e) {
throw SensitiveRequestUtils.sanitize(e);
Expand Down Expand Up @@ -373,6 +373,12 @@ public <R, T> R execute(RequestExecutor<R, T> executor, String uri, T data)
dataForLog);
}

@Override
public <R, T> R executeWithoutToken(RequestExecutor<R, T> executor, String uri, T data)
throws WxErrorException {
return executeWithRetry(url -> executor.execute(url, data, WxType.MiniApp), uri, null, false);
}

@Override
public WxMaApiResponse execute(
ApiSignaturePostRequestExecutor<?, ?> executor,
Expand All @@ -393,10 +399,16 @@ private static interface ExecutorAction<R> {

private <R, T> R executeWithRetry(ExecutorAction<R> executor, String uri, String dataForLog)
throws WxErrorException {
return executeWithRetry(executor, uri, dataForLog, true);
}

private <R> R executeWithRetry(ExecutorAction<R> executor, String uri, String dataForLog,
boolean withToken) throws WxErrorException {
int retryTimes = 0;
do {
try {
return this.executeInternal(executor, uri, dataForLog, false, JSCODE_TO_SESSION_URL.equals(uri));
return this.executeInternal(executor, uri, dataForLog, false,
!withToken || JSCODE_TO_SESSION_URL.equals(uri), withToken);
Comment on lines +410 to +411
} catch (WxErrorException e) {
if (retryTimes + 1 > this.maxRetryTimes) {
log.warn("重试达到最大次数【{}】", maxRetryTimes);
Expand Down Expand Up @@ -430,21 +442,21 @@ private <R, T> R executeWithRetry(ExecutorAction<R> executor, String uri, String

private <R, T> R executeInternal(
ExecutorAction<R> executor, String uri, String dataForLog, boolean doNotAutoRefreshToken,
boolean code2Session)
boolean code2Session, boolean withToken)
throws WxErrorException {

if (uri.contains("access_token=")) {
if (withToken && uri.contains("access_token=")) {
throw new IllegalArgumentException("uri参数中不允许有access_token: " + uri);
}
String accessToken = getAccessToken(false);
String accessToken = withToken ? getAccessToken(false) : null;

String effectiveApiHostUrl = this.getWxMaConfig().getEffectiveApiHostUrl();
if (!WxMaConfig.DEFAULT_API_HOST_URL.equals(effectiveApiHostUrl)) {
uri = uri.replace(WxMaConfig.DEFAULT_API_HOST_URL, effectiveApiHostUrl);
}

String uriWithAccessToken =
uri + (uri.contains("?") ? "&" : "?") + "access_token=" + accessToken;
withToken ? uri + (uri.contains("?") ? "&" : "?") + "access_token=" + accessToken : uri;
try {
R result = executor.execute(uriWithAccessToken);
if (code2Session) {
Expand All @@ -455,7 +467,7 @@ private <R, T> R executeInternal(
return result;
} catch (WxErrorException e) {
WxError error = e.getError();
if (WxConsts.ACCESS_TOKEN_ERROR_CODES.contains(error.getErrorCode())) {
if (withToken && WxConsts.ACCESS_TOKEN_ERROR_CODES.contains(error.getErrorCode())) {
// 强制设置WxMaConfig的access token过期了,这样在下一次请求里就会刷新access token
Lock lock = this.getWxMaConfig().getAccessTokenLock();
lock.lock();
Expand All @@ -474,7 +486,7 @@ private <R, T> R executeInternal(
code2Session ? "[redacted]" : error.getErrorMsg());
// 下一次不再自动重试
// 当小程序误调用第三方平台专属接口时,第三方无法使用小程序的access token,如果可以继续自动获取token会导致无限循环重试,直到栈溢出
return this.executeInternal(executor, uri, dataForLog, true, code2Session);
return this.executeInternal(executor, uri, dataForLog, true, code2Session, withToken);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,24 @@ public void allHttpClientsSendEncodedValuesAndPreserveSession() throws Exception
out.write(body);
}
});
server.createContext("/custom", exchange -> {
query.set(exchange.getRequestURI().getRawQuery());
String result = exchange.getRequestMethod();
if ("POST".equals(result)) {
java.io.ByteArrayOutputStream body = new java.io.ByteArrayOutputStream();
byte[] buffer = new byte[128];
int count;
while ((count = exchange.getRequestBody().read(buffer)) != -1) {
body.write(buffer, 0, count);
}
result += ":" + new String(body.toByteArray(), StandardCharsets.UTF_8);
}
byte[] body = ("{\"result\":\"" + result + "\"}").getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
try (java.io.OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
});
server.start();
try (LogCapture logs = new LogCapture()) {
BaseWxMaServiceImpl<?, ?>[] services = {new WxMaServiceImpl(), new WxMaServiceHttpComponentsImpl(),
Expand All @@ -58,15 +76,22 @@ public void allHttpClientsSendEncodedValuesAndPreserveSession() throws Exception
config.setApiHostUrl("http://127.0.0.1:" + server.getAddress().getPort());
service.setWxMaConfig(config);
try {
config.expireAccessToken();
assertEquals(service.getWithoutToken("https://api.weixin.qq.com/custom", "value=a%2Fb"), "{\"result\":\"GET\"}");
assertEquals(decode(query.get()).get("value"), "a/b");
assertFalse(query.get().contains("access_token="));
assertEquals(service.postWithoutToken("https://api.weixin.qq.com/custom?value=1", "payload"),
"{\"result\":\"POST:payload\"}");
assertEquals(query.get(), "value=1");
response.set(RESULT);
for (String code : new String[]{"NORMAL_CODE", "a b\n", "a+&=#?%2F", "中文\uD83D\uDE00", ""}) {
assertEquals(service.jsCode2SessionInfo(code).getSessionKey(), SESSION);
Map<String, String> params = decode(query.get());
assertEquals(params.size(), 5);
assertEquals(params.size(), 4);
assertEquals(params.get("js_code"), code);
assertEquals(params.get("secret"), config.getSecret());
assertEquals(params.get("appid"), config.getAppid());
assertEquals(params.get("access_token"), TOKEN);
assertNull(params.get("access_token"));
}
response.set("{\"errcode\":40029,\"errmsg\":\"" + SECRET + TOKEN + "\"}");
try {
Expand Down Expand Up @@ -98,10 +123,10 @@ public void errorsAndRetryLogsDoNotContainCredentials() throws Exception {
TestService service = service();
service.getWxMaConfig().setApiHostUrl("http://proxy.invalid");
when(service.executor.execute(anyString(), anyString(), eq(WxType.MiniApp)))
.thenThrow(error(40001)).thenThrow(error(-1)).thenReturn(RESULT);
.thenThrow(error(-1)).thenReturn(RESULT);
assertEquals(service.jsCode2SessionInfo("FAKE_CODE").getSessionKey(), SESSION);
assertEquals(service.tokenRequests, 1);
verify(service.executor, times(3)).execute(startsWith("http://proxy.invalid/"), anyString(), eq(WxType.MiniApp));
assertEquals(service.tokenRequests, 0);
verify(service.executor, times(2)).execute(startsWith("http://proxy.invalid/"), anyString(), eq(WxType.MiniApp));

for (Exception failure : new Exception[]{error(40029), new IOException(SECRET + TOKEN),
new IllegalArgumentException(SECRET + TOKEN), new IllegalStateException(SECRET + TOKEN)}) {
Expand All @@ -123,13 +148,13 @@ public void errorsAndRetryLogsDoNotContainCredentials() throws Exception {
}

@Test
public void tokenAndParsingFailuresAreProtectedAndNullRemainsNullFailure() throws Exception {
public void loginDoesNotRequireTokenAndParsingFailuresRemainProtected() throws Exception {
try (LogCapture logs = new LogCapture()) {
TestService service = service();
service.getWxMaConfig().expireAccessToken();
when(service.executor.execute(anyString(), anyString(), eq(WxType.MiniApp))).thenReturn(RESULT);
assertEquals(service.jsCode2SessionInfo("FAKE_CODE").getSessionKey(), SESSION);
assertEquals(service.tokenRequests, 1);
assertEquals(service.tokenRequests, 0);
when(service.executor.execute(anyString(), anyString(), eq(WxType.MiniApp)))
.thenReturn("{\"session_key\":\"" + SESSION + "\",\"expires_in\":");
try {
Expand All @@ -146,16 +171,82 @@ public void tokenAndParsingFailuresAreProtectedAndNullRemainsNullFailure() throw
}
service.getWxMaConfig().expireAccessToken();
service.failToken = true;
try {
service.jsCode2SessionInfo("FAKE_CODE");
fail("Expected token failure");
} catch (WxRuntimeException e) {
assertSafe(e);
}
when(service.executor.execute(anyString(), anyString(), eq(WxType.MiniApp))).thenReturn(RESULT);
assertEquals(service.jsCode2SessionInfo("FAKE_CODE").getSessionKey(), SESSION);
assertEquals(service.tokenRequests, 0);
logs.assertSafe();
}
}

@Test
public void tokenFreeExecutionRetriesBusyButDoesNotRefreshInvalidToken() throws Exception {
TestService service = service();
service.getWxMaConfig().setApiHostUrl("http://proxy.invalid");
when(service.executor.execute(anyString(), eq("payload"), eq(WxType.MiniApp)))
.thenThrow(error(-1)).thenReturn("ok");
assertEquals(service.executeWithoutToken(service.executor,
"https://api.weixin.qq.com/custom", "payload"), "ok");
verify(service.executor, times(2)).execute("http://proxy.invalid/custom", "payload", WxType.MiniApp);
assertEquals(service.tokenRequests, 0);

reset(service.executor);
when(service.executor.execute(anyString(), anyString(), eq(WxType.MiniApp))).thenThrow(error(40001));
try {
service.executeWithoutToken(service.executor, "https://api.weixin.qq.com/custom", "payload");
fail("Expected invalid token error");
} catch (WxErrorException e) {
assertEquals(e.getError().getErrorCode(), 40001);
}
verify(service.executor).execute(anyString(), anyString(), eq(WxType.MiniApp));
assertEquals(service.getWxMaConfig().getAccessToken(), TOKEN);
assertFalse(service.getWxMaConfig().isAccessTokenExpired());
assertEquals(service.tokenRequests, 0);
}

@Test
public void tokenFreeExecutionStopsAtRetryLimit() throws Exception {
TestService service = service();
service.setMaxRetryTimes(2);
when(service.executor.execute(anyString(), anyString(), eq(WxType.MiniApp))).thenThrow(error(-1));
try {
service.executeWithoutToken(service.executor, "https://api.weixin.qq.com/custom", "payload");
fail("Expected exhausted retries");
} catch (WxErrorException e) {
assertEquals(e.getError().getErrorCode(), -1);
}
verify(service.executor, times(3)).execute(anyString(), anyString(), eq(WxType.MiniApp));
assertEquals(service.tokenRequests, 0);
}

@Test
public void customServiceDefaultExecutionPreservesTransportContract() throws Exception {
cn.binarywang.wx.miniapp.api.WxMaService service =
mock(cn.binarywang.wx.miniapp.api.WxMaService.class, CALLS_REAL_METHODS);
RequestExecutor<String, String> executor = mock(RequestExecutor.class);
when(executor.execute("https://example.invalid/custom", "payload", WxType.MiniApp)).thenReturn("ok");
assertEquals(service.executeWithoutToken(executor, "https://example.invalid/custom", "payload"), "ok");
IOException failure = new IOException("transport failure");
when(executor.execute(anyString(), anyString(), eq(WxType.MiniApp))).thenThrow(failure);
try {
service.executeWithoutToken(executor, "https://example.invalid/custom", "payload");
fail("Expected transport failure");
} catch (WxRuntimeException e) {
assertSame(e.getCause(), failure);
}
verify(service, never()).getAccessToken(anyBoolean());
}

@Test
public void ordinaryExecutionStillAddsAndRefreshesToken() throws Exception {
TestService service = service();
when(service.executor.execute(anyString(), anyString(), eq(WxType.MiniApp)))
.thenThrow(error(40001)).thenReturn("ok");
assertEquals(service.get("https://api.weixin.qq.com/custom", "query=1"), "ok");
verify(service.executor, times(2)).execute(
"https://api.weixin.qq.com/custom?access_token=" + TOKEN, "query=1", WxType.MiniApp);
assertEquals(service.tokenRequests, 1);
}

@Test
public void ordinaryGetKeepsSerializedQueryAndExceptionContract() throws Exception {
TestService service = service();
Expand Down Expand Up @@ -186,10 +277,10 @@ public void accountSwitchKeepsCredentialsWithTheirAccount() throws Exception {
String uri = invocation.getArgument(0);
if ("SECOND_APP_ID".equals(params.get("appid"))) {
assertEquals(params.get("secret"), "SECOND_SECRET");
assertTrue(uri.endsWith("access_token=SECOND_TOKEN"));
assertFalse(uri.contains("access_token="));
} else {
assertEquals(params.get("secret"), SECRET);
assertTrue(uri.endsWith("access_token=" + TOKEN));
assertFalse(uri.contains("access_token="));
}
return RESULT;
});
Expand Down Expand Up @@ -285,6 +376,11 @@ public String get(String url, String query) throws WxErrorException {
return (String) super.execute(executor, url, query);
}

@Override
public String getWithoutToken(String url, String query) throws WxErrorException {
return (String) super.executeWithoutToken(executor, url, query);
}

@Override
protected String doGetAccessTokenRequest() throws IOException {
tokenRequests++;
Expand Down
Loading