feat(media): 上传与改片自动入队转码并支持手动重转
This commit is contained in:
1 parent
7e6075a2e9
commit
4219186ab0
6 files changed
+169
No files matched your search
@@ -18,6 +18,9 @@
|
||||
3. 新增 `POST /admin/media/{id}/transcode`,校验媒资存在,复用 `episode:upload`。
|
||||
4. 返回任务 id 与状态,便于后台提示。
|
||||
|
||||
实现时对同一媒资行使用 `FOR UPDATE`,让重复请求在事务内串行判定;
|
||||
平台不存在的媒资保持 `DATA_NOT_FOUND`,不产生任务或 500。
|
||||
|
||||
## TDD 测试矩阵
|
||||
|
||||
| 场景 | 断言 |
|
||||
|
||||
@@ -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 契约、权限、服务测试全部通过。
|
||||
未启停服务、未执行数据库迁移、未操作真机。
|
||||
|
||||
+10
@@ -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<TranscodeTriggerRespVO> transcode(@PathVariable Long id) {
|
||||
ImpersonationGuard.rejectWrite();
|
||||
return Result.ok(mediaService.triggerTranscode(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "读取转码档位配置")
|
||||
@GetMapping("/transcode-profile")
|
||||
@PreAuthorize("@ss.hasPermission('episode:upload')")
|
||||
|
||||
+25
@@ -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.<MediaAsset>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);
|
||||
|
||||
+56
@@ -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.<TranscodeTask>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.<TranscodeTask>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) {
|
||||
|
||||
+17
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user