merge: 合并 K93 短剧类型多选

This commit is contained in:
Zhou Weidong committed 2026-10-21 17:01:36 +08:00
commit 33c1172da5
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` 热度仍取整部剧。首页、发现、分类、收藏、搜索等列表卡片结构一致。
>
> 卡片新增 `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<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` | 详情 |
| 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,%'` 整词匹配。
**剧集**
| 方法 | 路径 | 权限点 | 说明 |
@@ -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 评论列表与观看记录接口;
未安装或操作真机,等待用户验收。
## 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("id");
assertThat(query.getParamNameValuePairs().values())
.contains("published", "ANIME", 30, 1200L, 9L);
.contains("published", "%,ANIME,%", 30, 1200L, 9L);
}
@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 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;
@@ -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;
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<String> 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<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());
IPage<Drama> result = dramaMapper.selectPage(page, Wrappers.<Drama>lambdaQuery()
LambdaQueryWrapper<Drama> wrapper = Wrappers.<Drama>lambdaQuery()
.like(StringUtils.hasText(req.getKeyword()), Drama::getTitle, req.getKeyword())
.eq(StringUtils.hasText(req.getStatus()), Drama::getStatus, req.getStatus())
.eq(req.getCategoryId() != null, Drama::getCategoryId, req.getCategoryId())
.eq(req.getIsHot() != null, Drama::getIsHot, Boolean.TRUE.equals(req.getIsHot()) ? 1 : 0)
.in(!dramaTypes.isEmpty(), Drama::getDramaType, dramaTypes)
.orderByDesc(Drama::getSort)
.orderByDesc(Drama::getId));
.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<Drama> result = dramaMapper.selectPage(page, wrapper);
return new PageResult<>(result.getTotal(), toRespList(result.getRecords()));
}
@@ -210,11 +215,13 @@ public class DramaService {
/** 已上线短剧,按运营排序 + 播放量 */
public List<Drama> listPublished(Long categoryId, long limit) {
Page<Drama> page = new Page<>(1, Math.max(1, limit));
return dramaMapper.selectPage(page, Wrappers.<Drama>lambdaQuery()
.eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode())
.eq(categoryId != null && categoryId > 0, Drama::getCategoryId, categoryId)
.orderByDesc(Drama::getSort)
.orderByDesc(Drama::getViewCount)).getRecords();
LambdaQueryWrapper<Drama> wrapper = Wrappers.<Drama>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<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<>();
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<Long, String> categories = dramaCategoryService.nameMap();
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();
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<String> normalizeDramaTypes(List<String> dramaTypes) {
if (dramaTypes == null || dramaTypes.isEmpty()) {
return List.of();
}
return dramaTypes.stream()
.filter(StringUtils::hasText)
.map(this::normalizeDramaType)
.distinct()
.toList();
}
private String normalizeDramaType(String dramaType) {
String normalized = dramaType == null ? "" : dramaType.trim().toUpperCase(Locale.ROOT);
if (!DRAMA_TYPES.contains(normalized)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "内容类型仅支持 ANIME 或 LIVE");
}
return normalized;
}
private Long nonNegativeOrZero(Long value, String fieldName) {
return value == null ? 0L : nonNegative(value, fieldName);
}
@@ -16,6 +16,8 @@ public class DramaPageReqVO extends PageParam {
private String keyword;
private String status;
private Long categoryId;
/** 分类多选,空时回退 categoryId */
private List<Long> categoryIds;
/** 是否只看热门 */
private Boolean isHot;
/** 内容类型多选:ANIME/LIVE,空=全部 */
@@ -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<Long> categoryIds;
private List<String> 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<String> dramaTypes;
private Integer episodes;
private Integer freeEpisodes;
private Integer unlockCoin;
@@ -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<Long> 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<String> dramaTypes;
private Integer freeEpisodes;
private Integer unlockCoin;
@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,
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,