Add paginated tag fetching for backend and UI

This commit is contained in:
Tim
2025-09-24 15:37:26 +08:00
parent 2b5f6f2208
commit 6cd3cf47ef
8 changed files with 314 additions and 71 deletions

View File

@@ -2,6 +2,7 @@ package com.openisle.controller;
import com.openisle.dto.PostSummaryDto;
import com.openisle.dto.TagDto;
import com.openisle.dto.TagPageResponse;
import com.openisle.dto.TagRequest;
import com.openisle.mapper.PostMapper;
import com.openisle.mapper.TagMapper;
@@ -19,8 +20,13 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.*;
@RestController
@@ -96,24 +102,45 @@ public class TagController {
@ApiResponse(
responseCode = "200",
description = "List of tags",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = TagDto.class)))
content = @Content(schema = @Schema(implementation = TagPageResponse.class))
)
public List<TagDto> list(
public TagPageResponse list(
@RequestParam(value = "keyword", required = false) String keyword,
@RequestParam(value = "limit", required = false) Integer limit
@RequestParam(value = "limit", required = false) Integer limit,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "pageSize", required = false) Integer pageSize
) {
List<Tag> tags = tagService.searchTags(keyword);
int resolvedPageSize = Optional.ofNullable(pageSize)
.filter(s -> s > 0)
.or(() -> Optional.ofNullable(limit).filter(l -> l > 0))
.orElse(20);
int resolvedPage = Optional.ofNullable(page)
.filter(p -> p >= 0)
.orElse(0);
Pageable pageable = PageRequest.of(
resolvedPage,
resolvedPageSize,
Sort.by(Sort.Direction.DESC, "createdAt")
);
Page<Tag> tagPage = tagService.searchTags(keyword, pageable);
List<Tag> tags = tagPage.getContent();
List<Long> tagIds = tags.stream().map(Tag::getId).toList();
Map<Long, Long> postCntByTagIds = postService.countPostsByTagIds(tagIds);
Map<Long, Long> postCntByTagIds = Optional.ofNullable(
postService.countPostsByTagIds(tagIds)
).orElseGet(Map::of);
List<TagDto> dtos = tags
.stream()
.map(t -> tagMapper.toDto(t, postCntByTagIds.getOrDefault(t.getId(), 0L)))
.sorted((a, b) -> Long.compare(b.getCount(), a.getCount()))
.collect(Collectors.toList());
if (limit != null && limit > 0 && dtos.size() > limit) {
return dtos.subList(0, limit);
}
return dtos;
return new TagPageResponse(
dtos,
tagPage.getNumber(),
tagPage.getSize(),
tagPage.getTotalElements(),
tagPage.hasNext()
);
}
@GetMapping("/{id}")

View File

@@ -0,0 +1,18 @@
package com.openisle.dto;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TagPageResponse {
private List<TagDto> items;
private int page;
private int pageSize;
private long total;
private boolean hasNext;
}

View File

@@ -4,6 +4,7 @@ import com.openisle.model.Tag;
import com.openisle.model.User;
import java.util.List;
import java.util.Optional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -12,6 +13,8 @@ public interface TagRepository extends JpaRepository<Tag, Long> {
List<Tag> findByApproved(boolean approved);
List<Tag> findByApprovedTrue();
List<Tag> findByNameContainingIgnoreCaseAndApprovedTrue(String keyword);
Page<Tag> findByApprovedTrue(Pageable pageable);
Page<Tag> findByNameContainingIgnoreCaseAndApprovedTrue(String keyword, Pageable pageable);
List<Tag> findByCreatorOrderByCreatedAtDesc(User creator, Pageable pageable);
List<Tag> findByCreator(User creator);

View File

@@ -9,8 +9,11 @@ import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
@Service
@@ -113,16 +116,37 @@ public class TagService {
* @param keyword
* @return
*/
public List<Tag> searchTags(String keyword) {
return searchTags(keyword, Pageable.unpaged()).getContent();
}
@Cacheable(
value = CachingConfig.TAG_CACHE_NAME,
key = "'searchTags:' + (#keyword ?: '')" //keyword为null的场合返回空
key = "'searchTags:' + (#keyword ?: '') + ':' + (#pageable != null && #pageable.isPaged() ? #pageable.pageNumber : 0) + ':' + (#pageable != null && #pageable.isPaged() ? #pageable.pageSize : 0)"
)
public List<Tag> searchTags(String keyword) {
if (keyword == null || keyword.isBlank()) {
return tagRepository.findByApprovedTrue();
public Page<Tag> searchTags(String keyword, Pageable pageable) {
Pageable effectivePageable = pageable != null ? pageable : Pageable.unpaged();
boolean hasKeyword = keyword != null && !keyword.isBlank();
if (!effectivePageable.isPaged()) {
List<Tag> tags = hasKeyword
? tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword)
: tagRepository.findByApprovedTrue();
return new PageImpl<>(tags, Pageable.unpaged(), tags.size());
}
return tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword);
Pageable sortedPageable = effectivePageable;
if (effectivePageable.getSort().isUnsorted()) {
sortedPageable = PageRequest.of(
effectivePageable.getPageNumber(),
effectivePageable.getPageSize(),
Sort.by(Sort.Direction.DESC, "createdAt")
);
}
return hasKeyword
? tagRepository.findByNameContainingIgnoreCaseAndApprovedTrue(keyword, sortedPageable)
: tagRepository.findByApprovedTrue(sortedPageable);
}
public List<Tag> getRecentTagsByUser(String username, int limit) {

View File

@@ -12,6 +12,7 @@ import com.openisle.repository.UserRepository;
import com.openisle.service.PostService;
import com.openisle.service.TagService;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
@@ -19,6 +20,8 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
@@ -81,16 +84,26 @@ class TagControllerTest {
t.setDescription("d2");
t.setIcon("i2");
t.setSmallIcon("s2");
Mockito.when(tagService.searchTags(null)).thenReturn(List.of(t));
Mockito.when(tagService.searchTags(Mockito.isNull(), Mockito.any(Pageable.class))).thenAnswer(
invocation -> {
Pageable pageable = invocation.getArgument(1, Pageable.class);
return new PageImpl<>(List.of(t), pageable, 1);
}
);
Mockito.when(postService.countPostsByTagIds(List.of(2L))).thenReturn(Map.of(2L, 3L));
mockMvc
.perform(get("/api/tags"))
.andExpect(status().isOk())
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].name").value("spring"))
.andExpect(jsonPath("$[0].description").value("d2"))
.andExpect(jsonPath("$[0].icon").value("i2"))
.andExpect(jsonPath("$[0].smallIcon").value("s2"));
.andExpect(jsonPath("$.items[0].name").value("spring"))
.andExpect(jsonPath("$.items[0].description").value("d2"))
.andExpect(jsonPath("$.items[0].icon").value("i2"))
.andExpect(jsonPath("$.items[0].smallIcon").value("s2"))
.andExpect(jsonPath("$.items[0].count").value(3))
.andExpect(jsonPath("$.page").value(0))
.andExpect(jsonPath("$.pageSize").value(20))
.andExpect(jsonPath("$.hasNext").value(false))
.andExpect(jsonPath("$.total").value(1));
}
@Test