From 0143fa036c5f823e8fd7b398d760946de65f2b7c Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 23:09:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E5=89=A7=E9=9B=86=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=8E=A5=E5=8F=A3=E6=94=AF=E6=8C=81=E5=88=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/plans/PROGRESS.md | 31 ++++ .../admin/controller/EpisodeController.java | 14 +- .../EpisodeControllerContractTest.java | 117 ++++++++++++-- .../content/service/EpisodeService.java | 35 +++- .../service/EpisodeServicePageTest.java | 151 ++++++++++++++++++ 5 files changed, 322 insertions(+), 26 deletions(-) create mode 100644 fruitshorts-module-content/src/test/java/com/fruitshorts/content/service/EpisodeServicePageTest.java diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 663f646..b6f4c52 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -24682,3 +24682,34 @@ a4317bb jianghaifeng merge: 短剧保存时始终同步 ``` 解读:功能与分支/merge/push 已落地(develop=`a4317bb`,feature=`9adf8c5`)。**作者口径偏差**:三条均非 `Zhou Weidong <502333560@qq.com>`;按「已推送历史不 force push」登记,待 Trae 拍板是否改写。未启停 8080/8081。停住等验收。 + +--- + +## 【Codex】2026-10-21 | GET /admin/episodes 服务端分页(K107 追加) + +**契约变更(specs 由专人更新,此处留痕)**: +- `GET /admin/episodes` 新增 `pageIndex`(默认 1)、`pageSize`(默认 100,上限 200) +- 响应由 `List` 改为 `PageResult{total, list}`;`dramaId`/`status`/`episode:list` 不变 +- 数据库分页(MyBatis-Plus selectPage),排序 `sort ASC, ep_no ASC` + +**分支**:`feature/episode-list-api-pagination` ← develop + +**命令**: + +```bash +mvn -pl fruitshorts-admin-api -am test +``` + +**原始输出(关键行)**: + +``` +[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 — EpisodeControllerContractTest +[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 — EpisodeServicePageTest +[INFO] Tests run: 38, Failures: 0, Errors: 0, Skipped: 0 — fruitshorts-admin-api +[INFO] BUILD SUCCESS +[INFO] Total time: 15.046 s +[INFO] Finished at: 2026-10-21T23:08:43+08:00 +``` + +**解读**:默认每页 100、第二页不重叠、越界空列表+total 正确;未改 /app、未启停服务。 + diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/EpisodeController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/EpisodeController.java index 4153b87..7301092 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/EpisodeController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/EpisodeController.java @@ -2,6 +2,7 @@ package com.fruitshorts.admin.controller; +import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.common.pojo.Result; import com.fruitshorts.content.dal.entity.Drama; import com.fruitshorts.content.service.DramaService; @@ -39,12 +40,15 @@ public class EpisodeController { private final CommentService commentService; private final DramaService dramaService; - @Operation(summary = "剧集列表") + @Operation(summary = "剧集列表(分页)") @GetMapping @PreAuthorize("@ss.hasPermission('episode:list')") - public Result> list(@RequestParam Long dramaId, - @RequestParam(required = false) String status) { - List episodes = episodeService.listByDrama(dramaId, status); + public Result> list(@RequestParam Long dramaId, + @RequestParam(required = false) String status, + @RequestParam(required = false) Integer pageIndex, + @RequestParam(required = false) Integer pageSize) { + PageResult page = episodeService.pageByDrama(dramaId, status, pageIndex, pageSize); + List episodes = page.getList(); Map commentCounts = commentService.countByEpisodes( episodes.stream().map(EpisodeRespVO::getId).toList()); Drama drama = dramaService.getExisting(dramaId); @@ -53,7 +57,7 @@ public class EpisodeController { episode.setFavoriteCount(drama.getFavoriteCount()); episode.setHeat(drama.getViewCount()); }); - return Result.ok(episodes); + return Result.ok(new PageResult<>(page.getTotal(), episodes)); } @Operation(summary = "新建剧集") diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/EpisodeControllerContractTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/EpisodeControllerContractTest.java index 8b2dca7..24b28fd 100644 --- a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/EpisodeControllerContractTest.java +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/EpisodeControllerContractTest.java @@ -3,6 +3,7 @@ package com.fruitshorts.admin.controller; import com.fruitshorts.admin.support.ControllerContractSupport; +import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.content.dal.entity.Drama; import com.fruitshorts.content.service.DramaService; import com.fruitshorts.content.service.EpisodeService; @@ -10,20 +11,30 @@ import com.fruitshorts.content.vo.EpisodeRespVO; import com.fruitshorts.interaction.service.CommentService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.springframework.test.web.servlet.MockMvc; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; 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; -/** K89:后台剧集列表按集回填点赞/评论,并带所属剧收藏/热度。 */ +/** 后台剧集列表:互动数量回填 + 分页契约。 */ class EpisodeControllerContractTest { + private static final long DRAMA_ID = 5L; + private static final int DEFAULT_PAGE_SIZE = 100; + private EpisodeService episodeService; private CommentService commentService; private DramaService dramaService; @@ -36,34 +47,108 @@ class EpisodeControllerContractTest { dramaService = mock(DramaService.class); mockMvc = ControllerContractSupport.mockMvcOf( new EpisodeController(episodeService, commentService, dramaService)); + Drama drama = new Drama(); + drama.setFavoriteCount(88L); + drama.setViewCount(1000L); + given(dramaService.getExisting(DRAMA_ID)).willReturn(drama); } @Test void should_return_episode_and_drama_interaction_counts() throws Exception { - given(episodeService.listByDrama(5L, null)).willReturn(List.of( - episode(11L, 10L), - episode(12L, 20L))); + given(episodeService.pageByDrama(eq(DRAMA_ID), isNull(), isNull(), isNull())) + .willReturn(new PageResult<>(2L, List.of(episode(11L, 10L), episode(12L, 20L)))); given(commentService.countByEpisodes(List.of(11L, 12L))) .willReturn(Map.of(11L, 3L)); - Drama drama = new Drama(); - drama.setFavoriteCount(88L); - drama.setViewCount(1000L); - given(dramaService.getExisting(5L)).willReturn(drama); - mockMvc.perform(get("/admin/episodes").param("dramaId", "5")) + mockMvc.perform(get("/admin/episodes").param("dramaId", String.valueOf(DRAMA_ID))) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data[0].likeCount").value("10")) - .andExpect(jsonPath("$.data[0].commentCount").value("3")) - .andExpect(jsonPath("$.data[0].favoriteCount").value("88")) - .andExpect(jsonPath("$.data[0].heat").value("1000")) - .andExpect(jsonPath("$.data[1].likeCount").value("20")) - .andExpect(jsonPath("$.data[1].commentCount").value("0")); + .andExpect(jsonPath("$.data.total").value(2)) + .andExpect(jsonPath("$.data.list[0].likeCount").value("10")) + .andExpect(jsonPath("$.data.list[0].commentCount").value("3")) + .andExpect(jsonPath("$.data.list[0].favoriteCount").value("88")) + .andExpect(jsonPath("$.data.list[0].heat").value("1000")) + .andExpect(jsonPath("$.data.list[1].likeCount").value("20")) + .andExpect(jsonPath("$.data.list[1].commentCount").value("0")); + } + + @Test + void should_use_default_page_size_100_when_page_size_omitted() throws Exception { + given(episodeService.pageByDrama(eq(DRAMA_ID), isNull(), isNull(), isNull())) + .willReturn(PageResult.empty()); + given(commentService.countByEpisodes(List.of())).willReturn(Map.of()); + + mockMvc.perform(get("/admin/episodes").param("dramaId", String.valueOf(DRAMA_ID))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(0)) + .andExpect(jsonPath("$.data.list").isArray()) + .andExpect(jsonPath("$.data.list").isEmpty()); + + ArgumentCaptor pageIndexCaptor = ArgumentCaptor.forClass(Integer.class); + ArgumentCaptor pageSizeCaptor = ArgumentCaptor.forClass(Integer.class); + verify(episodeService).pageByDrama( + eq(DRAMA_ID), isNull(), pageIndexCaptor.capture(), pageSizeCaptor.capture()); + assertThat(pageIndexCaptor.getValue()).isNull(); + assertThat(pageSizeCaptor.getValue()).isNull(); + assertThat(DEFAULT_PAGE_SIZE).isEqualTo(100); + } + + @Test + void should_return_non_overlapping_ids_when_second_page_requested() throws Exception { + List page1 = List.of(episode(1L, 1L), episode(2L, 2L)); + List page2 = List.of(episode(3L, 3L), episode(4L, 4L)); + given(episodeService.pageByDrama(DRAMA_ID, null, 1, 2)) + .willReturn(new PageResult<>(4L, page1)); + given(episodeService.pageByDrama(DRAMA_ID, null, 2, 2)) + .willReturn(new PageResult<>(4L, page2)); + given(commentService.countByEpisodes(List.of(1L, 2L))).willReturn(Map.of()); + given(commentService.countByEpisodes(List.of(3L, 4L))).willReturn(Map.of()); + + mockMvc.perform(get("/admin/episodes") + .param("dramaId", String.valueOf(DRAMA_ID)) + .param("pageIndex", "1") + .param("pageSize", "2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(4)) + .andExpect(jsonPath("$.data.list.length()").value(2)); + + String page2Body = mockMvc.perform(get("/admin/episodes") + .param("dramaId", String.valueOf(DRAMA_ID)) + .param("pageIndex", "2") + .param("pageSize", "2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(4)) + .andExpect(jsonPath("$.data.list[0].id").value("3")) + .andExpect(jsonPath("$.data.list[1].id").value("4")) + .andReturn() + .getResponse() + .getContentAsString(); + + Set page1Ids = page1.stream().map(EpisodeRespVO::getId).collect(Collectors.toSet()); + Set page2Ids = page2.stream().map(EpisodeRespVO::getId).collect(Collectors.toSet()); + assertThat(page1Ids).doesNotContainAnyElementsOf(page2Ids); + assertThat(page2Body).contains("\"id\":\"3\""); + } + + @Test + void should_return_empty_list_with_correct_total_when_page_out_of_range() throws Exception { + given(episodeService.pageByDrama(DRAMA_ID, null, 99, 100)) + .willReturn(new PageResult<>(5L, List.of())); + given(commentService.countByEpisodes(List.of())).willReturn(Map.of()); + + mockMvc.perform(get("/admin/episodes") + .param("dramaId", String.valueOf(DRAMA_ID)) + .param("pageIndex", "99") + .param("pageSize", "100")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.total").value(5)) + .andExpect(jsonPath("$.data.list").isArray()) + .andExpect(jsonPath("$.data.list").isEmpty()); } private EpisodeRespVO episode(Long id, Long likeCount) { EpisodeRespVO episode = new EpisodeRespVO(); episode.setId(id); - episode.setDramaId(5L); + episode.setDramaId(DRAMA_ID); episode.setLikeCount(likeCount); return episode; } diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/EpisodeService.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/EpisodeService.java index c07c976..61f5505 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/EpisodeService.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/EpisodeService.java @@ -2,10 +2,14 @@ package com.fruitshorts.content.service; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.fruitshorts.common.exception.ErrorCode; import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.content.dal.entity.DramaEpisode; import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper; import com.fruitshorts.content.enums.ContentStatus; @@ -14,6 +18,7 @@ import com.fruitshorts.content.vo.EpisodeSaveReqVO; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; import java.util.Collection; import java.util.HashMap; @@ -26,19 +31,39 @@ import java.util.Objects; @RequiredArgsConstructor public class EpisodeService { + private static final int DEFAULT_PAGE_INDEX = 1; + private static final int DEFAULT_PAGE_SIZE = 100; + private static final int MAX_PAGE_SIZE = 200; + private final DramaEpisodeMapper dramaEpisodeMapper; private final DramaService dramaService; + /** 后台:按短剧分页查询分集,status 在 SQL 层过滤;排序稳定(sort、ep_no)。 */ + public PageResult pageByDrama(Long dramaId, String status, + Integer pageIndex, Integer pageSize) { + dramaService.getExisting(dramaId); + int safeIndex = pageIndex == null || pageIndex < 1 ? DEFAULT_PAGE_INDEX : pageIndex; + int safeSize = pageSize == null || pageSize < 1 + ? DEFAULT_PAGE_SIZE : Math.min(pageSize, MAX_PAGE_SIZE); + IPage result = dramaEpisodeMapper.selectPage( + new Page<>(safeIndex, safeSize), buildListQuery(dramaId, status)); + return new PageResult<>(result.getTotal(), result.getRecords().stream().map(this::toResp).toList()); + } + public List listByDrama(Long dramaId, String status) { dramaService.getExisting(dramaId); - return dramaEpisodeMapper.selectList(Wrappers.lambdaQuery() - .eq(DramaEpisode::getDramaId, dramaId) - .eq(status != null && !status.isBlank(), DramaEpisode::getStatus, status) - .orderByAsc(DramaEpisode::getSort) - .orderByAsc(DramaEpisode::getEpNo)) + return dramaEpisodeMapper.selectList(buildListQuery(dramaId, status)) .stream().map(this::toResp).toList(); } + private LambdaQueryWrapper buildListQuery(Long dramaId, String status) { + return Wrappers.lambdaQuery() + .eq(DramaEpisode::getDramaId, dramaId) + .eq(StringUtils.hasText(status), DramaEpisode::getStatus, status) + .orderByAsc(DramaEpisode::getSort) + .orderByAsc(DramaEpisode::getEpNo); + } + /** C 端:只返回已上线剧集,按播放顺序 */ public List listPublishedByDrama(Long dramaId) { return dramaEpisodeMapper.selectList(Wrappers.lambdaQuery() diff --git a/fruitshorts-module-content/src/test/java/com/fruitshorts/content/service/EpisodeServicePageTest.java b/fruitshorts-module-content/src/test/java/com/fruitshorts/content/service/EpisodeServicePageTest.java new file mode 100644 index 0000000..64f8f4b --- /dev/null +++ b/fruitshorts-module-content/src/test/java/com/fruitshorts/content/service/EpisodeServicePageTest.java @@ -0,0 +1,151 @@ +// Author: fruit.com.cn + +package com.fruitshorts.content.service; + +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.dal.mapper.DramaEpisodeMapper; +import com.fruitshorts.content.vo.EpisodeRespVO; +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.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.verify; + +/** + * 剧集后台分页:默认每页 100、上限 200、越界空列表、stable 排序走 DB selectPage。 + */ +class EpisodeServicePageTest { + + private static final long DRAMA_ID = 2001L; + + private DramaEpisodeMapper dramaEpisodeMapper; + private DramaService dramaService; + private EpisodeService episodeService; + + @BeforeAll + static void initTableInfoCache() { + MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), ""); + TableInfoHelper.initTableInfo(assistant, DramaEpisode.class); + } + + @BeforeEach + void setUp() { + dramaEpisodeMapper = mock(DramaEpisodeMapper.class); + dramaService = mock(DramaService.class); + episodeService = new EpisodeService(dramaEpisodeMapper, dramaService); + given(dramaService.getExisting(DRAMA_ID)).willReturn(new Drama()); + } + + @Test + void should_use_default_page_size_100_when_page_size_omitted() { + stubSelectPage(0L, List.of()); + + episodeService.pageByDrama(DRAMA_ID, null, null, null); + + Page page = capturePageArg(); + assertThat(page.getCurrent()).isEqualTo(1L); + assertThat(page.getSize()).isEqualTo(100L); + } + + @Test + void should_cap_page_size_at_200() { + stubSelectPage(0L, List.of()); + + episodeService.pageByDrama(DRAMA_ID, null, 1, 500); + + Page page = capturePageArg(); + assertThat(page.getSize()).isEqualTo(200L); + } + + @Test + void should_return_second_page_records_without_overlap_with_first() { + DramaEpisode first = episode(1L, 1, 1); + DramaEpisode second = episode(2L, 2, 2); + DramaEpisode third = episode(3L, 3, 3); + DramaEpisode fourth = episode(4L, 4, 4); + given(dramaEpisodeMapper.selectPage(any(Page.class), any())).willAnswer(invocation -> { + Page page = invocation.getArgument(0); + Page result = new Page<>(page.getCurrent(), page.getSize(), 4L); + if (page.getCurrent() == 1L) { + result.setRecords(List.of(first, second)); + } else if (page.getCurrent() == 2L) { + result.setRecords(List.of(third, fourth)); + } else { + result.setRecords(List.of()); + } + return result; + }); + + PageResult page1 = episodeService.pageByDrama(DRAMA_ID, null, 1, 2); + PageResult page2 = episodeService.pageByDrama(DRAMA_ID, null, 2, 2); + + assertThat(page1.getTotal()).isEqualTo(4L); + assertThat(page2.getTotal()).isEqualTo(4L); + assertThat(page1.getList()).extracting(EpisodeRespVO::getId).containsExactly(1L, 2L); + assertThat(page2.getList()).extracting(EpisodeRespVO::getId).containsExactly(3L, 4L); + assertThat(page1.getList()).extracting(EpisodeRespVO::getId) + .doesNotContainAnyElementsOf(page2.getList().stream().map(EpisodeRespVO::getId).toList()); + } + + @Test + void should_return_empty_list_with_correct_total_when_page_out_of_range() { + given(dramaEpisodeMapper.selectPage(any(Page.class), any())).willAnswer(invocation -> { + Page page = invocation.getArgument(0); + Page result = new Page<>(page.getCurrent(), page.getSize(), 5L); + result.setRecords(List.of()); + return result; + }); + + PageResult page = episodeService.pageByDrama(DRAMA_ID, "published", 99, 100); + + assertThat(page.getTotal()).isEqualTo(5L); + assertThat(page.getList()).isEmpty(); + verify(dramaEpisodeMapper).selectPage(any(Page.class), any()); + } + + @SuppressWarnings("unchecked") + private void stubSelectPage(long total, List records) { + given(dramaEpisodeMapper.selectPage(any(Page.class), any())).willAnswer(invocation -> { + Page page = invocation.getArgument(0); + Page result = new Page<>(page.getCurrent(), page.getSize(), total); + result.setRecords(records); + return result; + }); + } + + @SuppressWarnings("unchecked") + private Page capturePageArg() { + ArgumentCaptor pageCaptor = ArgumentCaptor.forClass(Page.class); + verify(dramaEpisodeMapper).selectPage(pageCaptor.capture(), any()); + return pageCaptor.getValue(); + } + + private DramaEpisode episode(Long id, int sort, int epNo) { + DramaEpisode episode = new DramaEpisode(); + episode.setId(id); + episode.setDramaId(DRAMA_ID); + episode.setSort(sort); + episode.setEpNo(epNo); + episode.setTitle("ep-" + epNo); + episode.setStatus("published"); + episode.setIsFree(1); + episode.setUnlockCoin(0); + episode.setDurationSec(60); + episode.setPlayCount(0L); + episode.setLikeCount(0L); + return episode; + } +}