fix(p21-2e): 红娘服务拦截接线(blocker 首次生效 + 竞态兜底);2d 确认 Onboarding 本地资格已实现;文档回写

P16 遗留 2e(红娘服务拦截):
- checkCanWithdraw 硬编码 0 → 接 MatchmakerServiceMapper.countActiveByUserId(非终态口径
  submitted/assigned/in_service/waiting_user,与 findActiveByOperatorAndUser 一致),blocker 首次真正生效
- notifyMatchmaker TODO 空转 → terminateActiveMatchmakerServices 竞态兜底(残留非终态置 cancelled);
  不发通知:Phase 18 D2 的 notifications.user_id→users(id) FK 教训
- 守卫 MatchmakerWithdrawBlockerGuardTest 4 用例全绿;首跑暴露测试基建坑:@Transactional Service
  是 CGLIB 代理,ReflectionTestUtils 直调私有方法拿到的是代理实例的 null 字段 → 须先
  AopTestUtils.getTargetObject 解包(已在测试内注释说明)
- 另发现历史遗留:countInProgressByOperator 使用白名单外的 'processing'(已登记)

P04-T10:复核确认 Onboarding 本地资格步骤早已实现(onboarding 消息/动作 + 路由 + /me/status 字段),
JIRA ⏳ → ✅;清除首页语义错位的死状态 isVerified(miniapp tsc EXIT=0)

文档:JIRA 收尾行/计划文档 Task 3.1/3.3 勾选
This commit is contained in:
WorkBuddy
2026-09-19 19:30:38 +08:00
parent 469d7194bd
commit 08b9cb2468
6 changed files with 194 additions and 22 deletions
@@ -85,6 +85,7 @@ public class AccountLifecycleService {
private final ChatService chatService;
private final UserIdentityMapper userIdentityMapper;
private final CounselingFollowupMapper counselingFollowupMapper;
private final com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper matchmakerServiceMapper;
// ==================== 退出婚恋服务(暂停推荐) ====================
@@ -166,8 +167,9 @@ public class AccountLifecycleService {
Map<String, Object> blockers = new HashMap<>();
// 检查是否有进行中的红娘服务
long activeMatchmakerServices = 0; // 需要 MatchmakerServiceMapper
// 检查是否有进行中的红娘服务(2e 接线:原硬编码 0,blocker 从未生效)。
// 状态口径 = 生产 chk_matchmaker_status 白名单的非终态值(submitted/assigned/in_service/waiting_user)。
long activeMatchmakerServices = matchmakerServiceMapper.countActiveByUserId(userId);
if (activeMatchmakerServices > 0) {
blockers.put("matchmakerService", "您有进行中的红娘服务,请先完成或取消");
}
@@ -270,8 +272,8 @@ public class AccountLifecycleService {
// 2b. 通知所有活跃 Match 对方
notifyActiveMatches(user.getId(), "account_closed");
// 2c. 通知红娘(如果有)
notifyMatchmaker(user.getId());
// 2c. 终止残留红娘服务(竞态兜底,见方法注释;不能向 operator 发通知——Phase 18 D2 FK 教训)
terminateActiveMatchmakerServices(user.getId());
// 2d. 取消所有未开始的活动报名
cancelPendingActivityRegistrations(userId);
@@ -425,11 +427,34 @@ public class AccountLifecycleService {
}
/**
* 通知红娘服务已终止
* 注销收尾(2e 接线,原 {@code notifyMatchmaker} 为 TODO 空转):把该用户残留的非终态
* 红娘服务置为 cancelled。
*
* <p>正常路径下 {@code checkCanWithdraw} 的 blocker 会要求用户先完成/取消红娘服务,
* 走到注销时不应再有非终态记录 —— 本方法是<b>竞态兜底</b>(blocker 检查与注销事务之间,
* 红娘管理员恰好受理了新服务)。
*
* <p><b>为什么不是「发通知」</b>:Phase 18 D2 的教训 —— {@code notifications.user_id → users(id)}
* 外键下,operator 不在 users 表,向红娘发用户通知在真实 PG 上必然使注销事务回滚。
* 因此以状态收尾 + 日志代替「通知」,效果更实质且不会炸注销事务。
*/
private void notifyMatchmaker(String userId) {
// TODO: 查询该用户的红娘服务,通知相关红娘
log.info("通知红娘服务终止: userId={}", userId);
private void terminateActiveMatchmakerServices(String userId) {
List<com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord> active =
matchmakerServiceMapper.selectList(
new LambdaQueryWrapper<com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord>()
.eq(com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord::getUserId, userId)
.in(com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord::getStatus,
Constants.MATCHMAKER_STATUS_SUBMITTED,
Constants.MATCHMAKER_STATUS_ASSIGNED,
Constants.MATCHMAKER_STATUS_IN_SERVICE,
Constants.MATCHMAKER_STATUS_WAITING_USER)
.isNull(com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord::getDeletedAt));
for (com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord svc : active) {
svc.setStatus(Constants.MATCHMAKER_STATUS_CANCELLED);
svc.setUpdatedAt(Instant.now());
matchmakerServiceMapper.updateById(svc);
log.info("注销收尾: 残留红娘服务已终止 userId={}, serviceId={}", userId, svc.getId());
}
}
/**
@@ -51,4 +51,14 @@ public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRec
"AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1")
Optional<MatchmakerServiceRecord> findActiveByOperatorAndUser(@Param("operatorId") String operatorId,
@Param("userId") String userId);
/**
* 注销前检查(P16 收尾 2e):该用户仍处于非终态的红娘服务数。
* 状态口径与 {@link #findActiveByOperatorAndUser} 一致(submitted/assigned/in_service/waiting_user)。
* ⚠️ 勿用 {@code Constants.MATCHMAKER_STATUS_PENDING} / {@code IN_PROGRESS} ——
* 这两个常量不在生产 chk_matchmaker_status 白名单(V009),该表永不出现这两个值。
*/
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE user_id = #{userId} " +
"AND status IN ('submitted','assigned','in_service','waiting_user') AND deleted_at IS NULL")
long countActiveByUserId(@Param("userId") String userId);
}
@@ -0,0 +1,129 @@
package com.danyang.match.platform.account;
import com.danyang.match.platform.account.service.AccountLifecycleService;
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
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.AopTestUtils;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.annotation.Transactional;
import java.util.Map;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.*;
/**
* 2e 守卫(Phase 21 遗留清偿):注销前红娘服务拦截 + 注销竞态兜底。
*
* <h3>缺陷本体</h3>
* {@code checkCanWithdraw} 曾把进行中红娘服务数**硬编码为 0** —— blocker 从未生效,
* 有进行中红娘服务的用户可以直接注销,红娘工作台留下永不终结的孤儿服务。
* 原因注释写「需要 MatchmakerServiceMapper」,但该 Mapper 与 {@code matchmaker_services} 表
* (V009)其实一直都在 —— 原 STATUS「数据模型无该表」的判定已被实测推翻。
*
* <h3>覆盖</h3>
* <ol>
* <li>blocker 接线:有非终态红娘服务(in_service)→ 注销被拦;</li>
* <li>无红娘服务 → 不产生该 blocker;</li>
* <li>竞态兜底:{@code terminateActiveMatchmakerServices} 把残留非终态服务置为 cancelled
* (方法为 private,用 ReflectionTestUtils 直调,与 {@code AuthServiceAccountClosedGuardTest}
* 同模式 —— 注销主流程被 blocker 挡住,正常路径到不了这里)。</li>
* </ol>
*/
@SpringBootTest
@ActiveProfiles("test")
@Transactional
class MatchmakerWithdrawBlockerGuardTest {
@Autowired private AccountLifecycleService accountLifecycleService;
@Autowired private UserMapper userMapper;
@Autowired private MatchmakerServiceMapper matchmakerServiceMapper;
private User newUser() {
User user = new User();
user.setId(UUID.randomUUID().toString());
user.setWechatOpenid("openid-" + UUID.randomUUID());
user.setAccountStatus("normal");
userMapper.insert(user);
return user;
}
private MatchmakerServiceRecord insertService(String userId, String status) {
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
svc.setId(UUID.randomUUID().toString());
svc.setUserId(userId);
svc.setServiceType("recommend_partner");
svc.setStatus(status);
matchmakerServiceMapper.insert(svc);
return svc;
}
@Test
@DisplayName("blocker 接线:有进行中红娘服务的用户注销被拦(原硬编码 0,拦截从未生效)")
void activeMatchmakerServiceBlocksWithdraw() {
User user = newUser();
insertService(user.getId(), Constants.MATCHMAKER_STATUS_IN_SERVICE);
Map<String, Object> result = accountLifecycleService.checkCanWithdraw(user.getId());
@SuppressWarnings("unchecked")
Map<String, Object> blockers = (Map<String, Object>) result.get("blockers");
assertTrue(blockers.containsKey("matchmakerService"),
"进行中红娘服务必须成为注销 blocker(P16 遗留 2e 缺陷本体)");
assertEquals(false, result.get("canWithdraw"));
}
@Test
@DisplayName("无红娘服务的用户:不产生 matchmakerService blocker")
void noMatchmakerServiceNoBlocker() {
User user = newUser();
Map<String, Object> result = accountLifecycleService.checkCanWithdraw(user.getId());
@SuppressWarnings("unchecked")
Map<String, Object> blockers = (Map<String, Object>) result.get("blockers");
assertFalse(blockers.containsKey("matchmakerService"),
"无红娘服务时不得误报该 blocker");
}
@Test
@DisplayName("已完成的红娘服务不拦截注销(终态不阻塞)")
void completedServiceDoesNotBlock() {
User user = newUser();
insertService(user.getId(), Constants.MATCHMAKER_STATUS_COMPLETED);
Map<String, Object> result = accountLifecycleService.checkCanWithdraw(user.getId());
@SuppressWarnings("unchecked")
Map<String, Object> blockers = (Map<String, Object>) result.get("blockers");
assertFalse(blockers.containsKey("matchmakerService"),
"终态(completed)红娘服务不应拦截注销");
}
@Test
@DisplayName("竞态兜底:terminateActiveMatchmakerServices 把残留非终态服务置为 cancelled")
void terminateResidualServicesOnClose() {
User user = newUser();
MatchmakerServiceRecord svc = insertService(user.getId(), Constants.MATCHMAKER_STATUS_ASSIGNED);
// ⚠️ 必须先解包 CGLIB 代理再反射调私有方法:该 Service 含 @Transactional 方法,
// 注入的是代理实例 —— 代理对象自身字段全为 null(委托给 target),绕过拦截器直调
// 私有方法会拿到 null 字段 NPE。直接调代理上的 public 方法(checkCanWithdraw)
// 走拦截器委托 target,不受影响 —— 这也是本守卫首跑暴露的测试基建坑。
Object target = AopTestUtils.getTargetObject(accountLifecycleService);
ReflectionTestUtils.invokeMethod(target,
"terminateActiveMatchmakerServices", user.getId());
MatchmakerServiceRecord after = matchmakerServiceMapper.selectById(svc.getId());
assertEquals(Constants.MATCHMAKER_STATUS_CANCELLED, after.getStatus(),
"注销收尾必须终止残留的非终态红娘服务(原 notifyMatchmaker 为 TODO 空转)");
}
}