mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-08 03:07:51 +00:00
fix(notification): complete polling migration
Signed-off-by: XiaoSeS <87064762+XiaoSeS@users.noreply.github.com>
This commit is contained in:
parent
efa3c1ae65
commit
4d71a16ddd
8 changed files with 219 additions and 52 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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[]) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue