diff --git a/backend/src/main/java/com/danyang/match/platform/audit/service/AuditDenyWriter.java b/backend/src/main/java/com/danyang/match/platform/audit/service/AuditDenyWriter.java new file mode 100644 index 0000000..ad67b0a --- /dev/null +++ b/backend/src/main/java/com/danyang/match/platform/audit/service/AuditDenyWriter.java @@ -0,0 +1,33 @@ +package com.danyang.match.platform.audit.service; + +import com.danyang.match.platform.audit.entity.AuditLog; +import com.danyang.match.platform.audit.mapper.AuditLogMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * 拒绝类审计的独立事务写入器(运行时实测修复)。 + * + *
背景:拒绝审计(DATA_SCOPE_DENIED / AUTHORIZATION_DENIED)原先与业务方法同一事务—— + * 拒绝时抛异常必然触发业务事务回滚,审计 insert(已执行 Updates:1)随之被回滚吞掉: + * 生产全量 deny 审计零落库。H2 集成测试因测试类 @Transactional(测试事务内可见未提交行) + * 结构性假绿。运行时实测(outsider consent → deny 行 0 条)抓到。 + * + *
语义:REQUIRES_NEW 独立事务提交——外层业务事务回滚与否均不影响拒绝审计落库;
+ * 内层失败仍由调用方 catch 记日志后照常拒绝(审计失败不阻断拒绝响应)。
+ * 成功路径审计(SENSITIVE_ACCESS / 业务动作)保持与业务同事务(业务回滚审计一并消失,语义正确),
+ * 不走本写入器。
+ */
+@Component
+@RequiredArgsConstructor
+public class AuditDenyWriter {
+
+ private final AuditLogMapper auditLogMapper;
+
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
+ public void insertIndependent(AuditLog auditLog) {
+ auditLogMapper.insert(auditLog);
+ }
+}
diff --git a/backend/src/main/java/com/danyang/match/platform/shared/security/AuthorizationAuditListener.java b/backend/src/main/java/com/danyang/match/platform/shared/security/AuthorizationAuditListener.java
index 9ac9bad..5f91b65 100644
--- a/backend/src/main/java/com/danyang/match/platform/shared/security/AuthorizationAuditListener.java
+++ b/backend/src/main/java/com/danyang/match/platform/shared/security/AuthorizationAuditListener.java
@@ -2,6 +2,7 @@ package com.danyang.match.platform.shared.security;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
+import com.danyang.match.platform.audit.service.AuditDenyWriter;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -21,6 +22,7 @@ import java.util.UUID;
public class AuthorizationAuditListener {
private final AuditLogMapper auditLogMapper;
+ private final AuditDenyWriter auditDenyWriter;
@EventListener
public void onAuthorizationDenied(AuthorizationDeniedEvent> event) {
@@ -46,7 +48,8 @@ public class AuthorizationAuditListener {
}
try {
- auditLogMapper.insert(audit);
+ // 拒绝审计独立事务提交:@PreAuthorize 拒绝若发生在业务事务内,同事务写入会被回滚吞掉
+ auditDenyWriter.insertIndependent(audit);
} catch (RuntimeException exception) {
log.error("写入授权拒绝审计失败: operatorId={}", principal.id(), exception);
}
diff --git a/backend/src/main/java/com/danyang/match/platform/shared/security/DataScopeGuard.java b/backend/src/main/java/com/danyang/match/platform/shared/security/DataScopeGuard.java
index 6839d66..8b99829 100644
--- a/backend/src/main/java/com/danyang/match/platform/shared/security/DataScopeGuard.java
+++ b/backend/src/main/java/com/danyang/match/platform/shared/security/DataScopeGuard.java
@@ -2,6 +2,7 @@ package com.danyang.match.platform.shared.security;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
+import com.danyang.match.platform.audit.service.AuditDenyWriter;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
import lombok.RequiredArgsConstructor;
@@ -22,6 +23,7 @@ import org.slf4j.MDC;
public class DataScopeGuard {
private final AuditLogMapper auditLogMapper;
+ private final AuditDenyWriter auditDenyWriter;
public void deny(String operatorId, String resourceType, String resourceId, String message) {
// ZC-MM-006 / T01 兼容性:缺省调用(不带 purposeCode/assignmentId)仍可写
@@ -48,7 +50,8 @@ public class DataScopeGuard {
audit.setRequestTraceId(MDC.get("requestId"));
audit.setCreatedAt(Instant.now());
try {
- auditLogMapper.insert(audit);
+ // 拒绝审计必须独立事务提交:与业务同事务会被拒绝异常的回滚吞掉(运行时实测缺陷)
+ auditDenyWriter.insertIndependent(audit);
} catch (RuntimeException exception) {
log.error("写入数据范围拒绝审计失败: operatorId={}, resourceType={}, resourceId={}",
operatorId, resourceType, resourceId, exception);
@@ -81,7 +84,8 @@ public class DataScopeGuard {
audit.setRequestTraceId(MDC.get("requestId"));
audit.setCreatedAt(Instant.now());
try {
- auditLogMapper.insert(audit);
+ // 拒绝审计必须独立事务提交:与业务同事务会被拒绝异常的回滚吞掉(运行时实测缺陷)
+ auditDenyWriter.insertIndependent(audit);
} catch (RuntimeException exception) {
log.error("写入数据范围拒绝审计失败: operatorId={}, resourceType={}, resourceId={}",
operatorId, resourceType, resourceId, exception);
@@ -105,7 +109,8 @@ public class DataScopeGuard {
audit.setRequestTraceId(MDC.get("requestId"));
audit.setCreatedAt(Instant.now());
try {
- auditLogMapper.insert(audit);
+ // 拒绝审计必须独立事务提交:与业务同事务会被拒绝异常的回滚吞掉(运行时实测缺陷)
+ auditDenyWriter.insertIndependent(audit);
} catch (RuntimeException exception) {
log.error("写入用户主体数据范围拒绝审计失败: subjectUserId={}, resourceType={}, resourceId={}",
userId, resourceType, resourceId, exception);
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 fe8396c..26cdb7f 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
@@ -67,6 +67,7 @@ class MatchmakerContactAuthorizationTest {
@Autowired private UserMapper userMapper;
@Autowired private AuditLogMapper auditLogMapper;
@Autowired private SensitiveAccessLogMapper sensitiveAccessLogMapper;
+ @Autowired private org.mybatis.spring.SqlSessionTemplate sqlSessionTemplate;
private String mmOwner; // 分配持有人(红娘,org X)
private String leaderA; // 组长,org X(team.assign 审批岗)
@@ -257,6 +258,59 @@ class MatchmakerContactAuthorizationTest {
}
}
+ // ==================== 运行时回归:拒绝审计独立事务(REQUIRES_NEW) ====================
+
+ /**
+ * 运行时实测缺陷回归(判别性用例):NOT_SUPPORTED 挂起测试事务,让 service 跑真事务——
+ * deny 抛异常触发业务回滚后,拒绝审计必须仍然落库(REQUIRES_NEW 独立提交)。
+ * 旧实现(同事务写入)在本用例必红:回滚吞掉审计。类级 @Transactional 用例对此结构性假绿
+ * (测试事务内可见未提交行),故本用例独立夹具+显式清理。
+ */
+ @Test
+ @Transactional(propagation = org.springframework.transaction.annotation.Propagation.NOT_SUPPORTED)
+ @DisplayName("B1 运行时回归: 业务事务回滚不得吞掉拒绝审计(REQUIRES_NEW 独立提交)")
+ void denyAuditSurvivesBusinessRollback() {
+ String opOwner = createOperator("独立事务红娘", ORG_X);
+ String opLeader = createOperator("独立事务组长", ORG_X);
+ String ownUser = createUser("13811112222");
+ String otherUser = createUser("13811113333");
+ MatchmakerServiceRecord svc = new MatchmakerServiceRecord();
+ svc.setUserId(ownUser);
+ svc.setServiceType(Constants.MATCHMAKER_SERVICE_TYPE_RECOMMEND_PARTNER);
+ svc.setDescription("独立事务验证服务单");
+ svc.setStatus(Constants.MATCHMAKER_STATUS_SUBMITTED);
+ svc.setAssignedOperatorId(opOwner);
+ serviceMapper.insert(svc);
+ MatchmakerAssignment assignment = assignmentService.create(opLeader, true,
+ ownUser, opOwner, svc.getId(), "独立事务前置分配", null);
+ MatchmakerContactAuthorization auth = contactAuthorizationService.request(
+ opOwner, ownUser, Constants.CONTACT_CHANNEL_PHONE, "电话沟通");
+ contactAuthorizationService.approve(auth.getId(), opLeader, P_LEADER);
+ try {
+ assertThrows(AccessDeniedException.class,
+ () -> contactAuthorizationService.consent(auth.getId(), otherUser));
+ AuditLog denyAudit = auditLogMapper.selectList(new LambdaQueryWrapper