From bf319332c519b3dee07013fc6d2ec164be56d7de Mon Sep 17 00:00:00 2001 From: yun-zhi-ztl Date: Sat, 14 Mar 2026 19:44:11 +0800 Subject: [PATCH] feat(token): paginate token list --- .../skillhub/controller/TokenController.java | 14 ++++--- .../controller/TokenControllerTest.java | 40 +++++++++++++++++++ .../auth/repository/ApiTokenRepository.java | 3 ++ .../skillhub/auth/token/ApiTokenService.java | 8 ++++ web/src/api/client.ts | 31 +++++++++----- web/src/features/token/token-list.tsx | 26 ++++++++---- 6 files changed, 99 insertions(+), 23 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java index cb2af098..f0978f65 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java @@ -4,6 +4,7 @@ import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.auth.token.ApiTokenService; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.ApiResponseFactory; +import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.dto.TokenCreateRequest; import com.iflytek.skillhub.dto.TokenCreateResponse; import com.iflytek.skillhub.dto.TokenSummaryResponse; @@ -45,17 +46,20 @@ public class TokenController extends BaseApiController { } @GetMapping - public ApiResponse> list(@AuthenticationPrincipal PlatformPrincipal principal) { - var tokens = apiTokenService.listActiveTokens(principal.userId()); - var result = tokens.stream().map(t -> new TokenSummaryResponse( + public ApiResponse> list( + @AuthenticationPrincipal PlatformPrincipal principal, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + var tokens = apiTokenService.listActiveTokens(principal.userId(), page, size); + var result = tokens.map(t -> new TokenSummaryResponse( t.getId(), t.getName(), t.getTokenPrefix(), t.getCreatedAt().toString(), t.getExpiresAt() != null ? t.getExpiresAt().toString() : "", t.getLastUsedAt() != null ? t.getLastUsedAt().toString() : "" - )).toList(); - return ok("response.success.read", result); + )); + return ok("response.success.read", PageResponse.from(result)); } @DeleteMapping("/{id}") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java index ebf5a512..7b133405 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java @@ -19,6 +19,8 @@ import org.springframework.test.web.servlet.MockMvc; import java.util.List; import java.util.Set; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verify; @@ -27,6 +29,7 @@ import static org.springframework.security.test.web.servlet.request.SecurityMock import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -88,4 +91,41 @@ class TokenControllerTest { .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.msg").value("Token 名称最多 64 个字符")); } + + @Test + void list_returns_paginated_tokens() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", "tester", "tester@example.com", "", "github", Set.of("USER") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + var tokenPage = new PageImpl<>( + List.of( + new com.iflytek.skillhub.auth.entity.ApiToken("user-42", "cli", "sk_123456", "hash-1", "[]"), + new com.iflytek.skillhub.auth.entity.ApiToken("user-42", "deploy", "sk_654321", "hash-2", "[]") + ), + PageRequest.of(1, 10), + 12 + ); + var first = tokenPage.getContent().get(0); + var second = tokenPage.getContent().get(1); + org.springframework.test.util.ReflectionTestUtils.setField(first, "id", 7L); + org.springframework.test.util.ReflectionTestUtils.setField(first, "createdAt", java.time.LocalDateTime.of(2026, 3, 14, 10, 0)); + org.springframework.test.util.ReflectionTestUtils.setField(second, "id", 8L); + org.springframework.test.util.ReflectionTestUtils.setField(second, "createdAt", java.time.LocalDateTime.of(2026, 3, 14, 11, 0)); + + given(apiTokenService.listActiveTokens("user-42", 1, 10)).willReturn(tokenPage); + + mockMvc.perform(get("/api/v1/tokens") + .with(authentication(auth)) + .param("page", "1") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].name").value("cli")) + .andExpect(jsonPath("$.data.items[1].name").value("deploy")) + .andExpect(jsonPath("$.data.total").value(12)) + .andExpect(jsonPath("$.data.page").value(1)) + .andExpect(jsonPath("$.data.size").value(10)); + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java index 368b0dd6..ba7f919f 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/repository/ApiTokenRepository.java @@ -1,6 +1,8 @@ package com.iflytek.skillhub.auth.repository; import com.iflytek.skillhub.auth.entity.ApiToken; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; import java.util.List; @@ -11,5 +13,6 @@ public interface ApiTokenRepository extends JpaRepository { Optional findByTokenHash(String tokenHash); List findByUserId(String userId); List findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId); + Page findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId, Pageable pageable); boolean existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase(String userId, String name); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java index f546e148..8d3cf4d0 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java @@ -3,6 +3,8 @@ package com.iflytek.skillhub.auth.token; import com.iflytek.skillhub.auth.entity.ApiToken; import com.iflytek.skillhub.auth.repository.ApiTokenRepository; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -71,6 +73,12 @@ public class ApiTokenService { return tokenRepo.findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(userId); } + public Page listActiveTokens(String userId, int page, int size) { + int resolvedPage = Math.max(page, 0); + int resolvedSize = Math.max(size, 1); + return tokenRepo.findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(userId, PageRequest.of(resolvedPage, resolvedSize)); + } + @Transactional public void touchLastUsed(ApiToken token) { token.setLastUsedAt(LocalDateTime.now()); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 465dfddc..605f7e2d 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -372,19 +372,28 @@ export const accountApi = { } export const tokenApi = { - async getTokens(): Promise { - const tokens = await unwrap(client.GET('/api/v1/tokens', { + async getTokens(params?: { page?: number, size?: number }): Promise<{ items: ApiToken[], total: number, page: number, size: number }> { + const page = await unwrap<{ items: ApiToken[], total: number, page: number, size: number }>(client.GET('/api/v1/tokens', { + params: { + query: { + page: params?.page ?? 0, + size: params?.size ?? 10, + }, + }, headers: withRequestHeaders(), } as never) as never) - return tokens - .filter((token) => token.id !== undefined && token.name && token.tokenPrefix && token.createdAt) - .map((token) => ({ - ...token, - id: token.id!, - name: token.name!, - tokenPrefix: token.tokenPrefix!, - createdAt: token.createdAt!, - })) + return { + ...page, + items: page.items + .filter((token) => token.id !== undefined && token.name && token.tokenPrefix && token.createdAt) + .map((token) => ({ + ...token, + id: token.id!, + name: token.name!, + tokenPrefix: token.tokenPrefix!, + createdAt: token.createdAt!, + })), + } }, async createToken(request: CreateTokenRequest): Promise { diff --git a/web/src/features/token/token-list.tsx b/web/src/features/token/token-list.tsx index 55daa5c2..e266a23f 100644 --- a/web/src/features/token/token-list.tsx +++ b/web/src/features/token/token-list.tsx @@ -13,19 +13,24 @@ import { } from '@/shared/ui/table' import { CreateTokenDialog } from './create-token-dialog' import { ConfirmDialog } from '@/shared/components/confirm-dialog' +import { Pagination } from '@/shared/components/pagination' import { toast } from '@/shared/lib/toast' +import { formatLocalDateTime } from '@/shared/lib/date-time' import type { ApiToken } from '@/api/types' +const PAGE_SIZE = 10 + export function TokenList() { - const { t } = useTranslation() + const { t, i18n } = useTranslation() const queryClient = useQueryClient() + const [page, setPage] = useState(0) const [deleteDialog, setDeleteDialog] = useState<{ open: boolean; tokenId?: number; name?: string }>({ open: false, }) - const { data: tokens, isLoading } = useQuery({ - queryKey: ['tokens'], - queryFn: tokenApi.getTokens, + const { data: tokenPage, isLoading } = useQuery<{ items: ApiToken[]; total: number; page: number; size: number }>({ + queryKey: ['tokens', page, PAGE_SIZE], + queryFn: () => tokenApi.getTokens({ page, size: PAGE_SIZE }), }) const deleteMutation = useMutation({ @@ -51,9 +56,12 @@ export function TokenList() { const formatDate = (dateString?: string | null) => { if (!dateString) return '-' - return new Date(dateString).toLocaleString('zh-CN') + return formatLocalDateTime(dateString, i18n.language) } + const tokens = tokenPage?.items ?? [] + const totalPages = tokenPage ? Math.max(Math.ceil(tokenPage.total / tokenPage.size), 1) : 1 + if (isLoading) { return
{t('token.loading')}
} @@ -62,12 +70,12 @@ export function TokenList() {

{t('token.title')}

- token.name)}> + token.name)}>
- {!tokens || tokens.length === 0 ? ( + {!tokenPage || tokenPage.total === 0 ? (

{t('token.empty')}

{t('token.emptyHint')}

@@ -114,6 +122,10 @@ export function TokenList() {
)} + {tokenPage && tokenPage.total > PAGE_SIZE ? ( + + ) : null} + setDeleteDialog({ ...deleteDialog, open })}