暂存提交
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;
|
||||
Reference in New Issue
Block a user