diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CreatorContentController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CreatorContentController.java index 162d423..35bd8d3 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CreatorContentController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CreatorContentController.java @@ -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 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 deleteDrama(@PathVariable Long id) { + creatorContentService.deleteDrama(id); + return Result.ok(); + } + /** 批量排序:body 里按新顺序给出分集 id。 */ @Operation(summary = "分集批量排序") @PutMapping("/dramas/{id}/episodes/sort") diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DictController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DictController.java index 99c8b3b..e96555a 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DictController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DictController.java @@ -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> 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>> contentStatus() { return Result.ok(Arrays.stream(ContentStatus.values()) .map(status -> Map.of("code", status.getCode(), "text", status.getText())) diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorContentService.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorContentService.java index 361a920..16476cf 100644 --- a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorContentService.java +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorContentService.java @@ -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()); } + /** + * 一键发布:建作品 + 建全部分集 + 绑定视频 + (可选)提交审核,一个事务做完。 + * + *

抖音式创作流程的落点:创作者先选好视频(前端已按顺序排列并截好封面), + * 这里按数组顺序分配集号。任何一集绑定失败都整体回滚,不会留下半成品草稿。 + */ + @Transactional(rollbackFor = Exception.class) + public CreatorPublishRespVO publish(CreatorPublishReqVO req) { + Creator creator = centerService.requireCurrentCreator(); + centerService.requireWritable(); + requireApprovedCreator(creator); + List 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, diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorPublishReqVO.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorPublishReqVO.java new file mode 100644 index 0000000..03a47c0 --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorPublishReqVO.java @@ -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 episodes; + + @Data + public static class EpisodeItem { + + @NotNull(message = "缺少媒资 id") + private Long assetId; + + private String title; + private Integer durationSec; + private String cover; + } +} diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorPublishRespVO.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorPublishRespVO.java new file mode 100644 index 0000000..08e9ec1 --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorPublishRespVO.java @@ -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; +} diff --git a/sql/data-phase1.sql b/sql/data-phase1.sql index b4d47e5..c011526 100644 --- a/sql/data-phase1.sql +++ b/sql/data-phase1.sql @@ -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, diff --git a/sql/patch-2026-11-05-creator-publish-menu.sql b/sql/patch-2026-11-05-creator-publish-menu.sql new file mode 100644 index 0000000..2c11d21 --- /dev/null +++ b/sql/patch-2026-11-05-creator-publish-menu.sql @@ -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;