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

@@ -20,6 +20,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 com.fruitshorts.content.vo.DramaCategoryRespVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -50,6 +51,7 @@ public class AppCatalogService {
private final DramaCategoryService dramaCategoryService;
private final PlaybackSigner playbackSigner;
private final CommentService commentService;
private final ThumbnailUrlResolver thumbnailUrlResolver;
public AppFeedVO homeFeed() {
Map<Long, String> categoryNames = categoryNameMap();
@@ -110,6 +112,8 @@ public class AppCatalogService {
List<DramaEpisode> episodes = episodeService.listPublishedByDrama(dramaId);
Map<Long, Long> episodeCommentCounts = commentService.countByEpisodes(
episodes.stream().map(DramaEpisode::getId).sorted().toList());
Map<Long, String> thumbnailUrls = thumbnailUrlResolver.resolveAll(
episodes.stream().map(DramaEpisode::getId).sorted().toList());
String categoryName = categoryNameMap().getOrDefault(drama.getCategoryId(), "");
AppDramaDetailVO detail = new AppDramaDetailVO();
@@ -128,7 +132,7 @@ public class AppCatalogService {
detail.setFavoriteCount(drama.getFavoriteCount());
detail.setCommentCount(commentService.countByDrama(dramaId));
detail.setEpisodes(episodes.stream()
.map(episode -> toEpisode(episode, episodeCommentCounts))
.map(episode -> toEpisode(episode, episodeCommentCounts, thumbnailUrls))
.toList());
return detail;
}
@@ -176,14 +180,18 @@ public class AppCatalogService {
Map<Long, DramaEpisode> previews = episodeService.firstPlayableByDramas(ids);
Map<Long, Long> commentCounts = commentService.countByEpisodes(
previews.values().stream().map(DramaEpisode::getId).sorted().toList());
Map<Long, String> thumbnailUrls = thumbnailUrlResolver.resolveAll(
previews.values().stream().map(DramaEpisode::getId).sorted().toList());
return dramas.stream()
.map(drama -> toCard(
drama, categoryNames, previews.get(drama.getId()), commentCounts))
drama, categoryNames, previews.get(drama.getId()), commentCounts,
thumbnailUrls))
.toList();
}
private AppDramaCardVO toCard(Drama drama, Map<Long, String> categoryNames,
DramaEpisode preview, Map<Long, Long> commentCounts) {
DramaEpisode preview, Map<Long, Long> commentCounts,
Map<Long, String> thumbnailUrls) {
AppDramaCardVO card = new AppDramaCardVO();
card.setId(drama.getId());
card.setTitle(drama.getTitle());
@@ -201,11 +209,13 @@ public class AppCatalogService {
? 0L : commentCounts.getOrDefault(preview.getId(), 0L));
card.setLanguage(drama.getLanguage());
card.setDramaType(drama.getDramaType());
card.setPreview(preview == null ? null : toEpisode(preview, commentCounts));
card.setPreview(preview == null
? null : toEpisode(preview, commentCounts, thumbnailUrls));
return card;
}
private AppEpisodeVO toEpisode(DramaEpisode episode, Map<Long, Long> commentCounts) {
private AppEpisodeVO toEpisode(DramaEpisode episode, Map<Long, Long> commentCounts,
Map<Long, String> thumbnailUrls) {
AppEpisodeVO vo = new AppEpisodeVO();
vo.setId(episode.getId());
vo.setNumber(episode.getEpNo());
@@ -219,6 +229,7 @@ public class AppCatalogService {
vo.setUnlockCoin(episode.getUnlockCoin());
vo.setLikeCount(episode.getLikeCount() == null ? 0L : episode.getLikeCount());
vo.setCommentCount(commentCounts.getOrDefault(episode.getId(), 0L));
vo.setThumbnailsUrl(thumbnailUrls.get(episode.getId()));
if (free) {
// 播放地址统一在这里签名:客户端拿不到可长期复用的裸地址
vo.setPlayUrl(playbackSigner.sign(playableSource(episode)));
@@ -24,4 +24,6 @@ public class AppEpisodeVO {
* 详情页拿到就能直接播,不必按集再请求一次播放票据。
*/
private String playUrl;
/** 雪碧图 WebVTT 时间轴地址;没有产物时为 null */
private String thumbnailsUrl;
}
@@ -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();
}
}