merge: 合并 K88 内容类型与分类 feed
This commit is contained in:
commit
fd402d9efe
19 files changed
+557
-4
No files matched your search
@@ -167,6 +167,7 @@ app.register.max-per-device-per-day
|
||||
| --- | --- | --- |
|
||||
| GET | `/app/home/feed` | 首页:轮播 + 分区,卡片带 `preview` 可播预览集与 `language` 语种 |
|
||||
| GET | `/app/discover/feed` | 发现页分区 |
|
||||
| GET | `/app/feed?types=ANIME,LIVE&cursor=&size=20` | 推荐流;按内容类型多选过滤,游标翻页 |
|
||||
| GET | `/app/categories` | 分类列表 |
|
||||
| GET | `/app/dramas/{id}` | 详情(含剧集列表,免费集带**已签名**播放地址) |
|
||||
| GET | `/app/search?keyword=&pageIndex=&pageSize=` | 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表 |
|
||||
@@ -177,6 +178,15 @@ app.register.max-per-device-per-day
|
||||
> 卡片同时下发 `likeCount` / `favoriteCount` / `commentCount`(均为 JSON 字符串),
|
||||
> 点赞、收藏取 `drama` 冗余计数字段,评论数由 `content_comment` 按 `drama_id` 批量统计,
|
||||
> 排除逻辑删除与 `status = 'deleted'` 的记录。首页、发现、分类、收藏、搜索等列表卡片结构一致。
|
||||
>
|
||||
> 卡片新增 `dramaType`:`ANIME` 表示漫剧,`LIVE` 表示真人剧。
|
||||
|
||||
#### 推荐类型流(`GET /app/feed`)
|
||||
|
||||
- `types` 可选,逗号分隔,支持 `ANIME`、`LIVE` 或多选 `ANIME,LIVE`;不传或空值返回全部已上线内容。
|
||||
- `cursor` 可选,上一页返回的 `nextCursor`;`size` 默认 20,最大 50。
|
||||
- 排序:`recommend_weight DESC NULLS LAST, view_count DESC, id DESC`。
|
||||
- 返回 `Result<CursorResult<AppDramaCardVO>>`,卡片字段与首页一致,包含互动计数、`preview`、`language`、`dramaType`。
|
||||
|
||||
#### 短剧详情响应(`GET /app/dramas/{id}`)
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# K88 内容类型、互动数据编辑与分类 Feed 实施计划
|
||||
|
||||
## 目标
|
||||
|
||||
- 后台短剧管理支持内容类型和互动数据编辑:收藏、点赞、热度可编辑,评论数实时统计只读。
|
||||
- 新增 `drama.drama_type`,取值为 `ANIME` / `LIVE`,默认 `LIVE`。
|
||||
- App 新增 `GET /app/feed`,支持 `ANIME,LIVE` 多选类型,不传类型时返回推荐全部。
|
||||
|
||||
## FruitServer A
|
||||
|
||||
1. 数据库
|
||||
- 新增 `sql/migrate-k88-drama-type.sql`,只提交迁移文件,不执行数据库变更。
|
||||
- `sql/schema.sql` 的 drama 建表语句补 `drama_type`,默认 `LIVE`。
|
||||
|
||||
2. 后台管理
|
||||
- `Drama`、`DramaSaveReqVO`、`DramaRespVO` 增加 `dramaType`。
|
||||
- `DramaSaveReqVO` 增加可空 `favoriteCount`、`likeCount`、`heat`;服务层校验非负,`heat` 映射 `view_count`。
|
||||
- create 默认内容类型 LIVE、互动数 0;update 仅覆盖请求非 null 的互动字段。
|
||||
- 后台分页增加 `dramaTypes` 多选过滤,并保持 CommentService 批量回填评论数。
|
||||
|
||||
3. App Feed
|
||||
- `AppDramaCardVO` 增加 `dramaType`,现有 home/discover/detail 仅新增字段。
|
||||
- `GET /app/feed` 支持 `types`、`cursor`、`size`;size 默认 20、最大 50。
|
||||
- 排序为 `recommend_weight DESC NULLS LAST, view_count DESC, id DESC`。
|
||||
- cursor 使用最后一条 drama id,服务端反查其 recommendWeight/viewCount/id,再按三元组做 keyset 查询,避免 offset 翻页重漏。
|
||||
- 卡片复用现有 `toCards`,确保 preview、互动数、评论数与首页一致。
|
||||
- 更新 `docs/api-phase1.md` 的 dramaType 与 `/app/feed` 契约。
|
||||
|
||||
4. 测试
|
||||
- `/app/feed`:全部、ANIME、ANIME+LIVE、游标翻页不重不漏。
|
||||
- 后台 update:部分字段更新、heat 写 viewCount、负数与非法 dramaType 报错。
|
||||
|
||||
## ReelShortWeb B
|
||||
|
||||
1. 类型定义补齐 dramaType、favoriteCount、likeCount、commentCount、heat。
|
||||
2. 短剧列表增加内容类型筛选和标签列,播放量列改为热度,增加收藏/点赞/评论列。
|
||||
3. 编辑弹窗增加必填内容类型、互动数据区块;评论数只读。
|
||||
4. 不改审核、发布、删除逻辑。
|
||||
|
||||
## 风险与处理
|
||||
|
||||
- 推荐排序包含可空 recommend_weight,SQL 与 Java 条件必须显式处理 NULLS LAST。
|
||||
- 三字段排序不能只按 id 做 cursor,需以 cursor id 反查上一页最后一条的排序值。
|
||||
- 存量数据不主动修改,迁移默认 LIVE;数据库脚本由 PM 执行。
|
||||
- 全部改动只使用显式文件暂存,不执行 `git add -A`,不改 specs 与 Flutter 客户端。
|
||||
|
||||
## 门禁与 Git
|
||||
|
||||
- FruitServer:`mvn test` 全过;提交 `feat(content): 新增内容类型与首页分类 feed`;`--no-ff` 合回 develop 并 push。
|
||||
- ReelShortWeb:`pnpm ts:check`、`pnpm build:dev` 通过;提交 `feat(admin): 短剧列表展示并编辑互动数据`;`--no-ff` 合回 develop 并 push。
|
||||
- 作者统一 `Zhou Weidong <502333560@qq.com>`;各自 PROGRESS 追加 K88 留痕。
|
||||
+3
@@ -52,6 +52,7 @@ class DramaControllerContractTest {
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.list[0].likeCount").value("3200"))
|
||||
.andExpect(jsonPath("$.data.list[0].favoriteCount").value("1800"))
|
||||
.andExpect(jsonPath("$.data.list[0].dramaType").value("ANIME"))
|
||||
.andExpect(jsonPath("$.data.list[0].commentCount").value("11"))
|
||||
.andExpect(jsonPath("$.data.list[1].likeCount").value("120"))
|
||||
.andExpect(jsonPath("$.data.list[1].favoriteCount").value("80"))
|
||||
@@ -69,6 +70,7 @@ class DramaControllerContractTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.data.likeCount").value("3200"))
|
||||
.andExpect(jsonPath("$.data.favoriteCount").value("1800"))
|
||||
.andExpect(jsonPath("$.data.dramaType").value("ANIME"))
|
||||
.andExpect(jsonPath("$.data.commentCount").value("11"));
|
||||
}
|
||||
|
||||
@@ -78,6 +80,7 @@ class DramaControllerContractTest {
|
||||
drama.setTitle("短剧" + id);
|
||||
drama.setLikeCount(likeCount);
|
||||
drama.setFavoriteCount(favoriteCount);
|
||||
drama.setDramaType("ANIME");
|
||||
return drama;
|
||||
}
|
||||
}
|
||||
+12
@@ -5,12 +5,15 @@ package com.fruitshorts.app.controller;
|
||||
import com.fruitshorts.app.service.AppCatalogService;
|
||||
import com.fruitshorts.app.vo.AppCategoryVO;
|
||||
import com.fruitshorts.app.vo.AppFeedVO;
|
||||
import com.fruitshorts.app.vo.AppDramaCardVO;
|
||||
import com.fruitshorts.common.pojo.CursorResult;
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
@@ -38,6 +41,15 @@ public class AppCatalogController {
|
||||
return Result.ok(appCatalogService.discoverFeed());
|
||||
}
|
||||
|
||||
@Operation(summary = "推荐流:支持内容类型多选与游标翻页")
|
||||
@GetMapping("/feed")
|
||||
public Result<CursorResult<AppDramaCardVO>> feed(
|
||||
@RequestParam(required = false) String types,
|
||||
@RequestParam(required = false) Long cursor,
|
||||
@RequestParam(required = false) Integer size) {
|
||||
return Result.ok(appCatalogService.feed(types, cursor, size));
|
||||
}
|
||||
|
||||
@Operation(summary = "分类列表")
|
||||
@GetMapping("/categories")
|
||||
public Result<List<AppCategoryVO>> categories() {
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.fruitshorts.app.vo.AppDramaDetailVO;
|
||||
import com.fruitshorts.app.vo.AppEpisodeVO;
|
||||
import com.fruitshorts.app.vo.AppFeedVO;
|
||||
import com.fruitshorts.app.vo.AppSectionVO;
|
||||
import com.fruitshorts.common.pojo.CursorResult;
|
||||
import com.fruitshorts.content.dal.entity.ContentBanner;
|
||||
import com.fruitshorts.content.dal.entity.Drama;
|
||||
import com.fruitshorts.content.dal.entity.DramaEpisode;
|
||||
@@ -27,6 +28,7 @@ import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* C 端目录聚合:把内容域的只读视图组装成客户端一次请求就能渲染的结构。
|
||||
@@ -39,6 +41,8 @@ public class AppCatalogService {
|
||||
private static final int HOME_SECTION_SIZE = 6;
|
||||
private static final int DISCOVER_SECTION_SIZE = 10;
|
||||
private static final int MAX_CATEGORY_SECTIONS = 4;
|
||||
private static final int DEFAULT_FEED_SIZE = 20;
|
||||
private static final int MAX_FEED_SIZE = 50;
|
||||
|
||||
private final DramaService dramaService;
|
||||
private final EpisodeService episodeService;
|
||||
@@ -70,6 +74,24 @@ public class AppCatalogService {
|
||||
.toList();
|
||||
}
|
||||
|
||||
public CursorResult<AppDramaCardVO> feed(String types, Long cursor, Integer size) {
|
||||
int pageSize = size == null ? DEFAULT_FEED_SIZE : Math.max(1, Math.min(size, MAX_FEED_SIZE));
|
||||
List<String> dramaTypes = types == null
|
||||
? List.of()
|
||||
: Arrays.stream(types.split(","))
|
||||
.map(String::trim)
|
||||
.filter(type -> !type.isEmpty())
|
||||
.toList();
|
||||
List<Drama> rows = dramaService.listPublishedFeed(dramaTypes, cursor, pageSize + 1);
|
||||
if (rows.isEmpty()) {
|
||||
return CursorResult.empty();
|
||||
}
|
||||
boolean hasMore = rows.size() > pageSize;
|
||||
List<Drama> pageRows = hasMore ? rows.subList(0, pageSize) : rows;
|
||||
Long nextCursor = hasMore ? pageRows.get(pageRows.size() - 1).getId() : null;
|
||||
return new CursorResult<>(cards(pageRows, categoryNameMap()), nextCursor, hasMore);
|
||||
}
|
||||
|
||||
/**
|
||||
* 供收藏、观看记录等列表复用卡片结构:字段与首页卡片完全一致,
|
||||
* 客户端可以用同一套渲染逻辑,不用为每个列表各写一份。
|
||||
@@ -169,6 +191,7 @@ public class AppCatalogService {
|
||||
card.setFavoriteCount(drama.getFavoriteCount());
|
||||
card.setCommentCount(commentCounts.getOrDefault(drama.getId(), 0L));
|
||||
card.setLanguage(drama.getLanguage());
|
||||
card.setDramaType(drama.getDramaType());
|
||||
card.setPreview(preview == null ? null : toEpisode(preview));
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ public class AppDramaCardVO {
|
||||
/** 语种,供客户端按语言筛选(如 zh-CN / id)。 */
|
||||
@Schema(description = "短剧语种,如 zh-CN / id")
|
||||
private String language;
|
||||
/** 内容类型:ANIME=漫剧,LIVE=真人剧 */
|
||||
private String dramaType;
|
||||
/**
|
||||
* 首个可播免费集。首页竖屏播放需要在列表阶段就能起播,
|
||||
* 带上这一集可以省掉「点开→请求详情→再请求播放地址」的往返。
|
||||
|
||||
+23
@@ -9,6 +9,7 @@ import com.fruitshorts.app.vo.AppDramaCardVO;
|
||||
import com.fruitshorts.app.vo.AppDramaDetailVO;
|
||||
import com.fruitshorts.app.vo.AppFeedVO;
|
||||
import com.fruitshorts.app.vo.AppSectionVO;
|
||||
import com.fruitshorts.common.pojo.CursorResult;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.playback.service.PlaybackService;
|
||||
@@ -86,6 +87,28 @@ class AppCatalogControllerContractTest {
|
||||
.andExpect(jsonPath("$.data.sections").isArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_return_typed_cursor_feed() throws Exception {
|
||||
AppDramaCardVO card = new AppDramaCardVO();
|
||||
card.setId(12L);
|
||||
card.setTitle("漫剧卡片");
|
||||
card.setDramaType("ANIME");
|
||||
given(catalogService.feed("ANIME", null, 20))
|
||||
.willReturn(new CursorResult<>(List.of(card), 12L, true));
|
||||
|
||||
catalogMvc.perform(get("/app/feed")
|
||||
.param("types", "ANIME")
|
||||
.param("size", "20"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.list[0].id").value("12"))
|
||||
.andExpect(jsonPath("$.data.list[0].dramaType").value("ANIME"))
|
||||
.andExpect(jsonPath("$.data.nextCursor").value("12"))
|
||||
.andExpect(jsonPath("$.data.hasMore").value(true));
|
||||
|
||||
verify(catalogService).feed("ANIME", null, 20);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_return_category_list_when_categories_are_requested() throws Exception {
|
||||
AppCategoryVO category = new AppCategoryVO(3L, "都市");
|
||||
|
||||
+29
-1
@@ -3,6 +3,8 @@
|
||||
package com.fruitshorts.app.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fruitshorts.app.service.AppCatalogService;
|
||||
import com.fruitshorts.common.pojo.CursorResult;
|
||||
import com.fruitshorts.framework.log.AccessDeniedRecorder;
|
||||
import com.fruitshorts.framework.security.JwtAuthenticationFilter;
|
||||
import com.fruitshorts.framework.security.JwtTokenProvider;
|
||||
@@ -28,13 +30,15 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.context.WebApplicationContext;
|
||||
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/** 评论总数接口的安全链契约:匿名只读请求必须直接进入控制器。 */
|
||||
/** 客户端公开只读接口的安全链契约:匿名请求必须直接进入控制器。 */
|
||||
@ExtendWith(SpringExtension.class)
|
||||
@WebAppConfiguration
|
||||
@ContextConfiguration(classes = AppCommentCountAnonymousSecurityTest.TestConfig.class)
|
||||
@@ -45,10 +49,14 @@ class AppCommentCountAnonymousSecurityTest {
|
||||
|
||||
@Autowired
|
||||
private CommentService commentService;
|
||||
@Autowired
|
||||
private AppCatalogService catalogService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
given(commentService.countByDrama(7L)).willReturn(9L);
|
||||
given(catalogService.feed("ANIME", null, 20))
|
||||
.willReturn(new CursorResult<>(List.of(), null, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -59,6 +67,16 @@ class AppCommentCountAnonymousSecurityTest {
|
||||
.andExpect(jsonPath("$.data").value(9));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_allow_anonymous_typed_feed_request_through_security_chain() throws Exception {
|
||||
mockMvc.perform(get("/app/feed")
|
||||
.param("types", "ANIME")
|
||||
.param("size", "20"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.code").value(0))
|
||||
.andExpect(jsonPath("$.data.list").isArray());
|
||||
}
|
||||
|
||||
@Configuration
|
||||
@EnableWebMvc
|
||||
@EnableWebSecurity
|
||||
@@ -75,6 +93,16 @@ class AppCommentCountAnonymousSecurityTest {
|
||||
return new AppCommentController(commentService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
AppCatalogService appCatalogService() {
|
||||
return mock(AppCatalogService.class);
|
||||
}
|
||||
|
||||
@Bean
|
||||
AppCatalogController appCatalogController(AppCatalogService appCatalogService) {
|
||||
return new AppCatalogController(appCatalogService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
JwtAuthenticationFilter jwtAuthenticationFilter() {
|
||||
return new JwtAuthenticationFilter(
|
||||
|
||||
+61
@@ -3,6 +3,7 @@ package com.fruitshorts.app.service;
|
||||
|
||||
import com.fruitshorts.app.vo.AppDramaCardVO;
|
||||
import com.fruitshorts.app.vo.AppDramaDetailVO;
|
||||
import com.fruitshorts.common.pojo.CursorResult;
|
||||
import com.fruitshorts.content.dal.entity.Drama;
|
||||
import com.fruitshorts.content.service.BannerService;
|
||||
import com.fruitshorts.content.service.DramaCategoryService;
|
||||
@@ -99,4 +100,64 @@ class AppCatalogServiceTest {
|
||||
verify(commentService).countByDramas(List.of(1L, 2L));
|
||||
verify(commentService, never()).countByDrama(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_filter_feed_by_drama_type_and_expose_it_on_card() {
|
||||
Drama anime = drama(1L, "ANIME");
|
||||
given(dramaService.listPublishedFeed(List.of("ANIME"), null, 21))
|
||||
.willReturn(List.of(anime));
|
||||
given(dramaCategoryService.listBasic()).willReturn(List.of());
|
||||
given(episodeService.firstPlayableByDramas(List.of(1L))).willReturn(Map.of());
|
||||
given(commentService.countByDramas(List.of(1L))).willReturn(Map.of(1L, 3L));
|
||||
|
||||
CursorResult<AppDramaCardVO> result = catalogService.feed("ANIME", null, null);
|
||||
|
||||
assertThat(result.getList()).hasSize(1);
|
||||
assertThat(result.getList().get(0).getDramaType()).isEqualTo("ANIME");
|
||||
assertThat(result.getList().get(0).getCommentCount()).isEqualTo(3L);
|
||||
assertThat(result.isHasMore()).isFalse();
|
||||
verify(dramaService).listPublishedFeed(List.of("ANIME"), null, 21);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_parse_multiple_feed_types() {
|
||||
given(dramaService.listPublishedFeed(List.of("ANIME", "LIVE"), null, 11))
|
||||
.willReturn(List.of());
|
||||
|
||||
CursorResult<AppDramaCardVO> result = catalogService.feed("ANIME,LIVE", null, 10);
|
||||
|
||||
assertThat(result.getList()).isEmpty();
|
||||
verify(dramaService).listPublishedFeed(List.of("ANIME", "LIVE"), null, 11);
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_page_feed_with_cursor_without_duplicates() {
|
||||
Drama first = drama(1L, "ANIME");
|
||||
Drama second = drama(2L, "LIVE");
|
||||
given(dramaService.listPublishedFeed(List.of(), null, 2))
|
||||
.willReturn(List.of(first, second));
|
||||
given(dramaService.listPublishedFeed(List.of(), 1L, 2))
|
||||
.willReturn(List.of(second));
|
||||
given(dramaCategoryService.listBasic()).willReturn(List.of());
|
||||
given(episodeService.firstPlayableByDramas(any())).willReturn(Map.of());
|
||||
given(commentService.countByDramas(any())).willReturn(Map.of());
|
||||
|
||||
CursorResult<AppDramaCardVO> firstPage = catalogService.feed(null, null, 1);
|
||||
CursorResult<AppDramaCardVO> secondPage = catalogService.feed(null, firstPage.getNextCursor(), 1);
|
||||
|
||||
assertThat(firstPage.getList()).extracting(AppDramaCardVO::getId).containsExactly(1L);
|
||||
assertThat(firstPage.getNextCursor()).isEqualTo(1L);
|
||||
assertThat(firstPage.isHasMore()).isTrue();
|
||||
assertThat(secondPage.getList()).extracting(AppDramaCardVO::getId).containsExactly(2L);
|
||||
assertThat(secondPage.getNextCursor()).isNull();
|
||||
assertThat(secondPage.isHasMore()).isFalse();
|
||||
}
|
||||
|
||||
private Drama drama(Long id, String dramaType) {
|
||||
Drama drama = new Drama();
|
||||
drama.setId(id);
|
||||
drama.setTitle("短剧" + id);
|
||||
drama.setDramaType(dramaType);
|
||||
return drama;
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.app.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
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 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.List;
|
||||
|
||||
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.verify;
|
||||
|
||||
/** K88 App 推荐流的类型过滤与三字段 keyset 游标。 */
|
||||
class DramaServiceFeedTest {
|
||||
|
||||
private DramaMapper dramaMapper;
|
||||
private DramaService dramaService;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new MybatisConfiguration(), ""), Drama.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
dramaMapper = mock(DramaMapper.class);
|
||||
dramaService = new DramaService(
|
||||
dramaMapper,
|
||||
mock(DramaEpisodeMapper.class),
|
||||
mock(DramaReviewRecordMapper.class),
|
||||
mock(DramaCategoryService.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void should_filter_types_and_apply_recommendation_cursor() {
|
||||
Drama marker = new Drama();
|
||||
marker.setId(9L);
|
||||
marker.setStatus("published");
|
||||
marker.setDramaType("ANIME");
|
||||
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);
|
||||
|
||||
verify(dramaMapper).selectList(captor.capture());
|
||||
QueryWrapper<Drama> query = captor.getValue();
|
||||
assertThat(query.getSqlSegment())
|
||||
.contains("status")
|
||||
.contains("drama_type")
|
||||
.contains("recommend_weight")
|
||||
.contains("view_count")
|
||||
.contains("id");
|
||||
assertThat(query.getParamNameValuePairs().values())
|
||||
.contains("published", "ANIME", 30, 1200L, 9L);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void should_handle_null_recommend_weight_in_cursor() {
|
||||
Drama marker = new Drama();
|
||||
marker.setId(9L);
|
||||
marker.setStatus("published");
|
||||
marker.setDramaType("LIVE");
|
||||
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("LIVE"), 9L, 5);
|
||||
|
||||
verify(dramaMapper).selectList(captor.capture());
|
||||
assertThat(captor.getValue().getSqlSegment())
|
||||
.contains("recommend_weight IS NULL")
|
||||
.contains("view_count")
|
||||
.contains("id");
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.app.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
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.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 static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/** K88 后台短剧内容类型与互动数据编辑规则。 */
|
||||
class DramaServiceK88Test {
|
||||
|
||||
private DramaMapper dramaMapper;
|
||||
private DramaService dramaService;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
TableInfoHelper.initTableInfo(
|
||||
new MapperBuilderAssistant(new MybatisConfiguration(), ""), Drama.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
dramaMapper = mock(DramaMapper.class);
|
||||
dramaService = new DramaService(
|
||||
dramaMapper,
|
||||
mock(DramaEpisodeMapper.class),
|
||||
mock(DramaReviewRecordMapper.class),
|
||||
mock(DramaCategoryService.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_update_only_requested_counts_and_preserve_drama_type() {
|
||||
Drama existing = drama();
|
||||
given(dramaMapper.selectById(7L)).willReturn(existing);
|
||||
DramaSaveReqVO req = new DramaSaveReqVO();
|
||||
req.setTitle("更新后的标题");
|
||||
req.setFavoriteCount(99L);
|
||||
req.setHeat(500L);
|
||||
|
||||
dramaService.update(7L, req);
|
||||
|
||||
ArgumentCaptor<Drama> captor = ArgumentCaptor.forClass(Drama.class);
|
||||
verify(dramaMapper).updateById(captor.capture());
|
||||
Drama updated = captor.getValue();
|
||||
assertThat(updated.getFavoriteCount()).isEqualTo(99L);
|
||||
assertThat(updated.getLikeCount()).isEqualTo(20L);
|
||||
assertThat(updated.getViewCount()).isEqualTo(500L);
|
||||
assertThat(updated.getDramaType()).isEqualTo("ANIME");
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_negative_interaction_count() {
|
||||
given(dramaMapper.selectById(7L)).willReturn(drama());
|
||||
DramaSaveReqVO req = new DramaSaveReqVO();
|
||||
req.setTitle("更新后的标题");
|
||||
req.setFavoriteCount(-1L);
|
||||
|
||||
assertThatThrownBy(() -> dramaService.update(7L, req))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.satisfies(error -> assertThat(((ServiceException) error).getCode())
|
||||
.isEqualTo(ErrorCode.BAD_REQUEST.getCode()));
|
||||
verify(dramaMapper, never()).updateById(any(Drama.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_reject_invalid_drama_type() {
|
||||
given(dramaMapper.selectById(7L)).willReturn(drama());
|
||||
DramaSaveReqVO req = new DramaSaveReqVO();
|
||||
req.setTitle("更新后的标题");
|
||||
req.setDramaType("MOVIE");
|
||||
|
||||
assertThatThrownBy(() -> dramaService.update(7L, req))
|
||||
.isInstanceOf(ServiceException.class)
|
||||
.satisfies(error -> assertThat(((ServiceException) error).getCode())
|
||||
.isEqualTo(ErrorCode.BAD_REQUEST.getCode()));
|
||||
verify(dramaMapper, never()).updateById(any(Drama.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void should_default_new_drama_to_live_and_zero_counts() {
|
||||
DramaSaveReqVO req = new DramaSaveReqVO();
|
||||
req.setTitle("新短剧");
|
||||
|
||||
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.getFavoriteCount()).isZero();
|
||||
assertThat(created.getLikeCount()).isZero();
|
||||
assertThat(created.getViewCount()).isZero();
|
||||
}
|
||||
|
||||
private Drama drama() {
|
||||
Drama drama = new Drama();
|
||||
drama.setId(7L);
|
||||
drama.setTitle("原标题");
|
||||
drama.setStatus("published");
|
||||
drama.setDramaType("ANIME");
|
||||
drama.setFavoriteCount(10L);
|
||||
drama.setLikeCount(20L);
|
||||
drama.setViewCount(30L);
|
||||
return drama;
|
||||
}
|
||||
}
|
||||
+1
@@ -36,6 +36,7 @@ public class SecurityConfig {
|
||||
"/app/auth/**",
|
||||
"/app/home/**",
|
||||
"/app/discover/**",
|
||||
"/app/feed",
|
||||
"/app/categories/**",
|
||||
"/app/banners/**",
|
||||
"/app/dramas/**",
|
||||
|
||||
@@ -27,6 +27,8 @@ public class Drama {
|
||||
private String coverUrl;
|
||||
private String horizontalCoverUrl;
|
||||
private String language;
|
||||
/** 内容类型:ANIME=漫剧,LIVE=真人剧 */
|
||||
private String dramaType;
|
||||
private Integer totalEpisodes;
|
||||
private Integer freeEpisodes;
|
||||
private Integer unlockCoin;
|
||||
|
||||
+98
-3
@@ -3,6 +3,7 @@
|
||||
package com.fruitshorts.content.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
@@ -27,6 +28,7 @@ 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;
|
||||
|
||||
@@ -35,6 +37,8 @@ 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;
|
||||
@@ -42,12 +46,14 @@ public class DramaService {
|
||||
private final DramaCategoryService dramaCategoryService;
|
||||
|
||||
public PageResult<DramaRespVO> page(DramaPageReqVO req) {
|
||||
List<String> dramaTypes = normalizeDramaTypes(req.getDramaTypes());
|
||||
Page<Drama> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
|
||||
IPage<Drama> result = dramaMapper.selectPage(page, 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));
|
||||
return new PageResult<>(result.getTotal(), toRespList(result.getRecords()));
|
||||
@@ -86,10 +92,10 @@ public class DramaService {
|
||||
drama.setStatus(ContentStatus.DRAFT.getCode());
|
||||
drama.setCreatorId(creatorId == null ? 0L : creatorId);
|
||||
drama.setTotalEpisodes(0);
|
||||
drama.setViewCount(0L);
|
||||
drama.setViewCount(nonNegativeOrZero(req.getHeat(), "热度"));
|
||||
drama.setPlayCount(0L);
|
||||
drama.setFavoriteCount(0L);
|
||||
drama.setLikeCount(0L);
|
||||
drama.setFavoriteCount(nonNegativeOrZero(req.getFavoriteCount(), "收藏数"));
|
||||
drama.setLikeCount(nonNegativeOrZero(req.getLikeCount(), "点赞数"));
|
||||
drama.setCreateBy(operatorId == null ? 0L : operatorId);
|
||||
dramaMapper.insert(drama);
|
||||
return drama.getId();
|
||||
@@ -99,6 +105,15 @@ public class DramaService {
|
||||
public void update(Long id, DramaSaveReqVO req) {
|
||||
Drama drama = getExisting(id);
|
||||
apply(drama, req);
|
||||
if (req.getFavoriteCount() != null) {
|
||||
drama.setFavoriteCount(nonNegative(req.getFavoriteCount(), "收藏数"));
|
||||
}
|
||||
if (req.getLikeCount() != null) {
|
||||
drama.setLikeCount(nonNegative(req.getLikeCount(), "点赞数"));
|
||||
}
|
||||
if (req.getHeat() != null) {
|
||||
drama.setViewCount(nonNegative(req.getHeat(), "热度"));
|
||||
}
|
||||
dramaMapper.updateById(drama);
|
||||
}
|
||||
|
||||
@@ -211,6 +226,31 @@ public class DramaService {
|
||||
.orderByDesc(Drama::getId)).getRecords();
|
||||
}
|
||||
|
||||
/**
|
||||
* App 推荐流:按推荐权重、热度、ID 倒序,并支持内容类型多选。
|
||||
* cursor 使用上一页最后一条 ID,服务端反查排序键后做 keyset 查询。
|
||||
*/
|
||||
public List<Drama> listPublishedFeed(List<String> dramaTypes, Long cursor, int limit) {
|
||||
List<String> normalizedTypes = normalizeDramaTypes(dramaTypes);
|
||||
QueryWrapper<Drama> query = new QueryWrapper<>();
|
||||
query.eq("status", ContentStatus.PUBLISHED.getCode());
|
||||
if (!normalizedTypes.isEmpty()) {
|
||||
query.in("drama_type", normalizedTypes);
|
||||
}
|
||||
if (cursor != null) {
|
||||
Drama marker = dramaMapper.selectById(cursor);
|
||||
if (marker == null
|
||||
|| !ContentStatus.PUBLISHED.getCode().equals(marker.getStatus())
|
||||
|| (!normalizedTypes.isEmpty() && !normalizedTypes.contains(marker.getDramaType()))) {
|
||||
return List.of();
|
||||
}
|
||||
applyFeedCursor(query, marker);
|
||||
}
|
||||
int safeLimit = Math.max(1, limit);
|
||||
query.last("ORDER BY recommend_weight DESC NULLS LAST, view_count DESC, id DESC LIMIT " + safeLimit);
|
||||
return dramaMapper.selectList(query);
|
||||
}
|
||||
|
||||
/** 已上线的热门短剧 */
|
||||
public List<Drama> listPublishedHot(long limit) {
|
||||
Page<Drama> page = new Page<>(1, Math.max(1, limit));
|
||||
@@ -300,6 +340,11 @@ public class DramaService {
|
||||
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);
|
||||
}
|
||||
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);
|
||||
@@ -330,6 +375,7 @@ public class DramaService {
|
||||
vo.setCover(drama.getCoverUrl());
|
||||
vo.setTrailerUrl(drama.getTrailerUrl());
|
||||
vo.setLanguage(drama.getLanguage());
|
||||
vo.setDramaType(drama.getDramaType());
|
||||
vo.setEpisodes(drama.getTotalEpisodes());
|
||||
vo.setFreeEpisodes(drama.getFreeEpisodes());
|
||||
vo.setUnlockCoin(drama.getUnlockCoin());
|
||||
@@ -346,4 +392,53 @@ public class DramaService {
|
||||
return vo;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
private void applyFeedCursor(QueryWrapper<Drama> query, Drama marker) {
|
||||
Long viewCount = marker.getViewCount() == null ? 0L : marker.getViewCount();
|
||||
Long id = marker.getId();
|
||||
if (marker.getRecommendWeight() != null) {
|
||||
Integer weight = marker.getRecommendWeight();
|
||||
query.and(wrapper -> wrapper
|
||||
.lt("recommend_weight", weight)
|
||||
.or(or -> or.eq("recommend_weight", weight)
|
||||
.and(inner -> inner
|
||||
.lt("view_count", viewCount)
|
||||
.or(tail -> tail.eq("view_count", viewCount).lt("id", id)))));
|
||||
} else {
|
||||
query.and(wrapper -> wrapper.isNull("recommend_weight")
|
||||
.and(inner -> inner
|
||||
.lt("view_count", viewCount)
|
||||
.or(tail -> tail.eq("view_count", viewCount).lt("id", id))));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private Long nonNegative(Long value, String fieldName) {
|
||||
if (value < 0) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, fieldName + "不能小于 0");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+4
@@ -6,6 +6,8 @@ import com.fruitshorts.common.pojo.PageParam;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class DramaPageReqVO extends PageParam {
|
||||
@@ -16,4 +18,6 @@ public class DramaPageReqVO extends PageParam {
|
||||
private Long categoryId;
|
||||
/** 是否只看热门 */
|
||||
private Boolean isHot;
|
||||
/** 内容类型多选:ANIME/LIVE,空=全部 */
|
||||
private List<String> dramaTypes;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ public class DramaRespVO {
|
||||
/** 整剧预告片地址 */
|
||||
private String trailerUrl;
|
||||
private String language;
|
||||
private String dramaType;
|
||||
private Integer episodes;
|
||||
private Integer freeEpisodes;
|
||||
private Integer unlockCoin;
|
||||
|
||||
+10
@@ -3,6 +3,8 @@
|
||||
package com.fruitshorts.content.vo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@@ -19,8 +21,16 @@ public class DramaSaveReqVO {
|
||||
/** 整剧预告片地址(复用已有的 drama.trailer_url 列) */
|
||||
private String trailerUrl;
|
||||
private String language;
|
||||
@Pattern(regexp = "ANIME|LIVE", message = "内容类型仅支持 ANIME 或 LIVE")
|
||||
private String dramaType;
|
||||
private Integer freeEpisodes;
|
||||
private Integer unlockCoin;
|
||||
@PositiveOrZero(message = "收藏数不能小于 0")
|
||||
private Long favoriteCount;
|
||||
@PositiveOrZero(message = "点赞数不能小于 0")
|
||||
private Long likeCount;
|
||||
@PositiveOrZero(message = "热度不能小于 0")
|
||||
private Long heat;
|
||||
/** 是否热门 */
|
||||
private Boolean isHot;
|
||||
private Integer sort;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE drama ADD COLUMN IF NOT EXISTS drama_type VARCHAR(16) NOT NULL DEFAULT 'LIVE';
|
||||
COMMENT ON COLUMN drama.drama_type IS '内容类型:ANIME=漫剧,LIVE=真人剧';
|
||||
@@ -169,6 +169,7 @@ CREATE TABLE IF NOT EXISTS drama (
|
||||
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',
|
||||
total_episodes INT NOT NULL DEFAULT 0,
|
||||
free_episodes INT NOT NULL DEFAULT 3,
|
||||
unlock_coin INT NOT NULL DEFAULT 0,
|
||||
|
||||
Reference in new issue
Block a user