feat(content): 短剧类型与内容类型支持多选

This commit is contained in:
Zhou Weidong committed 2026-10-21 17:01:12 +08:00
1 parent 030df5c5dc
commit 4c614a5ece
13 files changed
+665 -50

No files matched your search

+15 -3
View File
@@ -181,10 +181,13 @@ app.register.max-per-device-per-day
> `viewCount` 热度仍取整部剧。首页、发现、分类、收藏、搜索等列表卡片结构一致。 > `viewCount` 热度仍取整部剧。首页、发现、分类、收藏、搜索等列表卡片结构一致。
> >
> 卡片新增 `dramaType`:`ANIME` 表示漫剧,`LIVE` 表示真人剧。 > 卡片新增 `dramaType`:`ANIME` 表示漫剧,`LIVE` 表示真人剧。
> 客户端字段仍返回主内容类型;后台多选值保存在 `drama.drama_types`,
> App 类型筛选按逗号列整词匹配,因此 `ANIME,LIVE` 多标签剧可被任一类型命中。
#### 推荐类型流(`GET /app/feed`) #### 推荐类型流(`GET /app/feed`)
- `types` 可选,逗号分隔,支持 `ANIME`、`LIVE` 或多选 `ANIME,LIVE`;不传或空值返回全部已上线内容。 - `types` 可选,逗号分隔,支持 `ANIME`、`LIVE` 或多选 `ANIME,LIVE`;不传或空值返回全部已上线内容。
过滤按 `drama_types` 整词匹配,多标签剧可被多个类型同时命中。
- `cursor` 可选,上一页返回的 `nextCursor`;`size` 默认 20,最大 50。 - `cursor` 可选,上一页返回的 `nextCursor`;`size` 默认 20,最大 50。
- 排序:`recommend_weight DESC NULLS LAST, view_count DESC, id DESC`。 - 排序:`recommend_weight DESC NULLS LAST, view_count DESC, id DESC`。
- 返回 `Result<CursorResult<AppDramaCardVO>>`,卡片字段与首页一致,包含互动计数、`preview`、`language`、`dramaType`。 - 返回 `Result<CursorResult<AppDramaCardVO>>`,卡片字段与首页一致,包含互动计数、`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` | 详情 | | GET | `/admin/dramas/{id}` | `drama:list` | 详情 |
| POST | `/admin/dramas` | `drama:create` | 新建短剧 | | POST | `/admin/dramas` | `drama:create` | 新建短剧;优先接收 `dramaTypes[]` / `categoryIds[]` |
| PUT | `/admin/dramas/{id}` | `drama:update` | 编辑短剧 | | PUT | `/admin/dramas/{id}` | `drama:update` | 编辑短剧;优先接收 `dramaTypes[]` / `categoryIds[]` |
| DELETE | `/admin/dramas/{id}` | `drama:delete` | 删除短剧 | | DELETE | `/admin/dramas/{id}` | `drama:delete` | 删除短剧 |
| POST | `/admin/dramas/{id}/submit` | `drama:review` / `drama:update` | 提交审核 | | POST | `/admin/dramas/{id}/submit` | `drama:review` / `drama:update` | 提交审核 |
| POST | `/admin/dramas/{id}/approve` | `drama:review` | 审核通过 | | 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` | 下线 | | POST | `/admin/dramas/{id}/offline` | `drama:review` | 下线 |
| GET | `/admin/dramas/{id}/review-records` | `drama:list` | 审核记录 | | GET | `/admin/dramas/{id}/review-records` | `drama:list` | 审核记录 |
多选字段说明:
- 保存优先使用 `dramaTypes[]` / `categoryIds[]`,去重并保持提交顺序;
`drama_type` / `category_id` 写第一个值,供旧客户端与旧接口兼容。
- 仅传 `dramaType` / `categoryId` 时保持原单值行为。
- 列表响应返回 `dramaTypes[]`、`categoryIds[]` 与按选中顺序映射的 `categoryNames[]`;
分类名缺失时回退 `genre`。
- 后台与 App 过滤均使用 `(',' || column || ',') LIKE '%,value,%'` 整词匹配。
**剧集** **剧集**
| 方法 | 路径 | 权限点 | 说明 | | 方法 | 路径 | 权限点 | 说明 |
@@ -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<String> dramaTypes`、`List<Long> categoryIds`,列表优先。
- 保留 `dramaType/categoryId` 单值兜底。
- `DramaPageReqVO`:增加 `List<Long> 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`。
+56
View File
@@ -23947,3 +23947,59 @@ dev 构建保留既有 `element-plus` circular chunk 警告,不影响成功退
门禁已独立完成。 门禁已独立完成。
- 本任务无数据库迁移;未修改 specs、Flutter 客户端、K91 评论列表与观看记录接口; - 本任务无数据库迁移;未修改 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=<jdk21> 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=<jdk21> 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 项测试全过,含新增多标签测试。
@@ -71,7 +71,7 @@ class DramaServiceFeedTest {
.contains("view_count") .contains("view_count")
.contains("id"); .contains("id");
assertThat(query.getParamNameValuePairs().values()) assertThat(query.getParamNameValuePairs().values())
.contains("published", "ANIME", 30, 1200L, 9L); .contains("published", "%,ANIME,%", 30, 1200L, 9L);
} }
@Test @Test
@@ -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<Drama> 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<Drama> 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<Drama> 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<LambdaQueryWrapper<Drama>> 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<QueryWrapper<Drama>> 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<Drama> page = new Page<>(1, 10);
page.setRecords(List.of());
given(dramaMapper.selectPage(any(Page.class), any())).willReturn(page);
ArgumentCaptor<LambdaQueryWrapper<Drama>> 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;
}
}
@@ -22,6 +22,8 @@ public class Drama {
private String title; private String title;
private String subtitle; private String subtitle;
private Long categoryId; private Long categoryId;
/** 分类多选,逗号分隔的 category_id,category_id 保留第一个 */
private String categoryIds;
private String genre; private String genre;
private String synopsis; private String synopsis;
private String coverUrl; private String coverUrl;
@@ -29,6 +31,8 @@ public class Drama {
private String language; private String language;
/** 内容类型:ANIME=漫剧,LIVE=真人剧 */ /** 内容类型:ANIME=漫剧,LIVE=真人剧 */
private String dramaType; private String dramaType;
/** 内容类型多选,逗号分隔(ANIME/LIVE),drama_type 保留第一个 */
private String dramaTypes;
private Integer totalEpisodes; private Integer totalEpisodes;
private Integer freeEpisodes; private Integer freeEpisodes;
private Integer unlockCoin; private Integer unlockCoin;
@@ -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<String> DRAMA_TYPES = Set.of("ANIME", "LIVE");
private DramaMultiTagSupport() {
}
static void applyDramaTypes(Drama drama, DramaSaveReqVO req) {
List<String> 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<Long> 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<Drama> wrapper,
List<String> 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<Drama> wrapper,
List<Long> 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<Drama> wrapper,
List<String> 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<String> parseDramaTypes(Drama drama) {
List<String> 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<Long> parseCategoryIds(Drama drama) {
List<Long> values = parseLongTokens(drama.getCategoryIds());
if (values.isEmpty() && drama.getCategoryId() != null && drama.getCategoryId() > 0L) {
return List.of(drama.getCategoryId());
}
return values;
}
static List<String> categoryNames(Drama drama, List<Long> categoryIds,
Map<Long, String> names) {
List<String> 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<String> normalizeDramaTypes(List<String> dramaTypes) {
if (dramaTypes == null || dramaTypes.isEmpty()) {
return List.of();
}
return dramaTypes.stream()
.filter(StringUtils::hasText)
.map(DramaMultiTagSupport::normalizeDramaType)
.distinct()
.toList();
}
static List<Long> normalizeCategoryIds(List<Long> 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<String> parseStringTokens(String value) {
if (!StringUtils.hasText(value)) {
return List.of();
}
List<String> 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<Long> parseLongTokens(String value) {
if (!StringUtils.hasText(value)) {
return List.of();
}
List<Long> 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 + ",%";
}
}
@@ -3,6 +3,7 @@
package com.fruitshorts.content.service; package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.metadata.IPage; 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.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -28,7 +29,6 @@ import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@@ -37,8 +37,6 @@ import java.util.Set;
public class DramaService { public class DramaService {
private static final DateTimeFormatter CODE_DATE = DateTimeFormatter.ofPattern("yyyyMMdd"); private static final DateTimeFormatter CODE_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
private static final String DEFAULT_DRAMA_TYPE = "LIVE";
private static final Set<String> DRAMA_TYPES = Set.of("ANIME", "LIVE");
private final DramaMapper dramaMapper; private final DramaMapper dramaMapper;
private final DramaEpisodeMapper dramaEpisodeMapper; private final DramaEpisodeMapper dramaEpisodeMapper;
@@ -46,16 +44,23 @@ public class DramaService {
private final DramaCategoryService dramaCategoryService; private final DramaCategoryService dramaCategoryService;
public PageResult<DramaRespVO> page(DramaPageReqVO req) { public PageResult<DramaRespVO> page(DramaPageReqVO req) {
List<String> dramaTypes = normalizeDramaTypes(req.getDramaTypes()); List<String> dramaTypes = DramaMultiTagSupport.normalizeDramaTypes(req.getDramaTypes());
List<Long> categoryIds = DramaMultiTagSupport.normalizeCategoryIds(req.getCategoryIds());
Page<Drama> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize()); Page<Drama> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
IPage<Drama> result = dramaMapper.selectPage(page, Wrappers.<Drama>lambdaQuery() LambdaQueryWrapper<Drama> wrapper = Wrappers.<Drama>lambdaQuery()
.like(StringUtils.hasText(req.getKeyword()), Drama::getTitle, req.getKeyword()) .like(StringUtils.hasText(req.getKeyword()), Drama::getTitle, req.getKeyword())
.eq(StringUtils.hasText(req.getStatus()), Drama::getStatus, req.getStatus()) .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);
.eq(req.getIsHot() != null, Drama::getIsHot, Boolean.TRUE.equals(req.getIsHot()) ? 1 : 0) if (!dramaTypes.isEmpty()) {
.in(!dramaTypes.isEmpty(), Drama::getDramaType, dramaTypes) DramaMultiTagSupport.applyDramaTypeTokens(wrapper, dramaTypes);
.orderByDesc(Drama::getSort) }
.orderByDesc(Drama::getId)); 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<Drama> result = dramaMapper.selectPage(page, wrapper);
return new PageResult<>(result.getTotal(), toRespList(result.getRecords())); return new PageResult<>(result.getTotal(), toRespList(result.getRecords()));
} }
@@ -210,11 +215,13 @@ public class DramaService {
/** 已上线短剧,按运营排序 + 播放量 */ /** 已上线短剧,按运营排序 + 播放量 */
public List<Drama> listPublished(Long categoryId, long limit) { public List<Drama> listPublished(Long categoryId, long limit) {
Page<Drama> page = new Page<>(1, Math.max(1, limit)); Page<Drama> page = new Page<>(1, Math.max(1, limit));
return dramaMapper.selectPage(page, Wrappers.<Drama>lambdaQuery() LambdaQueryWrapper<Drama> wrapper = Wrappers.<Drama>lambdaQuery()
.eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode()) .eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode());
.eq(categoryId != null && categoryId > 0, Drama::getCategoryId, categoryId) if (categoryId != null && categoryId > 0) {
.orderByDesc(Drama::getSort) DramaMultiTagSupport.applyCategoryIdTokens(wrapper, List.of(categoryId));
.orderByDesc(Drama::getViewCount)).getRecords(); }
wrapper.orderByDesc(Drama::getSort).orderByDesc(Drama::getViewCount);
return dramaMapper.selectPage(page, wrapper).getRecords();
} }
/** 已上线短剧,按上线时间倒序 */ /** 已上线短剧,按上线时间倒序 */
@@ -231,17 +238,19 @@ public class DramaService {
* cursor 使用上一页最后一条 ID,服务端反查排序键后做 keyset 查询。 * cursor 使用上一页最后一条 ID,服务端反查排序键后做 keyset 查询。
*/ */
public List<Drama> listPublishedFeed(List<String> dramaTypes, Long cursor, int limit) { public List<Drama> listPublishedFeed(List<String> dramaTypes, Long cursor, int limit) {
List<String> normalizedTypes = normalizeDramaTypes(dramaTypes); List<String> normalizedTypes = DramaMultiTagSupport.normalizeDramaTypes(dramaTypes);
QueryWrapper<Drama> query = new QueryWrapper<>(); QueryWrapper<Drama> query = new QueryWrapper<>();
query.eq("status", ContentStatus.PUBLISHED.getCode()); query.eq("status", ContentStatus.PUBLISHED.getCode());
if (!normalizedTypes.isEmpty()) { if (!normalizedTypes.isEmpty()) {
query.in("drama_type", normalizedTypes); DramaMultiTagSupport.applyDramaTypeTokens(query, normalizedTypes);
} }
if (cursor != null) { if (cursor != null) {
Drama marker = dramaMapper.selectById(cursor); Drama marker = dramaMapper.selectById(cursor);
if (marker == null if (marker == null
|| !ContentStatus.PUBLISHED.getCode().equals(marker.getStatus()) || !ContentStatus.PUBLISHED.getCode().equals(marker.getStatus())
|| (!normalizedTypes.isEmpty() && !normalizedTypes.contains(marker.getDramaType()))) { || (!normalizedTypes.isEmpty()
&& DramaMultiTagSupport.parseDramaTypes(marker).stream()
.noneMatch(normalizedTypes::contains))) {
return List.of(); return List.of();
} }
applyFeedCursor(query, marker); applyFeedCursor(query, marker);
@@ -333,18 +342,14 @@ public class DramaService {
private void apply(Drama drama, DramaSaveReqVO req) { private void apply(Drama drama, DramaSaveReqVO req) {
drama.setTitle(req.getTitle()); drama.setTitle(req.getTitle());
drama.setSubtitle(req.getSubtitle() == null ? "" : req.getSubtitle()); 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.setGenre(req.getGenre() == null ? "" : req.getGenre());
drama.setSynopsis(req.getDescription() == null ? "" : req.getDescription()); drama.setSynopsis(req.getDescription() == null ? "" : req.getDescription());
drama.setCoverUrl(req.getCover() == null ? "" : req.getCover()); drama.setCoverUrl(req.getCover() == null ? "" : req.getCover());
drama.setTrailerUrl(req.getTrailerUrl() == null ? "" : req.getTrailerUrl()); drama.setTrailerUrl(req.getTrailerUrl() == null ? "" : req.getTrailerUrl());
drama.setHorizontalCoverUrl(drama.getHorizontalCoverUrl() == null ? "" : drama.getHorizontalCoverUrl()); drama.setHorizontalCoverUrl(drama.getHorizontalCoverUrl() == null ? "" : drama.getHorizontalCoverUrl());
drama.setLanguage(req.getLanguage() == null ? "zh-CN" : req.getLanguage()); drama.setLanguage(req.getLanguage() == null ? "zh-CN" : req.getLanguage());
if (StringUtils.hasText(req.getDramaType())) { DramaMultiTagSupport.applyDramaTypes(drama, req);
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.setFreeEpisodes(req.getFreeEpisodes() == null ? 3 : req.getFreeEpisodes());
drama.setUnlockCoin(req.getUnlockCoin() == null ? 0 : req.getUnlockCoin()); drama.setUnlockCoin(req.getUnlockCoin() == null ? 0 : req.getUnlockCoin());
drama.setIsHot(Boolean.TRUE.equals(req.getIsHot()) ? 1 : 0); drama.setIsHot(Boolean.TRUE.equals(req.getIsHot()) ? 1 : 0);
@@ -363,19 +368,29 @@ public class DramaService {
} }
Map<Long, String> categories = dramaCategoryService.nameMap(); Map<Long, String> categories = dramaCategoryService.nameMap();
return dramas.stream().map(drama -> { return dramas.stream().map(drama -> {
List<Long> categoryIds = DramaMultiTagSupport.parseCategoryIds(drama);
Long primaryCategoryId = categoryIds.isEmpty()
? (drama.getCategoryId() == null ? 0L : drama.getCategoryId())
: categoryIds.get(0);
List<String> categoryNames =
DramaMultiTagSupport.categoryNames(drama, categoryIds, categories);
List<String> dramaTypes = DramaMultiTagSupport.parseDramaTypes(drama);
DramaRespVO vo = new DramaRespVO(); DramaRespVO vo = new DramaRespVO();
vo.setId(drama.getId()); vo.setId(drama.getId());
vo.setCode(drama.getCode()); vo.setCode(drama.getCode());
vo.setTitle(drama.getTitle()); vo.setTitle(drama.getTitle());
vo.setSubtitle(drama.getSubtitle()); vo.setSubtitle(drama.getSubtitle());
vo.setCategoryId(drama.getCategoryId()); vo.setCategoryId(primaryCategoryId);
vo.setCategory(categories.getOrDefault(drama.getCategoryId(), "")); vo.setCategoryIds(categoryIds);
vo.setCategoryNames(categoryNames);
vo.setCategory(categories.getOrDefault(primaryCategoryId, ""));
vo.setGenre(drama.getGenre()); vo.setGenre(drama.getGenre());
vo.setDescription(drama.getSynopsis()); vo.setDescription(drama.getSynopsis());
vo.setCover(drama.getCoverUrl()); vo.setCover(drama.getCoverUrl());
vo.setTrailerUrl(drama.getTrailerUrl()); vo.setTrailerUrl(drama.getTrailerUrl());
vo.setLanguage(drama.getLanguage()); vo.setLanguage(drama.getLanguage());
vo.setDramaType(drama.getDramaType()); vo.setDramaType(dramaTypes.get(0));
vo.setDramaTypes(dramaTypes);
vo.setEpisodes(drama.getTotalEpisodes()); vo.setEpisodes(drama.getTotalEpisodes());
vo.setFreeEpisodes(drama.getFreeEpisodes()); vo.setFreeEpisodes(drama.getFreeEpisodes());
vo.setUnlockCoin(drama.getUnlockCoin()); vo.setUnlockCoin(drama.getUnlockCoin());
@@ -412,25 +427,6 @@ public class DramaService {
} }
} }
private List<String> normalizeDramaTypes(List<String> dramaTypes) {
if (dramaTypes == null || dramaTypes.isEmpty()) {
return List.of();
}
return dramaTypes.stream()
.filter(StringUtils::hasText)
.map(this::normalizeDramaType)
.distinct()
.toList();
}
private String normalizeDramaType(String dramaType) {
String normalized = dramaType == null ? "" : dramaType.trim().toUpperCase(Locale.ROOT);
if (!DRAMA_TYPES.contains(normalized)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "内容类型仅支持 ANIME 或 LIVE");
}
return normalized;
}
private Long nonNegativeOrZero(Long value, String fieldName) { private Long nonNegativeOrZero(Long value, String fieldName) {
return value == null ? 0L : nonNegative(value, fieldName); return value == null ? 0L : nonNegative(value, fieldName);
} }
@@ -16,6 +16,8 @@ public class DramaPageReqVO extends PageParam {
private String keyword; private String keyword;
private String status; private String status;
private Long categoryId; private Long categoryId;
/** 分类多选,空时回退 categoryId */
private List<Long> categoryIds;
/** 是否只看热门 */ /** 是否只看热门 */
private Boolean isHot; private Boolean isHot;
/** 内容类型多选:ANIME/LIVE,空=全部 */ /** 内容类型多选:ANIME/LIVE,空=全部 */
@@ -6,6 +6,7 @@ import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
/** /**
* 短剧列表/详情响应,字段名与后台页面表格列一致。 * 短剧列表/详情响应,字段名与后台页面表格列一致。
@@ -18,6 +19,8 @@ public class DramaRespVO {
private String title; private String title;
private String subtitle; private String subtitle;
private Long categoryId; private Long categoryId;
private List<Long> categoryIds;
private List<String> categoryNames;
private String category; private String category;
private String genre; private String genre;
private String description; private String description;
@@ -26,6 +29,7 @@ public class DramaRespVO {
private String trailerUrl; private String trailerUrl;
private String language; private String language;
private String dramaType; private String dramaType;
private List<String> dramaTypes;
private Integer episodes; private Integer episodes;
private Integer freeEpisodes; private Integer freeEpisodes;
private Integer unlockCoin; private Integer unlockCoin;
@@ -7,6 +7,8 @@ import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.PositiveOrZero; import jakarta.validation.constraints.PositiveOrZero;
import lombok.Data; import lombok.Data;
import java.util.List;
@Data @Data
public class DramaSaveReqVO { public class DramaSaveReqVO {
@@ -15,6 +17,8 @@ public class DramaSaveReqVO {
private String subtitle; private String subtitle;
private Long categoryId; private Long categoryId;
/** 后台多选优先,创作者中心可继续只传 categoryId */
private List<Long> categoryIds;
private String genre; private String genre;
private String description; private String description;
private String cover; private String cover;
@@ -23,6 +27,8 @@ public class DramaSaveReqVO {
private String language; private String language;
@Pattern(regexp = "ANIME|LIVE", message = "内容类型仅支持 ANIME 或 LIVE") @Pattern(regexp = "ANIME|LIVE", message = "内容类型仅支持 ANIME 或 LIVE")
private String dramaType; private String dramaType;
/** 后台多选优先,创作者中心可继续只传 dramaType */
private List<String> dramaTypes;
private Integer freeEpisodes; private Integer freeEpisodes;
private Integer unlockCoin; private Integer unlockCoin;
@PositiveOrZero(message = "收藏数不能小于 0") @PositiveOrZero(message = "收藏数不能小于 0")
+16
View File
@@ -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;
+2
View File
@@ -164,12 +164,14 @@ CREATE TABLE IF NOT EXISTS drama (
title VARCHAR(128) NOT NULL, title VARCHAR(128) NOT NULL,
subtitle VARCHAR(128) NOT NULL DEFAULT '', subtitle VARCHAR(128) NOT NULL DEFAULT '',
category_id BIGINT NOT NULL DEFAULT 0, category_id BIGINT NOT NULL DEFAULT 0,
category_ids VARCHAR(255) NOT NULL DEFAULT '',
genre VARCHAR(64) NOT NULL DEFAULT '', genre VARCHAR(64) NOT NULL DEFAULT '',
synopsis VARCHAR(1000) NOT NULL DEFAULT '', synopsis VARCHAR(1000) NOT NULL DEFAULT '',
cover_url VARCHAR(512) NOT NULL DEFAULT '', cover_url VARCHAR(512) NOT NULL DEFAULT '',
horizontal_cover_url VARCHAR(512) NOT NULL DEFAULT '', horizontal_cover_url VARCHAR(512) NOT NULL DEFAULT '',
language VARCHAR(16) NOT NULL DEFAULT 'zh-CN', language VARCHAR(16) NOT NULL DEFAULT 'zh-CN',
drama_type VARCHAR(16) NOT NULL DEFAULT 'LIVE', drama_type VARCHAR(16) NOT NULL DEFAULT 'LIVE',
drama_types VARCHAR(32) NOT NULL DEFAULT '',
total_episodes INT NOT NULL DEFAULT 0, total_episodes INT NOT NULL DEFAULT 0,
free_episodes INT NOT NULL DEFAULT 3, free_episodes INT NOT NULL DEFAULT 3,
unlock_coin INT NOT NULL DEFAULT 0, unlock_coin INT NOT NULL DEFAULT 0,