feat(account): 数据保留流程落地(36 个月下限,V049)—— closed_at + 人工审核/幂等匿名化/审计 + 运营端点 + 守卫 5 用例;测试库补 user_photos,MyBatis null 更新修复

This commit is contained in:
WorkBuddy
2026-09-20 22:30:29 +08:00
parent 76a152bccf
commit 6e76e2f2ea
10 changed files with 517 additions and 2 deletions
@@ -0,0 +1,52 @@
package com.danyang.match.platform.account.controller;
import com.danyang.match.platform.account.service.DataRetentionService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 注销数据到期处置(Operator 端,2026-09-19 数据保留决策落地)。
*
* <p>权限 {@code account.retention.purge}(V049)仅授予 super_admin ——
* 处置不可逆,不做批量授权。流程为「人工审核」模式:先查到期清单,
* 运营确认后逐个执行;保留期内(产品确认下限 36 个月)的账号服务端拒绝处置。
*/
@RestController
@RequestMapping("/operator/account/retention")
@RequiredArgsConstructor
public class AccountRetentionOperatorController {
private final DataRetentionService dataRetentionService;
/**
* 保留期已满的注销账号清单
* GET /api/v1/operator/account/retention/due
*/
@GetMapping("/due")
@PreAuthorize("hasAuthority('account.retention.purge')")
public ApiResponse<List<Map<String, Object>>> dueAccounts() {
return ApiResponse.ok(dataRetentionService.listDueAccounts());
}
/**
* 执行到期匿名化清除(幂等)
* POST /api/v1/operator/account/retention/{userId}/purge
*/
@PostMapping("/{userId}/purge")
@PreAuthorize("hasAuthority('account.retention.purge')")
public ApiResponse<Map<String, Object>> purge(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String userId) {
if (principal == null) {
return ApiResponse.unauthorized();
}
return ApiResponse.ok(dataRetentionService.purgeExpired(userId, principal.id()));
}
}
@@ -298,6 +298,9 @@ public class AccountLifecycleService {
// 4. 记录退出时间 // 4. 记录退出时间
Instant now = Instant.now(); Instant now = Instant.now();
// V049:注销时间落库 —— 数据保留到期(closed_at + account.data-retention-months,
// 产品 2026-09-19 确认下限 36 个月)由此起算;此前只存在于日志,保留期无法计算。
user.setClosedAt(now);
user.setUpdatedAt(now); user.setUpdatedAt(now);
userMapper.updateById(user); userMapper.updateById(user);
@@ -0,0 +1,181 @@
package com.danyang.match.platform.account.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
import com.danyang.match.platform.local_eligibility.entity.LocalEligibilityEvidence;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityEvidenceMapper;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
import com.danyang.match.platform.photo.entity.UserPhoto;
import com.danyang.match.platform.photo.mapper.UserPhotoMapper;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.shared.base.BaseEntity;
import com.danyang.match.platform.shared.config.AccountConfig;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.*;
/**
* 注销数据到期处置(批 3 收尾:数据保留流程,2026-09-19 产品决策落地)。
*
* <h3>决策与口径</h3>
* 产品 2026-09-19 确认:注销后用户数据保留<b>不得低于 36 个月</b>(隐私政策 §3.3 原文
* 「30 天内删除」与本决策冲突,已按用户最新明确指令同步修订政策文本)。
* 保留期经 {@link AccountConfig#getDataRetentionMonths()} 配置化(AGENTS.md #12),
* 到期 = {@code closed_at + retentionMonths}。
*
* <h3>「配置 + 人工审核」而非自动作业</h3>
* 计划 Phase 21 的既定要求是「数据保留期限未确认的敏感材料采用配置和人工审核,
* 不擅自永久保存或删除」。因此本服务只提供<b>人工审核的处置工具</b>:
* {@code listDueAccounts()} 列出保留期已满的注销账号,由持
* {@code account.retention.purge} 权限(V049,仅授 super_admin)的运营确认后
* 调用 {@code purgeExpired()} 执行 —— <b>不引入定时自动删除</b>。
*
* <h3>处置内容(断开 PII 关联,保留统计与审计所需最小信息)</h3>
* <ol>
* <li>{@code users.wechat_openid} → {@code purged-<userId>}(不可再登录、不可回连微信身份;
* phone 清空。列有 UNIQUE 约束,purged 前缀 + userId 保证唯一);</li>
* <li>{@code user_profiles}:displayName → 「已注销用户」、selfIntro 清空
* (性别/年龄段等非直接标识符保留,供匿名统计);</li>
* <li>{@code user_photos} / {@code local_eligibility_evidences}:全部软删;</li>
* <li>审计:{@code audit_logs} 记 {@code DATA_RETENTION_PURGE}。</li>
* </ol>
*
* <p><b>如实登记的边界</b>:佐证/照片的<b>对象存储文件本体</b>不在此处删除
* (需要对象存储客户端与逐文件凭证),由对象存储生命周期规则兜底 —— 登记于
* {@code docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md} 批 3。
* 咨询摘要/正文已在<b>注销时</b>脱敏({@code anonymizeServiceHistory}),无需重复处理。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class DataRetentionService {
static final String PURGED_OPENID_PREFIX = "purged-";
private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper;
private final UserPhotoMapper userPhotoMapper;
private final LocalEligibilityMapper localEligibilityMapper;
private final LocalEligibilityEvidenceMapper evidenceMapper;
private final AuditLogMapper auditLogMapper;
private final AccountConfig accountConfig;
/** 保留到期时点:closed_at + retentionMonths(按 UTC 日历月计算,非 30 天近似) */
public Instant retentionDueAt(Instant closedAt) {
return closedAt.atZone(ZoneOffset.UTC).plusMonths(accountConfig.getDataRetentionMonths())
.toInstant();
}
/** 保留期已满、待人工审核处置的注销账号清单 */
public List<Map<String, Object>> listDueAccounts() {
Instant now = Instant.now();
List<User> closed = userMapper.selectList(
new LambdaQueryWrapper<User>()
.eq(User::getAccountStatus, Constants.ACCOUNT_STATUS_CLOSED)
.isNotNull(User::getClosedAt)
.isNull(BaseEntity::getDeletedAt));
List<Map<String, Object>> due = new ArrayList<>();
for (User user : closed) {
Instant dueAt = retentionDueAt(user.getClosedAt());
if (dueAt.isAfter(now)) {
continue; // 仍在保留期内 —— 产品确认的下限,不得提前处置
}
boolean alreadyPurged = user.getWechatOpenid() != null
&& user.getWechatOpenid().startsWith(PURGED_OPENID_PREFIX);
Map<String, Object> row = new HashMap<>();
row.put("userId", user.getId());
row.put("closedAt", user.getClosedAt());
row.put("retentionMonths", accountConfig.getDataRetentionMonths());
row.put("retentionDueAt", dueAt);
row.put("purged", alreadyPurged);
due.add(row);
}
return due;
}
/**
* 执行到期匿名化清除(人工审核后调用)。
*
* @return 处置结果;{@code purged=true} 表示此前已处置过(幂等重放)
*/
@Transactional
public Map<String, Object> purgeExpired(String userId, String operatorId) {
User user = userMapper.selectById(userId);
if (user == null) {
throw new IllegalArgumentException("用户不存在");
}
if (!Constants.ACCOUNT_STATUS_CLOSED.equals(user.getAccountStatus())) {
throw new IllegalArgumentException("仅限已注销账号执行到期处置");
}
if (user.getClosedAt() == null || retentionDueAt(user.getClosedAt()).isAfter(Instant.now())) {
throw new IllegalArgumentException("该账号仍在保留期内(下限 "
+ accountConfig.getDataRetentionMonths() + " 个月),不得提前处置");
}
Map<String, Object> result = new HashMap<>();
if (user.getWechatOpenid() != null
&& user.getWechatOpenid().startsWith(PURGED_OPENID_PREFIX)) {
result.put("purged", true);
result.put("alreadyPurged", true);
return result; // 幂等:重复调用不产生第二次审计与写入
}
// 1. 断开账号级直接标识符。
// ⚠️ 必须用 LambdaUpdateWrapper.set(..., null):MyBatis-Plus 默认 FieldStrategy=NOT_NULL,
// updateById 时 phone=null 不会进 SQL(守卫实测:清 phone 断言失败,旧值仍在库)。
userMapper.update(null, new LambdaUpdateWrapper<User>()
.eq(User::getId, userId)
.set(User::getWechatOpenid, PURGED_OPENID_PREFIX + userId)
.set(User::getPhone, null));
// 2. 资料直接标识字段匿名化(selfIntro 置 null 同理须显式 set)
userProfileMapper.findByUserId(userId).ifPresent(profile ->
userProfileMapper.update(null, new LambdaUpdateWrapper<UserProfile>()
.eq(UserProfile::getUserId, userId)
.set(UserProfile::getDisplayName, "已注销用户")
.set(UserProfile::getSelfIntro, null)));
// 3. 照片与本地资格佐证软删(文件本体由对象存储生命周期规则兜底,见类注释边界)。
// 佐证表无 user_id 直连列,须经 user_local_eligibilities 关联。
Instant now = Instant.now();
for (UserPhoto photo : userPhotoMapper.selectList(
new LambdaQueryWrapper<UserPhoto>().eq(UserPhoto::getUserId, userId))) {
photo.setDeletedAt(now);
userPhotoMapper.updateById(photo);
}
localEligibilityMapper.findByUserId(userId).ifPresent(el -> {
for (LocalEligibilityEvidence evidence : evidenceMapper.selectList(
new LambdaQueryWrapper<LocalEligibilityEvidence>()
.eq(LocalEligibilityEvidence::getLocalEligibilityId, el.getId()))) {
evidence.setDeletedAt(now);
evidenceMapper.updateById(evidence);
}
});
// 4. 审计(不可逆操作必须留痕)
AuditLog auditLog = new AuditLog();
auditLog.setOperatorId(operatorId);
auditLog.setAction("DATA_RETENTION_PURGE");
auditLog.setResourceType("user");
auditLog.setResourceId(userId);
auditLogMapper.insert(auditLog);
log.info("注销数据到期处置完成: userId={}, operatorId={}, retentionMonths={}",
userId, operatorId, accountConfig.getDataRetentionMonths());
result.put("purged", true);
result.put("alreadyPurged", false);
return result;
}
}
@@ -0,0 +1,31 @@
package com.danyang.match.platform.shared.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 账户生命周期配置。
*
* <p><b>配置化纪律</b>(AGENTS.md #12:未确认数量参数不得硬编码):保留期来自产品决策
* (2026-09-19 确认「注销后数据保留不得低于 36 个月」),以配置承载并可经 yml 覆盖。
* 读取点:{@code DataRetentionService}(到期清单计算与处置闸)——
* 不留「配了没人读」的死配置(Phase 22 T01~T05 教训)。
*/
@Component
@ConfigurationProperties(prefix = "account")
public class AccountConfig {
/**
* 注销后数据保留月数(下限)。产品 2026-09-19 确认:不得低于 36 个月。
* 到期后由持 {@code account.retention.purge} 权限的运营经人工审核执行匿名化清除。
*/
private int dataRetentionMonths = 36;
public int getDataRetentionMonths() {
return dataRetentionMonths;
}
public void setDataRetentionMonths(int dataRetentionMonths) {
this.dataRetentionMonths = dataRetentionMonths;
}
}
@@ -30,4 +30,12 @@ public class User extends BaseEntity {
@TableField("last_login_at") @TableField("last_login_at")
private java.time.Instant lastLoginAt; private java.time.Instant lastLoginAt;
/**
* 注销时间(V049)。数据保留到期 = closed_at + {@code account.data-retention-months}
* (默认 36 个月,产品 2026-09-19 确认下限);到期后经人工审核执行匿名化清除。
* 仅 account_status = closed 的记录才有值。
*/
@TableField("closed_at")
private java.time.Instant closedAt;
} }
@@ -0,0 +1,30 @@
-- ============================================================
-- V049: 账户数据保留 —— 注销时间落库 + 到期处置权限
--
-- 背景(2026-09-19 产品决策):注销后用户数据保留**不得低于 36 个月**,
-- 到期后经人工审核执行匿名化清除。此前隐私政策 3.3 写「注销后 30 天内删除」,
-- 与本决策冲突,政策文本已同步修订(docs/legal/privacy-policy.md §3.3)。
--
-- 1. users.closed_at:closeAccount 写入;保留到期 = closed_at + 36 个月
-- (account.data-retention-months 配置,默认 36)。
-- 此前注销时间只存在于日志,保留期计算无数据基础 —— 到期处置流程因此无法落地。
-- 2. account.retention.purge 权限:到期数据的人工审核处置
-- (列出到期账号 / 执行匿名化清除)。仅授予 super_admin ——
-- 该操作不可逆,不做批量授权(Phase 22「死配置」教训:权限必须有真实读取点)。
--
-- 幂等:ADD COLUMN IF NOT EXISTS / ON CONFLICT DO NOTHING。
-- ============================================================
ALTER TABLE users ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;
INSERT INTO permissions (id, code, name, module, description) VALUES
('20000000-0000-0000-0000-000000000490', 'account.retention.purge', '注销数据到期处置', 'account',
'列出保留期已满的注销账号并执行匿名化清除(不可逆;产品 2026-09-19 确认保留期下限 36 个月)')
ON CONFLICT (code) DO NOTHING;
-- super_admin 自动获得全部权限的定义在 V044 是「执行时点快照」,
-- 本迁移新增的权限必须自行回补(V044 根因说明)。
INSERT INTO role_permissions (role_id, permission_id)
SELECT '00000000-0000-0000-0000-000000000001', id
FROM permissions WHERE code = 'account.retention.purge'
ON CONFLICT (role_id, permission_id) DO NOTHING;
@@ -0,0 +1,189 @@
package com.danyang.match.platform.account;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.account.service.DataRetentionService;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
import com.danyang.match.platform.local_eligibility.entity.LocalEligibilityEvidence;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityEvidenceMapper;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
import com.danyang.match.platform.photo.entity.UserPhoto;
import com.danyang.match.platform.photo.mapper.UserPhotoMapper;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
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.test.context.ActiveProfiles;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/**
* 数据保留流程守卫(批 3 收尾,2026-09-19 产品决策落地)。
*
* <h3>决策</h3>
* 注销后数据保留<b>不得低于 36 个月</b>(产品 2026-09-19 确认;隐私政策 §3.3 原文
* 「30 天内删除」已同步修订)。到期后走<b>人工审核</b>处置 —— 本守卫钉住三条不变量:
* <ol>
* <li><b>下限不可提前</b>:保留期内的注销账号既不进到期清单,purge 被服务端拒绝;</li>
* <li><b>到期可处置</b>:到期账号进入清单,purge 后 PII 关联断开
* (openid 匿名、phone 清空、资料脱敏、照片/佐证软删)且审计落库;</li>
* <li><b>幂等</b>:重复 purge 不产生第二次审计与写入。</li>
* </ol>
* 负向验证已做:摘掉 {@code listDueAccounts} 的到期判断 → 「保留期内不进清单」用例红。
*/
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class DataRetentionGuardTest {
@Autowired private DataRetentionService dataRetentionService;
@Autowired private UserMapper userMapper;
@Autowired private UserProfileMapper userProfileMapper;
@Autowired private UserPhotoMapper userPhotoMapper;
@Autowired private LocalEligibilityMapper localEligibilityMapper;
@Autowired private LocalEligibilityEvidenceMapper evidenceMapper;
@Autowired private AuditLogMapper auditLogMapper;
private static final String OPERATOR = "op-retention-1";
private User newClosedUser(Instant closedAt) {
User user = new User();
user.setId(UUID.randomUUID().toString());
user.setWechatOpenid("openid-" + UUID.randomUUID());
user.setPhone("138" + UUID.randomUUID().toString().substring(0, 8));
user.setAccountStatus(Constants.ACCOUNT_STATUS_CLOSED);
user.setClosedAt(closedAt);
userMapper.insert(user);
return user;
}
private UserProfile newProfile(String userId) {
UserProfile profile = new UserProfile();
profile.setUserId(userId);
profile.setDisplayName("张三");
profile.setSelfIntro("自我介绍内容");
userProfileMapper.insert(profile);
return profile;
}
private long countPurgeAudit(String userId) {
return auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
.eq(AuditLog::getAction, "DATA_RETENTION_PURGE")
.eq(AuditLog::getResourceType, "user")
.eq(AuditLog::getResourceId, userId));
}
@Test
@DisplayName("下限不可提前:保留期内(<36 个月)的注销账号不进到期清单")
void withinRetentionNotListed() {
User user = newClosedUser(Instant.now().atZone(ZoneOffset.UTC).minusMonths(1).toInstant());
List<Map<String, Object>> due = dataRetentionService.listDueAccounts();
assertTrue(due.stream().noneMatch(r -> user.getId().equals(r.get("userId"))),
"保留期内的账号绝不能进入处置清单(产品确认下限 36 个月)");
}
@Test
@DisplayName("下限不可提前:保留期内的账号 purge 被服务端拒绝")
void withinRetentionPurgeRejected() {
User user = newClosedUser(Instant.now().atZone(ZoneOffset.UTC).minusMonths(1).toInstant());
assertThrows(IllegalArgumentException.class,
() -> dataRetentionService.purgeExpired(user.getId(), OPERATOR),
"未到 36 个月下限不得处置");
assertEquals(0, countPurgeAudit(user.getId()));
}
@Test
@DisplayName("到期可处置:进入清单;purge 后 PII 断开、照片/佐证软删、审计落库")
void dueAccountCanBePurged() {
Instant closedAt = Instant.now().atZone(ZoneOffset.UTC).minusMonths(37).toInstant();
User user = newClosedUser(closedAt);
newProfile(user.getId());
UserPhoto photo = new UserPhoto();
photo.setId(UUID.randomUUID().toString());
photo.setUserId(user.getId());
photo.setFileKey("photos/" + user.getId() + "/1.jpg");
userPhotoMapper.insert(photo);
LocalEligibility el = new LocalEligibility();
el.setUserId(user.getId());
el.setEligibilityType("household_registration");
el.setVerificationStatus("rejected");
localEligibilityMapper.insert(el);
LocalEligibilityEvidence evidence = new LocalEligibilityEvidence();
evidence.setId(UUID.randomUUID().toString());
evidence.setLocalEligibilityId(el.getId());
evidence.setFileId(UUID.randomUUID().toString());
evidenceMapper.insert(evidence);
// 到期清单
List<Map<String, Object>> due = dataRetentionService.listDueAccounts();
Map<String, Object> row = due.stream()
.filter(r -> user.getId().equals(r.get("userId"))).findFirst().orElse(null);
assertNotNull(row, "保留期已满的注销账号必须进入处置清单");
assertEquals(Boolean.FALSE, row.get("purged"), "未处置账号 purged 应为 false");
// 执行处置
Map<String, Object> result = dataRetentionService.purgeExpired(user.getId(), OPERATOR);
assertEquals(Boolean.TRUE, result.get("purged"));
// PII 关联断开(回查真实落库)
User after = userMapper.selectById(user.getId());
assertTrue(after.getWechatOpenid().startsWith("purged-"),
"openid 必须匿名化(不可登录、不可回连微信身份)");
assertNull(after.getPhone(), "手机号必须清空");
UserProfile profile = userProfileMapper.findByUserId(user.getId()).orElseThrow();
assertEquals("已注销用户", profile.getDisplayName());
assertNull(profile.getSelfIntro());
assertNotNull(userPhotoMapper.selectById(photo.getId()).getDeletedAt(), "照片必须软删");
assertNotNull(evidenceMapper.selectById(evidence.getId()).getDeletedAt(), "佐证必须软删");
// 审计落库
assertEquals(1, countPurgeAudit(user.getId()), "不可逆处置必须留痕");
}
@Test
@DisplayName("幂等:重复 purge 不产生第二次审计与写入")
void purgeIsIdempotent() {
Instant closedAt = Instant.now().atZone(ZoneOffset.UTC).minusMonths(37).toInstant();
User user = newClosedUser(closedAt);
String originalOpenid = user.getWechatOpenid();
dataRetentionService.purgeExpired(user.getId(), OPERATOR);
Map<String, Object> second = dataRetentionService.purgeExpired(user.getId(), OPERATOR);
assertEquals(Boolean.TRUE, second.get("alreadyPurged"), "第二次调用应识别为已处置");
assertEquals(1, countPurgeAudit(user.getId()), "幂等重放不得产生第二次审计");
User after = userMapper.selectById(user.getId());
assertEquals("purged-" + user.getId(), after.getWechatOpenid());
assertNotEquals(originalOpenid, after.getWechatOpenid());
}
@Test
@DisplayName("未注销账号不得处置")
void nonClosedAccountRejected() {
User user = new User();
user.setId(UUID.randomUUID().toString());
user.setWechatOpenid("openid-" + UUID.randomUUID());
user.setAccountStatus("normal");
user.setClosedAt(Instant.now().atZone(ZoneOffset.UTC).minusMonths(40).toInstant());
userMapper.insert(user);
assertThrows(IllegalArgumentException.class,
() -> dataRetentionService.purgeExpired(user.getId(), OPERATOR),
"仅限已注销账号");
}
}
@@ -67,6 +67,8 @@ class EntityMigrationParityTest {
"sensitive_access_logs", "sensitive_access_logs",
"system_configs", "system_configs",
"user_interests", "user_interests",
// 数据保留守卫(DataRetentionGuardTest,2026-09-20):user_photos 此前长期挂在本表
// (= 照片路径零覆盖,测试环境 BadSqlGrammar 不可见)。已补入 schema-h2.sql。
"user_photos", "user_photos",
"user_preferences", "user_preferences",
"user_privacy_settings" "user_privacy_settings"
@@ -25,7 +25,9 @@ class MigrationValidationTest {
// Phase 23:V047 隐私设置补齐小程序端契约列(allow_activity_invitation / show_last_online) // Phase 23:V047 隐私设置补齐小程序端契约列(allow_activity_invitation / show_last_online)
// Phase 23 收尾:V048 feature_flag_overrides —— PUT /operator/feature-flags 真持久化 // Phase 23 收尾:V048 feature_flag_overrides —— PUT /operator/feature-flags 真持久化
// (此前只改内存单例,重启即丢、多实例不一致) // (此前只改内存单例,重启即丢、多实例不一致)
private static final int LATEST_MIGRATION = 48; // 数据保留(2026-09-19 产品确认保留期下限 36 个月):
// V049 users.closed_at(保留期起算点,此前注销时间只存在于日志)+ account.retention.purge 权限
private static final int LATEST_MIGRATION = 49;
private static final String MIGRATION_DIR = private static final String MIGRATION_DIR =
"backend/src/main/resources/db/migration/"; "backend/src/main/resources/db/migration/";
+18 -1
View File
@@ -4,9 +4,26 @@ CREATE TABLE users (
id VARCHAR(36) PRIMARY KEY, wechat_openid VARCHAR(128) NOT NULL UNIQUE, id VARCHAR(36) PRIMARY KEY, wechat_openid VARCHAR(128) NOT NULL UNIQUE,
wechat_unionid VARCHAR(128), phone VARCHAR(32), account_status VARCHAR(32), wechat_unionid VARCHAR(128), phone VARCHAR(32), account_status VARCHAR(32),
relationship_status VARCHAR(32), recommendation_enabled BOOLEAN, relationship_status VARCHAR(32), recommendation_enabled BOOLEAN,
last_login_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE, last_login_at TIMESTAMP WITH TIME ZONE, closed_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE,
updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE
); );
-- 数据保留守卫(DataRetentionGuardTest):user_photos 表此前完全缺失于测试库
-- (且留在 EntityMigrationParityTest 的可选白名单里 = 零覆盖),
-- 照片软删路径在测试环境直接 BadSqlGrammar。按 V002 生产定义补齐(UUID→VARCHAR(36) 约定)。
CREATE TABLE user_photos (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
file_key VARCHAR(512) NOT NULL,
photo_type VARCHAR(32) NOT NULL DEFAULT 'lifestyle',
sort_order SMALLINT NOT NULL DEFAULT 0,
review_status VARCHAR(32) NOT NULL DEFAULT 'pending',
review_reason VARCHAR(256),
created_at TIMESTAMP WITH TIME ZONE,
updated_at TIMESTAMP WITH TIME ZONE,
deleted_at TIMESTAMP WITH TIME ZONE
);
CREATE TABLE user_relations ( CREATE TABLE user_relations (
id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36), target_user_id VARCHAR(36), relation_type VARCHAR(32), id VARCHAR(36) PRIMARY KEY, user_id VARCHAR(36), target_user_id VARCHAR(36), relation_type VARCHAR(32),
created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE, updated_at TIMESTAMP WITH TIME ZONE, deleted_at TIMESTAMP WITH TIME ZONE,