diff --git a/http/src/main/java/apelet/common/ecommerce/http/HttpRequest.java b/http/src/main/java/apelet/common/ecommerce/http/HttpRequest.java new file mode 100644 index 0000000..3d31603 --- /dev/null +++ b/http/src/main/java/apelet/common/ecommerce/http/HttpRequest.java @@ -0,0 +1,110 @@ +package apelet.common.ecommerce.http; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** + * 统一请求对象。保留原始 header/body 供留痕。 + */ +public final class HttpRequest { + + private final String method; + private final String url; + private final Map headers; + private final String body; + private final String operation; + + private HttpRequest(Builder b) { + this.method = Objects.requireNonNull(b.method, "method"); + this.url = Objects.requireNonNull(b.url, "url"); + this.headers = Collections.unmodifiableMap(new LinkedHashMap(b.headers)); + this.body = b.body; + this.operation = b.operation; + } + + public static Builder builder() { + return new Builder(); + } + + public static HttpRequest get(String url) { + return builder().method("GET").url(url).build(); + } + + public static HttpRequest post(String url, String body) { + return builder().method("POST").url(url).body(body).build(); + } + + public String method() { + return method; + } + + public String url() { + return url; + } + + public Map headers() { + return headers; + } + + public String body() { + return body; + } + + /** 操作名,如 salesOrders.pull / token.refresh。 */ + public String operation() { + return operation; + } + + public Builder toBuilder() { + return builder().method(method).url(url).headers(headers).body(body).operation(operation); + } + + public static final class Builder { + + private String method; + private String url; + private Map headers = new LinkedHashMap(); + private String body; + private String operation; + + public Builder method(String method) { + this.method = method; + return this; + } + + public Builder url(String url) { + this.url = url; + return this; + } + + public Builder header(String name, String value) { + if (value != null) { + this.headers.put(name, value); + } + return this; + } + + public Builder headers(Map headers) { + if (headers != null) { + this.headers.putAll(headers); + } + return this; + } + + public Builder body(String body) { + this.body = body; + return this; + } + + public Builder operation(String operation) { + this.operation = operation; + return this; + } + + public HttpRequest build() { + return new HttpRequest(this); + } + } +} diff --git a/http/src/main/java/apelet/common/ecommerce/http/HttpResponse.java b/http/src/main/java/apelet/common/ecommerce/http/HttpResponse.java new file mode 100644 index 0000000..d189c65 --- /dev/null +++ b/http/src/main/java/apelet/common/ecommerce/http/HttpResponse.java @@ -0,0 +1,69 @@ +package apelet.common.ecommerce.http; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 统一响应对象。保留原始 header/body 供留痕与错误映射。 + */ +public final class HttpResponse { + + private final int statusCode; + private final Map headers; + private final String body; + private final long latencyMillis; + + private HttpResponse(int statusCode, Map headers, String body, long latencyMillis) { + this.statusCode = statusCode; + this.headers = Collections.unmodifiableMap(new LinkedHashMap(headers)); + this.body = body; + this.latencyMillis = latencyMillis; + } + + public static HttpResponse of(int statusCode, Map headers, String body, long latencyMillis) { + return new HttpResponse(statusCode, headers == null ? Collections.emptyMap() : headers, + body, latencyMillis); + } + + public int statusCode() { + return statusCode; + } + + public Map headers() { + return headers; + } + + public String body() { + return body; + } + + public long latencyMillis() { + return latencyMillis; + } + + public boolean isSuccess() { + return statusCode >= 200 && statusCode < 300; + } + + public boolean isAuthFailure() { + return statusCode == 401 || statusCode == 403; + } + + public boolean isRateLimited() { + return statusCode == 429; + } + + public boolean isServerError() { + return statusCode >= 500; + } + + public String header(String name) { + for (Map.Entry e : headers.entrySet()) { + if (e.getKey().equalsIgnoreCase(name)) { + return e.getValue(); + } + } + return null; + } +} diff --git a/http/src/main/java/apelet/common/ecommerce/http/HttpTransport.java b/http/src/main/java/apelet/common/ecommerce/http/HttpTransport.java new file mode 100644 index 0000000..67de3d7 --- /dev/null +++ b/http/src/main/java/apelet/common/ecommerce/http/HttpTransport.java @@ -0,0 +1,9 @@ +package apelet.common.ecommerce.http; + +/** + * HTTP 传输接口。默认实现 OkHttp,可替换为 Apache HttpClient 或测试用 Fake。 + */ +public interface HttpTransport { + + HttpResponse execute(HttpRequest request); +} diff --git a/http/src/main/java/apelet/common/ecommerce/http/JacksonJsonCodec.java b/http/src/main/java/apelet/common/ecommerce/http/JacksonJsonCodec.java new file mode 100644 index 0000000..3386ea0 --- /dev/null +++ b/http/src/main/java/apelet/common/ecommerce/http/JacksonJsonCodec.java @@ -0,0 +1,86 @@ +package apelet.common.ecommerce.http; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; + +import java.util.ArrayList; +import java.util.List; + +/** + * Jackson 实现。 + */ +public final class JacksonJsonCodec implements JsonCodec { + + private final ObjectMapper mapper; + + public JacksonJsonCodec() { + this.mapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + } + + public ObjectMapper mapper() { + return mapper; + } + + @Override + public String toJson(Object value) { + try { + return mapper.writeValueAsString(value); + } catch (Exception e) { + throw new IllegalArgumentException("serialize failed", e); + } + } + + @Override + public T fromJson(String json, Class type) { + try { + return mapper.readValue(json, type); + } catch (Exception e) { + throw new IllegalArgumentException("deserialize failed for " + type.getSimpleName(), e); + } + } + + @Override + public String stringAt(String json, String pointer) { + JsonNode node = nodeAt(json, pointer); + return node == null || node.isNull() ? null : node.asText(); + } + + /** 按点路径取节点,如 {@code data.list}。数组下标用数字段。 */ + public JsonNode nodeAt(String json, String pointer) { + if (json == null || pointer == null || pointer.isEmpty()) { + return null; + } + try { + JsonNode cur = mapper.readTree(json); + for (String seg : pointer.split("\\.")) { + if (cur == null || cur.isNull()) { + return null; + } + if (cur.isArray() && seg.matches("\\d+")) { + cur = cur.get(Integer.parseInt(seg)); + } else { + cur = cur.get(seg); + } + } + return cur; + } catch (Exception e) { + return null; + } + } + + /** 把点路径下的数组节点反序列化为列表。 */ + public List listAt(String json, String pointer, Class type) { + JsonNode node = nodeAt(json, pointer); + if (node == null || !node.isArray()) { + return new ArrayList(); + } + List out = new ArrayList(node.size()); + for (JsonNode item : (ArrayNode) node) { + out.add(mapper.convertValue(item, type)); + } + return out; + } +} diff --git a/http/src/main/java/apelet/common/ecommerce/http/JsonCodec.java b/http/src/main/java/apelet/common/ecommerce/http/JsonCodec.java new file mode 100644 index 0000000..0392fec --- /dev/null +++ b/http/src/main/java/apelet/common/ecommerce/http/JsonCodec.java @@ -0,0 +1,17 @@ +package apelet.common.ecommerce.http; + +/** + * 够用的最小 JSON 读写抽象,避免 core 层绑死 Jackson。 + */ +public interface JsonCodec { + + String toJson(Object value); + + T fromJson(String json, Class type); + + /** + * 按点路径取字段值,用于解析平台响应里的 data.list 这类结构。 + * 找不到返回 null。 + */ + String stringAt(String json, String pointer); +} diff --git a/http/src/main/java/apelet/common/ecommerce/http/OkHttpTransport.java b/http/src/main/java/apelet/common/ecommerce/http/OkHttpTransport.java new file mode 100644 index 0000000..64f8ff9 --- /dev/null +++ b/http/src/main/java/apelet/common/ecommerce/http/OkHttpTransport.java @@ -0,0 +1,70 @@ +package apelet.common.ecommerce.http; + +import apelet.common.ecommerce.error.TransientException; + +import java.util.concurrent.TimeUnit; + +/** + * OkHttp 实现。连接池、超时、统一 User-Agent。 + */ +public final class OkHttpTransport implements HttpTransport { + + private final okhttp3.OkHttpClient client; + private final String userAgent; + + public OkHttpTransport(String userAgent) { + this(userAgent, 5_000, 30_000); + } + + public OkHttpTransport(String userAgent, long connectTimeoutMillis, long readTimeoutMillis) { + this.userAgent = userAgent; + this.client = new okhttp3.OkHttpClient.Builder() + .connectTimeout(connectTimeoutMillis, TimeUnit.MILLISECONDS) + .readTimeout(readTimeoutMillis, TimeUnit.MILLISECONDS) + .writeTimeout(readTimeoutMillis, TimeUnit.MILLISECONDS) + .build(); + } + + @Override + public HttpResponse execute(HttpRequest request) { + long start = System.nanoTime(); + okhttp3.Request.Builder rb = new okhttp3.Request.Builder() + .url(request.url()) + .header("User-Agent", userAgent); + for (java.util.Map.Entry e : request.headers().entrySet()) { + rb.header(e.getKey(), e.getValue()); + } + + String method = request.method().toUpperCase(); + okhttp3.RequestBody body = null; + if (request.body() != null) { + body = okhttp3.RequestBody.create(request.body(), + okhttp3.MediaType.parse("application/json; charset=utf-8")); + } + if ("POST".equals(method)) { + rb.post(body == null ? okhttp3.RequestBody.create(new byte[0], null) : body); + } else if ("PUT".equals(method)) { + rb.put(body == null ? okhttp3.RequestBody.create(new byte[0], null) : body); + } else if ("DELETE".equals(method)) { + rb.delete(); + } else { + rb.get(); + } + + try (okhttp3.Response resp = client.newCall(rb.build()).execute()) { + long latency = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + String respBody = resp.body() == null ? null : resp.body().string(); + java.util.Map headers = new java.util.LinkedHashMap(); + for (String name : resp.headers().names()) { + headers.put(name, resp.header(name)); + } + return HttpResponse.of(resp.code(), headers, respBody, latency); + } catch (java.io.IOException e) { + throw TransientException.class.cast(new TransientException( + apelet.common.ecommerce.error.ChannelException.builder() + .message("http transport failed: " + e.getMessage()) + .operation(request.operation()) + .retryable(true), e)); + } + } +} diff --git a/http/src/main/java/apelet/common/ecommerce/http/Responses.java b/http/src/main/java/apelet/common/ecommerce/http/Responses.java new file mode 100644 index 0000000..36e56b6 --- /dev/null +++ b/http/src/main/java/apelet/common/ecommerce/http/Responses.java @@ -0,0 +1,56 @@ +package apelet.common.ecommerce.http; + +import apelet.common.ecommerce.error.AuthException; +import apelet.common.ecommerce.error.ChannelException; +import apelet.common.ecommerce.error.RateLimitException; +import apelet.common.ecommerce.error.TransientException; + +/** + * 响应判定与重试分类。 + * + *
    + *
  • 2xx:成功
  • + *
  • 401/403:{@link AuthException},刷新后重试一次
  • + *
  • 429:{@link RateLimitException},遵守 Retry-After
  • + *
  • 5xx / 网络:{@link TransientException},退避加抖动
  • + *
  • 其他 4xx:业务错误,不在此层重试
  • + *
+ */ +public final class Responses { + + private Responses() { + } + + public static void throwIfNotSuccess(HttpResponse response, String operation) { + if (response.isSuccess()) { + return; + } + ChannelException.Builder b = ChannelException.builder() + .message("http " + response.statusCode() + " on " + operation) + .operation(operation) + .platformCode(String.valueOf(response.statusCode())) + .rawResponse(response.body()); + + if (response.isAuthFailure()) { + throw new AuthException(b); + } + if (response.isRateLimited()) { + throw new RateLimitException(b, parseRetryAfterMillis(response.header("Retry-After"))); + } + if (response.isServerError()) { + throw new TransientException(b.retryable(true), null); + } + throw new apelet.common.ecommerce.error.ValidationException(b); + } + + static long parseRetryAfterMillis(String raw) { + if (raw == null || raw.trim().isEmpty()) { + return 1_000L; + } + try { + return Math.max(0L, Long.parseLong(raw.trim()) * 1000L); + } catch (NumberFormatException e) { + return 1_000L; + } + } +} diff --git a/http/src/main/java/apelet/common/ecommerce/http/Signer.java b/http/src/main/java/apelet/common/ecommerce/http/Signer.java new file mode 100644 index 0000000..cb396ed --- /dev/null +++ b/http/src/main/java/apelet/common/ecommerce/http/Signer.java @@ -0,0 +1,10 @@ +package apelet.common.ecommerce.http; + +/** + * 签名器。按平台实现,差异收敛在适配器内。 + */ +public interface Signer { + + /** 在请求上补齐签名相关的 header / query。 */ + HttpRequest sign(HttpRequest request, apelet.common.ecommerce.spi.ChannelSecret secret); +} diff --git a/http/src/main/java/apelet/common/ecommerce/http/TokenManager.java b/http/src/main/java/apelet/common/ecommerce/http/TokenManager.java new file mode 100644 index 0000000..af27de6 --- /dev/null +++ b/http/src/main/java/apelet/common/ecommerce/http/TokenManager.java @@ -0,0 +1,85 @@ +package apelet.common.ecommerce.http; + +import apelet.common.ecommerce.spi.AccessToken; +import apelet.common.ecommerce.spi.ChannelSecret; +import apelet.common.ecommerce.spi.LockProvider; +import apelet.common.ecommerce.spi.TokenStore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Clock; +import java.time.Instant; +import java.util.Optional; + +/** + * 令牌管理模板。 + * + *
    + *
  • 按账户隔离缓存
  • + *
  • 提前 {@code earlyRefreshSeconds} 续期
  • + *
  • 刷新用 {@link LockProvider} 防击穿
  • + *
  • 刷新失败不返回旧 token(除显式宽限窗口)
  • + *
  • 日志只打印指纹
  • + *
+ */ +public abstract class TokenManager { + + private static final Logger LOG = LoggerFactory.getLogger(TokenManager.class); + + protected final TokenStore tokenStore; + protected final LockProvider lockProvider; + protected final Clock clock; + private final long earlyRefreshSeconds; + + protected TokenManager(TokenStore tokenStore, LockProvider lockProvider, Clock clock, long earlyRefreshSeconds) { + this.tokenStore = tokenStore; + this.lockProvider = lockProvider; + this.clock = clock; + this.earlyRefreshSeconds = earlyRefreshSeconds; + } + + /** 拿到可用的 access token;必要时刷新。 */ + public AccessToken getOrRefresh(String tokenKey, ChannelSecret secret) { + Instant now = Instant.now(clock); + Optional cached = tokenStore.get(tokenKey); + if (cached.isPresent() && !cached.get().needsRefresh(now, earlyRefreshSeconds)) { + return cached.get(); + } + + Optional lock = lockProvider.tryLock("token-refresh:" + tokenKey, 30); + if (!lock.isPresent()) { + // 别的线程在刷新:再读一次,读不到就短等待后重读 + Optional again = tokenStore.get(tokenKey); + if (again.isPresent() && !again.get().needsRefresh(now, earlyRefreshSeconds)) { + return again.get(); + } + throw new apelet.common.ecommerce.error.AuthException( + apelet.common.ecommerce.error.ChannelException.builder() + .message("token refresh in progress for " + tokenKey) + .operation("token.refresh")); + } + + try { + Optional again = tokenStore.get(tokenKey); + Instant nowInLock = Instant.now(clock); + if (again.isPresent() && !again.get().needsRefresh(nowInLock, earlyRefreshSeconds)) { + return again.get(); + } + AccessToken refreshed = refresh(secret, cached.orElse(null)); + tokenStore.save(tokenKey, refreshed); + LOG.info("token refreshed for {} [{}]", tokenKey, refreshed.fingerprint()); + return refreshed; + } finally { + lock.get().close(); + } + } + + /** 401 后作废并强制重取一次。 */ + public AccessToken forceRefresh(String tokenKey, ChannelSecret secret) { + tokenStore.remove(tokenKey); + return getOrRefresh(tokenKey, secret); + } + + /** 由平台实现:向平台换取新令牌。 */ + protected abstract AccessToken refresh(ChannelSecret secret, AccessToken previous); +} diff --git a/http/src/test/java/apelet/common/ecommerce/http/ResponsesTest.java b/http/src/test/java/apelet/common/ecommerce/http/ResponsesTest.java new file mode 100644 index 0000000..3bbab42 --- /dev/null +++ b/http/src/test/java/apelet/common/ecommerce/http/ResponsesTest.java @@ -0,0 +1,43 @@ +package apelet.common.ecommerce.http; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ResponsesTest { + + @Test + void successPasses() { + Responses.throwIfNotSuccess(HttpResponse.of(200, null, "{}", 1), "op"); + } + + @Test + void authMapsToAuthException() { + assertThrows(apelet.common.ecommerce.error.AuthException.class, + () -> Responses.throwIfNotSuccess(HttpResponse.of(401, null, "{}", 1), "op")); + } + + @Test + void rateLimitCarriesRetryAfter() { + apelet.common.ecommerce.error.RateLimitException ex = assertThrows( + apelet.common.ecommerce.error.RateLimitException.class, + () -> Responses.throwIfNotSuccess( + HttpResponse.of(429, java.util.Collections.singletonMap("Retry-After", "3"), "", 1), "op")); + assertEquals(3000L, ex.retryAfterMillis()); + } + + @Test + void serverErrorIsRetryable() { + apelet.common.ecommerce.error.TransientException ex = assertThrows( + apelet.common.ecommerce.error.TransientException.class, + () -> Responses.throwIfNotSuccess(HttpResponse.of(503, null, "", 1), "op")); + assertEquals(true, ex.retryable()); + } + + @Test + void badRequestIsValidation() { + assertThrows(apelet.common.ecommerce.error.ValidationException.class, + () -> Responses.throwIfNotSuccess(HttpResponse.of(400, null, "", 1), "op")); + } +}