feat(media): 支持后台转码配置与缩略图轴

This commit is contained in:
Zhou Weidong committed 2026-10-22 11:20:28 +08:00
1 parent 70da939a58
commit 316baf28d6
39 files changed
+1523 -38

No files matched your search

@@ -15,6 +15,7 @@ import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.Map;
import static org.hamcrest.Matchers.nullValue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
@@ -55,7 +56,9 @@ class AppPlaybackContractTest {
@Test
void should_return_free_access_when_episode_is_free() throws Exception {
given(playbackService.play(isNull(), eq(11L), any(), any()))
.willReturn(ticket(11L, true, PlaybackService.ACCESS_FREE, "/files/ep.mp4?s=1"));
.willReturn(ticket(11L, true, PlaybackService.ACCESS_FREE,
"/files/ep.mp4?s=1",
"/files/transcoded/11/thumbnails/thumbnails.vtt?e=1&s=sig"));
mockMvc.perform(get("/app/episodes/11/play").header("X-Device-Id", "device-9"))
.andExpect(status().isOk())
@@ -66,13 +69,15 @@ class AppPlaybackContractTest {
.andExpect(jsonPath("$.data.accessible").value(true))
.andExpect(jsonPath("$.data.accessType").value(PlaybackService.ACCESS_FREE))
.andExpect(jsonPath("$.data.playUrl").value("/files/ep.mp4?s=1"))
.andExpect(jsonPath("$.data.thumbnailsUrl")
.value("/files/transcoded/11/thumbnails/thumbnails.vtt?e=1&s=sig"))
.andExpect(jsonPath("$.data.likeCount").value("88"))
.andExpect(jsonPath("$.data.commentCount").value("0"));
}
@Test
void should_return_coin_required_when_episode_is_locked_and_not_entitled() throws Exception {
PlayRespVO locked = ticket(12L, false, PlaybackService.ACCESS_COIN_REQUIRED, "");
PlayRespVO locked = ticket(12L, false, PlaybackService.ACCESS_COIN_REQUIRED, "", null);
locked.setUnlockCoin(60);
given(playbackService.play(isNull(), eq(12L), any(), any())).willReturn(locked);
@@ -81,13 +86,15 @@ class AppPlaybackContractTest {
.andExpect(jsonPath("$.data.accessible").value(false))
.andExpect(jsonPath("$.data.accessType").value(PlaybackService.ACCESS_COIN_REQUIRED))
.andExpect(jsonPath("$.data.playUrl").value(""))
.andExpect(jsonPath("$.data.thumbnailsUrl").value(nullValue()))
.andExpect(jsonPath("$.data.unlockCoin").value(60));
}
@Test
void should_pass_episode_id_and_device_header_when_play_is_requested() throws Exception {
given(playbackService.play(isNull(), eq(13L), eq("device-9"), any()))
.willReturn(ticket(13L, true, PlaybackService.ACCESS_FREE, "/files/ep13.mp4?s=1"));
.willReturn(ticket(13L, true, PlaybackService.ACCESS_FREE,
"/files/ep13.mp4?s=1", null));
mockMvc.perform(get("/app/episodes/13/play").header("X-Device-Id", "device-9"))
.andExpect(status().isOk());
@@ -95,7 +102,8 @@ class AppPlaybackContractTest {
verify(playbackService).play(isNull(), eq(13L), eq("device-9"), any());
}
private PlayRespVO ticket(Long episodeId, boolean accessible, String accessType, String playUrl) {
private PlayRespVO ticket(Long episodeId, boolean accessible, String accessType,
String playUrl, String thumbnailsUrl) {
PlayRespVO vo = new PlayRespVO();
vo.setEpisodeId(episodeId);
vo.setDramaId(7L);
@@ -105,6 +113,7 @@ class AppPlaybackContractTest {
vo.setAccessible(accessible);
vo.setAccessType(accessType);
vo.setPlayUrl(playUrl);
vo.setThumbnailsUrl(thumbnailsUrl);
vo.setQualities(List.of("AUTO"));
vo.setUnlockCoin(0);
vo.setPreviewSeconds(0);
@@ -12,6 +12,7 @@ import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.interaction.service.CommentService;
import com.fruitshorts.media.service.PlaybackSigner;
import com.fruitshorts.media.service.ThumbnailUrlResolver;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -35,6 +36,7 @@ class AppCatalogServiceTest {
private EpisodeService episodeService;
private DramaCategoryService dramaCategoryService;
private CommentService commentService;
private ThumbnailUrlResolver thumbnailUrlResolver;
private AppCatalogService catalogService;
@BeforeEach
@@ -44,9 +46,11 @@ class AppCatalogServiceTest {
BannerService bannerService = mock(BannerService.class);
dramaCategoryService = mock(DramaCategoryService.class);
PlaybackSigner playbackSigner = mock(PlaybackSigner.class);
thumbnailUrlResolver = mock(ThumbnailUrlResolver.class);
given(thumbnailUrlResolver.resolveAll(any())).willReturn(Map.of());
commentService = mock(CommentService.class);
catalogService = new AppCatalogService(dramaService, episodeService, bannerService,
dramaCategoryService, playbackSigner, commentService);
dramaCategoryService, playbackSigner, commentService, thumbnailUrlResolver);
}
@Test
@@ -66,6 +70,8 @@ class AppCatalogServiceTest {
given(commentService.countByDrama(42L)).willReturn(96L);
given(commentService.countByEpisodes(List.of(11L, 22L)))
.willReturn(Map.of(11L, 2L, 22L, 4L));
given(thumbnailUrlResolver.resolveAll(List.of(11L, 22L)))
.willReturn(Map.of(22L, "/files/transcoded/22/thumbnails/thumbnails.vtt"));
AppDramaDetailVO detail = catalogService.dramaDetail(42L);
@@ -75,7 +81,10 @@ class AppCatalogServiceTest {
assertThat(detail.getCommentCount()).isEqualTo(96L);
assertThat(detail.getEpisodes().get(0).getLikeCount()).isEqualTo(66L);
assertThat(detail.getEpisodes().get(0).getCommentCount()).isEqualTo(4L);
assertThat(detail.getEpisodes().get(0).getThumbnailsUrl())
.isEqualTo("/files/transcoded/22/thumbnails/thumbnails.vtt");
verify(commentService).countByDrama(42L);
verify(thumbnailUrlResolver).resolveAll(List.of(11L, 22L));
}
@Test
@@ -12,6 +12,7 @@ import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
import com.fruitshorts.content.service.DramaCategoryService;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodePriceSync;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
@@ -41,7 +42,8 @@ class DramaServiceFavoriteCountTest {
dramaMapper,
mock(DramaEpisodeMapper.class),
mock(DramaReviewRecordMapper.class),
mock(DramaCategoryService.class));
mock(DramaCategoryService.class),
mock(EpisodePriceSync.class));
given(dramaMapper.update(isNull(), any())).willReturn(1);
ArgumentCaptor<Wrapper<Drama>> captor = ArgumentCaptor.forClass(Wrapper.class);
@@ -11,6 +11,7 @@ import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
import com.fruitshorts.content.service.DramaCategoryService;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodePriceSync;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
@@ -45,7 +46,8 @@ class DramaServiceFeedTest {
dramaMapper,
mock(DramaEpisodeMapper.class),
mock(DramaReviewRecordMapper.class),
mock(DramaCategoryService.class));
mock(DramaCategoryService.class),
mock(EpisodePriceSync.class));
}
@Test
@@ -12,6 +12,7 @@ import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
import com.fruitshorts.content.service.DramaCategoryService;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodePriceSync;
import com.fruitshorts.content.vo.DramaSaveReqVO;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
@@ -46,7 +47,8 @@ class DramaServiceK88Test {
dramaMapper,
mock(DramaEpisodeMapper.class),
mock(DramaReviewRecordMapper.class),
mock(DramaCategoryService.class));
mock(DramaCategoryService.class),
mock(EpisodePriceSync.class));
}
@Test
@@ -13,6 +13,7 @@ import com.fruitshorts.content.dal.mapper.DramaMapper;
import com.fruitshorts.content.dal.mapper.DramaReviewRecordMapper;
import com.fruitshorts.content.service.DramaCategoryService;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodePriceSync;
import com.fruitshorts.content.vo.DramaPageReqVO;
import com.fruitshorts.content.vo.DramaRespVO;
import com.fruitshorts.content.vo.DramaSaveReqVO;
@@ -54,7 +55,8 @@ class DramaServiceMultiTagTest {
dramaMapper,
mock(DramaEpisodeMapper.class),
mock(DramaReviewRecordMapper.class),
categoryService);
categoryService,
mock(EpisodePriceSync.class));
}
@Test
@@ -6,6 +6,7 @@ import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.service.DramaService;
import com.fruitshorts.content.service.EpisodeService;
import com.fruitshorts.media.service.PlaybackSigner;
import com.fruitshorts.media.service.ThumbnailUrlResolver;
import com.fruitshorts.media.service.TranscodeService;
import com.fruitshorts.playback.dal.entity.PlaybackTicket;
import com.fruitshorts.playback.dal.mapper.PlaybackTicketMapper;
@@ -42,6 +43,7 @@ class PlaybackServiceTest {
private PlaybackSigner signer;
private EntitlementPort entitlementPort;
private TranscodeService transcodeService;
private ThumbnailUrlResolver thumbnailUrlResolver;
private PlaybackService playbackService;
@BeforeEach
@@ -52,10 +54,12 @@ class PlaybackServiceTest {
signer = mock(PlaybackSigner.class);
entitlementPort = mock(EntitlementPort.class);
transcodeService = mock(TranscodeService.class);
thumbnailUrlResolver = mock(ThumbnailUrlResolver.class);
playbackService = new PlaybackService(episodeService, dramaService, ticketMapper, signer,
entitlementPort, transcodeService);
entitlementPort, transcodeService, thumbnailUrlResolver);
given(dramaService.getPublished(anyLong())).willReturn(drama());
given(transcodeService.listRenditions(anyLong())).willReturn(List.of());
given(thumbnailUrlResolver.resolve(anyLong())).willReturn(null);
given(signer.sign(anyString())).willReturn("/files/episode/1.mp4?e=1&s=sig");
}
@@ -72,6 +76,27 @@ class PlaybackServiceTest {
verify(ticketMapper, never()).insert(any(PlaybackTicket.class));
}
@Test
void should_return_signed_thumbnails_url_when_thumbnail_exists() {
given(episodeService.getPublished(11L)).willReturn(episode(11L, 1, 0, "/files/episode/1.mp4"));
given(thumbnailUrlResolver.resolve(11L))
.willReturn("/files/transcoded/1/thumbnails/thumbnails.vtt?e=1&s=sig");
PlayRespVO resp = playbackService.play(null, 11L, "device-9", "203.0.113.8");
assertThat(resp.getThumbnailsUrl())
.isEqualTo("/files/transcoded/1/thumbnails/thumbnails.vtt?e=1&s=sig");
}
@Test
void should_return_null_thumbnails_url_when_thumbnail_is_absent() {
given(episodeService.getPublished(11L)).willReturn(episode(11L, 1, 0, "/files/episode/1.mp4"));
PlayRespVO resp = playbackService.play(null, 11L, "device-9", "203.0.113.8");
assertThat(resp.getThumbnailsUrl()).isNull();
}
@Test
void should_return_coin_required_when_episode_is_locked_and_not_entitled() {
given(episodeService.getPublished(12L)).willReturn(episode(12L, 0, 60, "/files/episode/2.mp4"));
@@ -0,0 +1,55 @@
// Author: fruit.com.cn
package com.fruitshorts.app.service;
import com.fruitshorts.media.dal.entity.MediaThumbnail;
import com.fruitshorts.media.service.MediaStorageService;
import com.fruitshorts.media.service.PlaybackSigner;
import com.fruitshorts.media.service.ThumbnailUrlResolver;
import com.fruitshorts.media.service.TranscodeService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* 缩略图 objectKey 到公开签名地址的转换。
*/
class ThumbnailUrlResolverTest {
private TranscodeService transcodeService;
private MediaStorageService storageService;
private PlaybackSigner signer;
private ThumbnailUrlResolver resolver;
@BeforeEach
void setUp() {
transcodeService = mock(TranscodeService.class);
storageService = mock(MediaStorageService.class);
signer = mock(PlaybackSigner.class);
resolver = new ThumbnailUrlResolver(transcodeService, storageService, signer);
}
@Test
void should_sign_vtt_public_url_when_ready_thumbnail_exists() {
MediaThumbnail thumbnail = new MediaThumbnail();
thumbnail.setVttObjectKey("transcoded/88/thumbnails/thumbnails.vtt");
given(transcodeService.findReadyThumbnail(11L)).willReturn(thumbnail);
given(storageService.toPublicUrl(thumbnail.getVttObjectKey()))
.willReturn("/files/transcoded/88/thumbnails/thumbnails.vtt");
given(signer.sign("/files/transcoded/88/thumbnails/thumbnails.vtt"))
.willReturn("/files/transcoded/88/thumbnails/thumbnails.vtt?e=1&s=sig");
assertThat(resolver.resolve(11L))
.isEqualTo("/files/transcoded/88/thumbnails/thumbnails.vtt?e=1&s=sig");
verify(signer).sign("/files/transcoded/88/thumbnails/thumbnails.vtt");
}
@Test
void should_return_null_when_thumbnail_record_is_absent() {
assertThat(resolver.resolve(12L)).isNull();
}
}