feat: 07 创作者中心批 2(内容编辑 / 分集 / 提交审核)

为什么:批 1 只给了创作者"看"的能力(工作台、审核进度、通知),内容还是得后台代建。
批 2 补上"改":创作者自己建草稿、编分集、提交审核,后台只管审。落地过程中发现三个必须一起修的问题:

1. 【会卡死创作者】后台驳回提交时不动短剧状态,剧会永远停在 reviewing,
   而批 2 里 reviewing 是只读 —— 创作者既改不了也提不了。现在驳回与撤回都会把剧退回 draft。
2. 【上线了却放不出来】C 端只播 status=published 的分集,但没有任何地方在剧上线时把分集带上线。
   审核通过 PUBLISH 时,仍处于草稿的分集一并上线。
3. 【编辑会误删字段】底层 apply() 对 null 会写空串/默认值:只想改封面的一次调用会把标题、时长、
   排序一起清掉(自测时两集排序都变成 1 才发现)。creator 侧改成"未传字段沿用现值"。

- 新增 CreatorContentService + /admin/creator-center 的 10 个写接口与 2 个读接口,
  一律不接收 creatorId,归属走 requireMyDrama,改别人的作品/分集统一 403
- 状态闸:只有 draft / offline 可直接编辑;reviewing / approved / published 只读并给出下一步动作
- 提交矩阵:draft→CREATE(首次)/UPDATE/PUBLISH,offline→UPDATE/PUBLISH,reviewing/approved→PUBLISH,published→OFFLINE;
  同剧同时只允许一条在审提交;撤回后作品退回草稿,支持撤回重提
- 权限点 creator-center:drama:edit(创作者角色 + 超管),docs/api-phase1.md 2.10 补批 2 接口与规则表
- 分集收费与试看秒数本批不碰(批 3 的收费配置页负责),新增分集默认免费,避免误定价
This commit is contained in:
Vicky committed 2026-10-15 13:58:45 +08:00
1 parent ea875c3e2b
commit 255bcbf02b
11 files changed
+616 -5

No files matched your search

@@ -9,7 +9,9 @@ import com.fruitshorts.common.pojo.PageResult;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.enums.ContentStatus;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.creator.dal.entity.ContentSubmission;
import com.fruitshorts.creator.dal.entity.Creator;
import com.fruitshorts.creator.dal.mapper.ContentSubmissionMapper;
import com.fruitshorts.creator.vo.SubmissionAuditReqVO;
import com.fruitshorts.creator.vo.SubmissionQueryReqVO;
@@ -19,6 +21,8 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/** 创作者内容提交:后台检索、审核,通过后按 type 落到短剧状态机。 */
@@ -40,8 +44,12 @@ public class ContentSubmissionService {
private static final Set<String> REVIEWABLE = Set.of(STATUS_SUBMITTED, STATUS_REVIEWING);
/** 在审(还没出结果)的提交:同一部剧同时只允许一条,避免审核结论互相打架。 */
public static final Set<String> PENDING = Set.of(STATUS_SUBMITTED, STATUS_REVIEWING);
private final ContentSubmissionMapper submissionMapper;
private final DramaService dramaService;
private final EpisodeService episodeService;
public PageResult<ContentSubmission> page(SubmissionQueryReqVO req) {
Page<ContentSubmission> page = new Page<>(req.getSafePageIndex(), req.getSafePageSize());
@@ -63,6 +71,138 @@ public class ContentSubmissionService {
return submission;
}
/**
* 创作者提交内容。
*
* 规则(和短剧状态机对齐,不新造状态):
* - 同一部剧不能有第二条在审提交;
* - 草稿 / 已下线:可以「新建 / 编辑 / 申请上线」,提交后剧进入 reviewing(申请下线除外);
* - 待发布(approved):只能「申请上线」,由后台审核通过后上线;
* - 已上线:只能「申请下线」,审核通过才真正下线。
*
* 创作者归属在这里再校验一次:即使调用方漏了,creatorId 对不上也进不来。
*/
@Transactional(rollbackFor = Exception.class)
public ContentSubmission submit(Creator creator, Long dramaId, String type) {
Drama drama = dramaService.getExisting(dramaId);
if (!Objects.equals(drama.getCreatorId(), creator.getId())) {
throw new ServiceException(ErrorCode.FORBIDDEN, "无权访问该作品");
}
if (!Set.of(TYPE_CREATE, TYPE_UPDATE, TYPE_PUBLISH, TYPE_OFFLINE).contains(type)) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "未知的提交类型: " + type);
}
Long pending = submissionMapper.selectCount(Wrappers.<ContentSubmission>lambdaQuery()
.eq(ContentSubmission::getDramaId, dramaId)
.in(ContentSubmission::getStatus, PENDING));
if (pending != null && pending > 0) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "已有待审核的提交,请先撤回后再提交");
}
Long history = submissionMapper.selectCount(Wrappers.<ContentSubmission>lambdaQuery()
.eq(ContentSubmission::getDramaId, dramaId));
boolean firstSubmit = history == null || history == 0;
String status = drama.getStatus();
ContentStatus current = ContentStatus.of(status);
if (current == null) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "未知的作品状态: " + status);
}
switch (current) {
case DRAFT -> {
if (TYPE_CREATE.equals(type) && !firstSubmit) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"该作品已经提交过,请用「编辑」提交变更");
}
}
case OFFLINE -> {
if (TYPE_CREATE.equals(type)) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"已下线的作品请用「编辑」或「申请上线」提交");
}
}
case APPROVED -> {
if (!TYPE_PUBLISH.equals(type)) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "待发布的作品只能提交「申请上线」");
}
}
case REVIEWING -> {
// 内容审核(CREATE/UPDATE)通过后剧还停在 reviewing:这一步允许再提交「申请上线」,
// 由后台再走一次上线审核(applyToDrama 会把 reviewing 顺推成 approved → published)。
if (!TYPE_PUBLISH.equals(type)) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"审核中的作品只能提交「申请上线」,或先撤回提交");
}
}
case PUBLISHED -> {
if (!TYPE_OFFLINE.equals(type)) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"已上线的作品只读,请提交「申请下线」后再改");
}
}
default -> throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"当前状态(" + ContentStatus.textOf(status) + ")不允许提交");
}
ContentSubmission submission = new ContentSubmission();
submission.setCreatorId(creator.getId());
submission.setDramaId(dramaId);
submission.setType(type);
submission.setStatus(STATUS_SUBMITTED);
submission.setVersion((int) (history + 1));
submission.setSubmitAt(LocalDateTime.now());
submission.setReviewerId(0L);
submission.setReviewNote("");
submission.setRejectCode("");
submissionMapper.insert(submission);
// 申请上线/申请下线要靠后台审核结论去动状态;新建与编辑提交则先推到 reviewing,让创作者看得到进度
if (!TYPE_OFFLINE.equals(type)
&& (ContentStatus.DRAFT.getCode().equals(status)
|| ContentStatus.OFFLINE.getCode().equals(status))) {
dramaService.changeStatus(dramaId, "SUBMIT", "创作者提交审核", creator.getId(), creator.getName());
}
return submission;
}
/**
* 创作者撤回自己的提交(撤回重提)。
* 只有还没出结果的提交能撤回;撤回后短剧退回草稿,创作者可以继续改。
*/
@Transactional(rollbackFor = Exception.class)
public ContentSubmission withdraw(Creator creator, Long submissionId) {
ContentSubmission submission = getExisting(submissionId);
if (!Objects.equals(submission.getCreatorId(), creator.getId())) {
throw new ServiceException(ErrorCode.FORBIDDEN, "无权撤回他人的提交");
}
requireState(submission.getStatus(), "撤回", Set.of(STATUS_SUBMITTED, STATUS_REVIEWING));
submission.setStatus(STATUS_WITHDRAWN);
submission.setReviewerId(creator.getId());
submission.setReviewAt(LocalDateTime.now());
submission.setReviewNote("创作者撤回");
submissionMapper.updateById(submission);
rejectOnDrama(submission, "创作者撤回提交", creator.getId(), creator.getName());
return submission;
}
/** 某部剧的提交历史(新的在前),创作者侧看进度用。 */
public List<ContentSubmission> listByDrama(Long creatorId, Long dramaId) {
return submissionMapper.selectList(Wrappers.<ContentSubmission>lambdaQuery()
.eq(ContentSubmission::getCreatorId, creatorId)
.eq(ContentSubmission::getDramaId, dramaId)
.orderByDesc(ContentSubmission::getId));
}
/**
* 驳回 / 撤回之后把短剧从「审核中」退回草稿。
* 不做这件事的话,创作者的剧会永远停在只读的审核中状态 —— 既改不了也提不了。
*/
private void rejectOnDrama(ContentSubmission submission, String reason, Long operatorId, String operatorName) {
Drama drama = dramaService.getExisting(submission.getDramaId());
if (ContentStatus.REVIEWING.getCode().equals(drama.getStatus())) {
dramaService.changeStatus(submission.getDramaId(), "REJECT", reason, operatorId, operatorName);
}
}
/** 审核:通过 / 驳回 / 撤回。 */
@Transactional(rollbackFor = Exception.class)
public ContentSubmission audit(Long id, SubmissionAuditReqVO req, Long operatorId, String operatorName) {
@@ -82,6 +222,7 @@ public class ContentSubmissionService {
throw new ServiceException(ErrorCode.BAD_REQUEST, "驳回必须填写原因");
}
submission.setStatus(STATUS_REJECTED);
rejectOnDrama(submission, req.getReason(), operatorId, operatorName);
}
case "WITHDRAW" -> {
requireState(current, "撤回", Set.of(STATUS_DRAFT, STATUS_SUBMITTED));
@@ -120,6 +261,8 @@ public class ContentSubmissionService {
dramaService.changeStatus(dramaId, "APPROVE", reason, operatorId, operatorName);
}
dramaService.changeStatus(dramaId, "PUBLISH", reason, operatorId, operatorName);
// 分集还停在草稿的话一并上线,否则会出现"剧上线了却一集都播不了"
episodeService.publishDraftEpisodes(dramaId);
} else if ("OFFLINE".equals(type)) {
Drama drama = dramaService.getExisting(dramaId);
if (ContentStatus.PUBLISHED.getCode().equals(drama.getStatus())) {
@@ -0,0 +1,216 @@
package com.fruitshorts.creator.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.fruitshorts.common.exception.ErrorCode;
import com.fruitshorts.common.exception.ServiceException;
import com.fruitshorts.content.dal.entity.Drama;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.content.enums.ContentStatus;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.content.vo.DramaSaveReqVO;
import com.fruitshorts.content.vo.EpisodeRespVO;
import com.fruitshorts.content.vo.EpisodeSaveReqVO;
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 lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Objects;
/**
* 创作者中心 · 内容编辑(剧信息、分集、提交审核、撤回)。
*
* 与 {@link CreatorCenterService} 分工:那边是"看"(工作台 / 通知),这边是"改"。
* 归属一律走 `requireMyDrama`,**接口不接收 creatorId**;改之前还要过 {@link #requireEditable} 这道状态闸:
* 只有草稿和已下线能直接改,审核中 / 待发布 / 已上线都只读,改动必须走提交审核。
*/
@Service
@RequiredArgsConstructor
public class CreatorContentService {
private final CreatorCenterService centerService;
private final ContentSubmissionService submissionService;
private final DramaService dramaService;
private final EpisodeService episodeService;
private final DramaEpisodeMapper episodeMapper;
/** 新建草稿:归属写自己,状态固定 draft(是否上线由后续提交审核决定)。 */
@Transactional(rollbackFor = Exception.class)
public Long createDrama(CreatorDramaEditReqVO req) {
Creator creator = centerService.requireCurrentCreator();
requireApprovedCreator(creator);
return dramaService.createForCreator(toSaveReq(req), creator.getId(), creator.getAdminUserId());
}
/** 编辑作品信息。定价、热门、排序不在本接口的可改范围,原样回填,避免被默认值冲掉。 */
@Transactional(rollbackFor = Exception.class)
public void updateDrama(Long dramaId, CreatorDramaEditReqVO req) {
Drama drama = centerService.requireMyDrama(dramaId);
requireEditable(drama);
// 没传的字段沿用现值:底层 apply() 对 null 会写空串 / 默认值,直接透传会把"没改的字段"抹掉
DramaSaveReqVO vo = toSaveReq(req, drama);
vo.setFreeEpisodes(drama.getFreeEpisodes());
vo.setUnlockCoin(drama.getUnlockCoin());
vo.setIsHot(drama.getIsHot() != null && drama.getIsHot() == 1);
vo.setSort(drama.getSort());
dramaService.update(dramaId, vo);
}
public List<EpisodeRespVO> listEpisodes(Long dramaId) {
centerService.requireMyDrama(dramaId);
return episodeService.listByDrama(dramaId, null);
}
/** 新增分集:集号不传就接在当前最大集号后面;新分集默认免费,收费由批 3 的配置页决定。 */
@Transactional(rollbackFor = Exception.class)
public Long addEpisode(Long dramaId, CreatorEpisodeReqVO req) {
Drama drama = centerService.requireMyDrama(dramaId);
requireEditable(drama);
Integer epNo = req.getEpNo();
if (epNo == null) {
DramaEpisode last = episodeMapper.selectOne(Wrappers.<DramaEpisode>lambdaQuery()
.eq(DramaEpisode::getDramaId, dramaId)
.orderByDesc(DramaEpisode::getEpNo)
.last("limit 1"));
epNo = last == null || last.getEpNo() == null ? 1 : last.getEpNo() + 1;
}
EpisodeSaveReqVO vo = new EpisodeSaveReqVO();
vo.setDramaId(dramaId);
vo.setNumber(epNo);
vo.setTitle(req.getTitle());
vo.setDurationSec(req.getDurationSec());
vo.setCover(req.getCover());
vo.setVideoUrl(req.getVideoUrl());
vo.setSort(req.getSort());
vo.setIsFree(true);
vo.setUnlockCoin(0);
return episodeService.create(vo);
}
@Transactional(rollbackFor = Exception.class)
public void updateEpisode(Long dramaId, Long episodeId, CreatorEpisodeReqVO req) {
Drama drama = centerService.requireMyDrama(dramaId);
requireEditable(drama);
DramaEpisode episode = requireMyEpisode(dramaId, episodeId);
EpisodeSaveReqVO vo = new EpisodeSaveReqVO();
vo.setDramaId(dramaId);
vo.setNumber(req.getEpNo() == null ? episode.getEpNo() : req.getEpNo());
// 同样是"没传就沿用现值":否则只想改封面的一次调用会把标题、时长和排序一起清掉
vo.setTitle(req.getTitle() == null ? episode.getTitle() : req.getTitle());
vo.setDurationSec(req.getDurationSec() == null ? episode.getDurationSec() : req.getDurationSec());
vo.setCover(req.getCover() == null ? episode.getCoverUrl() : req.getCover());
vo.setVideoUrl(req.getVideoUrl() == null ? episode.getVideoUrl() : req.getVideoUrl());
vo.setSort(req.getSort() == null ? episode.getSort() : req.getSort());
// 收费与试看秒数属于批 3 的收费配置,这里原样带回,别顺手改掉
vo.setIsFree(episode.getIsFree() != null && episode.getIsFree() == 1);
vo.setUnlockCoin(episode.getUnlockCoin());
episodeService.update(episodeId, vo);
}
@Transactional(rollbackFor = Exception.class)
public void deleteEpisode(Long dramaId, Long episodeId) {
Drama drama = centerService.requireMyDrama(dramaId);
requireEditable(drama);
requireMyEpisode(dramaId, episodeId);
episodeService.delete(episodeId);
}
/** 批量排序:先确认列表里每一集都属于这部剧,避免借排序接口动别人的分集。 */
@Transactional(rollbackFor = Exception.class)
public void sortEpisodes(Long dramaId, List<Long> ids) {
Drama drama = centerService.requireMyDrama(dramaId);
requireEditable(drama);
List<DramaEpisode> episodes = episodeMapper.selectBatchIds(ids);
boolean allMine = episodes.size() == ids.size()
&& episodes.stream().allMatch(episode -> Objects.equals(episode.getDramaId(), dramaId));
if (!allMine) {
throw new ServiceException(ErrorCode.FORBIDDEN, "排序列表里含有不属于该作品的分集");
}
episodeService.updateSort(ids);
}
public ContentSubmission submit(Long dramaId, String type) {
Creator creator = centerService.requireCurrentCreator();
requireApprovedCreator(creator);
return submissionService.submit(creator, dramaId, type);
}
public ContentSubmission withdraw(Long submissionId) {
Creator creator = centerService.requireCurrentCreator();
return submissionService.withdraw(creator, submissionId);
}
public List<ContentSubmission> listSubmissions(Long dramaId) {
Creator creator = centerService.requireCurrentCreator();
centerService.requireMyDrama(dramaId);
return submissionService.listByDrama(creator.getId(), dramaId);
}
/** 分集归属:分集必须属于这部剧,否则 403(防止拿自己的剧 id 配别人的分集 id)。 */
private DramaEpisode requireMyEpisode(Long dramaId, Long episodeId) {
DramaEpisode episode = episodeId == null ? null : episodeMapper.selectById(episodeId);
if (episode == null || !Objects.equals(episode.getDramaId(), dramaId)) {
throw new ServiceException(ErrorCode.FORBIDDEN, "无权访问该分集");
}
return episode;
}
/**
* 可编辑状态闸:只有草稿与已下线能直接改。
* 审核中改内容会让审核结论对不上;已上线必须走「申请下线」,待发布等后台发布。
*/
private void requireEditable(Drama drama) {
ContentStatus status = ContentStatus.of(drama.getStatus());
if (status == ContentStatus.DRAFT || status == ContentStatus.OFFLINE) {
return;
}
if (status == null) {
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"作品状态异常(" + drama.getStatus() + "),暂不可编辑");
}
String reason = switch (status) {
case PUBLISHED -> ":已上线的作品只读,请先提交「申请下线」";
case REVIEWING -> ":提交正在审核中,请先撤回再改";
case APPROVED -> ":作品已通过审核待发布,暂不可编辑";
default -> ":当前状态不可编辑";
};
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED,
"「" + ContentStatus.textOf(drama.getStatus()) + "」" + reason);
}
private void requireApprovedCreator(Creator creator) {
if (!CreatorService.STATUS_APPROVED.equals(creator.getStatus())) {
throw new ServiceException(ErrorCode.FORBIDDEN,
"入驻审核通过后才能提交作品,当前状态:" + CreatorService.statusText(creator.getStatus()));
}
}
/** 新建:null 走底层默认值;编辑:null 表示"这次不改这个字段"。 */
private DramaSaveReqVO toSaveReq(CreatorDramaEditReqVO req) {
return toSaveReq(req, null);
}
private DramaSaveReqVO toSaveReq(CreatorDramaEditReqVO req, Drama current) {
DramaSaveReqVO vo = new DramaSaveReqVO();
vo.setTitle(req.getTitle());
vo.setSubtitle(req.getSubtitle() == null && current != null ? current.getSubtitle() : req.getSubtitle());
vo.setCategoryId(req.getCategoryId() == null && current != null ? current.getCategoryId() : req.getCategoryId());
vo.setGenre(req.getGenre() == null && current != null ? current.getGenre() : req.getGenre());
vo.setDescription(req.getDescription() == null && current != null
? current.getSynopsis() : req.getDescription());
vo.setCover(req.getCover() == null && current != null ? current.getCoverUrl() : req.getCover());
vo.setTrailerUrl(req.getTrailerUrl() == null && current != null
? current.getTrailerUrl() : req.getTrailerUrl());
vo.setLanguage(req.getLanguage() == null && current != null ? current.getLanguage() : req.getLanguage());
return vo;
}
}
@@ -0,0 +1,26 @@
package com.fruitshorts.creator.vo;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 创作者中心 · 作品基本信息(新建 / 编辑共用)。
*
* 这里**故意只放内容字段**:免费集数、解锁金币这类定价,以及是否热门、排序等运营字段,
* 由后台或专门的收费配置页改(批 3),创作者编辑内容时不会把定价顺手改掉。
*/
@Data
public class CreatorDramaEditReqVO {
@NotBlank(message = "请填写作品名称")
private String title;
private String subtitle;
private Long categoryId;
private String genre;
private String description;
private String cover;
/** 整剧预告片(复用 drama.trailer_url) */
private String trailerUrl;
private String language;
}
@@ -0,0 +1,22 @@
package com.fruitshorts.creator.vo;
import lombok.Data;
/**
* 创作者中心 · 分集(新增 / 编辑共用)。
*
* 集号不传时按"当前最大集号 + 1"自动排;收费与试看秒数不在这里改(批 3 的收费配置页负责),
* 新增分集默认免费,避免不小心把内容做成收费。
*/
@Data
public class CreatorEpisodeReqVO {
/** 不传则自动取下一集 */
private Integer epNo;
private String title;
private Integer durationSec;
private String cover;
/** 片源地址(批 3 接分片上传后会写这里) */
private String videoUrl;
private Integer sort;
}
@@ -0,0 +1,12 @@
package com.fruitshorts.creator.vo;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/** 创作者中心 · 提交审核:type 取 CREATE / UPDATE / PUBLISH / OFFLINE。 */
@Data
public class CreatorSubmitReqVO {
@NotBlank(message = "请选择提交类型")
private String type;
}