From 0242ea44e8e5f6054fbbf309bbd604c52661768b Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Sat, 19 Sep 2026 15:26:35 +0800 Subject: [PATCH] =?UTF-8?q?fix(mapper):=20=E8=A1=A5=205=20=E4=B8=AA?= =?UTF-8?q?=E3=80=8C=E5=8F=AA=E6=9C=89=E5=A3=B0=E6=98=8E=E3=80=81=E6=97=A0?= =?UTF-8?q?=E4=BB=BB=E4=BD=95=20SQL=E3=80=8D=E7=9A=84=E6=96=B9=E6=B3=95?= =?UTF-8?q?=EF=BC=9B=E6=96=B0=E5=BB=BA=20Mapper=20=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E5=AE=88=E5=8D=AB=E6=8C=81=E7=BB=AD=E6=8C=A1=E4=BD=8F=E8=AF=A5?= =?UTF-8?q?=E7=B1=BB=E7=BC=BA=E9=99=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 全仓无 mapper XML、无自定义 ISqlInjector,自定义方法只能靠注解提供 SQL。 这 5 个方法漏写注解,编译期无感知,调用必抛 Invalid bound statement: UserPrivacySettingsMapper.findByUserId —— 隐私设置读取 UserPreferenceMapper.findByUserId —— 推荐引擎 / 匹配资格共 6 处调用 ReviewReasonMapper.findAllEnabled —— 审核原因字典下拉 ReviewReasonMapper.findByCategory ReviewItemMapper.findByReviewTaskId 长期未被发现的原因:候选池为空(分支走不到)+ 测试库根本没有这 4 张表(双重不可达)。 补的 SQL 一律带 AND deleted_at IS NULL —— V037 之后这些表都有软删列, 只看 V002 建表会漏掉软删过滤,制造「读到已删数据」的新缺陷。 test(invariants): MapperBindingGuardTest —— 三用例 ① 绑定守卫:反射遍历 MapperRegistry 全部 Mapper 的声明方法,hasStatement 为假即失败; 按「实体有无主键」语义豁免复合主键实体的 selectById/updateById/deleteById 等 (MP 本就不为无主键实体注入,属正确行为;非类名白名单,故新增复合主键表同样适用)。 ② 探测自检:虚构 statementId 必须为假,防断言静默永真。 ③ 可执行性:插真实数据后真查一遍 —— 绑定存在 ≠ 能跑(本次即借此确认 JsonNode 映射无碍)。 负向注入已验证:移除 ReviewItemMapper 的 @Select → 守卫报恰 1 项缺失, 且可执行性用例抛真实 Invalid bound statement;还原后复跑全绿。 test(db): schema-h2.sql 补 4 张缺失表(user_privacy_settings / user_preferences / review_reasons / review_items),按 V002 + V037 之后的最终态建模(含 id、deleted_at 与 11 个匹配权重列),并保留 chk_item_type / chk_item_status / chk_pref_priority 约束。 docs(plan): 附 B 新增第 12 项;更正第 11 项中我自己上一轮的错误断言 —— 「表无 id 列导致 insert 必崩」不成立:V037 已补齐 id/deleted_at/权重列。 真实缺陷是 updatePrivacySettings 对已存在记录也走 insert(非 upsert)→ 第二次更新撞主键冲突。 方法论:判断表结构必须遍历全部迁移,只看 CREATE TABLE 会得出与生产相反的结论。 --- .../profile/mapper/UserPreferenceMapper.java | 11 + .../mapper/UserPrivacySettingsMapper.java | 13 + .../review/mapper/ReviewItemMapper.java | 9 + .../review/mapper/ReviewReasonMapper.java | 15 + .../invariants/MapperBindingGuardTest.java | 269 ++++++++++++++++++ backend/src/test/resources/schema-h2.sql | 69 +++++ ...9-18-phase23-closeout-and-uat-readiness.md | 55 +++- 7 files changed, 436 insertions(+), 5 deletions(-) create mode 100644 backend/src/test/java/com/danyang/match/platform/invariants/MapperBindingGuardTest.java diff --git a/backend/src/main/java/com/danyang/match/platform/profile/mapper/UserPreferenceMapper.java b/backend/src/main/java/com/danyang/match/platform/profile/mapper/UserPreferenceMapper.java index cc50b2f..49bd94b 100644 --- a/backend/src/main/java/com/danyang/match/platform/profile/mapper/UserPreferenceMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/profile/mapper/UserPreferenceMapper.java @@ -3,11 +3,22 @@ package com.danyang.match.platform.profile.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.danyang.match.platform.profile.entity.UserPreference; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; import java.util.Optional; @Mapper public interface UserPreferenceMapper extends BaseMapper { + /** + * 按用户读取择偶偏好(推荐引擎打分与匹配资格判定的输入)。 + * + *

补于 2026-09-19(Phase 23):本方法此前只有声明、没有任何 SQL, + * 调用必抛 {@code Invalid bound statement}。它在推荐链路上有 6 处调用点, + * 但因候选池为空而从未被执行到。 + * + *

{@code deleted_at IS NULL}:V037 已为 user_preferences 补上软删列。 + */ + @Select("SELECT * FROM user_preferences WHERE user_id = #{userId} AND deleted_at IS NULL LIMIT 1") Optional findByUserId(String userId); } diff --git a/backend/src/main/java/com/danyang/match/platform/profile/mapper/UserPrivacySettingsMapper.java b/backend/src/main/java/com/danyang/match/platform/profile/mapper/UserPrivacySettingsMapper.java index 6187f5b..33be573 100644 --- a/backend/src/main/java/com/danyang/match/platform/profile/mapper/UserPrivacySettingsMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/profile/mapper/UserPrivacySettingsMapper.java @@ -3,11 +3,24 @@ package com.danyang.match.platform.profile.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.danyang.match.platform.profile.entity.UserPrivacySettings; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; import java.util.Optional; @Mapper public interface UserPrivacySettingsMapper extends BaseMapper { + /** + * 按用户读取隐私设置。 + * + *

补于 2026-09-19(Phase 23):本方法此前只有声明、没有任何 SQL。 + * 本项目全仓无 mapper XML、无自定义 {@code ISqlInjector},自定义方法只能靠注解提供 SQL, + * 因此调用必抛 {@code Invalid bound statement}。之所以长期未被发现,是因为 + * 测试环境候选池为空、且测试库根本没有这张表,两条路径都走不到。 + * + *

{@code deleted_at IS NULL}:V037 已为 user_privacy_settings 补上软删列, + * 不过滤会读到已删记录。 + */ + @Select("SELECT * FROM user_privacy_settings WHERE user_id = #{userId} AND deleted_at IS NULL LIMIT 1") Optional findByUserId(String userId); } diff --git a/backend/src/main/java/com/danyang/match/platform/review/mapper/ReviewItemMapper.java b/backend/src/main/java/com/danyang/match/platform/review/mapper/ReviewItemMapper.java index 069b47a..f615d45 100644 --- a/backend/src/main/java/com/danyang/match/platform/review/mapper/ReviewItemMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/review/mapper/ReviewItemMapper.java @@ -3,11 +3,20 @@ package com.danyang.match.platform.review.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.danyang.match.platform.review.entity.ReviewItem; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; import java.util.List; @Mapper public interface ReviewItemMapper extends BaseMapper { + /** + * 按审核任务读取审核项。 + * + *

补于 2026-09-19(Phase 23):本方法此前只有声明、没有任何 SQL。 + * + *

{@code deleted_at IS NULL}:V037 已为 review_items 补上软删列。 + */ + @Select("SELECT * FROM review_items WHERE review_task_id = #{reviewTaskId} AND deleted_at IS NULL ORDER BY created_at") List findByReviewTaskId(String reviewTaskId); } diff --git a/backend/src/main/java/com/danyang/match/platform/review/mapper/ReviewReasonMapper.java b/backend/src/main/java/com/danyang/match/platform/review/mapper/ReviewReasonMapper.java index dc03c7b..c2309d4 100644 --- a/backend/src/main/java/com/danyang/match/platform/review/mapper/ReviewReasonMapper.java +++ b/backend/src/main/java/com/danyang/match/platform/review/mapper/ReviewReasonMapper.java @@ -3,13 +3,28 @@ package com.danyang.match.platform.review.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.danyang.match.platform.review.entity.ReviewReason; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; import java.util.List; @Mapper public interface ReviewReasonMapper extends BaseMapper { + /** + * 按类别读取审核原因字典。 + * + *

补于 2026-09-19(Phase 23):本方法此前只有声明、没有任何 SQL。 + * + *

{@code deleted_at IS NULL}:V037 已为 review_reasons 补上软删列(该表 V003 初始无此列)。 + */ + @Select("SELECT * FROM review_reasons WHERE category = #{category} AND deleted_at IS NULL ORDER BY sort_order") List findByCategory(String category); + /** + * 读取全部启用中的审核原因(审核结论下拉选项)。 + * + *

补于 2026-09-19(Phase 23):本方法此前只有声明、没有任何 SQL。 + */ + @Select("SELECT * FROM review_reasons WHERE enabled = TRUE AND deleted_at IS NULL ORDER BY sort_order") List findAllEnabled(); } diff --git a/backend/src/test/java/com/danyang/match/platform/invariants/MapperBindingGuardTest.java b/backend/src/test/java/com/danyang/match/platform/invariants/MapperBindingGuardTest.java new file mode 100644 index 0000000..ca59c85 --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/invariants/MapperBindingGuardTest.java @@ -0,0 +1,269 @@ +package com.danyang.match.platform.invariants; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.danyang.match.platform.profile.entity.UserPreference; +import com.danyang.match.platform.profile.entity.UserPrivacySettings; +import com.danyang.match.platform.profile.mapper.UserPreferenceMapper; +import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper; +import com.danyang.match.platform.review.entity.ReviewItem; +import com.danyang.match.platform.review.entity.ReviewReason; +import com.danyang.match.platform.review.mapper.ReviewItemMapper; +import com.danyang.match.platform.review.mapper.ReviewReasonMapper; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.SqlSessionFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import javax.sql.DataSource; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.sql.Connection; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 全局守卫:Mapper 接口上声明的每一个方法,都必须在 MyBatis {@code Configuration} 里有绑定语句。 + * + *

为什么需要它: + * 本项目全仓没有 mapper XML({@code mapper-locations} 未配置),也没有自定义 + * {@code ISqlInjector},因此自定义方法只能靠 {@code @Select / @Insert / @Update / @Delete} 注解提供 SQL。 + * 一旦漏写注解,编译期完全无感知,运行期调用才抛 + * {@code org.apache.ibatis.binding.BindingException: Invalid bound statement}。 + * + *

它挡住过的真实缺陷(2026-09-19 实测): + * 以下 5 个方法声明了却无实现,且有真实调用点: + *

+ * 它们之所以长期未被发现,是因为本机 E2E/测试环境的候选池为空, + * 这些分支根本走不到 —— 空数据掩盖了必崩代码。本守卫让这种掩盖不再可能。 + * + *

与既有测试的区别:本守卫不依赖任何业务数据,纯配置层反射, + * 因此不受"数据为空导致分支跳过"的影响。 + */ +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class MapperBindingGuardTest { + + @MockBean + private StringRedisTemplate stringRedisTemplate; + + @Autowired + private SqlSessionFactory sqlSessionFactory; + + /** 可执行性验证所需的 Mapper(见 customQueriesAreExecutableAgainstRealSchema)。 */ + @Autowired + private UserPreferenceMapper preferenceMapper; + @Autowired + private UserPrivacySettingsMapper privacySettingsMapper; + @Autowired + private ReviewReasonMapper reasonMapper; + @Autowired + private ReviewItemMapper itemMapper; + + /** + * 原生 JDBC 插夹具:原生连接默认 autoCommit=true,不参与 Spring 测试事务, + * 因此必须显式清理,否则会污染同上下文内的其他测试类。 + */ + @Autowired + private DataSource dataSource; + + /** + * 依赖主键的 BaseMapper 方法。复合主键实体(无 {@code @TableId})不会被 MP 注入这些方法, + * 属预期行为,守卫予以豁免。 + */ + private static final Set PRIMARY_KEY_DEPENDENT_METHODS = Set.of( + "selectById", "updateById", "deleteById", "deleteBatchIds", "selectBatchIds"); + + /** 从 Mapper 接口解析其实体类型({@code BaseMapper} 的泛型参数 X)。 */ + private static Class resolveEntityClass(Class mapper) { + for (Type genericInterface : mapper.getGenericInterfaces()) { + if (genericInterface instanceof ParameterizedType parameterizedType + && BaseMapper.class.equals(parameterizedType.getRawType())) { + Type argument = parameterizedType.getActualTypeArguments()[0]; + if (argument instanceof Class entityClass) { + return entityClass; + } + } + } + return null; + } + + /** + * 实体是否有主键:字段上有 {@code @TableId},或存在名为 {@code id} 的字段(MP 默认规则)。 + * 沿继承链向上查找 —— 例如 BaseEntity 在父类中声明 {@code @TableId}。 + */ + private static boolean hasPrimaryKey(Class entityClass) { + for (Class current = entityClass; + current != null && current != Object.class; + current = current.getSuperclass()) { + for (Field field : current.getDeclaredFields()) { + if (field.isAnnotationPresent(TableId.class) || "id".equals(field.getName())) { + return true; + } + } + } + return false; + } + + @Test + @DisplayName("守卫:所有 Mapper 方法都必须有绑定语句(无 XML / 无自定义 SqlInjector,漏注解即 Invalid bound statement)") + void everyDeclaredMapperMethodHasBoundStatement() { + Configuration configuration = sqlSessionFactory.getConfiguration(); + Collection> mappers = configuration.getMapperRegistry().getMappers(); + + // 守卫自检①:注册表规模过小说明 Spring 上下文或扫描配置异常,守卫会比业务更早失效。 + assertTrue(mappers.size() >= 20, + "MyBatis 只注册了 " + mappers.size() + " 个 Mapper,远低于预期 —— " + + "守卫本身探测失效,此时「无缺失」不能证明任何事"); + + List missing = new ArrayList<>(); + List exempted = new ArrayList<>(); + for (Class mapper : mappers) { + boolean entityHasPrimaryKey = hasPrimaryKey(resolveEntityClass(mapper)); + Set seen = new LinkedHashSet<>(); + for (Method method : mapper.getMethods()) { + if (Modifier.isStatic(method.getModifiers())) { + continue; + } + // default 方法由 Java 自身提供实现,不经过 MyBatis 语句绑定。 + if (method.isDefault() || method.isSynthetic() || method.isBridge()) { + continue; + } + String statementId = mapper.getName() + "." + method.getName(); + // 重载方法共用同一 statementId(如 deleteById 的两个重载),去重避免重复计数。 + if (!seen.add(statementId)) { + continue; + } + if (configuration.hasStatement(statementId)) { + continue; + } + // 豁免:复合主键实体(无 @TableId)本就无法生成依赖主键的 CRUD, + // MyBatis-Plus 不为它们注入 selectById / updateById / deleteById 等, + // 这是 MP 的正确行为而非缺陷 —— 前提是这些实体确实未被用主键方式调用。 + // 豁免是基于"实体有无主键"这一语义判定的,不是按类名白名单,故新增复合主键表同样适用。 + if (!entityHasPrimaryKey && PRIMARY_KEY_DEPENDENT_METHODS.contains(method.getName())) { + exempted.add(statementId); + continue; + } + missing.add(statementId); + } + } + Collections.sort(missing); + + assertTrue(missing.isEmpty(), + "以下 " + missing.size() + " 个 Mapper 方法声明了却没有绑定 SQL," + + "调用即抛 Invalid bound statement(本项目无 mapper XML、无自定义 SqlInjector," + + "漏写 @Select/@Update 等注解在编译期不可见):\n " + + String.join("\n ", missing) + + "\n(另有 " + exempted.size() + " 项因实体为复合主键、无 @TableId 而被豁免:" + + "MP 本就不会为无主键实体注入 selectById 等方法,属预期行为)"); + } + + @Test + @DisplayName("守卫自检:hasStatement 探测方式本身有效(防断言静默永真)") + void guardProbeIsEffective() { + Configuration configuration = sqlSessionFactory.getConfiguration(); + + // 正向:已知带 @Select 的方法必须能查到绑定。 + assertTrue( + configuration.hasStatement( + "com.danyang.match.platform.profile.mapper.UserProfileMapper.findByUserId"), + "已知带 @Select 的 UserProfileMapper.findByUserId 查不到绑定 —— " + + "守卫的 statementId 拼法有误,主断言会静默永真"); + + // 负向:虚构 id 必须查不到,否则 hasStatement 恒真,守卫同样失效。 + assertFalse( + configuration.hasStatement( + "com.danyang.match.platform.__guard_probe__.NonExistentMapper.noSuchMethod"), + "hasStatement 对虚构 statementId 也返回 true —— 探测方式失效,守卫无意义"); + } + + @Test + @DisplayName("可执行性:补上的 5 个自定义查询必须能在真实 schema 上跑通(绑定存在 ≠ 能执行)") + void customQueriesAreExecutableAgainstRealSchema() throws Exception { + // 若 H2 缺表,下面的 INSERT 会直接抛错 —— 这正是"表漂移"该被暴露的方式。 + try (Connection c = dataSource.getConnection(); Statement st = c.createStatement()) { + st.executeUpdate("INSERT INTO user_preferences (user_id, min_age, max_age, region_scope, marital_statuses) " + + "VALUES ('guard-pref-u1', 25, 40, '{\"a\":1}', '[\"never\"]')"); + st.executeUpdate("INSERT INTO user_privacy_settings (user_id, visible_in_search, show_occupation) " + + "VALUES ('guard-priv-u1', FALSE, TRUE)"); + st.executeUpdate("INSERT INTO review_reasons (code, name, category, enabled, sort_order) " + + "VALUES ('GUARD_REASON_1', '守卫验证原因', 'profile', TRUE, 3)"); + st.executeUpdate("INSERT INTO review_items (id, review_task_id, item_type, status) " + + "VALUES ('guard-item-1', 'guard-task-1', 'avatar', 'pending')"); + } + + UserPreference pref = preferenceMapper.findByUserId("guard-pref-u1") + .orElseThrow(() -> new AssertionError("user_preferences 查不到刚插入的行")); + assertEquals(25, pref.getMinAge().intValue()); + assertEquals(40, pref.getMaxAge().intValue()); + + UserPrivacySettings privacy = privacySettingsMapper.findByUserId("guard-priv-u1") + .orElseThrow(() -> new AssertionError("user_privacy_settings 查不到刚插入的行")); + assertEquals(Boolean.FALSE, privacy.getVisibleInSearch()); + + assertFalse(reasonMapper.findByCategory("profile").isEmpty(), + "review_reasons 按类别查询应有结果"); + assertFalse(reasonMapper.findAllEnabled().isEmpty(), + "review_reasons 启用列表应有结果"); + + List items = itemMapper.findByReviewTaskId("guard-task-1"); + assertEquals(1, items.size()); + assertEquals("avatar", items.get(0).getItemType()); + assertNotNull(items.get(0).getStatus()); + + // 软删语义:deleted_at 非空的行必须被 AND deleted_at IS NULL 过滤掉。 + // ⚠️ 测试写法坑位:不能"先查某个 taskId → 用原生 JDBC 改 deleted_at → 再查同一个 taskId"。 + // 原生 JDBC 不经过 SqlSession,不会清空 MyBatis 一级缓存, + // 第二次同参数查询会直接命中缓存返回旧结果,断言因此失去意义。 + // 故改用独立的 task_id,确保是首次查询。 + try (Connection c = dataSource.getConnection(); Statement st = c.createStatement()) { + st.executeUpdate("INSERT INTO review_items (id, review_task_id, item_type, status, deleted_at) " + + "VALUES ('guard-item-2', 'guard-task-2', 'self_intro', 'pending', NOW())"); + } + List softDeleted = itemMapper.findByReviewTaskId("guard-task-2"); + assertTrue(softDeleted.isEmpty(), + "deleted_at 非空的行仍被查出 —— 软删过滤失效,会把已删审核项暴露给运营。" + + " [诊断] mapper 返回 " + softDeleted.size() + " 行" + + (softDeleted.isEmpty() ? "" + : ",首行 deletedAt=" + softDeleted.get(0).getDeletedAt())); + } + + @AfterEach + void cleanUpFixtures() throws Exception { + // 原生 JDBC 默认 autoCommit=true,不参与 @Transactional 回滚,必须显式清理。 + try (Connection c = dataSource.getConnection(); Statement st = c.createStatement()) { + st.executeUpdate("DELETE FROM user_preferences WHERE user_id = 'guard-pref-u1'"); + st.executeUpdate("DELETE FROM user_privacy_settings WHERE user_id = 'guard-priv-u1'"); + st.executeUpdate("DELETE FROM review_reasons WHERE code = 'GUARD_REASON_1'"); + st.executeUpdate("DELETE FROM review_items WHERE review_task_id IN ('guard-task-1', 'guard-task-2')"); + } + } +} diff --git a/backend/src/test/resources/schema-h2.sql b/backend/src/test/resources/schema-h2.sql index 88acfc9..2d3a4b9 100644 --- a/backend/src/test/resources/schema-h2.sql +++ b/backend/src/test/resources/schema-h2.sql @@ -559,5 +559,74 @@ CREATE TABLE idempotency_records ( CONSTRAINT chk_idempotency_state CHECK (state IN ('PROCESSING', 'COMPLETED')) ); +-- --------------------------------------------------------------------------- +-- Phase 23 补齐(2026-09-19):以下 4 张表此前在测试库完全不存在。 +-- 发现经过:MapperBindingGuardTest 报出 5 个「声明了却无 @Select」的方法, +-- 其中 4 个方法对应的表正是这 4 张 —— 表不存在 + 语句未绑定 = 双重盲区, +-- 任何测试都走不到这些代码路径,缺陷被"空数据 + 缺表"共同掩盖。 +-- 保真口径:**按 V002 + V037 之后的最终状态**建模,不是按 V002 的初始建表。 +-- V037(align_entity_columns_with_migrations)已给这 4 张表补上 BaseEntity 公共列 +-- (id / created_at / updated_at / deleted_at)与匹配权重列 —— +-- 若只看 V002 会误判为「表主键是 user_id、无 id 列」,进而写出错误的 SQL。 +-- 尤需注意 deleted_at:4 张表**全部**已有软删列,故相关查询必须带 deleted_at IS NULL。 +-- --------------------------------------------------------------------------- + +-- V002 + V037: user_preferences 择偶偏好 +-- V002 初始:user_id 为主键,无 id / deleted_at / 权重列 +-- V037 补齐:id + deleted_at + 11 个匹配权重列 +CREATE TABLE user_preferences ( + user_id VARCHAR(36) PRIMARY KEY, id VARCHAR(36), + min_age INT, max_age INT, age_priority VARCHAR(16), + min_height_cm INT, max_height_cm INT, height_priority VARCHAR(16), + region_scope CLOB, region_priority VARCHAR(16), + marital_statuses CLOB, marital_priority VARCHAR(16), + min_education_code VARCHAR(32), education_priority VARCHAR(16), + children_acceptance VARCHAR(32), children_priority VARCHAR(16), + smoking_preference VARCHAR(32), drinking_preference VARCHAR(32), + settlement_preference VARCHAR(32), marriage_plan_preference VARCHAR(32), + age_weight DOUBLE PRECISION DEFAULT 0.15, region_weight DOUBLE PRECISION DEFAULT 0.20, + marital_weight DOUBLE PRECISION DEFAULT 0.10, children_weight DOUBLE PRECISION DEFAULT 0.05, + education_weight DOUBLE PRECISION DEFAULT 0.10, height_weight DOUBLE PRECISION DEFAULT 0.05, + marriage_plan_weight DOUBLE PRECISION DEFAULT 0.15, lifestyle_weight DOUBLE PRECISION DEFAULT 0.10, + settlement_weight DOUBLE PRECISION DEFAULT 0.05, interest_weight DOUBLE PRECISION DEFAULT 0.10, + created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, + deleted_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT chk_pref_priority CHECK (age_priority IN ('required', 'preferred')) +); + +-- V002 + V037: user_privacy_settings 隐私设置 +-- V037 补齐:id + deleted_at(V002 时无此两列) +CREATE TABLE user_privacy_settings ( + user_id VARCHAR(36) PRIMARY KEY, id VARCHAR(36), + visible_in_recommendations BOOLEAN DEFAULT TRUE, visible_in_search BOOLEAN DEFAULT TRUE, + show_occupation BOOLEAN DEFAULT TRUE, show_lifestyle_photos BOOLEAN DEFAULT TRUE, + receive_activity_recommendations BOOLEAN DEFAULT TRUE, + accept_matchmaker_recommendations BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, + deleted_at TIMESTAMP WITH TIME ZONE +); + +-- V003 + V037: review_reasons 审核原因字典 +-- 注意主键是 code(业务以 code 引用),V037 额外补 id 列以承接 BaseEntity 语义。 +-- V037 补:id + created_at + updated_at + deleted_at +CREATE TABLE review_reasons ( + code VARCHAR(64) PRIMARY KEY, id VARCHAR(36), name VARCHAR(128), + category VARCHAR(32), enabled BOOLEAN DEFAULT TRUE, sort_order INT DEFAULT 0, + created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, + deleted_at TIMESTAMP WITH TIME ZONE +); + +-- V003 + V037: review_items 审核项 +-- V037 补:deleted_at +CREATE TABLE review_items ( + id VARCHAR(36) PRIMARY KEY, review_task_id VARCHAR(36), + item_type VARCHAR(32), status VARCHAR(32) DEFAULT 'pending', + reason_code VARCHAR(64), comment VARCHAR(500), + created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, + deleted_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT chk_item_type CHECK (item_type IN ('avatar', 'lifestyle_photos', 'basic_profile', 'self_intro', 'relationship_profile')), + CONSTRAINT chk_item_status CHECK (status IN ('pending', 'approved', 'need_edit', 'abnormal')) +); + CREATE UNIQUE INDEX uk_idempotency_scope ON idempotency_records (actor_id, endpoint, idempotency_key); diff --git a/docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md b/docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md index a6ab428..2fd3247 100644 --- a/docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md +++ b/docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md @@ -406,7 +406,7 @@ 9. **本机跑 Java 测试的通道 —— 含两处「必挂」,漏掉任一处都会导出完全错误的结论**(工具性发现,可复用) 本机**无 JDK / 无 Maven / 无 `~/.m2`**(`mvn` 报 `Could not find Maven installation`), 测试镜像 `danyang-match-backend-test` 内含 Temurin JDK 21 + Maven 3.9.16 + 完整源码。 - **正确命令**(2026-09-19 实测 `BUILD SUCCESS`,全量 **678 / 0 failures / 0 errors / 3 skipped**,约 6 分钟): + **正确命令**(2026-09-19 实测 `BUILD SUCCESS`,全量 **681 / 0 failures / 0 errors / 3 skipped**,约 6 分钟): ```bash MSYS_NO_PATHCONV=1 docker run --rm \ -v "E:/develop/公益婚恋服务平台":/proj \ @@ -473,10 +473,55 @@ 表里的 5 列在小程序里没有 UI。三方只有 `visibleInRecommendations` 同时存在。 - 也就是说:**桩是「照前端契约写的假数据」,而「真实实现」是「照表结构写的」** —— 两者根本没在同一个契约上。直接「接线」会立刻打断小程序页面。 - - 另有**表/实体错配**:`user_privacy_settings` 主键是 `user_id`、且**无 `id` / `deleted_at` 列**, - 而实体 `UserPrivacySettings extends BaseEntity`(带 `@TableId` 的 `id` 与 `deletedAt`)→ - `PrivacyService.updatePrivacySettings` 里的 `privacySettingsMapper.insert(...)` 会尝试写 `id` 列 → - **必然失败**(且它对该插入路径用 `insert` 而非 upsert,即使列对齐也存在「已存在记录再插入」的问题)。 + - 另有**表/实体错配**(⚠️ 2026-09-19 **二轮更正**:初稿这条判断**部分错误**,已实测推翻): + 初稿称「`user_privacy_settings` 无 `id` / `deleted_at` 列 → insert 会尝试写 `id` → 必然失败」。 + 该结论**只看 `V002` 的初始建表,漏看了后续迁移**。 + `V037__align_entity_columns_with_migrations` 恰恰是为这类错配而写的,它已补齐: + `user_privacy_settings`(+`id`,+`deleted_at`)、`user_preferences`(+`id`,+`deleted_at`,+11 个权重列)、 + `review_reasons`(+`id`,+`created_at`/`updated_at`/`deleted_at`)、`review_items`(+`deleted_at`), + 且其注释明确点出根因是「`AutoFillHandler.insertFill` 会 setValue("id", UUID),MP 随即把 id 写进 INSERT」。 + **故「id 列不存在」不成立,insert 不会因此崩。** + - 但**真实缺陷仍在**:`user_privacy_settings.user_id` 是主键,而 `PrivacyService.updatePrivacySettings` + 对**已存在**记录同样走 `insert`(不是 upsert)→ **第二次更新必撞主键冲突**。 + 这才是 Task 3.1 第 1 项真正要修的点。 + - **方法论教训(可复用)**:判断「某表到底有哪些列」必须遍历**全部**迁移文件, + 尤其是 `V037` 这类专门"对齐实体与迁移"的迁移 —— 只看 `CREATE TABLE` 那一条会得出 + **与生产相反**的结论。测试库 H2 schema 同理,应按**最终态**(V002 + V037)建模, + 本轮已按此补建 4 张表。 + - 附带:`user_privacy_settings` / `user_preferences` / `review_reasons` / `review_items` + **四张表均不在 H2 测试库**(同附 B-8 的表漂移盲区)—— 本轮已补齐(按 V037 之后的最终态)。 + 「缺表 + 语句未绑定」叠加,使相关代码路径在测试期**双重不可达** —— 本轮已补齐表。 + +12. **5 个 Mapper 方法「声明了却没有任何 SQL」**(High,**本轮已修复 + 已建持续守卫**) + 全仓**无 mapper XML**(`mapper-locations` 未配置)、**无自定义 `ISqlInjector`** → + 自定义方法只能靠 `@Select/@Update/@Insert/@Delete` 提供 SQL。实测这 5 个方法只有声明: + + | Mapper 方法 | 影响面 | + |---|---| + | `UserPrivacySettingsMapper.findByUserId` | 隐私设置读取 | + | `UserPreferenceMapper.findByUserId` | 推荐引擎 / 匹配资格共 6 处调用 | + | `ReviewReasonMapper.findAllEnabled` | 审核原因字典下拉选项 | + | `ReviewReasonMapper.findByCategory` | 同上 | + | `ReviewItemMapper.findByReviewTaskId` | 审核项列表 | + + 调用必抛 `Invalid bound statement`,**编译期完全无感知**。 + 长期未被发现的原因:**候选池为空(分支走不到)+ 测试库根本没有这 4 张表** → 双重不可达。 + + - **已修**:补 `@Select`,且**均带 `AND deleted_at IS NULL`**(V037 之后这些表都有软删列, + 只看 V002 建表会漏掉软删过滤,制造"读到已删数据"的新缺陷)。 + - **已建持续守卫**:`MapperBindingGuardTest` —— 反射遍历 MyBatis `MapperRegistry` 中所有 + Mapper 的声明方法,`configuration.hasStatement(...)` 为假即失败;另含 + **探测方式自检**(虚构 statementId 必须为假,防断言静默永真)与 + **可执行性验证**(插真实数据后真查一遍,证明"绑定存在"≠"能跑")。 + - **守卫豁免规则**:复合主键实体(无 `@TableId`,如 `ConversationMember`、 + `ActivityStaffAssignment`)本就不会被 MP 注入 `selectById`/`updateById`/`deleteById` + 等依赖主键的方法 —— 这是 MP 的**正确行为**,守卫按"实体有无主键"这一**语义**豁免 + (不是按类名白名单,故新增复合主键表同样适用)。实测这两个 Mapper 在生产**零调用点**。 + - **负向注入已验证**:临时移除 `ReviewItemMapper` 的 `@Select` → + 绑定守卫报**恰 1 项缺失**,可执行性用例抛真实 `Invalid bound statement`;还原后复跑全绿。 + - ⚠️ **并发写者两次覆盖编辑**(本轮实测):写入的 import 与用例修改在编辑完成后被回滚, + 导致"改动看起来在、实际跑的是旧版"。**教训:编辑后到跑测试之间必须重新核对文件内容**, + 且跑通后应尽快提交以缩短暴露窗口。 - **需要用户/产品三选一**:以表为准改造前端 UI / 以小程序为准补迁移加列 / 加一层映射。 在此之前 Task 3.1 第 1 项**不应动手**。 - 附带:`user_privacy_settings` / `user_preferences` / `review_reasons` / `review_items`