fix(media): 清理旧 rendition 并确保失败状态落库

This commit is contained in:
Zhou Weidong committed 2026-10-22 15:36:35 +08:00
1 parent 5d1a157085
commit 91b621fe75
9 files changed
+226 -35

No files matched your search

@@ -199,6 +199,53 @@ class TranscodeClaimAndStorageTest {
verify(transcodeTaskMapper).updateById(task);
}
@Test
void should_delete_old_renditions_by_media_or_episode_before_insert() {
TranscodeTask task = task(6L);
task.setMediaAssetId(99L);
task.setEpisodeId(31L);
given(transcodeTaskMapper.selectById(6L)).willReturn(task);
MediaRendition rendition = new MediaRendition();
rendition.setQuality("360p");
rendition.setFormat("HLS");
transcodeService.succeed(6L, java.util.List.of(rendition));
@SuppressWarnings("unchecked")
ArgumentCaptor<Wrapper<MediaRendition>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(mediaRenditionMapper).delete(captor.capture());
assertThat(captor.getValue().getSqlSegment())
.containsIgnoringCase("media_asset_id")
.containsIgnoringCase("episode_id")
.containsIgnoringCase("OR");
assertThat(captor.getValue().getSqlSegment())
.doesNotContainIgnoringCase("AND (");
verify(mediaRenditionMapper).insert(rendition);
}
@Test
void should_replace_existing_thumbnail_by_episode_id() {
TranscodeTask task = task(7L);
task.setDramaId(7L);
task.setEpisodeId(31L);
MediaThumbnail existing = new MediaThumbnail();
existing.setId(70L);
existing.setEpisodeId(31L);
given(mediaThumbnailMapper.selectOne(any())).willReturn(existing);
MediaThumbnail thumbnail = new MediaThumbnail();
thumbnail.setEpisodeId(31L);
thumbnail.setStatus(MediaThumbnail.STATUS_READY);
ThumbnailRecordService recordService = new ThumbnailRecordService(mediaThumbnailMapper);
recordService.upsert(task, thumbnail);
@SuppressWarnings("unchecked")
ArgumentCaptor<Wrapper<MediaThumbnail>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(mediaThumbnailMapper).delete(captor.capture());
assertThat(captor.getValue().getSqlSegment()).containsIgnoringCase("episode_id");
verify(mediaThumbnailMapper).insert(thumbnail);
}
@Test
void should_reject_object_key_that_escapes_the_storage_root() {
assertThatThrownBy(() -> storageService.fileFor("../../etc/passwd"))
@@ -3,6 +3,7 @@
package com.fruitshorts.worker;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fruitshorts.media.dal.entity.MediaRendition;
import com.fruitshorts.media.dal.entity.MediaThumbnail;
@@ -17,6 +18,9 @@ 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.mockito.ArgumentCaptor;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
@@ -142,6 +146,32 @@ class TranscodeServiceTriggerTest {
verify(taskMapper).update(any(), any());
}
@Test
void should_persist_failed_status_and_tail_when_fail_final_is_called() {
given(taskMapper.update(any(), any())).willReturn(1);
String reason = "ffmpeg output head: " + "x".repeat(1000)
+ " UNIQUE_TAIL_DUPLICATE_KEY";
transcodeService.failFinal(61L, reason);
@SuppressWarnings("unchecked")
ArgumentCaptor<com.baomidou.mybatisplus.core.conditions.Wrapper<TranscodeTask>>
captor = ArgumentCaptor.forClass(
com.baomidou.mybatisplus.core.conditions.Wrapper.class);
verify(taskMapper).update(any(), captor.capture());
@SuppressWarnings("unchecked")
LambdaUpdateWrapper<TranscodeTask> wrapper =
(LambdaUpdateWrapper<TranscodeTask>) captor.getValue();
assertThat(wrapper.getSqlSet())
.containsIgnoringCase("status")
.containsIgnoringCase("error_msg")
.containsIgnoringCase("finished_at");
Map<String, Object> params = wrapper.getParamNameValuePairs();
assertThat(params.values()).contains("FAILED");
assertThat(params.values())
.anyMatch(value -> String.valueOf(value).contains("UNIQUE_TAIL_DUPLICATE_KEY"));
}
private TranscodeTask task(Long id, String status) {
TranscodeTask task = new TranscodeTask();
task.setId(id);
@@ -15,7 +15,10 @@ import com.fruitshorts.media.service.transcode.TranscodeRunner;
import com.fruitshorts.media.service.transcode.TranscodeWorker;
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.Paths;
import java.util.List;
@@ -28,6 +31,7 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
/**
@@ -35,6 +39,7 @@ import static org.mockito.Mockito.verify;
*
* <p>用替身 `TranscodeRunner`,所以**不依赖 ffmpeg**;真实 ffmpeg 的验证在 ffmpeg smoke 测试里。
*/
@ExtendWith(OutputCaptureExtension.class)
class TranscodeWorkerTest {
private static final Long TASK_ID = 88L;
@@ -138,33 +143,43 @@ class TranscodeWorkerTest {
}
@Test
void should_keep_task_queued_and_record_reason_when_transcode_fails_before_max_retry() {
void should_mark_task_failed_immediately_when_transcode_fails() {
TranscodeTask task = queuedTask();
task.setRetryCount(1);
given(transcodeService.claimNext()).willReturn(task);
given(transcodeService.fail(eq(TASK_ID), anyString())).willReturn(TranscodeService.STATUS_QUEUED);
runner.failure = new IllegalStateException("ffmpeg 退出码 1:Invalid data found");
worker.processOne();
verify(transcodeService).fail(eq(TASK_ID), contains("Invalid data found"));
// 还会重试,所以媒资不能置 failed(仍期待下一次成功)
verify(mediaService, never()).markFailed(any(), anyString());
verify(transcodeService).failFinal(eq(TASK_ID), contains("Invalid data found"));
verify(mediaService).markFailed(eq(ASSET_ID), contains("Invalid data found"));
verify(mediaService, never()).markReady(any(), anyString(), anyString(), any());
}
@Test
void should_mark_asset_failed_when_retries_are_exhausted() {
void should_mark_asset_failed_when_rendition_write_throws_duplicate_key() {
TranscodeTask task = queuedTask();
task.setRetryCount(2);
given(transcodeService.claimNext()).willReturn(task);
given(transcodeService.fail(eq(TASK_ID), anyString())).willReturn(TranscodeService.STATUS_FAILED);
runner.failure = new IllegalStateException("ffmpeg 未产出播放列表");
doThrow(new IllegalStateException(
"duplicate key uk_rendition"))
.when(transcodeService).succeed(eq(TASK_ID), any(), any());
worker.processOne();
verify(transcodeService).fail(eq(TASK_ID), contains("未产出播放列表"));
verify(mediaService).markFailed(eq(ASSET_ID), contains("未产出播放列表"));
verify(transcodeService).failFinal(eq(TASK_ID), contains("uk_rendition"));
verify(mediaService).markFailed(eq(ASSET_ID), contains("uk_rendition"));
}
@Test
void should_log_error_without_escaping_when_failure_persistence_throws(CapturedOutput output) {
given(transcodeService.claimNext()).willReturn(queuedTask());
runner.failure = new IllegalStateException("ffmpeg failed");
doThrow(new IllegalStateException("database unavailable"))
.when(transcodeService).failFinal(eq(TASK_ID), anyString());
worker.processOne();
assertThat(output).contains("失败状态落库失败").contains("database unavailable");
}
@Test
@@ -175,7 +190,7 @@ class TranscodeWorkerTest {
assertThat(worker.processOne()).isFalse();
assertThat(runner.called).isFalse();
verify(transcodeService, never()).succeed(any(), any());
verify(transcodeService, never()).fail(any(), anyString());
verify(transcodeService, never()).failFinal(any(), anyString());
}
private TranscodeTask queuedTask() {