feat(content): 新增运营分类与剧单分类管理

This commit is contained in:
Zhou Weidong authored and trae-solo-agent committed 2026-10-22 22:17:58 +08:00
1 parent 758c7521bb
commit 7b360ddccd
26 files changed
+1106 -3

No files matched your search

@@ -0,0 +1,24 @@
// Author: fruit.com.cn
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;
/** 运营分类:与短剧类型(drama_category)是两套独立概念。 */
@Data
@TableName("drama_classification")
public class DramaClassification {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String name;
private Integer sort;
private Integer status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,22 @@
// Author: fruit.com.cn
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_classification_drama")
public class DramaClassificationDrama {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long classificationId;
private Long dramaId;
private LocalDateTime createdAt;
}
@@ -0,0 +1,26 @@
// Author: fruit.com.cn
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;
/** 剧单专属分类:只服务于剧单分组,与运营分类(drama_classification)无关。 */
@Data
@TableName("drama_playlist_category")
public class DramaPlaylistCategory {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private String name;
/** 分类描述,便于运营说明该分类的用途 */
private String description;
private Integer sort;
private Integer status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,22 @@
// Author: fruit.com.cn
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_playlist_category_drama")
public class DramaPlaylistCategoryDrama {
@TableId(type = IdType.ASSIGN_ID)
private Long id;
private Long categoryId;
private Long dramaId;
private LocalDateTime createdAt;
}
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
package com.fruitshorts.content.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.content.dal.entity.DramaClassificationDrama;
public interface DramaClassificationDramaMapper extends BaseMapper<DramaClassificationDrama> {
}
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
package com.fruitshorts.content.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.content.dal.entity.DramaClassification;
public interface DramaClassificationMapper extends BaseMapper<DramaClassification> {
}
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
package com.fruitshorts.content.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.content.dal.entity.DramaPlaylistCategoryDrama;
public interface DramaPlaylistCategoryDramaMapper extends BaseMapper<DramaPlaylistCategoryDrama> {
}
@@ -0,0 +1,9 @@
// Author: fruit.com.cn
package com.fruitshorts.content.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.fruitshorts.content.dal.entity.DramaPlaylistCategory;
public interface DramaPlaylistCategoryMapper extends BaseMapper<DramaPlaylistCategory> {
}
@@ -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.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.DramaClassification;
import com.fruitshorts.content.dal.entity.DramaClassificationDrama;
import com.fruitshorts.content.dal.mapper.DramaClassificationDramaMapper;
import com.fruitshorts.content.dal.mapper.DramaClassificationMapper;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.vo.DramaBriefVO;
import com.fruitshorts.content.vo.DramaClassificationRespVO;
import com.fruitshorts.content.vo.DramaClassificationSaveReqVO;
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;
/**
* 运营分类服务。分类是独立于「短剧类型(drama_category)」的一套概念,
* 由运营自定义增删,并通过关联表挂载任意多部短剧。
*/
@Service
@RequiredArgsConstructor
public class DramaClassificationService {
private final DramaClassificationMapper classificationMapper;
private final DramaClassificationDramaMapper classificationDramaMapper;
private final DramaMapper dramaMapper;
public List<DramaClassificationRespVO> list() {
Map<Long, Integer> counts = countDramas();
return classificationMapper.selectList(Wrappers.<DramaClassification>lambdaQuery()
.orderByAsc(DramaClassification::getSort)
.orderByDesc(DramaClassification::getId))
.stream().map(item -> {
DramaClassificationRespVO vo = new DramaClassificationRespVO();
vo.setId(item.getId());
vo.setName(item.getName());
vo.setSort(item.getSort());
vo.setStatus(item.getStatus());
vo.setUpdatedAt(item.getUpdatedAt());
vo.setDramaCount(counts.getOrDefault(item.getId(), 0));
return vo;
}).toList();
}
public Long create(DramaClassificationSaveReqVO req) {
ensureNameAvailable(req.getName(), null);
DramaClassification entity = new DramaClassification();
entity.setName(req.getName().trim());
Long count = classificationMapper.selectCount(null);
entity.setSort(req.getSort() == null
? (count == null ? 0 : count.intValue()) + 1
: req.getSort());
entity.setStatus(req.getStatus() == null ? 1 : req.getStatus());
classificationMapper.insert(entity);
return entity.getId();
}
public void update(Long id, DramaClassificationSaveReqVO req) {
DramaClassification entity = getExisting(id);
ensureNameAvailable(req.getName(), id);
entity.setName(req.getName().trim());
if (req.getSort() != null) {
entity.setSort(req.getSort());
}
if (req.getStatus() != null) {
entity.setStatus(req.getStatus());
}
classificationMapper.updateById(entity);
}
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
getExisting(id);
classificationDramaMapper.delete(Wrappers.<DramaClassificationDrama>lambdaQuery()
.eq(DramaClassificationDrama::getClassificationId, id));
classificationMapper.deleteById(id);
}
/** 分类下已关联的短剧(分页)。 */
public PageResult<DramaBriefVO> pageDramas(Long id, String keyword,
int pageIndex, int pageSize) {
return pageDramas(id, keyword, pageIndex, pageSize, true);
}
/** 尚未关联到该分类的短剧(分页),供「添加短剧」多选。 */
public PageResult<DramaBriefVO> pageCandidates(Long id, String keyword,
int pageIndex, int pageSize) {
return pageDramas(id, keyword, pageIndex, pageSize, false);
}
/** 批量关联短剧;已关联的自动跳过。 */
@Transactional(rollbackFor = Exception.class)
public int bindDramas(Long id, List<Long> dramaIds) {
getExisting(id);
if (dramaIds == null || dramaIds.isEmpty()) {
return 0;
}
int added = 0;
for (Long dramaId : dramaIds) {
if (dramaId == null || dramaMapper.selectById(dramaId) == null) {
continue;
}
Long exists = classificationDramaMapper.selectCount(
Wrappers.<DramaClassificationDrama>lambdaQuery()
.eq(DramaClassificationDrama::getClassificationId, id)
.eq(DramaClassificationDrama::getDramaId, dramaId));
if (exists != null && exists > 0) {
continue;
}
DramaClassificationDrama relation = new DramaClassificationDrama();
relation.setClassificationId(id);
relation.setDramaId(dramaId);
classificationDramaMapper.insert(relation);
added++;
}
return added;
}
/** 移出分类;短剧本身不受影响。 */
public void unbindDrama(Long id, Long dramaId) {
getExisting(id);
classificationDramaMapper.delete(Wrappers.<DramaClassificationDrama>lambdaQuery()
.eq(DramaClassificationDrama::getClassificationId, id)
.eq(DramaClassificationDrama::getDramaId, dramaId));
}
private PageResult<DramaBriefVO> pageDramas(Long id, String keyword,
int pageIndex, int pageSize, boolean bound) {
getExisting(id);
Page<Drama> page = new Page<>(Math.max(1, pageIndex), Math.max(1, Math.min(pageSize, 100)));
LambdaQueryWrapper<Drama> wrapper = Wrappers.<Drama>lambdaQuery()
.like(StringUtils.hasText(keyword), Drama::getTitle, keyword)
.orderByDesc(Drama::getSort)
.orderByDesc(Drama::getId);
String subQuery = "SELECT drama_id FROM drama_classification_drama WHERE classification_id = " + id;
if (bound) {
wrapper.inSql(Drama::getId, subQuery);
} else {
wrapper.notInSql(Drama::getId, subQuery);
}
IPage<Drama> result = dramaMapper.selectPage(page, wrapper);
return new PageResult<>(result.getTotal(),
result.getRecords().stream().map(this::toBrief).toList());
}
private Map<Long, Integer> countDramas() {
Map<Long, Integer> counts = new HashMap<>();
classificationDramaMapper.selectMaps(new QueryWrapper<DramaClassificationDrama>()
.select("classification_id AS cid", "COUNT(*) AS cnt")
.groupBy("classification_id"))
.forEach(row -> {
Object cid = row.get("cid");
Object cnt = row.get("cnt");
if (cid instanceof Number c && cnt instanceof Number n) {
counts.put(c.longValue(), n.intValue());
}
});
return counts;
}
private DramaClassification getExisting(Long id) {
DramaClassification entity = id == null ? null : classificationMapper.selectById(id);
if (entity == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "分类不存在");
}
return entity;
}
private void ensureNameAvailable(String name, Long excludeId) {
Long count = classificationMapper.selectCount(Wrappers.<DramaClassification>lambdaQuery()
.eq(DramaClassification::getName, name.trim())
.ne(excludeId != null, DramaClassification::getId, excludeId));
if (count != null && count > 0) {
throw new ServiceException(ErrorCode.DATA_EXISTS, "分类名称已存在");
}
}
private DramaBriefVO toBrief(Drama drama) {
DramaBriefVO vo = new DramaBriefVO();
vo.setId(drama.getId());
vo.setTitle(drama.getTitle());
vo.setCover(drama.getCoverUrl());
vo.setStatus(drama.getStatus());
vo.setTotalEpisodes(drama.getTotalEpisodes());
vo.setSort(drama.getSort());
vo.setUpdatedAt(drama.getUpdatedAt());
return vo;
}
}
@@ -0,0 +1,208 @@
// 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.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.DramaPlaylistCategory;
import com.fruitshorts.content.dal.entity.DramaPlaylistCategoryDrama;
import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.dal.mapper.DramaPlaylistCategoryDramaMapper;
import com.fruitshorts.content.dal.mapper.DramaPlaylistCategoryMapper;
import com.fruitshorts.content.vo.DramaBriefVO;
import com.fruitshorts.content.vo.DramaPlaylistCategoryRespVO;
import com.fruitshorts.content.vo.DramaPlaylistCategorySaveReqVO;
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 DramaPlaylistCategoryService {
private final DramaPlaylistCategoryMapper categoryMapper;
private final DramaPlaylistCategoryDramaMapper categoryDramaMapper;
private final DramaMapper dramaMapper;
public List<DramaPlaylistCategoryRespVO> list() {
Map<Long, Integer> counts = countDramas();
return categoryMapper.selectList(Wrappers.<DramaPlaylistCategory>lambdaQuery()
.orderByAsc(DramaPlaylistCategory::getSort)
.orderByDesc(DramaPlaylistCategory::getId))
.stream().map(item -> {
DramaPlaylistCategoryRespVO vo = new DramaPlaylistCategoryRespVO();
vo.setId(item.getId());
vo.setName(item.getName());
vo.setDescription(item.getDescription());
vo.setSort(item.getSort());
vo.setStatus(item.getStatus());
vo.setDramaCount(counts.getOrDefault(item.getId(), 0));
return vo;
}).toList();
}
public Long create(DramaPlaylistCategorySaveReqVO req) {
ensureNameAvailable(req.getName(), null);
DramaPlaylistCategory entity = new DramaPlaylistCategory();
entity.setName(req.getName().trim());
entity.setDescription(req.getDescription() == null ? "" : req.getDescription().trim());
Long count = categoryMapper.selectCount(null);
entity.setSort(req.getSort() == null
? (count == null ? 0 : count.intValue()) + 1
: req.getSort());
entity.setStatus(req.getStatus() == null ? 1 : req.getStatus());
categoryMapper.insert(entity);
return entity.getId();
}
public void update(Long id, DramaPlaylistCategorySaveReqVO req) {
DramaPlaylistCategory entity = getExisting(id);
ensureNameAvailable(req.getName(), id);
entity.setName(req.getName().trim());
if (req.getDescription() != null) {
entity.setDescription(req.getDescription().trim());
}
if (req.getSort() != null) {
entity.setSort(req.getSort());
}
if (req.getStatus() != null) {
entity.setStatus(req.getStatus());
}
categoryMapper.updateById(entity);
}
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
getExisting(id);
categoryDramaMapper.delete(Wrappers.<DramaPlaylistCategoryDrama>lambdaQuery()
.eq(DramaPlaylistCategoryDrama::getCategoryId, id));
categoryMapper.deleteById(id);
}
/** 分类下已关联的短剧(分页)。 */
public PageResult<DramaBriefVO> pageDramas(Long id, String keyword,
int pageIndex, int pageSize) {
return pageDramas(id, keyword, pageIndex, pageSize, true);
}
/** 尚未关联到该分类的短剧(分页),供「选择短剧」多选。 */
public PageResult<DramaBriefVO> pageCandidates(Long id, String keyword,
int pageIndex, int pageSize) {
return pageDramas(id, keyword, pageIndex, pageSize, false);
}
/** 批量把短剧加入分类;已关联的自动跳过。 */
@Transactional(rollbackFor = Exception.class)
public int bindDramas(Long id, List<Long> dramaIds) {
getExisting(id);
if (dramaIds == null || dramaIds.isEmpty()) {
return 0;
}
int added = 0;
for (Long dramaId : dramaIds) {
if (dramaId == null || dramaMapper.selectById(dramaId) == null) {
continue;
}
Long exists = categoryDramaMapper.selectCount(
Wrappers.<DramaPlaylistCategoryDrama>lambdaQuery()
.eq(DramaPlaylistCategoryDrama::getCategoryId, id)
.eq(DramaPlaylistCategoryDrama::getDramaId, dramaId));
if (exists != null && exists > 0) {
continue;
}
DramaPlaylistCategoryDrama relation = new DramaPlaylistCategoryDrama();
relation.setCategoryId(id);
relation.setDramaId(dramaId);
categoryDramaMapper.insert(relation);
added++;
}
return added;
}
/** 把短剧移出分类;短剧本身不受影响。 */
public void unbindDrama(Long id, Long dramaId) {
getExisting(id);
categoryDramaMapper.delete(Wrappers.<DramaPlaylistCategoryDrama>lambdaQuery()
.eq(DramaPlaylistCategoryDrama::getCategoryId, id)
.eq(DramaPlaylistCategoryDrama::getDramaId, dramaId));
}
private PageResult<DramaBriefVO> pageDramas(Long id, String keyword,
int pageIndex, int pageSize, boolean bound) {
getExisting(id);
Page<Drama> page = new Page<>(Math.max(1, pageIndex), Math.max(1, Math.min(pageSize, 100)));
LambdaQueryWrapper<Drama> wrapper = Wrappers.<Drama>lambdaQuery()
.like(StringUtils.hasText(keyword), Drama::getTitle, keyword)
.orderByDesc(Drama::getSort)
.orderByDesc(Drama::getId);
String subQuery = "SELECT drama_id FROM drama_playlist_category_drama WHERE category_id = " + id;
if (bound) {
wrapper.inSql(Drama::getId, subQuery);
} else {
wrapper.notInSql(Drama::getId, subQuery);
}
IPage<Drama> result = dramaMapper.selectPage(page, wrapper);
return new PageResult<>(result.getTotal(),
result.getRecords().stream().map(this::toBrief).toList());
}
private Map<Long, Integer> countDramas() {
Map<Long, Integer> counts = new HashMap<>();
categoryDramaMapper.selectMaps(new QueryWrapper<DramaPlaylistCategoryDrama>()
.select("category_id AS cid", "COUNT(*) AS cnt")
.groupBy("category_id"))
.forEach(row -> {
Object cid = row.get("cid");
Object cnt = row.get("cnt");
if (cid instanceof Number c && cnt instanceof Number n) {
counts.put(c.longValue(), n.intValue());
}
});
return counts;
}
private DramaPlaylistCategory getExisting(Long id) {
DramaPlaylistCategory entity = id == null ? null : categoryMapper.selectById(id);
if (entity == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "剧单分类不存在");
}
return entity;
}
private void ensureNameAvailable(String name, Long excludeId) {
Long count = categoryMapper.selectCount(Wrappers.<DramaPlaylistCategory>lambdaQuery()
.eq(DramaPlaylistCategory::getName, name.trim())
.ne(excludeId != null, DramaPlaylistCategory::getId, excludeId));
if (count != null && count > 0) {
throw new ServiceException(ErrorCode.DATA_EXISTS, "剧单分类名称已存在");
}
}
private DramaBriefVO toBrief(Drama drama) {
DramaBriefVO vo = new DramaBriefVO();
vo.setId(drama.getId());
vo.setTitle(drama.getTitle());
vo.setCover(drama.getCoverUrl());
vo.setStatus(drama.getStatus());
vo.setTotalEpisodes(drama.getTotalEpisodes());
vo.setSort(drama.getSort());
vo.setUpdatedAt(drama.getUpdatedAt());
return vo;
}
}
@@ -0,0 +1,20 @@
// Author: fruit.com.cn
package com.fruitshorts.content.vo;
import lombok.Data;
import java.time.LocalDateTime;
/** 短剧精简信息:分类关联页列表用。 */
@Data
public class DramaBriefVO {
private Long id;
private String title;
private String cover;
private String status;
private Integer totalEpisodes;
private Integer sort;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,19 @@
// Author: fruit.com.cn
package com.fruitshorts.content.vo;
import lombok.Data;
import java.time.LocalDateTime;
/** 分类列表项:带关联短剧数量。 */
@Data
public class DramaClassificationRespVO {
private Long id;
private String name;
private Integer sort;
private Integer status;
private Integer dramaCount;
private LocalDateTime updatedAt;
}
@@ -0,0 +1,17 @@
// Author: fruit.com.cn
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/** 分类新增/编辑请求体。 */
@Data
public class DramaClassificationSaveReqVO {
@NotBlank(message = "请填写分类名称")
private String name;
private Integer sort;
private Integer status;
}
@@ -0,0 +1,16 @@
// Author: fruit.com.cn
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import java.util.List;
/** 批量操作短剧 id 的通用请求体。 */
@Data
public class DramaIdsReqVO {
@NotEmpty(message = "请选择短剧")
private List<Long> dramaIds;
}
@@ -0,0 +1,18 @@
// Author: fruit.com.cn
package com.fruitshorts.content.vo;
import lombok.Data;
/** 剧单分类列表项:带该分类下的短剧数量。 */
@Data
public class DramaPlaylistCategoryRespVO {
private Long id;
private String name;
private String description;
private Integer sort;
private Integer status;
/** 该分类下已关联的短剧数量 */
private Integer dramaCount;
}
@@ -0,0 +1,21 @@
// Author: fruit.com.cn
package com.fruitshorts.content.vo;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
/** 剧单分类新增/编辑请求体。 */
@Data
public class DramaPlaylistCategorySaveReqVO {
@NotBlank(message = "请填写剧单分类名称")
private String name;
@Size(max = 255, message = "描述最多 255 个字符")
private String description;
private Integer sort;
private Integer status;
}