merge: 合并 K93 短剧类型多选
This commit is contained in:
commit
33c1172da5
13 files changed
+665
-50
No files matched your search
+15
-3
@@ -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`。
|
||||||
@@ -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 项测试全过,含新增多标签测试。
|
||||||
+1
-1
@@ -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
|
||||||
|
|||||||
+192
@@ -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;
|
||||||
|
|||||||
+204
@@ -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 + ",%";
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
-46
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -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;
|
||||||
|
|||||||
+6
@@ -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")
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in new issue
Block a user