This commit is contained in:
2026-09-29 18:47:37 +08:00
parent 2c60a29e0f
commit c2927ab9a6
291 changed files with 4008 additions and 6298 deletions
@@ -3,6 +3,13 @@ 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;
@@ -22,6 +29,9 @@ public class EventDeliveryFeignConfiguration {
}
/** 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; }
@@ -29,5 +39,26 @@ public class EventDeliveryFeignConfiguration {
// 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);
};
}
}
}
@@ -0,0 +1,78 @@
package com.baiyue.common.web;
import io.micrometer.core.instrument.Gauge;
import com.baiyue.common.event.EventDeliveryRoute;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.jdbc.core.JdbcTemplate;
import java.sql.Timestamp;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/** Fixed labels only. Scheduled samplers query the local schema; scrape callbacks only read memory. */
public final class EventDeliveryMetrics {
public enum Stream {
FIRSTLEG("firstleg", "fl_inbound_outbox", "publish_status", "PROCESSING",
List.of("FirstLegLoadingPlanCompleted", "FirstLegContainerShippingNodeRecorded")),
LASTLEG("lastleg", "ll_event_outbox", "status", "PROCESSING",
List.of("OverseasContainerUnloaded", "OverseasInboundOrderDetainedChanged", "OverseasDeliveryCompleted", "WalletCommandRequested")),
FEE("lastleg", "ll_event_outbox", "status", "PROCESSING", List.of("BusinessFeeSourceChanged")),
FINANCE("finance", "fin_wallet_event_outbox", "status", "SENDING", List.of("WalletCommandCompleted"));
public final String service, table, status, processing;
public final List<String> types;
Stream(String service, String table, String status, String processing, List<String> types) {
this.service = service; this.table = table; this.status = status; this.processing = processing; this.types = types;
}
public String type(String candidate) { return candidate != null && types.contains(candidate) ? candidate : "UNKNOWN"; }
public String target(String type) {
if (!types.contains(type)) return "UNRESOLVED"; // Audit only; never a transport fallback.
var route = EventDeliveryRoute.forEventType(type);
return route.serviceName() + route.path();
}
}
private record Snapshot(double pending, double dead, double oldestSeconds, double sampledAt, double success) { }
private static final class Cell { volatile Snapshot value = new Snapshot(Double.NaN, Double.NaN, Double.NaN, 0, 0); }
private final JdbcTemplate jdbc;
private final MeterRegistry registry;
private final Map<String, Cell> cells = new ConcurrentHashMap<>();
public EventDeliveryMetrics(JdbcTemplate jdbc, MeterRegistry registry) { this.jdbc = jdbc; this.registry = registry; }
public void sample(Stream stream, LocalDateTime now) {
for (String type : stream.types) {
Cell cell = cells.computeIfAbsent(stream.name() + ":" + type, ignored -> register(stream, type));
try {
String filter = stream == Stream.FINANCE ? "" : " WHERE event_type=?";
String sql = "SELECT SUM(CASE WHEN " + stream.status + " IN ('PENDING','" + stream.processing
+ "') THEN 1 ELSE 0 END) AS pending_count, SUM(CASE WHEN " + stream.status
+ "='DEAD' THEN 1 ELSE 0 END) AS dead_count, MIN(CASE WHEN " + stream.status
+ " IN ('PENDING','" + stream.processing + "') THEN created_at END) AS oldest FROM " + stream.table + filter;
Map<String, Object> result = stream == Stream.FINANCE ? jdbc.queryForMap(sql) : jdbc.queryForMap(sql, type);
LocalDateTime oldest = databaseTime(result.get("oldest"));
cell.value = new Snapshot(count(result.get("pending_count")), count(result.get("dead_count")),
oldest == null ? 0 : Math.max(0, Duration.between(oldest, now).toMillis() / 1000.0),
System.currentTimeMillis() / 1000.0, 1);
} catch (RuntimeException failure) {
Snapshot old = cell.value;
cell.value = new Snapshot(old.pending, old.dead, old.oldestSeconds, old.sampledAt, 0);
}
}
}
private Cell register(Stream stream, String type) {
Cell cell = new Cell();
Gauge.builder("baiyue.event.delivery.pending", cell, c -> c.value.pending).tags("service", stream.service, "event_type", type).register(registry);
Gauge.builder("baiyue.event.delivery.dead", cell, c -> c.value.dead).tags("service", stream.service, "event_type", type).register(registry);
Gauge.builder("baiyue.event.delivery.oldest.pending.seconds", cell, c -> c.value.oldestSeconds).tags("service", stream.service, "event_type", type).register(registry);
Gauge.builder("baiyue.event.delivery.sample.timestamp", cell, c -> c.value.sampledAt).tags("service", stream.service, "event_type", type).register(registry);
Gauge.builder("baiyue.event.delivery.sample.success", cell, c -> c.value.success).tags("service", stream.service, "event_type", type).register(registry);
return cell;
}
private static LocalDateTime databaseTime(Object value) {
if (value == null) return null;
if (value instanceof LocalDateTime local) return local;
if (value instanceof Timestamp timestamp) return timestamp.toLocalDateTime();
throw new IllegalArgumentException("Unsupported Outbox timestamp type");
}
private static double count(Object value) { return value == null ? 0 : ((Number) value).doubleValue(); }
}
@@ -0,0 +1,29 @@
package com.baiyue.common.web;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.baiyue.common.event.EventIdentifiers;
import feign.FeignException;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.Set;
/** Diagnostic identity and finite audit error codes; never serialize exception messages or payloads. */
public final class EventDeliveryObservation {
private EventDeliveryObservation() { }
public static String eventId(ObjectMapper mapper, String json) {
try {
String id = mapper.readTree(json).path("eventId").asText("");
return EventIdentifiers.isSupported(id) ? id : null;
} catch (Exception invalid) { return null; } // Invalid payloads are still audited by Outbox entry ID.
}
public static String errorCode(Throwable error, boolean permanent, boolean requestStarted) {
Set<Throwable> seen = Collections.newSetFromMap(new IdentityHashMap<>());
for (Throwable cause = error; cause != null && seen.add(cause); cause = cause.getCause()) {
if (cause instanceof FeignException http && http.status() >= 100 && http.status() <= 599) {
return "HTTP_" + http.status();
}
}
if (!requestStarted) return "INVALID_EVENT";
return permanent ? "PERMANENT_FAILURE" : "DELIVERY_FAILURE";
}
}
@@ -0,0 +1,25 @@
package com.baiyue.common.web;
import com.baiyue.common.core.outbox.EventDeliveryJournal;
import com.baiyue.common.core.outbox.EventDeliveryProducer;
import io.micrometer.core.instrument.MeterRegistry;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.boot.autoconfigure.jdbc.JdbcTemplateAutoConfiguration;
import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
@AutoConfiguration(after = {JdbcTemplateAutoConfiguration.class, DataSourceTransactionManagerAutoConfiguration.class})
@ConditionalOnBean(EventDeliveryProducer.class)
public class EventDeliveryObservationAutoConfiguration {
@Bean
EventDeliveryJournal eventDeliveryJournal(JdbcTemplate jdbc, PlatformTransactionManager manager) {
return new EventDeliveryJournal(jdbc, manager);
}
@Bean
EventDeliveryMetrics eventDeliveryMetrics(JdbcTemplate jdbc, MeterRegistry registry) {
return new EventDeliveryMetrics(jdbc, registry);
}
}