fix(mapper): 补 5 个「只有声明、无任何 SQL」的方法;新建 Mapper 绑定守卫持续挡住该类缺陷

全仓无 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 会得出与生产相反的结论。
This commit is contained in:
WorkBuddy
2026-09-19 15:26:35 +08:00
parent 9466b2b636
commit 0242ea44e8
7 changed files with 436 additions and 5 deletions
@@ -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<UserPreference> {
/**
* 按用户读取择偶偏好(推荐引擎打分与匹配资格判定的输入)。
*
* <p>补于 2026-09-19(Phase 23):本方法此前<b>只有声明、没有任何 SQL</b>,
* 调用必抛 {@code Invalid bound statement}。它在推荐链路上有 6 处调用点,
* 但因候选池为空而从未被执行到。
*
* <p>{@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<UserPreference> findByUserId(String userId);
}
@@ -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<UserPrivacySettings> {
/**
* 按用户读取隐私设置。
*
* <p>补于 2026-09-19(Phase 23):本方法此前<b>只有声明、没有任何 SQL</b>。
* 本项目全仓无 mapper XML、无自定义 {@code ISqlInjector},自定义方法只能靠注解提供 SQL,
* 因此调用必抛 {@code Invalid bound statement}。之所以长期未被发现,是因为
* 测试环境候选池为空、且测试库<b>根本没有这张表</b>,两条路径都走不到。
*
* <p>{@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<UserPrivacySettings> findByUserId(String userId);
}
@@ -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<ReviewItem> {
/**
* 按审核任务读取审核项。
*
* <p>补于 2026-09-19(Phase 23):本方法此前<b>只有声明、没有任何 SQL</b>。
*
* <p>{@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<ReviewItem> findByReviewTaskId(String reviewTaskId);
}
@@ -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<ReviewReason> {
/**
* 按类别读取审核原因字典。
*
* <p>补于 2026-09-19(Phase 23):本方法此前<b>只有声明、没有任何 SQL</b>。
*
* <p>{@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<ReviewReason> findByCategory(String category);
/**
* 读取全部启用中的审核原因(审核结论下拉选项)。
*
* <p>补于 2026-09-19(Phase 23):本方法此前<b>只有声明、没有任何 SQL</b>。
*/
@Select("SELECT * FROM review_reasons WHERE enabled = TRUE AND deleted_at IS NULL ORDER BY sort_order")
List<ReviewReason> findAllEnabled();
}
@@ -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} 里有绑定语句。
*
* <p><b>为什么需要它</b>:
* 本项目全仓<b>没有</b> mapper XML({@code mapper-locations} 未配置),也<b>没有</b>自定义
* {@code ISqlInjector},因此自定义方法只能靠 {@code @Select / @Insert / @Update / @Delete} 注解提供 SQL。
* 一旦漏写注解,编译期<b>完全无感知</b>,运行期调用才抛
* {@code org.apache.ibatis.binding.BindingException: Invalid bound statement}。
*
* <p><b>它挡住过的真实缺陷(2026-09-19 实测)</b>:
* 以下 5 个方法声明了却无实现,且有真实调用点:
* <ul>
* <li>{@code UserPrivacySettingsMapper.findByUserId} —— 隐私设置读 + 推荐打分</li>
* <li>{@code UserPreferenceMapper.findByUserId} —— 推荐引擎 / 匹配资格共 6 处调用</li>
* <li>{@code ReviewReasonMapper.findAllEnabled} —— 审核原因字典下拉</li>
* <li>{@code ReviewReasonMapper.findByCategory}</li>
* <li>{@code ReviewItemMapper.findByReviewTaskId}</li>
* </ul>
* 它们之所以长期未被发现,是因为本机 E2E/测试环境的<b>候选池为空</b>,
* 这些分支根本走不到 —— 空数据掩盖了必崩代码。本守卫让这种掩盖不再可能。
*
* <p><b>与既有测试的区别</b>:本守卫不依赖任何业务数据,纯配置层反射,
* 因此不受"数据为空导致分支跳过"的影响。
*/
@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,<b>不参与 Spring 测试事务</b>,
* 因此必须显式清理,否则会污染同上下文内的其他测试类。
*/
@Autowired
private DataSource dataSource;
/**
* 依赖主键的 BaseMapper 方法。复合主键实体(无 {@code @TableId})不会被 MP 注入这些方法,
* 属预期行为,守卫予以豁免。
*/
private static final Set<String> PRIMARY_KEY_DEPENDENT_METHODS = Set.of(
"selectById", "updateById", "deleteById", "deleteBatchIds", "selectBatchIds");
/** 从 Mapper 接口解析其实体类型({@code BaseMapper<X>} 的泛型参数 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<Class<?>> mappers = configuration.getMapperRegistry().getMappers();
// 守卫自检①:注册表规模过小说明 Spring 上下文或扫描配置异常,守卫会比业务更早失效。
assertTrue(mappers.size() >= 20,
"MyBatis 只注册了 " + mappers.size() + " 个 Mapper,远低于预期 —— "
+ "守卫本身探测失效,此时「无缺失」不能证明任何事");
List<String> missing = new ArrayList<>();
List<String> exempted = new ArrayList<>();
for (Class<?> mapper : mappers) {
boolean entityHasPrimaryKey = hasPrimaryKey(resolveEntityClass(mapper));
Set<String> 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<ReviewItem> 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<ReviewItem> 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')");
}
}
}
+69
View File
@@ -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);
@@ -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`