diff --git a/docs/skillhub/en/guide/social.md b/docs/skillhub/en/guide/social.md index a18df1e3..be6f1049 100644 --- a/docs/skillhub/en/guide/social.md +++ b/docs/skillhub/en/guide/social.md @@ -74,6 +74,11 @@ View skill packages with the most stars and highest ratings to discover best pra 3. Click a notification to navigate to the relevant page 4. Mark as read or mark all as read +The notification list and unread count refresh through ordinary HTTP requests every 10 seconds and +immediately when the window regains focus. The legacy `GET /api/v1/notifications/sse` endpoint has +been removed. Custom clients should poll `GET /api/v1/notifications` and +`GET /api/v1/notifications/unread-count` instead. + **Viewing My Stars**: 1. Navigate to `/dashboard/stars` diff --git a/docs/skillhub/guide/social.md b/docs/skillhub/guide/social.md index 17e9fc94..9b74c84f 100644 --- a/docs/skillhub/guide/social.md +++ b/docs/skillhub/guide/social.md @@ -74,6 +74,10 @@ SkillHub 提供了丰富的社交功能,让团队成员可以互动、分享 3. 点击通知跳转到相关页面 4. 标记为已读或全部已读 +通知列表和未读数每 10 秒通过普通 HTTP 请求刷新,并在窗口重新获得焦点时立即刷新。旧版 +`GET /api/v1/notifications/sse` 接口已移除;自建客户端应改为轮询 +`GET /api/v1/notifications` 和 `GET /api/v1/notifications/unread-count`。 + **查看我的收藏**: 1. 访问 `/dashboard/stars` 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 3b78155d..551c7fb8 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 @@ -27,10 +27,6 @@ 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) throws ServletException, IOException { @@ -84,11 +80,6 @@ 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 871e0a45..b3a4b476 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 @@ -16,7 +16,6 @@ import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.slf4j.LoggerFactory; -import org.springframework.http.HttpHeaders; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.web.util.ContentCachingResponseWrapper; @@ -82,31 +81,6 @@ class RequestLoggingFilterTest { assertThat(loggedMessages()).noneMatch(message -> message.contains("/actuator/health")); } - @Test - void doFilterInternal_skipsOtherSseEndpointsWithoutWrappingResponse() - throws ServletException, IOException { - RequestLoggingFilter filter = new RequestLoggingFilter(); - attachAppender(); - - MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/web/scan/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.getHeader("X-Accel-Buffering")).isNull(); - assertThat(response.getHeader(HttpHeaders.CACHE_CONTROL)).isNull(); - assertThat(response.getContentAsString()).isEqualTo("event:connected\n"); - assertThat(loggedMessages()).noneMatch(message -> message.contains("/api/web/scan/sse")); - } - @Test void doFilterInternal_logsCoreSummaryFields() throws ServletException, IOException { diff --git a/web/e2e/promotions-review.spec.ts b/web/e2e/promotions-review.spec.ts index af7d98a2..400ab72a 100644 --- a/web/e2e/promotions-review.spec.ts +++ b/web/e2e/promotions-review.spec.ts @@ -87,13 +87,6 @@ test.describe('Promotion review dashboard', () => { }), }) }) - await page.route('**/api/web/notifications/sse', async (route) => { - await route.fulfill({ - status: 200, - contentType: 'text/event-stream', - body: '', - }) - }) }) async function installPromotionRouteMock(page: Page, expectedSignatures: string[]) { diff --git a/web/e2e/theme-toggle.spec.ts b/web/e2e/theme-toggle.spec.ts index fa32cb97..e01105c8 100644 --- a/web/e2e/theme-toggle.spec.ts +++ b/web/e2e/theme-toggle.spec.ts @@ -51,9 +51,6 @@ test.describe('Light and dark theme', () => { }), }) }) - await page.route('**/api/web/notifications/sse', async (route) => { - await route.fulfill({ status: 204 }) - }) await page.route('**/api/web/me/stars?*', async (route) => { await route.fulfill({ status: 200, diff --git a/web/src/features/notification/use-notifications.test.ts b/web/src/features/notification/use-notifications.test.ts index 9acb1ea6..d8ec6572 100644 --- a/web/src/features/notification/use-notifications.test.ts +++ b/web/src/features/notification/use-notifications.test.ts @@ -1,5 +1,150 @@ -import { describe, expect, it } from 'vitest' -import { getNotificationListQueryOptions, getUnreadCountQueryOptions } from './use-notifications' +// @vitest-environment jsdom + +import { createElement, type ReactNode } from 'react' +import { focusManager, QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { NotificationItem, PagedResponse } from '@/api/types' + +const { getUnreadCount } = vi.hoisted(() => ({ + getUnreadCount: vi.fn().mockResolvedValue({ count: 1 }), +})) + +vi.mock('@/api/client', () => ({ + notificationApi: { + getUnreadCount, + }, +})) + +import { + getNotificationListQueryOptions, + getUnreadCountQueryOptions, + markAllCachedNotificationsRead, + markCachedNotificationRead, + removeCachedNotification, + useUnreadCount, +} from './use-notifications' + +function createWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client: queryClient }, children) + } +} + +describe('useUnreadCount polling lifecycle', () => { + beforeEach(() => { + vi.useFakeTimers() + getUnreadCount.mockClear() + }) + + afterEach(() => { + focusManager.setFocused(undefined) + vi.useRealTimers() + }) + + it('polls every ten seconds and stops after logout', async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }) + const { rerender, unmount } = renderHook( + ({ userId }: { userId?: string }) => useUnreadCount(userId), + { initialProps: { userId: 'user-a' as string | undefined }, wrapper: createWrapper(queryClient) }, + ) + + await vi.waitFor(() => expect(getUnreadCount).toHaveBeenCalledTimes(1)) + await act(() => vi.advanceTimersByTimeAsync(10_000)) + await vi.waitFor(() => expect(getUnreadCount).toHaveBeenCalledTimes(2)) + + rerender({ userId: undefined }) + await act(() => vi.advanceTimersByTimeAsync(20_000)) + expect(getUnreadCount).toHaveBeenCalledTimes(2) + + unmount() + queryClient.clear() + }) + + it('uses a separate cache key after the authenticated user changes', async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }) + const { rerender, unmount } = renderHook( + ({ userId }: { userId?: string }) => useUnreadCount(userId), + { initialProps: { userId: 'user-a' as string | undefined }, wrapper: createWrapper(queryClient) }, + ) + + await vi.waitFor(() => expect(getUnreadCount).toHaveBeenCalledTimes(1)) + rerender({ userId: 'user-b' }) + await vi.waitFor(() => expect( + queryClient.getQueryData(['notifications', 'user-b', 'unread-count']), + ).toEqual({ count: 1 })) + + expect(queryClient.getQueryData(['notifications', 'user-a', 'unread-count'])).toEqual({ count: 1 }) + + unmount() + queryClient.clear() + }) + + it('refreshes on focus and clears its timer after unmount', async () => { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }) + focusManager.setFocused(false) + const { unmount } = renderHook(() => useUnreadCount('user-a'), { + wrapper: createWrapper(queryClient), + }) + + await vi.waitFor(() => expect(getUnreadCount).toHaveBeenCalledTimes(1)) + focusManager.setFocused(true) + await vi.waitFor(() => expect(getUnreadCount).toHaveBeenCalledTimes(2)) + + unmount() + await act(() => vi.advanceTimersByTimeAsync(20_000)) + expect(getUnreadCount).toHaveBeenCalledTimes(2) + queryClient.clear() + }) +}) + +describe('notification mutation cache updates', () => { + const userAKey = ['notifications', 'user-a', 'list', 0, 20] as const + const userBKey = ['notifications', 'user-b', 'list', 0, 20] as const + const page: PagedResponse = { + items: [ + { id: 1, category: 'REVIEW' as const, eventType: 'A', title: 'A', status: 'UNREAD' as const, createdAt: '2026-09-03T00:00:00Z' }, + { id: 2, category: 'REVIEW' as const, eventType: 'B', title: 'B', status: 'UNREAD' as const, createdAt: '2026-09-03T00:00:01Z' }, + ], + total: 2, + page: 0, + size: 20, + } + + it('updates only the active user list immediately after mark-read operations', () => { + const queryClient = new QueryClient() + queryClient.setQueryData(userAKey, page) + queryClient.setQueryData(userBKey, page) + + markCachedNotificationRead(queryClient, 'user-a', 1, '2026-09-03T01:00:00Z') + expect(queryClient.getQueryData(userAKey)?.items[0]).toMatchObject({ + status: 'READ', + readAt: '2026-09-03T01:00:00Z', + }) + expect(queryClient.getQueryData(userBKey)?.items[0].status).toBe('UNREAD') + + markAllCachedNotificationsRead(queryClient, 'user-a', '2026-09-03T01:01:00Z') + expect(queryClient.getQueryData(userAKey)?.items.every((item) => item.status === 'READ')).toBe(true) + }) + + it('removes a deleted notification and adjusts the cached total', () => { + const queryClient = new QueryClient() + queryClient.setQueryData(userAKey, page) + + removeCachedNotification(queryClient, 'user-a', 1) + + expect(queryClient.getQueryData(userAKey)).toMatchObject({ + items: [{ id: 2 }], + total: 1, + }) + }) +}) describe('getUnreadCountQueryOptions', () => { it('polls the unread count over HTTP every ten seconds while the user is signed in', () => { diff --git a/web/src/features/notification/use-notifications.ts b/web/src/features/notification/use-notifications.ts index 05c5fa74..3fe9e3d1 100644 --- a/web/src/features/notification/use-notifications.ts +++ b/web/src/features/notification/use-notifications.ts @@ -1,5 +1,6 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' import { notificationApi } from '@/api/client' +import type { NotificationItem, PagedResponse } from '@/api/types' import { decrementUnreadCount, resetUnreadCount } from './notification-unread-cache' import { getNotificationQueryKeyScope } from './notification-session' @@ -15,6 +16,60 @@ export const NOTIFICATION_QUERY_KEYS = { } const NOTIFICATION_POLL_INTERVAL_MS = 10_000 +type NotificationPage = PagedResponse + +function updateCachedLists( + queryClient: QueryClient, + userId: string | null | undefined, + update: (page: NotificationPage) => NotificationPage, +) { + queryClient.setQueriesData( + { queryKey: [...getNotificationQueryKeyScope(userId), 'list'] }, + (current) => current ? update(current) : current, + ) +} + +export function markCachedNotificationRead( + queryClient: QueryClient, + userId: string | null | undefined, + notificationId: number, + readAt = new Date().toISOString(), +) { + updateCachedLists(queryClient, userId, (page) => ({ + ...page, + items: page.items.map((item) => item.id === notificationId + ? { ...item, status: 'READ', readAt: item.readAt ?? readAt } + : item), + })) +} + +export function markAllCachedNotificationsRead( + queryClient: QueryClient, + userId: string | null | undefined, + readAt = new Date().toISOString(), +) { + updateCachedLists(queryClient, userId, (page) => ({ + ...page, + items: page.items.map((item) => item.status === 'READ' + ? item + : { ...item, status: 'READ', readAt }), + })) +} + +export function removeCachedNotification( + queryClient: QueryClient, + userId: string | null | undefined, + notificationId: number, +) { + updateCachedLists(queryClient, userId, (page) => { + const items = page.items.filter((item) => item.id !== notificationId) + return { + ...page, + items, + total: page.total - (page.items.length - items.length), + } + }) +} export function getNotificationListQueryOptions( userId?: string | null, @@ -66,7 +121,8 @@ export function useMarkAllRead(userId?: string | null) { mutationFn: () => notificationApi.markAllRead(), onSuccess: () => { resetUnreadCount(queryClient, userId) - void queryClient.invalidateQueries({ queryKey: ['notifications'] }) + markAllCachedNotificationsRead(queryClient, userId) + void queryClient.invalidateQueries({ queryKey: getNotificationQueryKeyScope(userId) }) }, }) } @@ -78,9 +134,10 @@ export function useMarkRead(userId?: string | null) { const queryClient = useQueryClient() return useMutation({ mutationFn: (id: number) => notificationApi.markRead(id), - onSuccess: () => { + onSuccess: (_result, id) => { decrementUnreadCount(queryClient, userId) - void queryClient.invalidateQueries({ queryKey: ['notifications'] }) + markCachedNotificationRead(queryClient, userId, id) + void queryClient.invalidateQueries({ queryKey: getNotificationQueryKeyScope(userId) }) }, }) } @@ -89,7 +146,8 @@ export function useDeleteReadNotification(userId?: string | null) { const queryClient = useQueryClient() return useMutation({ mutationFn: (id: number) => notificationApi.deleteRead(id), - onSuccess: () => { + onSuccess: (_result, id) => { + removeCachedNotification(queryClient, userId, id) void queryClient.invalidateQueries({ queryKey: getNotificationQueryKeyScope(userId) }) }, })