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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user