Browse Source

feat(http): HTTP 传输、JSON 编解码、签名与令牌管理模板

- HttpTransport 接口 + OkHttpTransport(连接 5s / 读 30s / 统一 UA)
- HttpRequest / HttpResponse 保留原始 header 与 body 供留痕
- JsonCodec + JacksonJsonCodec(点路径取值、数组反序列化)
- Signer 接口:签名差异收敛到各适配器
- TokenManager:按账户隔离、提前续期、LockProvider 防击穿、
  刷新失败不返回旧 token、日志只打指纹
- Responses:401→Auth、429→RateLimit(带 Retry-After)、5xx→Transient、
  其他 4xx→Validation

Co-Authored-By: Claude <noreply@anthropic.com>
master
chenchuchuan 19 hours ago
parent
commit
d87d9dec53
  1. 110
      http/src/main/java/apelet/common/ecommerce/http/HttpRequest.java
  2. 69
      http/src/main/java/apelet/common/ecommerce/http/HttpResponse.java
  3. 9
      http/src/main/java/apelet/common/ecommerce/http/HttpTransport.java
  4. 86
      http/src/main/java/apelet/common/ecommerce/http/JacksonJsonCodec.java
  5. 17
      http/src/main/java/apelet/common/ecommerce/http/JsonCodec.java
  6. 70
      http/src/main/java/apelet/common/ecommerce/http/OkHttpTransport.java
  7. 56
      http/src/main/java/apelet/common/ecommerce/http/Responses.java
  8. 10
      http/src/main/java/apelet/common/ecommerce/http/Signer.java
  9. 85
      http/src/main/java/apelet/common/ecommerce/http/TokenManager.java
  10. 43
      http/src/test/java/apelet/common/ecommerce/http/ResponsesTest.java

110
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<String, String> 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<String, String>(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<String, String> 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<String, String> headers = new LinkedHashMap<String, String>();
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<String, String> 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);
}
}
}

69
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<String, String> headers;
private final String body;
private final long latencyMillis;
private HttpResponse(int statusCode, Map<String, String> headers, String body, long latencyMillis) {
this.statusCode = statusCode;
this.headers = Collections.unmodifiableMap(new LinkedHashMap<String, String>(headers));
this.body = body;
this.latencyMillis = latencyMillis;
}
public static HttpResponse of(int statusCode, Map<String, String> headers, String body, long latencyMillis) {
return new HttpResponse(statusCode, headers == null ? Collections.<String, String>emptyMap() : headers,
body, latencyMillis);
}
public int statusCode() {
return statusCode;
}
public Map<String, String> 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<String, String> e : headers.entrySet()) {
if (e.getKey().equalsIgnoreCase(name)) {
return e.getValue();
}
}
return null;
}
}

9
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);
}

86
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> T fromJson(String json, Class<T> 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 <T> List<T> listAt(String json, String pointer, Class<T> type) {
JsonNode node = nodeAt(json, pointer);
if (node == null || !node.isArray()) {
return new ArrayList<T>();
}
List<T> out = new ArrayList<T>(node.size());
for (JsonNode item : (ArrayNode) node) {
out.add(mapper.convertValue(item, type));
}
return out;
}
}

17
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> T fromJson(String json, Class<T> type);
/**
* 按点路径取字段值,用于解析平台响应里的 data.list 这类结构。
* 找不到返回 null。
*/
String stringAt(String json, String pointer);
}

70
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<String, String> 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<String, String> headers = new java.util.LinkedHashMap<String, String>();
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));
}
}
}

56
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;
/**
* 响应判定与重试分类。
*
* <ul>
* <li>2xx:成功</li>
* <li>401/403:{@link AuthException},刷新后重试一次</li>
* <li>429:{@link RateLimitException},遵守 Retry-After</li>
* <li>5xx / 网络:{@link TransientException},退避加抖动</li>
* <li>其他 4xx:业务错误,不在此层重试</li>
* </ul>
*/
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;
}
}
}

10
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);
}

85
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;
/**
* 令牌管理模板。
*
* <ul>
* <li>按账户隔离缓存</li>
* <li>提前 {@code earlyRefreshSeconds} 续期</li>
* <li>刷新用 {@link LockProvider} 防击穿</li>
* <li>刷新失败不返回旧 token(除显式宽限窗口)</li>
* <li>日志只打印指纹</li>
* </ul>
*/
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<AccessToken> cached = tokenStore.get(tokenKey);
if (cached.isPresent() && !cached.get().needsRefresh(now, earlyRefreshSeconds)) {
return cached.get();
}
Optional<LockProvider.Lock> lock = lockProvider.tryLock("token-refresh:" + tokenKey, 30);
if (!lock.isPresent()) {
// 别的线程在刷新:再读一次,读不到就短等待后重读
Optional<AccessToken> 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<AccessToken> 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);
}

43
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"));
}
}
Loading…
Cancel
Save