diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DramaClassificationController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DramaClassificationController.java new file mode 100644 index 0000000..d9e8661 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DramaClassificationController.java @@ -0,0 +1,115 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.controller; + +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.content.service.DramaClassificationService; +import com.fruitshorts.content.vo.DramaBriefVO; +import com.fruitshorts.content.vo.DramaClassificationRespVO; +import com.fruitshorts.content.vo.DramaClassificationSaveReqVO; +import com.fruitshorts.content.vo.DramaIdsReqVO; +import com.fruitshorts.framework.log.OperationLog; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 运营分类管理。分类与短剧类型(drama-categories)是两套独立概念, + * 分类可自定义增删,并关联任意多部短剧。 + */ +@Tag(name = "后台-运营分类") +@RestController +@RequestMapping("/admin/drama-classifications") +@RequiredArgsConstructor +public class DramaClassificationController { + + private final DramaClassificationService classificationService; + + @Operation(summary = "分类列表(带关联数量)") + @GetMapping + @PreAuthorize("@ss.hasPermission('drama:list')") + public Result> list() { + return Result.ok(classificationService.list()); + } + + @Operation(summary = "新建分类") + @PostMapping + @PreAuthorize("@ss.hasPermission('drama:create')") + @OperationLog(module = "内容管理", action = "新增分类") + public Result create(@Valid @RequestBody DramaClassificationSaveReqVO req) { + return Result.ok(classificationService.create(req)); + } + + @Operation(summary = "编辑分类") + @PutMapping("/{id}") + @PreAuthorize("@ss.hasPermission('drama:update')") + @OperationLog(module = "内容管理", action = "编辑分类") + public Result update(@PathVariable Long id, + @Valid @RequestBody DramaClassificationSaveReqVO req) { + classificationService.update(id, req); + return Result.ok(); + } + + @Operation(summary = "删除分类") + @DeleteMapping("/{id}") + @PreAuthorize("@ss.hasPermission('drama:delete')") + @OperationLog(module = "内容管理", action = "删除分类") + public Result delete(@PathVariable Long id) { + classificationService.delete(id); + return Result.ok(); + } + + @Operation(summary = "分类下已关联的短剧") + @GetMapping("/{id}/dramas") + @PreAuthorize("@ss.hasPermission('drama:list')") + public Result> dramas( + @PathVariable Long id, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "1") int pageIndex, + @RequestParam(defaultValue = "10") int pageSize) { + return Result.ok(classificationService.pageDramas(id, keyword, pageIndex, pageSize)); + } + + @Operation(summary = "尚未关联到该分类的短剧") + @GetMapping("/{id}/dramas/candidates") + @PreAuthorize("@ss.hasPermission('drama:list')") + public Result> candidates( + @PathVariable Long id, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "1") int pageIndex, + @RequestParam(defaultValue = "10") int pageSize) { + return Result.ok(classificationService.pageCandidates(id, keyword, pageIndex, pageSize)); + } + + @Operation(summary = "批量关联短剧") + @PostMapping("/{id}/dramas") + @PreAuthorize("@ss.hasPermission('drama:update')") + @OperationLog(module = "内容管理", action = "分类关联短剧") + public Result bindDramas(@PathVariable Long id, + @Valid @RequestBody DramaIdsReqVO req) { + return Result.ok(classificationService.bindDramas(id, req.getDramaIds())); + } + + @Operation(summary = "移除分类下的短剧") + @DeleteMapping("/{id}/dramas/{dramaId}") + @PreAuthorize("@ss.hasPermission('drama:update')") + @OperationLog(module = "内容管理", action = "分类移除短剧") + public Result unbindDrama(@PathVariable Long id, @PathVariable Long dramaId) { + classificationService.unbindDrama(id, dramaId); + return Result.ok(); + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DramaPlaylistCategoryController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DramaPlaylistCategoryController.java new file mode 100644 index 0000000..02a4db7 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DramaPlaylistCategoryController.java @@ -0,0 +1,115 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.controller; + +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.content.service.DramaPlaylistCategoryService; +import com.fruitshorts.content.vo.DramaBriefVO; +import com.fruitshorts.content.vo.DramaIdsReqVO; +import com.fruitshorts.content.vo.DramaPlaylistCategoryRespVO; +import com.fruitshorts.content.vo.DramaPlaylistCategorySaveReqVO; +import com.fruitshorts.framework.log.OperationLog; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 剧单分类管理:分类直接关联短剧,没有「剧单」这一层。 + * 与「类型」「分类管理」均无关。 + */ +@Tag(name = "后台-剧单分类") +@RestController +@RequestMapping("/admin/drama-playlist-categories") +@RequiredArgsConstructor +public class DramaPlaylistCategoryController { + + private final DramaPlaylistCategoryService categoryService; + + @Operation(summary = "剧单分类列表") + @GetMapping + @PreAuthorize("@ss.hasPermission('drama:list')") + public Result> list() { + return Result.ok(categoryService.list()); + } + + @Operation(summary = "新建剧单分类") + @PostMapping + @PreAuthorize("@ss.hasPermission('drama:create')") + @OperationLog(module = "内容管理", action = "新增剧单分类") + public Result create(@Valid @RequestBody DramaPlaylistCategorySaveReqVO req) { + return Result.ok(categoryService.create(req)); + } + + @Operation(summary = "编辑剧单分类") + @PutMapping("/{id}") + @PreAuthorize("@ss.hasPermission('drama:update')") + @OperationLog(module = "内容管理", action = "编辑剧单分类") + public Result update(@PathVariable Long id, + @Valid @RequestBody DramaPlaylistCategorySaveReqVO req) { + categoryService.update(id, req); + return Result.ok(); + } + + @Operation(summary = "删除剧单分类") + @DeleteMapping("/{id}") + @PreAuthorize("@ss.hasPermission('drama:delete')") + @OperationLog(module = "内容管理", action = "删除剧单分类") + public Result delete(@PathVariable Long id) { + categoryService.delete(id); + return Result.ok(); + } + + @Operation(summary = "分类下已关联的短剧") + @GetMapping("/{id}/dramas") + @PreAuthorize("@ss.hasPermission('drama:list')") + public Result> dramas( + @PathVariable Long id, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "1") int pageIndex, + @RequestParam(defaultValue = "10") int pageSize) { + return Result.ok(categoryService.pageDramas(id, keyword, pageIndex, pageSize)); + } + + @Operation(summary = "尚未关联到该分类的短剧") + @GetMapping("/{id}/dramas/candidates") + @PreAuthorize("@ss.hasPermission('drama:list')") + public Result> candidates( + @PathVariable Long id, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "1") int pageIndex, + @RequestParam(defaultValue = "10") int pageSize) { + return Result.ok(categoryService.pageCandidates(id, keyword, pageIndex, pageSize)); + } + + @Operation(summary = "批量关联短剧") + @PostMapping("/{id}/dramas") + @PreAuthorize("@ss.hasPermission('drama:update')") + @OperationLog(module = "内容管理", action = "剧单分类关联短剧") + public Result bindDramas(@PathVariable Long id, + @Valid @RequestBody DramaIdsReqVO req) { + return Result.ok(categoryService.bindDramas(id, req.getDramaIds())); + } + + @Operation(summary = "移除分类下的短剧") + @DeleteMapping("/{id}/dramas/{dramaId}") + @PreAuthorize("@ss.hasPermission('drama:update')") + @OperationLog(module = "内容管理", action = "剧单分类移除短剧") + public Result unbindDrama(@PathVariable Long id, @PathVariable Long dramaId) { + categoryService.unbindDrama(id, dramaId); + return Result.ok(); + } +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaClassification.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaClassification.java new file mode 100644 index 0000000..5911e20 --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaClassification.java @@ -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; +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaClassificationDrama.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaClassificationDrama.java new file mode 100644 index 0000000..cf0b0dc --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaClassificationDrama.java @@ -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; +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaPlaylistCategory.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaPlaylistCategory.java new file mode 100644 index 0000000..61ef7d2 --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaPlaylistCategory.java @@ -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; +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaPlaylistCategoryDrama.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaPlaylistCategoryDrama.java new file mode 100644 index 0000000..82d70e1 --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/entity/DramaPlaylistCategoryDrama.java @@ -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; +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/mapper/DramaClassificationDramaMapper.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/mapper/DramaClassificationDramaMapper.java new file mode 100644 index 0000000..cf368e2 --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/mapper/DramaClassificationDramaMapper.java @@ -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 { +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/mapper/DramaClassificationMapper.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/mapper/DramaClassificationMapper.java new file mode 100644 index 0000000..40dec93 --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/mapper/DramaClassificationMapper.java @@ -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 { +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/mapper/DramaPlaylistCategoryDramaMapper.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/mapper/DramaPlaylistCategoryDramaMapper.java new file mode 100644 index 0000000..0aa03b5 --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/mapper/DramaPlaylistCategoryDramaMapper.java @@ -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 { +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/mapper/DramaPlaylistCategoryMapper.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/mapper/DramaPlaylistCategoryMapper.java new file mode 100644 index 0000000..ccb37f7 --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/dal/mapper/DramaPlaylistCategoryMapper.java @@ -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 { +} 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 new file mode 100644 index 0000000..96f2df4 --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaClassificationService.java @@ -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 list() { + Map counts = countDramas(); + return classificationMapper.selectList(Wrappers.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.lambdaQuery() + .eq(DramaClassificationDrama::getClassificationId, id)); + classificationMapper.deleteById(id); + } + + /** 分类下已关联的短剧(分页)。 */ + public PageResult pageDramas(Long id, String keyword, + int pageIndex, int pageSize) { + return pageDramas(id, keyword, pageIndex, pageSize, true); + } + + /** 尚未关联到该分类的短剧(分页),供「添加短剧」多选。 */ + public PageResult 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 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.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.lambdaQuery() + .eq(DramaClassificationDrama::getClassificationId, id) + .eq(DramaClassificationDrama::getDramaId, dramaId)); + } + + private PageResult pageDramas(Long id, String keyword, + int pageIndex, int pageSize, boolean bound) { + getExisting(id); + Page page = new Page<>(Math.max(1, pageIndex), Math.max(1, Math.min(pageSize, 100))); + LambdaQueryWrapper wrapper = Wrappers.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 result = dramaMapper.selectPage(page, wrapper); + return new PageResult<>(result.getTotal(), + result.getRecords().stream().map(this::toBrief).toList()); + } + + private Map countDramas() { + Map counts = new HashMap<>(); + classificationDramaMapper.selectMaps(new QueryWrapper() + .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.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; + } +} 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 new file mode 100644 index 0000000..7507847 --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaPlaylistCategoryService.java @@ -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 list() { + Map counts = countDramas(); + return categoryMapper.selectList(Wrappers.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.lambdaQuery() + .eq(DramaPlaylistCategoryDrama::getCategoryId, id)); + categoryMapper.deleteById(id); + } + + /** 分类下已关联的短剧(分页)。 */ + public PageResult pageDramas(Long id, String keyword, + int pageIndex, int pageSize) { + return pageDramas(id, keyword, pageIndex, pageSize, true); + } + + /** 尚未关联到该分类的短剧(分页),供「选择短剧」多选。 */ + public PageResult 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 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.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.lambdaQuery() + .eq(DramaPlaylistCategoryDrama::getCategoryId, id) + .eq(DramaPlaylistCategoryDrama::getDramaId, dramaId)); + } + + private PageResult pageDramas(Long id, String keyword, + int pageIndex, int pageSize, boolean bound) { + getExisting(id); + Page page = new Page<>(Math.max(1, pageIndex), Math.max(1, Math.min(pageSize, 100))); + LambdaQueryWrapper wrapper = Wrappers.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 result = dramaMapper.selectPage(page, wrapper); + return new PageResult<>(result.getTotal(), + result.getRecords().stream().map(this::toBrief).toList()); + } + + private Map countDramas() { + Map counts = new HashMap<>(); + categoryDramaMapper.selectMaps(new QueryWrapper() + .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.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; + } +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaBriefVO.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaBriefVO.java new file mode 100644 index 0000000..d9c6edb --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaBriefVO.java @@ -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; +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaClassificationRespVO.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaClassificationRespVO.java new file mode 100644 index 0000000..65cf039 --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaClassificationRespVO.java @@ -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; +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaClassificationSaveReqVO.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaClassificationSaveReqVO.java new file mode 100644 index 0000000..6c023f4 --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaClassificationSaveReqVO.java @@ -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; +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaIdsReqVO.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaIdsReqVO.java new file mode 100644 index 0000000..193b272 --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaIdsReqVO.java @@ -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 dramaIds; +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaPlaylistCategoryRespVO.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaPlaylistCategoryRespVO.java new file mode 100644 index 0000000..1163548 --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaPlaylistCategoryRespVO.java @@ -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; +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaPlaylistCategorySaveReqVO.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaPlaylistCategorySaveReqVO.java new file mode 100644 index 0000000..5275cf4 --- /dev/null +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/vo/DramaPlaylistCategorySaveReqVO.java @@ -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; +} diff --git a/sql/data.sql b/sql/data.sql index 69c2c1e..972c339 100644 --- a/sql/data.sql +++ b/sql/data.sql @@ -57,6 +57,8 @@ INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, r (5, 3, '剧集管理', 'episodes', 'episodes', 'views/contentManagement/EpisodeManagementView', '', '', 'episode:list', 'MENU', 0, 0, 2), (12, 3, '轮播图管理', 'banners', 'banners', 'views/contentManagement/BannerManagementView', '', '', 'banner:list', 'MENU', 0, 0, 3), (13, 3, '类型管理', 'categories', 'categories', 'views/contentManagement/CategoryManagementView','', '', 'drama:list', 'MENU', 0, 0, 4), + (36, 3, '分类管理', 'category-dramas','category-dramas','views/contentManagement/CategoryDramaView', '', '', 'drama:list', 'MENU', 0, 0, 5), + (37, 3, '剧单管理', 'drama-playlists','drama-playlists','views/contentManagement/DramaPlaylistView', '', '', 'drama:list', 'MENU', 0, 0, 6), (6, 0, '后台管理', '/system', 'System', '#', '/system/access', 'material-symbols:settings-rounded', 'system:role', 'CATALOG', 1, 0, 3), (7, 6, '角色权限配置', 'access', 'access', 'views/permission/RoleManagementView', '', '', 'system:role', 'MENU', 0, 0, 1), (8, 6, '管理员用户列表','admin-users','admin-users', 'views/userManagement/AdminUsersView', '', '', 'system:admin-user','MENU', 0, 0, 2), @@ -77,9 +79,17 @@ ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED. redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, permission_code = EXCLUDED.permission_code, type = EXCLUDED.type, always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort; +-- 转码设置菜单(挂在后台管理下,仅超级管理员可见) +INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon, + permission_code, type, always_show, affix, sort) VALUES + (35, 6, '转码设置', 'transcode-settings', 'transcode-settings', 'views/systemManagement/TranscodeSettingsView', '', '', 'episode:upload', 'MENU', 0, 0, 7) +ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.title, + route_path = EXCLUDED.route_path, route_name = EXCLUDED.route_name, component = EXCLUDED.component, + redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, permission_code = EXCLUDED.permission_code, + type = EXCLUDED.type, always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort; + -- K23:二级菜单统一只显示文字,历史图标值也一并清空。 UPDATE sys_menu SET icon = '' WHERE type = 'MENU'; - -- ---------- 角色-权限 ---------- INSERT INTO sys_role_permission (id, role_id, permission_id) SELECT r.id * 1000000 + p.id, r.id, p.id FROM sys_role r CROSS JOIN sys_permission p WHERE r.code = 'super_admin' @@ -112,7 +122,7 @@ ON CONFLICT DO NOTHING; INSERT INTO sys_role_menu (id, role_id, menu_id) SELECT r.id * 1000000 + m.id, r.id, m.id FROM sys_role r CROSS JOIN sys_menu m -WHERE r.code IN ('content_operator','content_reviewer','data_analyst') AND m.id IN (1,2,3,4,5,12,13) +WHERE r.code IN ('content_operator','content_reviewer','data_analyst') AND m.id IN (1,2,3,4,5,12,13,36,37) ON CONFLICT DO NOTHING; -- ---------- 管理员(密码均为 admin123) ---------- diff --git a/sql/patch-2026-10-22-category-drama-menu.sql b/sql/patch-2026-10-22-category-drama-menu.sql new file mode 100644 index 0000000..49978ec --- /dev/null +++ b/sql/patch-2026-10-22-category-drama-menu.sql @@ -0,0 +1,21 @@ +-- Author: fruit.com.cn +-- 后台「分类管理」菜单与分类-短剧关联页(幂等,仅提交文件,由发布流程执行) + +INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon, + permission_code, type, always_show, affix, sort) VALUES + (36, 3, '分类管理', 'category-dramas', 'category-dramas', + 'views/contentManagement/CategoryDramaView', '', '', + 'drama:list', 'MENU', 0, 0, 5) +ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.title, + route_path = EXCLUDED.route_path, route_name = EXCLUDED.route_name, + component = EXCLUDED.component, redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, + permission_code = EXCLUDED.permission_code, type = EXCLUDED.type, + always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort; + +INSERT INTO sys_role_menu (id, role_id, menu_id) +SELECT r.id * 1000000 + m.id, r.id, m.id +FROM sys_role r +CROSS JOIN sys_menu m +WHERE r.code IN ('super_admin', 'content_operator', 'content_reviewer', 'data_analyst') + AND m.id = 36 +ON CONFLICT DO NOTHING; diff --git a/sql/patch-2026-10-22-drama-classification.sql b/sql/patch-2026-10-22-drama-classification.sql new file mode 100644 index 0000000..d1d2605 --- /dev/null +++ b/sql/patch-2026-10-22-drama-classification.sql @@ -0,0 +1,31 @@ +-- Author: fruit.com.cn +-- 运营分类(与「类型」drama_category 是两个概念):分类可自定义增删,并关联多部短剧 +-- 幂等,仅提交文件,由发布流程执行 + +CREATE TABLE IF NOT EXISTS drama_classification ( + id BIGINT PRIMARY KEY, + name VARCHAR(64) NOT NULL, + sort INTEGER NOT NULL DEFAULT 0, + status SMALLINT NOT NULL DEFAULT 1, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_drama_classification_name ON drama_classification (name); +COMMENT ON TABLE drama_classification IS '运营分类;与短剧类型(drama_category)相互独立'; + +CREATE TABLE IF NOT EXISTS drama_classification_drama ( + id BIGINT PRIMARY KEY, + classification_id BIGINT NOT NULL, + drama_id BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_classification_drama + ON drama_classification_drama (classification_id, drama_id); +CREATE INDEX IF NOT EXISTS idx_classification_drama_drama + ON drama_classification_drama (drama_id); +COMMENT ON TABLE drama_classification_drama IS '分类与短剧的关联关系'; + +DROP TRIGGER IF EXISTS trg_drama_classification_updated_at ON drama_classification; +CREATE TRIGGER trg_drama_classification_updated_at + BEFORE UPDATE ON drama_classification + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); diff --git a/sql/patch-2026-10-22-drama-playlist-menu.sql b/sql/patch-2026-10-22-drama-playlist-menu.sql new file mode 100644 index 0000000..bf1fb13 --- /dev/null +++ b/sql/patch-2026-10-22-drama-playlist-menu.sql @@ -0,0 +1,21 @@ +-- Author: fruit.com.cn +-- 后台「剧单管理」菜单(幂等,仅提交文件,由发布流程执行) + +INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon, + permission_code, type, always_show, affix, sort) VALUES + (37, 3, '剧单管理', 'drama-playlists', 'drama-playlists', + 'views/contentManagement/DramaPlaylistView', '', '', + 'drama:list', 'MENU', 0, 0, 6) +ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.title, + route_path = EXCLUDED.route_path, route_name = EXCLUDED.route_name, + component = EXCLUDED.component, redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, + permission_code = EXCLUDED.permission_code, type = EXCLUDED.type, + always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort; + +INSERT INTO sys_role_menu (id, role_id, menu_id) +SELECT r.id * 1000000 + m.id, r.id, m.id +FROM sys_role r +CROSS JOIN sys_menu m +WHERE r.code IN ('super_admin', 'content_operator', 'content_reviewer', 'data_analyst') + AND m.id = 37 +ON CONFLICT DO NOTHING; diff --git a/sql/patch-2026-10-22-drama-playlist.sql b/sql/patch-2026-10-22-drama-playlist.sql new file mode 100644 index 0000000..79ecf4c --- /dev/null +++ b/sql/patch-2026-10-22-drama-playlist.sql @@ -0,0 +1,31 @@ +-- Author: fruit.com.cn +-- 剧单:运营编排的短剧集合,归属某个分类,并可关联多部短剧 +-- 幂等,仅提交文件,由发布流程执行 + +CREATE TABLE IF NOT EXISTS drama_playlist ( + id BIGINT PRIMARY KEY, + classification_id BIGINT NOT NULL DEFAULT 0, + name VARCHAR(64) NOT NULL, + sort INTEGER NOT NULL DEFAULT 0, + status SMALLINT NOT NULL DEFAULT 1, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_drama_playlist_name ON drama_playlist (name); +CREATE INDEX IF NOT EXISTS idx_drama_playlist_classification ON drama_playlist (classification_id); +COMMENT ON TABLE drama_playlist IS '剧单;归属分类,可关联多部短剧'; + +CREATE TABLE IF NOT EXISTS drama_playlist_drama ( + id BIGINT PRIMARY KEY, + playlist_id BIGINT NOT NULL, + drama_id BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_playlist_drama ON drama_playlist_drama (playlist_id, drama_id); +CREATE INDEX IF NOT EXISTS idx_playlist_drama_drama ON drama_playlist_drama (drama_id); +COMMENT ON TABLE drama_playlist_drama IS '剧单与短剧的关联关系'; + +DROP TRIGGER IF EXISTS trg_drama_playlist_updated_at ON drama_playlist; +CREATE TRIGGER trg_drama_playlist_updated_at + BEFORE UPDATE ON drama_playlist + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); diff --git a/sql/patch-2026-10-22-playlist-category-drama.sql b/sql/patch-2026-10-22-playlist-category-drama.sql new file mode 100644 index 0000000..56fa881 --- /dev/null +++ b/sql/patch-2026-10-22-playlist-category-drama.sql @@ -0,0 +1,18 @@ +-- Author: fruit.com.cn +-- 剧单分类直接关联短剧(不再有「剧单」这一层) +-- 幂等,仅提交文件,由发布流程执行 + +CREATE TABLE IF NOT EXISTS drama_playlist_category_drama ( + id BIGINT PRIMARY KEY, + category_id BIGINT NOT NULL, + drama_id BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_playlist_category_drama + ON drama_playlist_category_drama (category_id, drama_id); +CREATE INDEX IF NOT EXISTS idx_playlist_category_drama_drama + ON drama_playlist_category_drama (drama_id); +COMMENT ON TABLE drama_playlist_category_drama IS '剧单分类与短剧的关联关系'; + +DROP TABLE IF EXISTS drama_playlist_drama; +DROP TABLE IF EXISTS drama_playlist; diff --git a/sql/patch-2026-10-22-playlist-category.sql b/sql/patch-2026-10-22-playlist-category.sql new file mode 100644 index 0000000..2e9a0e5 --- /dev/null +++ b/sql/patch-2026-10-22-playlist-category.sql @@ -0,0 +1,31 @@ +-- Author: fruit.com.cn +-- 剧单专属分类(与「分类管理」的运营分类无关),并调整剧单归属字段 +-- 幂等,仅提交文件,由发布流程执行 + +CREATE TABLE IF NOT EXISTS drama_playlist_category ( + id BIGINT PRIMARY KEY, + name VARCHAR(64) NOT NULL, + sort INTEGER NOT NULL DEFAULT 0, + status SMALLINT NOT NULL DEFAULT 1, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_drama_playlist_category_name + ON drama_playlist_category (name); +COMMENT ON TABLE drama_playlist_category IS '剧单专属分类;与运营分类(drama_classification)相互独立'; + +ALTER TABLE drama_playlist DROP COLUMN IF EXISTS classification_id; +ALTER TABLE drama_playlist ADD COLUMN IF NOT EXISTS playlist_category_id BIGINT NOT NULL DEFAULT 0; +CREATE INDEX IF NOT EXISTS idx_drama_playlist_category + ON drama_playlist (playlist_category_id); +COMMENT ON COLUMN drama_playlist.playlist_category_id IS '剧单分类;0 表示未分类'; + +DROP TRIGGER IF EXISTS trg_drama_playlist_category_updated_at ON drama_playlist_category; +CREATE TRIGGER trg_drama_playlist_category_updated_at + BEFORE UPDATE ON drama_playlist_category + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +-- Author: fruit.com.cn +-- 剧单分类增加描述字段(幂等) + +ALTER TABLE drama_playlist_category ADD COLUMN IF NOT EXISTS description VARCHAR(255) NOT NULL DEFAULT ''; +COMMENT ON COLUMN drama_playlist_category.description IS '分类描述'; diff --git a/sql/schema-phase1.sql b/sql/schema-phase1.sql index 1110c7f..c41f4fd 100644 --- a/sql/schema-phase1.sql +++ b/sql/schema-phase1.sql @@ -869,9 +869,64 @@ CREATE INDEX IF NOT EXISTS idx_audit_log_biz ON audit_log (biz_type, biz_id, cre COMMENT ON TABLE audit_log IS '业务对象变更审计(支付、权益、定价等敏感操作)'; -- ============================================================ --- 九、updated_at 触发器(与已有表保持一致) +-- 八之二、运营分类(与短剧类型 drama_category 相互独立) -- ============================================================ +CREATE TABLE IF NOT EXISTS drama_classification ( + id BIGINT PRIMARY KEY, + name VARCHAR(64) NOT NULL, + sort INTEGER NOT NULL DEFAULT 0, + status SMALLINT NOT NULL DEFAULT 1, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_drama_classification_name ON drama_classification (name); +COMMENT ON TABLE drama_classification IS '运营分类;与短剧类型(drama_category)相互独立'; + +CREATE TABLE IF NOT EXISTS drama_classification_drama ( + id BIGINT PRIMARY KEY, + classification_id BIGINT NOT NULL, + drama_id BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_classification_drama + ON drama_classification_drama (classification_id, drama_id); +CREATE INDEX IF NOT EXISTS idx_classification_drama_drama + ON drama_classification_drama (drama_id); +COMMENT ON TABLE drama_classification_drama IS '分类与短剧的关联关系'; + +-- ============================================================ +-- 八之三、剧单(剧单有自己的一套分类,与运营分类无关) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS drama_playlist_category ( + id BIGINT PRIMARY KEY, + name VARCHAR(64) NOT NULL, + description VARCHAR(255) NOT NULL DEFAULT '', + sort INTEGER NOT NULL DEFAULT 0, + status SMALLINT NOT NULL DEFAULT 1, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_drama_playlist_category_name + ON drama_playlist_category (name); +COMMENT ON TABLE drama_playlist_category IS '剧单专属分类;与运营分类(drama_classification)相互独立'; + +CREATE TABLE IF NOT EXISTS drama_playlist_category_drama ( + id BIGINT PRIMARY KEY, + category_id BIGINT NOT NULL, + drama_id BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_playlist_category_drama + ON drama_playlist_category_drama (category_id, drama_id); +CREATE INDEX IF NOT EXISTS idx_playlist_category_drama_drama + ON drama_playlist_category_drama (drama_id); +COMMENT ON TABLE drama_playlist_category_drama IS '剧单分类与短剧的关联关系'; + +-- ============================================================ +-- 九、updated_at 触发器(与已有表保持一致) +-- ============================================================ DO $$ DECLARE t TEXT; @@ -885,6 +940,8 @@ BEGIN 'reconcile_task', 'creator', 'creator_document', 'content_submission', 'creator_stat_daily', 'creator_settlement', 'transcode_task', 'media_rendition', 'media_thumbnail', + 'drama_classification', + 'drama_playlist_category', 'ops_notice', 'ops_daily_stat' ] LOOP