feat(account): 数据保留流程落地(36 个月下限,V049)—— closed_at + 人工审核/幂等匿名化/审计 + 运营端点 + 守卫 5 用例;测试库补 user_photos,MyBatis null 更新修复
This commit is contained in:
+52
@@ -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()));
|
||||
}
|
||||
}
|
||||
+3
@@ -298,6 +298,9 @@ public class AccountLifecycleService {
|
||||
|
||||
// 4. 记录退出时间
|
||||
Instant now = Instant.now();
|
||||
// V049:注销时间落库 —— 数据保留到期(closed_at + account.data-retention-months,
|
||||
// 产品 2026-09-19 确认下限 36 个月)由此起算;此前只存在于日志,保留期无法计算。
|
||||
user.setClosedAt(now);
|
||||
user.setUpdatedAt(now);
|
||||
userMapper.updateById(user);
|
||||
|
||||
|
||||
+181
@@ -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")
|
||||
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),
|
||||
"仅限已注销账号");
|
||||
}
|
||||
}
|
||||
+2
@@ -67,6 +67,8 @@ class EntityMigrationParityTest {
|
||||
"sensitive_access_logs",
|
||||
"system_configs",
|
||||
"user_interests",
|
||||
// 数据保留守卫(DataRetentionGuardTest,2026-09-20):user_photos 此前长期挂在本表
|
||||
// (= 照片路径零覆盖,测试环境 BadSqlGrammar 不可见)。已补入 schema-h2.sql。
|
||||
"user_photos",
|
||||
"user_preferences",
|
||||
"user_privacy_settings"
|
||||
|
||||
+3
-1
@@ -25,7 +25,9 @@ class MigrationValidationTest {
|
||||
// Phase 23:V047 隐私设置补齐小程序端契约列(allow_activity_invitation / show_last_online)
|
||||
// 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 =
|
||||
"backend/src/main/resources/db/migration/";
|
||||
|
||||
@@ -4,9 +4,26 @@ CREATE TABLE users (
|
||||
id VARCHAR(36) PRIMARY KEY, wechat_openid VARCHAR(128) NOT NULL UNIQUE,
|
||||
wechat_unionid VARCHAR(128), phone VARCHAR(32), account_status VARCHAR(32),
|
||||
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
|
||||
);
|
||||
-- 数据保留守卫(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 (
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user