diff --git a/docs/api-phase1.md b/docs/api-phase1.md index 45512db..adfb2a0 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -181,10 +181,13 @@ app.register.max-per-device-per-day > `viewCount` 热度仍取整部剧。首页、发现、分类、收藏、搜索等列表卡片结构一致。 > > 卡片新增 `dramaType`:`ANIME` 表示漫剧,`LIVE` 表示真人剧。 +> 客户端字段仍返回主内容类型;后台多选值保存在 `drama.drama_types`, +> App 类型筛选按逗号列整词匹配,因此 `ANIME,LIVE` 多标签剧可被任一类型命中。 #### 推荐类型流(`GET /app/feed`) - `types` 可选,逗号分隔,支持 `ANIME`、`LIVE` 或多选 `ANIME,LIVE`;不传或空值返回全部已上线内容。 + 过滤按 `drama_types` 整词匹配,多标签剧可被多个类型同时命中。 - `cursor` 可选,上一页返回的 `nextCursor`;`size` 默认 20,最大 50。 - 排序:`recommend_weight DESC NULLS LAST, view_count DESC, id DESC`。 - 返回 `Result>`,卡片字段与首页一致,包含互动计数、`preview`、`language`、`dramaType`。 @@ -527,10 +530,10 @@ GET /app/notices?pageIndex=1&pageSize=20 | 方法 | 路径 | 权限点 | 说明 | | --- | --- | --- | --- | -| GET | `/admin/dramas/page` | `drama:list` | 检索(含热门、状态筛选) | +| GET | `/admin/dramas/page` | `drama:list` | 检索(含热门、状态、`dramaTypes[]`、`categoryIds[]` 多选筛选) | | GET | `/admin/dramas/{id}` | `drama:list` | 详情 | -| POST | `/admin/dramas` | `drama:create` | 新建短剧 | -| PUT | `/admin/dramas/{id}` | `drama:update` | 编辑短剧 | +| POST | `/admin/dramas` | `drama:create` | 新建短剧;优先接收 `dramaTypes[]` / `categoryIds[]` | +| PUT | `/admin/dramas/{id}` | `drama:update` | 编辑短剧;优先接收 `dramaTypes[]` / `categoryIds[]` | | DELETE | `/admin/dramas/{id}` | `drama:delete` | 删除短剧 | | POST | `/admin/dramas/{id}/submit` | `drama:review` / `drama:update` | 提交审核 | | POST | `/admin/dramas/{id}/approve` | `drama:review` | 审核通过 | @@ -539,6 +542,15 @@ GET /app/notices?pageIndex=1&pageSize=20 | POST | `/admin/dramas/{id}/offline` | `drama:review` | 下线 | | GET | `/admin/dramas/{id}/review-records` | `drama:list` | 审核记录 | +多选字段说明: + +- 保存优先使用 `dramaTypes[]` / `categoryIds[]`,去重并保持提交顺序; + `drama_type` / `category_id` 写第一个值,供旧客户端与旧接口兼容。 +- 仅传 `dramaType` / `categoryId` 时保持原单值行为。 +- 列表响应返回 `dramaTypes[]`、`categoryIds[]` 与按选中顺序映射的 `categoryNames[]`; + 分类名缺失时回退 `genre`。 +- 后台与 App 过滤均使用 `(',' || column || ',') LIKE '%,value,%'` 整词匹配。 + **剧集** | 方法 | 路径 | 权限点 | 说明 | diff --git a/docs/plans/2026-10-21-K93-drama-multi-tag.md b/docs/plans/2026-10-21-K93-drama-multi-tag.md new file mode 100644 index 0000000..f1c95dc --- /dev/null +++ b/docs/plans/2026-10-21-K93-drama-multi-tag.md @@ -0,0 +1,121 @@ +# K93 短剧类型与内容类型支持多选 + +## 目标 + +- 后台短剧基础信息中的「类型」「内容类型」改为至少选一项的多选。 +- 后台列表筛选、列表展示支持多分类与多内容类型。 +- App 分类分区和内容类型 feed 使用多标签字段做整词匹配。 +- 单值字段继续保留,兼容创作者中心、旧接口和现有 App 卡片。 + +## 分支 + +- FruitServer:`feat/k93-drama-multi-tag`,从 `origin/develop` 创建。 +- ReelShortWeb:`feat/k93-drama-multi-tag-ui`,从 `origin/develop` 创建。 + +## A. FruitServer + +### A1 数据库 + +- 新增 `sql/migrate-k93-drama-multi-tag.sql`: + - `drama.drama_types VARCHAR(32) NOT NULL DEFAULT ''` + - `drama.category_ids VARCHAR(255) NOT NULL DEFAULT ''` + - 从现有 `drama_type / category_id` 幂等回填。 +- 同步 `sql/schema.sql` 的 drama 建表字段。 +- 只提交迁移文件,不执行数据库。 + +### A2 实体与 VO + +- `Drama`:增加 `dramaTypes/categoryIds` 逗号字符串。 +- `DramaSaveReqVO`: + - 新增 `List dramaTypes`、`List categoryIds`,列表优先。 + - 保留 `dramaType/categoryId` 单值兜底。 +- `DramaPageReqVO`:增加 `List categoryIds`,保留 `categoryId`。 +- `DramaRespVO`:增加 `dramaTypes/categoryIds/categoryNames`,旧单值字段语义不变。 + +### A3 保存规则 + +- 内容类型列表:normalize、去重、保持提交顺序;主值写第一个,完整列表写 `drama_types`。 +- 分类列表:过滤 null/非正数、去重、保持提交顺序;主值写第一个或 0,完整列表写 `category_ids`。 +- 列表为空时回退单值;单值也没有时保留当前值。 +- 创建时内容类型最终为空则默认 LIVE。 +- 非法内容类型继续返回 BAD_REQUEST。 + +### A4 查询规则 + +统一使用整词匹配: + +```sql +(',' || column || ',') LIKE '%,value,%' +``` + +- 后台分页: + - `dramaTypes` 非空:`drama_types` token 匹配。 + - `categoryIds` 非空:`category_ids` token 匹配。 + - `categoryIds` 为空:回退 `category_id` 等值。 +- App `listPublishedFeed`:`drama_types` token 匹配。 +- App feed 游标:marker 的解析后 `dramaTypes` 与任一筛选类型相交才允许继续。 +- App `listPublished(categoryId)`:`category_ids` token 匹配,使多分类剧出现在每个分类分区。 + +### A5 响应装配 + +- 解析 `drama_types/category_ids`,空值回退单值。 +- `categoryNames` 按 categoryIds 顺序映射分类名。 +- 分类名缺失时回退 `genre`。 +- 现有 `dramaType/categoryId/category` 保持旧语义。 + +### A6 文档与测试 + +- 更新 `docs/api-phase1.md` 的后台保存/分页字段、App feed 和分类说明。 +- 增加服务测试覆盖: + - 多类型/多分类去重、顺序与主值写回。 + - 单值兜底行为不变。 + - 后台分页 token 条件。 + - feed 多标签 marker 可被 ANIME/LIVE 命中。 + - App 分类分区 token 条件。 + - 响应多值解析和分类名顺序。 +- 门禁:`mvn test` 全模块通过。 + +## B. ReelShortWeb + +### B1 类型 + +- `DramaItem` 增加 `dramaTypes/categoryIds/categoryNames`。 +- `DramaSaveData` 增加 `dramaTypes/categoryIds`。 +- `DramaPageParams` 增加 `categoryIds`。 + +### B2 表单 + +- `form.categoryIds: number[]`,默认空数组。 +- `form.dramaTypes: string[]`,创建默认 `['LIVE']`。 +- 两个下拉均使用 multiple、collapse-tags、collapse-tags-tooltip、filterable。 +- 校验至少 1 项。 +- 编辑时优先数组,缺失回退单值。 +- 提交数组;`genre` 使用所选分类名以 ` / ` 拼接。 + +### B3 筛选与表格 + +- 分类筛选改为 `categoryIds: number[]`。 +- 内容类型筛选改为 `dramaTypes: string[]`。 +- 查询传数组,重置为空数组。 +- 类型列显示 `categoryNames.join(' / ')`,回退 `genre`。 +- 内容类型列逐个渲染标签,缺失时回退 `dramaType`。 + +### B4 门禁 + +- `pnpm ts:check` +- `pnpm build:dev` +- 对修改文件执行定向 ESLint + +## 风险与说明 + +1. 新代码依赖迁移后的 `drama_types/category_ids` 列,发布顺序必须先执行迁移再启动新版本。 +2. 逗号列仅存储受控内容类型和数字 id,不允许用户输入;查询值均来自校验后的数组。 +3. 主值继续保留,降低旧客户端、创作者中心和旧接口的兼容风险。 +4. 本次不改 specs 与 Flutter 客户端,不执行数据库迁移,不操作真机。 + +## Git + +- FruitServer:`feat(content): 短剧类型与内容类型支持多选` +- ReelShortWeb:`feat(admin): 短剧类型与内容类型多选` +- 两仓库均 `--no-ff` 合回 `develop` 并 push。 +- 作者:`Zhou Weidong <502333560@qq.com>`;禁止 `git add -A`。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index f138c24..90db4d7 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -23947,3 +23947,59 @@ dev 构建保留既有 `element-plus` circular chunk 警告,不影响成功退 门禁已独立完成。 - 本任务无数据库迁移;未修改 specs、Flutter 客户端、K91 评论列表与观看记录接口; 未安装或操作真机,等待用户验收。 + +## 2026-10-21 K93 短剧类型与内容类型多选 + +- 已完整读取 K93 `docs/plans/INBOX.md` 及项目协作、编码、Git 规范。 +- 已创建 FruitServer `feat/k93-drama-multi-tag` 与 ReelShortWeb + `feat/k93-drama-multi-tag-ui`,均基于最新 `origin/develop`。 +- 已先写计划 `docs/plans/2026-10-21-K93-drama-multi-tag.md`,明确双列存储、 + 主值兼容、token 整词匹配、feed 游标与 Web 多选交互。 +- 本任务迁移仅写文件,不执行数据库;不改 specs 与 Flutter 客户端。 +- 已完成 A 主体代码:`Drama` / 保存、分页、响应 VO 增加双列与数组字段; + `DramaService.apply` 支持多值去重、顺序保留和单值兜底;响应装配分类名顺序。 +- 后台分页、App feed 与 App 分类列表已改用逗号列整词匹配;feed 游标会校验 marker + 的多内容类型是否与筛选类型相交。 +- 已新增迁移 `sql/migrate-k93-drama-multi-tag.sql` 并同步 `sql/schema.sql`,迁移未执行; + `docs/api-phase1.md` 已补多选保存、过滤与响应说明。 +- 已新增 `DramaServiceMultiTagTest`,覆盖多值去重/顺序/主值、单值兼容、后台 token 条件、 + feed 多标签游标、App 分类 token 条件与响应分类名顺序。 +- K93 定向后端测试已通过: + +```text +$ JAVA_HOME= mvn -pl fruitshorts-app-api -am \ + -Dtest=DramaServiceMultiTagTest,DramaServiceK88Test,DramaServiceFeedTest \ + -Dsurefire.failIfNoSpecifiedTests=false test +[INFO] fruitshorts-app-api ... SUCCESS +[INFO] Tests run: 12, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +解读:多值保存、单值兼容、后台分页、feed 多标签游标和分类分区匹配均通过。 +- 已完成 B:Web 类型与分页参数扩展;表单两个下拉改为多选并至少 1 项;编辑优先回填数组, + 缺失回退单值;筛选传数组;表格显示多分类名与多个内容类型标签。 +- ReelShortWeb `pnpm ts:check`、定向 ESLint 已通过;dev 构建已通过: + +```text +$ pnpm build:dev +> fruitshorts-admin@1.0.0 build:dev +> VITE_CJS_IGNORE_WARNING=1 vite build --mode dev +Build successful. Please see dist-dev directory +``` + +解读:多选表单、筛选与展示通过类型检查、Lint 和构建;构建仅有既有 circular chunk 警告。 +- 规范复核发现 `DramaService` 增至 618 行,超过 500 行红线;已将多标签存储、解析与 + Wrapper token 条件抽到同包 `DramaMultiTagSupport`,`DramaService` 回落至 440 行, + 新工具类 204 行。重构后定向测试再次通过:12 项,`BUILD SUCCESS`。 +- FruitServer 全模块门禁已通过: + +```text +$ JAVA_HOME= mvn test +[INFO] fruitshorts-admin-api .............................. SUCCESS +[INFO] fruitshorts-app-api ................................ SUCCESS +[INFO] fruitshorts-transcode-worker ....................... SUCCESS +[INFO] BUILD SUCCESS +[INFO] Total time: 15.119 s +``` + +解读:全部 14 个 Reactor 模块通过;app-api 共 95 项测试全过,含新增多标签测试。 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 5f5ebfe..46e2b7e 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 @@ -71,7 +71,7 @@ class DramaServiceFeedTest { .contains("view_count") .contains("id"); assertThat(query.getParamNameValuePairs().values()) - .contains("published", "ANIME", 30, 1200L, 9L); + .contains("published", "%,ANIME,%", 30, 1200L, 9L); } @Test 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 new file mode 100644 index 0000000..2111529 --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/DramaServiceMultiTagTest.java @@ -0,0 +1,192 @@ +// Author: fruit.com.cn + +package com.fruitshorts.app.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +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.DramaPageReqVO; +import com.fruitshorts.content.vo.DramaRespVO; +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 java.util.Arrays; +import java.util.List; +import java.util.Map; + +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.times; +import static org.mockito.Mockito.verify; + +/** K93 短剧分类与内容类型多选。 */ +class DramaServiceMultiTagTest { + + private DramaMapper dramaMapper; + private DramaCategoryService categoryService; + private DramaService dramaService; + + @BeforeAll + static void initTableInfo() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), Drama.class); + } + + @BeforeEach + void setUp() { + dramaMapper = mock(DramaMapper.class); + categoryService = mock(DramaCategoryService.class); + dramaService = new DramaService( + dramaMapper, + mock(DramaEpisodeMapper.class), + mock(DramaReviewRecordMapper.class), + categoryService); + } + + @Test + void should_save_distinct_multi_tags_and_write_first_value_as_primary() { + DramaSaveReqVO req = new DramaSaveReqVO(); + req.setTitle("多标签短剧"); + req.setDramaTypes(List.of("live", "ANIME", "LIVE", " anime ")); + req.setCategoryIds(Arrays.asList(3L, 1L, 3L, 0L, null)); + req.setGenre("类型三 / 类型一"); + + 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.getDramaTypes()).isEqualTo("LIVE,ANIME"); + assertThat(created.getCategoryId()).isEqualTo(3L); + assertThat(created.getCategoryIds()).isEqualTo("3,1"); + } + + @Test + void should_keep_single_value_behavior_when_lists_are_absent() { + Drama existing = drama(7L); + existing.setDramaType("ANIME"); + existing.setDramaTypes("ANIME"); + existing.setCategoryId(2L); + existing.setCategoryIds("2"); + given(dramaMapper.selectById(7L)).willReturn(existing); + DramaSaveReqVO req = new DramaSaveReqVO(); + req.setTitle("单值更新"); + req.setDramaType("live"); + req.setCategoryId(4L); + + dramaService.update(7L, req); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Drama.class); + verify(dramaMapper).updateById(captor.capture()); + assertThat(captor.getValue().getDramaType()).isEqualTo("LIVE"); + assertThat(captor.getValue().getDramaTypes()).isEqualTo("LIVE"); + assertThat(captor.getValue().getCategoryId()).isEqualTo(4L); + assertThat(captor.getValue().getCategoryIds()).isEqualTo("4"); + } + + @Test + @SuppressWarnings("unchecked") + void should_apply_token_filters_for_admin_page_multi_tags() { + Page page = new Page<>(1, 10); + page.setRecords(List.of()); + given(dramaMapper.selectPage(any(Page.class), any())).willReturn(page); + DramaPageReqVO req = new DramaPageReqVO(); + req.setDramaTypes(List.of("ANIME", "LIVE")); + req.setCategoryIds(List.of(2L, 3L)); + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + + dramaService.page(req); + + verify(dramaMapper).selectPage(any(), captor.capture()); + assertThat(captor.getValue().getSqlSegment()) + .contains("drama_types") + .contains("category_ids") + .contains("LIKE"); + assertThat(captor.getValue().getParamNameValuePairs().values()) + .contains("%,ANIME,%", "%,LIVE,%", "%,2,%", "%,3,%"); + } + + @Test + @SuppressWarnings("unchecked") + void should_allow_multi_tag_cursor_marker_for_each_filter_type() { + Drama marker = drama(9L); + marker.setDramaType("ANIME"); + marker.setDramaTypes("ANIME,LIVE"); + 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); + dramaService.listPublishedFeed(List.of("LIVE"), 9L, 5); + + verify(dramaMapper, times(2)).selectList(captor.capture()); + assertThat(captor.getAllValues()).allSatisfy(query -> + assertThat(query.getSqlSegment()) + .contains("drama_types") + .contains("LIKE")); + } + + @Test + @SuppressWarnings("unchecked") + void should_match_multi_category_when_loading_app_category_section() { + Page page = new Page<>(1, 10); + page.setRecords(List.of()); + given(dramaMapper.selectPage(any(Page.class), any())).willReturn(page); + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + + dramaService.listPublished(3L, 10); + + verify(dramaMapper).selectPage(any(), captor.capture()); + assertThat(captor.getValue().getSqlSegment()) + .contains("category_ids") + .contains("LIKE"); + assertThat(captor.getValue().getParamNameValuePairs().values()).contains("%,3,%"); + } + + @Test + void should_parse_multi_values_and_keep_category_name_order_in_response() { + Drama drama = drama(7L); + drama.setDramaType("LIVE"); + drama.setDramaTypes("LIVE,ANIME"); + drama.setCategoryId(3L); + drama.setCategoryIds("3,1"); + drama.setGenre("Fallback Genre"); + given(dramaMapper.selectById(7L)).willReturn(drama); + given(categoryService.nameMap()).willReturn(Map.of(1L, "Type A", 3L, "Type B")); + + DramaRespVO vo = dramaService.get(7L); + + assertThat(vo.getDramaTypes()).containsExactly("LIVE", "ANIME"); + assertThat(vo.getCategoryIds()).containsExactly(3L, 1L); + assertThat(vo.getCategoryNames()).containsExactly("Type B", "Type A"); + assertThat(vo.getDramaType()).isEqualTo("LIVE"); + assertThat(vo.getCategoryId()).isEqualTo(3L); + } + + private Drama drama(Long id) { + Drama drama = new Drama(); + drama.setId(id); + drama.setStatus("published"); + drama.setViewCount(0L); + return drama; + } +} 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 21c938d..5cceb5b 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 @@ -22,6 +22,8 @@ public class Drama { private String title; private String subtitle; private Long categoryId; + /** 分类多选,逗号分隔的 category_id,category_id 保留第一个 */ + private String categoryIds; private String genre; private String synopsis; private String coverUrl; @@ -29,6 +31,8 @@ public class Drama { private String language; /** 内容类型:ANIME=漫剧,LIVE=真人剧 */ private String dramaType; + /** 内容类型多选,逗号分隔(ANIME/LIVE),drama_type 保留第一个 */ + private String dramaTypes; private Integer totalEpisodes; private Integer freeEpisodes; private Integer unlockCoin; 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 new file mode 100644 index 0000000..497ce59 --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaMultiTagSupport.java @@ -0,0 +1,204 @@ +// Author: fruit.com.cn + +package com.fruitshorts.content.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.content.dal.entity.Drama; +import com.fruitshorts.content.vo.DramaSaveReqVO; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** 短剧分类与内容类型多选值的存储、解析和整词过滤。 */ +final class DramaMultiTagSupport { + + static final String DEFAULT_DRAMA_TYPE = "LIVE"; + private static final Set DRAMA_TYPES = Set.of("ANIME", "LIVE"); + + private DramaMultiTagSupport() { + } + + static void applyDramaTypes(Drama drama, DramaSaveReqVO req) { + List values = normalizeDramaTypes(req.getDramaTypes()); + if (values.isEmpty() && StringUtils.hasText(req.getDramaType())) { + values = List.of(normalizeDramaType(req.getDramaType())); + } + if (!values.isEmpty()) { + drama.setDramaType(values.get(0)); + drama.setDramaTypes(String.join(",", values)); + return; + } + if (!StringUtils.hasText(drama.getDramaTypes()) + && !StringUtils.hasText(drama.getDramaType())) { + drama.setDramaType(DEFAULT_DRAMA_TYPE); + drama.setDramaTypes(DEFAULT_DRAMA_TYPE); + } + } + + static void applyCategoryIds(Drama drama, DramaSaveReqVO req) { + List values = normalizeCategoryIds(req.getCategoryIds()); + if (values.isEmpty() && req.getCategoryId() != null && req.getCategoryId() > 0L) { + values = List.of(req.getCategoryId()); + } + if (!values.isEmpty()) { + drama.setCategoryId(values.get(0)); + drama.setCategoryIds(String.join(",", values.stream().map(String::valueOf).toList())); + return; + } + if (drama.getCategoryId() == null) { + drama.setCategoryId(0L); + } + if (drama.getCategoryIds() == null) { + drama.setCategoryIds(""); + } + } + + static void applyDramaTypeTokens(LambdaQueryWrapper wrapper, + List dramaTypes) { + wrapper.and(nested -> { + for (int index = 0; index < dramaTypes.size(); index++) { + if (index > 0) { + nested.or(); + } + nested.apply("(',' || drama_types || ',') LIKE {0}", + tokenPattern(dramaTypes.get(index))); + } + }); + } + + static void applyCategoryIdTokens(LambdaQueryWrapper 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 void applyDramaTypeTokens(QueryWrapper wrapper, + List dramaTypes) { + wrapper.and(nested -> { + for (int index = 0; index < dramaTypes.size(); index++) { + if (index > 0) { + nested.or(); + } + nested.apply("(',' || drama_types || ',') LIKE {0}", + tokenPattern(dramaTypes.get(index))); + } + }); + } + + static List parseDramaTypes(Drama drama) { + List values = parseStringTokens(drama.getDramaTypes()).stream() + .map(DramaMultiTagSupport::normalizeDramaType) + .distinct() + .toList(); + if (!values.isEmpty()) { + return values; + } + if (StringUtils.hasText(drama.getDramaType())) { + return List.of(normalizeDramaType(drama.getDramaType())); + } + return List.of(DEFAULT_DRAMA_TYPE); + } + + static List parseCategoryIds(Drama drama) { + List values = parseLongTokens(drama.getCategoryIds()); + if (values.isEmpty() && drama.getCategoryId() != null && drama.getCategoryId() > 0L) { + return List.of(drama.getCategoryId()); + } + return values; + } + + static List categoryNames(Drama drama, List categoryIds, + Map names) { + List values = categoryIds.stream() + .map(names::get) + .filter(StringUtils::hasText) + .toList(); + if (values.isEmpty() && StringUtils.hasText(drama.getGenre())) { + return List.of(drama.getGenre()); + } + return values; + } + + static List normalizeDramaTypes(List dramaTypes) { + if (dramaTypes == null || dramaTypes.isEmpty()) { + return List.of(); + } + return dramaTypes.stream() + .filter(StringUtils::hasText) + .map(DramaMultiTagSupport::normalizeDramaType) + .distinct() + .toList(); + } + + static List normalizeCategoryIds(List categoryIds) { + if (categoryIds == null || categoryIds.isEmpty()) { + return List.of(); + } + return categoryIds.stream() + .filter(id -> id != null && id > 0L) + .distinct() + .toList(); + } + + private static 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 static List parseStringTokens(String value) { + if (!StringUtils.hasText(value)) { + return List.of(); + } + List result = new ArrayList<>(); + for (String token : value.split(",")) { + String trimmed = token.trim(); + if (StringUtils.hasText(trimmed) && !result.contains(trimmed)) { + result.add(trimmed); + } + } + return result; + } + + private static List parseLongTokens(String value) { + if (!StringUtils.hasText(value)) { + return List.of(); + } + List result = new ArrayList<>(); + for (String token : value.split(",")) { + Long parsed = parseLongValue(token); + if (parsed != null && parsed > 0L && !result.contains(parsed)) { + result.add(parsed); + } + } + return result; + } + + private static Long parseLongValue(String value) { + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException ignored) { + return null; + } + } + + private static String tokenPattern(Object value) { + return "%," + value + ",%"; + } +} 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 07e0ef9..90d9fb1 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.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; @@ -28,7 +29,6 @@ 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; @@ -37,8 +37,6 @@ 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; @@ -46,16 +44,23 @@ public class DramaService { private final DramaCategoryService dramaCategoryService; public PageResult page(DramaPageReqVO req) { - List dramaTypes = normalizeDramaTypes(req.getDramaTypes()); + List dramaTypes = DramaMultiTagSupport.normalizeDramaTypes(req.getDramaTypes()); + List categoryIds = DramaMultiTagSupport.normalizeCategoryIds(req.getCategoryIds()); Page page = new Page<>(req.getSafePageIndex(), req.getSafePageSize()); - IPage result = dramaMapper.selectPage(page, Wrappers.lambdaQuery() + LambdaQueryWrapper wrapper = 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)); + .eq(req.getIsHot() != null, Drama::getIsHot, Boolean.TRUE.equals(req.getIsHot()) ? 1 : 0); + if (!dramaTypes.isEmpty()) { + DramaMultiTagSupport.applyDramaTypeTokens(wrapper, dramaTypes); + } + if (!categoryIds.isEmpty()) { + DramaMultiTagSupport.applyCategoryIdTokens(wrapper, categoryIds); + } else { + wrapper.eq(req.getCategoryId() != null, Drama::getCategoryId, req.getCategoryId()); + } + wrapper.orderByDesc(Drama::getSort).orderByDesc(Drama::getId); + IPage result = dramaMapper.selectPage(page, wrapper); return new PageResult<>(result.getTotal(), toRespList(result.getRecords())); } @@ -210,11 +215,13 @@ public class DramaService { /** 已上线短剧,按运营排序 + 播放量 */ public List listPublished(Long categoryId, long limit) { Page page = new Page<>(1, Math.max(1, limit)); - return dramaMapper.selectPage(page, Wrappers.lambdaQuery() - .eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode()) - .eq(categoryId != null && categoryId > 0, Drama::getCategoryId, categoryId) - .orderByDesc(Drama::getSort) - .orderByDesc(Drama::getViewCount)).getRecords(); + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() + .eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode()); + if (categoryId != null && categoryId > 0) { + DramaMultiTagSupport.applyCategoryIdTokens(wrapper, List.of(categoryId)); + } + wrapper.orderByDesc(Drama::getSort).orderByDesc(Drama::getViewCount); + return dramaMapper.selectPage(page, wrapper).getRecords(); } /** 已上线短剧,按上线时间倒序 */ @@ -231,17 +238,19 @@ public class DramaService { * cursor 使用上一页最后一条 ID,服务端反查排序键后做 keyset 查询。 */ public List listPublishedFeed(List dramaTypes, Long cursor, int limit) { - List normalizedTypes = normalizeDramaTypes(dramaTypes); + List normalizedTypes = DramaMultiTagSupport.normalizeDramaTypes(dramaTypes); QueryWrapper query = new QueryWrapper<>(); query.eq("status", ContentStatus.PUBLISHED.getCode()); if (!normalizedTypes.isEmpty()) { - query.in("drama_type", normalizedTypes); + DramaMultiTagSupport.applyDramaTypeTokens(query, normalizedTypes); } if (cursor != null) { Drama marker = dramaMapper.selectById(cursor); if (marker == null || !ContentStatus.PUBLISHED.getCode().equals(marker.getStatus()) - || (!normalizedTypes.isEmpty() && !normalizedTypes.contains(marker.getDramaType()))) { + || (!normalizedTypes.isEmpty() + && DramaMultiTagSupport.parseDramaTypes(marker).stream() + .noneMatch(normalizedTypes::contains))) { return List.of(); } applyFeedCursor(query, marker); @@ -333,18 +342,14 @@ public class DramaService { private void apply(Drama drama, DramaSaveReqVO req) { drama.setTitle(req.getTitle()); drama.setSubtitle(req.getSubtitle() == null ? "" : req.getSubtitle()); - drama.setCategoryId(req.getCategoryId() == null ? 0L : req.getCategoryId()); + DramaMultiTagSupport.applyCategoryIds(drama, req); drama.setGenre(req.getGenre() == null ? "" : req.getGenre()); drama.setSynopsis(req.getDescription() == null ? "" : req.getDescription()); drama.setCoverUrl(req.getCover() == null ? "" : req.getCover()); 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); - } + DramaMultiTagSupport.applyDramaTypes(drama, req); 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); @@ -363,19 +368,29 @@ public class DramaService { } Map categories = dramaCategoryService.nameMap(); return dramas.stream().map(drama -> { + List categoryIds = DramaMultiTagSupport.parseCategoryIds(drama); + Long primaryCategoryId = categoryIds.isEmpty() + ? (drama.getCategoryId() == null ? 0L : drama.getCategoryId()) + : categoryIds.get(0); + List categoryNames = + DramaMultiTagSupport.categoryNames(drama, categoryIds, categories); + List dramaTypes = DramaMultiTagSupport.parseDramaTypes(drama); DramaRespVO vo = new DramaRespVO(); vo.setId(drama.getId()); vo.setCode(drama.getCode()); vo.setTitle(drama.getTitle()); vo.setSubtitle(drama.getSubtitle()); - vo.setCategoryId(drama.getCategoryId()); - vo.setCategory(categories.getOrDefault(drama.getCategoryId(), "")); + vo.setCategoryId(primaryCategoryId); + vo.setCategoryIds(categoryIds); + vo.setCategoryNames(categoryNames); + vo.setCategory(categories.getOrDefault(primaryCategoryId, "")); vo.setGenre(drama.getGenre()); vo.setDescription(drama.getSynopsis()); vo.setCover(drama.getCoverUrl()); vo.setTrailerUrl(drama.getTrailerUrl()); vo.setLanguage(drama.getLanguage()); - vo.setDramaType(drama.getDramaType()); + vo.setDramaType(dramaTypes.get(0)); + vo.setDramaTypes(dramaTypes); vo.setEpisodes(drama.getTotalEpisodes()); vo.setFreeEpisodes(drama.getFreeEpisodes()); vo.setUnlockCoin(drama.getUnlockCoin()); @@ -412,25 +427,6 @@ public class DramaService { } } - 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); } 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 61cb39f..d77e475 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 @@ -16,6 +16,8 @@ public class DramaPageReqVO extends PageParam { private String keyword; private String status; private Long categoryId; + /** 分类多选,空时回退 categoryId */ + private List categoryIds; /** 是否只看热门 */ private Boolean isHot; /** 内容类型多选:ANIME/LIVE,空=全部 */ 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 fb2f3f2..58e9456 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 @@ -6,6 +6,7 @@ import com.fasterxml.jackson.annotation.JsonFormat; import lombok.Data; import java.time.LocalDateTime; +import java.util.List; /** * 短剧列表/详情响应,字段名与后台页面表格列一致。 @@ -18,6 +19,8 @@ public class DramaRespVO { private String title; private String subtitle; private Long categoryId; + private List categoryIds; + private List categoryNames; private String category; private String genre; private String description; @@ -26,6 +29,7 @@ public class DramaRespVO { private String trailerUrl; private String language; private String dramaType; + private List dramaTypes; 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 9cdd10f..2d5cd63 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 @@ -7,6 +7,8 @@ import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.PositiveOrZero; import lombok.Data; +import java.util.List; + @Data public class DramaSaveReqVO { @@ -15,6 +17,8 @@ public class DramaSaveReqVO { private String subtitle; private Long categoryId; + /** 后台多选优先,创作者中心可继续只传 categoryId */ + private List categoryIds; private String genre; private String description; private String cover; @@ -23,6 +27,8 @@ public class DramaSaveReqVO { private String language; @Pattern(regexp = "ANIME|LIVE", message = "内容类型仅支持 ANIME 或 LIVE") private String dramaType; + /** 后台多选优先,创作者中心可继续只传 dramaType */ + private List dramaTypes; private Integer freeEpisodes; private Integer unlockCoin; @PositiveOrZero(message = "收藏数不能小于 0") diff --git a/sql/migrate-k93-drama-multi-tag.sql b/sql/migrate-k93-drama-multi-tag.sql new file mode 100644 index 0000000..abfee1b --- /dev/null +++ b/sql/migrate-k93-drama-multi-tag.sql @@ -0,0 +1,16 @@ +-- Author: fruit.com.cn + +-- K93 短剧分类与内容类型多选(幂等,仅提交文件,由发布流程执行) + +ALTER TABLE drama ADD COLUMN IF NOT EXISTS drama_types VARCHAR(32) NOT NULL DEFAULT ''; +COMMENT ON COLUMN drama.drama_types IS '内容类型多选,逗号分隔(ANIME/LIVE),drama_type 保留第一个'; + +ALTER TABLE drama ADD COLUMN IF NOT EXISTS category_ids VARCHAR(255) NOT NULL DEFAULT ''; +COMMENT ON COLUMN drama.category_ids IS + '短剧分类多选,逗号分隔的 category_id,category_id 保留第一个'; + +UPDATE drama SET drama_types = drama_type +WHERE (drama_types IS NULL OR drama_types = '') AND drama_type IS NOT NULL AND drama_type <> ''; + +UPDATE drama SET category_ids = category_id::text +WHERE (category_ids IS NULL OR category_ids = '') AND category_id IS NOT NULL AND category_id > 0; diff --git a/sql/schema.sql b/sql/schema.sql index d5c8348..ad7c011 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -164,12 +164,14 @@ CREATE TABLE IF NOT EXISTS drama ( title VARCHAR(128) NOT NULL, subtitle VARCHAR(128) NOT NULL DEFAULT '', category_id BIGINT NOT NULL DEFAULT 0, + category_ids VARCHAR(255) NOT NULL DEFAULT '', genre VARCHAR(64) NOT NULL DEFAULT '', synopsis VARCHAR(1000) NOT NULL DEFAULT '', 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', + drama_types VARCHAR(32) NOT NULL DEFAULT '', total_episodes INT NOT NULL DEFAULT 0, free_episodes INT NOT NULL DEFAULT 3, unlock_coin INT NOT NULL DEFAULT 0,