diff --git a/docs/plans/2026-10-22-transcode-media-id-fix.md b/docs/plans/2026-10-22-transcode-media-id-fix.md new file mode 100644 index 0000000..0f43782 --- /dev/null +++ b/docs/plans/2026-10-22-transcode-media-id-fix.md @@ -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`。 diff --git a/docs/plans/PROGRESS.md b/docs/plans/PROGRESS.md index d88bae4..d96fb64 100644 --- a/docs/plans/PROGRESS.md +++ b/docs/plans/PROGRESS.md @@ -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 反查命中、文件存在补登记及原有转码链全部通过。 + diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/EpisodeTranscodeAdminService.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/EpisodeTranscodeAdminService.java index ef0bae3..1a5bfd7 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/EpisodeTranscodeAdminService.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/EpisodeTranscodeAdminService.java @@ -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) diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/EpisodeTranscodeRespVO.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/EpisodeTranscodeRespVO.java index 4f40e2f..6399012 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/EpisodeTranscodeRespVO.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/EpisodeTranscodeRespVO.java @@ -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; diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/EpisodeTranscodeContractTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/EpisodeTranscodeContractTest.java index 1efe399..d05650d 100644 --- a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/EpisodeTranscodeContractTest.java +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/controller/EpisodeTranscodeContractTest.java @@ -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); diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/EpisodeTranscodeAdminServiceTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/EpisodeTranscodeAdminServiceTest.java index bb8339d..b7f3d88 100644 --- a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/EpisodeTranscodeAdminServiceTest.java +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/EpisodeTranscodeAdminServiceTest.java @@ -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; + } } diff --git a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/EpisodeService.java b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/EpisodeService.java index 6bf6761..7c73325 100644 --- a/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/EpisodeService.java +++ b/fruitshorts-module-content/src/main/java/com/fruitshorts/content/service/EpisodeService.java @@ -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 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.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.lambdaUpdate() + .eq(DramaEpisode::getId, episodeId) + .set(DramaEpisode::getVideoAssetId, mediaAssetId)); + } + /** 后台:按短剧分页查询分集,status 在 SQL 层过滤;排序稳定(sort、ep_no)。 */ public PageResult pageByDrama(Long dramaId, String status, Integer pageIndex, Integer pageSize) { diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/EpisodeVideoSourcePort.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/EpisodeVideoSourcePort.java new file mode 100644 index 0000000..d3d57e3 --- /dev/null +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/EpisodeVideoSourcePort.java @@ -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 resolve(Long candidateId); + + /** 回填分集的 video_asset_id。 */ + void link(Long episodeId, Long mediaAssetId); + + record EpisodeVideoSource( + Long episodeId, + Long dramaId, + Long videoAssetId, + String videoUrl, + Integer durationSec) { + } +} diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaService.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaService.java index 9f81a83..8372793 100644 --- a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaService.java +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaService.java @@ -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 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.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.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.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, "图片媒资无需转码"); } diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaTranscodeOutputService.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaTranscodeOutputService.java index 7cbfc27..9385963 100644 --- a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaTranscodeOutputService.java +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/MediaTranscodeOutputService.java @@ -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.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(); } 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 ed73426..ce95335 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 @@ -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); + } }