PRIVACY_KEYS =
+ Set.of("visibleInRecommendations", "allowActivityInvitation", "showLastOnline");
+
+ /**
+ * 获取隐私设置。
+ *
+ * 原为 TODO 桩:永远返回写死的默认值、不读库,用户改过的设置读不回来。
+ * 现委托 {@link PrivacyService} 读真实 {@code user_privacy_settings}。
*/
public Map getPrivacySettings(String userId) {
- // TODO: Phase 21 实现 user_privacy_settings 查询
- Map settings = new HashMap<>();
- settings.put("visibleInRecommendations", true);
- settings.put("allowActivityInvitation", true);
- settings.put("showLastOnline", false);
- return settings;
+ return new HashMap<>(privacyService.getPrivacySettings(userId));
}
/**
- * 更新隐私设置
+ * 更新隐私设置。
+ *
+ * 原为 TODO 桩:只回 {@code {updated:true}}、不落库,
+ * 用户看到"保存成功"后刷新即丢失。现委托 {@link PrivacyService} 真实 upsert。
*/
@Transactional
public Map updatePrivacySettings(String userId, Map settings) {
- // TODO: Phase 21 实现 user_privacy_settings 更新
- Map result = new HashMap<>();
- result.put("updated", true);
- return result;
+ return new HashMap<>(privacyService.updatePrivacySettings(userId, toBooleanMap(settings)));
+ }
+
+ private Map toBooleanMap(Map settings) {
+ Map typed = new HashMap<>();
+ if (settings == null) {
+ return typed;
+ }
+ for (Map.Entry 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;
}
// ==================== 私有辅助方法 ====================
diff --git a/backend/src/main/java/com/danyang/match/platform/profile/entity/UserPrivacySettings.java b/backend/src/main/java/com/danyang/match/platform/profile/entity/UserPrivacySettings.java
index b4e0762..df0ec54 100644
--- a/backend/src/main/java/com/danyang/match/platform/profile/entity/UserPrivacySettings.java
+++ b/backend/src/main/java/com/danyang/match/platform/profile/entity/UserPrivacySettings.java
@@ -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;
}
diff --git a/backend/src/main/java/com/danyang/match/platform/profile/service/PrivacyService.java b/backend/src/main/java/com/danyang/match/platform/profile/service/PrivacyService.java
index 9b62f6a..eb9b6f4 100644
--- a/backend/src/main/java/com/danyang/match/platform/profile/service/PrivacyService.java
+++ b/backend/src/main/java/com/danyang/match/platform/profile/service/PrivacyService.java
@@ -17,31 +17,38 @@ public class PrivacyService {
private final UserPrivacySettingsMapper privacySettingsMapper;
/**
- * 获取隐私设置
+ * 获取隐私设置。
+ *
+ * 键集以小程序为准(2026-09-19 产品裁定):
+ * {@code visibleInRecommendations} / {@code allowActivityInvitation} / {@code showLastOnline}。
+ * 后两列由 V047 补齐(此前后端表与小程序只有 {@code visibleInRecommendations} 一个交集)。
+ *
+ *
默认值与小程序 {@code data} 初值严格一致,尤其
+ * {@code showLastOnline} 默认 false(不公开在线状态)—— 写成 true 等于默认公开。
+ *
+ *
注意:既有的 6 个列并未废弃 —— {@link com.danyang.match.platform.profile.service.UserProfileService}
+ * 的资料详情 DTO 仍在全量返回它们。此处只是隐私设置读写契约以小程序为准。
*/
public Map getPrivacySettings(String userId) {
return privacySettingsMapper.findByUserId(userId)
- .map(s -> {
- Map 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());
- return map;
- })
- .orElseGet(() -> {
- // 返回默认值
- Map 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;
- });
+ .map(this::toMiniappContract)
+ .orElseGet(this::defaults);
+ }
+
+ private Map toMiniappContract(UserPrivacySettings s) {
+ Map map = new HashMap<>();
+ map.put("visibleInRecommendations", s.getVisibleInRecommendations());
+ map.put("allowActivityInvitation", s.getAllowActivityInvitation());
+ map.put("showLastOnline", s.getShowLastOnline());
+ return map;
+ }
+
+ private Map defaults() {
+ Map map = new HashMap<>();
+ map.put("visibleInRecommendations", true);
+ map.put("allowActivityInvitation", true);
+ map.put("showLastOnline", false);
+ return map;
}
/**
@@ -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,
diff --git a/backend/src/main/resources/db/migration/V047__align_privacy_settings_with_miniapp.sql b/backend/src/main/resources/db/migration/V047__align_privacy_settings_with_miniapp.sql
new file mode 100644
index 0000000..a790c02
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V047__align_privacy_settings_with_miniapp.sql
@@ -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;
diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java
index fdb9605..4e4e5c8 100644
--- a/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java
+++ b/backend/src/test/java/com/danyang/match/platform/deployment/MigrationValidationTest.java
@@ -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/";
diff --git a/backend/src/test/java/com/danyang/match/platform/profile/PrivacySettingsUpsertGuardTest.java b/backend/src/test/java/com/danyang/match/platform/profile/PrivacySettingsUpsertGuardTest.java
index 8e21ad8..5b67965 100644
--- a/backend/src/test/java/com/danyang/match/platform/profile/PrivacySettingsUpsertGuardTest.java
+++ b/backend/src/test/java/com/danyang/match/platform/profile/PrivacySettingsUpsertGuardTest.java
@@ -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 行为守卫 + 前后端键集契约守卫。
*
- * 守卫的缺陷:同一用户第二次更新隐私设置必然 500。
- * {@code user_privacy_settings} 的主键自 V002 起就是 {@code user_id},
- * 而旧实现无论记录是否已存在都无条件 {@code insert} → 第二次调用撞主键冲突。
+ *
为什么要解析小程序源文件而不是抄一份契约副本:本项目已多次因「契约副本与
+ * 真实源文件漂移」而让守卫静默失效(见 EntityMigrationParityTest 的做法)。
+ * 本类直接解析 {@code miniapp/pages/privacy-settings/privacy-settings.js} 的
+ * PUT payload 得出权威键集 —— 小程序一旦改键,测试立刻变红。
*
- *
为什么这个缺陷能长期存活:本机/测试环境的用户从未改过隐私设置,
- * 该分支永远只被走到第一次;且测试库此前根本没有这张表(见 MapperBindingGuardTest)
- * —— 与「Mapper 声明了却无实现」是同一类空数据掩盖 + 表漂移的双重盲区。
- *
- *
本测试全部走真实落库(H2),不用 Mockito 断言 mapper 调用次数:
- * 只有真的插进去、再真的更新一遍,才能证明「第二次不炸」。
+ *
覆盖两类缺陷:
+ *
+ * - upsert 缺陷:主键自 V002 起是 {@code user_id},旧实现无条件 {@code insert}
+ * → 同一用户第二次更新必然 {@code DuplicateKeyException} → 500。
+ * - 键集漂移:后端 6 键与小程序 3 键长期只有 1 个交集(V047 已补齐)。
+ *
*/
@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()
.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 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 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 miniappDefaults(String src, Set keys) {
+ Matcher d = Pattern.compile("data:\\s*\\{([^}]*)\\}", Pattern.DOTALL).matcher(src);
+ assertTrue(d.find(), "未能在小程序源文件中定位 data 块 —— 页面结构变了,守卫需同步更新");
+ Map 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 expected = miniappContractKeys(src);
+
+ Map 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 keys = miniappContractKeys(src);
+ Map expected = miniappDefaults(src, keys);
+
+ Map 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 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 readBack = accountLifecycleService.getPrivacySettings(userId);
+ assertEquals(Boolean.FALSE, readBack.get("visibleInRecommendations"));
+ assertEquals(Boolean.TRUE, readBack.get("showLastOnline"),
+ "读回的值应与写入一致 —— 若读回默认值,说明桩仍未真正接通");
+ }
+
+ @Test
+ @DisplayName("已知键传错类型必须报错,不得静默丢弃(否则前端提示保存成功而实际未生效)")
+ void wrongTypeOnKnownKeyIsRejected() {
+ String userId = newUserId();
+ Map 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 first = new HashMap<>();
- first.put("visibleInSearch", false);
+ first.put("visibleInRecommendations", false);
Map firstResult = privacyService.updatePrivacySettings(userId, first);
- assertFalse(firstResult.get("visibleInSearch"), "首次更新应生效");
+ assertFalse(firstResult.get("visibleInRecommendations"), "首次更新应生效");
assertEquals(1, rowCount(userId), "首次更新应恰好落库 1 行");
// === 修复前在此处抛主键冲突(DuplicateKeyException)→ 500 ===
Map second = new HashMap<>();
- second.put("visibleInSearch", true);
+ second.put("visibleInRecommendations", true);
Map 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 turnOffSearch = new HashMap<>();
- turnOffSearch.put("visibleInSearch", false);
- privacyService.updatePrivacySettings(userId, turnOffSearch);
+ Map turnOff = new HashMap<>();
+ turnOff.put("visibleInRecommendations", false);
+ privacyService.updatePrivacySettings(userId, turnOff);
- // 第二次只改另一个字段:先前关闭的 visibleInSearch 必须保持 false
- Map turnOffPhotos = new HashMap<>();
- turnOffPhotos.put("showLifestylePhotos", false);
- Map result = privacyService.updatePrivacySettings(userId, turnOffPhotos);
+ // 第二次只改另一个字段:先前关闭的 visibleInRecommendations 必须保持 false
+ Map turnOnLastOnline = new HashMap<>();
+ turnOnLastOnline.put("showLastOnline", true);
+ Map 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 keys = miniappContractKeys(src);
+ Map defaults = miniappDefaults(src, keys);
+ String userId = newUserId();
Map only = new HashMap<>();
- only.put("showOccupation", false);
+ only.put("showLastOnline", true);
Map 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 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 keys = miniappContractKeys(src);
+ Map expected = miniappDefaults(src, keys);
+ String userId = newUserId();
Map defaults = privacyService.getPrivacySettings(userId);
- assertTrue(defaults.values().stream().allMatch(v -> v), "无记录时应返回全 true 默认值");
+ assertEquals(expected, defaults, "无记录时应返回小程序约定的默认值");
assertEquals(0, rowCount(userId), "读取操作不应产生副作用写入");
}
}
diff --git a/backend/src/test/resources/schema-h2.sql b/backend/src/test/resources/schema-h2.sql
index 2d3a4b9..34a600f 100644
--- a/backend/src/test/resources/schema-h2.sql
+++ b/backend/src/test/resources/schema-h2.sql
@@ -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
);