diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCatalogController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCatalogController.java index 77fbee7..11f9b6a 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCatalogController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCatalogController.java @@ -6,12 +6,15 @@ 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.app.vo.AppPlaylistDetailVO; import com.fruitshorts.common.pojo.CursorResult; +import com.fruitshorts.common.pojo.PageResult; 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.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -56,4 +59,19 @@ public class AppCatalogController { public Result> categories() { return Result.ok(appCatalogService.categories()); } + + @Operation(summary = "分类频道:按分类 ID 分页查短剧卡片") + @GetMapping("/categories/{id}/dramas") + public Result> categoryDramas( + @PathVariable Long id, + @RequestParam(defaultValue = "1") int pageIndex, + @RequestParam(defaultValue = "20") int pageSize) { + return Result.ok(appCatalogService.categoryDramas(id, pageIndex, pageSize)); + } + + @Operation(summary = "剧单详情:剧单基本信息 + 该剧单下已上线短剧卡片") + @GetMapping("/playlists/{id}") + public Result playlistDetail(@PathVariable Long id) { + return Result.ok(appCatalogService.playlistDetail(id)); + } } diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java index c73a5b3..580747e 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java @@ -8,14 +8,18 @@ import com.fruitshorts.app.vo.AppDramaCardVO; import com.fruitshorts.app.vo.AppDramaDetailVO; import com.fruitshorts.app.vo.AppEpisodeVO; import com.fruitshorts.app.vo.AppFeedVO; +import com.fruitshorts.app.vo.AppPlaylistDetailVO; import com.fruitshorts.app.vo.AppSectionVO; import com.fruitshorts.common.pojo.CursorResult; +import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.content.dal.entity.ContentBanner; import com.fruitshorts.content.dal.entity.Drama; import com.fruitshorts.content.dal.entity.DramaEpisode; import com.fruitshorts.content.enums.BannerPosition; import com.fruitshorts.content.service.BannerService; import com.fruitshorts.content.service.DramaCategoryService; +import com.fruitshorts.content.service.DramaClassificationService; +import com.fruitshorts.content.service.DramaPlaylistCategoryService; import com.fruitshorts.content.service.DramaService; import com.fruitshorts.content.service.EpisodeService; import com.fruitshorts.interaction.service.CommentService; @@ -49,6 +53,8 @@ public class AppCatalogService { private final EpisodeService episodeService; private final BannerService bannerService; private final DramaCategoryService dramaCategoryService; + private final DramaClassificationService dramaClassificationService; + private final DramaPlaylistCategoryService dramaPlaylistCategoryService; private final PlaybackSigner playbackSigner; private final CommentService commentService; private final ThumbnailUrlResolver thumbnailUrlResolver; @@ -69,13 +75,42 @@ public class AppCatalogService { categorySections(DISCOVER_SECTION_SIZE, categoryNameMap())); } + /** 分类频道:运营自定义的精选/新剧/榜单等(drama_classification)。 */ public List categories() { - return dramaCategoryService.listBasic().stream() - .filter(category -> category.getStatus() == null || category.getStatus() == 1) - .map(category -> new AppCategoryVO(category.getId(), category.getName())) + return dramaClassificationService.listEnabled().stream() + .map(c -> new AppCategoryVO(c.getId(), c.getName())) .toList(); } + /** 分类频道:按分类分页查已上线短剧卡片(通过分类关联表过滤)。 */ + public PageResult categoryDramas(Long classificationId, + int pageIndex, int pageSize) { + dramaClassificationService.getEnabledById(classificationId); // 校验存在/启用 + List allIds = dramaClassificationService.listDramaIdsByClassification(classificationId); + if (allIds.isEmpty()) { + return PageResult.empty(); + } + // 只取已上线的,再按关联表原始顺序排 + Map orderMap = new HashMap<>(); + for (int i = 0; i < allIds.size(); i++) orderMap.put(allIds.get(i), i); + List published = dramaService.listPublishedByIds(allIds); + List ordered = published.stream() + .sorted((a, b) -> Integer.compare( + orderMap.getOrDefault(a.getId(), Integer.MAX_VALUE), + orderMap.getOrDefault(b.getId(), Integer.MAX_VALUE))) + .toList(); + int safeIndex = Math.max(1, pageIndex); + int safeSize = Math.max(1, Math.min(pageSize, 50)); + long total = ordered.size(); + int fromIndex = (safeIndex - 1) * safeSize; + if (fromIndex >= ordered.size()) { + return new PageResult<>(total, List.of()); + } + int toIndex = Math.min(fromIndex + safeSize, ordered.size()); + List pageRows = ordered.subList(fromIndex, toIndex); + return new PageResult<>(total, cards(pageRows, categoryNameMap())); + } + public CursorResult feed( String types, Long categoryId, Long cursor, Integer size) { int pageSize = size == null ? DEFAULT_FEED_SIZE : Math.max(1, Math.min(size, MAX_FEED_SIZE)); @@ -137,6 +172,33 @@ public class AppCatalogService { return detail; } + /** C 端剧单详情:剧单基本信息 + 该剧单下所有已上线短剧卡片。 */ + public AppPlaylistDetailVO playlistDetail(Long playlistId) { + var category = dramaPlaylistCategoryService.getEnabledById(playlistId); + List allIds = dramaPlaylistCategoryService.listDramaIdsByCategory(playlistId); + List published = dramaService.listPublishedByIds(allIds); + // 保留关联表原始顺序:按 allIds 位置重新排序 + Map orderMap = new HashMap<>(); + for (int i = 0; i < allIds.size(); i++) { + orderMap.put(allIds.get(i), i); + } + List ordered = published.stream() + .sorted((a, b) -> { + Integer ia = orderMap.getOrDefault(a.getId(), Integer.MAX_VALUE); + Integer ib = orderMap.getOrDefault(b.getId(), Integer.MAX_VALUE); + return Integer.compare(ia, ib); + }) + .toList(); + + AppPlaylistDetailVO vo = new AppPlaylistDetailVO(); + vo.setId(category.getId()); + vo.setName(category.getName()); + vo.setDescription(category.getDescription()); + vo.setDramaCount(ordered.size()); + vo.setList(cards(ordered, categoryNameMap())); + return vo; + } + private List banners(String position) { return bannerService.listActive(position).stream().map(this::toBanner).toList(); } diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppPlaylistDetailVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppPlaylistDetailVO.java new file mode 100644 index 0000000..acc89b6 --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppPlaylistDetailVO.java @@ -0,0 +1,19 @@ +// Author: fruit.com.cn + +package com.fruitshorts.app.vo; + +import lombok.Data; + +import java.util.List; + +/** C 端剧单详情:基本信息 + 该剧单下已上线的短剧卡片。 */ +@Data +public class AppPlaylistDetailVO { + + private Long id; + private String name; + private String description; + /** 该剧单下已上线短剧的实际条数(被下架/下线的不计入) */ + private Integer dramaCount; + private List list; +} diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java index 5e147de..745bc2d 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/AppCatalogServiceTest.java @@ -8,6 +8,8 @@ import com.fruitshorts.content.dal.entity.Drama; import com.fruitshorts.content.dal.entity.DramaEpisode; import com.fruitshorts.content.service.BannerService; import com.fruitshorts.content.service.DramaCategoryService; +import com.fruitshorts.content.service.DramaClassificationService; +import com.fruitshorts.content.service.DramaPlaylistCategoryService; import com.fruitshorts.content.service.DramaService; import com.fruitshorts.content.service.EpisodeService; import com.fruitshorts.interaction.service.CommentService; @@ -45,12 +47,15 @@ class AppCatalogServiceTest { episodeService = mock(EpisodeService.class); BannerService bannerService = mock(BannerService.class); dramaCategoryService = mock(DramaCategoryService.class); + DramaClassificationService dramaClassificationService = mock(DramaClassificationService.class); + DramaPlaylistCategoryService dramaPlaylistCategoryService = mock(DramaPlaylistCategoryService.class); PlaybackSigner playbackSigner = mock(PlaybackSigner.class); thumbnailUrlResolver = mock(ThumbnailUrlResolver.class); given(thumbnailUrlResolver.resolveAll(any())).willReturn(Map.of()); commentService = mock(CommentService.class); catalogService = new AppCatalogService(dramaService, episodeService, bannerService, - dramaCategoryService, playbackSigner, commentService, thumbnailUrlResolver); + dramaCategoryService, dramaClassificationService, dramaPlaylistCategoryService, + playbackSigner, commentService, thumbnailUrlResolver); } @Test diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java index 1c1f523..6422ebe 100644 --- a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java @@ -38,6 +38,7 @@ public class SecurityConfig { "/app/discover/**", "/app/feed", "/app/categories/**", + "/app/playlists/**", "/app/banners/**", "/app/dramas/**", "/app/search", diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaClassificationService.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaClassificationService.java index 96f2df4..31a43ef 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaClassificationService.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaClassificationService.java @@ -27,6 +27,7 @@ import org.springframework.util.StringUtils; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; /** * 运营分类服务。分类是独立于「短剧类型(drama_category)」的一套概念, @@ -190,6 +191,37 @@ public class DramaClassificationService { } } + // ========== C 端只读视图 ========== + + /** C 端:只返回已启用(status=1)的分类,按 sort 升序。 */ + public List listEnabled() { + return classificationMapper.selectList(Wrappers.lambdaQuery() + .eq(DramaClassification::getStatus, 1) + .orderByAsc(DramaClassification::getSort) + .orderByAsc(DramaClassification::getId)); + } + + /** C 端:校验分类存在且启用。 */ + public DramaClassification getEnabledById(Long id) { + DramaClassification entity = getExisting(id); + if (entity.getStatus() != 1) { + throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "分类不存在或已下架"); + } + return entity; + } + + /** C 端:分类下关联的全部 dramaId(按关联创建时间 desc)。 */ + public List listDramaIdsByClassification(Long classificationId) { + return classificationDramaMapper.selectList( + Wrappers.lambdaQuery() + .eq(DramaClassificationDrama::getClassificationId, classificationId) + .orderByDesc(DramaClassificationDrama::getCreatedAt) + .orderByDesc(DramaClassificationDrama::getId)) + .stream() + .map(DramaClassificationDrama::getDramaId) + .collect(Collectors.toList()); + } + private DramaBriefVO toBrief(Drama drama) { DramaBriefVO vo = new DramaBriefVO(); vo.setId(drama.getId()); diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaPlaylistCategoryService.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaPlaylistCategoryService.java index 7507847..9d2e676 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaPlaylistCategoryService.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaPlaylistCategoryService.java @@ -27,6 +27,7 @@ import org.springframework.util.StringUtils; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; /** * 剧单分类服务:分类直接关联短剧,没有「剧单」这一层。 @@ -177,6 +178,26 @@ public class DramaPlaylistCategoryService { return counts; } + /** C 端用:只返回已启用(status=1)的剧单。 */ + public DramaPlaylistCategory getEnabledById(Long id) { + DramaPlaylistCategory entity = getExisting(id); + if (entity.getStatus() == null || entity.getStatus() != 1) { + throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "剧单不存在或已下架"); + } + return entity; + } + + /** C 端用:剧单下关联的全部 dramaId(按 created_at desc)。 */ + public List listDramaIdsByCategory(Long categoryId) { + return categoryDramaMapper.selectList(Wrappers.lambdaQuery() + .eq(DramaPlaylistCategoryDrama::getCategoryId, categoryId) + .orderByDesc(DramaPlaylistCategoryDrama::getCreatedAt) + .orderByDesc(DramaPlaylistCategoryDrama::getId)) + .stream() + .map(DramaPlaylistCategoryDrama::getDramaId) + .collect(Collectors.toList()); + } + private DramaPlaylistCategory getExisting(Long id) { DramaPlaylistCategory entity = id == null ? null : categoryMapper.selectById(id); if (entity == null) { diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java index 01f0974..1c01677 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java @@ -226,6 +226,22 @@ public class DramaService { return dramaMapper.selectPage(page, wrapper).getRecords(); } + /** C 端分类频道:按分类分页查已上线短剧,含 total。 */ + public PageResult pagePublishedByCategory(Long categoryId, int pageIndex, int pageSize) { + int safeIndex = Math.max(1, pageIndex); + int safeSize = Math.max(1, Math.min(pageSize, 50)); + Page page = new Page<>(safeIndex, safeSize); + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() + .eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode()); + if (categoryId != null && categoryId > 0) { + DramaMultiTagSupport.applyCategoryIdTokens(wrapper, List.of(categoryId)); + } + wrapper.orderByDesc(Drama::getSort).orderByDesc(Drama::getViewCount) + .orderByDesc(Drama::getId); + IPage result = dramaMapper.selectPage(page, wrapper); + return new PageResult<>(result.getTotal(), result.getRecords()); + } + /** 已上线短剧,按上线时间倒序 */ public List listPublishedLatest(long limit) { Page page = new Page<>(1, Math.max(1, limit));