refactor: 后端标识统一为 FruitShorts,并落地 05 离线下载

为什么:
1) 产品已定名 FruitShorts,但后端仍是 reelshort 系列标识 —— Java 包 com.reelshort、
   模块名与 jar 名 reelshort-*、spring.application.name、配置前缀 reelshort.*,
   这些会出现在启动日志、命令行和 IDE 里,与产品名不一致。本次整体替换为 fruitshorts,
   数据库连接名上一轮已改名,保持不变。
2) 第一阶段下一批第 1 步(05 离线下载)要求把下载授权(设备绑定/有效期/吊销)、
   下载任务与断点续传、配额与过期清理、仅 Wi-Fi 规则做成完整接口,本提交一并落地。
3) 顺带修复:schema-phase1.sql 里 playback_ticket / order_event 仍按普通表定义唯一索引,
   表分区化之后重跑脚本必定报错(分区表的唯一索引必须包含分区键)。改为在本脚本里就
   按分区表定义,并验证脚本可重复执行。
This commit is contained in:
Vicky committed 2026-10-15 11:44:26 +08:00
1 parent a4aa749cc6
commit b3179fc836
251 files changed
+7261 -767

No files matched your search

@@ -0,0 +1,36 @@
package com.fruitshorts.content.dal.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("content_banner")
public class ContentBanner {
@TableId(type = IdType.AUTO)
private Long id;
private String imageUrl;
/** 展示位置,列名为 slot(position 在 PostgreSQL 里是关键字) */
@TableField("slot")
private String position;
private String linkType;
private String linkValue;
private Integer sort;
private Integer status;
private Long createBy;
private LocalDateTime createdAt;
/** updated_at 交给数据库的 ON UPDATE CURRENT_TIMESTAMP 维护 */
@TableField(updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,69 @@
package com.fruitshorts.content.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("drama")
public class Drama {
@TableId(type = IdType.AUTO)
private Long id;
private String code;
private String title;
private String subtitle;
private Long categoryId;
private String genre;
private String synopsis;
private String coverUrl;
private String horizontalCoverUrl;
private String language;
private Integer totalEpisodes;
private Integer freeEpisodes;
private Integer unlockCoin;
/** 是否热门:1 是,0 否 */
private Integer isHot;
/** 创作者归属,0 表示平台自制 */
private Long creatorId;
private String tags;
/** 内容分级:ALL/R13/R17/R21(印尼) */
private String rating;
/** 可播区域,逗号分隔 ISO 国家码,空=不限 */
private String regionCodes;
private String copyrightOwner;
private LocalDateTime copyrightStartAt;
/** 版权到期时间,到期自动下架 */
private LocalDateTime copyrightEndAt;
/** 预留:多语言字幕(第三阶段) */
private String subtitleLangs;
private String trailerUrl;
/** 预留:推荐权重(第三阶段) */
private Integer recommendWeight;
/** 预留:会员专属(第二阶段) */
private Integer isVipOnly;
/** 创作者内容的审核状态 */
private String auditStatus;
private String auditNote;
private String status;
private LocalDateTime publishAt;
private LocalDateTime publishedAt;
private Long viewCount;
private Long playCount;
private Long favoriteCount;
private Long likeCount;
private Integer sort;
private Long createBy;
private LocalDateTime createdAt;
/** updated_at 交给数据库的 ON UPDATE CURRENT_TIMESTAMP 维护 */
@TableField(updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,17 @@
package com.fruitshorts.content.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@Data
@TableName("drama_category")
public class DramaCategory {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
private Integer sort;
private Integer status;
}
@@ -0,0 +1,48 @@
package com.fruitshorts.content.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("drama_episode")
public class DramaEpisode {
@TableId(type = IdType.AUTO)
private Long id;
private Long dramaId;
private Integer epNo;
private String title;
private Integer durationSec;
private String coverUrl;
private Long videoAssetId;
private String videoUrl;
private Integer isFree;
private Integer unlockCoin;
/** 转码状态:none/queued/running/success/failed */
private String transcodeStatus;
/** 多码率 master playlist 地址,转码完成后写入 */
private String hlsUrl;
/** 试看秒数,0 表示不限制 */
private Integer previewSeconds;
private String subtitleUrl;
private String subtitleLang;
/** 预留:会员专属(第二阶段) */
private Integer isVipOnly;
private String status;
private LocalDateTime releaseAt;
private Long playCount;
private Integer sort;
private LocalDateTime createdAt;
/** updated_at 交给数据库的 ON UPDATE CURRENT_TIMESTAMP 维护 */
@TableField(updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
@TableLogic
private Integer deleted;
}
@@ -0,0 +1,23 @@
package com.fruitshorts.content.dal.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("drama_review_record")
public class DramaReviewRecord {
@TableId(type = IdType.AUTO)
private Long id;
private String bizType;
private Long bizId;
private String action;
private String reason;
private Long operatorId;
private String operatorName;
private LocalDateTime createdAt;
}
@@ -0,0 +1,7 @@
package com.fruitshorts.content.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.content.dal.entity.ContentBanner;
public interface ContentBannerMapper extends BaseMapper<ContentBanner> {
}
@@ -0,0 +1,7 @@
package com.fruitshorts.content.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.content.dal.entity.DramaCategory;
public interface DramaCategoryMapper extends BaseMapper<DramaCategory> {
}
@@ -0,0 +1,7 @@
package com.fruitshorts.content.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.content.dal.entity.DramaEpisode;
public interface DramaEpisodeMapper extends BaseMapper<DramaEpisode> {
}
@@ -0,0 +1,7 @@
package com.fruitshorts.content.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.content.dal.entity.Drama;
public interface DramaMapper extends BaseMapper<Drama> {
}
@@ -0,0 +1,7 @@
package com.fruitshorts.content.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.content.dal.entity.DramaReviewRecord;
public interface DramaReviewRecordMapper extends BaseMapper<DramaReviewRecord> {
}
@@ -0,0 +1,36 @@
package com.fruitshorts.content.enums;
import lombok.Getter;
/** 轮播图点击后的跳转类型。 */
@Getter
public enum BannerLinkType {
NONE("不跳转"),
DRAMA("跳转短剧"),
URL("打开链接");
private final String text;
BannerLinkType(String text) {
this.text = text;
}
public static String textOf(String code) {
for (BannerLinkType type : values()) {
if (type.name().equals(code)) {
return type.text;
}
}
return code;
}
public static boolean isValid(String code) {
for (BannerLinkType type : values()) {
if (type.name().equals(code)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,35 @@
package com.fruitshorts.content.enums;
import lombok.Getter;
/** 轮播图展示位置。 */
@Getter
public enum BannerPosition {
HOME_TOP("首页顶部"),
DISCOVER("发现页");
private final String text;
BannerPosition(String text) {
this.text = text;
}
public static String textOf(String code) {
for (BannerPosition position : values()) {
if (position.name().equals(code)) {
return position.text;
}
}
return code;
}
public static boolean isValid(String code) {
for (BannerPosition position : values()) {
if (position.name().equals(code)) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,33 @@
package com.fruitshorts.content.enums;
import lombok.Getter;
/**
* 短剧与剧集共用一套状态码,与前端展示文案一一对应。
*/
@Getter
public enum ContentStatus {
DRAFT("draft", "草稿"),
REVIEWING("reviewing", "审核中"),
APPROVED("approved", "待发布"),
PUBLISHED("published", "已上线"),
OFFLINE("offline", "已下线");
private final String code;
private final String text;
ContentStatus(String code, String text) {
this.code = code;
this.text = text;
}
public static String textOf(String code) {
for (ContentStatus status : values()) {
if (status.code.equals(code)) {
return status.text;
}
}
return code;
}
}
@@ -0,0 +1,191 @@
package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
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.mapper.ContentBannerMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.enums.BannerLinkType;
import com.fruitshorts.content.enums.BannerPosition;
import com.fruitshorts.content.vo.BannerRespVO;
import com.fruitshorts.content.vo.BannerSaveReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class BannerService {
private final ContentBannerMapper contentBannerMapper;
private final DramaMapper dramaMapper;
public PageResult<BannerRespVO> page(String position, Integer status, Integer pageIndex, Integer pageSize) {
Page<ContentBanner> page = new Page<>(
pageIndex == null || pageIndex < 1 ? 1 : pageIndex,
pageSize == null || pageSize < 1 ? 10 : pageSize);
IPage<ContentBanner> result = contentBannerMapper.selectPage(page,
Wrappers.<ContentBanner>lambdaQuery()
.eq(StringUtils.hasText(position), ContentBanner::getPosition, position)
.eq(status != null, ContentBanner::getStatus, status)
.orderByAsc(ContentBanner::getSort)
.orderByDesc(ContentBanner::getId));
return new PageResult<>(result.getTotal(), toRespList(result.getRecords()));
}
public BannerRespVO get(Long id) {
return toRespList(List.of(getExisting(id))).get(0);
}
/** C 端:启用中的轮播图,按运营排序 */
public List<ContentBanner> listActive(String position) {
return contentBannerMapper.selectList(Wrappers.<ContentBanner>lambdaQuery()
.eq(ContentBanner::getStatus, 1)
.eq(StringUtils.hasText(position), ContentBanner::getPosition, position)
.orderByAsc(ContentBanner::getSort)
.orderByAsc(ContentBanner::getId));
}
@Transactional(rollbackFor = Exception.class)
public Long create(BannerSaveReqVO req, Long operatorId) {
ContentBanner banner = new ContentBanner();
apply(banner, req);
banner.setCreateBy(operatorId == null ? 0L : operatorId);
contentBannerMapper.insert(banner);
return banner.getId();
}
@Transactional(rollbackFor = Exception.class)
public void update(Long id, BannerSaveReqVO req) {
ContentBanner banner = getExisting(id);
apply(banner, req);
contentBannerMapper.updateById(banner);
}
public void delete(Long id) {
getExisting(id);
contentBannerMapper.deleteById(id);
}
public void updateStatus(Long id, Integer status) {
ContentBanner banner = getExisting(id);
banner.setStatus(status == null || status == 0 ? 0 : 1);
contentBannerMapper.updateById(banner);
}
/** 按传入顺序重排 sort */
@Transactional(rollbackFor = Exception.class)
public void updateSort(List<Long> ids) {
for (int index = 0; index < ids.size(); index++) {
ContentBanner banner = contentBannerMapper.selectById(ids.get(index));
if (banner != null) {
banner.setSort(index + 1);
contentBannerMapper.updateById(banner);
}
}
}
private ContentBanner getExisting(Long id) {
ContentBanner banner = id == null ? null : contentBannerMapper.selectById(id);
if (banner == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "轮播图不存在");
}
return banner;
}
private void apply(ContentBanner banner, BannerSaveReqVO req) {
String position = StringUtils.hasText(req.getPosition()) ? req.getPosition() : BannerPosition.HOME_TOP.name();
if (!BannerPosition.isValid(position)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "未知的展示位置: " + position);
}
String linkType = StringUtils.hasText(req.getLinkType()) ? req.getLinkType() : BannerLinkType.NONE.name();
if (!BannerLinkType.isValid(linkType)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "未知的跳转类型: " + linkType);
}
String linkValue = req.getLinkValue() == null ? "" : req.getLinkValue().trim();
if (BannerLinkType.DRAMA.name().equals(linkType)) {
if (!StringUtils.hasText(linkValue)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "请选择要跳转的短剧");
}
// link_value 存的是字符串,查询前必须转成 Long,
// 否则 PostgreSQL 会直接拒绝 varchar 与 bigint 比较。
Long dramaId = parseId(linkValue);
if (dramaId == null || dramaMapper.selectById(dramaId) == null) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "跳转的短剧不存在");
}
linkValue = String.valueOf(dramaId);
} else if (BannerLinkType.URL.name().equals(linkType)) {
if (!StringUtils.hasText(linkValue)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "请填写跳转地址");
}
} else {
linkValue = "";
}
banner.setImageUrl(req.getImageUrl().trim());
banner.setPosition(position);
banner.setLinkType(linkType);
banner.setLinkValue(linkValue);
banner.setSort(req.getSort() == null ? 0 : req.getSort());
banner.setStatus(req.getStatus() == null ? 1 : req.getStatus());
}
private List<BannerRespVO> toRespList(List<ContentBanner> banners) {
if (banners.isEmpty()) {
return List.of();
}
// 批量取跳转短剧名称
List<Long> dramaIds = banners.stream()
.filter(banner -> BannerLinkType.DRAMA.name().equals(banner.getLinkType()))
.map(banner -> parseId(banner.getLinkValue()))
.filter(id -> id != null)
.distinct()
.toList();
Map<Long, String> dramaNames = new HashMap<>();
if (!dramaIds.isEmpty()) {
dramaMapper.selectList(Wrappers.<Drama>lambdaQuery().in(Drama::getId, dramaIds))
.forEach(drama -> dramaNames.put(drama.getId(), drama.getTitle()));
}
return banners.stream().map(banner -> {
BannerRespVO vo = new BannerRespVO();
vo.setId(banner.getId());
vo.setImageUrl(banner.getImageUrl());
vo.setPosition(banner.getPosition());
vo.setPositionText(BannerPosition.textOf(banner.getPosition()));
vo.setLinkType(banner.getLinkType());
vo.setLinkTypeText(BannerLinkType.textOf(banner.getLinkType()));
vo.setLinkValue(banner.getLinkValue());
if (BannerLinkType.DRAMA.name().equals(banner.getLinkType())) {
Long dramaId = parseId(banner.getLinkValue());
vo.setLinkText(dramaId == null ? "" : dramaNames.getOrDefault(dramaId, "已删除的短剧"));
} else if (BannerLinkType.URL.name().equals(banner.getLinkType())) {
vo.setLinkText(banner.getLinkValue());
} else {
vo.setLinkText("");
}
vo.setSort(banner.getSort());
vo.setStatus(banner.getStatus());
vo.setStatusText(banner.getStatus() != null && banner.getStatus() == 1 ? "启用" : "停用");
vo.setUpdatedAt(banner.getUpdatedAt());
return vo;
}).toList();
}
private Long parseId(String value) {
try {
return Long.valueOf(value);
} catch (NumberFormatException ex) {
return null;
}
}
}
@@ -0,0 +1,127 @@
package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaCategory;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.dal.mapper.DramaCategoryMapper;
import com.fruitshorts.content.vo.DramaCategoryRespVO;
import com.fruitshorts.content.vo.DramaCategorySaveReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class DramaCategoryService {
private final DramaCategoryMapper dramaCategoryMapper;
private final DramaMapper dramaMapper;
public List<DramaCategoryRespVO> list() {
Map<Long, Integer> counts = countDramasByCategory();
return dramaCategoryMapper.selectList(
Wrappers.<DramaCategory>lambdaQuery().orderByAsc(DramaCategory::getSort))
.stream().map(category -> {
DramaCategoryRespVO vo = new DramaCategoryRespVO();
vo.setId(category.getId());
vo.setName(category.getName());
vo.setSort(category.getSort());
vo.setStatus(category.getStatus());
vo.setDramaCount(counts.getOrDefault(category.getId(), 0));
return vo;
}).toList();
}
public Long create(DramaCategorySaveReqVO req) {
ensureNameAvailable(req.getName(), null);
DramaCategory category = new DramaCategory();
category.setName(req.getName().trim());
Long count = dramaCategoryMapper.selectCount(null);
category.setSort(req.getSort() == null
? (count == null ? 0 : count.intValue()) + 1
: req.getSort());
category.setStatus(req.getStatus() == null ? 1 : req.getStatus());
dramaCategoryMapper.insert(category);
return category.getId();
}
/**
* 轻量列表:只查分类本身,不统计短剧数量。
* C 端展示和名称映射用它,避免为了一个名称把整张短剧表扫一遍。
*/
public List<DramaCategoryRespVO> listBasic() {
return dramaCategoryMapper.selectList(
Wrappers.<DramaCategory>lambdaQuery().orderByAsc(DramaCategory::getSort))
.stream().map(category -> {
DramaCategoryRespVO vo = new DramaCategoryRespVO();
vo.setId(category.getId());
vo.setName(category.getName());
vo.setSort(category.getSort());
vo.setStatus(category.getStatus());
return vo;
}).toList();
}
public void update(Long id, DramaCategorySaveReqVO req) {
DramaCategory category = getExisting(id);
ensureNameAvailable(req.getName(), id);
category.setName(req.getName().trim());
if (req.getSort() != null) {
category.setSort(req.getSort());
}
if (req.getStatus() != null) {
category.setStatus(req.getStatus());
}
dramaCategoryMapper.updateById(category);
}
public void delete(Long id) {
DramaCategory category = getExisting(id);
Integer used = countDramasByCategory().getOrDefault(id, 0);
if (used > 0) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"「" + category.getName() + "」下还有 " + used + " 部短剧,无法删除");
}
dramaCategoryMapper.deleteById(id);
}
private DramaCategory getExisting(Long id) {
DramaCategory category = id == null ? null : dramaCategoryMapper.selectById(id);
if (category == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "类型不存在");
}
return category;
}
private void ensureNameAvailable(String name, Long excludeId) {
Long count = dramaCategoryMapper.selectCount(Wrappers.<DramaCategory>lambdaQuery()
.eq(DramaCategory::getName, name.trim())
.ne(excludeId != null, DramaCategory::getId, excludeId));
if (count != null && count > 0) {
throw new ServiceException(ErrorCode.DATA_EXISTS, "类型名称已存在");
}
}
private Map<Long, Integer> countDramasByCategory() {
Map<Long, Integer> counts = new HashMap<>();
dramaMapper.selectList(Wrappers.<Drama>lambdaQuery().select(Drama::getCategoryId))
.forEach(drama -> {
if (drama.getCategoryId() != null && drama.getCategoryId() > 0) {
counts.merge(drama.getCategoryId(), 1, Integer::sum);
}
});
return counts;
}
public Map<Long, String> nameMap() {
return dramaCategoryMapper.selectList(null).stream()
.collect(Collectors.toMap(DramaCategory::getId, DramaCategory::getName, (a, b) -> a));
}
}
@@ -0,0 +1,281 @@
package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.entity.DramaReviewRecord;
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.enums.ContentStatus;
import com.fruitshorts.content.vo.DramaPageReqVO;
import com.fruitshorts.content.vo.DramaRespVO;
import com.fruitshorts.content.vo.DramaSaveReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Service
@RequiredArgsConstructor
public class DramaService {
private static final DateTimeFormatter CODE_DATE = DateTimeFormatter.ofPattern("yyyyMMdd");
private final DramaMapper dramaMapper;
private final DramaEpisodeMapper dramaEpisodeMapper;
private final DramaReviewRecordMapper dramaReviewRecordMapper;
private final DramaCategoryService dramaCategoryService;
public PageResult<DramaRespVO> page(DramaPageReqVO req) {
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)
.orderByDesc(Drama::getSort)
.orderByDesc(Drama::getId));
return new PageResult<>(result.getTotal(), toRespList(result.getRecords()));
}
public DramaRespVO get(Long id) {
return toRespList(List.of(getExisting(id))).get(0);
}
public Drama getExisting(Long id) {
Drama drama = id == null ? null : dramaMapper.selectById(id);
if (drama == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "短剧不存在");
}
return drama;
}
@Transactional(rollbackFor = Exception.class)
public Long create(DramaSaveReqVO req, Long operatorId) {
Drama drama = new Drama();
apply(drama, req);
drama.setCode(generateCode());
drama.setStatus(ContentStatus.DRAFT.getCode());
drama.setTotalEpisodes(0);
drama.setViewCount(0L);
drama.setPlayCount(0L);
drama.setFavoriteCount(0L);
drama.setLikeCount(0L);
drama.setCreateBy(operatorId == null ? 0L : operatorId);
dramaMapper.insert(drama);
return drama.getId();
}
@Transactional(rollbackFor = Exception.class)
public void update(Long id, DramaSaveReqVO req) {
Drama drama = getExisting(id);
apply(drama, req);
dramaMapper.updateById(drama);
}
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
Drama drama = getExisting(id);
if (ContentStatus.PUBLISHED.getCode().equals(drama.getStatus())) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "已上线的短剧需先下线再删除");
}
dramaMapper.deleteById(id);
dramaEpisodeMapper.delete(Wrappers.<DramaEpisode>lambdaQuery().eq(DramaEpisode::getDramaId, id));
}
/** 审核与发布的状态流转。 */
@Transactional(rollbackFor = Exception.class)
public void changeStatus(Long id, String action, String reason, Long operatorId, String operatorName) {
Drama drama = getExisting(id);
String current = drama.getStatus();
String target = switch (action) {
case "SUBMIT" -> {
requireState(current, "SUBMIT", Set.of(ContentStatus.DRAFT.getCode(), ContentStatus.OFFLINE.getCode()));
yield ContentStatus.REVIEWING.getCode();
}
case "APPROVE" -> {
requireState(current, "APPROVE", Set.of(ContentStatus.REVIEWING.getCode()));
yield ContentStatus.APPROVED.getCode();
}
case "REJECT" -> {
requireState(current, "REJECT", Set.of(ContentStatus.REVIEWING.getCode()));
yield ContentStatus.DRAFT.getCode();
}
case "PUBLISH" -> {
requireState(current, "PUBLISH", Set.of(ContentStatus.APPROVED.getCode(),
ContentStatus.OFFLINE.getCode()));
yield ContentStatus.PUBLISHED.getCode();
}
case "OFFLINE" -> {
requireState(current, "OFFLINE", Set.of(ContentStatus.PUBLISHED.getCode()));
yield ContentStatus.OFFLINE.getCode();
}
default -> throw new ServiceException(ErrorCode.BAD_REQUEST, "未知的审核动作: " + action);
};
drama.setStatus(target);
if (ContentStatus.PUBLISHED.getCode().equals(target)) {
drama.setPublishedAt(LocalDateTime.now());
}
dramaMapper.updateById(drama);
DramaReviewRecord record = new DramaReviewRecord();
record.setBizType("DRAMA");
record.setBizId(id);
record.setAction(action);
record.setReason(reason == null ? "" : reason);
record.setOperatorId(operatorId == null ? 0L : operatorId);
record.setOperatorName(operatorName == null ? "" : operatorName);
dramaReviewRecordMapper.insert(record);
}
public List<DramaReviewRecord> listReviewRecords(Long dramaId) {
return dramaReviewRecordMapper.selectList(Wrappers.<DramaReviewRecord>lambdaQuery()
.eq(DramaReviewRecord::getBizType, "DRAMA")
.eq(DramaReviewRecord::getBizId, dramaId)
.orderByDesc(DramaReviewRecord::getCreatedAt));
}
// ========== C 端只读视图:只暴露已上线内容 ==========
/** 已上线短剧,按运营排序 + 播放量 */
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();
}
/** 已上线短剧,按上线时间倒序 */
public List<Drama> listPublishedLatest(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())
.orderByDesc(Drama::getPublishedAt)
.orderByDesc(Drama::getId)).getRecords();
}
/** 已上线的热门短剧 */
public List<Drama> listPublishedHot(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(Drama::getIsHot, 1)
.orderByDesc(Drama::getSort)
.orderByDesc(Drama::getViewCount)).getRecords();
}
/** 已上线的短剧,未上线/已下线一律按不存在处理 */
public Drama getPublished(Long id) {
Drama drama = getExisting(id);
if (!ContentStatus.PUBLISHED.getCode().equals(drama.getStatus())) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "短剧不存在或已下线");
}
return drama;
}
/** C 端:按 ID 批量取已上线短剧(收藏与观看记录用),返回顺序由调用方决定。 */
public List<Drama> listPublishedByIds(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return List.of();
}
return dramaMapper.selectList(Wrappers.<Drama>lambdaQuery()
.in(Drama::getId, ids)
.eq(Drama::getStatus, ContentStatus.PUBLISHED.getCode()));
}
/** 按 ID 批量取短剧,不限状态:后台查观看流水时要能看到已下线短剧的名字。 */
public List<Drama> listByIds(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return List.of();
}
return dramaMapper.selectList(Wrappers.<Drama>lambdaQuery().in(Drama::getId, ids));
}
/** 剧集数量变化后同步冗余字段。 */
public void syncTotalEpisodes(Long dramaId) {
Long count = dramaEpisodeMapper.selectCount(
Wrappers.<DramaEpisode>lambdaQuery().eq(DramaEpisode::getDramaId, dramaId));
Drama drama = dramaMapper.selectById(dramaId);
if (drama != null) {
drama.setTotalEpisodes(count == null ? 0 : count.intValue());
dramaMapper.updateById(drama);
}
}
private void requireState(String current, String action, Set<String> allowed) {
if (!allowed.contains(current)) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"当前状态(" + ContentStatus.textOf(current) + ")不允许执行该操作");
}
}
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());
drama.setGenre(req.getGenre() == null ? "" : req.getGenre());
drama.setSynopsis(req.getDescription() == null ? "" : req.getDescription());
drama.setCoverUrl(req.getCover() == null ? "" : req.getCover());
drama.setHorizontalCoverUrl(drama.getHorizontalCoverUrl() == null ? "" : drama.getHorizontalCoverUrl());
drama.setLanguage(req.getLanguage() == null ? "zh-CN" : req.getLanguage());
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);
drama.setSort(req.getSort() == null ? 0 : req.getSort());
}
private String generateCode() {
Long count = dramaMapper.selectCount(null);
return "D" + LocalDate.now().format(CODE_DATE)
+ String.format("%03d", (count == null ? 0 : count + 1));
}
private List<DramaRespVO> toRespList(List<Drama> dramas) {
if (dramas.isEmpty()) {
return List.of();
}
Map<Long, String> categories = dramaCategoryService.nameMap();
return dramas.stream().map(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.setGenre(drama.getGenre());
vo.setDescription(drama.getSynopsis());
vo.setCover(drama.getCoverUrl());
vo.setLanguage(drama.getLanguage());
vo.setEpisodes(drama.getTotalEpisodes());
vo.setFreeEpisodes(drama.getFreeEpisodes());
vo.setUnlockCoin(drama.getUnlockCoin());
vo.setIsHot(drama.getIsHot() != null && drama.getIsHot() == 1);
vo.setStatus(drama.getStatus());
vo.setStatusText(ContentStatus.textOf(drama.getStatus()));
vo.setViews(drama.getViewCount());
vo.setSort(drama.getSort());
vo.setPublishAt(drama.getPublishedAt());
vo.setCreatedAt(drama.getCreatedAt());
vo.setUpdatedAt(drama.getUpdatedAt());
return vo;
}).toList();
}
}
@@ -0,0 +1,189 @@
package com.fruitshorts.content.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.enums.ContentStatus;
import com.fruitshorts.content.vo.EpisodeRespVO;
import com.fruitshorts.content.vo.EpisodeSaveReqVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class EpisodeService {
private final DramaEpisodeMapper dramaEpisodeMapper;
private final DramaService dramaService;
public List<EpisodeRespVO> listByDrama(Long dramaId, String status) {
dramaService.getExisting(dramaId);
return dramaEpisodeMapper.selectList(Wrappers.<DramaEpisode>lambdaQuery()
.eq(DramaEpisode::getDramaId, dramaId)
.eq(status != null && !status.isBlank(), DramaEpisode::getStatus, status)
.orderByAsc(DramaEpisode::getSort)
.orderByAsc(DramaEpisode::getEpNo))
.stream().map(this::toResp).toList();
}
/** C 端:只返回已上线剧集,按播放顺序 */
public List<DramaEpisode> listPublishedByDrama(Long dramaId) {
return dramaEpisodeMapper.selectList(Wrappers.<DramaEpisode>lambdaQuery()
.eq(DramaEpisode::getDramaId, dramaId)
.eq(DramaEpisode::getStatus, ContentStatus.PUBLISHED.getCode())
.orderByAsc(DramaEpisode::getSort)
.orderByAsc(DramaEpisode::getEpNo));
}
/**
* C 端:批量取每部剧的首个可播免费集,用于列表卡片直接起播。
* 整屏卡片共用一次 IN 查询,避免逐条查库。
*/
public Map<Long, DramaEpisode> firstPlayableByDramas(List<Long> dramaIds) {
if (dramaIds == null || dramaIds.isEmpty()) {
return Map.of();
}
List<DramaEpisode> episodes = dramaEpisodeMapper.selectList(
Wrappers.<DramaEpisode>lambdaQuery()
.in(DramaEpisode::getDramaId, dramaIds)
.eq(DramaEpisode::getStatus, ContentStatus.PUBLISHED.getCode())
.eq(DramaEpisode::getIsFree, 1)
.isNotNull(DramaEpisode::getVideoUrl)
.ne(DramaEpisode::getVideoUrl, "")
.orderByAsc(DramaEpisode::getDramaId)
.orderByAsc(DramaEpisode::getSort)
.orderByAsc(DramaEpisode::getEpNo));
Map<Long, DramaEpisode> firstPlayable = new LinkedHashMap<>();
for (DramaEpisode episode : episodes) {
firstPlayable.putIfAbsent(episode.getDramaId(), episode);
}
return firstPlayable;
}
/** C 端:已上线剧集,否则按不存在处理 */
public DramaEpisode getPublished(Long id) {
DramaEpisode episode = getExisting(id);
if (!ContentStatus.PUBLISHED.getCode().equals(episode.getStatus())) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "剧集不存在或未上线");
}
return episode;
}
public DramaEpisode getExisting(Long id) {
DramaEpisode episode = id == null ? null : dramaEpisodeMapper.selectById(id);
if (episode == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "剧集不存在");
}
return episode;
}
/** 按 ID 批量取剧集,不限状态:后台查观看流水时用。 */
public List<DramaEpisode> listByIds(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return List.of();
}
return dramaEpisodeMapper.selectList(
Wrappers.<DramaEpisode>lambdaQuery().in(DramaEpisode::getId, ids));
}
@Transactional(rollbackFor = Exception.class)
public Long create(EpisodeSaveReqVO req) {
dramaService.getExisting(req.getDramaId());
ensureEpNoAvailable(req.getDramaId(), req.getNumber(), null);
DramaEpisode episode = new DramaEpisode();
apply(episode, req);
episode.setStatus(req.getStatus() == null ? ContentStatus.DRAFT.getCode() : req.getStatus());
episode.setPlayCount(0L);
episode.setVideoAssetId(0L);
dramaEpisodeMapper.insert(episode);
dramaService.syncTotalEpisodes(req.getDramaId());
return episode.getId();
}
@Transactional(rollbackFor = Exception.class)
public void update(Long id, EpisodeSaveReqVO req) {
DramaEpisode episode = getExisting(id);
dramaService.getExisting(req.getDramaId());
ensureEpNoAvailable(req.getDramaId(), req.getNumber(), id);
apply(episode, req);
if (req.getStatus() != null) {
episode.setStatus(req.getStatus());
}
dramaEpisodeMapper.updateById(episode);
if (!req.getDramaId().equals(episode.getDramaId())) {
dramaService.syncTotalEpisodes(req.getDramaId());
}
dramaService.syncTotalEpisodes(episode.getDramaId());
}
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
DramaEpisode episode = getExisting(id);
dramaEpisodeMapper.deleteById(id);
dramaService.syncTotalEpisodes(episode.getDramaId());
}
@Transactional(rollbackFor = Exception.class)
public void updateSort(List<Long> ids) {
for (int index = 0; index < ids.size(); index++) {
DramaEpisode episode = dramaEpisodeMapper.selectById(ids.get(index));
if (episode != null) {
episode.setSort(index + 1);
dramaEpisodeMapper.updateById(episode);
}
}
}
private void ensureEpNoAvailable(Long dramaId, Integer epNo, Long excludeId) {
Long count = dramaEpisodeMapper.selectCount(Wrappers.<DramaEpisode>lambdaQuery()
.eq(DramaEpisode::getDramaId, dramaId)
.eq(DramaEpisode::getEpNo, epNo)
.ne(excludeId != null, DramaEpisode::getId, excludeId));
if (count != null && count > 0) {
throw new ServiceException(ErrorCode.DATA_EXISTS, "第 " + epNo + " 集已存在");
}
}
private void apply(DramaEpisode episode, EpisodeSaveReqVO req) {
episode.setDramaId(req.getDramaId());
episode.setEpNo(req.getNumber());
episode.setTitle(req.getTitle() == null ? "" : req.getTitle());
episode.setDurationSec(req.getDurationSec() == null ? 0 : req.getDurationSec());
episode.setCoverUrl(req.getCover() == null ? "" : req.getCover());
episode.setVideoUrl(req.getVideoUrl() == null ? "" : req.getVideoUrl());
episode.setIsFree(Boolean.TRUE.equals(req.getIsFree()) ? 1 : 0);
episode.setUnlockCoin(req.getUnlockCoin() == null ? 0 : req.getUnlockCoin());
episode.setSort(req.getSort() == null ? (req.getNumber() == null ? 0 : req.getNumber()) : req.getSort());
}
private EpisodeRespVO toResp(DramaEpisode episode) {
EpisodeRespVO vo = new EpisodeRespVO();
vo.setId(episode.getId());
vo.setDramaId(episode.getDramaId());
vo.setNumber(episode.getEpNo());
vo.setTitle(episode.getTitle());
vo.setDurationSec(episode.getDurationSec());
vo.setDuration(formatDuration(episode.getDurationSec()));
vo.setCover(episode.getCoverUrl());
vo.setVideoUrl(episode.getVideoUrl());
vo.setIsFree(episode.getIsFree() != null && episode.getIsFree() == 1);
vo.setUnlockCoin(episode.getUnlockCoin());
vo.setStatus(episode.getStatus());
vo.setStatusText(ContentStatus.textOf(episode.getStatus()));
vo.setViews(episode.getPlayCount());
vo.setSort(episode.getSort());
return vo;
}
private String formatDuration(Integer seconds) {
int value = seconds == null ? 0 : seconds;
return String.format("%02d:%02d", value / 60, value % 60);
}
}
@@ -0,0 +1,26 @@
package com.fruitshorts.content.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class BannerRespVO {
private Long id;
private String imageUrl;
private String position;
private String positionText;
private String linkType;
private String linkTypeText;
private String linkValue;
/** 跳转目标的展示文案,如短剧名称 */
private String linkText;
private Integer sort;
private Integer status;
private String statusText;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updatedAt;
}
@@ -0,0 +1,22 @@
package com.fruitshorts.content.vo;
import lombok.Data;
@Data
public class BannerSaveReqVO {
@jakarta.validation.constraints.NotBlank(message = "请上传轮播图片")
private String imageUrl;
/** HOME_TOP / DISCOVER */
private String position;
/** NONE / DRAMA / URL */
private String linkType;
/** 剧集 ID 或跳转地址 */
private String linkValue;
private Integer sort;
private Integer status;
}
@@ -0,0 +1,14 @@
package com.fruitshorts.content.vo;
import lombok.Data;
@Data
public class DramaCategoryRespVO {
private Long id;
private String name;
private Integer sort;
private Integer status;
/** 该类型下的短剧数量 */
private Integer dramaCount;
}
@@ -0,0 +1,14 @@
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class DramaCategorySaveReqVO {
@NotBlank(message = "请填写类型名称")
private String name;
private Integer sort;
private Integer status;
}
@@ -0,0 +1,17 @@
package com.fruitshorts.content.vo;
import com.fruitshorts.common.pojo.PageParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class DramaPageReqVO extends PageParam {
/** 标题关键字 */
private String keyword;
private String status;
private Long categoryId;
/** 是否只看热门 */
private Boolean isHot;
}
@@ -0,0 +1,41 @@
package com.fruitshorts.content.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 短剧列表/详情响应,字段名与后台页面表格列一致。
*/
@Data
public class DramaRespVO {
private Long id;
private String code;
private String title;
private String subtitle;
private Long categoryId;
private String category;
private String genre;
private String description;
private String cover;
private String language;
private Integer episodes;
private Integer freeEpisodes;
private Integer unlockCoin;
private Boolean isHot;
private String status;
private String statusText;
private Long views;
private Integer sort;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime publishAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdAt;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updatedAt;
}
@@ -0,0 +1,23 @@
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class DramaSaveReqVO {
@NotBlank(message = "请填写短剧名称")
private String title;
private String subtitle;
private Long categoryId;
private String genre;
private String description;
private String cover;
private String language;
private Integer freeEpisodes;
private Integer unlockCoin;
/** 是否热门 */
private Boolean isHot;
private Integer sort;
}
@@ -0,0 +1,10 @@
package com.fruitshorts.content.vo;
import lombok.Data;
@Data
public class DramaStatusReqVO {
/** 驳回原因,其他动作可空 */
private String reason;
}
@@ -0,0 +1,23 @@
package com.fruitshorts.content.vo;
import lombok.Data;
@Data
public class EpisodeRespVO {
private Long id;
private Long dramaId;
private Integer number;
private String title;
/** 展示用时长,mm:ss */
private String duration;
private Integer durationSec;
private String cover;
private String videoUrl;
private Boolean isFree;
private Integer unlockCoin;
private String status;
private String statusText;
private Long views;
private Integer sort;
}
@@ -0,0 +1,23 @@
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class EpisodeSaveReqVO {
@NotNull(message = "请选择所属短剧")
private Long dramaId;
@NotNull(message = "请填写集号")
private Integer number;
private String title;
private Integer durationSec;
private String cover;
private String videoUrl;
private Boolean isFree;
private Integer unlockCoin;
private String status;
private Integer sort;
}
@@ -0,0 +1,13 @@
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.List;
@Data
public class EpisodeSortReqVO {
@NotEmpty(message = "请提供排序后的剧集 ID 列表")
private List<Long> ids;
}