diff --git a/docs/plans/2026-10-22-transcode-claim-fix.md b/docs/plans/2026-10-22-transcode-claim-fix.md new file mode 100644 index 0000000..3246efa --- /dev/null +++ b/docs/plans/2026-10-22-transcode-claim-fix.md @@ -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`。 diff --git a/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/MediaServiceTranscodePipelineTest.java b/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/MediaServiceTranscodePipelineTest.java index ce95335..8b01bec 100644 --- a/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/MediaServiceTranscodePipelineTest.java +++ b/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/MediaServiceTranscodePipelineTest.java @@ -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) { diff --git a/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/TranscodeClaimAndStorageTest.java b/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/TranscodeClaimAndStorageTest.java index 3d3b8ba..1ebd756 100644 --- a/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/TranscodeClaimAndStorageTest.java +++ b/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/TranscodeClaimAndStorageTest.java @@ -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> 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 diff --git a/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/TranscodeServiceTriggerTest.java b/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/TranscodeServiceTriggerTest.java index 998de70..533cadc 100644 --- a/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/TranscodeServiceTriggerTest.java +++ b/fruitshorts-transcode-worker/src/test/java/com/fruitshorts/worker/TranscodeServiceTriggerTest.java @@ -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