diff --git a/docs/plans/2026-10-22-media-transcode-pipeline.md b/docs/plans/2026-10-22-media-transcode-pipeline.md index c914691..a2cf1d7 100644 --- a/docs/plans/2026-10-22-media-transcode-pipeline.md +++ b/docs/plans/2026-10-22-media-transcode-pipeline.md @@ -18,6 +18,9 @@ 3. 新增 `POST /admin/media/{id}/transcode`,校验媒资存在,复用 `episode:upload`。 4. 返回任务 id 与状态,便于后台提示。 +实现时对同一媒资行使用 `FOR UPDATE`,让重复请求在事务内串行判定; +平台不存在的媒资保持 `DATA_NOT_FOUND`,不产生任务或 500。 + ## TDD 测试矩阵 | 场景 | 断言 | diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index 98809ef..0065835 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -25256,3 +25256,61 @@ $ mvn -pl fruitshorts-admin-api -am \ 解读:控制器测试已经先于实现落地,现有上传/分集链路未被破坏。 +### 实现 + +- `TranscodeService.trigger(mediaAssetId, sourceUrl)`: + - QUEUED / RUNNING:直接返回当前任务; + - FAILED / CANCELED:原任务重置为 QUEUED,清空进度、重试次数与错误; + - SUCCESS:复制原任务的 drama/episode 绑定,新建一轮 QUEUED; + - 同一次手动重转产生的 QUEUED 会被下一次请求直接复用。 +- `MediaService.triggerTranscode` 在事务内以 `SELECT ... FOR UPDATE` 锁定媒体行, + 保证同一媒体的并发请求串行;媒体不存在返回 `DATA_NOT_FOUND`,图片拒绝转码。 +- 新增 `POST /admin/media/{id}/transcode`,权限沿用 `episode:upload`, + 写操作受 `ImpersonationGuard` 保护,并记录 `触发媒资转码` 操作日志。 +- worker 既有认领、ffmpeg、HLS/缩略图产物、媒资与分集回填逻辑未改; + 失败任务仍由 worker 写入 `FAILED + error_msg`。 + +### 定向测试 + +```text +$ mvn -pl fruitshorts-transcode-worker -am \ + -Dtest=MediaServiceTranscodePipelineTest,TranscodeServiceTriggerTest \ + -Dsurefire.failIfNoSpecifiedTests=false test +[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 +[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS + +$ mvn -pl fruitshorts-admin-api -am \ + -Dtest=MediaTranscodeTriggerContractTest \ + -Dsurefire.failIfNoSpecifiedTests=false test +[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0 +[INFO] BUILD SUCCESS +``` + +解读:上传完成自动入队、图片不入队、SUCCESS 重转只新建一次、 +QUEUED 重复触发复用、FAILED 原地重置、最终失败原因保留、 +媒资不存在错误语义与手动接口返回结构均通过。 + +### 最终门禁 + +```text +$ mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test +[INFO] Tests run: 32, Failures: 0, Errors: 0, Skipped: 0 +[INFO] fruitshorts-module-media ........................... SUCCESS +[INFO] fruitshorts-transcode-worker ....................... SUCCESS +[INFO] BUILD SUCCESS +``` + +补充跑 admin-api 与依赖模块全量测试: + +```text +$ mvn -pl fruitshorts-admin-api -am test +[INFO] Tests run: 43, Failures: 0, Errors: 0, Skipped: 0 +[INFO] fruitshorts-admin-api .............................. SUCCESS +[INFO] BUILD SUCCESS +``` + +解读:指定门禁含真实 ffmpeg smoke,worker 在本机成功生成 HLS 与缩略图; +后台控制器改动连同既有 admin 契约、权限、服务测试全部通过。 +未启停服务、未执行数据库迁移、未操作真机。 + diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MediaController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MediaController.java index c776ac3..e8781db 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MediaController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MediaController.java @@ -10,6 +10,7 @@ import com.fruitshorts.media.config.MediaTranscodeProfile; import com.fruitshorts.media.service.MediaService; import com.fruitshorts.media.service.MediaTranscodeProfileService; import com.fruitshorts.media.vo.MediaAssetRespVO; +import com.fruitshorts.media.vo.TranscodeTriggerRespVO; import com.fruitshorts.media.vo.UploadCompleteReqVO; import com.fruitshorts.media.vo.UploadInitReqVO; import com.fruitshorts.media.vo.UploadInitRespVO; @@ -84,6 +85,15 @@ public class MediaController { return Result.ok(mediaService.get(id)); } + @Operation(summary = "手动触发或重转媒资") + @PostMapping("/{id}/transcode") + @PreAuthorize("@ss.hasPermission('episode:upload')") + @OperationLog(module = "后台管理", action = "触发媒资转码") + public Result transcode(@PathVariable Long id) { + ImpersonationGuard.rejectWrite(); + return Result.ok(mediaService.triggerTranscode(id)); + } + @Operation(summary = "读取转码档位配置") @GetMapping("/transcode-profile") @PreAuthorize("@ss.hasPermission('episode:upload')") diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaService.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaService.java index 376c56d..9f81a83 100644 --- a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaService.java +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaService.java @@ -11,6 +11,7 @@ import com.fruitshorts.media.dal.entity.MediaUploadTask; import com.fruitshorts.media.dal.mapper.MediaAssetMapper; import com.fruitshorts.media.dal.mapper.MediaUploadTaskMapper; import com.fruitshorts.media.vo.MediaAssetRespVO; +import com.fruitshorts.media.vo.TranscodeTriggerRespVO; import com.fruitshorts.media.vo.UploadCompleteReqVO; import com.fruitshorts.media.vo.UploadInitReqVO; import com.fruitshorts.media.vo.UploadInitRespVO; @@ -199,6 +200,30 @@ public class MediaService { return toResp(asset); } + /** 后台手动触发/重转:锁住媒资行,保证同一媒体的重复请求串行判定任务状态。 */ + @Transactional(rollbackFor = Exception.class) + public TranscodeTriggerRespVO triggerTranscode(Long id) { + MediaAsset asset = id == null ? null : mediaAssetMapper.selectOne( + Wrappers.lambdaQuery() + .eq(MediaAsset::getId, id) + .last("for update")); + if (asset == null) { + throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "媒资不存在"); + } + if (isImage(asset.getFileName())) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "图片媒资无需转码"); + } + String sourceUrl = asset.getSourceUrl(); + if (sourceUrl == null || sourceUrl.isBlank()) { + sourceUrl = asset.getMp4Url(); + } + TranscodeTriggerRespVO result = transcodeService.trigger(asset.getId(), sourceUrl); + asset.setStatus(STATUS_TRANSCODING); + asset.setTranscodeStatus(result.getStatus()); + mediaAssetMapper.updateById(asset); + return result; + } + /** 断点续传用:回显传到第几片、还剩多少;会话归属同样校验。 */ public UploadProgressRespVO progress(String uploadId, Long userId) { MediaUploadTask task = getTask(uploadId); diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/TranscodeService.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/TranscodeService.java index 1f5c893..8f6df97 100644 --- a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/TranscodeService.java +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/TranscodeService.java @@ -11,6 +11,7 @@ import com.fruitshorts.media.dal.entity.TranscodeTask; import com.fruitshorts.media.dal.mapper.MediaRenditionMapper; import com.fruitshorts.media.dal.mapper.MediaThumbnailMapper; import com.fruitshorts.media.dal.mapper.TranscodeTaskMapper; +import com.fruitshorts.media.vo.TranscodeTriggerRespVO; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -71,6 +72,28 @@ public class TranscodeService { return task.getId(); } + /** + * 后台手动触发/重转。进行中任务直接复用;失败任务原地重置;成功任务才新建一轮。 + */ + @Transactional(rollbackFor = Exception.class) + public TranscodeTriggerRespVO trigger(Long mediaAssetId, String sourceUrl) { + TranscodeTask latest = latestTask(mediaAssetId); + if (latest != null && isActive(latest.getStatus())) { + return response(latest.getId(), latest.getStatus()); + } + if (latest != null && isRetryable(latest.getStatus())) { + resetFailedTask(latest, sourceUrl); + return response(latest.getId(), STATUS_QUEUED); + } + + Long dramaId = latest == null ? 0L : latest.getDramaId(); + Long episodeId = latest == null ? 0L : latest.getEpisodeId(); + String retrySource = latest == null || latest.getSourceUrl() == null + || latest.getSourceUrl().isBlank() ? sourceUrl : latest.getSourceUrl(); + Long taskId = enqueue(mediaAssetId, dramaId, episodeId, retrySource); + return response(taskId, STATUS_QUEUED); + } + /** * 分集保存视频地址后绑定任务;成功任务复用并同步回填已有的零绑定产物。 */ @@ -246,6 +269,39 @@ public class TranscodeService { .eq(TranscodeTask::getStatus, status)); } + private TranscodeTask latestTask(Long mediaAssetId) { + return transcodeTaskMapper.selectOne(Wrappers.lambdaQuery() + .eq(TranscodeTask::getMediaAssetId, mediaAssetId) + .orderByDesc(TranscodeTask::getId) + .last("limit 1")); + } + + private boolean isActive(String status) { + return STATUS_QUEUED.equals(status) || STATUS_RUNNING.equals(status); + } + + private boolean isRetryable(String status) { + return STATUS_FAILED.equals(status) || "CANCELED".equals(status); + } + + private void resetFailedTask(TranscodeTask task, String sourceUrl) { + boolean hasSource = sourceUrl != null && !sourceUrl.isBlank(); + transcodeTaskMapper.update(null, Wrappers.lambdaUpdate() + .eq(TranscodeTask::getId, task.getId()) + .in(TranscodeTask::getStatus, List.of(STATUS_FAILED, "CANCELED")) + .set(TranscodeTask::getStatus, STATUS_QUEUED) + .set(TranscodeTask::getProgress, 0) + .set(TranscodeTask::getRetryCount, 0) + .set(TranscodeTask::getErrorMsg, "") + .set(TranscodeTask::getStartedAt, null) + .set(TranscodeTask::getFinishedAt, null) + .set(hasSource, TranscodeTask::getSourceUrl, sourceUrl)); + } + + private TranscodeTriggerRespVO response(Long taskId, String status) { + return new TranscodeTriggerRespVO(taskId, status); + } + private void updateTaskBinding(TranscodeTask task, Long dramaId, Long episodeId, String sourceUrl) { if (task == null) { diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/TranscodeTriggerRespVO.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/TranscodeTriggerRespVO.java new file mode 100644 index 0000000..1bd86f1 --- /dev/null +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/TranscodeTriggerRespVO.java @@ -0,0 +1,17 @@ +// Author: fruit.com.cn + +package com.fruitshorts.media.vo; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** 后台手动触发转码后的任务状态。 */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class TranscodeTriggerRespVO { + + private Long taskId; + private String status; +}