feat(creator): 新增一键发布与删除作品接口

This commit is contained in:
Zhou Weidong committed 2026-11-09 10:32:46 +08:00
1 parent a36276051d
commit af0bf7f90e
7 files changed
+185 -4

No files matched your search

@@ -12,6 +12,8 @@ import com.fruitshorts.creator.service.CreatorContentService;
import com.fruitshorts.creator.vo.CreatorAttachReqVO;
import com.fruitshorts.creator.vo.CreatorDramaEditReqVO;
import com.fruitshorts.creator.vo.CreatorEpisodeReqVO;
import com.fruitshorts.creator.vo.CreatorPublishReqVO;
import com.fruitshorts.creator.vo.CreatorPublishRespVO;
import com.fruitshorts.creator.vo.CreatorSubmitReqVO;
import com.fruitshorts.framework.log.OperationLog;
import io.swagger.v3.oas.annotations.Operation;
@@ -53,6 +55,15 @@ public class CreatorContentController {
return Result.ok(creatorContentService.createDrama(req));
}
/** 一键发布:建作品 + 建分集 + 绑定视频(可选同时提交审核),一个事务完成。 */
@Operation(summary = "一键发布(建作品 / 分集 / 绑定视频 / 可选提交审核)")
@PostMapping("/publish")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "一键发布作品")
public Result<CreatorPublishRespVO> publish(@Valid @RequestBody CreatorPublishReqVO req) {
return Result.ok(creatorContentService.publish(req));
}
/** 编辑作品信息(仅草稿 / 已下线可改)。 */
@Operation(summary = "编辑作品信息(仅草稿 / 已下线可改)")
@PutMapping("/dramas/{id}")
@@ -101,6 +112,15 @@ public class CreatorContentController {
return Result.ok();
}
@Operation(summary = "删除作品")
@DeleteMapping("/dramas/{id}")
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
@OperationLog(module = "创作者中心", action = "删除作品")
public Result<Void> deleteDrama(@PathVariable Long id) {
creatorContentService.deleteDrama(id);
return Result.ok();
}
/** 批量排序:body 里按新顺序给出分集 id。 */
@Operation(summary = "分集批量排序")
@PutMapping("/dramas/{id}/episodes/sort")
@@ -31,14 +31,14 @@ public class DictController {
@Operation(summary = "短剧分类字典")
@GetMapping("/drama-category")
@PreAuthorize("@ss.hasPermission('drama:list')")
@PreAuthorize("@ss.hasAnyPermission('drama:list','creator-center:view')")
public Result<List<DramaCategoryRespVO>> dramaCategories() {
return Result.ok(dramaCategoryService.list());
}
@Operation(summary = "内容状态字典")
@GetMapping("/content-status")
@PreAuthorize("@ss.hasPermission('drama:list')")
@PreAuthorize("@ss.hasAnyPermission('drama:list','creator-center:view')")
public Result<List<Map<String, String>>> contentStatus() {
return Result.ok(Arrays.stream(ContentStatus.values())
.map(status -> Map.of("code", status.getCode(), "text", status.getText()))
@@ -21,9 +21,12 @@ import com.fruitshorts.creator.dal.entity.ContentSubmission;
import com.fruitshorts.creator.dal.entity.Creator;
import com.fruitshorts.creator.vo.CreatorDramaEditReqVO;
import com.fruitshorts.creator.vo.CreatorEpisodeReqVO;
import com.fruitshorts.creator.vo.CreatorPublishReqVO;
import com.fruitshorts.creator.vo.CreatorPublishRespVO;
import com.fruitshorts.framework.security.SecurityUtils;
import com.fruitshorts.media.dal.entity.MediaAsset;
import com.fruitshorts.media.dal.mapper.MediaAssetMapper;
import com.fruitshorts.media.service.EpisodeTranscodeBindingService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -49,6 +52,7 @@ public class CreatorContentService {
private final PriceService priceService;
private final DramaEpisodeMapper episodeMapper;
private final MediaAssetMapper mediaAssetMapper;
private final EpisodeTranscodeBindingService transcodeBindingService;
/** 新建草稿:归属写自己,状态固定 draft(是否上线由后续提交审核决定)。 */
@Transactional(rollbackFor = Exception.class)
@@ -59,6 +63,59 @@ public class CreatorContentService {
return dramaService.createForCreator(toSaveReq(req), creator.getId(), creator.getAdminUserId());
}
/**
* 一键发布:建作品 + 建全部分集 + 绑定视频 + (可选)提交审核,一个事务做完。
*
* <p>抖音式创作流程的落点:创作者先选好视频(前端已按顺序排列并截好封面),
* 这里按数组顺序分配集号。任何一集绑定失败都整体回滚,不会留下半成品草稿。
*/
@Transactional(rollbackFor = Exception.class)
public CreatorPublishRespVO publish(CreatorPublishReqVO req) {
Creator creator = centerService.requireCurrentCreator();
centerService.requireWritable();
requireApprovedCreator(creator);
List<CreatorPublishReqVO.EpisodeItem> items = req.getEpisodes();
if (items == null || items.isEmpty()) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "请至少上传一集视频");
}
CreatorDramaEditReqVO dramaReq = new CreatorDramaEditReqVO();
dramaReq.setTitle(req.getTitle());
dramaReq.setSubtitle(req.getSubtitle());
dramaReq.setCategoryId(req.getCategoryId());
dramaReq.setGenre(req.getGenre());
dramaReq.setDescription(req.getDescription());
dramaReq.setCover(req.getCover());
dramaReq.setLanguage(req.getLanguage());
if ((dramaReq.getCover() == null || dramaReq.getCover().isBlank())
&& items.get(0).getCover() != null) {
// 没单独传作品封面时,用第一集封面兜底:抖音式流程里用户通常只截了视频首帧
dramaReq.setCover(items.get(0).getCover());
}
Long dramaId = createDrama(dramaReq);
for (int i = 0; i < items.size(); i++) {
CreatorPublishReqVO.EpisodeItem item = items.get(i);
CreatorEpisodeReqVO ep = new CreatorEpisodeReqVO();
ep.setEpNo(i + 1);
ep.setTitle(item.getTitle() == null || item.getTitle().isBlank()
? "第 " + (i + 1) + " 集" : item.getTitle().trim());
ep.setDurationSec(item.getDurationSec());
ep.setCover(item.getCover());
Long episodeId = addEpisode(dramaId, ep);
attachAsset(dramaId, episodeId, item.getAssetId());
}
Long submissionId = null;
if (Boolean.TRUE.equals(req.getSubmit())) {
ContentSubmission submission = submissionService.submit(
creator, dramaId, ContentSubmissionService.TYPE_CREATE);
submissionId = submission.getId();
}
Drama created = dramaService.getExisting(dramaId);
return new CreatorPublishRespVO(dramaId, submissionId, created.getStatus());
}
/** 编辑作品信息。定价、热门、排序不在本接口的可改范围,原样回填,避免被默认值冲掉。 */
@Transactional(rollbackFor = Exception.class)
public void updateDrama(Long dramaId, CreatorDramaEditReqVO req) {
@@ -195,7 +252,10 @@ public class CreatorContentService {
"只能把视频产物(bizType=EPISODE)挂到分集,当前是 " + asset.getBizType());
}
String assetStatus = asset.getStatus() == null ? "" : asset.getStatus();
if (!"ready".equals(assetStatus) && !"uploaded".equals(assetStatus)) {
// transcoding 也要放行:转码任务在 upload complete 时就已入队,此时任务还没绑定分集,
// 必须先把分集挂上再让 bind 绑任务,否则 worker 一直跳过(episode_id 未绑定)。
if (!"ready".equals(assetStatus) && !"uploaded".equals(assetStatus)
&& !"transcoding".equals(assetStatus)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "产物尚未就绪,当前状态:" + assetStatus);
}
@@ -206,6 +266,8 @@ public class CreatorContentService {
episode.setDurationSec(asset.getDurationSec());
}
episodeMapper.updateById(episode);
// 绑定转码任务:把排队中的任务挂到这部剧 / 这一集,worker 才会开始转码并回写 HLS
transcodeBindingService.bind(episode.getId(), dramaId, episode.getVideoUrl());
}
public PriceRespVO priceDetail(Long dramaId) {
@@ -260,6 +322,21 @@ public class CreatorContentService {
"「" + ContentStatus.textOf(drama.getStatus()) + "」" + reason);
}
/** 草稿 / 已下线 / 审核中(允许撤掉)均可直接删;已上线的走 DramaService 自己拦。 */
@Transactional(rollbackFor = Exception.class)
public void deleteDrama(Long dramaId) {
Creator creator = centerService.requireCurrentCreator();
centerService.requireWritable();
Drama drama = dramaService.getExisting(dramaId);
if (!Objects.equals(creator.getId(), drama.getCreatorId())) {
throw new ServiceException(ErrorCode.FORBIDDEN, "无权删除该作品");
}
// 清 submissions(审核中的、撤回的、已审核的),否则残留 submission 挂在不存在的 drama 上
submissionService.deleteByDrama(dramaId);
// 再调 DramaService.delete(禁删 PUBLISHED、级联删 drama_episode)
dramaService.delete(dramaId);
}
private void requireApprovedCreator(Creator creator) {
if (!CreatorService.STATUS_APPROVED.equals(creator.getStatus())) {
throw new ServiceException(ErrorCode.FORBIDDEN,
@@ -0,0 +1,52 @@
// Author: fruit.com.cn
package com.fruitshorts.creator.vo;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.util.List;
/**
* 创作者中心 · 一键发布(抖音式:选视频 → 填标题简介 → 发布)。
*
* 一次请求把「建作品 + 建分集 + 绑定视频」全部做完,避免前端分多次调用时
* 中途失败留下半成品草稿。视频必须先经分片上传拿到 assetId,再由这里绑定。
*/
@Data
public class CreatorPublishReqVO {
@NotBlank(message = "请填写作品名称")
private String title;
private String subtitle;
/** 短剧分类 id(创作者下拉选择,如都市 / 古装 / 甜宠) */
private Long categoryId;
/** 题材标签(可选,自由文本,如"闪婚"、"霸总") */
private String genre;
private String description;
/** 作品封面;不传时取第一集封面 */
private String cover;
private String language;
/** 创建后立即提交审核,默认 true(抖音式一键发布) */
private Boolean submit = true;
@Valid
@NotEmpty(message = "请至少上传一集视频")
private List<EpisodeItem> episodes;
@Data
public static class EpisodeItem {
@NotNull(message = "缺少媒资 id")
private Long assetId;
private String title;
private Integer durationSec;
private String cover;
}
}
@@ -0,0 +1,17 @@
// Author: fruit.com.cn
package com.fruitshorts.creator.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
/** 一键发布结果:作品 id 与本次提交的审核单 id(不提交审核时为空)。 */
@Data
@AllArgsConstructor
public class CreatorPublishRespVO {
private Long dramaId;
private Long submissionId;
/** 作品状态:提交审核后为 reviewing */
private String status;
}
+3 -1
View File
@@ -261,7 +261,9 @@ INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, r
(27, 26, '我的作品', 'workspace', 'creator-workspace', 'views/creator-center/WorkspaceView', '', '', 'creator-center:view', 'MENU', 0, 0, 1, 1),
(28, 26, '作品编辑', 'dramas/:id', 'creator-drama-edit', 'views/creator-center/DramaEditView', '', '', 'creator-center:drama:edit', 'MENU', 0, 0, 2, 0),
(29, 26, '收费配置', 'prices', 'creator-prices', 'views/creator-center/PriceConfigView', '', '', 'creator-center:price:update', 'MENU', 0, 0, 3, 1),
(30, 26, '数据看板', 'dashboard', 'creator-dashboard', 'views/creator-center/DashboardView', '', '', 'creator-center:stat:view', 'MENU', 0, 0, 4, 1)
(30, 26, '数据看板', 'dashboard', 'creator-dashboard', 'views/creator-center/DashboardView', '', '', 'creator-center:stat:view', 'MENU', 0, 0, 4, 1),
-- 31 是隐藏路由(一键发布页,从「我的作品」页的发布按钮进入,不进侧边栏)
(31, 26, '发布作品', 'publish', 'creator-publish', 'views/creator-center/PublishView', '', '', 'creator-center:upload', 'MENU', 0, 0, 0, 0)
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,
@@ -0,0 +1,13 @@
-- Author: fruit.com.cn
-- 创作者中心「一键发布」隐藏路由(幂等,由发布流程执行)
-- visible=0:只作为路由存在,从「我的作品」页的发布按钮进入,不进侧边栏。
INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon,
permission_code, type, always_show, affix, sort, visible) VALUES
(31, 26, '发布作品', 'publish', 'creator-publish', 'views/creator-center/PublishView',
'', '', 'creator-center:upload', 'MENU', 0, 0, 0, 0)
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, visible = EXCLUDED.visible;