From d9c08d85f949a38c7b7bf1e013fb3f56ca1f7492 Mon Sep 17 00:00:00 2001 From: WorkBuddy Date: Sat, 19 Sep 2026 17:11:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(privacy):=20=E9=94=AE=E9=9B=86=E4=BB=A5?= =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=E4=B8=BA=E5=87=86=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20V047=20=E8=A1=A5=E5=88=97=20+=20=E6=8E=A5=E9=80=9A?= =?UTF-8?q?=20/account/privacy-settings=20=E6=A1=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 产品裁定:隐私设置键集**以小程序为准**,用迁移补齐后端缺失列。 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,否则全量回归报「版本号超出当前基线」。 该守卫同时断言「迁移文件总数 == 基线」(不允许缺号),是有效守卫 —— 正确做法是更新常量,不要为了让测试变绿而改断言方式。 --- .../service/AccountLifecycleService.java | 57 ++++- .../profile/entity/UserPrivacySettings.java | 5 + .../profile/service/PrivacyService.java | 62 +++--- ...7__align_privacy_settings_with_miniapp.sql | 24 ++ .../deployment/MigrationValidationTest.java | 3 +- .../PrivacySettingsUpsertGuardTest.java | 207 ++++++++++++++---- backend/src/test/resources/schema-h2.sql | 3 + 7 files changed, 283 insertions(+), 78 deletions(-) create mode 100644 backend/src/main/resources/db/migration/V047__align_privacy_settings_with_miniapp.sql diff --git a/backend/src/main/java/com/danyang/match/platform/account/service/AccountLifecycleService.java b/backend/src/main/java/com/danyang/match/platform/account/service/AccountLifecycleService.java index 9922b52..dbed074 100644 --- a/backend/src/main/java/com/danyang/match/platform/account/service/AccountLifecycleService.java +++ b/backend/src/main/java/com/danyang/match/platform/account/service/AccountLifecycleService.java @@ -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 页的契约键集(产品裁定:隐私设置键集以小程序为准)。 + * 用于入参校验:未知键宽容忽略,但已知键类型错误必须显式报错 —— + * 若静默丢弃,用户会看到"保存成功"而实际未生效,这类缺陷最难排查。 + */ + private static final Set 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 调用次数: - * 只有真的插进去、再真的更新一遍,才能证明「第二次不炸」。 + *

覆盖两类缺陷: + *

    + *
  1. upsert 缺陷:主键自 V002 起是 {@code user_id},旧实现无条件 {@code insert} + * → 同一用户第二次更新必然 {@code DuplicateKeyException} → 500。
  2. + *
  3. 键集漂移:后端 6 键与小程序 3 键长期只有 1 个交集(V047 已补齐)。
  4. + *
*/ @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 );