From 8f35160b568c1736d9013f8dd701289fe369ba68 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Wed, 21 Oct 2026 18:23:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20=E6=8E=A8=E8=8D=90=E6=B5=81?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=88=86=E7=B1=BB=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-phase1.md | 5 +- docs/plans/2026-10-21-K96-theater-feed.md | 86 +++++++++++++++++++ docs/plans/PROGRESS.md | 39 +++++++++ .../app/controller/AppCatalogController.java | 5 +- .../app/service/AppCatalogService.java | 6 +- .../AppCatalogControllerContractTest.java | 5 +- .../AppCommentCountAnonymousSecurityTest.java | 2 +- .../app/service/AppCatalogServiceTest.java | 38 +++++--- .../app/service/DramaServiceFeedTest.java | 66 +++++++++++++- .../app/service/DramaServiceMultiTagTest.java | 4 +- .../content/service/DramaMultiTagSupport.java | 13 +++ .../content/service/DramaService.java | 10 ++- 12 files changed, 255 insertions(+), 24 deletions(-) create mode 100644 docs/plans/2026-10-21-K96-theater-feed.md diff --git a/docs/api-phase1.md b/docs/api-phase1.md index adfb2a0..99b0abb 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -167,7 +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/feed?types=ANIME,LIVE&categoryId=7&cursor=&size=20` | 推荐流;支持内容类型与分类叠加过滤,游标翻页 | | GET | `/app/categories` | 分类列表 | | GET | `/app/dramas/{id}` | 详情(含剧集列表,免费集带**已签名**播放地址) | | GET | `/app/search?keyword=&pageIndex=&pageSize=` | 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表 | @@ -188,7 +188,10 @@ app.register.max-per-device-per-day - `types` 可选,逗号分隔,支持 `ANIME`、`LIVE` 或多选 `ANIME,LIVE`;不传或空值返回全部已上线内容。 过滤按 `drama_types` 整词匹配,多标签剧可被多个类型同时命中。 +- `categoryId` 可选;不传返回全部分类。传入后按 `category_ids` 整词匹配, + 可与 `types` 叠加,只返回同时命中内容类型和分类的已上线短剧。 - `cursor` 可选,上一页返回的 `nextCursor`;`size` 默认 20,最大 50。 + 游标短剧必须同时满足已上线、内容类型命中和分类命中,否则返回空列表。 - 排序:`recommend_weight DESC NULLS LAST, view_count DESC, id DESC`。 - 返回 `Result>`,卡片字段与首页一致,包含互动计数、`preview`、`language`、`dramaType`。 diff --git a/docs/plans/2026-10-21-K96-theater-feed.md b/docs/plans/2026-10-21-K96-theater-feed.md new file mode 100644 index 0000000..4bd3da9 --- /dev/null +++ b/docs/plans/2026-10-21-K96-theater-feed.md @@ -0,0 +1,86 @@ +# K96 剧场页服务端分类分页 + +## 目标 + +- `/app/feed` 支持按分类过滤,并与内容类型过滤叠加。 +- 剧场页「全部」展示全部已上线短剧,任意分类展示该分类全部已上线短剧。 +- 列表使用游标分页,切分类优先命中缓存,支持静默刷新、加载更多和失败重试。 + +## 分支 + +- FruitServer:`feat/k96-feed-category` +- ReelShort:`feat/k96-theater-feed` + +## A. FruitServer + +1. `AppCatalogController.feed` 增加可选 `Long categoryId` 并传给服务层。 +2. `AppCatalogService.feed` 增加 `categoryId`,传给 + `DramaService.listPublishedFeed`。 +3. `DramaService.listPublishedFeed` 增加 `categoryId`: + - 非空时使用 `DramaMultiTagSupport.applyCategoryIdTokens` + 按 `category_ids` 整词匹配。 + - 游标 marker 必须同时满足已上线、内容类型命中和分类命中,否则返回空列表。 +4. 补充服务层、控制器契约测试: + - 仅 categoryId。 + - categoryId + types。 + - 分类游标翻页。 + - marker 分类不命中返回空。 +5. 更新 `docs/api-phase1.md` 的 `/app/feed` 参数和分类过滤说明。 + +门禁:`mvn test` 全模块通过。 + +## B. ReelShort + +### B1. API 与仓库 + +- `CatalogApi.feed` 增加可选 `String? categoryId`,非空才拼 query。 +- `CatalogRepository.fetchFeed` 同步参数,HTTP 实现透传。 + +### B2. TheaterFeedController + +- 新增 `lib/core/data/services/theater_feed_controller.dart`。 +- 按 `String? categoryId` 独立缓存状态: + `dramas / cursor / hasMore / loaded / loading / loadingMore / lastError / + requestSeq / inFlight`。 +- `select(id)` 同步切分类;有数据先渲染并静默刷新,无数据时加载。 +- `loadMore` 游标追加并按 id 去重;`hasMore=false` 停止。 +- `refreshCurrent` 失败保留旧数据;切换回失败分类自动重试。 +- 单请求 10 秒超时,响应写回发起分类,requestSeq 仅处理同分类顺序。 + +### B3. 剧场接入 + +- `DiscoverPage` 创建并持有 TheaterFeedController,初始请求「全部」。 +- 「全部」和分类 chips 的网格统一读取 `theater.dramas`,不再使用 + `categories.feed.dramas` / `dramasOf`。 +- `_selectCategory` 同时更新 `DiscoverLogic.selectedCategoryId` 和 + `theater.select(categoryId)`。 +- 滚动剩余不足 400px 时触发 `loadMore`;搜索模式不触发剧场分页。 +- 下拉刷新并行执行 `categories.load()` 和 `theater.refreshCurrent()`。 +- 无缓存首屏显示转圈;空、失败和重试保持既有剧场视觉。 + +### B4. 测试 + +- TheaterFeedController: + - 全部使用空 categoryId,分类使用对应 id。 + - 缓存切回不闪烁。 + - 分页去重、hasMore=false 停止。 + - 失败保留旧数据、失败分类切回自动重试。 +- 剧场 widget: + - 点击分类请求对应 categoryId。 + - 接近底部触发下一页。 + - 搜索、下拉刷新与既有布局不回归。 + +门禁:`dart analyze lib test` 零告警、`flutter test` 全过、 +`flutter build apk --debug` 成功。 + +## Git + +- FruitServer 提交:`feat(app): 推荐流支持分类过滤` +- ReelShort 提交:`feat(theater): 剧场分类列表服务端分页` +- 两仓库作者:`Zhou Weidong <502333560@qq.com>`,禁止 `git add -A`。 +- 各自 `--no-ff` 合回 `develop` 并 push,两仓库 PROGRESS 留痕。 + +## 约束 + +- 全程中文;不改 specs、ReelShortWeb,不改首页 K94/K95 已交付逻辑。 +- 不安装或操作真机;完成后停住等待验收。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 50cccee..9d3bb37 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -24253,3 +24253,42 @@ Running Gradle task 'assembleDebug'... 27.6s `838ca2c` 的代码差异为空。 - 本地分支 `fix/k95.2-poster-transition` 已删除;远端原本不存在该分支。 - K95、K95.1 成果保持不变;未实现其他改动,未安装或操作真机。 + +## 2026-10-21 K96 剧场页服务端分类分页 + +- 已完整读取 K96 `docs/plans/INBOX.md`。 +- 已从最新 `origin/develop` 创建 FruitServer `feat/k96-feed-category` 与 + ReelShort `feat/k96-theater-feed`。 +- 已先写计划 `docs/plans/2026-10-21-K96-theater-feed.md`,覆盖分类参数、 + category_ids 整词匹配、游标 marker 三重校验、客户端分类缓存分页和剧场接入。 +- 已完成 `/app/feed` 可选 `categoryId`:可与 `types` 叠加;游标 marker 必须同时满足 + 已上线、类型命中和分类命中,否则返回空列表。 +- 已补分类单独过滤、分类 + 类型、分类游标和 marker 不匹配测试,并更新 + `docs/api-phase1.md`。 +- 客户端已完成 Catalog API 分类参数、TheaterFeedController、分类缓存/静默刷新、 + 游标追加去重、10 秒超时与滚动加载;剧场「全部 / 分类」已改用 `/app/feed`, + 搜索与首页 K94/K95 逻辑未改。 + +门禁原始输出: + +```text +$ mvn test +[INFO] fruitshorts-app-api ................................ SUCCESS +[INFO] BUILD SUCCESS +[INFO] Total time: 15.436 s + +$ dart analyze lib test +Analyzing lib, test... +No issues found! + +$ flutter test --no-pub +00:52 +553: All tests passed! + +$ flutter build apk --debug +Running Gradle task 'assembleDebug'... 24.8s +✓ Built build/app/outputs/flutter-apk/app-debug.apk +``` + +解读:FruitServer 全部 14 个 Reactor 模块通过,app-api 99 项测试全过; +ReelShort 静态分析零告警、553 项测试全过、debug APK 构建成功。 +`pubspec.lock` 已还原,无依赖变更;未安装或操作真机。 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 073bfe8..77fbee7 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 @@ -41,13 +41,14 @@ public class AppCatalogController { return Result.ok(appCatalogService.discoverFeed()); } - @Operation(summary = "推荐流:支持内容类型多选与游标翻页") + @Operation(summary = "推荐流:支持内容类型、分类过滤与游标翻页") @GetMapping("/feed") public Result> feed( @RequestParam(required = false) String types, + @RequestParam(required = false) Long categoryId, @RequestParam(required = false) Long cursor, @RequestParam(required = false) Integer size) { - return Result.ok(appCatalogService.feed(types, cursor, size)); + return Result.ok(appCatalogService.feed(types, categoryId, cursor, size)); } @Operation(summary = "分类列表") 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 44e5e16..674d64a 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 @@ -74,7 +74,8 @@ public class AppCatalogService { .toList(); } - public CursorResult feed(String types, Long cursor, Integer size) { + public CursorResult feed( + String types, Long categoryId, 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() @@ -82,7 +83,8 @@ public class AppCatalogService { .map(String::trim) .filter(type -> !type.isEmpty()) .toList(); - List rows = dramaService.listPublishedFeed(dramaTypes, cursor, pageSize + 1); + List rows = dramaService.listPublishedFeed( + dramaTypes, categoryId, cursor, pageSize + 1); if (rows.isEmpty()) { return CursorResult.empty(); } 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 f27b4f9..4d8d844 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 @@ -96,11 +96,12 @@ class AppCatalogControllerContractTest { card.setId(12L); card.setTitle("漫剧卡片"); card.setDramaType("ANIME"); - given(catalogService.feed("ANIME", null, 20)) + given(catalogService.feed("ANIME", 7L, null, 20)) .willReturn(new CursorResult<>(List.of(card), 12L, true)); catalogMvc.perform(get("/app/feed") .param("types", "ANIME") + .param("categoryId", "7") .param("size", "20")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) @@ -109,7 +110,7 @@ class AppCatalogControllerContractTest { .andExpect(jsonPath("$.data.nextCursor").value("12")) .andExpect(jsonPath("$.data.hasMore").value(true)); - verify(catalogService).feed("ANIME", null, 20); + verify(catalogService).feed("ANIME", 7L, null, 20); } @Test 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 b765ade..e3120d4 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 @@ -56,7 +56,7 @@ class AppCommentCountAnonymousSecurityTest { @BeforeEach void setUp() { given(commentService.countByDrama(7L)).willReturn(9L); - given(catalogService.feed("ANIME", null, 20)) + given(catalogService.feed("ANIME", null, null, 20)) .willReturn(new CursorResult<>(List.of(), null, false)); } 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 711a5be..d4cb24e 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 @@ -118,7 +118,7 @@ class AppCatalogServiceTest { @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)) + given(dramaService.listPublishedFeed(List.of("ANIME"), null, null, 21)) .willReturn(List.of(anime)); given(dramaCategoryService.listBasic()).willReturn(List.of()); DramaEpisode preview = episode(11L, 1L, 45L); @@ -126,41 +126,59 @@ class AppCatalogServiceTest { .willReturn(Map.of(1L, preview)); given(commentService.countByEpisodes(List.of(11L))).willReturn(Map.of(11L, 3L)); - CursorResult result = catalogService.feed("ANIME", null, null); + CursorResult result = catalogService.feed( + "ANIME", null, 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.getList().get(0).getLikeCount()).isEqualTo(45L); assertThat(result.isHasMore()).isFalse(); - verify(dramaService).listPublishedFeed(List.of("ANIME"), null, 21); + verify(dramaService).listPublishedFeed(List.of("ANIME"), null, null, 21); } @Test void should_parse_multiple_feed_types() { - given(dramaService.listPublishedFeed(List.of("ANIME", "LIVE"), null, 11)) + given(dramaService.listPublishedFeed( + List.of("ANIME", "LIVE"), null, null, 11)) .willReturn(List.of()); - CursorResult result = catalogService.feed("ANIME,LIVE", null, 10); + CursorResult result = catalogService.feed( + "ANIME,LIVE", null, null, 10); assertThat(result.getList()).isEmpty(); - verify(dramaService).listPublishedFeed(List.of("ANIME", "LIVE"), null, 11); + verify(dramaService).listPublishedFeed( + List.of("ANIME", "LIVE"), null, null, 11); + } + + @Test + void should_filter_feed_by_category_id_only() { + given(dramaService.listPublishedFeed(List.of(), 7L, null, 21)) + .willReturn(List.of()); + + CursorResult result = catalogService.feed( + null, 7L, null, null); + + assertThat(result.getList()).isEmpty(); + verify(dramaService).listPublishedFeed(List.of(), 7L, null, 21); } @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)) + given(dramaService.listPublishedFeed(List.of(), null, null, 2)) .willReturn(List.of(first, second)); - given(dramaService.listPublishedFeed(List.of(), 1L, 2)) + given(dramaService.listPublishedFeed(List.of(), null, 1L, 2)) .willReturn(List.of(second)); given(dramaCategoryService.listBasic()).willReturn(List.of()); given(episodeService.firstPlayableByDramas(any())).willReturn(Map.of()); given(commentService.countByEpisodes(any())).willReturn(Map.of()); - CursorResult firstPage = catalogService.feed(null, null, 1); - CursorResult secondPage = catalogService.feed(null, firstPage.getNextCursor(), 1); + CursorResult firstPage = catalogService.feed( + null, null, null, 1); + CursorResult secondPage = catalogService.feed( + null, null, firstPage.getNextCursor(), 1); assertThat(firstPage.getList()).extracting(AppDramaCardVO::getId).containsExactly(1L); assertThat(firstPage.getNextCursor()).isEqualTo(1L); 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 index 46e2b7e..5d74ea5 100644 --- 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 @@ -23,6 +23,7 @@ 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.never; import static org.mockito.Mockito.verify; /** K88 App 推荐流的类型过滤与三字段 keyset 游标。 */ @@ -60,7 +61,7 @@ class DramaServiceFeedTest { given(dramaMapper.selectList(any())).willReturn(List.of()); ArgumentCaptor> captor = ArgumentCaptor.forClass(QueryWrapper.class); - dramaService.listPublishedFeed(List.of("ANIME"), 9L, 5); + dramaService.listPublishedFeed(List.of("ANIME"), null, 9L, 5); verify(dramaMapper).selectList(captor.capture()); QueryWrapper query = captor.getValue(); @@ -86,7 +87,7 @@ class DramaServiceFeedTest { given(dramaMapper.selectList(any())).willReturn(List.of()); ArgumentCaptor> captor = ArgumentCaptor.forClass(QueryWrapper.class); - dramaService.listPublishedFeed(List.of("LIVE"), 9L, 5); + dramaService.listPublishedFeed(List.of("LIVE"), null, 9L, 5); verify(dramaMapper).selectList(captor.capture()); assertThat(captor.getValue().getSqlSegment()) @@ -94,4 +95,65 @@ class DramaServiceFeedTest { .contains("view_count") .contains("id"); } + + @Test + @SuppressWarnings("unchecked") + void should_apply_category_and_type_tokens_together() { + given(dramaMapper.selectList(any())).willReturn(List.of()); + ArgumentCaptor> captor = ArgumentCaptor.forClass(QueryWrapper.class); + + dramaService.listPublishedFeed(List.of("ANIME"), 7L, null, 5); + + verify(dramaMapper).selectList(captor.capture()); + QueryWrapper query = captor.getValue(); + assertThat(query.getSqlSegment()) + .contains("drama_types") + .contains("category_ids") + .contains("recommend_weight"); + assertThat(query.getParamNameValuePairs().values()) + .contains("%,ANIME,%", "%,7,%"); + } + + @Test + @SuppressWarnings("unchecked") + void should_apply_category_cursor_for_matching_marker() { + Drama marker = new Drama(); + marker.setId(9L); + marker.setStatus("published"); + marker.setDramaType("ANIME"); + marker.setCategoryId(7L); + marker.setCategoryIds("7,8"); + 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(), 7L, 9L, 5); + + verify(dramaMapper).selectList(captor.capture()); + QueryWrapper query = captor.getValue(); + assertThat(query.getSqlSegment()) + .contains("category_ids") + .contains("recommend_weight") + .contains("id"); + assertThat(query.getParamNameValuePairs().values()) + .contains("%,7,%", 30, 1200L, 9L); + } + + @Test + void should_return_empty_when_cursor_marker_does_not_match_category() { + Drama marker = new Drama(); + marker.setId(9L); + marker.setStatus("published"); + marker.setDramaType("ANIME"); + marker.setCategoryId(8L); + marker.setCategoryIds("8"); + given(dramaMapper.selectById(9L)).willReturn(marker); + + List rows = dramaService.listPublishedFeed(List.of("ANIME"), 7L, 9L, 5); + + assertThat(rows).isEmpty(); + verify(dramaMapper, never()).selectList(any()); + } } diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceMultiTagTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceMultiTagTest.java index 2111529..70a14ee 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceMultiTagTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceMultiTagTest.java @@ -134,8 +134,8 @@ class DramaServiceMultiTagTest { given(dramaMapper.selectList(any())).willReturn(List.of()); ArgumentCaptor> captor = ArgumentCaptor.forClass(QueryWrapper.class); - dramaService.listPublishedFeed(List.of("ANIME"), 9L, 5); - dramaService.listPublishedFeed(List.of("LIVE"), 9L, 5); + dramaService.listPublishedFeed(List.of("ANIME"), null, 9L, 5); + dramaService.listPublishedFeed(List.of("LIVE"), null, 9L, 5); verify(dramaMapper, times(2)).selectList(captor.capture()); assertThat(captor.getAllValues()).allSatisfy(query -> diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaMultiTagSupport.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaMultiTagSupport.java index 497ce59..5876bb3 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaMultiTagSupport.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaMultiTagSupport.java @@ -99,6 +99,19 @@ final class DramaMultiTagSupport { }); } + static void applyCategoryIdTokens(QueryWrapper wrapper, + List categoryIds) { + wrapper.and(nested -> { + for (int index = 0; index < categoryIds.size(); index++) { + if (index > 0) { + nested.or(); + } + nested.apply("(',' || category_ids || ',') LIKE {0}", + tokenPattern(categoryIds.get(index))); + } + }); + } + static List parseDramaTypes(Drama drama) { List values = parseStringTokens(drama.getDramaTypes()).stream() .map(DramaMultiTagSupport::normalizeDramaType) 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 90d9fb1..d7d1450 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 @@ -237,20 +237,26 @@ public class DramaService { * App 推荐流:按推荐权重、热度、ID 倒序,并支持内容类型多选。 * cursor 使用上一页最后一条 ID,服务端反查排序键后做 keyset 查询。 */ - public List listPublishedFeed(List dramaTypes, Long cursor, int limit) { + public List listPublishedFeed( + List dramaTypes, Long categoryId, Long cursor, int limit) { List normalizedTypes = DramaMultiTagSupport.normalizeDramaTypes(dramaTypes); QueryWrapper query = new QueryWrapper<>(); query.eq("status", ContentStatus.PUBLISHED.getCode()); if (!normalizedTypes.isEmpty()) { DramaMultiTagSupport.applyDramaTypeTokens(query, normalizedTypes); } + if (categoryId != null) { + DramaMultiTagSupport.applyCategoryIdTokens(query, List.of(categoryId)); + } if (cursor != null) { Drama marker = dramaMapper.selectById(cursor); if (marker == null || !ContentStatus.PUBLISHED.getCode().equals(marker.getStatus()) || (!normalizedTypes.isEmpty() && DramaMultiTagSupport.parseDramaTypes(marker).stream() - .noneMatch(normalizedTypes::contains))) { + .noneMatch(normalizedTypes::contains)) + || (categoryId != null + && !DramaMultiTagSupport.parseCategoryIds(marker).contains(categoryId))) { return List.of(); } applyFeedCursor(query, marker);