feat(content): 新增内容类型与首页分类 feed

This commit is contained in:
Zhou Weidong committed 2026-10-21 15:40:19 +08:00
1 parent d9cdbc8e15
commit 1052b97306
19 files changed
+557 -4

No files matched your search

@@ -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;
@@ -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;
}
}
@@ -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;
@@ -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;