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:
+14
-4
@@ -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";
|
||||||
|
|
||||||
|
|||||||
+70
-21
@@ -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);
|
||||||
|
|||||||
+77
-2
@@ -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
|
||||||
|
|||||||
+104
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user