feat(privacy): 键集以小程序为准 —— V047 补列 + 接通 /account/privacy-settings 桩

产品裁定:隐私设置键集**以小程序为准**,用迁移补齐后端缺失列。

V047 新增 allow_activity_invitation(默认 TRUE)与 show_last_online(默认 FALSE)。
默认值**不对称**,与小程序 data 初值严格一致 —— showLastOnline 写成 TRUE 等于默认公开用户在线状态。
既有 6 列保留不动:UserProfileService 的资料详情 DTO 仍在全量返回它们(第 74-79 行),
那是仍在生效的独立读取出口。本迁移只做加法,不做替换。

AccountLifecycleService 的两个 TODO 桩改为委托 PrivacyService 真实读写。
这是**用户真正走的路径**(小程序调 /account/privacy-settings,而 /me/* 与 /profile/* 无人调用),
此前「保存成功」只是回 {updated:true} 而从不落库。

入参校验:已知键类型错误必须显式抛错,未知键宽容忽略 ——
若静默丢弃,用户会看到「保存成功」而设置从未生效,这类缺陷最难排查。
另容错 Boolean 的字符串形式("true"/"false")。

test(privacy): PrivacySettingsUpsertGuardTest 扩至 8 用例,其中 2 个是契约守卫

契约守卫**直接解析小程序源文件**的 PUT payload 与 data 块得出权威键集/默认值,
不抄契约副本(沿用 EntityMigrationParityTest 的纪律)—— 小程序改键即变红。
① 键集:后端返回键必须与小程序 PUT payload 键完全一致(不多不少)
② 默认值:后端默认值必须与小程序 data 初值一致(含 showLastOnline=false 这条不对称项)
③ 桩已接通:经 /account/privacy-settings 写入必须真落库且可读回
④ 已知键传错类型必须报错,不得静默丢弃
其余 4 个为 upsert 行为守卫(连续两次更新不炸且不产生第二行 / 部分更新不重置 / 默认值 / 读取无副作用)。

负向注入:把桩退回「不落库」→ 恰 2 红(③ 与 ④),其余 6 个正确未红。已还原并复跑全绿。

test(deployment): MigrationValidationTest 基线 46 → 47

新增迁移必须同步更新 LATEST_MIGRATION,否则全量回归报「版本号超出当前基线」。
该守卫同时断言「迁移文件总数 == 基线」(不允许缺号),是有效守卫 ——
正确做法是更新常量,不要为了让测试变绿而改断言方式。
This commit is contained in:
WorkBuddy
2026-09-19 17:11:46 +08:00
parent 04b07b5821
commit d9c08d85f9
7 changed files with 283 additions and 78 deletions
@@ -10,6 +10,7 @@ import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
import com.danyang.match.platform.notification.service.NotificationService;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.profile.service.PrivacyService;
import com.danyang.match.platform.risk.mapper.ReportMapper;
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
import com.danyang.match.platform.shared.constant.Constants;
@@ -68,6 +69,7 @@ public class AccountLifecycleService {
private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper;
private final PrivacyService privacyService;
private final ActivityRegistrationMapper registrationMapper;
private final RecommendationRecordMapper recommendationRecordMapper;
private final ReportMapper reportMapper;
@@ -342,26 +344,57 @@ public class AccountLifecycleService {
// ==================== 隐私设置 ====================
/**
* 获取隐私设置
* 小程序 privacy-settings 页的契约键集(产品裁定:隐私设置键集<b>以小程序为准</b>)。
* 用于入参校验:未知键宽容忽略,但<b>已知键类型错误必须显式报错</b> ——
* 若静默丢弃,用户会看到"保存成功"而实际未生效,这类缺陷最难排查。
*/
private static final Set<String> PRIVACY_KEYS =
Set.of("visibleInRecommendations", "allowActivityInvitation", "showLastOnline");
/**
* 获取隐私设置。
*
* <p>原为 TODO 桩:永远返回写死的默认值、<b>不读库</b>,用户改过的设置读不回来。
* 现委托 {@link PrivacyService} 读真实 {@code user_privacy_settings}。
*/
public Map<String, Object> getPrivacySettings(String userId) {
// TODO: Phase 21 实现 user_privacy_settings 查询
Map<String, Object> settings = new HashMap<>();
settings.put("visibleInRecommendations", true);
settings.put("allowActivityInvitation", true);
settings.put("showLastOnline", false);
return settings;
return new HashMap<>(privacyService.getPrivacySettings(userId));
}
/**
* 更新隐私设置
* 更新隐私设置。
*
* <p>原为 TODO 桩:只回 {@code {updated:true}}、<b>不落库</b>,
* 用户看到"保存成功"后刷新即丢失。现委托 {@link PrivacyService} 真实 upsert。
*/
@Transactional
public Map<String, Object> updatePrivacySettings(String userId, Map<String, Object> settings) {
// TODO: Phase 21 实现 user_privacy_settings 更新
Map<String, Object> result = new HashMap<>();
result.put("updated", true);
return result;
return new HashMap<>(privacyService.updatePrivacySettings(userId, toBooleanMap(settings)));
}
private Map<String, Boolean> toBooleanMap(Map<String, Object> settings) {
Map<String, Boolean> typed = new HashMap<>();
if (settings == null) {
return typed;
}
for (Map.Entry<String, Object> e : settings.entrySet()) {
String key = e.getKey();
Object value = e.getValue();
if (value == null) {
continue;
}
if (value instanceof Boolean b) {
typed.put(key, b);
} else if (value instanceof String s
&& ("true".equalsIgnoreCase(s) || "false".equalsIgnoreCase(s))) {
// 容错:部分客户端会把 boolean 序列化成字符串
typed.put(key, Boolean.valueOf(s));
} else if (PRIVACY_KEYS.contains(key)) {
throw new IllegalArgumentException("隐私设置字段类型错误: " + key);
}
// 未知键:宽容忽略
}
return typed;
}
// ==================== 私有辅助方法 ====================
@@ -17,4 +17,9 @@ public class UserPrivacySettings extends BaseEntity {
private Boolean showLifestylePhotos;
private Boolean receiveActivityRecommendations;
private Boolean acceptMatchmakerRecommendations;
// V047:小程序 privacy-settings 页契约字段(产品裁定以小程序为准)。
// 默认值与小程序 data 初值一致 —— showLastOnline 默认 false(不公开在线状态)。
private Boolean allowActivityInvitation;
private Boolean showLastOnline;
}
@@ -17,31 +17,38 @@ public class PrivacyService {
private final UserPrivacySettingsMapper privacySettingsMapper;
/**
* 获取隐私设置
* 获取隐私设置。
*
* <p><b>键集以小程序为准</b>(2026-09-19 产品裁定):
* {@code visibleInRecommendations} / {@code allowActivityInvitation} / {@code showLastOnline}。
* 后两列由 V047 补齐(此前后端表与小程序只有 {@code visibleInRecommendations} 一个交集)。
*
* <p>默认值与小程序 {@code data} 初值<b>严格一致</b>,尤其
* {@code showLastOnline} 默认 <b>false</b>(不公开在线状态)—— 写成 true 等于默认公开。
*
* <p>注意:既有的 6 个列并未废弃 —— {@link com.danyang.match.platform.profile.service.UserProfileService}
* 的资料详情 DTO 仍在全量返回它们。此处只是<b>隐私设置读写契约</b>以小程序为准。
*/
public Map<String, Boolean> getPrivacySettings(String userId) {
return privacySettingsMapper.findByUserId(userId)
.map(s -> {
.map(this::toMiniappContract)
.orElseGet(this::defaults);
}
private Map<String, Boolean> toMiniappContract(UserPrivacySettings s) {
Map<String, Boolean> map = new HashMap<>();
map.put("visibleInRecommendations", s.getVisibleInRecommendations());
map.put("visibleInSearch", s.getVisibleInSearch());
map.put("showOccupation", s.getShowOccupation());
map.put("showLifestylePhotos", s.getShowLifestylePhotos());
map.put("receiveActivityRecommendations", s.getReceiveActivityRecommendations());
map.put("acceptMatchmakerRecommendations", s.getAcceptMatchmakerRecommendations());
map.put("allowActivityInvitation", s.getAllowActivityInvitation());
map.put("showLastOnline", s.getShowLastOnline());
return map;
}
private Map<String, Boolean> defaults() {
Map<String, Boolean> map = new HashMap<>();
map.put("visibleInRecommendations", true);
map.put("allowActivityInvitation", true);
map.put("showLastOnline", false);
return map;
})
.orElseGet(() -> {
// 返回默认值
Map<String, Boolean> defaults = new HashMap<>();
defaults.put("visibleInRecommendations", true);
defaults.put("visibleInSearch", true);
defaults.put("showOccupation", true);
defaults.put("showLifestylePhotos", true);
defaults.put("receiveActivityRecommendations", true);
defaults.put("acceptMatchmakerRecommendations", true);
return defaults;
});
}
/**
@@ -70,20 +77,21 @@ public class PrivacyService {
} else {
privacy = new UserPrivacySettings();
privacy.setUserId(userId);
// 既有 6 列:仍被 UserProfileService 资料详情 DTO 读取,保持默认 true
privacy.setVisibleInRecommendations(true);
privacy.setVisibleInSearch(true);
privacy.setShowOccupation(true);
privacy.setShowLifestylePhotos(true);
privacy.setReceiveActivityRecommendations(true);
privacy.setAcceptMatchmakerRecommendations(true);
// V047 新增:小程序契约列(showLastOnline 默认 false)
privacy.setAllowActivityInvitation(true);
privacy.setShowLastOnline(false);
}
if (settings.containsKey("visibleInRecommendations")) privacy.setVisibleInRecommendations(settings.get("visibleInRecommendations"));
if (settings.containsKey("visibleInSearch")) privacy.setVisibleInSearch(settings.get("visibleInSearch"));
if (settings.containsKey("showOccupation")) privacy.setShowOccupation(settings.get("showOccupation"));
if (settings.containsKey("showLifestylePhotos")) privacy.setShowLifestylePhotos(settings.get("showLifestylePhotos"));
if (settings.containsKey("receiveActivityRecommendations")) privacy.setReceiveActivityRecommendations(settings.get("receiveActivityRecommendations"));
if (settings.containsKey("acceptMatchmakerRecommendations")) privacy.setAcceptMatchmakerRecommendations(settings.get("acceptMatchmakerRecommendations"));
if (settings.containsKey("allowActivityInvitation")) privacy.setAllowActivityInvitation(settings.get("allowActivityInvitation"));
if (settings.containsKey("showLastOnline")) privacy.setShowLastOnline(settings.get("showLastOnline"));
if (existing != null) {
privacySettingsMapper.update(privacy,
@@ -0,0 +1,24 @@
-- V047: 隐私设置补齐小程序端契约字段
--
-- 背景:小程序 pages/privacy-settings 使用的键集与后端 user_privacy_settings 长期不一致。
-- 小程序(权威): visibleInRecommendations / allowActivityInvitation / showLastOnline
-- 后端既有列 : visible_in_recommendations / visible_in_search / show_occupation /
-- show_lifestyle_photos / receive_activity_recommendations /
-- accept_matchmaker_recommendations
-- 两者**仅有 visible_in_recommendations 一个交集**。
--
-- 产品裁定(2026-09-19):**以小程序为准**,用迁移补齐缺失列。
--
-- 既有 6 列**保留不动**:UserProfileService 的资料详情 DTO 仍在全量返回这 6 个键
-- (UserProfileService.java:74-79),那是一条独立的、仍在生效的读取出口。
-- 本迁移只做加法,不做替换。
--
-- ⚠️ 默认值不对称,必须保真(小程序 data 初值即如此):
-- allow_activity_invitation 默认 TRUE
-- show_last_online 默认 FALSE ← 不可写成 TRUE,否则等于默认公开在线状态
ALTER TABLE user_privacy_settings
ADD COLUMN IF NOT EXISTS allow_activity_invitation BOOLEAN NOT NULL DEFAULT TRUE;
ALTER TABLE user_privacy_settings
ADD COLUMN IF NOT EXISTS show_last_online BOOLEAN NOT NULL DEFAULT FALSE;
@@ -22,7 +22,8 @@ class MigrationValidationTest {
// 幂等键检修:V045 新增幂等记录表(idempotency_records);
// V046 修正其 actor_id 列类型(VARCHAR → uuid,否则参数以 uuid 到达时报
// `operator does not exist: character varying = uuid`,机制整体 500)
private static final int LATEST_MIGRATION = 46;
// Phase 23:V047 隐私设置补齐小程序端契约列(allow_activity_invitation / show_last_online)
private static final int LATEST_MIGRATION = 47;
private static final String MIGRATION_DIR =
"backend/src/main/resources/db/migration/";
@@ -1,6 +1,7 @@
package com.danyang.match.platform.profile;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.account.service.AccountLifecycleService;
import com.danyang.match.platform.profile.entity.UserPrivacySettings;
import com.danyang.match.platform.profile.mapper.UserPrivacySettingsMapper;
import com.danyang.match.platform.profile.service.PrivacyService;
@@ -13,44 +14,61 @@ import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.test.context.ActiveProfiles;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
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;
/**
* 隐私设置 upsert 行为守卫。
* 隐私设置:upsert 行为守卫 + 前后端键集契约守卫。
*
* <p>守卫的缺陷:<b>同一用户第二次更新隐私设置必然 500</b>。
* {@code user_privacy_settings} 的主键自 V002 起就是 {@code user_id},
* 而旧实现无论记录是否已存在都无条件 {@code insert} → 第二次调用撞主键冲突。
* <p><b>为什么要解析小程序源文件而不是抄一份契约副本</b>:本项目已多次因「契约副本与
* 真实源文件漂移」而让守卫静默失效(见 EntityMigrationParityTest 的做法)。
* 本类直接解析 {@code miniapp/pages/privacy-settings/privacy-settings.js} 的
* PUT payload 得出权威键集 —— 小程序一旦改键,测试立刻变红。
*
* <p>为什么这个缺陷能长期存活:本机/测试环境的用户<b>从未改过隐私设置</b>,
* 该分支永远只被走到第一次;且测试库此前根本没有这张表(见 MapperBindingGuardTest)
* —— 与「Mapper 声明了却无实现」是同一类<b>空数据掩盖 + 表漂移</b>的双重盲区。
*
* <p>本测试全部走真实落库(H2),不用 Mockito 断言 mapper 调用次数:
* 只有真的插进去、再真的更新一遍,才能证明「第二次不炸」。
* <p>覆盖两类缺陷:
* <ol>
* <li><b>upsert 缺陷</b>:主键自 V002 起是 {@code user_id},旧实现无条件 {@code insert}
* → 同一用户第二次更新必然 {@code DuplicateKeyException} → 500。</li>
* <li><b>键集漂移</b>:后端 6 键与小程序 3 键长期只有 1 个交集(V047 已补齐)。</li>
* </ol>
*/
@SpringBootTest
@ActiveProfiles("test")
class PrivacySettingsUpsertGuardTest {
/** 与既有守卫(EntityMigrationParityTest)一致:路径相对仓库根。 */
private static final Path MINIAPP_PAGE =
Paths.get("miniapp/pages/privacy-settings/privacy-settings.js");
@MockBean
private StringRedisTemplate stringRedisTemplate;
@Autowired
private PrivacyService privacyService;
@Autowired
private AccountLifecycleService accountLifecycleService;
@Autowired
private UserPrivacySettingsMapper privacySettingsMapper;
@AfterEach
void cleanUp() {
// 清理本类写入的全部行,避免污染其他测试(H2 是共享的同库同 schema)
privacySettingsMapper.delete(
new LambdaQueryWrapper<UserPrivacySettings>()
.likeRight(UserPrivacySettings::getUserId, "upsert-guard-"));
@@ -67,23 +85,128 @@ class PrivacySettingsUpsertGuardTest {
.eq(UserPrivacySettings::getUserId, userId));
}
// ==================== 契约守卫:解析小程序源文件 ====================
private static String miniappSource() throws IOException {
assertTrue(Files.exists(MINIAPP_PAGE),
"找不到小程序隐私设置页:" + MINIAPP_PAGE.toAbsolutePath()
+ " —— 守卫定位失败必须大声报错,不能退化成跳过");
return Files.readString(MINIAPP_PAGE, StandardCharsets.UTF_8);
}
/** 权威键集 = 小程序 PUT /account/privacy-settings 的 payload 键。 */
private static Set<String> miniappContractKeys(String src) {
Matcher m = Pattern
.compile("api\\.put\\('/account/privacy-settings',\\s*\\{([^}]*)\\}", Pattern.DOTALL)
.matcher(src);
assertTrue(m.find(),
"未能在小程序源文件中定位 PUT /account/privacy-settings 的 payload "
+ "—— 页面结构变了,本守卫需同步更新(不可静默跳过)");
Set<String> keys = new TreeSet<>();
Matcher k = Pattern.compile("(\\w+):\\s*this\\.data\\.\\w+").matcher(m.group(1));
while (k.find()) {
keys.add(k.group(1));
}
assertFalse(keys.isEmpty(), "解析出的契约键集为空 —— 正则失效,守卫无意义");
return keys;
}
/** 小程序 data 初值(即默认值),仅取契约键。 */
private static Map<String, Boolean> miniappDefaults(String src, Set<String> keys) {
Matcher d = Pattern.compile("data:\\s*\\{([^}]*)\\}", Pattern.DOTALL).matcher(src);
assertTrue(d.find(), "未能在小程序源文件中定位 data 块 —— 页面结构变了,守卫需同步更新");
Map<String, Boolean> defaults = new HashMap<>();
Matcher kv = Pattern.compile("(\\w+):\\s*(true|false)").matcher(d.group(1));
while (kv.find()) {
if (keys.contains(kv.group(1))) {
defaults.put(kv.group(1), Boolean.valueOf(kv.group(2)));
}
}
return defaults;
}
@Test
@DisplayName("键集契约:后端返回的键必须与小程序 PUT payload 的键完全一致(不多不少)")
void backendKeySetMatchesMiniappContract() throws IOException {
String src = miniappSource();
Set<String> expected = miniappContractKeys(src);
Map<String, Boolean> actual = privacyService.getPrivacySettings(newUserId());
assertEquals(expected, new TreeSet<>(actual.keySet()),
"后端隐私设置键集与小程序契约不一致 —— 小程序会读到 undefined,"
+ "且它提交的值会被后端忽略(键集漂移曾导致 6 键 vs 3 键仅 1 个交集)");
}
@Test
@DisplayName("默认值契约:后端默认值必须与小程序 data 初值一致(showLastOnline 默认 false)")
void backendDefaultsMatchMiniappInitialData() throws IOException {
String src = miniappSource();
Set<String> keys = miniappContractKeys(src);
Map<String, Boolean> expected = miniappDefaults(src, keys);
Map<String, Boolean> actual = privacyService.getPrivacySettings(newUserId());
assertEquals(expected, actual,
"后端默认隐私值与小程序初值不一致 —— 尤其 showLastOnline 必须默认 false,"
+ "写成 true 等于默认公开用户在线状态");
assertTrue(expected.containsValue(Boolean.FALSE),
"小程序契约里没有任何默认 false 的键 —— 正则可能只匹配到 true,解析需复核");
}
@Test
@DisplayName("桩已接通:经 /account/privacy-settings 写入的值必须真的落库并可读回")
void accountEndpointPersistsThroughStubRemoval() {
String userId = newUserId();
Map<String, Object> payload = new HashMap<>();
payload.put("visibleInRecommendations", false);
payload.put("showLastOnline", true);
accountLifecycleService.updatePrivacySettings(userId, payload);
assertEquals(1, rowCount(userId),
"经 /account/privacy-settings(小程序实际调用的路径)更新后必须真的落库 —— "
+ "修复前这里是 TODO 桩,只回 {updated:true} 而不写库");
Map<String, Object> readBack = accountLifecycleService.getPrivacySettings(userId);
assertEquals(Boolean.FALSE, readBack.get("visibleInRecommendations"));
assertEquals(Boolean.TRUE, readBack.get("showLastOnline"),
"读回的值应与写入一致 —— 若读回默认值,说明桩仍未真正接通");
}
@Test
@DisplayName("已知键传错类型必须报错,不得静默丢弃(否则前端提示保存成功而实际未生效)")
void wrongTypeOnKnownKeyIsRejected() {
String userId = newUserId();
Map<String, Object> payload = new HashMap<>();
payload.put("showLastOnline", "yes"); // 既非 Boolean 也非 true/false 字符串
IllegalArgumentException ex = org.junit.jupiter.api.Assertions.assertThrows(
IllegalArgumentException.class,
() -> accountLifecycleService.updatePrivacySettings(userId, payload),
"已知键类型错误必须显式报错 —— 静默丢弃会让用户看到「保存成功」而设置从未生效");
assertTrue(ex.getMessage().contains("showLastOnline"));
}
// ==================== upsert 行为守卫 ====================
@Test
@DisplayName("核心缺陷守卫:同一用户连续更新两次 —— 第二次不得抛异常,且不得产生第二行")
void repeatUpdateIsUpsertNotDuplicateInsert() {
String userId = newUserId();
Map<String, Boolean> first = new HashMap<>();
first.put("visibleInSearch", false);
first.put("visibleInRecommendations", false);
Map<String, Boolean> firstResult = privacyService.updatePrivacySettings(userId, first);
assertFalse(firstResult.get("visibleInSearch"), "首次更新应生效");
assertFalse(firstResult.get("visibleInRecommendations"), "首次更新应生效");
assertEquals(1, rowCount(userId), "首次更新应恰好落库 1 行");
// === 修复前在此处抛主键冲突(DuplicateKeyException)→ 500 ===
Map<String, Boolean> second = new HashMap<>();
second.put("visibleInSearch", true);
second.put("visibleInRecommendations", true);
Map<String, Boolean> secondResult = privacyService.updatePrivacySettings(userId, second);
assertTrue(secondResult.get("visibleInSearch"), "第二次更新应生效(修复前此处 500)");
assertTrue(secondResult.get("visibleInRecommendations"), "第二次更新应生效(修复前此处 500)");
assertEquals(1, rowCount(userId),
"两次更新后仍必须只有 1 行 —— 若变成 2 行,说明退化成「重复插入」,"
+ "后续 findByUserId 查出多行会让 Optional 返回值抛出 TooManyResultsException");
@@ -94,46 +217,54 @@ class PrivacySettingsUpsertGuardTest {
void partialUpdatePreservesPreviouslySetFields() {
String userId = newUserId();
Map<String, Boolean> turnOffSearch = new HashMap<>();
turnOffSearch.put("visibleInSearch", false);
privacyService.updatePrivacySettings(userId, turnOffSearch);
Map<String, Boolean> turnOff = new HashMap<>();
turnOff.put("visibleInRecommendations", false);
privacyService.updatePrivacySettings(userId, turnOff);
// 第二次只改另一个字段:先前关闭的 visibleInSearch 必须保持 false
Map<String, Boolean> turnOffPhotos = new HashMap<>();
turnOffPhotos.put("showLifestylePhotos", false);
Map<String, Boolean> result = privacyService.updatePrivacySettings(userId, turnOffPhotos);
// 第二次只改另一个字段:先前关闭的 visibleInRecommendations 必须保持 false
Map<String, Boolean> turnOnLastOnline = new HashMap<>();
turnOnLastOnline.put("showLastOnline", true);
Map<String, Boolean> result = privacyService.updatePrivacySettings(userId, turnOnLastOnline);
assertFalse(result.get("showLifestylePhotos"), "本次提交的字段应生效");
assertFalse(result.get("visibleInSearch"),
assertTrue(result.get("showLastOnline"), "本次提交的字段应生效");
assertFalse(result.get("visibleInRecommendations"),
"未提交的字段必须保持上次的值 —— 若被重置为 true,说明更新走了「重建默认值再 insert」的路径");
assertEquals(1, rowCount(userId));
}
@Test
@DisplayName("首次更新只提交部分字段时,其余字段应取默认值 true")
void firstUpdateAppliesDefaultsForOmittedFields() {
String userId = newUserId();
@DisplayName("首次更新只提交部分字段时,其余字段应取小程序约定的默认值")
void firstUpdateAppliesMiniappDefaultsForOmittedFields() throws IOException {
String src = miniappSource();
Set<String> keys = miniappContractKeys(src);
Map<String, Boolean> defaults = miniappDefaults(src, keys);
String userId = newUserId();
Map<String, Boolean> only = new HashMap<>();
only.put("showOccupation", false);
only.put("showLastOnline", true);
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");
assertTrue(result.get("showLastOnline"), "提交的字段应为 true");
for (Map.Entry<String, Boolean> e : defaults.entrySet()) {
if (!"showLastOnline".equals(e.getKey())) {
assertNotNull(result.get(e.getKey()), "契约键缺失: " + e.getKey());
assertEquals(e.getValue(), result.get(e.getKey()),
"未提交字段应取小程序约定的默认值: " + e.getKey());
}
}
}
@Test
@DisplayName("无记录时读取返回全 true 默认值(固化既有契约,且不应落库)")
void readReturnsDefaultsWithoutPersisting() {
String userId = newUserId();
@DisplayName("无记录时读取返回默认值(固化既有契约,且不应落库)")
void readReturnsDefaultsWithoutPersisting() throws IOException {
String src = miniappSource();
Set<String> keys = miniappContractKeys(src);
Map<String, Boolean> expected = miniappDefaults(src, keys);
String userId = newUserId();
Map<String, Boolean> defaults = privacyService.getPrivacySettings(userId);
assertTrue(defaults.values().stream().allMatch(v -> v), "无记录时应返回全 true 默认值");
assertEquals(expected, defaults, "无记录时应返回小程序约定的默认值");
assertEquals(0, rowCount(userId), "读取操作不应产生副作用写入");
}
}
+3
View File
@@ -602,6 +602,9 @@ CREATE TABLE user_privacy_settings (
show_occupation BOOLEAN DEFAULT TRUE, show_lifestyle_photos BOOLEAN DEFAULT TRUE,
receive_activity_recommendations BOOLEAN DEFAULT TRUE,
accept_matchmaker_recommendations BOOLEAN DEFAULT TRUE,
-- V047:小程序 privacy-settings 契约列。注意 show_last_online 默认 FALSE(不公开在线状态)
allow_activity_invitation BOOLEAN DEFAULT TRUE,
show_last_online BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE,
deleted_at TIMESTAMP WITH TIME ZONE
);