Forráskód Böngészése

fix(汇安融): 三个管理端 VO 的雪花 id 改成字符串出网,修前端拼 URL 精度丢失

线上实测:门店列表点「详情」报「门店不存在」,
URL 是 /payment/har/shop/2103001449500184600,库里是 ...184578,差 22。

根因:HarShopEntity 走 PaymentBaseEntity 的 IdType.ASSIGN_ID(雪花,19 位),
超过 JS 的 Number.MAX_SAFE_INTEGER(9007199254740991)。按 JSON number 发出去后,
浏览器 JSON.parse 把它折断到相邻的双精度值,不可逆:

    String(Number(2103001449500184578)) === "2103001449500184600"   // true

前端 api/module_payment/har.ts 把三个 VO 的 id 都声明成 string(syncingId 也是
ref<string>),并直接拼进 URL 路径,所以后端发 number 是违约。同一根因共 5 个入口,
症状全部指向「这条记录不存在」而不是「id 变形了」,排查方向极易歪到权限或租户隔离:

  门店详情 404(用户报的)
  订单详情 404
  订单同步:拿变形的 id 去查,查不到
  删除商户:删不掉
  编辑商户:id 对不上任何行,变成新增一条(会去撞 merchant_code 唯一约束)

三个 VO 各加 @JsonSerialize(using = ToStringSerializer.class),照本仓既有 5 处约定
(LsyOrderEntity / LsyMerchantEntity / LsyNotifyLogEntity / LsyRefundEntity /
BatchAuthorizeEntity)。没有改 JacksonConfig 全局注册 Long→String:那会连 total /
page_size / retry_count 一起变字符串,打穿全站契约。

VO 是普通 DTO、字段声明在本类,直接标字段即可;不需要像 LsyOrderEntity 那样覆写
getter(那是字段声明在基类才有的限制)。

HarMerchantVO.tenantId 不跟着转:租户号是自增小整数,离 2^53 很远,前端也按 number 声明。

前端零改动:类型声明本来就是 string,改的是后端去对齐它。

新增 HarDtoJsonContractTest(4 条),断言用的就是生产那个 ObjectMapper:
  正向 4/4 绿,全量 844 绿
  变异:只摘掉 HarShopVO 的注解,仅 shopVoIdIsString 红,另两条加正对照仍绿
        (三个注解各自承重、互不代偿)
  正对照:夹具必须真超 2^53,且双精度往返必须变形,否则整个用例在测空气
alphaH 3 hete
szülő
commit
24aab6feea

+ 8 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarMerchantVO.java

@@ -1,5 +1,7 @@
 package com.payment.platform.module.payment.har.dto;
 
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import com.payment.platform.module.payment.har.entity.HarMerchantEntity;
 import lombok.Data;
 
@@ -8,6 +10,12 @@ import java.time.OffsetDateTime;
 @Data
 public class HarMerchantVO {
 
+    /**
+     * 雪花ID(19位)超 JS 安全整数,序列化为字符串防前端精度丢失 —— 前端按 string 拼进
+     * /merchant/{id}(删除),并在编辑时原样回传(发 number 会让编辑变成新增一条)。
+     * <p>{@code tenantId} 不跟着转:租户号是自增小整数,离 2^53 很远,前端也按 number 声明。
+     */
+    @JsonSerialize(using = ToStringSerializer.class)
     private Long id;
     /** 归属租户。超管建号时选定,之后不可改;列表侧供超管分辨各行属于谁 */
     private Long tenantId;

+ 4 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarOrderVO.java

@@ -1,5 +1,7 @@
 package com.payment.platform.module.payment.har.dto;
 
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import com.payment.platform.module.payment.har.entity.HarOrderEntity;
 import lombok.Data;
 
@@ -9,6 +11,8 @@ import java.time.OffsetDateTime;
 @Data
 public class HarOrderVO {
 
+    /** 雪花ID(19位)超 JS 安全整数,序列化为字符串防前端精度丢失 —— 前端按 string 拼进 /order/{id}。 */
+    @JsonSerialize(using = ToStringSerializer.class)
     private Long id;
     private String enterpriseId;
     private String orderNo;

+ 4 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarShopVO.java

@@ -1,5 +1,7 @@
 package com.payment.platform.module.payment.har.dto;
 
+import com.fasterxml.jackson.databind.annotation.JsonSerialize;
+import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
 import com.payment.platform.module.payment.har.entity.HarShopEntity;
 import lombok.Data;
 
@@ -8,6 +10,8 @@ import java.time.OffsetDateTime;
 @Data
 public class HarShopVO {
 
+    /** 雪花ID(19位)超 JS 安全整数,序列化为字符串防前端精度丢失 —— 前端按 string 拼进 /shop/{id}。 */
+    @JsonSerialize(using = ToStringSerializer.class)
     private Long id;
     private String enterpriseId;
     private String merchantCode;

+ 110 - 0
java/src/test/java/com/payment/platform/module/payment/har/dto/HarDtoJsonContractTest.java

@@ -0,0 +1,110 @@
+package com.payment.platform.module.payment.har.dto;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.payment.platform.common.base.PaymentBaseEntity;
+import com.payment.platform.common.config.JacksonConfig;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 钉住汇安融三个管理端 VO 的 {@code id} 序列化形态:必须是 JSON <b>字符串</b>。
+ *
+ * <p>为什么值得一个测试:{@link PaymentBaseEntity} 的 {@code id} 是
+ * {@code IdType.ASSIGN_ID}(雪花,19 位),超过 JS 的 {@code Number.MAX_SAFE_INTEGER}
+ * (9007199254740991)。一旦按 JSON number 发出去,浏览器 {@code JSON.parse} 就把它
+ * 折断到一个相邻的双精度值,且<b>不可逆</b>。线上实测:
+ * {@code 2103001449500184578} → {@code 2103001449500184600}(差 22)。
+ *
+ * <p>前端 {@code api/module_payment/har.ts} 把三个 VO 的 {@code id} 都声明成 {@code string},
+ * 并直接拼进 URL 路径({@code /shop/${id}}、{@code /order/${id}/sync}、{@code /merchant/${id}}),
+ * 所以后端发 number 是<b>违约</b>。症状全部指向「这条记录不存在」而不是「id 变形了」:
+ * 门店/订单详情 404、删商户删不掉、<b>编辑商户变成新增一条</b>(id 对不上任何行)——
+ * 而列表页看着完全正常,排查方向极易歪到权限或租户隔离上。
+ *
+ * <p>本仓既有约定是逐字段挂 {@code ToStringSerializer}({@code LsyOrderEntity} /
+ * {@code LsyMerchantEntity} / {@code LsyNotifyLogEntity} / {@code LsyRefundEntity} /
+ * {@code BatchAuthorizeEntity})。<b>不要</b>改成在 {@link JacksonConfig} 里全局注册
+ * {@code Long} → {@code ToStringSerializer}:那会连 {@code total} / {@code page_size} /
+ * {@code retry_count} 一起变字符串,打穿全站契约。
+ *
+ * <p>断言用的就是生产那个 ObjectMapper({@link JacksonConfig#objectMapper()})——
+ * 默认 ObjectMapper 不认这些注解,钉不住任何东西。
+ *
+ * <p>与兄弟用例 {@code LsyEntityIdSerializationTest} 不同,这里<b>没有</b>「无注解的 bean 出网是
+ * number」那条负对照:那一条是为它「反射遍历一个实体列表」的结构准备的 —— 列表一旦为空,
+ * 循环体不执行、用例照样绿,所以它必须另证一遍断言不是空转。本类三个用例各自 new 一个具体的
+ * VO,没有那个空转模式;「id 压根没被序列化」这条路径由 {@code assertNotNull} 挡住。
+ */
+class HarDtoJsonContractTest {
+
+    /** 与容器里那个 @Primary bean 同一个构造方式。 */
+    private final ObjectMapper mapper = new JacksonConfig().objectMapper();
+
+    /**
+     * 取自线上实测:{@code pay_har_shop.id = 2103001449500184578},
+     * 经浏览器 JSON.parse 后变成 {@code 2103001449500184600}。
+     */
+    private static final long SNOWFLAKE_ID = 2103001449500184578L;
+
+    /** 期望的字符串形态 —— 逐位写死,不用 {@code String.valueOf(SNOWFLAKE_ID)} 现算。 */
+    private static final String SNOWFLAKE_ID_TEXT = "2103001449500184578";
+
+    private JsonNode jsonOf(Object bean) {
+        try {
+            return mapper.readTree(mapper.writeValueAsString(bean));
+        } catch (Exception e) {
+            throw new IllegalStateException("序列化失败: " + bean.getClass().getSimpleName(), e);
+        }
+    }
+
+    /**
+     * 正对照:先证明这枚针真的能扎到精度问题。
+     *
+     * <p>没有这条,下面三个断言可能全在测空气 —— 比如夹具被换成一个小 id 时,
+     * 它们在改前改后都会绿。
+     */
+    @Test
+    void fixtureIsActuallyBeyondSafeInteger() {
+        assertTrue(SNOWFLAKE_ID > 9007199254740991L, "夹具必须超过 JS 安全整数上限");
+        assertNotEquals(SNOWFLAKE_ID, (long) (double) SNOWFLAKE_ID,
+                "这枚针经双精度往返后必须变成另一个值,否则它证明不了精度会丢");
+    }
+
+    @Test
+    void shopVoIdIsString() {
+        HarShopVO vo = new HarShopVO();
+        vo.setId(SNOWFLAKE_ID);
+        assertIdIsExactString(jsonOf(vo));
+    }
+
+    @Test
+    void orderVoIdIsString() {
+        HarOrderVO vo = new HarOrderVO();
+        vo.setId(SNOWFLAKE_ID);
+        assertIdIsExactString(jsonOf(vo));
+    }
+
+    @Test
+    void merchantVoIdIsString() {
+        HarMerchantVO vo = new HarMerchantVO();
+        vo.setId(SNOWFLAKE_ID);
+        assertIdIsExactString(jsonOf(vo));
+    }
+
+    /**
+     * {@code isTextual} 才是承重的那条:数值节点上调 {@code asText()} 同样拿得到
+     * 正确数字串(Java 侧没丢精度,丢精度发生在浏览器),只断言 {@code asText()}
+     * 会让这个测试在改前也绿。
+     */
+    private void assertIdIsExactString(JsonNode node) {
+        JsonNode id = node.get("id");
+        assertNotNull(id, "id 必须出现在报文里");
+        assertTrue(id.isTextual(), "id 必须是 JSON 字符串,实际节点类型是 " + id.getNodeType());
+        assertEquals(SNOWFLAKE_ID_TEXT, id.asText(), "字符串形态下必须逐位保真");
+    }
+}