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

+10
View File
@@ -167,6 +167,7 @@ app.register.max-per-device-per-day
| --- | --- | --- |
| GET | `/app/home/feed` | 首页:轮播 + 分区,卡片带 `preview` 可播预览集与 `language` 语种 |
| GET | `/app/discover/feed` | 发现页分区 |
| GET | `/app/feed?types=ANIME,LIVE&cursor=&size=20` | 推荐流;按内容类型多选过滤,游标翻页 |
| GET | `/app/categories` | 分类列表 |
| GET | `/app/dramas/{id}` | 详情(含剧集列表,免费集带**已签名**播放地址) |
| GET | `/app/search?keyword=&pageIndex=&pageSize=` | 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表 |
@@ -177,6 +178,15 @@ app.register.max-per-device-per-day
> 卡片同时下发 `likeCount` / `favoriteCount` / `commentCount`(均为 JSON 字符串),
> 点赞、收藏取 `drama` 冗余计数字段,评论数由 `content_comment` 按 `drama_id` 批量统计,
> 排除逻辑删除与 `status = 'deleted'` 的记录。首页、发现、分类、收藏、搜索等列表卡片结构一致。
>
> 卡片新增 `dramaType`:`ANIME` 表示漫剧,`LIVE` 表示真人剧。
#### 推荐类型流(`GET /app/feed`)
- `types` 可选,逗号分隔,支持 `ANIME`、`LIVE` 或多选 `ANIME,LIVE`;不传或空值返回全部已上线内容。
- `cursor` 可选,上一页返回的 `nextCursor`;`size` 默认 20,最大 50。
- 排序:`recommend_weight DESC NULLS LAST, view_count DESC, id DESC`。
- 返回 `Result<CursorResult<AppDramaCardVO>>`,卡片字段与首页一致,包含互动计数、`preview`、`language`、`dramaType`。
#### 短剧详情响应(`GET /app/dramas/{id}`)
@@ -0,0 +1,51 @@
# K88 内容类型、互动数据编辑与分类 Feed 实施计划
## 目标
- 后台短剧管理支持内容类型和互动数据编辑:收藏、点赞、热度可编辑,评论数实时统计只读。
- 新增 `drama.drama_type`,取值为 `ANIME` / `LIVE`,默认 `LIVE`。
- App 新增 `GET /app/feed`,支持 `ANIME,LIVE` 多选类型,不传类型时返回推荐全部。
## FruitServer A
1. 数据库
- 新增 `sql/migrate-k88-drama-type.sql`,只提交迁移文件,不执行数据库变更。
- `sql/schema.sql` 的 drama 建表语句补 `drama_type`,默认 `LIVE`。
2. 后台管理
- `Drama`、`DramaSaveReqVO`、`DramaRespVO` 增加 `dramaType`。
- `DramaSaveReqVO` 增加可空 `favoriteCount`、`likeCount`、`heat`;服务层校验非负,`heat` 映射 `view_count`。
- create 默认内容类型 LIVE、互动数 0;update 仅覆盖请求非 null 的互动字段。
- 后台分页增加 `dramaTypes` 多选过滤,并保持 CommentService 批量回填评论数。
3. App Feed
- `AppDramaCardVO` 增加 `dramaType`,现有 home/discover/detail 仅新增字段。
- `GET /app/feed` 支持 `types`、`cursor`、`size`;size 默认 20、最大 50。
- 排序为 `recommend_weight DESC NULLS LAST, view_count DESC, id DESC`。
- cursor 使用最后一条 drama id,服务端反查其 recommendWeight/viewCount/id,再按三元组做 keyset 查询,避免 offset 翻页重漏。
- 卡片复用现有 `toCards`,确保 preview、互动数、评论数与首页一致。
- 更新 `docs/api-phase1.md` 的 dramaType 与 `/app/feed` 契约。
4. 测试
- `/app/feed`:全部、ANIME、ANIME+LIVE、游标翻页不重不漏。
- 后台 update:部分字段更新、heat 写 viewCount、负数与非法 dramaType 报错。
## ReelShortWeb B
1. 类型定义补齐 dramaType、favoriteCount、likeCount、commentCount、heat。
2. 短剧列表增加内容类型筛选和标签列,播放量列改为热度,增加收藏/点赞/评论列。
3. 编辑弹窗增加必填内容类型、互动数据区块;评论数只读。
4. 不改审核、发布、删除逻辑。
## 风险与处理
- 推荐排序包含可空 recommend_weight,SQL 与 Java 条件必须显式处理 NULLS LAST。
- 三字段排序不能只按 id 做 cursor,需以 cursor id 反查上一页最后一条的排序值。
- 存量数据不主动修改,迁移默认 LIVE;数据库脚本由 PM 执行。
- 全部改动只使用显式文件暂存,不执行 `git add -A`,不改 specs 与 Flutter 客户端。
## 门禁与 Git
- FruitServer:`mvn test` 全过;提交 `feat(content): 新增内容类型与首页分类 feed`;`--no-ff` 合回 develop 并 push。
- ReelShortWeb:`pnpm ts:check`、`pnpm build:dev` 通过;提交 `feat(admin): 短剧列表展示并编辑互动数据`;`--no-ff` 合回 develop 并 push。
- 作者统一 `Zhou Weidong <502333560@qq.com>`;各自 PROGRESS 追加 K88 留痕。
@@ -52,6 +52,7 @@ class DramaControllerContractTest {
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.list[0].likeCount").value("3200"))
.andExpect(jsonPath("$.data.list[0].favoriteCount").value("1800"))
.andExpect(jsonPath("$.data.list[0].dramaType").value("ANIME"))
.andExpect(jsonPath("$.data.list[0].commentCount").value("11"))
.andExpect(jsonPath("$.data.list[1].likeCount").value("120"))
.andExpect(jsonPath("$.data.list[1].favoriteCount").value("80"))
@@ -69,6 +70,7 @@ class DramaControllerContractTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.likeCount").value("3200"))
.andExpect(jsonPath("$.data.favoriteCount").value("1800"))
.andExpect(jsonPath("$.data.dramaType").value("ANIME"))
.andExpect(jsonPath("$.data.commentCount").value("11"));
}
@@ -78,6 +80,7 @@ class DramaControllerContractTest {
drama.setTitle("短剧" + id);
drama.setLikeCount(likeCount);
drama.setFavoriteCount(favoriteCount);
drama.setDramaType("ANIME");
return drama;
}
}
@@ -5,12 +5,15 @@ package com.fruitshorts.app.controller;
import com.fruitshorts.app.service.AppCatalogService;
import com.fruitshorts.app.vo.AppCategoryVO;
import com.fruitshorts.app.vo.AppFeedVO;
import com.fruitshorts.app.vo.AppDramaCardVO;
import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.common.pojo.Result;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@@ -38,6 +41,15 @@ public class AppCatalogController {
return Result.ok(appCatalogService.discoverFeed());
}
@Operation(summary = "推荐流:支持内容类型多选与游标翻页")
@GetMapping("/feed")
public Result<CursorResult<AppDramaCardVO>> feed(
@RequestParam(required = false) String types,
@RequestParam(required = false) Long cursor,
@RequestParam(required = false) Integer size) {
return Result.ok(appCatalogService.feed(types, cursor, size));
}
@Operation(summary = "分类列表")
@GetMapping("/categories")
public Result<List<AppCategoryVO>> categories() {
@@ -9,6 +9,7 @@ import com.fruitshorts.app.vo.AppDramaDetailVO;
import com.fruitshorts.app.vo.AppEpisodeVO;
import com.fruitshorts.app.vo.AppFeedVO;
import com.fruitshorts.app.vo.AppSectionVO;
import com.fruitshorts.common.pojo.CursorResult;
import com.fruitshorts.content.dal.entity.ContentBanner;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
@@ -27,6 +28,7 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Arrays;
/**
* C 端目录聚合:把内容域的只读视图组装成客户端一次请求就能渲染的结构。
@@ -39,6 +41,8 @@ public class AppCatalogService {
private static final int HOME_SECTION_SIZE = 6;
private static final int DISCOVER_SECTION_SIZE = 10;
private static final int MAX_CATEGORY_SECTIONS = 4;
private static final int DEFAULT_FEED_SIZE = 20;
private static final int MAX_FEED_SIZE = 50;
private final DramaService dramaService;
private final EpisodeService episodeService;
@@ -70,6 +74,24 @@ public class AppCatalogService {
.toList();
}
public CursorResult<AppDramaCardVO> feed(String types, Long cursor, Integer size) {
int pageSize = size == null ? DEFAULT_FEED_SIZE : Math.max(1, Math.min(size, MAX_FEED_SIZE));
List<String> dramaTypes = types == null
? List.of()
: Arrays.stream(types.split(","))
.map(String::trim)
.filter(type -> !type.isEmpty())
.toList();
List<Drama> rows = dramaService.listPublishedFeed(dramaTypes, cursor, pageSize + 1);
if (rows.isEmpty()) {
return CursorResult.empty();
}
boolean hasMore = rows.size() > pageSize;
List<Drama> pageRows = hasMore ? rows.subList(0, pageSize) : rows;
Long nextCursor = hasMore ? pageRows.get(pageRows.size() - 1).getId() : null;
return new CursorResult<>(cards(pageRows, categoryNameMap()), nextCursor, hasMore);
}
/**
* 供收藏、观看记录等列表复用卡片结构:字段与首页卡片完全一致,
* 客户端可以用同一套渲染逻辑,不用为每个列表各写一份。
@@ -169,6 +191,7 @@ public class AppCatalogService {
card.setFavoriteCount(drama.getFavoriteCount());
card.setCommentCount(commentCounts.getOrDefault(drama.getId(), 0L));
card.setLanguage(drama.getLanguage());
card.setDramaType(drama.getDramaType());
card.setPreview(preview == null ? null : toEpisode(preview));
return card;
}
@@ -27,6 +27,8 @@ public class AppDramaCardVO {
/** 语种,供客户端按语言筛选(如 zh-CN / id)。 */
@Schema(description = "短剧语种,如 zh-CN / id")
private String language;
/** 内容类型:ANIME=漫剧,LIVE=真人剧 */
private String dramaType;
/**
* 首个可播免费集。首页竖屏播放需要在列表阶段就能起播,
* 带上这一集可以省掉「点开→请求详情→再请求播放地址」的往返。
@@ -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;
}
}
@@ -36,6 +36,7 @@ public class SecurityConfig {
"/app/auth/**",
"/app/home/**",
"/app/discover/**",
"/app/feed",
"/app/categories/**",
"/app/banners/**",
"/app/dramas/**",
@@ -27,6 +27,8 @@ public class Drama {
private String coverUrl;
private String horizontalCoverUrl;
private String language;
/** 内容类型:ANIME=漫剧,LIVE=真人剧 */
private String dramaType;
private Integer totalEpisodes;
private Integer freeEpisodes;
private Integer unlockCoin;
@@ -3,6 +3,7 @@
package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.common.exception.ErrorCode;
@@ -27,6 +28,7 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@@ -35,6 +37,8 @@ import java.util.Set;
public class DramaService {
private static final DateTimeFormatter CODE_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
private static final String DEFAULT_DRAMA_TYPE = "LIVE";
private static final Set<String> DRAMA_TYPES = Set.of("ANIME", "LIVE");
private final DramaMapper dramaMapper;
private final DramaEpisodeMapper dramaEpisodeMapper;
@@ -42,12 +46,14 @@ public class DramaService {
private final DramaCategoryService dramaCategoryService;
public PageResult<DramaRespVO> page(DramaPageReqVO req) {
List<String> dramaTypes = normalizeDramaTypes(req.getDramaTypes());
Page<Drama> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
IPage<Drama> result = dramaMapper.selectPage(page, Wrappers.<Drama>lambdaQuery()
.like(StringUtils.hasText(req.getKeyword()), Drama::getTitle, req.getKeyword())
.eq(StringUtils.hasText(req.getStatus()), Drama::getStatus, req.getStatus())
.eq(req.getCategoryId() != null, Drama::getCategoryId, req.getCategoryId())
.eq(req.getIsHot() != null, Drama::getIsHot, Boolean.TRUE.equals(req.getIsHot()) ? 1 : 0)
.in(!dramaTypes.isEmpty(), Drama::getDramaType, dramaTypes)
.orderByDesc(Drama::getSort)
.orderByDesc(Drama::getId));
return new PageResult<>(result.getTotal(), toRespList(result.getRecords()));
@@ -86,10 +92,10 @@ public class DramaService {
drama.setStatus(ContentStatus.DRAFT.getCode());
drama.setCreatorId(creatorId == null ? 0L : creatorId);
drama.setTotalEpisodes(0);
drama.setViewCount(0L);
drama.setViewCount(nonNegativeOrZero(req.getHeat(), "热度"));
drama.setPlayCount(0L);
drama.setFavoriteCount(0L);
drama.setLikeCount(0L);
drama.setFavoriteCount(nonNegativeOrZero(req.getFavoriteCount(), "收藏数"));
drama.setLikeCount(nonNegativeOrZero(req.getLikeCount(), "点赞数"));
drama.setCreateBy(operatorId == null ? 0L : operatorId);
dramaMapper.insert(drama);
return drama.getId();
@@ -99,6 +105,15 @@ public class DramaService {
public void update(Long id, DramaSaveReqVO req) {
Drama drama = getExisting(id);
apply(drama, req);
if (req.getFavoriteCount() != null) {
drama.setFavoriteCount(nonNegative(req.getFavoriteCount(), "收藏数"));
}
if (req.getLikeCount() != null) {
drama.setLikeCount(nonNegative(req.getLikeCount(), "点赞数"));
}
if (req.getHeat() != null) {
drama.setViewCount(nonNegative(req.getHeat(), "热度"));
}
dramaMapper.updateById(drama);
}
@@ -211,6 +226,31 @@ public class DramaService {
.orderByDesc(Drama::getId)).getRecords();
}
/**
* App 推荐流:按推荐权重、热度、ID 倒序,并支持内容类型多选。
* cursor 使用上一页最后一条 ID,服务端反查排序键后做 keyset 查询。
*/
public List<Drama> listPublishedFeed(List<String> dramaTypes, Long cursor, int limit) {
List<String> normalizedTypes = normalizeDramaTypes(dramaTypes);
QueryWrapper<Drama> query = new QueryWrapper<>();
query.eq("status", ContentStatus.PUBLISHED.getCode());
if (!normalizedTypes.isEmpty()) {
query.in("drama_type", normalizedTypes);
}
if (cursor != null) {
Drama marker = dramaMapper.selectById(cursor);
if (marker == null
|| !ContentStatus.PUBLISHED.getCode().equals(marker.getStatus())
|| (!normalizedTypes.isEmpty() && !normalizedTypes.contains(marker.getDramaType()))) {
return List.of();
}
applyFeedCursor(query, marker);
}
int safeLimit = Math.max(1, limit);
query.last("ORDER BY recommend_weight DESC NULLS LAST, view_count DESC, id DESC LIMIT " + safeLimit);
return dramaMapper.selectList(query);
}
/** 已上线的热门短剧 */
public List<Drama> listPublishedHot(long limit) {
Page<Drama> page = new Page<>(1, Math.max(1, limit));
@@ -300,6 +340,11 @@ public class DramaService {
drama.setTrailerUrl(req.getTrailerUrl() == null ? "" : req.getTrailerUrl());
drama.setHorizontalCoverUrl(drama.getHorizontalCoverUrl() == null ? "" : drama.getHorizontalCoverUrl());
drama.setLanguage(req.getLanguage() == null ? "zh-CN" : req.getLanguage());
if (StringUtils.hasText(req.getDramaType())) {
drama.setDramaType(normalizeDramaType(req.getDramaType()));
} else if (!StringUtils.hasText(drama.getDramaType())) {
drama.setDramaType(DEFAULT_DRAMA_TYPE);
}
drama.setFreeEpisodes(req.getFreeEpisodes() == null ? 3 : req.getFreeEpisodes());
drama.setUnlockCoin(req.getUnlockCoin() == null ? 0 : req.getUnlockCoin());
drama.setIsHot(Boolean.TRUE.equals(req.getIsHot()) ? 1 : 0);
@@ -330,6 +375,7 @@ public class DramaService {
vo.setCover(drama.getCoverUrl());
vo.setTrailerUrl(drama.getTrailerUrl());
vo.setLanguage(drama.getLanguage());
vo.setDramaType(drama.getDramaType());
vo.setEpisodes(drama.getTotalEpisodes());
vo.setFreeEpisodes(drama.getFreeEpisodes());
vo.setUnlockCoin(drama.getUnlockCoin());
@@ -346,4 +392,53 @@ public class DramaService {
return vo;
}).toList();
}
private void applyFeedCursor(QueryWrapper<Drama> query, Drama marker) {
Long viewCount = marker.getViewCount() == null ? 0L : marker.getViewCount();
Long id = marker.getId();
if (marker.getRecommendWeight() != null) {
Integer weight = marker.getRecommendWeight();
query.and(wrapper -> wrapper
.lt("recommend_weight", weight)
.or(or -> or.eq("recommend_weight", weight)
.and(inner -> inner
.lt("view_count", viewCount)
.or(tail -> tail.eq("view_count", viewCount).lt("id", id)))));
} else {
query.and(wrapper -> wrapper.isNull("recommend_weight")
.and(inner -> inner
.lt("view_count", viewCount)
.or(tail -> tail.eq("view_count", viewCount).lt("id", id))));
}
}
private List<String> normalizeDramaTypes(List<String> dramaTypes) {
if (dramaTypes == null || dramaTypes.isEmpty()) {
return List.of();
}
return dramaTypes.stream()
.filter(StringUtils::hasText)
.map(this::normalizeDramaType)
.distinct()
.toList();
}
private String normalizeDramaType(String dramaType) {
String normalized = dramaType == null ? "" : dramaType.trim().toUpperCase(Locale.ROOT);
if (!DRAMA_TYPES.contains(normalized)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "内容类型仅支持 ANIME 或 LIVE");
}
return normalized;
}
private Long nonNegativeOrZero(Long value, String fieldName) {
return value == null ? 0L : nonNegative(value, fieldName);
}
private Long nonNegative(Long value, String fieldName) {
if (value < 0) {
throw new ServiceException(ErrorCode.BAD_REQUEST, fieldName + "不能小于 0");
}
return value;
}
}
@@ -6,6 +6,8 @@ import com.fruitshorts.common.pojo.PageParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
@Data
@EqualsAndHashCode(callSuper = true)
public class DramaPageReqVO extends PageParam {
@@ -16,4 +18,6 @@ public class DramaPageReqVO extends PageParam {
private Long categoryId;
/** 是否只看热门 */
private Boolean isHot;
/** 内容类型多选:ANIME/LIVE,空=全部 */
private List<String> dramaTypes;
}
@@ -25,6 +25,7 @@ public class DramaRespVO {
/** 整剧预告片地址 */
private String trailerUrl;
private String language;
private String dramaType;
private Integer episodes;
private Integer freeEpisodes;
private Integer unlockCoin;
@@ -3,6 +3,8 @@
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Data;
@Data
@@ -19,8 +21,16 @@ public class DramaSaveReqVO {
/** 整剧预告片地址(复用已有的 drama.trailer_url 列) */
private String trailerUrl;
private String language;
@Pattern(regexp = "ANIME|LIVE", message = "内容类型仅支持 ANIME 或 LIVE")
private String dramaType;
private Integer freeEpisodes;
private Integer unlockCoin;
@PositiveOrZero(message = "收藏数不能小于 0")
private Long favoriteCount;
@PositiveOrZero(message = "点赞数不能小于 0")
private Long likeCount;
@PositiveOrZero(message = "热度不能小于 0")
private Long heat;
/** 是否热门 */
private Boolean isHot;
private Integer sort;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE drama ADD COLUMN IF NOT EXISTS drama_type VARCHAR(16) NOT NULL DEFAULT 'LIVE';
COMMENT ON COLUMN drama.drama_type IS '内容类型:ANIME=漫剧,LIVE=真人剧';
+1
View File
@@ -169,6 +169,7 @@ CREATE TABLE IF NOT EXISTS drama (
cover_url VARCHAR(512) NOT NULL DEFAULT '',
horizontal_cover_url VARCHAR(512) NOT NULL DEFAULT '',
language VARCHAR(16) NOT NULL DEFAULT 'zh-CN',
drama_type VARCHAR(16) NOT NULL DEFAULT 'LIVE',
total_episodes INT NOT NULL DEFAULT 0,
free_episodes INT NOT NULL DEFAULT 3,
unlock_coin INT NOT NULL DEFAULT 0,