mirror of
https://github.com/iflytek/skillhub.git
synced 2026-09-07 08:26:00 +00:00
feat(token): paginate token list
This commit is contained in:
parent
495b05f63f
commit
bf319332c5
6 changed files with 99 additions and 23 deletions
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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 })}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue