feat(app): 收藏与观看历史支持分页

This commit is contained in:
Zhou Weidong committed 2026-10-22 12:23:43 +08:00
1 parent ec15950851
commit 71b0c7315a
11 files changed
+672 -37

No files matched your search

@@ -0,0 +1,183 @@
// Author: fruit.com.cn
package com.fruitshorts.app.controller;
import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.app.support.ControllerContractSupport;
import com.fruitshorts.app.vo.AppDramaCardVO;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.framework.security.LoginUser;
import com.fruitshorts.playback.service.FavoriteService;
import com.fruitshorts.playback.service.WatchProgressService;
import com.fruitshorts.playback.vo.ProgressRespVO;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.web.servlet.MockMvc;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
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.status;
/** 客户端收藏与观看历史分页契约。 */
class AppLibraryPagingContractTest {
private static final long USER_ID = 1001L;
private FavoriteService favoriteService;
private AppCatalogService catalogService;
private WatchProgressService progressService;
private MockMvc favoriteMvc;
private MockMvc watchMvc;
@BeforeEach
void setUp() {
favoriteService = mock(FavoriteService.class);
catalogService = mock(AppCatalogService.class);
progressService = mock(WatchProgressService.class);
favoriteMvc = ControllerContractSupport.mockMvcOf(
new AppFavoriteController(favoriteService, catalogService));
watchMvc = ControllerContractSupport.mockMvcOf(
new AppWatchController(progressService));
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(
new LoginUser(USER_ID, "app-user", "APP_USER", List.of()),
null,
List.of()));
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
@Test
void should_page_favorites_with_default_size_and_non_overlapping_second_page()
throws Exception {
Drama first = drama(101L, "收藏一");
Drama second = drama(102L, "收藏二");
Drama third = drama(103L, "收藏三");
given(favoriteService.page(USER_ID, 1, 20))
.willReturn(new PageResult<>(21L, List.of(first, second)));
given(favoriteService.page(USER_ID, 2, 20))
.willReturn(new PageResult<>(21L, List.of(third)));
given(catalogService.toCards(List.of(first, second)))
.willReturn(List.of(card(101L), card(102L)));
given(catalogService.toCards(List.of(third)))
.willReturn(List.of(card(103L)));
favoriteMvc.perform(get("/app/favorites"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.total").value("21"))
.andExpect(jsonPath("$.data.list.length()").value(2))
.andExpect(jsonPath("$.data.list[0].id").value("101"))
.andExpect(jsonPath("$.data.list[1].id").value("102"));
favoriteMvc.perform(get("/app/favorites").param("pageIndex", "2"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.total").value("21"))
.andExpect(jsonPath("$.data.list.length()").value(1))
.andExpect(jsonPath("$.data.list[0].id").value("103"));
verify(favoriteService).page(USER_ID, 1, 20);
verify(favoriteService).page(USER_ID, 2, 20);
}
@Test
void should_page_recent_watch_with_display_fields() throws Exception {
ProgressRespVO first = progress(12L, 88L, 6, "剧名一", 68);
ProgressRespVO second = progress(13L, 99L, 2, "剧名二", 20);
ProgressRespVO third = progress(14L, 100L, 1, "剧名三", 16);
given(progressService.recentPage(USER_ID, 1, 20))
.willReturn(new PageResult<>(23L, List.of(first, second)));
given(progressService.recentPage(USER_ID, 2, 20))
.willReturn(new PageResult<>(23L, List.of(third)));
watchMvc.perform(get("/app/watch/recent"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.total").value("23"))
.andExpect(jsonPath("$.data.list.length()").value(2))
.andExpect(jsonPath("$.data.list[0].dramaTitle").value("剧名一"))
.andExpect(jsonPath("$.data.list[0].episodeNumber").value(6))
.andExpect(jsonPath("$.data.list[0].totalEpisodes").value(68))
.andExpect(jsonPath("$.data.list[1].dramaTitle").value("剧名二"))
.andExpect(jsonPath("$.data.list[1].episodeNumber").value(2));
watchMvc.perform(get("/app/watch/recent").param("pageIndex", "2"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.total").value("23"))
.andExpect(jsonPath("$.data.list.length()").value(1))
.andExpect(jsonPath("$.data.list[0].dramaId").value("14"))
.andExpect(jsonPath("$.data.list[0].dramaTitle").value("剧名三"));
verify(progressService).recentPage(USER_ID, 1, 20);
verify(progressService).recentPage(USER_ID, 2, 20);
}
@Test
void should_keep_total_and_return_empty_list_when_watch_page_is_out_of_range()
throws Exception {
given(progressService.recentPage(USER_ID, 9, 20))
.willReturn(new PageResult<>(23L, List.of()));
watchMvc.perform(get("/app/watch/recent").param("pageIndex", "9"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.total").value("23"))
.andExpect(jsonPath("$.data.list").isArray())
.andExpect(jsonPath("$.data.list").isEmpty());
}
@Test
void should_return_legacy_list_when_recent_limit_is_present() throws Exception {
given(progressService.recent(USER_ID, 5))
.willReturn(List.of(progress(12L, 88L, 6, "剧名一", 68)));
watchMvc.perform(get("/app/watch/recent").param("limit", "5"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data").isArray())
.andExpect(jsonPath("$.data[0].dramaTitle").value("剧名一"))
.andExpect(jsonPath("$.data[0].episodeNumber").value(6));
verify(progressService).recent(USER_ID, 5);
}
private Drama drama(Long id, String title) {
Drama drama = new Drama();
drama.setId(id);
drama.setTitle(title);
return drama;
}
private AppDramaCardVO card(Long id) {
AppDramaCardVO card = new AppDramaCardVO();
card.setId(id);
return card;
}
private ProgressRespVO progress(Long dramaId, Long episodeId, int episodeNumber,
String dramaTitle, int totalEpisodes) {
ProgressRespVO vo = new ProgressRespVO();
vo.setDramaId(dramaId);
vo.setEpisodeId(episodeId);
vo.setEpisodeNumber(episodeNumber);
vo.setDramaTitle(dramaTitle);
vo.setDramaCover("/files/cover/" + dramaId + ".jpg");
vo.setTotalEpisodes(totalEpisodes);
vo.setPositionMs(80_000L);
vo.setDurationMs(120_000L);
vo.setFinished(false);
vo.setPlayCount(1);
vo.setUpdatedAt(LocalDateTime.of(2026, 10, 22, 10, 0));
return vo;
}
}
@@ -2,13 +2,23 @@
package com.fruitshorts.playback.service;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.playback.dal.entity.UserFavorite;
import com.fruitshorts.playback.dal.mapper.UserFavoriteMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.Arrays;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@@ -20,6 +30,12 @@ import static org.mockito.Mockito.verify;
/** 收藏关系变化时同步维护短剧收藏数。 */
class FavoriteServiceTest {
@BeforeAll
static void initTableInfoCache() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, UserFavorite.class);
}
private UserFavoriteMapper favoriteMapper;
private DramaService dramaService;
private FavoriteService favoriteService;
@@ -77,4 +93,46 @@ class FavoriteServiceTest {
verify(dramaService, never()).adjustFavoriteCount(8L, -1);
verify(dramaService).adjustFavoriteCount(9L, -1);
}
@Test
void should_use_database_page_and_keep_favorite_order_when_listing() {
UserFavorite first = favorite(51L);
UserFavorite second = favorite(52L);
Page<UserFavorite> databasePage = new Page<>(2, 20, 25);
databasePage.setRecords(List.of(first, second));
given(favoriteMapper.selectPage(any(Page.class), any())).willReturn(databasePage);
Drama firstDrama = drama(51L);
Drama secondDrama = drama(52L);
given(dramaService.listPublishedByIds(List.of(51L, 52L)))
.willReturn(List.of(secondDrama, firstDrama));
PageResult<Drama> result = favoriteService.page(1L, 2, 20);
assertThat(result.getTotal()).isEqualTo(25);
assertThat(result.getList()).containsExactly(firstDrama, secondDrama);
@SuppressWarnings("unchecked")
ArgumentCaptor<Page<UserFavorite>> pageCaptor = ArgumentCaptor.forClass(Page.class);
@SuppressWarnings("unchecked")
ArgumentCaptor<Wrapper<UserFavorite>> wrapperCaptor =
ArgumentCaptor.forClass(Wrapper.class);
verify(favoriteMapper).selectPage(pageCaptor.capture(), wrapperCaptor.capture());
assertThat(pageCaptor.getValue().getCurrent()).isEqualTo(2);
assertThat(pageCaptor.getValue().getSize()).isEqualTo(20);
assertThat(wrapperCaptor.getValue().getSqlSegment())
.contains("created_at DESC")
.contains("drama_id DESC");
verify(dramaService).listPublishedByIds(List.of(51L, 52L));
}
private UserFavorite favorite(Long dramaId) {
UserFavorite favorite = new UserFavorite();
favorite.setDramaId(dramaId);
return favorite;
}
private Drama drama(Long id) {
Drama drama = new Drama();
drama.setId(id);
return drama;
}
}
@@ -0,0 +1,126 @@
// Author: fruit.com.cn
package com.fruitshorts.playback.service;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.playback.dal.entity.WatchProgress;
import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper;
import com.fruitshorts.playback.dal.mapper.WatchProgressMapper;
import com.fruitshorts.playback.vo.ProgressRespVO;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
/** 观看历史分页、稳定排序与批量展示字段装配。 */
class WatchProgressServiceTest {
@BeforeAll
static void initTableInfoCache() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, WatchProgress.class);
}
private WatchProgressMapper progressMapper;
private DramaService dramaService;
private EpisodeService episodeService;
private WatchProgressService progressService;
@BeforeEach
void setUp() {
progressMapper = mock(WatchProgressMapper.class);
dramaService = mock(DramaService.class);
episodeService = mock(EpisodeService.class);
progressService = new WatchProgressService(
progressMapper,
mock(WatchHistoryMapper.class),
dramaService,
episodeService);
}
@Test
void should_page_recent_and_fill_display_fields_with_two_batch_queries() {
WatchProgress first = progress(12L, 88L);
WatchProgress second = progress(13L, 99L);
Page<WatchProgress> databasePage = new Page<>(1, 20, 23);
databasePage.setRecords(List.of(first, second));
given(progressMapper.selectPage(any(Page.class), any())).willReturn(databasePage);
given(dramaService.listByIds(List.of(12L, 13L)))
.willReturn(List.of(drama(13L, "剧名二", 20), drama(12L, "剧名一", 68)));
given(episodeService.listByIds(List.of(88L, 99L)))
.willReturn(List.of(episode(99L, 2), episode(88L, 6)));
PageResult<ProgressRespVO> result =
progressService.recentPage(1001L, 1, 20);
assertThat(result.getTotal()).isEqualTo(23);
assertThat(result.getList()).hasSize(2);
assertThat(result.getList().get(0).getDramaTitle()).isEqualTo("剧名一");
assertThat(result.getList().get(0).getEpisodeNumber()).isEqualTo(6);
assertThat(result.getList().get(0).getTotalEpisodes()).isEqualTo(68);
assertThat(result.getList().get(1).getDramaTitle()).isEqualTo("剧名二");
assertThat(result.getList().get(1).getEpisodeNumber()).isEqualTo(2);
@SuppressWarnings("unchecked")
ArgumentCaptor<Page<WatchProgress>> pageCaptor = ArgumentCaptor.forClass(Page.class);
@SuppressWarnings("unchecked")
ArgumentCaptor<Wrapper<WatchProgress>> wrapperCaptor =
ArgumentCaptor.forClass(Wrapper.class);
verify(progressMapper).selectPage(pageCaptor.capture(), wrapperCaptor.capture());
assertThat(pageCaptor.getValue().getCurrent()).isEqualTo(1);
assertThat(pageCaptor.getValue().getSize()).isEqualTo(20);
assertThat(wrapperCaptor.getValue().getSqlSegment())
.contains("updated_at DESC")
.contains("drama_id DESC");
verify(dramaService, times(1)).listByIds(List.of(12L, 13L));
verify(episodeService, times(1)).listByIds(List.of(88L, 99L));
}
private WatchProgress progress(Long dramaId, Long episodeId) {
WatchProgress progress = new WatchProgress();
progress.setUserId(1001L);
progress.setDramaId(dramaId);
progress.setEpisodeId(episodeId);
progress.setPositionMs(80_000L);
progress.setDurationMs(120_000L);
progress.setFinished(0);
progress.setPlayCount(1);
progress.setUpdatedAt(LocalDateTime.of(2026, 10, 22, 10, 0));
return progress;
}
private Drama drama(Long id, String title, int totalEpisodes) {
Drama drama = new Drama();
drama.setId(id);
drama.setTitle(title);
drama.setCoverUrl("/files/cover/" + id + ".jpg");
drama.setTotalEpisodes(totalEpisodes);
return drama;
}
private DramaEpisode episode(Long id, int epNo) {
DramaEpisode episode = new DramaEpisode();
episode.setId(id);
episode.setEpNo(epNo);
return episode;
}
}