diff --git a/backend/src/main/java/com/danyang/match/platform/account/controller/AccountRetentionOperatorController.java b/backend/src/main/java/com/danyang/match/platform/account/controller/AccountRetentionOperatorController.java new file mode 100644 index 0000000..af41c27 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/account/controller/AccountRetentionOperatorController.java @@ -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 数据保留决策落地)。 + * + *

权限 {@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>> dueAccounts() { + return ApiResponse.ok(dataRetentionService.listDueAccounts()); + } + + /** + * 执行到期匿名化清除(幂等) + * POST /api/v1/operator/account/retention/{userId}/purge + */ + @PostMapping("/{userId}/purge") + @PreAuthorize("hasAuthority('account.retention.purge')") + public ApiResponse> purge( + @AuthenticationPrincipal OperatorPrincipal principal, + @PathVariable String userId) { + if (principal == null) { + return ApiResponse.unauthorized(); + } + return ApiResponse.ok(dataRetentionService.purgeExpired(userId, principal.id())); + } +} 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 9274a50..eb0ca22 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 @@ -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); diff --git a/backend/src/main/java/com/danyang/match/platform/account/service/DataRetentionService.java b/backend/src/main/java/com/danyang/match/platform/account/service/DataRetentionService.java new file mode 100644 index 0000000..1d22ad4 --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/account/service/DataRetentionService.java @@ -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 产品决策落地)。 + * + *

决策与口径

+ * 产品 2026-09-19 确认:注销后用户数据保留不得低于 36 个月(隐私政策 §3.3 原文 + * 「30 天内删除」与本决策冲突,已按用户最新明确指令同步修订政策文本)。 + * 保留期经 {@link AccountConfig#getDataRetentionMonths()} 配置化(AGENTS.md #12), + * 到期 = {@code closed_at + retentionMonths}。 + * + *

「配置 + 人工审核」而非自动作业

+ * 计划 Phase 21 的既定要求是「数据保留期限未确认的敏感材料采用配置和人工审核, + * 不擅自永久保存或删除」。因此本服务只提供人工审核的处置工具: + * {@code listDueAccounts()} 列出保留期已满的注销账号,由持 + * {@code account.retention.purge} 权限(V049,仅授 super_admin)的运营确认后 + * 调用 {@code purgeExpired()} 执行 —— 不引入定时自动删除。 + * + *

处置内容(断开 PII 关联,保留统计与审计所需最小信息)

+ *
    + *
  1. {@code users.wechat_openid} → {@code purged-}(不可再登录、不可回连微信身份; + * phone 清空。列有 UNIQUE 约束,purged 前缀 + userId 保证唯一);
  2. + *
  3. {@code user_profiles}:displayName → 「已注销用户」、selfIntro 清空 + * (性别/年龄段等非直接标识符保留,供匿名统计);
  4. + *
  5. {@code user_photos} / {@code local_eligibility_evidences}:全部软删;
  6. + *
  7. 审计:{@code audit_logs} 记 {@code DATA_RETENTION_PURGE}。
  8. + *
+ * + *

如实登记的边界:佐证/照片的对象存储文件本体不在此处删除 + * (需要对象存储客户端与逐文件凭证),由对象存储生命周期规则兜底 —— 登记于 + * {@code docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md} 批 3。 + * 咨询摘要/正文已在注销时脱敏({@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> listDueAccounts() { + Instant now = Instant.now(); + List closed = userMapper.selectList( + new LambdaQueryWrapper() + .eq(User::getAccountStatus, Constants.ACCOUNT_STATUS_CLOSED) + .isNotNull(User::getClosedAt) + .isNull(BaseEntity::getDeletedAt)); + List> 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 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 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 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() + .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() + .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().eq(UserPhoto::getUserId, userId))) { + photo.setDeletedAt(now); + userPhotoMapper.updateById(photo); + } + localEligibilityMapper.findByUserId(userId).ifPresent(el -> { + for (LocalEligibilityEvidence evidence : evidenceMapper.selectList( + new LambdaQueryWrapper() + .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; + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/shared/config/AccountConfig.java b/backend/src/main/java/com/danyang/match/platform/shared/config/AccountConfig.java new file mode 100644 index 0000000..5fe69af --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/shared/config/AccountConfig.java @@ -0,0 +1,31 @@ +package com.danyang.match.platform.shared.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 账户生命周期配置。 + * + *

配置化纪律(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; + } +} diff --git a/backend/src/main/java/com/danyang/match/platform/user/entity/User.java b/backend/src/main/java/com/danyang/match/platform/user/entity/User.java index 6005bb6..1113206 100644 --- a/backend/src/main/java/com/danyang/match/platform/user/entity/User.java +++ b/backend/src/main/java/com/danyang/match/platform/user/entity/User.java @@ -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; } diff --git a/backend/src/main/resources/db/migration/V049__add_account_retention.sql b/backend/src/main/resources/db/migration/V049__add_account_retention.sql new file mode 100644 index 0000000..c0757e0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V049__add_account_retention.sql @@ -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; diff --git a/backend/src/test/java/com/danyang/match/platform/account/DataRetentionGuardTest.java b/backend/src/test/java/com/danyang/match/platform/account/DataRetentionGuardTest.java new file mode 100644 index 0000000..c99511a --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/account/DataRetentionGuardTest.java @@ -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 产品决策落地)。 + * + *

决策

+ * 注销后数据保留不得低于 36 个月(产品 2026-09-19 确认;隐私政策 §3.3 原文 + * 「30 天内删除」已同步修订)。到期后走人工审核处置 —— 本守卫钉住三条不变量: + *
    + *
  1. 下限不可提前:保留期内的注销账号既不进到期清单,purge 被服务端拒绝;
  2. + *
  3. 到期可处置:到期账号进入清单,purge 后 PII 关联断开 + * (openid 匿名、phone 清空、资料脱敏、照片/佐证软删)且审计落库;
  4. + *
  5. 幂等:重复 purge 不产生第二次审计与写入。
  6. + *
+ * 负向验证已做:摘掉 {@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() + .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> 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> due = dataRetentionService.listDueAccounts(); + Map 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 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 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), + "仅限已注销账号"); + } +} diff --git a/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java b/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java index 63646ed..8d42d73 100644 --- a/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java +++ b/backend/src/test/java/com/danyang/match/platform/deployment/EntityMigrationParityTest.java @@ -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" 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 af3ff0f..8f5a889 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 @@ -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/"; diff --git a/backend/src/test/resources/schema-h2.sql b/backend/src/test/resources/schema-h2.sql index 649e36d..cbe5b4e 100644 --- a/backend/src/test/resources/schema-h2.sql +++ b/backend/src/test/resources/schema-h2.sql @@ -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,