From 33abc33b588b974adfba9bcd0cb88b24fc03e889 Mon Sep 17 00:00:00 2001 From: wing <10123261+rattan-xiamingrui@user.noreply.gitee.com> Date: Wed, 19 Nov 2025 00:12:47 +0800 Subject: [PATCH] send-stream --- .../src/components/ChatMessageRenderer.vue | 155 +- frontend/app/web-gold/src/router/index.js | 1 - frontend/app/web-gold/src/stores/voiceCopy.js | 174 +- .../app/web-gold/src/utils/video-cover.ts | 2 +- frontend/app/web-gold/src/views/dh/Avatar.vue | 10 +- frontend/app/web-gold/src/views/dh/Video.vue | 951 +++++----- .../app/web-gold/src/views/dh/VoiceCopy.vue | 1542 ++++++----------- .../src/views/material/MaterialList.vue | 169 +- .../app/web-gold/src/views/mix/MixEditor.vue | 22 - .../file/core/client/s3/S3FileClient.java | 10 +- yudao-module-tik/DESIGN.md | 282 --- yudao-module-tik/LOGIC_ANALYSIS.md | 87 - yudao-module-tik/LOGIC_REVIEW.md | 111 -- yudao-module-tik/UPLOAD_STRATEGY.md | 76 - .../app/AppAiChatMessageController.java | 16 +- .../module/tik/enmus/ErrorCodeConstants.java | 8 + .../file/service/TikUserFileServiceImpl.java | 174 +- .../service/TikFileTransCharacters.java | 36 +- .../tik/tikhup/service/TikHupServiceImpl.java | 37 +- .../src/main/resources/application-local.yaml | 7 +- .../src/main/resources/application.yaml | 7 + 21 files changed, 1630 insertions(+), 2247 deletions(-) delete mode 100644 frontend/app/web-gold/src/views/mix/MixEditor.vue delete mode 100644 yudao-module-tik/DESIGN.md delete mode 100644 yudao-module-tik/LOGIC_ANALYSIS.md delete mode 100644 yudao-module-tik/LOGIC_REVIEW.md delete mode 100644 yudao-module-tik/UPLOAD_STRATEGY.md diff --git a/frontend/app/web-gold/src/components/ChatMessageRenderer.vue b/frontend/app/web-gold/src/components/ChatMessageRenderer.vue index 7fc24cd261..57afcf98ad 100644 --- a/frontend/app/web-gold/src/components/ChatMessageRenderer.vue +++ b/frontend/app/web-gold/src/components/ChatMessageRenderer.vue @@ -3,7 +3,7 @@ diff --git a/frontend/app/web-gold/src/views/material/MaterialList.vue b/frontend/app/web-gold/src/views/material/MaterialList.vue index 6c5582b285..0397e61001 100644 --- a/frontend/app/web-gold/src/views/material/MaterialList.vue +++ b/frontend/app/web-gold/src/views/material/MaterialList.vue @@ -9,6 +9,14 @@ 上传素材 + + 素材混剪 + + +
+

选中素材:{{ selectedFiles.length }} 个

+

视频素材:{{ selectedVideoUrls.length }} 个

+

背景音乐:{{ selectedAudioUrls.length }} 个

+
+ + + + + + + + + + + +
- - - - - - - diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java index 94ba6a3ebb..00d3511966 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/s3/S3FileClient.java @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.infra.framework.file.core.client.s3; import cn.hutool.core.io.IoUtil; import cn.hutool.core.util.BooleanUtil; import cn.hutool.core.util.StrUtil; +import cn.hutool.core.util.URLUtil; import cn.hutool.http.HttpUtil; import cn.iocoder.yudao.framework.common.util.http.HttpUtils; import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient; @@ -23,6 +24,9 @@ import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignReques import java.net.URI; import java.net.URL; import java.time.Duration; +import java.nio.charset.StandardCharsets; + +import org.springframework.web.util.UriUtils; /** * 基于 S3 协议的文件客户端,实现 MinIO、阿里云、腾讯云、七牛云、华为云等云服务 @@ -115,15 +119,17 @@ public class S3FileClient extends AbstractFileClient { // 1. 将 url 转换为 path String path = StrUtil.removePrefix(url, config.getDomain() + "/"); path = HttpUtils.removeUrlQuery(path); + String decodedPath = URLUtil.decode(path, StandardCharsets.UTF_8); // 2.1 情况一:公开访问:无需签名 // 考虑到老版本的兼容,所以必须是 config.getEnablePublicAccess() 为 false 时,才进行签名 if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) { - return config.getDomain() + "/" + path; + String encodedPath = UriUtils.encodePath(decodedPath, StandardCharsets.UTF_8); + return config.getDomain() + "/" + encodedPath; } // 2.2 情况二:私有访问:生成 GET 预签名 URL - String finalPath = path; + String finalPath = decodedPath; Duration expiration = expirationSeconds != null ? Duration.ofSeconds(expirationSeconds) : EXPIRATION_DEFAULT; URL signedUrl = presigner.presignGetObject(GetObjectPresignRequest.builder() .signatureDuration(expiration) diff --git a/yudao-module-tik/DESIGN.md b/yudao-module-tik/DESIGN.md deleted file mode 100644 index e2a3d84a12..0000000000 --- a/yudao-module-tik/DESIGN.md +++ /dev/null @@ -1,282 +0,0 @@ -# Tik 文件管理模块设计文档 - -## 一、模块概述 - -Tik 文件管理模块负责用户文件的上传、存储、管理和分组功能,支持多种文件类型(视频、图片、音频等)和分类管理。 - -## 二、表结构设计 - -### 2.1 核心表 - -#### 1. `tik_user_file` - 用户文件表 -**作用**:存储用户上传的文件元数据 - -**关键字段**: -- `file_path` (varchar(1024)): **完整OSS路径**,格式:`{手机号MD5}/{租户ID}/{分类}/{日期}/{文件名}_{时间戳}.ext` -- `file_url` (varchar(1024)): 文件访问URL(预签名URL或公开URL) -- `oss_root_path` (varchar(256)): OSS根路径,用于快速定位用户文件目录 -- `file_category`: 文件分类(video/generate/audio/mix/voice) -- `file_id`: 关联 `infra_file.id`(可选,用于关联系统文件表) - -**索引设计**: -- `idx_user_id`: 用户ID索引 -- `idx_file_category`: 文件分类索引 -- `idx_user_tenant`: 用户+租户联合索引 -- `idx_create_time`: 创建时间索引 - -#### 2. `tik_user_oss_init` - OSS初始化记录表 -**作用**:记录用户OSS目录初始化状态和路径信息 - -**关键字段**: -- `mobile_md5`: 手机号MD5值(用于生成OSS路径) -- `oss_root_path`: OSS根路径 -- `video_path`, `generate_path`, `audio_path`, `mix_path`, `voice_path`: 各分类目录路径 -- `init_status`: 初始化状态(0-未初始化,1-已初始化) - -**设计要点**: -- 懒加载策略:首次上传时自动初始化 -- 路径格式:`{手机号MD5}/{租户ID}/{分类}` - -#### 3. `tik_file_group` - 文件分组表 -**作用**:用户自定义文件分组(支持层级分组) - -**关键字段**: -- `parent_id`: 父分组ID(0表示根分组) -- `sort`: 排序字段 - -#### 4. `tik_user_file_group` - 文件分组关联表 -**作用**:文件与分组的关联关系(支持一个文件属于多个分组) - -**设计要点**: -- 多对多关系 -- 唯一索引:`uk_file_group` (file_id, group_id) - -#### 5. `tik_user_quota` - 用户配额表 -**作用**:管理用户存储配额和VIP等级 - -**关键字段**: -- `total_storage`: 总存储空间(字节) -- `used_storage`: 已使用存储空间(字节) -- `vip_level`: VIP等级 - -## 三、架构设计 - -### 3.1 分层架构 - -``` -Controller 层 (AppTikUserFileController) - ↓ -Service 层 (TikUserFileService) - ↓ -Mapper 层 (TikUserFileMapper) - ↓ -DataObject 层 (TikUserFileDO) -``` - -### 3.2 核心服务 - -#### 1. TikUserFileService - 文件管理服务 -**职责**: -- 文件上传(带配额校验) -- 文件查询(分页、筛选) -- 文件删除(逻辑删除 + 物理删除) -- 预签名URL生成 - -**关键流程**: -1. **上传流程**: - ``` - 校验文件分类 → 校验配额 → 获取OSS目录 → 生成完整路径 → 上传到OSS → 保存元数据 → 更新配额 - ``` - -2. **删除流程**: - ``` - 校验权限 → 物理删除OSS文件 → 逻辑删除记录 → 释放配额 - ``` - -#### 2. TikOssInitService - OSS初始化服务 -**职责**: -- 初始化用户OSS目录结构 -- 获取OSS路径信息 -- 懒加载策略实现 - -**设计要点**: -- OSS目录是虚拟的,不需要显式创建 -- 首次上传时自动初始化 -- 路径格式:`{手机号MD5}/{租户ID}/{分类}` - -#### 3. TikFileGroupService - 文件分组服务 -**职责**: -- 分组CRUD -- 层级分组支持 - -#### 4. TikUserQuotaService - 配额管理服务 -**职责**: -- 配额校验 -- 配额更新 -- VIP等级管理 - -## 四、路径设计 - -### 4.1 OSS路径结构 - -``` -{手机号MD5}/{租户ID}/{分类}/{日期}/{文件名}_{时间戳}.ext -``` - -**示例**: -``` -abc123def45678901234567890123456/1/video/20250101/my_video_1234567890123.mp4 -``` - -**路径组成部分**: -1. **手机号MD5** (32字符): 用户唯一标识,保护隐私 -2. **租户ID**: 多租户隔离 -3. **分类** (video/generate/audio/mix/voice): 文件分类 -4. **日期** (yyyyMMdd): 按日期分目录,便于管理 -5. **文件名+时间戳**: 保证唯一性,避免覆盖 - -### 4.2 路径存储策略 - -- **file_path**: 存储完整OSS路径(用于物理删除) -- **file_url**: 存储访问URL(用于前端展示) -- **oss_root_path**: 存储根路径(用于快速定位) - -## 五、设计亮点 - -### 5.1 优点 - -1. **分层清晰**:Controller → Service → Mapper → DO,职责明确 -2. **配额管理**:上传前校验,删除后释放 -3. **多租户支持**:通过 tenant_id 隔离 -4. **懒加载策略**:OSS目录按需初始化 -5. **路径设计合理**:包含用户、租户、分类、日期等信息 -6. **分组功能**:支持多分组、层级分组 - -### 5.2 需要改进的地方 - -1. **物理删除OSS文件**: - - 当前只做了逻辑删除,OSS文件未删除 - - 建议:删除时调用 FileService 或 FileClient 删除OSS文件 - - 或者:定期清理已逻辑删除的文件 - -2. **file_path 字段长度**: - - 当前:varchar(512) - - 建议:varchar(1024) 更安全 - -3. **文件关联 infra_file 表**: - - `file_id` 字段存在但未充分利用 - - 建议:上传时关联 infra_file 表,便于统一管理 - -4. **预览图生成**: - - 视频封面和图片缩略图功能未实现 - - 建议:异步生成预览图 - -5. **批量操作优化**: - - 删除文件时逐个删除OSS文件,可能较慢 - - 建议:批量删除或异步删除 - -## 六、数据流 - -### 6.1 上传流程 - -``` -前端上传文件 - ↓ -Controller 接收 - ↓ -Service 校验(分类、配额) - ↓ -获取OSS目录(懒加载初始化) - ↓ -生成完整路径 - ↓ -上传到OSS(FileApi) - ↓ -保存元数据到 tik_user_file - ↓ -更新配额(tik_user_quota) - ↓ -返回文件ID -``` - -### 6.2 查询流程 - -``` -前端请求文件列表 - ↓ -Controller 接收查询参数 - ↓ -Service 查询数据库(分页、筛选) - ↓ -转换为VO(生成预览URL) - ↓ -返回分页结果 -``` - -### 6.3 删除流程 - -``` -前端请求删除 - ↓ -Controller 接收文件ID列表 - ↓ -Service 校验权限 - ↓ -物理删除OSS文件(TODO) - ↓ -逻辑删除数据库记录 - ↓ -释放配额 - ↓ -返回成功 -``` - -## 七、API设计 - -### 7.1 文件管理API - -- `POST /api/tik/file/upload` - 上传文件 -- `GET /api/tik/file/page` - 分页查询 -- `DELETE /api/tik/file/delete-batch` - 批量删除 -- `GET /api/tik/file/video/play-url` - 获取视频播放URL -- `GET /api/tik/file/audio/play-url` - 获取音频播放URL -- `GET /api/tik/file/preview-url` - 获取预览URL - -### 7.2 分组管理API - -- `POST /api/tik/file/group/create` - 创建分组 -- `PUT /api/tik/file/group/update` - 更新分组 -- `DELETE /api/tik/file/group/delete` - 删除分组 -- `GET /api/tik/file/group/list` - 查询分组列表 -- `POST /api/tik/file/group/add-files` - 添加文件到分组 -- `POST /api/tik/file/group/remove-files` - 从分组移除文件 - -## 八、总结 - -### 8.1 表结构建议 - -1. **必须修改**: - - `file_path` 字段长度:512 → 1024 - -2. **可选优化**: - - 添加 `file_path` 索引(如果经常按路径查询) - - 添加 `file_id` 索引(如果关联 infra_file 表) - -### 8.2 功能完善建议 - -1. **物理删除OSS文件**:删除时调用 FileService 删除OSS文件 -2. **预览图生成**:实现视频封面和图片缩略图异步生成 -3. **文件关联**:充分利用 `file_id` 关联 infra_file 表 -4. **批量操作优化**:优化批量删除性能 - -### 8.3 整体评价 - -**设计评分:8.5/10** - -- ✅ 架构清晰,分层合理 -- ✅ 路径设计合理,支持多租户 -- ✅ 配额管理完善 -- ⚠️ 物理删除功能缺失 -- ⚠️ 预览图功能未实现 -- ⚠️ 部分字段未充分利用 - diff --git a/yudao-module-tik/LOGIC_ANALYSIS.md b/yudao-module-tik/LOGIC_ANALYSIS.md deleted file mode 100644 index 4083fa7fb4..0000000000 --- a/yudao-module-tik/LOGIC_ANALYSIS.md +++ /dev/null @@ -1,87 +0,0 @@ -# 文件上传逻辑分析与问题 - -## 🔴 严重问题:路径不一致 - -### 问题描述 - -当前代码存在**路径不一致**的严重问题: - -1. **FileService.createFile()** 内部调用 `generateUploadPath()` 生成路径 - - 使用 `System.currentTimeMillis()` 作为时间戳 - - 实际存储路径:`{baseDirectory}/{yyyyMMdd}/{filename}_{timestamp1}.ext` - -2. **我们手动调用 generateFullFilePath()** 生成路径 - - 也使用 `System.currentTimeMillis()` 作为时间戳 - - 但调用时间不同,时间戳可能不同:`{baseDirectory}/{yyyyMMdd}/{filename}_{timestamp2}.ext` - -3. **结果**:`filePath` 字段保存的路径 ≠ 实际 OSS 存储路径 - - 导致删除文件时无法找到正确的文件 - - 导致路径查询不准确 - -### 时间戳不一致示例 - -``` -FileService.createFile() 调用时间:2025-01-15 10:30:45.123 - → 生成时间戳:1736905845123 - → 实际路径:video/20250115/file_1736905845123.mp4 - -generateFullFilePath() 调用时间:2025-01-15 10:30:45.125(2毫秒后) - → 生成时间戳:1736905845125 - → 保存路径:video/20250115/file_1736905845125.mp4 - -❌ 路径不匹配! -``` - -## 📋 冗余代码分析 - -### 1. generateFullFilePath() 方法 -- **状态**:冗余 -- **原因**:完全复制了 `FileService.generateUploadPath()` 的逻辑 -- **问题**:时间戳不一致导致路径不匹配 - -### 2. extractPathFromUrl() 方法 -- **状态**:未使用 -- **原因**:创建了但从未调用 -- **建议**:删除或实现使用 - -## ✅ 解决方案 - -### 方案1:从 infra_file 表查询 path(推荐) - -**优点**: -- 路径100%准确 -- 可以关联 file_id -- 逻辑清晰 - -**实现**: -```java -// 上传后,通过 URL 查询 infra_file 表获取 path -FileDO infraFile = fileMapper.selectOne( - new LambdaQueryWrapperX() - .eq(FileDO::getUrl, fileUrl) - .orderByDesc(FileDO::getCreateTime) - .last("LIMIT 1") -); -String filePath = infraFile != null ? infraFile.getPath() : null; -``` - -### 方案2:从 URL 中提取 path - -**优点**: -- 不需要查询数据库 -- 性能好 - -**缺点**: -- URL 可能包含域名、查询参数 -- 提取逻辑复杂,可能不准确 - -### 方案3:修改 FileApi 返回 path(不推荐) - -**缺点**: -- 需要修改框架代码 -- 影响其他模块 - -## 🎯 推荐实现 - -**使用方案1**:从 infra_file 表查询 path,确保路径100%准确。 - diff --git a/yudao-module-tik/LOGIC_REVIEW.md b/yudao-module-tik/LOGIC_REVIEW.md deleted file mode 100644 index f9541c975f..0000000000 --- a/yudao-module-tik/LOGIC_REVIEW.md +++ /dev/null @@ -1,111 +0,0 @@ -# 文件上传逻辑检查报告 - -## ✅ 已修复的问题 - -### 1. 路径不一致问题(已修复) - -**问题**: -- `FileService.createFile()` 和 `generateFullFilePath()` 使用不同的时间戳 -- 导致 `filePath` 和实际 OSS 路径不匹配 - -**修复方案**: -- 从 `infra_file` 表查询实际路径(通过 URL + 文件大小) -- 确保路径100%准确 -- 兜底方案:从 URL 提取路径 - -**代码位置**: -```java -// 从 infra_file 表查询实际的文件路径(确保路径100%准确) -String filePath = getFilePathFromInfraFile(fileUrl, file.getSize()); -if (StrUtil.isBlank(filePath)) { - // 如果查询失败,从URL中提取路径(兜底方案) - filePath = extractPathFromUrl(fileUrl); -} -``` - -### 2. 冗余代码清理 - -**已删除**: -- `generateFullFilePath()` 方法(已删除,不再需要手动生成路径) - -**保留**: -- `extractPathFromUrl()` 方法(作为兜底方案,在删除文件时也会用到) - -## 📊 当前逻辑流程 - -``` -1. 校验文件分类 - ↓ -2. 校验配额 - ↓ -3. 获取OSS基础目录 - ↓ -4. 读取文件内容 - ↓ -5. 上传到OSS(FileService.createFile) - - FileService 自动生成路径并保存到 infra_file 表 - - 返回 fileUrl - ↓ -6. 从 infra_file 表查询实际路径(✅ 确保准确) - - 通过 URL + 文件大小精确匹配 - - 兜底:从 URL 提取路径 - ↓ -7. 获取OSS根路径 - ↓ -8. 保存文件记录到 tik_user_file 表 - - file_path: 从 infra_file 表查询的准确路径 - - file_url: FileService 返回的 URL - ↓ -9. 更新配额 -``` - -## ✅ 逻辑可行性检查 - -### 1. 路径准确性 ✅ -- **方案**:从 `infra_file` 表查询 -- **准确性**:100%(直接使用 FileService 保存的路径) -- **性能**:一次数据库查询,可接受 - -### 2. 兜底方案 ✅ -- **方案**:从 URL 提取路径 -- **适用场景**:查询失败时使用 -- **准确性**:中等(URL 可能包含域名和查询参数) - -### 3. 文件删除 ✅ -- **当前**:使用 `file_path` 字段 -- **准确性**:高(路径来自 infra_file 表) -- **TODO**:实现物理删除 OSS 文件 - -## 🎯 优化建议 - -### 1. 关联 file_id(可选) - -如果后续需要关联 `infra_file` 表,可以在查询时保存 `file_id`: - -```java -FileDO infraFile = fileMapper.selectOne(...); -if (infraFile != null) { - userFile.setFileId(infraFile.getId()); // 关联 infra_file 表 - filePath = infraFile.getPath(); -} -``` - -### 2. 性能优化(可选) - -如果担心查询性能,可以: -- 添加缓存(URL → path 的映射) -- 或者:直接使用 URL 提取路径(但准确性降低) - -## 📝 总结 - -**当前逻辑**: -- ✅ 路径准确性:100%(从 infra_file 表查询) -- ✅ 代码简洁:删除了冗余的路径生成逻辑 -- ✅ 兜底方案:URL 提取路径 -- ✅ 可行性:完全可行 - -**建议**: -- 当前实现已经是最优方案 -- 路径准确性有保障 -- 代码逻辑清晰,无冗余 - diff --git a/yudao-module-tik/UPLOAD_STRATEGY.md b/yudao-module-tik/UPLOAD_STRATEGY.md deleted file mode 100644 index 5200358283..0000000000 --- a/yudao-module-tik/UPLOAD_STRATEGY.md +++ /dev/null @@ -1,76 +0,0 @@ -# 文件上传策略分析 - -## 🎯 业界成熟方案:先上传OSS,再存数据库 - -### 方案对比 - -| 方案 | 优点 | 缺点 | 适用场景 | -|------|------|------|----------| -| **先上传OSS,再存数据库** ✅ | 1. OSS上传失败不影响数据库
2. 数据库事务可快速回滚
3. 用户体验好(文件已上传)
4. 孤立文件可定时清理 | 1. 数据库失败会产生孤立文件
2. 需要清理机制 | **推荐方案**(业界主流) | -| 先存数据库,再上传OSS | 1. 数据库失败不会上传OSS
2. 不会产生孤立文件 | 1. OSS上传失败需要回滚数据库
2. 数据库事务时间长
3. 用户体验差 | 不推荐 | - -### 为什么选择"先上传OSS,再存数据库"? - -1. **性能优势** - - OSS上传是外部服务调用,不应该阻塞数据库事务 - - 数据库事务时间短,减少锁竞争 - -2. **可靠性优势** - - OSS上传失败,直接返回错误,不产生脏数据 - - 数据库保存失败,OSS文件可以后续清理(定时任务) - -3. **用户体验优势** - - 文件已上传成功,即使数据库失败,文件还在 - - 可以重试数据库保存,无需重新上传 - -4. **业界实践** - - 阿里云、腾讯云、AWS 等主流云服务都推荐此方案 - - 大多数开源项目采用此方案 - -### 当前实现方案 - -``` -1. 校验(文件分类、配额) - ↓ -2. 读取文件内容 - ↓ -3. 上传到OSS(FileService.createFile) - - 成功:返回 fileUrl 和 filePath - - 失败:直接抛出异常,不保存数据库 - ↓ -4. 保存数据库(事务中) - - 成功:返回文件ID - - 失败:删除OSS文件,抛出异常 - ↓ -5. 更新配额 -``` - -### 异常处理 - -1. **OSS上传失败** - - 直接抛出异常,不保存数据库 - - 用户可重试上传 - -2. **数据库保存失败** - - 删除已上传的OSS文件(清理) - - 抛出异常,用户可重试 - -3. **孤立文件清理** - - 定时任务清理未关联数据库的OSS文件 - - 基于 infra_file 表的创建时间判断 - -### 优化建议 - -1. **异步清理孤立文件** - - 定时任务扫描 infra_file 表 - - 删除超过7天未关联 tik_user_file 的文件 - -2. **重试机制** - - 数据库保存失败时,记录重试队列 - - 后台任务重试保存 - -3. **监控告警** - - 监控OSS上传失败率 - - 监控数据库保存失败率 - - 监控孤立文件数量 - diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/chat/controller/app/AppAiChatMessageController.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/chat/controller/app/AppAiChatMessageController.java index ba1c6553a5..c4883b0526 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/chat/controller/app/AppAiChatMessageController.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/chat/controller/app/AppAiChatMessageController.java @@ -8,6 +8,7 @@ import cn.iocoder.yudao.module.tik.chat.vo.app.AppAiChatMessageRespVO; import cn.iocoder.yudao.module.tik.chat.vo.app.AppAiChatMessageSendReqVO; import cn.iocoder.yudao.module.tik.chat.vo.app.AppAiChatMessageSendRespVO; import cn.iocoder.yudao.module.tik.controller.admin.chat.vo.message.AiChatMessageSendReqVO; +import cn.iocoder.yudao.module.tik.controller.admin.chat.vo.message.AiChatMessageSendRespVO; import cn.iocoder.yudao.module.tik.dal.dataobject.chat.AiChatConversationDO; import cn.iocoder.yudao.module.tik.dal.dataobject.chat.AiChatMessageDO; import cn.iocoder.yudao.module.tik.dal.dataobject.knowledge.AiKnowledgeDocumentDO; @@ -116,19 +117,8 @@ public class AppAiChatMessageController { @Operation(summary = "发送消息(流式)", description = "流式返回,响应较快") @PostMapping(value = "/send-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - public Flux> sendChatMessageStream(@Valid @RequestBody AppAiChatMessageSendReqVO sendReqVO) { - // 将 App VO 转换为 Admin VO - AiChatMessageSendReqVO adminReqVO = BeanUtils.toBean(sendReqVO, AiChatMessageSendReqVO.class); - // 调用 Service,然后转换响应流 - return chatMessageService.sendChatMessageStream(adminReqVO, getLoginUserId()) - .map(result -> { - if (result.getData() != null) { - // 手动转换 segments,因为内部类类型不同 - AppAiChatMessageSendRespVO appResp = convertSendRespVO(result.getData()); - return success(appResp); - } - return success((AppAiChatMessageSendRespVO) null); - }); + public Flux> sendChatMessageStream(@Valid @RequestBody AiChatMessageSendReqVO sendReqVO) { + return chatMessageService.sendChatMessageStream(sendReqVO, getLoginUserId()); } @Operation(summary = "获得指定对话的消息列表") diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/enmus/ErrorCodeConstants.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/enmus/ErrorCodeConstants.java index c4c146b880..b33a2e548c 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/enmus/ErrorCodeConstants.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/enmus/ErrorCodeConstants.java @@ -25,4 +25,12 @@ public interface ErrorCodeConstants { ErrorCode FILE_GROUP_NAME_DUPLICATE = new ErrorCode(1_030_000_012, "分组名称重复"); ErrorCode FILE_GROUP_NOT_BELONG_TO_USER = new ErrorCode(1_030_000_013, "分组不属于当前用户"); + // ========== 配音管理 1-030-001-000 ========== + ErrorCode VOICE_NOT_EXISTS = new ErrorCode(1_030_001_001, "配音不存在"); + ErrorCode VOICE_NAME_DUPLICATE = new ErrorCode(1_030_001_002, "配音名称重复"); + ErrorCode VOICE_FILE_NOT_EXISTS = new ErrorCode(1_030_001_003, "音频文件不存在"); + ErrorCode VOICE_TRANSCRIBE_FAILED = new ErrorCode(1_030_001_004, "语音识别失败"); + ErrorCode VOICE_TTS_FAILED = new ErrorCode(1_030_001_005, "语音合成失败"); + ErrorCode LATENTSYNC_SUBMIT_FAILED = new ErrorCode(1_030_001_101, "口型同步任务提交失败"); + } diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java index f9490b48b7..de1578dec1 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java @@ -5,13 +5,22 @@ import cn.hutool.core.io.IoUtil; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils; +import cn.iocoder.yudao.framework.common.util.http.HttpUtils; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; import cn.iocoder.yudao.module.infra.api.file.FileApi; import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO; import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper; +import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient; import cn.iocoder.yudao.module.infra.framework.file.core.utils.FileTypeUtils; +import cn.iocoder.yudao.module.infra.service.file.FileConfigService; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.lang.Assert; +import cn.hutool.crypto.digest.DigestUtil; + +import static cn.hutool.core.date.DatePattern.PURE_DATE_PATTERN; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.yudao.module.tik.file.dal.dataobject.TikUserFileDO; import cn.iocoder.yudao.module.tik.file.dal.mysql.TikUserFileMapper; @@ -60,6 +69,9 @@ public class TikUserFileServiceImpl implements TikUserFileService { @Resource private FileMapper fileMapper; + @Resource + private FileConfigService fileConfigService; + @Override public Long uploadFile(MultipartFile file, String fileCategory, String coverBase64) { Long userId = SecurityFrameworkUtils.getLoginUserId(); @@ -86,28 +98,52 @@ public class TikUserFileServiceImpl implements TikUserFileService { throw exception(FILE_NOT_EXISTS, "文件读取失败"); } - // ========== 第二阶段:上传到OSS(不在事务中,优先执行) ========== - // 5. 上传文件到OSS(FileService会自动处理文件名,添加日期前缀和时间戳后缀) - // FileService.createFile 会自动生成路径:{baseDirectory}/{yyyyMMdd}/{filename}_{timestamp}.ext - // 注意:FileService 内部会使用原始文件名,并自动添加时间戳后缀保证唯一性 + // ========== 第二阶段:上传到OSS并保存文件记录(不在事务中,优先执行) ========== + // 采用业界成熟方案:直接使用 fileMapper.insert() 获取文件ID,避免通过 URL 查询 String fileUrl; String filePath; - Long infraFileId = null; // 用于失败时删除OSS文件 + Long infraFileId; + try { - fileUrl = fileApi.createFile(fileContent, file.getOriginalFilename(), - baseDirectory, file.getContentType()); - - // 6. 从 infra_file 表查询实际的文件路径(确保路径100%准确) - // 因为 FileService 已经保存了文件记录到 infra_file 表,我们可以通过 URL 查询获取准确的 path - FileDO infraFile = getInfraFileByUrl(fileUrl, file.getSize()); - if (infraFile != null) { - filePath = infraFile.getPath(); - infraFileId = infraFile.getId(); // 保存 infra_file.id,用于失败时删除 - } else { - // 如果查询失败,从URL中提取路径(兜底方案) - filePath = extractPathFromUrl(fileUrl); - log.warn("[uploadFile][无法从infra_file表查询路径,使用URL提取,URL({})]", fileUrl); + // 1. 处理文件名和类型 + String fileName = file.getOriginalFilename(); + String fileType = file.getContentType(); + if (StrUtil.isEmpty(fileType)) { + fileType = FileTypeUtils.getMineType(fileContent, fileName); } + if (StrUtil.isEmpty(fileName)) { + fileName = DigestUtil.sha256Hex(fileContent); + } + if (StrUtil.isEmpty(FileUtil.extName(fileName))) { + String extension = FileTypeUtils.getExtension(fileType); + if (StrUtil.isNotEmpty(extension)) { + fileName = fileName + "." + extension; + } + } + + // 2. 生成上传路径(与 FileService 保持一致) + filePath = generateUploadPath(fileName, baseDirectory); + + // 3. 上传到OSS + FileClient client = fileConfigService.getMasterFileClient(); + Assert.notNull(client, "客户端(master) 不能为空"); + String presignedUrl = client.upload(fileContent, filePath, fileType); + + // 3.1 移除预签名URL中的签名参数,获取基础URL(用于存储) + fileUrl = HttpUtils.removeUrlQuery(presignedUrl); + + // 4. 保存到 infra_file 表,直接获取文件ID(MyBatis Plus 会自动填充自增ID) + FileDO infraFile = new FileDO() + .setConfigId(client.getId()) + .setName(fileName) + .setPath(filePath) + .setUrl(fileUrl) + .setType(fileType) + .setSize((int) file.getSize()); + fileMapper.insert(infraFile); + infraFileId = infraFile.getId(); // MyBatis Plus 会自动填充自增ID + + log.info("[uploadFile][文件上传成功,文件编号({}),路径({})]", infraFileId, filePath); } catch (Exception e) { log.error("[uploadFile][上传OSS失败]", e); throw exception(FILE_NOT_EXISTS, "上传OSS失败:" + e.getMessage()); @@ -115,7 +151,7 @@ public class TikUserFileServiceImpl implements TikUserFileService { // ========== 第三阶段:保存数据库(在事务中,如果失败则删除OSS文件) ========== try { - return saveFileRecord(userId, file, fileCategory, fileUrl, filePath, coverBase64, baseDirectory); + return saveFileRecord(userId, file, fileCategory, fileUrl, filePath, coverBase64, baseDirectory, infraFileId); } catch (Exception e) { // 数据库保存失败,删除已上传的OSS文件 log.error("[uploadFile][保存数据库失败,准备删除OSS文件,URL({})]", fileUrl, e); @@ -129,8 +165,14 @@ public class TikUserFileServiceImpl implements TikUserFileService { */ @Transactional(rollbackFor = Exception.class) public Long saveFileRecord(Long userId, MultipartFile file, String fileCategory, - String fileUrl, String filePath, String coverBase64, String baseDirectory) { - // 7. 处理视频封面(如果有前端传递的 base64 封面,先处理封面再插入主记录) + String fileUrl, String filePath, String coverBase64, String baseDirectory, Long infraFileId) { + // 7. 验证 infraFileId 不为空(必须在保存记录之前检查) + if (infraFileId == null) { + log.error("[saveFileRecord][infra_file.id 为空,无法保存文件记录,用户({}),URL({})]", userId, fileUrl); + throw exception(FILE_NOT_EXISTS, "文件记录保存失败:无法获取文件ID"); + } + + // 8. 处理视频封面(如果有前端传递的 base64 封面,先处理封面再插入主记录) String coverUrl = null; if (StrUtil.isNotBlank(coverBase64) && StrUtil.containsIgnoreCase(file.getContentType(), "video")) { try { @@ -162,7 +204,8 @@ public class TikUserFileServiceImpl implements TikUserFileService { // 严格验证:确保返回的是有效的 URL,而不是 base64 字符串 if (StrUtil.isNotBlank(uploadedUrl) && !uploadedUrl.equals(coverBase64) && !uploadedUrl.contains("data:image")) { - coverUrl = uploadedUrl; + // 移除预签名URL中的签名参数,获取基础URL(用于存储) + coverUrl = HttpUtils.removeUrlQuery(uploadedUrl); log.info("[saveFileRecord][视频封面上传成功,封面URL({})]", coverUrl); } else { log.error("[saveFileRecord][视频封面上传返回无效URL,跳过保存封面。返回URL: {}", uploadedUrl); @@ -177,10 +220,10 @@ public class TikUserFileServiceImpl implements TikUserFileService { } } - // 8. 创建文件记录(保存完整路径,包含封面URL和Base64) + // 9. 创建文件记录(保存完整路径,包含封面URL和Base64) TikUserFileDO userFile = new TikUserFileDO() .setUserId(userId) - .setFileId(null) // 显式设置为null,file_id是可选的,用于关联infra_file表 + .setFileId(infraFileId) // 关联infra_file表,用于后续通过FileService管理文件 .setFileName(file.getOriginalFilename()) // 保存原始文件名,用于展示 .setFileType(file.getContentType()) .setFileCategory(fileCategory) @@ -191,11 +234,12 @@ public class TikUserFileServiceImpl implements TikUserFileService { .setCoverBase64(StrUtil.isNotBlank(coverBase64) ? coverBase64 : null); // 保存原始base64数据(如果有) userFileMapper.insert(userFile); - // 9. 更新配额 + // 10. 更新配额 quotaService.increaseUsedStorage(userId, file.getSize()); - log.info("[saveFileRecord][用户({})保存文件记录成功,文件编号({})]", userId, userFile.getId()); - return userFile.getId(); + log.info("[saveFileRecord][用户({})保存文件记录成功,文件编号({}),infra文件编号({})]", userId, userFile.getId(), infraFileId); + // 返回 infra_file.id,因为创建配音等操作需要使用 infra_file.id + return infraFileId; } /** @@ -221,31 +265,41 @@ public class TikUserFileServiceImpl implements TikUserFileService { } /** - * 从 infra_file 表查询文件信息(返回完整对象,包含 id) + * 生成上传路径(与 FileService 保持一致) + * 格式:{directory}/{yyyyMMdd}/{filename}_{timestamp}.ext */ - private FileDO getInfraFileByUrl(String fileUrl, long fileSize) { - if (StrUtil.isBlank(fileUrl)) { - return null; + private String generateUploadPath(String name, String directory) { + // 1. 生成前缀、后缀 + String prefix = null; + boolean PATH_PREFIX_DATE_ENABLE = true; + boolean PATH_SUFFIX_TIMESTAMP_ENABLE = true; + + if (PATH_PREFIX_DATE_ENABLE) { + prefix = LocalDateTimeUtil.format(LocalDateTimeUtil.now(), PURE_DATE_PATTERN); } - try { - // 移除URL中的查询参数(如果有) - String cleanUrl = fileUrl; - if (fileUrl.contains("?")) { - cleanUrl = fileUrl.substring(0, fileUrl.indexOf("?")); + String suffix = null; + if (PATH_SUFFIX_TIMESTAMP_ENABLE) { + suffix = String.valueOf(System.currentTimeMillis()); + } + + // 2.1 先拼接 suffix 后缀 + if (StrUtil.isNotEmpty(suffix)) { + String ext = FileUtil.extName(name); + if (StrUtil.isNotEmpty(ext)) { + name = FileUtil.mainName(name) + "_" + suffix + "." + ext; + } else { + name = name + "_" + suffix; } - - // 通过 URL 和文件大小查询(提高准确性) - return fileMapper.selectOne( - new LambdaQueryWrapperX() - .eq(FileDO::getUrl, cleanUrl) - .eq(FileDO::getSize, (int) fileSize) // FileDO.size 是 Integer - .orderByDesc(FileDO::getCreateTime) - .last("LIMIT 1") - ); - } catch (Exception e) { - log.warn("[getInfraFileByUrl][查询infra_file表失败,URL({})]", fileUrl, e); } - return null; + // 2.2 再拼接 prefix 前缀 + if (StrUtil.isNotEmpty(prefix)) { + name = prefix + "/" + name; + } + // 2.3 最后拼接 directory 目录 + if (StrUtil.isNotEmpty(directory)) { + name = directory + "/" + name; + } + return name; } @Override @@ -466,16 +520,28 @@ public class TikUserFileServiceImpl implements TikUserFileService { return null; } try { + // 移除URL中的查询参数(签名参数等) + String cleanUrl = url; + if (url.contains("?")) { + cleanUrl = url.substring(0, url.indexOf("?")); + } + // 如果URL包含域名,提取路径部分 - if (url.contains("://")) { - int pathStart = url.indexOf("/", url.indexOf("://") + 3); + if (cleanUrl.contains("://")) { + int pathStart = cleanUrl.indexOf("/", cleanUrl.indexOf("://") + 3); if (pathStart > 0) { - return url.substring(pathStart); + String fullPath = cleanUrl.substring(pathStart); + // 路径可能包含 bucket 名称,需要提取实际的文件路径 + // 例如:/bucket-name/user-id/tenant-id/voice/20251117/file.wav + // 实际 path 可能是:user-id/tenant-id/voice/20251117/file.wav + // 但数据库中的 path 格式是:voice/20251117/file_timestamp.wav + // 所以我们需要找到包含日期格式的部分(yyyyMMdd) + return fullPath; } } - // 如果已经是路径格式,直接返回 - if (url.startsWith("/")) { - return url; + // 如果已经是路径格式,直接返回(去除查询参数) + if (cleanUrl.startsWith("/")) { + return cleanUrl; } } catch (Exception e) { log.warn("[extractPathFromUrl][从URL提取路径失败,URL({})]", url, e); diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/tikhup/service/TikFileTransCharacters.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/tikhup/service/TikFileTransCharacters.java index 01b8ad1cc4..4fc4d19eec 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/tikhup/service/TikFileTransCharacters.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/tikhup/service/TikFileTransCharacters.java @@ -73,7 +73,7 @@ public class TikFileTransCharacters { // 设置是否输出词信息,默认为false,开启时需要设置version为4.0及以上。 taskObject.put(KEY_ENABLE_WORDS, true); String task = taskObject.toJSONString(); - System.out.println(task); + System.out.println("[TikFileTransCharacters][submitFileTransRequest] 请求参数: " + task); // 设置以上JSON字符串为Body参数。 postRequest.putBodyParameter(KEY_TASK, task); // 设置为POST方式的请求。 @@ -85,15 +85,24 @@ public class TikFileTransCharacters { String taskId = null; try { CommonResponse postResponse = client.getCommonResponse(postRequest); - System.err.println("提交录音文件识别请求的响应:" + postResponse.getData()); - if (postResponse.getHttpStatus() == 200) { + System.err.println("[TikFileTransCharacters][submitFileTransRequest] 提交录音文件识别请求的响应:" + postResponse.getData()); + int httpStatus = postResponse.getHttpStatus(); + System.out.println("[TikFileTransCharacters][submitFileTransRequest] HTTP状态码: " + httpStatus); + if (httpStatus == 200) { JSONObject result = JSONObject.parseObject(postResponse.getData()); String statusText = result.getString(KEY_STATUS_TEXT); + System.out.println("[TikFileTransCharacters][submitFileTransRequest] 状态文本: " + statusText); if (STATUS_SUCCESS.equals(statusText)) { taskId = result.getString(KEY_TASK_ID); + System.out.println("[TikFileTransCharacters][submitFileTransRequest] 任务ID: " + taskId); + } else { + System.err.println("[TikFileTransCharacters][submitFileTransRequest] 状态不是SUCCESS,状态文本: " + statusText); } + } else { + System.err.println("[TikFileTransCharacters][submitFileTransRequest] HTTP状态码不是200,状态码: " + httpStatus + ",响应: " + postResponse.getData()); } } catch (ClientException e) { + System.err.println("[TikFileTransCharacters][submitFileTransRequest] 异常: " + e.getMessage()); e.printStackTrace(); } return taskId; @@ -120,17 +129,25 @@ public class TikFileTransCharacters { * 以轮询的方式进行识别结果的查询,直到服务端返回的状态描述为“SUCCESS”或错误描述,则结束轮询。 */ String result = null; + int pollCount = 0; while (true) { + pollCount++; try { + System.out.println("[TikFileTransCharacters][getFileTransResult] 第" + pollCount + "次轮询,taskId: " + taskId); CommonResponse getResponse = client.getCommonResponse(getRequest); - System.err.println("识别查询结果:" + getResponse.getData()); - if (getResponse.getHttpStatus() != 200) { + int httpStatus = getResponse.getHttpStatus(); + String responseData = getResponse.getData(); + System.err.println("[TikFileTransCharacters][getFileTransResult] 识别查询结果,HTTP状态码: " + httpStatus + ",响应: " + responseData); + if (httpStatus != 200) { + System.err.println("[TikFileTransCharacters][getFileTransResult] HTTP状态码不是200,停止轮询,taskId: " + taskId); break; } - JSONObject rootObj = JSONObject.parseObject(getResponse.getData()); + JSONObject rootObj = JSONObject.parseObject(responseData); String statusText = rootObj.getString(KEY_STATUS_TEXT); + System.out.println("[TikFileTransCharacters][getFileTransResult] 状态文本: " + statusText); if (STATUS_RUNNING.equals(statusText) || STATUS_QUEUEING.equals(statusText)) { // 继续轮询,注意设置轮询时间间隔。 + System.out.println("[TikFileTransCharacters][getFileTransResult] 任务进行中,等待10秒后继续轮询,taskId: " + taskId); Thread.sleep(10000); } else { @@ -139,15 +156,22 @@ public class TikFileTransCharacters { result = rootObj.getString(KEY_RESULT); // 状态信息为成功,但没有识别结果,则可能是由于文件里全是静音、噪音等导致识别为空。 if(result == null) { + System.out.println("[TikFileTransCharacters][getFileTransResult] 识别成功但结果为空,taskId: " + taskId); result = ""; + } else { + System.out.println("[TikFileTransCharacters][getFileTransResult] 识别成功,结果长度: " + result.length() + ",taskId: " + taskId); } + } else { + System.err.println("[TikFileTransCharacters][getFileTransResult] 状态不是SUCCESS,状态文本: " + statusText + ",taskId: " + taskId); } break; } } catch (Exception e) { + System.err.println("[TikFileTransCharacters][getFileTransResult] 轮询异常,taskId: " + taskId + ",异常信息: " + e.getMessage()); e.printStackTrace(); } } + System.out.println("[TikFileTransCharacters][getFileTransResult] 轮询结束,taskId: " + taskId + ",结果: " + (result != null ? "非空,长度" + result.length() : "null")); return result; } public static void main(String args[]) throws Exception { diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/tikhup/service/TikHupServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/tikhup/service/TikHupServiceImpl.java index 267192e78f..4c68a312d7 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/tikhup/service/TikHupServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/tikhup/service/TikHupServiceImpl.java @@ -165,17 +165,20 @@ public class TikHupServiceImpl implements TikHupService { @Override public Object videoToCharacters(String fileLink){ + log.info("[videoToCharacters][开始识别,文件链接({})]", fileLink); TikFileTransCharacters tikFileTransCharacters = new TikFileTransCharacters(accessKeyId, accessKeySecret); - // 第一步:提交录音文件识别请求,获取任务ID用于后续的识别结果轮询。 String taskId = tikFileTransCharacters.submitFileTransRequest(appKey, fileLink); if (taskId == null) { + log.error("[videoToCharacters][提交识别请求失败,taskId为null,fileLink({})]", fileLink); return CommonResult.error(500,"录音文件识别请求失败!"); } - // 第二步:根据任务ID轮询识别结果。 + log.info("[videoToCharacters][提交识别请求成功,taskId({})]", taskId); String transResult = tikFileTransCharacters.getFileTransResult(taskId); if (transResult == null) { + log.error("[videoToCharacters][识别结果查询失败,taskId({}),transResult为null]", taskId); return CommonResult.error(501,"录音文件识别请求失败!"); } + log.info("[videoToCharacters][识别成功,taskId({}),结果长度({})]", taskId, transResult.length()); return CommonResult.success(transResult); } @@ -183,30 +186,28 @@ public class TikHupServiceImpl implements TikHupService { @Override public Object videoToCharacters2(List fileLinkList){ - // 创建转写请求参数 - TranscriptionParam param = - TranscriptionParam.builder() - // 若没有将API Key配置到环境变量中,需将apiKey替换为自己的API Key - .apiKey(apiKey) - .model("paraformer-v1") - // “language_hints”只支持paraformer-v2模型 - .parameter("language_hints", new String[]{"zh", "en"}) - .fileUrls(fileLinkList) - .build(); + log.info("[videoToCharacters2][开始识别,文件数量({}),文件URL({})]", + fileLinkList != null ? fileLinkList.size() : 0, fileLinkList); + TranscriptionParam param = TranscriptionParam.builder() + .apiKey(apiKey) + .model("paraformer-v1") + .parameter("language_hints", new String[]{"zh", "en"}) + .fileUrls(fileLinkList) + .build(); try { Transcription transcription = new Transcription(); - // 提交转写请求 TranscriptionResult result = transcription.asyncCall(param); - log.info("RequestId: {}" ,result.getRequestId()); - // 阻塞等待任务完成并获取结果 + log.info("[videoToCharacters2][提交转写请求成功,TaskId({})]", result.getTaskId()); result = transcription.wait( TranscriptionQueryParam.FromTranscriptionParam(param, result.getTaskId())); - return CommonResult.success(new GsonBuilder().setPrettyPrinting().create().toJson(result.getOutput())); + String outputJson = new GsonBuilder().setPrettyPrinting().create().toJson(result.getOutput()); + log.info("[videoToCharacters2][识别成功,TaskId({}),结果长度({})]", + result.getTaskId(), outputJson != null ? outputJson.length() : 0); + return CommonResult.success(outputJson); } catch (Exception e) { - log.error(e.getMessage()); + log.error("[videoToCharacters2][识别失败,文件URL({}),异常({})]", fileLinkList, e.getMessage(), e); return CommonResult.error(500,"录音文件识别请求失败!"); } - } diff --git a/yudao-server/src/main/resources/application-local.yaml b/yudao-server/src/main/resources/application-local.yaml index dd438bfb55..3c0fc150a9 100644 --- a/yudao-server/src/main/resources/application-local.yaml +++ b/yudao-server/src/main/resources/application-local.yaml @@ -223,6 +223,11 @@ wx: # 芋道配置项,设置当前项目所有自定义的配置 yudao: + cosyvoice: + api-key: sk-10c746f8cb8640738f8d6b71af699003 + # tik: + # latentsync: + # api-key: ${TIK_LATENTSYNC_API_KEY:} # 建议通过环境变量覆盖仓库默认值 captcha: enable: false # 本地环境,暂时关闭图片验证码,方便登录等接口的测试; security: @@ -265,4 +270,4 @@ justauth: cache: type: REDIS prefix: 'social_auth_state:' # 缓存前缀,目前只对 Redis 缓存生效,默认 JUSTAUTH::STATE:: - timeout: 24h # 超时时长,目前只对 Redis 缓存生效,默认 3 分钟 \ No newline at end of file + timeout: 24h # 超时时长,目前只对 Redis 缓存生效,默认 3 分钟 diff --git a/yudao-server/src/main/resources/application.yaml b/yudao-server/src/main/resources/application.yaml index 98315b3424..ce3848874e 100644 --- a/yudao-server/src/main/resources/application.yaml +++ b/yudao-server/src/main/resources/application.yaml @@ -213,6 +213,13 @@ spring: sse-endpoint: /sse yudao: + cosyvoice: + enabled: true + api-key: sk-10c746f8cb8640738f8d6b71af699003 + default-model: cosyvoice-v2 + sample-rate: 24000 + audio-format: mp3 + preview-text: 您好,欢迎体验专属音色 ai: gemini: # 谷歌 Gemini enable: true