管家婆
This commit is contained in:
parent
2c01c58c08
commit
c07e85ce39
@ -100,7 +100,7 @@ public class NgpApiClient {
|
|||||||
JsonNode response = execute(method, requestBody, token);
|
JsonNode response = execute(method, requestBody, token);
|
||||||
if (!tokenRefreshed && isExpiredToken(response)) {
|
if (!tokenRefreshed && isExpiredToken(response)) {
|
||||||
tokenRefreshed = true;
|
tokenRefreshed = true;
|
||||||
token = tokenProvider.refreshAccessToken();
|
token = tokenProvider.refreshAccessToken(token);
|
||||||
attempt--;
|
attempt--;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -175,11 +175,12 @@ public class NgpApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isExpiredToken(JsonNode response) {
|
static boolean isExpiredToken(JsonNode response) {
|
||||||
if (response == null) return false;
|
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")
|
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) {
|
private boolean transientStatus(int status) {
|
||||||
|
|||||||
@ -10,17 +10,24 @@ import org.springframework.web.reactive.function.client.WebClient;
|
|||||||
import reactor.netty.http.client.HttpClient;
|
import reactor.netty.http.client.HttpClient;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
public class NgpTokenProvider {
|
public class NgpTokenProvider {
|
||||||
private final FoundationProperties.Ngp config;
|
private final FoundationProperties.Ngp config;
|
||||||
|
private final NgpTokenRepository tokenRepository;
|
||||||
private final WebClient webClient;
|
private final WebClient webClient;
|
||||||
private final Duration readTimeout;
|
private final Duration readTimeout;
|
||||||
private volatile String accessToken;
|
private volatile String accessToken;
|
||||||
private volatile String refreshToken;
|
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.config = properties.getNgp();
|
||||||
|
this.tokenRepository = tokenRepository;
|
||||||
HttpClient httpClient = HttpClient.create()
|
HttpClient httpClient = HttpClient.create()
|
||||||
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, Math.max(config.getConnectTimeoutMs(), 1))
|
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, Math.max(config.getConnectTimeoutMs(), 1))
|
||||||
.responseTimeout(Duration.ofMillis(Math.max(config.getReadTimeoutMs(), 1)));
|
.responseTimeout(Duration.ofMillis(Math.max(config.getReadTimeoutMs(), 1)));
|
||||||
@ -31,8 +38,11 @@ public class NgpTokenProvider {
|
|||||||
.maxInMemorySize(Math.max(config.getMaxResponseBytes(), 256 * 1024)))
|
.maxInMemorySize(Math.max(config.getMaxResponseBytes(), 256 * 1024)))
|
||||||
.build();
|
.build();
|
||||||
this.readTimeout = Duration.ofMillis(Math.max(config.getReadTimeoutMs(), 1));
|
this.readTimeout = Duration.ofMillis(Math.max(config.getReadTimeoutMs(), 1));
|
||||||
this.accessToken = config.getAccessToken();
|
NgpTokenRepository.TokenState persisted = tokenRepository.find(config.getAppKey()).orElse(null);
|
||||||
this.refreshToken = config.getRefreshToken();
|
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() {
|
public String getAccessToken() {
|
||||||
@ -42,6 +52,21 @@ public class NgpTokenProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public synchronized String refreshAccessToken() {
|
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();
|
ensureBaseConfiguration();
|
||||||
if (blank(refreshToken)) throw new NgpApiException("NGP access token 已失效,且未配置 refresh token");
|
if (blank(refreshToken)) throw new NgpApiException("NGP access token 已失效,且未配置 refresh token");
|
||||||
try {
|
try {
|
||||||
@ -64,6 +89,7 @@ public class NgpTokenProvider {
|
|||||||
accessToken = tokenData.path("access_token").asText();
|
accessToken = tokenData.path("access_token").asText();
|
||||||
String nextRefreshToken = tokenData.path("refresh_token").asText("");
|
String nextRefreshToken = tokenData.path("refresh_token").asText("");
|
||||||
if (!nextRefreshToken.isBlank()) refreshToken = nextRefreshToken;
|
if (!nextRefreshToken.isBlank()) refreshToken = nextRefreshToken;
|
||||||
|
tokenRepository.save(config.getAppKey(), accessToken, refreshToken);
|
||||||
return accessToken;
|
return accessToken;
|
||||||
} catch (NgpApiException exception) {
|
} catch (NgpApiException exception) {
|
||||||
throw 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;
|
package com.company.agentfoundation.integration.ngp;
|
||||||
|
|
||||||
import com.company.agentfoundation.config.FoundationProperties;
|
import com.company.agentfoundation.config.FoundationProperties;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.web.reactive.function.client.WebClient;
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
@ -11,6 +12,8 @@ import java.util.Map;
|
|||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
class NgpApiClientTest {
|
class NgpApiClientTest {
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void producesDocumentedHmacMd5Signature() {
|
void producesDocumentedHmacMd5Signature() {
|
||||||
Map<String, String> parameters = new LinkedHashMap<>();
|
Map<String, String> parameters = new LinkedHashMap<>();
|
||||||
@ -50,4 +53,18 @@ class NgpApiClientTest {
|
|||||||
.containsEntry("showAllEshopType", true)
|
.containsEntry("showAllEshopType", true)
|
||||||
.doesNotContainKeys("postStateList", "refundType", "otypeIds");
|
.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