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

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

No files matched your search

@@ -22,6 +22,8 @@ public class Drama {
private String title;
private String subtitle;
private Long categoryId;
/** 分类多选,逗号分隔的 category_id,category_id 保留第一个 */
private String categoryIds;
private String genre;
private String synopsis;
private String coverUrl;
@@ -29,6 +31,8 @@ public class Drama {
private String language;
/** 内容类型:ANIME=漫剧,LIVE=真人剧 */
private String dramaType;
/** 内容类型多选,逗号分隔(ANIME/LIVE),drama_type 保留第一个 */
private String dramaTypes;
private Integer totalEpisodes;
private Integer freeEpisodes;
private Integer unlockCoin;
@@ -0,0 +1,204 @@
// Author: fruit.com.cn
package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.vo.DramaSaveReqVO;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/** 短剧分类与内容类型多选值的存储、解析和整词过滤。 */
final class DramaMultiTagSupport {
static final String DEFAULT_DRAMA_TYPE = "LIVE";
private static final Set<String> DRAMA_TYPES = Set.of("ANIME", "LIVE");
private DramaMultiTagSupport() {
}
static void applyDramaTypes(Drama drama, DramaSaveReqVO req) {
List<String> values = normalizeDramaTypes(req.getDramaTypes());
if (values.isEmpty() && StringUtils.hasText(req.getDramaType())) {
values = List.of(normalizeDramaType(req.getDramaType()));
}
if (!values.isEmpty()) {
drama.setDramaType(values.get(0));
drama.setDramaTypes(String.join(",", values));
return;
}
if (!StringUtils.hasText(drama.getDramaTypes())
&& !StringUtils.hasText(drama.getDramaType())) {
drama.setDramaType(DEFAULT_DRAMA_TYPE);
drama.setDramaTypes(DEFAULT_DRAMA_TYPE);
}
}
static void applyCategoryIds(Drama drama, DramaSaveReqVO req) {
List<Long> values = normalizeCategoryIds(req.getCategoryIds());
if (values.isEmpty() && req.getCategoryId() != null && req.getCategoryId() > 0L) {
values = List.of(req.getCategoryId());
}
if (!values.isEmpty()) {
drama.setCategoryId(values.get(0));
drama.setCategoryIds(String.join(",", values.stream().map(String::valueOf).toList()));
return;
}
if (drama.getCategoryId() == null) {
drama.setCategoryId(0L);
}
if (drama.getCategoryIds() == null) {
drama.setCategoryIds("");
}
}
static void applyDramaTypeTokens(LambdaQueryWrapper<Drama> wrapper,
List<String> dramaTypes) {
wrapper.and(nested -> {
for (int index = 0; index < dramaTypes.size(); index++) {
if (index > 0) {
nested.or();
}
nested.apply("(',' || drama_types || ',') LIKE {0}",
tokenPattern(dramaTypes.get(index)));
}
});
}
static void applyCategoryIdTokens(LambdaQueryWrapper<Drama> wrapper,
List<Long> categoryIds) {
wrapper.and(nested -> {
for (int index = 0; index < categoryIds.size(); index++) {
if (index > 0) {
nested.or();
}
nested.apply("(',' || category_ids || ',') LIKE {0}",
tokenPattern(categoryIds.get(index)));
}
});
}
static void applyDramaTypeTokens(QueryWrapper<Drama> wrapper,
List<String> dramaTypes) {
wrapper.and(nested -> {
for (int index = 0; index < dramaTypes.size(); index++) {
if (index > 0) {
nested.or();
}
nested.apply("(',' || drama_types || ',') LIKE {0}",
tokenPattern(dramaTypes.get(index)));
}
});
}
static List<String> parseDramaTypes(Drama drama) {
List<String> values = parseStringTokens(drama.getDramaTypes()).stream()
.map(DramaMultiTagSupport::normalizeDramaType)
.distinct()
.toList();
if (!values.isEmpty()) {
return values;
}
if (StringUtils.hasText(drama.getDramaType())) {
return List.of(normalizeDramaType(drama.getDramaType()));
}
return List.of(DEFAULT_DRAMA_TYPE);
}
static List<Long> parseCategoryIds(Drama drama) {
List<Long> values = parseLongTokens(drama.getCategoryIds());
if (values.isEmpty() && drama.getCategoryId() != null && drama.getCategoryId() > 0L) {
return List.of(drama.getCategoryId());
}
return values;
}
static List<String> categoryNames(Drama drama, List<Long> categoryIds,
Map<Long, String> names) {
List<String> values = categoryIds.stream()
.map(names::get)
.filter(StringUtils::hasText)
.toList();
if (values.isEmpty() && StringUtils.hasText(drama.getGenre())) {
return List.of(drama.getGenre());
}
return values;
}
static List<String> normalizeDramaTypes(List<String> dramaTypes) {
if (dramaTypes == null || dramaTypes.isEmpty()) {
return List.of();
}
return dramaTypes.stream()
.filter(StringUtils::hasText)
.map(DramaMultiTagSupport::normalizeDramaType)
.distinct()
.toList();
}
static List<Long> normalizeCategoryIds(List<Long> categoryIds) {
if (categoryIds == null || categoryIds.isEmpty()) {
return List.of();
}
return categoryIds.stream()
.filter(id -> id != null && id > 0L)
.distinct()
.toList();
}
private static String normalizeDramaType(String dramaType) {
String normalized = dramaType == null ? "" : dramaType.trim().toUpperCase(Locale.ROOT);
if (!DRAMA_TYPES.contains(normalized)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "内容类型仅支持 ANIME 或 LIVE");
}
return normalized;
}
private static List<String> parseStringTokens(String value) {
if (!StringUtils.hasText(value)) {
return List.of();
}
List<String> result = new ArrayList<>();
for (String token : value.split(",")) {
String trimmed = token.trim();
if (StringUtils.hasText(trimmed) && !result.contains(trimmed)) {
result.add(trimmed);
}
}
return result;
}
private static List<Long> parseLongTokens(String value) {
if (!StringUtils.hasText(value)) {
return List.of();
}
List<Long> result = new ArrayList<>();
for (String token : value.split(",")) {
Long parsed = parseLongValue(token);
if (parsed != null && parsed > 0L && !result.contains(parsed)) {
result.add(parsed);
}
}
return result;
}
private static Long parseLongValue(String value) {
try {
return Long.parseLong(value.trim());
} catch (NumberFormatException ignored) {
return null;
}
}
private static String tokenPattern(Object value) {
return "%," + value + ",%";
}
}
@@ -3,6 +3,7 @@
package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -28,7 +29,6 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
@@ -37,8 +37,6 @@ import java.util.Set;
public class DramaService {
private static final DateTimeFormatter CODE_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
private static final String DEFAULT_DRAMA_TYPE = "LIVE";
private static final Set<String> DRAMA_TYPES = Set.of("ANIME", "LIVE");
private final DramaMapper dramaMapper;
private final DramaEpisodeMapper dramaEpisodeMapper;
@@ -46,16 +44,23 @@ public class DramaService {
private final DramaCategoryService dramaCategoryService;
public PageResult<DramaRespVO> page(DramaPageReqVO req) {
List<String> dramaTypes = normalizeDramaTypes(req.getDramaTypes());
List<String> dramaTypes = DramaMultiTagSupport.normalizeDramaTypes(req.getDramaTypes());
List<Long> categoryIds = DramaMultiTagSupport.normalizeCategoryIds(req.getCategoryIds());
Page<Drama> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
IPage<Drama> result = dramaMapper.selectPage(page, Wrappers.<Drama>lambdaQuery()
LambdaQueryWrapper<Drama> wrapper = Wrappers.<Drama>lambdaQuery()
.like(StringUtils.hasText(req.getKeyword()), Drama::getTitle, req.getKeyword())
.eq(StringUtils.hasText(req.getStatus()), Drama::getStatus, req.getStatus())
.eq(req.getCategoryId() != null, Drama::getCategoryId, req.getCategoryId())
.eq(req.getIsHot() != null, Drama::getIsHot, Boolean.TRUE.equals(req.getIsHot()) ? 1 : 0)
.in(!dramaTypes.isEmpty(), Drama::getDramaType, dramaTypes)
.orderByDesc(Drama::getSort)
.orderByDesc(Drama::getId));
.eq(req.getIsHot() != null, Drama::getIsHot, Boolean.TRUE.equals(req.getIsHot()) ? 1 : 0);
if (!dramaTypes.isEmpty()) {
DramaMultiTagSupport.applyDramaTypeTokens(wrapper, dramaTypes);
}
if (!categoryIds.isEmpty()) {
DramaMultiTagSupport.applyCategoryIdTokens(wrapper, categoryIds);
} else {
wrapper.eq(req.getCategoryId() != null, Drama::getCategoryId, req.getCategoryId());
}
wrapper.orderByDesc(Drama::getSort).orderByDesc(Drama::getId);
IPage<Drama> result = dramaMapper.selectPage(page, wrapper);
return new PageResult<>(result.getTotal(), toRespList(result.getRecords()));
}
@@ -210,11 +215,13 @@ public class DramaService {
/** 已上线短剧,按运营排序 + 播放量 */
public List<Drama> listPublished(Long categoryId, long limit) {
Page<Drama> page = new Page<>(1, Math.max(1, limit));
return dramaMapper.selectPage(page, Wrappers.<Drama>lambdaQuery()
.eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode())
.eq(categoryId != null && categoryId > 0, Drama::getCategoryId, categoryId)
.orderByDesc(Drama::getSort)
.orderByDesc(Drama::getViewCount)).getRecords();
LambdaQueryWrapper<Drama> wrapper = Wrappers.<Drama>lambdaQuery()
.eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode());
if (categoryId != null && categoryId > 0) {
DramaMultiTagSupport.applyCategoryIdTokens(wrapper, List.of(categoryId));
}
wrapper.orderByDesc(Drama::getSort).orderByDesc(Drama::getViewCount);
return dramaMapper.selectPage(page, wrapper).getRecords();
}
/** 已上线短剧,按上线时间倒序 */
@@ -231,17 +238,19 @@ public class DramaService {
* cursor 使用上一页最后一条 ID,服务端反查排序键后做 keyset 查询。
*/
public List<Drama> listPublishedFeed(List<String> dramaTypes, Long cursor, int limit) {
List<String> normalizedTypes = normalizeDramaTypes(dramaTypes);
List<String> normalizedTypes = DramaMultiTagSupport.normalizeDramaTypes(dramaTypes);
QueryWrapper<Drama> query = new QueryWrapper<>();
query.eq("status", ContentStatus.PUBLISHED.getCode());
if (!normalizedTypes.isEmpty()) {
query.in("drama_type", normalizedTypes);
DramaMultiTagSupport.applyDramaTypeTokens(query, normalizedTypes);
}
if (cursor != null) {
Drama marker = dramaMapper.selectById(cursor);
if (marker == null
|| !ContentStatus.PUBLISHED.getCode().equals(marker.getStatus())
|| (!normalizedTypes.isEmpty() && !normalizedTypes.contains(marker.getDramaType()))) {
|| (!normalizedTypes.isEmpty()
&& DramaMultiTagSupport.parseDramaTypes(marker).stream()
.noneMatch(normalizedTypes::contains))) {
return List.of();
}
applyFeedCursor(query, marker);
@@ -333,18 +342,14 @@ public class DramaService {
private void apply(Drama drama, DramaSaveReqVO req) {
drama.setTitle(req.getTitle());
drama.setSubtitle(req.getSubtitle() == null ? "" : req.getSubtitle());
drama.setCategoryId(req.getCategoryId() == null ? 0L : req.getCategoryId());
DramaMultiTagSupport.applyCategoryIds(drama, req);
drama.setGenre(req.getGenre() == null ? "" : req.getGenre());
drama.setSynopsis(req.getDescription() == null ? "" : req.getDescription());
drama.setCoverUrl(req.getCover() == null ? "" : req.getCover());
drama.setTrailerUrl(req.getTrailerUrl() == null ? "" : req.getTrailerUrl());
drama.setHorizontalCoverUrl(drama.getHorizontalCoverUrl() == null ? "" : drama.getHorizontalCoverUrl());
drama.setLanguage(req.getLanguage() == null ? "zh-CN" : req.getLanguage());
if (StringUtils.hasText(req.getDramaType())) {
drama.setDramaType(normalizeDramaType(req.getDramaType()));
} else if (!StringUtils.hasText(drama.getDramaType())) {
drama.setDramaType(DEFAULT_DRAMA_TYPE);
}
DramaMultiTagSupport.applyDramaTypes(drama, req);
drama.setFreeEpisodes(req.getFreeEpisodes() == null ? 3 : req.getFreeEpisodes());
drama.setUnlockCoin(req.getUnlockCoin() == null ? 0 : req.getUnlockCoin());
drama.setIsHot(Boolean.TRUE.equals(req.getIsHot()) ? 1 : 0);
@@ -363,19 +368,29 @@ public class DramaService {
}
Map<Long, String> categories = dramaCategoryService.nameMap();
return dramas.stream().map(drama -> {
List<Long> categoryIds = DramaMultiTagSupport.parseCategoryIds(drama);
Long primaryCategoryId = categoryIds.isEmpty()
? (drama.getCategoryId() == null ? 0L : drama.getCategoryId())
: categoryIds.get(0);
List<String> categoryNames =
DramaMultiTagSupport.categoryNames(drama, categoryIds, categories);
List<String> dramaTypes = DramaMultiTagSupport.parseDramaTypes(drama);
DramaRespVO vo = new DramaRespVO();
vo.setId(drama.getId());
vo.setCode(drama.getCode());
vo.setTitle(drama.getTitle());
vo.setSubtitle(drama.getSubtitle());
vo.setCategoryId(drama.getCategoryId());
vo.setCategory(categories.getOrDefault(drama.getCategoryId(), ""));
vo.setCategoryId(primaryCategoryId);
vo.setCategoryIds(categoryIds);
vo.setCategoryNames(categoryNames);
vo.setCategory(categories.getOrDefault(primaryCategoryId, ""));
vo.setGenre(drama.getGenre());
vo.setDescription(drama.getSynopsis());
vo.setCover(drama.getCoverUrl());
vo.setTrailerUrl(drama.getTrailerUrl());
vo.setLanguage(drama.getLanguage());
vo.setDramaType(drama.getDramaType());
vo.setDramaType(dramaTypes.get(0));
vo.setDramaTypes(dramaTypes);
vo.setEpisodes(drama.getTotalEpisodes());
vo.setFreeEpisodes(drama.getFreeEpisodes());
vo.setUnlockCoin(drama.getUnlockCoin());
@@ -412,25 +427,6 @@ public class DramaService {
}
}
private List<String> normalizeDramaTypes(List<String> dramaTypes) {
if (dramaTypes == null || dramaTypes.isEmpty()) {
return List.of();
}
return dramaTypes.stream()
.filter(StringUtils::hasText)
.map(this::normalizeDramaType)
.distinct()
.toList();
}
private String normalizeDramaType(String dramaType) {
String normalized = dramaType == null ? "" : dramaType.trim().toUpperCase(Locale.ROOT);
if (!DRAMA_TYPES.contains(normalized)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "内容类型仅支持 ANIME 或 LIVE");
}
return normalized;
}
private Long nonNegativeOrZero(Long value, String fieldName) {
return value == null ? 0L : nonNegative(value, fieldName);
}
@@ -16,6 +16,8 @@ public class DramaPageReqVO extends PageParam {
private String keyword;
private String status;
private Long categoryId;
/** 分类多选,空时回退 categoryId */
private List<Long> categoryIds;
/** 是否只看热门 */
private Boolean isHot;
/** 内容类型多选:ANIME/LIVE,空=全部 */
@@ -6,6 +6,7 @@ import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* 短剧列表/详情响应,字段名与后台页面表格列一致。
@@ -18,6 +19,8 @@ public class DramaRespVO {
private String title;
private String subtitle;
private Long categoryId;
private List<Long> categoryIds;
private List<String> categoryNames;
private String category;
private String genre;
private String description;
@@ -26,6 +29,7 @@ public class DramaRespVO {
private String trailerUrl;
private String language;
private String dramaType;
private List<String> dramaTypes;
private Integer episodes;
private Integer freeEpisodes;
private Integer unlockCoin;
@@ -7,6 +7,8 @@ import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.PositiveOrZero;
import lombok.Data;
import java.util.List;
@Data
public class DramaSaveReqVO {
@@ -15,6 +17,8 @@ public class DramaSaveReqVO {
private String subtitle;
private Long categoryId;
/** 后台多选优先,创作者中心可继续只传 categoryId */
private List<Long> categoryIds;
private String genre;
private String description;
private String cover;
@@ -23,6 +27,8 @@ public class DramaSaveReqVO {
private String language;
@Pattern(regexp = "ANIME|LIVE", message = "内容类型仅支持 ANIME 或 LIVE")
private String dramaType;
/** 后台多选优先,创作者中心可继续只传 dramaType */
private List<String> dramaTypes;
private Integer freeEpisodes;
private Integer unlockCoin;
@PositiveOrZero(message = "收藏数不能小于 0")