From 0192380e81160b8fcb196ad985ae0bcccdaa9130 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Fri, 14 Aug 2026 15:15:43 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9A=82=E5=AD=98=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/EventPayloadCanonicalizer.java | 34 ++++++++++++++++++- .../event/EventPayloadCanonicalizerTest.java | 29 ++++++++++++++++ deploy/sql/头程数据库统一增量-MySQL8.sql | 2 ++ .../service/EventInboxService.java | 4 ++- .../firstleg/client/EventBridgeClient.java | 2 +- firstleg/src/main/resources/application.yml | 2 ++ .../resources/db/phase2-inbound-schema.sql | 2 +- ...se3-loading-completed-outbox-migration.sql | 2 ++ 8 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 common-event-contract/src/test/java/com/baiyue/common/event/EventPayloadCanonicalizerTest.java diff --git a/common-event-contract/src/main/java/com/baiyue/common/event/EventPayloadCanonicalizer.java b/common-event-contract/src/main/java/com/baiyue/common/event/EventPayloadCanonicalizer.java index 57d173010..6e86f8374 100644 --- a/common-event-contract/src/main/java/com/baiyue/common/event/EventPayloadCanonicalizer.java +++ b/common-event-contract/src/main/java/com/baiyue/common/event/EventPayloadCanonicalizer.java @@ -22,8 +22,20 @@ public final class EventPayloadCanonicalizer { if (payload == null || payload.isNull()) { throw new IllegalArgumentException("事件负载不能为空"); } + return write(sort(payload)); + } + + /** 仅用于接收规范化数字规则上线前已经生成的历史摘要。 */ + public static byte[] canonicalizeLegacy(JsonNode payload) { + if (payload == null || payload.isNull()) { + throw new IllegalArgumentException("事件负载不能为空"); + } + return write(sortLegacy(payload)); + } + + private static byte[] write(JsonNode payload) { try { - return OBJECT_MAPPER.writeValueAsBytes(sort(payload)); + return OBJECT_MAPPER.writeValueAsBytes(payload); } catch (JsonProcessingException exception) { throw new IllegalArgumentException("事件负载无法规范化", exception); } @@ -43,6 +55,26 @@ public final class EventPayloadCanonicalizer { node.forEach(child -> result.add(sort(child))); return result; } + if (node.isNumber()) { + return com.fasterxml.jackson.databind.node.DecimalNode.valueOf(node.decimalValue().stripTrailingZeros()); + } + return node; + } + + private static JsonNode sortLegacy(JsonNode node) { + if (node.isObject()) { + var names = new java.util.ArrayList(); + node.fieldNames().forEachRemaining(names::add); + names.sort(String::compareTo); + var result = OBJECT_MAPPER.createObjectNode(); + names.forEach(name -> result.set(name, sortLegacy(node.get(name)))); + return result; + } + if (node.isArray()) { + var result = OBJECT_MAPPER.createArrayNode(); + node.forEach(child -> result.add(sortLegacy(child))); + return result; + } return node; } } diff --git a/common-event-contract/src/test/java/com/baiyue/common/event/EventPayloadCanonicalizerTest.java b/common-event-contract/src/test/java/com/baiyue/common/event/EventPayloadCanonicalizerTest.java new file mode 100644 index 000000000..7d0f2938b --- /dev/null +++ b/common-event-contract/src/test/java/com/baiyue/common/event/EventPayloadCanonicalizerTest.java @@ -0,0 +1,29 @@ +package com.baiyue.common.event; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +class EventPayloadCanonicalizerTest { + private final ObjectMapper json = new ObjectMapper(); + + @Test + void equivalentNumberLexemesHaveTheSameCanonicalFormAndHash() throws Exception { + var scaled = json.readTree("{\"weight\":30.0,\"volume\":0.3150,\"quantity\":3}"); + var normalized = json.readTree("{\"quantity\":3.0,\"volume\":0.315,\"weight\":3E+1}"); + assertArrayEquals(EventPayloadCanonicalizer.canonicalize(scaled), EventPayloadCanonicalizer.canonicalize(normalized)); + assertEquals(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(scaled)), + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(normalized))); + } + + @Test + void negativeZeroIsZeroAndLegacyHashRemainsAvailable() throws Exception { + var negativeZero = json.readTree("{\"value\":-0.000}"); + var zero = json.readTree("{\"value\":0}"); + assertArrayEquals(EventPayloadCanonicalizer.canonicalize(negativeZero), EventPayloadCanonicalizer.canonicalize(zero)); + assertNotEquals(EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(negativeZero)), + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalizeLegacy(negativeZero))); + } +} diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index b79ee14be..4d1f2a252 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -14959,6 +14959,8 @@ USE baiyue_firstleg; -- 标准跨区事件摘要包含 sha256: 前缀,共 71 字符;兼容既有 Finance 64 位摘要。 ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀'; +ALTER TABLE fl_inbound_outbox + MODIFY COLUMN payload_json LONGTEXT NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法'; CALL baiyue_firstleg.assert_unified_final_schema(); DROP PROCEDURE IF EXISTS baiyue_firstleg.assert_unified_final_schema; SELECT 'PASS' AS migration_status, diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java index 8b68a2789..47042bb03 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java @@ -35,7 +35,9 @@ public class EventInboxService { if (!envelope.targetRegion().name().equalsIgnoreCase(localRegion)) { throw new IllegalArgumentException("事件目标区域与当前事件桥区域不一致"); } - if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash())) { + boolean currentHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), envelope.payloadHash()); + boolean legacyHash = EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalizeLegacy(envelope.payload()), envelope.payloadHash()); + if (!currentHash && !legacyHash) { throw new IllegalArgumentException("payloadHash 与规范化事件负载不一致"); } BridgeInbox existing = inboxStore.findByEventId(envelope.eventId()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java b/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java index 491896aea..f48074f63 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java @@ -8,7 +8,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; /** 将本地业务 Outbox 投递到跨区事件桥。 */ -@FeignClient(name = "event-bridge-service", contextId = "loadingCompletedEventBridgeClient") +@FeignClient(name = "event-bridge-service", contextId = "loadingCompletedEventBridgeClient", url = "${baiyue.lastleg.event-bridge-url:}") public interface EventBridgeClient { @PostMapping("/internal/event-bridge/events") ApiResponse receive( diff --git a/firstleg/src/main/resources/application.yml b/firstleg/src/main/resources/application.yml index 60af1f819..81cab7574 100644 --- a/firstleg/src/main/resources/application.yml +++ b/firstleg/src/main/resources/application.yml @@ -28,6 +28,8 @@ baiyue: security: jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} internal-service-token: ${INTERNAL_SERVICE_TOKEN:change-me-to-an-internal-token-at-least-32-bytes-long} + lastleg: + event-bridge-url: ${LASTLEG_EVENT_BRIDGE_URL:http://127.0.0.1:9312} clients: order: url: ${ORDER_SERVICE_URL:} diff --git a/firstleg/src/main/resources/db/phase2-inbound-schema.sql b/firstleg/src/main/resources/db/phase2-inbound-schema.sql index bb9bc4393..b80cf4024 100644 --- a/firstleg/src/main/resources/db/phase2-inbound-schema.sql +++ b/firstleg/src/main/resources/db/phase2-inbound-schema.sql @@ -733,7 +733,7 @@ CREATE TABLE IF NOT EXISTS fl_inbound_outbox ( aggregate_type VARCHAR(32) NOT NULL DEFAULT 'INBOUND' COMMENT '聚合类型', aggregate_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', event_type VARCHAR(64) NOT NULL COMMENT '事件类型', - payload_json JSON NOT NULL COMMENT '事件负载快照', + payload_json LONGTEXT NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法', publish_status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '事件发布状态:PENDING待发布、PUBLISHED已发布、FAILED发布失败', retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数', next_retry_at DATETIME(3) NULL COMMENT '下次重试时间', diff --git a/firstleg/src/main/resources/db/phase3-loading-completed-outbox-migration.sql b/firstleg/src/main/resources/db/phase3-loading-completed-outbox-migration.sql index 4b5514187..3190364e2 100644 --- a/firstleg/src/main/resources/db/phase3-loading-completed-outbox-migration.sql +++ b/firstleg/src/main/resources/db/phase3-loading-completed-outbox-migration.sql @@ -1,2 +1,4 @@ -- 装柜完成跨区事件使用标准 sha256: 摘要,共 71 字符。 ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_hash VARCHAR(71) NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀'; +-- MySQL JSON 会重写 0.3150 等数值词法,导致入库前计算的 payloadHash 失效。 +ALTER TABLE fl_inbound_outbox MODIFY COLUMN payload_json LONGTEXT NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法';