fix(media): 修正转码媒资 id 解析与存量分集补登记

This commit is contained in:
Zhou Weidong committed 2026-10-22 14:59:16 +08:00
1 parent a35b02dd18
commit 5ef5dd8eaf
11 files changed
+427 -31

No files matched your search

@@ -0,0 +1,28 @@
# 转码媒资 ID 精度与存量兜底修复
## 取证
- 示例分集 `2113150431755636737` 的 `video_asset_id` 为
`2113150398670966786`,与 `media_asset.id` 完全一致。
- JS `Number('2113150398670966786')` 序列化为 `2113150398670966800`,
超出安全整数后发生精度丢失,导致手动转码查询了错误 ID。
## 修复
1. 聚合响应的 episodeId/mediaAssetId/taskId 使用字符串。
2. 手动转码仍由后端 Long 接收精确路径 ID。
3. 媒资不存在时通过 content 模块的端口按候选 ID 查找分集:
- 分集 id;
- video_asset_id;
- transcode_task.id。
4. 找到分集后按 video_url/source_url/objectKey 反查媒资;
文件存在但媒资缺失时补登记并回填分集 video_asset_id。
5. 无可用视频时保持明确空态/错误语义。
## 验证
- 超 2^53 ID 字符串契约。
- 聚合无媒资空态。
- video_url 反查命中。
- 文件存在时补登记。
- `mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test`。
+89
View File
@@ -25460,3 +25460,92 @@ $ mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test
解读:P2 查询测试、worker 入队/失败回写测试和新增宽高比 smoke 全部通过。
未启停服务、未执行数据库变更、未改前端/App/specs 或 P2 接口字段。
## 2026-10-22 转码媒资 ID 精度与存量兜底
- 分支:`feature/transcode-media-id-fix`
- 基线:`develop` / `origin/develop` = `a35b02d`
### 现场取证
数据库只读查询确认:
```text
drama_episode.id = 2113150431755636737
drama_episode.video_asset_id = 2113150398670966786
media_asset.id = 2113150398670966786
transcode_task.media_asset_id= 2113150398670966786
```
JS 精度复现:
```text
raw: 2113150398670966786
Number(raw): 2113150398670966800
serialized: 2113150398670966800
same: false
```
解读:错误来自前端 Number 转换后的 Snowflake ID 精度丢失;后端兜底仍需覆盖
存量分集 video_asset_id 为空或误传分集/任务 ID 的情况。
### TDD 红测
```text
$ mvn -pl fruitshorts-transcode-worker -am \
-Dtest=MediaServiceTranscodePipelineTest \
-Dsurefire.failIfNoSpecifiedTests=false test
[ERROR] 找不到符号: 类 EpisodeVideoSourcePort
[INFO] BUILD FAILURE
$ mvn -pl fruitshorts-admin-api -am \
-Dtest=EpisodeTranscodeAdminServiceTest,EpisodeTranscodeContractTest \
-Dsurefire.failIfNoSpecifiedTests=false test
[ERROR] String 无法转换为 Long
[ERROR] 找不到符号: 方法 mediaAssetExists(Long)
[INFO] BUILD FAILURE
```
解读:红测先锁定字符串契约和跨域分集来源端口,生产代码尚未实现。
### 实现
- `EpisodeVideoSourcePort` 由 content 域 `EpisodeService` 实现,可按分集 id 或
`video_asset_id` 找到分集来源,并回填 `video_asset_id`。
- 手动转码先按 media_asset.id 锁定;未命中时通过端口找到分集,
按 video_url → source_url/objectKey 反查媒资。
- 仍缺失但本地文件真实存在时自动补登记 `media_asset`,再调用原转码入队。
- 聚合接口先按 video_url 解析媒资,失败才校验 `video_asset_id`;
episodeId/mediaAssetId/taskId 均显式转字符串。
- 无媒资空态返回 `NO_MEDIA_ASSET` 和可读 errorMsg。
### 定向测试
```text
$ mvn -pl fruitshorts-transcode-worker -am \
-Dtest=MediaServiceTranscodePipelineTest \
-Dsurefire.failIfNoSpecifiedTests=false test
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
$ mvn -pl fruitshorts-admin-api -am \
-Dtest=EpisodeTranscodeAdminServiceTest,EpisodeTranscodeContractTest \
-Dsurefire.failIfNoSpecifiedTests=false test
[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
### 最终门禁
```text
$ mvn -pl fruitshorts-module-media,fruitshorts-transcode-worker test
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
[INFO] Tests run: 37, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
$ mvn -pl fruitshorts-admin-api -am test
[INFO] Tests run: 53, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
```
解读:精度字符串、空态、video_url 反查命中、文件存在补登记及原有转码链全部通过。
@@ -24,6 +24,9 @@ public class EpisodeTranscodeAdminService {
private static final String STATUS_NO_VIDEO = "NO_VIDEO";
private static final String STATUS_NO_MEDIA_ASSET = "NO_MEDIA_ASSET";
private static final String STATUS_NO_TASK = "NO_TASK";
private static final String REASON_EPISODE_NOT_FOUND = "分集不存在或已删除";
private static final String REASON_NO_VIDEO = "分集未配置视频";
private static final String REASON_NO_MEDIA_ASSET = "未找到分集视频对应的媒资";
private static final int THUMBNAIL_PAGE_INDEX = 1;
private static final int THUMBNAIL_PAGE_SIZE = 100;
@@ -33,22 +36,25 @@ public class EpisodeTranscodeAdminService {
public EpisodeTranscodeRespVO detail(Long episodeId) {
DramaEpisode episode = episodeId == null ? null : episodeMapper.selectById(episodeId);
if (episode == null) {
return empty(episodeId, STATUS_EPISODE_NOT_FOUND);
return empty(episodeId, STATUS_EPISODE_NOT_FOUND, REASON_EPISODE_NOT_FOUND);
}
boolean hasVideoAsset = episode.getVideoAssetId() != null
&& episode.getVideoAssetId() > 0;
if (!StringUtils.hasText(episode.getVideoUrl()) && !hasVideoAsset) {
return empty(episodeId, STATUS_NO_VIDEO);
return empty(episodeId, STATUS_NO_VIDEO, REASON_NO_VIDEO);
}
Long mediaAssetId = hasVideoAsset
? episode.getVideoAssetId() : outputService.resolveMediaAssetId(episode.getVideoUrl());
if (mediaAssetId == null) {
return empty(episodeId, STATUS_NO_MEDIA_ASSET);
Long mediaAssetId = outputService.resolveMediaAssetId(episode.getVideoUrl());
if ((mediaAssetId == null || mediaAssetId <= 0) && hasVideoAsset
&& outputService.mediaAssetExists(episode.getVideoAssetId())) {
mediaAssetId = episode.getVideoAssetId();
}
if (mediaAssetId == null || mediaAssetId <= 0) {
return empty(episodeId, STATUS_NO_MEDIA_ASSET, REASON_NO_MEDIA_ASSET);
}
EpisodeTranscodeRespVO result = new EpisodeTranscodeRespVO();
result.setEpisodeId(episodeId);
result.setMediaAssetId(mediaAssetId);
result.setEpisodeId(idString(episodeId));
result.setMediaAssetId(idString(mediaAssetId));
result.setRenditions(outputService.renditionsForMediaAssetId(mediaAssetId));
result.setThumbnails(outputService.thumbnailForEpisode(
episodeId, mediaAssetId, THUMBNAIL_PAGE_INDEX, THUMBNAIL_PAGE_SIZE));
@@ -61,7 +67,7 @@ public class EpisodeTranscodeAdminService {
result.setErrorMsg("");
return result;
}
result.setTaskId(task.getId());
result.setTaskId(idString(task.getId()));
result.setStatus(task.getStatus());
result.setProgress(task.getProgress());
result.setRetryCount(task.getRetryCount());
@@ -69,13 +75,13 @@ public class EpisodeTranscodeAdminService {
return result;
}
private EpisodeTranscodeRespVO empty(Long episodeId, String status) {
private EpisodeTranscodeRespVO empty(Long episodeId, String status, String reason) {
EpisodeTranscodeRespVO result = new EpisodeTranscodeRespVO();
result.setEpisodeId(episodeId);
result.setEpisodeId(idString(episodeId));
result.setStatus(status);
result.setProgress(0);
result.setRetryCount(0);
result.setErrorMsg("");
result.setErrorMsg(reason);
result.setRenditions(List.of());
result.setThumbnails(MediaThumbnailRespVO.empty(
null, episodeId, MediaThumbnailRespVO.STATUS_EMPTY,
@@ -83,6 +89,10 @@ public class EpisodeTranscodeAdminService {
return result;
}
private String idString(Long id) {
return id == null ? null : String.valueOf(id);
}
private String taskStatusOf(DramaEpisode episode) {
return StringUtils.hasText(episode.getTranscodeStatus())
? episode.getTranscodeStatus().toUpperCase(Locale.ROOT)
@@ -14,9 +14,9 @@ import java.util.List;
@Schema(description = "分集转码聚合信息")
public class EpisodeTranscodeRespVO {
private Long episodeId;
private Long mediaAssetId;
private Long taskId;
private String episodeId;
private String mediaAssetId;
private String taskId;
private String status;
private Integer progress;
private Integer retryCount;
@@ -29,19 +29,23 @@ class EpisodeTranscodeContractTest {
@Test
void should_return_aggregated_episode_transcode_state() throws Exception {
String episodeId = "2113150431755636737";
String mediaAssetId = "2113150398670966786";
String taskId = "2113150398935207938";
EpisodeTranscodeRespVO vo = new EpisodeTranscodeRespVO();
vo.setEpisodeId(31L);
vo.setMediaAssetId(88L);
vo.setTaskId(8L);
vo.setEpisodeId(episodeId);
vo.setMediaAssetId(mediaAssetId);
vo.setTaskId(taskId);
vo.setStatus("SUCCESS");
vo.setProgress(100);
given(service.detail(31L)).willReturn(vo);
given(service.detail(Long.parseLong(episodeId))).willReturn(vo);
mockMvc.perform(get("/admin/episodes/31/transcode"))
mockMvc.perform(get("/admin/episodes/" + episodeId + "/transcode"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.episodeId").value("31"))
.andExpect(jsonPath("$.data.mediaAssetId").value("88"))
.andExpect(jsonPath("$.data.episodeId").value(episodeId))
.andExpect(jsonPath("$.data.mediaAssetId").value(mediaAssetId))
.andExpect(jsonPath("$.data.taskId").value(taskId))
.andExpect(jsonPath("$.data.status").value("SUCCESS"))
.andExpect(jsonPath("$.data.progress").value(100));
}
@@ -49,7 +53,7 @@ class EpisodeTranscodeContractTest {
@Test
void should_return_explicit_empty_state_when_episode_has_no_video() throws Exception {
EpisodeTranscodeRespVO vo = new EpisodeTranscodeRespVO();
vo.setEpisodeId(31L);
vo.setEpisodeId("31");
vo.setStatus("NO_VIDEO");
given(service.detail(31L)).willReturn(vo);
@@ -5,9 +5,13 @@ package com.fruitshorts.admin.service;
import com.fruitshorts.content.dal.entity.DramaEpisode;
import com.fruitshorts.content.dal.mapper.DramaEpisodeMapper;
import com.fruitshorts.media.service.MediaTranscodeOutputService;
import com.fruitshorts.media.vo.MediaThumbnailRespVO;
import com.fruitshorts.media.vo.TranscodeTaskRespVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
@@ -15,6 +19,10 @@ import static org.mockito.Mockito.verifyNoInteractions;
class EpisodeTranscodeAdminServiceTest {
private static final Long EPISODE_ID = 2113150431755636737L;
private static final Long MEDIA_ASSET_ID = 2113150398670966786L;
private static final Long TASK_ID = 2113150398935207938L;
private DramaEpisodeMapper episodeMapper;
private MediaTranscodeOutputService outputService;
private EpisodeTranscodeAdminService service;
@@ -33,7 +41,7 @@ class EpisodeTranscodeAdminServiceTest {
var result = service.detail(404L);
assertThat(result.getStatus()).isEqualTo("EPISODE_NOT_FOUND");
assertThat(result.getEpisodeId()).isEqualTo(404L);
assertThat(result.getEpisodeId()).isEqualTo("404");
assertThat(result.getRenditions()).isEmpty();
verifyNoInteractions(outputService);
}
@@ -51,4 +59,55 @@ class EpisodeTranscodeAdminServiceTest {
assertThat(result.getRenditions()).isEmpty();
verifyNoInteractions(outputService);
}
@Test
void should_return_exact_snowflake_media_asset_id_as_string() {
DramaEpisode episode = episode(MEDIA_ASSET_ID);
given(episodeMapper.selectById(EPISODE_ID)).willReturn(episode);
given(outputService.resolveMediaAssetId(episode.getVideoUrl())).willReturn(null);
given(outputService.mediaAssetExists(MEDIA_ASSET_ID)).willReturn(true);
given(outputService.renditionsForMediaAssetId(MEDIA_ASSET_ID)).willReturn(List.of());
given(outputService.thumbnailForEpisode(
EPISODE_ID, MEDIA_ASSET_ID, 1, 100))
.willReturn(MediaThumbnailRespVO.empty(
MEDIA_ASSET_ID, EPISODE_ID,
MediaThumbnailRespVO.STATUS_EMPTY, 1, 100));
TranscodeTaskRespVO task = new TranscodeTaskRespVO();
task.setId(TASK_ID);
task.setMediaAssetId(MEDIA_ASSET_ID);
task.setStatus("SUCCESS");
task.setProgress(100);
given(outputService.latestTask(MEDIA_ASSET_ID)).willReturn(task);
var result = service.detail(EPISODE_ID);
assertThat(result.getEpisodeId()).isEqualTo("2113150431755636737");
assertThat(result.getMediaAssetId()).isEqualTo("2113150398670966786");
assertThat(result.getTaskId()).isEqualTo("2113150398935207938");
}
@Test
void should_return_explicit_empty_state_when_episode_media_is_missing() {
DramaEpisode episode = episode(MEDIA_ASSET_ID);
given(episodeMapper.selectById(EPISODE_ID)).willReturn(episode);
given(outputService.mediaAssetExists(MEDIA_ASSET_ID)).willReturn(false);
given(outputService.resolveMediaAssetId(episode.getVideoUrl())).willReturn(null);
var result = service.detail(EPISODE_ID);
assertThat(result.getMediaAssetId()).isNull();
assertThat(result.getStatus()).isEqualTo("NO_MEDIA_ASSET");
assertThat(result.getErrorMsg()).isNotBlank();
assertThat(result.getRenditions()).isEmpty();
}
private DramaEpisode episode(Long videoAssetId) {
DramaEpisode episode = new DramaEpisode();
episode.setId(EPISODE_ID);
episode.setDramaId(7L);
episode.setDurationSec(120);
episode.setVideoAssetId(videoAssetId);
episode.setVideoUrl("/files/episode/202610/video.mp4");
return episode;
}
}
@@ -17,6 +17,7 @@ import com.fruitshorts.content.vo.EpisodeRespVO;
import com.fruitshorts.content.vo.EpisodeSaveReqVO;
import com.fruitshorts.media.service.EpisodeTranscodeBindingService;
import com.fruitshorts.media.service.EpisodeTranscodeCompletionPort;
import com.fruitshorts.media.service.EpisodeVideoSourcePort;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -31,7 +32,7 @@ import java.util.Objects;
@Service
@RequiredArgsConstructor
public class EpisodeService implements EpisodeTranscodeCompletionPort {
public class EpisodeService implements EpisodeTranscodeCompletionPort, EpisodeVideoSourcePort {
private static final int DEFAULT_PAGE_INDEX = 1;
private static final int DEFAULT_PAGE_SIZE = 100;
@@ -41,6 +42,36 @@ public class EpisodeService implements EpisodeTranscodeCompletionPort {
private final DramaService dramaService;
private final EpisodeTranscodeBindingService transcodeBindingService;
@Override
public java.util.Optional<EpisodeVideoSource> resolve(Long candidateId) {
if (candidateId == null || candidateId <= 0) {
return java.util.Optional.empty();
}
DramaEpisode episode = dramaEpisodeMapper.selectById(candidateId);
if (episode == null) {
episode = dramaEpisodeMapper.selectOne(Wrappers.<DramaEpisode>lambdaQuery()
.eq(DramaEpisode::getVideoAssetId, candidateId)
.last("limit 1"));
}
if (episode == null) {
return java.util.Optional.empty();
}
return java.util.Optional.of(new EpisodeVideoSource(
episode.getId(), episode.getDramaId(), episode.getVideoAssetId(),
episode.getVideoUrl(), episode.getDurationSec()));
}
@Override
@Transactional(rollbackFor = Exception.class)
public void link(Long episodeId, Long mediaAssetId) {
if (episodeId == null || episodeId <= 0 || mediaAssetId == null || mediaAssetId <= 0) {
return;
}
dramaEpisodeMapper.update(null, Wrappers.<DramaEpisode>lambdaUpdate()
.eq(DramaEpisode::getId, episodeId)
.set(DramaEpisode::getVideoAssetId, mediaAssetId));
}
/** 后台:按短剧分页查询分集,status 在 SQL 层过滤;排序稳定(sort、ep_no)。 */
public PageResult<EpisodeRespVO> pageByDrama(Long dramaId, String status,
Integer pageIndex, Integer pageSize) {
@@ -0,0 +1,25 @@
// Author: fruit.com.cn
package com.fruitshorts.media.service;
import java.util.Optional;
/**
* 媒资域读取分集视频来源的跨域端口,由 content 域实现。
*/
public interface EpisodeVideoSourcePort {
/** 按候选 id 查找分集;候选值可能是分集 id 或 video_asset_id。 */
Optional<EpisodeVideoSource> resolve(Long candidateId);
/** 回填分集的 video_asset_id。 */
void link(Long episodeId, Long mediaAssetId);
record EpisodeVideoSource(
Long episodeId,
Long dramaId,
Long videoAssetId,
String videoUrl,
Integer durationSec) {
}
}
@@ -21,6 +21,9 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Set;
import java.util.UUID;
@@ -34,6 +37,8 @@ public class MediaService {
private static final String STATUS_FAILED = "failed";
private static final String TASK_UPLOADING = "uploading";
private static final String TASK_COMPLETED = "completed";
private static final String BIZ_TYPE_EPISODE = "EPISODE";
private static final String STORAGE_TYPE_LOCAL = "LOCAL";
private static final Set<String> IMAGE_EXTENSIONS =
Set.of(".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".avif");
@@ -42,6 +47,7 @@ public class MediaService {
private final MediaAssetMapper mediaAssetMapper;
private final MediaUploadTaskMapper mediaUploadTaskMapper;
private final TranscodeService transcodeService;
private final EpisodeVideoSourcePort episodeVideoSourcePort;
public UploadInitRespVO init(UploadInitReqVO req, Long userId) {
if (req.getFileSize() > properties.getMaxFileSize()) {
@@ -203,13 +209,92 @@ public class MediaService {
/** 后台手动触发/重转:锁住媒资行,保证同一媒体的重复请求串行判定任务状态。 */
@Transactional(rollbackFor = Exception.class)
public TranscodeTriggerRespVO triggerTranscode(Long id) {
MediaAsset asset = id == null ? null : mediaAssetMapper.selectOne(
Wrappers.<MediaAsset>lambdaQuery()
.eq(MediaAsset::getId, id)
.last("for update"));
MediaAsset asset = lockAsset(id);
if (asset == null) {
asset = resolveLegacyAsset(id);
}
if (asset == null) {
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "媒资不存在");
}
return triggerAsset(asset);
}
private MediaAsset lockAsset(Long id) {
return id == null ? null : mediaAssetMapper.selectOne(
Wrappers.<MediaAsset>lambdaQuery()
.eq(MediaAsset::getId, id)
.last("for update"));
}
private MediaAsset resolveLegacyAsset(Long candidateId) {
return episodeVideoSourcePort.resolve(candidateId)
.map(this::resolveOrRegisterAsset)
.orElse(null);
}
private MediaAsset resolveOrRegisterAsset(EpisodeVideoSourcePort.EpisodeVideoSource source) {
String videoUrl = source.videoUrl();
if (videoUrl == null || videoUrl.isBlank()) {
return null;
}
String objectKey = storageService.objectKeyOf(videoUrl);
if (objectKey == null || objectKey.isBlank()) {
return null;
}
MediaAsset asset = mediaAssetMapper.selectOne(Wrappers.<MediaAsset>lambdaQuery()
.and(wrapper -> wrapper.eq(MediaAsset::getSourceUrl, videoUrl)
.or().eq(MediaAsset::getObjectKey, objectKey))
.last("limit 1"));
if (asset == null) {
asset = registerLegacyAsset(source, objectKey);
}
if (asset != null && source.episodeId() != null) {
episodeVideoSourcePort.link(source.episodeId(), asset.getId());
}
return asset;
}
private MediaAsset registerLegacyAsset(
EpisodeVideoSourcePort.EpisodeVideoSource source, String objectKey) {
Path file;
try {
file = storageService.fileFor(objectKey);
} catch (ServiceException ex) {
return null;
}
if (!Files.isRegularFile(file)) {
return null;
}
MediaAsset asset = new MediaAsset();
asset.setBizType(BIZ_TYPE_EPISODE);
asset.setFileName(file.getFileName().toString());
asset.setObjectKey(objectKey);
asset.setSourceUrl(source.videoUrl());
asset.setSizeBytes(sizeOf(file));
asset.setMd5("");
asset.setDurationSec(source.durationSec() == null ? 0 : source.durationSec());
asset.setWidth(0);
asset.setHeight(0);
asset.setStatus(STATUS_UPLOADED);
asset.setHlsUrl("");
asset.setMp4Url(source.videoUrl());
asset.setPosterUrl("");
asset.setTranscodeStatus(TranscodeService.STATUS_QUEUED);
asset.setStorageType(STORAGE_TYPE_LOCAL);
asset.setCreateBy(0L);
mediaAssetMapper.insert(asset);
return asset;
}
private long sizeOf(Path file) {
try {
return Files.size(file);
} catch (IOException ex) {
return 0L;
}
}
private TranscodeTriggerRespVO triggerAsset(MediaAsset asset) {
if (isImage(asset.getFileName())) {
throw new ServiceException(ErrorCode.BAD_REQUEST, "图片媒资无需转码");
}
@@ -106,6 +106,10 @@ public class MediaTranscodeOutputService {
transcodeService.findLatestTaskByMediaAssetId(mediaAssetId));
}
public boolean mediaAssetExists(Long mediaAssetId) {
return mediaAssetId != null && mediaAssetMapper.selectById(mediaAssetId) != null;
}
public Long resolveMediaAssetId(String videoUrl) {
if (!StringUtils.hasText(videoUrl)) {
return null;
@@ -115,7 +119,8 @@ public class MediaTranscodeOutputService {
return null;
}
MediaAsset asset = mediaAssetMapper.selectOne(Wrappers.<MediaAsset>lambdaQuery()
.eq(MediaAsset::getObjectKey, objectKey)
.and(wrapper -> wrapper.eq(MediaAsset::getSourceUrl, videoUrl)
.or().eq(MediaAsset::getObjectKey, objectKey))
.last("limit 1"));
return asset == null ? null : asset.getId();
}
@@ -9,13 +9,17 @@ import com.fruitshorts.media.dal.mapper.MediaAssetMapper;
import com.fruitshorts.media.dal.mapper.MediaUploadTaskMapper;
import com.fruitshorts.media.service.MediaService;
import com.fruitshorts.media.service.MediaStorageService;
import com.fruitshorts.media.service.EpisodeVideoSourcePort;
import com.fruitshorts.media.service.TranscodeService;
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;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -33,12 +37,19 @@ import static org.mockito.Mockito.verify;
class MediaServiceTranscodePipelineTest {
private static final Long ASSET_ID = 88L;
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;
private MediaStorageService storageService;
private TranscodeService transcodeService;
private EpisodeVideoSourcePort episodeVideoSourcePort;
private MediaService mediaService;
@BeforeEach
@@ -47,9 +58,10 @@ class MediaServiceTranscodePipelineTest {
uploadTaskMapper = mock(MediaUploadTaskMapper.class);
storageService = mock(MediaStorageService.class);
transcodeService = mock(TranscodeService.class);
episodeVideoSourcePort = mock(EpisodeVideoSourcePort.class);
mediaService = new MediaService(
new MediaProperties(), storageService, mediaAssetMapper,
uploadTaskMapper, transcodeService);
uploadTaskMapper, transcodeService, episodeVideoSourcePort);
}
@Test
@@ -87,6 +99,7 @@ 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)
@@ -110,6 +123,48 @@ class MediaServiceTranscodePipelineTest {
verify(mediaAssetMapper).updateById(asset);
}
@Test
void should_resolve_existing_media_asset_by_episode_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));
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);
}
private void givenCompletedUpload(String fileName) {
String objectKey = "episode/202610/" + fileName;
MediaUploadTask task = new MediaUploadTask();
@@ -143,4 +198,9 @@ class MediaServiceTranscodePipelineTest {
asset.setStatus("uploaded");
return asset;
}
private EpisodeVideoSourcePort.EpisodeVideoSource episodeSource() {
return new EpisodeVideoSourcePort.EpisodeVideoSource(
EPISODE_ID, DRAMA_ID, ASSET_ID, SOURCE_URL, 120);
}
}