merge: 修复转码媒资 id 精度与存量兜底
This commit is contained in:
commit
f9bc6eee6d
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`。
|
||||
@@ -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 反查命中、文件存在补登记及原有转码链全部通过。
|
||||
|
||||
+22
-12
@@ -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)
|
||||
|
||||
+3
-3
@@ -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;
|
||||
|
||||
+12
-8
@@ -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);
|
||||
|
||||
|
||||
+60
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
+32
-1
@@ -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) {
|
||||
|
||||
+25
@@ -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) {
|
||||
}
|
||||
}
|
||||
+89
-4
@@ -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, "图片媒资无需转码");
|
||||
}
|
||||
|
||||
+6
-1
@@ -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();
|
||||
}
|
||||
|
||||
+61
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user