fix(notification): complete polling migration

Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
XiaoSeS 2026-09-03 10:30:25 +08:00
parent efa3c1ae65
commit 4d71a16ddd
8 changed files with 219 additions and 52 deletions

View file

@ -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`

View file

@ -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`

View file

@ -27,10 +27,6 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
private static final Set<String> SKIP_PREFIXES = Set.of(
"/actuator", "/favicon.ico", "/assets/"
);
private static final Set<String> 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;
}

View file

@ -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 {

View file

@ -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[]) {

View file

@ -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,

View file

@ -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<NotificationItem> = {
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<typeof page>(userAKey)?.items[0]).toMatchObject({
status: 'READ',
readAt: '2026-09-03T01:00:00Z',
})
expect(queryClient.getQueryData<typeof page>(userBKey)?.items[0].status).toBe('UNREAD')
markAllCachedNotificationsRead(queryClient, 'user-a', '2026-09-03T01:01:00Z')
expect(queryClient.getQueryData<typeof page>(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<typeof page>(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', () => {

View file

@ -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<NotificationItem>
function updateCachedLists(
queryClient: QueryClient,
userId: string | null | undefined,
update: (page: NotificationPage) => NotificationPage,
) {
queryClient.setQueriesData<NotificationPage>(
{ 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) })
},
})