diff --git a/docs/05-business-flows.md b/docs/05-business-flows.md index b130eec62..4e1239302 100644 --- a/docs/05-business-flows.md +++ b/docs/05-business-flows.md @@ -251,11 +251,14 @@ Web 端与 CLI 保持同一发布语义,只是在交互上可提供更明确 | `SkillDownloadedEvent` | 已发布版本下载结果构建成功并完成同步计数后 | 当前未发现生产消费者;下载计数由下载服务同步完成 | | `SkillStarredEvent` | 收藏/取消 | 收藏计数 | | `SkillRatedEvent` | 评分提交 | 评分重算 | +| `SkillSuiteReviewSubmittedEvent` | PUBLIC 或 NAMESPACE_ONLY 套件版本进入待审核 | 事务提交后异步创建 `REVIEW` 类站内通知;团队空间通知其 Owner/Admin,全局空间通知平台技能管理员,通知目标为对应审核任务 | | `ReviewCompletedEvent` | 审核完成 | 预留给后续通知能力(当前可不消费) | | `SkillPublishedEvent`(提升路径) | 首次提升或提升更新审核通过 | 重建目标全局 skill 的搜索文档;不是独立的 `SkillPromotedEvent` | 一期用 Spring ApplicationEvent + `@Async` 实现,后续可替换为消息队列。 +套件提交审核通知沿用用户的“审核通知”偏好,关闭该类别后不创建站内通知。通知从本次提交的套件版本读取名称和版本号,点击后进入统一审核中心的对应任务;进入详情和批准权限仍由审核接口校验,收到通知不等于获得审核权限。通知为提交后的 best-effort 副作用,不保证进程终止后的补发,也不新增邮件投递。 + ### 审计日志写入策略 审计要求是同步落库,与业务操作在同一请求内记录,不以异步事件替代。审计是企业内部平台的刚性需求;普通 Web / CLI 下载链的已知实现缺口见第 3 节,不能将本要求视为所有入口均已实现的证明。 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java index cb8f7d4cf..6bbdfa29c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/portal/NotificationController.java @@ -100,7 +100,11 @@ public class NotificationController extends BaseApiController { String namespace = body.get("namespace") instanceof String value ? value : null; String slug = body.get("slug") instanceof String value ? value : null; - if ("REVIEW_SUBMITTED".equals(eventType) && entityId != null) { + if ("SUITE_REVIEW_APPROVED".equals(eventType) || "SUITE_REVIEW_REJECTED".equals(eventType)) { + return new NotificationTarget("REVIEW", entityId, "/dashboard/review-progress"); + } + if (("REVIEW_SUBMITTED".equals(eventType) || "SUITE_REVIEW_SUBMITTED".equals(eventType)) + && entityId != null) { return new NotificationTarget("REVIEW", entityId, "/dashboard/reviews/" + entityId); } if ("PROFILE_REVIEW_SUBMITTED".equals(eventType) && entityId != null) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationListener.java new file mode 100644 index 000000000..e05d9a0aa --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationListener.java @@ -0,0 +1,101 @@ +package com.iflytek.skillhub.listener; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.event.SkillSuiteReviewSubmittedEvent; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.suite.SkillSuiteRepository; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository; +import com.iflytek.skillhub.notification.domain.NotificationCategory; +import com.iflytek.skillhub.notification.service.NotificationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; + +/** Keeps Suite review notifications separate from Skill-specific event payloads. */ +@Component +public class SkillSuiteReviewNotificationListener { + + private static final Logger log = LoggerFactory.getLogger(SkillSuiteReviewNotificationListener.class); + + private final SkillSuiteRepository suiteRepository; + private final SkillSuiteVersionRepository versionRepository; + private final NamespaceRepository namespaceRepository; + private final RecipientResolver recipientResolver; + private final NotificationService notificationService; + private final ObjectMapper objectMapper; + + public SkillSuiteReviewNotificationListener( + SkillSuiteRepository suiteRepository, + SkillSuiteVersionRepository versionRepository, + NamespaceRepository namespaceRepository, + RecipientResolver recipientResolver, + NotificationService notificationService, + ObjectMapper objectMapper + ) { + this.suiteRepository = suiteRepository; + this.versionRepository = versionRepository; + this.namespaceRepository = namespaceRepository; + this.recipientResolver = recipientResolver; + this.notificationService = notificationService; + this.objectMapper = objectMapper; + } + + @Async("skillhubEventExecutor") + @TransactionalEventListener + public void onReviewSubmitted(SkillSuiteReviewSubmittedEvent event) { + var suite = suiteRepository.findById(event.suiteId()).orElse(null); + if (suite == null || !Objects.equals(suite.getNamespaceId(), event.namespaceId())) { + return; + } + var version = versionRepository.findById(event.versionId()).orElse(null); + if (version == null || !Objects.equals(version.getSuiteId(), suite.getId())) { + return; + } + var namespace = namespaceRepository.findById(event.namespaceId()).orElse(null); + if (namespace == null) { + return; + } + + String displayName = version.getDisplayName(); + String name = displayName != null && !displayName.isBlank() ? displayName : suite.getSlug(); + Map body = new LinkedHashMap<>(); + body.put("suiteId", suite.getId()); + body.put("suiteName", name); + body.put("namespace", namespace.getSlug()); + body.put("slug", suite.getSlug()); + body.put("version", version.getVersion()); + body.put("reviewId", event.reviewId()); + body.put("submitterId", event.submitterId()); + String json = toJson(body, event.reviewId()); + var recipients = namespace.getType() == NamespaceType.GLOBAL + ? recipientResolver.resolvePlatformSkillAdmins() + : recipientResolver.resolveNamespaceAdmins(event.namespaceId()); + for (String recipient : recipients.stream().distinct().toList()) { + try { + notificationService.create(recipient, NotificationCategory.REVIEW, + "SUITE_REVIEW_SUBMITTED", "Suite review submitted", + json, "REVIEW", event.reviewId(), event.eventId(), event.occurredAt()); + } catch (RuntimeException exception) { + log.warn("Failed to create Suite review notification [reviewId={}, recipientId={}]", + event.reviewId(), recipient); + } + } + } + + private String toJson(Map body, Long reviewId) { + try { + return objectMapper.writeValueAsString(body); + } catch (JsonProcessingException exception) { + log.warn("Failed to serialize Suite review notification [reviewId={}]", reviewId); + return "{}"; + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteReviewResultNotificationListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteReviewResultNotificationListener.java new file mode 100644 index 000000000..0b9ed5ac3 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/SkillSuiteReviewResultNotificationListener.java @@ -0,0 +1,91 @@ +package com.iflytek.skillhub.listener; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.event.SkillSuiteReviewCompletedEvent; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.review.ReviewSubjectType; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.suite.SkillSuiteRepository; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository; +import com.iflytek.skillhub.notification.domain.NotificationCategory; +import com.iflytek.skillhub.notification.service.NotificationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.LinkedHashMap; +import java.util.Objects; + +/** Review outcomes belong to the submitter, independently of reviewer notifications. */ +@Component +public class SkillSuiteReviewResultNotificationListener { + private static final Logger log = LoggerFactory.getLogger(SkillSuiteReviewResultNotificationListener.class); + private final ReviewTaskRepository reviews; + private final SkillSuiteRepository suites; + private final SkillSuiteVersionRepository versions; + private final NamespaceRepository namespaces; + private final NotificationService notifications; + private final ObjectMapper mapper; + + public SkillSuiteReviewResultNotificationListener(ReviewTaskRepository reviews, SkillSuiteRepository suites, + SkillSuiteVersionRepository versions, NamespaceRepository namespaces, + NotificationService notifications, ObjectMapper mapper) { + this.reviews = reviews; + this.suites = suites; + this.versions = versions; + this.namespaces = namespaces; + this.notifications = notifications; + this.mapper = mapper; + } + + @Async("skillhubEventExecutor") + @TransactionalEventListener + public void onReviewCompleted(SkillSuiteReviewCompletedEvent event) { + var task = reviews.findById(event.reviewId()).orElse(null); + if (task == null || task.getSubjectType() != ReviewSubjectType.SUITE_VERSION + || task.getStatus() != event.status() + || !Objects.equals(task.getSubjectId(), event.suiteId()) + || !Objects.equals(task.getSubjectVersionId(), event.versionId()) + || !Objects.equals(task.getNamespaceId(), event.namespaceId()) + || !Objects.equals(task.getSubmittedBy(), event.submitterId())) { + return; + } + var suite = suites.findById(event.suiteId()).orElse(null); + if (suite == null || !Objects.equals(suite.getNamespaceId(), event.namespaceId())) { + return; + } + var version = versions.findById(event.versionId()).orElse(null); + if (version == null || !Objects.equals(version.getSuiteId(), event.suiteId())) { + return; + } + var namespace = namespaces.findById(event.namespaceId()).orElse(null); + if (namespace == null) { + return; + } + var body = new LinkedHashMap(); + body.put("suiteId", suite.getId()); + body.put("suiteName", version.getDisplayName() == null || version.getDisplayName().isBlank() + ? suite.getSlug() : version.getDisplayName()); + body.put("namespace", namespace.getSlug()); + body.put("slug", suite.getSlug()); + body.put("version", version.getVersion()); + body.put("reviewId", event.reviewId()); + body.put("submitterId", event.submitterId()); + body.put("eventId", event.eventId()); + body.put("occurredAt", event.occurredAt().toString()); + boolean approved = event.status() == ReviewTaskStatus.APPROVED; + try { + notifications.create(event.submitterId(), NotificationCategory.REVIEW, + approved ? "SUITE_REVIEW_APPROVED" : "SUITE_REVIEW_REJECTED", + approved ? "Suite review approved" : "Suite review rejected", + mapper.writeValueAsString(body), "REVIEW", event.reviewId(), event.eventId(), event.occurredAt()); + } catch (JsonProcessingException | RuntimeException failure) { + log.warn("Failed to create Suite review result notification [reviewId={}, recipientId={}]", + event.reviewId(), event.submitterId()); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java index 90e195bd4..ce3c4ee3a 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/portal/NotificationControllerTest.java @@ -68,6 +68,45 @@ class NotificationControllerTest { verify(notificationService).list(org.mockito.ArgumentMatchers.eq("user-1"), org.mockito.ArgumentMatchers.eq(NotificationCategory.REVIEW), org.mockito.ArgumentMatchers.any(Pageable.class)); } + @Test + void list_shouldExposeReviewTargetRouteForSubmittedSuiteReviewNotifications() { + Notification notification = notification( + 12L, + NotificationCategory.REVIEW, + "SUITE_REVIEW_SUBMITTED", + "{\"namespace\":\"demo\",\"slug\":\"writers\",\"suiteName\":\"Writing suite\"}", + "REVIEW", + 101L + ); + when(notificationService.list(org.mockito.ArgumentMatchers.eq("admin-1"), + org.mockito.ArgumentMatchers.eq(NotificationCategory.REVIEW), + org.mockito.ArgumentMatchers.any(Pageable.class))) + .thenReturn(new PageImpl<>(java.util.List.of(notification))); + + PageResponse page = controller.list("admin-1", "REVIEW", 0, 20).data(); + + assertThat(page.items()).singleElement().satisfies(item -> { + assertThat(item.targetType()).isEqualTo("REVIEW"); + assertThat(item.targetId()).isEqualTo(101L); + assertThat(item.targetRoute()).isEqualTo("/dashboard/reviews/101"); + }); + } + + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"SUITE_REVIEW_APPROVED", "SUITE_REVIEW_REJECTED"}) + void completedSuiteReviewLinksToTheSubmittersProgressInsteadOfReviewerOnlyDetails(String eventType) { + Notification item = notification(13L, NotificationCategory.REVIEW, eventType, "{}", "REVIEW", 101L); + when(notificationService.list(org.mockito.ArgumentMatchers.eq("user-1"), + org.mockito.ArgumentMatchers.eq(NotificationCategory.REVIEW), + org.mockito.ArgumentMatchers.any(Pageable.class))) + .thenReturn(new PageImpl<>(java.util.List.of(item))); + var page = controller.list("user-1", "REVIEW", 0, 20).data(); + assertThat(page.items()).singleElement().satisfies(result -> { + assertThat(result.targetId()).isEqualTo(101L); + assertThat(result.targetRoute()).isEqualTo("/dashboard/review-progress"); + }); + } + @Test void list_shouldExposeProfileReviewTargetRouteForSubmittedProfileReviewNotifications() { Notification notification = notification( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationListenerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationListenerTest.java new file mode 100644 index 000000000..25dc55981 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationListenerTest.java @@ -0,0 +1,218 @@ +package com.iflytek.skillhub.listener; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.event.SkillSuiteReviewSubmittedEvent; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceType; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.suite.SkillSuite; +import com.iflytek.skillhub.domain.suite.SkillSuiteRepository; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersion; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository; +import com.iflytek.skillhub.notification.domain.Notification; +import com.iflytek.skillhub.notification.domain.NotificationCategory; +import com.iflytek.skillhub.notification.domain.NotificationChannel; +import com.iflytek.skillhub.notification.domain.NotificationRepository; +import com.iflytek.skillhub.notification.service.NotificationPreferenceService; +import com.iflytek.skillhub.notification.service.NotificationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Clock; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SkillSuiteReviewNotificationListenerTest { + + @Mock private SkillSuiteRepository suiteRepository; + @Mock private SkillSuiteVersionRepository versionRepository; + @Mock private NamespaceRepository namespaceRepository; + @Mock private RecipientResolver recipientResolver; + @Mock private NotificationService notificationService; + + private final ObjectMapper mapper = new ObjectMapper(); + private SkillSuiteReviewNotificationListener listener; + private SkillSuite suite; + private SkillSuiteVersion version; + private Namespace namespace; + private final SkillSuiteReviewSubmittedEvent event = + new SkillSuiteReviewSubmittedEvent(100L, 10L, 20L, "author", 5L); + + @BeforeEach + void setUp() { + listener = listener(notificationService); + suite = new SkillSuite(5L, "writers", "Previously published name", "author"); + ReflectionTestUtils.setField(suite, "id", 10L); + version = new SkillSuiteVersion(10L, "2.0.0", "Writing suite", "New summary", + SkillVisibility.PUBLIC, "author"); + ReflectionTestUtils.setField(version, "id", 20L); + namespace = new Namespace("team", "Team", "owner"); + } + + @Test + void teamSubmissionNotifiesOnlyResolvedNamespaceAdministratorsOnceWithTheBoundVersion() throws Exception { + stubResources(); + when(recipientResolver.resolveNamespaceAdmins(5L)) + .thenReturn(List.of("owner", "admin", "owner")); + + listener.onReviewSubmitted(event); + + ArgumentCaptor json = ArgumentCaptor.forClass(String.class); + verify(notificationService).create(eq("owner"), eq(NotificationCategory.REVIEW), + eq("SUITE_REVIEW_SUBMITTED"), eq("Suite review submitted"), json.capture(), + eq("REVIEW"), eq(100L), eq(event.eventId()), eq(event.occurredAt())); + verify(notificationService).create(eq("admin"), eq(NotificationCategory.REVIEW), + eq("SUITE_REVIEW_SUBMITTED"), anyString(), anyString(), eq("REVIEW"), eq(100L), eq(event.eventId()), eq(event.occurredAt())); + var body = mapper.readTree(json.getValue()); + assertThat(body.path("suiteId").asLong()).isEqualTo(10L); + assertThat(body.path("suiteName").asText()).isEqualTo("Writing suite"); + assertThat(body.path("namespace").asText()).isEqualTo("team"); + assertThat(body.path("slug").asText()).isEqualTo("writers"); + assertThat(body.path("version").asText()).isEqualTo("2.0.0"); + assertThat(body.path("reviewId").asLong()).isEqualTo(100L); + assertThat(body.path("submitterId").asText()).isEqualTo("author"); + assertThat(body.has("skillId")).isFalse(); + verify(recipientResolver, never()).resolvePlatformSkillAdmins(); + verify(notificationService, never()).create(eq("unrelated-member"), any(), any(), any(), any(), any(), any(), any(), any()); + } + + @Test + void globalSubmissionNotifiesPlatformSkillAdministratorsInsteadOfNamespaceMembers() { + namespace.setType(NamespaceType.GLOBAL); + stubResources(); + when(recipientResolver.resolvePlatformSkillAdmins()).thenReturn(List.of("skill-admin", "super-admin")); + + listener.onReviewSubmitted(event); + + verify(notificationService).create(eq("skill-admin"), eq(NotificationCategory.REVIEW), + eq("SUITE_REVIEW_SUBMITTED"), anyString(), anyString(), eq("REVIEW"), eq(100L), eq(event.eventId()), eq(event.occurredAt())); + verify(notificationService).create(eq("super-admin"), eq(NotificationCategory.REVIEW), + eq("SUITE_REVIEW_SUBMITTED"), anyString(), anyString(), eq("REVIEW"), eq(100L), eq(event.eventId()), eq(event.occurredAt())); + verify(recipientResolver, never()).resolveNamespaceAdmins(anyLong()); + } + + @Test + void missingSuiteDoesNotCreateANotification() { + when(suiteRepository.findById(10L)).thenReturn(Optional.empty()); + + listener.onReviewSubmitted(event); + + verifyNoInteractions(notificationService, recipientResolver); + } + + @Test + void mismatchedNamespaceDoesNotExposeSuiteMetadataToAnotherSpace() { + when(suiteRepository.findById(10L)).thenReturn(Optional.of(suite)); + + listener.onReviewSubmitted(new SkillSuiteReviewSubmittedEvent(100L, 10L, 20L, "author", 6L)); + + verifyNoInteractions(notificationService, recipientResolver, namespaceRepository, versionRepository); + } + + @Test + void mismatchedVersionDoesNotCreateANotificationForAnotherSuite() { + when(suiteRepository.findById(10L)).thenReturn(Optional.of(suite)); + SkillSuiteVersion otherVersion = new SkillSuiteVersion(11L, "1.0.0", SkillVisibility.PUBLIC, "author"); + when(versionRepository.findById(20L)).thenReturn(Optional.of(otherVersion)); + + listener.onReviewSubmitted(event); + + verifyNoInteractions(notificationService, recipientResolver, namespaceRepository); + } + + @Test + void oneRecipientFailureDoesNotStopOtherAdministratorsFromReceivingTheRequest() { + stubResources(); + when(recipientResolver.resolveNamespaceAdmins(5L)).thenReturn(List.of("unavailable-admin", "owner")); + doThrow(new IllegalStateException("Controlled notification storage failure")) + .when(notificationService).create(eq("unavailable-admin"), any(), any(), any(), any(), any(), any(), any(), any()); + + listener.onReviewSubmitted(event); + + verify(notificationService).create(eq("owner"), eq(NotificationCategory.REVIEW), + eq("SUITE_REVIEW_SUBMITTED"), anyString(), anyString(), eq("REVIEW"), eq(100L), eq(event.eventId()), eq(event.occurredAt())); + } + + @Test + void suiteReviewUsesTheExistingReviewPreferenceRatherThanASeparateNotificationSetting() { + stubResources(); + when(recipientResolver.resolveNamespaceAdmins(5L)).thenReturn(List.of("muted-owner", "admin")); + NotificationRepository notifications = mock(NotificationRepository.class); + NotificationPreferenceService preferences = mock(NotificationPreferenceService.class); + when(preferences.isEnabled("muted-owner", NotificationCategory.REVIEW, NotificationChannel.IN_APP)) + .thenReturn(false); + when(preferences.isEnabled("admin", NotificationCategory.REVIEW, NotificationChannel.IN_APP)) + .thenReturn(true); + NotificationService service = new NotificationService(notifications, preferences, Clock.systemUTC()); + + listener(service).onReviewSubmitted(event); + + ArgumentCaptor saved = ArgumentCaptor.forClass(Notification.class); + verify(notifications).save(saved.capture()); + assertThat(saved.getValue().getRecipientId()).isEqualTo("admin"); + assertThat(saved.getValue().getCategory()).isEqualTo(NotificationCategory.REVIEW); + } + + @Test + void maximumLengthSuiteNameRemainsInTheBodyWithoutOverflowingTheNotificationTitle() throws Exception { + String longName = "套".repeat(256); + version.setDisplayName(longName); + stubResources(); + when(recipientResolver.resolveNamespaceAdmins(5L)).thenReturn(List.of("owner")); + + listener.onReviewSubmitted(event); + + ArgumentCaptor title = ArgumentCaptor.forClass(String.class); + ArgumentCaptor body = ArgumentCaptor.forClass(String.class); + verify(notificationService).create(eq("owner"), any(), anyString(), title.capture(), body.capture(), + eq("REVIEW"), eq(100L), eq(event.eventId()), eq(event.occurredAt())); + assertThat(title.getValue()).hasSizeLessThanOrEqualTo(200); + assertThat(mapper.readTree(body.getValue()).path("suiteName").asText()).isEqualTo(longName); + } + + @Test + void replayKeepsTheSameEmailIdentityForEveryRecipient() { + stubResources(); + when(recipientResolver.resolveNamespaceAdmins(5L)).thenReturn(List.of("owner", "admin")); + + listener.onReviewSubmitted(event); + listener.onReviewSubmitted(event); + + for (String recipient : List.of("owner", "admin")) { + verify(notificationService, org.mockito.Mockito.times(2)).create(eq(recipient), + eq(NotificationCategory.REVIEW), eq("SUITE_REVIEW_SUBMITTED"), + anyString(), anyString(), eq("REVIEW"), eq(100L), + eq(event.eventId()), eq(event.occurredAt())); + } + } + + private void stubResources() { + when(suiteRepository.findById(10L)).thenReturn(Optional.of(suite)); + when(versionRepository.findById(20L)).thenReturn(Optional.of(version)); + when(namespaceRepository.findById(5L)).thenReturn(Optional.of(namespace)); + } + + private SkillSuiteReviewNotificationListener listener(NotificationService notifications) { + return new SkillSuiteReviewNotificationListener(suiteRepository, versionRepository, namespaceRepository, + recipientResolver, notifications, mapper); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationTransactionTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationTransactionTest.java new file mode 100644 index 000000000..f50f62fc3 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewNotificationTransactionTest.java @@ -0,0 +1,127 @@ +package com.iflytek.skillhub.listener; + +import com.iflytek.skillhub.domain.event.SkillSuiteReviewSubmittedEvent; +import com.iflytek.skillhub.domain.event.SkillSuiteReviewCompletedEvent; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.suite.SkillSuite; +import com.iflytek.skillhub.domain.suite.SkillSuiteRepository; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersion; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository; +import com.iflytek.skillhub.notification.domain.NotificationCategory; +import com.iflytek.skillhub.notification.service.NotificationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.after; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** Exercises Spring's real transactional event delivery; database lifecycle coverage is separate. */ +@SpringBootTest +@ActiveProfiles("test") +class SkillSuiteReviewNotificationTransactionTest { + + @Autowired private ApplicationEventPublisher events; + @Autowired private PlatformTransactionManager transactions; + @MockBean private SkillSuiteRepository suites; + @MockBean private SkillSuiteVersionRepository versions; + @MockBean private NamespaceRepository namespaces; + @MockBean private RecipientResolver recipients; + @MockBean private NotificationService notifications; + @MockBean private ReviewTaskRepository reviewTasks; + + private final SkillSuiteReviewSubmittedEvent event = + new SkillSuiteReviewSubmittedEvent(912350L, 912351L, 912352L, "author", 912353L); + + @BeforeEach + void setUp() { + SkillSuite suite = new SkillSuite(912353L, "suite-event-test", "Suite", "author"); + ReflectionTestUtils.setField(suite, "id", 912351L); + SkillSuiteVersion version = new SkillSuiteVersion( + 912351L, "1.0.0", "Suite snapshot", "Summary", SkillVisibility.PUBLIC, "author"); + Namespace namespace = new Namespace("suite-review-team", "Team", "owner"); + when(suites.findById(912351L)).thenReturn(Optional.of(suite)); + when(versions.findById(912352L)).thenReturn(Optional.of(version)); + when(namespaces.findById(912353L)).thenReturn(Optional.of(namespace)); + when(recipients.resolveNamespaceAdmins(912353L)).thenReturn(List.of("suite-review-admin")); + } + + @Test + void committedSubmissionNotifiesOnlyAfterTheTransactionCompletes() { + new TransactionTemplate(transactions).executeWithoutResult(status -> { + events.publishEvent(event); + verify(notifications, never()).create(any(), any(), any(), any(), any(), any(), any(), any(), any()); + }); + + verify(notifications, timeout(5000)).create(eq("suite-review-admin"), eq(NotificationCategory.REVIEW), + eq("SUITE_REVIEW_SUBMITTED"), anyString(), anyString(), eq("REVIEW"), eq(912350L), eq(event.eventId()), eq(event.occurredAt())); + } + + @Test + void rolledBackSubmissionDoesNotNotify() { + new TransactionTemplate(transactions).executeWithoutResult(status -> { + events.publishEvent(event); + status.setRollbackOnly(); + }); + + verify(notifications, after(500).never()).create(any(), any(), any(), any(), any(), any(), any(), any(), any()); + } + + @Test + void eventOutsideATransactionDoesNotBypassTheCommitBoundary() { + events.publishEvent(event); + + verify(notifications, after(500).never()).create(any(), any(), any(), any(), any(), any(), any(), any(), any()); + } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.EnumSource(value = ReviewTaskStatus.class, names = {"APPROVED", "REJECTED"}) + void committedResultNotifiesTheSubmitterOnlyAfterCommit(ReviewTaskStatus result) { + var completed = completed(result); + new TransactionTemplate(transactions).executeWithoutResult(status -> { + events.publishEvent(completed); + verify(notifications, never()).create(any(), any(), any(), any(), any(), any(), any(), any(), any()); + }); + verify(notifications, timeout(5000)).create(eq("author"), eq(NotificationCategory.REVIEW), + eq("SUITE_REVIEW_" + result), anyString(), anyString(), eq("REVIEW"), eq(912350L), eq(completed.eventId()), eq(completed.occurredAt())); + } + + @Test + void rolledBackResultDoesNotNotify() { + var completed = completed(ReviewTaskStatus.APPROVED); + new TransactionTemplate(transactions).executeWithoutResult(status -> { + events.publishEvent(completed); + status.setRollbackOnly(); + }); + verify(notifications, after(500).never()).create(any(), any(), any(), any(), any(), any(), any(), any(), any()); + } + + private SkillSuiteReviewCompletedEvent completed(ReviewTaskStatus result) { + ReviewTask task = ReviewTask.forSuiteVersion(912352L, 912351L, 912353L, "1.0.0", "author"); + task.setStatus(result); + when(reviewTasks.findById(912350L)).thenReturn(Optional.of(task)); + return new SkillSuiteReviewCompletedEvent(912350L, 912351L, 912352L, "author", "suite-review-admin", + 912353L, result, "00000000-0000-0000-0000-000000912350", java.time.Instant.now()); + } + +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewResultNotificationListenerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewResultNotificationListenerTest.java new file mode 100644 index 000000000..bf732c7d1 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/SkillSuiteReviewResultNotificationListenerTest.java @@ -0,0 +1,163 @@ +package com.iflytek.skillhub.listener; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.domain.event.SkillSuiteReviewCompletedEvent; +import com.iflytek.skillhub.domain.namespace.Namespace; +import com.iflytek.skillhub.domain.namespace.NamespaceRepository; +import com.iflytek.skillhub.domain.review.ReviewTask; +import com.iflytek.skillhub.domain.review.ReviewTaskRepository; +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.suite.SkillSuite; +import com.iflytek.skillhub.domain.suite.SkillSuiteRepository; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersion; +import com.iflytek.skillhub.domain.suite.SkillSuiteVersionRepository; +import com.iflytek.skillhub.notification.domain.Notification; +import com.iflytek.skillhub.notification.domain.NotificationCategory; +import com.iflytek.skillhub.notification.domain.NotificationChannel; +import com.iflytek.skillhub.notification.domain.NotificationRepository; +import com.iflytek.skillhub.notification.service.NotificationPreferenceService; +import com.iflytek.skillhub.notification.service.NotificationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Clock; +import java.time.Instant; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class SkillSuiteReviewResultNotificationListenerTest { + @Mock private ReviewTaskRepository reviews; + @Mock private SkillSuiteRepository suites; + @Mock private SkillSuiteVersionRepository versions; + @Mock private NamespaceRepository namespaces; + @Mock private NotificationService notifications; + private final ObjectMapper mapper = new ObjectMapper(); + private SkillSuiteReviewResultNotificationListener listener; + + @BeforeEach + void setUp() { + listener = new SkillSuiteReviewResultNotificationListener(reviews, suites, versions, + namespaces, notifications, mapper); + } + + @ParameterizedTest + @EnumSource(value = ReviewTaskStatus.class, names = {"APPROVED", "REJECTED"}) + void resultNotifiesTheVersionSubmitterWithoutCopyingReviewerComments(ReviewTaskStatus status) throws Exception { + stub(status, "submitter"); + var event = event(status, "submitter"); + listener.onReviewCompleted(event); + + ArgumentCaptor body = ArgumentCaptor.forClass(String.class); + verify(notifications).create(eq("submitter"), eq(NotificationCategory.REVIEW), + eq("SUITE_REVIEW_" + status), anyString(), body.capture(), eq("REVIEW"), eq(30L), eq(event.eventId()), eq(event.occurredAt())); + var json = mapper.readTree(body.getValue()); + assertThat(json.path("suiteName").asText()).isEqualTo("Current Suite"); + assertThat(json.path("version").asText()).isEqualTo("2.0.0"); + assertThat(json.path("eventId").asText()).isEqualTo(event.eventId()); + assertThat(json.path("occurredAt").asText()).isEqualTo(event.occurredAt().toString()); + assertThat(json.has("skillId")).isFalse(); + assertThat(body.getValue()).doesNotContain("Private review comment"); + verifyNoMoreInteractions(notifications); + } + + @Test + void mismatchedSubmitterDoesNotReceiveAnotherAuthorsResult() { + ReviewTask task = ReviewTask.forSuiteVersion(20L, 10L, 5L, "2.0.0", "real-submitter"); + task.setStatus(ReviewTaskStatus.APPROVED); + when(reviews.findById(30L)).thenReturn(Optional.of(task)); + listener.onReviewCompleted(event(ReviewTaskStatus.APPROVED, "other-user")); + verifyNoInteractions(notifications, suites, versions, namespaces); + } + + @Test + void staleTaskStatusDoesNotProduceAFalseOutcome() { + ReviewTask task = ReviewTask.forSuiteVersion(20L, 10L, 5L, "2.0.0", "submitter"); + when(reviews.findById(30L)).thenReturn(Optional.of(task)); + listener.onReviewCompleted(event(ReviewTaskStatus.APPROVED, "submitter")); + verifyNoInteractions(notifications, suites, versions, namespaces); + } + + @Test + void notificationFailureIsIsolatedFromTheAlreadyCommittedReview() { + stub(ReviewTaskStatus.APPROVED, "submitter"); + doThrow(new IllegalStateException("Controlled notification failure")) + .when(notifications).create(any(), any(), any(), any(), any(), any(), any(), any(), any()); + assertThatCode(() -> listener.onReviewCompleted(event(ReviewTaskStatus.APPROVED, "submitter"))) + .doesNotThrowAnyException(); + } + + @Test + void resultUsesTheExistingReviewPreferenceAndDoesNotRequireAnEmailAddress() { + stub(ReviewTaskStatus.REJECTED, "submitter"); + NotificationRepository stored = mock(NotificationRepository.class); + NotificationPreferenceService preferences = mock(NotificationPreferenceService.class); + when(preferences.isEnabled("submitter", NotificationCategory.REVIEW, NotificationChannel.IN_APP)) + .thenReturn(true); + var realService = new NotificationService(stored, preferences, Clock.systemUTC()); + new SkillSuiteReviewResultNotificationListener(reviews, suites, versions, namespaces, realService, mapper) + .onReviewCompleted(event(ReviewTaskStatus.REJECTED, "submitter")); + ArgumentCaptor saved = ArgumentCaptor.forClass(Notification.class); + verify(stored).save(saved.capture()); + assertThat(saved.getValue().getRecipientId()).isEqualTo("submitter"); + } + + @Test + void replayedCompletionPreservesTheActualMailCommandIdentityAndTime() { + stub(ReviewTaskStatus.APPROVED, "submitter"); + var stored = mock(NotificationRepository.class); + var preferences = mock(NotificationPreferenceService.class); + var enqueuer = mock(com.iflytek.skillhub.notification.email.delivery.NotificationEmailEnqueuer.class); + @SuppressWarnings("unchecked") + var provider = (org.springframework.beans.factory.ObjectProvider) + mock(org.springframework.beans.factory.ObjectProvider.class); + when(provider.getIfAvailable()).thenReturn(enqueuer); + var service = new NotificationService(stored, preferences, Clock.systemUTC()); + ReflectionTestUtils.setField(service, "emailEnqueuers", provider); + var actual = new SkillSuiteReviewResultNotificationListener(reviews, suites, versions, namespaces, service, mapper); + var event = event(ReviewTaskStatus.APPROVED, "submitter"); + actual.onReviewCompleted(event); + actual.onReviewCompleted(event); + var commands = ArgumentCaptor.forClass(com.iflytek.skillhub.notification.email.delivery.NotificationEmailCommand.class); + verify(enqueuer, times(2)).enqueue(commands.capture()); + assertThat(commands.getAllValues()).allSatisfy(command -> { + assertThat(command.eventKey()).isEqualTo("SUITE_REVIEW_APPROVED:" + event.eventId()); + assertThat(command.occurredAt()).isEqualTo(event.occurredAt()); + assertThat(command.entityType()).isEqualTo("REVIEW"); + assertThat(command.entityId()).isEqualTo(30L); + }); + } + + private SkillSuiteReviewCompletedEvent event(ReviewTaskStatus status, String submitter) { + return new SkillSuiteReviewCompletedEvent(30L, 10L, 20L, submitter, "reviewer", 5L, + status, "00000000-0000-0000-0000-000000000030", Instant.parse("2026-11-06T00:00:00Z")); + } + + private void stub(ReviewTaskStatus status, String submitter) { + ReviewTask task = ReviewTask.forSuiteVersion(20L, 10L, 5L, "2.0.0", submitter); + task.setStatus(status); + task.setReviewComment("Private review comment"); + when(reviews.findById(30L)).thenReturn(Optional.of(task)); + SkillSuite suite = new SkillSuite(5L, "writers", "Old name", "original-creator"); + ReflectionTestUtils.setField(suite, "id", 10L); + when(suites.findById(10L)).thenReturn(Optional.of(suite)); + SkillSuiteVersion version = new SkillSuiteVersion(10L, "2.0.0", "Current Suite", "Summary", + SkillVisibility.PUBLIC, "submitter"); + when(versions.findById(20L)).thenReturn(Optional.of(version)); + when(namespaces.findById(5L)).thenReturn(Optional.of(new Namespace("team", "Team", "namespace-owner"))); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillSuiteReviewCompletedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillSuiteReviewCompletedEvent.java new file mode 100644 index 000000000..0dbe49828 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillSuiteReviewCompletedEvent.java @@ -0,0 +1,27 @@ +package com.iflytek.skillhub.domain.event; + +import com.iflytek.skillhub.domain.review.ReviewTaskStatus; + +import java.time.Instant; +import java.util.Objects; + +/** Immutable result of one Suite review; delivered only after the transaction commits. */ +public record SkillSuiteReviewCompletedEvent( + Long reviewId, + Long suiteId, + Long versionId, + String submitterId, + String reviewerId, + Long namespaceId, + ReviewTaskStatus status, + String eventId, + Instant occurredAt +) { + public SkillSuiteReviewCompletedEvent { + if (status != ReviewTaskStatus.APPROVED && status != ReviewTaskStatus.REJECTED) { + throw new IllegalArgumentException("Suite review result must be approved or rejected"); + } + Objects.requireNonNull(eventId, "eventId"); + Objects.requireNonNull(occurredAt, "occurredAt"); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillSuiteReviewSubmittedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillSuiteReviewSubmittedEvent.java new file mode 100644 index 000000000..b77b29e23 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/SkillSuiteReviewSubmittedEvent.java @@ -0,0 +1,27 @@ +package com.iflytek.skillhub.domain.event; + +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; + +/** A Suite version has entered review; consumers run only after its transaction commits. */ +public record SkillSuiteReviewSubmittedEvent( + Long reviewId, + Long suiteId, + Long versionId, + String submitterId, + Long namespaceId, + String eventId, + Instant occurredAt +) { + public SkillSuiteReviewSubmittedEvent(Long reviewId, Long suiteId, Long versionId, + String submitterId, Long namespaceId) { + this(reviewId, suiteId, versionId, submitterId, namespaceId, + UUID.randomUUID().toString(), Instant.now()); + } + + public SkillSuiteReviewSubmittedEvent { + Objects.requireNonNull(eventId); + Objects.requireNonNull(occurredAt); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleService.java index 6d487ebb5..92d6db5d4 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleService.java @@ -2,6 +2,8 @@ package com.iflytek.skillhub.domain.suite; import com.iflytek.skillhub.domain.audit.AuditDetail; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.event.SkillSuiteReviewSubmittedEvent; +import com.iflytek.skillhub.domain.event.SkillSuiteReviewCompletedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; @@ -18,6 +20,7 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,6 +30,7 @@ import java.util.Comparator; import java.util.ConcurrentModificationException; import java.util.EnumSet; import java.util.Set; +import java.util.UUID; /** Coordinates Suite version review and publication without changing any member lifecycle. */ @Service @@ -41,6 +45,7 @@ public class SkillSuiteLifecycleService { private final NamespaceRepository namespaceRepository; private final SkillSuitePublicationValidator publicationValidator; private final AuditLogService auditLogService; + private final ApplicationEventPublisher eventPublisher; private final Clock clock; private final boolean reviewWritesEnabled; @@ -52,6 +57,7 @@ public class SkillSuiteLifecycleService { NamespaceRepository namespaceRepository, SkillSuitePublicationValidator publicationValidator, AuditLogService auditLogService, + ApplicationEventPublisher eventPublisher, Clock clock, @Value("${skillhub.suite.review-writes-enabled}") boolean reviewWritesEnabled ) { @@ -62,6 +68,7 @@ public class SkillSuiteLifecycleService { this.namespaceRepository = namespaceRepository; this.publicationValidator = publicationValidator; this.auditLogService = auditLogService; + this.eventPublisher = eventPublisher; this.clock = clock; this.reviewWritesEnabled = reviewWritesEnabled; if (!reviewWritesEnabled) { @@ -91,6 +98,8 @@ public class SkillSuiteLifecycleService { versionId, suiteId, loaded.suite().getNamespaceId(), loaded.version().getVersion(), context.actorUserId())); audit(context, "SUBMIT_SKILL_SUITE_REVIEW", "SKILL_SUITE_VERSION", versionId, null); + eventPublisher.publishEvent(new SkillSuiteReviewSubmittedEvent( + task.getId(), suiteId, versionId, context.actorUserId(), loaded.suite().getNamespaceId())); log.info("Suite review submitted [suiteId={}, versionId={}, actorId={}, requestId={}]", suiteId, versionId, context.actorUserId(), context.requestId()); return task; @@ -133,6 +142,10 @@ public class SkillSuiteLifecycleService { publish(loaded.suite(), loaded.version(), context.actorUserId()); audit(context, "APPROVE_SKILL_SUITE_REVIEW", "REVIEW_TASK", reviewTaskId, AuditDetail.of("suiteVersionId", loaded.version().getId())); + eventPublisher.publishEvent(new SkillSuiteReviewCompletedEvent( + task.getId(), loaded.suite().getId(), loaded.version().getId(), task.getSubmittedBy(), + context.actorUserId(), loaded.suite().getNamespaceId(), ReviewTaskStatus.APPROVED, + UUID.randomUUID().toString(), Instant.now(clock))); log.info("Suite review approved [reviewTaskId={}, suiteId={}, versionId={}, actorId={}, requestId={}]", reviewTaskId, loaded.suite().getId(), loaded.version().getId(), context.actorUserId(), context.requestId()); @@ -153,6 +166,10 @@ public class SkillSuiteLifecycleService { versionRepository.save(loaded.version()); audit(context, "REJECT_SKILL_SUITE_REVIEW", "REVIEW_TASK", reviewTaskId, AuditDetail.of("suiteVersionId", loaded.version().getId())); + eventPublisher.publishEvent(new SkillSuiteReviewCompletedEvent( + task.getId(), loaded.suite().getId(), loaded.version().getId(), task.getSubmittedBy(), + context.actorUserId(), loaded.suite().getNamespaceId(), ReviewTaskStatus.REJECTED, + UUID.randomUUID().toString(), Instant.now(clock))); log.info("Suite review rejected [reviewTaskId={}, suiteId={}, versionId={}, actorId={}, requestId={}]", reviewTaskId, loaded.suite().getId(), loaded.version().getId(), context.actorUserId(), context.requestId()); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/event/BusinessEventIdentityCompatibilityTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/event/BusinessEventIdentityCompatibilityTest.java new file mode 100644 index 000000000..d8b062a21 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/event/BusinessEventIdentityCompatibilityTest.java @@ -0,0 +1,40 @@ +package com.iflytek.skillhub.domain.event; + +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +class BusinessEventIdentityCompatibilityTest { + @Test + void snapshotConstructorsKeepProducerTimeAndGenerateOneStableIdentity() throws Exception { + var time = Instant.parse("2026-01-01T00:00:00Z"); + var snapshot = new BusinessEventSnapshot(1L, SkillVisibility.PUBLIC, "actor", "user", "web", time); + List events = List.of( + new SkillPublishedEvent(1L, 2L, "actor", snapshot), + new SkillVersionYankedEvent(1L, 2L, "actor", true, snapshot), + new ReviewApprovedEvent(3L, 1L, 2L, "actor", "author", snapshot), + new ReviewRejectedEvent(3L, 1L, 2L, "actor", "author", "reason", snapshot), + new PromotionApprovedEvent(3L, 1L, "actor", "author", snapshot), + new PromotionRejectedEvent(3L, 1L, "actor", "author", "reason", snapshot)); + for (var event : events) { + assertThat(event.snapshot()).isSameAs(snapshot); + assertThat(event.getClass().getMethod("occurredAt").invoke(event)).isEqualTo(time); + var accessor = event.getClass().getMethod("eventId"); + assertThat((String) accessor.invoke(event)).isNotBlank().isEqualTo(accessor.invoke(event)); + } + } + + @Test + void emailIdentityConstructorDoesNotInventABusinessSnapshot() { + var time = Instant.parse("2026-01-01T00:00:00Z"); + var event = new SkillPublishedEvent(1L, 2L, "actor", "replayed-event", time); + assertThat(event.snapshot()).isNull(); + assertThat(event.eventId()).isEqualTo("replayed-event"); + assertThat(event.occurredAt()).isEqualTo(time); + var next = new SkillPublishedEvent(1L, 2L, "actor"); + assertThat(next.snapshot()).isNull(); + assertThat(next.eventId()).isNotEqualTo(new SkillPublishedEvent(1L, 2L, "actor").eventId()); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleServiceTest.java index af5e6441f..dfdcb1c4c 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/suite/SkillSuiteLifecycleServiceTest.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.domain.suite; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.event.SkillSuiteReviewSubmittedEvent; +import com.iflytek.skillhub.domain.event.SkillSuiteReviewCompletedEvent; import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceRepository; import com.iflytek.skillhub.domain.namespace.NamespaceRole; @@ -17,8 +19,12 @@ import com.iflytek.skillhub.domain.skill.SkillVisibility; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.mockito.Mock; +import org.mockito.ArgumentCaptor; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import java.time.Clock; import java.time.Instant; @@ -45,6 +51,7 @@ class SkillSuiteLifecycleServiceTest { @Mock private NamespaceRepository namespaceRepository; @Mock private SkillSuitePublicationValidator publicationValidator; @Mock private AuditLogService auditLogService; + @Mock private ApplicationEventPublisher eventPublisher; private SkillSuiteLifecycleService service; private SkillSuite suite; @@ -55,7 +62,7 @@ class SkillSuiteLifecycleServiceTest { Clock clock = Clock.fixed(Instant.parse("2026-09-07T08:00:00Z"), ZoneOffset.UTC); service = new SkillSuiteLifecycleService( suiteRepository, versionRepository, reviewTaskRepository, new ReviewPermissionChecker(), namespaceRepository, - publicationValidator, auditLogService, clock, true); + publicationValidator, auditLogService, eventPublisher, clock, true); suite = new SkillSuite(1L, "writers", "Writers", "author"); version = new SkillSuiteVersion(10L, "1.0.0", SkillVisibility.PRIVATE, "author"); setId(suite, 10L); @@ -75,6 +82,7 @@ class SkillSuiteLifecycleServiceTest { verify(auditLogService).record( "author", "PUBLISH_SKILL_SUITE_VERSION", "SKILL_SUITE_VERSION", 20L, "request-1", "127.0.0.1", "test", null); + verify(eventPublisher, never()).publishEvent(any(Object.class)); } @Test @@ -93,12 +101,17 @@ class SkillSuiteLifecycleServiceTest { verify(suiteRepository, never()).save(any()); } - @Test - void publicDraftCreatesTypedReviewAndBecomesPending() { - version.setVisibility(SkillVisibility.PUBLIC); + @ParameterizedTest + @EnumSource(value = SkillVisibility.class, names = {"PUBLIC", "NAMESPACE_ONLY"}) + void shareableDraftCreatesTypedReviewAndBecomesPending(SkillVisibility visibility) throws Exception { + version.setVisibility(visibility); stubLoaded(); when(versionRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); - when(reviewTaskRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(reviewTaskRepository.save(any())).thenAnswer(invocation -> { + ReviewTask task = invocation.getArgument(0); + setId(task, 30L); + return task; + }); ReviewTask task = service.submitForReview(10L, 20L, context()); @@ -107,6 +120,46 @@ class SkillSuiteLifecycleServiceTest { assertThat(task.getSubjectId()).isEqualTo(10L); assertThat(task.getSubjectVersionId()).isEqualTo(20L); verify(publicationValidator).validateForPublication(suite, version); + var submitted = org.mockito.ArgumentCaptor.forClass(SkillSuiteReviewSubmittedEvent.class); + verify(eventPublisher).publishEvent(submitted.capture()); + assertThat(submitted.getValue().reviewId()).isEqualTo(30L); + assertThat(submitted.getValue().suiteId()).isEqualTo(10L); + assertThat(submitted.getValue().versionId()).isEqualTo(20L); + assertThat(submitted.getValue().submitterId()).isEqualTo("author"); + assertThat(submitted.getValue().namespaceId()).isEqualTo(1L); + assertThat(submitted.getValue().eventId()).isNotBlank(); + assertThat(submitted.getValue().occurredAt()).isNotNull(); + } + + @Test + void repeatedSubmissionDoesNotCreateAnotherReviewOrEmitAnotherNotificationEvent() { + version.setVisibility(SkillVisibility.NAMESPACE_ONLY); + version.setStatus(SkillSuiteVersionStatus.PENDING_REVIEW); + stubLoaded(); + + assertThatThrownBy(() -> service.submitForReview(10L, 20L, context())) + .isInstanceOf(DomainBadRequestException.class) + .hasMessage("error.suite.review.notDraft"); + + verify(reviewTaskRepository, never()).save(any()); + verify(versionRepository, never()).save(any()); + verify(eventPublisher, never()).publishEvent(any(Object.class)); + } + + @Test + void failedSubmissionAuditDoesNotEmitANotificationEvent() { + version.setVisibility(SkillVisibility.PUBLIC); + stubLoaded(); + when(versionRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(reviewTaskRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + doThrow(new IllegalStateException("Controlled audit failure")).when(auditLogService).record( + "author", "SUBMIT_SKILL_SUITE_REVIEW", "SKILL_SUITE_VERSION", 20L, + "request-1", "127.0.0.1", "test", null); + + assertThatThrownBy(() -> service.submitForReview(10L, 20L, context())) + .isInstanceOf(IllegalStateException.class); + + verify(eventPublisher, never()).publishEvent(any(Object.class)); } @Test @@ -123,6 +176,7 @@ class SkillSuiteLifecycleServiceTest { assertThat(version.getStatus()).isEqualTo(SkillSuiteVersionStatus.DRAFT); verify(reviewTaskRepository, never()).save(any()); verify(versionRepository, never()).save(any()); + verify(eventPublisher, never()).publishEvent(any(Object.class)); } @Test @@ -143,6 +197,54 @@ class SkillSuiteLifecycleServiceTest { assertThat(task.getStatus()).isEqualTo(ReviewTaskStatus.APPROVED); } + @ParameterizedTest + @EnumSource(value = ReviewTaskStatus.class, names = {"APPROVED", "REJECTED"}) + void completedReviewEmitsOnlyOneBoundResultForTheActualSubmitter(ReviewTaskStatus result) throws Exception { + version.setVisibility(SkillVisibility.PUBLIC); + version.setStatus(SkillSuiteVersionStatus.PENDING_REVIEW); + ReviewTask task = pendingReview("version-submitter"); + stubLoaded(); + when(reviewTaskRepository.findById(30L)).thenReturn(Optional.of(task)); + when(reviewTaskRepository.updateStatusWithVersion(30L, result, "owner", "Decision", 1)).thenReturn(1); + when(versionRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + if (result == ReviewTaskStatus.APPROVED) { + when(suiteRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + service.approveReview(30L, "Decision", adminContext()); + } else { + service.rejectReview(30L, "Decision", adminContext()); + } + + ArgumentCaptor event = + ArgumentCaptor.forClass(SkillSuiteReviewCompletedEvent.class); + verify(eventPublisher).publishEvent(event.capture()); + assertThat(event.getValue().reviewId()).isEqualTo(30L); + assertThat(event.getValue().suiteId()).isEqualTo(10L); + assertThat(event.getValue().versionId()).isEqualTo(20L); + assertThat(event.getValue().namespaceId()).isEqualTo(1L); + assertThat(event.getValue().submitterId()).isEqualTo("version-submitter"); + assertThat(event.getValue().reviewerId()).isEqualTo("owner"); + assertThat(event.getValue().status()).isEqualTo(result); + assertThat(event.getValue().occurredAt()).isEqualTo(Instant.parse("2026-09-07T08:00:00Z")); + assertThat(java.util.UUID.fromString(event.getValue().eventId())).isNotNull(); + assertThatThrownBy(() -> service.approveReview(30L, "Again", adminContext())) + .isInstanceOf(DomainBadRequestException.class); + verify(eventPublisher).publishEvent(any(SkillSuiteReviewCompletedEvent.class)); + } + + @ParameterizedTest + @EnumSource(value = ReviewTaskStatus.class, names = {"APPROVED", "REJECTED"}) + void failedReviewCompareAndSetDoesNotEmitAResult(ReviewTaskStatus result) throws Exception { + version.setVisibility(SkillVisibility.PUBLIC); + version.setStatus(SkillSuiteVersionStatus.PENDING_REVIEW); + stubLoaded(); + when(reviewTaskRepository.findById(30L)).thenReturn(Optional.of(pendingReview("author"))); + assertThatThrownBy(() -> { + if (result == ReviewTaskStatus.APPROVED) service.approveReview(30L, "Decision", adminContext()); + else service.rejectReview(30L, "Decision", adminContext()); + }).isInstanceOf(java.util.ConcurrentModificationException.class); + verify(eventPublisher, never()).publishEvent(any(Object.class)); + } + @Test void approvalFailureKeepsTheReviewAndSuiteVersionPending() throws Exception { version.setVisibility(SkillVisibility.PUBLIC); @@ -210,7 +312,7 @@ class SkillSuiteLifecycleServiceTest { void rolloutGateRejectsTypedReviewWritesBeforeLoadingTheSuite() { SkillSuiteLifecycleService disabled = new SkillSuiteLifecycleService( suiteRepository, versionRepository, reviewTaskRepository, new ReviewPermissionChecker(), - namespaceRepository, publicationValidator, auditLogService, + namespaceRepository, publicationValidator, auditLogService, eventPublisher, Clock.systemUTC(), false); assertThatThrownBy(() -> disabled.submitForReview(10L, 20L, context())) @@ -224,7 +326,7 @@ class SkillSuiteLifecycleServiceTest { version.setVisibility(SkillVisibility.PUBLIC); SkillSuiteLifecycleService disabled = new SkillSuiteLifecycleService( suiteRepository, versionRepository, reviewTaskRepository, new ReviewPermissionChecker(), - namespaceRepository, publicationValidator, auditLogService, + namespaceRepository, publicationValidator, auditLogService, eventPublisher, Clock.systemUTC(), false); Set actions = disabled.allowedActions( diff --git a/web/src/features/notification/notification-content.test.ts b/web/src/features/notification/notification-content.test.ts index 7ff569731..98357ffca 100644 --- a/web/src/features/notification/notification-content.test.ts +++ b/web/src/features/notification/notification-content.test.ts @@ -2,6 +2,45 @@ import { describe, expect, it } from 'vitest' import { resolveNotificationDisplay } from './notification-content' describe('resolveNotificationDisplay', () => { + it.each([ + ['zh-CN', '套件审核提交'], + ['en', 'Suite review submitted'], + ['ko-KR', '스킬 모음 검토 제출'], + ['ru', 'Набор отправлен на проверку'], + ])('renders a Suite submission with its own title and bound version in %s', (language, title) => { + const display = resolveNotificationDisplay({ + id: 1, + category: 'REVIEW', + eventType: 'SUITE_REVIEW_SUBMITTED', + title: 'Suite review submitted', + bodyJson: JSON.stringify({ suiteName: 'Writing suite', version: '2.0.0' }), + status: 'UNREAD', + createdAt: '2026-11-05T00:00:00Z', + }, language) + + expect(display.title).toBe(title) + expect(display.description).toContain('Writing suite') + expect(display.description).toContain('2.0.0') + expect(display.description).not.toContain('{{') + }) + + it.each([ + ['zh', 'SUITE_REVIEW_APPROVED', '套件审核通过'], + ['zh', 'SUITE_REVIEW_REJECTED', '套件审核未通过'], + ['en', 'SUITE_REVIEW_APPROVED', 'Suite review approved'], + ['ko', 'SUITE_REVIEW_REJECTED', '스킬 모음 검토 반려'], + ['ru', 'SUITE_REVIEW_APPROVED', 'Проверка набора одобрена'], + ])('renders a Suite result with its submitted version in %s', (language, eventType, title) => { + const display = resolveNotificationDisplay({ + id: 2, category: 'REVIEW', eventType, title: 'Server title', + bodyJson: JSON.stringify({ suiteName: 'Writing suite', version: '2.0.0' }), + status: 'UNREAD', createdAt: '2026-11-06T00:00:00Z', + }, language) + expect(display.title).toBe(title) + expect(display.description).toContain('Writing suite') + expect(display.description).toContain('2.0.0') + }) + it('renders review submitted content in Chinese', () => { const display = resolveNotificationDisplay({ category: 'REVIEW', diff --git a/web/src/features/notification/notification-content.ts b/web/src/features/notification/notification-content.ts index 6a2c09191..b0a477f93 100644 --- a/web/src/features/notification/notification-content.ts +++ b/web/src/features/notification/notification-content.ts @@ -8,6 +8,7 @@ export type NotificationDisplay = { type NotificationBody = { skillName?: string + suiteName?: string version?: string } @@ -24,6 +25,7 @@ function parseBody(bodyJson?: string): NotificationBody { } const localizedEvents = new Set([ + 'SUITE_REVIEW_SUBMITTED', 'SUITE_REVIEW_APPROVED', 'SUITE_REVIEW_REJECTED', 'REVIEW_SUBMITTED', 'REVIEW_APPROVED', 'REVIEW_REJECTED', 'PROMOTION_SUBMITTED', 'PROMOTION_APPROVED', 'PROMOTION_REJECTED', 'REPORT_SUBMITTED', 'REPORT_RESOLVED', 'SKILL_PUBLISHED', @@ -35,6 +37,8 @@ export function resolveNotificationDisplay(item: NotificationItem, language: str const t = i18n.getFixedT(language) const body = parseBody(item.bodyJson) const skillName = body.skillName ?? '' + const suiteName = body.suiteName ?? '' + const resourceName = item.eventType.startsWith('SUITE_REVIEW_') ? suiteName : skillName const version = body.version ?? '' const versionSuffix = version ? (zh ? `(${version})` : ` (${version})`) : '' @@ -44,8 +48,8 @@ export function resolveNotificationDisplay(item: NotificationItem, language: str return { title: t(`notificationEvents.${item.eventType}.title`), - description: skillName - ? t(`notificationEvents.${item.eventType}.description`, { skillName, versionSuffix }) + description: resourceName + ? t(`notificationEvents.${item.eventType}.description`, { skillName, suiteName, versionSuffix }) : '', } } diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 44e360f7a..30e4ce0e4 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -3725,6 +3725,18 @@ "days": "{{count}}d" }, "notificationEvents": { + "SUITE_REVIEW_APPROVED": { + "title": "Suite review approved", + "description": "The review of {{suiteName}}{{versionSuffix}} was approved." + }, + "SUITE_REVIEW_REJECTED": { + "title": "Suite review rejected", + "description": "The review of {{suiteName}}{{versionSuffix}} was rejected. Check review progress for details." + }, + "SUITE_REVIEW_SUBMITTED": { + "title": "Suite review submitted", + "description": "{{suiteName}}{{versionSuffix}} was submitted for review." + }, "REVIEW_SUBMITTED": { "title": "Review submitted", "description": "{{skillName}}{{versionSuffix}} was submitted for review." diff --git a/web/src/i18n/locales/ko.json b/web/src/i18n/locales/ko.json index 7f74b65d1..b01735d3d 100644 --- a/web/src/i18n/locales/ko.json +++ b/web/src/i18n/locales/ko.json @@ -3723,6 +3723,18 @@ "days": "{{count}}일 전" }, "notificationEvents": { + "SUITE_REVIEW_APPROVED": { + "title": "스킬 모음 검토 승인", + "description": "{{suiteName}}{{versionSuffix}} 검토가 승인되었습니다." + }, + "SUITE_REVIEW_REJECTED": { + "title": "스킬 모음 검토 반려", + "description": "{{suiteName}}{{versionSuffix}} 검토가 반려되었습니다. 검토 진행 상황을 확인하세요." + }, + "SUITE_REVIEW_SUBMITTED": { + "title": "스킬 모음 검토 제출", + "description": "{{suiteName}}{{versionSuffix}}의 검토를 요청했습니다." + }, "REVIEW_SUBMITTED": { "title": "스킬 검토 제출", "description": "{{skillName}}{{versionSuffix}}의 검토를 요청했습니다." diff --git a/web/src/i18n/locales/ru.json b/web/src/i18n/locales/ru.json index 1383027fc..0029be0df 100644 --- a/web/src/i18n/locales/ru.json +++ b/web/src/i18n/locales/ru.json @@ -3729,6 +3729,18 @@ "days": "{{count}} д" }, "notificationEvents": { + "SUITE_REVIEW_APPROVED": { + "title": "Проверка набора одобрена", + "description": "Проверка {{suiteName}}{{versionSuffix}} одобрена." + }, + "SUITE_REVIEW_REJECTED": { + "title": "Проверка набора отклонена", + "description": "Проверка {{suiteName}}{{versionSuffix}} отклонена. Подробности доступны в ходе проверки." + }, + "SUITE_REVIEW_SUBMITTED": { + "title": "Набор отправлен на проверку", + "description": "{{suiteName}}{{versionSuffix}} отправлен на проверку." + }, "REVIEW_SUBMITTED": { "title": "Проверка отправлена", "description": "{{skillName}}{{versionSuffix}} отправлен на проверку." diff --git a/web/src/i18n/locales/zh.json b/web/src/i18n/locales/zh.json index 3a985113b..cad0b4195 100644 --- a/web/src/i18n/locales/zh.json +++ b/web/src/i18n/locales/zh.json @@ -3723,6 +3723,18 @@ "days": "{{count}}天" }, "notificationEvents": { + "SUITE_REVIEW_APPROVED": { + "title": "套件审核通过", + "description": "{{suiteName}}{{versionSuffix}} 的审核已通过。" + }, + "SUITE_REVIEW_REJECTED": { + "title": "套件审核未通过", + "description": "{{suiteName}}{{versionSuffix}} 的审核未通过,请查看审核进度。" + }, + "SUITE_REVIEW_SUBMITTED": { + "title": "套件审核提交", + "description": "{{suiteName}}{{versionSuffix}} 已提交审核。" + }, "REVIEW_SUBMITTED": { "title": "技能审核提交", "description": "{{skillName}}{{versionSuffix}} 已提交审核。"