feat(content): 新增内容类型与首页分类 feed

This commit is contained in:
Zhou Weidong committed 2026-10-21 15:40:19 +08:00
1 parent d9cdbc8e15
commit 1052b97306
19 files changed
+557 -4

No files matched your search

@@ -9,6 +9,7 @@ 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.common.pojo.CursorResult;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.playback.service.PlaybackService;
@@ -86,6 +87,28 @@ class AppCatalogControllerContractTest {
.andExpect(jsonPath("$.data.sections").isArray());
}
@Test
void should_return_typed_cursor_feed() throws Exception {
AppDramaCardVO card = new AppDramaCardVO();
card.setId(12L);
card.setTitle("漫剧卡片");
card.setDramaType("ANIME");
given(catalogService.feed("ANIME", null, 20))
.willReturn(new CursorResult<>(List.of(card), 12L, true));
catalogMvc.perform(get("/app/feed")
.param("types", "ANIME")
.param("size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.list[0].id").value("12"))
.andExpect(jsonPath("$.data.list[0].dramaType").value("ANIME"))
.andExpect(jsonPath("$.data.nextCursor").value("12"))
.andExpect(jsonPath("$.data.hasMore").value(true));
verify(catalogService).feed("ANIME", null, 20);
}
@Test
void should_return_category_list_when_categories_are_requested() throws Exception {
AppCategoryVO category = new AppCategoryVO(3L, "都市");
@@ -3,6 +3,8 @@
package com.fruitshorts.app.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.framework.log.AccessDeniedRecorder;
import com.fruitshorts.framework.security.JwtAuthenticationFilter;
import com.fruitshorts.framework.security.JwtTokenProvider;
@@ -28,13 +30,15 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import java.util.List;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
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;
/** 评论总数接口的安全链契约:匿名只读请求必须直接进入控制器。 */
/** 客户端公开只读接口的安全链契约:匿名请求必须直接进入控制器。 */
@ExtendWith(SpringExtension.class)
@WebAppConfiguration
@ContextConfiguration(classes = AppCommentCountAnonymousSecurityTest.TestConfig.class)
@@ -45,10 +49,14 @@ class AppCommentCountAnonymousSecurityTest {
@Autowired
private CommentService commentService;
@Autowired
private AppCatalogService catalogService;
@BeforeEach
void setUp() {
given(commentService.countByDrama(7L)).willReturn(9L);
given(catalogService.feed("ANIME", null, 20))
.willReturn(new CursorResult<>(List.of(), null, false));
}
@Test
@@ -59,6 +67,16 @@ class AppCommentCountAnonymousSecurityTest {
.andExpect(jsonPath("$.data").value(9));
}
@Test
void should_allow_anonymous_typed_feed_request_through_security_chain() throws Exception {
mockMvc.perform(get("/app/feed")
.param("types", "ANIME")
.param("size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.list").isArray());
}
@Configuration
@EnableWebMvc
@EnableWebSecurity
@@ -75,6 +93,16 @@ class AppCommentCountAnonymousSecurityTest {
return new AppCommentController(commentService);
}
@Bean
AppCatalogService appCatalogService() {
return mock(AppCatalogService.class);
}
@Bean
AppCatalogController appCatalogController(AppCatalogService appCatalogService) {
return new AppCatalogController(appCatalogService);
}
@Bean
JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(
@@ -3,6 +3,7 @@ package com.fruitshorts.app.service;
import com.fruitshorts.app.vo.AppDramaCardVO;
import com.fruitshorts.app.vo.AppDramaDetailVO;
import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.service.BannerService;
import com.fruitshorts.content.service.DramaCategoryService;
@@ -99,4 +100,64 @@ class AppCatalogServiceTest {
verify(commentService).countByDramas(List.of(1L, 2L));
verify(commentService, never()).countByDrama(any());
}
@Test
void should_filter_feed_by_drama_type_and_expose_it_on_card() {
Drama anime = drama(1L, "ANIME");
given(dramaService.listPublishedFeed(List.of("ANIME"), null, 21))
.willReturn(List.of(anime));
given(dramaCategoryService.listBasic()).willReturn(List.of());
given(episodeService.firstPlayableByDramas(List.of(1L))).willReturn(Map.of());
given(commentService.countByDramas(List.of(1L))).willReturn(Map.of(1L, 3L));
CursorResult<AppDramaCardVO> result = catalogService.feed("ANIME", null, null);
assertThat(result.getList()).hasSize(1);
assertThat(result.getList().get(0).getDramaType()).isEqualTo("ANIME");
assertThat(result.getList().get(0).getCommentCount()).isEqualTo(3L);
assertThat(result.isHasMore()).isFalse();
verify(dramaService).listPublishedFeed(List.of("ANIME"), null, 21);
}
@Test
void should_parse_multiple_feed_types() {
given(dramaService.listPublishedFeed(List.of("ANIME", "LIVE"), null, 11))
.willReturn(List.of());
CursorResult<AppDramaCardVO> result = catalogService.feed("ANIME,LIVE", null, 10);
assertThat(result.getList()).isEmpty();
verify(dramaService).listPublishedFeed(List.of("ANIME", "LIVE"), null, 11);
}
@Test
void should_page_feed_with_cursor_without_duplicates() {
Drama first = drama(1L, "ANIME");
Drama second = drama(2L, "LIVE");
given(dramaService.listPublishedFeed(List.of(), null, 2))
.willReturn(List.of(first, second));
given(dramaService.listPublishedFeed(List.of(), 1L, 2))
.willReturn(List.of(second));
given(dramaCategoryService.listBasic()).willReturn(List.of());
given(episodeService.firstPlayableByDramas(any())).willReturn(Map.of());
given(commentService.countByDramas(any())).willReturn(Map.of());
CursorResult<AppDramaCardVO> firstPage = catalogService.feed(null, null, 1);
CursorResult<AppDramaCardVO> secondPage = catalogService.feed(null, firstPage.getNextCursor(), 1);
assertThat(firstPage.getList()).extracting(AppDramaCardVO::getId).containsExactly(1L);
assertThat(firstPage.getNextCursor()).isEqualTo(1L);
assertThat(firstPage.isHasMore()).isTrue();
assertThat(secondPage.getList()).extracting(AppDramaCardVO::getId).containsExactly(2L);
assertThat(secondPage.getNextCursor()).isNull();
assertThat(secondPage.isHasMore()).isFalse();
}
private Drama drama(Long id, String dramaType) {
Drama drama = new Drama();
drama.setId(id);
drama.setTitle("短剧" + id);
drama.setDramaType(dramaType);
return drama;
}
}
@@ -0,0 +1,97 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
import com.fruitshorts.content.service.DramaCategoryService;
import com.fruitshorts.content.service.DramaService;
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;
/** K88 App 推荐流的类型过滤与三字段 keyset 游标。 */
class DramaServiceFeedTest {
private DramaMapper dramaMapper;
private DramaService dramaService;
@BeforeAll
static void initTableInfo() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""), Drama.class);
}
@BeforeEach
void setUp() {
dramaMapper = mock(DramaMapper.class);
dramaService = new DramaService(
dramaMapper,
mock(DramaEpisodeMapper.class),
mock(DramaReviewRecordMapper.class),
mock(DramaCategoryService.class));
}
@Test
@SuppressWarnings("unchecked")
void should_filter_types_and_apply_recommendation_cursor() {
Drama marker = new Drama();
marker.setId(9L);
marker.setStatus("published");
marker.setDramaType("ANIME");
marker.setRecommendWeight(30);
marker.setViewCount(1200L);
given(dramaMapper.selectById(9L)).willReturn(marker);
given(dramaMapper.selectList(any())).willReturn(List.of());
ArgumentCaptor<QueryWrapper<Drama>> captor = ArgumentCaptor.forClass(QueryWrapper.class);
dramaService.listPublishedFeed(List.of("ANIME"), 9L, 5);
verify(dramaMapper).selectList(captor.capture());
QueryWrapper<Drama> query = captor.getValue();
assertThat(query.getSqlSegment())
.contains("status")
.contains("drama_type")
.contains("recommend_weight")
.contains("view_count")
.contains("id");
assertThat(query.getParamNameValuePairs().values())
.contains("published", "ANIME", 30, 1200L, 9L);
}
@Test
@SuppressWarnings("unchecked")
void should_handle_null_recommend_weight_in_cursor() {
Drama marker = new Drama();
marker.setId(9L);
marker.setStatus("published");
marker.setDramaType("LIVE");
marker.setViewCount(1200L);
given(dramaMapper.selectById(9L)).willReturn(marker);
given(dramaMapper.selectList(any())).willReturn(List.of());
ArgumentCaptor<QueryWrapper<Drama>> captor = ArgumentCaptor.forClass(QueryWrapper.class);
dramaService.listPublishedFeed(List.of("LIVE"), 9L, 5);
verify(dramaMapper).selectList(captor.capture());
assertThat(captor.getValue().getSqlSegment())
.contains("recommend_weight IS NULL")
.contains("view_count")
.contains("id");
}
}
@@ -0,0 +1,127 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
import com.fruitshorts.content.service.DramaCategoryService;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.vo.DramaSaveReqVO;
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 static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/** K88 后台短剧内容类型与互动数据编辑规则。 */
class DramaServiceK88Test {
private DramaMapper dramaMapper;
private DramaService dramaService;
@BeforeAll
static void initTableInfo() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""), Drama.class);
}
@BeforeEach
void setUp() {
dramaMapper = mock(DramaMapper.class);
dramaService = new DramaService(
dramaMapper,
mock(DramaEpisodeMapper.class),
mock(DramaReviewRecordMapper.class),
mock(DramaCategoryService.class));
}
@Test
void should_update_only_requested_counts_and_preserve_drama_type() {
Drama existing = drama();
given(dramaMapper.selectById(7L)).willReturn(existing);
DramaSaveReqVO req = new DramaSaveReqVO();
req.setTitle("更新后的标题");
req.setFavoriteCount(99L);
req.setHeat(500L);
dramaService.update(7L, req);
ArgumentCaptor<Drama> captor = ArgumentCaptor.forClass(Drama.class);
verify(dramaMapper).updateById(captor.capture());
Drama updated = captor.getValue();
assertThat(updated.getFavoriteCount()).isEqualTo(99L);
assertThat(updated.getLikeCount()).isEqualTo(20L);
assertThat(updated.getViewCount()).isEqualTo(500L);
assertThat(updated.getDramaType()).isEqualTo("ANIME");
}
@Test
void should_reject_negative_interaction_count() {
given(dramaMapper.selectById(7L)).willReturn(drama());
DramaSaveReqVO req = new DramaSaveReqVO();
req.setTitle("更新后的标题");
req.setFavoriteCount(-1L);
assertThatThrownBy(() -> dramaService.update(7L, req))
.isInstanceOf(ServiceException.class)
.satisfies(error -> assertThat(((ServiceException) error).getCode())
.isEqualTo(ErrorCode.BAD_REQUEST.getCode()));
verify(dramaMapper, never()).updateById(any(Drama.class));
}
@Test
void should_reject_invalid_drama_type() {
given(dramaMapper.selectById(7L)).willReturn(drama());
DramaSaveReqVO req = new DramaSaveReqVO();
req.setTitle("更新后的标题");
req.setDramaType("MOVIE");
assertThatThrownBy(() -> dramaService.update(7L, req))
.isInstanceOf(ServiceException.class)
.satisfies(error -> assertThat(((ServiceException) error).getCode())
.isEqualTo(ErrorCode.BAD_REQUEST.getCode()));
verify(dramaMapper, never()).updateById(any(Drama.class));
}
@Test
void should_default_new_drama_to_live_and_zero_counts() {
DramaSaveReqVO req = new DramaSaveReqVO();
req.setTitle("新短剧");
dramaService.create(req, 1L);
ArgumentCaptor<Drama> captor = ArgumentCaptor.forClass(Drama.class);
verify(dramaMapper).insert(captor.capture());
Drama created = captor.getValue();
assertThat(created.getDramaType()).isEqualTo("LIVE");
assertThat(created.getFavoriteCount()).isZero();
assertThat(created.getLikeCount()).isZero();
assertThat(created.getViewCount()).isZero();
}
private Drama drama() {
Drama drama = new Drama();
drama.setId(7L);
drama.setTitle("原标题");
drama.setStatus("published");
drama.setDramaType("ANIME");
drama.setFavoriteCount(10L);
drama.setLikeCount(20L);
drama.setViewCount(30L);
return drama;
}
}