test(matchmaker,referral): ZC-MM-003/004 纯护栏——B1 联系规则锁定 + 禁止代用户决定固化

依据《善缘丹阳 V1.1 红娘角色权限正式决策与开发规则》DEC-MATCHMAKER-003(B1
平台内优先、授权例外)与 DEC-MATCHMAKER-004(红娘不得代替用户执行交友决策)。
本阶段生产代码零改动,交付物为护栏测试(MM-CONTACT-001~004、MM-ACTION-001~003
全部有自动化用例)。

MatchmakerContactGuardTest(4 用例,standalone 源码扫描,FieldPrivacyGuardTest 范式):
- MM-CONTACT-002:ReferralService 全部响应 put 键 ⊆ 16 键白名单(逐键对账吻合),
  禁词 {emergencycontact,phone,mobile,wechat,weixin,address,idnumber,id_card}——
  锁定红娘侧零联系方式回显(紧急联系人信息纯入口 write-only,连红娘本人都无读回面);
  含防清空自检(三关键键必须在场)
- MM-CONTACT-003:红娘/转介 Controller 无 contact/call/phone/channel/offline 端点;
  全仓无 ContactAuthorization 类、无 matchmaker_contact_authorizations 迁移表——
  锁定「无授权机制」;未来引入联系功能必须连同本守卫+治理同步更新
- MM-CONTACT-004:机制不存在 ⇒ 无授权到期场景(与 003 同源,未来引入同时变红)
- 004 佐证:matchmaker/referral 包 @Scheduled=0(无批处理代决策面)

MatchmakerActionGuardTest(6 用例,@SpringBootTest 行为锁定):
- MM-ACTION-001(3 例负向):operator token 调用户决策端点
  (/matchmaker/recommendations/{id}/accept、/pass、/referrals/{id}/confirm)→ 403
  且业务状态零变化(403 来源于 SecurityConfig hasRole("USER") 身份类型防线,
  与 PermissionService mock 无关,变异验证锚定真实)
- MM-ACTION-002:operator token 开启会话端点 → 403
- MM-ACTION-003 must-confirm 语义锁定:红娘代建转介 → needsUserConfirmation=true/
  userConfirmedAt=null/status=pending,后续 recommend/followup/complete 不改确认态
  (「代填草稿、用户确认」灰色点永久锁定)
- MM-CONTACT-001(正向):红娘对本人分配对象创建推荐/转介 → 平台内服务通知落库,
  title/content 无手机号/微信模式,红娘 operatorId 不在通知收件面

负向注入自证:
- 注入 A:ReferralService 临时加 emergencyContactPhoneEnc 回显 → 守卫三重命中判红 → 还原
- 注入 B:SecurityConfig hasRole("USER")→authenticated() → 4/4 负向用例判红
  (403→401/500)→ 还原复绿

验证:定向 42/42、全量 861/861(3 预存 skip)、UI 契约 24/24。
003 方案 A(纯测试锁定)决策记录与五项 LEGAL_VERIFY open questions 随 ZC-MM-007 登记;
ContactAuthorizationPolicy 桩 + matchmaker_contact_authorizations 表推迟至 ZC-MM-006
(仅当产品确认开放受控联系时立项)。
This commit is contained in:
WorkBuddy
2026-10-10 14:46:53 +08:00
parent 3caf5f8816
commit 5e484d0e74
2 changed files with 589 additions and 0 deletions
@@ -0,0 +1,332 @@
package com.danyang.match.platform.matchmaker;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.activity.entity.ActivityRegistration;
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
import com.danyang.match.platform.auth.token.JwtTokenService;
import com.danyang.match.platform.auth.token.TokenBlacklistService;
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
import com.danyang.match.platform.matchmaker.entity.MatchmakerRecommendation;
import com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerAssignmentMapper;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerRecommendationMapper;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
import com.danyang.match.platform.matchmaker.service.MatchmakerServiceService;
import com.danyang.match.platform.notification.entity.Notification;
import com.danyang.match.platform.notification.mapper.NotificationMapper;
import com.danyang.match.platform.referral.service.ReferralService;
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.mapper.UserIdentityMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* ZC-MM-004(禁止代替用户决定复核)+ MM-CONTACT-001 行为锁定
* ({@code @SpringBootTest} 走真实安全过滤器链,沿用 MatchmakerReferralPhase13Test 数据构造范式)。
*
* <p>锁定四条行为不变量(架构师设计 T02):
* <ol>
* <li><b>MM-ACTION-001 强化(负向)</b>:operator 主体 token 打用户专属决定端点
* (推荐 accept / pass、转介 confirm)→ 认证层 403
* (SecurityConfig 兜底 {@code hasRole("USER")}:运营身份根本进不到 Controller,
* SecurityPathCoverageTest 已机械锁定该兜底不得退回 authenticated()),
* 且 recommendation / registration 状态零变化 —— 「红娘不能替用户 Like/Pass/确认转介」。</li>
* <li><b>MM-ACTION-002</b>:会话开启端点(POST /conversations/from-match/{matchId})同为
* UserPrincipal 路径 → operator token 403(match 的产生同样只在用户 Like 路径,
* 无 operator 触达面)。</li>
* <li><b>MM-ACTION-003 / must-confirm 语义</b>:红娘创建转介 → 响应
* needsUserConfirmation=true、库中 userConfirmedAt=null、status 停留 pending;
* 红娘侧 recommend / followup / complete 动作不改变用户确认状态 ——
* 「转介代建 = 代填草稿、用户确认」语义被永久锁定。</li>
* <li><b>MM-CONTACT-001(正向)</b>:红娘对本人分配对象创建推荐/转介 → notifications 表
* 落平台内服务通知(type=matchmaker),title/body 不含手机号/微信号模式;
* 通知收件面只有 C 端用户(无 operator 收件行)—— 平台内联系经通知中转,
* 红娘与用户之间不存在直连联系方式交换。</li>
* </ol>
*/
@SpringBootTest
@ActiveProfiles("test")
@Transactional
@AutoConfigureMockMvc
class MatchmakerActionGuardTest {
@Autowired private MockMvc mockMvc;
@Autowired private JwtTokenService jwtTokenService;
/** 测试环境无 Redis:黑名单查询打桩为"未拉黑"(ChatPhase09IntegrationTest 同款) */
@MockBean private TokenBlacklistService tokenBlacklistService;
/**
* 测试 H2 库无 permissions/operator_roles 表:JwtAuthFilter 对 operator token 会调
* PermissionService.getPermissions → 打桩为空权限(测试只断言 ROLE_OPERATOR 与
* hasRole("USER") 的身份类型 403,不依赖具体权限装载)。
*/
@MockBean private com.danyang.match.platform.permission.service.PermissionService permissionService;
@Autowired private MatchmakerServiceMapper matchmakerServiceMapper;
@Autowired private MatchmakerAssignmentMapper matchmakerAssignmentMapper;
@Autowired private MatchmakerRecommendationMapper recommendationMapper;
@Autowired private ActivityRegistrationMapper activityRegistrationMapper;
@Autowired private NotificationMapper notificationMapper;
@Autowired private UserMapper userMapper;
@Autowired private com.danyang.match.platform.profile.mapper.UserProfileMapper userProfileMapper;
@Autowired private UserIdentityMapper userIdentityMapper;
@Autowired private MatchmakerServiceService matchmakerServiceService;
@Autowired private ReferralService referralService;
private static final String MATCHMAKER_OWNER = "op-ag-owner";
private String user;
private String target;
private String serviceId;
private String operatorToken;
/** 手机号模式(中国大陆 11 位) */
private static final Pattern PHONE_PATTERN = Pattern.compile("1[3-9]\\d{9}");
/** 微信号/微信引导词模式 */
private static final List<String> WECHAT_TOKENS = List.of("wechat", "weixin", "微信", "vx", "加v", "v信");
@BeforeEach
void setUp() {
user = createUser("ag-user");
target = createUser("ag-target");
// 一条归属 MATCHMAKER_OWNER 的红娘服务(assigned),并补生效分配(E1 纯判定器口径)
serviceId = createService(user, MATCHMAKER_OWNER, Constants.MATCHMAKER_STATUS_ASSIGNED);
MatchmakerAssignment assignment = new MatchmakerAssignment();
assignment.setUserId(user);
assignment.setMatchmakerOperatorId(MATCHMAKER_OWNER);
assignment.setServiceId(serviceId);
assignment.setAssignedBy(MATCHMAKER_OWNER);
assignment.setAssignmentScope(Constants.ASSIGNMENT_SCOPE_PRIMARY);
assignment.setAssignmentReason("003/004 守卫前置分配");
assignment.setStatus(Constants.ASSIGNMENT_STATUS_ACTIVE);
assignment.setAssignedAt(Instant.now());
matchmakerAssignmentMapper.insert(assignment);
// operator token:type=operator_access → JwtAuthFilter 授 ROLE_OPERATOR
when(permissionService.getPermissions(anyString())).thenReturn(List.of());
operatorToken = jwtTokenService.generateOperatorToken(MATCHMAKER_OWNER, null);
}
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
// ==================== 数据构造(Phase13 范式) ====================
/** 基础资格满足的用户:正常账号 + 实名 + 资料通过(RelationService.like 路径可走) */
private String createUser(String tag) {
User u = new User();
u.setWechatOpenid("ag_" + tag + "_" + System.nanoTime());
u.setAccountStatus(Constants.ACCOUNT_STATUS_NORMAL);
u.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
u.setRecommendationEnabled(true);
userMapper.insert(u);
var profile = new com.danyang.match.platform.profile.entity.UserProfile();
profile.setUserId(u.getId());
profile.setDisplayName(tag);
profile.setProfileStatus(Constants.PROFILE_STATUS_APPROVED);
userProfileMapper.insert(profile);
var identity = new com.danyang.match.platform.verification.entity.UserIdentity();
identity.setUserId(u.getId());
identity.setVerificationStatus(Constants.VERIFICATION_STATUS_VERIFIED);
userIdentityMapper.insert(identity);
return u.getId();
}
private String createService(String userId, String operatorId, String status) {
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
svc.setId(UUID.randomUUID().toString());
svc.setUserId(userId);
svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
svc.setStatus(status);
svc.setAssignedOperatorId(operatorId);
matchmakerServiceMapper.insert(svc);
return svc.getId();
}
private MatchmakerRecommendation insertRecommendation(String recUser, String recTarget) {
MatchmakerRecommendation rec = new MatchmakerRecommendation();
rec.setId(UUID.randomUUID().toString());
rec.setServiceId(serviceId);
rec.setUserId(recUser);
rec.setTargetUserId(recTarget);
rec.setOperatorId(MATCHMAKER_OWNER);
rec.setReason("004 守卫测试推荐");
rec.setStatus(Constants.RECOMMENDATION_STATUS_PENDING_VIEW);
recommendationMapper.insert(rec);
return rec;
}
private ActivityRegistration insertReferralRegistration(String regUser, String activityId) {
ActivityRegistration reg = new ActivityRegistration();
reg.setId(UUID.randomUUID().toString());
reg.setActivityId(activityId);
reg.setUserId(regUser);
reg.setStatus(Constants.REGISTRATION_STATUS_PENDING);
reg.setSource(Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL);
reg.setRequestSource(Constants.REFERRAL_SOURCE_MATCHMAKER_REFERRAL);
reg.setCreatedByOperatorId(MATCHMAKER_OWNER);
reg.setCreatedAt(Instant.now());
activityRegistrationMapper.insert(reg);
return reg;
}
// ==================== MM-ACTION-001:红娘不能替用户做 Like/Pass/确认决定 ====================
@Test
@DisplayName("MM-ACTION-001(负向): operator token 打推荐 accept → 403,推荐状态零变化")
void operatorCannotAcceptRecommendationOnBehalfOfUser() throws Exception {
MatchmakerRecommendation rec = insertRecommendation(user, target);
mockMvc.perform(post("/matchmaker/recommendations/{id}/accept", rec.getId())
.header("Authorization", "Bearer " + operatorToken))
.andExpect(status().isForbidden());
MatchmakerRecommendation after = recommendationMapper.selectById(rec.getId());
assertEquals(Constants.RECOMMENDATION_STATUS_PENDING_VIEW, after.getStatus(),
"越权 accept 被认证层 403 拒绝后,推荐状态必须零变化");
assertNull(after.getRespondedAt(), "越权 accept 不得写入 respondedAt");
}
@Test
@DisplayName("MM-ACTION-001(负向): operator token 打推荐 pass → 403,推荐状态零变化")
void operatorCannotPassRecommendationOnBehalfOfUser() throws Exception {
MatchmakerRecommendation rec = insertRecommendation(user, target);
mockMvc.perform(post("/matchmaker/recommendations/{id}/pass", rec.getId())
.header("Authorization", "Bearer " + operatorToken))
.andExpect(status().isForbidden());
MatchmakerRecommendation after = recommendationMapper.selectById(rec.getId());
assertEquals(Constants.RECOMMENDATION_STATUS_PENDING_VIEW, after.getStatus(),
"越权 pass 被拒绝后,推荐状态必须零变化(仍 pending_view)");
assertNull(after.getRespondedAt(), "越权 pass 不得写入 respondedAt");
}
@Test
@DisplayName("MM-ACTION-001(负向): operator token 打转介 confirm → 403,用户确认状态零变化")
void operatorCannotConfirmReferralOnBehalfOfUser() throws Exception {
ActivityRegistration reg = insertReferralRegistration(user, "act-ag-001");
mockMvc.perform(post("/referrals/{id}/confirm", reg.getId())
.header("Authorization", "Bearer " + operatorToken)
.contentType("application/json")
.content("{\"targetType\":\"activity\"}"))
.andExpect(status().isForbidden());
ActivityRegistration after = activityRegistrationMapper.selectById(reg.getId());
assertNull(after.getUserConfirmedAt(),
"越权 confirm 被拒绝后,userConfirmedAt 必须仍为 null(用户决定权)");
assertEquals(Constants.REGISTRATION_STATUS_PENDING, after.getStatus(),
"越权 confirm 后报名状态必须停留 pending");
}
// ==================== MM-ACTION-002:会话/关系开启无 operator 触达面 ====================
@Test
@DisplayName("MM-ACTION-002(负向): operator token 打会话开启端点 → 403(match/conversation 仅用户路径)")
void operatorCannotOpenConversationFromMatch() throws Exception {
// 认证层即拒绝(hasRole("USER")),无需真实 match 存在
mockMvc.perform(post("/conversations/from-match/{matchId}", "no-such-match")
.header("Authorization", "Bearer " + operatorToken))
.andExpect(status().isForbidden());
}
// ==================== MM-ACTION-003:must-confirm 语义锁定 ====================
@Test
@DisplayName("MM-ACTION-003: 转介代建=代填草稿(needsUserConfirmation=true、userConfirmedAt=null);红娘后续动作不改变确认状态")
void referralCreationStaysPendingUntilUserConfirms() {
// 红娘创建转介(本人分配对象)
Map<String, Object> result = referralService.referToActivity(
MATCHMAKER_OWNER, user, "act-ag-002", "紧急联系人", "enc-phone");
// must-confirm 语义:响应标记待确认,库中确认字段为空、状态停留 pending
assertEquals(Boolean.TRUE, result.get("needsUserConfirmation"),
"红娘创建转介必须标记 needsUserConfirmation=true(不替用户完成报名)");
String regId = (String) result.get("registrationId");
ActivityRegistration reg = activityRegistrationMapper.selectById(regId);
assertNotNull(reg, "转介必须真实落库");
assertNull(reg.getUserConfirmedAt(), "创建时 userConfirmedAt 必须为 null");
assertEquals(Constants.REGISTRATION_STATUS_PENDING, reg.getStatus(),
"创建时报名状态必须停留 pending");
// 红娘侧后续动作:recommend / followup / complete —— 均不得改变用户确认状态
matchmakerServiceService.recommendPartner(serviceId, MATCHMAKER_OWNER, target, "合适人选");
matchmakerServiceService.addFollowup(serviceId, MATCHMAKER_OWNER, "note", "003 守卫跟进", null);
MatchmakerServiceRecord svc = matchmakerServiceMapper.selectById(serviceId);
svc.setStatus(Constants.MATCHMAKER_STATUS_IN_SERVICE);
matchmakerServiceMapper.updateById(svc);
matchmakerServiceService.completeService(serviceId, MATCHMAKER_OWNER);
ActivityRegistration after = activityRegistrationMapper.selectById(regId);
assertNull(after.getUserConfirmedAt(),
"recommend/followup/complete 红娘侧动作不得代替用户确认(userConfirmedAt 必须仍为 null)");
assertEquals(Constants.REGISTRATION_STATUS_PENDING, after.getStatus(),
"红娘侧动作不得推动报名状态(必须停留 pending 等待用户确认)");
}
// ==================== MM-CONTACT-001:平台内联系 = 通知中转,无联系方式交换 ====================
@Test
@DisplayName("MM-CONTACT-001(正向): 推荐/转介经平台通知触达用户,文案无手机号/微信号模式,收件面仅用户")
void platformNotificationsCarryNoContactPatternsAndOnlyTargetUsers() {
// 推荐路径:recommendPartner → sendAsync(type=matchmaker, businessType=matchmaker_recommendation)
matchmakerServiceService.recommendPartner(serviceId, MATCHMAKER_OWNER, target, "003 正向推荐");
// 转介路径:referToServiceStation → sendMatchmakerReferralCreated(type=matchmaker, businessType=service_station)
referralService.referToServiceStation(MATCHMAKER_OWNER, user, "loc-ag-001",
"counselor_consultation", null, "备注");
List<Notification> notes = notificationMapper.selectList(new LambdaQueryWrapper<Notification>()
.eq(Notification::getUserId, user)
.eq(Notification::getType, Constants.NOTIFICATION_TYPE_MATCHMAKER));
assertFalse(notes.isEmpty(), "推荐/转介必须为用户落平台内服务通知(type=matchmaker)");
for (Notification note : notes) {
String title = note.getTitle() == null ? "" : note.getTitle();
String content = note.getContent() == null ? "" : note.getContent();
assertFalse(PHONE_PATTERN.matcher(title).find(),
"通知标题不得含手机号模式: " + title);
assertFalse(PHONE_PATTERN.matcher(content).find(),
"通知正文不得含手机号模式: " + content);
for (String token : WECHAT_TOKENS) {
assertFalse(title.toLowerCase().contains(token),
"通知标题不得含微信引导词「" + token + "」: " + title);
assertFalse(content.toLowerCase().contains(token),
"通知正文不得含微信引导词「" + token + "」: " + content);
}
}
// 负向佐证:通知收件面只有 C 端用户 —— 红娘 operatorId 不在 notification 收件面
long operatorInbox = notificationMapper.selectCount(new LambdaQueryWrapper<Notification>()
.eq(Notification::getUserId, MATCHMAKER_OWNER));
assertEquals(0L, operatorInbox,
"红娘不得成为通知收件人(联系方式交换无 operator 侧触达面)");
}
}
@@ -0,0 +1,257 @@
package com.danyang.match.platform.matchmaker;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.*;
/**
* ZC-MM-003(B1 联系规则护栏)+ ZC-MM-004 佐证 —— standalone 源码扫描守卫
* (沿用 {@code MatchmakerFieldPrivacyGuardTest} 范式,无需 Spring 上下文)。
*
* <p>锁定四条不变量(架构师设计 T01):
* <ol>
* <li><b>MM-CONTACT-002</b>:ReferralService 全部响应 put 键 ⊆ 白名单(逐键对照实际代码
* 核定,见 {@link #RESPONSE_KEY_WHITELIST}),且键名不得含 emergencyContact / phone /
* mobile / wechat / address / idNumber 等联系方式字样 —— 锁定「红娘侧零联系方式回显」;
* emergencyContact* 是纯入口 write-only(写入 activity_registration 的紧急联系人字段),
* 连红娘本人都无读回面。</li>
* <li><b>MM-CONTACT-003</b>:matchmaker / referral Controller 无 contact / call / phone /
* channel / offline 类端点;全仓无 ContactAuthorization 类、无
* matchmaker_contact_authorizations 建表迁移 —— 锁定「不存在联系授权机制」。
* 契约:未来新增联系功能必须连同本守卫 + 007 治理登记同步更新,守卫即红。</li>
* <li><b>MM-CONTACT-004</b>:无联系授权机制即无授权到期场景 —— 与 MM-CONTACT-003 同源断言
* 承载(若未来引入 ContactAuthorization / matchmaker_contact_authorizations,
* 003/004 两条守卫同时判红,迫使开发者补「到期 + 审计」语义并过 007 评审)。</li>
* <li><b>ZC-MM-004 佐证</b>:matchmaker / referral 包无 @Scheduled —— 全仓无批处理
* 代操作面(禁止代替用户决定的自动化通道不存在)。</li>
* </ol>
*/
class MatchmakerContactGuardTest {
private static final Path SRC_ROOT = resolveSourceRoot();
private static final Path REFERRAL_SERVICE =
SRC_ROOT.resolve("com/danyang/match/platform/referral/service/ReferralService.java");
private static final List<Path> CONTROLLER_DIRS = List.of(
SRC_ROOT.resolve("com/danyang/match/platform/matchmaker/controller"),
SRC_ROOT.resolve("com/danyang/match/platform/referral/controller"));
private static final List<Path> PACKAGE_DIRS = List.of(
SRC_ROOT.resolve("com/danyang/match/platform/matchmaker"),
SRC_ROOT.resolve("com/danyang/match/platform/referral"));
/**
* ReferralService 响应键白名单(ZC-MM-003 / T01:以实际代码逐键核对后核定):
* <ul>
* <li>referToServiceStation → appointmentId / status / referralType /
* referralFromOperatorId / needsUserConfirmation</li>
* <li>referToActivity → registrationId / activityId / status / referralType /
* referralFromOperatorId / needsUserConfirmation</li>
* <li>confirmReferral → id / status / confirmedAt</li>
* <li>getReferralHistory → type / id / serviceType|activityId / status /
* createdByOperatorId / userConfirmedAt / createdAt|registeredAt</li>
* <li>getReferralHistoryByOperator → 同上 + userId(operator 查看本人发起的
* 转介记录需要 userId 标识服务对象;userId 是内部标识符,非联系方式,
* 不落入 B1 禁放范畴)</li>
* </ul>
*/
private static final Set<String> RESPONSE_KEY_WHITELIST = Set.of(
"appointmentId", "registrationId", "activityId", "id", "status", "type",
"serviceType", "referralType", "referralFromOperatorId", "needsUserConfirmation",
"confirmedAt", "userConfirmedAt", "createdByOperatorId", "createdAt",
"registeredAt", "userId");
/** 联系方式字样(出现在响应键名中即违规;emergencyContact* 仅允许作为入口 write-only 存在) */
private static final List<String> FORBIDDEN_KEY_TOKENS = List.of(
"emergencycontact", "phone", "mobile", "wechat", "weixin", "address",
"idnumber", "id_card");
/** MM-CONTACT-003:联系/触达类禁用端点词 */
private static final List<String> FORBIDDEN_ENDPOINT_TOKENS = List.of(
"contact", "call", "phone", "channel", "offline");
// ==================== MM-CONTACT-002:红娘侧零联系方式回显 ====================
@Test
@DisplayName("MM-CONTACT-002: ReferralService 全部响应 put 键 ⊆ 白名单,无任何联系方式回显")
void referralResponseKeysAreWhitelistedWithNoContactEcho() throws IOException {
String src = stripComments(Files.readString(REFERRAL_SERVICE, StandardCharsets.UTF_8));
Set<String> keys = new LinkedHashSet<>();
Matcher put = Pattern.compile("\\.put\\(\"([^\"]+)\"").matcher(src);
while (put.find()) {
keys.add(put.group(1));
}
// 防止"装配被搬空后守卫永真":核心语义键必须仍在
assertTrue(keys.contains("needsUserConfirmation"),
"must-confirm 语义键必须存在(守卫非永真自检)");
assertTrue(keys.contains("referralFromOperatorId"),
"来源追溯键必须存在(守卫非永真自检)");
assertTrue(keys.contains("status"), "状态键必须存在(守卫非永真自检)");
List<String> violations = new ArrayList<>();
for (String key : keys) {
if (!RESPONSE_KEY_WHITELIST.contains(key)) {
violations.add("非白名单响应键: " + key);
}
String lower = key.toLowerCase();
for (String token : FORBIDDEN_KEY_TOKENS) {
if (lower.contains(token)) {
violations.add("响应键含联系方式 token: " + key + "(含 " + token + ")");
}
}
}
assertEquals(List.of(), violations,
"B1 联系规则契约被破坏(MM-CONTACT-002):响应键出现白名单外键或联系方式字样。\n"
+ "实际输出键=" + keys + "\n违规项:\n - " + String.join("\n - ", violations));
}
// ==================== MM-CONTACT-003:无联系端点 / 无授权机制 ====================
@Test
@DisplayName("MM-CONTACT-003: matchmaker/referral Controller 无 contact 类端点;全仓无 ContactAuthorization 机制")
void noContactEndpointsAndNoContactAuthorizationMechanism() throws IOException {
List<String> violations = new ArrayList<>();
// a) 端点扫描:matchmaker / referral Controller 无联系/触达类路径
for (Path dir : CONTROLLER_DIRS) {
assertTrue(Files.isDirectory(dir), "Controller 目录不存在: " + dir);
try (Stream<Path> files = Files.list(dir)) {
for (Path f : files.filter(p -> p.toString().endsWith(".java")).toList()) {
String src = Files.readString(f, StandardCharsets.UTF_8);
Matcher mapping = Pattern.compile(
"@(Get|Post|Put|Delete)Mapping\\(([^)]*)\\)", Pattern.DOTALL).matcher(src);
while (mapping.find()) {
String args = mapping.group(2).toLowerCase();
for (String banned : FORBIDDEN_ENDPOINT_TOKENS) {
if (args.contains(banned)) {
violations.add(f.getFileName() + " → " + mapping.group(0)
.replaceAll("\\s+", " ") + " 含联系类端点: " + banned);
}
}
}
}
}
}
// b) 机制扫描:全仓 main 源码无 ContactAuthorization 类;迁移目录无建表
violations.addAll(scanForContactAuthorizationMechanism());
assertEquals(List.of(), violations,
"B1 联系规则契约被破坏(MM-CONTACT-003):出现联系类端点或联系授权机制。\n"
+ "契约:未来新增联系功能必须连同本守卫 + 007 治理登记同步更新。\n违规项:\n - "
+ String.join("\n - ", violations));
}
// ==================== MM-CONTACT-004:无机制即无到期场景 ====================
@Test
@DisplayName("MM-CONTACT-004: 无联系授权机制 ⇒ 不存在授权到期场景(同源断言承载)")
void noContactAuthorizationMechanismImpliesNoExpiryScenario() throws IOException {
/*
* ZC-MM-003(B1)「临时授权到期」场景的守卫:授权机制本身不存在(MM-CONTACT-003),
* 因此不存在"到期后权限回收"场景。本用例与 MM-CONTACT-003 共享同一底层断言 ——
* 若未来引入 ContactAuthorizationPolicy / matchmaker_contact_authorizations 表,
* 两条守卫同时判红,迫使实现者补齐「授权期限 + 到期回收 + 审计」语义,
* 并同步 007 治理登记(安全默认 = 不开放完整联系方式 / 未授权线下联系)。
*/
List<String> violations = scanForContactAuthorizationMechanism();
assertEquals(List.of(), violations,
"MM-CONTACT-004:联系授权机制出现 ⇒ 必须同步实现到期回收与审计,并过 007 评审。\n违规项:\n - "
+ String.join("\n - ", violations));
}
// ==================== ZC-MM-004 佐证:无批处理代操作面 ====================
@Test
@DisplayName("ZC-MM-004 佐证: matchmaker/referral 包无 @Scheduled(无批处理代操作通道)")
void noScheduledTasksInMatchmakerAndReferralPackages() throws IOException {
List<String> violations = new ArrayList<>();
for (Path dir : PACKAGE_DIRS) {
assertTrue(Files.isDirectory(dir), "包目录不存在: " + dir);
try (Stream<Path> files = Files.walk(dir)) {
for (Path f : files.filter(p -> p.toString().endsWith(".java")).toList()) {
String src = stripComments(Files.readString(f, StandardCharsets.UTF_8));
if (src.contains("@Scheduled")) {
violations.add(f.getFileName() + " 含 @Scheduled 定时任务"
+ "(ZC-MM-004:禁止引入代替用户决定/代操作的自动化通道)");
}
}
}
}
assertEquals(List.of(), violations,
"红娘/转介域出现定时任务必须先过「禁止代用户决定」评审(ZC-MM-004)");
}
// ==================== 解析实现 ====================
/** 全仓 main 源码 + 迁移脚本扫描 ContactAuthorization 机制(003/004 共享)。 */
private List<String> scanForContactAuthorizationMechanism() throws IOException {
List<String> violations = new ArrayList<>();
try (Stream<Path> files = Files.walk(SRC_ROOT)) {
for (Path f : files.filter(p -> p.toString().endsWith(".java")).toList()) {
String name = f.getFileName().toString();
if (name.contains("ContactAuthorization")) {
violations.add("出现联系授权类(机制被引入): " + name);
continue;
}
String src = stripComments(Files.readString(f, StandardCharsets.UTF_8));
if (src.contains("ContactAuthorization")) {
violations.add("源码引用联系授权机制: " + f.getFileName());
}
}
}
for (Path migrationDir : migrationDirCandidates()) {
if (!Files.isDirectory(migrationDir)) {
continue;
}
try (Stream<Path> files = Files.list(migrationDir)) {
for (Path f : files.filter(p -> p.toString().endsWith(".sql")).toList()) {
String src = Files.readString(f, StandardCharsets.UTF_8).toLowerCase();
if (src.contains("matchmaker_contact_authorizations")) {
violations.add("出现 matchmaker_contact_authorizations 建表迁移: " + f.getFileName()
+ "(安全默认=不开放;仅当产品确认开放联系功能时随 006 评审引入)");
}
}
}
}
return violations;
}
/** 迁移脚本目录(兼容仓库根与单模块两种布局)。 */
private List<Path> migrationDirCandidates() {
Path parent = SRC_ROOT.getParent();
return List.of(
parent != null ? parent.resolve("resources/db/migration") : Path.of("backend/src/main/resources/db/migration"),
Path.of("backend/src/main/resources/db/migration"),
Path.of("src/main/resources/db/migration"));
}
private static String stripComments(String src) {
return src.replaceAll("//[^\\n]*", "").replaceAll("/\\*.*?\\*/", "");
}
/** 定位源码根目录(与 SecurityPathCoverageTest 同款双候选)。 */
private static Path resolveSourceRoot() {
for (String candidate : new String[]{"src/main/java", "backend/src/main/java"}) {
Path path = Path.of(candidate);
if (Files.isDirectory(path)) {
return path;
}
}
throw new IllegalStateException(
"无法定位源码根目录(尝试过 src/main/java 与 backend/src/main/java);当前工作目录="
+ Path.of("").toAbsolutePath());
}
}