diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 1fea61ca..68540961 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -167,3 +167,4 @@ validation.auth.password.reset.email.invalid=Email format is invalid validation.auth.password.reset.code.notBlank=Verification code cannot be blank validation.auth.password.reset.code.invalid=Verification code must be 6 digits validation.auth.password.reset.newPassword.notBlank=New password cannot be blank +promotion.target_skill_conflict=The target global skill "{0}" already exists diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 05bd0905..c1f782f0 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -167,3 +167,4 @@ validation.auth.password.reset.email.invalid=邮箱格式不正确 validation.auth.password.reset.code.notBlank=验证码不能为空 validation.auth.password.reset.code.invalid=验证码必须为 6 位数字 validation.auth.password.reset.newPassword.notBlank=新密码不能为空 +promotion.target_skill_conflict=目标全局技能“{0}”已存在 diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java index eb533b8a..67d70c0f 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java @@ -16,6 +16,7 @@ import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; import com.iflytek.skillhub.domain.skill.*; import jakarta.persistence.EntityManager; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -207,6 +208,8 @@ public class PromotionService { SkillVersion sourceVersion = skillVersionRepository.findById(approvedRequest.getSourceVersionId()) .orElseThrow(() -> new DomainNotFoundException("skill_version.not_found", approvedRequest.getSourceVersionId())); + assertTargetSkillNotExists(approvedRequest, sourceSkill); + // Create new skill in global namespace Skill newSkill = new Skill(approvedRequest.getTargetNamespaceId(), sourceSkill.getSlug(), sourceSkill.getOwnerId(), SkillVisibility.PUBLIC); @@ -215,7 +218,11 @@ public class PromotionService { newSkill.setSourceSkillId(sourceSkill.getId()); newSkill.setCreatedBy(reviewerId); newSkill.setUpdatedBy(reviewerId); - newSkill = skillRepository.save(newSkill); + try { + newSkill = skillRepository.save(newSkill); + } catch (DataIntegrityViolationException ex) { + throw duplicateTargetSkillConflict(sourceSkill.getSlug(), ex); + } // Create new version copying metadata from source SkillVersion newVersion = new SkillVersion(newSkill.getId(), sourceVersion.getVersion(), @@ -264,6 +271,24 @@ public class PromotionService { return savedRequest; } + private void assertTargetSkillNotExists(PromotionRequest approvedRequest, Skill sourceSkill) { + skillRepository.findByNamespaceIdAndSlugAndOwnerId( + approvedRequest.getTargetNamespaceId(), + sourceSkill.getSlug(), + sourceSkill.getOwnerId() + ).ifPresent(existing -> { + throw duplicateTargetSkillConflict(sourceSkill.getSlug(), null); + }); + } + + private DomainBadRequestException duplicateTargetSkillConflict(String slug, Exception cause) { + DomainBadRequestException ex = new DomainBadRequestException("promotion.target_skill_conflict", slug); + if (cause != null) { + ex.initCause(cause); + } + return ex; + } + /** * Rejects a pending promotion request without changing the source skill. */ diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java index ea00a449..0df9342d 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java @@ -19,6 +19,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; import java.time.Clock; import java.time.Instant; @@ -511,6 +512,28 @@ class PromotionServiceTest { () -> promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ok", Set.of("SKILL_ADMIN"))); } + @Test + void shouldTranslateDuplicateTargetSkillIntoBadRequest() { + PromotionRequest pr = createPendingPromotion(); + Skill sourceSkill = createSourceSkill(); + SkillVersion sourceVersion = createPublishedVersion(); + + when(promotionRequestRepository.findById(PROMOTION_ID)).thenReturn(Optional.of(pr)); + when(permissionChecker.canReviewPromotion(pr, REVIEWER_ID, Set.of("SKILL_ADMIN"))).thenReturn(true); + when(promotionRequestRepository.updateStatusWithVersion( + PROMOTION_ID, ReviewTaskStatus.APPROVED, REVIEWER_ID, "ok", null, pr.getVersion())) + .thenReturn(1); + when(skillRepository.findById(SOURCE_SKILL_ID)).thenReturn(Optional.of(sourceSkill)); + when(skillVersionRepository.findById(SOURCE_VERSION_ID)).thenReturn(Optional.of(sourceVersion)); + when(skillRepository.save(any(Skill.class))) + .thenThrow(new DataIntegrityViolationException("duplicate key value violates unique constraint")); + + DomainBadRequestException ex = assertThrows(DomainBadRequestException.class, + () -> promotionService.approvePromotion(PROMOTION_ID, REVIEWER_ID, "ok", Set.of("SKILL_ADMIN"))); + + assertEquals("promotion.target_skill_conflict", ex.messageCode()); + } + @Test void shouldCopyDisplayNameAndSummaryToNewSkill() { PromotionRequest pr = createPendingPromotion();