feat(token): paginate token list

This commit is contained in:
yun-zhi-ztl 2026-03-14 19:44:11 +08:00
parent 495b05f63f
commit bf319332c5
6 changed files with 99 additions and 23 deletions

View file

@ -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<TokenSummaryResponse>> list(@AuthenticationPrincipal PlatformPrincipal principal) {
var tokens = apiTokenService.listActiveTokens(principal.userId());
var result = tokens.stream().map(t -> new TokenSummaryResponse(
public ApiResponse<PageResponse<TokenSummaryResponse>> 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}")

View file

@ -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));
}
}

View file

@ -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<ApiToken, Long> {
Optional<ApiToken> findByTokenHash(String tokenHash);
List<ApiToken> findByUserId(String userId);
List<ApiToken> findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId);
Page<ApiToken> findByUserIdAndRevokedAtIsNullOrderByCreatedAtDesc(String userId, Pageable pageable);
boolean existsByUserIdAndRevokedAtIsNullAndNameIgnoreCase(String userId, String name);
}

View file

@ -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<ApiToken> 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());

View file

@ -372,19 +372,28 @@ export const accountApi = {
}
export const tokenApi = {
async getTokens(): Promise<ApiToken[]> {
const tokens = await unwrap<ApiToken[]>(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<CreateTokenResponse> {

View file

@ -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<ApiToken[]>({
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 <div className="text-center py-8 text-muted-foreground">{t('token.loading')}</div>
}
@ -62,12 +70,12 @@ export function TokenList() {
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{t('token.title')}</h2>
<CreateTokenDialog existingNames={(tokens ?? []).map((token) => token.name)}>
<CreateTokenDialog existingNames={tokens.map((token) => token.name)}>
<Button>{t('token.createNew')}</Button>
</CreateTokenDialog>
</div>
{!tokens || tokens.length === 0 ? (
{!tokenPage || tokenPage.total === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<p>{t('token.empty')}</p>
<p className="text-sm mt-2">{t('token.emptyHint')}</p>
@ -114,6 +122,10 @@ export function TokenList() {
</div>
)}
{tokenPage && tokenPage.total > PAGE_SIZE ? (
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
) : null}
<ConfirmDialog
open={deleteDialog.open}
onOpenChange={(open) => setDeleteDialog({ ...deleteDialog, open })}