diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/config/ContactAuthorizationConfig.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/config/ContactAuthorizationConfig.java index a8d89f5..2609df1 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/config/ContactAuthorizationConfig.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/config/ContactAuthorizationConfig.java @@ -29,10 +29,20 @@ public class ContactAuthorizationConfig { private boolean enabled = false; /** - * 审批岗权限码(逗号分隔,任一命中即可审批)。 - * 默认 "matchmaker.team.assign,matchmaker.assign"(组长组织内审批 / 平台授权岗位全量审批)。 - * 审批权限在 Service 层动态判定(principal 权限 ∈ 本集合),不用 @PreAuthorize 静态注解 —— - * 审批岗名单可经 yml 调整而无需改代码。 + * 审批岗权限码(逗号分隔,分层语义,用户裁决①:组长主审 + 管理员兜底)。 + * 默认 "matchmaker.team.assign,matchmaker.assign"(键名与默认值不变)。 + * + *
分层规则(ContactAuthorizationService.assertApproverAuthority): + *
审批权限在 Service 层动态判定(principal 权限 ∈ 分层规则),不用 @PreAuthorize + * 静态注解 —— 主审岗名单可经 yml 调整而无需改代码。 */ private String approverAuthorities = "matchmaker.team.assign,matchmaker.assign"; diff --git a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/ContactAuthorizationService.java b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/ContactAuthorizationService.java index 488c757..022a8f6 100644 --- a/backend/src/main/java/com/danyang/match/platform/matchmaker/service/ContactAuthorizationService.java +++ b/backend/src/main/java/com/danyang/match/platform/matchmaker/service/ContactAuthorizationService.java @@ -34,10 +34,11 @@ import java.util.stream.Collectors; * *
六操作状态机(全矩阵): *
- * request : 红娘(须持分配)→ pending_approval,审计含 service_assignment_id
- * approve : 审批岗(权限码动态判定 ∈ approver-authorities + 组长组织范围)
- * pending_approval → pending_consent;违规 403(无资源枚举面维持 403)
- * reject : 审批岗拒绝 pending_approval → rejected
+ * request : 红娘(须持分配 + 机制开启)→ pending_approval,审计含 service_assignment_id
+ * approve : 审批岗分层判定(用户裁决①:组长主审 + 管理员兜底,见
+ * {@link #assertApproverAuthority})pending_approval → pending_consent;
+ * 违规 403(无资源枚举面维持 403)
+ * reject : 审批岗(分层同 approve)拒绝 pending_approval → rejected
* consent : C 端用户本人(归属校验,独立端点、无预同意参数)
* pending_consent → active(expires_at = consent + default-validity-days)
* decline : C 端用户本人 pending_consent → rejected
@@ -59,6 +60,11 @@ public class ContactAuthorizationService {
private static final String RESOURCE_TYPE = "matchmaker_contact_authorization";
/** 披露敏感访问的数据类型(SensitiveAccessLog.data_type) */
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 ServiceAssignmentPolicy serviceAssignmentPolicy;
@@ -75,6 +81,12 @@ public class ContactAuthorizationService {
@Transactional
public MatchmakerContactAuthorization request(String operatorId, String userId,
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()) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "授权目的必填");
}
@@ -114,17 +126,28 @@ public class ContactAuthorizationService {
return auth;
}
- // ==================== ② approve / ③ reject:审批岗 ====================
+ // ==================== ② approve / ③ reject:审批岗(分层) ====================
- /** 审批岗权限码动态判定(principal 权限 ∈ approver-authorities 配置,非 @PreAuthorize 静态)。 */
+ /**
+ * 审批岗分层判定(用户裁决①:组长主审 + 管理员兜底)。
+ *
+ * 分层规则:
+ *
+ * - 持 {@code matchmaker.assign}(兜底岗,平台管理员)→ 可审批全部申请,
+ * 不做组织校验(见 {@link #assertOrgScopeForApprover});
+ * - 否则持主审岗权限键(approver-authorities 列表首段,默认
+ * {@code matchmaker.team.assign},组长)→ 主审,须通过
+ * {@code assertTeamScope} 组织范围校验(NULL=NULL 平台直属,现状维持);
+ * - 两者皆无 → 403 deny(不变)。
+ *
+ *
+ * 主审岗键仍由配置驱动(列表首段),兜底岗键固定为 {@code matchmaker.assign}
+ * —— 配置只能收紧/调整主审岗,不能扩大兜底岗权限面。
+ */
private void assertApproverAuthority(String approverOperatorId, String authId,
Set principalPermissions) {
- Set approverAuthorities = Arrays.stream(config.getApproverAuthorities().split(","))
- .map(String::trim)
- .filter(s -> !s.isEmpty())
- .collect(Collectors.toSet());
- boolean authorized = principalPermissions != null
- && principalPermissions.stream().anyMatch(approverAuthorities::contains);
+ boolean authorized = isFallbackApprover(principalPermissions)
+ || isPrimaryApprover(principalPermissions);
if (!authorized) {
// 非审批岗 → 403(approve 无资源枚举面,维持 403;边界登记 T06 OQ-MATCHMAKER-009)
dataScopeGuard.deny(approverOperatorId, RESOURCE_TYPE, authId,
@@ -132,6 +155,35 @@ public class ContactAuthorizationService {
}
}
+ /** 兜底岗判定:持固定键 matchmaker.assign(平台管理员)→ 全量审批、无组织校验。 */
+ private boolean isFallbackApprover(Set principalPermissions) {
+ return principalPermissions != null && principalPermissions.contains(FALLBACK_APPROVER_AUTHORITY);
+ }
+
+ /** 主审岗判定:principal 权限 ∈ approver-authorities 任一(默认首段 team.assign 组长)。 */
+ private boolean isPrimaryApprover(Set principalPermissions) {
+ Set 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 principalPermissions, String targetOperatorId) {
+ if (isFallbackApprover(principalPermissions)) {
+ return;
+ }
+ matchmakerAssignmentService.assertTeamScope(approverOperatorId, false,
+ targetOperatorId, authId);
+ }
+
@Transactional
public MatchmakerContactAuthorization approve(String authId, String approverOperatorId,
Set principalPermissions) {
@@ -141,11 +193,9 @@ public class ContactAuthorizationService {
throw new BusinessException(ApiResponseCode.CONFLICT,
"当前状态(" + auth.getStatus() + ")不允许审批");
}
- // 组长组织范围校验(002 assertAssignScope 复用):matchmaker.assign 持有者不限组织
- boolean hasGlobalAssign = principalPermissions != null
- && principalPermissions.contains("matchmaker.assign");
- matchmakerAssignmentService.assertTeamScope(approverOperatorId, hasGlobalAssign,
- auth.getOperatorId(), authId);
+ // 分层组织范围校验(用户裁决①):管理员兜底全量 / 组长主审限同组织(NULL=NULL 平台直属)
+ assertOrgScopeForApprover(approverOperatorId, authId, principalPermissions,
+ auth.getOperatorId());
auth.setApprovedBy(approverOperatorId);
auth.setApprovedAt(Instant.now());
@@ -170,10 +220,9 @@ public class ContactAuthorizationService {
throw new BusinessException(ApiResponseCode.CONFLICT,
"当前状态(" + auth.getStatus() + ")不允许拒绝");
}
- boolean hasGlobalAssign = principalPermissions != null
- && principalPermissions.contains("matchmaker.assign");
- matchmakerAssignmentService.assertTeamScope(approverOperatorId, hasGlobalAssign,
- auth.getOperatorId(), authId);
+ // 分层组织范围校验(用户裁决①,与 approve 同口径)
+ assertOrgScopeForApprover(approverOperatorId, authId, principalPermissions,
+ auth.getOperatorId());
auth.setStatus(Constants.CONTACT_AUTH_STATUS_REJECTED);
authorizationMapper.updateById(auth);
diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactAuthorizationTest.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactAuthorizationTest.java
index eabf51e..a549761 100644
--- a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactAuthorizationTest.java
+++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactAuthorizationTest.java
@@ -79,6 +79,9 @@ class MatchmakerContactAuthorizationTest {
private static final String ORG_Y = "55555555-5555-5555-5555-555555555552";
private static final Set P_LEADER = Set.of("matchmaker.view", "matchmaker.team.assign");
private static final Set P_PLATFORM = Set.of("matchmaker.view", "matchmaker.assign");
+ /** 双岗:team.assign(主审)+ assign(兜底)——按分层规则含 assign 即兜底全量 */
+ private static final Set P_DUAL =
+ Set.of("matchmaker.view", "matchmaker.team.assign", "matchmaker.assign");
@BeforeEach
void setUp() {
@@ -266,13 +269,85 @@ class MatchmakerContactAuthorizationTest {
}
@Test
- @DisplayName("B1 负向③: 跨组织组长 approve → 403(组织范围校验)")
+ @DisplayName("B1 负向③: 仅 team.assign 组长审他组织 → 403 + DATA_SCOPE_DENIED 审计(分层·主审岗)")
void crossOrgLeaderCannotApprove() {
MatchmakerContactAuthorization auth = contactAuthorizationService.request(
mmOwner, user, Constants.CONTACT_CHANNEL_PHONE, "电话沟通");
+ long before = deniedAudits(leaderY, auth.getId());
assertThrows(AccessDeniedException.class,
() -> 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
diff --git a/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactDisabledTest.java b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactDisabledTest.java
new file mode 100644
index 0000000..94509a3
--- /dev/null
+++ b/backend/src/test/java/com/danyang/match/platform/matchmaker/MatchmakerContactDisabledTest.java
@@ -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 行为。
+ *
+ * fail-safe 语义(ContactAuthorizationConfig.enabled Java 缺省 false,配置缺失=机制关闭):
+ *
+ * - disclose → 404 化拒绝(fail-safe 校验最先,G4 防探测:与"资源不存在"同响应);
+ * - request → 404 化拒绝(ZC-MM-003 裁决落地 T-C 补齐:原实现漏查 enabled,
+ * 属真实缺口,本轮在 Service 层补 fail-safe 校验,口径与 disclose 一致);
+ * - 拒绝时不落 SensitiveAccessLog、不落任何 matchmaker.contact.* 成功审计行
+ * (DATA_SCOPE_DENIED deny 审计可有,用于事后追责)。
+ *
+ */
+@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()
+ .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()
+ .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()
+ .eq(MatchmakerContactAuthorization::getOperatorId, OPERATOR_ID));
+ assertEquals(0L, authCount, "机制关闭的拒绝申请不得产生授权记录");
+
+ // 不落 matchmaker.contact.request 成功审计行(DATA_SCOPE_DENIED deny 审计可有)
+ Long requestAuditCount = auditLogMapper.selectCount(new LambdaQueryWrapper()
+ .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());
+ }
+}