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<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());
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;
});
.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("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;
}
/**
@@ -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,