Merge pull request #787 from iflytek/fix/issue-615-audit-atomicity-20260831

fix(governance): make mutations and audit atomic
This commit is contained in:
XiaoSeS 2026-08-31 18:48:30 +08:00 committed by GitHub
commit bdb42b1be9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 68 additions and 0 deletions

View file

@ -24,6 +24,7 @@ import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class PromotionPortalAppService {
@ -49,6 +50,7 @@ public class PromotionPortalAppService {
this.requestIdAccessor = requestIdAccessor;
}
@Transactional
public PromotionResponseDto submitPromotion(Long sourceSkillId,
Long sourceVersionId,
Long targetNamespaceId,
@ -73,6 +75,7 @@ public class PromotionPortalAppService {
return governanceQueryRepository.getPromotionResponse(promotion);
}
@Transactional
public PromotionResponseDto approvePromotion(Long promotionId,
String comment,
String userId,
@ -88,6 +91,7 @@ public class PromotionPortalAppService {
return governanceQueryRepository.getPromotionResponse(promotion);
}
@Transactional
public PromotionResponseDto rejectPromotion(Long promotionId,
String comment,
String userId,

View file

@ -25,6 +25,7 @@ import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class ReviewPortalAppService {
@ -53,6 +54,7 @@ public class ReviewPortalAppService {
this.requestIdAccessor = requestIdAccessor;
}
@Transactional
public ReviewTaskResponse submitReview(Long skillVersionId,
String userId,
Map<Long, NamespaceRole> userNsRoles,
@ -67,6 +69,7 @@ public class ReviewPortalAppService {
return governanceQueryRepository.getReviewTaskResponse(task);
}
@Transactional
public ReviewTaskResponse approveReview(Long reviewTaskId,
String comment,
String userId,
@ -83,6 +86,7 @@ public class ReviewPortalAppService {
return governanceQueryRepository.getReviewTaskResponse(task);
}
@Transactional
public ReviewTaskResponse rejectReview(Long reviewTaskId,
String comment,
String userId,
@ -99,6 +103,7 @@ public class ReviewPortalAppService {
return governanceQueryRepository.getReviewTaskResponse(task);
}
@Transactional
public void withdrawReview(Long reviewTaskId,
String userId,
AuditRequestContext auditContext) {

View file

@ -5,6 +5,7 @@ import com.iflytek.skillhub.TestRedisConfig;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.rbac.RbacService;
import com.iflytek.skillhub.domain.audit.AuditLogRepository;
import com.iflytek.skillhub.domain.governance.GovernanceNotificationService;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceType;
@ -32,6 +33,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
@ -91,6 +93,9 @@ class PromotionApprovalFlowIntegrationTest {
@MockBean
private NotificationDispatcher notificationDispatcher;
@MockBean
private AuditLogRepository auditLogRepository;
@BeforeEach
void setUp() {
when(rbacService.getUserRoleCodes(REVIEWER_ID)).thenReturn(Set.of("SUPER_ADMIN"));
@ -195,6 +200,29 @@ class PromotionApprovalFlowIntegrationTest {
assertThat(savedRequest.getTargetSkillId()).isNull();
}
@Test
void approvePromotion_rollsBackTargetCopyWhenAuditPersistenceFails() throws Exception {
PromotionGraph graph = createPromotionGraph();
when(auditLogRepository.save(any()))
.thenThrow(new DataIntegrityViolationException("forced audit failure"));
mockMvc.perform(post("/api/web/promotions/" + graph.request().getId() + "/approve")
.contentType("application/json")
.content("{\"comment\":\"ship it\"}")
.with(authentication(portalAuth(REVIEWER_ID, "SUPER_ADMIN")))
.with(csrf()))
.andExpect(status().isInternalServerError());
PromotionRequest savedRequest = promotionRequestRepository.findAllById(List.of(graph.request().getId()))
.stream()
.findFirst()
.orElseThrow();
assertThat(savedRequest.getStatus()).isEqualTo(ReviewTaskStatus.PENDING);
assertThat(savedRequest.getTargetSkillId()).isNull();
assertThat(skillRepository.findByNamespaceIdAndSlug(
graph.globalNamespace().getId(), graph.sourceSkill().getSlug())).isEmpty();
}
@Test
@Transactional
void listPromotions_sortsApprovedAndRejectedHistoryByReviewedAtWithNullsLastAndTieBreaker() throws Exception {

View file

@ -5,6 +5,7 @@ import com.iflytek.skillhub.TestRedisConfig;
import com.iflytek.skillhub.auth.device.DeviceAuthService;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.rbac.RbacService;
import com.iflytek.skillhub.domain.audit.AuditLogRepository;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
@ -34,6 +35,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.context.ActiveProfiles;
@ -41,6 +43,7 @@ import org.springframework.test.web.servlet.MockMvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@ -84,6 +87,9 @@ class SkillApprovalVisibilityFlowIntegrationTest {
@MockBean
private RbacService rbacService;
@MockBean
private AuditLogRepository auditLogRepository;
@BeforeEach
void setUp() {
when(searchEmbeddingService.embed(anyString())).thenReturn("");
@ -160,6 +166,31 @@ class SkillApprovalVisibilityFlowIntegrationTest {
assertThat(savedVersion.getPublishedAt()).isNotNull();
}
@Test
void approveReview_rollsBackDomainStateWhenAuditPersistenceFails() throws Exception {
PendingSkillGraph graph = createPendingGlobalSkill("local-user");
when(auditLogRepository.save(any()))
.thenThrow(new DataIntegrityViolationException("forced audit failure"));
mockMvc.perform(post("/api/v1/reviews/" + graph.reviewTask().getId() + "/approve")
.contentType("application/json")
.content("{\"comment\":\"ship it\"}")
.with(authentication(apiAuth("super-1", "SUPER_ADMIN")))
.with(csrf()))
.andExpect(status().isInternalServerError());
ReviewTask savedReviewTask = reviewTaskJpaRepository.findAllById(List.of(graph.reviewTask().getId()))
.stream()
.findFirst()
.orElseThrow();
assertThat(savedReviewTask.getStatus())
.isEqualTo(com.iflytek.skillhub.domain.review.ReviewTaskStatus.PENDING);
assertThat(skillVersionRepository.findById(graph.version().getId()).orElseThrow().getStatus())
.isEqualTo(SkillVersionStatus.PENDING_REVIEW);
assertThat(skillRepository.findById(graph.skill().getId()).orElseThrow().getLatestVersionId()).isNull();
assertThat(skillSearchDocumentJpaRepository.findBySkillId(graph.skill().getId())).isEmpty();
}
private PendingSkillGraph createPendingGlobalSkill(String ownerId) {
String suffix = UUID.randomUUID().toString().substring(0, 8);