فهرست منبع

fix(乐收赢测试): 实体↔DDL 分叉防线放行 `@TableField(exist = false)` 字段,并把这个前提钉住

用户跑全量 `mvn test` 报红:`LsyEntityMappingTest.everyEntityFieldHasAColumnInTheMigration`
—— `pay_lsy_order 缺列 refunding`。根因不是实体写错,是**防线自己少了一条语义**:

那条防线按**反射**枚举实体字段(含父类),把每个字段都当作 MP 会写进 SQL 的列。
而 `41b0ed89` 新加的 `LsyOrderEntity.refunding` 是**派生量**(`@TableField(exist = false)`,
不落库),MyBatis-Plus 本来就会把它从列清单里剔掉 ⇒ 它不可能引发 42703,
这条防线的前提(「MP 按实体生成列清单」)在它身上不成立。

**放行它不等于放宽防线**,两处证据都是量出来的,不是推的:

- 新增 `nonPersistentFieldsAreExcludedFromMybatisPlusColumnList`:不引注解作证据,
  直接问 MP 要 `TableInfo`(不碰库、不起 Spring),逐字段核对非持久字段**不在**列清单里,
  并配正对照(同一实体里至少一个真列在清单里 —— 否则空清单能让否定读数也通过)
  与防空转断言(四张实体里一个这类字段都没有时直接红)。
- 两向变异(都还原并用 sha256 核过,`05668d7b…`):
  · 摘掉 `refunding` 上的注解 ⇒ **2 红**(原防线 `:124` 与 新用例的空转分支 `:189`);
  · 给**真列** `finishTime` 挂上 `exist = false`(也就是「有人用这个注解来消音」那条路)
    ⇒ **1 红,来自方向二**(`多出实体未映射的列 finish_time`)⇒ 放行的口子没有绕过反向防线。

全量读数:`mvn test` **1026 条 / 0 失败**(用户那一跑是 1025 条 / 1 失败,多的这条就是新用例)。
只动测试,生产码与 `target/classes` 未变 ⇒ 15:32 那个 jar 仍是有效产物,不必重打。
alphaH 2 هفته پیش
والد
کامیت
7a82dbcb72

+ 81 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/entity/LsyEntityMappingTest.java

@@ -106,6 +106,15 @@ class LsyEntityMappingTest {
                             || java.lang.reflect.Modifier.isStatic(f.getModifiers())) {
                         continue;
                     }
+                    // ★ 标了 `@TableField(exist = false)` 的字段**不进 MP 的列清单**
+                    //   (`LsyOrderEntity.refunding` 是第一个:它是派生量,不是库里的列),
+                    //   于是本条测试的前提「MP 按实体生成列清单 ⇒ 缺列会 42703」在它身上不成立。
+                    //   放行它**不是**放宽防线:那个前提由
+                    //   nonPersistentFieldsAreExcludedFromMybatisPlusColumnList 逐字段钉住 ——
+                    //   注解除掉、或 MP 换了行为,那条当场变红。
+                    if (isNonPersistent(f)) {
+                        continue;
+                    }
                     mapped.add(camelToSnake(f.getName()));
                 }
             }
@@ -130,6 +139,78 @@ class LsyEntityMappingTest {
         }
     }
 
+    /**
+     * 钉住上一条测试**放行** `@TableField(exist = false)` 字段所依赖的那个前提。
+     *
+     * <p>前提不是「注解写了就没事」,而是「**MyBatis-Plus 真的会把这类字段从列清单里剔掉**」。
+     * 本用例不引用注解作证据,直接问 MP 要它的 {@code TableInfo}(不碰库、不起 Spring),
+     * 逐字段核对 —— 于是「注解被谁删掉」「升级换了行为」都会在这里当场变红,
+     * 而不会退化成「上一条测试悄悄放行了一个真列」。
+     *
+     * <p>★ 两向都在:非持久字段**不在**列清单里(否定读数),
+     * 同一实体里至少有一个真列**在**清单里(正对照 —— 否则一个空清单能让前半条也通过)。
+     */
+    @Test
+    void nonPersistentFieldsAreExcludedFromMybatisPlusColumnList() {
+        int checked = 0;
+        for (Class<?> entity : new Class<?>[]{LsyMerchantEntity.class, LsyOrderEntity.class,
+                                             LsyRefundEntity.class, LsyNotifyLogEntity.class}) {
+            Set<String> mpProperties = new LinkedHashSet<>();
+            for (var f : tableInfo(entity).getFieldList()) {
+                mpProperties.add(f.getProperty());
+            }
+            assertFalse(mpProperties.isEmpty(),
+                    entity.getSimpleName() + " 的 MP 列清单是空的 —— 量具没在读数,本用例没有意义");
+
+            Set<String> persistentDeclared = new LinkedHashSet<>();
+            Set<String> nonPersistentDeclared = new LinkedHashSet<>();
+            for (Class<?> c = entity; c != null && c != Object.class; c = c.getSuperclass()) {
+                for (var f : c.getDeclaredFields()) {
+                    if (f.isSynthetic() || java.lang.reflect.Modifier.isStatic(f.getModifiers())) {
+                        continue;
+                    }
+                    (isNonPersistent(f) ? nonPersistentDeclared : persistentDeclared).add(f.getName());
+                }
+            }
+
+            for (String name : nonPersistentDeclared) {
+                checked++;
+                assertFalse(mpProperties.contains(name),
+                        entity.getSimpleName() + "." + name + " 标了 `@TableField(exist = false)`,"
+                                + "却仍出现在 MP 的列清单里 —— 那么它会进 SQL,缺列就会抛 42703。"
+                                + "此时上一条测试放行它是错的。");
+            }
+            if (!nonPersistentDeclared.isEmpty()) {
+                assertTrue(persistentDeclared.stream().anyMatch(mpProperties::contains),
+                        entity.getSimpleName() + " 里连一个真列都不在 MP 列清单里 ⇒ 上面那条否定读数"
+                                + "是「清单没读到」而不是「字段被剔掉」");
+            }
+        }
+        assertTrue(checked > 0,
+                "四张实体里一个 `@TableField(exist = false)` 字段都没有 ⇒ 本用例在空转;"
+                        + "若确实一个都没有了,请连同上一条测试里的放行分支一起删掉");
+    }
+
+    /**
+     * 手工让 MP 建出 {@code TableInfo}:不碰库、不起 Spring,只喂一个空的
+     * {@code MapperBuilderAssistant}。取的是 MP 自己算出来的列清单,
+     * 而不是我们按字段名推的(两者若不同,正是本条要发现的事)。
+     */
+    private static com.baomidou.mybatisplus.core.metadata.TableInfo tableInfo(Class<?> entity) {
+        org.apache.ibatis.builder.MapperBuilderAssistant assistant =
+                new org.apache.ibatis.builder.MapperBuilderAssistant(
+                        new com.baomidou.mybatisplus.core.MybatisConfiguration(), "");
+        var info = com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(assistant, entity);
+        assertNotNull(info, entity.getSimpleName() + " 的 MP TableInfo 建不出来");
+        return info;
+    }
+
+    /** 字段是否被显式标成「不是库里的列」。 */
+    private static boolean isNonPersistent(java.lang.reflect.Field f) {
+        var ann = f.getAnnotation(com.baomidou.mybatisplus.annotation.TableField.class);
+        return ann != null && !ann.exist();
+    }
+
     /** 抽出一张表的建表语句(到第一个分号为止)。 */
     private static String tableDdl(String sql, String table) {
         int at = sql.indexOf("CREATE TABLE IF NOT EXISTS " + table + " (");