fix(privacy): updatePrivacySettings 改 upsert —— 同一用户第二次更新不再撞 user_id 主键

user_privacy_settings 的主键自 V002 起就是 user_id,旧实现无论记录是否存在都无条件 insert,
导致同一用户第二次更新必然 DuplicateKeyException → 500,隐私设置从此再也改不动。
现改为「存在则按 user_id 更新,不存在才插入」。

更新条件刻意用 user_id 而非实体 id:id 是 V037 为「实体↔表对齐」后补的独立列,
不作为寻址依据 —— 否则历史行 id 缺失时更新会静默失效。

已知边界(未擅自处理):findByUserId 带 deleted_at IS NULL,若将来引入软删路径,
已软删的行会让本方法走插入分支并撞主键。当前不存在任何软删隐私设置的路径,故不可达;
若将来新增软删,需同步改为 PG ON CONFLICT (user_id) DO UPDATE。已在代码注释中标注。

test(privacy): PrivacySettingsUpsertGuardTest —— 4 用例,全走真实落库(H2),不用 mock 断言调用次数
① 连续更新两次:第二次不得抛异常 + 库中仍只有 1 行(防退化成重复插入,
   那会让 findByUserId 的 Optional 返回值抛 TooManyResultsException)
② 部分更新不得把未提交字段重置回默认值
③ 首次更新未提交字段取默认 true
④ 无记录时读取返回全 true 且不落库(固化既有契约)
负向注入已验证:退回无条件 insert → 恰 2 红,失败原因即 DuplicateKeyException;
另 2 个只更新一次的用例正确未红(走不到该分支)。已还原并复跑全绿。

docs(plan): Task 3.1 拆两层并记录进展;附 B 增第 13 项;基线 681→685

⚠️ 关键发现(附 B-13):隐私设置有两套实现,前端走的是桩那一套 ——
小程序 pages/privacy-settings 调 /account/privacy-settings(AccountLifecycleService 桩,不落库),
管理端对隐私端点零调用。故本次修复位于「前端尚未触达」的路径:必要但当前用户不可感知。
真正的用户可见故障是「改隐私设置根本不保存」(桩),属 Task 3.1 上层,仍待契约裁定。
本次修复是该上层工作的必要前置 —— 桩一旦接到 PrivacyService 立刻会撞上此缺陷。

方法论:评估「某修复的价值」必须先确认前端实际调用哪条路径 ——
本项目同业务存在 /me/* 与 /account/* 多套并存端点,后端有真实实现 ≠ 用户能走到。
This commit is contained in:
WorkBuddy
2026-09-19 15:49:54 +08:00
parent 0242ea44e8
commit 04b07b5821
3 changed files with 220 additions and 19 deletions
@@ -1,6 +1,6 @@
package com.danyang.match.platform.profile.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
import com.danyang.match.platform.profile.entity.UserPrivacySettings;
import lombok.RequiredArgsConstructor;
@@ -45,22 +45,38 @@ public class PrivacyService {
}
/**
* 更新隐私设置
* 更新隐私设置(upsert 语义:存在则更新,不存在则插入)。
*
* <p><b>修复说明</b>:此前无论记录是否已存在都无条件走 {@code insert},
* 而 {@code user_privacy_settings} 的主键自 V002 起就是 {@code user_id},
* 因此同一用户<b>第二次</b>调用必然撞主键冲突 → 500,隐私设置从此再也改不动。
*
* <p><b>更新条件刻意用 {@code user_id} 而非实体 {@code id}</b>:该表的业务主键是
* {@code user_id};{@code id} 是 V037 为「实体↔表对齐」后补的独立列,
* 不作为寻址依据 —— 否则一旦某行 id 缺失(V037 回填前的历史行),更新会静默失效。
*
* <p><b>已知边界(未擅自处理)</b>:{@code findByUserId} 带 {@code deleted_at IS NULL},
* 若将来引入软删路径,已软删的行会让本方法走插入分支并撞主键。当前生产代码
* <b>不存在任何软删隐私设置的路径</b>,故该分支不可达;若将来新增软删,需同步改为
* 显式 upsert(如 PG 的 {@code ON CONFLICT (user_id) DO UPDATE})。
*/
@Transactional
public Map<String, Boolean> updatePrivacySettings(String userId, Map<String, Boolean> settings) {
UserPrivacySettings privacy = privacySettingsMapper.findByUserId(userId)
.orElseGet(() -> {
UserPrivacySettings p = new UserPrivacySettings();
p.setUserId(userId);
p.setVisibleInRecommendations(true);
p.setVisibleInSearch(true);
p.setShowOccupation(true);
p.setShowLifestylePhotos(true);
p.setReceiveActivityRecommendations(true);
p.setAcceptMatchmakerRecommendations(true);
return p;
});
UserPrivacySettings existing = privacySettingsMapper.findByUserId(userId).orElse(null);
UserPrivacySettings privacy;
if (existing != null) {
privacy = existing;
} else {
privacy = new UserPrivacySettings();
privacy.setUserId(userId);
privacy.setVisibleInRecommendations(true);
privacy.setVisibleInSearch(true);
privacy.setShowOccupation(true);
privacy.setShowLifestylePhotos(true);
privacy.setReceiveActivityRecommendations(true);
privacy.setAcceptMatchmakerRecommendations(true);
}
if (settings.containsKey("visibleInRecommendations")) privacy.setVisibleInRecommendations(settings.get("visibleInRecommendations"));
if (settings.containsKey("visibleInSearch")) privacy.setVisibleInSearch(settings.get("visibleInSearch"));
@@ -69,7 +85,13 @@ public class PrivacyService {
if (settings.containsKey("receiveActivityRecommendations")) privacy.setReceiveActivityRecommendations(settings.get("receiveActivityRecommendations"));
if (settings.containsKey("acceptMatchmakerRecommendations")) privacy.setAcceptMatchmakerRecommendations(settings.get("acceptMatchmakerRecommendations"));
privacySettingsMapper.insert(privacy);
if (existing != null) {
privacySettingsMapper.update(privacy,
new LambdaUpdateWrapper<UserPrivacySettings>()
.eq(UserPrivacySettings::getUserId, userId));
} else {
privacySettingsMapper.insert(privacy);
}
return getPrivacySettings(userId);
}
}
@@ -0,0 +1,139 @@
package com.danyang.match.platform.profile;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.profile.entity.UserPrivacySettings;
import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
import com.danyang.match.platform.profile.service.PrivacyService;
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 java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 隐私设置 upsert 行为守卫。
*
* <p>守卫的缺陷:<b>同一用户第二次更新隐私设置必然 500</b>。
* {@code user_privacy_settings} 的主键自 V002 起就是 {@code user_id},
* 而旧实现无论记录是否已存在都无条件 {@code insert} → 第二次调用撞主键冲突。
*
* <p>为什么这个缺陷能长期存活:本机/测试环境的用户<b>从未改过隐私设置</b>,
* 该分支永远只被走到第一次;且测试库此前根本没有这张表(见 MapperBindingGuardTest)
* —— 与「Mapper 声明了却无实现」是同一类<b>空数据掩盖 + 表漂移</b>的双重盲区。
*
* <p>本测试全部走真实落库(H2),不用 Mockito 断言 mapper 调用次数:
* 只有真的插进去、再真的更新一遍,才能证明「第二次不炸」。
*/
@SpringBootTest
@ActiveProfiles("test")
class PrivacySettingsUpsertGuardTest {
@MockBean
private StringRedisTemplate stringRedisTemplate;
@Autowired
private PrivacyService privacyService;
@Autowired
private UserPrivacySettingsMapper privacySettingsMapper;
@AfterEach
void cleanUp() {
// 清理本类写入的全部行,避免污染其他测试(H2 是共享的同库同 schema)
privacySettingsMapper.delete(
new LambdaQueryWrapper<UserPrivacySettings>()
.likeRight(UserPrivacySettings::getUserId, "upsert-guard-"));
}
/** user_id 列是 VARCHAR(36),故 id 不能带过长前缀。 */
private String newUserId() {
return "upsert-guard-" + UUID.randomUUID().toString().substring(0, 8);
}
private long rowCount(String userId) {
return privacySettingsMapper.selectCount(
new LambdaQueryWrapper<UserPrivacySettings>()
.eq(UserPrivacySettings::getUserId, userId));
}
@Test
@DisplayName("核心缺陷守卫:同一用户连续更新两次 —— 第二次不得抛异常,且不得产生第二行")
void repeatUpdateIsUpsertNotDuplicateInsert() {
String userId = newUserId();
Map<String, Boolean> first = new HashMap<>();
first.put("visibleInSearch", false);
Map<String, Boolean> firstResult = privacyService.updatePrivacySettings(userId, first);
assertFalse(firstResult.get("visibleInSearch"), "首次更新应生效");
assertEquals(1, rowCount(userId), "首次更新应恰好落库 1 行");
// === 修复前在此处抛主键冲突(DuplicateKeyException)→ 500 ===
Map<String, Boolean> second = new HashMap<>();
second.put("visibleInSearch", true);
Map<String, Boolean> secondResult = privacyService.updatePrivacySettings(userId, second);
assertTrue(secondResult.get("visibleInSearch"), "第二次更新应生效(修复前此处 500)");
assertEquals(1, rowCount(userId),
"两次更新后仍必须只有 1 行 —— 若变成 2 行,说明退化成「重复插入」,"
+ "后续 findByUserId 查出多行会让 Optional 返回值抛出 TooManyResultsException");
}
@Test
@DisplayName("部分更新不得把未提交的字段重置回默认值")
void partialUpdatePreservesPreviouslySetFields() {
String userId = newUserId();
Map<String, Boolean> turnOffSearch = new HashMap<>();
turnOffSearch.put("visibleInSearch", false);
privacyService.updatePrivacySettings(userId, turnOffSearch);
// 第二次只改另一个字段:先前关闭的 visibleInSearch 必须保持 false
Map<String, Boolean> turnOffPhotos = new HashMap<>();
turnOffPhotos.put("showLifestylePhotos", false);
Map<String, Boolean> result = privacyService.updatePrivacySettings(userId, turnOffPhotos);
assertFalse(result.get("showLifestylePhotos"), "本次提交的字段应生效");
assertFalse(result.get("visibleInSearch"),
"未提交的字段必须保持上次的值 —— 若被重置为 true,说明更新走了「重建默认值再 insert」的路径");
assertEquals(1, rowCount(userId));
}
@Test
@DisplayName("首次更新只提交部分字段时,其余字段应取默认值 true")
void firstUpdateAppliesDefaultsForOmittedFields() {
String userId = newUserId();
Map<String, Boolean> only = new HashMap<>();
only.put("showOccupation", false);
Map<String, Boolean> result = privacyService.updatePrivacySettings(userId, only);
assertFalse(result.get("showOccupation"), "提交的字段应为 false");
assertTrue(result.get("visibleInRecommendations"), "未提交字段应取默认 true");
assertTrue(result.get("visibleInSearch"), "未提交字段应取默认 true");
assertTrue(result.get("showLifestylePhotos"), "未提交字段应取默认 true");
assertTrue(result.get("receiveActivityRecommendations"), "未提交字段应取默认 true");
assertTrue(result.get("acceptMatchmakerRecommendations"), "未提交字段应取默认 true");
}
@Test
@DisplayName("无记录时读取返回全 true 默认值(固化既有契约,且不应落库)")
void readReturnsDefaultsWithoutPersisting() {
String userId = newUserId();
Map<String, Boolean> defaults = privacyService.getPrivacySettings(userId);
assertTrue(defaults.values().stream().allMatch(v -> v), "无记录时应返回全 true 默认值");
assertEquals(0, rowCount(userId), "读取操作不应产生副作用写入");
}
}