管家婆

This commit is contained in:
tanglong 2026-09-23 11:30:45 +08:00
parent 2c01c58c08
commit c07e85ce39
5 changed files with 153 additions and 7 deletions

View File

@ -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) {

View File

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

View File

@ -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) {
}
}

View File

@ -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();
}
}

View File

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