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 空转)");
}
}
+6 -2
View File
@@ -187,7 +187,7 @@
| 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 通过用户验收 |
| 批 2/3 收尾(2026-09-19) | 进行中 | E2E 互选 8 + 预约 8 场景真实实现(本机 65 passed)+ INV-011 显式守卫完成;隐私设置接真实落库(V047)、5 死开关 + 2 私有桶死配置移除(产品裁定)、`PUT /feature-flags` 真持久化(V048)、5 个无 SQL Mapper 方法补齐——均已处置;剩余:红娘服务拦截接线(原「无表」判定已推翻,V009 已建表)、数据保留流程(待产品决策)、Task 3.3 四项(P16-T13 / P13A-T31 / P13A-T33 / P04-T10)、CI 全绿实测(需 GitHub Actions 环境) |
| 批 2/3 收尾(2026-09-19) | 基本完成 | E2E 互选 8 + 预约 8 场景真实实现(本机 65 passed)+ INV-011 显式守卫;隐私设置接真实落库(V047)、5 死开关 + 2 死配置移除(产品裁定)、`PUT /feature-flags` 真持久化(V048)、5 个无 SQL Mapper 方法补齐;**Task 3.3 四项 + 红娘服务拦截接线全部完成**(互选 `expires_at` 写入接线、随访审计 `recordSensitiveAccess`、随访正文风险检测、Onboarding 本地资格确认已实现并回写;`checkCanWithdraw` 红娘 blocker 首次生效 + `terminateActiveMatchmakerServices` 竞态兜底);**仅余**:数据保留流程(待产品决策)、CI 全绿实测(需 GitHub Actions 环境)、E2E 场景对账表首项之外的 CI 复跑 |
| V1.1 UAT / RC / Production | 进行中 | `/me/*` 契约已收敛(2026-09-16);当前停在 Phase 24(UAT)启动门前 —— 启动需用户确认,前置收尾见 `docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md` |
---
@@ -305,7 +305,11 @@
### Miniapp
- P04-V11-T08 ✅ U62 本地资格说明页
- P04-V11-T09 ✅ U63 本地资格补充核验页
- P04-V11-T10 ⏳ Onboarding增加本地资格步骤(后续Phase)
- P04-V11-T10 ✅ Onboarding 本地资格步骤 —— **2026-09-19 复核确认已实现**:首页 onboarding 消息/动作覆盖
`not_declared` / `needs_evidence`,动作「声明本地资格」路由至 `/pages/local-eligibility/local-eligibility`
(`pages/index/index.ts` buildOnboardingMessage / buildOnboardingActions / handleQuickAction),
`eligStatus` 取自 `/me/status` 的 `localEligibilityStatus`(该字段断言守卫见 `MeStatusLocalEligibilityGuardTest`);
同轮清除语义错位的死状态 `isVerified`(WXML/WXSS 零引用,实名判定是 `userState.verified`)
### MeStatus
- P04-V11-T11 ✅ /me/status增加localEligibilityStatus
@@ -343,16 +343,18 @@
- 负向注入:把桩退回「不落库」→ **恰 2 红**(落库用例 + 类型校验用例),
其余 6 个正确未红。已还原。
- 至此小程序隐私设置**真正可保存**,① ② 两层均已关闭。
- [ ] **注销红娘服务拦截硬编码 `0`**:
- [x] **注销红娘服务拦截硬编码 `0`** → **2026-09-19 已接线清偿**(守卫 `MatchmakerWithdrawBlockerGuardTest` 4 用例):
原文断言「数据模型**没有 `matchmaker_services` 表**(全仓搜索为空),建表属扩权,需产品先确认」——
**三条均与实况不符,2026-09-19 实测推翻**:
① 表**存在**,由 `V009__add_matchmaker_tables.sql` 创建(`chk_matchmaker_status` 白名单:
`submitted / assigned / in_service / waiting_user / completed / cancelled`);
② `MatchmakerServiceMapper`、`MatchmakerServiceRecord`、`MatchmakerServiceService` **均已存在**;
③ 因此**不需要扩权、不需要产品裁定**,属可直接实现的接线缺陷。
另:`AccountLifecycleService.notifyMatchmaker`(注销时通知红娘服务)同样是 TODO 空转。
位置:`AccountLifecycleService.checkCanWithdraw` 中
`long activeMatchmakerServices = 0; // 需要 MatchmakerServiceMapper`。
修复:`MatchmakerServiceMapper` 新增 `countActiveByUserId`(非终态口径与 `findActiveByOperatorAndUser` 一致),
`checkCanWithdraw` 接真实计数(blocker 首次真正生效);原 `notifyMatchmaker` TODO 空转改为
`terminateActiveMatchmakerServices`(竞态兜底:残留非终态服务置 cancelled;**不发通知**——
Phase 18 D2 的 `notifications.user_id→users(id)` FK 教训)。
另发现历史遗留:`countInProgressByOperator` 用了白名单外的 `'processing'`(已登记,另行处置)。
(原文「全仓搜索为空」很可能是只在 `db/migration/` 内搜了表名的另一种写法 —— 该表在
`demo-seed-*.sql` 与 `V009` 中都有;凡「全仓无 X」这类断言必须给出**搜索命令与范围**,
否则无法复核,本项目已多次因此产生失实结论。)
@@ -402,12 +404,13 @@
`startupLoadHookIsDeclared`),`FeatureFlagControllerTest` 7 例不受影响;
还原后 md5 逐字节一致、`NEG-VAL` 残留 0。
### Task 3.3: 任务级 ⏳ 清偿
### Task 3.3: 任务级 ⏳ 清偿 ✅(2026-09-19 全部关闭)
- [ ] **P16-T13**:互选超时接线(`expires_at` 已由 V041 提供)。
- [ ] **P13A-T31**:随访审计(`counseling_followups` 访问需审计)。
- [ ] **P13A-T33**:`RiskEvent source=counseling` 来源扩展。
- [ ] **P04-T10**:Onboarding 本地资格步骤。
- [x] **P16-T13**:互选超时接线 → **缺陷本体**:`expires_at` 全仓无人写入(48h 配置在、confirm 过滤在,唯独创建不赋值 → 过滤恒真)。修复:创建时写 `expires_at` = 活动结束时间 + `ActivityConfig.mutualDeadlineHours`(已确认口径「截止后48小时」),缺失回退创建时刻。守卫 2 用例,负向验证(摘掉写入)恰 2 红。
- [x] **P13A-T31**:随访审计 → `getDetailForCounselor` 接线 `recordSensitiveAccess(counseling_followup)`;本人/manager 摘要视图不审计。守卫含两个不审计分支断言。
- [x] **P13A-T33**:`RiskEvent source=counseling` 来源扩展 → `detectCounselingRisk` 此前只扫申请摘要;`addFollowup` 正文接入同一检测(INV-013 加固:不记正文只记级别)。守卫含正常内容不误报断言。
守卫:`CounselingFollowupAuditAndRiskGuardTest` 4 用例(真实落库回查),全绿。
- [x] **P04-T10**:Onboarding 本地资格步骤 → **复核确认早已实现**(首页 onboarding 消息/动作覆盖 `not_declared`/`needs_evidence` 并路由本地资格页,`eligStatus` 取自 `/me/status.localEligibilityStatus`);JIRA 行滞后已回写 ✅;同轮清除语义错位的死状态 `isVerified`(`miniapp tsc` EXIT=0)。
---
+4 -3
View File
@@ -34,7 +34,6 @@ Page({
loadError: false,
userState: null,
isOnboarding: false,
isVerified: false,
isPaused: false,
isSuccessful: false,
onboardingMessage: '',
@@ -112,7 +111,9 @@ Page({
const isOnboarding = !userState || !userState.verified || (profileStatus !== 'approved' && profileStatus !== 'completed')
const isPaused = relStatus === 'paused'
const isSuccessful = relStatus === 'matched_successfully'
const isVerified = eligStatus === 'verified'
// 注意:本地资格核验通过与否由 checks 里的「本地资格」项表达(eligStatus === 'verified')。
// 此前曾有一个 isVerified = eligStatus === 'verified' 的死状态 —— 名字像「实名」实为本地资格,
// WXML/WXSS 零引用且语义错位(实名判定是 userState.verified),已移除以防未来误用。
const checks = [
{ label: '已实名', done: userState?.verified || false },
@@ -123,7 +124,7 @@ Page({
this.setData({
loading: false, loadError: false,
userState,
isOnboarding, isVerified, isPaused, isSuccessful,
isOnboarding, isPaused, isSuccessful,
onboardingMessage: this.buildOnboardingMessage(userState, profileStatus, eligStatus),
onboardingActions: this.buildOnboardingActions(userState, profileStatus, eligStatus),
statusChecks: checks,