package com.baiyue.common.web; import feign.Request; import feign.Capability; import feign.Retryer; import feign.codec.Decoder; import feign.codec.DecodeException; import com.baiyue.common.core.api.ApiResponse; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import java.lang.reflect.ParameterizedType; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import java.util.concurrent.TimeUnit; /** Explicitly selected by reliable event clients; never registered as a global configuration. */ public class EventDeliveryFeignConfiguration { @Bean public DeliveryTransportCapability eventDeliveryCapability( @Value("${baiyue.event-delivery.connect-timeout-ms:3000}") int connectTimeout, @Value("${baiyue.event-delivery.read-timeout-ms:15000}") int readTimeout, @Value("${baiyue.event-delivery.lease-seconds:60}") long leaseSeconds) { if (connectTimeout < 1 || readTimeout < 1 || leaseSeconds < 1 || leaseSeconds > Integer.MAX_VALUE || (long) connectTimeout + readTimeout >= leaseSeconds * 1000) { throw new IllegalArgumentException("Event delivery timeouts must fit inside the lease"); } return new DeliveryTransportCapability(new Request.Options(connectTimeout, TimeUnit.MILLISECONDS, readTimeout, TimeUnit.MILLISECONDS, false)); } /** Applied at Feign build time after global/client properties, without changing other clients. */ public static final class DeliveryTransportCapability implements Capability { private static final ObjectMapper RESPONSE_JSON = new ObjectMapper() .enable(JsonParser.Feature.STRICT_DUPLICATE_DETECTION) .enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS); private final Request.Options options; public DeliveryTransportCapability(Request.Options options) { this.options = options; } @Override public Request.Options enrich(Request.Options ignored) { return options; } @Override public Retryer enrich(Retryer ignored) { // The persisted Outbox owns the retry budget. return Retryer.NEVER_RETRY; } @Override public Decoder enrich(Decoder decoder) { return (response, type) -> { if (type instanceof ParameterizedType parameterized && parameterized.getRawType() == ApiResponse.class) { // Jackson normally coerces a numeric 200 into String. Inspect the wire value first. if (response.body() == null) throw new DecodeException(response.status(), "Missing event response", response.request()); byte[] bytes; try (var stream = response.body().asInputStream()) { bytes = stream.readNBytes(65537); } if (bytes.length > 65536) throw new DecodeException(response.status(), "Event response exceeds limit", response.request()); try { var root = RESPONSE_JSON.readTree(bytes); if (root == null || !root.isObject() || !root.path("code").isTextual()) { throw new IllegalArgumentException(); } } catch (Exception invalid) { throw new DecodeException(response.status(), "Invalid event response contract", response.request()); } return decoder.decode(response.toBuilder().body(bytes).build(), type); } return decoder.decode(response, type); }; } } }