diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerActionGuardTest.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerActionGuardTest.java
new file mode 100644
index 0000000..943142f
--- /dev/null
+++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerActionGuardTest.java
@@ -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 数据构造范式)。
+ *
+ *
锁定四条行为不变量(架构师设计 T02):
+ *
+ * - MM-ACTION-001 强化(负向):operator 主体 token 打用户专属决定端点
+ * (推荐 accept / pass、转介 confirm)→ 认证层 403
+ * (SecurityConfig 兜底 {@code hasRole("USER")}:运营身份根本进不到 Controller,
+ * SecurityPathCoverageTest 已机械锁定该兜底不得退回 authenticated()),
+ * 且 recommendation / registration 状态零变化 —— 「红娘不能替用户 Like/Pass/确认转介」。
+ * - MM-ACTION-002:会话开启端点(POST /conversations/from-match/{matchId})同为
+ * UserPrincipal 路径 → operator token 403(match 的产生同样只在用户 Like 路径,
+ * 无 operator 触达面)。
+ * - MM-ACTION-003 / must-confirm 语义:红娘创建转介 → 响应
+ * needsUserConfirmation=true、库中 userConfirmedAt=null、status 停留 pending;
+ * 红娘侧 recommend / followup / complete 动作不改变用户确认状态 ——
+ * 「转介代建 = 代填草稿、用户确认」语义被永久锁定。
+ * - MM-CONTACT-001(正向):红娘对本人分配对象创建推荐/转介 → notifications 表
+ * 落平台内服务通知(type=matchmaker),title/body 不含手机号/微信号模式;
+ * 通知收件面只有 C 端用户(无 operator 收件行)—— 平台内联系经通知中转,
+ * 红娘与用户之间不存在直连联系方式交换。
+ *
+ */
+@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 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 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 notes = notificationMapper.selectList(new LambdaQueryWrapper()
+ .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()
+ .eq(Notification::getUserId, MATCHMAKER_OWNER));
+ assertEquals(0L, operatorInbox,
+ "红娘不得成为通知收件人(联系方式交换无 operator 侧触达面)");
+ }
+}
diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactGuardTest.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactGuardTest.java
new file mode 100644
index 0000000..af45d44
--- /dev/null
+++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactGuardTest.java
@@ -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 上下文)。
+ *
+ * 锁定四条不变量(架构师设计 T01):
+ *
+ * - MM-CONTACT-002:ReferralService 全部响应 put 键 ⊆ 白名单(逐键对照实际代码
+ * 核定,见 {@link #RESPONSE_KEY_WHITELIST}),且键名不得含 emergencyContact / phone /
+ * mobile / wechat / address / idNumber 等联系方式字样 —— 锁定「红娘侧零联系方式回显」;
+ * emergencyContact* 是纯入口 write-only(写入 activity_registration 的紧急联系人字段),
+ * 连红娘本人都无读回面。
+ * - MM-CONTACT-003:matchmaker / referral Controller 无 contact / call / phone /
+ * channel / offline 类端点;全仓无 ContactAuthorization 类、无
+ * matchmaker_contact_authorizations 建表迁移 —— 锁定「不存在联系授权机制」。
+ * 契约:未来新增联系功能必须连同本守卫 + 007 治理登记同步更新,守卫即红。
+ * - MM-CONTACT-004:无联系授权机制即无授权到期场景 —— 与 MM-CONTACT-003 同源断言
+ * 承载(若未来引入 ContactAuthorization / matchmaker_contact_authorizations,
+ * 003/004 两条守卫同时判红,迫使开发者补「到期 + 审计」语义并过 007 评审)。
+ * - ZC-MM-004 佐证:matchmaker / referral 包无 @Scheduled —— 全仓无批处理
+ * 代操作面(禁止代替用户决定的自动化通道不存在)。
+ *
+ */
+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 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 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:以实际代码逐键核对后核定):
+ *
+ * - referToServiceStation → appointmentId / status / referralType /
+ * referralFromOperatorId / needsUserConfirmation
+ * - referToActivity → registrationId / activityId / status / referralType /
+ * referralFromOperatorId / needsUserConfirmation
+ * - confirmReferral → id / status / confirmedAt
+ * - getReferralHistory → type / id / serviceType|activityId / status /
+ * createdByOperatorId / userConfirmedAt / createdAt|registeredAt
+ * - getReferralHistoryByOperator → 同上 + userId(operator 查看本人发起的
+ * 转介记录需要 userId 标识服务对象;userId 是内部标识符,非联系方式,
+ * 不落入 B1 禁放范畴)
+ *
+ */
+ private static final Set 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 FORBIDDEN_KEY_TOKENS = List.of(
+ "emergencycontact", "phone", "mobile", "wechat", "weixin", "address",
+ "idnumber", "id_card");
+
+ /** MM-CONTACT-003:联系/触达类禁用端点词 */
+ private static final List 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 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 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 violations = new ArrayList<>();
+
+ // a) 端点扫描:matchmaker / referral Controller 无联系/触达类路径
+ for (Path dir : CONTROLLER_DIRS) {
+ assertTrue(Files.isDirectory(dir), "Controller 目录不存在: " + dir);
+ try (Stream 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 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 violations = new ArrayList<>();
+ for (Path dir : PACKAGE_DIRS) {
+ assertTrue(Files.isDirectory(dir), "包目录不存在: " + dir);
+ try (Stream 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 scanForContactAuthorizationMechanism() throws IOException {
+ List violations = new ArrayList<>();
+ try (Stream 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 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 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());
+ }
+}