test(media): 覆盖转码认领与非法媒资 id

This commit is contained in:
Zhou Weidong committed 2026-10-22 15:27:37 +08:00
1 parent 08eddc9ed2
commit ad84704d28
4 files changed
+68 -49

No files matched your search

@@ -0,0 +1,24 @@
# 转码任务认领与媒资 ID 校验
## 现场结论
- `media_asset.id=21` 确实存在,source_url 对应分集 id=13。
- 分集 13 的 video_asset_id=0,旧手动触发只拿媒资,不知道分集绑定,
因而创建了 media_asset_id=21、episode_id=0 的 QUEUED 任务。
- claim 只筛选 episode_id>0,因此该任务永久跳过且没有原因日志。
## 修复
1. POST /admin/media/{id}/transcode 严格只按 media_asset.id 查询;
不再把候选 id 当分集 id 兜底或补登记。
2. 媒资存在时按 source_url 反查分集;找到后绑定 drama_id/episode_id。
3. 找不到分集时拒绝建任务,避免生成永远无法认领的 episode_id=0 记录。
4. claim 增加 media_asset_id/source_url 条件,并记录被跳过任务 id 与原因。
5. RUNNING 无更新阈值收敛到 5 分钟,覆盖 worker 重启后的孤儿任务。
6. 一次性 patch 将 media_asset_id 不存在且 episode_id=0 的 QUEUED/RUNNING 标记 FAILED。
## 验证
- 非法 media id 拒绝、未绑定分集拒绝、合法 URL 自动绑定。
- claim 跳过原因、进度更新、僵尸任务恢复。
- `mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test`。
@@ -15,9 +15,7 @@ import com.fruitshorts.media.vo.TranscodeTriggerRespVO;
import com.fruitshorts.media.vo.UploadCompleteReqVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
@@ -40,10 +38,6 @@ class MediaServiceTranscodePipelineTest {
private static final Long EPISODE_ID = 31L;
private static final Long DRAMA_ID = 7L;
private static final String SOURCE_URL = "/files/episode/202610/video.mp4";
private static final String OBJECT_KEY = "episode/202610/video.mp4";
@TempDir
Path tempDir;
private MediaAssetMapper mediaAssetMapper;
private MediaUploadTaskMapper uploadTaskMapper;
@@ -99,70 +93,46 @@ class MediaServiceTranscodePipelineTest {
@Test
void should_return_not_found_when_triggering_transcode_for_missing_media() {
given(mediaAssetMapper.selectOne(any())).willReturn(null);
given(episodeVideoSourcePort.resolve(404L)).willReturn(Optional.empty());
assertThatThrownBy(() -> mediaService.triggerTranscode(404L))
.isInstanceOf(com.fruitshorts.common.exception.ServiceException.class)
.hasMessage("媒资不存在");
verify(transcodeService, never()).trigger(any(), anyString());
verify(transcodeService, never()).trigger(
any(), anyString(), any(), any());
verify(episodeVideoSourcePort, never()).resolveByVideoUrl(anyString());
}
@Test
void should_trigger_transcode_and_mark_asset_queued() {
void should_bind_episode_by_video_url_before_triggering_transcode() {
MediaAsset asset = asset("video.mp4");
given(mediaAssetMapper.selectOne(any())).willReturn(asset);
given(transcodeService.trigger(ASSET_ID, SOURCE_URL))
given(episodeVideoSourcePort.resolveByVideoUrl(SOURCE_URL))
.willReturn(Optional.of(episodeSource()));
given(transcodeService.trigger(ASSET_ID, SOURCE_URL, DRAMA_ID, EPISODE_ID))
.willReturn(new TranscodeTriggerRespVO(100L, TranscodeService.STATUS_QUEUED));
TranscodeTriggerRespVO result = mediaService.triggerTranscode(ASSET_ID);
assertThat(result.getTaskId()).isEqualTo(100L);
assertThat(result.getStatus()).isEqualTo(TranscodeService.STATUS_QUEUED);
verify(episodeVideoSourcePort).link(EPISODE_ID, ASSET_ID);
assertThat(asset.getStatus()).isEqualTo("transcoding");
assertThat(asset.getTranscodeStatus()).isEqualTo(TranscodeService.STATUS_QUEUED);
verify(mediaAssetMapper).updateById(asset);
}
@Test
void should_resolve_existing_media_asset_by_episode_video_url() {
void should_reject_existing_media_when_no_episode_uses_video_url() {
MediaAsset asset = asset("video.mp4");
given(mediaAssetMapper.selectOne(any())).willReturn(null).willReturn(asset);
given(storageService.objectKeyOf(SOURCE_URL)).willReturn(OBJECT_KEY);
given(episodeVideoSourcePort.resolve(EPISODE_ID))
.willReturn(Optional.of(episodeSource()));
given(transcodeService.trigger(ASSET_ID, SOURCE_URL))
.willReturn(new TranscodeTriggerRespVO(100L, TranscodeService.STATUS_QUEUED));
given(mediaAssetMapper.selectOne(any())).willReturn(asset);
given(episodeVideoSourcePort.resolveByVideoUrl(SOURCE_URL))
.willReturn(Optional.empty());
TranscodeTriggerRespVO result = mediaService.triggerTranscode(EPISODE_ID);
assertThat(result.getTaskId()).isEqualTo(100L);
verify(episodeVideoSourcePort).link(EPISODE_ID, ASSET_ID);
verify(transcodeService).trigger(ASSET_ID, SOURCE_URL);
}
@Test
void should_register_legacy_media_asset_when_episode_file_exists() throws Exception {
Path file = tempDir.resolve("video.mp4");
Files.writeString(file, "legacy-video");
given(mediaAssetMapper.selectOne(any())).willReturn(null);
doAnswer(invocation -> {
MediaAsset inserted = invocation.getArgument(0);
inserted.setId(ASSET_ID);
return 1;
}).when(mediaAssetMapper).insert(any(MediaAsset.class));
given(storageService.objectKeyOf(SOURCE_URL)).willReturn(OBJECT_KEY);
given(storageService.fileFor(OBJECT_KEY)).willReturn(file);
given(episodeVideoSourcePort.resolve(EPISODE_ID))
.willReturn(Optional.of(episodeSource()));
given(transcodeService.trigger(ASSET_ID, SOURCE_URL))
.willReturn(new TranscodeTriggerRespVO(100L, TranscodeService.STATUS_QUEUED));
TranscodeTriggerRespVO result = mediaService.triggerTranscode(EPISODE_ID);
assertThat(result.getTaskId()).isEqualTo(100L);
verify(episodeVideoSourcePort).link(EPISODE_ID, ASSET_ID);
verify(mediaAssetMapper).insert(any(MediaAsset.class));
verify(transcodeService).trigger(ASSET_ID, SOURCE_URL);
assertThatThrownBy(() -> mediaService.triggerTranscode(ASSET_ID))
.isInstanceOf(com.fruitshorts.common.exception.ServiceException.class)
.hasMessage("媒资未绑定分集视频");
verify(transcodeService, never()).trigger(
any(), anyString(), any(), any());
}
private void givenCompletedUpload(String fileName) {
@@ -20,7 +20,10 @@ import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import java.nio.file.Path;
@@ -38,6 +41,7 @@ import static org.mockito.Mockito.verify;
* ① 认领任务靠状态条件更新,抢不到就是 null(多进程同时跑也只有一个能转);
* ② objectKey → 本地路径要挡住目录穿越。
*/
@ExtendWith(OutputCaptureExtension.class)
class TranscodeClaimAndStorageTest {
/**
@@ -98,6 +102,10 @@ class TranscodeClaimAndStorageTest {
@Test
void should_exclude_unbound_queued_task_from_claim_query() {
TranscodeTask unbound = task(3L);
unbound.setMediaAssetId(88L);
unbound.setEpisodeId(0L);
given(transcodeTaskMapper.selectList(any())).willReturn(java.util.List.of(unbound));
given(transcodeTaskMapper.selectOne(any())).willReturn(null);
assertThat(transcodeService.claimNext()).isNull();
@@ -105,7 +113,23 @@ class TranscodeClaimAndStorageTest {
@SuppressWarnings("unchecked")
ArgumentCaptor<Wrapper<TranscodeTask>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(transcodeTaskMapper).selectOne(captor.capture());
assertThat(captor.getValue().getSqlSegment()).contains("episode_id >");
assertThat(captor.getValue().getSqlSegment())
.contains("episode_id >")
.contains("media_asset_id >")
.contains("source_url");
}
@Test
void should_log_skip_reason_when_queued_task_has_no_episode(CapturedOutput output) {
TranscodeTask unbound = task(9L);
unbound.setMediaAssetId(88L);
unbound.setEpisodeId(0L);
given(transcodeTaskMapper.selectList(any())).willReturn(java.util.List.of(unbound));
given(transcodeTaskMapper.selectOne(any())).willReturn(null);
assertThat(transcodeService.claimNext()).isNull();
assertThat(output).contains("跳过转码任务 9").contains("episode_id");
}
@Test
@@ -21,6 +21,7 @@ import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -68,7 +69,7 @@ class TranscodeServiceTriggerTest {
assertThat(second.getTaskId()).isEqualTo(10L);
assertThat(second.getStatus()).isEqualTo(TranscodeService.STATUS_QUEUED);
verify(taskMapper, never()).insert(any(TranscodeTask.class));
verify(taskMapper).update(any(), any());
verify(taskMapper, atLeastOnce()).update(any(), any());
}
@Test