From 5080ce31684629e531cb4efe573f4beb86cfc560 Mon Sep 17 00:00:00 2001 From: sion123 <450702724@qq.com> Date: Sun, 23 Nov 2025 13:24:19 +0800 Subject: [PATCH] =?UTF-8?q?=E7=94=9F=E6=88=90=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/views/material/MaterialList.vue | 62 ++++++++++-- .../service/LatentsyncPollingService.java | 99 +++++++++++++++++-- 2 files changed, 146 insertions(+), 15 deletions(-) diff --git a/frontend/app/web-gold/src/views/material/MaterialList.vue b/frontend/app/web-gold/src/views/material/MaterialList.vue index 2e5e951855..bd733b8dec 100644 --- a/frontend/app/web-gold/src/views/material/MaterialList.vue +++ b/frontend/app/web-gold/src/views/material/MaterialList.vue @@ -123,14 +123,18 @@ 图片 文件 - -
- -
+ +
+ +
+ +
+ +
@@ -249,7 +253,8 @@ import { SearchOutlined, FileOutlined, DeleteOutlined, - TagsOutlined + TagsOutlined, + DownloadOutlined } from '@ant-design/icons-vue' import { MaterialService, MaterialGroupService } from '@/api/material' import { MixService } from '@/api/mix' @@ -425,6 +430,24 @@ const handleDeleteFile = (file) => { }) } +// 下载文件 +const handleDownloadFile = (file) => { + if (!file?.previewUrl) { + message.warning('文件地址无效') + return + } + + const fileUrl = file.previewUrl + const link = document.createElement('a') + link.href = fileUrl + link.download = file.fileName || 'download' + link.target = '_blank' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + message.success('开始下载') +} + // 文件点击 const handleFileClick = (file) => { const isSelected = selectedFileIds.value.includes(file.id) @@ -853,7 +876,7 @@ const handleGroupCancel = () => { .material-item__group { position: absolute; bottom: 8px; - right: 42px; /* 在删除按钮左边 */ + right: 76px; /* 在下载按钮左边 */ width: 28px; height: 28px; background: rgba(24, 144, 255, 0.9); @@ -868,6 +891,24 @@ const handleGroupCancel = () => { font-size: 16px; } +.material-item__download { + position: absolute; + bottom: 8px; + right: 42px; /* 在删除按钮左边 */ + width: 28px; + height: 28px; + background: rgba(82, 196, 26, 0.9); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + cursor: pointer; + opacity: 0; + transition: all 0.3s; + font-size: 16px; +} + .material-item:hover .material-item__delete { opacity: 1; } @@ -876,6 +917,10 @@ const handleGroupCancel = () => { opacity: 1; } +.material-item:hover .material-item__download { + opacity: 1; +} + .material-item__delete:hover { background: rgb(255, 77, 79); transform: scale(1.1); @@ -886,6 +931,11 @@ const handleGroupCancel = () => { transform: scale(1.1); } +.material-item__download:hover { + background: rgb(82, 196, 26); + transform: scale(1.1); +} + .material-item__info { padding: 12px; } diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncPollingService.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncPollingService.java index ac77294b20..e8eed31334 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncPollingService.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/voice/service/LatentsyncPollingService.java @@ -6,6 +6,8 @@ import cn.hutool.http.HttpResponse; import cn.iocoder.yudao.framework.common.util.http.HttpUtils; import cn.iocoder.yudao.framework.common.util.number.NumberUtils; import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore; +import cn.iocoder.yudao.module.tik.file.dal.dataobject.TikUserFileDO; +import cn.iocoder.yudao.module.tik.file.dal.mysql.TikUserFileMapper; import cn.iocoder.yudao.module.tik.file.service.TikOssInitService; import cn.iocoder.yudao.module.tik.voice.dal.dataobject.TikDigitalHumanTaskDO; import cn.iocoder.yudao.module.tik.voice.dal.mysql.TikDigitalHumanTaskMapper; @@ -42,6 +44,7 @@ public class LatentsyncPollingService { private final RedissonClient redissonClient; private final TikOssInitService ossInitService; private final cn.iocoder.yudao.module.infra.api.file.FileApi fileApi; + private final TikUserFileMapper userFileMapper; /** * Redis键前缀 @@ -213,14 +216,14 @@ public class LatentsyncPollingService { } // 保存视频到OSS(异步处理,轻量化逻辑) - String savedVideoUrl = videoUrl; + OssSaveResult saveResult = null; try { // 保存视频到OSS,避免临时URL过期 - savedVideoUrl = saveVideoToOss(task, videoUrl); - log.info("[handleTaskCompleted][任务({})视频已保存到OSS][url={}]", taskId, savedVideoUrl); + saveResult = saveVideoToOss(task, videoUrl); + log.info("[handleTaskCompleted][任务({})视频已保存到OSS][url={}]", taskId, saveResult.getUrl()); } catch (Exception e) { log.warn("[handleTaskCompleted][任务({})保存视频失败,使用原URL][error={}]", taskId, e.getMessage()); - savedVideoUrl = videoUrl; // 降级处理 + saveResult = new OssSaveResult(videoUrl, 0, null); // 降级处理 } // 更新任务状态为成功 @@ -229,18 +232,21 @@ public class LatentsyncPollingService { updateObj.setStatus("SUCCESS"); updateObj.setCurrentStep("finishing"); updateObj.setProgress(100); - updateObj.setResultVideoUrl(savedVideoUrl); + updateObj.setResultVideoUrl(saveResult.getUrl()); updateObj.setFinishTime(LocalDateTime.now()); taskMapper.updateById(updateObj); // 缓存结果到Redis(快速回显) try { String resultKey = "digital_human:task:result:" + taskId; - stringRedisTemplate.opsForValue().set(resultKey, savedVideoUrl, Duration.ofHours(24)); + stringRedisTemplate.opsForValue().set(resultKey, saveResult.getUrl(), Duration.ofHours(24)); } catch (Exception e) { log.warn("[handleTaskCompleted][任务({})缓存结果失败]", taskId, e); } + // 保存结果视频到用户文件表(这样用户可以在素材库中查看) + saveResultVideoToUserFiles(task, saveResult); + // 从轮询队列中移除 removeFromPollingQueue(taskId, requestId); @@ -355,8 +361,9 @@ public class LatentsyncPollingService { /** * 保存视频到OSS - 流式处理优化内存 + * 返回保存结果,包含URL和文件大小 */ - private String saveVideoToOss(TikDigitalHumanTaskDO task, String remoteVideoUrl) throws Exception { + private OssSaveResult saveVideoToOss(TikDigitalHumanTaskDO task, String remoteVideoUrl) throws Exception { log.info("[saveVideoToOss][任务({})开始下载并保存视频到OSS][remoteUrl={}]", task.getId(), remoteVideoUrl); try { @@ -382,14 +389,44 @@ public class LatentsyncPollingService { // 6. 移除预签名URL中的签名参数,获取基础URL String cleanOssUrl = HttpUtils.removeUrlQuery(ossUrl); + // 7. 生成文件路径(用于后续删除) + String filePath = baseDirectory + "/" + fileName; + log.info("[saveVideoToOss][任务({})视频保存到OSS完成][directory={}, fileName={}, ossUrl={}]", task.getId(), baseDirectory, fileName, cleanOssUrl); - return cleanOssUrl; + return new OssSaveResult(cleanOssUrl, videoBytes.length, filePath); } catch (Exception e) { log.error("[saveVideoToOss][任务({})保存视频到OSS失败][remoteUrl={}]", task.getId(), remoteVideoUrl, e); // 如果保存失败,返回原始URL(降级处理) - return remoteVideoUrl; + return new OssSaveResult(remoteVideoUrl, 0, null); + } + } + + /** + * OSS保存结果 + */ + private static class OssSaveResult { + private final String url; + private final int fileSize; + private final String filePath; + + public OssSaveResult(String url, int fileSize, String filePath) { + this.url = url; + this.fileSize = fileSize; + this.filePath = filePath; + } + + public String getUrl() { + return url; + } + + public int getFileSize() { + return fileSize; + } + + public String getFilePath() { + return filePath; } } @@ -414,4 +451,48 @@ public class LatentsyncPollingService { } } + /** + * 保存结果视频到用户文件表 + * 这样用户就可以在素材库中查看和管理生成的文件 + */ + private void saveResultVideoToUserFiles(TikDigitalHumanTaskDO task, OssSaveResult saveResult) { + try { + Long userId = task.getUserId(); + if (userId == null) { + log.warn("[saveResultVideoToUserFiles][任务({})用户ID为空,无法保存文件]", task.getId()); + return; + } + + // 验证结果视频URL + String resultVideoUrl = saveResult.getUrl(); + if (StrUtil.isBlank(resultVideoUrl)) { + log.warn("[saveResultVideoToUserFiles][任务({})结果视频URL为空,无法保存文件]", task.getId()); + return; + } + + // 生成文件名(使用任务ID和时间戳) + String fileName = String.format("数字人视频_%d_%d.mp4", task.getId(), System.currentTimeMillis()); + + // 创建用户文件记录 + TikUserFileDO userFile = new TikUserFileDO(); + userFile.setUserId(userId); + userFile.setFileName(fileName); + userFile.setFileType("video/mp4"); + userFile.setFileCategory("generate"); // 文件分类:生成文件 + userFile.setFileUrl(resultVideoUrl); + userFile.setFilePath(saveResult.getFilePath()); // 设置文件路径(用于后续删除) + userFile.setFileSize((long) saveResult.getFileSize()); // 设置文件大小 + userFile.setDescription("数字人生成结果视频,任务ID:" + task.getId()); + + // 插入到用户文件表 + userFileMapper.insert(userFile); + + log.info("[saveResultVideoToUserFiles][任务({})结果视频已保存到用户文件表,文件编号({})]", + task.getId(), userFile.getId()); + } catch (Exception e) { + // 保存失败不影响主流程,只记录日志 + log.error("[saveResultVideoToUserFiles][任务({})保存结果视频到用户文件表失败]", task.getId(), e); + } + } + }