fix(account-lifecycle): close Phase 21 KEEP-VERIFY — 6 real defects + fake-green tests
KEEP-VERIFY re-audit of account lifecycle (EXTEND): STATUS marked ✅ but contained hard Gate violations and dead code. Real defects fixed: - D-21-A: AuthService.checkAccountStatus did not reject CLOSED → closed account could still log in (Gate ❌→✅); added CLOSED branch before BANNED/SUSPENDED. - D-21-B: notifyActiveMatches was a TODO stub → active Matches/conversations not closed on withdrawal (Gate ❌→✅); now finds active matches, sets UNMATCHED + closes conversation via chatService.closeConversationForMatch. - D-21-C: cancelPendingActivityRegistrations was a TODO stub → future activity registrations not cancelled (Gate ❌→✅); now cancels pending/approved. - D-21-D: anonymizeServiceHistory existed but was never wired into closeAccount → personal identity + counseling text leaked (Gate ❌→✅); wired in, anonymizes id_number/real_name with placeholder, plus counseling_followups content. - D-21-E: closeAccount hard-threw on blockers → all cancelXxx unreachable (dead code contradiction); changed to informational log + continue cleanup. - D-21-F: 7× assertTrue(true) fake-green tests → replaced with 11 real DB integration tests + 3 login-guard tests (Redis-decoupled via ReflectionTestUtils). Cross-module defenses (RecommendationEngine/MatchEligibility/Capability reject non-NORMAL) confirmed effective. Remaining (no silent scope creep): matchmaker in-progress interception still hardcodes 0 (no table), data retention delete flow is a product decision, privacy-settings endpoint stub. Full regression: 650 tests / 0 failures / 0 errors / 3 skipped (baseline 648, +2).
This commit is contained in:
+89
-12
@@ -21,8 +21,15 @@ import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
|
||||
import com.danyang.match.platform.service_program.entity.ServiceProgramMember;
|
||||
import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper;
|
||||
import com.danyang.match.platform.chat.service.ChatService;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingFollowup;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingFollowupMapper;
|
||||
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
|
||||
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
|
||||
import com.danyang.match.platform.match.entity.Match;
|
||||
import com.danyang.match.platform.match.mapper.MatchMapper;
|
||||
import com.danyang.match.platform.verification.entity.UserIdentity;
|
||||
import com.danyang.match.platform.verification.mapper.UserIdentityMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -72,6 +79,10 @@ public class AccountLifecycleService {
|
||||
private final CounselingServiceMapper counselingServiceMapper;
|
||||
private final ServiceProgramMemberMapper serviceProgramMemberMapper;
|
||||
private final LocalEligibilityMapper localEligibilityMapper;
|
||||
private final MatchMapper matchMapper;
|
||||
private final ChatService chatService;
|
||||
private final UserIdentityMapper userIdentityMapper;
|
||||
private final CounselingFollowupMapper counselingFollowupMapper;
|
||||
|
||||
// ==================== 退出婚恋服务(暂停推荐) ====================
|
||||
|
||||
@@ -235,10 +246,16 @@ public class AccountLifecycleService {
|
||||
throw new RuntimeException("用户不存在");
|
||||
}
|
||||
|
||||
// 1. 检查是否可以注销
|
||||
// 1. 注销前检查(信息性,不阻断):让 GET /can-withdraw 能把待处理业务展示给用户/运营。
|
||||
// 依据 AGENTS「注销必须在线便捷、不设置障碍」与计划 520「注销事务关闭未来服务」,
|
||||
// 这里**不再抛异常阻断**——closeAccount 会自行取消/退出/失效这些业务。
|
||||
// 早期实现在此抛异常,导致下方 cancelXxx / invalidateLocalEligibility 的清理逻辑
|
||||
// 永远不可达(死代码),且给用户注销制造了「需先手动取消所有业务」的障碍。
|
||||
Map<String, Object> checkResult = checkCanWithdraw(userId);
|
||||
if (!(boolean) checkResult.get("canWithdraw")) {
|
||||
throw new RuntimeException("存在进行中的业务,无法注销:" + checkResult.get("blockers"));
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> blockers = (Map<String, Object>) checkResult.get("blockers");
|
||||
if (blockers != null && !blockers.isEmpty()) {
|
||||
log.info("注销时存在进行中业务(将自动关闭): userId={}, blockers={}", userId, blockers.keySet());
|
||||
}
|
||||
|
||||
// 2. 停止所有进行中的业务
|
||||
@@ -271,6 +288,9 @@ public class AccountLifecycleService {
|
||||
|
||||
// 3. 敏感数据脱敏(保留审计所需的最小信息)
|
||||
sanitizeUserData(user.getId());
|
||||
// 3b. 服务历史(含咨询正文)脱敏 —— 此前 P21-T05 的实现从未被调用(死代码),
|
||||
// 导致注销后咨询摘要/正文仍以明文留存,违反 Gate「不反向暴露个人和咨询正文」。
|
||||
anonymizeServiceHistory(user.getId());
|
||||
|
||||
// 4. 记录退出时间
|
||||
Instant now = Instant.now();
|
||||
@@ -347,11 +367,28 @@ public class AccountLifecycleService {
|
||||
// ==================== 私有辅助方法 ====================
|
||||
|
||||
/**
|
||||
* 通知所有活跃 Match 对方账号已关闭
|
||||
* 注销时关闭用户所有活跃 Match 并通知对方。
|
||||
*
|
||||
* <p>这是 Gate「注销后不能聊天」的核心机制:仅仅把 {@code accountStatus} 置为
|
||||
* {@code closed} 并不足以阻止聊天 —— 既有的 active Match 仍会让对方看到可聊入口。
|
||||
* 因此这里把每条 active/getting_to_know 的 Match 置为 {@code unmatched}(与 Unmatch 同态),
|
||||
* 并通过 {@link ChatService#closeConversationForMatch} 触发 {@code conversation.closed}
|
||||
* 事件,让对方实时灰显、发送时被 {@code assertMatchAlive} 拦截。
|
||||
*
|
||||
* <p>此前该方法是 TODO 桩(只写日志),注销后 Match 仍 active、对方完全不知情 →
|
||||
* 用户已注销却还能被匹配对象继续聊天,Gate 形同虚设。
|
||||
*/
|
||||
private void notifyActiveMatches(String userId, String reason) {
|
||||
// TODO: 查询该用户的所有 active match,通知对方
|
||||
log.info("通知 Match 对方账号关闭: userId={}, reason={}", userId, reason);
|
||||
List<Match> matches = matchMapper.findActiveMatchesByUser(userId);
|
||||
int closed = 0;
|
||||
for (Match m : matches) {
|
||||
m.setStatus(Constants.MATCH_STATUS_UNMATCHED);
|
||||
m.setUnmatchedAt(Instant.now());
|
||||
matchMapper.updateById(m);
|
||||
chatService.closeConversationForMatch(m.getId(), Constants.CONVERSATION_STATUS_UNMATCHED);
|
||||
closed++;
|
||||
}
|
||||
log.info("注销关闭活跃 Match 并通知对方: userId={}, closed={}, reason={}", userId, closed, reason);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -363,16 +400,36 @@ public class AccountLifecycleService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消所有未开始的活动报名
|
||||
* 注销时取消用户所有进行中的活动报名(pending/approved)。
|
||||
*
|
||||
* <p>计划 520 要求注销事务「关闭未来服务」——活动报名属于未来服务,若不取消,
|
||||
* 已注销用户仍停留在活动名单、可能收到签到提醒或现场被识别。
|
||||
* 此前该方法是 TODO 桩(只写日志),报名完全不被取消。
|
||||
*
|
||||
* <p>注意:{@code ActivityRegistration} 无 {@code deleted_at} 列(D12 软删过滤不适用),
|
||||
* 且既有 {@code findByUserId} 带 {@code LIMIT 20} 不足以覆盖全部报名,故用
|
||||
* {@code selectList} 全量查询后逐条置 {@code cancelled}。
|
||||
*/
|
||||
private void cancelPendingActivityRegistrations(String userId) {
|
||||
// TODO: 查询 activity_registrations 中 pending/approved 且活动未开始的记录,将其状态设为 cancelled
|
||||
log.info("取消用户活动报名: userId={}", userId);
|
||||
List<ActivityRegistration> regs = registrationMapper.selectList(
|
||||
new LambdaQueryWrapper<ActivityRegistration>()
|
||||
.eq(ActivityRegistration::getUserId, userId)
|
||||
.in(ActivityRegistration::getStatus,
|
||||
Constants.REGISTRATION_STATUS_PENDING,
|
||||
Constants.REGISTRATION_STATUS_APPROVED));
|
||||
int cancelled = 0;
|
||||
for (ActivityRegistration reg : regs) {
|
||||
reg.setStatus(Constants.REGISTRATION_STATUS_CANCELLED);
|
||||
reg.setUpdatedAt(Instant.now());
|
||||
registrationMapper.updateById(reg);
|
||||
cancelled++;
|
||||
}
|
||||
log.info("取消用户活动报名: userId={}, cancelled={}", userId, cancelled);
|
||||
}
|
||||
|
||||
/**
|
||||
* 敏感数据脱敏
|
||||
* 核心:身份证/手机号脱敏,保留审计所需的最小信息
|
||||
* 核心:身份证/手机号/实名姓名脱敏,保留审计所需的最小信息
|
||||
*/
|
||||
private void sanitizeUserData(String userId) {
|
||||
// 脱敏手机号
|
||||
@@ -387,7 +444,14 @@ public class AccountLifecycleService {
|
||||
}
|
||||
userMapper.updateById(user);
|
||||
}
|
||||
// TODO: 同步更新 user_identities 中的身份证号码
|
||||
// 脱敏身份证号与姓名(user_identities):两列均为 TEXT NOT NULL,
|
||||
// 不能以 null 删除,只能覆盖为不可还原的占位符(原文为密文,部分打码无意义)。
|
||||
// 此前此处是 TODO,导致注销后身份证明文(密文)永久留存。
|
||||
userIdentityMapper.findByUserId(userId).ifPresent(identity -> {
|
||||
identity.setIdNumberEnc("***ACCOUNT_CLOSED***");
|
||||
identity.setRealNameEnc("***ACCOUNT_CLOSED***");
|
||||
userIdentityMapper.updateById(identity);
|
||||
});
|
||||
log.info("敏感数据脱敏: userId={}", userId);
|
||||
}
|
||||
|
||||
@@ -462,8 +526,21 @@ public class AccountLifecycleService {
|
||||
private void anonymizeServiceHistory(String userId) {
|
||||
List<CounselingServiceRecord> services = counselingServiceMapper.findByUserId(userId);
|
||||
for (CounselingServiceRecord cs : services) {
|
||||
if (cs.getUserRequestSummary() != null && cs.getUserRequestSummary().length() > 10) {
|
||||
boolean changed = false;
|
||||
if (cs.getUserRequestSummary() != null && !cs.getUserRequestSummary().isEmpty()) {
|
||||
cs.setUserRequestSummary("***已脱敏***");
|
||||
changed = true;
|
||||
}
|
||||
// 咨询正文在独立的 counseling_followups 表(INV-013 保护的敏感正文),
|
||||
// 仅脱敏主表摘要会漏掉逐条跟进记录 → 注销后咨询详情仍明文留存。
|
||||
List<CounselingFollowup> followups = counselingFollowupMapper.findByServiceId(cs.getId());
|
||||
for (CounselingFollowup f : followups) {
|
||||
if (f.getContent() != null && !f.getContent().isEmpty()) {
|
||||
f.setContent("***已脱敏***");
|
||||
counselingFollowupMapper.updateById(f);
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
counselingServiceMapper.updateById(cs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +205,9 @@ public class AuthService {
|
||||
}
|
||||
|
||||
private void checkAccountStatus(User user) {
|
||||
if (Constants.ACCOUNT_STATUS_CLOSED.equals(user.getAccountStatus())) {
|
||||
throw new RuntimeException("账号已注销");
|
||||
}
|
||||
if (Constants.ACCOUNT_STATUS_BANNED.equals(user.getAccountStatus())) {
|
||||
throw new RuntimeException("账号已被封禁");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user