diff --git a/src/main/java/com/company/agentfoundation/integration/ngp/NgpApiClient.java b/src/main/java/com/company/agentfoundation/integration/ngp/NgpApiClient.java index 6306160..62cc469 100644 --- a/src/main/java/com/company/agentfoundation/integration/ngp/NgpApiClient.java +++ b/src/main/java/com/company/agentfoundation/integration/ngp/NgpApiClient.java @@ -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) { diff --git a/src/main/java/com/company/agentfoundation/integration/ngp/NgpTokenProvider.java b/src/main/java/com/company/agentfoundation/integration/ngp/NgpTokenProvider.java index 74ebbe2..086b16c 100644 --- a/src/main/java/com/company/agentfoundation/integration/ngp/NgpTokenProvider.java +++ b/src/main/java/com/company/agentfoundation/integration/ngp/NgpTokenProvider.java @@ -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; diff --git a/src/main/java/com/company/agentfoundation/integration/ngp/NgpTokenRepository.java b/src/main/java/com/company/agentfoundation/integration/ngp/NgpTokenRepository.java new file mode 100644 index 0000000..eac34e6 --- /dev/null +++ b/src/main/java/com/company/agentfoundation/integration/ngp/NgpTokenRepository.java @@ -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 find(String appKey) { + if (appKey == null || appKey.isBlank()) return Optional.empty(); + List 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) { + } +} diff --git a/src/test/java/com/company/agentfoundation/integration/ngp/NgpApiClientTest.java b/src/test/java/com/company/agentfoundation/integration/ngp/NgpApiClientTest.java index 60d71b7..c0aa306 100644 --- a/src/test/java/com/company/agentfoundation/integration/ngp/NgpApiClientTest.java +++ b/src/test/java/com/company/agentfoundation/integration/ngp/NgpApiClientTest.java @@ -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 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(); + } } diff --git a/src/test/java/com/company/agentfoundation/integration/ngp/NgpTokenProviderTest.java b/src/test/java/com/company/agentfoundation/integration/ngp/NgpTokenProviderTest.java new file mode 100644 index 0000000..2169b56 --- /dev/null +++ b/src/test/java/com/company/agentfoundation/integration/ngp/NgpTokenProviderTest.java @@ -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"); + } +}