管家婆
This commit is contained in:
parent
2c01c58c08
commit
c07e85ce39
@ -100,7 +100,7 @@ public class NgpApiClient {
|
||||
JsonNode response = execute(method, requestBody, token);
|
||||
if (!tokenRefreshed && isExpiredToken(response)) {
|
||||
tokenRefreshed = true;
|
||||
token = tokenProvider.refreshAccessToken();
|
||||
token = tokenProvider.refreshAccessToken(token);
|
||||
attempt--;
|
||||
continue;
|
||||
}
|
||||
@ -175,11 +175,12 @@ public class NgpApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isExpiredToken(JsonNode response) {
|
||||
static boolean isExpiredToken(JsonNode response) {
|
||||
if (response == null) return false;
|
||||
String message = response.path("message").asText("").toLowerCase();
|
||||
String message = response.path("message").asText(response.path("msg").asText("")).toLowerCase();
|
||||
return message.contains("token") && (message.contains("expired") || message.contains("invalid")
|
||||
|| message.contains("失效") || message.contains("过期"));
|
||||
|| message.contains("error") || message.contains("失效") || message.contains("过期")
|
||||
|| message.contains("错误"));
|
||||
}
|
||||
|
||||
private boolean transientStatus(int status) {
|
||||
|
||||
@ -10,17 +10,24 @@ import org.springframework.web.reactive.function.client.WebClient;
|
||||
import reactor.netty.http.client.HttpClient;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
|
||||
@Component
|
||||
public class NgpTokenProvider {
|
||||
private final FoundationProperties.Ngp config;
|
||||
private final NgpTokenRepository tokenRepository;
|
||||
private final WebClient webClient;
|
||||
private final Duration readTimeout;
|
||||
private volatile String accessToken;
|
||||
private volatile String refreshToken;
|
||||
|
||||
public NgpTokenProvider(FoundationProperties properties, WebClient.Builder builder) {
|
||||
public NgpTokenProvider(
|
||||
FoundationProperties properties,
|
||||
NgpTokenRepository tokenRepository,
|
||||
WebClient.Builder builder
|
||||
) {
|
||||
this.config = properties.getNgp();
|
||||
this.tokenRepository = tokenRepository;
|
||||
HttpClient httpClient = HttpClient.create()
|
||||
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, Math.max(config.getConnectTimeoutMs(), 1))
|
||||
.responseTimeout(Duration.ofMillis(Math.max(config.getReadTimeoutMs(), 1)));
|
||||
@ -31,8 +38,11 @@ public class NgpTokenProvider {
|
||||
.maxInMemorySize(Math.max(config.getMaxResponseBytes(), 256 * 1024)))
|
||||
.build();
|
||||
this.readTimeout = Duration.ofMillis(Math.max(config.getReadTimeoutMs(), 1));
|
||||
this.accessToken = config.getAccessToken();
|
||||
this.refreshToken = config.getRefreshToken();
|
||||
NgpTokenRepository.TokenState persisted = tokenRepository.find(config.getAppKey()).orElse(null);
|
||||
this.accessToken = persisted == null || blank(persisted.accessToken())
|
||||
? config.getAccessToken() : persisted.accessToken();
|
||||
this.refreshToken = persisted == null || blank(persisted.refreshToken())
|
||||
? config.getRefreshToken() : persisted.refreshToken();
|
||||
}
|
||||
|
||||
public String getAccessToken() {
|
||||
@ -42,6 +52,21 @@ public class NgpTokenProvider {
|
||||
}
|
||||
|
||||
public synchronized String refreshAccessToken() {
|
||||
return refreshAccessTokenInternal();
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes only when the rejected token is still the active token. Concurrent
|
||||
* callers that already observed the same expired token reuse the first caller's
|
||||
* refreshed value instead of rotating the refresh token repeatedly.
|
||||
*/
|
||||
public synchronized String refreshAccessToken(String rejectedAccessToken) {
|
||||
ensureBaseConfiguration();
|
||||
if (!blank(accessToken) && !Objects.equals(accessToken, rejectedAccessToken)) return accessToken;
|
||||
return refreshAccessTokenInternal();
|
||||
}
|
||||
|
||||
private String refreshAccessTokenInternal() {
|
||||
ensureBaseConfiguration();
|
||||
if (blank(refreshToken)) throw new NgpApiException("NGP access token 已失效,且未配置 refresh token");
|
||||
try {
|
||||
@ -64,6 +89,7 @@ public class NgpTokenProvider {
|
||||
accessToken = tokenData.path("access_token").asText();
|
||||
String nextRefreshToken = tokenData.path("refresh_token").asText("");
|
||||
if (!nextRefreshToken.isBlank()) refreshToken = nextRefreshToken;
|
||||
tokenRepository.save(config.getAppKey(), accessToken, refreshToken);
|
||||
return accessToken;
|
||||
} catch (NgpApiException exception) {
|
||||
throw exception;
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
package com.company.agentfoundation.integration.ngp;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
@Repository
|
||||
public class NgpTokenRepository {
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public NgpTokenRepository(JdbcTemplate jdbcTemplate) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void initializeSchema() {
|
||||
jdbcTemplate.execute("""
|
||||
CREATE TABLE IF NOT EXISTS ngp_token_state (
|
||||
app_key VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
access_token VARCHAR(512) NOT NULL,
|
||||
refresh_token VARCHAR(512) NOT NULL,
|
||||
updated_at DATETIME(3) NOT NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""");
|
||||
}
|
||||
|
||||
public Optional<TokenState> find(String appKey) {
|
||||
if (appKey == null || appKey.isBlank()) return Optional.empty();
|
||||
List<TokenState> rows = jdbcTemplate.query("""
|
||||
SELECT access_token, refresh_token
|
||||
FROM ngp_token_state
|
||||
WHERE app_key = ?
|
||||
""",
|
||||
(resultSet, rowNum) -> new TokenState(
|
||||
resultSet.getString("access_token"),
|
||||
resultSet.getString("refresh_token")
|
||||
),
|
||||
appKey);
|
||||
return rows.stream().findFirst();
|
||||
}
|
||||
|
||||
public void save(String appKey, String accessToken, String refreshToken) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO ngp_token_state (app_key, access_token, refresh_token, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
access_token = VALUES(access_token),
|
||||
refresh_token = VALUES(refresh_token),
|
||||
updated_at = VALUES(updated_at)
|
||||
""", appKey, accessToken, refreshToken, Timestamp.valueOf(LocalDateTime.now()));
|
||||
}
|
||||
|
||||
public record TokenState(String accessToken, String refreshToken) {
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package com.company.agentfoundation.integration.ngp;
|
||||
|
||||
import com.company.agentfoundation.config.FoundationProperties;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
@ -11,6 +12,8 @@ import java.util.Map;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class NgpApiClientTest {
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void producesDocumentedHmacMd5Signature() {
|
||||
Map<String, String> parameters = new LinkedHashMap<>();
|
||||
@ -50,4 +53,18 @@ class NgpApiClientTest {
|
||||
.containsEntry("showAllEshopType", true)
|
||||
.doesNotContainKeys("postStateList", "refundType", "otypeIds");
|
||||
}
|
||||
|
||||
@Test
|
||||
void recognizesNgpErrorTokenResponseAsExpiredToken() throws Exception {
|
||||
JsonNode response = objectMapper.readTree("{\"code\":-1,\"message\":\"error token\"}");
|
||||
|
||||
assertThat(NgpApiClient.isExpiredToken(response)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotTreatUnrelatedBusinessFailureAsExpiredToken() throws Exception {
|
||||
JsonNode response = objectMapper.readTree("{\"code\":-1,\"message\":\"parameter error\"}");
|
||||
|
||||
assertThat(NgpApiClient.isExpiredToken(response)).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
package com.company.agentfoundation.integration.ngp;
|
||||
|
||||
import com.company.agentfoundation.config.FoundationProperties;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class NgpTokenProviderTest {
|
||||
@Test
|
||||
void reusesTokenAlreadyRefreshedByAnotherCaller() {
|
||||
FoundationProperties properties = new FoundationProperties();
|
||||
properties.getNgp().setAppKey("app-key");
|
||||
properties.getNgp().setAppSecret("app-secret");
|
||||
properties.getNgp().setAccessToken("new-token");
|
||||
properties.getNgp().setRefreshToken("refresh-token");
|
||||
NgpTokenRepository repository = mock(NgpTokenRepository.class);
|
||||
when(repository.find("app-key")).thenReturn(Optional.empty());
|
||||
NgpTokenProvider provider = new NgpTokenProvider(properties, repository, WebClient.builder());
|
||||
|
||||
assertThat(provider.refreshAccessToken("old-token")).isEqualTo("new-token");
|
||||
}
|
||||
|
||||
@Test
|
||||
void loadsLatestPersistedTokensInsteadOfStaleConfiguration() {
|
||||
FoundationProperties properties = new FoundationProperties();
|
||||
properties.getNgp().setAppKey("app-key");
|
||||
properties.getNgp().setAppSecret("app-secret");
|
||||
properties.getNgp().setAccessToken("stale-token");
|
||||
properties.getNgp().setRefreshToken("stale-refresh-token");
|
||||
NgpTokenRepository repository = mock(NgpTokenRepository.class);
|
||||
when(repository.find("app-key")).thenReturn(Optional.of(
|
||||
new NgpTokenRepository.TokenState("persisted-token", "persisted-refresh-token")));
|
||||
NgpTokenProvider provider = new NgpTokenProvider(properties, repository, WebClient.builder());
|
||||
|
||||
assertThat(provider.getAccessToken()).isEqualTo("persisted-token");
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user