diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java index be6fbb47..4bf92feb 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/PromotionPortalAppService.java @@ -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, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java index b05b9b19..f944f2d5 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/ReviewPortalAppService.java @@ -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 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) { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java index aaa74655..ca543a37 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/PromotionApprovalFlowIntegrationTest.java @@ -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 { diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java index 1bd5f879..081f5133 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/SkillApprovalVisibilityFlowIntegrationTest.java @@ -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);