From 1052b9730610a3b570de248396439a4a3a74d5c8 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 15:40:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(content):=20=E6=96=B0=E5=A2=9E=E5=86=85?= =?UTF-8?q?=E5=AE=B9=E7=B1=BB=E5=9E=8B=E4=B8=8E=E9=A6=96=E9=A1=B5=E5=88=86?= =?UTF-8?q?=E7=B1=BB=20feed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-phase1.md | 10 ++ docs/plans/2026-10-21-K88-drama-type-feed.md | 51 +++++++ .../DramaControllerContractTest.java | 3 + .../app/controller/AppCatalogController.java | 12 ++ .../app/service/AppCatalogService.java | 23 ++++ .../fruitshorts/app/vo/AppDramaCardVO.java | 2 + .../AppCatalogControllerContractTest.java | 23 ++++ .../AppCommentCountAnonymousSecurityTest.java | 30 ++++- .../app/service/AppCatalogServiceTest.java | 61 +++++++++ .../app/service/DramaServiceFeedTest.java | 97 +++++++++++++ .../app/service/DramaServiceK88Test.java | 127 ++++++++++++++++++ .../framework/security/SecurityConfig.java | 1 + .../fruitshorts/content/dal/entity/Drama.java | 2 + .../content/service/DramaService.java | 101 +++++++++++++- .../content/vo/DramaPageReqVO.java | 4 + .../fruitshorts/content/vo/DramaRespVO.java | 1 + .../content/vo/DramaSaveReqVO.java | 10 ++ sql/migrate-k88-drama-type.sql | 2 + sql/schema.sql | 1 + 19 files changed, 557 insertions(+), 4 deletions(-) create mode 100644 docs/plans/2026-10-21-K88-drama-type-feed.md create mode 100644 fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceFeedTest.java create mode 100644 fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceK88Test.java create mode 100644 sql/migrate-k88-drama-type.sql diff --git a/docs/api-phase1.md b/docs/api-phase1.md index 18eea04..1c4b138 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -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>`,卡片字段与首页一致,包含互动计数、`preview`、`language`、`dramaType`。 #### 短剧详情响应(`GET /app/dramas/{id}`) diff --git a/docs/plans/2026-10-21-K88-drama-type-feed.md b/docs/plans/2026-10-21-K88-drama-type-feed.md new file mode 100644 index 0000000..e8a6a07 --- /dev/null +++ b/docs/plans/2026-10-21-K88-drama-type-feed.md @@ -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 留痕。 diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/DramaControllerContractTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/DramaControllerContractTest.java index 79be4de..8f5fbb9 100644 --- a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/DramaControllerContractTest.java +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/DramaControllerContractTest.java @@ -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; } } diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCatalogController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCatalogController.java index b3f797b..073bfe8 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCatalogController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCatalogController.java @@ -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> 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> categories() { diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java index 291ebb4..c6321e1 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java @@ -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 feed(String types, Long cursor, Integer size) { + int pageSize = size == null ? DEFAULT_FEED_SIZE : Math.max(1, Math.min(size, MAX_FEED_SIZE)); + List dramaTypes = types == null + ? List.of() + : Arrays.stream(types.split(",")) + .map(String::trim) + .filter(type -> !type.isEmpty()) + .toList(); + List rows = dramaService.listPublishedFeed(dramaTypes, cursor, pageSize + 1); + if (rows.isEmpty()) { + return CursorResult.empty(); + } + boolean hasMore = rows.size() > pageSize; + List 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; } diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaCardVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaCardVO.java index 4310add..4a9d080 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaCardVO.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaCardVO.java @@ -27,6 +27,8 @@ public class AppDramaCardVO { /** 语种,供客户端按语言筛选(如 zh-CN / id)。 */ @Schema(description = "短剧语种,如 zh-CN / id") private String language; + /** 内容类型:ANIME=漫剧,LIVE=真人剧 */ + private String dramaType; /** * 首个可播免费集。首页竖屏播放需要在列表阶段就能起播, * 带上这一集可以省掉「点开→请求详情→再请求播放地址」的往返。 diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCatalogControllerContractTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCatalogControllerContractTest.java index c7d6c54..e5e2334 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCatalogControllerContractTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCatalogControllerContractTest.java @@ -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, "都市"); diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentCountAnonymousSecurityTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentCountAnonymousSecurityTest.java index c34c106..ba5726d 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentCountAnonymousSecurityTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/controller/AppCommentCountAnonymousSecurityTest.java @@ -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( diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java index cd726da..a37c02d 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java @@ -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 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 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 firstPage = catalogService.feed(null, null, 1); + CursorResult 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; + } } diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceFeedTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceFeedTest.java new file mode 100644 index 0000000..5f5ebfe --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceFeedTest.java @@ -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> captor = ArgumentCaptor.forClass(QueryWrapper.class); + + dramaService.listPublishedFeed(List.of("ANIME"), 9L, 5); + + verify(dramaMapper).selectList(captor.capture()); + QueryWrapper 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> 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"); + } +} diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceK88Test.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceK88Test.java new file mode 100644 index 0000000..1f428c5 --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceK88Test.java @@ -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 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 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; + } +} diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java index 0496693..e9370e2 100644 --- a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java @@ -36,6 +36,7 @@ public class SecurityConfig { "/app/auth/**", "/app/home/**", "/app/discover/**", + "/app/feed", "/app/categories/**", "/app/banners/**", "/app/dramas/**", diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/Drama.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/Drama.java index 6a1bc20..21c938d 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/Drama.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/Drama.java @@ -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; diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java index 24072c3..07e0ef9 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java @@ -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 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 page(DramaPageReqVO req) { + List dramaTypes = normalizeDramaTypes(req.getDramaTypes()); Page page = new Page<>(req.getSafePageIndex(), req.getSafePageSize()); IPage result = dramaMapper.selectPage(page, Wrappers.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 listPublishedFeed(List dramaTypes, Long cursor, int limit) { + List normalizedTypes = normalizeDramaTypes(dramaTypes); + QueryWrapper 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 listPublishedHot(long limit) { Page 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 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 normalizeDramaTypes(List 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; + } } diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaPageReqVO.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaPageReqVO.java index faee6fe..61cb39f 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaPageReqVO.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaPageReqVO.java @@ -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 dramaTypes; } diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaRespVO.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaRespVO.java index 70e0391..fb2f3f2 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaRespVO.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaRespVO.java @@ -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; diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaSaveReqVO.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaSaveReqVO.java index f216637..9cdd10f 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaSaveReqVO.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaSaveReqVO.java @@ -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; diff --git a/sql/migrate-k88-drama-type.sql b/sql/migrate-k88-drama-type.sql new file mode 100644 index 0000000..b8a0694 --- /dev/null +++ b/sql/migrate-k88-drama-type.sql @@ -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=真人剧'; diff --git a/sql/schema.sql b/sql/schema.sql index 6b533f2..d5c8348 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -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,