暂存提交
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 跨服务和跨区域传输使用的不可变事件信封。
|
||||
*
|
||||
* <p>租户 ID 和聚合 ID 使用字符串,避免跨语言传输时发生大整数精度丢失。
|
||||
* {@code payloadHash} 是原始规范化负载字节的 SHA-256 摘要。</p>
|
||||
*
|
||||
* @param eventId 事件全局唯一 ID,使用标准 UUID 或 26 位 ULID
|
||||
* @param eventType 事件类型,不包含版本号
|
||||
* @param eventVersion 事件契约版本,从 1 开始
|
||||
* @param occurredAt 业务事实实际发生时间,使用 UTC 时间点
|
||||
* @param sourceRegion 事件来源业务单元
|
||||
* @param targetRegion 事件目标业务单元;国内一期允许与来源区域相同
|
||||
* @param tenantId 事件所属客户租户 ID
|
||||
* @param aggregateType 聚合类型
|
||||
* @param aggregateId 聚合 ID
|
||||
* @param aggregateVersion 聚合版本,用于阻止乱序事件覆盖新状态
|
||||
* @param handoverVersion 跨区交接版本;非交接事件可为空
|
||||
* @param traceId 分布式链路 ID
|
||||
* @param correlationId 同一业务流程的关联 ID
|
||||
* @param causationId 直接触发当前事件的上游事件 ID;根事件可为空
|
||||
* @param payload 事件业务负载
|
||||
* @param payloadHash 负载摘要,格式为 sha256 加冒号和 64 位十六进制字符
|
||||
*/
|
||||
public record EventEnvelope(
|
||||
String eventId,
|
||||
String eventType,
|
||||
int eventVersion,
|
||||
Instant occurredAt,
|
||||
RegionCode sourceRegion,
|
||||
RegionCode targetRegion,
|
||||
String tenantId,
|
||||
String aggregateType,
|
||||
String aggregateId,
|
||||
long aggregateVersion,
|
||||
Long handoverVersion,
|
||||
String traceId,
|
||||
String correlationId,
|
||||
String causationId,
|
||||
JsonNode payload,
|
||||
String payloadHash
|
||||
) {
|
||||
/**
|
||||
* 校验事件信封,不符合基础契约时抛出异常。
|
||||
*/
|
||||
public EventEnvelope requireValid() {
|
||||
EventEnvelopeValidator.requireValid(this);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 事件信封基础校验器。
|
||||
*
|
||||
* <p>这里只校验所有事件共有的传输约束,具体事件负载由对应业务消费者按事件版本校验。</p>
|
||||
*/
|
||||
public final class EventEnvelopeValidator {
|
||||
private static final Pattern EVENT_TYPE_PATTERN = Pattern.compile("^[A-Z][A-Za-z0-9]{1,63}$");
|
||||
private static final Pattern AGGREGATE_TYPE_PATTERN = Pattern.compile("^[A-Z][A-Z0-9_]{1,63}$");
|
||||
private static final int MAX_ID_LENGTH = 128;
|
||||
|
||||
private EventEnvelopeValidator() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回全部基础校验错误;合法时返回空列表。
|
||||
*/
|
||||
public static List<String> validate(EventEnvelope envelope) {
|
||||
List<String> errors = new ArrayList<>();
|
||||
if (envelope == null) {
|
||||
return List.of("事件信封不能为空");
|
||||
}
|
||||
if (!EventIdentifiers.isSupported(envelope.eventId())) {
|
||||
errors.add("eventId 必须是标准 UUID 或 26 位 ULID");
|
||||
}
|
||||
if (envelope.eventType() == null || !EVENT_TYPE_PATTERN.matcher(envelope.eventType()).matches()) {
|
||||
errors.add("eventType 必须以大写字母开头且只能包含字母和数字,长度为 2 至 64 位");
|
||||
}
|
||||
if (envelope.eventVersion() < 1) {
|
||||
errors.add("eventVersion 必须大于等于 1");
|
||||
}
|
||||
if (envelope.occurredAt() == null) {
|
||||
errors.add("occurredAt 不能为空");
|
||||
}
|
||||
if (envelope.sourceRegion() == null) {
|
||||
errors.add("sourceRegion 不能为空");
|
||||
}
|
||||
if (envelope.targetRegion() == null) {
|
||||
errors.add("targetRegion 不能为空");
|
||||
}
|
||||
requireText(errors, "tenantId", envelope.tenantId(), MAX_ID_LENGTH);
|
||||
if (envelope.aggregateType() == null
|
||||
|| !AGGREGATE_TYPE_PATTERN.matcher(envelope.aggregateType()).matches()) {
|
||||
errors.add("aggregateType 必须使用大写字母、数字或下划线,长度为 2 至 64 位");
|
||||
}
|
||||
requireText(errors, "aggregateId", envelope.aggregateId(), MAX_ID_LENGTH);
|
||||
if (envelope.aggregateVersion() < 0) {
|
||||
errors.add("aggregateVersion 不能小于 0");
|
||||
}
|
||||
if (envelope.handoverVersion() != null && envelope.handoverVersion() < 1) {
|
||||
errors.add("handoverVersion 存在时必须大于等于 1");
|
||||
}
|
||||
requireText(errors, "traceId", envelope.traceId(), MAX_ID_LENGTH);
|
||||
requireText(errors, "correlationId", envelope.correlationId(), MAX_ID_LENGTH);
|
||||
if (envelope.causationId() != null && !EventIdentifiers.isSupported(envelope.causationId())) {
|
||||
errors.add("causationId 存在时必须是标准 UUID 或 26 位 ULID");
|
||||
}
|
||||
if (envelope.payload() == null || envelope.payload().isNull()) {
|
||||
errors.add("payload 不能为空");
|
||||
}
|
||||
if (!EventPayloadHash.hasValidFormat(envelope.payloadHash())) {
|
||||
errors.add("payloadHash 必须使用 sha256 加冒号和 64 位十六进制字符格式");
|
||||
}
|
||||
return List.copyOf(errors);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验事件信封,存在错误时抛出包含全部原因的异常。
|
||||
*/
|
||||
public static void requireValid(EventEnvelope envelope) {
|
||||
List<String> errors = validate(envelope);
|
||||
if (!errors.isEmpty()) {
|
||||
throw new IllegalArgumentException(String.join(";", errors));
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireText(List<String> errors, String field, String value, int maxLength) {
|
||||
if (value == null || value.isBlank()) {
|
||||
errors.add(field + " 不能为空");
|
||||
} else if (value.length() > maxLength) {
|
||||
errors.add(field + " 长度不能超过 " + maxLength + " 位");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 事件标识生成与格式判断工具。
|
||||
*/
|
||||
public final class EventIdentifiers {
|
||||
private static final Pattern UUID_PATTERN = Pattern.compile(
|
||||
"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$");
|
||||
private static final Pattern ULID_PATTERN = Pattern.compile(
|
||||
"^[0-7][0-9A-HJKMNP-TV-Z]{25}$", Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private EventIdentifiers() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成标准 UUID 字符串。
|
||||
*/
|
||||
public static String newUuid() {
|
||||
return UUID.randomUUID().toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断字符串是否为标准 UUID 或 26 位 ULID。
|
||||
*/
|
||||
public static boolean isSupported(String value) {
|
||||
return value != null && (UUID_PATTERN.matcher(value).matches() || ULID_PATTERN.matcher(value).matches());
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
|
||||
/**
|
||||
* 将 JSON 负载转换为稳定字节序列,避免对象字段顺序不同导致摘要不一致。
|
||||
*/
|
||||
public final class EventPayloadCanonicalizer {
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper()
|
||||
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
|
||||
|
||||
private EventPayloadCanonicalizer() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归排序对象字段并输出 UTF-8 JSON 字节。
|
||||
*/
|
||||
public static byte[] canonicalize(JsonNode payload) {
|
||||
if (payload == null || payload.isNull()) {
|
||||
throw new IllegalArgumentException("事件负载不能为空");
|
||||
}
|
||||
try {
|
||||
return OBJECT_MAPPER.writeValueAsBytes(sort(payload));
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw new IllegalArgumentException("事件负载无法规范化", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonNode sort(JsonNode node) {
|
||||
if (node.isObject()) {
|
||||
var names = new java.util.ArrayList<String>();
|
||||
node.fieldNames().forEachRemaining(names::add);
|
||||
names.sort(String::compareTo);
|
||||
var result = OBJECT_MAPPER.createObjectNode();
|
||||
names.forEach(name -> result.set(name, sort(node.get(name))));
|
||||
return result;
|
||||
}
|
||||
if (node.isArray()) {
|
||||
var result = OBJECT_MAPPER.createArrayNode();
|
||||
node.forEach(child -> result.add(sort(child)));
|
||||
return result;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 事件负载 SHA-256 摘要工具。
|
||||
*/
|
||||
public final class EventPayloadHash {
|
||||
private static final Pattern HASH_PATTERN = Pattern.compile("^sha256:[0-9a-fA-F]{64}$");
|
||||
|
||||
private EventPayloadHash() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算调用方提供的规范化负载字节摘要。
|
||||
*/
|
||||
public static String sha256(byte[] canonicalPayload) {
|
||||
if (canonicalPayload == null) {
|
||||
throw new IllegalArgumentException("规范化负载字节不能为空");
|
||||
}
|
||||
try {
|
||||
byte[] digest = MessageDigest.getInstance("SHA-256").digest(canonicalPayload);
|
||||
return "sha256:" + HexFormat.of().formatHex(digest);
|
||||
} catch (NoSuchAlgorithmException exception) {
|
||||
throw new IllegalStateException("当前运行环境不支持 SHA-256", exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断摘要是否符合约定格式。
|
||||
*/
|
||||
public static boolean hasValidFormat(String value) {
|
||||
return value != null && HASH_PATTERN.matcher(value).matches();
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用常量时间比较校验规范化负载字节与摘要是否一致。
|
||||
*/
|
||||
public static boolean matches(byte[] canonicalPayload, String expectedHash) {
|
||||
if (canonicalPayload == null || !hasValidFormat(expectedHash)) {
|
||||
return false;
|
||||
}
|
||||
byte[] actual = sha256(canonicalPayload).getBytes(StandardCharsets.US_ASCII);
|
||||
byte[] expected = expectedHash.toLowerCase(Locale.ROOT).getBytes(StandardCharsets.US_ASCII);
|
||||
return MessageDigest.isEqual(actual, expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
/**
|
||||
* 跨服务和跨区域事件类型常量。
|
||||
*
|
||||
* <p>事件版本由事件信封的 {@code eventVersion} 单独表达,类型名称本身不携带版本号。</p>
|
||||
*/
|
||||
public final class EventTypes {
|
||||
public static final String ORDER_APPROVED = "OrderApproved";
|
||||
public static final String ORDER_CANCELLED = "OrderCancelled";
|
||||
public static final String FIRST_LEG_SHIPMENT_DEPARTED = "FirstLegShipmentDeparted";
|
||||
public static final String LAST_MILE_HANDOFF_ACCEPTED = "LastMileHandoffAccepted";
|
||||
public static final String LAST_MILE_HANDOFF_REJECTED = "LastMileHandoffRejected";
|
||||
public static final String LAST_MILE_MILESTONE_CHANGED = "LastMileMilestoneChanged";
|
||||
public static final String LAST_MILE_COMPLETED = "LastMileCompleted";
|
||||
public static final String TRACKING_MILESTONE_PROJECTED = "TrackingMilestoneProjected";
|
||||
|
||||
private EventTypes() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
/**
|
||||
* 事件来源与目标部署区域。
|
||||
*
|
||||
* <p>区域表示独立业务单元,不表示用户当前所在地。</p>
|
||||
*/
|
||||
public enum RegionCode {
|
||||
/** 国内业务单元。 */
|
||||
CN,
|
||||
/** 海外业务单元。 */
|
||||
OVERSEAS
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* 国内与海外业务单元共享的稳定事件契约。
|
||||
*/
|
||||
package com.baiyue.common.event;
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.baiyue.common.event;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class EventEnvelopeTest {
|
||||
private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
|
||||
@Test
|
||||
void 标准Uuid事件可以通过校验并完成序列化往返() throws Exception {
|
||||
EventEnvelope source = validEnvelope(EventIdentifiers.newUuid());
|
||||
|
||||
source.requireValid();
|
||||
String json = objectMapper.writeValueAsString(source);
|
||||
EventEnvelope restored = objectMapper.readValue(json, EventEnvelope.class);
|
||||
|
||||
assertThat(restored).isEqualTo(source);
|
||||
assertThat(objectMapper.readTree(json).path("eventType").asText())
|
||||
.isEqualTo(EventTypes.FIRST_LEG_SHIPMENT_DEPARTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 合法Ulid可以作为事件标识() {
|
||||
EventEnvelope envelope = validEnvelope("01J8Z4QX9K7M2N3P4R5S6T7V8W");
|
||||
|
||||
assertThat(EventEnvelopeValidator.validate(envelope)).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 校验器一次返回全部基础错误() {
|
||||
EventEnvelope envelope = new EventEnvelope(
|
||||
"bad-id", "bad_type", 0, null, null, null, " ", "bad-type", " ",
|
||||
-1, 0L, " ", " ", "bad-cause", null, "bad-hash");
|
||||
|
||||
List<String> errors = EventEnvelopeValidator.validate(envelope);
|
||||
|
||||
assertThat(errors).hasSizeGreaterThanOrEqualTo(10);
|
||||
assertThatThrownBy(envelope::requireValid)
|
||||
.isInstanceOf(IllegalArgumentException.class)
|
||||
.hasMessageContaining("eventId")
|
||||
.hasMessageContaining("payloadHash");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 负载摘要可计算并校验() {
|
||||
byte[] payload = "{\"orderId\":\"90001\"}".getBytes(StandardCharsets.UTF_8);
|
||||
String hash = EventPayloadHash.sha256(payload);
|
||||
|
||||
assertThat(hash).matches("sha256:[0-9a-f]{64}");
|
||||
assertThat(EventPayloadHash.matches(payload, hash)).isTrue();
|
||||
assertThat(EventPayloadHash.matches("{}".getBytes(StandardCharsets.UTF_8), hash)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void 对象字段顺序不同仍产生相同规范摘要() throws Exception {
|
||||
JsonNode first = objectMapper.readTree("{\"orderId\":\"90001\",\"route\":{\"country\":\"US\",\"city\":\"LA\"}}");
|
||||
JsonNode second = objectMapper.readTree("{\"route\":{\"city\":\"LA\",\"country\":\"US\"},\"orderId\":\"90001\"}");
|
||||
|
||||
assertThat(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(first)))
|
||||
.isEqualTo(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(second)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void 数组顺序仍作为业务语义保留() throws Exception {
|
||||
JsonNode first = objectMapper.readTree("{\"steps\":[\"A\",\"B\"]}");
|
||||
JsonNode second = objectMapper.readTree("{\"steps\":[\"B\",\"A\"]}");
|
||||
|
||||
assertThat(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(first)))
|
||||
.isNotEqualTo(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(second)));
|
||||
}
|
||||
|
||||
private EventEnvelope validEnvelope(String eventId) {
|
||||
JsonNode payload = objectMapper.createObjectNode()
|
||||
.put("containerNo", "MSCU1234567")
|
||||
.put("orderId", "90001");
|
||||
byte[] canonicalPayload = payload.toString().getBytes(StandardCharsets.UTF_8);
|
||||
return new EventEnvelope(
|
||||
eventId,
|
||||
EventTypes.FIRST_LEG_SHIPMENT_DEPARTED,
|
||||
1,
|
||||
Instant.parse("2026-07-29T08:30:00Z"),
|
||||
RegionCode.CN,
|
||||
RegionCode.OVERSEAS,
|
||||
"20001",
|
||||
"FIRST_LEG_SHIPMENT",
|
||||
"90001",
|
||||
7,
|
||||
1L,
|
||||
"trace-90001",
|
||||
"handover-90001",
|
||||
null,
|
||||
payload,
|
||||
EventPayloadHash.sha256(canonicalPayload));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user