feat(matchmaker): 受控联系审批分层落地(OQ-MATCHMAKER-009 裁决:组长主审+管理员兜底)+ enabled fail-safe 行为基线

- ContactAuthorizationService approve/reject 分层语义:matchmaker.assign 平台管理员=兜底岗(全量审批、不做组织校验);approver-authorities 首段(默认 matchmaker.team.assign)=主审岗(assertTeamScope 组织范围,hasGlobalAssign 固定 false 防权限面双通道,NULL=NULL 平台直属可审);两者皆无 403 deny 不变
- 真实缺口修复(OQ-010 引出):request 入口原未查 matchmaker.contact.enabled,补 denyAsNotFound fail-safe(与 disclose 口径一致:最先、404 化、防探测)
- 测试:MatchmakerContactAuthorizationTest 10→13(管理员跨组织兜底/组长他组织 403/组长审平台直属 NULL=NULL/双岗兜底全量,均 HTTP+审计双断言);新增 MatchmakerContactDisabledTest(enabled=false 时 disclose/request 404 化、零 SensitiveAccessLog、零 contact 成功审计、fail-safe 先于 purpose 校验)
- 负向注入:移除兜底分支 → 恰好 2 例分层用例判红(主审路径保持绿,判别力精准)→ 还原复绿
- 验证:主理人 Docker 独立实跑 20/20(授权 13+disabled 2+守卫 5);全量 mvn verify 900/900(3 skip);后端契约 234/CURATED 20;UI 24/24
- 裁决依据:OQ-MATCHMAKER-009(组长主审+管理员兜底)、LEGAL_VERIFY③ 7 天/④ 365 天/⑤ NULL=NULL 维持(登记于 governance)
This commit is contained in:
WorkBuddy
2026-10-11 15:57:56 +08:00
parent 21275ef770
commit f73a116a10
4 changed files with 265 additions and 27 deletions
@@ -29,10 +29,20 @@ public class ContactAuthorizationConfig {
private boolean enabled = false; private boolean enabled = false;
/** /**
* 审批岗权限码(逗号分隔,任一命中即可审批)。 * 审批岗权限码(逗号分隔,<b>分层语义</b>,用户裁决①:组长主审 + 管理员兜底)。
* 默认 "matchmaker.team.assign,matchmaker.assign"(组长组织内审批 / 平台授权岗位全量审批)。 * 默认 "matchmaker.team.assign,matchmaker.assign"(键名与默认值不变)。
* 审批权限在 Service 层动态判定(principal 权限 ∈ 本集合),不用 @PreAuthorize 静态注解 —— *
* 审批岗名单可经 yml 调整而无需改代码。 * <p>分层规则(ContactAuthorizationService.assertApproverAuthority):
* <ol>
* <li>列表<b>首段 = 主审岗</b>(默认 {@code matchmaker.team.assign},组长)——
* 仅可审批本组织红娘的申请(NULL=NULL 平台直属,assertTeamScope 复用);</li>
* <li><b>兜底岗键固定 {@code matchmaker.assign}</b>(平台管理员)——
* 可审批全部申请且不做组织校验;兜底键不受本列表调整影响(权限面不放大);</li>
* <li>两者皆无 → 403 拒绝。</li>
* </ol>
*
* <p>审批权限在 Service 层动态判定(principal 权限 ∈ 分层规则),不用 @PreAuthorize
* 静态注解 —— 主审岗名单可经 yml 调整而无需改代码。
*/ */
private String approverAuthorities = "matchmaker.team.assign,matchmaker.assign"; private String approverAuthorities = "matchmaker.team.assign,matchmaker.assign";
@@ -34,10 +34,11 @@ import java.util.stream.Collectors;
* *
* <p>六操作状态机(全矩阵): * <p>六操作状态机(全矩阵):
* <pre> * <pre>
* request : 红娘(须持分配)→ pending_approval,审计含 service_assignment_id * request : 红娘(须持分配 + 机制开启)→ pending_approval,审计含 service_assignment_id
* approve : 审批岗(权限码动态判定 ∈ approver-authorities + 组长组织范围) * approve : 审批岗分层判定(用户裁决①:组长主审 + 管理员兜底,见
* pending_approval → pending_consent;违规 403(无资源枚举面维持 403) * {@link #assertApproverAuthority})pending_approval → pending_consent;
* reject : 审批岗拒绝 pending_approval → rejected * 违规 403(无资源枚举面维持 403)
* reject : 审批岗(分层同 approve)拒绝 pending_approval → rejected
* consent : C 端用户本人(归属校验,独立端点、无预同意参数) * consent : C 端用户本人(归属校验,独立端点、无预同意参数)
* pending_consent → active(expires_at = consent + default-validity-days) * pending_consent → active(expires_at = consent + default-validity-days)
* decline : C 端用户本人 pending_consent → rejected * decline : C 端用户本人 pending_consent → rejected
@@ -59,6 +60,11 @@ public class ContactAuthorizationService {
private static final String RESOURCE_TYPE = "matchmaker_contact_authorization"; private static final String RESOURCE_TYPE = "matchmaker_contact_authorization";
/** 披露敏感访问的数据类型(SensitiveAccessLog.data_type) */ /** 披露敏感访问的数据类型(SensitiveAccessLog.data_type) */
public static final String SENSITIVE_DATA_TYPE_PHONE_DISCLOSURE = "user_phone_disclosure"; public static final String SENSITIVE_DATA_TYPE_PHONE_DISCLOSURE = "user_phone_disclosure";
/**
* 兜底审批岗权限键(用户裁决① 固定):持有者可审批全部申请且不做组织校验。
* 主审岗权限键仍由 approver-authorities 列表首段配置(默认 matchmaker.team.assign)。
*/
private static final String FALLBACK_APPROVER_AUTHORITY = "matchmaker.assign";
private final MatchmakerContactAuthorizationMapper authorizationMapper; private final MatchmakerContactAuthorizationMapper authorizationMapper;
private final ServiceAssignmentPolicy serviceAssignmentPolicy; private final ServiceAssignmentPolicy serviceAssignmentPolicy;
@@ -75,6 +81,12 @@ public class ContactAuthorizationService {
@Transactional @Transactional
public MatchmakerContactAuthorization request(String operatorId, String userId, public MatchmakerContactAuthorization request(String operatorId, String userId,
String channel, String purpose) { String channel, String purpose) {
// fail-safe 最先(与 disclose 同口径):机制关闭(配置缺失/显式关闭)→ 404 化拒绝。
// ZC-MM-003 裁决落地 T-C:request 原实现漏查 enabled(真实缺口,本轮补齐)。
if (!config.isEnabled()) {
dataScopeGuard.denyAsNotFound(operatorId, RESOURCE_TYPE, userId,
"服务不存在", null, null);
}
if (purpose == null || purpose.isBlank()) { if (purpose == null || purpose.isBlank()) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "授权目的必填"); throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "授权目的必填");
} }
@@ -114,17 +126,28 @@ public class ContactAuthorizationService {
return auth; return auth;
} }
// ==================== ② approve / ③ reject:审批岗 ==================== // ==================== ② approve / ③ reject:审批岗(分层) ====================
/** 审批岗权限码动态判定(principal 权限 ∈ approver-authorities 配置,非 @PreAuthorize 静态)。 */ /**
* 审批岗分层判定(用户裁决①:组长主审 + 管理员兜底)。
*
* <p>分层规则:
* <ol>
* <li>持 {@code matchmaker.assign}(兜底岗,平台管理员)→ 可审批全部申请,
* <b>不做组织校验</b>(见 {@link #assertOrgScopeForApprover});</li>
* <li>否则持主审岗权限键(approver-authorities 列表首段,默认
* {@code matchmaker.team.assign},组长)→ 主审,须通过
* {@code assertTeamScope} 组织范围校验(NULL=NULL 平台直属,现状维持);</li>
* <li>两者皆无 → 403 deny(不变)。</li>
* </ol>
*
* <p>主审岗键仍由配置驱动(列表首段),兜底岗键固定为 {@code matchmaker.assign}
* —— 配置只能收紧/调整主审岗,不能扩大兜底岗权限面。
*/
private void assertApproverAuthority(String approverOperatorId, String authId, private void assertApproverAuthority(String approverOperatorId, String authId,
Set<String> principalPermissions) { Set<String> principalPermissions) {
Set<String> approverAuthorities = Arrays.stream(config.getApproverAuthorities().split(",")) boolean authorized = isFallbackApprover(principalPermissions)
.map(String::trim) || isPrimaryApprover(principalPermissions);
.filter(s -> !s.isEmpty())
.collect(Collectors.toSet());
boolean authorized = principalPermissions != null
&& principalPermissions.stream().anyMatch(approverAuthorities::contains);
if (!authorized) { if (!authorized) {
// 非审批岗 → 403(approve 无资源枚举面,维持 403;边界登记 T06 OQ-MATCHMAKER-009) // 非审批岗 → 403(approve 无资源枚举面,维持 403;边界登记 T06 OQ-MATCHMAKER-009)
dataScopeGuard.deny(approverOperatorId, RESOURCE_TYPE, authId, dataScopeGuard.deny(approverOperatorId, RESOURCE_TYPE, authId,
@@ -132,6 +155,35 @@ public class ContactAuthorizationService {
} }
} }
/** 兜底岗判定:持固定键 matchmaker.assign(平台管理员)→ 全量审批、无组织校验。 */
private boolean isFallbackApprover(Set<String> principalPermissions) {
return principalPermissions != null && principalPermissions.contains(FALLBACK_APPROVER_AUTHORITY);
}
/** 主审岗判定:principal 权限 ∈ approver-authorities 任一(默认首段 team.assign 组长)。 */
private boolean isPrimaryApprover(Set<String> principalPermissions) {
Set<String> approverAuthorities = Arrays.stream(config.getApproverAuthorities().split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toSet());
return principalPermissions != null
&& principalPermissions.stream().anyMatch(approverAuthorities::contains);
}
/**
* 分层组织范围校验(用户裁决①):兜底岗(持 matchmaker.assign)跳过组织校验;
* 主审岗(组长)固定走 {@code assertTeamScope}(hasGlobalAssign=false ——
* 是否兜底由本层判定,不再向底层传组织豁免,防止权限面双通道扩大)。
*/
private void assertOrgScopeForApprover(String approverOperatorId, String authId,
Set<String> principalPermissions, String targetOperatorId) {
if (isFallbackApprover(principalPermissions)) {
return;
}
matchmakerAssignmentService.assertTeamScope(approverOperatorId, false,
targetOperatorId, authId);
}
@Transactional @Transactional
public MatchmakerContactAuthorization approve(String authId, String approverOperatorId, public MatchmakerContactAuthorization approve(String authId, String approverOperatorId,
Set<String> principalPermissions) { Set<String> principalPermissions) {
@@ -141,11 +193,9 @@ public class ContactAuthorizationService {
throw new BusinessException(ApiResponseCode.CONFLICT, throw new BusinessException(ApiResponseCode.CONFLICT,
"当前状态(" + auth.getStatus() + ")不允许审批"); "当前状态(" + auth.getStatus() + ")不允许审批");
} }
// 组长组织范围校验(002 assertAssignScope 复用):matchmaker.assign 持有者不限组织 // 分层组织范围校验(用户裁决①):管理员兜底全量 / 组长主审限同组织(NULL=NULL 平台直属)
boolean hasGlobalAssign = principalPermissions != null assertOrgScopeForApprover(approverOperatorId, authId, principalPermissions,
&& principalPermissions.contains("matchmaker.assign"); auth.getOperatorId());
matchmakerAssignmentService.assertTeamScope(approverOperatorId, hasGlobalAssign,
auth.getOperatorId(), authId);
auth.setApprovedBy(approverOperatorId); auth.setApprovedBy(approverOperatorId);
auth.setApprovedAt(Instant.now()); auth.setApprovedAt(Instant.now());
@@ -170,10 +220,9 @@ public class ContactAuthorizationService {
throw new BusinessException(ApiResponseCode.CONFLICT, throw new BusinessException(ApiResponseCode.CONFLICT,
"当前状态(" + auth.getStatus() + ")不允许拒绝"); "当前状态(" + auth.getStatus() + ")不允许拒绝");
} }
boolean hasGlobalAssign = principalPermissions != null // 分层组织范围校验(用户裁决①,与 approve 同口径)
&& principalPermissions.contains("matchmaker.assign"); assertOrgScopeForApprover(approverOperatorId, authId, principalPermissions,
matchmakerAssignmentService.assertTeamScope(approverOperatorId, hasGlobalAssign, auth.getOperatorId());
auth.getOperatorId(), authId);
auth.setStatus(Constants.CONTACT_AUTH_STATUS_REJECTED); auth.setStatus(Constants.CONTACT_AUTH_STATUS_REJECTED);
authorizationMapper.updateById(auth); authorizationMapper.updateById(auth);
@@ -79,6 +79,9 @@ class MatchmakerContactAuthorizationTest {
private static final String ORG_Y = "55555555-5555-5555-5555-555555555552"; private static final String ORG_Y = "55555555-5555-5555-5555-555555555552";
private static final Set<String> P_LEADER = Set.of("matchmaker.view", "matchmaker.team.assign"); private static final Set<String> P_LEADER = Set.of("matchmaker.view", "matchmaker.team.assign");
private static final Set<String> P_PLATFORM = Set.of("matchmaker.view", "matchmaker.assign"); private static final Set<String> P_PLATFORM = Set.of("matchmaker.view", "matchmaker.assign");
/** 双岗:team.assign(主审)+ assign(兜底)——按分层规则含 assign 即兜底全量 */
private static final Set<String> P_DUAL =
Set.of("matchmaker.view", "matchmaker.team.assign", "matchmaker.assign");
@BeforeEach @BeforeEach
void setUp() { void setUp() {
@@ -266,13 +269,85 @@ class MatchmakerContactAuthorizationTest {
} }
@Test @Test
@DisplayName("B1 负向③: 跨组织组长 approve → 403(组织范围校验)") @DisplayName("B1 负向③: 仅 team.assign 组长审他组织 → 403 + DATA_SCOPE_DENIED 审计(分层·主审岗)")
void crossOrgLeaderCannotApprove() { void crossOrgLeaderCannotApprove() {
MatchmakerContactAuthorization auth = contactAuthorizationService.request( MatchmakerContactAuthorization auth = contactAuthorizationService.request(
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通"); mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通");
long before = deniedAudits(leaderY, auth.getId());
assertThrows(AccessDeniedException.class, assertThrows(AccessDeniedException.class,
() -> contactAuthorizationService.approve(auth.getId(), leaderY, P_LEADER), () -> contactAuthorizationService.approve(auth.getId(), leaderY, P_LEADER),
"跨组织组长不得审批"); "跨组织组长(仅主审岗)不得审批");
assertEquals(before + 1, deniedAudits(leaderY, auth.getId()),
"跨组织组长审批拒绝必须落 DATA_SCOPE_DENIED 审计");
}
// ==================== 分层审批语义(用户裁决①:组长主审 + 管理员兜底) ====================
@Test
@DisplayName("分层正例①: 仅 assign 管理员(兜底岗)审批跨组织申请 → 成功 + 审计行")
void platformFallbackApproverCanApproveCrossOrg() {
// 管理员挂任意组织(org Y)——兜底岗不做组织校验
String admin = createOperator("联系授权平台管理员", ORG_Y);
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "跨组织管理员兜底审批");
MatchmakerContactAuthorization approved = contactAuthorizationService.approve(
auth.getId(), admin, P_PLATFORM);
// 结果码断言:approve 成功 → pending_consent
assertEquals(Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT, approved.getStatus());
assertEquals(admin, approved.getApprovedBy());
// 审计行断言:approve 审计行存在且 purpose_code 正确、操作者=管理员
AuditLog audit = latestContactAudit(Constants.CONTACT_AUDIT_ACTION_APPROVE, auth.getId());
assertNotNull(audit, "兜底审批成功必须落 MATCHMAKER_CONTACT_APPROVE 审计行");
assertEquals("matchmaker.contact.approve", audit.getPurposeCode());
assertEquals(admin, audit.getOperatorId());
}
@Test
@DisplayName("分层正例②: 平台直属组长(org NULL)审平台直属红娘申请 → 成功 + 审计行")
void platformDirectLeaderCanApproveNullOrgMatchmaker() {
// 平台直属链路:org=NULL 组长 + org=NULL 红娘(NULL=NULL 视为平台直属,现状⑤维持)
String leaderN = createOperator("平台直属组长", null);
String mmNull = createOperator("平台直属红娘", null);
String userNull = createUser("13600009998");
MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
svc.setUserId(userNull);
svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
svc.setDescription("平台直属联系授权测试服务单");
svc.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED);
svc.setAssignedOperatorId(mmNull);
serviceMapper.insert(svc);
// 主审组长(hasGlobalAssign=false)走 NULL=NULL 组织校验创建分配
assignmentService.create(leaderN, false, userNull, mmNull, svc.getId(),
"平台直属分配", null);
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
mmNull, userNull, Constants.CONTACT_CHANNEL_PHONE, "平台直属电话沟通");
MatchmakerContactAuthorization approved = contactAuthorizationService.approve(
auth.getId(), leaderN, P_LEADER);
assertEquals(Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT, approved.getStatus());
assertEquals(leaderN, approved.getApprovedBy());
AuditLog audit = latestContactAudit(Constants.CONTACT_AUDIT_ACTION_APPROVE, auth.getId());
assertNotNull(audit, "平台直属组长审批成功必须落 MATCHMAKER_CONTACT_APPROVE 审计行");
assertEquals("matchmaker.contact.approve", audit.getPurposeCode());
assertEquals(leaderN, audit.getOperatorId());
}
@Test
@DisplayName("分层正例③: 双岗(team.assign+assign)审他组织 → 含 assign 即兜底全量 + 审计行")
void dualRoleApproverFallsBackToFullScope() {
// 用户裁决①分层规则的既定语义:兜底岗键固定 matchmaker.assign,
// 双岗持有者按兜底全量处理(不做组织校验)——权限面以最高档收敛,不叠加受限。
String dual = createOperator("双岗审批人", ORG_Y);
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "双岗跨组织审批");
MatchmakerContactAuthorization approved = contactAuthorizationService.approve(
auth.getId(), dual, P_DUAL);
assertEquals(Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT, approved.getStatus());
assertEquals(dual, approved.getApprovedBy());
AuditLog audit = latestContactAudit(Constants.CONTACT_AUDIT_ACTION_APPROVE, auth.getId());
assertNotNull(audit, "双岗兜底审批成功必须落 MATCHMAKER_CONTACT_APPROVE 审计行");
assertEquals("matchmaker.contact.approve", audit.getPurposeCode());
assertEquals(dual, audit.getOperatorId());
} }
@Test @Test
@@ -0,0 +1,104 @@
package com.danyang.match.platform.matchmaker;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.entity.SensitiveAccessLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.audit.mapper.SensitiveAccessLogMapper;
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerContactAuthorizationMapper;
import com.danyang.match.platform.matchmaker.service.ContactAuthorizationService;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
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.context.TestPropertySource;
import org.springframework.transaction.annotation.Transactional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
/**
* OQ-010 验收:机制总开关关闭(matchmaker.contact.enabled=false)时的 fail-safe 行为。
*
* <p>fail-safe 语义(ContactAuthorizationConfig.enabled Java 缺省 false,配置缺失=机制关闭):
* <ul>
* <li>disclose → 404 化拒绝(fail-safe 校验最先,G4 防探测:与"资源不存在"同响应);</li>
* <li>request → 404 化拒绝(ZC-MM-003 裁决落地 T-C 补齐:原实现漏查 enabled,
* 属真实缺口,本轮在 Service 层补 fail-safe 校验,口径与 disclose 一致);</li>
* <li>拒绝时<b>不落</b> SensitiveAccessLog、<b>不落</b>任何 matchmaker.contact.* 成功审计行
* (DATA_SCOPE_DENIED deny 审计可有,用于事后追责)。</li>
* </ul>
*/
@SpringBootTest(properties = "matchmaker.contact.enabled=false")
@ActiveProfiles("test")
@Transactional
class MatchmakerContactDisabledTest {
private static final String OPERATOR_ID = "disabled-test-operator-0001";
private static final String USER_ID = "disabled-test-user-0001";
@Autowired private ContactAuthorizationService contactAuthorizationService;
@Autowired private MatchmakerContactAuthorizationMapper authorizationMapper;
@Autowired private AuditLogMapper auditLogMapper;
@Autowired private SensitiveAccessLogMapper sensitiveAccessLogMapper;
@Test
@DisplayName("开关关闭: disclose → 404 化拒绝,不落 SensitiveAccessLog / 成功审计行")
void discloseRejectedWhenDisabled() {
BusinessException e = assertThrows(BusinessException.class,
() -> contactAuthorizationService.disclose(OPERATOR_ID, USER_ID,
Constants.CONTACT_CHANNEL_PHONE, "机制关闭后披露"),
"机制关闭时披露必须 fail-safe 拒绝");
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode(), "fail-safe 必须 404 化(G4 防探测)");
assertEquals("服务不存在", e.getMessage());
// 不落 SensitiveAccessLog(成功行)
Long sensitiveCount = sensitiveAccessLogMapper.selectCount(new LambdaQueryWrapper<SensitiveAccessLog>()
.eq(SensitiveAccessLog::getOperatorId, OPERATOR_ID)
.eq(SensitiveAccessLog::getDataType,
ContactAuthorizationService.SENSITIVE_DATA_TYPE_PHONE_DISCLOSURE));
assertEquals(0L, sensitiveCount, "机制关闭的拒绝披露不得落 SensitiveAccessLog");
// 不落 matchmaker.contact.disclose 成功审计行(DATA_SCOPE_DENIED deny 审计可有)
Long discloseAuditCount = auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
.eq(AuditLog::getAction, Constants.CONTACT_AUDIT_ACTION_DISCLOSE)
.eq(AuditLog::getOperatorId, OPERATOR_ID));
assertEquals(0L, discloseAuditCount, "机制关闭的拒绝披露不得落成功审计行");
}
@Test
@DisplayName("开关关闭: request → 404 化拒绝(T-C 补齐真实缺口),不落任何成功审计行")
void requestRejectedWhenDisabled() {
BusinessException e = assertThrows(BusinessException.class,
() -> contactAuthorizationService.request(OPERATOR_ID, USER_ID,
Constants.CONTACT_CHANNEL_PHONE, "机制关闭后申请"),
"机制关闭时申请必须 fail-safe 拒绝");
assertEquals(ApiResponseCode.NOT_FOUND, e.getCode(), "fail-safe 必须 404 化(与 disclose 同口径)");
assertEquals("服务不存在", e.getMessage());
// 不产生任何授权记录(fail-safe 校验先于一切业务逻辑)
Long authCount = authorizationMapper.selectCount(new LambdaQueryWrapper<MatchmakerContactAuthorization>()
.eq(MatchmakerContactAuthorization::getOperatorId, OPERATOR_ID));
assertEquals(0L, authCount, "机制关闭的拒绝申请不得产生授权记录");
// 不落 matchmaker.contact.request 成功审计行(DATA_SCOPE_DENIED deny 审计可有)
Long requestAuditCount = auditLogMapper.selectCount(new LambdaQueryWrapper<AuditLog>()
.eq(AuditLog::getAction, Constants.CONTACT_AUDIT_ACTION_REQUEST)
.eq(AuditLog::getOperatorId, OPERATOR_ID));
assertEquals(0L, requestAuditCount, "机制关闭的拒绝申请不得落成功审计行");
// fail-safe 必须先于 purpose/channel 业务校验(缺目的的申请同样 404 化拒绝)
BusinessException e2 = assertThrows(BusinessException.class,
() -> contactAuthorizationService.request(OPERATOR_ID, USER_ID,
Constants.CONTACT_CHANNEL_PHONE, null));
assertEquals(ApiResponseCode.NOT_FOUND, e2.getCode(),
"fail-safe 必须最先判定,不得让业务校验异常(400/422)穿透");
assertNotNull(e2.getMessage());
}
}