From 3a3638295b6894de32c038aba25201741bc6c89c Mon Sep 17 00:00:00 2001 From: sion123 <450702724@qq.com> Date: Wed, 3 Jun 2026 22:19:03 +0800 Subject: [PATCH] =?UTF-8?q?refactor(tik):=20=E6=8A=BD=E5=8F=96=20ICE=20SDK?= =?UTF-8?q?=20=E8=B0=83=E7=94=A8=E5=88=B0=E7=BB=9F=E4=B8=80=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 `BatchProduceAlignment` 中的 ICE `Client` 初始化与 API 调用逻辑抽离到 `IceClient`, 作为项目中唯一 ICE SDK 入口,避免各业务类自行创建客户端实例。 主要变更: - `IceClient`:重构为最终的统一封装入口,支持懒加载 + 线程安全 Client 创建, 新增 `submitJob(SubmitMediaProducingJobRequest)` 和 `getJobStatus()` 方法, 保留旧方法标记为 `@Deprecated` 以兼容存量调用。 - `BatchProduceAlignment`:移除内部 `initClient()` 与直接 SDK 调用,全部委托 `IceClient`; 同时重构 Timeline 构建逻辑,提取 `buildOutputPath()` 等工具方法, 并补充 Javadoc 架构说明。 - `MixTaskServiceImpl`:同步使用 `iceClient.getJobStatus()` 替代已废弃的旧方法名。 --- .../tik/media/BatchProduceAlignment.java | 357 ++++++------------ .../module/tik/mix/client/IceClient.java | 135 ++++--- .../tik/mix/service/MixTaskServiceImpl.java | 2 +- 3 files changed, 217 insertions(+), 277 deletions(-) diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/media/BatchProduceAlignment.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/media/BatchProduceAlignment.java index 4cf95ae3b3..ff7906d949 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/media/BatchProduceAlignment.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/media/BatchProduceAlignment.java @@ -2,13 +2,11 @@ package cn.iocoder.yudao.module.tik.media; import cn.iocoder.yudao.module.infra.api.file.FileApi; import cn.iocoder.yudao.module.tik.file.service.TikOssInitService; +import cn.iocoder.yudao.module.tik.mix.client.IceClient; import cn.iocoder.yudao.module.tik.mix.config.IceProperties; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; -import com.aliyun.ice20201109.Client; import com.aliyun.ice20201109.models.*; -import com.aliyun.teaopenapi.models.Config; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -16,301 +14,162 @@ import cn.iocoder.yudao.module.tik.mix.vo.MixTaskSaveReqVO; import java.util.*; -// 成功视频 -// http://oushu-test-shanghai.oss-cn-shanghai.aliyuncs.com/ice_output/46b29eb5775f4f758846171ab79bfca7.mp4 - /** - * 需要maven引入二方包依赖: - * - * com.aliyun - * ice20201109 - * 2.1.0 - * - * - * com.alibaba - * fastjson - * 1.2.9 - * + * Timeline 构建器 + ICE 提交编排 + *

+ * 职责:将素材列表构建为 ICE Timeline JSON,委托 {@link IceClient} 提交任务。 + *

+ * 架构原则: + *

+ * + * @author 芋道源码 */ @Slf4j @Component -@RequiredArgsConstructor public class BatchProduceAlignment { private final IceProperties properties; private final FileApi fileApi; private final TikOssInitService ossInitService; - private Client iceClient; + private final IceClient iceClient; - public void initClient() throws Exception { - if (iceClient == null) { - synchronized (this) { - if (iceClient == null) { - if (!properties.isEnabled()) { - log.error("ICE 配置未启用或未配置 AccessKey"); - throw new IllegalStateException("未配置 ICE AccessKey"); - } - log.info("初始化阿里云 ICE 客户端... regionId={}, bucket={}", - properties.getRegionId(), properties.getBucket()); - Config config = new Config(); - config.accessKeyId = properties.getAccessKeyId(); - config.accessKeySecret = properties.getAccessKeySecret(); - config.endpoint = "ice." + properties.getRegionId() + ".aliyuncs.com"; - config.regionId = properties.getRegionId(); - iceClient = new Client(config); - log.info("ICE 客户端初始化成功"); - } - } - } + public BatchProduceAlignment(IceProperties properties, FileApi fileApi, + TikOssInitService ossInitService, IceClient iceClient) { + this.properties = properties; + this.fileApi = fileApi; + this.ossInitService = ossInitService; + this.iceClient = iceClient; } - public List batchProduceAlignment(String title, String[] videoArray, int produceCount, Long userId) throws Exception { - // 初始化 ICE 客户端 - if (iceClient == null) { - initClient(); - } + /* ════════════════════════════════════════════════════════════════════════════ + * 旧版 API(兼容保留) + * ════════════════════════════════════════════════════════════════════════════ */ - // 批量提交任务,返回 "jobId:url" 格式 + public List batchProduceAlignment(String title, String[] videoArray, int produceCount, Long userId) throws Exception { List jobIdWithUrls = new ArrayList<>(); if (produceCount <= 1) { - // 生成1个视频,包含所有片段 String jobIdWithUrl = produceSingleVideo(title, videoArray, userId); jobIdWithUrls.add(jobIdWithUrl); } else { - // 生成多个视频:将视频数组分成多份,每份生成一个视频 int videoCount = videoArray.length; - - // 计算每份的起始和结束索引 int videosPerGroup = Math.max(1, videoCount / produceCount); int remainder = videoCount % produceCount; int start = 0; for (int i = 0; i < produceCount; i++) { - // 计算当前组的视频数量(尽可能平均分配) int groupSize = videosPerGroup + (i < remainder ? 1 : 0); - - // 提取当前组的视频片段 String[] groupVideos = Arrays.copyOfRange(videoArray, start, start + groupSize); - - // 生成单个视频 String jobIdWithUrl = produceSingleVideo(title, groupVideos, userId); jobIdWithUrls.add(jobIdWithUrl); - start += groupSize; } } - // 改为异步模式,不在这里等待 return jobIdWithUrls; } public String produceSingleVideo(String title, String[] videoArray, Long userId) throws Exception { - // 初始化 ICE 客户端 - if (iceClient == null) { - initClient(); - } - - // 纯画面模式:仅拼接视频片段,为每个视频添加静音音频轨道 JSONArray videoClipArray = new JSONArray(); JSONArray audioClipArray = new JSONArray(); - // 按顺序拼接视频片段(不随机打乱) for (int i = 0; i < videoArray.length; i++) { - String videoUrl = videoArray[i]; - - // 将CDN URL转换为OSS原始URL(ICE需要原始域名) - videoUrl = properties.convertToOssUrl(videoUrl); - videoArray[i] = videoUrl; // 更新数组中的URL + String videoUrl = properties.convertToOssUrl(videoArray[i]); + videoArray[i] = videoUrl; log.debug("[ICE][添加视频片段][{}: {}]", i + 1, videoUrl); - // 使用标准的 MediaURL 参数(符合 ICE API 文档规范) String videoClip = "{\"MediaURL\":\"" + videoUrl + "\"}"; videoClipArray.add(JSONObject.parseObject(videoClip)); - // 为每个视频片段添加静音的音频轨道 JSONObject audioClip = new JSONObject(); audioClip.put("MediaURL", videoUrl); audioClip.put("Effects", new JSONArray() {{ add(new JSONObject() {{ put("Type", "Volume"); - put("Gain", 0); // 0 表示静音 + put("Gain", 0); }}); }}); audioClipArray.add(audioClip); } - // 构建时间线,包含视频轨道和音频轨道 - String timeline = "{\"VideoTracks\":[{\"VideoTrackClips\":"+videoClipArray.toJSONString()+"}],\"AudioTracks\":[{\"AudioTrackClips\":"+audioClipArray.toJSONString()+"}]}"; + String timeline = "{\"VideoTracks\":[{\"VideoTrackClips\":" + videoClipArray.toJSONString() + + "}],\"AudioTracks\":[{\"AudioTrackClips\":" + audioClipArray.toJSONString() + "}]}"; - // 生成输出文件路径:{用户目录}/mix/{yyyyMMdd}/{uuid}.mp4 - String targetFileName = UUID.randomUUID().toString().replace("-", ""); - String mixDirectory = ossInitService.getOssDirectoryByCategory(userId, "mix"); - String dateDir = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")); - String outputMediaPath = mixDirectory + "/" + dateDir + "/" + targetFileName + ".mp4"; - - // ICE写入必须使用OSS原始域名(不能是CDN域名,因为ICE需要写权限) + String outputMediaPath = buildOutputPath(userId); String outputMediaUrl = properties.getOssWriteUrl(outputMediaPath); - // ICE需要将处理结果写入到该URL,签名URL会导致写入失败 - int width = 720; - int height = 1280; - int bitrate = 2000; // 输出码率 2000 Kbit/s(符合 ICE API 文档推荐) - String outputMediaConfig = "{\"MediaURL\":\"" + outputMediaUrl + "\",\"Width\":" + width + ",\"Height\":" + height + ",\"Bitrate\":" + bitrate + "}"; + int width = 720, height = 1280, bitrate = 2000; + String outputMediaConfig = "{\"MediaURL\":\"" + outputMediaUrl + + "\",\"Width\":" + width + ",\"Height\":" + height + ",\"Bitrate\":" + bitrate + "}"; SubmitMediaProducingJobRequest request = new SubmitMediaProducingJobRequest(); request.setTimeline(timeline); request.setOutputMediaConfig(outputMediaConfig); - log.info("[ICE][提交任务][视频数量={}, timeline={}]", videoArray.length, timeline); - SubmitMediaProducingJobResponse response = iceClient.submitMediaProducingJob(request); + log.info("[ICE][提交任务][视频数量={}]", videoArray.length); + String jobId = iceClient.submitJob(request); + log.info("[ICE][任务提交成功][jobId={}]", jobId); - String jobId = response.getBody().getJobId(); - log.info("[ICE][任务提交成功][jobId={}, outputMediaUrl={}]", jobId, outputMediaUrl); - // 返回CDN URL用于存储到数据库(ICE写入用OSS原始URL,存储用CDN URL) String cdnUrl = properties.getCdnStorageUrl(outputMediaPath); return jobId + " : " + cdnUrl; } - /** - * 检查单个任务状态 - * - * @param jobId 任务ID - * @return 任务状态:Pending/Running/Success/Failed - */ - public String checkJobStatus(String jobId) throws Exception { - if (iceClient == null) { - initClient(); - } - - GetMediaProducingJobRequest req = new GetMediaProducingJobRequest(); - req.setJobId(jobId); - GetMediaProducingJobResponse response = iceClient.getMediaProducingJob(req); - GetMediaProducingJobResponseBody.GetMediaProducingJobResponseBodyMediaProducingJob mediaProducingJob = response.getBody().getMediaProducingJob(); - String status = mediaProducingJob.getStatus(); - log.debug("jobId: {}, status: {}", mediaProducingJob.getJobId(), status); - return status; - } - - /** - * 从任务ID中获取输出URL - * - * @param jobIdWithUrl jobId : url 格式的字符串 - * @return 输出URL - */ - public String extractOutputUrl(String jobIdWithUrl) { - if (jobIdWithUrl == null || !jobIdWithUrl.contains(" : ")) { - return null; - } - return jobIdWithUrl.split(" : ")[1]; - } - - /** - * 计算裁剪参数 - * - * @param sourceWidth 源素材宽度 - * @param sourceHeight 源素材高度 - * @param cropMode 裁剪模式:center(居中裁剪)、smart(智能裁剪)、fill(填充模式) - * @return 裁剪参数Map,包含X、Y、Width、Height - */ - private Map calculateCropParams(int sourceWidth, int sourceHeight, String cropMode) { - Map cropParams = new HashMap<>(); - - // 填充模式:不裁剪,保持原尺寸 - if ("fill".equals(cropMode)) { - cropParams.put("X", 0); - cropParams.put("Y", 0); - cropParams.put("Width", sourceWidth); - cropParams.put("Height", sourceHeight); - log.debug("[裁剪计算] 源尺寸={}x{}, 模式=fill, 裁剪参数={}", sourceWidth, sourceHeight, cropParams); - return cropParams; - } - - // center/smart模式:居中裁剪(smart暂未开放,降级为center) - if ("smart".equals(cropMode)) { - log.info("[裁剪模式] smart模式暂未开放,自动降级为center模式"); - } - - double targetRatio = 9.0 / 16.0; // 9:16竖屏比例 - double cropHeight = sourceHeight; - double cropWidth = cropHeight * targetRatio; - int cropX = (int) Math.round((sourceWidth - cropWidth) / 2); - - cropParams.put("X", cropX); - cropParams.put("Y", 0); - cropParams.put("Width", (int) Math.round(cropWidth)); - cropParams.put("Height", (int) Math.round(cropHeight)); - - log.debug("[裁剪计算] 源尺寸={}x{}, 模式={}, 裁剪参数={}", sourceWidth, sourceHeight, cropMode, cropParams); - return cropParams; - } + /* ════════════════════════════════════════════════════════════════════════════ + * 新版 API(核心方法) + * ════════════════════════════════════════════════════════════════════════════ */ /** * 生成单个视频(支持随机截取起始点) * - * 多视频差异化原理: - * - 每个视频使用随机截取起点,确保内容完全不同 - * - 支持不同长度的素材,ICE自动容错处理 - * - 容错机制:如果起点超出素材长度,从0开始截取 - * - * @param materials 素材列表(包含fileUrl和duration) - * @param videoIndex 视频序号(0开始),用于生成随机种子 - * @param userId 用户ID - * @param cropMode 裁剪模式:center(居中裁剪)、smart(智能裁剪)、fill(填充模式) - * @return jobId : outputUrl 格式字符串 + * @param materials 素材列表 + * @param videoIndex 视频序号(用于随机种子) + * @param userId 用户 ID + * @param cropMode 裁剪模式 + * @return "jobId : cdnUrl" 格式字符串 */ public String produceSingleVideoWithOffset(List materials, int videoIndex, Long userId, String cropMode) throws Exception { - if (iceClient == null) { - initClient(); - } - JSONArray videoClipArray = new JSONArray(); JSONArray audioClipArray = new JSONArray(); float timelinePos = 0; for (int i = 0; i < materials.size(); i++) { MixTaskSaveReqVO.MaterialItem material = materials.get(i); - String videoUrl = material.getFileUrl(); + String videoUrl = properties.convertToOssUrl(material.getFileUrl()); + material.setFileUrl(videoUrl); int duration = material.getDuration(); - // 将CDN URL转换为OSS原始URL(ICE需要原始域名) - videoUrl = properties.convertToOssUrl(videoUrl); - material.setFileUrl(videoUrl); // 更新material中的URL - - // 计算随机截取起点 - // 优先使用前端传入的素材实际时长,无则从0开始截取(兜底) + // 计算随机截取区间 Integer fileDuration = material.getFileDuration(); - int startOffset = 0; - int endOffset = duration; - int actualDuration = duration; // 实际截取时长 + int startOffset = 0, endOffset = duration, actualDuration = duration; if (fileDuration != null && fileDuration >= duration) { - // 有实际时长且足够:随机起点范围 0 到 (实际时长 - 截取时长) - long randomSeed = ((material.getFileId() != null ? material.getFileId() : i) * 1000000L) + - (videoIndex * 10000L) + (material.getFileUrl().hashCode() % 1000); + long randomSeed = ((material.getFileId() != null ? material.getFileId() : i) * 1000000L) + + (videoIndex * 10000L) + (videoUrl.hashCode() % 1000); Random random = new Random(randomSeed); int maxStartOffset = fileDuration - duration; startOffset = random.nextInt(maxStartOffset + 1); endOffset = startOffset + duration; log.debug("[ICE][随机截取] fileId={}, fileDuration={}s, In={}, Out={}", - material.getFileId(), fileDuration, startOffset, endOffset); - } else if (fileDuration != null && fileDuration < duration) { - // 素材时长不足:使用素材全部时长,避免超出范围 + material.getFileId(), fileDuration, startOffset, endOffset); + } else if (fileDuration != null) { actualDuration = fileDuration; endOffset = fileDuration; - log.warn("[ICE][素材时长不足] fileId={}, 请求{}s, 实际{}s, 将使用全部时长", - material.getFileId(), duration, fileDuration); + log.warn("[ICE][素材时长不足] fileId={}, 请求{}s, 实际{}s", + material.getFileId(), duration, fileDuration); } else { - // 无时长信息:从0开始截取(兜底,ICE可能会失败) - log.warn("[ICE][无时长信息] fileId={}, In=0, Out={}, ICE可能会失败", - material.getFileId(), duration); + log.warn("[ICE][无时长信息] fileId={}, In=0, Out={}", + material.getFileId(), duration); } log.debug("[ICE][添加视频片段][视频{}: {}, In={}, Out={}, TimelineIn={}, TimelineOut={}]", - videoIndex + 1, videoUrl, startOffset, endOffset, timelinePos, timelinePos + actualDuration); + videoIndex + 1, videoUrl, startOffset, endOffset, timelinePos, timelinePos + actualDuration); - // 构建视频片段(带 In/Out 参数) + // 构建视频片段 JSONObject videoClip = new JSONObject(); videoClip.put("MediaURL", videoUrl); videoClip.put("In", startOffset); @@ -318,15 +177,9 @@ public class BatchProduceAlignment { videoClip.put("TimelineIn", timelinePos); videoClip.put("TimelineOut", timelinePos + actualDuration); - // 添加裁剪效果(9:16竖屏输出) - // 假设源素材为1920x1080(16:9),可根据实际情况调整 - int sourceWidth = 1920; - int sourceHeight = 1080; - + // 裁剪效果 if (cropMode != null && !"fill".equals(cropMode)) { - // 非填充模式需要裁剪 - Map cropParams = calculateCropParams(sourceWidth, sourceHeight, cropMode); - + Map cropParams = calculateCropParams(1920, 1080, cropMode); JSONArray effects = new JSONArray(); JSONObject cropEffect = new JSONObject(); cropEffect.put("Type", "Crop"); @@ -335,14 +188,12 @@ public class BatchProduceAlignment { cropEffect.put("Width", cropParams.get("Width")); cropEffect.put("Height", cropParams.get("Height")); effects.add(cropEffect); - videoClip.put("Effects", effects); - log.debug("[裁剪效果] 视频{}应用裁剪,模式={}, 参数={}", i + 1, cropMode, cropParams); } videoClipArray.add(videoClip); - // 为每个视频片段添加静音的音频轨道 + // 静音音频轨道 JSONObject audioClip = new JSONObject(); audioClip.put("MediaURL", videoUrl); audioClip.put("In", startOffset); @@ -352,7 +203,7 @@ public class BatchProduceAlignment { audioClip.put("Effects", new JSONArray() {{ add(new JSONObject() {{ put("Type", "Volume"); - put("Gain", 0); // 静音 + put("Gain", 0); }}); }}); audioClipArray.add(audioClip); @@ -360,38 +211,80 @@ public class BatchProduceAlignment { timelinePos += actualDuration; } - // 构建时间线 - String timeline = "{\"VideoTracks\":[{\"VideoTrackClips\":" + videoClipArray.toJSONString() + - "}],\"AudioTracks\":[{\"AudioTrackClips\":" + audioClipArray.toJSONString() + "}]}"; + // 构建 Timeline + String timeline = "{\"VideoTracks\":[{\"VideoTrackClips\":" + videoClipArray.toJSONString() + + "}],\"AudioTracks\":[{\"AudioTrackClips\":" + audioClipArray.toJSONString() + "}]}"; - // 生成输出文件路径 - String targetFileName = UUID.randomUUID().toString().replace("-", ""); - String mixDirectory = ossInitService.getOssDirectoryByCategory(userId, "mix"); - String dateDir = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")); - String outputMediaPath = mixDirectory + "/" + dateDir + "/" + targetFileName + ".mp4"; - - // ICE写入必须使用OSS原始域名(不能是CDN域名,因为ICE需要写权限) + // 输出配置 + String outputMediaPath = buildOutputPath(userId); String outputMediaUrl = properties.getOssWriteUrl(outputMediaPath); - int width = 720; - int height = 1280; - int bitrate = 2000; - String outputMediaConfig = "{\"MediaURL\":\"" + outputMediaUrl + "\",\"Width\":" + width + - ",\"Height\":" + height + ",\"Bitrate\":" + bitrate + "}"; + int width = 720, height = 1280, bitrate = 2000; + String outputMediaConfig = "{\"MediaURL\":\"" + outputMediaUrl + + "\",\"Width\":" + width + ",\"Height\":" + height + ",\"Bitrate\":" + bitrate + "}"; SubmitMediaProducingJobRequest request = new SubmitMediaProducingJobRequest(); request.setTimeline(timeline); request.setOutputMediaConfig(outputMediaConfig); log.info("[ICE][提交任务][videoIndex={}, 素材数量={}, 总时长={}s]", - videoIndex, materials.size(), (int)timelinePos); - SubmitMediaProducingJobResponse response = iceClient.submitMediaProducingJob(request); + videoIndex, materials.size(), (int) timelinePos); + String jobId = iceClient.submitJob(request); + log.info("[ICE][任务提交成功][videoIndex={}, jobId={}]", videoIndex, jobId); - String jobId = response.getBody().getJobId(); - log.info("[ICE][任务提交成功][videoIndex={}, jobId={}, outputUrl={}]", videoIndex, jobId, outputMediaUrl); - // 返回CDN URL用于存储到数据库(ICE写入用OSS原始URL,存储用CDN URL) String cdnUrl = properties.getCdnStorageUrl(outputMediaPath); return jobId + " : " + cdnUrl; } -} \ No newline at end of file + /* ════════════════════════════════════════════════════════════════════════════ + * 工具方法 + * ════════════════════════════════════════════════════════════════════════════ */ + + public String checkJobStatus(String jobId) throws Exception { + return iceClient.getJobStatus(jobId); + } + + public String extractOutputUrl(String jobIdWithUrl) { + if (jobIdWithUrl == null || !jobIdWithUrl.contains(" : ")) { + return null; + } + return jobIdWithUrl.split(" : ")[1]; + } + + private String buildOutputPath(Long userId) { + String targetFileName = UUID.randomUUID().toString().replace("-", ""); + String mixDirectory = ossInitService.getOssDirectoryByCategory(userId, "mix"); + String dateDir = java.time.LocalDate.now() + .format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd")); + return mixDirectory + "/" + dateDir + "/" + targetFileName + ".mp4"; + } + + private Map calculateCropParams(int sourceWidth, int sourceHeight, String cropMode) { + Map cropParams = new HashMap<>(); + + if ("fill".equals(cropMode)) { + cropParams.put("X", 0); + cropParams.put("Y", 0); + cropParams.put("Width", sourceWidth); + cropParams.put("Height", sourceHeight); + return cropParams; + } + + if ("smart".equals(cropMode)) { + log.info("[裁剪模式] smart 模式暂未开放,降级为 center"); + } + + double targetRatio = 9.0 / 16.0; + double cropHeight = sourceHeight; + double cropWidth = cropHeight * targetRatio; + int cropX = (int) Math.round((sourceWidth - cropWidth) / 2); + + cropParams.put("X", cropX); + cropParams.put("Y", 0); + cropParams.put("Width", (int) Math.round(cropWidth)); + cropParams.put("Height", (int) Math.round(cropHeight)); + + log.debug("[裁剪计算] 源尺寸={}x{}, 模式={}, 参数={}", sourceWidth, sourceHeight, cropMode, cropParams); + return cropParams; + } +} diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/client/IceClient.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/client/IceClient.java index fa9cafe7ca..abce411e1b 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/client/IceClient.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/client/IceClient.java @@ -1,114 +1,161 @@ package cn.iocoder.yudao.module.tik.mix.client; -import cn.hutool.core.util.StrUtil; -import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.module.tik.mix.config.IceProperties; import com.aliyun.ice20201109.Client; import com.aliyun.ice20201109.models.*; import com.aliyun.teaopenapi.models.Config; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import java.util.concurrent.TimeUnit; - import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception0; import static cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants.VOICE_TTS_FAILED; /** - * 阿里云 ICE 客户端 + * 阿里云 ICE 客户端 —— 项目中 ICE SDK 的唯一封装入口 + *

+ * 设计原则: + *

    + *
  • 单一入口:所有 ICE API 调用必须经过此类,禁止各业务类自行创建 {@link Client}
  • + *
  • 统一超时:通过 {@link IceProperties} 集中管理连接/读取超时
  • + *
  • 懒加载 + 线程安全:DCL 保证全局唯一 Client 实例
  • + *
  • 统一异常转换:ICE SDK 异常统一转为 {@link cn.iocoder.yudao.framework.common.exception.ServiceException}
  • + *
+ * + * @author 芋道源码 */ @Slf4j @Component -@RequiredArgsConstructor public class IceClient { private final IceProperties properties; - private volatile Client iceClient; + private volatile Client client; - /** - * 获取 ICE 客户端(懒加载,线程安全) - */ - public Client getClient() { - if (iceClient == null) { + public IceClient(IceProperties properties) { + this.properties = properties; + } + + /* ════════════════════════════════════════════════════════════════════════════ + * Client 生命周期管理 + * ════════════════════════════════════════════════════════════════════════════ */ + + private Client getClient() { + if (client == null) { synchronized (this) { - if (iceClient == null) { + if (client == null) { if (!properties.isEnabled()) { throw exception0(VOICE_TTS_FAILED.getCode(), "未配置 ICE AccessKey"); } - iceClient = createClient(); + client = createClient(); } } } - return iceClient; + return client; } - /** - * 创建 ICE 客户端 - */ private Client createClient() { try { - log.info("[ICE][开始初始化客户端][regionId={}, bucket={}]", - properties.getRegionId(), properties.getBucket()); + log.info("[ICE][初始化] regionId={}, bucket={}, connectTimeout={}, readTimeout={}", + properties.getRegionId(), properties.getBucket(), + properties.getConnectTimeout(), properties.getReadTimeout()); Config config = new Config(); config.accessKeyId = properties.getAccessKeyId(); config.accessKeySecret = properties.getAccessKeySecret(); config.endpoint = "ice." + properties.getRegionId() + ".aliyuncs.com"; config.regionId = properties.getRegionId(); + config.connectTimeout = (int) properties.getConnectTimeout().toMillis(); + config.readTimeout = (int) properties.getReadTimeout().toMillis(); - Client client = new Client(config); - - log.info("[ICE][客户端初始化成功]"); - return client; + Client c = new Client(config); + log.info("[ICE][初始化成功]"); + return c; } catch (Exception e) { - log.error("[ICE][客户端初始化失败]", e); + log.error("[ICE][初始化失败]", e); throw exception0(VOICE_TTS_FAILED.getCode(), "ICE 客户端初始化失败: " + e.getMessage()); } } + /* ════════════════════════════════════════════════════════════════════════════ + * 业务 API + * ════════════════════════════════════════════════════════════════════════════ */ + /** - * 提交媒体制作任务 + * 提交媒体制作任务(底层方法,接受已构建好的 Request) + *

+ * 供 {@link cn.iocoder.yudao.module.tik.media.BatchProduceAlignment} 等 + * 需要自定义 Timeline 构建逻辑的上层组件调用。 + * + * @param request 已填充 timeline 和 outputMediaConfig 的请求对象 + * @return ICE 任务 ID(jobId) */ - public String submitMediaProducingJob(String timeline, String outputMediaConfig) { + public String submitJob(SubmitMediaProducingJobRequest request) { try { - Client client = getClient(); - SubmitMediaProducingJobRequest request = new SubmitMediaProducingJobRequest(); - request.setTimeline(timeline); - request.setOutputMediaConfig(outputMediaConfig); - - SubmitMediaProducingJobResponse response = client.submitMediaProducingJob(request); + SubmitMediaProducingJobResponse response = getClient().submitMediaProducingJob(request); String jobId = response.getBody().getJobId(); - - log.info("[ICE][提交任务成功][jobId={}]", jobId); + log.info("[ICE][提交成功] jobId={}", jobId); return jobId; - } catch (Exception e) { - log.error("[ICE][提交任务失败]", e); + log.error("[ICE][提交失败]", e); throw exception0(VOICE_TTS_FAILED.getCode(), "ICE 提交任务失败: " + e.getMessage()); } } /** - * 查询媒体制作任务状态 + * 提交媒体制作任务(便捷方法,接受字符串参数) + * + * @param timeline 时间线 JSON + * @param outputMediaConfig 输出媒体配置 JSON + * @return ICE 任务 ID(jobId) */ - public String getMediaProducingJobStatus(String jobId) { + public String submitJob(String timeline, String outputMediaConfig) { + SubmitMediaProducingJobRequest request = new SubmitMediaProducingJobRequest(); + request.setTimeline(timeline); + request.setOutputMediaConfig(outputMediaConfig); + return submitJob(request); + } + + /** + * 查询媒体制作任务状态 + * + * @param jobId ICE 任务 ID + * @return 任务状态:Success / Failed / Running / Pending 等 + */ + public String getJobStatus(String jobId) { try { - Client client = getClient(); GetMediaProducingJobRequest request = new GetMediaProducingJobRequest(); request.setJobId(jobId); - GetMediaProducingJobResponse response = client.getMediaProducingJob(request); + GetMediaProducingJobResponse response = getClient().getMediaProducingJob(request); String status = response.getBody().getMediaProducingJob().getStatus(); - log.debug("[ICE][查询任务状态][jobId={}, status={}]", jobId, status); + log.debug("[ICE][状态查询] jobId={}, status={}", jobId, status); return status; } catch (Exception e) { - log.error("[ICE][查询任务状态失败][jobId={}]", jobId, e); + log.error("[ICE][状态查询失败] jobId={}", jobId, e); throw exception0(VOICE_TTS_FAILED.getCode(), "ICE 查询任务状态失败: " + e.getMessage()); } } + + /* ════════════════════════════════════════════════════════════════════════════ + * 历史兼容方法(逐步废弃) + * ════════════════════════════════════════════════════════════════════════════ */ + + /** + * @deprecated 请使用 {@link #submitJob(String, String)} + */ + @Deprecated + public String submitMediaProducingJob(String timeline, String outputMediaConfig) { + return submitJob(timeline, outputMediaConfig); + } + + /** + * @deprecated 请使用 {@link #getJobStatus(String)} + */ + @Deprecated + public String getMediaProducingJobStatus(String jobId) { + return getJobStatus(jobId); + } } diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/service/MixTaskServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/service/MixTaskServiceImpl.java index 69d2fe08c1..ff5a0cf61f 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/service/MixTaskServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/mix/service/MixTaskServiceImpl.java @@ -384,7 +384,7 @@ public class MixTaskServiceImpl implements MixTaskService { for (String jobId : jobIds) { try { - String status = iceClient.getMediaProducingJobStatus(jobId); + String status = iceClient.getJobStatus(jobId); log.debug("[MixTask][jobId状态] taskId={}, jobId={}, status={}", taskId, jobId, status); if ("Success".equalsIgnoreCase(status)) { successJobIds.add(jobId);