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:
1 parent
ea875c3e2b
commit
255bcbf02b
11 files changed
+616
-5
No files matched your search
+34
-2
@@ -354,7 +354,7 @@ POST /app/auth/register
|
||||
菜单、权限点与超管授权由 `sql/data-phase1.sql` 幂等落库(内容治理 / 订单与权益 / 数据看板挂菜单 id 20–25,创作者两条为 17–19)。
|
||||
页面只读态与可操作态按权限点区分:没有 `*:update` / `*:audit` 的角色进入页面时按钮隐藏或显示「只读」。
|
||||
|
||||
### 2.10 创作者中心(批 1 已实现:工作台)
|
||||
### 2.10 创作者中心(批 1 + 批 2 已实现:工作台 / 内容编辑与提交)
|
||||
|
||||
创作者 Web 端做在后台管理里(`ReelShortWeb`),身份复用 `sys_admin_user`:
|
||||
`creator.admin_user_id` 绑定后台账号(部分唯一索引,0 = 未绑定),角色 `creator` 只拿 `creator-center:*` 权限点。
|
||||
@@ -373,7 +373,39 @@ POST /app/auth/register
|
||||
通知不新建表:第一阶段由 `creator`(入驻审核)与 `content_submission`(内容提交审核)派生;
|
||||
系统公告 `ops_notice` 目前没有产出方,等有公告功能再接。
|
||||
|
||||
**进度**:批 2(内容编辑与分集、提交审核)、批 3(分片上传、分集收费)、批 4(创作者数据看板)、批 5(前端页面)未开始。
|
||||
**内容编辑与提交(批 2)**
|
||||
|
||||
| 方法 | 路径 | 权限点 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| POST | `/admin/creator-center/dramas` | `creator-center:drama:edit` | 新建草稿(归属写自己,状态固定 `draft`) |
|
||||
| PUT | `/admin/creator-center/dramas/{id}` | `creator-center:drama:edit` | 编辑作品信息(**未传的字段沿用现值**) |
|
||||
| GET | `/admin/creator-center/dramas/{id}/episodes` | `creator-center:view` | 分集列表 |
|
||||
| POST | `/admin/creator-center/dramas/{id}/episodes` | `creator-center:drama:edit` | 新增分集(不传集号则排到最大集号之后,默认免费) |
|
||||
| PUT | `/admin/creator-center/dramas/{id}/episodes/{episodeId}` | `creator-center:drama:edit` | 编辑分集(时长 / 封面 / 片源 / 排序;未传字段沿用现值) |
|
||||
| DELETE | `/admin/creator-center/dramas/{id}/episodes/{episodeId}` | `creator-center:drama:edit` | 删除分集 |
|
||||
| PUT | `/admin/creator-center/dramas/{id}/episodes/sort` | `creator-center:drama:edit` | 批量排序(列表里出现不属于该剧的分集直接 403) |
|
||||
| POST | `/admin/creator-center/dramas/{id}/submit` | `creator-center:drama:edit` | 提交审核,`type` = `CREATE` / `UPDATE` / `PUBLISH` / `OFFLINE` |
|
||||
| POST | `/admin/creator-center/submissions/{id}/withdraw` | `creator-center:drama:edit` | 撤回自己的提交(撤回后作品退回草稿) |
|
||||
| GET | `/admin/creator-center/dramas/{id}/submissions` | `creator-center:view` | 我的提交历史(审核进度) |
|
||||
|
||||
**可编辑状态闸**:只有 `draft`(草稿)与 `offline`(已下线)能直接改内容;`reviewing` 提示"提交正在审核中,请先撤回再改",
|
||||
`approved` 不可编辑,`published` 提示"已上线的作品只读,请先提交「申请下线」"(错误码统一 `200003`)。
|
||||
|
||||
**提交矩阵**(状态 → 允许的提交类型):
|
||||
|
||||
| 作品状态 | 允许提交 | 提交后 |
|
||||
| --- | --- | --- |
|
||||
| `draft` | `CREATE`(仅首次)/ `UPDATE` / `PUBLISH` | 作品转 `reviewing` |
|
||||
| `offline` | `UPDATE` / `PUBLISH` | 作品转 `reviewing` |
|
||||
| `reviewing` | `PUBLISH`(内容审核通过后再申请上线) | 保持 `reviewing`,等后台上线审核 |
|
||||
| `approved` | `PUBLISH` | 保持 `approved`,等后台上线审核 |
|
||||
| `published` | `OFFLINE` | 保持 `published`,等后台下线审核 |
|
||||
|
||||
同一部剧**同时只能有一条在审提交**;提交后按其类型落到短剧状态机(审核动作仍在后台,创作者侧不新造状态)。
|
||||
后台审核 `PUBLISH` 通过时,该剧仍处于 `draft` 的分集**一并上线**(否则会出现"剧上线了却一集都播不了");
|
||||
后台**驳回**或创作者**撤回**时,处于 `reviewing` 的剧退回 `draft`,避免创作者卡在只读状态。
|
||||
|
||||
**进度**:批 3(分片上传、分集收费配置)、批 4(创作者数据看板)、批 5(前端页面)未开始。
|
||||
|
||||
## 3. 契约与实现状态对照
|
||||
|
||||
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package com.fruitshorts.admin.controller;
|
||||
|
||||
import com.fruitshorts.common.pojo.Result;
|
||||
import com.fruitshorts.content.vo.EpisodeRespVO;
|
||||
import com.fruitshorts.content.vo.EpisodeSortReqVO;
|
||||
import com.fruitshorts.creator.dal.entity.ContentSubmission;
|
||||
import com.fruitshorts.creator.service.CreatorContentService;
|
||||
import com.fruitshorts.creator.vo.CreatorDramaEditReqVO;
|
||||
import com.fruitshorts.creator.vo.CreatorEpisodeReqVO;
|
||||
import com.fruitshorts.creator.vo.CreatorSubmitReqVO;
|
||||
import com.fruitshorts.framework.log.OperationLog;
|
||||
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.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 创作者中心 · 内容编辑(剧信息 / 分集 / 提交审核 / 撤回)。
|
||||
*
|
||||
* 与 {@link CreatorCenterController} 一样**不接收 creatorId**:归属由登录态解析,
|
||||
* 服务层强制 `where creator_id = 当前创作者`,改别人的剧或分集一律 403。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/admin/creator-center")
|
||||
@RequiredArgsConstructor
|
||||
public class CreatorContentController {
|
||||
|
||||
private final CreatorContentService creatorContentService;
|
||||
|
||||
/** 新建草稿。 */
|
||||
@PostMapping("/dramas")
|
||||
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
|
||||
@OperationLog(module = "创作者中心", action = "新建作品")
|
||||
public Result<Long> createDrama(@Valid @RequestBody CreatorDramaEditReqVO req) {
|
||||
return Result.ok(creatorContentService.createDrama(req));
|
||||
}
|
||||
|
||||
/** 编辑作品信息(仅草稿 / 已下线可改)。 */
|
||||
@PutMapping("/dramas/{id}")
|
||||
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
|
||||
@OperationLog(module = "创作者中心", action = "编辑作品")
|
||||
public Result<Void> updateDrama(@PathVariable Long id, @Valid @RequestBody CreatorDramaEditReqVO req) {
|
||||
creatorContentService.updateDrama(id, req);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/** 分集列表。 */
|
||||
@GetMapping("/dramas/{id}/episodes")
|
||||
@PreAuthorize("@ss.hasPermission('creator-center:view')")
|
||||
public Result<List<EpisodeRespVO>> listEpisodes(@PathVariable Long id) {
|
||||
return Result.ok(creatorContentService.listEpisodes(id));
|
||||
}
|
||||
|
||||
/** 新增分集(集号不传自动排到最后)。 */
|
||||
@PostMapping("/dramas/{id}/episodes")
|
||||
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
|
||||
@OperationLog(module = "创作者中心", action = "新增分集")
|
||||
public Result<Long> addEpisode(@PathVariable Long id, @RequestBody CreatorEpisodeReqVO req) {
|
||||
return Result.ok(creatorContentService.addEpisode(id, req));
|
||||
}
|
||||
|
||||
/** 编辑分集(时长 / 封面 / 片源 / 排序)。 */
|
||||
@PutMapping("/dramas/{id}/episodes/{episodeId}")
|
||||
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
|
||||
@OperationLog(module = "创作者中心", action = "编辑分集")
|
||||
public Result<Void> updateEpisode(@PathVariable Long id, @PathVariable Long episodeId,
|
||||
@RequestBody CreatorEpisodeReqVO req) {
|
||||
creatorContentService.updateEpisode(id, episodeId, req);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/** 删除分集。 */
|
||||
@DeleteMapping("/dramas/{id}/episodes/{episodeId}")
|
||||
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
|
||||
@OperationLog(module = "创作者中心", action = "删除分集")
|
||||
public Result<Void> deleteEpisode(@PathVariable Long id, @PathVariable Long episodeId) {
|
||||
creatorContentService.deleteEpisode(id, episodeId);
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/** 批量排序:body 里按新顺序给出分集 id。 */
|
||||
@PutMapping("/dramas/{id}/episodes/sort")
|
||||
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
|
||||
@OperationLog(module = "创作者中心", action = "调整分集顺序")
|
||||
public Result<Void> sortEpisodes(@PathVariable Long id, @Valid @RequestBody EpisodeSortReqVO req) {
|
||||
creatorContentService.sortEpisodes(id, req.getIds());
|
||||
return Result.ok();
|
||||
}
|
||||
|
||||
/** 提交审核:CREATE / UPDATE / PUBLISH / OFFLINE。 */
|
||||
@PostMapping("/dramas/{id}/submit")
|
||||
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
|
||||
@OperationLog(module = "创作者中心", action = "提交审核")
|
||||
public Result<ContentSubmission> submit(@PathVariable Long id, @Valid @RequestBody CreatorSubmitReqVO req) {
|
||||
return Result.ok(creatorContentService.submit(id, req.getType()));
|
||||
}
|
||||
|
||||
/** 撤回自己的提交(撤回后作品退回草稿,可继续编辑)。 */
|
||||
@PostMapping("/submissions/{id}/withdraw")
|
||||
@PreAuthorize("@ss.hasPermission('creator-center:drama:edit')")
|
||||
@OperationLog(module = "创作者中心", action = "撤回提交")
|
||||
public Result<ContentSubmission> withdraw(@PathVariable Long id) {
|
||||
return Result.ok(creatorContentService.withdraw(id));
|
||||
}
|
||||
|
||||
/** 我的提交历史(审核进度)。 */
|
||||
@GetMapping("/dramas/{id}/submissions")
|
||||
@PreAuthorize("@ss.hasPermission('creator-center:view')")
|
||||
public Result<List<ContentSubmission>> listSubmissions(@PathVariable Long id) {
|
||||
return Result.ok(creatorContentService.listSubmissions(id));
|
||||
}
|
||||
}
|
||||
+10
@@ -30,4 +30,14 @@ public enum ContentStatus {
|
||||
}
|
||||
return code;
|
||||
}
|
||||
|
||||
/** 按状态码取枚举,未知状态返回 null(由调用方决定怎么提示)。 */
|
||||
public static ContentStatus of(String code) {
|
||||
for (ContentStatus status : values()) {
|
||||
if (status.code.equals(code)) {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+14
@@ -65,10 +65,24 @@ public class DramaService {
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long create(DramaSaveReqVO req, Long operatorId) {
|
||||
return insert(req, operatorId, 0L);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创作者侧建草稿:和后台建剧走同一套字段映射,只是额外写入归属 creator_id。
|
||||
* 后台账号记在 create_by(操作留痕),归属与操作人分开存,避免"谁建的"和"是谁的"混成一个字段。
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long createForCreator(DramaSaveReqVO req, Long creatorId, Long operatorId) {
|
||||
return insert(req, operatorId, creatorId);
|
||||
}
|
||||
|
||||
private Long insert(DramaSaveReqVO req, Long operatorId, Long creatorId) {
|
||||
Drama drama = new Drama();
|
||||
apply(drama, req);
|
||||
drama.setCode(generateCode());
|
||||
drama.setStatus(ContentStatus.DRAFT.getCode());
|
||||
drama.setCreatorId(creatorId == null ? 0L : creatorId);
|
||||
drama.setTotalEpisodes(0);
|
||||
drama.setViewCount(0L);
|
||||
drama.setPlayCount(0L);
|
||||
|
||||
+14
@@ -141,6 +141,20 @@ public class EpisodeService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 短剧上线时把仍处于「草稿」的分集一并上线,返回受影响条数。
|
||||
*
|
||||
* 为什么需要:C 端只播 status=published 的分集,创作者提交上线、后台审核通过后
|
||||
* 若分集还停在草稿,就会出现"剧上线了但一集都播不了"。已下线等其它状态不动。
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int publishDraftEpisodes(Long dramaId) {
|
||||
return dramaEpisodeMapper.update(null, Wrappers.<DramaEpisode>lambdaUpdate()
|
||||
.eq(DramaEpisode::getDramaId, dramaId)
|
||||
.eq(DramaEpisode::getStatus, ContentStatus.DRAFT.getCode())
|
||||
.set(DramaEpisode::getStatus, ContentStatus.PUBLISHED.getCode()));
|
||||
}
|
||||
|
||||
private void ensureEpNoAvailable(Long dramaId, Integer epNo, Long excludeId) {
|
||||
Long count = dramaEpisodeMapper.selectCount(Wrappers.<DramaEpisode>lambdaQuery()
|
||||
.eq(DramaEpisode::getDramaId, dramaId)
|
||||
|
||||
+143
@@ -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())) {
|
||||
|
||||
+216
@@ -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;
|
||||
}
|
||||
}
|
||||
+26
@@ -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;
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
+12
@@ -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;
|
||||
}
|
||||
+4
-3
@@ -178,7 +178,8 @@ ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, code = EXCLUDED.code,
|
||||
|
||||
-- 权限点按批补:批 1 只有「进创作者中心」这一个,后面各批用到什么再幂等追加
|
||||
INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES
|
||||
('creator-center:view', '访问创作者中心', '创作者中心', 'BUTTON', 150)
|
||||
('creator-center:view', '访问创作者中心', '创作者中心', 'BUTTON', 150),
|
||||
('creator-center:drama:edit', '创作者中心-编辑作品', '创作者中心', 'BUTTON', 151)
|
||||
ON CONFLICT (code) DO NOTHING;
|
||||
|
||||
-- 创作者角色只拿创作者中心这几个点,不给任何后台运营权限
|
||||
@@ -187,7 +188,7 @@ SELECT r.id, p.id
|
||||
FROM sys_role r
|
||||
CROSS JOIN sys_permission p
|
||||
WHERE r.code = 'creator'
|
||||
AND p.code = 'creator-center:view'
|
||||
AND p.code IN ('creator-center:view', 'creator-center:drama:edit')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- 超管也留一份,方便排查创作者视角的问题(超管没有绑定创作者主体,进 creator-center 仍会被挡)
|
||||
@@ -196,5 +197,5 @@ SELECT r.id, p.id
|
||||
FROM sys_role r
|
||||
CROSS JOIN sys_permission p
|
||||
WHERE r.code = 'super_admin'
|
||||
AND p.code = 'creator-center:view'
|
||||
AND p.code IN ('creator-center:view', 'creator-center:drama:edit')
|
||||
ON CONFLICT DO NOTHING;
|
||||
Reference in new issue
Block a user