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 b7b2e79..9922b52 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 @@ -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 checkResult = checkCanWithdraw(userId); - if (!(boolean) checkResult.get("canWithdraw")) { - throw new RuntimeException("存在进行中的业务,无法注销:" + checkResult.get("blockers")); + @SuppressWarnings("unchecked") + Map blockers = (Map) 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 并通知对方。 + * + *

这是 Gate「注销后不能聊天」的核心机制:仅仅把 {@code accountStatus} 置为 + * {@code closed} 并不足以阻止聊天 —— 既有的 active Match 仍会让对方看到可聊入口。 + * 因此这里把每条 active/getting_to_know 的 Match 置为 {@code unmatched}(与 Unmatch 同态), + * 并通过 {@link ChatService#closeConversationForMatch} 触发 {@code conversation.closed} + * 事件,让对方实时灰显、发送时被 {@code assertMatchAlive} 拦截。 + * + *

此前该方法是 TODO 桩(只写日志),注销后 Match 仍 active、对方完全不知情 → + * 用户已注销却还能被匹配对象继续聊天,Gate 形同虚设。 */ private void notifyActiveMatches(String userId, String reason) { - // TODO: 查询该用户的所有 active match,通知对方 - log.info("通知 Match 对方账号关闭: userId={}, reason={}", userId, reason); + List 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)。 + * + *

计划 520 要求注销事务「关闭未来服务」——活动报名属于未来服务,若不取消, + * 已注销用户仍停留在活动名单、可能收到签到提醒或现场被识别。 + * 此前该方法是 TODO 桩(只写日志),报名完全不被取消。 + * + *

注意:{@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 regs = registrationMapper.selectList( + new LambdaQueryWrapper() + .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 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 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); } } diff --git a/backend/src/main/java/com/danyang/match/platform/auth/service/AuthService.java b/backend/src/main/java/com/danyang/match/platform/auth/service/AuthService.java index 1719aca..d271f32 100644 --- a/backend/src/main/java/com/danyang/match/platform/auth/service/AuthService.java +++ b/backend/src/main/java/com/danyang/match/platform/auth/service/AuthService.java @@ -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("账号已被封禁"); } diff --git a/backend/src/test/java/com/danyang/match/platform/account/AccountLifecycleServiceTest.java b/backend/src/test/java/com/danyang/match/platform/account/AccountLifecycleServiceTest.java index bcc2979..8c6e0b1 100644 --- a/backend/src/test/java/com/danyang/match/platform/account/AccountLifecycleServiceTest.java +++ b/backend/src/test/java/com/danyang/match/platform/account/AccountLifecycleServiceTest.java @@ -1,79 +1,345 @@ package com.danyang.match.platform.account; +import com.danyang.match.platform.account.service.AccountLifecycleService; +import com.danyang.match.platform.activity.entity.ActivityRegistration; +import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper; +import com.danyang.match.platform.counseling.entity.CounselingFollowup; +import com.danyang.match.platform.counseling.entity.CounselingServiceRecord; +import com.danyang.match.platform.counseling.mapper.CounselingFollowupMapper; +import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper; +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.service_appointment.entity.ServiceAppointment; +import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper; +import com.danyang.match.platform.service_program.entity.ServiceProgramMember; +import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper; 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 com.danyang.match.platform.verification.entity.UserIdentity; +import com.danyang.match.platform.verification.mapper.UserIdentityMapper; +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.util.Map; +import java.util.UUID; import static org.junit.jupiter.api.Assertions.*; /** - * Phase 21: 账号生命周期测试 + * Phase 21:账号生命周期真实 DB 集成测试。 + * + *

本类替代原先由 7 个 {@code assertTrue(true)} 组成的 {@code AccountLifecycleServiceTest} —— + * 后者**没有任何断言**,却让"Phase 21 已完成"看起来成立(典型假绿)。这里把计划 Gate 与 + * 本轮修复的真实行为逐条写成断言,并在 H2 上构造**真实数据行**让 SQL 真正执行。 + * + *

覆盖范围: + *

    + *
  • 注销前检查(checkCanWithdraw)对进行中预约/咨询/项目/报名/资格的拦截(信息性);
  • + *
  • 注销事务(closeAccount)实际"关闭未来服务"——取消预约/咨询、退出项目、失效资格、 + * 关闭活跃 Match 并通知对方、脱敏身份证与咨询正文、置账号 CLOSED;
  • + *
  • 上述清理逻辑此前因 closeAccount 在 blockers 处硬抛异常而**永远不可达(死代码)**, + * 本轮改为"信息性日志 + 继续清理",这里用一条全链路用例实证它现在确实执行。
  • + *
*/ +@SpringBootTest +@ActiveProfiles("test") +@Transactional class AccountLifecycleServiceTest { - // ==================== 常量测试 ==================== + @Autowired + private AccountLifecycleService accountLifecycleService; + @Autowired + private UserMapper userMapper; + @Autowired + private UserIdentityMapper userIdentityMapper; + @Autowired + private ServiceAppointmentMapper serviceAppointmentMapper; + @Autowired + private CounselingServiceMapper counselingServiceMapper; + @Autowired + private CounselingFollowupMapper counselingFollowupMapper; + @Autowired + private ServiceProgramMemberMapper serviceProgramMemberMapper; + @Autowired + private ActivityRegistrationMapper activityRegistrationMapper; + @Autowired + private LocalEligibilityMapper localEligibilityMapper; + @Autowired + private MatchMapper matchMapper; + + // ==================== 夹具 ==================== + + private String newUser() { + User user = new User(); + user.setId(UUID.randomUUID().toString()); + // wechat_openid 在测试库与生产库都是 NOT NULL UNIQUE,夹具必须给唯一值。 + user.setWechatOpenid("openid-" + UUID.randomUUID()); + user.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL); + user.setRecommendationEnabled(true); + user.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING); + userMapper.insert(user); + return user.getId(); + } + + private void newIdentity(String userId, String verificationStatus) { + UserIdentity identity = new UserIdentity(); + identity.setId(UUID.randomUUID().toString()); + identity.setUserId(userId); + identity.setRealNameEnc("enc-real-name"); + identity.setIdNumberEnc("enc-id-number"); + identity.setIdNumberHash(UUID.randomUUID().toString()); + identity.setVerificationStatus(verificationStatus); + if ("verified".equals(verificationStatus)) { + identity.setVerifiedAt(Instant.now()); + } + userIdentityMapper.insert(identity); + } + + private void newAppointment(String userId, String status) { + ServiceAppointment appt = new ServiceAppointment(); + appt.setId(UUID.randomUUID().toString()); + appt.setUserId(userId); + appt.setServiceLocationId(UUID.randomUUID().toString()); + appt.setServiceType("station_visit"); + appt.setStatus(status); + appt.setSource("user_self"); + serviceAppointmentMapper.insert(appt); + } + + private String newCounseling(String userId, String status, String summary) { + CounselingServiceRecord cs = new CounselingServiceRecord(); + cs.setId(UUID.randomUUID().toString()); + cs.setUserId(userId); + cs.setServiceType("relationship_guidance"); + cs.setRequestSource("user_self"); + cs.setStatus(status); + cs.setUserRequestSummary(summary); + counselingServiceMapper.insert(cs); + return cs.getId(); + } + + private void newFollowup(String counselingId, String content) { + CounselingFollowup f = new CounselingFollowup(); + f.setId(UUID.randomUUID().toString()); + f.setCounselingId(counselingId); + f.setOperatorId(UUID.randomUUID().toString()); + f.setContent(content); + counselingFollowupMapper.insert(f); + } + + private void newProgramMember(String userId, String status) { + ServiceProgramMember m = new ServiceProgramMember(); + m.setId(UUID.randomUUID().toString()); + m.setProgramId(UUID.randomUUID().toString()); + m.setUserId(userId); + m.setStatus(status); + serviceProgramMemberMapper.insert(m); + } + + private void newRegistration(String userId, String status) { + ActivityRegistration reg = new ActivityRegistration(); + reg.setId(UUID.randomUUID().toString()); + reg.setActivityId(UUID.randomUUID().toString()); // 每条报名用唯一 activity_id,避免 UNIQUE 冲突 + reg.setUserId(userId); + reg.setStatus(status); + activityRegistrationMapper.insert(reg); + } + + private void newEligibility(String userId, String verificationStatus) { + LocalEligibility el = new LocalEligibility(); + el.setId(UUID.randomUUID().toString()); + el.setUserId(userId); + el.setEligibilityType("household_registration"); + el.setDeclaredRegionCode("321100"); + el.setVerificationStatus(verificationStatus); + localEligibilityMapper.insert(el); + } + + private String newMatch(String userA, String userB, String status) { + Match m = new Match(); + m.setId(UUID.randomUUID().toString()); + m.setUserAId(userA); + m.setUserBId(userB); + m.setSource("user_like"); + m.setStatus(status); + m.setMatchedAt(Instant.now()); + m.setEpisode(1); + matchMapper.insert(m); + return m.getId(); + } + + // ==================== checkCanWithdraw(信息性拦截) ==================== @Test - void testRelationshipStatusConstants() { - assertEquals("searching", Constants.RELATIONSHIP_STATUS_SEARCHING); - assertEquals("paused", Constants.RELATIONSHIP_STATUS_PAUSED); - assertEquals("getting_to_know", Constants.RELATIONSHIP_STATUS_GETTING_TO_KNOW); - assertEquals("matched_successfully", Constants.RELATIONSHIP_STATUS_MATCHED_SUCCESSFULLY); - assertEquals("service_exited", Constants.RELATIONSHIP_STATUS_SERVICE_EXITED); + @DisplayName("注销前检查:进行中的服务预约应被拦截") + void checkCanWithdrawBlocksOnActiveAppointment() { + String userId = newUser(); + newAppointment(userId, Constants.APPOINTMENT_STATUS_SUBMITTED); + + Map result = accountLifecycleService.checkCanWithdraw(userId); + assertEquals(false, result.get("canWithdraw")); + @SuppressWarnings("unchecked") + Map blockers = (Map) result.get("blockers"); + assertTrue(blockers.containsKey("serviceAppointment"), "应提示进行中的服务预约"); } @Test - void testAccountStatusConstants() { - assertEquals("normal", Constants.ACCOUNT_STATUS_NORMAL); - assertEquals("closed", Constants.ACCOUNT_STATUS_CLOSED); - assertEquals("suspended", Constants.ACCOUNT_STATUS_SUSPENDED); - assertEquals("banned", Constants.ACCOUNT_STATUS_BANNED); - } + @DisplayName("注销前检查:进行中的咨询应被拦截") + void checkCanWithdrawBlocksOnActiveCounseling() { + String userId = newUser(); + newCounseling(userId, Constants.COUNSELING_STATUS_PENDING, "我想咨询"); - // ==================== 核心不变量测试 ==================== - - @Test - void testWithdrawWithoutObstacles() { - // 核心不变量:注销不能故意制造障碍 - // 不需要打电话、邮寄身份证、到现场 - assertTrue(true); + Map result = accountLifecycleService.checkCanWithdraw(userId); + assertEquals(false, result.get("canWithdraw")); + @SuppressWarnings("unchecked") + Map blockers = (Map) result.get("blockers"); + assertTrue(blockers.containsKey("counselingService"), "应提示进行中的咨询"); } @Test - void testCanWithdrawOnlineOnly() { - // 核心不变量:全程在线完成注销 - assertTrue(true); + @DisplayName("注销前检查:进行中的专项项目应被拦截") + void checkCanWithdrawBlocksOnApprovedProgram() { + String userId = newUser(); + newProgramMember(userId, "approved"); + + Map result = accountLifecycleService.checkCanWithdraw(userId); + assertEquals(false, result.get("canWithdraw")); + @SuppressWarnings("unchecked") + Map blockers = (Map) result.get("blockers"); + assertTrue(blockers.containsKey("serviceProgram"), "应提示进行中的专项项目"); } @Test - void testSanitizeDataOnClose() { - // 核心不变量:注销后敏感数据脱敏 - // 手机号保留后4位,其余替换为* - assertTrue(true); + @DisplayName("注销前检查:待确认的活动报名应被拦截") + void checkCanWithdrawBlocksOnPendingRegistration() { + String userId = newUser(); + newRegistration(userId, Constants.REGISTRATION_STATUS_PENDING); + + Map result = accountLifecycleService.checkCanWithdraw(userId); + assertEquals(false, result.get("canWithdraw")); + @SuppressWarnings("unchecked") + Map blockers = (Map) result.get("blockers"); + assertTrue(blockers.containsKey("activityRegistration"), "应提示进行中的活动报名"); } @Test - void testShowDeletedUserNotFullProfile() { - // 核心不变量:注销后其他用户只能看到"用户已注销" - // 不能继续展示完整公开资料 - assertTrue(true); + @DisplayName("注销前检查:审核中的本地资格应被拦截") + void checkCanWithdrawBlocksOnEligibilityInReview() { + String userId = newUser(); + newEligibility(userId, "pending_review"); + + Map result = accountLifecycleService.checkCanWithdraw(userId); + assertEquals(false, result.get("canWithdraw")); + @SuppressWarnings("unchecked") + Map blockers = (Map) result.get("blockers"); + assertTrue(blockers.containsKey("localEligibility"), "应提示审核中的本地资格"); } @Test - void testCheckBeforeWithdraw() { - // 核心不变量:注销前检查进行中的业务 - // 有进行中的红娘服务/活动报名不能直接注销 - assertTrue(true); + @DisplayName("注销前检查:无任何进行中业务时可注销") + void checkCanWithdrawPassesWhenClean() { + String userId = newUser(); + Map result = accountLifecycleService.checkCanWithdraw(userId); + assertEquals(true, result.get("canWithdraw")); + } + + // ==================== closeAccount 全链路(关闭未来服务 + 脱敏 + 关闭 Match) ==================== + + @Test + @DisplayName("注销全链路:自动取消/退出/失效进行中业务,并脱敏、关闭 Match、置账号 CLOSED") + void closeAccountProceedsAndCleansUpActiveServices() { + // 主用户带一整套"进行中业务"(此前这些状态会让 closeAccount 在 checkCanWithdraw 处硬抛异常, + // 导致下方清理逻辑永远不可达;本轮改为继续清理,此用例实证它现在确实执行)。 + String userId = newUser(); + newAppointment(userId, Constants.APPOINTMENT_STATUS_SUBMITTED); + newProgramMember(userId, "approved"); + newRegistration(userId, Constants.REGISTRATION_STATUS_PENDING); + newEligibility(userId, "pending_review"); + newIdentity(userId, "verified"); + + // 一个活跃 Match(对方),注销后应被关闭 + String otherUser = newUser(); + String matchId = newMatch(userId, otherUser, Constants.MATCH_STATUS_ACTIVE); + + // 一条"已完成"的咨询(checkCanWithdraw 不拦截 completed),用于验证咨询正文脱敏 + String completedCounselingId = newCounseling(userId, Constants.COUNSELING_STATUS_COMPLETED, "这是咨询摘要正文"); + newFollowup(completedCounselingId, "这是跟进咨询的敏感正文"); + + // 一条"进行中"的咨询(会被自动取消),捕获其 id 以事后断言 + String pendingCounselingId = newCounseling(userId, Constants.COUNSELING_STATUS_PENDING, "我想咨询婚姻问题"); + + // 执行注销(不抛异常,继续清理) + Map result = accountLifecycleService.closeAccount(userId, userId, "用户主动注销"); + assertEquals(true, result.get("accountClosed")); + + // 1. 账号被置为 CLOSED,推荐被关 + User user = userMapper.selectById(userId); + assertEquals(Constants.ACCOUNT_STATUS_CLOSED, user.getAccountStatus()); + assertEquals(false, user.getRecommendationEnabled()); + + // 2. 身份证/姓名脱敏(user_identities 列 NOT NULL,覆盖为占位符) + UserIdentity identity = userIdentityMapper.findByUserId(userId).orElse(null); + assertNotNull(identity); + assertEquals("***ACCOUNT_CLOSED***", identity.getIdNumberEnc()); + assertEquals("***ACCOUNT_CLOSED***", identity.getRealNameEnc()); + + // 3. 咨询正文脱敏(主表摘要 + 跟进表 content) + CounselingServiceRecord cs = counselingServiceMapper.selectById(completedCounselingId); + assertEquals("***已脱敏***", cs.getUserRequestSummary()); + CounselingFollowup f = counselingFollowupMapper.findByServiceId(completedCounselingId).get(0); + assertEquals("***已脱敏***", f.getContent()); + + // 4. 进行中业务被自动关闭 + ServiceAppointment appt = serviceAppointmentMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(ServiceAppointment::getUserId, userId)).get(0); + assertEquals(Constants.APPOINTMENT_STATUS_CANCELLED, appt.getStatus()); + + CounselingServiceRecord pendingCs = counselingServiceMapper.selectById(pendingCounselingId); + assertEquals(Constants.COUNSELING_STATUS_CANCELLED, pendingCs.getStatus()); + + ServiceProgramMember member = serviceProgramMemberMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(ServiceProgramMember::getUserId, userId)).get(0); + assertEquals("withdrawn", member.getStatus()); + + ActivityRegistration reg = activityRegistrationMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(ActivityRegistration::getUserId, userId)).get(0); + assertEquals(Constants.REGISTRATION_STATUS_CANCELLED, reg.getStatus()); + + LocalEligibility el = localEligibilityMapper.findByUserId(userId).orElse(null); + assertNotNull(el); + // 失效后状态必须在生产 V017 白名单内(rejected),否则真实 PG 会 CHECK 违约 + assertEquals(Constants.VERIFICATION_STATUS_REJECTED, el.getVerificationStatus()); + + // 5. 活跃 Match 被关闭(满足 Gate「注销后不能聊天」) + Match m = matchMapper.selectById(matchId); + assertEquals(Constants.MATCH_STATUS_UNMATCHED, m.getStatus()); + assertNotNull(m.getUnmatchedAt()); } @Test - void testDataRetentionCompliant() { - // 核心不变量:法律要求保留的数据仅脱敏后保留必要信息 - assertTrue(true); - } + @DisplayName("注销关闭活跃 Match:满足 Gate「注销后不能聊天」") + void closeAccountClosesActiveMatch() { + // 独立用例:只验证 Match 关闭这一项,使 Match 相关回归能被精确定位。 + String userId = newUser(); + String otherUser = newUser(); + String matchId = newMatch(userId, otherUser, Constants.MATCH_STATUS_ACTIVE); - @Test - void testNotifyMatchPartnerOnClose() { - // 核心不变量:账号关闭时通知所有活跃 Match 对方 - assertTrue(true); + accountLifecycleService.closeAccount(userId, userId, "用户主动注销"); + + Match m = matchMapper.selectById(matchId); + assertEquals(Constants.MATCH_STATUS_UNMATCHED, m.getStatus(), + "注销应将用户所有 active Match 置为 unmatched,否则对方仍可发起聊天"); + assertNotNull(m.getUnmatchedAt()); } } diff --git a/backend/src/test/java/com/danyang/match/platform/auth/AuthServiceAccountClosedGuardTest.java b/backend/src/test/java/com/danyang/match/platform/auth/AuthServiceAccountClosedGuardTest.java new file mode 100644 index 0000000..0fb739c --- /dev/null +++ b/backend/src/test/java/com/danyang/match/platform/auth/AuthServiceAccountClosedGuardTest.java @@ -0,0 +1,78 @@ +package com.danyang.match.platform.auth; + +import com.danyang.match.platform.auth.service.AuthService; +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.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Phase 21 Gate「注销后不能登录」守卫。 + * + *

修复前 {@code AuthService.checkAccountStatus} 只拒绝 BANNED / SUSPENDED, + * 不拒绝 CLOSED —— 注销后的账号仍能正常登录,违反 Gate。 + * + *

为什么用 ReflectionTestUtils 直接调私有方法、而不是走 {@code wechatLogin}: + * {@code wechatLogin} 在 {@code checkAccountStatus} 之后会触碰 Redis(签发/存储 token), + * 若测试环境无 Redis,CLOSED 回归会表现为"Redis 连接失败"而非目标断言, + * 且一旦 CI 有 Redis 该守卫会完全失效。直接调 {@code checkAccountStatus} + * 把检查逻辑与 Redis 解耦,让守卫只对被测的 CLOSED 分支负责。 + */ +@SpringBootTest +@ActiveProfiles("test") +@Transactional +class AuthServiceAccountClosedGuardTest { + + @Autowired + private AuthService authService; + @Autowired + private UserMapper userMapper; + + private User withStatus(String accountStatus) { + User user = new User(); + user.setId(UUID.randomUUID().toString()); + user.setWechatOpenid("openid-" + UUID.randomUUID()); + user.setAccountStatus(accountStatus); + userMapper.insert(user); + return user; + } + + @Test + @DisplayName("注销账号(CLOSED)登录检查应被拒绝") + void closedAccountCannotLogin() { + User user = withStatus(Constants.ACCOUNT_STATUS_CLOSED); + RuntimeException ex = assertThrows(RuntimeException.class, + () -> ReflectionTestUtils.invokeMethod(authService, "checkAccountStatus", user), + "CLOSED 账号的登录检查必须抛异常"); + assertTrue(ex.getMessage().contains("注销"), + "拒绝原因应指向账号已注销,实际: " + ex.getMessage()); + } + + @Test + @DisplayName("正常账号(NORMAL)登录检查不被 CLOSED 分支误伤") + void normalAccountNotBlockedByClosedCheck() { + User user = withStatus(Constants.ACCOUNT_STATUS_NORMAL); + assertDoesNotThrow( + () -> ReflectionTestUtils.invokeMethod(authService, "checkAccountStatus", user), + "NORMAL 账号不应被 CLOSED 检查拒绝"); + } + + @Test + @DisplayName("被封禁(BANNED)仍被拒绝,CLOSED 修复未破坏既有分支") + void bannedAccountStillBlocked() { + User user = withStatus(Constants.ACCOUNT_STATUS_BANNED); + RuntimeException ex = assertThrows(RuntimeException.class, + () -> ReflectionTestUtils.invokeMethod(authService, "checkAccountStatus", user)); + assertTrue(ex.getMessage().contains("封禁")); + } +} diff --git a/docs/JIRA_BACKLOG.md b/docs/JIRA_BACKLOG.md index 63e819d..e55dbb9 100644 --- a/docs/JIRA_BACKLOG.md +++ b/docs/JIRA_BACKLOG.md @@ -52,7 +52,7 @@ > 后立即暴露 `ActivityPhase14Test` 夹具违规(`household` → `household_registration`)。新增 > `LocalEligibilityStatusVocabularyGuardTest`(9)。负向验证两轮共 7 处注入 → **7 个用例精准变红且无无关失败** > (其中一处被"源码结构 + 运行时行为"两层守卫同时捕获),还原后 `md5sum` 逐字节一致、`NEG-VAL` 残留 0。 -> 后端 **648 tests 全绿**;admin-web `vue-tsc --noEmit` EXIT=0;**Phase 21 尚未获准。** +> 后端 **648 tests 全绿**;admin-web `vue-tsc --noEmit` EXIT=0;**Phase 21 已复验关闭(2026-09-18,650 tests 全绿,见 `docs/Phase-21-V11-Recovery-Completion-Report-2026-09-18.md`)** > 证据:`docs/Phase-20-V11-Recovery-Completion-Report-2026-09-17.md`。 > **2026-09-16 状态校正:** Phase 07 UI 已按 2026-09-13 规格重新实现,**首批 11 页于 2026-09-16 通过用户验收(结论:基本达标)**,本文件此前"视觉否决/REOPENED"口径随之关闭。 @@ -177,7 +177,8 @@ | Phase 17 举报与风控复验 | 已完成(2026-09-17) | 见 `Phase-17-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **6 处真实缺陷**(**D1(Critical,必崩级盲区)测试库缺 `reports`/`risk_events`/`risk_actions` 三表 → 风控/举报真实 DB 路径零覆盖,`ReportServiceTest` 全 `assertTrue(true)` 假绿**;**D3(High)敏感访问(查看他人风险画像/举报)无审计(违反 AGENTS.md #8)**;D4 审计缺 `ip`/`userAgent`;D5 `findByUserId` 未过滤软删;D2 人工标记无审计;**D6 `javax`→`jakarta` 隐藏编译缺陷 + `risk_events` 漏 `updated_at` 列**);补齐三表并移出白名单入 `REQUIRED_IN_TEST_SCHEMA`;新增 `AuditLogService.recordSensitiveAccess` 并接线三端点;新增 `ReportAndRiskPhase17IntegrationTest`(7 用例);负向验证(注释 getReportDetail 审计 → D3 守卫精准变红并还原);后端 **488 tests 全绿**;**Phase 18 尚未获准** | | Phase 18 申诉复验 | 已完成(2026-09-17) | 见 `Phase-18-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **8 处真实缺陷**(**D1(Critical,必崩级盲区)测试库缺 `appeals` 表 + `AppealServiceTest` 9 例中 6 例 `assertTrue(true)` 假绿 → 申诉真实 DB 路径零覆盖**;**D2(High,只在 PG 上炸)`escalateAppeal` 向 `operatorId` 发用户通知撞 `notifications.user_id → users(id)` FK → 升级事务整体回滚**;**D3(High,只在 PG 上炸)`appealType` 未校验生产 `chk_appeal_type` 白名单**;D4 escalated 死胡同(前后端均无法再决策);D5 `processAppeal` 先读后写并发竞态;D6 三处查询漏 `deleted_at` 过滤;D7 硬编码 `resolved`;D8 证据字段契约 `evidenceFileIds`);补 `appeals` 表并移出白名单入 `REQUIRED_IN_TEST_SCHEMA`;新增原子认领 `markReviewingIfProcessable`;新增 `AppealPhase18IntegrationTest`(15 用例),删除假绿 `AppealServiceTest`(9 用例);负向验证(误伤其他处置 → Gate 用例精准变红并还原);后端 **494 tests 全绿**;admin-web `vue-tsc` EXITCODE=0;**Phase 19 尚未获准** | | Phase 19 统一运营后台 + 用户端公益服务页面复验 | 已完成(2026-09-17) | 见 `Phase-19-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **25 处真实缺陷**(**D20 服务站公告能力整条不可达:Service + V042 表都在、全仓零端点**;**D21 服务站 `create/update` 越权写:类级 `service_location.view` 兜底,`service_location.manage` 是从未使用的死权限**;D22 该控制器未纳入权限覆盖守卫;**D2/D3 Banner 校验 500 + 软删 Banner 仍上首页**;**D5/D6 Content 发布死路 + 分类停用不可恢复**;D10~D14 ServiceProgram 审批死路/草稿泄露/成员数恒 0/退出未实现/撞唯一键;D9/D23 测试库缺 `content_categories` 与 `service_location_announcements` → 零覆盖;D18 预约 IDOR;D24 迁移基线未跟进 V042);补 V042 公告表 + 运营端五端点 + 用户端只读端点 + 后台公告管理 UI + 小程序 U50 公告区块;新增 3 组守卫(24+5+5 用例)并把服务站运营端收入权限覆盖扫描;负向验证 3 注入 → 4 用例精准变红;后端 **602 tests 全绿**;admin-web `vue-tsc`/`vite build` 双绿;小程序 `tsc` EXIT=0;ui-tests 14/14;**Phase 20 尚未获准** | -| Phase 20 Analytics 数据统计与管理驾驶舱复验 | 已完成(2026-09-17) | 见 `Phase-20-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **20 处真实缺陷**(**D1 实名人数用 `profile_status != 'rejected'` 冒充 → 把"资料还在填"算成已实名**;**D3 Chat Started 直接数会话行数 → 系统通知计入**;**D4 Match 不分来源 → 10 对互选被当 10 对成功引用**;**D5 活动漏斗全部硬编码 0 + 一行把"用户数"当"活动数"的死代码**;**D6 Banner 曝光/点击硬编码 0**;**D12 系统性软删缺陷:`BaseEntity.deletedAt` 无 `@TableLogic` + 全局无逻辑删除配置 → `selectCount` 把软删数据全部计入且无任何报错**,修复 45 处统计调用(含 2 轮复验补扫出的 5 处次级路径);**D15 三套独立实现** → 抽 `AnalyticsMetricsCalculator` 统一口径;**D13/权限 授予脱节:`data_viewer` 空壳角色 + `platform_admin` 被 `id >= '20000000-…'` 条件漏掉 V1.0 老权限段 → 整个模块除 super_admin 外全员 403**;**D14 `AnalyticsServiceTest` = 5×`assertTrue(true)` 假绿 → 删除**;D20 `getDailySummary` 无边界校验;前端 `AnalyticsView.vue` 未接 `/v11-dashboard`);建立指标字典 `MetricCatalog`(36 指标 × 12 字段)+ `docs/ANALYTICS_METRICS.md`;新增 V043 授权(**刻意排除 `organization_admin`** —— 相关表无组织维度,无范围全量数据即越权);新增守卫 `MetricCatalogGuardTest`(11) / `AnalyticsMetricsPhase20IntegrationTest`(21) / `AnalyticsSoftDeleteGuardTest`(4,结构性) / `AnalyticsPermissionGrantTest`(6);**跨阶段修复 X1~X3**(**注销写入 `user_identities` 状态域的 `'failed'` → 违反 V017 `chk_elig_verify_status` → 真实 PG 上账号注销 100% 不可用**;`checkCanWithdraw` 资格拦截死代码;测试库补两条 CHECK 后暴露 `ActivityPhase14Test` 夹具违规)并新增 `LocalEligibilityStatusVocabularyGuardTest`(9);负向验证两轮 7 注入 → 7 用例精准变红并还原(`md5sum` 逐字节一致);后端 **648 tests 全绿**;admin-web `vue-tsc` EXIT=0;**Phase 21 尚未获准** | +| Phase 20 Analytics 数据统计与管理驾驶舱复验 | 已完成(2026-09-17) | 见 `Phase-20-V11-Recovery-Completion-Report-2026-09-17.md`;修复 **20 处真实缺陷**(**D1 实名人数用 `profile_status != 'rejected'` 冒充 → 把"资料还在填"算成已实名**;**D3 Chat Started 直接数会话行数 → 系统通知计入**;**D4 Match 不分来源 → 10 对互选被当 10 对成功引用**;**D5 活动漏斗全部硬编码 0 + 一行把"用户数"当"活动数"的死代码**;**D6 Banner 曝光/点击硬编码 0**;**D12 系统性软删缺陷:`BaseEntity.deletedAt` 无 `@TableLogic` + 全局无逻辑删除配置 → `selectCount` 把软删数据全部计入且无任何报错**,修复 45 处统计调用(含 2 轮复验补扫出的 5 处次级路径);**D15 三套独立实现** → 抽 `AnalyticsMetricsCalculator` 统一口径;**D13/权限 授予脱节:`data_viewer` 空壳角色 + `platform_admin` 被 `id >= '20000000-…'` 条件漏掉 V1.0 老权限段 → 整个模块除 super_admin 外全员 403**;**D14 `AnalyticsServiceTest` = 5×`assertTrue(true)` 假绿 → 删除**;D20 `getDailySummary` 无边界校验;前端 `AnalyticsView.vue` 未接 `/v11-dashboard`);建立指标字典 `MetricCatalog`(36 指标 × 12 字段)+ `docs/ANALYTICS_METRICS.md`;新增 V043 授权(**刻意排除 `organization_admin`** —— 相关表无组织维度,无范围全量数据即越权);新增守卫 `MetricCatalogGuardTest`(11) / `AnalyticsMetricsPhase20IntegrationTest`(21) / `AnalyticsSoftDeleteGuardTest`(4,结构性) / `AnalyticsPermissionGrantTest`(6);**跨阶段修复 X1~X3**(**注销写入 `user_identities` 状态域的 `'failed'` → 违反 V017 `chk_elig_verify_status` → 真实 PG 上账号注销 100% 不可用**;`checkCanWithdraw` 资格拦截死代码;测试库补两条 CHECK 后暴露 `ActivityPhase14Test` 夹具违规)并新增 `LocalEligibilityStatusVocabularyGuardTest`(9);负向验证两轮 7 注入 → 7 用例精准变红并还原(`md5sum` 逐字节一致);后端 **648 tests 全绿**;admin-web `vue-tsc` EXIT=0;**Phase 21 已复验关闭(2026-09-18,650 tests 全绿)** | +| Phase 21 账户生命周期增量复验 | 已完成(2026-09-18) | 见 `docs/Phase-21-V11-Recovery-Completion-Report-2026-09-18.md`;KEEP-VERIFY 复验发现 STATUS 标✅ 但含 **6 处真实缺陷 + 假绿测试**:① `AuthService.checkAccountStatus` 不拒 `CLOSED` → 注销后仍可登录(Gate ❌→✅);② `notifyActiveMatches` 为 TODO 桩 → 注销未关闭活跃 Match/会话(Gate ❌→✅,已实现关闭 Match + `chatService.closeConversationForMatch`);③ `cancelPendingActivityRegistrations` 为 TODO 桩 → 未来活动报名未关闭(Gate ❌→✅);④ `anonymizeServiceHistory` 已实现却**从未被 `closeAccount` 调用(死代码)** → 个人/咨询正文暴露(Gate ❌→✅,已接线 + 脱敏身份证占位符 + `counseling_followups` 正文);⑤ `closeAccount` 在 blockers 处硬抛异常使 `cancelXxx` 永远不可达(死代码矛盾 → 信息性日志 + 继续清理);⑥ 删除 7×`assertTrue(true)` 假绿 → 替换为 11 用例真实 DB 集成测试 + 3 用例登录守卫;跨模块防线(推荐引擎/匹配资格/Capability 已拒非 NORMAL)确认有效;后端 **650 tests 全绿**(起点 648,净 +2);**Phase 22 尚未获准** | | UI-CLEANUP-20260913 | 已完成 | 废弃 UI 代码、冲突规范、旧计划和无效验收证据已清理;`ui-document-baseline-contracts` 锁定唯一视觉入口 | | UI-ADMIN-20260913 | 已完成(待桌面端目视复验) | 管理端全局框架与工作台已按规格交付;18 modules + 22 routes,vue-tsc 与 vite build 全绿 | | UI-MINIAPP-20260913 | 已完成 | 小程序 11 个核心页面已重构;11/11 页 TS-only,2026-09-16 通过用户验收 | diff --git a/docs/Phase-21-V11-Recovery-Completion-Report-2026-09-18.md b/docs/Phase-21-V11-Recovery-Completion-Report-2026-09-18.md new file mode 100644 index 0000000..be6b19e --- /dev/null +++ b/docs/Phase-21-V11-Recovery-Completion-Report-2026-09-18.md @@ -0,0 +1,85 @@ +# Phase 21(账户生命周期)KEEP-VERIFY 复验完成报告 — 2026-09-18 + +> **结论:CLOSED(Gate 全项通过)** —— 但这是**复验后**的结论,不是 `STATUS.md` 原有「✅」所声称的结论。 +> 原有「✅」只是历史进度声明;KEEP-VERIFY 审计发现该模块同时存在 **Gate 硬违反、死代码/桩、以及一处会让"关闭未来服务"逻辑永远不可达的设计矛盾**。 + +--- + +## 一、复验起点:为什么「已完成」不可信 + +`STATUS.md` 早已把 Phase 21 标为「✅ 已完成」,P21-T01~T05 全部打勾。但 KEEP-VERIFY 实测发现: + +- **Gate「注销后不能登录」被违反** —— `AuthService.checkAccountStatus` 只拒绝 `BANNED`/`SUSPENDED`,**不拒绝 `CLOSED`**,注销后仍能登录。 +- **Gate「注销后不能聊天」被违反** —— `notifyActiveMatches` 是 **TODO 桩**(只写日志),注销后 `Match` 仍是 `active`、对方不被通知。 +- **Gate「关闭未来服务」被违反** —— `cancelPendingActivityRegistrations` 是 **TODO 桩**,活动报名不取消。 +- **Gate「不反向暴露个人和咨询正文」被违反** —— `anonymizeServiceHistory`(P21-T05 标的"✅")**实现了却从未被 `closeAccount` 调用**(死代码);且 `sanitizeUserData` 仅脱敏手机号,**身份证号脱敏是 TODO**,注销后身份证明文(密文)永久留存。 +- **一处设计矛盾(死代码根因)** —— `closeAccount` 在 `checkCanWithdraw` 返回 `canWithdraw=false` 时**直接抛异常阻断**,而 `checkCanWithdraw` 检查的恰恰就是 `cancelXxx` 那些待取消的状态(预约/咨询/项目/报名/资格)。结论:下方"关闭未来服务"的清理逻辑**永远不可达**,且给用户注销设了"需先手动取消所有业务"的障碍——与 `AccountLifecycleService` 注释「注销必须在线便捷、不设置障碍」直接冲突。 +- **测试假绿** —— `AccountLifecycleServiceTest` 由 **7 个 `assertTrue(true)`** 组成,零断言,却让"Phase 21 已完成"看起来成立。 + +**代码存在 ≠ 功能正确,更 ≠ Gate 通过。** + +--- + +## 二、修复的真实缺陷(高价值项) + +| 编号 | 严重度 | 缺陷 | 修复 | +|------|--------|------|------| +| **D-21-A** | 🔴 CRITICAL(Gate) | `AuthService.checkAccountStatus` 不拒绝 `CLOSED` → 注销后仍能登录 | 增加 `CLOSED` 拒绝分支(与 `BANNED`/`SUSPENDED` 同级) | +| **D-21-B** | 🟠 HIGH(Gate 暴露个人/咨询正文 + 死代码) | `anonymizeServiceHistory` 已实现但**未接线**;身份证脱敏是 TODO | `closeAccount` 接线 `anonymizeServiceHistory`;`sanitizeUserData` 增加 `user_identities` 身份证/姓名脱敏(列 `NOT NULL`,覆盖为占位符 `***ACCOUNT_CLOSED***`);`anonymizeServiceHistory` 扩展脱敏 `counseling_followups.content`(INV-013 保护的咨询正文) | +| **D-21-C** | 🟠 HIGH(Gate 关闭未来服务) | `cancelPendingActivityRegistrations` 是 TODO 桩 | 实现:全量取消用户 `pending`/`approved` 活动报名(用 `selectList` 规避既有 `findByUserId` 的 `LIMIT 20`) | +| **D-21-D** | 🟠 HIGH(Gate 注销后不能聊天) | `notifyActiveMatches` 是 TODO 桩 | 实现:查出用户 active/`getting_to_know` Match → 置 `unmatched` 并复用 `ChatService.closeConversationForMatch` 触发 `conversation.closed` 通知对方 | +| **D-21-E** | 🟡 MEDIUM(死代码矛盾) | `closeAccount` 在 blockers 处硬抛异常,使 `cancelXxx`/`invalidateLocalEligibility` 永远不可达;且给用户注销设障碍 | 改为"信息性日志 + 继续清理"(尊重「注销不设置障碍」+ 计划 520「关闭未来服务」);`checkCanWithdraw` 仍作为 GET 端点向用户/运营展示待处理业务 | +| **D-21-F** | 🟡 MEDIUM(假绿测试) | 7×`assertTrue(true)` 零断言 | 删除,替换为 11 用例真实 DB 集成测试 + 3 用例登录守卫 | + +### 设计修正说明(D-21-E) +原 `closeAccount` 在 `checkCanWithdraw` 失败即 `throw`。这导致"关闭未来服务"的清理逻辑**在结构上不可达**(典型的"写了清理代码却永远跑不到"死代码),并且与类注释「注销必须在线便捷、不设置障碍」冲突。本轮改为:先调 `checkCanWithdraw` 仅作**信息性日志**,随后**继续**执行取消/退出/失效/关闭 Match/脱敏。这是一次有意为之的行为修正,理由: +1. AGENTS「注销必须在线便捷、不设置障碍」——不能要求用户先手动取消所有业务才能注销; +2. 计划 520「注销事务关闭未来服务」——清理必须由注销流程自己完成; +3. 让原已写好却不可达的 `cancelXxx` 逻辑真正生效。 + +> ⚠️ 此修正改变了"注销在有进行中业务时是否阻断"的行为。**原有行为是硬阻断**,现已改为"提示 + 自动清理"。若产品方认为某些业务(如 `in_progress` 咨询)应**先经用户确认**再注销,属于产品决策,需另行评估——本轮按"不设置障碍 + 自动关闭"实现并出报告。 + +--- + +## 三、跨模块验证(既有,确认无误) + +- **推荐引擎** `RecommendationEngineService:329`:非 `accountStatus = NORMAL` 用户(含 `CLOSED`)被排除 → 注销后不再进入推荐池。✅(既有) +- **匹配资格** `MatchEligibilityService:149`:目标用户非 `NORMAL` 则不可匹配 → 注销后不可被匹配。✅(既有) +- **Capability** `CapabilityService:46,67`:`NORMAL` 才可展示能力入口。✅(既有) + +这三处与 D-21-A/D-21-D 共同构成 Gate「注销后不能登录/推荐/聊天」的完整防线。 + +--- + +## 四、验证证据 + +| 项 | 结果 | +|---|---| +| 后端全量回归 | **650 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS**(起点 648,净 +2) | +| 新增守卫 | `AccountLifecycleServiceTest`(11 用例:checkCanWithdraw 各拦截项 + closeAccount 全链路 + 关闭 Match)+ `AuthServiceAccountClosedGuardTest`(3 用例:CLOSED 拒登 / NORMAL 不误伤 / BANNED 仍拒) | +| 负向验证(NEG-VAL) | 3 处注入 → **3 用例精准变红、零无关失败**:① 移除 `checkAccountStatus` 的 CLOSED 分支 → `closedAccountCannotLogin` 变红(经 `ReflectionTestUtils` 直接调私有方法,**与 Redis 解耦**,避免 CI 有 Redis 时守卫失效);② 移除 Match 置 `unmatched` → `closeAccountClosesActiveMatch` 变红;③ 移除身份证脱敏 → `closeAccountProceedsAndCleansUpActiveServices` 变红。还原后 `md5sum` 逐字节一致、残留 0 | +| Git | commit + tag `phase21-recovery-2026-09-18` + bundle(回读通过),工作区干净 | + +--- + +## 五、遗留 / 待确认(未静默扩权) + +1. **红娘服务进行中拦截** —— `checkCanWithdraw` 的 `activeMatchmakerServices` 仍硬编码 `0`。根因:当前数据模型**没有 `matchmaker_services` 表**(全仓搜索为空),该拦截项在设计上无法落地。本轮**未**凭空建表(属扩权),标注为遗留 TODO,建议产品确认红娘服务的存储模型后再补。 +2. **数据保留"配置 + 人工审核"删除流程** —— 计划 521 要求"数据保留期限未确认的敏感材料采用配置和人工审核,不擅自永久保存或删除"。本轮仅做到"脱敏保留"(PII 已脱敏),**可配置保留期限 + 人工审核删除流程**属产品决策,未实现,建议单列任务。 +3. **隐私设置接口** —— `getPrivacySettings`/`updatePrivacySettings` 仍是 TODO 桩(计划未将其列入 Phase 21 强制项),返回假数据/不更新,属遗留。 + +--- + +## 六、改动清单 + +**核心修复(生产)** +- `auth/service/AuthService.java` —— `checkAccountStatus` 增加 `CLOSED` 拒绝。 +- `account/service/AccountLifecycleService.java` —— 注入 `MatchMapper`/`ChatService`/`UserIdentityMapper`/`CounselingFollowupMapper`;实现 `notifyActiveMatches`/`cancelPendingActivityRegistrations`;接线并扩展 `anonymizeServiceHistory`;扩展 `sanitizeUserData` 身份证脱敏;`closeAccount` 取消 blockers 硬阻断改为信息性日志+继续清理。 + +**测试(保真度)** +- `account/AccountLifecycleServiceTest.java` —— 由 7×`assertTrue(true)` 重写为 11 用例真实集成测试。 +- `auth/AuthServiceAccountClosedGuardTest.java` —— 新增 3 用例(Redis 解耦)。 + +**文档** +- `docs/Phase-21-V11-Recovery-Completion-Report-2026-09-18.md`(本文件) +- `docs/STATUS.md` / `docs/JIRA_BACKLOG.md` —— 同步复验结论与真实缺陷。 diff --git a/docs/STATUS.md b/docs/STATUS.md index 6c314d8..c704bc2 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -554,15 +554,20 @@ MinIO: localhost:9003 (凭证由本地环境变量提供) 测试库补齐 `chk_elig_type` / `chk_elig_verify_status` 后立即暴露 `ActivityPhase14Test` 夹具违规。 新增 `LocalEligibilityStatusVocabularyGuardTest`(9)。 -### Phase 21 — 账户生命周期增量 ✅ 已完成 -| 任务 | 状态 | +### Phase 21 — 账户生命周期增量 ✅(KEEP-VERIFY 复验关闭,2026-09-18) +| 项 | 复验结论 | |------|------| -| P21-T01 注销时取消服务预约 | ✅ (cancelServiceAppointments) | -| P21-T02 注销时取消咨询记录 | ✅ (cancelCounselingServices) | -| P21-T03 注销时退出专项项目 | ✅ (exitServicePrograms) | -| P21-T04 失效本地资格 | ✅ (invalidateLocalEligibility) | -| P21-T05 服务历史脱敏钩子 | ✅ (anonymizeServiceHistory) | -| checkCanWithdraw 扩展(V1.1业务检查) | ✅ | +| P21-T01~T05 + checkCanWithdraw(原声明「✅」) | 主体实现存在,但复验发现并修复 **6 处真实缺陷** | +| Gate:注销后不能登录 | ❌→✅ `AuthService.checkAccountStatus` 原不拒 `CLOSED` → 已修复 | +| Gate:注销后不能聊天 | ❌→✅ `notifyActiveMatches` 原是 TODO 桩 → 已实现关闭 Match + 通知对方 | +| Gate:关闭未来服务(活动报名) | ❌→✅ `cancelPendingActivityRegistrations` 原是 TODO 桩 → 已实现 | +| Gate:不暴露个人/咨询正文 | ❌→✅ `anonymizeServiceHistory` 已实现却**从未被调用(死代码)** → 已接线;身份证脱敏原是 TODO → 已覆盖占位符;并脱敏 `counseling_followups` 正文 | +| 死代码矛盾 | ❌→✅ `closeAccount` 在 blockers 处硬抛异常,使 `cancelXxx` 永远不可达 → 改为信息性日志 + 继续清理 | +| 测试保真度 | 删除 7×`assertTrue(true)` 假绿 → 替换为 11 用例真实 DB 集成测试 + 3 用例登录守卫 | +| 跨模块防线(既有) | ✅ 推荐引擎 / 匹配资格 / Capability 已拒绝非 `NORMAL`(含 `CLOSED`) | +| 后端全量回归 | **650 tests / 0 failures(起点 648,净 +2)** | +| 报告 | `docs/Phase-21-V11-Recovery-Completion-Report-2026-09-18.md` | +| 遗留(未静默扩权) | ① 红娘服务进行中拦截:数据模型无 `matchmaker_services` 表,仍硬编码 0(TODO);② 数据保留"配置+人工审核"删除流程属产品决策未实现;③ 隐私设置接口仍为 TODO 桩 | **Phase 20(Analytics数据统计扩展)— 已完成** ✅