From a865960cf3ae5b873f51e63229b31f054341acbc Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Tue, 16 Jun 2026 09:54:32 +0800 Subject: [PATCH 1/3] fix(notification): ISSUE-51 add profile review notifications Signed-off-by: dongmucat <1127093059@qq.com> --- docs/api/notification.openapi.yaml | 247 ++++++++++++++++++ .../portal/NotificationController.java | 3 + .../listener/NotificationEventListener.java | 16 ++ .../skillhub/listener/RecipientResolver.java | 10 + .../portal/NotificationControllerTest.java | 22 ++ .../NotificationEventListenerTest.java | 15 ++ .../listener/RecipientResolverTest.java | 16 ++ .../event/ProfileReviewSubmittedEvent.java | 10 + .../domain/user/UserProfileService.java | 22 +- .../domain/user/UserProfileServiceTest.java | 56 ++++ web/e2e/reviews-pagination.spec.ts | 8 + web/src/api/generated/schema.d.ts | 4 + web/src/app/router.tsx | 3 + web/src/pages/dashboard/reviews.test.ts | 14 + web/src/pages/dashboard/reviews.tsx | 22 +- 15 files changed, 456 insertions(+), 12 deletions(-) create mode 100644 docs/api/notification.openapi.yaml create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ProfileReviewSubmittedEvent.java diff --git a/docs/api/notification.openapi.yaml b/docs/api/notification.openapi.yaml new file mode 100644 index 00000000..af5ce4fd --- /dev/null +++ b/docs/api/notification.openapi.yaml @@ -0,0 +1,247 @@ +openapi: 3.0.3 +info: + title: SkillHub Notification API + version: 1.0.0 + description: In-app notification list, unread count, and SSE event contract. +servers: + - url: /api/web + - url: /api/v1 +paths: + /notifications: + get: + summary: List notifications for the current user + operationId: listNotifications + security: + - sessionAuth: [] + parameters: + - name: category + in: query + required: false + description: Optional category filter. Profile review notifications reuse REVIEW. + schema: + type: string + enum: [PUBLISH, REVIEW, PROMOTION, REPORT] + example: REVIEW + - name: page + in: query + required: false + schema: + type: integer + minimum: 0 + default: 0 + example: 0 + - name: size + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + example: 20 + responses: + "200": + description: Notification page. + content: + application/json: + schema: + $ref: "#/components/schemas/NotificationPageResponse" + "400": + description: Invalid category. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "401": + description: Authentication required. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /notifications/unread-count: + get: + summary: Get unread notification count for the current user + operationId: getNotificationUnreadCount + security: + - sessionAuth: [] + responses: + "200": + description: Unread count. + content: + application/json: + schema: + $ref: "#/components/schemas/UnreadCountResponse" + "401": + description: Authentication required. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /notifications/sse: + get: + summary: Open notification SSE stream for the current user + operationId: streamNotifications + security: + - sessionAuth: [] + responses: + "200": + description: SSE stream. Each `notification` event carries NotificationSsePayload. + content: + text/event-stream: + schema: + type: string + "401": + description: Authentication required. +components: + securitySchemes: + sessionAuth: + type: apiKey + in: cookie + name: SESSION + schemas: + NotificationPageResponse: + type: object + properties: + code: + type: integer + example: 0 + msg: + type: string + example: ok + data: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/Notification" + total: + type: integer + format: int64 + example: 1 + page: + type: integer + example: 0 + size: + type: integer + example: 20 + timestamp: + type: string + format: date-time + requestId: + type: string + Notification: + type: object + properties: + id: + type: integer + format: int64 + example: 1001 + category: + type: string + enum: [PUBLISH, REVIEW, PROMOTION, REPORT] + example: REVIEW + eventType: + type: string + description: Known values include REVIEW_SUBMITTED, REVIEW_APPROVED, REVIEW_REJECTED, PROFILE_REVIEW_SUBMITTED, PROMOTION_SUBMITTED, PROMOTION_APPROVED, PROMOTION_REJECTED, REPORT_SUBMITTED, REPORT_RESOLVED, SKILL_PUBLISHED, SUBSCRIPTION_NEW_VERSION, and SUBSCRIPTION_VERSION_YANKED. + example: PROFILE_REVIEW_SUBMITTED + title: + type: string + example: Profile review submitted + bodyJson: + type: string + description: JSON string. For PROFILE_REVIEW_SUBMITTED it contains profileReviewId, submitterId, and fields. + example: '{"profileReviewId":77,"submitterId":"user-1","fields":["displayName"]}' + entityType: + type: string + example: PROFILE_REVIEW + entityId: + type: integer + format: int64 + example: 77 + status: + type: string + enum: [UNREAD, READ] + example: UNREAD + createdAt: + type: string + format: date-time + readAt: + type: string + format: date-time + nullable: true + targetType: + type: string + example: PROFILE_REVIEW + targetId: + type: integer + format: int64 + example: 77 + targetRoute: + type: string + description: Profile review notifications route to the admin profile review queue. + example: /dashboard/reviews?type=profile + NotificationSsePayload: + type: object + properties: + id: + type: integer + format: int64 + example: 1001 + category: + type: string + example: REVIEW + eventType: + type: string + example: PROFILE_REVIEW_SUBMITTED + title: + type: string + example: Profile review submitted + bodyJson: + type: string + example: '{"profileReviewId":77,"submitterId":"user-1","fields":["displayName"]}' + entityType: + type: string + example: PROFILE_REVIEW + entityId: + type: integer + format: int64 + example: 77 + createdAt: + type: string + format: date-time + UnreadCountResponse: + type: object + properties: + code: + type: integer + example: 0 + msg: + type: string + example: ok + data: + type: object + properties: + count: + type: integer + format: int64 + example: 1 + timestamp: + type: string + format: date-time + requestId: + type: string + ErrorResponse: + type: object + properties: + code: + type: integer + example: 400 + msg: + type: string + example: Invalid category + timestamp: + type: string + format: date-time + requestId: + type: string 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 6a1f1c68..7ab2f759 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 @@ -113,6 +113,9 @@ public class NotificationController extends BaseApiController { if ("REVIEW_SUBMITTED".equals(eventType) && entityId != null) { return new NotificationTarget("REVIEW", entityId, "/dashboard/reviews/" + entityId); } + if ("PROFILE_REVIEW_SUBMITTED".equals(eventType) && entityId != null) { + return new NotificationTarget("PROFILE_REVIEW", entityId, "/dashboard/reviews?type=profile"); + } if ("PROMOTION_SUBMITTED".equals(eventType)) { return new NotificationTarget("PROMOTION", entityId, "/dashboard/promotions"); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java index 74ad10d8..6d38c9f7 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/NotificationEventListener.java @@ -127,6 +127,22 @@ public class NotificationEventListener { }); } + @Async("skillhubEventExecutor") + @TransactionalEventListener + public void onProfileReviewSubmitted(ProfileReviewSubmittedEvent event) { + String title = "Profile review submitted"; + Map body = new LinkedHashMap<>(); + body.put("profileReviewId", event.profileReviewId()); + body.put("submitterId", event.submitterId()); + body.put("fields", event.fields()); + String json = toJson(body); + List admins = recipientResolver.resolvePlatformUserAdmins(); + for (String admin : admins.stream().distinct().toList()) { + dispatcher.dispatch(admin, NotificationCategory.REVIEW, + "PROFILE_REVIEW_SUBMITTED", title, json, "PROFILE_REVIEW", event.profileReviewId()); + } + } + @Async("skillhubEventExecutor") @TransactionalEventListener public void onReviewApproved(ReviewApprovedEvent event) { diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/RecipientResolver.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/RecipientResolver.java index 90e076d7..0ea087d3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/RecipientResolver.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/listener/RecipientResolver.java @@ -39,4 +39,14 @@ public class RecipientResolver { List::copyOf )); } + + public List resolvePlatformUserAdmins() { + return userRoleBindingRepository.findByRole_CodeIn(Set.of("USER_ADMIN", "SUPER_ADMIN")) + .stream() + .map(binding -> binding.getUserId()) + .collect(java.util.stream.Collectors.collectingAndThen( + java.util.stream.Collectors.toCollection(LinkedHashSet::new), + List::copyOf + )); + } } 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 2536180e..2f344f2c 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 @@ -70,6 +70,28 @@ 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_shouldExposeProfileReviewTargetRouteForSubmittedProfileReviewNotifications() { + Notification notification = notification( + 15L, + NotificationCategory.REVIEW, + "PROFILE_REVIEW_SUBMITTED", + "{\"profileReviewId\":77,\"submitterId\":\"user-1\",\"fields\":[\"displayName\"]}", + "PROFILE_REVIEW", + 77L + ); + 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("PROFILE_REVIEW"); + assertThat(item.targetId()).isEqualTo(77L); + assertThat(item.targetRoute()).isEqualTo("/dashboard/reviews?type=profile"); + }); + } + @Test void list_shouldExposeSkillRouteForResolvedWorkflowNotifications() { Notification notification = notification( diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java index 8e59b434..8ffd251f 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/NotificationEventListenerTest.java @@ -99,6 +99,21 @@ class NotificationEventListenerTest { verify(dispatcher).dispatch(eq("admin-2"), any(), any(), any(), any(), any(), any()); } + @Test + void onProfileReviewSubmitted_shouldDispatchToPlatformUserAdmins() throws Exception { + when(objectMapper.writeValueAsString(any())).thenReturn("{}"); + when(recipientResolver.resolvePlatformUserAdmins()) + .thenReturn(List.of("user-admin-1", "super-admin-1", "user-admin-1")); + + listener.onProfileReviewSubmitted( + new ProfileReviewSubmittedEvent(77L, "submitter-1", List.of("displayName"))); + + verify(dispatcher, times(2)).dispatch(anyString(), eq(NotificationCategory.REVIEW), + eq("PROFILE_REVIEW_SUBMITTED"), anyString(), anyString(), eq("PROFILE_REVIEW"), eq(77L)); + verify(dispatcher).dispatch(eq("user-admin-1"), any(), any(), any(), any(), any(), any()); + verify(dispatcher).dispatch(eq("super-admin-1"), any(), any(), any(), any(), any(), any()); + } + @Test void onReviewApproved_shouldDispatchToSubmitter() throws Exception { Skill skill = mockSkill(1L); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/RecipientResolverTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/RecipientResolverTest.java index 20325b9d..c056a1ad 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/RecipientResolverTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/listener/RecipientResolverTest.java @@ -80,4 +80,20 @@ class RecipientResolverTest { assertThat(result).containsExactly("skill-admin", "super-admin"); } + + @Test + void resolvePlatformUserAdmins_shouldReturnUserAdminsAndSuperAdmins() { + UserRoleBinding userAdmin = mock(UserRoleBinding.class); + UserRoleBinding superAdmin = mock(UserRoleBinding.class); + UserRoleBinding duplicate = mock(UserRoleBinding.class); + when(userAdmin.getUserId()).thenReturn("user-admin"); + when(superAdmin.getUserId()).thenReturn("super-admin"); + when(duplicate.getUserId()).thenReturn("user-admin"); + when(userRoleBindingRepository.findByRole_CodeIn(Set.of("USER_ADMIN", "SUPER_ADMIN"))) + .thenReturn(List.of(userAdmin, superAdmin, duplicate)); + + List result = resolver.resolvePlatformUserAdmins(); + + assertThat(result).containsExactly("user-admin", "super-admin"); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ProfileReviewSubmittedEvent.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ProfileReviewSubmittedEvent.java new file mode 100644 index 00000000..f0c250d1 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/event/ProfileReviewSubmittedEvent.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.domain.event; + +import java.util.List; + +public record ProfileReviewSubmittedEvent(Long profileReviewId, String submitterId, List fields) { + + public ProfileReviewSubmittedEvent { + fields = List.copyOf(fields); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java index 68b3951b..c6f037c3 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserProfileService.java @@ -3,10 +3,13 @@ package com.iflytek.skillhub.domain.user; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.event.ProfileReviewSubmittedEvent; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** @@ -29,19 +32,22 @@ public class UserProfileService { private final ProfileModerationConfig moderationConfig; private final ProfileFieldPolicyConfig fieldPolicyConfig; private final AuditLogService auditLogService; + private final ApplicationEventPublisher eventPublisher; public UserProfileService(UserAccountRepository userAccountRepository, ProfileChangeRequestRepository changeRequestRepository, ProfileModerationService moderationService, ProfileModerationConfig moderationConfig, ProfileFieldPolicyConfig fieldPolicyConfig, - AuditLogService auditLogService) { + AuditLogService auditLogService, + ApplicationEventPublisher eventPublisher) { this.userAccountRepository = userAccountRepository; this.changeRequestRepository = changeRequestRepository; this.moderationService = moderationService; this.moderationConfig = moderationConfig; this.fieldPolicyConfig = fieldPolicyConfig; this.auditLogService = auditLogService; + this.eventPublisher = eventPublisher; } /** @@ -112,8 +118,10 @@ public class UserProfileService { // 5. Queue review changes if (!reviewChanges.isEmpty()) { cancelPendingRequests(userId); - saveChangeRequest(userId, reviewChanges, oldValues, ProfileChangeStatus.PENDING, - machineTag, null); + ProfileChangeRequest pendingRequest = saveChangeRequest(userId, reviewChanges, oldValues, + ProfileChangeStatus.PENDING, machineTag, null); + eventPublisher.publishEvent(new ProfileReviewSubmittedEvent( + pendingRequest.getId(), userId, List.copyOf(reviewChanges.keySet()))); } // 6. Return appropriate result @@ -166,9 +174,9 @@ public class UserProfileService { /** * Persist a change request record for audit and review purposes. */ - private void saveChangeRequest(String userId, Map changes, - Map oldValues, ProfileChangeStatus status, - String machineResult, String machineReason) { + private ProfileChangeRequest saveChangeRequest(String userId, Map changes, + Map oldValues, ProfileChangeStatus status, + String machineResult, String machineReason) { ProfileChangeRequest request = new ProfileChangeRequest( userId, toJson(changes), @@ -177,7 +185,7 @@ public class UserProfileService { machineResult, machineReason ); - changeRequestRepository.save(request); + return changeRequestRepository.save(request); } private String toJson(Object obj) { diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java index d6af56f6..1346e6a2 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/user/UserProfileServiceTest.java @@ -1,16 +1,21 @@ package com.iflytek.skillhub.domain.user; import com.iflytek.skillhub.domain.audit.AuditLogService; +import com.iflytek.skillhub.domain.event.ProfileReviewSubmittedEvent; +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.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import java.lang.reflect.Field; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -41,9 +46,24 @@ class UserProfileServiceTest { @Mock private AuditLogService auditLogService; + @Mock + private ApplicationEventPublisher eventPublisher; + @InjectMocks private UserProfileService userProfileService; + @BeforeEach + void setUp() { + AtomicLong ids = new AtomicLong(1L); + lenient().when(changeRequestRepository.save(any(ProfileChangeRequest.class))).thenAnswer(invocation -> { + ProfileChangeRequest request = invocation.getArgument(0); + if (request.getId() == null) { + setField(request, "id", ids.getAndIncrement()); + } + return request; + }); + } + // -- Helper -- private UserAccount testUser() { @@ -136,6 +156,32 @@ class UserProfileServiceTest { verify(auditLogService, never()).record(any(), any(), any(), any(), any(), any(), any(), any()); } + @Test + void updateProfile_humanReviewEnabled_shouldPublishProfileReviewSubmittedEvent() { + var user = testUser(); + when(userAccountRepository.findById("user-1")).thenReturn(Optional.of(user)); + when(moderationConfig.machineReview()).thenReturn(false); + when(moderationConfig.humanReview()).thenReturn(true); + stubFieldPolicies(true); + when(changeRequestRepository.findByUserIdAndStatus("user-1", ProfileChangeStatus.PENDING)) + .thenReturn(List.of()); + when(changeRequestRepository.save(any(ProfileChangeRequest.class))).thenAnswer(invocation -> { + ProfileChangeRequest request = invocation.getArgument(0); + setField(request, "id", 77L); + return request; + }); + + userProfileService.updateProfile( + "user-1", displayNameChange("NewName"), "req-1", "127.0.0.1", "TestAgent"); + + var eventCaptor = ArgumentCaptor.forClass(ProfileReviewSubmittedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + ProfileReviewSubmittedEvent event = eventCaptor.getValue(); + assertEquals(77L, event.profileReviewId()); + assertEquals("user-1", event.submitterId()); + assertEquals(List.of("displayName"), event.fields()); + } + // ===== AC-P-005: Overwrite existing PENDING request ===== @Test @@ -221,4 +267,14 @@ class UserProfileServiceTest { userProfileService.updateProfile( "nonexistent", displayNameChange("Name"), "req-1", "127.0.0.1", "TestAgent")); } + + private static void setField(Object target, String fieldName, Object value) { + try { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } } diff --git a/web/e2e/reviews-pagination.spec.ts b/web/e2e/reviews-pagination.spec.ts index f9906a35..83626e4d 100644 --- a/web/e2e/reviews-pagination.spec.ts +++ b/web/e2e/reviews-pagination.spec.ts @@ -81,4 +81,12 @@ test.describe('Review Management Pagination (Real API)', () => { } } }) + + test('opens the profile review queue from the review type search param', async ({ page }) => { + await page.goto('/dashboard/reviews?type=profile') + + await expect(page).toHaveURL(/\/dashboard\/reviews\?type=profile$/) + await expect(page.getByRole('heading', { name: 'Review Center' })).toBeVisible() + await expect(page.getByRole('heading', { name: 'Profile Review Queue' })).toBeVisible() + }) }) diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 9e056dff..07d8c65b 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -9938,6 +9938,8 @@ export interface operations { page?: number; size?: number; filter?: string; + q?: string; + namespace?: string; }; header?: never; path?: never; @@ -9962,6 +9964,8 @@ export interface operations { page?: number; size?: number; filter?: string; + q?: string; + namespace?: string; }; header?: never; path?: never; diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index d654e026..c9b3bac8 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -306,6 +306,9 @@ const dashboardReviewsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'dashboard/reviews', beforeLoad: requireAuth, + validateSearch: (search: Record): { type?: 'skill' | 'profile' } => ({ + type: search.type === 'skill' || search.type === 'profile' ? search.type : undefined, + }), component: ReviewsPage, }) diff --git a/web/src/pages/dashboard/reviews.test.ts b/web/src/pages/dashboard/reviews.test.ts index 694a0dce..186c15eb 100644 --- a/web/src/pages/dashboard/reviews.test.ts +++ b/web/src/pages/dashboard/reviews.test.ts @@ -2,8 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { renderToStaticMarkup } from 'react-dom/server' import { createElement } from 'react' +const useSearchMock = vi.fn() vi.mock('@tanstack/react-router', () => ({ useNavigate: () => vi.fn(), + useSearch: () => useSearchMock(), })) vi.mock('lucide-react', () => ({ @@ -119,6 +121,7 @@ describe('ReviewsPage', () => { data: [], isLoading: false, }) + useSearchMock.mockReturnValue({}) useReviewListMock.mockImplementation((status: string, _namespaceId: unknown, page: number, _size: number, _sortDirection: string, enabled: boolean) => { if (!enabled) { return { data: null, isLoading: false } @@ -180,4 +183,15 @@ describe('ReviewsPage', () => { expect(paginationProps[0]?.page).toBe(0) expect(paginationProps[0]?.totalPages).toBe(1) }) + + it('uses the profile review tab when the review type search param requests it', () => { + hasRoleMock.mockImplementation((role: string) => role === 'SKILL_ADMIN' || role === 'USER_ADMIN') + userMock.platformRoles = ['SUPER_ADMIN'] + useSearchMock.mockReturnValue({ type: 'profile' }) + + renderToStaticMarkup(createElement(ReviewsPage)) + + expect(useReviewListMock).toHaveBeenCalled() + expect(useReviewListMock.mock.calls.every((call) => call[5] === false)).toBe(true) + }) }) diff --git a/web/src/pages/dashboard/reviews.tsx b/web/src/pages/dashboard/reviews.tsx index 4ead6c84..94c0ded8 100644 --- a/web/src/pages/dashboard/reviews.tsx +++ b/web/src/pages/dashboard/reviews.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { useNavigate } from '@tanstack/react-router' +import { useNavigate, useSearch } from '@tanstack/react-router' import { FileCheck2 } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useMyNamespaces } from '@/shared/hooks/use-namespace-queries' @@ -38,6 +38,7 @@ const PAGE_SIZE = 20 export function ReviewsPage() { const { t, i18n } = useTranslation() const navigate = useNavigate() + const search = useSearch({ from: '/dashboard/reviews' }) const { hasRole, user } = useAuth() const { data: myNamespaces, isLoading: isLoadingNamespaces } = useMyNamespaces() const [pages, setPages] = useState>({ @@ -56,6 +57,9 @@ export function ReviewsPage() { // Determine default top-level tab const defaultType = isSkillAdmin ? 'skill' : 'profile' + const requestedType = search.type === 'skill' || search.type === 'profile' ? search.type : undefined + const activeType = showTypeTabs ? requestedType ?? defaultType : defaultType + const skillReviewEnabled = hasGlobalReviewAccess && isSkillAdmin && activeType === 'skill' useEffect(() => { if (hasGlobalReviewAccess || isLoadingNamespaces) { @@ -70,9 +74,9 @@ export function ReviewsPage() { void navigate({ to: '/dashboard', replace: true }) }, [hasGlobalReviewAccess, isLoadingNamespaces, namespaceReviewEntry, navigate]) - const pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'PENDING') - const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'APPROVED') - const rejectedQuery = useReviewList('REJECTED', undefined, pages.REJECTED, PAGE_SIZE, sortDirection, hasGlobalReviewAccess && activeStatus === 'REJECTED') + const pendingQuery = useReviewList('PENDING', undefined, pages.PENDING, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'PENDING') + const approvedQuery = useReviewList('APPROVED', undefined, pages.APPROVED, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'APPROVED') + const rejectedQuery = useReviewList('REJECTED', undefined, pages.REJECTED, PAGE_SIZE, sortDirection, skillReviewEnabled && activeStatus === 'REJECTED') const formatDate = (dateString: string) => formatLocalDateTime(dateString, i18n.language) @@ -93,6 +97,14 @@ export function ReviewsPage() { }) } + function handleTypeChange(value: string) { + void navigate({ + to: '/dashboard/reviews', + search: { type: value === 'profile' ? 'profile' : 'skill' }, + replace: true, + }) + } + function renderPagination(status: ReviewStatus, totalElements: number, totalPages: number) { const currentPage = pages[status] return ( @@ -249,7 +261,7 @@ export function ReviewsPage() { {showTypeTabs ? ( - + Date: Tue, 16 Jun 2026 11:57:49 +0800 Subject: [PATCH 2/3] fix(notification): keep SSE live push streams open (#524) Signed-off-by: dongmucat <1127093059@qq.com> --- docs/api/notification.openapi.yaml | 3 +- .../portal/NotificationController.java | 3 +- .../skillhub/filter/RequestLoggingFilter.java | 8 +++ .../filter/RequestLoggingFilterTest.java | 23 +++++++++ .../sse/SseEmitterManagerTest.java | 51 ++++++++++++++++++- web/src/api/generated/schema.d.ts | 4 +- 6 files changed, 87 insertions(+), 5 deletions(-) diff --git a/docs/api/notification.openapi.yaml b/docs/api/notification.openapi.yaml index af5ce4fd..86fe6c4c 100644 --- a/docs/api/notification.openapi.yaml +++ b/docs/api/notification.openapi.yaml @@ -80,12 +80,13 @@ paths: /notifications/sse: get: summary: Open notification SSE stream for the current user + description: Keeps a long-lived `text/event-stream` connection open. The server first sends a `connected` event, later sends `notification` events with NotificationSsePayload bodies, and may send heartbeat comments to keep the connection alive. operationId: streamNotifications security: - sessionAuth: [] responses: "200": - description: SSE stream. Each `notification` event carries NotificationSsePayload. + description: Long-lived SSE stream. Each `notification` event carries NotificationSsePayload. content: text/event-stream: schema: 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 7ab2f759..ec62ebb9 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 @@ -16,6 +16,7 @@ import jakarta.validation.constraints.Min; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; +import org.springframework.http.MediaType; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -78,7 +79,7 @@ public class NotificationController extends BaseApiController { return ok("response.success.deleted", null); } - @GetMapping("/sse") + @GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter sse(@RequestAttribute("userId") String userId) { return sseEmitterManager.register(userId); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java index cda766ff..0f83faee 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/RequestLoggingFilter.java @@ -30,6 +30,9 @@ public class RequestLoggingFilter extends OncePerRequestFilter { private static final Set SKIP_PREFIXES = Set.of( "/actuator", "/favicon.ico", "/assets/" ); + private static final Set SKIP_SUFFIXES = Set.of( + "/sse" + ); @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) @@ -89,6 +92,11 @@ public class RequestLoggingFilter extends OncePerRequestFilter { return true; } } + for (String suffix : SKIP_SUFFIXES) { + if (uri.endsWith(suffix)) { + return true; + } + } return false; } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java index c79708a3..495eeb63 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/RequestLoggingFilterTest.java @@ -78,6 +78,29 @@ class RequestLoggingFilterTest { assertThat(loggedMessages()).noneMatch(message -> message.contains("/actuator/health")); } + @Test + void doFilterInternal_skipsSseEndpointsWithoutWrappingResponse() + throws ServletException, IOException { + RequestLoggingFilter filter = new RequestLoggingFilter(); + attachAppender(); + + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/notifications/sse"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + FilterChain filterChain = (req, res) -> { + assertThat(res).isSameAs(response); + res.setContentType("text/event-stream"); + res.getWriter().write("event:connected\n"); + res.getWriter().flush(); + }; + + filter.doFilter(request, response, filterChain); + + assertThat(response.getHeader("Content-Length")).isNull(); + assertThat(response.getContentAsString()).isEqualTo("event:connected\n"); + assertThat(loggedMessages()).noneMatch(message -> message.contains("/api/web/notifications/sse")); + } + @Test void doFilterInternal_logsCoreSummaryFields() throws ServletException, IOException { diff --git a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java index 82c502c5..43646817 100644 --- a/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java +++ b/server/skillhub-notification/src/test/java/com/iflytek/skillhub/notification/sse/SseEmitterManagerTest.java @@ -7,10 +7,14 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import java.io.IOException; import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; import java.util.Queue; import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; class SseEmitterManagerTest { @@ -30,13 +34,19 @@ class SseEmitterManagerTest { @Test void register_shouldReturnEmitter() { - emitters.add(new TestEmitter()); + TestEmitter testEmitter = new TestEmitter(); + emitters.add(testEmitter); SseEmitter emitter = manager.register("user-1"); assertNotNull(emitter); assertEquals(1, manager.totalEmitters()); assertEquals(1, manager.emittersForUser("user-1")); + assertEquals(1, testEmitter.sentEventCount()); + assertTrue(testEmitter.sentEventData(0).stream() + .anyMatch(value -> value.toString().contains("event:connected"))); + assertTrue(testEmitter.sentEventData(0).contains("ok")); + assertTrue(testEmitter.isOpen()); } @Test @@ -93,6 +103,27 @@ class SseEmitterManagerTest { assertEquals(1, manager.emittersForUser("user-1")); } + @Test + void push_shouldSendNotificationEventToRegisteredOpenEmitter() { + TestEmitter emitter = new TestEmitter(); + emitters.add(emitter); + manager.register("user-1"); + + Map payload = Map.of( + "id", 42L, + "eventType", "PROFILE_REVIEW_SUBMITTED" + ); + manager.push("user-1", payload); + + assertEquals(2, emitter.sentEventCount()); + assertTrue(emitter.sentEventData(1).stream() + .anyMatch(value -> value.toString().contains("event:notification"))); + assertTrue(emitter.sentEventData(1).contains(payload)); + assertTrue(emitter.isOpen()); + assertEquals(1, manager.totalEmitters()); + assertEquals(1, manager.emittersForUser("user-1")); + } + @Test void heartbeat_shouldRemoveEmitterWhenSendFails() { TestEmitter healthy = new TestEmitter(); @@ -152,6 +183,8 @@ class SseEmitterManagerTest { private boolean failAfterConnected; private boolean throwOnComplete; private int sendCount; + private boolean completed; + private final List> sentEvents = new ArrayList<>(); private TestEmitter() { super(60_000L); @@ -173,6 +206,18 @@ class SseEmitterManagerTest { errorCallback.accept(new IOException("boom-" + userId + "-" + errorCallbacks.incrementAndGet())); } + boolean isOpen() { + return !completed; + } + + int sentEventCount() { + return sentEvents.size(); + } + + List sentEventData(int index) { + return sentEvents.get(index); + } + @Override public synchronized void onCompletion(Runnable callback) { this.completionCallback = callback; @@ -193,6 +238,7 @@ class SseEmitterManagerTest { if (throwOnComplete) { throw new IllegalStateException("already complete"); } + completed = true; completionCallback.run(); } @@ -202,6 +248,9 @@ class SseEmitterManagerTest { if (failAfterConnected && sendCount > 1) { throw new IOException("send failed"); } + sentEvents.add(builder.build().stream() + .map(ResponseBodyEmitter.DataWithMediaType::getData) + .toList()); } } } diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 07d8c65b..a141fb20 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -9717,7 +9717,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["SseEmitter"]; + "text/event-stream": components["schemas"]["SseEmitter"]; }; }; }; @@ -9737,7 +9737,7 @@ export interface operations { [name: string]: unknown; }; content: { - "*/*": components["schemas"]["SseEmitter"]; + "text/event-stream": components["schemas"]["SseEmitter"]; }; }; }; From 52251fcd0edd76f9372e14481458e1e61e0c20dd Mon Sep 17 00:00:00 2001 From: dongmucat <1127093059@qq.com> Date: Wed, 17 Jun 2026 10:25:34 +0800 Subject: [PATCH 3/3] chore(docs): remove handwritten notification OpenAPI (#524) Signed-off-by: dongmucat <1127093059@qq.com> --- docs/api/notification.openapi.yaml | 248 ----------------------------- 1 file changed, 248 deletions(-) delete mode 100644 docs/api/notification.openapi.yaml diff --git a/docs/api/notification.openapi.yaml b/docs/api/notification.openapi.yaml deleted file mode 100644 index 86fe6c4c..00000000 --- a/docs/api/notification.openapi.yaml +++ /dev/null @@ -1,248 +0,0 @@ -openapi: 3.0.3 -info: - title: SkillHub Notification API - version: 1.0.0 - description: In-app notification list, unread count, and SSE event contract. -servers: - - url: /api/web - - url: /api/v1 -paths: - /notifications: - get: - summary: List notifications for the current user - operationId: listNotifications - security: - - sessionAuth: [] - parameters: - - name: category - in: query - required: false - description: Optional category filter. Profile review notifications reuse REVIEW. - schema: - type: string - enum: [PUBLISH, REVIEW, PROMOTION, REPORT] - example: REVIEW - - name: page - in: query - required: false - schema: - type: integer - minimum: 0 - default: 0 - example: 0 - - name: size - in: query - required: false - schema: - type: integer - minimum: 1 - maximum: 100 - default: 20 - example: 20 - responses: - "200": - description: Notification page. - content: - application/json: - schema: - $ref: "#/components/schemas/NotificationPageResponse" - "400": - description: Invalid category. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - "401": - description: Authentication required. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - /notifications/unread-count: - get: - summary: Get unread notification count for the current user - operationId: getNotificationUnreadCount - security: - - sessionAuth: [] - responses: - "200": - description: Unread count. - content: - application/json: - schema: - $ref: "#/components/schemas/UnreadCountResponse" - "401": - description: Authentication required. - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - /notifications/sse: - get: - summary: Open notification SSE stream for the current user - description: Keeps a long-lived `text/event-stream` connection open. The server first sends a `connected` event, later sends `notification` events with NotificationSsePayload bodies, and may send heartbeat comments to keep the connection alive. - operationId: streamNotifications - security: - - sessionAuth: [] - responses: - "200": - description: Long-lived SSE stream. Each `notification` event carries NotificationSsePayload. - content: - text/event-stream: - schema: - type: string - "401": - description: Authentication required. -components: - securitySchemes: - sessionAuth: - type: apiKey - in: cookie - name: SESSION - schemas: - NotificationPageResponse: - type: object - properties: - code: - type: integer - example: 0 - msg: - type: string - example: ok - data: - type: object - properties: - items: - type: array - items: - $ref: "#/components/schemas/Notification" - total: - type: integer - format: int64 - example: 1 - page: - type: integer - example: 0 - size: - type: integer - example: 20 - timestamp: - type: string - format: date-time - requestId: - type: string - Notification: - type: object - properties: - id: - type: integer - format: int64 - example: 1001 - category: - type: string - enum: [PUBLISH, REVIEW, PROMOTION, REPORT] - example: REVIEW - eventType: - type: string - description: Known values include REVIEW_SUBMITTED, REVIEW_APPROVED, REVIEW_REJECTED, PROFILE_REVIEW_SUBMITTED, PROMOTION_SUBMITTED, PROMOTION_APPROVED, PROMOTION_REJECTED, REPORT_SUBMITTED, REPORT_RESOLVED, SKILL_PUBLISHED, SUBSCRIPTION_NEW_VERSION, and SUBSCRIPTION_VERSION_YANKED. - example: PROFILE_REVIEW_SUBMITTED - title: - type: string - example: Profile review submitted - bodyJson: - type: string - description: JSON string. For PROFILE_REVIEW_SUBMITTED it contains profileReviewId, submitterId, and fields. - example: '{"profileReviewId":77,"submitterId":"user-1","fields":["displayName"]}' - entityType: - type: string - example: PROFILE_REVIEW - entityId: - type: integer - format: int64 - example: 77 - status: - type: string - enum: [UNREAD, READ] - example: UNREAD - createdAt: - type: string - format: date-time - readAt: - type: string - format: date-time - nullable: true - targetType: - type: string - example: PROFILE_REVIEW - targetId: - type: integer - format: int64 - example: 77 - targetRoute: - type: string - description: Profile review notifications route to the admin profile review queue. - example: /dashboard/reviews?type=profile - NotificationSsePayload: - type: object - properties: - id: - type: integer - format: int64 - example: 1001 - category: - type: string - example: REVIEW - eventType: - type: string - example: PROFILE_REVIEW_SUBMITTED - title: - type: string - example: Profile review submitted - bodyJson: - type: string - example: '{"profileReviewId":77,"submitterId":"user-1","fields":["displayName"]}' - entityType: - type: string - example: PROFILE_REVIEW - entityId: - type: integer - format: int64 - example: 77 - createdAt: - type: string - format: date-time - UnreadCountResponse: - type: object - properties: - code: - type: integer - example: 0 - msg: - type: string - example: ok - data: - type: object - properties: - count: - type: integer - format: int64 - example: 1 - timestamp: - type: string - format: date-time - requestId: - type: string - ErrorResponse: - type: object - properties: - code: - type: integer - example: 400 - msg: - type: string - example: Invalid category - timestamp: - type: string - format: date-time - requestId: - type: string