diff --git a/docs/api-phase1.md b/docs/api-phase1.md index 6e258dd..927c514 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -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. 契约与实现状态对照 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 new file mode 100644 index 0000000..c488b95 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/CreatorContentController.java @@ -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 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 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> 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 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 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 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 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 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 withdraw(@PathVariable Long id) { + return Result.ok(creatorContentService.withdraw(id)); + } + + /** 我的提交历史(审核进度)。 */ + @GetMapping("/dramas/{id}/submissions") + @PreAuthorize("@ss.hasPermission('creator-center:view')") + public Result> listSubmissions(@PathVariable Long id) { + return Result.ok(creatorContentService.listSubmissions(id)); + } +} diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/enums/ContentStatus.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/enums/ContentStatus.java index b5753d2..d399899 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/enums/ContentStatus.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/enums/ContentStatus.java @@ -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; + } } diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java index cb75f15..f8c2d4c 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/DramaService.java @@ -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); diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/EpisodeService.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/EpisodeService.java index d3e6f3e..0ee8733 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/EpisodeService.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/EpisodeService.java @@ -141,6 +141,20 @@ public class EpisodeService { } } + /** + * 短剧上线时把仍处于「草稿」的分集一并上线,返回受影响条数。 + * + * 为什么需要:C 端只播 status=published 的分集,创作者提交上线、后台审核通过后 + * 若分集还停在草稿,就会出现"剧上线了但一集都播不了"。已下线等其它状态不动。 + */ + @Transactional(rollbackFor = Exception.class) + public int publishDraftEpisodes(Long dramaId) { + return dramaEpisodeMapper.update(null, Wrappers.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.lambdaQuery() .eq(DramaEpisode::getDramaId, dramaId) diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/ContentSubmissionService.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/ContentSubmissionService.java index aa9080b..2a85b61 100644 --- a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/ContentSubmissionService.java +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/ContentSubmissionService.java @@ -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 REVIEWABLE = Set.of(STATUS_SUBMITTED, STATUS_REVIEWING); + /** 在审(还没出结果)的提交:同一部剧同时只允许一条,避免审核结论互相打架。 */ + public static final Set PENDING = Set.of(STATUS_SUBMITTED, STATUS_REVIEWING); + private final ContentSubmissionMapper submissionMapper; private final DramaService dramaService; + private final EpisodeService episodeService; public PageResult page(SubmissionQueryReqVO req) { Page 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.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.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 listByDrama(Long creatorId, Long dramaId) { + return submissionMapper.selectList(Wrappers.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())) { 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 new file mode 100644 index 0000000..d8e0a75 --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/service/CreatorContentService.java @@ -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 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.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 ids) { + Drama drama = centerService.requireMyDrama(dramaId); + requireEditable(drama); + List 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 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; + } +} diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorDramaEditReqVO.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorDramaEditReqVO.java new file mode 100644 index 0000000..35e4a16 --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorDramaEditReqVO.java @@ -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; +} diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorEpisodeReqVO.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorEpisodeReqVO.java new file mode 100644 index 0000000..14f0995 --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorEpisodeReqVO.java @@ -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; +} diff --git a/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorSubmitReqVO.java b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorSubmitReqVO.java new file mode 100644 index 0000000..d67cd7c --- /dev/null +++ b/fruitshorts-module-creator/src/main/java/com/fruitshorts/creator/vo/CreatorSubmitReqVO.java @@ -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; +} diff --git a/sql/data-phase1.sql b/sql/data-phase1.sql index 630196f..d6849fe 100644 --- a/sql/data-phase1.sql +++ b/sql/data-phase1.sql @@ -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;