feat(catalog): 新增分类推荐榜快照接口
backend-ci / test (push) Failing after 4m45s

This commit is contained in:
Zhou Weidong committed 2026-11-02 14:45:43 +08:00
1 parent afd8d00752
commit 8a5a381b44
23 files changed
+793 -16

No files matched your search

@@ -3,11 +3,13 @@
package com.fruitshorts.app.controller;
import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.app.service.AppRankingService;
import com.fruitshorts.app.vo.AppCategoryVO;
import com.fruitshorts.app.vo.AppDramaPageReqVO;
import com.fruitshorts.app.vo.AppFeedVO;
import com.fruitshorts.app.vo.AppDramaCardVO;
import com.fruitshorts.app.vo.AppPlaylistDetailVO;
import com.fruitshorts.app.vo.AppRankingVO;
import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.common.pojo.Result;
@@ -37,6 +39,7 @@ public class AppCatalogController {
private static final int DRAMA_PAGE_SIZE = 20;
private final AppCatalogService appCatalogService;
private final AppRankingService appRankingService;
@Operation(summary = "首页聚合:轮播 + 分区,一次请求渲染整页")
@GetMapping("/home/feed")
@@ -75,6 +78,12 @@ public class AppCatalogController {
return Result.ok(appCatalogService.categoryDramas(id, pageIndex, pageSize));
}
@Operation(summary = "推荐榜:按后台配置顺序返回榜单快照")
@GetMapping("/categories/{id}/ranking")
public Result<AppRankingVO> categoryRanking(@PathVariable Long id) {
return Result.ok(appRankingService.ranking(id));
}
@Operation(summary = "已启用类型")
@GetMapping("/drama-categories")
public Result<List<AppCategoryVO>> dramaCategories() {
@@ -92,6 +92,11 @@ public class AppCatalogService {
c.getNameLocales(), c.getName(), c.getNameEn(), c.getNameId());
AppCategoryVO vo = new AppCategoryVO(c.getId(), ContentLocaleTexts.value(locales, ContentLocaleTexts.ZH));
vo.setLocales(locales);
vo.setCode(c.getCode());
vo.setLayoutType(c.getLayoutType() == null
? DramaClassificationService.LAYOUT_GRID
: c.getLayoutType());
vo.setRevision(c.getRevision() == null ? 1L : c.getRevision());
return vo;
})
.toList();
@@ -0,0 +1,79 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.fruitshorts.app.vo.AppDramaCardVO;
import com.fruitshorts.app.vo.AppRankingEntryVO;
import com.fruitshorts.app.vo.AppRankingVO;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.service.ContentLocaleTexts;
import com.fruitshorts.content.service.DramaClassificationService;
import com.fruitshorts.content.service.DramaService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** C 端推荐榜快照组装。 */
@Service
@RequiredArgsConstructor
public class AppRankingService {
private static final int MAX_RANKING_ITEMS = 50;
private final DramaClassificationService classificationService;
private final DramaService dramaService;
private final AppCatalogService catalogService;
/** 过滤未上线短剧后保持后台位置,排名连续补齐。 */
public AppRankingVO ranking(Long classificationId) {
var classification = classificationService.getEnabledById(classificationId);
if (!DramaClassificationService.LAYOUT_RANKING.equals(classification.getLayoutType())) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "该分类不是推荐榜");
}
List<Long> ids = classificationService.listDramaIdsByClassification(classificationId);
List<Drama> ordered = orderedPublished(ids).stream().limit(MAX_RANKING_ITEMS).toList();
List<AppDramaCardVO> cards = catalogService.toCards(ordered);
List<AppRankingEntryVO> entries = entries(cards);
Map<String, String> locales = ContentLocaleTexts.read(
classification.getNameLocales(), classification.getName(),
classification.getNameEn(), classification.getNameId());
AppRankingVO ranking = new AppRankingVO();
ranking.setId(classification.getId());
ranking.setCode(classification.getCode());
ranking.setLocales(locales);
ranking.setRevision(classification.getRevision() == null ? 1L : classification.getRevision());
ranking.setGeneratedAt(classification.getUpdatedAt());
ranking.setTotal(entries.size());
ranking.setList(entries);
return ranking;
}
private List<AppRankingEntryVO> entries(List<AppDramaCardVO> cards) {
List<AppRankingEntryVO> entries = new ArrayList<>();
for (int index = 0; index < cards.size(); index++) {
entries.add(new AppRankingEntryVO(index + 1, cards.get(index)));
}
return entries;
}
private List<Drama> orderedPublished(List<Long> ids) {
if (ids.isEmpty()) {
return List.of();
}
Map<Long, Integer> positions = new HashMap<>();
for (int index = 0; index < ids.size(); index++) {
positions.put(ids.get(index), index);
}
return dramaService.listPublishedByIds(ids).stream()
.sorted((left, right) -> Integer.compare(
positions.getOrDefault(left.getId(), Integer.MAX_VALUE),
positions.getOrDefault(right.getId(), Integer.MAX_VALUE)))
.toList();
}
}
@@ -11,10 +11,15 @@ import java.util.Map;
public class AppCategoryVO {
private Long id;
private String code;
private String layoutType;
private Long revision;
private Map<String, String> locales;
public AppCategoryVO(Long id, String name) {
this.id = id;
this.layoutType = "GRID";
this.revision = 1L;
this.locales = name == null || name.isBlank() ? Map.of() : Map.of("zh", name);
}
}
@@ -0,0 +1,15 @@
// Author: fruit.com.cn
package com.fruitshorts.app.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
/** 推荐榜单项,排名由服务端确定。 */
@Data
@AllArgsConstructor
public class AppRankingEntryVO {
private Integer rank;
private AppDramaCardVO drama;
}
@@ -0,0 +1,22 @@
// Author: fruit.com.cn
package com.fruitshorts.app.vo;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/** 推荐榜快照。 */
@Data
public class AppRankingVO {
private Long id;
private String code;
private Map<String, String> locales;
private Long revision;
private LocalDateTime generatedAt;
private Integer total;
private List<AppRankingEntryVO> list;
}
@@ -3,6 +3,7 @@ package com.fruitshorts.app.controller;
import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.app.service.AppEpisodeStatsService;
import com.fruitshorts.app.service.AppRankingService;
import com.fruitshorts.app.support.ControllerContractSupport;
import com.fruitshorts.app.vo.AppBannerVO;
import com.fruitshorts.app.vo.AppCategoryVO;
@@ -10,6 +11,8 @@ import com.fruitshorts.app.vo.AppDramaCardVO;
import com.fruitshorts.app.vo.AppDramaDetailVO;
import com.fruitshorts.app.vo.AppFeedVO;
import com.fruitshorts.app.vo.AppSectionVO;
import com.fruitshorts.app.vo.AppRankingEntryVO;
import com.fruitshorts.app.vo.AppRankingVO;
import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.common.exception.ErrorCode;
@@ -41,13 +44,16 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
class AppCatalogControllerContractTest {
private AppCatalogService catalogService;
private AppRankingService rankingService;
private MockMvc catalogMvc;
private MockMvc dramaMvc;
@BeforeEach
void setUp() {
catalogService = mock(AppCatalogService.class);
catalogMvc = ControllerContractSupport.mockMvcOf(new AppCatalogController(catalogService));
rankingService = mock(AppRankingService.class);
catalogMvc = ControllerContractSupport.mockMvcOf(
new AppCatalogController(catalogService, rankingService));
dramaMvc = ControllerContractSupport.mockMvcOf(
new AppDramaController(catalogService, mock(PlaybackService.class),
mock(EpisodeService.class), mock(CommentService.class),
@@ -122,15 +128,44 @@ class AppCatalogControllerContractTest {
@Test
void should_return_category_list_when_categories_are_requested() throws Exception {
AppCategoryVO category = new AppCategoryVO(3L, "都市");
category.setCode("RECOMMEND_RANKING");
category.setLayoutType("RANKING");
category.setRevision(7L);
given(catalogService.categories()).willReturn(List.of(category));
catalogMvc.perform(get("/app/categories"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data[0].id").value(3))
.andExpect(jsonPath("$.data[0].code").value("RECOMMEND_RANKING"))
.andExpect(jsonPath("$.data[0].layoutType").value("RANKING"))
.andExpect(jsonPath("$.data[0].revision").value("7"))
.andExpect(jsonPath("$.data[0].locales.zh").value("都市"));
}
@Test
void should_return_server_rank_when_ranking_is_requested() throws Exception {
AppDramaCardVO card = new AppDramaCardVO();
card.setId(12L);
card.setTitleLocales(Map.of("zh", "榜首短剧"));
AppRankingVO ranking = new AppRankingVO();
ranking.setId(3L);
ranking.setCode("RECOMMEND_RANKING");
ranking.setRevision(8L);
ranking.setTotal(1);
ranking.setList(List.of(new AppRankingEntryVO(1, card)));
given(rankingService.ranking(3L)).willReturn(ranking);
catalogMvc.perform(get("/app/categories/3/ranking"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.revision").value("8"))
.andExpect(jsonPath("$.data.list[0].rank").value(1))
.andExpect(jsonPath("$.data.list[0].drama.id").value("12"));
verify(rankingService).ranking(3L);
}
@Test
void should_bind_path_variable_when_drama_detail_is_requested() throws Exception {
AppDramaDetailVO detail = new AppDramaDetailVO();
@@ -328,7 +328,9 @@ class AppCommentCountAnonymousSecurityTest {
@Bean
AppCatalogController appCatalogController(AppCatalogService appCatalogService) {
return new AppCatalogController(appCatalogService);
return new AppCatalogController(
appCatalogService,
mock(com.fruitshorts.app.service.AppRankingService.class));
}
@Bean
@@ -0,0 +1,75 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.fruitshorts.app.vo.AppDramaCardVO;
import com.fruitshorts.app.vo.AppRankingVO;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaClassification;
import com.fruitshorts.content.service.DramaClassificationService;
import com.fruitshorts.content.service.DramaService;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/** 推荐榜快照顺序与排名测试。 */
class AppRankingServiceTest {
@Test
void should_preserve_server_order_and_compact_rank_when_offline_items_are_filtered() {
DramaClassificationService classificationService = mock(DramaClassificationService.class);
DramaService dramaService = mock(DramaService.class);
AppCatalogService catalogService = mock(AppCatalogService.class);
AppRankingService service = new AppRankingService(
classificationService, dramaService, catalogService);
DramaClassification classification = rankingClassification();
Drama second = drama(22L);
Drama first = drama(11L);
given(classificationService.getEnabledById(3L)).willReturn(classification);
given(classificationService.listDramaIdsByClassification(3L))
.willReturn(List.of(11L, 99L, 22L));
given(dramaService.listPublishedByIds(List.of(11L, 99L, 22L)))
.willReturn(List.of(second, first));
AppDramaCardVO firstCard = card(11L);
AppDramaCardVO secondCard = card(22L);
given(catalogService.toCards(List.of(first, second)))
.willReturn(List.of(firstCard, secondCard));
AppRankingVO result = service.ranking(3L);
assertThat(result.getRevision()).isEqualTo(7L);
assertThat(result.getList()).extracting(item -> item.getDrama().getId())
.containsExactly(11L, 22L);
assertThat(result.getList()).extracting(item -> item.getRank())
.containsExactly(1, 2);
}
private DramaClassification rankingClassification() {
DramaClassification classification = new DramaClassification();
classification.setId(3L);
classification.setCode("RECOMMEND_RANKING");
classification.setName("推荐榜");
classification.setNameLocales("{\"zh\":\"推荐榜\"}");
classification.setLayoutType(DramaClassificationService.LAYOUT_RANKING);
classification.setRevision(7L);
return classification;
}
private Drama drama(Long id) {
Drama drama = new Drama();
drama.setId(id);
return drama;
}
private AppDramaCardVO card(Long id) {
AppDramaCardVO card = new AppDramaCardVO();
card.setId(id);
card.setTitleLocales(Map.of("zh", "短剧" + id));
return card;
}
}